release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m24s
release / go (push) Successful in 1m44s
release / integration (push) Successful in 5m19s
release / android (push) Successful in 5m31s
release / Build signed APK (releases and dev) (push) Successful in 5m35s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
A one-time job in the wanted worker lists every album in the library as a held want, one per release group, with a record that it happened (acquisition_settings.library_listed_at). Albums whose tags name only a release are named on MusicBrainz first, 120 a pass; what nothing names is counted and shown on the Held tab. Re-acquisition now wants back only albums on the list. Taking a held album off the list leaves its files, and it is never fetched back; albums put in the library folder by hand afterwards are not added. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
590 lines
22 KiB
Go
590 lines
22 KiB
Go
package wanted
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"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
|
|
primary string
|
|
secondary []string
|
|
want bool
|
|
}{
|
|
{standard, "Album", nil, true},
|
|
{standard, "single", nil, true}, // MusicBrainz's case is not relied on
|
|
{standard, "Album", []string{"Live"}, false},
|
|
{standard, "Broadcast", nil, false},
|
|
{withLive, "Album", []string{"Live"}, true},
|
|
{withLive, "Album", []string{"Live", "Compilation"}, false},
|
|
{withLive, "EP", nil, false},
|
|
} {
|
|
if got := Passes(c.profile, c.primary, c.secondary); got != c.want {
|
|
t.Errorf("Passes(%v/%v, %s %v) = %v, want %v", c.profile.PrimaryTypes, c.profile.SecondaryTypes,
|
|
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
|
|
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")
|
|
n := 3
|
|
if f.added.Load() {
|
|
groups += "," + group(newSingle, "New Single", "Single", "", "") +
|
|
"," + group(oldSingle, "Old Single", "Single", "", "1997-02-03")
|
|
n = 5
|
|
}
|
|
_, _ = 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": []}`)
|
|
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)
|
|
|
|
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"} {
|
|
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
|
|
}
|
|
|
|
// startListing clears the reset's "already listed", as a fresh install has it.
|
|
func startListing(t *testing.T, pool *pgxpool.Pool) {
|
|
t.Helper()
|
|
if _, err := pool.Exec(context.Background(), `
|
|
UPDATE acquisition_settings SET library_listing_started_at = NULL, library_listed_at = NULL, library_unlisted = 0`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// The library's albums go on the list as held, once (#5567). An album the
|
|
// tags name by release only is named through MusicBrainz first; one nothing
|
|
// names is counted, not listed. Taken off the list, an album stays off, and
|
|
// one added to the library afterwards is never listed.
|
|
func TestListLibrary_OnceAndThenTheListIsTheTruth_Integration(t *testing.T) {
|
|
pool := newPool(t)
|
|
ctx := context.Background()
|
|
q := dbq.New(pool)
|
|
s := newService(t, pool, newFakeMB(t))
|
|
s.releaseGroup = func(_ context.Context, release string) (string, error) {
|
|
if release == "rel-parklife" {
|
|
return "rg-parklife", nil
|
|
}
|
|
return "", tags.ErrNotFound
|
|
}
|
|
blurAlbum := seedHeld(t, q, "Blur", "rel-blur", "rg-blur", 3)
|
|
parklife := seedHeld(t, q, "Parklife", "rel-parklife", "", 2)
|
|
_ = seedHeld(t, q, "Bootleg", "rel-bootleg", "", 1)
|
|
// An album with a track gone: held from before the loss, so
|
|
// re-acquisition still fetches it back.
|
|
lost := seedHeld(t, q, "13", "rel-13", "rg-13", 2)
|
|
if _, err := pool.Exec(ctx, `UPDATE tracks SET missing_since = now() - interval '1 day'
|
|
WHERE album_id = $1 AND track_number = 1`, lost.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
startListing(t, pool)
|
|
|
|
if err := s.listLibrary(ctx, &PassResult{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for group, album := range map[string]pgtype.UUID{"rg-blur": blurAlbum.ID, "rg-parklife": parklife.ID, "rg-13": lost.ID} {
|
|
w, err := q.GetWantByReleaseGroup(ctx, group)
|
|
if err != nil {
|
|
t.Fatalf("%s not listed: %v", group, err)
|
|
}
|
|
if w.Reason != ReasonLibrary || !w.FulfilledAt.Valid || w.AlbumID != album {
|
|
t.Errorf("%s = %+v: a held library want about the album", group, w)
|
|
}
|
|
}
|
|
if w, _ := q.GetWantByReleaseGroup(ctx, "rg-13"); !w.FulfilledAt.Time.Before(time.Now().Add(-23 * time.Hour)) {
|
|
t.Errorf("an album missing a track is held from %v, not from before the loss", w.FulfilledAt.Time)
|
|
}
|
|
state, err := q.GetLibraryListing(ctx)
|
|
if err != nil || !state.LibraryListedAt.Valid || state.LibraryUnlisted != 1 {
|
|
t.Fatalf("listing = %+v %v: done, with the bootleg unlisted", state, err)
|
|
}
|
|
|
|
// Off the list stays off; an album added later is never listed.
|
|
w, _ := q.GetWantByReleaseGroup(ctx, "rg-blur")
|
|
if _, err := s.RemoveWant(ctx, w.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = seedHeld(t, q, "Think Tank", "rel-think", "rg-think", 2)
|
|
if _, err := s.Pass(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, group := range []string{"rg-blur", "rg-think"} {
|
|
if _, err := q.GetWantByReleaseGroup(ctx, group); err == nil {
|
|
t.Errorf("%s is on the list", group)
|
|
}
|
|
}
|
|
}
|
|
|
|
// While MusicBrainz is switched off nothing can be named, so the listing
|
|
// finishes with what it has rather than waiting for ever.
|
|
func TestListLibrary_MusicBrainzOffFinishesWithWhatIsNamed_Integration(t *testing.T) {
|
|
pool := newPool(t)
|
|
ctx := context.Background()
|
|
q := dbq.New(pool)
|
|
s := newService(t, pool, newFakeMB(t))
|
|
s.releaseGroup = func(context.Context, string) (string, error) { return "", tags.ErrSwitchedOff }
|
|
_ = seedHeld(t, q, "Blur", "rel-blur", "rg-blur", 1)
|
|
_ = seedHeld(t, q, "Parklife", "rel-parklife", "", 1)
|
|
startListing(t, pool)
|
|
if err := s.listLibrary(ctx, &PassResult{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
state, _ := q.GetLibraryListing(ctx)
|
|
if !state.LibraryListedAt.Valid || state.LibraryUnlisted != 1 {
|
|
t.Errorf("listing = %+v", state)
|
|
}
|
|
var named *string
|
|
if err := pool.QueryRow(ctx, `SELECT release_group_mbid FROM albums WHERE title = 'Parklife'`).Scan(&named); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if named != nil {
|
|
t.Errorf("an album was marked unnameable while MusicBrainz was off: %q", *named)
|
|
}
|
|
}
|
|
|
|
// A MusicBrainz that does not answer pauses the naming; the next pass goes on.
|
|
func TestListLibrary_UnreachablePausesTheNaming_Integration(t *testing.T) {
|
|
pool := newPool(t)
|
|
ctx := context.Background()
|
|
q := dbq.New(pool)
|
|
s := newService(t, pool, newFakeMB(t))
|
|
down := true
|
|
s.releaseGroup = func(context.Context, string) (string, error) {
|
|
if down {
|
|
return "", tags.ErrTransient
|
|
}
|
|
return "rg-parklife", nil
|
|
}
|
|
_ = seedHeld(t, q, "Parklife", "rel-parklife", "", 1)
|
|
startListing(t, pool)
|
|
if err := s.listLibrary(ctx, &PassResult{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if state, _ := q.GetLibraryListing(ctx); state.LibraryListedAt.Valid {
|
|
t.Fatal("finished while an album was still to be named")
|
|
}
|
|
down = false
|
|
if err := s.listLibrary(ctx, &PassResult{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := q.GetWantByReleaseGroup(ctx, "rg-parklife"); err != nil {
|
|
t.Errorf("not listed once named: %v", err)
|
|
}
|
|
if state, _ := q.GetLibraryListing(ctx); !state.LibraryListedAt.Valid {
|
|
t.Error("not finished")
|
|
}
|
|
}
|