62e48642e2
12 TDD-style tasks covering SQL additions, DTOs, conversion helpers, per-endpoint handlers (tracks, albums, artist detail, artist list, search), route wiring, and an end-to-end smoke + PR. Follows the approved design at docs/superpowers/specs/2026-04-20-web-ui-library-reads-design.md. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2125 lines
65 KiB
Markdown
2125 lines
65 KiB
Markdown
# Web UI Library Reads Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Add five read-only JSON endpoints to `/api/*` — paged artist list (alpha + newest), artist/album/track detail, and unified search — so the SvelteKit SPA can render browse and search views.
|
||
|
||
**Architecture:** Handlers live in `internal/api` alongside existing auth code, gated by `RequireUser`. New SQL queries added to `internal/db/queries/` and regenerated via sqlc. Response DTOs follow a Ref/Detail split; lists use a generic `Page[T]` envelope; forward-reference stream/cover URLs are embedded now so Plan 3 can land transparently. Tiny conversion helpers are duplicated inline rather than shared with `internal/subsonic` — the two packages shape responses differently and the helpers are ~5 lines each.
|
||
|
||
**Tech Stack:** Go 1.23, chi/v5, pgx/v5, sqlc 1.27, pgxpool, pgtype.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-04-20-web-ui-library-reads-design.md`
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
New files:
|
||
|
||
| File | Responsibility |
|
||
|---|---|
|
||
| `internal/api/library.go` | Artist/album/track list + detail handlers |
|
||
| `internal/api/search.go` | Unified search handler |
|
||
| `internal/api/convert.go` | UUID↔string, year-from-pgdate, URL builders, dbq→ref projections |
|
||
| `internal/api/library_fixtures_test.go` | Shared seed helpers for integration tests |
|
||
| `internal/api/library_test.go` | Integration tests for library endpoints |
|
||
| `internal/api/search_test.go` | Integration tests for search |
|
||
|
||
Modified files:
|
||
|
||
| File | Change |
|
||
|---|---|
|
||
| `internal/api/api.go` | Register new routes inside the `authed` group |
|
||
| `internal/api/types.go` | Add `Page[T]`, `ArtistRef`, `AlbumRef`, `TrackRef`, `ArtistDetail`, `AlbumDetail`, `SearchResponse` |
|
||
| `internal/db/queries/artists.sql` | Add `ListArtistsAlpha`, `ListArtistsNewest`, `CountArtists`, `CountArtistsMatching` |
|
||
| `internal/db/queries/albums.sql` | Add `CountAlbumsMatching` |
|
||
| `internal/db/queries/tracks.sql` | Add `CountTracksMatching` |
|
||
| `internal/db/dbq/artists.sql.go`, `albums.sql.go`, `tracks.sql.go` | sqlc-regenerated; do not hand-edit |
|
||
|
||
---
|
||
|
||
## Working Conventions
|
||
|
||
- **Commit after each green test step.** One task = one commit minimum; split further if a task produces multiple logical units.
|
||
- **Integration tests require a live Postgres** at `MINSTREL_TEST_DATABASE_URL`. The existing `docker-compose.yml` spins one up; tests skip cleanly when the env var is unset.
|
||
- **sqlc regeneration:** run `make generate` after changing any `*.sql` file. Never hand-edit `internal/db/dbq/*.go`.
|
||
- **Run the full test suite before committing any task:** `MINSTREL_TEST_DATABASE_URL=postgres://... go test ./...`. Use `-short` only when explicitly noted.
|
||
- **`pgtype` imports:** add `"github.com/jackc/pgx/v5/pgtype"` only where needed; Go won't compile with unused imports.
|
||
|
||
---
|
||
|
||
## Task 1: Add SQL queries
|
||
|
||
**Files:**
|
||
- Modify: `internal/db/queries/artists.sql`
|
||
- Modify: `internal/db/queries/albums.sql`
|
||
- Modify: `internal/db/queries/tracks.sql`
|
||
- Regenerate: `internal/db/dbq/artists.sql.go`, `albums.sql.go`, `tracks.sql.go`
|
||
|
||
- [ ] **Step 1: Append to `internal/db/queries/artists.sql`**
|
||
|
||
Append these four queries (keep existing `ListArtists` untouched — subsonic's `buildArtistIndexes` still uses it):
|
||
|
||
```sql
|
||
|
||
-- name: ListArtistsAlpha :many
|
||
SELECT * FROM artists ORDER BY sort_name, name, id LIMIT $1 OFFSET $2;
|
||
|
||
-- name: ListArtistsNewest :many
|
||
-- Secondary id tiebreaker keeps pagination stable when created_at ties.
|
||
SELECT * FROM artists ORDER BY created_at DESC, id DESC LIMIT $1 OFFSET $2;
|
||
|
||
-- name: CountArtists :one
|
||
SELECT COUNT(*) FROM artists;
|
||
|
||
-- name: CountArtistsMatching :one
|
||
SELECT COUNT(*) FROM artists WHERE name ILIKE '%' || $1::text || '%';
|
||
```
|
||
|
||
- [ ] **Step 2: Append to `internal/db/queries/albums.sql`**
|
||
|
||
```sql
|
||
|
||
-- name: CountAlbumsMatching :one
|
||
SELECT COUNT(*) FROM albums WHERE title ILIKE '%' || $1::text || '%';
|
||
```
|
||
|
||
- [ ] **Step 3: Append to `internal/db/queries/tracks.sql`**
|
||
|
||
```sql
|
||
|
||
-- name: CountTracksMatching :one
|
||
SELECT COUNT(*) FROM tracks WHERE title ILIKE '%' || $1::text || '%';
|
||
```
|
||
|
||
- [ ] **Step 4: Regenerate sqlc code**
|
||
|
||
Run: `make generate`
|
||
Expected: no errors; `internal/db/dbq/artists.sql.go`, `albums.sql.go`, `tracks.sql.go` are updated with the new functions `ListArtistsAlpha`, `ListArtistsNewest`, `CountArtists`, `CountArtistsMatching`, `CountAlbumsMatching`, `CountTracksMatching`.
|
||
|
||
- [ ] **Step 5: Verify the package builds**
|
||
|
||
Run: `go build ./internal/db/...`
|
||
Expected: exit 0, no output.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add internal/db/queries/*.sql internal/db/dbq/*.sql.go
|
||
git commit -m "feat(db): add paged + count queries for library reads"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Add DTO types
|
||
|
||
**Files:**
|
||
- Modify: `internal/api/types.go`
|
||
|
||
- [ ] **Step 1: Extend `internal/api/types.go`**
|
||
|
||
Append to the existing `internal/api/types.go` (keep `LoginRequest`, `LoginResponse`, `UserView`):
|
||
|
||
```go
|
||
|
||
// Page is the generic pagination envelope used by list and search endpoints.
|
||
// Items is always non-nil at JSON encode time — handlers must initialize
|
||
// empty slices explicitly so absent results render as [] rather than null.
|
||
type Page[T any] struct {
|
||
Items []T `json:"items"`
|
||
Total int `json:"total"`
|
||
Limit int `json:"limit"`
|
||
Offset int `json:"offset"`
|
||
}
|
||
|
||
// ArtistRef is the lightweight artist shape used in lists and search results.
|
||
type ArtistRef struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
AlbumCount int `json:"album_count"`
|
||
}
|
||
|
||
// AlbumRef is the lightweight album shape used in lists, artist details,
|
||
// and search results. CoverURL points to /api/albums/{id}/cover which
|
||
// Plan 3 implements.
|
||
type AlbumRef struct {
|
||
ID string `json:"id"`
|
||
Title string `json:"title"`
|
||
ArtistID string `json:"artist_id"`
|
||
ArtistName string `json:"artist_name"`
|
||
Year int `json:"year,omitempty"`
|
||
TrackCount int `json:"track_count"`
|
||
DurationSec int `json:"duration_sec"`
|
||
CoverURL string `json:"cover_url"`
|
||
}
|
||
|
||
// TrackRef is the lightweight track shape used in album details and search.
|
||
// StreamURL points to /api/tracks/{id}/stream which Plan 3 implements.
|
||
type TrackRef struct {
|
||
ID string `json:"id"`
|
||
Title string `json:"title"`
|
||
AlbumID string `json:"album_id"`
|
||
AlbumTitle string `json:"album_title"`
|
||
ArtistID string `json:"artist_id"`
|
||
ArtistName string `json:"artist_name"`
|
||
TrackNumber int `json:"track_number,omitempty"`
|
||
DiscNumber int `json:"disc_number,omitempty"`
|
||
DurationSec int `json:"duration_sec"`
|
||
StreamURL string `json:"stream_url"`
|
||
}
|
||
|
||
// ArtistDetail is the response body of GET /api/artists/{id}. Embeds the ref
|
||
// so callers read id/name/album_count from the same path as list entries.
|
||
type ArtistDetail struct {
|
||
ArtistRef
|
||
Albums []AlbumRef `json:"albums"`
|
||
}
|
||
|
||
// AlbumDetail is the response body of GET /api/albums/{id}.
|
||
type AlbumDetail struct {
|
||
AlbumRef
|
||
Tracks []TrackRef `json:"tracks"`
|
||
}
|
||
|
||
// SearchResponse is the body of GET /api/search. Each facet carries its own
|
||
// total + limit + offset; the client sends one shared limit/offset and the
|
||
// server applies it uniformly to each facet's LIMIT/OFFSET query.
|
||
type SearchResponse struct {
|
||
Artists Page[ArtistRef] `json:"artists"`
|
||
Albums Page[AlbumRef] `json:"albums"`
|
||
Tracks Page[TrackRef] `json:"tracks"`
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify build**
|
||
|
||
Run: `go build ./internal/api/...`
|
||
Expected: exit 0.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add internal/api/types.go
|
||
git commit -m "feat(api): add DTOs for library reads and search"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Add conversion helpers
|
||
|
||
**Files:**
|
||
- Create: `internal/api/convert.go`
|
||
- Create: `internal/api/convert_test.go`
|
||
|
||
- [ ] **Step 1: Write failing test**
|
||
|
||
Create `internal/api/convert_test.go`:
|
||
|
||
```go
|
||
package api
|
||
|
||
import (
|
||
"testing"
|
||
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
)
|
||
|
||
func TestUUIDToString(t *testing.T) {
|
||
var u pgtype.UUID
|
||
if err := u.Scan("00112233-4455-6677-8899-aabbccddeeff"); err != nil {
|
||
t.Fatalf("scan: %v", err)
|
||
}
|
||
got := uuidToString(u)
|
||
want := "00112233-4455-6677-8899-aabbccddeeff"
|
||
if got != want {
|
||
t.Errorf("uuidToString = %q, want %q", got, want)
|
||
}
|
||
|
||
var zero pgtype.UUID
|
||
if s := uuidToString(zero); s != "" {
|
||
t.Errorf("uuidToString(invalid) = %q, want empty", s)
|
||
}
|
||
}
|
||
|
||
func TestParseUUID(t *testing.T) {
|
||
u, ok := parseUUID("00112233-4455-6677-8899-aabbccddeeff")
|
||
if !ok || !u.Valid {
|
||
t.Fatalf("parseUUID valid: ok=%v valid=%v", ok, u.Valid)
|
||
}
|
||
if _, ok := parseUUID("not-a-uuid"); ok {
|
||
t.Error("parseUUID accepted garbage")
|
||
}
|
||
if _, ok := parseUUID(""); ok {
|
||
t.Error("parseUUID accepted empty string")
|
||
}
|
||
}
|
||
|
||
func TestYearFromDate(t *testing.T) {
|
||
var d pgtype.Date
|
||
if y := yearFromDate(d); y != 0 {
|
||
t.Errorf("yearFromDate(invalid) = %d, want 0", y)
|
||
}
|
||
}
|
||
|
||
func TestDurationMsToSec(t *testing.T) {
|
||
cases := []struct {
|
||
ms int32
|
||
want int
|
||
}{
|
||
{0, 0},
|
||
{500, 1}, // rounds up
|
||
{499, 0}, // rounds down
|
||
{1500, 2}, // rounds up
|
||
{60000, 60},
|
||
}
|
||
for _, c := range cases {
|
||
if got := durationMsToSec(c.ms); got != c.want {
|
||
t.Errorf("durationMsToSec(%d) = %d, want %d", c.ms, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestCoverURLAndStreamURL(t *testing.T) {
|
||
var u pgtype.UUID
|
||
if err := u.Scan("00112233-4455-6677-8899-aabbccddeeff"); err != nil {
|
||
t.Fatalf("scan: %v", err)
|
||
}
|
||
if got := coverURL(u); got != "/api/albums/00112233-4455-6677-8899-aabbccddeeff/cover" {
|
||
t.Errorf("coverURL = %q", got)
|
||
}
|
||
if got := streamURL(u); got != "/api/tracks/00112233-4455-6677-8899-aabbccddeeff/stream" {
|
||
t.Errorf("streamURL = %q", got)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run — expect FAIL**
|
||
|
||
Run: `go test ./internal/api/ -run 'TestUUIDToString|TestParseUUID|TestYearFromDate|TestDurationMsToSec|TestCoverURLAndStreamURL' -count=1`
|
||
Expected: compile error — `undefined: uuidToString` etc.
|
||
|
||
- [ ] **Step 3: Create `internal/api/convert.go`**
|
||
|
||
```go
|
||
package api
|
||
|
||
import (
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
)
|
||
|
||
// uuidToString renders a pgtype.UUID as a canonical hyphenated string.
|
||
// Returns "" when the UUID is invalid (unset). Duplicated from the subsonic
|
||
// package intentionally — the two packages must not depend on each other.
|
||
func uuidToString(u pgtype.UUID) string {
|
||
if !u.Valid {
|
||
return ""
|
||
}
|
||
b := u.Bytes
|
||
const hex = "0123456789abcdef"
|
||
out := make([]byte, 36)
|
||
j := 0
|
||
for i, x := range b {
|
||
if i == 4 || i == 6 || i == 8 || i == 10 {
|
||
out[j] = '-'
|
||
j++
|
||
}
|
||
out[j] = hex[x>>4]
|
||
out[j+1] = hex[x&0x0f]
|
||
j += 2
|
||
}
|
||
return string(out)
|
||
}
|
||
|
||
// parseUUID accepts the canonical hyphenated form produced by uuidToString.
|
||
// Returns ok=false on any malformed input so handlers can 400 cleanly.
|
||
func parseUUID(s string) (pgtype.UUID, bool) {
|
||
var u pgtype.UUID
|
||
if err := u.Scan(s); err != nil {
|
||
return pgtype.UUID{}, false
|
||
}
|
||
return u, u.Valid
|
||
}
|
||
|
||
// yearFromDate returns the 4-digit year from a pgtype.Date, or 0 when the
|
||
// date is unset. Album release years are optional at scan time.
|
||
func yearFromDate(d pgtype.Date) int {
|
||
if !d.Valid {
|
||
return 0
|
||
}
|
||
return d.Time.Year()
|
||
}
|
||
|
||
// durationMsToSec converts millisecond durations (as stored in tracks.duration_ms)
|
||
// to seconds, rounding to nearest. Callers display seconds; sub-second precision
|
||
// is not useful in UI.
|
||
func durationMsToSec(ms int32) int {
|
||
return int((ms + 500) / 1000)
|
||
}
|
||
|
||
// coverURL returns the /api relative URL for an album's cover art. The
|
||
// endpoint ships in Plan 3; until then it 404s, and the SPA does not wire
|
||
// <img src> to it.
|
||
func coverURL(albumID pgtype.UUID) string {
|
||
return "/api/albums/" + uuidToString(albumID) + "/cover"
|
||
}
|
||
|
||
// streamURL returns the /api relative URL for a track's audio stream. The
|
||
// endpoint ships in Plan 3.
|
||
func streamURL(trackID pgtype.UUID) string {
|
||
return "/api/tracks/" + uuidToString(trackID) + "/stream"
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run — expect PASS**
|
||
|
||
Run: `go test ./internal/api/ -run 'TestUUIDToString|TestParseUUID|TestYearFromDate|TestDurationMsToSec|TestCoverURLAndStreamURL' -count=1 -v`
|
||
Expected: all five tests PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add internal/api/convert.go internal/api/convert_test.go
|
||
git commit -m "feat(api): add conversion helpers for library DTOs"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Add ref/detail projection helpers
|
||
|
||
These project dbq rows into the response DTOs. Keeping them next to the DTO types and conversion helpers means handlers stay thin.
|
||
|
||
**Files:**
|
||
- Modify: `internal/api/convert.go` (append)
|
||
- Modify: `internal/api/convert_test.go` (append)
|
||
|
||
- [ ] **Step 1: Append failing tests to `internal/api/convert_test.go`**
|
||
|
||
```go
|
||
|
||
func TestArtistRefFrom(t *testing.T) {
|
||
var id pgtype.UUID
|
||
_ = id.Scan("00112233-4455-6677-8899-aabbccddeeff")
|
||
a := dbq.Artist{ID: id, Name: "Beatles", SortName: "Beatles"}
|
||
got := artistRefFrom(a, 7)
|
||
if got.ID != "00112233-4455-6677-8899-aabbccddeeff" {
|
||
t.Errorf("ID = %q", got.ID)
|
||
}
|
||
if got.Name != "Beatles" || got.AlbumCount != 7 {
|
||
t.Errorf("ref = %+v", got)
|
||
}
|
||
}
|
||
|
||
func TestAlbumRefFrom(t *testing.T) {
|
||
var aid, artID pgtype.UUID
|
||
_ = aid.Scan("00000000-0000-0000-0000-000000000001")
|
||
_ = artID.Scan("00000000-0000-0000-0000-000000000002")
|
||
var rel pgtype.Date
|
||
_ = rel.Scan("1969-09-26")
|
||
a := dbq.Album{ID: aid, Title: "Abbey Road", ArtistID: artID, ReleaseDate: rel}
|
||
got := albumRefFrom(a, "Beatles", 17, 2820)
|
||
if got.Title != "Abbey Road" || got.ArtistName != "Beatles" {
|
||
t.Errorf("ref = %+v", got)
|
||
}
|
||
if got.TrackCount != 17 || got.DurationSec != 2820 {
|
||
t.Errorf("counts = %+v", got)
|
||
}
|
||
if got.Year != 1969 {
|
||
t.Errorf("year = %d", got.Year)
|
||
}
|
||
if got.CoverURL != "/api/albums/00000000-0000-0000-0000-000000000001/cover" {
|
||
t.Errorf("cover_url = %q", got.CoverURL)
|
||
}
|
||
}
|
||
|
||
func TestTrackRefFrom(t *testing.T) {
|
||
var tid, aid, artID pgtype.UUID
|
||
_ = tid.Scan("00000000-0000-0000-0000-000000000010")
|
||
_ = aid.Scan("00000000-0000-0000-0000-000000000001")
|
||
_ = artID.Scan("00000000-0000-0000-0000-000000000002")
|
||
trackNum := int32(3)
|
||
discNum := int32(1)
|
||
t2 := dbq.Track{
|
||
ID: tid, Title: "Something", AlbumID: aid, ArtistID: artID,
|
||
TrackNumber: &trackNum, DiscNumber: &discNum,
|
||
DurationMs: 183_000,
|
||
}
|
||
got := trackRefFrom(t2, "Abbey Road", "Beatles")
|
||
if got.Title != "Something" || got.AlbumTitle != "Abbey Road" || got.ArtistName != "Beatles" {
|
||
t.Errorf("ref = %+v", got)
|
||
}
|
||
if got.TrackNumber != 3 || got.DiscNumber != 1 {
|
||
t.Errorf("positions = %+v", got)
|
||
}
|
||
if got.DurationSec != 183 {
|
||
t.Errorf("duration = %d, want 183", got.DurationSec)
|
||
}
|
||
if got.StreamURL != "/api/tracks/00000000-0000-0000-0000-000000000010/stream" {
|
||
t.Errorf("stream_url = %q", got.StreamURL)
|
||
}
|
||
}
|
||
|
||
func TestTrackRefFromNilPositions(t *testing.T) {
|
||
var tid, aid, artID pgtype.UUID
|
||
_ = tid.Scan("00000000-0000-0000-0000-000000000010")
|
||
_ = aid.Scan("00000000-0000-0000-0000-000000000001")
|
||
_ = artID.Scan("00000000-0000-0000-0000-000000000002")
|
||
t2 := dbq.Track{ID: tid, AlbumID: aid, ArtistID: artID, DurationMs: 1000}
|
||
got := trackRefFrom(t2, "A", "B")
|
||
if got.TrackNumber != 0 || got.DiscNumber != 0 {
|
||
t.Errorf("nil positions should be 0, got %+v", got)
|
||
}
|
||
}
|
||
```
|
||
|
||
Add these imports to `internal/api/convert_test.go` (or merge into existing import block):
|
||
|
||
```go
|
||
import (
|
||
"testing"
|
||
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 2: Run — expect FAIL**
|
||
|
||
Run: `go test ./internal/api/ -run 'TestArtistRefFrom|TestAlbumRefFrom|TestTrackRefFrom' -count=1`
|
||
Expected: compile error — `undefined: artistRefFrom` etc.
|
||
|
||
- [ ] **Step 3: Append to `internal/api/convert.go`**
|
||
|
||
Extend the existing import block to include `dbq`:
|
||
|
||
```go
|
||
import (
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||
)
|
||
```
|
||
|
||
Append to the file:
|
||
|
||
```go
|
||
|
||
// artistRefFrom projects a dbq.Artist into an ArtistRef. albumCount must be
|
||
// pre-computed by the caller (one query per artist in lists).
|
||
func artistRefFrom(a dbq.Artist, albumCount int) ArtistRef {
|
||
return ArtistRef{
|
||
ID: uuidToString(a.ID),
|
||
Name: a.Name,
|
||
AlbumCount: albumCount,
|
||
}
|
||
}
|
||
|
||
// albumRefFrom projects a dbq.Album into an AlbumRef. artistName must be
|
||
// pre-resolved because Album only carries artist_id. trackCount and
|
||
// durationSec may be 0 when the caller does not compute them.
|
||
func albumRefFrom(a dbq.Album, artistName string, trackCount, durationSec int) AlbumRef {
|
||
return AlbumRef{
|
||
ID: uuidToString(a.ID),
|
||
Title: a.Title,
|
||
ArtistID: uuidToString(a.ArtistID),
|
||
ArtistName: artistName,
|
||
Year: yearFromDate(a.ReleaseDate),
|
||
TrackCount: trackCount,
|
||
DurationSec: durationSec,
|
||
CoverURL: coverURL(a.ID),
|
||
}
|
||
}
|
||
|
||
// trackRefFrom projects a dbq.Track into a TrackRef. Parent names must be
|
||
// pre-resolved because Track only carries album_id and artist_id.
|
||
func trackRefFrom(t dbq.Track, albumTitle, artistName string) TrackRef {
|
||
ref := TrackRef{
|
||
ID: uuidToString(t.ID),
|
||
Title: t.Title,
|
||
AlbumID: uuidToString(t.AlbumID),
|
||
AlbumTitle: albumTitle,
|
||
ArtistID: uuidToString(t.ArtistID),
|
||
ArtistName: artistName,
|
||
DurationSec: durationMsToSec(t.DurationMs),
|
||
StreamURL: streamURL(t.ID),
|
||
}
|
||
if t.TrackNumber != nil {
|
||
ref.TrackNumber = int(*t.TrackNumber)
|
||
}
|
||
if t.DiscNumber != nil {
|
||
ref.DiscNumber = int(*t.DiscNumber)
|
||
}
|
||
return ref
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run — expect PASS**
|
||
|
||
Run: `go test ./internal/api/ -run 'TestArtistRefFrom|TestAlbumRefFrom|TestTrackRefFrom' -count=1 -v`
|
||
Expected: four tests PASS.
|
||
|
||
- [ ] **Step 5: Full package builds clean**
|
||
|
||
Run: `go build ./internal/api/...`
|
||
Expected: exit 0, no output.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add internal/api/convert.go internal/api/convert_test.go
|
||
git commit -m "feat(api): add dbq→ref projection helpers"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Test fixtures helper
|
||
|
||
Shared seed helpers that library_test.go and search_test.go both use. Putting them in `library_fixtures_test.go` means they compile only in the test binary and don't pollute the production package.
|
||
|
||
**Files:**
|
||
- Create: `internal/api/library_fixtures_test.go`
|
||
|
||
- [ ] **Step 1: Create `internal/api/library_fixtures_test.go`**
|
||
|
||
```go
|
||
package api
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
"github.com/jackc/pgx/v5/pgxpool"
|
||
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||
)
|
||
|
||
// truncateLibrary clears the music tables between tests. Sessions/users are
|
||
// cleared by testHandlers. CASCADE covers tracks→albums→artists FK chain.
|
||
func truncateLibrary(t *testing.T, pool *pgxpool.Pool) {
|
||
t.Helper()
|
||
if _, err := pool.Exec(context.Background(),
|
||
"TRUNCATE tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil {
|
||
t.Fatalf("truncate: %v", err)
|
||
}
|
||
}
|
||
|
||
// seedArtist inserts an artist with deterministic sort_name = name.
|
||
func seedArtist(t *testing.T, pool *pgxpool.Pool, name string) dbq.Artist {
|
||
t.Helper()
|
||
a, err := dbq.New(pool).UpsertArtist(context.Background(), dbq.UpsertArtistParams{
|
||
Name: name,
|
||
SortName: name,
|
||
Mbid: nil,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("UpsertArtist(%s): %v", name, err)
|
||
}
|
||
return a
|
||
}
|
||
|
||
// seedAlbum inserts an album belonging to artistID. releaseYear=0 leaves
|
||
// release_date unset.
|
||
func seedAlbum(t *testing.T, pool *pgxpool.Pool, artistID pgtype.UUID, title string, releaseYear int) dbq.Album {
|
||
t.Helper()
|
||
var rel pgtype.Date
|
||
if releaseYear > 0 {
|
||
rel = pgtype.Date{Time: time.Date(releaseYear, 1, 1, 0, 0, 0, 0, time.UTC), Valid: true}
|
||
}
|
||
a, err := dbq.New(pool).UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
|
||
Title: title,
|
||
SortTitle: title,
|
||
ArtistID: artistID,
|
||
ReleaseDate: rel,
|
||
Mbid: nil,
|
||
CoverArtPath: nil,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("UpsertAlbum(%s): %v", title, err)
|
||
}
|
||
return a
|
||
}
|
||
|
||
// seedTrack inserts a track. trackNumber<=0 leaves it NULL. filePath is
|
||
// synthesized from title to stay unique across seeds.
|
||
func seedTrack(t *testing.T, pool *pgxpool.Pool, albumID, artistID pgtype.UUID, title string, trackNumber int, durationMs int32) dbq.Track {
|
||
t.Helper()
|
||
var tn *int32
|
||
if trackNumber > 0 {
|
||
v := int32(trackNumber)
|
||
tn = &v
|
||
}
|
||
tr, err := dbq.New(pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{
|
||
Title: title,
|
||
AlbumID: albumID,
|
||
ArtistID: artistID,
|
||
TrackNumber: tn,
|
||
DiscNumber: nil,
|
||
DurationMs: durationMs,
|
||
FilePath: "/seed/" + title + ".flac",
|
||
FileSize: 1024,
|
||
FileFormat: "flac",
|
||
Bitrate: nil,
|
||
Mbid: nil,
|
||
Genre: nil,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("UpsertTrack(%s): %v", title, err)
|
||
}
|
||
return tr
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify test binary compiles**
|
||
|
||
Run: `go test -run '^$' ./internal/api/... -count=1`
|
||
Expected: no output and exit 0 (no tests match `^$`, but the package must compile).
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add internal/api/library_fixtures_test.go
|
||
git commit -m "test(api): add library seed fixtures"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Implement GET /api/tracks/{id}
|
||
|
||
Starting with the simplest detail endpoint so the route-wiring pattern is in place before the more complex ones. TDD: write tests, verify fail, implement.
|
||
|
||
**Files:**
|
||
- Create: `internal/api/library.go`
|
||
- Create: `internal/api/library_test.go`
|
||
- Modify: `internal/api/api.go`
|
||
|
||
- [ ] **Step 1: Write failing test in `internal/api/library_test.go`**
|
||
|
||
```go
|
||
package api
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"testing"
|
||
|
||
"github.com/go-chi/chi/v5"
|
||
)
|
||
|
||
// newLibraryRouter builds a test-only chi router with the library handlers
|
||
// mounted at their path-style routes. Tests hit this router rather than
|
||
// calling handler methods directly so chi URL params populate correctly.
|
||
// RequireUser is NOT applied — library handlers don't read user context.
|
||
func newLibraryRouter(h *handlers) chi.Router {
|
||
r := chi.NewRouter()
|
||
r.Get("/api/artists", h.handleListArtists)
|
||
r.Get("/api/artists/{id}", h.handleGetArtist)
|
||
r.Get("/api/albums/{id}", h.handleGetAlbum)
|
||
r.Get("/api/tracks/{id}", h.handleGetTrack)
|
||
r.Get("/api/search", h.handleSearch)
|
||
return r
|
||
}
|
||
|
||
func TestHandleGetTrack_HappyPath(t *testing.T) {
|
||
h, pool := testHandlers(t)
|
||
truncateLibrary(t, pool)
|
||
artist := seedArtist(t, pool, "Beatles")
|
||
album := seedAlbum(t, pool, artist.ID, "Abbey Road", 1969)
|
||
track := seedTrack(t, pool, album.ID, artist.ID, "Something", 3, 183_000)
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/api/tracks/"+uuidToString(track.ID), nil)
|
||
w := httptest.NewRecorder()
|
||
newLibraryRouter(h).ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
|
||
}
|
||
var got TrackRef
|
||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||
t.Fatalf("decode: %v body=%s", err, w.Body.String())
|
||
}
|
||
if got.Title != "Something" || got.AlbumTitle != "Abbey Road" || got.ArtistName != "Beatles" {
|
||
t.Errorf("ref = %+v", got)
|
||
}
|
||
if got.TrackNumber != 3 || got.DurationSec != 183 {
|
||
t.Errorf("positions = %+v", got)
|
||
}
|
||
want := "/api/tracks/" + uuidToString(track.ID) + "/stream"
|
||
if got.StreamURL != want {
|
||
t.Errorf("stream_url = %q, want %q", got.StreamURL, want)
|
||
}
|
||
}
|
||
|
||
func TestHandleGetTrack_BadUUID(t *testing.T) {
|
||
h, _ := testHandlers(t)
|
||
req := httptest.NewRequest(http.MethodGet, "/api/tracks/not-a-uuid", nil)
|
||
w := httptest.NewRecorder()
|
||
newLibraryRouter(h).ServeHTTP(w, req)
|
||
if w.Code != http.StatusBadRequest {
|
||
t.Errorf("status = %d, want 400", w.Code)
|
||
}
|
||
}
|
||
|
||
func TestHandleGetTrack_NotFound(t *testing.T) {
|
||
h, pool := testHandlers(t)
|
||
truncateLibrary(t, pool)
|
||
req := httptest.NewRequest(http.MethodGet,
|
||
"/api/tracks/00000000-0000-0000-0000-000000000001", nil)
|
||
w := httptest.NewRecorder()
|
||
newLibraryRouter(h).ServeHTTP(w, req)
|
||
if w.Code != http.StatusNotFound {
|
||
t.Errorf("status = %d, want 404", w.Code)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run — expect FAIL**
|
||
|
||
Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -run 'TestHandleGetTrack' -count=1`
|
||
Expected: compile error — `h.handleGetTrack` undefined, plus sibling methods referenced in `newLibraryRouter`.
|
||
|
||
- [ ] **Step 3: Create `internal/api/library.go` with all route method stubs + `handleGetTrack` implemented**
|
||
|
||
Stubbing sibling methods (`handleListArtists`, `handleGetArtist`, `handleGetAlbum`, `handleSearch`) lets `newLibraryRouter` compile even though subsequent tasks flesh them out.
|
||
|
||
```go
|
||
package api
|
||
|
||
import (
|
||
"errors"
|
||
"net/http"
|
||
|
||
"github.com/go-chi/chi/v5"
|
||
"github.com/jackc/pgx/v5"
|
||
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||
)
|
||
|
||
// handleGetTrack implements GET /api/tracks/{id}. Resolves parent album +
|
||
// artist names so the response is self-contained — clients should not need
|
||
// a follow-up request to render a track outside an album view.
|
||
func (h *handlers) handleGetTrack(w http.ResponseWriter, r *http.Request) {
|
||
id, ok := parseUUID(chi.URLParam(r, "id"))
|
||
if !ok {
|
||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid track id")
|
||
return
|
||
}
|
||
q := dbq.New(h.pool)
|
||
track, err := q.GetTrackByID(r.Context(), id)
|
||
if err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
writeErr(w, http.StatusNotFound, "not_found", "track not found")
|
||
return
|
||
}
|
||
h.logger.Error("api: get track failed", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
||
return
|
||
}
|
||
album, err := q.GetAlbumByID(r.Context(), track.AlbumID)
|
||
if err != nil {
|
||
h.logger.Error("api: get track album failed", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
||
return
|
||
}
|
||
artist, err := q.GetArtistByID(r.Context(), track.ArtistID)
|
||
if err != nil {
|
||
h.logger.Error("api: get track artist failed", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, trackRefFrom(track, album.Title, artist.Name))
|
||
}
|
||
|
||
// handleGetAlbum is implemented in Task 7.
|
||
func (h *handlers) handleGetAlbum(w http.ResponseWriter, r *http.Request) {
|
||
writeErr(w, http.StatusNotImplemented, "not_implemented", "stub")
|
||
}
|
||
|
||
// handleGetArtist is implemented in Task 8.
|
||
func (h *handlers) handleGetArtist(w http.ResponseWriter, r *http.Request) {
|
||
writeErr(w, http.StatusNotImplemented, "not_implemented", "stub")
|
||
}
|
||
|
||
// handleListArtists is implemented in Task 9.
|
||
func (h *handlers) handleListArtists(w http.ResponseWriter, r *http.Request) {
|
||
writeErr(w, http.StatusNotImplemented, "not_implemented", "stub")
|
||
}
|
||
|
||
// handleSearch is implemented in Task 10 (file: search.go).
|
||
func (h *handlers) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||
writeErr(w, http.StatusNotImplemented, "not_implemented", "stub")
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Add `writeJSON` helper to `internal/api/errors.go`**
|
||
|
||
Open `internal/api/errors.go` and append:
|
||
|
||
```go
|
||
|
||
// writeJSON emits a 2xx response with the given body JSON-encoded. Mirrors
|
||
// writeErr's shape so handlers have one shape for success and one for
|
||
// failure.
|
||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(status)
|
||
_ = json.NewEncoder(w).Encode(body)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run — expect PASS**
|
||
|
||
Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -run 'TestHandleGetTrack' -count=1 -v`
|
||
Expected: three tests PASS (`_HappyPath`, `_BadUUID`, `_NotFound`).
|
||
|
||
- [ ] **Step 6: Full test suite stays green**
|
||
|
||
Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -count=1`
|
||
Expected: all existing and new tests PASS.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add internal/api/library.go internal/api/library_test.go internal/api/errors.go
|
||
git commit -m "feat(api): GET /api/tracks/{id} handler with parent metadata"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Implement GET /api/albums/{id}
|
||
|
||
**Files:**
|
||
- Modify: `internal/api/library.go` (replace the `handleGetAlbum` stub)
|
||
- Modify: `internal/api/library_test.go` (append tests)
|
||
|
||
- [ ] **Step 1: Append failing tests to `internal/api/library_test.go`**
|
||
|
||
```go
|
||
|
||
func TestHandleGetAlbum_HappyPath(t *testing.T) {
|
||
h, pool := testHandlers(t)
|
||
truncateLibrary(t, pool)
|
||
artist := seedArtist(t, pool, "Beatles")
|
||
album := seedAlbum(t, pool, artist.ID, "Abbey Road", 1969)
|
||
seedTrack(t, pool, album.ID, artist.ID, "Come Together", 1, 259_000)
|
||
seedTrack(t, pool, album.ID, artist.ID, "Something", 2, 183_000)
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/api/albums/"+uuidToString(album.ID), nil)
|
||
w := httptest.NewRecorder()
|
||
newLibraryRouter(h).ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
|
||
}
|
||
var got AlbumDetail
|
||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||
t.Fatalf("decode: %v body=%s", err, w.Body.String())
|
||
}
|
||
if got.Title != "Abbey Road" || got.ArtistName != "Beatles" || got.Year != 1969 {
|
||
t.Errorf("album = %+v", got)
|
||
}
|
||
if len(got.Tracks) != 2 {
|
||
t.Fatalf("tracks len = %d, want 2", len(got.Tracks))
|
||
}
|
||
if got.Tracks[0].Title != "Come Together" || got.Tracks[1].Title != "Something" {
|
||
t.Errorf("tracks = %+v", got.Tracks)
|
||
}
|
||
if got.TrackCount != 2 || got.DurationSec != 442 { // 259 + 183
|
||
t.Errorf("counts = track=%d duration=%d", got.TrackCount, got.DurationSec)
|
||
}
|
||
want := "/api/albums/" + uuidToString(album.ID) + "/cover"
|
||
if got.CoverURL != want {
|
||
t.Errorf("cover_url = %q", got.CoverURL)
|
||
}
|
||
// StreamURL on nested tracks must be set
|
||
if got.Tracks[0].StreamURL == "" {
|
||
t.Error("nested track missing stream_url")
|
||
}
|
||
}
|
||
|
||
func TestHandleGetAlbum_NoTracks(t *testing.T) {
|
||
h, pool := testHandlers(t)
|
||
truncateLibrary(t, pool)
|
||
artist := seedArtist(t, pool, "Beatles")
|
||
album := seedAlbum(t, pool, artist.ID, "Empty", 0)
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/api/albums/"+uuidToString(album.ID), nil)
|
||
w := httptest.NewRecorder()
|
||
newLibraryRouter(h).ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
|
||
}
|
||
var got AlbumDetail
|
||
_ = json.Unmarshal(w.Body.Bytes(), &got)
|
||
if got.Tracks == nil {
|
||
t.Error("tracks = nil, want [] for JSON encoding")
|
||
}
|
||
if len(got.Tracks) != 0 {
|
||
t.Errorf("tracks len = %d, want 0", len(got.Tracks))
|
||
}
|
||
// Year omitempty: releaseYear=0 => Year=0 => field should be absent from JSON
|
||
if got.Year != 0 {
|
||
t.Errorf("year = %d, want 0", got.Year)
|
||
}
|
||
}
|
||
|
||
func TestHandleGetAlbum_BadUUID(t *testing.T) {
|
||
h, _ := testHandlers(t)
|
||
req := httptest.NewRequest(http.MethodGet, "/api/albums/not-a-uuid", nil)
|
||
w := httptest.NewRecorder()
|
||
newLibraryRouter(h).ServeHTTP(w, req)
|
||
if w.Code != http.StatusBadRequest {
|
||
t.Errorf("status = %d, want 400", w.Code)
|
||
}
|
||
}
|
||
|
||
func TestHandleGetAlbum_NotFound(t *testing.T) {
|
||
h, pool := testHandlers(t)
|
||
truncateLibrary(t, pool)
|
||
req := httptest.NewRequest(http.MethodGet,
|
||
"/api/albums/00000000-0000-0000-0000-000000000001", nil)
|
||
w := httptest.NewRecorder()
|
||
newLibraryRouter(h).ServeHTTP(w, req)
|
||
if w.Code != http.StatusNotFound {
|
||
t.Errorf("status = %d, want 404", w.Code)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run — expect FAIL (stub returns 501)**
|
||
|
||
Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -run 'TestHandleGetAlbum' -count=1`
|
||
Expected: all four fail because the stub returns 501.
|
||
|
||
- [ ] **Step 3: Replace `handleGetAlbum` stub in `internal/api/library.go`**
|
||
|
||
Find the stub:
|
||
|
||
```go
|
||
// handleGetAlbum is implemented in Task 7.
|
||
func (h *handlers) handleGetAlbum(w http.ResponseWriter, r *http.Request) {
|
||
writeErr(w, http.StatusNotImplemented, "not_implemented", "stub")
|
||
}
|
||
```
|
||
|
||
Replace with:
|
||
|
||
```go
|
||
// handleGetAlbum implements GET /api/albums/{id}. Returns the album plus its
|
||
// tracks (ordered by disc/track number via the underlying query) with
|
||
// duration summed from the track list — keeps one source of truth.
|
||
func (h *handlers) handleGetAlbum(w http.ResponseWriter, r *http.Request) {
|
||
id, ok := parseUUID(chi.URLParam(r, "id"))
|
||
if !ok {
|
||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid album id")
|
||
return
|
||
}
|
||
q := dbq.New(h.pool)
|
||
album, err := q.GetAlbumByID(r.Context(), id)
|
||
if err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
writeErr(w, http.StatusNotFound, "not_found", "album not found")
|
||
return
|
||
}
|
||
h.logger.Error("api: get album failed", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
||
return
|
||
}
|
||
artist, err := q.GetArtistByID(r.Context(), album.ArtistID)
|
||
if err != nil {
|
||
h.logger.Error("api: get album artist failed", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
||
return
|
||
}
|
||
tracks, err := q.ListTracksByAlbum(r.Context(), id)
|
||
if err != nil {
|
||
h.logger.Error("api: list tracks failed", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
||
return
|
||
}
|
||
refs := make([]TrackRef, 0, len(tracks))
|
||
durSec := 0
|
||
for _, t := range tracks {
|
||
ref := trackRefFrom(t, album.Title, artist.Name)
|
||
refs = append(refs, ref)
|
||
durSec += ref.DurationSec
|
||
}
|
||
detail := AlbumDetail{
|
||
AlbumRef: albumRefFrom(album, artist.Name, len(tracks), durSec),
|
||
Tracks: refs,
|
||
}
|
||
writeJSON(w, http.StatusOK, detail)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run — expect PASS**
|
||
|
||
Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -run 'TestHandleGetAlbum' -count=1 -v`
|
||
Expected: four tests PASS.
|
||
|
||
- [ ] **Step 5: Full api suite stays green**
|
||
|
||
Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -count=1`
|
||
Expected: all tests PASS.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add internal/api/library.go internal/api/library_test.go
|
||
git commit -m "feat(api): GET /api/albums/{id} with nested tracks"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Implement GET /api/artists/{id}
|
||
|
||
**Files:**
|
||
- Modify: `internal/api/library.go` (replace the `handleGetArtist` stub)
|
||
- Modify: `internal/api/library_test.go` (append tests)
|
||
|
||
- [ ] **Step 1: Append failing tests to `internal/api/library_test.go`**
|
||
|
||
```go
|
||
|
||
func TestHandleGetArtist_HappyPath(t *testing.T) {
|
||
h, pool := testHandlers(t)
|
||
truncateLibrary(t, pool)
|
||
artist := seedArtist(t, pool, "Beatles")
|
||
album1 := seedAlbum(t, pool, artist.ID, "Abbey Road", 1969)
|
||
album2 := seedAlbum(t, pool, artist.ID, "Let It Be", 1970)
|
||
seedTrack(t, pool, album1.ID, artist.ID, "Something", 3, 183_000)
|
||
seedTrack(t, pool, album2.ID, artist.ID, "Get Back", 1, 189_000)
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/api/artists/"+uuidToString(artist.ID), nil)
|
||
w := httptest.NewRecorder()
|
||
newLibraryRouter(h).ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
|
||
}
|
||
var got ArtistDetail
|
||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||
t.Fatalf("decode: %v body=%s", err, w.Body.String())
|
||
}
|
||
if got.Name != "Beatles" {
|
||
t.Errorf("name = %q", got.Name)
|
||
}
|
||
if got.AlbumCount != 2 {
|
||
t.Errorf("album_count = %d, want 2", got.AlbumCount)
|
||
}
|
||
if len(got.Albums) != 2 {
|
||
t.Fatalf("albums len = %d, want 2", len(got.Albums))
|
||
}
|
||
// ListAlbumsByArtist orders by release_date then sort_title; Abbey Road (1969) first.
|
||
if got.Albums[0].Title != "Abbey Road" {
|
||
t.Errorf("first album = %q, want Abbey Road", got.Albums[0].Title)
|
||
}
|
||
for _, a := range got.Albums {
|
||
if a.ArtistName != "Beatles" {
|
||
t.Errorf("album.artist_name = %q", a.ArtistName)
|
||
}
|
||
if a.CoverURL == "" {
|
||
t.Error("album missing cover_url")
|
||
}
|
||
if a.TrackCount != 1 {
|
||
t.Errorf("album.track_count = %d, want 1", a.TrackCount)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestHandleGetArtist_NoAlbums(t *testing.T) {
|
||
h, pool := testHandlers(t)
|
||
truncateLibrary(t, pool)
|
||
artist := seedArtist(t, pool, "Solo")
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/api/artists/"+uuidToString(artist.ID), nil)
|
||
w := httptest.NewRecorder()
|
||
newLibraryRouter(h).ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("status = %d", w.Code)
|
||
}
|
||
var got ArtistDetail
|
||
_ = json.Unmarshal(w.Body.Bytes(), &got)
|
||
if got.Albums == nil {
|
||
t.Error("albums = nil, want []")
|
||
}
|
||
if got.AlbumCount != 0 {
|
||
t.Errorf("album_count = %d, want 0", got.AlbumCount)
|
||
}
|
||
}
|
||
|
||
func TestHandleGetArtist_BadUUID(t *testing.T) {
|
||
h, _ := testHandlers(t)
|
||
req := httptest.NewRequest(http.MethodGet, "/api/artists/not-a-uuid", nil)
|
||
w := httptest.NewRecorder()
|
||
newLibraryRouter(h).ServeHTTP(w, req)
|
||
if w.Code != http.StatusBadRequest {
|
||
t.Errorf("status = %d, want 400", w.Code)
|
||
}
|
||
}
|
||
|
||
func TestHandleGetArtist_NotFound(t *testing.T) {
|
||
h, pool := testHandlers(t)
|
||
truncateLibrary(t, pool)
|
||
req := httptest.NewRequest(http.MethodGet,
|
||
"/api/artists/00000000-0000-0000-0000-000000000001", nil)
|
||
w := httptest.NewRecorder()
|
||
newLibraryRouter(h).ServeHTTP(w, req)
|
||
if w.Code != http.StatusNotFound {
|
||
t.Errorf("status = %d, want 404", w.Code)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run — expect FAIL (stub 501)**
|
||
|
||
Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -run 'TestHandleGetArtist' -count=1`
|
||
Expected: four fail against the stub.
|
||
|
||
- [ ] **Step 3: Replace `handleGetArtist` stub in `internal/api/library.go`**
|
||
|
||
Replace with:
|
||
|
||
```go
|
||
// handleGetArtist implements GET /api/artists/{id}. Returns artist + albums;
|
||
// each album carries its own track_count (one count query per album, same
|
||
// pattern as subsonic). At M1 sizes (albums-per-artist << 100) this is fine.
|
||
func (h *handlers) handleGetArtist(w http.ResponseWriter, r *http.Request) {
|
||
id, ok := parseUUID(chi.URLParam(r, "id"))
|
||
if !ok {
|
||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid artist id")
|
||
return
|
||
}
|
||
q := dbq.New(h.pool)
|
||
artist, err := q.GetArtistByID(r.Context(), id)
|
||
if err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
writeErr(w, http.StatusNotFound, "not_found", "artist not found")
|
||
return
|
||
}
|
||
h.logger.Error("api: get artist failed", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
||
return
|
||
}
|
||
albums, err := q.ListAlbumsByArtist(r.Context(), id)
|
||
if err != nil {
|
||
h.logger.Error("api: list albums by artist failed", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
||
return
|
||
}
|
||
refs := make([]AlbumRef, 0, len(albums))
|
||
for _, a := range albums {
|
||
count, cerr := q.CountTracksByAlbum(r.Context(), a.ID)
|
||
if cerr != nil {
|
||
h.logger.Error("api: count tracks failed", "err", cerr)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
||
return
|
||
}
|
||
refs = append(refs, albumRefFrom(a, artist.Name, int(count), 0))
|
||
}
|
||
detail := ArtistDetail{
|
||
ArtistRef: artistRefFrom(artist, len(albums)),
|
||
Albums: refs,
|
||
}
|
||
writeJSON(w, http.StatusOK, detail)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run — expect PASS**
|
||
|
||
Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -run 'TestHandleGetArtist' -count=1 -v`
|
||
Expected: four tests PASS.
|
||
|
||
- [ ] **Step 5: Full api suite stays green**
|
||
|
||
Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -count=1`
|
||
Expected: all PASS.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add internal/api/library.go internal/api/library_test.go
|
||
git commit -m "feat(api): GET /api/artists/{id} with nested albums"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Implement GET /api/artists (paged list with sort)
|
||
|
||
**Files:**
|
||
- Modify: `internal/api/library.go` (replace the `handleListArtists` stub; add pagination helper)
|
||
- Modify: `internal/api/library_test.go` (append tests)
|
||
|
||
- [ ] **Step 1: Append failing tests to `internal/api/library_test.go`**
|
||
|
||
```go
|
||
|
||
func TestHandleListArtists_DefaultAlpha(t *testing.T) {
|
||
h, pool := testHandlers(t)
|
||
truncateLibrary(t, pool)
|
||
seedArtist(t, pool, "Beatles")
|
||
seedArtist(t, pool, "ABBA")
|
||
seedArtist(t, pool, "Zeppelin")
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/api/artists", nil)
|
||
w := httptest.NewRecorder()
|
||
newLibraryRouter(h).ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
|
||
}
|
||
var got Page[ArtistRef]
|
||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||
t.Fatalf("decode: %v", err)
|
||
}
|
||
if got.Total != 3 {
|
||
t.Errorf("total = %d, want 3", got.Total)
|
||
}
|
||
if got.Limit != 50 || got.Offset != 0 {
|
||
t.Errorf("pagination defaults = limit=%d offset=%d, want 50/0", got.Limit, got.Offset)
|
||
}
|
||
if len(got.Items) != 3 {
|
||
t.Fatalf("items len = %d", len(got.Items))
|
||
}
|
||
if got.Items[0].Name != "ABBA" || got.Items[2].Name != "Zeppelin" {
|
||
t.Errorf("alpha order wrong: %q, %q, %q", got.Items[0].Name, got.Items[1].Name, got.Items[2].Name)
|
||
}
|
||
}
|
||
|
||
func TestHandleListArtists_SortNewest(t *testing.T) {
|
||
h, pool := testHandlers(t)
|
||
truncateLibrary(t, pool)
|
||
// Seeded in this order; created_at DESC means Third first.
|
||
seedArtist(t, pool, "First")
|
||
seedArtist(t, pool, "Second")
|
||
seedArtist(t, pool, "Third")
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/api/artists?sort=newest", nil)
|
||
w := httptest.NewRecorder()
|
||
newLibraryRouter(h).ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("status = %d", w.Code)
|
||
}
|
||
var got Page[ArtistRef]
|
||
_ = json.Unmarshal(w.Body.Bytes(), &got)
|
||
if len(got.Items) != 3 {
|
||
t.Fatalf("items len = %d", len(got.Items))
|
||
}
|
||
if got.Items[0].Name != "Third" || got.Items[2].Name != "First" {
|
||
t.Errorf("newest order wrong: %q, %q, %q", got.Items[0].Name, got.Items[1].Name, got.Items[2].Name)
|
||
}
|
||
}
|
||
|
||
func TestHandleListArtists_InvalidSort(t *testing.T) {
|
||
h, pool := testHandlers(t)
|
||
truncateLibrary(t, pool)
|
||
seedArtist(t, pool, "Only")
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/api/artists?sort=garbage", nil)
|
||
w := httptest.NewRecorder()
|
||
newLibraryRouter(h).ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusBadRequest {
|
||
t.Errorf("status = %d, want 400", w.Code)
|
||
}
|
||
}
|
||
|
||
func TestHandleListArtists_Pagination(t *testing.T) {
|
||
h, pool := testHandlers(t)
|
||
truncateLibrary(t, pool)
|
||
for _, n := range []string{"A", "B", "C", "D", "E"} {
|
||
seedArtist(t, pool, n)
|
||
}
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/api/artists?limit=2&offset=2", nil)
|
||
w := httptest.NewRecorder()
|
||
newLibraryRouter(h).ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("status = %d", w.Code)
|
||
}
|
||
var got Page[ArtistRef]
|
||
_ = json.Unmarshal(w.Body.Bytes(), &got)
|
||
if got.Total != 5 {
|
||
t.Errorf("total = %d, want 5", got.Total)
|
||
}
|
||
if got.Limit != 2 || got.Offset != 2 {
|
||
t.Errorf("page = limit=%d offset=%d", got.Limit, got.Offset)
|
||
}
|
||
if len(got.Items) != 2 {
|
||
t.Fatalf("items len = %d", len(got.Items))
|
||
}
|
||
if got.Items[0].Name != "C" || got.Items[1].Name != "D" {
|
||
t.Errorf("slice = %q, %q", got.Items[0].Name, got.Items[1].Name)
|
||
}
|
||
}
|
||
|
||
func TestHandleListArtists_LimitClamped(t *testing.T) {
|
||
h, pool := testHandlers(t)
|
||
truncateLibrary(t, pool)
|
||
seedArtist(t, pool, "One")
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/api/artists?limit=9999", nil)
|
||
w := httptest.NewRecorder()
|
||
newLibraryRouter(h).ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("status = %d", w.Code)
|
||
}
|
||
var got Page[ArtistRef]
|
||
_ = json.Unmarshal(w.Body.Bytes(), &got)
|
||
if got.Limit != 200 {
|
||
t.Errorf("limit = %d, want 200 (max clamp)", got.Limit)
|
||
}
|
||
}
|
||
|
||
func TestHandleListArtists_LimitNonNumeric(t *testing.T) {
|
||
h, _ := testHandlers(t)
|
||
req := httptest.NewRequest(http.MethodGet, "/api/artists?limit=abc", nil)
|
||
w := httptest.NewRecorder()
|
||
newLibraryRouter(h).ServeHTTP(w, req)
|
||
if w.Code != http.StatusBadRequest {
|
||
t.Errorf("status = %d, want 400", w.Code)
|
||
}
|
||
}
|
||
|
||
func TestHandleListArtists_AlbumCount(t *testing.T) {
|
||
h, pool := testHandlers(t)
|
||
truncateLibrary(t, pool)
|
||
artist := seedArtist(t, pool, "A")
|
||
seedAlbum(t, pool, artist.ID, "X", 0)
|
||
seedAlbum(t, pool, artist.ID, "Y", 0)
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/api/artists", nil)
|
||
w := httptest.NewRecorder()
|
||
newLibraryRouter(h).ServeHTTP(w, req)
|
||
|
||
var got Page[ArtistRef]
|
||
_ = json.Unmarshal(w.Body.Bytes(), &got)
|
||
if len(got.Items) != 1 || got.Items[0].AlbumCount != 2 {
|
||
t.Errorf("items = %+v", got.Items)
|
||
}
|
||
}
|
||
|
||
func TestHandleListArtists_EmptyDB(t *testing.T) {
|
||
h, pool := testHandlers(t)
|
||
truncateLibrary(t, pool)
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/api/artists", nil)
|
||
w := httptest.NewRecorder()
|
||
newLibraryRouter(h).ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("status = %d", w.Code)
|
||
}
|
||
var got Page[ArtistRef]
|
||
_ = json.Unmarshal(w.Body.Bytes(), &got)
|
||
if got.Items == nil {
|
||
t.Error("items = nil, want []")
|
||
}
|
||
if got.Total != 0 {
|
||
t.Errorf("total = %d", got.Total)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run — expect FAIL**
|
||
|
||
Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -run 'TestHandleListArtists' -count=1`
|
||
Expected: all eight fail against the stub.
|
||
|
||
- [ ] **Step 3: Add pagination parsing helper to `internal/api/convert.go`**
|
||
|
||
Append:
|
||
|
||
```go
|
||
|
||
// parsePaging reads limit/offset from the query string, applying defaults
|
||
// and clamping. Returns a 400-worthy error when the values are non-numeric
|
||
// or negative; out-of-range values silently clamp (deliberate — UX).
|
||
func parsePaging(raw url.Values) (limit, offset int, err error) {
|
||
const (
|
||
defLimit = 50
|
||
maxLimit = 200
|
||
)
|
||
limit = defLimit
|
||
if s := strings.TrimSpace(raw.Get("limit")); s != "" {
|
||
n, perr := strconv.Atoi(s)
|
||
if perr != nil {
|
||
return 0, 0, errBadPaging
|
||
}
|
||
if n < 1 {
|
||
n = 1
|
||
}
|
||
if n > maxLimit {
|
||
n = maxLimit
|
||
}
|
||
limit = n
|
||
}
|
||
if s := strings.TrimSpace(raw.Get("offset")); s != "" {
|
||
n, perr := strconv.Atoi(s)
|
||
if perr != nil {
|
||
return 0, 0, errBadPaging
|
||
}
|
||
if n < 0 {
|
||
return 0, 0, errBadPaging
|
||
}
|
||
offset = n
|
||
}
|
||
return limit, offset, nil
|
||
}
|
||
```
|
||
|
||
Extend the import block at the top of `internal/api/convert.go` with the new imports + `errBadPaging`:
|
||
|
||
```go
|
||
import (
|
||
"errors"
|
||
"net/url"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||
)
|
||
|
||
var errBadPaging = errors.New("invalid limit or offset")
|
||
```
|
||
|
||
- [ ] **Step 4: Replace `handleListArtists` stub in `internal/api/library.go`**
|
||
|
||
Replace the stub with:
|
||
|
||
```go
|
||
// handleListArtists implements GET /api/artists with two sort modes
|
||
// (alpha|newest) and enveloped pagination. album_count per artist is
|
||
// computed via an N+1 — acceptable at limit=200.
|
||
func (h *handlers) handleListArtists(w http.ResponseWriter, r *http.Request) {
|
||
sort := r.URL.Query().Get("sort")
|
||
if sort == "" {
|
||
sort = "alpha"
|
||
}
|
||
if sort != "alpha" && sort != "newest" {
|
||
writeErr(w, http.StatusBadRequest, "bad_request", "sort must be alpha or newest")
|
||
return
|
||
}
|
||
limit, offset, err := parsePaging(r.URL.Query())
|
||
if err != nil {
|
||
writeErr(w, http.StatusBadRequest, "bad_request", err.Error())
|
||
return
|
||
}
|
||
|
||
q := dbq.New(h.pool)
|
||
var artists []dbq.Artist
|
||
switch sort {
|
||
case "newest":
|
||
artists, err = q.ListArtistsNewest(r.Context(), dbq.ListArtistsNewestParams{
|
||
Limit: int32(limit), Offset: int32(offset),
|
||
})
|
||
default: // alpha
|
||
artists, err = q.ListArtistsAlpha(r.Context(), dbq.ListArtistsAlphaParams{
|
||
Limit: int32(limit), Offset: int32(offset),
|
||
})
|
||
}
|
||
if err != nil {
|
||
h.logger.Error("api: list artists failed", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
||
return
|
||
}
|
||
total, err := q.CountArtists(r.Context())
|
||
if err != nil {
|
||
h.logger.Error("api: count artists failed", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "count failed")
|
||
return
|
||
}
|
||
|
||
items := make([]ArtistRef, 0, len(artists))
|
||
for _, a := range artists {
|
||
albums, aerr := q.ListAlbumsByArtist(r.Context(), a.ID)
|
||
if aerr != nil {
|
||
h.logger.Error("api: list albums for artist failed", "err", aerr)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
||
return
|
||
}
|
||
items = append(items, artistRefFrom(a, len(albums)))
|
||
}
|
||
writeJSON(w, http.StatusOK, Page[ArtistRef]{
|
||
Items: items,
|
||
Total: int(total),
|
||
Limit: limit,
|
||
Offset: offset,
|
||
})
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run — expect PASS**
|
||
|
||
Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -run 'TestHandleListArtists' -count=1 -v`
|
||
Expected: all eight tests PASS.
|
||
|
||
- [ ] **Step 6: Full api suite stays green**
|
||
|
||
Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -count=1`
|
||
Expected: all PASS.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add internal/api/library.go internal/api/library_test.go internal/api/convert.go
|
||
git commit -m "feat(api): GET /api/artists with alpha/newest sort and paging"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Implement GET /api/search
|
||
|
||
**Files:**
|
||
- Create: `internal/api/search.go`
|
||
- Create: `internal/api/search_test.go`
|
||
- Modify: `internal/api/library.go` (remove `handleSearch` stub)
|
||
|
||
- [ ] **Step 1: Write failing tests in `internal/api/search_test.go`**
|
||
|
||
```go
|
||
package api
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"testing"
|
||
)
|
||
|
||
func TestHandleSearch_ThreeFacets(t *testing.T) {
|
||
h, pool := testHandlers(t)
|
||
truncateLibrary(t, pool)
|
||
artist := seedArtist(t, pool, "Beatles")
|
||
album := seedAlbum(t, pool, artist.ID, "Beatles Greatest", 1982)
|
||
seedTrack(t, pool, album.ID, artist.ID, "Beatles Jam", 1, 60_000)
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/api/search?q=beatles", nil)
|
||
w := httptest.NewRecorder()
|
||
newLibraryRouter(h).ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
|
||
}
|
||
var got SearchResponse
|
||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||
t.Fatalf("decode: %v", err)
|
||
}
|
||
if got.Artists.Total != 1 || len(got.Artists.Items) != 1 {
|
||
t.Errorf("artists = %+v", got.Artists)
|
||
}
|
||
if got.Albums.Total != 1 || len(got.Albums.Items) != 1 {
|
||
t.Errorf("albums = %+v", got.Albums)
|
||
}
|
||
if got.Tracks.Total != 1 || len(got.Tracks.Items) != 1 {
|
||
t.Errorf("tracks = %+v", got.Tracks)
|
||
}
|
||
if got.Artists.Items[0].Name != "Beatles" {
|
||
t.Errorf("artist name = %q", got.Artists.Items[0].Name)
|
||
}
|
||
if got.Albums.Items[0].ArtistName != "Beatles" {
|
||
t.Errorf("album.artist_name = %q", got.Albums.Items[0].ArtistName)
|
||
}
|
||
if got.Tracks.Items[0].AlbumTitle != "Beatles Greatest" {
|
||
t.Errorf("track.album_title = %q", got.Tracks.Items[0].AlbumTitle)
|
||
}
|
||
}
|
||
|
||
func TestHandleSearch_NoMatches(t *testing.T) {
|
||
h, pool := testHandlers(t)
|
||
truncateLibrary(t, pool)
|
||
seedArtist(t, pool, "Beatles")
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/api/search?q=nothinghere", nil)
|
||
w := httptest.NewRecorder()
|
||
newLibraryRouter(h).ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("status = %d", w.Code)
|
||
}
|
||
var got SearchResponse
|
||
_ = json.Unmarshal(w.Body.Bytes(), &got)
|
||
if got.Artists.Total != 0 || got.Albums.Total != 0 || got.Tracks.Total != 0 {
|
||
t.Errorf("totals = %+v", got)
|
||
}
|
||
// All three item slices must JSON-encode as [] not null.
|
||
if got.Artists.Items == nil || got.Albums.Items == nil || got.Tracks.Items == nil {
|
||
t.Errorf("nil item slice: %+v", got)
|
||
}
|
||
}
|
||
|
||
func TestHandleSearch_EmptyQuery(t *testing.T) {
|
||
h, _ := testHandlers(t)
|
||
req := httptest.NewRequest(http.MethodGet, "/api/search", nil)
|
||
w := httptest.NewRecorder()
|
||
newLibraryRouter(h).ServeHTTP(w, req)
|
||
if w.Code != http.StatusBadRequest {
|
||
t.Errorf("status = %d, want 400", w.Code)
|
||
}
|
||
}
|
||
|
||
func TestHandleSearch_BlankQuery(t *testing.T) {
|
||
h, _ := testHandlers(t)
|
||
req := httptest.NewRequest(http.MethodGet, "/api/search?q=%20%20", nil)
|
||
w := httptest.NewRecorder()
|
||
newLibraryRouter(h).ServeHTTP(w, req)
|
||
if w.Code != http.StatusBadRequest {
|
||
t.Errorf("status = %d, want 400", w.Code)
|
||
}
|
||
}
|
||
|
||
func TestHandleSearch_PagingAppliedPerFacet(t *testing.T) {
|
||
h, pool := testHandlers(t)
|
||
truncateLibrary(t, pool)
|
||
// Create three matching artists so paging shows.
|
||
a1 := seedArtist(t, pool, "Match One")
|
||
a2 := seedArtist(t, pool, "Match Two")
|
||
a3 := seedArtist(t, pool, "Match Three")
|
||
_ = a1
|
||
_ = a2
|
||
_ = a3
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/api/search?q=Match&limit=2&offset=1", nil)
|
||
w := httptest.NewRecorder()
|
||
newLibraryRouter(h).ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("status = %d", w.Code)
|
||
}
|
||
var got SearchResponse
|
||
_ = json.Unmarshal(w.Body.Bytes(), &got)
|
||
if got.Artists.Total != 3 {
|
||
t.Errorf("total = %d, want 3", got.Artists.Total)
|
||
}
|
||
if got.Artists.Limit != 2 || got.Artists.Offset != 1 {
|
||
t.Errorf("page = limit=%d offset=%d", got.Artists.Limit, got.Artists.Offset)
|
||
}
|
||
if len(got.Artists.Items) != 2 {
|
||
t.Errorf("items len = %d, want 2", len(got.Artists.Items))
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run — expect FAIL (stub returns 501)**
|
||
|
||
Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -run 'TestHandleSearch' -count=1`
|
||
Expected: five fail against stub.
|
||
|
||
- [ ] **Step 3: Create `internal/api/search.go`**
|
||
|
||
```go
|
||
package api
|
||
|
||
import (
|
||
"context"
|
||
"net/http"
|
||
"strings"
|
||
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||
)
|
||
|
||
// handleSearch implements GET /api/search. Runs three paged facets sharing
|
||
// one limit/offset. Each facet returns its own total reflecting the full
|
||
// match count (not just the page). Nil slices are explicitly [] so JSON
|
||
// doesn't emit null.
|
||
func (h *handlers) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||
if q == "" {
|
||
writeErr(w, http.StatusBadRequest, "bad_request", "q is required")
|
||
return
|
||
}
|
||
limit, offset, err := parsePaging(r.URL.Query())
|
||
if err != nil {
|
||
writeErr(w, http.StatusBadRequest, "bad_request", err.Error())
|
||
return
|
||
}
|
||
|
||
dbQ := dbq.New(h.pool)
|
||
needle := &q
|
||
|
||
artists, err := dbQ.SearchArtists(r.Context(), dbq.SearchArtistsParams{
|
||
Column1: needle, Limit: int32(limit), Offset: int32(offset),
|
||
})
|
||
if err != nil {
|
||
h.logger.Error("api: search artists failed", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "search failed")
|
||
return
|
||
}
|
||
artistTotal, err := dbQ.CountArtistsMatching(r.Context(), q)
|
||
if err != nil {
|
||
h.logger.Error("api: count artists matching failed", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "search failed")
|
||
return
|
||
}
|
||
artistItems := make([]ArtistRef, 0, len(artists))
|
||
for _, a := range artists {
|
||
albums, aerr := dbQ.ListAlbumsByArtist(r.Context(), a.ID)
|
||
if aerr != nil {
|
||
h.logger.Error("api: list albums for search artist failed", "err", aerr)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "search failed")
|
||
return
|
||
}
|
||
artistItems = append(artistItems, artistRefFrom(a, len(albums)))
|
||
}
|
||
|
||
albums, err := dbQ.SearchAlbums(r.Context(), dbq.SearchAlbumsParams{
|
||
Column1: needle, Limit: int32(limit), Offset: int32(offset),
|
||
})
|
||
if err != nil {
|
||
h.logger.Error("api: search albums failed", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "search failed")
|
||
return
|
||
}
|
||
albumTotal, err := dbQ.CountAlbumsMatching(r.Context(), q)
|
||
if err != nil {
|
||
h.logger.Error("api: count albums matching failed", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "search failed")
|
||
return
|
||
}
|
||
albumItems, err := h.resolveAlbumRefs(r.Context(), dbQ, albums)
|
||
if err != nil {
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "search failed")
|
||
return
|
||
}
|
||
|
||
tracks, err := dbQ.SearchTracks(r.Context(), dbq.SearchTracksParams{
|
||
Column1: needle, Limit: int32(limit), Offset: int32(offset),
|
||
})
|
||
if err != nil {
|
||
h.logger.Error("api: search tracks failed", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "search failed")
|
||
return
|
||
}
|
||
trackTotal, err := dbQ.CountTracksMatching(r.Context(), q)
|
||
if err != nil {
|
||
h.logger.Error("api: count tracks matching failed", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "search failed")
|
||
return
|
||
}
|
||
trackItems, err := h.resolveTrackRefs(r.Context(), dbQ, tracks)
|
||
if err != nil {
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "search failed")
|
||
return
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, SearchResponse{
|
||
Artists: Page[ArtistRef]{Items: artistItems, Total: int(artistTotal), Limit: limit, Offset: offset},
|
||
Albums: Page[AlbumRef]{Items: albumItems, Total: int(albumTotal), Limit: limit, Offset: offset},
|
||
Tracks: Page[TrackRef]{Items: trackItems, Total: int(trackTotal), Limit: limit, Offset: offset},
|
||
})
|
||
}
|
||
|
||
// resolveAlbumRefs builds AlbumRefs from dbq.Album rows: resolves artist
|
||
// name (one query per distinct artist) and track count per album (one
|
||
// query each). Acceptable at limit=200 page size.
|
||
func (h *handlers) resolveAlbumRefs(ctx context.Context, q *dbq.Queries, albums []dbq.Album) ([]AlbumRef, error) {
|
||
items := make([]AlbumRef, 0, len(albums))
|
||
names, err := resolveArtistNames(ctx, q, collectArtistIDs(albums))
|
||
if err != nil {
|
||
h.logger.Error("api: resolve artist names failed", "err", err)
|
||
return nil, err
|
||
}
|
||
for _, a := range albums {
|
||
count, cerr := q.CountTracksByAlbum(ctx, a.ID)
|
||
if cerr != nil {
|
||
h.logger.Error("api: count tracks for album failed", "err", cerr)
|
||
return nil, cerr
|
||
}
|
||
items = append(items, albumRefFrom(a, names[uuidToString(a.ArtistID)], int(count), 0))
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
// resolveTrackRefs builds TrackRefs from dbq.Track rows: looks up the
|
||
// parent album + artist by id for each track (pair of queries per track).
|
||
// At limit=200 this is 400 queries worst case — acceptable for M1.5.
|
||
func (h *handlers) resolveTrackRefs(ctx context.Context, q *dbq.Queries, tracks []dbq.Track) ([]TrackRef, error) {
|
||
items := make([]TrackRef, 0, len(tracks))
|
||
for _, t := range tracks {
|
||
album, aerr := q.GetAlbumByID(ctx, t.AlbumID)
|
||
if aerr != nil {
|
||
h.logger.Error("api: get album for track failed", "err", aerr)
|
||
return nil, aerr
|
||
}
|
||
artist, aerr := q.GetArtistByID(ctx, t.ArtistID)
|
||
if aerr != nil {
|
||
h.logger.Error("api: get artist for track failed", "err", aerr)
|
||
return nil, aerr
|
||
}
|
||
items = append(items, trackRefFrom(t, album.Title, artist.Name))
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
// collectArtistIDs extracts the distinct-preserving ordered list of artist
|
||
// ids from a slice of albums. resolveArtistNames dedupes internally.
|
||
func collectArtistIDs(albums []dbq.Album) []pgtype.UUID {
|
||
ids := make([]pgtype.UUID, 0, len(albums))
|
||
for _, a := range albums {
|
||
ids = append(ids, a.ArtistID)
|
||
}
|
||
return ids
|
||
}
|
||
|
||
// resolveArtistNames fetches each distinct artist id and returns a
|
||
// uuid-string → name map. Duplicated from subsonic intentionally.
|
||
func resolveArtistNames(ctx context.Context, q *dbq.Queries, ids []pgtype.UUID) (map[string]string, error) {
|
||
seen := make(map[string]struct{}, len(ids))
|
||
out := make(map[string]string, len(ids))
|
||
for _, id := range ids {
|
||
key := uuidToString(id)
|
||
if _, ok := seen[key]; ok {
|
||
continue
|
||
}
|
||
seen[key] = struct{}{}
|
||
a, err := q.GetArtistByID(ctx, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out[key] = a.Name
|
||
}
|
||
return out, nil
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Remove the `handleSearch` stub from `internal/api/library.go`**
|
||
|
||
Delete this block from `library.go`:
|
||
|
||
```go
|
||
// handleSearch is implemented in Task 10 (file: search.go).
|
||
func (h *handlers) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||
writeErr(w, http.StatusNotImplemented, "not_implemented", "stub")
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run — expect PASS**
|
||
|
||
Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -run 'TestHandleSearch' -count=1 -v`
|
||
Expected: five tests PASS.
|
||
|
||
- [ ] **Step 6: Full api suite stays green**
|
||
|
||
Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -count=1`
|
||
Expected: all PASS.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add internal/api/search.go internal/api/search_test.go internal/api/library.go
|
||
git commit -m "feat(api): GET /api/search with three paged facets"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: Register routes in production mount
|
||
|
||
The tests exercise a test-only router; the production `api.Mount` must also wire all five routes so the SPA can hit them. Once wired, `RequireUser` gates them.
|
||
|
||
**Files:**
|
||
- Modify: `internal/api/api.go`
|
||
|
||
- [ ] **Step 1: Write failing test for production route registration**
|
||
|
||
Append to `internal/api/library_test.go`:
|
||
|
||
```go
|
||
|
||
// TestRoutesRegisteredInMount verifies the production Mount() wires every
|
||
// library route. We hit each path through the real Mount (no RequireUser
|
||
// stripped — we expect 401 from the middleware, which is fine — that
|
||
// proves the route reached the authenticated group).
|
||
func TestRoutesRegisteredInMount(t *testing.T) {
|
||
h, _ := testHandlers(t)
|
||
r := chi.NewRouter()
|
||
Mount(r, h.pool, h.logger)
|
||
|
||
paths := []string{
|
||
"/api/artists",
|
||
"/api/artists/00000000-0000-0000-0000-000000000001",
|
||
"/api/albums/00000000-0000-0000-0000-000000000001",
|
||
"/api/tracks/00000000-0000-0000-0000-000000000001",
|
||
"/api/search?q=x",
|
||
}
|
||
for _, p := range paths {
|
||
req := httptest.NewRequest(http.MethodGet, p, nil)
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, req)
|
||
// Unauthenticated → 401. What we must NOT see is 404 (route missing).
|
||
if w.Code == http.StatusNotFound {
|
||
t.Errorf("path %s returned 404 — route not registered", p)
|
||
}
|
||
if w.Code != http.StatusUnauthorized {
|
||
t.Errorf("path %s status = %d, want 401 (unauth)", p, w.Code)
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run — expect FAIL**
|
||
|
||
Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -run 'TestRoutesRegisteredInMount' -count=1`
|
||
Expected: 404s — routes not in Mount yet.
|
||
|
||
- [ ] **Step 3: Update `internal/api/api.go`**
|
||
|
||
Replace the existing `Mount` function with:
|
||
|
||
```go
|
||
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
||
// RequireUser; everything else is gated by the middleware.
|
||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger) {
|
||
h := &handlers{pool: pool, logger: logger}
|
||
|
||
r.Route("/api", func(api chi.Router) {
|
||
api.Post("/auth/login", h.handleLogin)
|
||
api.Group(func(authed chi.Router) {
|
||
authed.Use(auth.RequireUser(pool))
|
||
authed.Post("/auth/logout", h.handleLogout)
|
||
authed.Get("/me", h.handleGetMe)
|
||
|
||
authed.Get("/artists", h.handleListArtists)
|
||
authed.Get("/artists/{id}", h.handleGetArtist)
|
||
authed.Get("/albums/{id}", h.handleGetAlbum)
|
||
authed.Get("/tracks/{id}", h.handleGetTrack)
|
||
authed.Get("/search", h.handleSearch)
|
||
})
|
||
})
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run — expect PASS**
|
||
|
||
Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -run 'TestRoutesRegisteredInMount' -count=1 -v`
|
||
Expected: all paths return 401, test PASSes.
|
||
|
||
- [ ] **Step 5: api package suite stays green**
|
||
|
||
Run: `MINSTREL_TEST_DATABASE_URL=$MINSTREL_TEST_DATABASE_URL go test ./internal/api/ -count=1`
|
||
Expected: all api tests PASS.
|
||
|
||
Optionally run `go test ./... -count=1` to catch cross-package regressions. Known flaky integration tests outside `internal/api/` (bootstrap, scanner) may fail independently — those are pre-existing and out of scope for this plan.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add internal/api/api.go internal/api/library_test.go
|
||
git commit -m "feat(api): register library + search routes under RequireUser"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: Manual smoke + PR
|
||
|
||
Live-binary verification: start the server and hit the endpoints end-to-end, then open a PR.
|
||
|
||
- [ ] **Step 1: Build and run the server**
|
||
|
||
Run: `make build && ./bin/minstrel server` (or whatever the invocation is on your branch — check the Makefile's `build` target).
|
||
|
||
Expected: server listens on the configured port without errors in the log.
|
||
|
||
- [ ] **Step 2: Log in to capture a session cookie + token**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
curl -s -i -X POST http://localhost:8080/api/auth/login \
|
||
-H 'content-type: application/json' \
|
||
-d '{"username":"admin","password":"<your admin bootstrap password>"}'
|
||
```
|
||
|
||
Expected: `200 OK`, `Set-Cookie: minstrel_session=...`, JSON body with `token` and `user`.
|
||
|
||
Copy the token from the JSON body; call it `$TOKEN` below.
|
||
|
||
- [ ] **Step 3: Smoke each endpoint**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/artists | jq .
|
||
curl -s -H "Authorization: Bearer $TOKEN" 'http://localhost:8080/api/artists?sort=newest&limit=5' | jq .
|
||
curl -s -H "Authorization: Bearer $TOKEN" 'http://localhost:8080/api/search?q=a&limit=3' | jq .
|
||
# Grab a real artist id from /api/artists output, substitute below:
|
||
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/artists/<artist-id> | jq .
|
||
# Grab a real album id from the artist detail output:
|
||
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/albums/<album-id> | jq .
|
||
# Grab a real track id from the album detail output:
|
||
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/tracks/<track-id> | jq .
|
||
```
|
||
|
||
Expected:
|
||
- Each returns 200 with valid JSON in the documented shape.
|
||
- List response contains `items`, `total`, `limit`, `offset`.
|
||
- Album detail's `cover_url` ends in `/cover` and tracks' `stream_url` end in `/stream`.
|
||
- Error path: `curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/albums/not-a-uuid | jq .` returns 400 with `{error:{code:"bad_request",...}}`.
|
||
|
||
- [ ] **Step 4: Verify unauth paths are 401**
|
||
|
||
Run: `curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/api/artists`
|
||
Expected: `401`.
|
||
|
||
- [ ] **Step 5: Push branch and open PR**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
git push -u origin dev
|
||
```
|
||
|
||
Then open a PR against `main` via `gh` (or whatever the project uses; the memory notes Forgejo MCP):
|
||
|
||
PR title: `feat: library reads + search API (/api/artists, albums, tracks, search)`
|
||
|
||
PR body:
|
||
|
||
```
|
||
Implements Plan 2 of the web UI rollout: paginated artist list with alpha/newest sort, artist/album/track detail, and unified search.
|
||
|
||
Spec: docs/superpowers/specs/2026-04-20-web-ui-library-reads-design.md
|
||
|
||
Highlights:
|
||
- Enveloped pagination: {items, total, limit, offset}
|
||
- Ref/Detail response split mirroring subsonic
|
||
- Forward-reference stream/cover URLs embedded (Plan 3 implements the endpoints)
|
||
- Empty slices always render as [] (never null)
|
||
|
||
Test plan:
|
||
- [x] Unit tests for conversion helpers
|
||
- [x] Integration tests per endpoint (happy path, 400/404 paths, paging, sort)
|
||
- [x] Route-registration test verifies all five routes wired under RequireUser
|
||
- [x] Manual smoke verified against running server
|
||
```
|
||
|
||
- [ ] **Step 6: Wait for CI**
|
||
|
||
Check Forgejo Actions; fix any lint/test failures surfaced there and push. Once green, merge per project workflow (dev → protected main).
|
||
|
||
---
|
||
|
||
## Self-Review Checklist (run before handing off)
|
||
|
||
1. **Spec coverage:**
|
||
- Routes: Tasks 6 (tracks), 7 (albums), 8 (artist detail), 9 (artist list), 10 (search), 11 (mount) ✓
|
||
- Pagination envelope `Page[T]`: Task 2 ✓
|
||
- Sort modes alpha/newest: Task 1 (SQL), Task 9 (handler + tests) ✓
|
||
- URL embedding: Task 3 (helpers), projection in Task 4 ✓
|
||
- Error taxonomy (400/404/401/500): exercised per-endpoint test ✓
|
||
- Empty-slice-never-null rule: Tasks 7, 8, 9, 10 assert explicitly ✓
|
||
|
||
2. **Placeholder scan:** no TBD/TODO/"add appropriate error handling" — every step shows code or exact commands.
|
||
|
||
3. **Type consistency:** Helpers named `artistRefFrom`, `albumRefFrom`, `trackRefFrom` consistently across Tasks 4–10. `parsePaging` signature `(raw map[string][]string) (limit, offset int, err error)` used identically in Tasks 9 and 10. `newLibraryRouter` and `withUserCtx` helpers defined in Task 6 are reused in Tasks 7–11.
|