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
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>
311 lines
11 KiB
Go
311 lines
11 KiB
Go
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)
|
||
}
|
||
}
|