Model/tests/backend/app/documents/test_bulk_download_router.py
Khalim Conn-Kowlessar 3c00e0ef00 FastAPI trigger route: POST /v1/documents/bulk-download 🟩
Resolves the authenticated requester's email (ADR-0059), resolves + caps the
property selection, pins the recipe (landlord_property_ids, recipient_email,
package_name) onto one pre-created sub_task (raw SQL, dodging the mirror
double-registration), and enqueues one message to the worker. Refuses
double-submits (409) and oversized selections (400).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 09:59:02 +00:00

140 lines
4.6 KiB
Python

"""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
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import Engine
from sqlmodel import Session
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 domain.tasks.tasks import Task
from infrastructure.postgres.property_table import PropertyRow
from repositories.tasks.subtask_postgres_repository import SubTaskPostgresRepository
from repositories.tasks.task_postgres_repository import TaskPostgresRepository
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) -> Task:
with Session(self.engine) as session:
return TaskPostgresRepository(session).create(
Task.create(task_source="app:bulk_document_download")
)
def seed_property(self, landlord_property_id: str) -> int:
with Session(self.engine) as session:
row = PropertyRow(
portfolio_id=PORTFOLIO_ID, landlord_property_id=landlord_property_id
)
session.add(row)
session.commit()
session.refresh(row)
assert row.id is not None
return row.id
def subtask_inputs(self, task: Task) -> 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: Task, property_ids: list[int]) -> Any:
return api.client.post(
"/v1/documents/bulk-download",
json={
"task_id": str(task.id),
"portfolio_id": PORTFOLIO_ID,
"property_ids": property_ids,
},
)
def test_pins_the_recipe_and_enqueues_one_message(api: Api) -> None:
# arrange — two selected properties, matched by landlord_property_id.
task = api.seed_task()
p1 = api.seed_property("LP1")
p2 = api.seed_property("LP2")
# act
response = _trigger(api, task, [p1, p2])
# assert — accepted; one sub_task pins the recipe; one message enqueued.
assert response.status_code == 202
inputs = api.subtask_inputs(task)
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_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 = api.seed_task()
p1 = api.seed_property("LP1")
p2 = api.seed_property("LP2")
# act
response = _trigger(api, task, [p1, p2])
# assert — refused; nothing created or sent.
assert response.status_code == 400
assert api.subtask_inputs(task) == []
assert api.sent_bodies == []
def test_rejects_a_task_that_already_has_a_subtask(api: Api) -> None:
# arrange — a task already triggered once.
task = api.seed_task()
p1 = api.seed_property("LP1")
assert _trigger(api, task, [p1]).status_code == 202
# act
response = _trigger(api, task, [p1])
# assert — the double-submit is refused; still one sub_task, one message.
assert response.status_code == 409
assert len(api.subtask_inputs(task)) == 1
assert len(api.sent_bodies) == 1