from types import SimpleNamespace from uuid import uuid4 import asyncio from fastapi.testclient import TestClient import pytest from app.api.v1.endpoints.timeseries import playback from app.auth.project_dependencies import ProjectContext from tests.conftest import build_test_app def client(monkeypatch): context = ProjectContext(project_id=uuid4(), project_code="test", user_id=uuid4(), project_role="viewer") app = build_test_app(playback.router, "/api/v1") app.dependency_overrides[playback.get_project_context] = lambda: context app.dependency_overrides[playback.get_project_pg_connection] = lambda: object() app.dependency_overrides[playback.get_timescale_connection] = lambda: object() async def geometry(*_): return SimpleNamespace(version="a" * 24, content=b"geometry") monkeypatch.setattr(playback, "geometry_for_project", geometry) return TestClient(app) def test_geometry_is_private_and_frames_reject_a_different_model_version(monkeypatch): http = client(monkeypatch) response = http.get("/api/v1/timeseries/realtime/playback/geometry") assert response.status_code == 200 assert response.headers["cache-control"] == "private, no-store" assert response.content == b"geometry" response = http.get("/api/v1/timeseries/realtime/playback/frames", params={ "start_time": "2026-07-16T00:00:00+08:00", "model_version": "b" * 24, }) assert response.status_code == 409 @pytest.mark.parametrize("params", [ {"start_time": "2026-07-16T00:00:00"}, {"start_time": "2026-07-16T00:00:00Z", "frame_count": 9}, {"start_time": "2026-07-16T00:00:00Z", "step_seconds": 0}, ]) def test_frames_require_timezone_and_bound_the_block_size(monkeypatch, params): http = client(monkeypatch) response = http.get("/api/v1/timeseries/realtime/playback/frames", params={"model_version": "a" * 24, **params}) assert response.status_code == 422 def test_frames_pass_exact_utc_time_and_counts_to_reader(monkeypatch): http = client(monkeypatch) async def frames(_connection, geometry, start, count, step): assert geometry.version == "a" * 24 assert start.isoformat() == "2026-07-15T16:00:00+00:00" assert count == 4 and step == 900 return b"frames" monkeypatch.setattr(playback, "read_frames", frames) response = http.get("/api/v1/timeseries/realtime/playback/frames", params={ "start_time": "2026-07-16T00:00:00+08:00", "model_version": "a" * 24, }) assert response.status_code == 200 and response.content == b"frames" def test_cache_separates_projects_and_database_routes(monkeypatch): playback._cache.clear() calls = [] async def read(conn): calls.append(conn.info.dbname) return SimpleNamespace(version=conn.info.dbname) monkeypatch.setattr(playback, "read_geometry", read) ctx = SimpleNamespace(project_id=uuid4()) async def run(): for name in ["one", "one", "two"]: connection = SimpleNamespace(info=SimpleNamespace(host="host", port=5432, dbname=name)) result = await playback.geometry_for_project(ctx, connection) assert result.version == name other = SimpleNamespace(project_id=uuid4()) await playback.geometry_for_project(other, SimpleNamespace(info=SimpleNamespace(host="host", port=5432, dbname="one"))) asyncio.run(run()) assert calls == ["one", "two", "one"] playback._cache.clear()