Removes the redundant open_workstreams mirror field from StateSummary (schema,
router, MCP get_domain_summary reader, dashboard consumers + empty-state stub,
and tests). Consumers already preferred open_workplans, so this is the
low-risk half of T04.
Deferred (still have live callers — not yet retirement-ready):
- workstream_id query/body field alias on preferred routes — external
session-close curls/scripts fleet-wide still send it.
- flows/workstream.yaml — /flows/workstream/{id} routes are still served and
exercised by tests; retire only once no callers remain.
Staged on branch state-wp-0070-legacy-retirement — do not merge until the 7th
documented zero-usage window is captured.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
195 lines
No EOL
6.1 KiB
Python
195 lines
No EOL
6.1 KiB
Python
"""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_workplans=[],
|
|
)
|
|
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_workplans=[],
|
|
)
|
|
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 |