Files
minstrel/internal/requests/requests_test.go
T
bvandeusenandClaude Opus 5.5 04515a977c
release / govulncheck (push) Successful in 15s
release / web (push) Failing after 1m21s
release / go (push) Successful in 1m35s
release / integration (push) Failing after 4m57s
release / android (push) Successful in 5m11s
release / Build signed APK (releases and dev) (push) Successful in 5m28s
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: a wanted list — follow artists on release-type profiles; requests and re-acquisition become wants (M502 #5520)
Minstrel keeps its own list of what it is to get, keyed by MusicBrainz
release group. A want comes from an approved request, a new release by a
followed artist, or an album whose files went missing, and is held once the
library has a full edition of it.

- Migration 0081: release_type_profiles (Standard seeded: Album, EP,
  Single, studio only), follows, follow_seen_groups and wants.
  lidarr_requests becomes requests with plain *_mbid columns; approved
  album and track requests move onto the wanted list, approved artist
  requests become follows, and the library's album artists start out
  followed. missing_reacquisitions and the retry settings go.
- internal/wanted: follows are checked against MusicBrainz discographies
  daily. The first check sets a baseline, and only releases after it are
  wanted unless the follow asked for what already exists. Profiles filter
  by primary and secondary type, so live recordings stay out unless a
  profile ticks Live.
- internal/requests (was lidarrrequests): approving puts the request on
  the wanted list, and an artist request can be approved on a chosen
  profile. A request completes when its want is held.
- The re-acquisition sweeper wants a missing album back instead of asking
  Lidarr; searching for it is the search schedule's (#5521).
- Web: an admin Wanted page (Wanted, Missing files, Held, Following), a
  follow control on artist pages, a release-type profiles card on
  Integrations, "Approve on a profile" on Requests, and want state on
  Missing files. The artist detail API returns the artist's MBID.
- Android: the request body sends artist_mbid, album_mbid and track_mbid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:46:22 -04:00

311 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package requests
import (
"context"
"encoding/json"
"errors"
"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/notifications"
)
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) pgtype.UUID {
t.Helper()
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + name, PasswordHash: "x", ApiTokenHash: "x-" + name,
})
if err != nil {
t.Fatalf("seed user: %v", err)
}
return u.ID
}
func seedAlbum(t *testing.T, q *dbq.Queries, artistName, artistMBID, title, group string) (dbq.Artist, dbq.Album) {
t.Helper()
ctx := context.Background()
artist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: artistName, SortName: artistName, Mbid: &artistMBID})
if err != nil {
t.Fatal(err)
}
release := group + "-release"
album, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: title, SortTitle: title, ArtistID: artist.ID, Mbid: &release})
if err != nil {
t.Fatal(err)
}
if err := q.SetAlbumReleaseGroupMbidIfNull(ctx, dbq.SetAlbumReleaseGroupMbidIfNullParams{ID: album.ID, ReleaseGroupMbid: &group}); err != nil {
t.Fatal(err)
}
return artist, album
}
func seedTrack(t *testing.T, q *dbq.Queries, album dbq.Album, artist dbq.Artist, title, path string) dbq.Track {
t.Helper()
tr, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
Title: title, AlbumID: album.ID, ArtistID: artist.ID,
DurationMs: 180000, FilePath: path, FileSize: 1024, FileFormat: "flac",
})
if err != nil {
t.Fatal(err)
}
return tr
}
func quietLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
func TestCreate_ValidatesEachKind(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "creator")
svc := NewService(pool)
ctx := context.Background()
if r, err := svc.Create(ctx, user, CreateParams{Kind: "artist", ArtistMBID: "a-mbid", ArtistName: "X"}); err != nil ||
r.Status != dbq.RequestStatusPending {
t.Fatalf("artist: %+v, %v", r, err)
}
for name, p := range map[string]CreateParams{
"album without its group": {Kind: "album", ArtistMBID: "a", ArtistName: "X"},
"track without its album": {Kind: "track", ArtistMBID: "a", ArtistName: "X", TrackMBID: "t", TrackTitle: "Y"},
"unknown kind": {Kind: "playlist", ArtistMBID: "a", ArtistName: "X"},
} {
if _, err := svc.Create(ctx, user, p); !errors.Is(err, ErrInvalidKindFields) {
t.Errorf("%s: err = %v, want ErrInvalidKindFields", name, err)
}
}
again, created, err := svc.CreateTracked(ctx, user, CreateParams{Kind: "artist", ArtistMBID: "a-mbid", ArtistName: "X"})
if err != nil || created || again.Kind != dbq.RequestKindArtist {
t.Errorf("a repeat request dedupes: created=%v, %v", created, err)
}
}
func TestApprove_AlbumAndTrackRequestsWantTheirReleaseGroup(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
user := seedUser(t, pool, "wanter")
admin := seedUser(t, pool, "approver")
svc := NewService(pool)
ctx := context.Background()
album, _ := svc.Create(ctx, user, CreateParams{
Kind: "album", ArtistMBID: "artist-1", ArtistName: "Gorillaz", AlbumMBID: "group-demon-days", AlbumTitle: "Demon Days",
})
track, _ := svc.Create(ctx, user, CreateParams{
Kind: "track", ArtistMBID: "artist-1", ArtistName: "Gorillaz", AlbumMBID: "group-humanz", AlbumTitle: "Humanz",
TrackMBID: "rec-1", TrackTitle: "Andromeda",
})
for _, r := range []dbq.Request{album, track} {
got, err := svc.Approve(ctx, r.ID, admin, pgtype.UUID{})
if err != nil || got.Status != dbq.RequestStatusApproved || got.DecidedBy != admin {
t.Fatalf("approve %s: %+v, %v", r.Kind, got, err)
}
}
for group, title := range map[string]string{"group-demon-days": "Demon Days", "group-humanz": "Humanz"} {
w, err := q.GetWantByReleaseGroup(ctx, group)
if err != nil {
t.Fatalf("want %s: %v", group, err)
}
if w.Title != title || w.Reason != "request" || w.CreatedBy != user || w.FulfilledAt.Valid {
t.Errorf("want %s = %+v: the requester's open want", group, w)
}
}
if _, err := svc.Approve(ctx, album.ID, admin, pgtype.UUID{}); !errors.Is(err, ErrNotPending) {
t.Errorf("approving twice: err = %v, want ErrNotPending", err)
}
if _, err := svc.Approve(ctx, pgtype.UUID{Bytes: [16]byte{1}, Valid: true}, admin, pgtype.UUID{}); !errors.Is(err, ErrNotFound) {
t.Errorf("no such request: err = %v", err)
}
}
func TestApprove_ArtistRequestFollowsAndWantsWhatExists(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
user := seedUser(t, pool, "follower")
ctx := context.Background()
svc := NewService(pool)
albumsOnly, err := q.CreateReleaseTypeProfile(ctx, dbq.CreateReleaseTypeProfileParams{
Name: "Albums only", PrimaryTypes: []string{"Album"}, SecondaryTypes: []string{},
})
if err != nil {
t.Fatal(err)
}
r, _ := svc.Create(ctx, user, CreateParams{Kind: "artist", ArtistMBID: "artist-2", ArtistName: "Moe Shop"})
if _, err := svc.Approve(ctx, r.ID, user, albumsOnly.ID); err != nil {
t.Fatal(err)
}
f, err := q.GetFollow(ctx, "artist-2")
if err != nil {
t.Fatal(err)
}
if f.ProfileID != albumsOnly.ID || !f.WantExisting || f.BaselineAt.Valid {
t.Errorf("follow = %+v: on the chosen profile, wanting what exists, baseline still to read", f)
}
r2, _ := svc.Create(ctx, user, CreateParams{Kind: "artist", ArtistMBID: "artist-3", ArtistName: "Kero Kero Bonito"})
if _, err := svc.Approve(ctx, r2.ID, user, pgtype.UUID{}); err != nil {
t.Fatal(err)
}
if f, _ := q.GetFollow(ctx, "artist-3"); f.ProfileName != "Standard" {
t.Errorf("no profile chosen: on %q, want the default", f.ProfileName)
}
}
func TestRejectAndCancel(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "rejected")
svc := NewService(pool)
ctx := context.Background()
r, _ := svc.Create(ctx, user, CreateParams{Kind: "artist", ArtistMBID: "a-1", ArtistName: "X"})
if got, err := svc.Reject(ctx, r.ID, user, "low quality"); err != nil || got.Status != dbq.RequestStatusRejected {
t.Fatalf("reject: %+v, %v", got, err)
}
if _, err := svc.Reject(ctx, r.ID, user, "again"); !errors.Is(err, ErrNotPending) {
t.Errorf("reject twice: err = %v", err)
}
c, _ := svc.Create(ctx, user, CreateParams{Kind: "artist", ArtistMBID: "a-2", ArtistName: "Y"})
if _, err := svc.Cancel(ctx, c.ID, user); err != nil {
t.Fatal(err)
}
if _, err := svc.Cancel(ctx, c.ID, user); !errors.Is(err, ErrNotPending) {
t.Errorf("cancel twice: err = %v", err)
}
}
func TestReconciler_CompletesWhenTheWantIsHeld(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
user := seedUser(t, pool, "reconciled")
svc := NewService(pool)
ctx := context.Background()
req, _ := svc.Create(ctx, user, CreateParams{
Kind: "track", ArtistMBID: "artist-4", ArtistName: "Notify Artist", AlbumMBID: "group-notify", AlbumTitle: "Notify Album",
TrackMBID: "rec-wanted", TrackTitle: "Wanted Track",
})
if _, err := svc.Approve(ctx, req.ID, user, pgtype.UUID{}); err != nil {
t.Fatal(err)
}
rec := NewReconciler(pool, quietLogger(), nil)
rec.SetNotifier(notifications.New(pool, nil, nil))
if err := rec.tickOnce(ctx); err != nil {
t.Fatal(err)
}
if got, _ := q.GetRequestByID(ctx, req.ID); got.Status != dbq.RequestStatusApproved {
t.Fatalf("status %v before the want is held, want approved", got.Status)
}
artist, album := seedAlbum(t, q, "Notify Artist", "artist-4", "Notify Album", "group-notify")
_ = seedTrack(t, q, album, artist, "Other Track", "/music/notify/01.flac")
wantedTrack := seedTrack(t, q, album, artist, "Wanted Track", "/music/notify/02.flac")
if _, err := pool.Exec(ctx, "UPDATE tracks SET mbid = 'rec-wanted', added_at = added_at - interval '1 hour' WHERE id = $1", wantedTrack.ID); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, "UPDATE wants SET fulfilled_at = now(), album_id = $1 WHERE release_group_mbid = 'group-notify'", album.ID); err != nil {
t.Fatal(err)
}
for range 2 { // the second tick finds nothing left to complete
if err := rec.tickOnce(ctx); err != nil {
t.Fatal(err)
}
}
got, _ := q.GetRequestByID(ctx, req.ID)
if got.Status != dbq.RequestStatusCompleted || got.MatchedAlbumID != album.ID || got.MatchedTrackID != wantedTrack.ID {
t.Errorf("request = %+v: completed, matched to the album and the track it named", got)
}
inbox, err := q.ListNotifications(ctx, dbq.ListNotificationsParams{UserID: user, PageLimit: 10})
if err != nil {
t.Fatal(err)
}
if len(inbox) != 1 || inbox[0].Kind != string(notifications.KindRequestCompleted) {
t.Fatalf("inbox = %+v, want one request_completed", inbox)
}
var p notifications.Payload
if err := json.Unmarshal(inbox[0].Payload, &p); err != nil {
t.Fatal(err)
}
if p.Name != "Notify Artist – Wanted Track" || p.AlbumID != formatUUIDForBus(album.ID) {
t.Errorf("payload = %+v", p)
}
}
func TestReconciler_ArtistRequestCompletesOnceTheArtistIsHere(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
user := seedUser(t, pool, "artistreq")
svc := NewService(pool)
ctx := context.Background()
req, _ := svc.Create(ctx, user, CreateParams{Kind: "artist", ArtistMBID: "artist-5", ArtistName: "Arrives"})
if _, err := svc.Approve(ctx, req.ID, user, pgtype.UUID{}); err != nil {
t.Fatal(err)
}
rec := NewReconciler(pool, quietLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatal(err)
}
if got, _ := q.GetRequestByID(ctx, req.ID); got.Status != dbq.RequestStatusApproved {
t.Fatalf("status %v with nothing here", got.Status)
}
artist, _ := seedAlbum(t, q, "Arrives", "artist-5", "First", "group-first")
if err := rec.tickOnce(ctx); err != nil {
t.Fatal(err)
}
if got, _ := q.GetRequestByID(ctx, req.ID); got.Status != dbq.RequestStatusCompleted || got.MatchedArtistID != artist.ID {
t.Errorf("request = %+v", got)
}
}
func TestReconciler_RewantsWhatWasRemovedWhileTheRequestWaited(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
user := seedUser(t, pool, "rewant")
svc := NewService(pool)
ctx := context.Background()
req, _ := svc.Create(ctx, user, CreateParams{
Kind: "album", ArtistMBID: "artist-6", ArtistName: "Gone", AlbumMBID: "group-gone", AlbumTitle: "Gone",
})
if _, err := svc.Approve(ctx, req.ID, user, pgtype.UUID{}); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, "DELETE FROM wants"); err != nil {
t.Fatal(err)
}
if err := NewReconciler(pool, quietLogger(), nil).tickOnce(ctx); err != nil {
t.Fatal(err)
}
if _, err := q.GetWantByReleaseGroup(ctx, "group-gone"); err != nil {
t.Errorf("an approved request's want should be recorded again: %v", err)
}
}