15 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 05-database-library-tests | 02 | execute | 1 |
|
true |
|
|
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.
<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>
@.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.mdFrom backend/library/library.go — entity cache:
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:
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:
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:
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:
type ScanMetrics struct { ... }
func newScanMetrics() *ScanMetrics
From backend/database:
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):
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:
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 testsdatabase.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
Pure helper tests (no DB dependency):
-
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). -
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)
-
TestToNullString— table-driven subtests:- "" → sql.NullString{Valid: false}
- "rock" → sql.NullString{String: "rock", Valid: true}
-
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"]
-
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.
cd backend && go test -race -run "TestGetRecordingName|TestToNullInt64|TestToNullString|TestSplitGenres|TestMapTrackRow" ./library/ -v -count=1
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.
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):
-
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
- Create library + DB, create fresh entityCache via
-
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
-
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")
-
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
- Call
-
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
-
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):
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:
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).
cd backend && go test -race ./library/ -v -count=1
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.
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>