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(ctx 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)) } }