generated from coulomb/repo-seed
Replace the fixed 15s TTL on GET /state/summary with per-table revision watermarks, stale-while-revalidate background refresh, and a progress-tail section split. SQLAlchemy write hooks invalidate core or progress sections on mutation. Adds tests, benchmark script, and operator docs.
164 lines
5.6 KiB
Python
164 lines
5.6 KiB
Python
"""
|
|
Shared pytest fixtures for the state-hub API test suite.
|
|
|
|
Uses a real PostgreSQL test database (custodian_test) — never mocked.
|
|
Set TEST_DATABASE_URL to override the default.
|
|
|
|
Schema is created/dropped once per session via psycopg2 (synchronous) to
|
|
avoid asyncpg "another operation is in progress" errors during create_all.
|
|
Tables are truncated between tests for isolation.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
import sqlalchemy
|
|
from httpx import ASGITransport, AsyncClient
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
|
|
# Make api/ importable when running pytest from state-hub/
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
|
|
_ASYNC_URL = os.getenv(
|
|
"TEST_DATABASE_URL",
|
|
"postgresql+asyncpg://custodian:changeme@127.0.0.1:5432/custodian_test",
|
|
)
|
|
_SYNC_URL = _ASYNC_URL.replace("+asyncpg", "+psycopg2")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Schema lifecycle (synchronous — avoids asyncpg concurrent-query errors)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.fixture(scope="session", autouse=True)
|
|
def _schema():
|
|
"""Create all tables before the session; drop them after."""
|
|
# Import all models so metadata is fully populated
|
|
from api.models import Base # noqa: F401
|
|
|
|
engine = sqlalchemy.create_engine(_SYNC_URL)
|
|
Base.metadata.create_all(engine)
|
|
yield
|
|
Base.metadata.drop_all(engine)
|
|
engine.dispose()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _truncate(_schema):
|
|
"""Truncate all tables after each test for isolation."""
|
|
from api.models import Base
|
|
import api.routers.state as _state_router
|
|
import api.routers.workstreams as _ws_router
|
|
from api.services.summary_cache import reset_summary_cache_for_tests
|
|
|
|
# Reset in-process caches so stale data from a previous test can't bleed through.
|
|
reset_summary_cache_for_tests()
|
|
_state_router._OVERVIEW_CACHE = None
|
|
_state_router._OVERVIEW_CACHE_AT = 0.0
|
|
_ws_router._INDEX_CACHE = None
|
|
_ws_router._INDEX_CACHE_AT = 0.0
|
|
_ws_router._INDEX_REFRESH_TASK = None
|
|
_ws_router._INDEX_LAST_ERROR = None
|
|
|
|
yield
|
|
engine = sqlalchemy.create_engine(_SYNC_URL)
|
|
with engine.begin() as conn:
|
|
for table in reversed(Base.metadata.sorted_tables):
|
|
conn.execute(table.delete())
|
|
engine.dispose()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Async engine (function-scoped, shared via session factory)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest_asyncio.fixture
|
|
async def test_engine(_schema):
|
|
engine = create_async_engine(_ASYNC_URL, echo=False)
|
|
yield engine
|
|
await engine.dispose()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# HTTP client
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest_asyncio.fixture
|
|
async def client(test_engine):
|
|
"""AsyncClient backed by the FastAPI app with get_session overridden."""
|
|
from api.database import get_session
|
|
from api.main import app
|
|
|
|
factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
|
|
|
|
async def _override():
|
|
async with factory() as session:
|
|
yield session
|
|
|
|
app.dependency_overrides[get_session] = _override
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
|
yield ac
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shared entity helpers (workplan-first; legacy workstream names retained)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def create_test_domain(client, slug="infotech", name="Infotech"):
|
|
r = await client.post("/domains/", json={"slug": slug, "name": name})
|
|
assert r.status_code == 201, r.text
|
|
return r.json()
|
|
|
|
|
|
async def create_test_topic(client, domain_slug="infotech", slug="testtopic", title="Test Topic"):
|
|
r = await client.post("/topics/", json={
|
|
"slug": slug, "title": title, "domain": domain_slug,
|
|
})
|
|
assert r.status_code == 201, r.text
|
|
return r.json()
|
|
|
|
|
|
async def create_test_repo(client, domain_slug="infotech", slug="test-repo", **extra):
|
|
payload = {
|
|
"domain_slug": domain_slug,
|
|
"slug": slug,
|
|
"name": "Test Repo",
|
|
**extra,
|
|
}
|
|
r = await client.post("/repos/", json=payload)
|
|
assert r.status_code == 201, r.text
|
|
return r.json()
|
|
|
|
|
|
async def create_test_workplan(
|
|
client,
|
|
repo_id,
|
|
topic_id=None,
|
|
slug="test-wp",
|
|
title="Test Workplan",
|
|
status="active",
|
|
**extra,
|
|
):
|
|
payload = {"repo_id": repo_id, "slug": slug, "title": title, "status": status, **extra}
|
|
if topic_id is not None:
|
|
payload["topic_id"] = topic_id
|
|
r = await client.post("/workplans/", json=payload)
|
|
assert r.status_code == 201, r.text
|
|
return r.json()
|
|
|
|
|
|
async def create_test_workstream(client, topic_id=None, repo_id=None, slug="test-wp", **kwargs):
|
|
"""Legacy helper name — creates a repo-anchored workplan."""
|
|
if repo_id is None:
|
|
domain = await create_test_domain(client)
|
|
if topic_id is None:
|
|
topic = await create_test_topic(client, domain_slug=domain["slug"])
|
|
topic_id = topic["id"]
|
|
repo = await create_test_repo(client, domain_slug=domain["slug"], slug=f"{slug}-repo")
|
|
repo_id = repo["id"]
|
|
return await create_test_workplan(client, repo_id=repo_id, topic_id=topic_id, slug=slug, **kwargs)
|