Files
bvandeusen 5c386dc73e fix(server,web/m7-cover-sources): forward-fix CI
Three failures from the prior push:

1. internal/api/library.go and 3 other callers of q.GetArtistByID
   broke because T2 modified the query to use an explicit column
   list, making sqlc generate GetArtistByIDRow instead of returning
   dbq.Artist. dbq.Artist already has every column added by
   migration 0018 (artist_thumb_path / artist_fanart_path /
   artist_art_source / artist_art_sources_version), so the explicit
   column list was unnecessary work. Revert the SELECT to SELECT *,
   regenerate sqlc, GetArtistByIDRow disappears, all callers compile
   again.

2. internal/tracks/service_test.go missed the dataDir parameter
   added to NewService in T9. Add "" as the 4th arg to every test
   call site (tests don't exercise the artist-art cleanup path; the
   empty dataDir is fine).

3. integrations.test.ts:356's mock.calls.filter callback had a
   too-narrow type annotation that svelte-check rejected. Relax to
   any[] to match what mock.calls actually is.
2026-05-06 13:22:36 -04:00

452 lines
14 KiB
Go

package tracks
import (
"context"
"errors"
"io"
"log/slog"
"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"
)
// --- test seed helpers (inlined; mirrors lidarrquarantine patterns) -----
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 seedAdmin(t *testing.T, pool *pgxpool.Pool, name string) dbq.User {
t.Helper()
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + name, PasswordHash: "x",
ApiToken: name + "-token", IsAdmin: true,
})
if err != nil {
t.Fatalf("seed admin %s: %v", name, err)
}
return u
}
// seedArtistAlbumTrack creates an artist + album + track triple. mbid is
// set on both the album and the track when non-empty (the album mbid is
// derived from the track mbid suffix); empty leaves both nil so the
// track exercises the local-only path. filePath is the on-disk path
// stored on the track row. Names are unique per call so multiple seeds
// in one test don't collide on UNIQUE constraints.
func seedArtistAlbumTrack(t *testing.T, pool *pgxpool.Pool, prefix, mbid, filePath string) (dbq.Artist, dbq.Album, dbq.Track) {
t.Helper()
q := dbq.New(pool)
artist, err := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{
Name: prefix + " Artist", SortName: prefix + " Artist",
})
if err != nil {
t.Fatalf("artist: %v", err)
}
var albumMBIDPtr *string
if mbid != "" {
s := mbid + "-album"
albumMBIDPtr = &s
}
album, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
Title: prefix + " Album", SortTitle: prefix + " Album",
ArtistID: artist.ID, Mbid: albumMBIDPtr,
})
if err != nil {
t.Fatalf("album: %v", err)
}
var trackMBIDPtr *string
if mbid != "" {
s := mbid
trackMBIDPtr = &s
}
track, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
Title: prefix + " Track", AlbumID: album.ID, ArtistID: artist.ID,
DurationMs: 1000, FilePath: filePath, FileSize: 1, FileFormat: "mp3",
Mbid: trackMBIDPtr,
})
if err != nil {
t.Fatalf("track: %v", err)
}
return artist, album, track
}
// --- fake LidarrUnmonitorer --------------------------------------------
// fakeLidarrUnmonitorer captures call args + a configurable error so
// tests can assert "called with mbid X" and "RemoveTrack tolerated this
// Lidarr error."
type fakeLidarrUnmonitorer struct {
calls []string // captured trackMbid per call
albumMbid string // last albumMbid received
err error
}
func (f *fakeLidarrUnmonitorer) UnmonitorTrack(_ context.Context, trackMbid, albumMbid string) error {
f.calls = append(f.calls, trackMbid)
f.albumMbid = albumMbid
return f.err
}
// --- tests --------------------------------------------------------------
func TestRemoveTrack_NotFound(t *testing.T) {
pool := newPool(t)
admin := seedAdmin(t, pool, "alice")
var bogus pgtype.UUID
bogus.Bytes = [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
bogus.Valid = true
svc := NewService(pool, nil, nil, "")
_, _, _, err := svc.RemoveTrack(context.Background(), bogus, admin.ID, false)
if !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
}
func TestRemoveTrack_LocalFile_HappyPath(t *testing.T) {
pool := newPool(t)
admin := seedAdmin(t, pool, "alice")
dir := t.TempDir()
path := filepath.Join(dir, "track.mp3")
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
// mbid empty → no Lidarr branch even with unmonitor=true.
_, _, track := seedArtistAlbumTrack(t, pool, "Solo", "", path)
fake := &fakeLidarrUnmonitorer{}
svc := NewService(pool, nil, fake, "")
_, _, lidarrFailed, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID, false)
if err != nil {
t.Fatalf("RemoveTrack: %v", err)
}
if lidarrFailed {
t.Error("lidarrUnmonitorFailed = true; want false on local-file happy path")
}
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Errorf("file still present: %v", err)
}
q := dbq.New(pool)
if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil {
t.Errorf("track row still exists")
}
if len(fake.calls) != 0 {
t.Errorf("Lidarr called %d times; want 0 (no mbid, unmonitor=false)", len(fake.calls))
}
}
func TestRemoveTrack_FileAlreadyMissing(t *testing.T) {
pool := newPool(t)
admin := seedAdmin(t, pool, "alice")
// Path doesn't exist on disk; service must tolerate and clean up DB.
missing := filepath.Join(t.TempDir(), "does-not-exist.mp3")
_, _, track := seedArtistAlbumTrack(t, pool, "Ghost", "", missing)
svc := NewService(pool, nil, nil, "")
_, _, _, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID, false)
if err != nil {
t.Fatalf("RemoveTrack: %v", err)
}
q := dbq.New(pool)
if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil {
t.Errorf("track row still exists")
}
}
func TestRemoveTrack_Cascade_AlbumEmpty(t *testing.T) {
pool := newPool(t)
admin := seedAdmin(t, pool, "alice")
q := dbq.New(pool)
// Two-album artist: removing the sole track of album A leaves A
// orphaned, but the artist still has album B (so the artist cascade
// must NOT fire).
artist, err := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{
Name: "Cascade Artist", SortName: "Cascade Artist",
})
if err != nil {
t.Fatalf("artist: %v", err)
}
albumA, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
Title: "Album A", SortTitle: "Album A", ArtistID: artist.ID,
})
if err != nil {
t.Fatalf("albumA: %v", err)
}
albumB, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
Title: "Album B", SortTitle: "Album B", ArtistID: artist.ID,
})
if err != nil {
t.Fatalf("albumB: %v", err)
}
dir := t.TempDir()
pathA := filepath.Join(dir, "a.mp3")
pathB := filepath.Join(dir, "b.mp3")
_ = os.WriteFile(pathA, []byte("x"), 0o644)
_ = os.WriteFile(pathB, []byte("x"), 0o644)
trackA, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
Title: "TA", AlbumID: albumA.ID, ArtistID: artist.ID,
DurationMs: 1000, FilePath: pathA, FileSize: 1, FileFormat: "mp3",
})
if err != nil {
t.Fatalf("trackA: %v", err)
}
if _, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
Title: "TB", AlbumID: albumB.ID, ArtistID: artist.ID,
DurationMs: 1000, FilePath: pathB, FileSize: 1, FileFormat: "mp3",
}); err != nil {
t.Fatalf("trackB: %v", err)
}
svc := NewService(pool, nil, nil, "")
delAlbum, delArtist, _, err := svc.RemoveTrack(context.Background(), trackA.ID, admin.ID, false)
if err != nil {
t.Fatalf("RemoveTrack: %v", err)
}
if delAlbum == nil || *delAlbum != albumA.ID {
t.Errorf("deletedAlbumID = %v, want %v", delAlbum, albumA.ID)
}
if delArtist != nil {
t.Errorf("deletedArtistID = %v, want nil (artist still has album B)", delArtist)
}
if _, err := q.GetAlbumByID(context.Background(), albumA.ID); err == nil {
t.Errorf("album A still exists after cascade")
}
if _, err := q.GetAlbumByID(context.Background(), albumB.ID); err != nil {
t.Errorf("album B unexpectedly removed: %v", err)
}
if _, err := q.GetArtistByID(context.Background(), artist.ID); err != nil {
t.Errorf("artist unexpectedly removed: %v", err)
}
}
func TestRemoveTrack_Cascade_ArtistEmpty(t *testing.T) {
pool := newPool(t)
admin := seedAdmin(t, pool, "alice")
dir := t.TempDir()
path := filepath.Join(dir, "lone.mp3")
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
artist, album, track := seedArtistAlbumTrack(t, pool, "Lone", "", path)
svc := NewService(pool, nil, nil, "")
delAlbum, delArtist, _, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID, false)
if err != nil {
t.Fatalf("RemoveTrack: %v", err)
}
if delAlbum == nil || *delAlbum != album.ID {
t.Errorf("deletedAlbumID = %v, want %v", delAlbum, album.ID)
}
if delArtist == nil || *delArtist != artist.ID {
t.Errorf("deletedArtistID = %v, want %v", delArtist, artist.ID)
}
q := dbq.New(pool)
if _, err := q.GetAlbumByID(context.Background(), album.ID); err == nil {
t.Errorf("album still exists after cascade")
}
if _, err := q.GetArtistByID(context.Background(), artist.ID); err == nil {
t.Errorf("artist still exists after cascade")
}
}
// unmonitor=true AND mbid set → Lidarr called once; lidarrUnmonitorFailed=false.
// Uses two albums on the same artist so the album's row survives the
// cascade — that lets us assert the album mbid (captured pre-tx) is
// passed through correctly.
func TestRemoveTrack_Unmonitor_HappyPath(t *testing.T) {
pool := newPool(t)
admin := seedAdmin(t, pool, "alice")
q := dbq.New(pool)
artist, err := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{
Name: "Lidarr Artist", SortName: "Lidarr Artist",
})
if err != nil {
t.Fatalf("artist: %v", err)
}
albumMbid := "album-mbid-xyz"
album, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
Title: "Lidarr Album", SortTitle: "Lidarr Album",
ArtistID: artist.ID, Mbid: &albumMbid,
})
if err != nil {
t.Fatalf("album: %v", err)
}
dir := t.TempDir()
pathA := filepath.Join(dir, "a.mp3")
pathB := filepath.Join(dir, "b.mp3")
_ = os.WriteFile(pathA, []byte("x"), 0o644)
_ = os.WriteFile(pathB, []byte("x"), 0o644)
trackMbid := "track-mbid-abc"
track, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
Title: "T1", AlbumID: album.ID, ArtistID: artist.ID,
DurationMs: 1000, FilePath: pathA, FileSize: 1, FileFormat: "mp3",
Mbid: &trackMbid,
})
if err != nil {
t.Fatalf("track1: %v", err)
}
// Sibling track keeps the album alive after the delete.
if _, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
Title: "T2", AlbumID: album.ID, ArtistID: artist.ID,
DurationMs: 1000, FilePath: pathB, FileSize: 1, FileFormat: "mp3",
}); err != nil {
t.Fatalf("track2: %v", err)
}
fake := &fakeLidarrUnmonitorer{}
svc := NewService(pool, nil, fake, "")
_, _, lidarrFailed, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID, true)
if err != nil {
t.Fatalf("RemoveTrack: %v", err)
}
if lidarrFailed {
t.Error("lidarrUnmonitorFailed = true; want false on happy path")
}
if len(fake.calls) != 1 {
t.Fatalf("Lidarr call count = %d, want 1", len(fake.calls))
}
if fake.calls[0] != trackMbid {
t.Errorf("Lidarr called with mbid %q, want %q", fake.calls[0], trackMbid)
}
if fake.albumMbid != albumMbid {
t.Errorf("Lidarr called with albumMbid %q, want %q", fake.albumMbid, albumMbid)
}
}
// unmonitor=true AND Lidarr fails → file + DB still deleted, flag set,
// service returns nil error.
func TestRemoveTrack_Unmonitor_LidarrErrorIsNonFatal(t *testing.T) {
pool := newPool(t)
admin := seedAdmin(t, pool, "alice")
q := dbq.New(pool)
artist, err := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{
Name: "ErrFatal Artist", SortName: "ErrFatal Artist",
})
if err != nil {
t.Fatalf("artist: %v", err)
}
albumMbid := "err-album-mbid"
album, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
Title: "ErrFatal Album", SortTitle: "ErrFatal Album",
ArtistID: artist.ID, Mbid: &albumMbid,
})
if err != nil {
t.Fatalf("album: %v", err)
}
dir := t.TempDir()
path := filepath.Join(dir, "f.mp3")
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
trackMbid := "err-track-mbid"
track, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
Title: "T", AlbumID: album.ID, ArtistID: artist.ID,
DurationMs: 1000, FilePath: path, FileSize: 1, FileFormat: "mp3",
Mbid: &trackMbid,
})
if err != nil {
t.Fatalf("track: %v", err)
}
fake := &fakeLidarrUnmonitorer{err: errors.New("simulated lidarr 5xx")}
svc := NewService(pool, nil, fake, "")
_, _, lidarrFailed, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID, true)
if err != nil {
t.Fatalf("RemoveTrack: %v (want nil — Lidarr failure is non-fatal)", err)
}
if !lidarrFailed {
t.Error("lidarrUnmonitorFailed = false; want true after Lidarr error")
}
// File + DB still gone.
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Errorf("file still present after Lidarr error: %v", err)
}
if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil {
t.Errorf("track row still present after Lidarr error")
}
}
// unmonitor=true AND mbid empty (non-Lidarr track) → no Lidarr call.
func TestRemoveTrack_Unmonitor_NoMbidSkipsLidarr(t *testing.T) {
pool := newPool(t)
admin := seedAdmin(t, pool, "alice")
dir := t.TempDir()
path := filepath.Join(dir, "local.mp3")
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
_, _, track := seedArtistAlbumTrack(t, pool, "Local", "", path)
fake := &fakeLidarrUnmonitorer{}
svc := NewService(pool, nil, fake, "")
_, _, lidarrFailed, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID, true)
if err != nil {
t.Fatalf("RemoveTrack: %v", err)
}
if lidarrFailed {
t.Error("lidarrUnmonitorFailed = true; want false (no mbid)")
}
if len(fake.calls) != 0 {
t.Errorf("Lidarr call count = %d, want 0 (track has no mbid)", len(fake.calls))
}
}
// unmonitor=false AND mbid set → no Lidarr call.
func TestRemoveTrack_NoUnmonitor_SkipsLidarr(t *testing.T) {
pool := newPool(t)
admin := seedAdmin(t, pool, "alice")
dir := t.TempDir()
path := filepath.Join(dir, "lidarr.mp3")
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
_, _, track := seedArtistAlbumTrack(t, pool, "NoMon", "track-mbid", path)
fake := &fakeLidarrUnmonitorer{}
svc := NewService(pool, nil, fake, "")
_, _, lidarrFailed, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID, false)
if err != nil {
t.Fatalf("RemoveTrack: %v", err)
}
if lidarrFailed {
t.Error("lidarrUnmonitorFailed = true; want false (unmonitor=false)")
}
if len(fake.calls) != 0 {
t.Errorf("Lidarr call count = %d, want 0 (unmonitor=false)", len(fake.calls))
}
}