Files
yellowjacket/.planning/phases/05-database-library-tests/05-02-PLAN.md
T

332 lines
15 KiB
Markdown

---
phase: 05-database-library-tests
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
- backend/library/scan_test.go
autonomous: true
requirements: [TEST-06]
must_haves:
truths:
- "Entity cache returns cached value on second call (no DB hit)"
- "cachedLinkArtist skips duplicate INSERT when linkedCredits cache hit"
- "cachedLinkArtist silently ignores UNIQUE constraint violations from DB"
- "cachedUpsertGenre returns cached genre on repeated calls"
- "resolveReleaseGroup returns cached release group and updates cover art if new art available"
- "getRecordingName falls back to filename when title is empty"
- "toNullInt64 treats 0 as null, non-zero as valid"
- "toNullString treats empty as null, non-empty as valid"
- "splitGenres splits on || delimiter correctly"
- "mapTrackRow maps all 16 columns correctly including NullInt64 fields"
- "Orphan deletion removes audio_file and search_index entries"
- "Entity cache functions work with plain context.Context (no Wails dependency)"
- "All tests pass with -race flag"
artifacts:
- path: "backend/library/scan_test.go"
provides: "Entity cache tests, pure helper tests, orphan cleanup tests"
min_lines: 300
key_links:
- from: "backend/library/scan_test.go"
to: "backend/library/library.go"
via: "direct function calls (same package — internal tests)"
pattern: "cachedUpsertArtistCredit|cachedLinkArtist|cachedUpsertGenre|resolveReleaseGroup|getRecordingName|toNullInt64|toNullString"
- from: "backend/library/scan_test.go"
to: "backend/library/query.go"
via: "direct function calls (same package)"
pattern: "splitGenres|mapTrackRow"
- from: "backend/library/scan_test.go"
to: "backend/database/testhelper.go"
via: "NewTestDB(t) for DB-backed tests"
pattern: "database\\.NewTestDB"
---
<objective>
Write unit tests for library scan logic covering entity cache functions (cachedUpsertArtistCredit, cachedLinkArtist, cachedUpsertGenre, resolveReleaseGroup), pure helper functions (getRecordingName, toNullInt64, toNullString, splitGenres, mapTrackRow), and orphan track cleanup at the DB level.
Purpose: Lock down library scan behavior before Phase 7's performance optimization — these tests ensure entity caching, metadata processing, and orphan cleanup work correctly as the safety net for lazy loading changes.
Output: backend/library/scan_test.go with ~12-15 tests, all passing with `-race`.
</objective>
<execution_context>
@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md
@/home/caleb/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/05-database-library-tests/05-CONTEXT.md
@.planning/phases/03-test-infrastructure/03-01-SUMMARY.md
@.planning/phases/04-queue-config-player-tests/04-01-SUMMARY.md
<interfaces>
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
<!-- Executor should use these directly — no codebase exploration needed. -->
From backend/library/library.go — entity cache:
```go
type entityCache struct {
artistCredits map[string]sqlcgen.ArtistCredit
artists map[string]sqlcgen.Artist
releaseGroups map[string]sqlcgen.ReleaseGroup
coverArt map[string]sqlcgen.CoverArt
genres map[string]sqlcgen.Genre
linkedCredits map[string]struct{} // key is "artistID:creditID"
}
func newEntityCache() *entityCache
// Library methods (receiver is *Library — needs l.ctx and l.db):
func (l *Library) cachedUpsertArtistCredit(q *sqlcgen.Queries, cache *entityCache, name string) (sqlcgen.ArtistCredit, error)
func (l *Library) cachedLinkArtist(q *sqlcgen.Queries, cache *entityCache, metrics *ScanMetrics, name string, creditID int64)
func (l *Library) cachedUpsertGenre(q *sqlcgen.Queries, cache *entityCache, name string) (sqlcgen.Genre, error)
func (l *Library) resolveReleaseGroup(q *sqlcgen.Queries, cache *entityCache, tags *metadata.TrackMetadata, albumArtistCreditID sql.NullInt64, coverArtID sql.NullInt64) sql.NullInt64
func (l *Library) resolveAlbumArtistCredit(q *sqlcgen.Queries, cache *entityCache, metrics *ScanMetrics, tags *metadata.TrackMetadata, trackArtistCreditID int64) sql.NullInt64
func (l *Library) getRecordingName(tags *metadata.TrackMetadata, filePath string) string
```
From backend/library/library.go — pure helpers:
```go
func toNullInt64(v int) sql.NullInt64 // 0 → {Valid:false}, non-zero → {Valid:true}
func toNullString(v string) sql.NullString // "" → {Valid:false}, non-empty → {Valid:true}
```
From backend/library/query.go:
```go
type Track struct {
TrackName string
ArtistName string
TrackLength string // NOTE: string, formatted via strconv.FormatInt
FilePath string
TrackNumber int64
DiscNumber int64
Album string
Genre []string
Year int64
Composer string
FileType string
SampleRate int64
BitDepth int64
Channels int64
Bitrate int64
FileSize int64
}
func splitGenres(concatenated string) []string // splits on "||"
func mapTrackRow(filePath string, lengthMs int64, title, artistName string, trackNumber, discNumber sql.NullInt64, album, genre string, year int64, composer, fileType string, sampleRate, bitDepth, channels, bitrate, fileSize int64) Track
```
From backend/library/library.go — Library struct:
```go
type Library struct {
mu sync.Mutex
ctx context.Context
logger *slog.Logger
conf *Config
db *database.DB
rescanHooks RescanHooks
}
func NewLibrary(ctx context.Context, logger *slog.Logger, conf *Config, db *database.DB) (*Library, error)
```
From backend/library/metrics.go:
```go
type ScanMetrics struct { ... }
func newScanMetrics() *ScanMetrics
```
From backend/database:
```go
func NewTestDB(t *testing.T) *DB
func (d *DB) DeleteSearchIndex(rowid int64) error
func IsUniqueViolation(err error) bool
```
From backend/database/sql/sqlcgen (generated queries used by entity cache):
```go
func (q *Queries) UpsertArtistCredit(ctx context.Context, text string) (ArtistCredit, error)
func (q *Queries) UpsertArtist(ctx context.Context, name string) (Artist, error)
func (q *Queries) CreateArtistCreditArtist(ctx context.Context, arg CreateArtistCreditArtistParams) (ArtistCreditArtist, error)
func (q *Queries) UpsertGenre(ctx context.Context, name string) (Genre, error)
func (q *Queries) UpsertReleaseGroup(ctx context.Context, arg UpsertReleaseGroupParams) (ReleaseGroup, error)
func (q *Queries) DeleteAudioFile(ctx context.Context, id int64) error
```
From backend/metadata:
```go
type TrackMetadata struct {
Title string
Artist string
AlbumArtist string
Album string
Genre string
Year int
TrackNumber int
DiscNumber int
Composer string
Lyrics string
Comment string
Picture *PictureData
}
```
Key patterns from Phase 4 (queue tests):
- Internal tests (`package library`) to access unexported fields
- `t.Parallel()` on all tests
- `database.NewTestDB(t)` for DB-backed tests
- Construct test data inline per CONTEXT.md decision (no shared metadata builders)
- Seed data via raw SQL (db.ExecContext) for explicit control
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Pure helper tests (no DB needed)</name>
<files>backend/library/scan_test.go</files>
<action>
Create `backend/library/scan_test.go` (package library — internal tests, access unexported functions).
**Pure helper tests (no DB dependency):**
1. `TestGetRecordingName` — table-driven subtests:
- Title present: tags.Title="Bohemian Rhapsody" → returns "Bohemian Rhapsody"
- Title empty, falls back to filename: tags.Title="", filePath="/music/song.mp3" → returns "song"
- Title empty, complex path: filePath="/music/Artist - Track.flac" → returns "Artist - Track"
Create a minimal Library struct for calling: `lib := &Library{logger: slog.Default()}` (getRecordingName only uses l.logger indirectly — actually it doesn't use logger at all, just tags and filePath).
2. `TestToNullInt64` — table-driven subtests:
- 0 → sql.NullInt64{Valid: false}
- 5 → sql.NullInt64{Int64: 5, Valid: true}
- -1 → sql.NullInt64{Int64: -1, Valid: true} (negative is non-zero)
3. `TestToNullString` — table-driven subtests:
- "" → sql.NullString{Valid: false}
- "rock" → sql.NullString{String: "rock", Valid: true}
4. `TestSplitGenres` — table-driven subtests:
- Empty string → nil
- Single genre "Rock" → ["Rock"]
- Multiple genres "Rock||Jazz||Blues" → ["Rock", "Jazz", "Blues"]
- Two genres "Electronic||Ambient" → ["Electronic", "Ambient"]
5. `TestMapTrackRow` — single test, verify all 16 fields mapped correctly:
- Pass specific values for all parameters including sql.NullInt64 for track_number/disc_number
- Assert Track struct has correct values for all fields
- Verify TrackLength is string-formatted milliseconds (e.g., int64 180000 → "180000")
- Verify Genre is split from "Rock||Jazz" → []string{"Rock", "Jazz"}
- Verify NullInt64 fields: Valid=true extracts Int64, Valid=false yields 0
Follow established patterns: `t.Parallel()`, table-driven subtests with `t.Run()`, standard library testing (no testify), `TestFunctionName_Scenario` naming.
</action>
<verify>
<automated>cd backend && go test -race -run "TestGetRecordingName|TestToNullInt64|TestToNullString|TestSplitGenres|TestMapTrackRow" ./library/ -v -count=1</automated>
</verify>
<done>5 pure helper test functions pass: getRecordingName falls back to filename sans extension, toNullInt64/toNullString treat zero/empty as null, splitGenres handles || delimiter, mapTrackRow maps all 16 columns correctly including string-formatted TrackLength.</done>
</task>
<task type="auto">
<name>Task 2: Entity cache + orphan cleanup tests (DB-backed)</name>
<files>backend/library/scan_test.go</files>
<action>
Add to the existing `backend/library/scan_test.go` file created in Task 1.
**Test helper:**
Create `setupTestLibrary(t *testing.T) (*Library, *database.DB)` that:
- Calls `database.NewTestDB(t)` for a fresh in-memory DB
- Creates a Library with `NewLibrary(t.Context(), slog.Default(), &Config{DirectoryPath: "/test"}, db)`
- Returns both for direct DB seeding in tests
**Entity cache tests (DB-backed):**
6. `TestCachedUpsertArtistCredit` — test cache hit behavior:
- Create library + DB, create fresh entityCache via `newEntityCache()`
- Call `cachedUpsertArtistCredit(q, cache, "Queen")` — first call hits DB, returns ArtistCredit with valid ID
- Call again with same name — verify returns same ID (cache hit)
- Call with different name "Beyoncé" — verify returns different ID
- Verify cache map has 2 entries
7. `TestCachedLinkArtist` — test artist-credit link creation and dedup:
- Create library + DB + cache
- First: upsert an artist credit to get a creditID
- Call `cachedLinkArtist(q, cache, metrics, "Queen", creditID)` — creates artist + link
- Call again with same args — should skip (linkedCredits cache hit, no duplicate INSERT)
- Verify linkedCredits cache has exactly 1 entry
- Verify the artist exists in the artists cache
8. `TestCachedLinkArtist_MultiCredit` — test same artist in different credits:
- Upsert two different artist credits: "Queen" (creditID=1) and "Queen feat. David Bowie" (creditID=2)
- Call cachedLinkArtist for "Queen" with creditID=1
- Call cachedLinkArtist for "Queen" with creditID=2
- Verify artist cached once (artists map has 1 "Queen" entry) but linkedCredits has 2 entries ("artistID:1" and "artistID:2")
9. `TestCachedUpsertGenre` — test genre cache:
- Call `cachedUpsertGenre(q, cache, "Rock")` — first call creates genre
- Call again — returns same ID from cache
- Verify cache has 1 entry
10. `TestResolveReleaseGroup` — test release group resolution + cover art update:
- Call with tags.Album="A Night at the Opera", no cover art → returns valid NullInt64
- Call again with same album but with cover art → should update the cached release group's cover art
- Call with tags.Album="" → returns invalid NullInt64
11. `TestResolveReleaseGroup_CacheHit` — separate test for pure cache behavior:
- Pre-populate cache.releaseGroups with a known release group
- Call resolveReleaseGroup — verify returns cached ID without DB query
- This documents that the cache is the first check
**Orphan cleanup test (DB-level):**
12. `TestOrphanDeletion` — test DeleteAudioFile + DeleteSearchIndex at DB level:
- Seed an audio_file row + search_index entry via raw SQL
- Call `db.Queries.DeleteAudioFile(ctx, id)` — verify audio_files row gone
- Call `db.DeleteSearchIndex(id)` — verify search_index entry gone
- Verify a SearchFTS query no longer returns the deleted track
**Missing fields / empty metadata test:**
13. `TestEntityCache_EmptyFields` — verify behavior with missing metadata:
- Call cachedUpsertArtistCredit with empty name "" — documents what happens (likely creates a "" credit or errors)
- Call resolveReleaseGroup with empty Album — should return invalid NullInt64
- Test resolveAlbumArtistCredit when AlbumArtist=="" — should reuse track artist credit
All tests use `t.Parallel()`. Construct metadata structs inline per CONTEXT.md decision. Use `t.Context()` for context per CONTEXT.md decision (documents no Wails dependency).
</action>
<verify>
<automated>cd backend && go test -race ./library/ -v -count=1</automated>
</verify>
<done>8+ entity cache and orphan cleanup tests pass with -race: cachedUpsertArtistCredit caches on second call, cachedLinkArtist skips duplicate inserts via linkedCredits cache, multi-credit scenario handles same artist across different credits, cachedUpsertGenre caches correctly, resolveReleaseGroup handles cache + cover art updates, orphan deletion removes both audio_file and search_index entries, empty metadata fields handled gracefully.</done>
</task>
</tasks>
<verification>
```bash
# All library package tests pass with race detector (includes existing config_test.go)
cd backend && go test -race ./library/ -v -count=1
# Verify test count is in target range (10-15 new tests, plus existing config tests)
cd backend && go test ./library/ -v -count=1 2>&1 | grep -c "=== RUN"
```
</verification>
<success_criteria>
- backend/library/scan_test.go exists with 12-15 tests
- Pure helpers tested: getRecordingName, toNullInt64, toNullString, splitGenres, mapTrackRow
- Entity cache tested: cachedUpsertArtistCredit, cachedLinkArtist (including multi-credit), cachedUpsertGenre, resolveReleaseGroup
- Orphan cleanup tested at DB level (DeleteAudioFile + DeleteSearchIndex)
- All entity cache tests use plain context.Context (no Wails dependency)
- Empty/missing metadata fields handled and documented
- All tests pass with `go test -race`
</success_criteria>
<output>
After completion, create `.planning/phases/05-database-library-tests/05-02-SUMMARY.md`
</output>