package catalogue import ( "context" "errors" "os" "path/filepath" "sync" "sync/atomic" "testing" "time" "git.fabledsword.com/bvandeusen/minstrel/internal/coverart" ) const someMBID = "e21857d5-3256-4547-afb3-4b6ded592596" // fakeSource is an artist and album source answering with fixed results. type fakeSource struct { thumb, fanart, cover []byte err error calls atomic.Int32 gate chan struct{} // when set, each fetch waits for it lastRef coverart.AlbumRef mu sync.Mutex } func (f *fakeSource) ID() string { return "fake" } func (f *fakeSource) DisplayName() string { return "Fake" } func (f *fakeSource) RequiresAPIKey() bool { return false } func (f *fakeSource) DefaultEnabled() bool { return true } func (f *fakeSource) Configure(_ coverart.ProviderSettings) error { return nil } func (f *fakeSource) wait() { f.calls.Add(1) if f.gate != nil { <-f.gate } } func (f *fakeSource) FetchArtistArt(_ context.Context, _ coverart.ArtistRef) ([]byte, []byte, error) { f.wait() return f.thumb, f.fanart, f.err } func (f *fakeSource) FetchAlbumCover(_ context.Context, ref coverart.AlbumRef) ([]byte, error) { f.wait() f.mu.Lock() f.lastRef = ref f.mu.Unlock() return f.cover, f.err } // groupSource can also answer by release group, as the Cover Art Archive does. type groupSource struct{ *fakeSource } func (g groupSource) FetchReleaseGroupCover(_ context.Context, _ string) ([]byte, error) { g.wait() return []byte("by-group"), nil } type sources struct { artists []coverart.ArtistArtProvider albums []coverart.AlbumCoverProvider } func (s sources) EnabledArtistProviders() []coverart.ArtistArtProvider { return s.artists } func (s sources) EnabledAlbumProviders() []coverart.AlbumCoverProvider { return s.albums } func TestArt_FetchesOnceAndKeepsIt(t *testing.T) { src := &fakeSource{fanart: []byte("backdrop")} a := NewArt(t.TempDir(), sources{artists: []coverart.ArtistArtProvider{src}}) body, err := a.Artist(context.Background(), someMBID, "Gorillaz") if err != nil || string(body) != "backdrop" { t.Fatalf("with only a backdrop, use it: %q, %v", body, err) } if body, _ := a.Artist(context.Background(), someMBID, "Gorillaz"); string(body) != "backdrop" || src.calls.Load() != 1 { t.Errorf("the second ask should come from disk: %q after %d calls", body, src.calls.Load()) } } func TestArt_NobodyHasOneIsBelievedForAWeek(t *testing.T) { src := &fakeSource{err: coverart.ErrNotFound} a := NewArt(t.TempDir(), sources{artists: []coverart.ArtistArtProvider{src}}) if _, err := a.Artist(context.Background(), someMBID, "x"); !errors.Is(err, ErrNoArt) { t.Fatalf("err = %v, want ErrNoArt", err) } if _, err := a.Artist(context.Background(), someMBID, "x"); !errors.Is(err, ErrNoArt) || src.calls.Load() != 1 { t.Errorf("a definite miss is remembered: %d calls", src.calls.Load()) } a.now = func() time.Time { return time.Now().Add(NoArtRetry + time.Hour) } src.err, src.thumb = nil, []byte("thumb") if body, err := a.Artist(context.Background(), someMBID, "x"); err != nil || string(body) != "thumb" { t.Errorf("after a week the sources are asked again: %q, %v", body, err) } } func TestArt_AnUnreachableSourceIsAskedAgain(t *testing.T) { src := &fakeSource{err: coverart.ErrTransient} a := NewArt(t.TempDir(), sources{artists: []coverart.ArtistArtProvider{src}}) for range 2 { if _, err := a.Artist(context.Background(), someMBID, "x"); !errors.Is(err, ErrNoArt) { t.Fatalf("err = %v", err) } } if src.calls.Load() != 2 { t.Errorf("calls = %d: an outage must not be written down as 'no art'", src.calls.Load()) } } func TestArt_ReleaseGroupAsksByGroupThenByName(t *testing.T) { byName := &fakeSource{cover: []byte("by-name")} caa := groupSource{&fakeSource{}} a := NewArt(t.TempDir(), sources{albums: []coverart.AlbumCoverProvider{caa, byName}}) if body, _ := a.ReleaseGroup(context.Background(), someMBID, "Gorillaz", "Demon Days"); string(body) != "by-group" { t.Errorf("a source that answers by group is asked by group: %q", body) } other := "0c4d5b5f-2f3c-3d31-9c4e-4b4f6b4c1a01" a = NewArt(t.TempDir(), sources{albums: []coverart.AlbumCoverProvider{byName}}) if body, _ := a.ReleaseGroup(context.Background(), other, "Gorillaz", "Demon Days"); string(body) != "by-name" { t.Errorf("by name: %q", body) } if byName.lastRef.MBID != "" || byName.lastRef.AlbumTitle != "Demon Days" { t.Errorf("a release-group id is not a release id, so it is not passed as one: %+v", byName.lastRef) } } func TestArt_ConcurrentAsksShareOneFetch(t *testing.T) { src := &fakeSource{thumb: []byte("t"), gate: make(chan struct{})} a := NewArt(t.TempDir(), sources{artists: []coverart.ArtistArtProvider{src}}) var wg sync.WaitGroup for range 5 { wg.Add(1) go func() { defer wg.Done() if body, err := a.Artist(context.Background(), someMBID, "x"); err != nil || string(body) != "t" { t.Errorf("%q, %v", body, err) } }() } // Let the fetch through once every caller has had time to queue on it. time.Sleep(50 * time.Millisecond) close(src.gate) wg.Wait() if src.calls.Load() != 1 { t.Errorf("calls = %d, want one fetch for five asks", src.calls.Load()) } } func TestArt_RefusesWhatIsNotAnMBID(t *testing.T) { dir := t.TempDir() a := NewArt(dir, sources{artists: []coverart.ArtistArtProvider{&fakeSource{thumb: []byte("t")}}}) if _, err := a.Artist(context.Background(), "../../etc/passwd", "x"); !errors.Is(err, ErrNoArt) { t.Fatalf("err = %v", err) } if _, err := os.Stat(filepath.Join(dir, "catalogue-art")); !os.IsNotExist(err) { t.Error("nothing should be written for a refused id") } if _, err := NewArt(dir, nil).Artist(context.Background(), someMBID, "x"); !errors.Is(err, ErrNoArt) { t.Errorf("no sources: err = %v", err) } }