Files
minstrel/internal/tracks/service_test.go
T
bvandeusenandClaude Opus 5.5 3a16732883
release / govulncheck (push) Successful in 15s
release / web (push) Failing after 17s
release / go (push) Successful in 1m13s
release / integration (push) Successful in 4m56s
release / android (push) Successful in 5m9s
release / Build signed APK (releases and dev) (push) Successful in 5m19s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
feat: Minstrel no longer talks to Lidarr: its connection, Delete via Lidarr and the unmonitor options are gone, and the track reports are plain quarantine (M502 #5525)
Migration 0091 drops lidarr_config and renames the quarantine tables, types,
indexes and the history column off the Lidarr name. The Integrations Lidarr
card, the Reports "Delete via Lidarr" action, the merge and remove-track
unmonitor options, the Lidarr audit actions and error codes go, on the
server, web and Android. The README describes acquisition as it is now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:48:20 -04:00

256 lines
7.8 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 quarantine 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",
ApiTokenHash: 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
}
// --- 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, "")
_, _, err := svc.RemoveTrack(context.Background(), bogus, admin.ID)
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)
}
_, _, track := seedArtistAlbumTrack(t, pool, "Solo", "", path)
svc := NewService(pool, nil, "")
_, _, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID)
if err != nil {
t.Fatalf("RemoveTrack: %v", err)
}
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")
}
}
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, "")
_, _, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID)
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, "")
delAlbum, delArtist, err := svc.RemoveTrack(context.Background(), trackA.ID, admin.ID)
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, "")
delAlbum, delArtist, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID)
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")
}
}