Files
minstrel/internal/coverart/artist_enricher_test.go
bvandeusen 412b9e37eb test(coverart): no-MBID enrich settles 'none' (per canonical behavior)
Operator decision: the enricher is canonical. No MBID still runs the
provider chain (name-based providers — Deezer/Last.fm — resolve
without an MBID); if every provider returns ErrNotFound the row
settles cover/artist source 'none' at the current sources version
(re-eligible only when the registered provider set changes). It does
NOT skip-and-leave-NULL.

The two _NoMBID_LeavesNull tests predated the name-based providers
(0020 slice) and asserted the old skip→NULL contract. Updated:
- TestEnrichArtist_NoMBID_SettlesNone: stub now returns ErrNotFound
  (realistic MBID-only-provider-with-empty-MBID), expect source 'none'.
- TestEnrichAlbum_NoSidecarNoMBID_SettlesNone: empty registry →
  allWere404 stays true → expect 'none'.

Last failing cluster from the CI-integration initiative; suite should
now be fully green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:27:01 -04:00

534 lines
15 KiB
Go

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_SettlesNone(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
q := dbq.New(pool)
resetRegistryForTests()
t.Cleanup(resetRegistryForTests)
// No MBID still runs the provider chain (name-based providers can
// resolve without one). An MBID-only provider returns ErrNotFound
// for an empty MBID; with the whole chain returning ErrNotFound the
// row settles 'none' (version-stamped), NOT NULL.
stub := &stubArtistProvider{
fakeProvider: fakeProvider{id: "stub-artist", defaultOn: true},
err: ErrNotFound,
}
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 || *row.ArtistArtSource != "none" {
t.Errorf("source = %v, want 'none' (settled)", row.ArtistArtSource)
}
}