331 lines
11 KiB
Go
331 lines
11 KiB
Go
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, r *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, r *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, r *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)
|
|
}
|
|
}
|
|
}
|