mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-19 17:03:02 +00:00
uploaded_files has no property_id — matching is on landlord_property_id — so the property_ids path was pointless indirection (look property.id up in property just to translate back to landlord_property_id). The FE holds landlord_property_id per row anyway. task.inputs hand-pick key is now landlord_property_ids: str[] (was property_ids: int[]). resolve_selection unions two landlord_property_id sources — project_codes expanded via hubspot_deal_data, and the hand-picked ids taken as given — and no longer queries the property table at all. Trigger-only change; the domain plan, orchestrator and matching already work in landlord_property_id. ADR-0060, CONTEXT.md and the request schema updated to match. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
224 lines
7.8 KiB
Python
224 lines
7.8 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
|
|
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 == []
|