"""Tests for revision-gated /state/summary caching.""" from __future__ import annotations import pytest from api.services.summary_cache import ( SummaryCache, SummaryRevision, fetch_summary_revision, get_summary_cache, invalidate_summary_cache, reset_summary_cache_for_tests, ) from tests.test_routers_core import _create_domain, _create_task, _create_topic, _create_workstream @pytest.mark.asyncio async def test_fetch_summary_revision_empty_db(test_engine): from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) async with factory() as session: revision = await fetch_summary_revision(session) assert revision.core_fingerprint() assert revision.progress_fingerprint() == "" @pytest.mark.asyncio async def test_summary_hit_revision_header(client): r1 = await client.get("/state/summary") assert r1.status_code == 200 assert r1.headers.get("X-StateHub-Cache") == "miss" assert r1.headers.get("X-StateHub-Revision") r2 = await client.get("/state/summary") assert r2.status_code == 200 assert r2.headers.get("X-StateHub-Cache") == "hit-revision" assert r2.headers.get("X-StateHub-Revision") == r1.headers.get("X-StateHub-Revision") @pytest.mark.asyncio async def test_summary_force_refresh_query(client): await client.get("/state/summary") r = await client.get("/state/summary", params={"refresh": "true"}) assert r.status_code == 200 assert r.headers.get("X-StateHub-Cache") == "miss" @pytest.mark.asyncio async def test_summary_no_cache_header(client): await client.get("/state/summary") r = await client.get("/state/summary", headers={"Cache-Control": "no-cache"}) assert r.status_code == 200 assert r.headers.get("X-StateHub-Cache") == "miss" @pytest.mark.asyncio async def test_summary_stale_while_revalidate(client, monkeypatch): await client.get("/state/summary") original = fetch_summary_revision async def bumped_revision(session): rev = await original(session) from datetime import datetime, timedelta, timezone return SummaryRevision( core=rev.core + timedelta(seconds=1), progress=rev.progress, sbom=rev.sbom, ) monkeypatch.setattr("api.routers.state.fetch_summary_revision", bumped_revision) r = await client.get("/state/summary") assert r.status_code == 200 assert r.headers.get("X-StateHub-Cache") == "stale" @pytest.mark.asyncio async def test_progress_section_refresh_without_full_rebuild(client): await _create_domain(client) topic = await _create_topic(client) await client.get("/state/summary") before = (await client.get("/state/summary")).headers.get("X-StateHub-Revision") await client.post( "/progress/", json={ "topic_id": topic["id"], "event_type": "note", "summary": "cache section test", "author": "pytest", }, ) r = await client.get("/state/summary") assert r.status_code == 200 assert r.headers.get("X-StateHub-Cache") == "hit-revision" assert r.json()["recent_progress"][0]["summary"] == "cache section test" assert r.headers.get("X-StateHub-Revision") != before @pytest.mark.asyncio async def test_task_mutation_invalidates_core_cache(client): await _create_domain(client) topic = await _create_topic(client) ws = await _create_workstream(client, topic_id=topic["id"]) task = await _create_task(client, ws["id"]) await client.get("/state/summary") assert (await client.get("/state/summary")).headers.get("X-StateHub-Cache") == "hit-revision" await client.patch(f"/tasks/{task['id']}", json={"status": "progress"}) r = await client.get("/state/summary") assert r.status_code == 200 assert r.headers.get("X-StateHub-Cache") == "miss" def test_summary_cache_unit_progress_section(): cache = SummaryCache() from datetime import datetime, timezone from api.schemas.state import StateSummary, Totals, TopicTotals, WorkstreamTotals, TaskTotals, DecisionTotals rev = SummaryRevision( core=datetime(2026, 1, 1, tzinfo=timezone.utc), progress=datetime(2026, 1, 2, tzinfo=timezone.utc), sbom=None, ) empty_totals = Totals( topics=TopicTotals(), workstreams=WorkstreamTotals(), tasks=TaskTotals(), decisions=DecisionTotals(), ) summary = StateSummary( generated_at=datetime(2026, 1, 1, tzinfo=timezone.utc), totals=empty_totals, topics=[], blocking_decisions=[], waiting_tasks=[], recent_progress=[], open_workstreams=[], ) cache.store(summary, rev) new_rev = SummaryRevision( core=rev.core, progress=datetime(2026, 1, 3, tzinfo=timezone.utc), sbom=None, ) status, cached = cache.resolve(new_rev, force_refresh=False) assert status == "progress-section" assert cached is summary def test_invalidate_summary_cache_scopes(): reset_summary_cache_for_tests() cache = get_summary_cache() from datetime import datetime, timezone from api.schemas.state import StateSummary, Totals, TopicTotals, WorkstreamTotals, TaskTotals, DecisionTotals rev = SummaryRevision( core=datetime(2026, 1, 1, tzinfo=timezone.utc), progress=None, sbom=None, ) empty_totals = Totals( topics=TopicTotals(), workstreams=WorkstreamTotals(), tasks=TaskTotals(), decisions=DecisionTotals(), ) summary = StateSummary( generated_at=datetime(2026, 1, 1, tzinfo=timezone.utc), totals=empty_totals, topics=[], blocking_decisions=[], waiting_tasks=[], recent_progress=[], open_workstreams=[], ) cache.store(summary, rev) invalidate_summary_cache("progress") status, _ = cache.resolve(rev, force_refresh=False) assert status == "progress-section" invalidate_summary_cache("core") status, cached = cache.resolve(rev, force_refresh=False) assert status == "miss" assert cached is None