fix(api): batch run remediation evidence reads

This commit is contained in:
ogt
2026-07-14 16:53:08 +08:00
parent 5ec6fd3c30
commit ceae823172
2 changed files with 352 additions and 31 deletions

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