From 44fbbf030fc0944ac2096da436b1613f519bd0b0 Mon Sep 17 00:00:00 2001 From: jlightner Date: Mon, 30 Mar 2026 07:17:42 +0000 Subject: [PATCH] =?UTF-8?q?test:=20Added=20version=20list/detail=20API=20e?= =?UTF-8?q?ndpoints,=20Pydantic=20schemas,=20versio=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "backend/schemas.py" - "backend/routers/techniques.py" - "backend/tests/test_public_api.py" GSD-Task: S04/T02 --- .gsd/milestones/M004/slices/S04/S04-PLAN.md | 2 +- .../M004/slices/S04/tasks/T01-VERIFY.json | 36 +++++ .../M004/slices/S04/tasks/T02-SUMMARY.md | 84 ++++++++++++ backend/routers/techniques.py | 75 ++++++++++- backend/schemas.py | 28 ++++ backend/tests/test_public_api.py | 123 ++++++++++++++++++ 6 files changed, 345 insertions(+), 3 deletions(-) create mode 100644 .gsd/milestones/M004/slices/S04/tasks/T01-VERIFY.json create mode 100644 .gsd/milestones/M004/slices/S04/tasks/T02-SUMMARY.md diff --git a/.gsd/milestones/M004/slices/S04/S04-PLAN.md b/.gsd/milestones/M004/slices/S04/S04-PLAN.md index b7aea28..6174972 100644 --- a/.gsd/milestones/M004/slices/S04/S04-PLAN.md +++ b/.gsd/milestones/M004/slices/S04/S04-PLAN.md @@ -50,7 +50,7 @@ - Estimate: 45m - Files: backend/models.py, alembic/versions/002_technique_page_versions.py, backend/pipeline/stages.py - Verify: cd backend && python -c "from models import TechniquePageVersion; print('Model OK')" && test -f ../alembic/versions/002_technique_page_versions.py && grep -q '_capture_pipeline_metadata' pipeline/stages.py && grep -q 'TechniquePageVersion' pipeline/stages.py -- [ ] **T02: Add version API endpoints, schemas, and integration tests** — Add Pydantic schemas for version responses, API endpoints for version list and version detail, extend TechniquePageDetail with version_count, and write integration tests covering the full flow. +- [x] **T02: Added version list/detail API endpoints, Pydantic schemas, version_count on technique detail, and 6 integration tests** — Add Pydantic schemas for version responses, API endpoints for version list and version detail, extend TechniquePageDetail with version_count, and write integration tests covering the full flow. ## Failure Modes diff --git a/.gsd/milestones/M004/slices/S04/tasks/T01-VERIFY.json b/.gsd/milestones/M004/slices/S04/tasks/T01-VERIFY.json new file mode 100644 index 0000000..bd0afa3 --- /dev/null +++ b/.gsd/milestones/M004/slices/S04/tasks/T01-VERIFY.json @@ -0,0 +1,36 @@ +{ + "schemaVersion": 1, + "taskId": "T01", + "unitId": "M004/S04/T01", + "timestamp": 1774854436398, + "passed": false, + "discoverySource": "task-plan", + "checks": [ + { + "command": "cd backend", + "exitCode": 0, + "durationMs": 6, + "verdict": "pass" + }, + { + "command": "test -f ../alembic/versions/002_technique_page_versions.py", + "exitCode": 1, + "durationMs": 4, + "verdict": "fail" + }, + { + "command": "grep -q '_capture_pipeline_metadata' pipeline/stages.py", + "exitCode": 2, + "durationMs": 5, + "verdict": "fail" + }, + { + "command": "grep -q 'TechniquePageVersion' pipeline/stages.py", + "exitCode": 2, + "durationMs": 5, + "verdict": "fail" + } + ], + "retryAttempt": 1, + "maxRetries": 2 +} diff --git a/.gsd/milestones/M004/slices/S04/tasks/T02-SUMMARY.md b/.gsd/milestones/M004/slices/S04/tasks/T02-SUMMARY.md new file mode 100644 index 0000000..436b036 --- /dev/null +++ b/.gsd/milestones/M004/slices/S04/tasks/T02-SUMMARY.md @@ -0,0 +1,84 @@ +--- +id: T02 +parent: S04 +milestone: M004 +provides: [] +requires: [] +affects: [] +key_files: ["backend/schemas.py", "backend/routers/techniques.py", "backend/tests/test_public_api.py"] +key_decisions: ["Version list returns all versions without pagination — version count per page expected to be small", "Version endpoints resolve slug first then query versions, 404 if slug not found (consistent with existing pattern)"] +patterns_established: [] +drill_down_paths: [] +observability_surfaces: [] +duration: "" +verification_result: "All 6 new version tests pass. All 8 pre-existing technique/topic tests pass (no regressions). 4 slice-level verification checks all pass: model import, migration file exists, _capture_pipeline_metadata grep, TechniquePageVersion grep in stages.py." +completed_at: 2026-03-30T07:17:34.668Z +blocker_discovered: false +--- + +# T02: Added version list/detail API endpoints, Pydantic schemas, version_count on technique detail, and 6 integration tests + +> Added version list/detail API endpoints, Pydantic schemas, version_count on technique detail, and 6 integration tests + +## What Happened +--- +id: T02 +parent: S04 +milestone: M004 +key_files: + - backend/schemas.py + - backend/routers/techniques.py + - backend/tests/test_public_api.py +key_decisions: + - Version list returns all versions without pagination — version count per page expected to be small + - Version endpoints resolve slug first then query versions, 404 if slug not found (consistent with existing pattern) +duration: "" +verification_result: passed +completed_at: 2026-03-30T07:17:34.668Z +blocker_discovered: false +--- + +# T02: Added version list/detail API endpoints, Pydantic schemas, version_count on technique detail, and 6 integration tests + +**Added version list/detail API endpoints, Pydantic schemas, version_count on technique detail, and 6 integration tests** + +## What Happened + +Added three Pydantic schemas (TechniquePageVersionSummary, TechniquePageVersionDetail, TechniquePageVersionListResponse) and version_count field to TechniquePageDetail. Added GET /{slug}/versions and GET /{slug}/versions/{version_number} endpoints to the techniques router with proper 404 handling. Modified get_technique to count version rows. Wrote 6 integration tests covering empty list, populated list with DESC ordering, detail with content_snapshot, 404 for missing version, 404 for missing slug, and version_count on technique detail. + +## Verification + +All 6 new version tests pass. All 8 pre-existing technique/topic tests pass (no regressions). 4 slice-level verification checks all pass: model import, migration file exists, _capture_pipeline_metadata grep, TechniquePageVersion grep in stages.py. + +## Verification Evidence + +| # | Command | Exit Code | Verdict | Duration | +|---|---------|-----------|---------|----------| +| 1 | `cd backend && python -m pytest tests/test_public_api.py -v -k version` | 0 | ✅ pass | 3200ms | +| 2 | `cd backend && python -c "from models import TechniquePageVersion; print('Model OK')"` | 0 | ✅ pass | 500ms | +| 3 | `test -f alembic/versions/002_technique_page_versions.py` | 0 | ✅ pass | 10ms | +| 4 | `grep -q '_capture_pipeline_metadata' backend/pipeline/stages.py` | 0 | ✅ pass | 10ms | +| 5 | `grep -q 'TechniquePageVersion' backend/pipeline/stages.py` | 0 | ✅ pass | 10ms | +| 6 | `cd backend && python -c "from schemas import TechniquePageVersionSummary, TechniquePageVersionDetail, TechniquePageVersionListResponse"` | 0 | ✅ pass | 500ms | + + +## Deviations + +None. + +## Known Issues + +5 pre-existing creator test failures unrelated to versioning (API response format change). Pre-existing deadlock issues in test_search.py. + +## Files Created/Modified + +- `backend/schemas.py` +- `backend/routers/techniques.py` +- `backend/tests/test_public_api.py` + + +## Deviations +None. + +## Known Issues +5 pre-existing creator test failures unrelated to versioning (API response format change). Pre-existing deadlock issues in test_search.py. diff --git a/backend/routers/techniques.py b/backend/routers/techniques.py index 7d2e676..ea1ca6f 100644 --- a/backend/routers/techniques.py +++ b/backend/routers/techniques.py @@ -6,12 +6,12 @@ import logging from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from database import get_session -from models import Creator, KeyMoment, RelatedTechniqueLink, SourceVideo, TechniquePage +from models import Creator, KeyMoment, RelatedTechniqueLink, SourceVideo, TechniquePage, TechniquePageVersion from schemas import ( CreatorInfo, KeyMomentSummary, @@ -19,6 +19,9 @@ from schemas import ( RelatedLinkItem, TechniquePageDetail, TechniquePageRead, + TechniquePageVersionDetail, + TechniquePageVersionListResponse, + TechniquePageVersionSummary, ) logger = logging.getLogger("chrysopedia.techniques") @@ -130,9 +133,77 @@ async def get_technique( ) base = TechniquePageRead.model_validate(page) + + # Count versions for this page + version_count_stmt = select(func.count()).where( + TechniquePageVersion.technique_page_id == page.id + ) + version_count_result = await db.execute(version_count_stmt) + version_count = version_count_result.scalar() or 0 + return TechniquePageDetail( **base.model_dump(), key_moments=key_moment_items, creator_info=creator_info, related_links=related_links, + version_count=version_count, ) + + +@router.get("/{slug}/versions", response_model=TechniquePageVersionListResponse) +async def list_technique_versions( + slug: str, + db: AsyncSession = Depends(get_session), +) -> TechniquePageVersionListResponse: + """List all version snapshots for a technique page, newest first.""" + # Resolve the technique page + page_stmt = select(TechniquePage).where(TechniquePage.slug == slug) + page_result = await db.execute(page_stmt) + page = page_result.scalar_one_or_none() + if page is None: + raise HTTPException(status_code=404, detail=f"Technique '{slug}' not found") + + # Fetch versions ordered by version_number DESC + versions_stmt = ( + select(TechniquePageVersion) + .where(TechniquePageVersion.technique_page_id == page.id) + .order_by(TechniquePageVersion.version_number.desc()) + ) + versions_result = await db.execute(versions_stmt) + versions = versions_result.scalars().all() + + items = [TechniquePageVersionSummary.model_validate(v) for v in versions] + return TechniquePageVersionListResponse(items=items, total=len(items)) + + +@router.get("/{slug}/versions/{version_number}", response_model=TechniquePageVersionDetail) +async def get_technique_version( + slug: str, + version_number: int, + db: AsyncSession = Depends(get_session), +) -> TechniquePageVersionDetail: + """Get a specific version snapshot by version number.""" + # Resolve the technique page + page_stmt = select(TechniquePage).where(TechniquePage.slug == slug) + page_result = await db.execute(page_stmt) + page = page_result.scalar_one_or_none() + if page is None: + raise HTTPException(status_code=404, detail=f"Technique '{slug}' not found") + + # Fetch the specific version + version_stmt = ( + select(TechniquePageVersion) + .where( + TechniquePageVersion.technique_page_id == page.id, + TechniquePageVersion.version_number == version_number, + ) + ) + version_result = await db.execute(version_stmt) + version = version_result.scalar_one_or_none() + if version is None: + raise HTTPException( + status_code=404, + detail=f"Version {version_number} not found for technique '{slug}'", + ) + + return TechniquePageVersionDetail.model_validate(version) diff --git a/backend/schemas.py b/backend/schemas.py index 7afe140..1e6fb9b 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -312,6 +312,34 @@ class TechniquePageDetail(TechniquePageRead): key_moments: list[KeyMomentSummary] = Field(default_factory=list) creator_info: CreatorInfo | None = None related_links: list[RelatedLinkItem] = Field(default_factory=list) + version_count: int = 0 + + +# ── Technique Page Versions ────────────────────────────────────────────────── + +class TechniquePageVersionSummary(BaseModel): + """Lightweight version entry for list responses.""" + model_config = ConfigDict(from_attributes=True) + + version_number: int + created_at: datetime + pipeline_metadata: dict | None = None + + +class TechniquePageVersionDetail(BaseModel): + """Full version snapshot for detail responses.""" + model_config = ConfigDict(from_attributes=True) + + version_number: int + content_snapshot: dict + pipeline_metadata: dict | None = None + created_at: datetime + + +class TechniquePageVersionListResponse(BaseModel): + """Response for version list endpoint.""" + items: list[TechniquePageVersionSummary] = Field(default_factory=list) + total: int = 0 # ── Topics ─────────────────────────────────────────────────────────────────── diff --git a/backend/tests/test_public_api.py b/backend/tests/test_public_api.py index bb29fc5..46eac31 100644 --- a/backend/tests/test_public_api.py +++ b/backend/tests/test_public_api.py @@ -401,3 +401,126 @@ async def test_creators_empty_list(client, db_engine): data = resp.json() assert data == [] + + +# ── Version Tests ──────────────────────────────────────────────────────────── + + +async def _insert_version(db_engine, technique_page_id: str, version_number: int, content_snapshot: dict, pipeline_metadata: dict | None = None): + """Insert a TechniquePageVersion row directly for testing.""" + from models import TechniquePageVersion + session_factory = async_sessionmaker( + db_engine, class_=AsyncSession, expire_on_commit=False + ) + async with session_factory() as session: + v = TechniquePageVersion( + technique_page_id=uuid.UUID(technique_page_id) if isinstance(technique_page_id, str) else technique_page_id, + version_number=version_number, + content_snapshot=content_snapshot, + pipeline_metadata=pipeline_metadata, + ) + session.add(v) + await session.commit() + + +@pytest.mark.asyncio +async def test_version_list_empty(client, db_engine): + """GET /techniques/{slug}/versions returns empty list when page has no versions.""" + seed = await _seed_full_data(db_engine) + + resp = await client.get(f"{TECHNIQUES_URL}/{seed['tp1_slug']}/versions") + assert resp.status_code == 200 + + data = resp.json() + assert data["items"] == [] + assert data["total"] == 0 + + +@pytest.mark.asyncio +async def test_version_list_with_versions(client, db_engine): + """GET /techniques/{slug}/versions returns versions after inserting them.""" + seed = await _seed_full_data(db_engine) + + # Get the technique page ID by fetching the detail + detail_resp = await client.get(f"{TECHNIQUES_URL}/{seed['tp1_slug']}") + page_id = detail_resp.json()["id"] + + # Insert two versions + snapshot1 = {"title": "Old Reese Bass v1", "summary": "First draft"} + snapshot2 = {"title": "Old Reese Bass v2", "summary": "Second draft"} + await _insert_version(db_engine, page_id, 1, snapshot1, {"model": "gpt-4o"}) + await _insert_version(db_engine, page_id, 2, snapshot2, {"model": "gpt-4o-mini"}) + + resp = await client.get(f"{TECHNIQUES_URL}/{seed['tp1_slug']}/versions") + assert resp.status_code == 200 + + data = resp.json() + assert data["total"] == 2 + assert len(data["items"]) == 2 + # Ordered by version_number DESC + assert data["items"][0]["version_number"] == 2 + assert data["items"][1]["version_number"] == 1 + assert data["items"][0]["pipeline_metadata"]["model"] == "gpt-4o-mini" + assert data["items"][1]["pipeline_metadata"]["model"] == "gpt-4o" + + +@pytest.mark.asyncio +async def test_version_detail_returns_content_snapshot(client, db_engine): + """GET /techniques/{slug}/versions/{version_number} returns full snapshot.""" + seed = await _seed_full_data(db_engine) + + detail_resp = await client.get(f"{TECHNIQUES_URL}/{seed['tp1_slug']}") + page_id = detail_resp.json()["id"] + + snapshot = {"title": "Old Title", "summary": "Old summary", "body_sections": {"intro": "Old intro"}} + metadata = {"model": "gpt-4o", "prompt_hash": "abc123"} + await _insert_version(db_engine, page_id, 1, snapshot, metadata) + + resp = await client.get(f"{TECHNIQUES_URL}/{seed['tp1_slug']}/versions/1") + assert resp.status_code == 200 + + data = resp.json() + assert data["version_number"] == 1 + assert data["content_snapshot"] == snapshot + assert data["pipeline_metadata"] == metadata + assert "created_at" in data + + +@pytest.mark.asyncio +async def test_version_detail_404_for_nonexistent_version(client, db_engine): + """GET /techniques/{slug}/versions/999 returns 404.""" + seed = await _seed_full_data(db_engine) + + resp = await client.get(f"{TECHNIQUES_URL}/{seed['tp1_slug']}/versions/999") + assert resp.status_code == 404 + assert "not found" in resp.json()["detail"].lower() + + +@pytest.mark.asyncio +async def test_versions_404_for_nonexistent_slug(client, db_engine): + """GET /techniques/nonexistent-slug/versions returns 404.""" + await _seed_full_data(db_engine) + + resp = await client.get(f"{TECHNIQUES_URL}/nonexistent-slug-xyz/versions") + assert resp.status_code == 404 + assert "not found" in resp.json()["detail"].lower() + + +@pytest.mark.asyncio +async def test_technique_detail_includes_version_count(client, db_engine): + """GET /techniques/{slug} includes version_count field.""" + seed = await _seed_full_data(db_engine) + + # Initially version_count should be 0 + resp = await client.get(f"{TECHNIQUES_URL}/{seed['tp1_slug']}") + assert resp.status_code == 200 + data = resp.json() + assert data["version_count"] == 0 + + # Insert a version and check again + page_id = data["id"] + await _insert_version(db_engine, page_id, 1, {"title": "Snapshot"}) + + resp2 = await client.get(f"{TECHNIQUES_URL}/{seed['tp1_slug']}") + assert resp2.status_code == 200 + assert resp2.json()["version_count"] == 1