feat(scada): add project monitoring replay
This commit is contained in:
@@ -616,3 +616,52 @@ def test_project_map_config_accepts_search_and_preserves_extra_options():
|
||||
AdminProjectUpdateRequest(map_config=None)
|
||||
with pytest.raises(ValidationError):
|
||||
AdminProjectUpdateRequest(map_config={'place_search': {'enabled': True, 'admin_code': 'wrong', 'region': '凤阳县'}})
|
||||
|
||||
|
||||
def test_project_scada_monitoring_interval_is_a_strict_schedule_parameter():
|
||||
from pydantic import ValidationError
|
||||
from app.domain.schemas.admin_metadata import AdminProjectUpdateRequest
|
||||
|
||||
config = {
|
||||
"scada_monitoring_interval_seconds": 900,
|
||||
"future_option": {"enabled": True, "values": [1, "demo", None]},
|
||||
}
|
||||
request = AdminProjectUpdateRequest(settings=config)
|
||||
assert request.model_dump(exclude_unset=True) == {"settings": config}
|
||||
for value in (0, 59, True, 900.0, "900", 31_536_001):
|
||||
with pytest.raises(ValidationError):
|
||||
AdminProjectUpdateRequest(settings={"scada_monitoring_interval_seconds": value})
|
||||
for value in (None, [], "settings", {"future_option": object()}):
|
||||
with pytest.raises(ValidationError):
|
||||
AdminProjectUpdateRequest(settings=value)
|
||||
assert AdminProjectUpdateRequest().model_dump(exclude_unset=True) == {}
|
||||
assert AdminProjectUpdateRequest(settings={}).model_dump(exclude_unset=True) == {"settings": {}}
|
||||
assert AdminProjectUpdateRequest(
|
||||
settings={"scada_monitoring_interval_seconds": None}
|
||||
).model_dump(exclude_unset=True) == {"settings": {"scada_monitoring_interval_seconds": None}}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_project_passes_only_requested_settings_and_returns_extra_keys(monkeypatch):
|
||||
from app.domain.schemas.admin_metadata import AdminProjectUpdateRequest
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
project = _project(
|
||||
code="demo", description=None, gs_workspace="demo", map_extent=None,
|
||||
settings={"scada_monitoring_interval_seconds": 900, "future_option": {"enabled": True}},
|
||||
status="active", created_at=now, updated_at=now,
|
||||
)
|
||||
repo = SimpleNamespace(session=object(), update_project=AsyncMock(return_value=project))
|
||||
monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock())
|
||||
|
||||
response = await admin_metadata.update_admin_project(
|
||||
AdminProjectUpdateRequest(settings={"future_option": {"enabled": True}}),
|
||||
project_id=project.id,
|
||||
current_user=_user(role="admin", is_superuser=True),
|
||||
metadata_repo=repo,
|
||||
)
|
||||
|
||||
repo.update_project.assert_awaited_once_with(
|
||||
project.id, updates={"settings": {"future_option": {"enabled": True}}}
|
||||
)
|
||||
assert response.settings.model_dump() == {**project.settings, "scada_monitoring": None}
|
||||
|
||||
@@ -86,6 +86,7 @@ async def test_preconfigured_service_access_avoids_server_delegation(monkeypatch
|
||||
source_ref=snapshot.source_ref, observed_at=now, recorded_at=now,
|
||||
recorded_by="service", has_snapshot=True)
|
||||
service = SimpleNamespace(can_read_source=AsyncMock(return_value=True),
|
||||
replay_context=AsyncMock(return_value=None),
|
||||
record_data_snapshot=AsyncMock(return_value=(evidence, snapshot)))
|
||||
source = AsyncMock(return_value=snapshot)
|
||||
monkeypatch.setattr(endpoints, "_service", lambda _ctx, _conn: service)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""HTTP boundary checks without project database access."""
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1.endpoints.timeseries import scada
|
||||
from app.domain.schemas.scada import ScadaRawPage
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def raw_api(monkeypatch):
|
||||
app = FastAPI()
|
||||
app.include_router(scada.router)
|
||||
project = uuid4()
|
||||
app.dependency_overrides[scada.get_timescale_connection] = lambda: object()
|
||||
app.dependency_overrides[scada.get_postgres_connection] = lambda: object()
|
||||
app.dependency_overrides[scada.get_project_context] = lambda: SimpleNamespace(project_id=project)
|
||||
devices = AsyncMock(return_value={"P1"})
|
||||
monkeypatch.setattr(scada.ScadaInfoRepository, "get_existing_device_ids", devices)
|
||||
read = AsyncMock(return_value=ScadaRawPage(items=[], next_cursor=None, scanned_through=datetime(2026, 10, 2, tzinfo=timezone.utc)))
|
||||
monkeypatch.setattr(scada, "read_raw_page", read)
|
||||
with TestClient(app) as client:
|
||||
yield client, devices, read, project
|
||||
|
||||
|
||||
def test_raw_http_returns_only_read_projection_and_binds_project(raw_api):
|
||||
client, devices, read, project = raw_api
|
||||
response = client.get("/timeseries/scada-readings/raw", params={
|
||||
"start_time": "2026-10-01T00:00:00Z", "end_time": "2026-10-02T00:00:00Z", "device_ids": "P1,P1",
|
||||
})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["items"] == []
|
||||
assert read.await_args.kwargs["project_id"] == str(project)
|
||||
assert read.await_args.kwargs["device_ids"] == ["P1"]
|
||||
devices.assert_awaited_once()
|
||||
|
||||
|
||||
def test_raw_http_rejects_naive_time_before_replay_lookup(raw_api):
|
||||
client, devices, read, _ = raw_api
|
||||
response = client.get("/timeseries/scada-readings/raw", params={
|
||||
"start_time": "2026-10-01T00:00:00", "end_time": "2026-10-02T00:00:00Z",
|
||||
"device_ids": "P1", "replay_id": str(uuid4()),
|
||||
})
|
||||
assert response.status_code == 422
|
||||
devices.assert_not_awaited()
|
||||
read.assert_not_awaited()
|
||||
|
||||
|
||||
def test_raw_http_rejects_cross_project_device_before_tsdb(raw_api):
|
||||
client, _, read, _ = raw_api
|
||||
response = client.get("/timeseries/scada-readings/raw", params={
|
||||
"start_time": "2026-10-01T00:00:00Z", "end_time": "2026-10-02T00:00:00Z", "device_ids": "other",
|
||||
})
|
||||
assert response.status_code == 422
|
||||
read.assert_not_awaited()
|
||||
@@ -0,0 +1,113 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1.endpoints import scada_replay
|
||||
from app.auth.project_dependencies import ProjectContext, get_project_context
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def replay_api(monkeypatch):
|
||||
context = ProjectContext(uuid4(), "demo", uuid4(), "member")
|
||||
project = SimpleNamespace(settings={"scada_monitoring_interval_seconds": 900, "other": True})
|
||||
metadata = SimpleNamespace(get_project_by_id=AsyncMock(return_value=project))
|
||||
repo = SimpleNamespace(available=AsyncMock(return_value=True), current=AsyncMock(return_value=None))
|
||||
create = AsyncMock(side_effect=RuntimeError("creation mock must be configured"))
|
||||
monkeypatch.setattr(scada_replay.ScadaReplayService, "create", create)
|
||||
app = FastAPI()
|
||||
app.include_router(scada_replay.router)
|
||||
app.dependency_overrides[get_project_context] = lambda: context
|
||||
app.dependency_overrides[scada_replay.repository] = lambda: repo
|
||||
app.dependency_overrides[scada_replay.get_metadata_repository] = lambda: metadata
|
||||
with TestClient(app) as client:
|
||||
yield client, project, metadata, context, create
|
||||
|
||||
|
||||
def test_capabilities_reads_frequency_from_current_project_settings(replay_api):
|
||||
client, project, metadata, context, _ = replay_api
|
||||
response = client.get("/scada-replays/capabilities")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["monitoring_interval_seconds"] == 900
|
||||
assert response.json()["burst_monitoring_ready"] is False
|
||||
assert "尚未配置" in response.json()["burst_monitoring_reason"]
|
||||
metadata.get_project_by_id.assert_awaited_once_with(context.project_id)
|
||||
project.settings = {}
|
||||
assert client.get("/scada-replays/capabilities").json()["monitoring_interval_seconds"] is None
|
||||
|
||||
|
||||
def test_capabilities_reports_ready_only_with_five_pressures_and_outlet_flow(replay_api):
|
||||
client, project, *_ = replay_api
|
||||
project.settings = {
|
||||
"scada_monitoring_interval_seconds": 900,
|
||||
"scada_monitoring": {
|
||||
"pressure_device_ids": ["P1", "P2", "P3", "P4", "P5"],
|
||||
"outlet_flow_device_ids": ["F1"],
|
||||
},
|
||||
}
|
||||
|
||||
response = client.get("/scada-replays/capabilities")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["burst_monitoring_ready"] is True
|
||||
assert response.json()["burst_monitoring_reason"] is None
|
||||
|
||||
|
||||
def test_capabilities_reports_only_missing_storage_not_worker():
|
||||
repo = SimpleNamespace(available=AsyncMock(return_value=False))
|
||||
context = ProjectContext(uuid4(), "demo", uuid4(), "member")
|
||||
|
||||
result = asyncio.run(scada_replay.replay_capabilities(
|
||||
repo=repo,
|
||||
ctx=context,
|
||||
settings=scada_replay.ProjectSettings(scada_monitoring_interval_seconds=900),
|
||||
))
|
||||
|
||||
assert result.available is False
|
||||
assert result.reason == "需先部署 SCADA 回放存储结构"
|
||||
|
||||
|
||||
def test_step_command_processes_pending_period_in_same_request(monkeypatch):
|
||||
calls = []
|
||||
|
||||
class Service:
|
||||
def __init__(self, repo, context, timescale):
|
||||
calls.append((repo, context, timescale))
|
||||
|
||||
async def command(self, replay_id, command):
|
||||
calls.append((replay_id, command.action))
|
||||
return SimpleNamespace(step_pending=True)
|
||||
|
||||
async def process_pending_step(self, replay_id):
|
||||
calls.append((replay_id, "processed"))
|
||||
return "completed-period"
|
||||
|
||||
monkeypatch.setattr(scada_replay, "ScadaReplayService", Service)
|
||||
replay_id = uuid4()
|
||||
context = ProjectContext(uuid4(), "demo", uuid4(), "member")
|
||||
command = scada_replay.ScadaReplayCommand(
|
||||
request_id=uuid4(), expected_revision=1, action="step"
|
||||
)
|
||||
|
||||
result = asyncio.run(scada_replay.replay_command(
|
||||
replay_id, command, repo="repo", ctx=context, timescale="timescale"
|
||||
))
|
||||
|
||||
assert result == "completed-period"
|
||||
assert calls[-2:] == [(replay_id, "step"), (replay_id, "processed")]
|
||||
|
||||
|
||||
def test_client_cannot_override_project_frequency_on_creation(replay_api):
|
||||
client, _, _, _, create = replay_api
|
||||
response = client.post("/scada-replays", json={
|
||||
"request_id": str(uuid4()), "start_time": "2026-10-08T00:00:00Z",
|
||||
"end_time": "2026-10-08T01:00:00Z", "device_ids": ["P1"],
|
||||
"rules": [{"rule_id": "p", "device_ids": ["P1"], "kind": "bounds", "upper": 10, "unit": "m"}],
|
||||
"monitoring_interval_seconds": 300,
|
||||
})
|
||||
assert response.status_code == 422
|
||||
create.assert_not_awaited()
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Real project SQL, with all schema, observations, events and tasks rolled back."""
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
import pytest
|
||||
|
||||
from app.auth.project_dependencies import ProjectContext
|
||||
from app.domain.schemas.scada_replay import ScadaReplayCreate, ScadaReplayCommand
|
||||
from app.infra.db.metadb.database import SessionLocal, engine
|
||||
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
|
||||
from app.infra.db.postgresql.scada_replays import ScadaReplayRepository
|
||||
from app.infra.db.postgresql.scada import ScadaInfoRepository
|
||||
from app.services.scada_replay import ScadaReplayService
|
||||
|
||||
pytestmark = pytest.mark.skipif(os.getenv("SCADA_REPLAY_STORAGE_TEST") != "1", reason="explicit rollback-only SQL test required")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend(): return "asyncio"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_real_manual_steps_clean_raw_and_persist_events_roll_back(monkeypatch):
|
||||
actual_project = UUID("871d7538-589c-47de-a6d5-3185ccb9f0e1")
|
||||
async with SessionLocal() as session:
|
||||
metadata = MetadataRepository(session)
|
||||
business = await metadata.get_project_db_routing(actual_project, "biz_data")
|
||||
timeseries = await metadata.get_project_db_routing(actual_project, "iot_data")
|
||||
await engine.dispose()
|
||||
pg = await psycopg.AsyncConnection.connect(business.dsn, row_factory=dict_row, connect_timeout=5)
|
||||
ts = await psycopg.AsyncConnection.connect(timeseries.dsn, row_factory=dict_row, connect_timeout=5)
|
||||
try:
|
||||
async with pg.transaction(force_rollback=True), ts.transaction(force_rollback=True):
|
||||
actual = await (await pg.execute("SELECT current_database() AS db")).fetchone()
|
||||
assert actual["db"] == "tjwater2_v2"
|
||||
migration = (Path(__file__).parents[2] / "resources/sql/031_scada_replays.sql").read_text()
|
||||
migration = migration.replace("BEGIN;\n", "", 1).removesuffix("COMMIT;\n")
|
||||
await pg.execute(migration)
|
||||
device = "SCADA_TEST_" + uuid4().hex[:12]
|
||||
start = datetime(2026, 10, 8, tzinfo=timezone.utc)
|
||||
for minute, value in ((0, 10), (15, 20), (30, 20), (45, 10), (60, 10)):
|
||||
await ts.execute("INSERT INTO scada.measurements(time,device_id,monitored_value) VALUES(%s,%s,%s)",
|
||||
(start + timedelta(minutes=minute), device, value))
|
||||
monkeypatch.setattr(ScadaInfoRepository, "get_scadas", AsyncMock(return_value=[{
|
||||
"device_id": device, "device_type": "pressure", "measurement_unit": "m",
|
||||
"transmission_frequency": "0:15:00", "transmission_mode": "non_realtime"
|
||||
}]))
|
||||
context = ProjectContext(
|
||||
project_id=uuid4(),
|
||||
project_code="tjwater2_v2",
|
||||
user_id=uuid4(),
|
||||
project_role="member",
|
||||
)
|
||||
repo = ScadaReplayRepository(pg, context.project_id)
|
||||
wall = [start]
|
||||
service = ScadaReplayService(repo, context, ts, clock=lambda: wall[0])
|
||||
config = ScadaReplayCreate(request_id=uuid4(), start_time=start, end_time=start + timedelta(minutes=75),
|
||||
device_ids=[device],
|
||||
rules=[{"rule_id": "pressure", "device_ids": [device], "kind": "bounds", "upper": 15, "unit": "m"}])
|
||||
result = await service.create(config, monitoring_interval_seconds=900)
|
||||
while result.status != "completed":
|
||||
await service.command(result.replay_id, ScadaReplayCommand(
|
||||
request_id=uuid4(), expected_revision=result.revision, action="step"
|
||||
))
|
||||
result = await service.process_pending_step(result.replay_id)
|
||||
assert result.status == "completed"
|
||||
assert result.processed_count == 5
|
||||
assert result.anomaly_count == 1
|
||||
events = await repo.events(result.replay_id)
|
||||
assert {event["change"] for event in events} == {"raised", "resolved"}
|
||||
assert all(event["task_id"] is None for event in events)
|
||||
raw = await (await ts.execute("SELECT monitored_value,cleaned_value FROM scada.measurements WHERE device_id=%s ORDER BY time", (device,))).fetchall()
|
||||
assert [row["monitored_value"] for row in raw] == [10, 20, 20, 10, 10]
|
||||
assert [row["cleaned_value"] for row in raw] == [10, 20, 20, 10, 10]
|
||||
finally:
|
||||
await pg.close(); await ts.close()
|
||||
@@ -300,6 +300,27 @@ def binding(task=False):
|
||||
return value
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tool_name", ["run_burst_detection", "query_element_history"])
|
||||
def test_replay_binding_denies_unscoped_computations_and_history(tool_name):
|
||||
bound = binding(task=True)
|
||||
bound.replay_context = {"replay_id": str(uuid4()), "knowledge_cutoff": "2026-10-08T01:00:00+00:00", "batch_sequence": 1}
|
||||
with pytest.raises(HTTPException) as error:
|
||||
asyncio.run(query_service.authorize_query(SimpleNamespace(), bound, TOOLS[tool_name], SimpleNamespace()))
|
||||
assert error.value.status_code == 403
|
||||
|
||||
|
||||
def test_replay_binding_denies_future_and_allows_device_metadata(monkeypatch):
|
||||
bound = binding(task=True)
|
||||
bound.replay_context = {"replay_id": str(uuid4()), "knowledge_cutoff": "2026-10-08T01:00:00+00:00", "batch_sequence": 1}
|
||||
args = SimpleNamespace(start=datetime(2026, 10, 8, tzinfo=timezone.utc),
|
||||
end=datetime(2026, 10, 8, 2, tzinfo=timezone.utc))
|
||||
with pytest.raises(HTTPException) as error:
|
||||
asyncio.run(query_service.authorize_query(SimpleNamespace(), bound, TOOLS["query_scada_readings"], args))
|
||||
assert error.value.status_code == 422
|
||||
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: PERMISSIONS)
|
||||
asyncio.run(query_service.authorize_query(SimpleNamespace(project=object()), bound, TOOLS["list_scada_devices"], SimpleNamespace()))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("seconds", [30, 120, 240])
|
||||
def test_all_tools_advertise_the_effective_host_request_budget(runtime, seconds):
|
||||
app, client, _, _ = runtime
|
||||
|
||||
@@ -7,6 +7,7 @@ from sqlalchemy.dialects import postgresql
|
||||
|
||||
from app.infra.db.metadb.repositories import metadata_repository
|
||||
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
|
||||
from app.domain.schemas.project_settings import ProjectSettings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -99,3 +100,60 @@ async def test_update_map_config_preserves_legacy_settings_and_other_projects():
|
||||
repository.get_project_by_id.assert_awaited_once_with(project_id)
|
||||
assert project.map_config == {'legend': 'original', 'coordinate_reference': {'source_srid': 990001}, 'place_search': search}
|
||||
session.commit.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_project_settings_preserves_unmentioned_parameters():
|
||||
original = {"scada_monitoring_interval_seconds": 300, "future_option": {"enabled": True}}
|
||||
project = SimpleNamespace(settings=original)
|
||||
session = SimpleNamespace(commit=AsyncMock(), refresh=AsyncMock())
|
||||
repository = MetadataRepository(session)
|
||||
repository.get_project_by_id = AsyncMock(return_value=project)
|
||||
project_id = uuid4()
|
||||
|
||||
await repository.update_project(
|
||||
project_id,
|
||||
updates={"settings": {"scada_monitoring_interval_seconds": 900}},
|
||||
)
|
||||
|
||||
assert project.settings == {"scada_monitoring_interval_seconds": 900, "future_option": {"enabled": True}}
|
||||
assert original["scada_monitoring_interval_seconds"] == 300
|
||||
session.commit.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_empty_settings_update_preserves_values_and_explicit_null_clears_interval():
|
||||
project = SimpleNamespace(settings={"scada_monitoring_interval_seconds": 900, "other": True})
|
||||
session = SimpleNamespace(commit=AsyncMock(), refresh=AsyncMock())
|
||||
repository = MetadataRepository(session)
|
||||
repository.get_project_by_id = AsyncMock(return_value=project)
|
||||
project_id = uuid4()
|
||||
|
||||
await repository.update_project(project_id, updates={"settings": {}})
|
||||
assert project.settings == {"scada_monitoring_interval_seconds": 900, "other": True}
|
||||
await repository.update_project(
|
||||
project_id, updates={"settings": {"scada_monitoring_interval_seconds": None}}
|
||||
)
|
||||
assert project.settings == {"scada_monitoring_interval_seconds": None, "other": True}
|
||||
|
||||
|
||||
def test_scada_monitoring_roles_are_typed_inside_extensible_project_settings():
|
||||
settings = ProjectSettings.model_validate({
|
||||
"scada_monitoring_interval_seconds": 900,
|
||||
"scada_monitoring": {
|
||||
"rule_version": "burst-monitor-v1",
|
||||
"pressure_device_ids": ["P1", "P2"],
|
||||
"outlet_flow_device_ids": ["F1"],
|
||||
},
|
||||
"future_option": True,
|
||||
})
|
||||
|
||||
assert settings.scada_monitoring.pressure_device_ids == ["P1", "P2"]
|
||||
assert settings.model_dump()["future_option"] is True
|
||||
with pytest.raises(ValueError):
|
||||
ProjectSettings.model_validate({
|
||||
"scada_monitoring": {
|
||||
"pressure_device_ids": ["D1"],
|
||||
"outlet_flow_device_ids": ["D1"],
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
|
||||
from app.services.scada_raw import read_raw_page
|
||||
|
||||
START = datetime(2026, 10, 8, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def reading(minutes, device="P1", value=1):
|
||||
return {"time": START + timedelta(minutes=minutes), "device_id": device,
|
||||
"monitored_value": value, "source_hash": "hash"}
|
||||
|
||||
|
||||
def test_pagination_keeps_same_timestamp_devices_and_freezes_clock(monkeypatch):
|
||||
fetch = AsyncMock(side_effect=[[reading(0), reading(0, "P2")], [reading(0, "P2")]])
|
||||
monkeypatch.setattr(ScadaRepository, "get_raw_page", fetch)
|
||||
async def run():
|
||||
args = dict(project_id="project", device_ids=["P1", "P2"], start=START,
|
||||
end=START + timedelta(hours=1), limit=1)
|
||||
first = await read_raw_page(None, **args, through=START + timedelta(minutes=15))
|
||||
second = await read_raw_page(None, **args, cursor=first.next_cursor, through=START + timedelta(minutes=30))
|
||||
assert first.items[0].device_id == "P1"
|
||||
assert second.items[0].device_id == "P2"
|
||||
assert second.scanned_through == START + timedelta(minutes=15)
|
||||
assert fetch.await_args.args[-1] == (START, "P1")
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_cursor_cannot_expand_query_or_move_into_future(monkeypatch):
|
||||
fetch = AsyncMock(return_value=[reading(0), reading(5)])
|
||||
monkeypatch.setattr(ScadaRepository, "get_raw_page", fetch)
|
||||
async def run():
|
||||
args = dict(project_id="project", device_ids=["P1"], start=START,
|
||||
end=START + timedelta(hours=1), limit=1)
|
||||
first = await read_raw_page(None, **args, through=START + timedelta(minutes=15))
|
||||
for change in ({"project_id": "other"}, {"device_ids": ["P2"]}, {"through": START}):
|
||||
with pytest.raises(ValueError, match="游标"):
|
||||
await read_raw_page(None, **(args | change), cursor=first.next_cursor)
|
||||
asyncio.run(run())
|
||||
assert fetch.await_count == 1
|
||||
|
||||
|
||||
def test_raw_response_does_not_expose_corrected_values_and_marks_bad_numbers(monkeypatch):
|
||||
monkeypatch.setattr(ScadaRepository, "get_raw_page", AsyncMock(return_value=[
|
||||
reading(0, value=float("nan")), reading(5, value=None), reading(10, value=0),
|
||||
]))
|
||||
page = asyncio.run(read_raw_page(None, project_id="project", device_ids=["P1"], start=START,
|
||||
end=START + timedelta(hours=1)))
|
||||
assert [item.quality for item in page.items] == ["invalid", "missing", "unvalidated"]
|
||||
assert page.items[2].monitored_value == 0
|
||||
assert "cleaned_value" not in page.model_dump_json()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cursor", ["not-base64", "e30=", "W10="])
|
||||
def test_malformed_cursor_is_rejected_without_query(monkeypatch, cursor):
|
||||
fetch = AsyncMock()
|
||||
monkeypatch.setattr(ScadaRepository, "get_raw_page", fetch)
|
||||
with pytest.raises(ValueError):
|
||||
asyncio.run(read_raw_page(None, project_id="project", device_ids=["P1"], start=START,
|
||||
end=START + timedelta(hours=1), cursor=cursor))
|
||||
fetch.assert_not_awaited()
|
||||
@@ -0,0 +1,108 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from app.domain.schemas.scada_replay import ScadaReplayCreate
|
||||
from app.services import scada_replay_monitoring
|
||||
|
||||
|
||||
TARGET = datetime(2026, 10, 16, tzinfo=timezone.utc)
|
||||
PRESSURE_IDS = [f"P{index}" for index in range(1, 6)]
|
||||
FLOW_IDS = ["F1"]
|
||||
|
||||
|
||||
def _document():
|
||||
config = ScadaReplayCreate.model_validate({
|
||||
"request_id": str(uuid4()),
|
||||
"start_time": TARGET - timedelta(days=15),
|
||||
"end_time": TARGET + timedelta(hours=1),
|
||||
"device_ids": PRESSURE_IDS + FLOW_IDS,
|
||||
"rules": [
|
||||
{"rule_id": "pressure_drop", "device_ids": PRESSURE_IDS,
|
||||
"kind": "relative_change", "direction": "decrease", "threshold": 20, "unit": "m"},
|
||||
{"rule_id": "outlet_flow", "device_ids": FLOW_IDS,
|
||||
"kind": "relative_change", "direction": "increase", "threshold": 20, "unit": "m3/h"},
|
||||
],
|
||||
})
|
||||
return {
|
||||
"replay_id": str(uuid4()),
|
||||
"config": config.model_dump(mode="json"),
|
||||
"monitoring_interval_seconds": 900,
|
||||
"monitoring_config": {
|
||||
"rule_version": "burst-monitor-v1",
|
||||
"pressure_device_ids": PRESSURE_IDS,
|
||||
"outlet_flow_device_ids": FLOW_IDS,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _rows():
|
||||
rows = []
|
||||
count = 15 * 96
|
||||
start = TARGET - timedelta(minutes=15 * (count - 1))
|
||||
for index in range(count):
|
||||
at = start + timedelta(minutes=15 * index)
|
||||
for device_id in PRESSURE_IDS:
|
||||
rows.append({"time": at.isoformat(), "device_id": device_id,
|
||||
"cleaned_value": 70 if at == TARGET else 100,
|
||||
"quality": "valid"})
|
||||
rows.append({"time": at.isoformat(), "device_id": "F1",
|
||||
"cleaned_value": 150 if at == TARGET else 100,
|
||||
"quality": "valid"})
|
||||
return rows
|
||||
|
||||
|
||||
def test_multiple_indicators_create_suspected_burst_result(monkeypatch):
|
||||
monkeypatch.setattr(scada_replay_monitoring, "run_burst_detection", lambda **_: {
|
||||
"rows": [{"Score": -0.05}],
|
||||
"summary": {"latest_sensor_rankings": []},
|
||||
})
|
||||
|
||||
result = scada_replay_monitoring.evaluate_monitoring_period(
|
||||
_document(), _rows(),
|
||||
period_start=TARGET - timedelta(minutes=15),
|
||||
period_end=TARGET,
|
||||
batch_sequence=1440,
|
||||
)
|
||||
|
||||
assert result["status"] == "suspected_burst"
|
||||
assert result["pressure"]["hit_device_ids"] == PRESSURE_IDS
|
||||
assert result["outlet_flow"]["hit_device_ids"] == FLOW_IDS
|
||||
assert result["burst_detection"]["hit"] is True
|
||||
|
||||
|
||||
def test_single_indicator_stays_attention(monkeypatch):
|
||||
monkeypatch.setattr(scada_replay_monitoring, "run_burst_detection", lambda **_: {
|
||||
"rows": [{"Score": 0.1}],
|
||||
"summary": {"latest_sensor_rankings": []},
|
||||
})
|
||||
rows = _rows()
|
||||
for row in rows:
|
||||
if row["device_id"] == "F1" and row["time"] == TARGET.isoformat():
|
||||
row["cleaned_value"] = 100
|
||||
|
||||
result = scada_replay_monitoring.evaluate_monitoring_period(
|
||||
_document(), rows,
|
||||
period_start=TARGET - timedelta(minutes=15),
|
||||
period_end=TARGET,
|
||||
batch_sequence=1440,
|
||||
)
|
||||
|
||||
assert result["status"] == "attention"
|
||||
assert result["pressure"]["hit"] is True
|
||||
assert result["outlet_flow"]["hit"] is False
|
||||
assert result["burst_detection"]["hit"] is False
|
||||
|
||||
|
||||
def test_missing_project_roles_is_data_gap_not_normal():
|
||||
document = _document()
|
||||
document["monitoring_config"] = None
|
||||
|
||||
result = scada_replay_monitoring.evaluate_monitoring_period(
|
||||
document, [],
|
||||
period_start=TARGET - timedelta(minutes=15),
|
||||
period_end=TARGET,
|
||||
batch_sequence=1,
|
||||
)
|
||||
|
||||
assert result["status"] == "insufficient_data"
|
||||
assert result["burst_detection"]["status"] == "not_run"
|
||||
@@ -0,0 +1,66 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from app.domain.schemas.scada_replay import ScadaReplayCreate
|
||||
from app.services.scada_replay_processing import prepare_cleaning, detect_frame
|
||||
|
||||
START = datetime(2026, 10, 8, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def config(**changes):
|
||||
return ScadaReplayCreate.model_validate({
|
||||
"request_id": str(uuid4()), "start_time": START, "end_time": START + timedelta(hours=2),
|
||||
"device_ids": ["P1"], "rules": [{"rule_id": "pressure", "device_ids": ["P1"],
|
||||
"kind": "relative_change", "direction": "decrease", "threshold": 30, "unit": "m"}],
|
||||
**changes,
|
||||
})
|
||||
|
||||
|
||||
def rows(value, minute=0):
|
||||
return [{"time": START + timedelta(minutes=minute), "device_id": "P1", "monitored_value": value, "source_hash": "h"}]
|
||||
|
||||
|
||||
def test_quality_does_not_repair_a_real_pressure_drop_or_reverse_flow():
|
||||
for value in (0, -5, 1):
|
||||
result = prepare_cleaning(rows(value), config())
|
||||
assert result[0]["quality"] == "valid"
|
||||
assert result[0]["cleaned_value"] == value
|
||||
invalid = prepare_cleaning(rows(-5), config(quality_ranges={"P1": {"lower": 0}}))
|
||||
assert invalid[0]["quality"] == "invalid"
|
||||
assert invalid[0]["monitored_value"] == -5
|
||||
assert invalid[0]["cleaned_value"] is None
|
||||
|
||||
|
||||
def test_persistent_drop_is_one_episode_and_recovery_then_drop_is_new_episode():
|
||||
cfg = config()
|
||||
doc = {"replay_id": "run", "histories": {"P1": [10, 10, 10, 10]}}
|
||||
all_events = []
|
||||
for minute, value in enumerate((1, 1, None, 10, 10, 1)):
|
||||
doc, events = detect_frame(doc, prepare_cleaning(rows(value, minute), cfg), cfg, START + timedelta(minutes=minute))
|
||||
all_events.extend(events)
|
||||
business = [event for event in all_events if event.get("category") != "data_quality"]
|
||||
quality = [event for event in all_events if event.get("category") == "data_quality"]
|
||||
assert [event["change"] for event in business] == ["raised", "resolved", "raised"]
|
||||
assert [event["change"] for event in quality] == ["raised", "resolved"]
|
||||
assert business[0]["event_id"] != business[2]["event_id"]
|
||||
assert doc["anomaly_count"] == 3
|
||||
assert doc["processed_count"] == 6
|
||||
|
||||
|
||||
def test_water_quality_zero_baseline_uses_explicit_bounds():
|
||||
cfg = config(rules=[{"rule_id": "tracer", "device_ids": ["P1"], "kind": "bounds", "upper": 0.1, "unit": "m"}])
|
||||
doc = {"replay_id": "run"}
|
||||
doc, events = detect_frame(doc, prepare_cleaning(rows(0), cfg), cfg, START)
|
||||
assert events == []
|
||||
_, events = detect_frame(doc, prepare_cleaning(rows(0.4, 15), cfg), cfg, START + timedelta(minutes=15))
|
||||
assert events[0]["change"] == "raised"
|
||||
|
||||
|
||||
def test_empty_clock_ticks_do_not_break_consecutive_observations():
|
||||
cfg = config(rules=[{"rule_id": "p", "device_ids": ["P1"], "kind": "bounds", "upper": 10,
|
||||
"unit": "m", "consecutive_samples": 2}])
|
||||
doc, events = detect_frame({"replay_id": "run"}, prepare_cleaning(rows(20), cfg), cfg, START)
|
||||
assert events == []
|
||||
doc, _ = detect_frame(doc, [], cfg, START + timedelta(minutes=1))
|
||||
_, events = detect_frame(doc, prepare_cleaning(rows(20, 15), cfg), cfg, START + timedelta(minutes=15))
|
||||
assert events[0]["change"] == "raised"
|
||||
@@ -0,0 +1,145 @@
|
||||
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.analysis_models import BurstLocationInput
|
||||
from app.mcp.catalog import TOOLS
|
||||
from app.mcp.models import BoundContext
|
||||
from app.mcp import query_service
|
||||
from app.services.scada_replay_queries import replay_burst_location_inputs
|
||||
|
||||
|
||||
START = datetime(2026, 10, 8, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
class Cursor:
|
||||
def __init__(self, rows): self.rows = rows
|
||||
async def fetchone(self): return self.rows[0] if self.rows else None
|
||||
async def fetchall(self): return self.rows
|
||||
|
||||
|
||||
class Connection:
|
||||
def __init__(self, results):
|
||||
self.results = list(results)
|
||||
self.statements = []
|
||||
|
||||
@asynccontextmanager
|
||||
async def transaction(self): yield
|
||||
|
||||
async def execute(self, statement, params):
|
||||
self.statements.append((statement, params))
|
||||
return Cursor(self.results.pop(0))
|
||||
|
||||
async def commit(self): pass
|
||||
|
||||
|
||||
def _args(**changes):
|
||||
return BurstLocationInput.model_validate({
|
||||
"context_handle": "a" * 43,
|
||||
"burst_leakage": 0.02,
|
||||
"pressure_scada_ids": ["J1"],
|
||||
"flow_scada_ids": ["L1"],
|
||||
"scada_burst_start": START + timedelta(minutes=15),
|
||||
"scada_burst_end": START + timedelta(minutes=30),
|
||||
"scada_normal_start": START,
|
||||
"scada_normal_end": START + timedelta(minutes=14),
|
||||
**changes,
|
||||
})
|
||||
|
||||
|
||||
def test_replay_location_builds_series_only_from_frozen_outputs():
|
||||
replay_id = str(uuid4())
|
||||
run = {
|
||||
"start_time": START.isoformat(),
|
||||
"devices": [
|
||||
{"device_id": "P1", "node_id": "J1", "link_id": None},
|
||||
{"device_id": "F1", "node_id": None, "link_id": "L1"},
|
||||
],
|
||||
"monitoring_config": {
|
||||
"pressure_device_ids": ["P1"],
|
||||
"outlet_flow_device_ids": ["F1"],
|
||||
},
|
||||
}
|
||||
readings = [
|
||||
{"reading": {"device_id": "P1", "time": START.isoformat(), "cleaned_value": 100, "quality": "valid"}},
|
||||
{"reading": {"device_id": "F1", "time": START.isoformat(), "cleaned_value": 50, "quality": "valid"}},
|
||||
{"reading": {"device_id": "P1", "time": (START + timedelta(minutes=15)).isoformat(), "cleaned_value": 70, "quality": "valid"}},
|
||||
{"reading": {"device_id": "F1", "time": (START + timedelta(minutes=15)).isoformat(), "cleaned_value": 80, "quality": "valid"}},
|
||||
]
|
||||
connection = Connection([[{"document": run}], readings])
|
||||
context = {"replay_id": replay_id, "knowledge_cutoff": (START + timedelta(hours=1)).isoformat(),
|
||||
"batch_sequence": 9}
|
||||
|
||||
result = asyncio.run(replay_burst_location_inputs(connection, uuid4(), context, _args()))
|
||||
|
||||
assert result["burst_pressure"] == {"J1": 70}
|
||||
assert result["normal_pressure"] == {"J1": 100}
|
||||
assert result["burst_flow"] == {"L1": 80}
|
||||
assert result["normal_flow"] == {"L1": 50}
|
||||
assert connection.statements[1][1][2] == 9
|
||||
|
||||
|
||||
def test_replay_location_authorization_requires_explicit_bounded_normal_window(monkeypatch):
|
||||
binding = BoundContext(
|
||||
context_handle="a" * 43, project_id=uuid4(), subject="u", client_id="host",
|
||||
tool_cap=["run_burst_location"], permission_cap=["burst.run", "scada.view"],
|
||||
native_session_id="s", purpose="task_execution", task_id=uuid4(),
|
||||
expires_at=START + timedelta(days=1),
|
||||
replay_context={"replay_id": str(uuid4()),
|
||||
"knowledge_cutoff": (START + timedelta(hours=1)).isoformat(),
|
||||
"batch_sequence": 9},
|
||||
)
|
||||
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"burst.run", "scada.view"})
|
||||
scope = SimpleNamespace(project=SimpleNamespace(project_role="member"))
|
||||
|
||||
asyncio.run(query_service.authorize_query(scope, binding, TOOLS["run_burst_location"], _args()))
|
||||
|
||||
missing_normal = _args().model_copy(update={"scada_normal_start": None, "scada_normal_end": None})
|
||||
with pytest.raises(HTTPException) as caught:
|
||||
asyncio.run(query_service.authorize_query(scope, binding, TOOLS["run_burst_location"], missing_normal))
|
||||
assert caught.value.status_code == 422
|
||||
|
||||
|
||||
def test_replay_location_execution_uses_frozen_adapter_instead_of_live_scada(monkeypatch):
|
||||
from app.mcp import analysis_service
|
||||
from app.services import scada_replay_queries
|
||||
|
||||
binding = BoundContext(
|
||||
context_handle="a" * 43, project_id=uuid4(), subject="u", client_id="host",
|
||||
tool_cap=["run_burst_location"], permission_cap=["burst.run", "scada.view"],
|
||||
native_session_id="s", purpose="task_execution", task_id=uuid4(),
|
||||
expires_at=START + timedelta(days=1),
|
||||
replay_context={"replay_id": str(uuid4()),
|
||||
"knowledge_cutoff": (START + timedelta(hours=1)).isoformat(),
|
||||
"batch_sequence": 9},
|
||||
)
|
||||
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"burst.run", "scada.view"})
|
||||
frozen = {"burst_leakage": 0.02, "burst_pressure": {"J1": 70}, "normal_pressure": {"J1": 100}}
|
||||
build = AsyncMock(return_value=frozen)
|
||||
calculate = AsyncMock(return_value={"candidates": [{"id": "PIPE-1"}]})
|
||||
monkeypatch.setattr(scada_replay_queries, "replay_burst_location_inputs", build)
|
||||
monkeypatch.setattr(analysis_service, "calculate_replay_burst_location", calculate)
|
||||
|
||||
@asynccontextmanager
|
||||
async def routing(): yield
|
||||
|
||||
scope = SimpleNamespace(
|
||||
connection=Connection([]),
|
||||
project=SimpleNamespace(project_id=binding.project_id, project_code="demo", project_role="member"),
|
||||
user=SimpleNamespace(username="tester"),
|
||||
simulation_routing=routing,
|
||||
)
|
||||
result = asyncio.run(query_service.execute_query(
|
||||
scope, binding, TOOLS["run_burst_location"], _args(),
|
||||
))
|
||||
|
||||
build.assert_awaited_once()
|
||||
calculate.assert_awaited_once_with(frozen, network="demo", username="tester")
|
||||
assert result.origin == "synthetic"
|
||||
assert result.data["result"]["candidates"][0]["id"] == "PIPE-1"
|
||||
@@ -0,0 +1,242 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from copy import deepcopy
|
||||
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.domain.schemas.scada_replay import ScadaReplayCreate, ScadaReplayCommand
|
||||
from app.infra.db.postgresql.scada import ScadaInfoRepository
|
||||
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
|
||||
from app.services.scada_replay import ScadaReplayService
|
||||
|
||||
START = datetime(2026, 10, 8, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
class Connection:
|
||||
@asynccontextmanager
|
||||
async def transaction(self):
|
||||
yield
|
||||
|
||||
|
||||
class Repository:
|
||||
def __init__(self):
|
||||
self.project_id = str(uuid4())
|
||||
self.connection = Connection()
|
||||
self.document = None
|
||||
self.batches = {}
|
||||
|
||||
async def available(self): return True
|
||||
@asynccontextmanager
|
||||
async def control(self): yield
|
||||
@asynccontextmanager
|
||||
async def processing(self): yield True
|
||||
async def by_request(self, key):
|
||||
return deepcopy(self.document) if self.document and self.document["config"]["request_id"] == str(key) else None
|
||||
async def active_other(self, key): return False
|
||||
async def insert(self, document): self.document = deepcopy(document)
|
||||
async def save(self, document): self.document = deepcopy(document)
|
||||
async def get(self, _): return deepcopy(self.document)
|
||||
async def current(self): return deepcopy(self.document)
|
||||
async def pending_batch(self, _):
|
||||
return next((deepcopy(batch) for batch in self.batches.values() if batch["stage"] != "detected"), None)
|
||||
async def save_batch(self, _, batch): self.batches[batch["sequence"]] = deepcopy(batch)
|
||||
async def monitoring_outputs(self, *_args): return []
|
||||
|
||||
|
||||
def setup(monkeypatch):
|
||||
repo = Repository()
|
||||
context = SimpleNamespace(
|
||||
project_id=repo.project_id,
|
||||
user_id=uuid4(),
|
||||
)
|
||||
service = ScadaReplayService(repo, context, Connection(), clock=lambda: START + timedelta(seconds=2))
|
||||
monkeypatch.setattr(ScadaInfoRepository, "get_scadas", AsyncMock(return_value=[{
|
||||
"device_id": "P1", "device_type": "pressure", "measurement_unit": "m", "transmission_frequency": "0:15:00"
|
||||
}]))
|
||||
async def raw_page(_connection, _devices, _start, _end, _through, _limit, after=None, **_):
|
||||
return ([{"time": START, "device_id": "P1", "monitored_value": 20., "source_hash": "h"}]
|
||||
if after is None else [])
|
||||
query = AsyncMock(side_effect=raw_page)
|
||||
cleaning = AsyncMock()
|
||||
monkeypatch.setattr(ScadaRepository, "get_raw_page", query)
|
||||
monkeypatch.setattr(ScadaRepository, "apply_replay_cleaning", cleaning)
|
||||
monkeypatch.setattr(service, "_create_investigations", AsyncMock())
|
||||
create = ScadaReplayCreate(request_id=uuid4(), start_time=START, end_time=START + timedelta(hours=1),
|
||||
device_ids=["P1"], rules=[{"rule_id": "p", "device_ids": ["P1"], "kind": "bounds", "upper": 10, "unit": "m"}])
|
||||
return repo, service, query, cleaning, create
|
||||
|
||||
|
||||
def test_step_command_is_revisioned_idempotent_and_does_not_process_until_requested(monkeypatch):
|
||||
repo, service, query, cleaning, create = setup(monkeypatch)
|
||||
async def run():
|
||||
first = await service.create(create, monitoring_interval_seconds=900)
|
||||
restored = await service.create(create, monitoring_interval_seconds=300)
|
||||
assert restored.replay_id == first.replay_id
|
||||
assert restored.monitoring_interval_seconds == 900
|
||||
command = ScadaReplayCommand(request_id=uuid4(), expected_revision=1, action="step")
|
||||
played = await service.command(first.replay_id, command)
|
||||
assert played.status == "paused" and played.step_pending and played.revision == 2
|
||||
assert (await service.command(first.replay_id, command)).revision == 2
|
||||
with pytest.raises(HTTPException) as conflict:
|
||||
await service.command(first.replay_id, command.model_copy(update={"request_id": uuid4()}))
|
||||
assert conflict.value.status_code == 409
|
||||
asyncio.run(run())
|
||||
query.assert_not_awaited(); cleaning.assert_not_awaited()
|
||||
|
||||
|
||||
def test_one_raw_read_then_correction_then_detection_without_reread(monkeypatch):
|
||||
repo, service, query, cleaning, create = setup(monkeypatch)
|
||||
async def run():
|
||||
first = await service.create(create, monitoring_interval_seconds=900)
|
||||
repo.document["warmed"] = True
|
||||
await service.command(first.replay_id, ScadaReplayCommand(request_id=uuid4(), expected_revision=1, action="step"))
|
||||
result = await service.process_pending_step(first.replay_id)
|
||||
assert result.processed_count == 1
|
||||
assert result.anomaly_count == 1
|
||||
assert repo.batches[1]["stage"] == "detected"
|
||||
asyncio.run(run())
|
||||
assert query.await_count == 2
|
||||
assert cleaning.await_count == 2
|
||||
assert cleaning.await_args_list[0].args[1][0]["cleaned_value"] == 20
|
||||
|
||||
|
||||
def test_detection_failure_resumes_cleaned_batch_without_duplicate_reads_or_writes(monkeypatch):
|
||||
repo, service, query, cleaning, create = setup(monkeypatch)
|
||||
publish = AsyncMock(side_effect=[RuntimeError("Agent storage unavailable"), None, None])
|
||||
monkeypatch.setattr(service, "_create_investigations", publish)
|
||||
async def run():
|
||||
first = await service.create(create, monitoring_interval_seconds=900)
|
||||
repo.document["warmed"] = True
|
||||
await service.command(first.replay_id, ScadaReplayCommand(request_id=uuid4(), expected_revision=1, action="step"))
|
||||
with pytest.raises(RuntimeError): await service.process_pending_step(first.replay_id)
|
||||
assert repo.document["status"] == "failed"
|
||||
assert repo.batches[1]["stage"] == "cleaned"
|
||||
assert repo.document["processed_count"] == 0
|
||||
await service.command(first.replay_id, ScadaReplayCommand(request_id=uuid4(), expected_revision=2, action="retry"))
|
||||
result = await service.process_pending_step(first.replay_id)
|
||||
assert result.processed_count == 1 and result.anomaly_count == 1
|
||||
asyncio.run(run())
|
||||
assert query.await_count == 2
|
||||
assert cleaning.await_count == 2
|
||||
|
||||
|
||||
def test_cleaning_failure_never_runs_detection_and_preserves_prepared_result(monkeypatch):
|
||||
repo, service, query, cleaning, create = setup(monkeypatch)
|
||||
cleaning.side_effect = RuntimeError("Timescale unavailable")
|
||||
async def run():
|
||||
first = await service.create(create, monitoring_interval_seconds=900)
|
||||
repo.document["warmed"] = True
|
||||
await service.command(first.replay_id, ScadaReplayCommand(request_id=uuid4(), expected_revision=1, action="step"))
|
||||
with pytest.raises(RuntimeError): await service.process_pending_step(first.replay_id)
|
||||
assert repo.batches[1]["stage"] == "clean_prepared"
|
||||
assert repo.document["processed_count"] == 0
|
||||
asyncio.run(run())
|
||||
service._create_investigations.assert_not_awaited()
|
||||
|
||||
|
||||
def test_creation_requires_explicit_project_monitoring_interval(monkeypatch):
|
||||
repo, service, query, cleaning, create = setup(monkeypatch)
|
||||
|
||||
async def run():
|
||||
with pytest.raises(HTTPException) as missing:
|
||||
await service.create(create, monitoring_interval_seconds=None)
|
||||
assert missing.value.status_code == 422
|
||||
assert repo.document is None
|
||||
|
||||
asyncio.run(run())
|
||||
query.assert_not_awaited()
|
||||
cleaning.assert_not_awaited()
|
||||
|
||||
|
||||
def test_step_processes_every_observation_within_one_monitoring_interval(monkeypatch):
|
||||
repo, service, query, cleaning, create = setup(monkeypatch)
|
||||
rows = [{"time": START + timedelta(minutes=minute), "device_id": "P1",
|
||||
"monitored_value": 20., "source_hash": f"h{minute}"} for minute in (0, 5, 10, 15, 20)]
|
||||
|
||||
async def raw_page(_connection, _devices, _start, _end, through, _limit, after=None, **_):
|
||||
return next(([row] for row in rows if row["time"] <= through
|
||||
and (after is None or (row["time"], row["device_id"]) > after)), [])
|
||||
|
||||
query.side_effect = raw_page
|
||||
|
||||
async def run():
|
||||
first = await service.create(create, monitoring_interval_seconds=900)
|
||||
repo.document["warmed"] = True
|
||||
stepping = await service.command(first.replay_id, ScadaReplayCommand(
|
||||
request_id=uuid4(), expected_revision=1, action="step"
|
||||
))
|
||||
assert stepping.status == "paused" and stepping.step_pending
|
||||
with pytest.raises(HTTPException):
|
||||
await service.command(first.replay_id, ScadaReplayCommand(
|
||||
request_id=uuid4(), expected_revision=2, action="step"
|
||||
))
|
||||
result = await service.process_pending_step(first.replay_id)
|
||||
assert result.processed_count == 4
|
||||
assert result.virtual_now == START + timedelta(minutes=15)
|
||||
assert result.status == "paused"
|
||||
assert not result.step_pending
|
||||
assert result.processed_until == START + timedelta(minutes=15)
|
||||
assert result.monitoring_count == 1
|
||||
assert result.last_monitoring.status == "insufficient_data"
|
||||
assert "step_target" not in repo.document
|
||||
assert all(call.args[4] == START + timedelta(minutes=15) for call in query.await_args_list)
|
||||
|
||||
asyncio.run(run())
|
||||
assert cleaning.await_count == 4
|
||||
|
||||
|
||||
def test_partial_final_step_checks_empty_interval_and_completes_without_fabricated_rows(monkeypatch):
|
||||
repo, service, query, cleaning, create = setup(monkeypatch)
|
||||
create = create.model_copy(update={"end_time": START + timedelta(minutes=22)})
|
||||
query.side_effect = None
|
||||
query.return_value = []
|
||||
|
||||
async def run():
|
||||
first = await service.create(create, monitoring_interval_seconds=900)
|
||||
repo.document.update(warmed=True, virtual_now=(START + timedelta(minutes=15)).isoformat())
|
||||
await service.command(first.replay_id, ScadaReplayCommand(
|
||||
request_id=uuid4(), expected_revision=1, action="step"
|
||||
))
|
||||
result = await service.process_pending_step(first.replay_id)
|
||||
assert result.virtual_now == create.end_time
|
||||
assert result.processed_until == create.end_time
|
||||
assert result.status == "completed" and not result.step_pending
|
||||
assert result.processed_count == 0
|
||||
|
||||
asyncio.run(run())
|
||||
assert query.await_args.args[4] == create.end_time
|
||||
|
||||
|
||||
def test_suspected_monitoring_creates_burst_report_with_frozen_replay_context(monkeypatch):
|
||||
repo, service, *_ = setup(monkeypatch)
|
||||
create_task = AsyncMock(return_value=SimpleNamespace(task_id=uuid4()))
|
||||
monkeypatch.setattr("app.services.scada_replay.permissions_for_context", lambda _: {"task.write"})
|
||||
monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object())
|
||||
monkeypatch.setattr(
|
||||
"app.services.operational_tasks.OperationalTaskService",
|
||||
lambda *_: SimpleNamespace(create=create_task),
|
||||
)
|
||||
monitoring = {
|
||||
"status": "suspected_burst",
|
||||
"period_end": (START + timedelta(minutes=15)).isoformat(),
|
||||
"knowledge_cutoff": (START + timedelta(minutes=15)).isoformat(),
|
||||
"rule_version": "burst-monitor-v1",
|
||||
"pressure": {"hit_device_ids": ["P1", "P2"]},
|
||||
"outlet_flow": {"hit_device_ids": ["F1"]},
|
||||
"burst_detection": {"target_score": -0.05},
|
||||
"task_id": None,
|
||||
}
|
||||
document = {"replay_id": str(uuid4())}
|
||||
|
||||
asyncio.run(service._create_monitoring_task(document, monitoring, {"sequence": 8}))
|
||||
|
||||
command = create_task.await_args.args[0]
|
||||
assert command.workflow == "burst_report"
|
||||
assert command.source.replay_context.batch_sequence == 8
|
||||
assert command.source.replay_context.knowledge_cutoff == START + timedelta(minutes=15)
|
||||
assert monitoring["task_id"] == str(create_task.return_value.task_id)
|
||||
Reference in New Issue
Block a user