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

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
backend/library/scan_test.go
true
TEST-06
truths artifacts key_links
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
path provides min_lines
backend/library/scan_test.go Entity cache tests, pure helper tests, orphan cleanup tests 300
from to via pattern
backend/library/scan_test.go backend/library/library.go direct function calls (same package — internal tests) cachedUpsertArtistCredit|cachedLinkArtist|cachedUpsertGenre|resolveReleaseGroup|getRecordingName|toNullInt64|toNullString
from to via pattern
backend/library/scan_test.go backend/library/query.go direct function calls (same package) splitGenres|mapTrackRow
from to via pattern
backend/library/scan_test.go backend/database/testhelper.go NewTestDB(t) for DB-backed tests database.NewTestDB
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.

<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.md

From 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 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
Task 1: Pure helper tests (no DB needed) backend/library/scan_test.go 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. 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.

Task 2: Entity cache + orphan cleanup tests (DB-backed) backend/library/scan_test.go 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):

  1. 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
  2. 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
  3. 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")
  4. 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
  5. 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
  6. 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):

  1. 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:

  1. 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.

```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>