package coverart import ( "context" "io" "log/slog" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/db" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" ) func newPool(t *testing.T) *pgxpool.Pool { t.Helper() if testing.Short() { t.Skip("skipping integration test in -short mode") } dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") if dsn == "" { t.Skip("MINSTREL_TEST_DATABASE_URL not set") } if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { t.Fatalf("migrate: %v", err) } pool, err := pgxpool.New(context.Background(), dsn) if err != nil { t.Fatalf("pool: %v", err) } t.Cleanup(pool.Close) dbtest.ResetDB(t, pool) return pool } func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } // seedAlbumWithTrack creates an artist + album + track triple in a temp // dir. Returns album ID and the album-dir path. func seedAlbumWithTrack(t *testing.T, pool *pgxpool.Pool, title, artistName, mbid string) (pgtype.UUID, string) { t.Helper() q := dbq.New(pool) dir := t.TempDir() a, err := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ Name: artistName, SortName: artistName, }) if err != nil { t.Fatalf("seed artist: %v", err) } var mbidParam *string if mbid != "" { mbidParam = &mbid } al, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ Title: title, SortTitle: title, ArtistID: a.ID, Mbid: mbidParam, }) if err != nil { t.Fatalf("seed album: %v", err) } trackPath := filepath.Join(dir, title+".mp3") if err := os.WriteFile(trackPath, []byte("audio"), 0o644); err != nil { t.Fatalf("write track: %v", err) } if _, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ Title: title, AlbumID: al.ID, ArtistID: a.ID, DurationMs: 1000, FilePath: trackPath, FileSize: 5, FileFormat: "mp3", }); err != nil { t.Fatalf("seed track: %v", err) } return al.ID, dir } func TestEnrichAlbum_SidecarFound(t *testing.T) { pool := newPool(t) id, dir := seedAlbumWithTrack(t, pool, "Sid", "Artist", "") if err := os.WriteFile(filepath.Join(dir, "cover.jpg"), []byte("img"), 0o644); err != nil { t.Fatal(err) } e := NewEnricher(pool, discardLogger(), nil, false) if err := e.EnrichAlbum(context.Background(), id); err != nil { t.Fatalf("enrich: %v", err) } row, err := dbq.New(pool).GetAlbumWithFirstTrackPath(context.Background(), id) if err != nil { t.Fatalf("get: %v", err) } if row.CoverArtSource == nil || *row.CoverArtSource != "sidecar" { t.Errorf("source = %v, want 'sidecar'", row.CoverArtSource) } if row.CoverArtPath == nil || *row.CoverArtPath != filepath.Join(dir, "cover.jpg") { t.Errorf("path = %v", row.CoverArtPath) } } func TestEnrichAlbum_NoSidecarNoMBID_RecordsNone(t *testing.T) { pool := newPool(t) id, _ := seedAlbumWithTrack(t, pool, "NoMbid", "Artist", "") e := NewEnricher(pool, discardLogger(), nil, true) if err := e.EnrichAlbum(context.Background(), id); err != nil { t.Fatalf("enrich: %v", err) } row, err := dbq.New(pool).GetAlbumWithFirstTrackPath(context.Background(), id) if err != nil { t.Fatalf("get: %v", err) } if row.CoverArtSource == nil || *row.CoverArtSource != "none" { t.Errorf("source = %v, want 'none'", row.CoverArtSource) } } func TestEnrichAlbum_MBCAAFetchSuccess(t *testing.T) { pool := newPool(t) id, dir := seedAlbumWithTrack(t, pool, "Mbcaa", "Artist", "test-mbid-123") upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "image/jpeg") _, _ = w.Write([]byte("MBCAA_BYTES")) })) defer upstream.Close() f := NewFetcher(FetcherConfig{BaseURL: upstream.URL, UserAgent: "test", MinPeriod: 0, HTTPClient: upstream.Client()}) e := NewEnricher(pool, discardLogger(), f, true) if err := e.EnrichAlbum(context.Background(), id); err != nil { t.Fatalf("enrich: %v", err) } row, err := dbq.New(pool).GetAlbumWithFirstTrackPath(context.Background(), id) if err != nil { t.Fatalf("get: %v", err) } if row.CoverArtSource == nil || *row.CoverArtSource != "mbcaa" { t.Errorf("source = %v, want 'mbcaa'", row.CoverArtSource) } expectedPath := filepath.Join(dir, "cover.jpg") if row.CoverArtPath == nil || *row.CoverArtPath != expectedPath { t.Errorf("path = %v, want %s", row.CoverArtPath, expectedPath) } body, err := os.ReadFile(expectedPath) if err != nil { t.Fatalf("read file: %v", err) } if string(body) != "MBCAA_BYTES" { t.Errorf("file body = %q", string(body)) } } func TestEnrichAlbum_MBCAA404_RecordsNone(t *testing.T) { pool := newPool(t) id, _ := seedAlbumWithTrack(t, pool, "Missing", "Artist", "missing-mbid") upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "not found", http.StatusNotFound) })) defer upstream.Close() f := NewFetcher(FetcherConfig{BaseURL: upstream.URL, UserAgent: "test", MinPeriod: 0, HTTPClient: upstream.Client()}) e := NewEnricher(pool, discardLogger(), f, true) if err := e.EnrichAlbum(context.Background(), id); err != nil { t.Fatalf("enrich: %v", err) } row, err := dbq.New(pool).GetAlbumWithFirstTrackPath(context.Background(), id) if err != nil { t.Fatalf("get: %v", err) } if row.CoverArtSource == nil || *row.CoverArtSource != "none" { t.Errorf("source = %v, want 'none'", row.CoverArtSource) } } func TestEnrichAlbum_AlreadySidecar_NoOp(t *testing.T) { pool := newPool(t) id, dir := seedAlbumWithTrack(t, pool, "Already", "Artist", "") if err := os.WriteFile(filepath.Join(dir, "cover.jpg"), []byte("first"), 0o644); err != nil { t.Fatal(err) } e := NewEnricher(pool, discardLogger(), nil, false) if err := e.EnrichAlbum(context.Background(), id); err != nil { t.Fatalf("first enrich: %v", err) } // Second call should be a no-op even if we change the file content. if err := os.WriteFile(filepath.Join(dir, "cover.jpg"), []byte("changed"), 0o644); err != nil { t.Fatal(err) } if err := e.EnrichAlbum(context.Background(), id); err != nil { t.Fatalf("second enrich: %v", err) } row, _ := dbq.New(pool).GetAlbumWithFirstTrackPath(context.Background(), id) if row.CoverArtSource == nil || *row.CoverArtSource != "sidecar" { t.Errorf("source = %v, want 'sidecar' (preserved)", row.CoverArtSource) } } func TestEnrichBatch_DrainsNullSourceOnly(t *testing.T) { pool := newPool(t) id1, dir1 := seedAlbumWithTrack(t, pool, "Drain1", "A", "") if err := os.WriteFile(filepath.Join(dir1, "cover.jpg"), []byte("c"), 0o644); err != nil { t.Fatal(err) } id2, _ := seedAlbumWithTrack(t, pool, "Drain2", "B", "") // Pre-mark id2 as 'none' — should NOT be drained by EnrichBatch. src := "none" if err := dbq.New(pool).SetAlbumCover(context.Background(), dbq.SetAlbumCoverParams{ ID: id2, Column2: "", CoverArtSource: &src, }); err != nil { t.Fatal(err) } e := NewEnricher(pool, discardLogger(), nil, false) processed, err := e.EnrichBatch(context.Background(), 100) if err != nil { t.Fatalf("batch: %v", err) } if processed != 1 { t.Errorf("processed = %d, want 1 (only id1 NULL)", processed) } row1, _ := dbq.New(pool).GetAlbumWithFirstTrackPath(context.Background(), id1) if row1.CoverArtSource == nil || *row1.CoverArtSource != "sidecar" { t.Errorf("id1 source = %v", row1.CoverArtSource) } row2, _ := dbq.New(pool).GetAlbumWithFirstTrackPath(context.Background(), id2) if row2.CoverArtSource == nil || *row2.CoverArtSource != "none" { t.Errorf("id2 source = %v (should still be 'none')", row2.CoverArtSource) } } func TestRetryAlbum_DeletesMbcaaFileAndRefetches(t *testing.T) { pool := newPool(t) id, dir := seedAlbumWithTrack(t, pool, "Retry", "Artist", "retry-mbid") hits := 0 upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { hits++ _, _ = w.Write([]byte("data")) })) defer upstream.Close() f := NewFetcher(FetcherConfig{BaseURL: upstream.URL, UserAgent: "test", MinPeriod: 0, HTTPClient: upstream.Client()}) e := NewEnricher(pool, discardLogger(), f, true) if err := e.EnrichAlbum(context.Background(), id); err != nil { t.Fatalf("first enrich: %v", err) } written := filepath.Join(dir, "cover.jpg") if _, err := os.Stat(written); err != nil { t.Fatalf("expected file at %s: %v", written, err) } if err := e.RetryAlbum(context.Background(), id); err != nil { t.Fatalf("retry: %v", err) } if hits < 2 { t.Errorf("expected ≥2 upstream hits (initial + retry), got %d", hits) } row, _ := dbq.New(pool).GetAlbumWithFirstTrackPath(context.Background(), id) if row.CoverArtSource == nil || *row.CoverArtSource != "mbcaa" { t.Errorf("source after retry = %v, want 'mbcaa'", row.CoverArtSource) } } func TestEnrichRetryMissing_DrainsNullAndNone(t *testing.T) { pool := newPool(t) // Three albums: // id1: NULL source, has sidecar → drains, becomes 'sidecar'. // id2: 'none' source, has sidecar → cleared then drained, becomes 'sidecar'. // id3: 'sidecar' source already → NOT touched. id1, dir1 := seedAlbumWithTrack(t, pool, "RM1", "A", "") if err := os.WriteFile(filepath.Join(dir1, "cover.jpg"), []byte("c1"), 0o644); err != nil { t.Fatal(err) } id2, dir2 := seedAlbumWithTrack(t, pool, "RM2", "B", "") if err := os.WriteFile(filepath.Join(dir2, "cover.jpg"), []byte("c2"), 0o644); err != nil { t.Fatal(err) } none := "none" if err := dbq.New(pool).SetAlbumCover(context.Background(), dbq.SetAlbumCoverParams{ ID: id2, Column2: "", CoverArtSource: &none, }); err != nil { t.Fatal(err) } id3, dir3 := seedAlbumWithTrack(t, pool, "RM3", "C", "") if err := os.WriteFile(filepath.Join(dir3, "cover.jpg"), []byte("c3"), 0o644); err != nil { t.Fatal(err) } sidecar := "sidecar" preservedPath := filepath.Join(dir3, "cover.jpg") if err := dbq.New(pool).SetAlbumCover(context.Background(), dbq.SetAlbumCoverParams{ ID: id3, Column2: preservedPath, CoverArtSource: &sidecar, }); err != nil { t.Fatal(err) } e := NewEnricher(pool, discardLogger(), nil, false) processed, err := e.EnrichRetryMissing(context.Background(), 100) if err != nil { t.Fatalf("retry missing: %v", err) } if processed != 2 { t.Errorf("processed = %d, want 2 (id1 NULL + id2 'none'; id3 already 'sidecar' not touched)", processed) } q := dbq.New(pool) for _, c := range []struct { id pgtype.UUID want string }{ {id1, "sidecar"}, {id2, "sidecar"}, {id3, "sidecar"}, } { row, _ := q.GetAlbumWithFirstTrackPath(context.Background(), c.id) if row.CoverArtSource == nil || *row.CoverArtSource != c.want { t.Errorf("album %v source = %v, want %q", c.id, row.CoverArtSource, c.want) } } }