"""Tests for the Bulk Document Download trigger endpoint (ADR-0060). Session, SQS and requester-email seams are dependency-injected; tests run against the ephemeral Postgres and record message bodies instead of calling AWS. """ import json from collections.abc import Iterator from dataclasses import dataclass, field from typing import Any from uuid import UUID import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from sqlalchemy import Engine from sqlmodel import Session from backend.app.db.models.hubspot_deal_data import HubspotDealData from backend.app.dependencies import validate_token from backend.app.documents import router as documents_router from backend.app.documents.router import ( get_message_sender, get_requesting_user_email, get_session, ) from infrastructure.postgres.task_table import TaskRow from repositories.tasks.subtask_postgres_repository import SubTaskPostgresRepository PORTFOLIO_ID = 814 RECIPIENT = "tester@example.com" @dataclass class Api: client: TestClient engine: Engine sent_bodies: list[str] = field(default_factory=list) def seed_task(self, selection: dict[str, Any]) -> UUID: """Create the app-owned task with the FE-written selection config on ``task.inputs`` (ADR-0060).""" with Session(self.engine) as session: row = TaskRow( task_source="app:bulk_document_download", inputs=json.dumps(selection), ) session.add(row) session.commit() session.refresh(row) return row.id def seed_deal(self, project_code: str, landlord_property_id: str) -> None: """A hubspot_deal_data row carrying the project↔property link the project-code selection resolves against (ADR-0060).""" with Session(self.engine) as session: session.add( HubspotDealData( deal_id=f"deal-{project_code}-{landlord_property_id}", project_code=project_code, landlord_property_id=landlord_property_id, ) ) session.commit() def subtask_inputs(self, task_id: UUID) -> list[dict[str, Any]]: with Session(self.engine) as session: subtasks = SubTaskPostgresRepository(session).list_by_task(task_id) return [st.inputs or {} for st in subtasks] @pytest.fixture def api(db_engine: Engine) -> Api: app = FastAPI() app.include_router(documents_router.router, prefix="/v1") harness = Api(client=TestClient(app), engine=db_engine) def _session() -> Iterator[Session]: with Session(db_engine) as session: yield session app.dependency_overrides[get_session] = _session app.dependency_overrides[get_message_sender] = lambda: harness.sent_bodies.append app.dependency_overrides[get_requesting_user_email] = lambda: RECIPIENT app.dependency_overrides[validate_token] = lambda: "test-token" return harness def _trigger(api: Api, task_id: UUID) -> Any: return api.client.post( "/v1/documents/bulk-download", json={"task_id": str(task_id)} ) def test_pins_the_recipe_and_enqueues_one_message(api: Api) -> None: # arrange — two hand-picked properties, by landlord_property_id (the key # uploaded_files is matched on), pinned as the FE's task.inputs config. task_id = api.seed_task( {"portfolio_id": PORTFOLIO_ID, "landlord_property_ids": ["LP1", "LP2"]} ) # act response = _trigger(api, task_id) # assert — accepted; one sub_task pins the recipe; one message enqueued. assert response.status_code == 202 inputs = api.subtask_inputs(task_id) assert len(inputs) == 1 assert inputs[0]["landlord_property_ids"] == ["LP1", "LP2"] assert inputs[0]["recipient_email"] == RECIPIENT assert len(api.sent_bodies) == 1 message = json.loads(api.sent_bodies[0]) assert message["task_id"] == str(task_id) assert "subtask_id" in message def test_resolves_all_properties_for_the_given_project_codes(api: Api) -> None: # arrange — two projects the user selects, plus one they don't; the # project↔property grain lives on hubspot_deal_data, not `property`. api.seed_deal("PROJ-A", "LP1") api.seed_deal("PROJ-A", "LP2") api.seed_deal("PROJ-B", "LP3") api.seed_deal("PROJ-C", "LP9") # a different project — must not appear task_id = api.seed_task({"project_codes": ["PROJ-A", "PROJ-B"]}) # act response = _trigger(api, task_id) # assert — the union of the selected projects' landlord_property_ids is pinned. assert response.status_code == 202 inputs = api.subtask_inputs(task_id) assert inputs[0]["landlord_property_ids"] == ["LP1", "LP2", "LP3"] def test_unions_project_codes_with_hand_picked_landlord_property_ids(api: Api) -> None: # arrange — one project, plus hand-picks that overlap it (LP2) and extend it (LP5). api.seed_deal("PROJ-A", "LP1") api.seed_deal("PROJ-A", "LP2") task_id = api.seed_task( {"project_codes": ["PROJ-A"], "landlord_property_ids": ["LP2", "LP5"]} ) # act response = _trigger(api, task_id) # assert — both sources contribute; the set is de-duplicated and sorted. assert response.status_code == 202 inputs = api.subtask_inputs(task_id) assert inputs[0]["landlord_property_ids"] == ["LP1", "LP2", "LP5"] def test_deal_rows_with_no_landlord_property_id_are_dropped(api: Api) -> None: # arrange — a project whose deal rows carry no landlord_property_id can match # no files (files are keyed on landlord_property_id), so the selection is empty. api.seed_deal("PROJ-A", "LP1") with Session(api.engine) as session: session.add(HubspotDealData(deal_id="deal-null", project_code="PROJ-A")) session.commit() task_id = api.seed_task({"project_codes": ["PROJ-A"]}) # act response = _trigger(api, task_id) # assert — only the row that carries an id contributes. assert response.status_code == 202 inputs = api.subtask_inputs(task_id) assert inputs[0]["landlord_property_ids"] == ["LP1"] def test_rejects_a_project_code_that_resolves_to_no_properties(api: Api) -> None: # arrange — a project with no matching deal rows at all. task_id = api.seed_task({"project_codes": ["PROJ-EMPTY"]}) # act response = _trigger(api, task_id) # assert — refused; nothing created or sent. assert response.status_code == 400 assert api.subtask_inputs(task_id) == [] assert api.sent_bodies == [] def test_rejects_a_selection_over_the_cap(api: Api, monkeypatch: Any) -> None: # arrange — cap of 1, two selected properties. monkeypatch.setattr(documents_router, "MAX_PROPERTIES", 1) task_id = api.seed_task( {"portfolio_id": PORTFOLIO_ID, "landlord_property_ids": ["LP1", "LP2"]} ) # act response = _trigger(api, task_id) # assert — refused; nothing created or sent. assert response.status_code == 400 assert api.subtask_inputs(task_id) == [] assert api.sent_bodies == [] def test_rejects_a_task_that_already_has_a_subtask(api: Api) -> None: # arrange — a task already triggered once. task_id = api.seed_task( {"portfolio_id": PORTFOLIO_ID, "landlord_property_ids": ["LP1"]} ) assert _trigger(api, task_id).status_code == 202 # act response = _trigger(api, task_id) # assert — the double-submit is refused; still one sub_task, one message. assert response.status_code == 409 assert len(api.subtask_inputs(task_id)) == 1 assert len(api.sent_bodies) == 1 def test_rejects_a_task_with_no_selection_config(api: Api) -> None: # arrange — a task whose inputs carry no selection. task_id = api.seed_task({}) # act response = _trigger(api, task_id) # assert assert response.status_code == 400 assert api.sent_bodies == []