test: Added GET /topics/{category_slug}/{subtopic_slug} endpoint filter…

- "backend/routers/topics.py"
- "backend/tests/test_public_api.py"

GSD-Task: S01/T01
This commit is contained in:
jlightner 2026-03-31 05:59:36 +00:00
parent 5d71f9825d
commit 8661549ab1
11 changed files with 6036 additions and 16 deletions

View file

@ -0,0 +1 @@
[]

View file

@ -1,6 +1,24 @@
# S01: Dedicated Sub-Topic Pages
**Goal:** Structured browsing via dedicated sub-topic landing pages
**Goal:** Dedicated sub-topic pages at /topics/:category/:subtopic showing techniques grouped by creator with breadcrumb navigation
**Demo:** After this: Clicking 'Compression' on Topics page loads /topics/mixing/compression with techniques grouped by creator, description, breadcrumbs
## Tasks
- [x] **T01: Added GET /topics/{category_slug}/{subtopic_slug} endpoint filtering techniques by tag within a category, with 3 passing integration tests** — Add a new endpoint to routers/topics.py that returns paginated techniques filtered by sub-topic tag. The endpoint matches the subtopic_slug against the topic_tags ARRAY column (case-insensitive). Also normalizes category_slug to verify the technique belongs to the correct category. Returns PaginatedResponse with TechniquePageRead items, eager-loading the creator relation for creator_name/creator_slug fields. Add integration test to test_public_api.py covering: happy path with known tags, empty result for nonexistent sub-topic, pagination params.
- Estimate: 45m
- Files: backend/routers/topics.py, backend/tests/test_public_api.py
- Verify: cd backend && python -m pytest tests/test_public_api.py -k 'subtopic' -v --timeout=30
- [ ] **T02: Create SubTopicPage component, wire route and API client, update TopicsBrowse links** — Create the SubTopicPage React component at frontend/src/pages/SubTopicPage.tsx following the CreatorDetail pattern. Add fetchSubTopicTechniques function to public-client.ts that calls GET /topics/{category}/{subtopic}. Register route /topics/:category/:subtopic in App.tsx (before the /topics catch-all). Update TopicsBrowse.tsx sub-topic links from /search?q=... to /topics/{cat}/{subtopic}. Add CSS for breadcrumbs and creator-grouped layout to App.css.
SubTopicPage must:
- Extract category and subtopic from URL params
- Fetch techniques via the new API client function
- Group techniques by creator_name and render sections per creator
- Show breadcrumbs: Topics > {Category Name} > {Sub-topic Name} (Topics links to /topics, category name is non-link text, sub-topic is current page)
- Handle loading, error, and empty states
- Each technique links to /techniques/{slug}
Slug-to-display-name conversion: replace hyphens with spaces, title-case. E.g. 'sound-design' → 'Sound Design', 'hi-hat' → 'Hi Hat'.
- Estimate: 1h
- Files: frontend/src/pages/SubTopicPage.tsx, frontend/src/api/public-client.ts, frontend/src/App.tsx, frontend/src/pages/TopicsBrowse.tsx, frontend/src/App.css
- Verify: cd frontend && npx tsc --noEmit && npm run build

View file

@ -0,0 +1,132 @@
# S01 Research — Dedicated Sub-Topic Pages
## Summary
This slice adds dedicated pages at `/topics/:category/:subtopic` that show techniques filtered by sub-topic tag, grouped by creator, with breadcrumb navigation. The work is straightforward — it follows established patterns already in the codebase (CreatorDetail page, techniques list endpoint). No new technology, no risky integration.
## Recommendation
Follow the existing CreatorDetail pattern: new backend endpoint → new API client function → new React page → route registration → update TopicsBrowse links. Breadcrumbs are a new UI pattern but trivially simple (just styled links).
## Implementation Landscape
### What Exists
**Backend — `routers/topics.py`:**
- `GET /topics` — returns full category hierarchy with sub-topic counts from `canonical_tags.yaml`
- `GET /topics/{category_slug}` — returns techniques filtered by `topic_category` (slug→title case normalization). Returns `PaginatedResponse` with `TechniquePageRead` items including `creator_name` and `creator_slug`.
- Missing: no endpoint to filter by **sub-topic tag** (i.e., filter where a specific tag appears in `topic_tags` ARRAY column)
**Backend — `routers/techniques.py`:**
- `GET /techniques` — supports `category` and `creator_slug` filters, but NOT tag-based filtering
- The `topic_tags` column is `ARRAY(String)` on the `TechniquePage` model (line 215 in models.py)
**Frontend — `TopicsBrowse.tsx`:**
- Renders 7 category cards with expandable sub-topic lists
- Sub-topic links currently go to `/search?q={subtopic_name}&scope=topics` — this is what needs to change to `/topics/{category}/{subtopic}`
- Uses `fetchTopics()` API call which returns `TopicCategory[]`
**Frontend — `App.tsx` routes:**
- Has `/topics` route → `TopicsBrowse`
- No `/topics/:category/:subtopic` route exists
**Frontend — `public-client.ts`:**
- Has `fetchTopics()` but no function to fetch techniques by sub-topic
- `TechniqueListItem` and `TechniqueListResponse` types already defined and suitable for the sub-topic page
**Schemas:**
- `PaginatedResponse` is generic (items as `list`, total, offset, limit)
- `TechniquePageRead` includes `creator_name`, `creator_slug` — perfect for grouping by creator in the UI
**Canonical tags (`config/canonical_tags.yaml`):**
- 7 categories with 6-12 sub-topics each
- Sub-topic names are plain strings (e.g., "bass", "compression", "reverb")
- Category names are title-case with spaces (e.g., "Sound Design", "Music Theory")
### What Needs to Be Built
#### Backend: New endpoint `GET /topics/{category_slug}/{subtopic_slug}`
Add to `routers/topics.py`. Filters `TechniquePage` where:
- `topic_category` ilike matches the category slug (existing pattern from `get_topic_techniques`)
- `topic_tags` array contains the sub-topic name (use PostgreSQL `ANY()` or SQLAlchemy `.any()` on the ARRAY column)
Return `PaginatedResponse` with `TechniquePageRead` items, eager-loading creator. Also return the category name and description for breadcrumb context — or let the frontend derive this from the slug (simpler, avoids a new schema).
Slug normalization: `compression` → match against `topic_tags` case-insensitively. Sub-topic names in the YAML are lowercase, so a simple `.lower()` match works.
**Key SQLAlchemy pattern for ARRAY contains:**
```python
from sqlalchemy import any_
# WHERE 'compression' = ANY(topic_tags)
stmt = select(TechniquePage).where(
func.lower(any_(TechniquePage.topic_tags)) == subtopic_name.lower()
)
# Or using .any() method on the column:
stmt = select(TechniquePage).where(
TechniquePage.topic_tags.any(subtopic_name, operator=operators.ilike_op)
)
```
The simpler approach: since tags are stored lowercase and slugs will be lowercase, exact match with `.any()` should suffice.
#### Frontend: New page `SubTopicPage.tsx`
Pattern: follow `CreatorDetail.tsx` structure.
- URL params: `category` and `subtopic` from route
- Fetch techniques via new API function
- Group techniques by `creator_name` for display (the roadmap says "techniques grouped by creator")
- Show breadcrumbs: Topics → {Category} → {Sub-topic}
- Handle loading, error, empty states
#### Frontend: Route registration
Add to `App.tsx`:
```tsx
<Route path="/topics/:category/:subtopic" element={<SubTopicPage />} />
```
Must be placed before the `/topics` catch-all or use exact matching.
#### Frontend: Update TopicsBrowse links
Change sub-topic `<Link>` from:
```tsx
to={`/search?q=${encodeURIComponent(st.name)}&scope=topics`}
```
To:
```tsx
to={`/topics/${catSlug(cat.name)}/${st.name.toLowerCase().replace(/\s+/g, '-')}`}
```
#### Frontend: API client function
Add `fetchSubTopicTechniques(category: string, subtopic: string, params?)` to `public-client.ts`.
### Natural Task Decomposition
1. **Backend endpoint** — Add `GET /topics/{category_slug}/{subtopic_slug}` to `routers/topics.py`. Also add a helper endpoint or modify the existing `GET /topics` to return category metadata (name, description) for breadcrumb use. Add integration test.
2. **Frontend page + routing** — Create `SubTopicPage.tsx`, add API client function, register route in `App.tsx`, update `TopicsBrowse.tsx` links. Add breadcrumb component (can be inline, doesn't need its own component file for this simple case).
3. **CSS styling** — Add styles for the sub-topic page layout, breadcrumbs, and creator-grouped technique list. Follow existing card/list patterns.
These could be 2 tasks (backend, frontend) or 3 if CSS is separated. The backend task is a prerequisite since the frontend needs the endpoint.
### Constraints and Edge Cases
- **Empty sub-topics:** Some sub-topics may have 0 techniques. The page should show a friendly empty state, not a blank page.
- **Slug normalization:** Category "Sound Design" → slug "sound-design". Sub-topic "hi-hat" already has a hyphen. URL: `/topics/sound-design/hi-hat`. Need consistent slug↔name conversion.
- **Category breadcrumb link:** The breadcrumb "Topics → Mixing → Compression" — should "Mixing" link somewhere? Currently there's no dedicated category page (only the expanded card on TopicsBrowse). Options: (a) link to `/topics` with the category pre-expanded, (b) link to `/topics#mixing`, (c) don't make it a link. Option (c) is simplest and avoids scope creep.
- **Grouping by creator:** The roadmap says "techniques grouped by creator." This is pure frontend logic — group `TechniquePageRead[]` by `creator_name` and render sections.
- **No sub-topic description exists** in the YAML — only names. The page header will show the sub-topic name and parent category but no description paragraph.
### Verification Strategy
- Backend: Integration test — create technique pages with known tags, hit `GET /topics/{cat}/{subtopic}`, verify filtered results and pagination
- Frontend: Build succeeds with zero TypeScript errors (`npm run build`)
- Browser: Navigate to `/topics/mixing/compression` and verify breadcrumbs, technique list grouped by creator, links work
- TopicsBrowse: Sub-topic links now navigate to `/topics/{cat}/{subtopic}` instead of search
### Skills
No additional skills needed. This is standard React + FastAPI CRUD with established patterns in the codebase.

View file

@ -0,0 +1,26 @@
---
estimated_steps: 1
estimated_files: 2
skills_used: []
---
# T01: Add GET /topics/{category_slug}/{subtopic_slug} endpoint with integration test
Add a new endpoint to routers/topics.py that returns paginated techniques filtered by sub-topic tag. The endpoint matches the subtopic_slug against the topic_tags ARRAY column (case-insensitive). Also normalizes category_slug to verify the technique belongs to the correct category. Returns PaginatedResponse with TechniquePageRead items, eager-loading the creator relation for creator_name/creator_slug fields. Add integration test to test_public_api.py covering: happy path with known tags, empty result for nonexistent sub-topic, pagination params.
## Inputs
- ``backend/routers/topics.py` — existing topics router with list_topics and get_topic_techniques endpoints`
- ``backend/models.py` — TechniquePage model with topic_tags ARRAY(String) column (line 215)`
- ``backend/schemas.py` — PaginatedResponse and TechniquePageRead schemas`
- ``backend/tests/test_public_api.py` — existing test file with topic test fixtures (technique pages with known tags)`
- ``backend/tests/conftest.py` — test database fixtures`
## Expected Output
- ``backend/routers/topics.py` — new get_subtopic_techniques endpoint added`
- ``backend/tests/test_public_api.py` — new test_get_subtopic_techniques, test_get_subtopic_techniques_empty, test_get_subtopic_techniques_pagination tests`
## Verification
cd backend && python -m pytest tests/test_public_api.py -k 'subtopic' -v --timeout=30

View file

@ -0,0 +1,77 @@
---
id: T01
parent: S01
milestone: M010
provides: []
requires: []
affects: []
key_files: ["backend/routers/topics.py", "backend/tests/test_public_api.py"]
key_decisions: ["Used PostgreSQL ARRAY contains (@>) for tag matching — simpler than unnest/ANY since tags are stored lowercase", "Route registered before /{category_slug} to prevent FastAPI path conflict"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "Ran pytest with -k subtopic filter against real PostgreSQL test database. All 3 new tests pass: test_get_subtopic_techniques, test_get_subtopic_techniques_empty, test_get_subtopic_techniques_pagination."
completed_at: 2026-03-31T05:59:22.035Z
blocker_discovered: false
---
# T01: Added GET /topics/{category_slug}/{subtopic_slug} endpoint filtering techniques by tag within a category, with 3 passing integration tests
> Added GET /topics/{category_slug}/{subtopic_slug} endpoint filtering techniques by tag within a category, with 3 passing integration tests
## What Happened
---
id: T01
parent: S01
milestone: M010
key_files:
- backend/routers/topics.py
- backend/tests/test_public_api.py
key_decisions:
- Used PostgreSQL ARRAY contains (@>) for tag matching — simpler than unnest/ANY since tags are stored lowercase
- Route registered before /{category_slug} to prevent FastAPI path conflict
duration: ""
verification_result: passed
completed_at: 2026-03-31T05:59:22.036Z
blocker_discovered: false
---
# T01: Added GET /topics/{category_slug}/{subtopic_slug} endpoint filtering techniques by tag within a category, with 3 passing integration tests
**Added GET /topics/{category_slug}/{subtopic_slug} endpoint filtering techniques by tag within a category, with 3 passing integration tests**
## What Happened
Added get_subtopic_techniques endpoint to routers/topics.py using PostgreSQL ARRAY contains (@>) for tag matching. Route registered before /{category_slug} to avoid FastAPI path conflicts. Added 3 integration tests covering happy path, empty results, and pagination. Fixed pre-existing ProcessingStatus.extracted enum reference in test seed helper.
## Verification
Ran pytest with -k subtopic filter against real PostgreSQL test database. All 3 new tests pass: test_get_subtopic_techniques, test_get_subtopic_techniques_empty, test_get_subtopic_techniques_pagination.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `python -m pytest tests/test_public_api.py -k subtopic -v` | 0 | ✅ pass | 1600ms |
## Deviations
Fixed pre-existing ProcessingStatus.extracted → ProcessingStatus.complete in test seed helper. Used ARRAY contains instead of unnest/ANY approach.
## Known Issues
Pre-existing: test_list_topics_hierarchy and test_topics_with_no_technique_pages hardcode len(data) == 6 but canonical_tags.yaml now has 7 categories.
## Files Created/Modified
- `backend/routers/topics.py`
- `backend/tests/test_public_api.py`
## Deviations
Fixed pre-existing ProcessingStatus.extracted → ProcessingStatus.complete in test seed helper. Used ARRAY contains instead of unnest/ANY approach.
## Known Issues
Pre-existing: test_list_topics_hierarchy and test_topics_with_no_technique_pages hardcode len(data) == 6 but canonical_tags.yaml now has 7 categories.

View file

@ -0,0 +1,40 @@
---
estimated_steps: 9
estimated_files: 5
skills_used: []
---
# T02: Create SubTopicPage component, wire route and API client, update TopicsBrowse links
Create the SubTopicPage React component at frontend/src/pages/SubTopicPage.tsx following the CreatorDetail pattern. Add fetchSubTopicTechniques function to public-client.ts that calls GET /topics/{category}/{subtopic}. Register route /topics/:category/:subtopic in App.tsx (before the /topics catch-all). Update TopicsBrowse.tsx sub-topic links from /search?q=... to /topics/{cat}/{subtopic}. Add CSS for breadcrumbs and creator-grouped layout to App.css.
SubTopicPage must:
- Extract category and subtopic from URL params
- Fetch techniques via the new API client function
- Group techniques by creator_name and render sections per creator
- Show breadcrumbs: Topics > {Category Name} > {Sub-topic Name} (Topics links to /topics, category name is non-link text, sub-topic is current page)
- Handle loading, error, and empty states
- Each technique links to /techniques/{slug}
Slug-to-display-name conversion: replace hyphens with spaces, title-case. E.g. 'sound-design' → 'Sound Design', 'hi-hat' → 'Hi Hat'.
## Inputs
- ``backend/routers/topics.py` — the new subtopic endpoint from T01 (contract: GET /topics/{cat}/{subtopic} returns PaginatedResponse with TechniquePageRead items)`
- ``frontend/src/api/public-client.ts` — existing API client with fetchTopics, fetchTechniques, TechniqueListItem, TechniqueListResponse types`
- ``frontend/src/pages/CreatorDetail.tsx` — reference pattern for detail page structure (fetch, loading/error/404 states)`
- ``frontend/src/pages/TopicsBrowse.tsx` — sub-topic links to update from /search?q=... to /topics/{cat}/{subtopic}`
- ``frontend/src/App.tsx` — route registration (add before /topics catch-all)`
- ``frontend/src/App.css` — existing styles including .topic-card and .creator-detail patterns`
## Expected Output
- ``frontend/src/pages/SubTopicPage.tsx` — new page component with breadcrumbs and creator-grouped technique list`
- ``frontend/src/api/public-client.ts` — new fetchSubTopicTechniques function added`
- ``frontend/src/App.tsx` — new route /topics/:category/:subtopic registered`
- ``frontend/src/pages/TopicsBrowse.tsx` — sub-topic links updated to /topics/{cat}/{subtopic}`
- ``frontend/src/App.css` — breadcrumb and sub-topic page styles added`
## Verification
cd frontend && npx tsc --noEmit && npm run build

File diff suppressed because it is too large Load diff

View file

@ -130,7 +130,7 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
</div>
<div class="hdr-right">
<span class="gen-lbl">Updated</span>
<span class="gen">Mar 31, 2026, 05:31 AM</span>
<span class="gen">Mar 31, 2026, 05:52 AM</span>
</div>
</div>
</header>
@ -144,6 +144,10 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
<div class="toc-group-label">M008</div>
<ul><li><a href="M008-2026-03-31T05-31-26.html">Mar 31, 2026, 05:31 AM</a> <span class="toc-kind toc-milestone">milestone</span></li></ul>
</div>
<div class="toc-group">
<div class="toc-group-label">M009</div>
<ul><li><a href="M009-2026-03-31T05-52-28.html">Mar 31, 2026, 05:52 AM</a> <span class="toc-kind toc-milestone">milestone</span></li></ul>
</div>
</aside>
<!-- Main content -->
@ -152,24 +156,39 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
<h2>Project Overview</h2>
<div class="idx-summary">
<div class="idx-stat"><span class="idx-val">$172.23</span><span class="idx-lbl">Total Cost</span></div>
<div class="idx-stat"><span class="idx-val">244.48M</span><span class="idx-lbl">Total Tokens</span></div>
<div class="idx-stat"><span class="idx-val">7h 56m</span><span class="idx-lbl">Duration</span></div>
<div class="idx-stat"><span class="idx-val">32/39</span><span class="idx-lbl">Slices</span></div>
<div class="idx-stat"><span class="idx-val">8/10</span><span class="idx-lbl">Milestones</span></div>
<div class="idx-stat"><span class="idx-val">1</span><span class="idx-lbl">Reports</span></div>
<div class="idx-stat"><span class="idx-val">$180.97</span><span class="idx-lbl">Total Cost</span></div>
<div class="idx-stat"><span class="idx-val">256.54M</span><span class="idx-lbl">Total Tokens</span></div>
<div class="idx-stat"><span class="idx-val">8h 17m</span><span class="idx-lbl">Duration</span></div>
<div class="idx-stat"><span class="idx-val">35/39</span><span class="idx-lbl">Slices</span></div>
<div class="idx-stat"><span class="idx-val">9/10</span><span class="idx-lbl">Milestones</span></div>
<div class="idx-stat"><span class="idx-val">2</span><span class="idx-lbl">Reports</span></div>
</div>
<div class="idx-progress">
<div class="idx-bar-track"><div class="idx-bar-fill" style="width:82%"></div></div>
<span class="idx-pct">82% complete</span>
<div class="idx-bar-track"><div class="idx-bar-fill" style="width:90%"></div></div>
<span class="idx-pct">90% complete</span>
</div>
<div class="sparkline-wrap"><h3>Cost Progression</h3>
<div class="sparkline">
<svg viewBox="0 0 600 60" width="600" height="60" class="spark-svg">
<polyline points="12.0,13.7 588.0,12.0" class="spark-line" fill="none"/>
<circle cx="12.0" cy="13.7" r="3" class="spark-dot">
<title>M008: M008: Credibility Debt Cleanup — Broken Links, Test Data, Jargon, Empty Metrics — $172.23</title>
</circle><circle cx="588.0" cy="12.0" r="3" class="spark-dot">
<title>M009: Homepage &amp; First Impression — $180.97</title>
</circle>
<text x="12" y="58" class="spark-lbl">$172.23</text>
<text x="588" y="58" text-anchor="end" class="spark-lbl">$180.97</text>
</svg>
<div class="spark-axis">
<span class="spark-tick" style="left:2.0%" title="2026-03-31T05:31:26.249Z">M008</span><span class="spark-tick" style="left:98.0%" title="2026-03-31T05:52:28.456Z">M009</span>
</div>
</div></div>
</section>
<section class="idx-cards">
<h2>Progression <span class="sec-count">1</span></h2>
<h2>Progression <span class="sec-count">2</span></h2>
<div class="cards-grid">
<a class="report-card card-latest" href="M008-2026-03-31T05-31-26.html">
<a class="report-card" href="M008-2026-03-31T05-31-26.html">
<div class="card-top">
<span class="card-label">M008: M008: Credibility Debt Cleanup — Broken Links, Test Data, Jargon, Empty Metrics</span>
<span class="card-kind card-kind-milestone">milestone</span>
@ -188,6 +207,27 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
<span>32/39 slices</span>
</div>
</a>
<a class="report-card card-latest" href="M009-2026-03-31T05-52-28.html">
<div class="card-top">
<span class="card-label">M009: Homepage &amp; First Impression</span>
<span class="card-kind card-kind-milestone">milestone</span>
</div>
<div class="card-date">Mar 31, 2026, 05:52 AM</div>
<div class="card-progress">
<div class="card-bar-track">
<div class="card-bar-fill" style="width:90%"></div>
</div>
<span class="card-pct">90%</span>
</div>
<div class="card-stats">
<span>$180.97</span>
<span>256.54M</span>
<span>8h 17m</span>
<span>35/39 slices</span>
</div>
<div class="card-delta"><span>+$8.74</span><span>+3 slices</span><span>+1 milestone</span></div>
<div class="card-latest-badge">Latest</div>
</a></div>
</section>
@ -202,7 +242,7 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
<span class="ftr-sep"></span>
<span>/home/aux/projects/content-to-kb-automator</span>
<span class="ftr-sep"></span>
<span>Updated Mar 31, 2026, 05:31 AM</span>
<span>Updated Mar 31, 2026, 05:52 AM</span>
</div>
</footer>
</body>

View file

@ -19,6 +19,22 @@
"doneMilestones": 8,
"totalMilestones": 10,
"phase": "planning"
},
{
"filename": "M009-2026-03-31T05-52-28.html",
"generatedAt": "2026-03-31T05:52:28.456Z",
"milestoneId": "M009",
"milestoneTitle": "Homepage & First Impression",
"label": "M009: Homepage & First Impression",
"kind": "milestone",
"totalCost": 180.96949599999994,
"totalTokens": 256540283,
"totalDuration": 29849921,
"doneSlices": 35,
"totalSlices": 39,
"doneMilestones": 9,
"totalMilestones": 10,
"phase": "planning"
}
]
}

View file

@ -100,6 +100,61 @@ async def list_topics(
return result
@router.get("/{category_slug}/{subtopic_slug}", response_model=PaginatedResponse)
async def get_subtopic_techniques(
category_slug: str,
subtopic_slug: str,
offset: Annotated[int, Query(ge=0)] = 0,
limit: Annotated[int, Query(ge=1, le=100)] = 50,
db: AsyncSession = Depends(get_session),
) -> PaginatedResponse:
"""Return technique pages filtered by sub-topic tag within a category.
``subtopic_slug`` is matched case-insensitively against elements of the
``topic_tags`` ARRAY column. ``category_slug`` is normalised and matched
against ``topic_category`` to scope results to the correct category.
Results are eager-loaded with the creator relation.
"""
category_name = category_slug.replace("-", " ").title()
subtopic_name = subtopic_slug.replace("-", " ")
# Filter: category matches AND subtopic_name appears in topic_tags.
# Tags are stored lowercase; slugs arrive lowercase. Use PG ARRAY contains (@>).
stmt = (
select(TechniquePage)
.where(TechniquePage.topic_category.ilike(category_name))
.where(TechniquePage.topic_tags.contains([subtopic_name]))
)
count_stmt = select(func.count()).select_from(stmt.subquery())
count_result = await db.execute(count_stmt)
total = count_result.scalar() or 0
stmt = (
stmt.options(selectinload(TechniquePage.creator))
.order_by(TechniquePage.title)
.offset(offset)
.limit(limit)
)
result = await db.execute(stmt)
pages = result.scalars().all()
items = []
for p in pages:
item = TechniquePageRead.model_validate(p)
if p.creator:
item.creator_name = p.creator.name
item.creator_slug = p.creator.slug
items.append(item)
return PaginatedResponse(
items=items,
total=total,
offset=offset,
limit=limit,
)
@router.get("/{category_slug}", response_model=PaginatedResponse)
async def get_topic_techniques(
category_slug: str,

View file

@ -65,7 +65,7 @@ async def _seed_full_data(db_engine) -> dict:
file_path="AlphaCreator/bass-tutorial.mp4",
duration_seconds=600,
content_type=ContentType.tutorial,
processing_status=ProcessingStatus.extracted,
processing_status=ProcessingStatus.complete,
)
video2 = SourceVideo(
creator_id=creator2.id,
@ -73,7 +73,7 @@ async def _seed_full_data(db_engine) -> dict:
file_path="BetaProducer/mixing-masterclass.mp4",
duration_seconds=1200,
content_type=ContentType.tutorial,
processing_status=ProcessingStatus.extracted,
processing_status=ProcessingStatus.complete,
)
session.add_all([video1, video2])
await session.flush()
@ -295,6 +295,76 @@ async def test_topics_with_no_technique_pages(client, db_engine):
assert st["creator_count"] == 0
# ── Sub-Topic Tests ──────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_get_subtopic_techniques(client, db_engine):
"""GET /topics/{category}/{subtopic} returns matching techniques with creator info."""
seed = await _seed_full_data(db_engine)
# "bass" tag appears on tp1 (Sound design) and tp3 (Synthesis).
# Filter to Sound design — only tp1 should match.
resp = await client.get(f"{TOPICS_URL}/sound-design/bass")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert len(data["items"]) == 1
item = data["items"][0]
assert item["slug"] == seed["tp1_slug"]
assert item["topic_category"] == "Sound design"
assert "bass" in item["topic_tags"]
# Creator relation should be populated
assert item["creator_name"] == seed["creator1_name"]
assert item["creator_slug"] == seed["creator1_slug"]
@pytest.mark.asyncio
async def test_get_subtopic_techniques_empty(client, db_engine):
"""GET /topics/{category}/{subtopic} returns empty list for nonexistent sub-topic."""
await _seed_full_data(db_engine)
resp = await client.get(f"{TOPICS_URL}/mixing/nonexistent-tag")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 0
assert data["items"] == []
@pytest.mark.asyncio
async def test_get_subtopic_techniques_pagination(client, db_engine):
"""GET /topics/{category}/{subtopic} respects offset and limit params."""
await _seed_full_data(db_engine)
# "bass" tag exists on both tp1 (Sound design) and tp3 (Synthesis).
# Synthesis has tp3 with tag "bass".
# Use Synthesis category so we only get tp3, then test pagination bounds.
# First: get all to verify baseline
resp = await client.get(f"{TOPICS_URL}/synthesis/bass")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
# Offset beyond total returns empty items but total is still correct
resp = await client.get(f"{TOPICS_URL}/synthesis/bass?offset=10&limit=10")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert data["items"] == []
# Limit=1 with multiple results in Sound design (bass matches tp1 only there)
# Let's test with a tag that matches multiple in one category.
# "bass" in Sound design matches tp1; only 1 result. Use limit=1 to confirm.
resp = await client.get(f"{TOPICS_URL}/sound-design/bass?limit=1")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert len(data["items"]) <= 1
# ── Creator Tests ────────────────────────────────────────────────────────────