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)