Full-network browsing expanded persisted recovery gates to unrelated devices, while full fifteen-day reference reads exhausted the row budget. Rebuild legacy cached scopes from report devices and project monitoring roles, and query only the nearest post-feedback frame and its historical phase. Regression tests fail before the fix for both new and cached scopes and verify automatic Agent requeue after recovery.
201 lines
6.3 KiB
Python
201 lines
6.3 KiB
Python
import asyncio
|
|
from datetime import datetime, timedelta, timezone
|
|
import importlib.util
|
|
from pathlib import Path
|
|
|
|
|
|
def _load_scada_repository():
|
|
module_path = (
|
|
Path(__file__).resolve().parents[2]
|
|
/ "app"
|
|
/ "infra"
|
|
/ "db"
|
|
/ "timescaledb"
|
|
/ "repositories"
|
|
/ "scada.py"
|
|
)
|
|
spec = importlib.util.spec_from_file_location("tests_scada_repo_under_test", module_path)
|
|
module = importlib.util.module_from_spec(spec)
|
|
assert spec and spec.loader
|
|
spec.loader.exec_module(module)
|
|
return module.ScadaRepository
|
|
|
|
|
|
class _FakeCursor:
|
|
def __init__(self, initial_rowcount: int):
|
|
self.initial_rowcount = initial_rowcount
|
|
self.rowcount = 0
|
|
self.calls: list[tuple[str, tuple]] = []
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
async def execute(self, query, params):
|
|
self.calls.append((str(query), params))
|
|
if len(self.calls) == 1:
|
|
self.rowcount = self.initial_rowcount
|
|
else:
|
|
self.rowcount = 1
|
|
|
|
|
|
class _FakeConnection:
|
|
def __init__(self, initial_rowcount: int):
|
|
self.cursor_instance = _FakeCursor(initial_rowcount)
|
|
|
|
def cursor(self):
|
|
return self.cursor_instance
|
|
|
|
|
|
class _ReadingCursor:
|
|
def __init__(self, rows):
|
|
self.rows = rows
|
|
self.calls: list[tuple[str, tuple]] = []
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
async def execute(self, query, params):
|
|
self.calls.append((str(query), params))
|
|
|
|
async def fetchall(self):
|
|
return self.rows
|
|
|
|
|
|
class _ReadingConnection:
|
|
def __init__(self, rows):
|
|
self.cursor_instance = _ReadingCursor(rows)
|
|
|
|
def cursor(self):
|
|
return self.cursor_instance
|
|
|
|
|
|
def test_update_scada_field_inserts_when_update_hits_no_rows():
|
|
ScadaRepository = _load_scada_repository()
|
|
conn = _FakeConnection(initial_rowcount=0)
|
|
point_time = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc)
|
|
|
|
asyncio.run(
|
|
ScadaRepository.update_scada_field(
|
|
conn,
|
|
point_time,
|
|
"170490",
|
|
"cleaned_value",
|
|
26.5,
|
|
)
|
|
)
|
|
|
|
assert len(conn.cursor_instance.calls) == 2
|
|
assert "UPDATE scada.measurements SET" in conn.cursor_instance.calls[0][0]
|
|
assert "INSERT INTO scada.measurements" in conn.cursor_instance.calls[1][0]
|
|
|
|
|
|
def test_update_scada_field_skips_insert_when_update_succeeds():
|
|
ScadaRepository = _load_scada_repository()
|
|
conn = _FakeConnection(initial_rowcount=1)
|
|
point_time = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc)
|
|
|
|
asyncio.run(
|
|
ScadaRepository.update_scada_field(
|
|
conn,
|
|
point_time,
|
|
"170490",
|
|
"cleaned_value",
|
|
26.5,
|
|
)
|
|
)
|
|
|
|
assert len(conn.cursor_instance.calls) == 1
|
|
assert "UPDATE scada.measurements SET" in conn.cursor_instance.calls[0][0]
|
|
|
|
|
|
def test_update_scada_field_batch_uses_one_set_based_statement():
|
|
ScadaRepository = _load_scada_repository()
|
|
conn = _FakeConnection(initial_rowcount=2)
|
|
first_time = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc)
|
|
second_time = datetime(2026, 1, 1, 0, 5, tzinfo=timezone.utc)
|
|
|
|
updated = asyncio.run(
|
|
ScadaRepository.update_scada_field_batch(
|
|
conn,
|
|
[
|
|
(first_time, "170490", 26.5),
|
|
(second_time, "170491", 27.0),
|
|
],
|
|
"cleaned_value",
|
|
)
|
|
)
|
|
|
|
assert updated == 2
|
|
assert len(conn.cursor_instance.calls) == 1
|
|
query, params = conn.cursor_instance.calls[0]
|
|
assert "unnest" in query.lower()
|
|
assert params == (
|
|
[first_time, second_time],
|
|
["170490", "170491"],
|
|
[26.5, 27.0],
|
|
)
|
|
|
|
|
|
def test_get_latest_scada_readings_uses_canonical_ids_and_one_bounded_query():
|
|
ScadaRepository = _load_scada_repository()
|
|
observed_at = datetime(2026, 10, 22, 1, 2, tzinfo=timezone.utc)
|
|
conn = _ReadingConnection([
|
|
{
|
|
"time": observed_at,
|
|
"device_id": "D-1",
|
|
"monitored_value": 0.31,
|
|
"cleaned_value": 0.3,
|
|
}
|
|
])
|
|
|
|
result = asyncio.run(
|
|
ScadaRepository.get_latest_scada_readings(conn, ["D-1", "D-2"])
|
|
)
|
|
|
|
assert result["D-1"]["time"] == observed_at
|
|
assert len(conn.cursor_instance.calls) == 1
|
|
query, params = conn.cursor_instance.calls[0]
|
|
assert "DISTINCT ON (device_id)" in query
|
|
assert "ORDER BY device_id, time DESC" in query
|
|
assert params == (["D-1", "D-2"],)
|
|
|
|
|
|
def test_get_latest_scada_readings_skips_database_for_empty_page():
|
|
ScadaRepository = _load_scada_repository()
|
|
conn = _ReadingConnection([])
|
|
|
|
assert asyncio.run(ScadaRepository.get_latest_scada_readings(conn, [])) == {}
|
|
assert conn.cursor_instance.calls == []
|
|
|
|
|
|
def test_recovery_read_selects_first_raw_frame_strictly_after_feedback_and_bounded_reference():
|
|
ScadaRepository = _load_scada_repository()
|
|
incident = datetime(2026, 7, 16, 0, 0, tzinfo=timezone.utc)
|
|
feedback = incident + timedelta(hours=5, minutes=7)
|
|
through = feedback + timedelta(hours=2)
|
|
rows = [{"time": incident - timedelta(days=1), "device_id": "P1", "monitored_value": 100., "cleaned_value": None},
|
|
{"time": incident + timedelta(hours=5, minutes=15), "device_id": "P1", "monitored_value": 100., "cleaned_value": None}]
|
|
conn = _ReadingConnection(rows)
|
|
result = asyncio.run(ScadaRepository.get_recovery_scada_readings(conn, ["P1"], incident, feedback, through))
|
|
assert result == rows
|
|
query, parameters = conn.cursor_instance.calls[0]
|
|
assert "min(time)" in query and "time>%s AND time<=%s" in query
|
|
assert "m.time>=%s AND m.time<%s" in query and "JOIN next_frame" in query
|
|
assert "mod(extract(epoch FROM m.time)::bigint,86400)=" in query
|
|
assert "mod(extract(epoch FROM f.time)::bigint,86400)" in query
|
|
assert parameters == (["P1"], feedback, through, ["P1"], incident - timedelta(days=15), incident, 100_001, ["P1"])
|
|
|
|
|
|
def test_recovery_read_does_not_query_when_feedback_is_not_in_the_past():
|
|
ScadaRepository = _load_scada_repository()
|
|
at = datetime(2026, 7, 16, tzinfo=timezone.utc)
|
|
conn = _ReadingConnection([])
|
|
assert asyncio.run(ScadaRepository.get_recovery_scada_readings(conn, ["P1"], at, at, at)) == []
|
|
assert conn.cursor_instance.calls == []
|