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
@@ -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)
+60
View File
@@ -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()
+113
View File
@@ -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()