package reacquisition import ( "context" "io" "log/slog" "os" "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" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests" "git.fabledsword.com/bvandeusen/minstrel/internal/notifications" "git.fabledsword.com/bvandeusen/minstrel/internal/tags" ) func newSweepPool(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) if _, err := pool.Exec(context.Background(), "DELETE FROM lidarr_requests"); err != nil { t.Fatalf("reset requests: %v", err) } return pool } // lostAlbum seeds an album whose only track has been missing for two days, // named by MusicBrainz release id and (when group is set) release group. func lostAlbum(t *testing.T, pool *pgxpool.Pool, title, release, group string) pgtype.UUID { t.Helper() ctx := context.Background() q := dbq.New(pool) artistMBID := "artist-" + release ar, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: title + " Artist", SortName: title, Mbid: &artistMBID}) if err != nil { t.Fatal(err) } params := dbq.UpsertAlbumParams{Title: title, SortTitle: title, ArtistID: ar.ID, Mbid: &release} if group != "" { params.ReleaseGroupMbid = &group } al, err := q.UpsertAlbum(ctx, params) if err != nil { t.Fatal(err) } tr, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{ Title: "Lost", AlbumID: al.ID, ArtistID: ar.ID, DurationMs: 180000, FilePath: "/music/" + release + "/01.flac", FileSize: 1, FileFormat: "flac", }) if err != nil { t.Fatal(err) } if _, err := pool.Exec(ctx, "UPDATE tracks SET missing_since = now() - interval '2 days' WHERE id = $1", tr.ID); err != nil { t.Fatal(err) } return al.ID } // #5241: re-acquisition asks Lidarr for the album's release group, never its // release id. A tag-supplied group is used as is; otherwise MusicBrainz names // it and the answer is cached onto the album; an album MusicBrainz cannot // name is skipped without spending an attempt. func TestSweep_RequestsAlbumsByReleaseGroup_Integration(t *testing.T) { pool := newSweepPool(t) ctx := context.Background() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) q := dbq.New(pool) admin, err := q.CreateUser(ctx, dbq.CreateUserParams{ Username: dbtest.TestUserPrefix + "sweepadmin", PasswordHash: "x", ApiTokenHash: "x", IsAdmin: true, }) if err != nil { t.Fatal(err) } settings, err := NewSettingsService(ctx, pool, logger) if err != nil { t.Fatal(err) } cfg := Defaults cfg.AutoApprove = false if _, err := settings.Set(ctx, cfg); err != nil { t.Fatal(err) } tagged := lostAlbum(t, pool, "Tagged", "release-tagged", "group-tagged") resolved := lostAlbum(t, pool, "Resolved", "release-resolved", "") unknown := lostAlbum(t, pool, "Unknown", "release-unknown", "") requests := lidarrrequests.NewService(pool, lidarrconfig.New(pool), nil, nil) s := NewSweeper(pool, settings, requests, logger) s.SetNotifier(notifications.New(pool, nil, nil)) asked := map[string]int{} s.releaseGroup = func(_ context.Context, release string) (string, error) { asked[release]++ if release == "release-resolved" { return "group-resolved", nil } return "", tags.ErrNotFound } if err := s.SweepOnce(ctx); err != nil { t.Fatalf("sweep: %v", err) } requested := func(album pgtype.UUID) (string, bool) { t.Helper() var mbid *string err := pool.QueryRow(ctx, ` SELECT lr.lidarr_album_mbid FROM missing_reacquisitions r JOIN lidarr_requests lr ON lr.id = r.last_request_id WHERE r.album_id = $1`, album).Scan(&mbid) if err != nil || mbid == nil { return "", false } return *mbid, true } if got, ok := requested(tagged); !ok || got != "group-tagged" { t.Errorf("tagged album requested as %q (ok=%v), want group-tagged", got, ok) } if asked["release-tagged"] != 0 { t.Error("MusicBrainz asked about an album whose tags named its group") } if got, ok := requested(resolved); !ok || got != "group-resolved" { t.Errorf("resolved album requested as %q (ok=%v), want group-resolved", got, ok) } var cached *string if err := pool.QueryRow(ctx, "SELECT release_group_mbid FROM albums WHERE id = $1", resolved).Scan(&cached); err != nil { t.Fatal(err) } if cached == nil || *cached != "group-resolved" { t.Errorf("resolved group not cached on the album: %v", cached) } var attempts int if err := pool.QueryRow(ctx, "SELECT count(*) FROM missing_reacquisitions WHERE album_id = $1", unknown).Scan(&attempts); err != nil { t.Fatal(err) } if attempts != 0 { t.Errorf("unresolvable album spent an attempt (%d state rows)", attempts) } // M489: the two requests filed without auto-approval wait on an admin, // so each reaches the admins' inbox; the unresolvable album filed none. inbox, err := q.ListNotifications(ctx, dbq.ListNotificationsParams{UserID: admin.ID, PageLimit: 10}) if err != nil { t.Fatal(err) } if len(inbox) != 2 { t.Errorf("admin inbox holds %d notifications, want 2", len(inbox)) } for _, n := range inbox { if n.Kind != string(notifications.KindRequestPending) { t.Errorf("admin inbox holds %q, want request_pending", n.Kind) } } var stray int if err := pool.QueryRow(ctx, "SELECT count(*) FROM lidarr_requests WHERE lidarr_album_mbid LIKE 'release-%'").Scan(&stray); err != nil { t.Fatal(err) } if stray != 0 { t.Errorf("%d request(s) named an album by its release id", stray) } }