5c386dc73e
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.
452 lines
14 KiB
Go
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))
|
|
}
|
|
}
|