Audit generator populates XLSX, uploads to S3, and records UploadedFile row 🟥

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel Roth 2026-06-09 11:59:09 +00:00
parent f08a75e103
commit a1d09aa880
11 changed files with 427 additions and 0 deletions

View file

@ -0,0 +1 @@
from applications.audit_generator.handler import handler # noqa: F401

View file

@ -0,0 +1 @@
from applications.audit_generator.audit_generator_trigger_request import AuditGeneratorTriggerRequest # noqa: F401

View file

View file

@ -0,0 +1,9 @@
from pydantic import BaseModel, ConfigDict
class AuditGeneratorTriggerRequest(BaseModel):
model_config = ConfigDict(extra="ignore")
task_id: str
sub_task_id: str
hubspot_deal_id: str

View file

@ -0,0 +1,40 @@
from __future__ import annotations
import os
from typing import Any
import boto3
from applications.audit_generator.audit_generator_trigger_request import (
AuditGeneratorTriggerRequest,
)
from infrastructure.postgres.config import PostgresConfig
from infrastructure.postgres.engine import make_engine, make_session
from infrastructure.s3.s3_client import S3Client
from orchestration.audit_generator_orchestrator import AuditGeneratorOrchestrator
from orchestration.audit_generator_unit_of_work import AuditGeneratorUnitOfWork
from utilities.aws_lambda.subtask_handler import subtask_handler
_engine = make_engine(PostgresConfig.from_env(os.environ))
@subtask_handler()
def handler(body: dict[str, Any], context: Any) -> None:
trigger = AuditGeneratorTriggerRequest.model_validate(body)
boto3_client: Any = boto3.client # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
boto_s3: Any = boto3_client("s3")
bucket = os.environ["S3_BUCKET_NAME"]
s3_client = S3Client(boto_s3_client=boto_s3, bucket=bucket)
def session_factory() -> Any:
return make_session(_engine)
def uow_factory() -> AuditGeneratorUnitOfWork:
return AuditGeneratorUnitOfWork(session_factory)
AuditGeneratorOrchestrator(
hubspot_deal_id=trigger.hubspot_deal_id,
s3_client=s3_client,
uow_factory=uow_factory,
).run()

View file

@ -0,0 +1,24 @@
from __future__ import annotations
from collections.abc import Callable
from typing import TYPE_CHECKING
from infrastructure.s3.s3_client import S3Client
if TYPE_CHECKING:
from orchestration.audit_generator_unit_of_work import AuditGeneratorUnitOfWork
class AuditGeneratorOrchestrator:
def __init__(
self,
hubspot_deal_id: str,
s3_client: S3Client,
uow_factory: Callable[[], "AuditGeneratorUnitOfWork"],
) -> None:
self._hubspot_deal_id = hubspot_deal_id
self._s3_client = s3_client
self._uow_factory = uow_factory
def run(self) -> None:
raise NotImplementedError

View file

@ -0,0 +1,39 @@
from __future__ import annotations
from collections.abc import Callable
from types import TracebackType
from typing import Optional
from sqlmodel import Session
from repositories.magic_plan.magic_plan_postgres_repository import (
MagicPlanPostgresRepository,
)
from repositories.uploaded_file.uploaded_file_postgres_repository import (
UploadedFilePostgresRepository,
)
class AuditGeneratorUnitOfWork:
def __init__(self, session_factory: Callable[[], Session]) -> None:
self._session_factory = session_factory
def __enter__(self) -> "AuditGeneratorUnitOfWork":
self._session = self._session_factory()
self.uploaded_file = UploadedFilePostgresRepository(self._session)
self.magic_plan = MagicPlanPostgresRepository(self._session)
return self
def __exit__(
self,
exc_type: Optional[type[BaseException]],
exc: Optional[BaseException],
tb: Optional[TracebackType],
) -> None:
try:
self._session.rollback()
finally:
self._session.close()
def commit(self) -> None:
self._session.commit()

View file

@ -0,0 +1,94 @@
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from pydantic import ValidationError
from applications.audit_generator.handler import handler
_ENV = {
"DATABASE_URL": "postgresql+psycopg://user:pass@localhost/db",
"S3_BUCKET_NAME": "test-bucket",
# PostgresConfig.from_env also reads these individual vars; DATABASE_URL is
# used directly when constructing the engine in the handler module scope, so
# we patch make_engine instead of the env.
}
_VALID_BODY: dict[str, Any] = {
"task_id": "task-1",
"sub_task_id": "subtask-1",
"hubspot_deal_id": "deal-xyz",
}
def _call(body: dict[str, Any]) -> Any:
return handler.__wrapped__(body, None) # type: ignore[attr-defined]
# --- request validation ---
def test_invalid_body_raises_validation_error() -> None:
# Arrange — body missing all required fields
body: dict[str, Any] = {}
# Act / Assert
with patch("applications.audit_generator.handler.AuditGeneratorOrchestrator"):
with pytest.raises(ValidationError):
_call(body)
# --- orchestrator construction ---
def test_handler_passes_hubspot_deal_id_from_body_to_orchestrator() -> None:
# Arrange
mock_orch = MagicMock()
mock_orch.run.return_value = None
# Act
with patch("applications.audit_generator.handler.os.environ", _ENV), \
patch("applications.audit_generator.handler.S3Client") as MockS3, \
patch("applications.audit_generator.handler.AuditGeneratorOrchestrator", return_value=mock_orch) as MockOrch:
MockS3.return_value = MagicMock()
_call(_VALID_BODY)
# Assert — deal id flows from body into the orchestrator constructor
MockOrch.assert_called_once()
assert MockOrch.call_args.kwargs["hubspot_deal_id"] == "deal-xyz"
def test_handler_passes_bucket_from_env_to_s3_client() -> None:
# Arrange
mock_orch = MagicMock()
mock_orch.run.return_value = None
# Act
with patch("applications.audit_generator.handler.os.environ", _ENV), \
patch("applications.audit_generator.handler.S3Client") as MockS3, \
patch("applications.audit_generator.handler.AuditGeneratorOrchestrator", return_value=mock_orch):
_call(_VALID_BODY)
# Assert — bucket name from env reaches S3Client constructor
MockS3.assert_called_once()
assert MockS3.call_args.kwargs["bucket"] == "test-bucket"
# --- return value ---
def test_handler_returns_none_on_success() -> None:
# Arrange
mock_orch = MagicMock()
mock_orch.run.return_value = None
# Act
with patch("applications.audit_generator.handler.os.environ", _ENV), \
patch("applications.audit_generator.handler.S3Client"), \
patch("applications.audit_generator.handler.AuditGeneratorOrchestrator", return_value=mock_orch):
result = _call(_VALID_BODY)
# Assert
assert result is None

View file

@ -0,0 +1,219 @@
from __future__ import annotations
from io import BytesIO
from typing import Any
from unittest.mock import MagicMock, call, patch
import pytest
from domain.magicplan.models import (
Door,
DoorVentilation,
Floor,
Plan,
Room,
Window,
WindowVentilation,
)
from infrastructure.postgres.uploaded_file_table import (
FileSourceEnum,
FileTypeEnum,
UploadedFile,
)
from infrastructure.s3.s3_client import S3Client
from orchestration.audit_generator_orchestrator import AuditGeneratorOrchestrator
_DEAL_ID = "deal-abc"
_BUCKET = "test-bucket"
_EXPECTED_S3_KEY = f"documents/hubspot_deal_id/{_DEAL_ID}/ventilation_audit.xlsx"
def _make_window(with_ventilation: bool = True) -> Window:
vent = (
WindowVentilation(
opening_type="Hinged",
num_openings=1,
pct_openable=50,
trickle_vent_area_mm2=1000,
num_trickle_vents=2,
)
if with_ventilation
else None
)
return Window(width_m=1.0, height_m=1.2, area_m2=1.2, ventilation=vent)
def _make_door(with_ventilation: bool = True) -> Door:
vent = DoorVentilation(undercut_mm=10.0) if with_ventilation else None
return Door(width_mm=800.0, height_mm=2000.0, ventilation=vent)
def _make_plan(
num_rooms: int = 1,
num_windows_per_room: int = 1,
num_doors_per_room: int = 1,
) -> Plan:
rooms = [
Room(
name=f"Room {i}",
width_m=3.0,
length_m=4.0,
area_m2=12.0,
windows=[_make_window() for _ in range(num_windows_per_room)],
doors=[_make_door() for _ in range(num_doors_per_room)],
)
for i in range(num_rooms)
]
return Plan(
uid="test-uid",
name="Test Plan",
address="1 Test St",
postcode="TE1 1ST",
floors=[Floor(level=0, name="Ground", rooms=rooms)],
)
def _make_uploaded_file_row(id: int = 1) -> UploadedFile:
return UploadedFile(
id=id,
s3_file_bucket=_BUCKET,
s3_file_key="documents/deal/plan.json",
s3_upload_timestamp=None, # type: ignore[arg-type]
hubspot_deal_id=_DEAL_ID,
file_type=FileTypeEnum.MAGIC_PLAN_JSON.value,
)
def _make_mock_uow(
uploaded_file_row: Any = None,
plan: Any = None,
) -> tuple[MagicMock, MagicMock]:
"""Return (mock_uow, mock_uow_factory)."""
mock_uow = MagicMock()
mock_uow.__enter__ = MagicMock(return_value=mock_uow)
mock_uow.__exit__ = MagicMock(return_value=False)
mock_uow.uploaded_file.get_latest_by_hubspot_deal_id.return_value = uploaded_file_row
mock_uow.magic_plan.get_plan_by_uploaded_file_id.return_value = plan
mock_uow_factory = MagicMock(return_value=mock_uow)
return mock_uow, mock_uow_factory
def _make_s3() -> MagicMock:
s3 = MagicMock(spec=S3Client)
s3.bucket = _BUCKET
return s3
def _make_orchestrator(
s3: Any = None,
uow_factory: Any = None,
deal_id: str = _DEAL_ID,
) -> AuditGeneratorOrchestrator:
return AuditGeneratorOrchestrator(
hubspot_deal_id=deal_id,
s3_client=s3 or _make_s3(),
uow_factory=uow_factory or MagicMock(),
)
# --- error: no uploaded file ---
def test_raises_when_no_magic_plan_json_uploaded() -> None:
# Arrange
mock_uow, mock_uow_factory = _make_mock_uow(uploaded_file_row=None)
orch = _make_orchestrator(uow_factory=mock_uow_factory)
# Act / Assert
with pytest.raises(ValueError, match="No MagicPlan"):
orch.run()
# --- error: plan not yet parsed ---
def test_raises_when_plan_not_yet_parsed() -> None:
# Arrange
mock_uow, mock_uow_factory = _make_mock_uow(
uploaded_file_row=_make_uploaded_file_row(), plan=None
)
orch = _make_orchestrator(uow_factory=mock_uow_factory)
# Act / Assert
with pytest.raises(ValueError, match="not yet parsed"):
orch.run()
# --- happy path ---
def test_uploads_to_correct_s3_key() -> None:
# Arrange
s3 = _make_s3()
plan = _make_plan()
mock_uow, mock_uow_factory = _make_mock_uow(
uploaded_file_row=_make_uploaded_file_row(), plan=plan
)
orch = _make_orchestrator(s3=s3, uow_factory=mock_uow_factory)
# Act
orch.run()
# Assert
s3.put_object.assert_called_once()
assert s3.put_object.call_args.args[0] == _EXPECTED_S3_KEY
def test_inserts_uploaded_file_with_correct_enums() -> None:
# Arrange
plan = _make_plan()
mock_uow, mock_uow_factory = _make_mock_uow(
uploaded_file_row=_make_uploaded_file_row(), plan=plan
)
orch = _make_orchestrator(uow_factory=mock_uow_factory)
# Act
orch.run()
# Assert — the UploadedFile inserted has the correct type/source
mock_uow.uploaded_file.insert.assert_called_once()
inserted: UploadedFile = mock_uow.uploaded_file.insert.call_args.args[0]
assert inserted.file_type == FileTypeEnum.VENTILATION_AUDIT.value
assert inserted.file_source == FileSourceEnum.AUDIT_GENERATOR.value
assert inserted.hubspot_deal_id == _DEAL_ID
assert inserted.s3_file_key == _EXPECTED_S3_KEY
def test_commits_after_s3_upload() -> None:
# Arrange
s3 = _make_s3()
plan = _make_plan()
mock_uow, mock_uow_factory = _make_mock_uow(
uploaded_file_row=_make_uploaded_file_row(), plan=plan
)
call_order: list[str] = []
s3.put_object.side_effect = lambda *a, **kw: call_order.append("s3_upload")
mock_uow.commit.side_effect = lambda: call_order.append("commit")
orch = _make_orchestrator(s3=s3, uow_factory=mock_uow_factory)
# Act
orch.run()
# Assert — S3 upload happens before DB commit
assert call_order == ["s3_upload", "commit"]
# --- 50-row limit ---
def test_raises_when_series_exceeds_50_rows() -> None:
# Arrange — 51 windows, which exceeds the template limit
plan = _make_plan(num_rooms=1, num_windows_per_room=51)
mock_uow, mock_uow_factory = _make_mock_uow(
uploaded_file_row=_make_uploaded_file_row(), plan=plan
)
orch = _make_orchestrator(uow_factory=mock_uow_factory)
# Act / Assert
with pytest.raises(ValueError, match="50"):
orch.run()