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