Files
minstrel/internal/quarantine/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

345 lines
10 KiB
Go

package quarantine
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"
)
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 seedUser(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: false,
})
if err != nil {
t.Fatalf("seed user %s: %v", name, err)
}
return u
}
func seedTrack(t *testing.T, pool *pgxpool.Pool, title, mbid string) (dbq.Track, dbq.Album, dbq.Artist) {
t.Helper()
q := dbq.New(pool)
artist, err := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{
Name: "Test Artist", SortName: "Test Artist",
})
if err != nil {
t.Fatalf("artist: %v", err)
}
albumMBID := mbid + "-album"
album, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
Title: "Test Album", SortTitle: "Test Album",
ArtistID: artist.ID, Mbid: &albumMBID,
})
if err != nil {
t.Fatalf("album: %v", err)
}
track, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
Title: title, AlbumID: album.ID, ArtistID: artist.ID,
DurationMs: 1000, FilePath: filepath.Join(t.TempDir(), title+".mp3"),
FileSize: 100, FileFormat: "mp3",
})
if err != nil {
t.Fatalf("track: %v", err)
}
return track, album, artist
}
func TestFlag_HappyPath(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
track, _, _ := seedTrack(t, pool, "Bad Track", "abc")
svc := NewService(pool, "")
row, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "crackly")
if err != nil {
t.Fatalf("Flag: %v", err)
}
if string(row.Reason) != "bad_rip" {
t.Errorf("reason = %v", row.Reason)
}
if row.Notes == nil || *row.Notes != "crackly" {
t.Errorf("notes = %v", row.Notes)
}
}
func TestFlag_UpsertOnSecondFlag(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
track, _, _ := seedTrack(t, pool, "T", "x")
svc := NewService(pool, "")
if _, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "first"); err != nil {
t.Fatalf("first flag: %v", err)
}
row, err := svc.Flag(context.Background(), user.ID, track.ID, "wrong_tags", "")
if err != nil {
t.Fatalf("second flag: %v", err)
}
if string(row.Reason) != "wrong_tags" {
t.Errorf("reason = %v", row.Reason)
}
if row.Notes != nil {
t.Errorf("notes = %v, want nil after empty notes upsert", row.Notes)
}
}
func TestFlag_NonexistentTrackReturnsErrTrackNotFound(t *testing.T) {
pool := newPool(t)
user := seedUser(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, "")
_, err := svc.Flag(context.Background(), user.ID, bogus, "bad_rip", "")
if !errors.Is(err, ErrTrackNotFound) {
t.Errorf("err = %v, want ErrTrackNotFound", err)
}
}
func TestFlag_BadReasonRejected(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
track, _, _ := seedTrack(t, pool, "T", "x")
svc := NewService(pool, "")
_, err := svc.Flag(context.Background(), user.ID, track.ID, "garbage", "")
if !errors.Is(err, ErrBadReason) {
t.Errorf("err = %v, want ErrBadReason", err)
}
}
func TestUnflag_DeletesRow(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
track, _, _ := seedTrack(t, pool, "T", "x")
svc := NewService(pool, "")
if _, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", ""); err != nil {
t.Fatalf("Flag: %v", err)
}
if err := svc.Unflag(context.Background(), user.ID, track.ID); err != nil {
t.Fatalf("Unflag: %v", err)
}
if err := svc.Unflag(context.Background(), user.ID, track.ID); !errors.Is(err, ErrQuarantineNotFound) {
t.Errorf("second Unflag err = %v, want ErrQuarantineNotFound", err)
}
}
func TestListMine_OrderedNewestFirst(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
t1, _, _ := seedTrack(t, pool, "T1", "x")
t2, _, _ := seedTrack(t, pool, "T2", "y")
svc := NewService(pool, "")
if _, err := svc.Flag(context.Background(), user.ID, t1.ID, "bad_rip", ""); err != nil {
t.Fatalf("Flag t1: %v", err)
}
if _, err := svc.Flag(context.Background(), user.ID, t2.ID, "duplicate", ""); err != nil {
t.Fatalf("Flag t2: %v", err)
}
rows, err := svc.ListMine(context.Background(), user.ID)
if err != nil {
t.Fatalf("ListMine: %v", err)
}
if len(rows) != 2 {
t.Fatalf("len = %d, want 2", len(rows))
}
// T2 was flagged second — newest first.
if rows[0].Quarantine.TrackID != t2.ID {
t.Errorf("first row track = %v, want T2 (%v)", rows[0].Quarantine.TrackID, t2.ID)
}
}
func TestListAdminQueue_AggregatesByTrackWithReasonCounts(t *testing.T) {
pool := newPool(t)
alice := seedUser(t, pool, "alice")
bob := seedUser(t, pool, "bob")
carol := seedUser(t, pool, "carol")
track, _, _ := seedTrack(t, pool, "Hot Mess", "abc")
svc := NewService(pool, "")
if _, err := svc.Flag(context.Background(), alice.ID, track.ID, "bad_rip", ""); err != nil {
t.Fatalf("alice flag: %v", err)
}
if _, err := svc.Flag(context.Background(), bob.ID, track.ID, "bad_rip", ""); err != nil {
t.Fatalf("bob flag: %v", err)
}
if _, err := svc.Flag(context.Background(), carol.ID, track.ID, "wrong_tags", ""); err != nil {
t.Fatalf("carol flag: %v", err)
}
rows, err := svc.ListAdminQueue(context.Background())
if err != nil {
t.Fatalf("ListAdminQueue: %v", err)
}
if len(rows) != 1 {
t.Fatalf("len = %d, want 1 aggregated row", len(rows))
}
r := rows[0]
if r.ReportCount != 3 {
t.Errorf("report_count = %d, want 3", r.ReportCount)
}
if r.ReasonCounts["bad_rip"] != 2 || r.ReasonCounts["wrong_tags"] != 1 {
t.Errorf("reason_counts = %+v, want bad_rip=2 wrong_tags=1", r.ReasonCounts)
}
if len(r.Reports) != 3 {
t.Errorf("reports len = %d, want 3", len(r.Reports))
}
}
func TestResolve_ClearsRowsAndWritesAudit(t *testing.T) {
pool := newPool(t)
alice := seedUser(t, pool, "alice")
bob := seedUser(t, pool, "bob")
track, _, _ := seedTrack(t, pool, "T", "x")
svc := NewService(pool, "")
if _, err := svc.Flag(context.Background(), alice.ID, track.ID, "bad_rip", ""); err != nil {
t.Fatalf("alice flag: %v", err)
}
if _, err := svc.Flag(context.Background(), bob.ID, track.ID, "wrong_tags", ""); err != nil {
t.Fatalf("bob flag: %v", err)
}
audit, err := svc.Resolve(context.Background(), track.ID, alice.ID)
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if audit.AffectedUsers != 2 {
t.Errorf("affected_users = %d, want 2", audit.AffectedUsers)
}
if audit.Action != dbq.QuarantineActionKindResolved {
t.Errorf("action = %v, want resolved", audit.Action)
}
n, _ := dbq.New(pool).CountQuarantineForTrack(context.Background(), track.ID)
if n != 0 {
t.Errorf("rows after resolve = %d, want 0", n)
}
}
func TestResolve_NoExistingRowsStillWritesAudit(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
track, _, _ := seedTrack(t, pool, "T", "x")
svc := NewService(pool, "")
// No flags applied — resolve a track with zero quarantine rows.
audit, err := svc.Resolve(context.Background(), track.ID, user.ID)
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if audit.AffectedUsers != 0 {
t.Errorf("affected_users = %d, want 0", audit.AffectedUsers)
}
if audit.Action != dbq.QuarantineActionKindResolved {
t.Errorf("action = %v, want resolved", audit.Action)
}
}
func TestResolve_TrackNotFound(t *testing.T) {
pool := newPool(t)
user := seedUser(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, "")
_, err := svc.Resolve(context.Background(), bogus, user.ID)
if !errors.Is(err, ErrTrackNotFound) {
t.Errorf("err = %v, want ErrTrackNotFound", err)
}
}
func TestDeleteFile_RemovesFileAndAuditsAffected(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
q := dbq.New(pool)
dir := t.TempDir()
path := filepath.Join(dir, "track.mp3")
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "A", SortName: "A"})
album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "Al", SortTitle: "Al", ArtistID: artist.ID})
track, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
Title: "T", AlbumID: album.ID, ArtistID: artist.ID,
DurationMs: 1000, FilePath: path, FileSize: 1, FileFormat: "mp3",
})
svc := NewService(pool, "")
if _, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", ""); err != nil {
t.Fatalf("Flag: %v", err)
}
audit, err := svc.DeleteFile(context.Background(), track.ID, user.ID)
if err != nil {
t.Fatalf("DeleteFile: %v", err)
}
if audit.AffectedUsers != 1 {
t.Errorf("affected_users = %d, want 1", audit.AffectedUsers)
}
if audit.Action != dbq.QuarantineActionKindDeletedFile {
t.Errorf("action = %v, want deleted_file", audit.Action)
}
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Errorf("file still exists: %v", err)
}
// Track row gone; CASCADE cleared the quarantine row.
if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil {
t.Errorf("track row still exists")
}
}
func TestDeleteFile_TrackNotFound(t *testing.T) {
pool := newPool(t)
user := seedUser(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, "")
_, err := svc.DeleteFile(context.Background(), bogus, user.ID)
if !errors.Is(err, ErrTrackNotFound) {
t.Errorf("err = %v, want ErrTrackNotFound", err)
}
}