mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
197 lines
6.8 KiB
Python
197 lines
6.8 KiB
Python
"""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"]
|