332 lines
15 KiB
Markdown
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>
|