fix(api): batch run remediation evidence reads
This commit is contained in:
216
apps/api/tests/test_platform_operator_run_remediation_batch.py
Normal file
216
apps/api/tests/test_platform_operator_run_remediation_batch.py
Normal file
@@ -0,0 +1,216 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
|
||||
import src.services.adr100_remediation_service as adr100_remediation_service
|
||||
import src.services.platform_operator_service as platform_operator_service
|
||||
|
||||
|
||||
class _FakeScalars:
|
||||
def __init__(self, rows: list[SimpleNamespace]) -> None:
|
||||
self._rows = rows
|
||||
|
||||
def all(self) -> list[SimpleNamespace]:
|
||||
return self._rows
|
||||
|
||||
|
||||
class _FakeResult:
|
||||
def __init__(self, rows: list[SimpleNamespace]) -> None:
|
||||
self._rows = rows
|
||||
|
||||
def scalars(self) -> _FakeScalars:
|
||||
return _FakeScalars(self._rows)
|
||||
|
||||
|
||||
class _FakeDb:
|
||||
def __init__(
|
||||
self,
|
||||
rows: list[SimpleNamespace],
|
||||
*,
|
||||
error: Exception | None = None,
|
||||
) -> None:
|
||||
self._rows = rows
|
||||
self._error = error
|
||||
self.execute_calls = 0
|
||||
self.statements: list[object] = []
|
||||
|
||||
async def execute(self, statement: object) -> _FakeResult:
|
||||
self.execute_calls += 1
|
||||
self.statements.append(statement)
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
return _FakeResult(self._rows)
|
||||
|
||||
|
||||
class _FakeDbContext:
|
||||
def __init__(self, db: _FakeDb, enter_count: list[int]) -> None:
|
||||
self._db = db
|
||||
self._enter_count = enter_count
|
||||
|
||||
async def __aenter__(self) -> _FakeDb:
|
||||
self._enter_count[0] += 1
|
||||
return self._db
|
||||
|
||||
async def __aexit__(self, *_args: object) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _history_row(incident_id: str, sequence: int) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
id=f"{incident_id}-{sequence}",
|
||||
incident_id=incident_id,
|
||||
auto_repair_id=f"repair-{sequence}",
|
||||
event_type="PRE_FLIGHT_PASSED",
|
||||
actor="pytest",
|
||||
success=True,
|
||||
created_at=datetime(2026, 7, 14, tzinfo=UTC) + timedelta(minutes=sequence),
|
||||
context={
|
||||
"schema_version": "adr100_remediation_dry_run_history_v1",
|
||||
"work_item_id": f"work-{incident_id}",
|
||||
"mode": "reverify",
|
||||
"allowed": True,
|
||||
"verification_result_preview": "success",
|
||||
"post_state_summary": {"tool_count": 1, "tools": ["current_state"]},
|
||||
"mcp_route": {
|
||||
"agent_id": "pytest",
|
||||
"tool_name": "current_state",
|
||||
"required_scope": "read",
|
||||
},
|
||||
"writes_incident_state": False,
|
||||
"writes_auto_repair_result": False,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _empty_legacy_history(
|
||||
_incident_ids: list[str],
|
||||
*,
|
||||
limit: int,
|
||||
) -> dict[str, list[dict[str, object]]]:
|
||||
assert limit > 0
|
||||
return {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_remediation_summaries_use_one_bounded_batch_query(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
run_a = SimpleNamespace(
|
||||
run_id=UUID("11111111-1111-1111-1111-111111111111"),
|
||||
state="completed",
|
||||
)
|
||||
run_b = SimpleNamespace(
|
||||
run_id=UUID("22222222-2222-2222-2222-222222222222"),
|
||||
state="completed",
|
||||
)
|
||||
incident_by_run = {
|
||||
run_a.run_id: "INC-20260714-A001",
|
||||
run_b.run_id: "INC-20260714-B001",
|
||||
}
|
||||
rows = [
|
||||
_history_row(incident_id, sequence)
|
||||
for incident_id in incident_by_run.values()
|
||||
for sequence in range(25)
|
||||
]
|
||||
db = _FakeDb(rows)
|
||||
enter_count = [0]
|
||||
|
||||
monkeypatch.setattr(
|
||||
platform_operator_service,
|
||||
"get_db_context",
|
||||
lambda *_args, **_kwargs: _FakeDbContext(db, enter_count),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
platform_operator_service,
|
||||
"_collect_run_incident_ids",
|
||||
lambda *, run, inbound_events, outbound_messages: [incident_by_run[run.run_id]],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
platform_operator_service,
|
||||
"_fetch_legacy_mcp_by_incident_ids",
|
||||
_empty_legacy_history,
|
||||
)
|
||||
|
||||
async def forbidden_per_incident_history(
|
||||
*_args: object, **_kwargs: object
|
||||
) -> object:
|
||||
raise AssertionError(
|
||||
"per-incident Adr100RemediationService.history must not run"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
adr100_remediation_service.Adr100RemediationService,
|
||||
"history",
|
||||
forbidden_per_incident_history,
|
||||
)
|
||||
|
||||
summaries = await platform_operator_service._build_run_remediation_summaries(
|
||||
runs=[run_a, run_b],
|
||||
inbound_by_run={},
|
||||
outbound_by_run={},
|
||||
db=db,
|
||||
)
|
||||
|
||||
assert enter_count == [0]
|
||||
assert db.execute_calls == 1
|
||||
assert len(db.statements) == 1
|
||||
assert summaries[run_a.run_id]["total"] == 20
|
||||
assert summaries[run_b.run_id]["total"] == 20
|
||||
assert summaries[run_a.run_id]["status"] == "read_only_dry_run"
|
||||
assert summaries[run_b.run_id]["status"] == "read_only_dry_run"
|
||||
assert summaries[run_a.run_id]["errors"] == []
|
||||
assert summaries[run_b.run_id]["errors"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_remediation_batch_failure_is_reported_for_each_incident(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
run_a = SimpleNamespace(
|
||||
run_id=UUID("11111111-1111-1111-1111-111111111111"),
|
||||
state="completed",
|
||||
)
|
||||
run_b = SimpleNamespace(
|
||||
run_id=UUID("22222222-2222-2222-2222-222222222222"),
|
||||
state="completed",
|
||||
)
|
||||
incident_by_run = {
|
||||
run_a.run_id: "INC-20260714-A001",
|
||||
run_b.run_id: "INC-20260714-B001",
|
||||
}
|
||||
db = _FakeDb([], error=RuntimeError("database pool exhausted"))
|
||||
enter_count = [0]
|
||||
|
||||
monkeypatch.setattr(
|
||||
platform_operator_service,
|
||||
"get_db_context",
|
||||
lambda *_args, **_kwargs: _FakeDbContext(db, enter_count),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
platform_operator_service,
|
||||
"_collect_run_incident_ids",
|
||||
lambda *, run, inbound_events, outbound_messages: [incident_by_run[run.run_id]],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
platform_operator_service,
|
||||
"_fetch_legacy_mcp_by_incident_ids",
|
||||
_empty_legacy_history,
|
||||
)
|
||||
|
||||
summaries = await platform_operator_service._build_run_remediation_summaries(
|
||||
runs=[run_a, run_b],
|
||||
inbound_by_run={},
|
||||
outbound_by_run={},
|
||||
db=db,
|
||||
)
|
||||
|
||||
assert enter_count == [0]
|
||||
assert db.execute_calls == 1
|
||||
for run in (run_a, run_b):
|
||||
incident_id = incident_by_run[run.run_id]
|
||||
assert summaries[run.run_id]["status"] == "no_evidence"
|
||||
assert summaries[run.run_id]["errors"] == [
|
||||
{"incident_id": incident_id, "error": "database pool exhausted"}
|
||||
]
|
||||
Reference in New Issue
Block a user