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()
|
||||
Reference in New Issue
Block a user