package wanted import ( "context" "errors" "fmt" "io" "log/slog" "net/http" "net/http/httptest" "os" "strings" "sync/atomic" "testing" "time" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/catalogue" "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/musicbrainz" "git.fabledsword.com/bvandeusen/minstrel/internal/tags" ) func TestPasses_PrimaryListedAndEverySecondaryAllowed(t *testing.T) { standard := dbq.ReleaseTypeProfile{PrimaryTypes: []string{"Album", "EP", "Single"}, SecondaryTypes: []string{}} withLive := dbq.ReleaseTypeProfile{PrimaryTypes: []string{"Album"}, SecondaryTypes: []string{"Live"}} for _, c := range []struct { profile dbq.ReleaseTypeProfile title string primary string secondary []string want bool }{ {standard, "Revenge", "Album", nil, true}, {standard, "Helena", "single", nil, true}, // MusicBrainz's case is not relied on {standard, "Live at Koko", "Album", []string{"Live"}, false}, {standard, "Radio Show", "Broadcast", nil, false}, {withLive, "Live at Koko", "Album", []string{"Live"}, true}, {withLive, "Live & Rare", "Album", []string{"Live", "Compilation"}, false}, {withLive, "Like Phantoms, Forever", "EP", nil, false}, // MusicBrainz types these plain Singles; their titles say otherwise (#5573). {standard, "Common People (Recorded For BBC Radio 1)", "Single", nil, false}, {standard, "Welcome to the Black Parade (Steve Aoki remix)", "Single", nil, false}, {standard, "The Five of Us Are Dying (Rough mix)", "Single", nil, false}, {withLive, "Desolation Row (live at BBC Maida Vale)", "Album", nil, true}, {withLive, "Helena (acoustic)", "Album", nil, false}, {standard, "Live Through This", "Album", nil, true}, } { if got := Passes(c.profile, c.title, c.primary, c.secondary); got != c.want { t.Errorf("Passes(%v/%v, %q %s %v) = %v, want %v", c.profile.PrimaryTypes, c.profile.SecondaryTypes, c.title, c.primary, c.secondary, got, c.want) } } } func TestProfileInput_NormalizesToMusicBrainzTypes(t *testing.T) { in, err := ProfileInput{Name: " Live too ", PrimaryTypes: []string{"single", "Album"}, SecondaryTypes: []string{"live"}}.normalize() if err != nil { t.Fatal(err) } if in.Name != "Live too" || fmt.Sprint(in.PrimaryTypes) != "[Album Single]" || fmt.Sprint(in.SecondaryTypes) != "[Live]" { t.Errorf("normalized = %+v", in) } for name, bad := range map[string]ProfileInput{ "no name": {PrimaryTypes: []string{"Album"}}, "no primary type": {Name: "x"}, "unknown type": {Name: "x", PrimaryTypes: []string{"Mixtape"}}, } { if _, err := bad.normalize(); !errors.Is(err, ErrProfileInvalid) { t.Errorf("%s: err = %v", name, err) } } } func TestRecent_ReadsAPartialDateAsItsLastDay(t *testing.T) { s := &Service{} baseline := time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC) for date, want := range map[string]bool{ "": true, // not yet known: an announced release "2026-11-01": true, "2026-09-15": true, // inside the grace before the follow "2026-08-01": false, "2026": true, // could be December "2025": false, "2026-09": true, "1998-04-01": false, } { if got := s.recent(date, baseline); got != want { t.Errorf("recent(%q) = %v, want %v", date, got, want) } } } func TestShortest_PrefersOfficialEditions(t *testing.T) { rel := func(status string, n int32) dbq.MbRelease { return dbq.MbRelease{Status: status, TrackCount: n} } for _, c := range []struct { releases []dbq.MbRelease want int }{ {nil, 0}, {[]dbq.MbRelease{rel("Official", 14), rel("Official", 12), rel("Promotion", 3)}, 12}, {[]dbq.MbRelease{rel("Bootleg", 9), rel("", 11)}, 9}, {[]dbq.MbRelease{rel("Official", 0), rel("Bootleg", 9)}, 9}, } { if got := Shortest(c.releases); got != c.want { t.Errorf("Shortest(%v) = %d, want %d", c.releases, got, c.want) } } } func TestAnswers(t *testing.T) { album := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} other := pgtype.UUID{Bytes: [16]byte{2}, Valid: true} missing := dbq.Want{Reason: ReasonMissing, AlbumID: album} request := dbq.Want{Reason: ReasonRequest} for name, c := range map[string]struct { w dbq.Want h dbq.WantHoldingsRow shortest int want bool }{ "nothing on disk": {request, dbq.WantHoldingsRow{AlbumID: other, ArrivedSince: true}, 10, false}, "a full edition": {request, dbq.WantHoldingsRow{AlbumID: other, Present: 10}, 10, true}, "short of every edition": {request, dbq.WantHoldingsRow{AlbumID: other, Present: 9, ArrivedSince: true}, 10, false}, "no editions known, an arrival": {request, dbq.WantHoldingsRow{AlbumID: other, Present: 1, ArrivedSince: true, MissingBefore: true}, 0, true}, "no editions known, still missing": {request, dbq.WantHoldingsRow{AlbumID: other, Present: 5, MissingBefore: true}, 0, false}, "the lost album, a gap remains": {missing, dbq.WantHoldingsRow{AlbumID: album, Present: 12, UncoveredMissing: 1}, 10, false}, "the lost album, every gap filled": {missing, dbq.WantHoldingsRow{AlbumID: album, Present: 9, UncoveredMissing: 0}, 10, true}, "another copy of the lost album": {missing, dbq.WantHoldingsRow{AlbumID: other, Present: 10}, 10, true}, } { if got := Answers(c.w, c.h, c.shortest); got != c.want { t.Errorf("%s: Answers = %v, want %v", name, got, c.want) } } } func TestFollowable(t *testing.T) { for _, c := range []struct { mbid, name string want bool }{ {"a", "Gorillaz", true}, {variousArtists, "Various Artists", false}, {"b", "[unknown]", false}, {"", "Nameless", false}, {"c", "[ ]Bracketed Band", true}, } { if got := Followable(c.mbid, c.name); got != c.want { t.Errorf("Followable(%q, %q) = %v", c.mbid, c.name, got) } } } // --- integration --- 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 } const ( blur = "ba853904-ae25-4ebb-89d6-c44cfbd71bd2" parklife = "11111111-0000-4000-8000-000000000001" // 1994 album, held liveAtWemb = "11111111-0000-4000-8000-000000000002" // 2009 live album theBallad = "11111111-0000-4000-8000-000000000003" // 2023 album, not held newSingle = "11111111-0000-4000-8000-000000000004" // appears after the baseline oldSingle = "11111111-0000-4000-8000-000000000005" // 1997 single, catalogued late bbcSingle = "11111111-0000-4000-8000-000000000006" // live, typed a plain Single (#5573) parkRelease = "22222222-0000-4000-8000-000000000001" ) // fakeMB is a MusicBrainz holding Blur's discography; added groups appear in // it after the baseline, and down makes every request a 503. type fakeMB struct { srv *httptest.Server added atomic.Bool down atomic.Bool } func newFakeMB(t *testing.T) *fakeMB { t.Helper() f := &fakeMB{} group := func(id, title, primary, secondary, date string) string { return fmt.Sprintf(`{"id": %q, "title": %q, "primary-type": %q, "secondary-types": [%s], "first-release-date": %q, "artist-credit": [{"name": "Blur", "artist": {"id": %q}}]}`, id, title, primary, secondary, date, blur) } f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if f.down.Load() { w.WriteHeader(http.StatusServiceUnavailable) return } switch { case r.URL.Path == "/artist/"+blur: _, _ = fmt.Fprintf(w, `{"id": %q, "name": "Blur", "type": "Group", "country": "GB"}`, blur) case r.URL.Path == "/release-group" && r.URL.Query().Get("artist") == blur: groups := group(parklife, "Parklife", "Album", "", "1994-04-25") + "," + group(liveAtWemb, "Live at Wembley Stadium", "Album", `"Live"`, "2009-07-02") + "," + group(theBallad, "The Ballad of Darren", "Album", "", "2023-07-21") + "," + group(bbcSingle, "Song 2 (Recorded For BBC Radio 1)", "Single", "", "2023-08-01") n := 4 if f.added.Load() { groups += "," + group(newSingle, "New Single", "Single", "", "") + "," + group(oldSingle, "Old Single", "Single", "", "1997-02-03") n = 6 } _, _ = fmt.Fprintf(w, `{"release-group-count": %d, "release-groups": [%s]}`, n, groups) case r.URL.Path == "/release-group/"+theBallad: _, _ = fmt.Fprint(w, group(theBallad, "The Ballad of Darren", "Album", "", "2023-07-21")) case r.URL.Path == "/release" && r.URL.Query().Get("release-group") == theBallad: _, _ = fmt.Fprint(w, `{"release-count": 2, "releases": [ {"id": "r-ballad-1", "title": "The Ballad of Darren", "status": "Official", "media": [{"position": 1, "tracks": [{"position": 1, "number": "1", "title": "The Ballad", "recording": {"id": "rec-1"}}, {"position": 2, "number": "2", "title": "St. Charles Square", "recording": {"id": "rec-2"}}]}]}, {"id": "r-ballad-2", "title": "The Ballad of Darren", "status": "Official", "media": [{"position": 1, "tracks": [{"position": 1, "number": "1", "title": "The Ballad", "recording": {"id": "rec-1"}}, {"position": 2, "number": "2", "title": "St. Charles Square", "recording": {"id": "rec-2"}}, {"position": 3, "number": "3", "title": "Bonus", "recording": {"id": "rec-3"}}]}]}]}`) case r.URL.Path == "/release": _, _ = fmt.Fprint(w, `{"release-count": 0, "releases": []}`) case r.URL.Path == "/release-group" && strings.Contains(r.URL.Query().Get("query"), "releasegroup:"): // The library listing's search by artist and title (#5569). found := "" if strings.Contains(r.URL.Query().Get("query"), "Ballad") { found = group(theBallad, "The Ballad of Darren", "Album", "", "2023-07-21") } _, _ = fmt.Fprintf(w, `{"release-groups": [%s]}`, found) default: w.WriteHeader(http.StatusNotFound) } })) t.Cleanup(f.srv.Close) return f } func newService(t *testing.T, pool *pgxpool.Pool, f *fakeMB) *Service { t.Helper() mb := &musicbrainz.Client{BaseURL: f.srv.URL, Pace: &musicbrainz.Pacer{}, Retries: -1} s := New(pool, catalogue.New(pool, mb, nil), slog.New(slog.NewTextHandler(io.Discard, nil))) // Never the real MusicBrainz from a test. s.releaseGroup = func(context.Context, string) (string, error) { return "", tags.ErrNotFound } return s } // seedHeld puts an album of Blur's in the library with n tracks on disk. func seedHeld(t *testing.T, q *dbq.Queries, title, release, group string, n int) dbq.Album { t.Helper() ctx := context.Background() mbid := blur artist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "Blur", SortName: "Blur", Mbid: &mbid}) if err != nil { t.Fatal(err) } params := dbq.UpsertAlbumParams{Title: title, SortTitle: title, ArtistID: artist.ID, Mbid: &release} if group != "" { params.ReleaseGroupMbid = &group } album, err := q.UpsertAlbum(ctx, params) if err != nil { t.Fatal(err) } for i := range n { num := int32(i + 1) if _, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{ Title: fmt.Sprintf("Track %d", i+1), AlbumID: album.ID, ArtistID: artist.ID, TrackNumber: &num, DurationMs: 180000, FilePath: fmt.Sprintf("/music/%s/%02d.flac", release, i+1), FileSize: 1, FileFormat: "flac", }); err != nil { t.Fatal(err) } } return album } func TestFollow_BaselineThenOnlyNewReleasesThatPass_Integration(t *testing.T) { pool := newPool(t) q := dbq.New(pool) ctx := context.Background() f := newFakeMB(t) s := newService(t, pool, f) _ = seedHeld(t, q, "Parklife", parkRelease, parklife, 2) if _, err := s.Follow(ctx, FollowParams{ArtistMBID: blur, ArtistName: "Blur"}); err != nil { t.Fatal(err) } res, err := s.Pass(ctx) if err != nil { t.Fatal(err) } if res.Baselined != 1 || res.Wanted != 0 { t.Fatalf("baseline pass = %+v: a follow wants nothing it already lists", res) } // MusicBrainz gains two singles: one new, one from 1997. A day later the // follow reads the discography again. f.added.Store(true) if _, err := pool.Exec(ctx, ` UPDATE mb_artists SET discography_refreshed_at = now() - interval '2 days'; UPDATE follows SET checked_at = now() - interval '2 days'`); err != nil { t.Fatal(err) } if res, err = s.Pass(ctx); err != nil { t.Fatal(err) } if res.Wanted != 1 { t.Errorf("second pass wanted %d, want 1 (the new single)", res.Wanted) } if w, err := q.GetWantByReleaseGroup(ctx, newSingle); err != nil || w.Reason != ReasonFollow || w.PrimaryType != "Single" { t.Errorf("new single: %+v, %v", w, err) } if _, err := q.GetWantByReleaseGroup(ctx, oldSingle); err == nil { t.Error("a 1997 single catalogued late is not a new release") } } func TestFollow_WantExistingWantsWhatPassesAndIsNotHeld_Integration(t *testing.T) { pool := newPool(t) q := dbq.New(pool) ctx := context.Background() f := newFakeMB(t) s := newService(t, pool, f) _ = seedHeld(t, q, "Parklife", parkRelease, parklife, 2) // Wanted before the follow read the version a title names (#5573). if _, err := WantWith(ctx, q, WantParams{GroupMBID: bbcSingle, ArtistMBID: blur, ArtistName: "Blur", Title: "Song 2 (Recorded For BBC Radio 1)", PrimaryType: "Single", Reason: ReasonFollow}); err != nil { t.Fatal(err) } if _, err := s.Follow(ctx, FollowParams{ArtistMBID: blur, ArtistName: "Blur", WantExisting: true}); err != nil { t.Fatal(err) } if _, err := s.Pass(ctx); err != nil { t.Fatal(err) } if _, err := q.GetWantByReleaseGroup(ctx, theBallad); err != nil { t.Errorf("the studio album not held should be wanted: %v", err) } for group, why := range map[string]string{parklife: "held", liveAtWemb: "live, and Standard is studio only", bbcSingle: "recorded for the BBC, which its title says, and Standard is studio only"} { if _, err := q.GetWantByReleaseGroup(ctx, group); err == nil { t.Errorf("%s wanted, but it is %s", group, why) } } if fol, _ := q.GetFollow(ctx, blur); fol.WantExisting || !fol.BaselineAt.Valid { t.Errorf("follow = %+v: want_existing applied once, then cleared", fol) } // A profile that allows live releases, applied with "want what exists". live, err := s.CreateProfile(ctx, ProfileInput{Name: "With live", PrimaryTypes: []string{"Album"}, SecondaryTypes: []string{"Live"}}) if err != nil { t.Fatal(err) } if _, err := s.Follow(ctx, FollowParams{ArtistMBID: blur, ArtistName: "Blur", ProfileID: live.ID, WantExisting: true}); err != nil { t.Fatal(err) } if _, err := s.Pass(ctx); err != nil { t.Fatal(err) } if _, err := q.GetWantByReleaseGroup(ctx, liveAtWemb); err != nil { t.Errorf("with live allowed, the live album is wanted: %v", err) } if err := s.DeleteProfile(ctx, live.ID); !errors.Is(err, ErrProfileInUse) { t.Errorf("deleting a profile in use: err = %v", err) } } func TestSettle_HeldOnceAFullEditionIsOnDisk_Integration(t *testing.T) { pool := newPool(t) q := dbq.New(pool) ctx := context.Background() s := newService(t, pool, newFakeMB(t)) if _, err := WantWith(ctx, q, WantParams{ GroupMBID: theBallad, ArtistMBID: blur, ArtistName: "Blur", Title: "The Ballad of Darren", Reason: ReasonRequest, }); err != nil { t.Fatal(err) } album := seedHeld(t, q, "The Ballad of Darren", "r-ballad-1", theBallad, 1) if res, err := s.Pass(ctx); err != nil || res.Held != 0 { t.Fatalf("one track of a two-track edition: %+v, %v", res, err) } _ = seedHeld(t, q, "The Ballad of Darren", "r-ballad-1", theBallad, 2) if res, err := s.Pass(ctx); err != nil || res.Held != 1 { t.Fatalf("the shortest edition complete: %+v, %v", res, err) } w, _ := q.GetWantByReleaseGroup(ctx, theBallad) if !w.FulfilledAt.Valid || w.AlbumID != album.ID { t.Errorf("want = %+v: held by the album", w) } } func TestSettle_MovesAWantNamedByReleaseIdToItsGroup_Integration(t *testing.T) { pool := newPool(t) q := dbq.New(pool) ctx := context.Background() s := newService(t, pool, newFakeMB(t)) s.releaseGroup = func(_ context.Context, release string) (string, error) { if release == "r-ballad-1" { return theBallad, nil } return "", tags.ErrNotFound } if _, err := WantWith(ctx, q, WantParams{ GroupMBID: "r-ballad-1", ArtistMBID: blur, ArtistName: "Blur", Title: "The Ballad of Darren", Reason: ReasonRequest, }); err != nil { t.Fatal(err) } group := "r-ballad-1" if _, err := q.CreateRequest(ctx, dbq.CreateRequestParams{ UserID: seedUser(t, pool), Kind: dbq.RequestKindAlbum, ArtistMbid: blur, AlbumMbid: &group, ArtistName: "Blur", }); err != nil { t.Fatal(err) } if _, err := s.Pass(ctx); err != nil { t.Fatal(err) } if _, err := q.GetWantByReleaseGroup(ctx, theBallad); err != nil { t.Errorf("the want should be under its group now: %v", err) } var moved string if err := pool.QueryRow(ctx, "SELECT album_mbid FROM requests").Scan(&moved); err != nil || moved != theBallad { t.Errorf("the request should follow its want: %q, %v", moved, err) } } func TestPass_MusicBrainzDownStopsFollowsButStillSettles_Integration(t *testing.T) { pool := newPool(t) q := dbq.New(pool) ctx := context.Background() f := newFakeMB(t) s := newService(t, pool, f) if _, err := s.Follow(ctx, FollowParams{ArtistMBID: blur, ArtistName: "Blur"}); err != nil { t.Fatal(err) } if _, err := WantWith(ctx, q, WantParams{ GroupMBID: "some-group", ArtistMBID: blur, ArtistName: "Blur", Title: "Some", Reason: ReasonRequest, }); err != nil { t.Fatal(err) } _ = seedHeld(t, q, "Some", "some-release", "some-group", 3) f.down.Store(true) res, err := s.Pass(ctx) if !errors.Is(err, musicbrainz.ErrUnreachable) { t.Errorf("err = %v, want ErrUnreachable", err) } if res.Held != 1 { t.Errorf("held = %d: with no editions known, an album that arrived since the want answers it", res.Held) } if fol, _ := q.GetFollow(ctx, blur); fol.BaselineAt.Valid { t.Error("a follow is not baselined without reading its discography") } } func seedUser(t *testing.T, pool *pgxpool.Pool) pgtype.UUID { t.Helper() u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ Username: dbtest.TestUserPrefix + "wanter", PasswordHash: "x", ApiTokenHash: "x", }) if err != nil { t.Fatal(err) } return u.ID }