Files
yellowjacket/backend/download/reconcile_test.go
logan 62bb40fc4d fix(download): make "check now" actually check now, and say what it did
The button ran a normal reconcile pass, which honours each request's
retry backoff — so a request searched an hour ago was not due, nothing
was searched, and the button looked broken. The backoff is a promise to
the providers, not to the user: a person pressing "check now" *is* the
schedule, so a user-initiated pass ignores it and the loop still does
not.

"Nothing happened" also needed a reason. Summary now carries how many
requests are still being looked for and whether any download client is
enabled at all, which is the one cause of silence the user can fix —
and the requests tab says so above the list rather than leaving an
inert list to be interpreted.

The rest is the retry schedule finally being admitted to: rows show
when the next check falls due, "Looking for" explains that a request
sitting there is waiting rather than failing, and the page header says
how often the list is worked.
2026-08-11 01:15:23 -04:00

596 lines
14 KiB
Go

package download
import (
"context"
"errors"
"sync"
"testing"
"time"
)
// fakeCatalog answers the reconciler's four questions from maps, so the
// wanted list can be tested without an explore index.
type fakeCatalog struct {
mu sync.Mutex
// discographies maps artist MBID to release groups.
discographies map[string][]CatalogItem
// tracklists maps an MBID to what it should contain.
tracklists map[string][]ExpectedTrack
// owned is the set of MBIDs the library has.
owned map[string]bool
// discographyErr is returned by ReleaseGroupsForArtist when set.
discographyErr error
}
func newFakeCatalog() *fakeCatalog {
return &fakeCatalog{
discographies: map[string][]CatalogItem{},
tracklists: map[string][]ExpectedTrack{},
owned: map[string]bool{},
}
}
func (c *fakeCatalog) ReleaseGroupsForArtist(
_ context.Context,
artistMBID string,
) ([]CatalogItem, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.discographyErr != nil {
return nil, c.discographyErr
}
return c.discographies[artistMBID], nil
}
func (c *fakeCatalog) Tracklist(
_ context.Context,
_ Entity,
mbid string,
) ([]ExpectedTrack, error) {
c.mu.Lock()
defer c.mu.Unlock()
return c.tracklists[mbid], nil
}
func (c *fakeCatalog) Owns(
_ context.Context,
_ Entity,
mbid string,
) (bool, error) {
c.mu.Lock()
defer c.mu.Unlock()
return c.owned[mbid], nil
}
func (c *fakeCatalog) Describe(
_ context.Context,
_ Entity,
_ string,
) (CatalogItem, bool) {
return CatalogItem{}, false
}
// reconcileFixture is a manager fixture plus a wanted list over it.
type reconcileFixture struct {
managerFixture
catalog *fakeCatalog
reconciler *Reconciler
}
func newReconcileFixture(t *testing.T) reconcileFixture {
t.Helper()
mf := newManagerFixture(t)
cat := newFakeCatalog()
r := NewReconciler(slogDiscard(), mf.store, mf.manager, cat)
return reconcileFixture{managerFixture: mf, catalog: cat, reconciler: r}
}
// An artist subscription becomes one want per album, and re-running
// adds nothing — which is what makes it a subscription rather than a
// one-time queue fill.
func TestExpandArtistIsIdempotent(t *testing.T) {
t.Parallel()
f := newReconcileFixture(t)
ctx := context.Background()
f.catalog.discographies["artist-1"] = []CatalogItem{
{MBID: "rg-1", Title: "First", FirstReleaseDate: "2030-01-01"},
{MBID: "rg-2", Title: "Second", FirstReleaseDate: "2030-06-01"},
}
if _, err := f.store.AddRequest(ctx, Request{
MBID: "artist-1",
Entity: EntityArtist,
LibraryID: 1,
Artist: "Radiohead",
Scope: ScopeAll,
}); err != nil {
t.Fatalf("AddRequest: %v", err)
}
first, err := f.reconciler.expandArtists(ctx)
if err != nil {
t.Fatalf("expandArtists: %v", err)
}
if first != 2 {
t.Fatalf("first pass created %d requests, want 2", first)
}
second, err := f.reconciler.expandArtists(ctx)
if err != nil {
t.Fatalf("expandArtists again: %v", err)
}
if second != 0 {
t.Errorf("second pass created %d requests, want 0", second)
}
// A new album appearing later is picked up by the same pass.
f.catalog.mu.Lock()
f.catalog.discographies["artist-1"] = append(
f.catalog.discographies["artist-1"],
CatalogItem{MBID: "rg-3", Title: "Third", FirstReleaseDate: "2031-01-01"},
)
f.catalog.mu.Unlock()
third, err := f.reconciler.expandArtists(ctx)
if err != nil {
t.Fatalf("expandArtists third: %v", err)
}
if third != 1 {
t.Errorf("third pass created %d requests, want 1", third)
}
}
// A default artist subscription takes new releases only, so subscribing
// does not silently queue a back catalogue.
func TestExpandArtistFutureScopeSkipsBackCatalogue(t *testing.T) {
t.Parallel()
f := newReconcileFixture(t)
ctx := context.Background()
f.catalog.discographies["artist-1"] = []CatalogItem{
{MBID: "rg-old", Title: "Old", FirstReleaseDate: "1997-04-22"},
{MBID: "rg-new", Title: "New", FirstReleaseDate: "2099-01-01"},
}
if _, err := f.store.AddRequest(ctx, Request{
MBID: "artist-1",
Entity: EntityArtist,
LibraryID: 1,
Scope: ScopeFuture,
}); err != nil {
t.Fatalf("AddRequest: %v", err)
}
if _, err := f.reconciler.expandArtists(ctx); err != nil {
t.Fatalf("expandArtists: %v", err)
}
requests, err := f.store.ListRequests(ctx)
if err != nil {
t.Fatalf("ListDownloads: %v", err)
}
for _, req := range requests {
if req.MBID == "rg-old" {
t.Error("future scope queued a back-catalogue album")
}
}
if len(requests) != 2 {
t.Errorf("got %d requests (artist + new album), want 2", len(requests))
}
}
// One artist whose discography will not resolve must not stop the rest
// of the list being expanded.
func TestExpandArtistToleratesCatalogFailure(t *testing.T) {
t.Parallel()
f := newReconcileFixture(t)
ctx := context.Background()
f.catalog.discographyErr = errors.New("index not ready") //nolint:err113 // test
if _, err := f.store.AddRequest(ctx, Request{
MBID: "artist-1",
Entity: EntityArtist,
LibraryID: 1,
}); err != nil {
t.Fatalf("AddRequest: %v", err)
}
created, err := f.reconciler.expandArtists(ctx)
if err != nil {
t.Fatalf("expandArtists returned an error for one bad artist: %v", err)
}
if created != 0 {
t.Errorf("created %d requests from a failing catalog, want 0", created)
}
}
// Something the library already owns is retired, however it got there.
func TestRetireOwnedSatisfiesRequests(t *testing.T) {
t.Parallel()
f := newReconcileFixture(t)
ctx := context.Background()
id, err := f.store.AddRequest(ctx, Request{
MBID: "rg-1",
Entity: EntityReleaseGroup,
LibraryID: 1,
})
if err != nil {
t.Fatalf("AddRequest: %v", err)
}
f.catalog.owned["rg-1"] = true
n, err := f.reconciler.retireOwned(ctx)
if err != nil {
t.Fatalf("retireOwned: %v", err)
}
if n != 1 {
t.Fatalf("retired %d requests, want 1", n)
}
req, err := f.store.GetRequest(ctx, id)
if err != nil {
t.Fatalf("GetRequest: %v", err)
}
if req.State != RequestStateSatisfied {
t.Errorf("state = %q, want satisfied", req.State)
}
}
// The end-to-end case: a want with a clear winner downloads without
// anyone watching, and is satisfied when the files land.
func TestReconcileDownloadsAndSatisfies(t *testing.T) {
t.Parallel()
f := newReconcileFixture(t)
ctx := context.Background()
provider := fakeWithAlbum(1, "source", ".flac")
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
id, err := f.store.AddRequest(ctx, Request{
MBID: "rg-1",
Entity: EntityReleaseGroup,
LibraryID: 1,
Artist: "Radiohead",
Title: "OK Computer",
})
if err != nil {
t.Fatalf("AddRequest: %v", err)
}
f.catalog.tracklists["rg-1"] = fourTrackDownload().Expected
summary, err := f.reconciler.RunOnce(ctx)
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if summary.Attempted != 1 || summary.Started != 1 {
t.Fatalf(
"attempted=%d started=%d, want 1 and 1 (last error: see want)",
summary.Attempted, summary.Started,
)
}
waitFor(t, func() bool {
req, err := f.store.GetRequest(ctx, id)
return err == nil && req.State == RequestStateSatisfied
}, "want was never satisfied after its download completed")
if provider.GrabCalls != 1 {
t.Errorf("grab calls = %d, want 1", provider.GrabCalls)
}
}
// Nothing good enough is not a failure. The want stays wanted, gains
// an attempt and a reason, and leaves no request row behind.
func TestReconcileKeepsRequestingWhenNothingIsGoodEnough(t *testing.T) {
t.Parallel()
f := newReconcileFixture(t)
ctx := context.Background()
// A provider that finds only an unrelated album: enough to return
// candidates, nowhere near enough to auto-pick.
provider := NewFakeProvider(1, "weak", Caps{CanSearch: true, CanTransport: true})
provider.Candidates = []Candidate{candidateFor(
"weak-1", []string{"Something Else Entirely"}, ".mp3", 3_000_000,
)}
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
id, err := f.store.AddRequest(ctx, Request{
MBID: "rg-1",
Entity: EntityReleaseGroup,
LibraryID: 1,
Artist: "Radiohead",
Title: "OK Computer",
})
if err != nil {
t.Fatalf("AddRequest: %v", err)
}
f.catalog.tracklists["rg-1"] = fourTrackDownload().Expected
summary, err := f.reconciler.RunOnce(ctx)
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if summary.Started != 0 {
t.Fatalf("started %d downloads, want 0", summary.Started)
}
req, err := f.store.GetRequest(ctx, id)
if err != nil {
t.Fatalf("GetRequest: %v", err)
}
if req.State != RequestStateWanted {
t.Errorf("state = %q, want it still wanted", req.State)
}
if req.Attempts != 1 {
t.Errorf("attempts = %d, want 1", req.Attempts)
}
if req.LastError == "" {
t.Error("no reason was recorded for the user")
}
if !req.NextTryAt.After(time.Now()) {
t.Errorf("next try at %v, want it in the future", req.NextTryAt)
}
// The whole point of Attempt over Start: an unsuccessful pass
// leaves no request row to clutter the downloads list.
requests, err := f.store.ListDownloads(ctx, 50)
if err != nil {
t.Fatalf("ListDownloads: %v", err)
}
if len(requests) != 0 {
t.Errorf("got %d request rows from a fruitless pass, want 0", len(requests))
}
}
// A want with no resolvable tracklist waits rather than guessing.
func TestReconcileWaitsWithoutTracklist(t *testing.T) {
t.Parallel()
f := newReconcileFixture(t)
ctx := context.Background()
provider := fakeWithAlbum(1, "source", ".flac")
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
if _, err := f.store.AddRequest(ctx, Request{
MBID: "rg-1",
Entity: EntityReleaseGroup,
LibraryID: 1,
Artist: "Radiohead",
Title: "OK Computer",
}); err != nil {
t.Fatalf("AddRequest: %v", err)
}
summary, err := f.reconciler.RunOnce(ctx)
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if summary.Started != 0 {
t.Errorf("started %d downloads with no tracklist, want 0", summary.Started)
}
if provider.SearchCalls != 0 {
t.Errorf(
"searched %d times with no tracklist to verify against, want 0",
provider.SearchCalls,
)
}
}
// Artist subscriptions are never attempted as downloads: they expand.
func TestArtistRequestsAreNeverDue(t *testing.T) {
t.Parallel()
f := newReconcileFixture(t)
ctx := context.Background()
if _, err := f.store.AddRequest(ctx, Request{
MBID: "artist-1",
Entity: EntityArtist,
LibraryID: 1,
}); err != nil {
t.Fatalf("AddRequest: %v", err)
}
due, err := f.store.ListDueRequests(ctx, 10)
if err != nil {
t.Fatalf("ListDueRequests: %v", err)
}
if len(due) != 0 {
t.Errorf("got %d due requests, want 0 — artists expand, not download", len(due))
}
}
// A pass attempts at most its batch size, so a large list is worked
// through steadily rather than in one flood.
func TestReconcileRespectsBatchSize(t *testing.T) {
t.Parallel()
f := newReconcileFixture(t)
ctx := context.Background()
f.reconciler.SetBatch(2)
for _, mbid := range []string{"rg-1", "rg-2", "rg-3", "rg-4"} {
if _, err := f.store.AddRequest(ctx, Request{
MBID: mbid,
Entity: EntityReleaseGroup,
LibraryID: 1,
Title: "Album " + mbid,
}); err != nil {
t.Fatalf("AddRequest: %v", err)
}
f.catalog.tracklists[mbid] = fourTrackDownload().Expected
}
summary, err := f.reconciler.RunOnce(ctx)
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if summary.Attempted != 2 {
t.Errorf("attempted %d requests, want 2 (the batch size)", summary.Attempted)
}
}
// waitFor polls a condition, failing the test if it never holds. Used
// where the pipeline hands work to a goroutine.
func waitFor(t *testing.T, cond func() bool, msg string) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal(msg)
}
// "Check now" is the user overriding the retry schedule, so it must
// search a request whose backoff has not elapsed. The scheduled pass
// must not: the backoff exists to keep a fruitless search off the
// providers, and a loop that ignored it would hammer them.
func TestRunNowIgnoresBackoffAndRunOnceDoesNot(t *testing.T) {
t.Parallel()
f := newReconcileFixture(t)
ctx := context.Background()
provider := NewFakeProvider(1, "weak", Caps{CanSearch: true, CanTransport: true})
provider.Candidates = []Candidate{candidateFor(
"weak-1", []string{"Something Else Entirely"}, ".mp3", 3_000_000,
)}
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
id, err := f.store.AddRequest(ctx, Request{
MBID: "rg-1",
Entity: EntityReleaseGroup,
LibraryID: 1,
Artist: "Radiohead",
Title: "OK Computer",
})
if err != nil {
t.Fatalf("AddRequest: %v", err)
}
f.catalog.tracklists["rg-1"] = fourTrackDownload().Expected
// First pass: attempted, found nothing, backoff armed.
if _, err := f.reconciler.RunOnce(ctx); err != nil {
t.Fatalf("RunOnce: %v", err)
}
scheduled, err := f.reconciler.RunOnce(ctx)
if err != nil {
t.Fatalf("RunOnce (second): %v", err)
}
if scheduled.Attempted != 0 {
t.Errorf("scheduled pass attempted %d, want 0 while backed off",
scheduled.Attempted)
}
forced, err := f.reconciler.RunNow(ctx)
if err != nil {
t.Fatalf("RunNow: %v", err)
}
if forced.Attempted != 1 {
t.Errorf("forced pass attempted %d, want 1", forced.Attempted)
}
if forced.Waiting != 1 {
t.Errorf("summary reported %d waiting, want 1 so the UI can say "+
"what was searched", forced.Waiting)
}
req, err := f.store.GetRequest(ctx, id)
if err != nil {
t.Fatalf("GetRequest: %v", err)
}
if req.Attempts != 2 {
t.Errorf("attempts = %d, want 2 after a forced re-check", req.Attempts)
}
}
// A pass with no providers says so, because "nothing happened" with no
// reason is the one outcome the user cannot act on.
func TestSummaryReportsNoProviders(t *testing.T) {
t.Parallel()
f := newReconcileFixture(t)
ctx := context.Background()
if _, err := f.store.AddRequest(ctx, Request{
MBID: "rg-1",
Entity: EntityReleaseGroup,
LibraryID: 1,
Title: "OK Computer",
}); err != nil {
t.Fatalf("AddRequest: %v", err)
}
f.catalog.tracklists["rg-1"] = fourTrackDownload().Expected
summary, err := f.reconciler.RunNow(ctx)
if err != nil {
t.Fatalf("RunNow: %v", err)
}
if !summary.NoProviders {
t.Error("summary did not report that no download client is enabled")
}
}