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)
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
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
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>
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
// Package quarantine owns the per-user track quarantine workflow.
|
||||
// Users flag a track as broken (Flag/Unflag), the SPA hides the track
|
||||
// from their views, and admins resolve the resulting reports via the
|
||||
// Service's admin actions (Resolve / DeleteFile).
|
||||
package quarantine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||
)
|
||||
|
||||
// Public errors. Handlers map these to API codes.
|
||||
var (
|
||||
ErrBadReason = errors.New("quarantine: invalid reason")
|
||||
ErrTrackNotFound = errors.New("quarantine: track not found")
|
||||
ErrQuarantineNotFound = errors.New("quarantine: quarantine row not found")
|
||||
)
|
||||
|
||||
// Service owns the quarantine lifecycle.
|
||||
type Service struct {
|
||||
// dataDir lets a Delete file that empties an artist clear that artist's
|
||||
// cached art, the same as the admin remove-track path.
|
||||
dataDir string
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewService constructs a Service.
|
||||
func NewService(pool *pgxpool.Pool, dataDir string) *Service {
|
||||
return &Service{pool: pool, dataDir: dataDir}
|
||||
}
|
||||
|
||||
// Flag inserts or updates a quarantine row for the caller. Re-flagging
|
||||
// the same (user, track) overwrites reason+notes (and bumps created_at).
|
||||
func (s *Service) Flag(ctx context.Context, userID, trackID pgtype.UUID, reason string, notes string) (dbq.Quarantine, error) {
|
||||
if !validReason(reason) {
|
||||
return dbq.Quarantine{}, ErrBadReason
|
||||
}
|
||||
var notesPtr *string
|
||||
if notes != "" {
|
||||
notesPtr = ¬es
|
||||
}
|
||||
row, err := dbq.New(s.pool).UpsertQuarantine(ctx, dbq.UpsertQuarantineParams{
|
||||
UserID: userID,
|
||||
TrackID: trackID,
|
||||
Reason: dbq.QuarantineReason(reason),
|
||||
Notes: notesPtr,
|
||||
})
|
||||
if err != nil {
|
||||
// FK violation on track_id (no such track) → ErrTrackNotFound so the
|
||||
// handler can return 404 instead of a generic 500. Code 23503 is the
|
||||
// Postgres SQLSTATE for foreign_key_violation; the constraint check
|
||||
// guards against future FKs (e.g. user_id) firing the same code.
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23503" &&
|
||||
(pgErr.ConstraintName == "" || strings.Contains(pgErr.ConstraintName, "track")) {
|
||||
return dbq.Quarantine{}, ErrTrackNotFound
|
||||
}
|
||||
return dbq.Quarantine{}, fmt.Errorf("upsert: %w", err)
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// Unflag removes the caller's row. Returns ErrQuarantineNotFound if no
|
||||
// row exists for that (user, track).
|
||||
func (s *Service) Unflag(ctx context.Context, userID, trackID pgtype.UUID) error {
|
||||
_, err := dbq.New(s.pool).DeleteQuarantine(ctx, dbq.DeleteQuarantineParams{
|
||||
UserID: userID, TrackID: trackID,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrQuarantineNotFound
|
||||
}
|
||||
return fmt.Errorf("delete: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListMine returns the caller's quarantines with track + album + artist
|
||||
// detail. Drives /library/hidden. Ordered newest-first.
|
||||
func (s *Service) ListMine(ctx context.Context, userID pgtype.UUID) ([]dbq.ListQuarantineForUserRow, error) {
|
||||
return dbq.New(s.pool).ListQuarantineForUser(ctx, userID)
|
||||
}
|
||||
|
||||
// AdminQueueRow is the assembled aggregated row served by the admin
|
||||
// queue endpoint. The handler post-processes the SQL result + a per-track
|
||||
// ListQuarantineReports call to materialize reason_counts and the
|
||||
// per-user reports list.
|
||||
type AdminQueueRow struct {
|
||||
TrackID pgtype.UUID
|
||||
TrackTitle string
|
||||
ArtistName string
|
||||
AlbumTitle string
|
||||
AlbumID pgtype.UUID
|
||||
ReportCount int32
|
||||
LatestAt pgtype.Timestamptz
|
||||
ReasonCounts map[string]int
|
||||
Reports []UserReport
|
||||
}
|
||||
|
||||
// UserReport is the per-user detail under an aggregated admin row.
|
||||
type UserReport struct {
|
||||
UserID pgtype.UUID
|
||||
Username string
|
||||
Reason string
|
||||
Notes *string
|
||||
CreatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
// ListAdminQueue returns the aggregated admin queue. One row per track.
|
||||
// Performs an N+1 (one ListQuarantineReportsForTrack per aggregated row)
|
||||
// because materializing reason_counts in pure SQL is awkward; the queue
|
||||
// is small in practice (≲100 tracks at household scale). Revisit with
|
||||
// json_object_agg if the queue grows.
|
||||
func (s *Service) ListAdminQueue(ctx context.Context) ([]AdminQueueRow, error) {
|
||||
q := dbq.New(s.pool)
|
||||
aggregated, err := q.ListAdminQuarantineQueue(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("aggregate: %w", err)
|
||||
}
|
||||
out := make([]AdminQueueRow, 0, len(aggregated))
|
||||
for _, r := range aggregated {
|
||||
reports, err := q.ListQuarantineReportsForTrack(ctx, r.TrackID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reports for track %v: %w", r.TrackID, err)
|
||||
}
|
||||
rc := make(map[string]int, len(reports))
|
||||
userReports := make([]UserReport, 0, len(reports))
|
||||
for _, rep := range reports {
|
||||
rc[string(rep.Reason)]++
|
||||
userReports = append(userReports, UserReport{
|
||||
UserID: rep.UserID,
|
||||
Username: rep.Username,
|
||||
Reason: string(rep.Reason),
|
||||
Notes: rep.Notes,
|
||||
CreatedAt: rep.CreatedAt,
|
||||
})
|
||||
}
|
||||
out = append(out, AdminQueueRow{
|
||||
TrackID: r.TrackID,
|
||||
TrackTitle: r.TrackTitle,
|
||||
ArtistName: r.ArtistName,
|
||||
AlbumTitle: r.AlbumTitle,
|
||||
AlbumID: r.AlbumID,
|
||||
ReportCount: r.ReportCount,
|
||||
LatestAt: r.LatestAt,
|
||||
ReasonCounts: rc,
|
||||
Reports: userReports,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validReason(r string) bool {
|
||||
switch r {
|
||||
case "bad_rip", "wrong_file", "wrong_tags", "duplicate", "other":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Resolve clears all per-user quarantine rows for a track and writes an
|
||||
// audit log row. Idempotent — a track with no rows still writes an audit
|
||||
// entry with affected_users=0 (so admin can see "I clicked resolve on a
|
||||
// track that already had no reports").
|
||||
func (s *Service) Resolve(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.QuarantineAction, error) {
|
||||
q := dbq.New(s.pool)
|
||||
track, err := q.GetTrackByID(ctx, trackID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return dbq.QuarantineAction{}, ErrTrackNotFound
|
||||
}
|
||||
return dbq.QuarantineAction{}, fmt.Errorf("get track: %w", err)
|
||||
}
|
||||
snap, err := s.snapshot(ctx, q, track)
|
||||
if err != nil {
|
||||
return dbq.QuarantineAction{}, err
|
||||
}
|
||||
|
||||
affected, err := q.CountQuarantineForTrack(ctx, trackID)
|
||||
if err != nil {
|
||||
return dbq.QuarantineAction{}, fmt.Errorf("count: %w", err)
|
||||
}
|
||||
if err := q.DeleteQuarantineForTrack(ctx, trackID); err != nil {
|
||||
return dbq.QuarantineAction{}, fmt.Errorf("delete rows: %w", err)
|
||||
}
|
||||
return q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{
|
||||
TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName,
|
||||
AlbumTitle: snap.AlbumTitle, Action: dbq.QuarantineActionKindResolved,
|
||||
AdminID: adminID, AffectedUsers: affected,
|
||||
})
|
||||
}
|
||||
|
||||
// quarantineSnapshot is the audit-row-ready snapshot of track + album +
|
||||
// artist text columns. Used by the admin actions.
|
||||
type quarantineSnapshot struct {
|
||||
TrackTitle string
|
||||
ArtistName string
|
||||
AlbumTitle *string
|
||||
}
|
||||
|
||||
// snapshot pulls the audit-row text columns from track / album / artist.
|
||||
// AlbumTitle is materialized as *string because WriteQuarantineActionParams
|
||||
// expects nullable; we coerce the schema's NOT NULL value into a pointer.
|
||||
func (s *Service) snapshot(ctx context.Context, q *dbq.Queries, track dbq.Track) (quarantineSnapshot, error) {
|
||||
album, err := q.GetAlbumByID(ctx, track.AlbumID)
|
||||
if err != nil {
|
||||
return quarantineSnapshot{}, fmt.Errorf("get album: %w", err)
|
||||
}
|
||||
artist, err := q.GetArtistByID(ctx, track.ArtistID)
|
||||
if err != nil {
|
||||
return quarantineSnapshot{}, fmt.Errorf("get artist: %w", err)
|
||||
}
|
||||
titlePtr := album.Title
|
||||
return quarantineSnapshot{
|
||||
TrackTitle: track.Title,
|
||||
ArtistName: artist.Name,
|
||||
AlbumTitle: &titlePtr,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteFile removes the track file from disk and the tracks row (tidying away
|
||||
// an album or artist that leaves empty), then — via FK ON DELETE CASCADE —
|
||||
// clears every per-user quarantine row for that track and writes an audit row.
|
||||
//
|
||||
// If the file cannot be removed, nothing is deleted and the per-user rows stay
|
||||
// so the admin can retry: the error wraps a *library.FileRemoveError, which the
|
||||
// handler turns into an answer naming the cause (#3918). No partial state.
|
||||
func (s *Service) DeleteFile(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.QuarantineAction, error) {
|
||||
q := dbq.New(s.pool)
|
||||
track, err := q.GetTrackByID(ctx, trackID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return dbq.QuarantineAction{}, ErrTrackNotFound
|
||||
}
|
||||
return dbq.QuarantineAction{}, fmt.Errorf("get track: %w", err)
|
||||
}
|
||||
snap, err := s.snapshot(ctx, q, track)
|
||||
if err != nil {
|
||||
return dbq.QuarantineAction{}, err
|
||||
}
|
||||
|
||||
affected, err := q.CountQuarantineForTrack(ctx, trackID)
|
||||
if err != nil {
|
||||
return dbq.QuarantineAction{}, fmt.Errorf("count: %w", err)
|
||||
}
|
||||
if _, err := library.DeleteTrackFile(ctx, s.pool, nil, s.dataDir, trackID); err != nil {
|
||||
return dbq.QuarantineAction{}, fmt.Errorf("delete file: %w", err)
|
||||
}
|
||||
// tracks row is gone; ON DELETE CASCADE on quarantine.track_id already
|
||||
// cleared the per-user rows. We don't call DeleteQuarantineForTrack
|
||||
// separately.
|
||||
return q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{
|
||||
TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName,
|
||||
AlbumTitle: snap.AlbumTitle, Action: dbq.QuarantineActionKindDeletedFile,
|
||||
AdminID: adminID, AffectedUsers: affected,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user