docs(05): create phase plan for database and library tests

This commit is contained in:
2026-03-04 15:06:06 -05:00
parent 963a5b594b
commit 455e3f8e13
3 changed files with 667 additions and 2 deletions
+5 -2
View File
@@ -82,7 +82,10 @@ Plans:
2. Library scan logic has ~10-15 tests covering metadata extraction processing, entity cache hit/miss behavior, and orphan track cleanup
3. FTS5 search tests verify that search ranking produces consistent, expected ordering for known test data
4. All tests in this phase pass with `-race` flag enabled
**Plans:** TBD
**Plans:** 2 plans
Plans:
- [ ] 05-01-PLAN.md — FTS5 search tests, pure helper tests, search index operations, migration verification
- [ ] 05-02-PLAN.md — Entity cache tests, library pure helpers, orphan cleanup tests
### Phase 6: SQL Consolidation & Code Quality
**Goal:** Duplicated SQL patterns are eliminated, event names are provably synchronized between Go and TypeScript, and intentional SQL exceptions are documented
@@ -124,7 +127,7 @@ Plans:
| 2. Backend Correctness | 2/2 | Complete | 2026-03-03 |
| 3. Test Infrastructure | 0/1 | Planned | — |
| 4. Queue, Config & Player Tests | 0/2 | Planned | — |
| 5. Database & Library Tests | 0/? | Not started | — |
| 5. Database & Library Tests | 0/2 | Planned | — |
| 6. SQL Consolidation & Code Quality | 0/? | Not started | — |
| 7. Backend Performance | 0/? | Not started | — |
| 8. Frontend Performance & UX | 0/? | Not started | — |
@@ -0,0 +1,331 @@
---
phase: 05-database-library-tests
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- backend/database/search_test.go
autonomous: true
requirements: [TEST-03]
must_haves:
truths:
- "SearchFTS returns correct results for basic term queries"
- "SearchFTS returns nil for empty queries"
- "SearchFTS handles special characters (quotes, slashes like AC/DC) without error"
- "SearchFTS multi-word queries match across title/artist/album columns"
- "SearchFTSByFilename scopes search to file_path column only"
- "SearchFTSTracks returns full 16-column track metadata"
- "FTS5 search ranking produces consistent BM25 ordering for known data"
- "Diacritics search works (Beyonce finds Beyoncé)"
- "RebuildSearchIndex repopulates the index from audio_files data"
- "tokeniseForFTS and buildFTSQuery produce correct FTS5 query syntax"
- "Schema migrations run successfully on a fresh database"
- "All tests pass with -race flag"
artifacts:
- path: "backend/database/search_test.go"
provides: "FTS5 search tests, pure helper tests, migration tests, rebuild tests"
min_lines: 300
key_links:
- from: "backend/database/search_test.go"
to: "backend/database/search.go"
via: "direct function calls (same package)"
pattern: "SearchFTS|SearchFTSByFilename|SearchFTSTracks|tokeniseForFTS|buildFTSQuery|stripExtForSearch"
- from: "backend/database/search_test.go"
to: "backend/database/testhelper.go"
via: "NewTestDB(t)"
pattern: "NewTestDB"
---
<objective>
Write unit tests for the database package covering FTS5 search queries (SearchFTS, SearchFTSByFilename, SearchFTSTracks), pure helper functions (tokeniseForFTS, buildFTSQuery, stripExtForSearch), search index operations (InsertSearchIndex, DeleteSearchIndex, ClearSearchIndex, RebuildSearchIndex), and schema migration verification.
Purpose: Lock down FTS5 search behavior before Phase 6's VIEW consolidation — these tests become the safety net that proves the VIEW doesn't break search ranking or result mapping.
Output: backend/database/search_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/database/database.go:
```go
type DB struct {
db *sql.DB
Ctx context.Context
Queries *sqlcgen.Queries
logger *slog.Logger
}
func (d *DB) ExecContext(query string, args ...any) (sql.Result, error)
func (d *DB) QueryContext(query string, args ...any) (*sql.Rows, error)
func (d *DB) BeginTx() (*sql.Tx, error)
```
From backend/database/testhelper.go:
```go
func NewTestDB(t *testing.T) *DB
```
From backend/database/search.go:
```go
type SearchRow struct {
FilePath string
LengthMilliseconds int64
Title string
Artist string
Album string
}
type SearchTrackRow struct {
FilePath string
LengthMilliseconds int64
Title string
ArtistName string
TrackNumber sql.NullInt64
DiscNumber sql.NullInt64
Album string
Genre string
Year int64
Composer string
FileType string
SampleRate int64
BitDepth int64
Channels int64
Bitrate int64
FileSize int64
}
func (d *DB) SearchFTS(query string, limit int) ([]SearchRow, error)
func (d *DB) SearchFTSByFilename(basename string, limit int) ([]SearchRow, error)
func (d *DB) SearchFTSTracks(query string, limit int) ([]SearchTrackRow, error)
func (d *DB) InsertSearchIndex(rowid int64, filePath, title, artist, album string) error
func (d *DB) DeleteSearchIndex(rowid int64) error
func (d *DB) ClearSearchIndex() error
func (d *DB) RebuildSearchIndex() error
// Unexported (same package, accessible in tests):
func buildFTSQuery(query string) string
func tokeniseForFTS(s string) []string
func stripExtForSearch(s string) string
```
SQL schema — search_index (FTS5 contentless table):
```sql
CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
file_path, title, artist, album,
content='',
tokenize='unicode61 remove_diacritics 2'
);
```
SQL schema — audio_files:
```sql
CREATE TABLE IF NOT EXISTS audio_files (
id integer PRIMARY KEY,
file_path text NOT NULL UNIQUE,
length_milliseconds int NOT NULL,
file_type_id int NOT NULL,
recording_id int NOT NULL,
sample_rate int NOT NULL DEFAULT 0,
bit_depth int NOT NULL DEFAULT 0,
channels int NOT NULL DEFAULT 0,
bitrate int NOT NULL DEFAULT 0,
file_size int NOT NULL DEFAULT 0,
basename text NOT NULL DEFAULT '',
FOREIGN KEY(file_type_id) REFERENCES file_types(id),
FOREIGN KEY(recording_id) REFERENCES recordings(id)
);
```
SQL schema — recordings:
```sql
CREATE TABLE IF NOT EXISTS recordings (
id INTEGER PRIMARY KEY, name TEXT NOT NULL,
artist_credit_id INTEGER NOT NULL, track_number INTEGER,
disc_number INTEGER, year INTEGER, genre TEXT, composer TEXT,
lyrics TEXT, comment TEXT,
FOREIGN KEY(artist_credit_id) REFERENCES artist_credit(id)
);
```
SQL schema — artist_credit:
```sql
CREATE TABLE IF NOT EXISTS artist_credit (id INTEGER PRIMARY KEY, text TEXT NOT NULL UNIQUE);
```
SQL schema — release_groups:
```sql
CREATE TABLE IF NOT EXISTS release_groups (
id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE,
cover_art_id INTEGER, album_artist_credit_id INTEGER,
year INTEGER, total_tracks INTEGER, total_discs INTEGER,
FOREIGN KEY(cover_art_id) REFERENCES cover_art(id),
FOREIGN KEY(album_artist_credit_id) REFERENCES artist_credit(id)
);
```
SQL schema — release_group_recordings:
```sql
CREATE TABLE IF NOT EXISTS release_group_recordings (
id INTEGER PRIMARY KEY, release_group_id INTEGER NOT NULL,
recording_id INTEGER NOT NULL, track_number INTEGER, disc_number INTEGER,
FOREIGN KEY(release_group_id) REFERENCES release_groups(id),
FOREIGN KEY(recording_id) REFERENCES recordings(id)
);
```
Existing test pattern from queue package (seedAudioFiles):
```go
// Creates FK chain: artist_credit → recordings → audio_files
_, err := db.ExecContext(
"INSERT OR IGNORE INTO artist_credit (id, text) VALUES (1, 'Test Artist')",
)
_, err = db.ExecContext(
"INSERT OR IGNORE INTO recordings (id, name, artist_credit_id) VALUES (?, ?, 1)",
recID, fmt.Sprintf("Track %d", i+1),
)
_, err = db.ExecContext(
"INSERT OR IGNORE INTO audio_files (id, file_path, length_milliseconds, file_type_id, recording_id) VALUES (?, ?, 180000, 0, ?)",
afID, fp, recID,
)
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Pure helper function tests + seed helper</name>
<files>backend/database/search_test.go</files>
<action>
Create `backend/database/search_test.go` (package database — internal tests, access unexported functions).
**Seed helper function:**
Create `seedSearchData(t *testing.T, db *DB)` that inserts ~6-8 tracks with the full FK chain needed for FTS5 search:
- artist_credit rows (e.g., "Queen", "Beyoncé", "AC/DC", "Pink Floyd")
- recordings with varied metadata (title, track_number, disc_number, year, genre, composer)
- audio_files with file_path, length_milliseconds, file_type_id=0, recording_id
- release_groups with album names (e.g., "A Night at the Opera", "Lemonade", "Back in Black", "The Dark Side of the Moon")
- release_group_recordings linking recordings to release_groups
- search_index entries via `InsertSearchIndex()` for each audio file (rowid must match audio_files.id)
Use realistic music metadata per CONTEXT.md decision: "Bohemian Rhapsody" by "Queen" on "A Night at the Opera", "Halo" by "Beyoncé" on "Lemonade", "Back in Black" by "AC/DC" on "Back in Black", "Comfortably Numb" by "Pink Floyd" on "The Dark Side of the Moon", "Another One Bites the Dust" by "Queen" on "The Game", etc.
**Pure helper tests (no DB needed):**
1. `TestTokeniseForFTS` — table-driven subtests:
- Simple word: "hello" → `["\"hello\""]`
- Multiple words: "hello world" → `["\"hello\"" "\"world\""]`
- Hyphens split: "rock-pop" → `["\"rock\"" "\"pop\""]`
- Slashes split: "AC/DC" → `["\"AC\"" "\"DC\""]`
- Dots split: "01.track" → `["\"01\"" "\"track\""]`
- Underscores split: "my_song" → `["\"my\"" "\"song\""]`
- Double quotes escaped: `he"llo``["\"he\"\"llo\""]`
- Empty string: "" → nil or empty slice
- Only separators: "---" → nil or empty slice
2. `TestBuildFTSQuery` — table-driven subtests:
- Single word: "queen" → `"\"queen\""`
- Multi-word: "bohemian rhapsody" → `"\"bohemian\" \"rhapsody\""`
- Empty string returns the original (empty)
3. `TestStripExtForSearch` — table-driven subtests:
- "song.mp3" → "song"
- "my.song.flac" → "my.song"
- "noextension" → "noextension"
- ".hidden" → ".hidden" (dot at position 0 is not stripped)
Follow established patterns: `t.Parallel()`, `t.Run()` subtests, standard library testing (no testify), `TestFunctionName_Scenario` naming convention.
</action>
<verify>
<automated>cd backend && go test -race -run "TestTokeniseForFTS|TestBuildFTSQuery|TestStripExtForSearch|seedSearchData" ./database/ -v -count=1</automated>
</verify>
<done>Pure helper tests pass: tokeniseForFTS handles all separator types and quote escaping, buildFTSQuery produces correct FTS5 syntax, stripExtForSearch handles edge cases. seedSearchData helper function creates full entity graph for search tests.</done>
</task>
<task type="auto">
<name>Task 2: FTS5 search + index operation + migration tests</name>
<files>backend/database/search_test.go</files>
<action>
Add to the existing `backend/database/search_test.go` file created in Task 1.
**FTS5 Search tests (use seedSearchData + NewTestDB):**
4. `TestSearchFTS_BasicTerm` — search for "queen", verify returns "Bohemian Rhapsody" and "Another One Bites the Dust" (both Queen tracks). Assert len >= 2, check FilePath and Title fields.
5. `TestSearchFTS_EmptyQuery` — search for "", verify returns nil (not an error). Also test whitespace-only " ".
6. `TestSearchFTS_SpecialCharacters` — search for "AC/DC", verify returns the AC/DC track. The tokeniser splits on `/`, so "AC" and "DC" both match. Also test a query with double quotes.
7. `TestSearchFTS_MultiWord` — search for "bohemian rhapsody", verify returns the Queen track as top result. Multi-word queries use implicit AND.
8. `TestSearchFTS_Diacritics` — search for "Beyonce" (no accent), verify returns the Beyoncé track. This tests `unicode61 remove_diacritics 2` tokeniser config.
9. `TestSearchFTS_Ranking` — seed data with specific artist/title combos where one track should rank higher. Search a term that appears in both title and artist of one track vs. only artist of another. Assert the more-relevant result comes first (lower BM25 rank = first). Use exact result ordering assertion per CONTEXT.md decision.
10. `TestSearchFTSByFilename` — search by basename "bohemian_rhapsody.mp3", verify matches. The search strips extension and scopes to file_path column. Also test empty basename returns nil.
11. `TestSearchFTSTracks` — search for "queen", verify returns SearchTrackRow with all 16 fields populated (FilePath, LengthMilliseconds, Title, ArtistName, TrackNumber, DiscNumber, Album, Genre, Year, Composer, FileType, SampleRate, BitDepth, Channels, Bitrate, FileSize). This is the safety net for the full-metadata search path.
**Search index operation tests:**
12. `TestInsertAndDeleteSearchIndex` — insert a search_index entry, verify SearchFTS finds it, delete it, verify SearchFTS no longer finds it.
13. `TestRebuildSearchIndex` — seed audio_files + recordings + artist_credit + release_groups + release_group_recordings (without search_index entries), call RebuildSearchIndex(), verify SearchFTS now returns results.
14. `TestClearSearchIndex` — seed search data, call ClearSearchIndex(), verify SearchFTS returns empty.
**Migration test:**
15. `TestMigrationsApplied` — call NewTestDB(t), verify user_version PRAGMA is >= 3 (all 3 migrations applied). Verify the artist_credit_artist UNIQUE index exists by attempting a duplicate insert and checking for UNIQUE violation error.
Each test gets its own `NewTestDB(t)` call + `seedSearchData(t, db)` where needed. Use `t.Parallel()` for all tests. Follow established Phase 4 patterns (table-driven subtests where appropriate, descriptive assertions with `t.Errorf`).
</action>
<verify>
<automated>cd backend && go test -race ./database/ -v -count=1</automated>
</verify>
<done>12+ database tests pass with -race: FTS5 search works for basic terms, empty queries, special characters (AC/DC), multi-word, diacritics (Beyonce→Beyoncé), ranking order is deterministic. SearchFTSByFilename scopes to file_path column. SearchFTSTracks returns full 16-column metadata. Insert/Delete/Clear/Rebuild index operations work correctly. Migrations verified applied.</done>
</task>
</tasks>
<verification>
```bash
# All database package tests pass with race detector
cd backend && go test -race ./database/ -v -count=1
# Verify test count is in target range (10-15)
cd backend && go test ./database/ -v -count=1 2>&1 | grep -c "=== RUN"
```
</verification>
<success_criteria>
- backend/database/search_test.go exists with 12-15 tests
- All search functions tested independently: SearchFTS, SearchFTSByFilename, SearchFTSTracks
- Pure helpers tested: tokeniseForFTS, buildFTSQuery, stripExtForSearch
- Index operations tested: InsertSearchIndex, DeleteSearchIndex, ClearSearchIndex, RebuildSearchIndex
- Diacritics behavior verified (Beyonce → Beyoncé)
- Special characters handled (AC/DC, quotes)
- Search ranking produces consistent ordering
- Migrations verified (user_version >= 3, UNIQUE index works)
- All tests pass with `go test -race`
</success_criteria>
<output>
After completion, create `.planning/phases/05-database-library-tests/05-01-SUMMARY.md`
</output>
@@ -0,0 +1,331 @@
---
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>