test: Added version list/detail API endpoints, Pydantic schemas, versio…
- "backend/schemas.py" - "backend/routers/techniques.py" - "backend/tests/test_public_api.py" GSD-Task: S04/T02
This commit is contained in:
parent
5c3e9b83c8
commit
44fbbf030f
6 changed files with 345 additions and 3 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
36
.gsd/milestones/M004/slices/S04/tasks/T01-VERIFY.json
Normal file
36
.gsd/milestones/M004/slices/S04/tasks/T01-VERIFY.json
Normal file
|
|
@ -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
|
||||
}
|
||||
84
.gsd/milestones/M004/slices/S04/tasks/T02-SUMMARY.md
Normal file
84
.gsd/milestones/M004/slices/S04/tasks/T02-SUMMARY.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue