feat(scada): add project monitoring replay
Generic Container CI/CD / test-build-publish (push) Successful in 3m39s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 3m39s

This commit is contained in:
2026-11-04 14:27:21 +08:00
parent f25423dec4
commit 26fa5d1909
53 changed files with 5153 additions and 20 deletions
@@ -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()