package catalogue import ( "context" "errors" "fmt" "io" "log/slog" "net/http" "net/http/httptest" "os" "strings" "sync/atomic" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/db" "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" "git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz" ) 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 ( gorillaz = "e21857d5-3256-4547-afb3-4b6ded592596" deLaSoul = "4f2a6b9e-9ae6-4f34-9a41-3e9f2f7c4c1d" demonDays = "0c4d5b5f-2f3c-3d31-9c4e-4b4f6b4c1a01" feelGood = "0c4d5b5f-2f3c-3d31-9c4e-4b4f6b4c1a02" // credited to both demonDaysR = "1a2b3c4d-0000-4000-8000-000000000001" demonDaysJ = "1a2b3c4d-0000-4000-8000-000000000002" ) // fakeMB is a MusicBrainz with two artists, a collaboration between them, // and one album with two editions. down makes every request a 503. type fakeMB struct { srv *httptest.Server calls atomic.Int32 down atomic.Bool } func newFakeMB(t *testing.T) *fakeMB { t.Helper() f := &fakeMB{} f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { f.calls.Add(1) if f.down.Load() { w.WriteHeader(http.StatusServiceUnavailable) return } q := r.URL.Query() switch { case r.URL.Path == "/artist/"+gorillaz: _, _ = fmt.Fprintf(w, `{"id": %q, "name": "Gorillaz", "sort-name": "Gorillaz", "type": "Group", "country": "GB"}`, gorillaz) case r.URL.Path == "/artist/"+deLaSoul: _, _ = fmt.Fprintf(w, `{"id": %q, "name": "De La Soul", "type": "Group", "country": "US"}`, deLaSoul) case r.URL.Path == "/release-group" && q.Get("artist") == gorillaz: _, _ = fmt.Fprintf(w, `{"release-group-count": 2, "release-groups": [ {"id": %q, "title": "Demon Days", "primary-type": "Album", "first-release-date": "2005-05-11", "artist-credit": [{"name": "Gorillaz", "artist": {"id": %q}}]}, {"id": %q, "title": "Feel Good Inc.", "primary-type": "Single", "secondary-types": [], "first-release-date": "2005-05-09", "artist-credit": [{"name": "Gorillaz", "joinphrase": " feat. ", "artist": {"id": %q}}, {"name": "De La Soul", "artist": {"id": %q}}]} ]}`, demonDays, gorillaz, feelGood, gorillaz, deLaSoul) case r.URL.Path == "/release-group" && q.Get("artist") == deLaSoul: _, _ = fmt.Fprintf(w, `{"release-group-count": 1, "release-groups": [ {"id": %q, "title": "Feel Good Inc.", "primary-type": "Single", "first-release-date": "2005-05-09"} ]}`, feelGood) case r.URL.Path == "/release-group/"+demonDays: _, _ = fmt.Fprintf(w, `{"id": %q, "title": "Demon Days", "primary-type": "Album", "first-release-date": "2005-05-11", "artist-credit": [{"name": "Gorillaz", "artist": {"id": %q}}]}`, demonDays, gorillaz) case r.URL.Path == "/release" && q.Get("release-group") == demonDays: _, _ = fmt.Fprintf(w, `{"release-count": 2, "releases": [ {"id": %q, "title": "Demon Days", "status": "Promotion", "date": "2005-04", "country": "JP", "media": [{"position": 1, "format": "CD", "tracks": [ {"position": 1, "number": "1", "title": "Intro", "length": 63000, "recording": {"id": "rec-intro"}}]}]}, {"id": %q, "title": "Demon Days", "status": "Official", "date": "2005-05-11", "country": "GB", "media": [{"position": 1, "format": "CD", "tracks": [ {"position": 1, "number": "1", "title": "Intro", "length": 63000, "recording": {"id": "rec-intro"}}, {"position": 2, "number": "2", "title": "Last Living Souls", "length": 190000, "recording": {"id": "rec-lls"}}]}]} ]}`, demonDaysJ, demonDaysR) default: w.WriteHeader(http.StatusNotFound) } })) t.Cleanup(f.srv.Close) return f } func (f *fakeMB) client() *musicbrainz.Client { return &musicbrainz.Client{BaseURL: f.srv.URL, Pace: &musicbrainz.Pacer{}, Retries: -1} } func TestDiscography_ReadsOnceThenServesTheHeldCopy_Integration(t *testing.T) { ctx := context.Background() mb := newFakeMB(t) s := New(newPool(t), mb.client(), nil) d, err := s.Discography(ctx, gorillaz) if err != nil { t.Fatal(err) } if d.Artist.Name != "Gorillaz" || d.Artist.Country != "GB" || len(d.Groups) != 2 || d.Stale { t.Fatalf("discography = %+v", d) } // Oldest first: the single came out two days before the album. if d.Groups[0].Mbid != feelGood || d.Groups[1].PrimaryType != "Album" { t.Errorf("order = %s, %s", d.Groups[0].Title, d.Groups[1].Title) } if d.Groups[0].ArtistCredit != "Gorillaz feat. De La Soul" { t.Errorf("credit = %q", d.Groups[0].ArtistCredit) } before := mb.calls.Load() if _, err := s.Discography(ctx, gorillaz); err != nil { t.Fatal(err) } if mb.calls.Load() != before { t.Error("a fresh discography should be served without asking MusicBrainz") } // The collaboration belongs to both artists. dls, err := s.Discography(ctx, deLaSoul) if err != nil { t.Fatal(err) } if len(dls.Groups) != 1 || dls.Groups[0].Mbid != feelGood { t.Errorf("De La Soul's discography = %+v", dls.Groups) } if again, _ := s.Discography(ctx, gorillaz); len(again.Groups) != 2 { t.Error("refreshing one artist must not take a shared group from the other") } } func TestDiscography_UnreachableServesStaleOrFails_Integration(t *testing.T) { ctx := context.Background() mb := newFakeMB(t) pool := newPool(t) s := New(pool, mb.client(), nil) mb.down.Store(true) if _, err := s.Discography(ctx, gorillaz); !errors.Is(err, musicbrainz.ErrUnreachable) { t.Fatalf("nothing held and MusicBrainz down: err = %v, want ErrUnreachable", err) } mb.down.Store(false) if _, err := s.Discography(ctx, gorillaz); err != nil { t.Fatal(err) } // A week and a day later, MusicBrainz is down: the held copy is served. s.now = func() time.Time { return time.Now().Add(DiscographyMaxAge + 24*time.Hour) } mb.down.Store(true) d, err := s.Discography(ctx, gorillaz) if err != nil || !d.Stale || len(d.Groups) != 2 { t.Fatalf("stale copy: %+v, %v", d, err) } } func TestDiscography_UnknownArtistIsNotFound_Integration(t *testing.T) { s := New(newPool(t), newFakeMB(t).client(), nil) if _, err := s.Discography(context.Background(), "00000000-0000-4000-8000-000000000000"); !errors.Is(err, ErrNotFound) { t.Fatalf("err = %v, want ErrNotFound", err) } } func TestReleases_ReadsEditionsAndTrackLists_Integration(t *testing.T) { ctx := context.Background() mb := newFakeMB(t) s := New(newPool(t), mb.client(), nil) // The group isn't held yet: it is looked up first. e, err := s.Releases(ctx, demonDays) if err != nil { t.Fatal(err) } if e.Group.Title != "Demon Days" || len(e.Editions) != 2 { t.Fatalf("editions = %+v", e) } official := e.Editions[0] if official.Release.Mbid != demonDaysR || official.Release.TrackCount != 2 || official.Release.Formats != "CD" { t.Errorf("the official edition should come first: %+v", official.Release) } if len(official.Tracks) != 2 || official.Tracks[1].RecordingMbid != "rec-lls" || official.Tracks[1].LengthMs != 190000 { t.Errorf("tracks = %+v", official.Tracks) } before := mb.calls.Load() if _, err := s.Releases(ctx, demonDays); err != nil { t.Fatal(err) } if mb.calls.Load() != before { t.Error("fresh editions should be served without asking MusicBrainz") } // Refreshing replaces rather than adds. if err := s.RefreshReleaseGroup(ctx, demonDays); err != nil { t.Fatal(err) } if again, _ := s.Releases(ctx, demonDays); len(again.Editions) != 2 || len(again.Editions[0].Tracks) != 2 { t.Errorf("after a refresh: %+v", again) } } func TestRefreshDue_ReadsWhatIsOldAndStopsWhenMusicBrainzIsDown_Integration(t *testing.T) { ctx := context.Background() mb := newFakeMB(t) s := New(newPool(t), mb.client(), nil) for _, a := range []string{gorillaz, deLaSoul} { if err := s.RefreshArtist(ctx, a); err != nil { t.Fatal(err) } } if n, err := s.RefreshDue(ctx, 10); err != nil || n != 0 { t.Fatalf("nothing is due yet: %d, %v", n, err) } s.now = func() time.Time { return time.Now().Add(DiscographyMaxAge + time.Hour) } if n, err := s.RefreshDue(ctx, 1); err != nil || n != 1 { t.Errorf("one due, limit one: %d, %v", n, err) } mb.down.Store(true) n, err := s.RefreshDue(ctx, 10) if !errors.Is(err, musicbrainz.ErrUnreachable) || n != 0 { t.Errorf("MusicBrainz down: %d, %v", n, err) } if !strings.Contains(err.Error(), "503") { t.Errorf("the error should say what MusicBrainz answered: %v", err) } }