180 lines
9.4 KiB
Python
180 lines
9.4 KiB
Python
import asyncio
|
|
from contextlib import asynccontextmanager
|
|
from datetime import datetime, timedelta, timezone
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
from app.mcp import query_service, scada_readings
|
|
from app.mcp.catalog import TOOLS
|
|
from app.mcp.models import ScadaInput
|
|
from app.domain.domain_errors import DomainError
|
|
from app.services.operational_scada_coverage import require_complete_scada_evidence
|
|
|
|
|
|
START = datetime(2026, 7, 16, tzinfo=timezone.utc)
|
|
|
|
|
|
def _args(**extra):
|
|
return ScadaInput(context_handle="a" * 43, start=START, end=START + timedelta(hours=1), **extra)
|
|
|
|
|
|
def test_all_scada_reads_every_catalogue_and_observation_page_with_full_attributes(monkeypatch):
|
|
devices = [{"device_id": f"D{i}", "device_type": ["pressure", "pipe_flow", "quality"][i % 3],
|
|
"measurement_unit": ["m", "m3/h", "mg/L"][i % 3], "node_id": f"J{i}",
|
|
"longitude": 121.5, "latitude": 31.0, "reliability": 1} for i in range(503)]
|
|
rows = [{"device_id": f"D{i % 502}", "time": START.isoformat(),
|
|
"monitored_value": float(i), "cleaned_value": None} for i in range(1205)]
|
|
|
|
async def catalog(connection, *, limit, offset):
|
|
return devices[offset:offset + limit]
|
|
|
|
async def series(connection, **args):
|
|
assert args["ids"] == [device["device_id"] for device in devices]
|
|
assert args["field"] is None
|
|
return rows[args["offset"]:args["offset"] + args["limit"]]
|
|
|
|
catalog_reader, series_reader = AsyncMock(side_effect=catalog), AsyncMock(side_effect=series)
|
|
monkeypatch.setattr(scada_readings.reads, "scada_devices", catalog_reader)
|
|
monkeypatch.setattr(scada_readings.reads, "series_rows", series_reader)
|
|
monkeypatch.setattr(query_service, "authorize_query", AsyncMock())
|
|
|
|
@asynccontextmanager
|
|
async def timescale(): yield object()
|
|
|
|
scope = SimpleNamespace(connection=object(), timescale=timescale)
|
|
result = asyncio.run(query_service.execute_query(scope, SimpleNamespace(replay_context=None),
|
|
TOOLS["query_scada_readings"], _args()))
|
|
assert result.data["devices"] == devices and result.data["readings"] == rows
|
|
assert [call.kwargs["offset"] for call in catalog_reader.await_args_list] == [0, 500]
|
|
assert [call.kwargs["offset"] for call in series_reader.await_args_list] == [0, 1000]
|
|
coverage = result.data["coverage"]
|
|
assert coverage["query_complete"] is True and coverage["queried_device_count"] == 503
|
|
assert coverage["devices_with_readings"] == 502 and coverage["missing_device_ids"] == ["D502"]
|
|
assert coverage["reading_count"] == 1205 and len(result.units) == 503
|
|
assert result.origin == "system_data" and result.page is None and not result.truncated
|
|
|
|
|
|
def test_all_scada_replay_reads_only_frozen_devices_batches_and_window(monkeypatch):
|
|
devices = [{"device_id": key, "device_type": kind, "measurement_unit": unit, "node_id": key}
|
|
for key, kind, unit in [("P1", "pressure", "m"), ("Q1", "quality", "mg/L"), ("NEW", "pressure", "m")]]
|
|
rows = [{"device_id": "P1", "time": START.isoformat(), "monitored_value": 20.,
|
|
"cleaned_value": 20., "quality": "valid", "reason": "冻结观测", "source_hash": "internal-hash"}]
|
|
context = {"replay_id": str(uuid4()), "knowledge_cutoff": (START + timedelta(hours=1)).isoformat(),
|
|
"batch_sequence": 9}
|
|
repository = SimpleNamespace(
|
|
get=AsyncMock(return_value={"start_time": START.isoformat(), "config": {"device_ids": ["P1", "Q1"]},
|
|
"devices": devices[:2]}),
|
|
detected_outputs=AsyncMock(return_value=rows),
|
|
)
|
|
monkeypatch.setattr(scada_readings.reads, "scada_devices", AsyncMock(return_value=devices))
|
|
monkeypatch.setattr(scada_readings, "ScadaReplayRepository", lambda *args: repository)
|
|
timescale = AsyncMock(side_effect=AssertionError("Frozen reads must never use live TSDB"))
|
|
scope = SimpleNamespace(connection=object(), project=SimpleNamespace(project_id=uuid4()), timescale=timescale)
|
|
data, units, gaps, origin = asyncio.run(scada_readings.read_complete_scada(
|
|
scope, SimpleNamespace(replay_context=context), _args(),
|
|
))
|
|
assert data["devices"] == devices
|
|
assert data["readings"] == [{key: value for key, value in rows[0].items() if key != "source_hash"}]
|
|
assert data["coverage"]["queried_device_ids"] == ["P1", "Q1"]
|
|
assert data["coverage"]["missing_device_ids"] == ["Q1"]
|
|
assert data["coverage"]["outside_frozen_scope_device_ids"] == ["NEW"]
|
|
repository.detected_outputs.assert_awaited_once_with(
|
|
context["replay_id"], ["P1", "Q1"], START, START + timedelta(hours=1), 9, limit=1001, offset=0,
|
|
)
|
|
assert origin == "synthetic" and len(gaps) == 2 and units == {"P1": "m", "Q1": "mg/L"}
|
|
timescale.assert_not_called()
|
|
|
|
|
|
def test_all_scada_empty_catalogue_does_not_query_unfiltered_timeseries(monkeypatch):
|
|
monkeypatch.setattr(scada_readings.reads, "scada_devices", AsyncMock(return_value=[]))
|
|
timescale = AsyncMock(side_effect=AssertionError("An empty catalogue must not select every TSDB row"))
|
|
data, units, _, _ = asyncio.run(scada_readings.read_complete_scada(
|
|
SimpleNamespace(connection=object(), timescale=timescale), SimpleNamespace(replay_context=None), _args(),
|
|
))
|
|
assert data["coverage"]["queried_device_count"] == 0 and data["readings"] == [] and units == {}
|
|
timescale.assert_not_called()
|
|
|
|
|
|
def test_all_scada_rejects_offset_and_empty_explicit_selection():
|
|
for values in ({"offset": 1}, {"device_ids": []}):
|
|
with pytest.raises(ValueError):
|
|
_args(**values)
|
|
assert _args(device_ids=["P1"], offset=100).offset == 100
|
|
|
|
|
|
@pytest.mark.parametrize("captured,approved,explicit,direct,allowed", [
|
|
({"scada_readings", "scada_devices"}, {"scada_readings"}, False, False, False),
|
|
({"scada_readings"}, {"scada_readings", "scada_devices"}, False, False, False),
|
|
({"scada_readings", "scada_devices"}, {"scada_devices"}, False, False, False),
|
|
({"scada_readings", "scada_devices"}, {"scada_readings", "scada_devices"}, False, False, True),
|
|
({"scada_readings"}, {"scada_readings"}, True, False, True),
|
|
(set(), set(), False, True, True),
|
|
])
|
|
def test_complete_scada_requires_both_captured_and_current_source_grants(
|
|
monkeypatch, captured, approved, explicit, direct, allowed,
|
|
):
|
|
@asynccontextmanager
|
|
async def transaction():
|
|
yield
|
|
|
|
can_read = AsyncMock(side_effect=lambda task_id, source: source in approved)
|
|
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"scada.view"} if direct else set())
|
|
monkeypatch.setattr(query_service, "task_service", lambda _: SimpleNamespace(can_read_source=can_read))
|
|
binding = SimpleNamespace(
|
|
replay_context=None, tool_cap=["query_scada_readings"],
|
|
permission_cap=["scada.view"] if direct else [], task_id=uuid4(), task_scope_cap=captured,
|
|
)
|
|
scope = SimpleNamespace(project=object(), connection=SimpleNamespace(transaction=transaction))
|
|
args = _args(**({"device_ids": ["P1"]} if explicit else {}))
|
|
call = query_service.authorize_query(scope, binding, TOOLS["query_scada_readings"], args)
|
|
if allowed:
|
|
asyncio.run(call)
|
|
else:
|
|
with pytest.raises(HTTPException) as error:
|
|
asyncio.run(call)
|
|
assert error.value.status_code == 403
|
|
if direct:
|
|
can_read.assert_not_awaited()
|
|
elif allowed:
|
|
expected = {"scada_readings"} if explicit else {"scada_readings", "scada_devices"}
|
|
assert {call.args[1] for call in can_read.await_args_list} == expected
|
|
|
|
|
|
def test_all_scada_reports_oversize_instead_of_successfully_returning_a_partial_scan(monkeypatch):
|
|
monkeypatch.setattr(scada_readings.reads, "scada_devices", AsyncMock(return_value=[
|
|
{"device_id": "P1", "description": "x" * 200},
|
|
]))
|
|
monkeypatch.setattr(scada_readings, "MAX_COMPLETE_SCADA_BYTES", 100)
|
|
with pytest.raises(HTTPException) as error:
|
|
asyncio.run(scada_readings.read_complete_scada(
|
|
SimpleNamespace(connection=object()), SimpleNamespace(replay_context=None), _args(),
|
|
))
|
|
assert error.value.status_code == 413
|
|
|
|
|
|
@pytest.mark.parametrize("defect", [None, "old_batch", "other_replay", "old_window", "unreferenced", "truncated"])
|
|
def test_final_scada_evidence_must_match_the_current_frozen_scope(defect):
|
|
replay_id = str(uuid4())
|
|
replay = {"replay_id": replay_id, "knowledge_cutoff": START.isoformat(), "batch_sequence": 9}
|
|
task = {"workflow": "burst_report", "source": {"replay_context": replay}}
|
|
coverage = {"mode": "all_scada", "query_complete": True, "replay_id": replay_id, "batch_sequence": 9}
|
|
window = {"start": (START - timedelta(hours=1)).isoformat(), "end": START.isoformat()}
|
|
if defect == "old_batch": coverage["batch_sequence"] = 8
|
|
if defect == "other_replay": coverage["replay_id"] = str(uuid4())
|
|
if defect == "old_window": window["end"] = (START - timedelta(minutes=15)).isoformat()
|
|
evidence = [{"evidence_id": "mcp:all", "data_snapshot": {
|
|
"source": "query_scada_readings", "truncated": defect == "truncated",
|
|
"data": {"data": {"coverage": coverage}, "window": window},
|
|
}}]
|
|
refs = [] if defect == "unreferenced" else ["mcp:all"]
|
|
if defect is None:
|
|
require_complete_scada_evidence(task, evidence, refs)
|
|
else:
|
|
with pytest.raises(DomainError) as error:
|
|
require_complete_scada_evidence(task, evidence, refs)
|
|
assert error.value.code == "SCADA_COVERAGE_REQUIRED"
|