package coverart import ( "context" "os" "path/filepath" "testing" "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) // stubArtistProvider is a configurable ArtistArtProvider for artist enricher // tests. Embeds fakeProvider for the base Provider methods; overrides // FetchArtistArt with controllable byte/error responses. type stubArtistProvider struct { fakeProvider thumb, fanart []byte err error } func (f *stubArtistProvider) FetchArtistArt(_ context.Context, _ ArtistRef) ([]byte, []byte, error) { return f.thumb, f.fanart, f.err } // insertArtist seeds a fresh artist via UpsertArtist and returns its UUID. // mbid may be "" to seed a MBID-less artist. func insertArtist(t *testing.T, q *dbq.Queries, ctx context.Context, name, mbid string) pgtype.UUID { t.Helper() var mbidParam *string if mbid != "" { mbidParam = &mbid } a, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{ Name: name, SortName: name, Mbid: mbidParam, }) if err != nil { t.Fatalf("insertArtist %q: %v", name, err) } return a.ID } // --- Eligibility tests --- func TestEnrichArtist_NullSource_Eligible(t *testing.T) { pool := newPool(t) ctx := context.Background() q := dbq.New(pool) resetRegistryForTests() t.Cleanup(resetRegistryForTests) stub := &stubArtistProvider{ fakeProvider: fakeProvider{id: "stub-artist", defaultOn: true}, thumb: []byte("thumb_bytes"), fanart: []byte("fanart_bytes"), } Register(stub) e := newTestEnricher(t, pool) artistID := insertArtist(t, q, ctx, "Null Artist", "11111111-1111-1111-1111-111111111111") dataDir := t.TempDir() if err := e.EnrichArtist(ctx, artistID, dataDir); err != nil { t.Fatalf("EnrichArtist: %v", err) } row, err := q.GetArtistByID(ctx, artistID) if err != nil { t.Fatalf("GetArtistByID: %v", err) } if row.ArtistArtSource == nil || *row.ArtistArtSource != "stub-artist" { t.Errorf("source = %v, want 'stub-artist'", row.ArtistArtSource) } } func TestEnrichArtist_FoundSource_Skip(t *testing.T) { pool := newPool(t) ctx := context.Background() q := dbq.New(pool) resetRegistryForTests() t.Cleanup(resetRegistryForTests) stub := &stubArtistProvider{ fakeProvider: fakeProvider{id: "stub-artist", defaultOn: true}, thumb: []byte("should_not_write"), } Register(stub) e := newTestEnricher(t, pool) artistID := insertArtist(t, q, ctx, "Found Artist", "22222222-2222-2222-2222-222222222222") // Pre-mark with a "found" source. src := "theaudiodb" if err := q.SetArtistArtWithVersion(ctx, dbq.SetArtistArtWithVersionParams{ ID: artistID, ArtistArtSource: &src, ArtistArtSourcesVersion: e.settings.CurrentVersion(), }); err != nil { t.Fatalf("set: %v", err) } dataDir := t.TempDir() if err := e.EnrichArtist(ctx, artistID, dataDir); err != nil { t.Fatalf("EnrichArtist: %v", err) } // Source must remain 'theaudiodb'; no files should have been written. row, err := q.GetArtistByID(ctx, artistID) if err != nil { t.Fatalf("GetArtistByID: %v", err) } if row.ArtistArtSource == nil || *row.ArtistArtSource != "theaudiodb" { t.Errorf("source = %v, want 'theaudiodb' (preserved)", row.ArtistArtSource) } thumbPath := filepath.Join(dataDir, "artist-art", uuidString(artistID), "thumb.jpg") if _, err := os.Stat(thumbPath); !os.IsNotExist(err) { t.Errorf("expected no thumb.jpg (skip), got stat err = %v", err) } } func TestEnrichArtist_NoneCurrentVersion_Skip(t *testing.T) { pool := newPool(t) ctx := context.Background() q := dbq.New(pool) resetRegistryForTests() t.Cleanup(resetRegistryForTests) stub := &stubArtistProvider{ fakeProvider: fakeProvider{id: "stub-artist", defaultOn: true}, thumb: []byte("should_not_write"), } Register(stub) e := newTestEnricher(t, pool) artistID := insertArtist(t, q, ctx, "None Current Artist", "33333333-3333-3333-3333-333333333333") src := "none" if err := q.SetArtistArtWithVersion(ctx, dbq.SetArtistArtWithVersionParams{ ID: artistID, ArtistArtSource: &src, ArtistArtSourcesVersion: e.settings.CurrentVersion(), }); err != nil { t.Fatalf("set: %v", err) } dataDir := t.TempDir() if err := e.EnrichArtist(ctx, artistID, dataDir); err != nil { t.Fatalf("EnrichArtist: %v", err) } // Source must remain 'none' at current version. row, err := q.GetArtistByID(ctx, artistID) if err != nil { t.Fatalf("GetArtistByID: %v", err) } if row.ArtistArtSource == nil || *row.ArtistArtSource != "none" { t.Errorf("source = %v, want 'none' (skip — current version)", row.ArtistArtSource) } } func TestEnrichArtist_NoneStaleVersion_FlipAndRetry(t *testing.T) { pool := newPool(t) ctx := context.Background() q := dbq.New(pool) resetRegistryForTests() t.Cleanup(resetRegistryForTests) stub := &stubArtistProvider{ fakeProvider: fakeProvider{id: "stub-artist", defaultOn: true}, thumb: []byte("retry_thumb"), fanart: []byte("retry_fanart"), } Register(stub) e := newTestEnricher(t, pool) artistID := insertArtist(t, q, ctx, "Stale None Artist", "44444444-4444-4444-4444-444444444444") // Set 'none' with a stale version (currentVersion - 1). staleVersion := e.settings.CurrentVersion() - 1 src := "none" if err := q.SetArtistArtWithVersion(ctx, dbq.SetArtistArtWithVersionParams{ ID: artistID, ArtistArtSource: &src, ArtistArtSourcesVersion: staleVersion, }); err != nil { t.Fatalf("set stale none: %v", err) } dataDir := t.TempDir() if err := e.EnrichArtist(ctx, artistID, dataDir); err != nil { t.Fatalf("EnrichArtist: %v", err) } row, err := q.GetArtistByID(ctx, artistID) if err != nil { t.Fatalf("GetArtistByID: %v", err) } // Stale 'none' should be flipped and re-enriched; stub succeeds so // source becomes the provider ID. if row.ArtistArtSource == nil || *row.ArtistArtSource != "stub-artist" { t.Errorf("source = %v, want 'stub-artist' (stale none flipped and retried)", row.ArtistArtSource) } } // --- Atomicity: thumb-only / fanart-only --- func TestEnrichArtist_ThumbOnly_ThumbPathSetFanartNull(t *testing.T) { pool := newPool(t) ctx := context.Background() q := dbq.New(pool) resetRegistryForTests() t.Cleanup(resetRegistryForTests) stub := &stubArtistProvider{ fakeProvider: fakeProvider{id: "thumb-only-provider", defaultOn: true}, thumb: []byte("thumb_only"), fanart: nil, } Register(stub) e := newTestEnricher(t, pool) artistID := insertArtist(t, q, ctx, "Thumb Only Artist", "55555555-5555-5555-5555-555555555555") dataDir := t.TempDir() if err := e.EnrichArtist(ctx, artistID, dataDir); err != nil { t.Fatalf("EnrichArtist: %v", err) } row, err := q.GetArtistByID(ctx, artistID) if err != nil { t.Fatalf("GetArtistByID: %v", err) } if row.ArtistArtSource == nil || *row.ArtistArtSource != "thumb-only-provider" { t.Errorf("source = %v, want 'thumb-only-provider'", row.ArtistArtSource) } if row.ArtistThumbPath == nil { t.Errorf("artist_thumb_path is nil, want a path") } if row.ArtistFanartPath != nil { t.Errorf("artist_fanart_path = %v, want nil (fanart not provided)", row.ArtistFanartPath) } if row.ArtistThumbPath != nil { body, rerr := os.ReadFile(*row.ArtistThumbPath) if rerr != nil { t.Fatalf("read thumb: %v", rerr) } if string(body) != "thumb_only" { t.Errorf("thumb content = %q, want 'thumb_only'", string(body)) } } } func TestEnrichArtist_FanartOnly_FanartPathSetThumbNull(t *testing.T) { pool := newPool(t) ctx := context.Background() q := dbq.New(pool) resetRegistryForTests() t.Cleanup(resetRegistryForTests) stub := &stubArtistProvider{ fakeProvider: fakeProvider{id: "fanart-only-provider", defaultOn: true}, thumb: nil, fanart: []byte("fanart_only"), } Register(stub) e := newTestEnricher(t, pool) artistID := insertArtist(t, q, ctx, "Fanart Only Artist", "66666666-6666-6666-6666-666666666666") dataDir := t.TempDir() if err := e.EnrichArtist(ctx, artistID, dataDir); err != nil { t.Fatalf("EnrichArtist: %v", err) } row, err := q.GetArtistByID(ctx, artistID) if err != nil { t.Fatalf("GetArtistByID: %v", err) } if row.ArtistArtSource == nil || *row.ArtistArtSource != "fanart-only-provider" { t.Errorf("source = %v, want 'fanart-only-provider'", row.ArtistArtSource) } if row.ArtistThumbPath != nil { t.Errorf("artist_thumb_path = %v, want nil (thumb not provided)", row.ArtistThumbPath) } if row.ArtistFanartPath == nil { t.Errorf("artist_fanart_path is nil, want a path") } } // --- All-404 settles to 'none' --- func TestEnrichArtist_All404_SettlesNone(t *testing.T) { pool := newPool(t) ctx := context.Background() q := dbq.New(pool) resetRegistryForTests() t.Cleanup(resetRegistryForTests) stub := &stubArtistProvider{ fakeProvider: fakeProvider{id: "404-provider", defaultOn: true}, err: ErrNotFound, } Register(stub) e := newTestEnricher(t, pool) artistID := insertArtist(t, q, ctx, "All404 Artist", "77777777-7777-7777-7777-777777777777") dataDir := t.TempDir() if err := e.EnrichArtist(ctx, artistID, dataDir); err != nil { t.Fatalf("EnrichArtist: %v", err) } row, err := q.GetArtistByID(ctx, artistID) if err != nil { t.Fatalf("GetArtistByID: %v", err) } if row.ArtistArtSource == nil || *row.ArtistArtSource != "none" { t.Errorf("source = %v, want 'none' (all providers 404)", row.ArtistArtSource) } if row.ArtistArtSourcesVersion != e.settings.CurrentVersion() { t.Errorf("version = %d, want %d", row.ArtistArtSourcesVersion, e.settings.CurrentVersion()) } } // --- Empty providers list settles vacuously to 'none' --- func TestEnrichArtist_EmptyProviders_SettlesNone(t *testing.T) { pool := newPool(t) ctx := context.Background() q := dbq.New(pool) // Register a plain provider that does NOT implement ArtistArtProvider — // EnabledArtistProviders will return an empty slice. resetRegistryForTests() t.Cleanup(resetRegistryForTests) Register(&fakeProvider{id: "album-only", defaultOn: true}) e := newTestEnricher(t, pool) artistID := insertArtist(t, q, ctx, "No Provider Artist", "88888888-8888-8888-8888-888888888888") dataDir := t.TempDir() if err := e.EnrichArtist(ctx, artistID, dataDir); err != nil { t.Fatalf("EnrichArtist: %v", err) } row, err := q.GetArtistByID(ctx, artistID) if err != nil { t.Fatalf("GetArtistByID: %v", err) } if row.ArtistArtSource == nil || *row.ArtistArtSource != "none" { t.Errorf("source = %v, want 'none' (vacuous all-404 with empty provider list)", row.ArtistArtSource) } } // --- ErrTransient leaves row NULL --- func TestEnrichArtist_ErrTransient_LeavesNull(t *testing.T) { pool := newPool(t) ctx := context.Background() q := dbq.New(pool) resetRegistryForTests() t.Cleanup(resetRegistryForTests) stub := &stubArtistProvider{ fakeProvider: fakeProvider{id: "transient-provider", defaultOn: true}, err: ErrTransient, } Register(stub) e := newTestEnricher(t, pool) artistID := insertArtist(t, q, ctx, "Transient Artist", "99999999-9999-9999-9999-999999999999") dataDir := t.TempDir() if err := e.EnrichArtist(ctx, artistID, dataDir); err != nil { t.Fatalf("EnrichArtist: %v", err) } row, err := q.GetArtistByID(ctx, artistID) if err != nil { t.Fatalf("GetArtistByID: %v", err) } // Transient: source must remain NULL (eligible for retry). if row.ArtistArtSource != nil { t.Errorf("source = %v, want nil (transient should not settle)", row.ArtistArtSource) } } // --- Filesystem layout --- func TestEnrichArtist_FilesystemLayout(t *testing.T) { pool := newPool(t) ctx := context.Background() q := dbq.New(pool) resetRegistryForTests() t.Cleanup(resetRegistryForTests) thumbBytes := []byte("thumb_content") fanartBytes := []byte("fanart_content") stub := &stubArtistProvider{ fakeProvider: fakeProvider{id: "layout-provider", defaultOn: true}, thumb: thumbBytes, fanart: fanartBytes, } Register(stub) e := newTestEnricher(t, pool) artistID := insertArtist(t, q, ctx, "Layout Artist", "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") dataDir := t.TempDir() if err := e.EnrichArtist(ctx, artistID, dataDir); err != nil { t.Fatalf("EnrichArtist: %v", err) } artistIDStr := uuidString(artistID) thumbPath := filepath.Join(dataDir, "artist-art", artistIDStr, "thumb.jpg") fanartPath := filepath.Join(dataDir, "artist-art", artistIDStr, "fanart.jpg") thumbBody, rerr := os.ReadFile(thumbPath) if rerr != nil { t.Fatalf("read thumb.jpg: %v", rerr) } if string(thumbBody) != string(thumbBytes) { t.Errorf("thumb.jpg = %q, want %q", thumbBody, thumbBytes) } fanartBody, rerr := os.ReadFile(fanartPath) if rerr != nil { t.Fatalf("read fanart.jpg: %v", rerr) } if string(fanartBody) != string(fanartBytes) { t.Errorf("fanart.jpg = %q, want %q", fanartBody, fanartBytes) } row, err := q.GetArtistByID(ctx, artistID) if err != nil { t.Fatalf("GetArtistByID: %v", err) } if row.ArtistThumbPath == nil || *row.ArtistThumbPath != thumbPath { t.Errorf("DB thumb_path = %v, want %s", row.ArtistThumbPath, thumbPath) } if row.ArtistFanartPath == nil || *row.ArtistFanartPath != fanartPath { t.Errorf("DB fanart_path = %v, want %s", row.ArtistFanartPath, fanartPath) } } // --- CleanupArtistArt --- func TestCleanupArtistArt_RemovesDirectory(t *testing.T) { dataDir := t.TempDir() artistID := pgtype.UUID{ Bytes: [16]byte{0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb}, Valid: true, } artistDir := filepath.Join(dataDir, "artist-art", uuidString(artistID)) if err := os.MkdirAll(artistDir, 0o755); err != nil { t.Fatalf("mkdir: %v", err) } if err := os.WriteFile(filepath.Join(artistDir, "thumb.jpg"), []byte("x"), 0o644); err != nil { t.Fatalf("write: %v", err) } if err := CleanupArtistArt(dataDir, artistID); err != nil { t.Fatalf("CleanupArtistArt: %v", err) } if _, err := os.Stat(artistDir); !os.IsNotExist(err) { t.Errorf("directory still exists after cleanup: stat err = %v", err) } } func TestCleanupArtistArt_IdempotentMissingDir(t *testing.T) { dataDir := t.TempDir() artistID := pgtype.UUID{ Bytes: [16]byte{0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc}, Valid: true, } // Directory does not exist — should be a no-op. if err := CleanupArtistArt(dataDir, artistID); err != nil { t.Fatalf("CleanupArtistArt on missing dir: %v", err) } } // --- MBID guard --- func TestEnrichArtist_NoMBID_LeavesNull(t *testing.T) { pool := newPool(t) ctx := context.Background() q := dbq.New(pool) resetRegistryForTests() t.Cleanup(resetRegistryForTests) stub := &stubArtistProvider{ fakeProvider: fakeProvider{id: "stub-artist", defaultOn: true}, thumb: []byte("should_not_write"), } Register(stub) e := newTestEnricher(t, pool) // Seed with no MBID. artistID := insertArtist(t, q, ctx, "No MBID Artist", "") dataDir := t.TempDir() if err := e.EnrichArtist(ctx, artistID, dataDir); err != nil { t.Fatalf("EnrichArtist: %v", err) } row, err := q.GetArtistByID(ctx, artistID) if err != nil { t.Fatalf("GetArtistByID: %v", err) } if row.ArtistArtSource != nil { t.Errorf("source = %v, want nil (no MBID — skip)", row.ArtistArtSource) } }