import asyncio from contextlib import asynccontextmanager from datetime import datetime, timedelta, timezone from types import SimpleNamespace from unittest.mock import AsyncMock from uuid import uuid4 import pytest from app.domain.schemas.timeseries_history import ElementHistoryQuery from app.infra.db.postgresql import read_queries from app.services import timeseries_history START = datetime(2026, 10, 1, tzinfo=timezone.utc) def test_history_pages_advance_across_raw_cleaned_and_model_series(monkeypatch): monkeypatch.setattr( timeseries_history.ScadaInfoRepository, "get_scadas_for_elements", AsyncMock(return_value=[{ "device_id": "D1", "device_type": "pressure", "node_id": "J1", "link_id": None, "measurement_unit": "m", }]), ) monkeypatch.setattr( timeseries_history.ScadaRepository, "get_scada_by_ids_time_range", AsyncMock(side_effect=AssertionError("must use paged SQL")), ) monkeypatch.setattr( timeseries_history.NetworkSettingsRepository, "get_result_units", AsyncMock(return_value={"pressure": "m"}), ) points = [ {"series_index": i, "time": START + timedelta(minutes=j), "value": i * 10 + j} for i in range(3) for j in range(2) ] async def page(conn, descriptors, *, start, end, run_id, limit, offset): assert len(descriptors) == 3 return points[offset:offset + limit] monkeypatch.setattr(read_queries, "history_rows", page, raising=False) query = ElementHistoryQuery( start_time=START, end_time=START + timedelta(hours=1), mode="realtime_comparison", elements=[{"element_type": "junction", "element_id": "J1"}], ) async def check(): seen = [] for offset in (0, 2, 4, 6): result = await timeseries_history.TimeseriesHistoryService.query( object(), object(), query, row_limit=2, row_offset=offset, ) seen.extend(point.value for series in result.series for point in series.points) assert result.truncated is (offset < 4) assert seen == [0, 1, 10, 11, 20, 21] asyncio.run(check()) class Connection: def __init__(self, rows): self.rows = rows self.statements = [] @asynccontextmanager async def transaction(self): yield self def cursor(self, **kwargs): return self async def __aenter__(self): return self async def __aexit__(self, *args): pass async def execute(self, statement, params): self.statements.append((statement.as_string() if hasattr(statement, "as_string") else statement, params)) async def fetchall(self): return self.rows def test_history_sql_applies_one_page_to_all_sources(): conn = Connection([]) asyncio.run(read_queries.history_rows( conn, [ {"source": "scada", "element_type": "junction", "id": "D'1", "field": "monitored_value"}, {"source": "analysis", "element_type": "pipe", "id": "P1", "field": "flow"}, ], start=START, end=START + timedelta(hours=1), run_id=uuid4(), limit=3, offset=2, )) statement, params = conn.statements[-1] assert "UNION ALL" in statement and "ORDER BY series_index, time" in statement assert statement.endswith("LIMIT %s OFFSET %s") and params[-2:] == (3, 2) assert "run_id=%s" in statement and "D'1" not in statement def test_evidence_projection_is_scoped_paged_and_size_guarded_in_sql(): payload = {"value": [2], "total": 3, "value_type": "array", "snapshot_metadata": {}, "query_metadata": None} conn = Connection([{"payload": payload, "too_large": False}]) result = asyncio.run(read_queries.evidence_page( conn, project_id=uuid4(), task_id=uuid4(), evidence_id="E1", data_path=["items"], limit=1, offset=1, mcp_sources=["query_scada_readings"], )) assert result == payload statement, params = conn.statements[-1] assert "project_id=%s AND task_id=%s AND evidence_id=%s" in statement assert "#> %s::text[]" in statement and "LIMIT %s OFFSET %s" in statement assert "octet_length" in statement and "CASE WHEN" in statement assert ["items"] in params def test_oversized_evidence_does_not_return_a_document(): from fastapi import HTTPException conn = Connection([{"payload": None, "too_large": True}]) with pytest.raises(HTTPException) as caught: asyncio.run(read_queries.evidence_page( conn, project_id=uuid4(), task_id=uuid4(), evidence_id="E1", data_path=[], limit=1, offset=0, mcp_sources=[], )) assert caught.value.status_code == 413 def test_demo_payload_has_a_size_gate_before_transfer(monkeypatch): from fastapi import HTTPException from app.mcp import peripheral from app.mcp.models import PeripheralInput calls = [] async def rows(conn, statement, params): calls.append(statement) return [{"document": None, "digest": "unused", "too_large": True}] monkeypatch.setattr(peripheral, "read_rows", rows) args = PeripheralInput( context_handle="a" * 43, source="peripheral_observations", point_id="DEMO-1", start=START, end=START + timedelta(hours=1), limit=1, ) with pytest.raises(HTTPException) as caught: asyncio.run(peripheral.read_peripheral_query( SimpleNamespace(connection=object(), project=SimpleNamespace(project_id=uuid4())), args, )) assert caught.value.status_code == 413 assert "CASE WHEN" in calls[0] and "octet_length" in calls[0] def test_history_tool_forwards_offset_and_keeps_last_page_partial(monkeypatch): from app.mcp import query_service from app.mcp.catalog import TOOLS from app.mcp.models import BoundContext, HistoryInput conn = Connection([]) scope = SimpleNamespace(connection=conn, project=object(), timescale=conn.transaction) binding = BoundContext( context_handle="a"*43, project_id=uuid4(), subject="u", client_id="host", tool_cap=["query_element_history"], permission_cap=["scada.view"], native_session_id="s", purpose="ordinary_chat", expires_at=START, ) monkeypatch.setattr(query_service, "permissions_for_context", lambda scope: {"scada.view"}) from app.domain.schemas.timeseries_history import ElementHistoryResponse reader = AsyncMock(return_value=ElementHistoryResponse(series=[], truncated=False)) monkeypatch.setattr(timeseries_history.TimeseriesHistoryService, "query", reader) args = HistoryInput( context_handle="a"*43, start=START, end=START+timedelta(hours=1), elements=[{"element_type": "junction", "element_id": "J1"}], limit=2, offset=4, ) result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["query_element_history"], args)) assert reader.await_args.kwargs == {"row_limit": 2, "row_offset": 4} assert result.page.offset == 4 and not result.page.has_more and result.truncated def test_task_context_retains_selected_report_revision(monkeypatch): from app.mcp import query_service from app.mcp.catalog import TOOLS from app.mcp.models import BoundContext, TaskContextInput reader = AsyncMock(return_value={"selected_report": {"revision": 3}}) monkeypatch.setattr(query_service.reads, "task_context_summary", reader) monkeypatch.setattr(query_service, "permissions_for_context", lambda project: {"operational_task.view"}) task_id = uuid4() binding = BoundContext( context_handle="a" * 43, project_id=uuid4(), subject="u", client_id="host", tool_cap=["get_task_context"], permission_cap=["operational_task.view"], native_session_id="s", purpose="task_collaboration", task_id=task_id, expires_at=START, ) scope = SimpleNamespace(connection=Connection([]), project=object()) args = TaskContextInput(context_handle="a" * 43, report_revision=3) result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["get_task_context"], args)) reader.assert_awaited_once_with(scope.connection, project_id=binding.project_id, task_id=task_id, report_revision=3) assert result.data["selected_report"]["revision"] == 3 with pytest.raises(ValueError): TaskContextInput(context_handle="a" * 43, report_revision=0) @pytest.mark.parametrize("section", ["evidence", "actions", "access_requests"]) def test_task_context_collection_pages_use_database_total_and_stable_paths(monkeypatch, section): from app.mcp import query_service from app.mcp.catalog import TOOLS from app.mcp.models import BoundContext, TaskContextInput reader = AsyncMock(return_value={ "total": 2, "value_type": "object", "nested": True, "snapshot_metadata": {"section": section, "total": 3}, "value": {"stable-id": {"collection_type": "object", "data_path": ["stable-id"], "count": 4}}, }) monkeypatch.setattr(query_service.reads, "task_context_page", reader) monkeypatch.setattr(query_service, "permissions_for_context", lambda project: {"operational_task.view"}) binding = BoundContext(context_handle="a" * 43, project_id=uuid4(), subject="u", client_id="host", tool_cap=["get_task_context"], permission_cap=["operational_task.view"], native_session_id="s", purpose="task_collaboration", task_id=uuid4(), expires_at=START) scope = SimpleNamespace(connection=Connection([]), project=object()) args = TaskContextInput(context_handle="a" * 43, section=section, limit=1, offset=1) result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["get_task_context"], args)) assert result.page.total == 3 and result.page.has_more and result.page.offset == 1 assert result.truncated and result.data["value"]["stable-id"]["data_path"] == ["stable-id"] reader.assert_awaited_once_with(scope.connection, project_id=binding.project_id, task_id=binding.task_id, section=section, report_revision=None, data_path=[], limit=1, offset=1)