diff --git a/applications/audit-generator/handler.py b/applications/audit-generator/handler.py index e69de29b..6ad89ccb 100644 --- a/applications/audit-generator/handler.py +++ b/applications/audit-generator/handler.py @@ -0,0 +1 @@ +from applications.audit_generator.handler import handler # noqa: F401 diff --git a/applications/audit-generator/handler/audit_generator_trigger_request.py b/applications/audit-generator/handler/audit_generator_trigger_request.py index e69de29b..165a8e6c 100644 --- a/applications/audit-generator/handler/audit_generator_trigger_request.py +++ b/applications/audit-generator/handler/audit_generator_trigger_request.py @@ -0,0 +1 @@ +from applications.audit_generator.audit_generator_trigger_request import AuditGeneratorTriggerRequest # noqa: F401 diff --git a/applications/audit_generator/__init__.py b/applications/audit_generator/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/applications/audit_generator/audit_generator_trigger_request.py b/applications/audit_generator/audit_generator_trigger_request.py new file mode 100644 index 00000000..042db001 --- /dev/null +++ b/applications/audit_generator/audit_generator_trigger_request.py @@ -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 diff --git a/applications/audit_generator/handler.py b/applications/audit_generator/handler.py new file mode 100644 index 00000000..b560f808 --- /dev/null +++ b/applications/audit_generator/handler.py @@ -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() diff --git a/orchestration/audit_generator_orchestrator.py b/orchestration/audit_generator_orchestrator.py new file mode 100644 index 00000000..887f0092 --- /dev/null +++ b/orchestration/audit_generator_orchestrator.py @@ -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 diff --git a/orchestration/audit_generator_unit_of_work.py b/orchestration/audit_generator_unit_of_work.py new file mode 100644 index 00000000..694d6039 --- /dev/null +++ b/orchestration/audit_generator_unit_of_work.py @@ -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() diff --git a/tests/applications/audit_generator/__init__.py b/tests/applications/audit_generator/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/applications/audit_generator/test_audit_generator_handler.py b/tests/applications/audit_generator/test_audit_generator_handler.py new file mode 100644 index 00000000..f9bc74b6 --- /dev/null +++ b/tests/applications/audit_generator/test_audit_generator_handler.py @@ -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 diff --git a/tests/orchestration/audit_generator/__init__.py b/tests/orchestration/audit_generator/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/orchestration/audit_generator/test_audit_generator_orchestrator.py b/tests/orchestration/audit_generator/test_audit_generator_orchestrator.py new file mode 100644 index 00000000..18445fe5 --- /dev/null +++ b/tests/orchestration/audit_generator/test_audit_generator_orchestrator.py @@ -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()