Model/tests/backend/app/modelling/test_trigger_run_router.py
Khalim Conn-Kowlessar ca799cc5e5 Scenarios outside the portfolio refuse the run 🟥
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 12:06:02 +00:00

197 lines
6.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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"]