"""Tests for the Modelling Run Distributor endpoint (ADR-0055). The router's session and SQS 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 uuid4 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.modelling.router import get_message_sender, get_session, router from domain.modelling.portfolio_goal import PortfolioGoal from domain.tasks.tasks import Task from infrastructure.postgres.modelling import ScenarioModel 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 @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:modelling_run", service="modelling_run") ) def seed_scenario(self, *, portfolio_id: int = PORTFOLIO_ID) -> int: with Session(self.engine) as session: row = ScenarioModel( portfolio_id=portfolio_id, goal=PortfolioGoal.INCREASING_EPC, goal_value="C", ) session.add(row) session.commit() session.refresh(row) assert row.id is not None return row.id def seed_property(self, *, postcode: str = "B93 8SU") -> int: with Session(self.engine) as session: row = PropertyRow(portfolio_id=PORTFOLIO_ID, postcode=postcode) session.add(row) session.commit() session.refresh(row) assert row.id is not None return row.id def subtasks_for(self, task: Task) -> list[Any]: with Session(self.engine) as session: return list(SubTaskPostgresRepository(session).list_by_task(task.id)) @pytest.fixture def api(db_engine: Engine) -> Api: app = FastAPI() app.include_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.extend app.dependency_overrides[validate_token] = lambda: "test-token" return harness def _trigger(api: Api, task: Task, scenario_ids: list[int]) -> Any: return api.client.post( "/v1/modelling/trigger-run", json={ "task_id": str(task.id), "portfolio_id": PORTFOLIO_ID, "scenario_ids": scenario_ids, "filters": {}, }, ) def test_trigger_run_rejects_a_task_that_already_has_subtasks(api: Api) -> None: """A blind retry or double-submit must not double the fan-out (ADR-0055): the app checks the task's progress instead of re-POSTing.""" # arrange — a task that has already been distributed task = api.seed_task() scenario = api.seed_scenario() api.seed_property() assert _trigger(api, task, [scenario]).status_code == 202 already_sent = len(api.sent_bodies) # act response = _trigger(api, task, [scenario]) # assert — refused, nothing new created or sent assert response.status_code == 409 assert len(api.sent_bodies) == already_sent assert len(api.subtasks_for(task)) == 1 def test_trigger_run_rejects_filters_that_match_no_properties(api: Api) -> None: """Zero resolved properties is a caller error (ADR-0055): a task with no sub_tasks could never roll up to complete, and the app's preview shows the same zero from the same rule before POSTing.""" # arrange — a portfolio with no properties at all task = api.seed_task() scenario = api.seed_scenario() # act response = _trigger(api, task, [scenario]) # assert assert response.status_code == 400 assert api.sent_bodies == [] assert api.subtasks_for(task) == [] def test_trigger_run_rejects_scenarios_outside_the_portfolio(api: Api) -> None: # arrange — one valid scenario, one belonging to a different portfolio task = api.seed_task() ours = api.seed_scenario() theirs = api.seed_scenario(portfolio_id=999) api.seed_property() # act response = _trigger(api, task, [ours, theirs]) # assert — refused outright; nothing partially distributed assert response.status_code == 400 assert api.sent_bodies == [] assert api.subtasks_for(task) == [] def test_trigger_run_fans_out_one_subtask_and_message_per_scenario_batch( api: Api, ) -> None: # arrange — 3 properties (one batch) × 2 scenarios = 2 messages task = api.seed_task() scenario_a = api.seed_scenario() scenario_b = api.seed_scenario() property_ids = [api.seed_property() for _ in range(3)] # act response = api.client.post( "/v1/modelling/trigger-run", json={ "task_id": str(task.id), "portfolio_id": PORTFOLIO_ID, "scenario_ids": [scenario_a, scenario_b], "filters": {}, }, ) # assert assert response.status_code == 202 subtasks = api.subtasks_for(task) assert len(subtasks) == 2 assert {s.status.value for s in subtasks} == {"waiting"} messages = [json.loads(body) for body in api.sent_bodies] assert len(messages) == 2 assert {m["scenario_id"] for m in messages} == {scenario_a, scenario_b} for message in messages: assert message["task_id"] == str(task.id) assert message["portfolio_id"] == PORTFOLIO_ID assert message["property_ids"] == property_ids # ADR-0055 pinned flags: live fetch, live predict, solar only-if-missing assert message["refetch_epc"] is True assert message["repredict_epc"] is True assert message["refetch_solar"] is True assert message["dry_run"] is False # each message's subtask_id is one of the pre-created sub_tasks, and the # sub_task's inputs are the message payload (its re-run recipe) subtask_ids = {str(s.id) for s in subtasks} assert {m["subtask_id"] for m in messages} == subtask_ids by_id = {str(s.id): s for s in subtasks} for message in messages: inputs = by_id[message["subtask_id"]].inputs assert inputs["property_ids"] == message["property_ids"] assert inputs["scenario_id"] == message["scenario_id"]