Files
yellowjacket/backend/download/manager_test.go
T
yonluandClaude Sonnet 5 e190fd75b9
Build & publish Arch package / arch-package (push) Successful in 2m12s
Search index maintenance / maintain-index (push) Successful in 2h22m28s
feat: data lifecycle rewrite, download clients, wanted list, and central catalog index
Ships the fresh-start schema cleanup: rebuilt explore catalog index
pipeline (dump import, artifact fetch/build, incremental listen-count
refresh), a new download subsystem (Lidarr/Prowlarr/qBittorrent/SABnzbd/
slskd/yt-dlp providers, staging, reconciliation, wanted list), and the
supporting schema/query/store changes across backend and frontend.

Also includes two smaller follow-ups: bump the central index's
rebuild-after cadence from 90 to 180 days, and remove the Explore
"library only" online/offline toggle entirely (frontend-only, no
backend counterpart) rather than carry unused UI/state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
2026-08-06 17:12:01 -04:00

508 lines
13 KiB
Go

package download
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
"yellowjacket/backend/database"
)
// managerFixture wires a manager over a real (in-memory) database, a
// temp staging area and a temp library root, with no network anywhere.
type managerFixture struct {
manager *Manager
store *Store
staging *Staging
lib *stubLibrary
tags *recordingTagWriter
root string
}
func newManagerFixture(t *testing.T) managerFixture {
t.Helper()
db := database.NewTestDB(t)
seedLibrary(t, db)
store := NewStore(db)
staging := newTestStaging(t)
tags := newRecordingTagWriter()
lib := &stubLibrary{}
root := t.TempDir()
imp := NewImporter(slogDiscard(), staging, tags, lib)
m := NewManager(
slogDiscard(), store, NewMemSecretStore(), staging, imp, lib,
)
m.SetImportOptions(ImportOptions{LibraryRoot: root})
return managerFixture{
manager: m,
store: store,
staging: staging,
lib: lib,
tags: tags,
root: root,
}
}
// seedLibrary inserts the library row download_requests references.
func seedLibrary(t *testing.T, db *database.DB) {
t.Helper()
if _, err := db.ExecContext(
`INSERT INTO libraries (id, name, path) VALUES (1, 'Test', '/music')`,
); err != nil {
t.Fatalf("seed library: %v", err)
}
}
// fakeWithAlbum returns a fake provider that finds and can deliver the
// four-track reference album.
func fakeWithAlbum(id int64, name string, ext string) *FakeProvider {
f := NewFakeProvider(id, name, Caps{CanSearch: true, CanTransport: true})
titles := allTitles()
c := candidateFor(name+"-cand", titles, ext, 30_000_000)
c.ProviderID = id
f.Candidates = []Candidate{c}
for i, tt := range titles {
f.Written[trackToken(i+1)+" - "+tt+ext] = []byte("audio-data")
}
return f
}
func TestManagerSearchRanksAcrossProviders(t *testing.T) {
t.Parallel()
f := newManagerFixture(t)
mp3 := fakeWithAlbum(1, "mp3-source", ".mp3")
flac := fakeWithAlbum(2, "flac-source", ".flac")
f.manager.installProvider(Config{ID: 1, Priority: 50}, mp3)
f.manager.installProvider(Config{ID: 2, Priority: 50}, flac)
ranked, err := f.manager.Search(context.Background(), fourTrackRequest())
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(ranked) != 2 {
t.Fatalf("got %d candidates, want 2", len(ranked))
}
if ranked[0].ProviderID != 2 {
t.Errorf(
"winner from provider %d, want 2 (FLAC)", ranked[0].ProviderID,
)
}
if mp3.SearchCalls != 1 || flac.SearchCalls != 1 {
t.Errorf(
"search calls: mp3=%d flac=%d, want 1 each",
mp3.SearchCalls, flac.SearchCalls,
)
}
}
// One broken provider must not take the others down with it.
func TestManagerSearchToleratesProviderFailure(t *testing.T) {
t.Parallel()
f := newManagerFixture(t)
broken := NewFakeProvider(1, "broken", Caps{CanSearch: true})
broken.SearchErr = errors.New("connection refused") //nolint:err113 // test
working := fakeWithAlbum(2, "working", ".flac")
f.manager.installProvider(Config{ID: 1, Priority: 50}, broken)
f.manager.installProvider(Config{ID: 2, Priority: 50}, working)
ranked, err := f.manager.Search(context.Background(), fourTrackRequest())
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(ranked) != 1 {
t.Fatalf("got %d candidates, want 1 from the working provider", len(ranked))
}
}
func TestManagerSearchNoProviders(t *testing.T) {
t.Parallel()
f := newManagerFixture(t)
_, err := f.manager.Search(context.Background(), fourTrackRequest())
if !errors.Is(err, ErrNoProviders) {
t.Fatalf("error = %v, want ErrNoProviders", err)
}
}
func TestManagerSearchNoCandidates(t *testing.T) {
t.Parallel()
f := newManagerFixture(t)
empty := NewFakeProvider(1, "empty", Caps{CanSearch: true})
f.manager.installProvider(Config{ID: 1, Priority: 50}, empty)
_, err := f.manager.Search(context.Background(), fourTrackRequest())
if !errors.Is(err, ErrNoCandidates) {
t.Fatalf("error = %v, want ErrNoCandidates", err)
}
}
// The whole pipeline: request → search → auto-pick → grab → verify →
// tag → import → library scan.
func TestManagerEndToEndAutoPick(t *testing.T) {
t.Parallel()
f := newManagerFixture(t)
provider := fakeWithAlbum(1, "flac-source", ".flac")
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
req := fourTrackRequest()
ranked, err := f.manager.Start(context.Background(), req)
if err != nil {
t.Fatalf("Start: %v", err)
}
if !AutoPickable(req, ranked) {
t.Fatalf(
"expected a clear winner to auto-pick; best match %f quality %f",
ranked[0].Match.Overall, ranked[0].Quality.Overall,
)
}
waitForRequestState(t, f.store, req.ID, StateComplete)
if provider.GrabCalls != 1 {
t.Errorf("grab calls = %d, want 1", provider.GrabCalls)
}
// Files landed in the library, laid out by the template.
want := filepath.Join(f.root, "Radiohead", "OK Computer", "01 Airbag.flac")
if _, err := os.Stat(want); err != nil {
t.Errorf("expected imported file at %s: %v", want, err)
}
// Staging was released only after a successful import.
entries, err := os.ReadDir(f.staging.Root())
if err != nil {
t.Fatalf("read staging root: %v", err)
}
if len(entries) != 0 {
t.Errorf("staging not released: %d dirs remain", len(entries))
}
// The library was told to rescan.
f.lib.mu.Lock()
scanned := len(f.lib.scanned)
f.lib.mu.Unlock()
if scanned != 1 {
t.Errorf("library scans = %d, want 1", scanned)
}
}
// An ambiguous result set must park for the user rather than guess.
func TestManagerWaitsWhenAmbiguous(t *testing.T) {
t.Parallel()
f := newManagerFixture(t)
a := fakeWithAlbum(1, "source-a", ".flac")
b := fakeWithAlbum(2, "source-b", ".flac")
f.manager.installProvider(Config{ID: 1, Priority: 50}, a)
f.manager.installProvider(Config{ID: 2, Priority: 50}, b)
req := fourTrackRequest()
ranked, err := f.manager.Start(context.Background(), req)
if err != nil {
t.Fatalf("Start: %v", err)
}
if AutoPickable(req, ranked) {
t.Fatal("two equivalent candidates must not auto-pick")
}
// Nothing was grabbed while waiting for the user.
if a.GrabCalls != 0 || b.GrabCalls != 0 {
t.Errorf(
"grabs happened without a pick: a=%d b=%d",
a.GrabCalls, b.GrabCalls,
)
}
stored, err := f.store.GetRequest(context.Background(), req.ID)
if err != nil {
t.Fatalf("GetRequest: %v", err)
}
if stored.ID != req.ID {
t.Errorf("stored request id = %s, want %s", stored.ID, req.ID)
}
// The user picks the second one explicitly.
if err := f.manager.Pick(
context.Background(), req.ID, ranked[1].ID,
); err != nil {
t.Fatalf("Pick: %v", err)
}
waitForRequestState(t, f.store, req.ID, StateComplete)
}
func TestManagerPickUnknownCandidate(t *testing.T) {
t.Parallel()
f := newManagerFixture(t)
provider := fakeWithAlbum(1, "source", ".flac")
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
req := fourTrackRequest()
req.Expected = nil // free text: never auto-picks
if _, err := f.manager.Start(context.Background(), req); err != nil {
t.Fatalf("Start: %v", err)
}
err := f.manager.Pick(context.Background(), req.ID, "no-such-candidate")
if !errors.Is(err, ErrCandidateGone) {
t.Fatalf("error = %v, want ErrCandidateGone", err)
}
}
// A failed grab must leave staging intact for retry and must not put
// anything in the library.
func TestManagerFailedGrabLeavesLibraryClean(t *testing.T) {
t.Parallel()
f := newManagerFixture(t)
provider := fakeWithAlbum(1, "source", ".flac")
provider.GrabErr = errors.New("peer went offline") //nolint:err113 // test
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
req := fourTrackRequest()
if _, err := f.manager.Start(context.Background(), req); err != nil {
t.Fatalf("Start: %v", err)
}
waitForRequestState(t, f.store, req.ID, StateFailed)
entries, err := os.ReadDir(f.root)
if err != nil {
t.Fatalf("read library root: %v", err)
}
if len(entries) != 0 {
t.Errorf("failed grab wrote %d entries into the library", len(entries))
}
staged, err := os.ReadDir(f.staging.Root())
if err != nil {
t.Fatalf("read staging root: %v", err)
}
if len(staged) == 0 {
t.Error("staging released after a failure; nothing left to retry")
}
}
// A search-only provider's candidate is fetched by whichever enabled
// transport handles its protocol.
func TestManagerPairsSearcherWithTransport(t *testing.T) {
t.Parallel()
f := newManagerFixture(t)
searcher := NewFakeProvider(1, "indexer", Caps{CanSearch: true})
titles := allTitles()
c := candidateFor("torrent-cand", titles, ".flac", 30_000_000)
c.Protocol = ProtocolTorrent
searcher.Candidates = []Candidate{c}
transport := NewFakeProvider(2, "torrent-client", Caps{
CanTransport: true,
Transports: []Protocol{ProtocolTorrent},
})
for i, tt := range titles {
transport.Written[trackToken(i+1)+" - "+tt+".flac"] = []byte("audio-data")
}
f.manager.installProvider(Config{ID: 1, Priority: 50}, searcher)
f.manager.installProvider(Config{ID: 2, Priority: 50}, transport)
req := fourTrackRequest()
if _, err := f.manager.Start(context.Background(), req); err != nil {
t.Fatalf("Start: %v", err)
}
waitForRequestState(t, f.store, req.ID, StateComplete)
if transport.GrabCalls != 1 {
t.Errorf("transport grabs = %d, want 1", transport.GrabCalls)
}
if searcher.GrabCalls != 0 {
t.Errorf("searcher should not have grabbed, got %d", searcher.GrabCalls)
}
}
// A candidate whose protocol nothing handles must fail loudly rather
// than being silently dropped.
func TestManagerNoTransportForProtocol(t *testing.T) {
t.Parallel()
f := newManagerFixture(t)
searcher := NewFakeProvider(1, "indexer", Caps{CanSearch: true})
c := candidateFor("usenet-cand", allTitles(), ".flac", 30_000_000)
c.Protocol = ProtocolUsenet
searcher.Candidates = []Candidate{c}
f.manager.installProvider(Config{ID: 1, Priority: 50}, searcher)
req := fourTrackRequest()
if _, err := f.manager.Start(context.Background(), req); err != nil {
t.Fatalf("Start: %v", err)
}
waitForRequestState(t, f.store, req.ID, StateFailed)
}
// waitForRequestState polls until a request reaches the wanted state.
// The pipeline runs on its own goroutine, so tests observe it through
// the store rather than by reaching into the manager.
func waitForRequestState(
t *testing.T,
store *Store,
requestID string,
want State,
) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
var last State
for time.Now().Before(deadline) {
state, _, err := store.GetRequestState(context.Background(), requestID)
if err == nil {
last = state
if last == want {
return
}
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("request state = %q after 5s, want %q", last, want)
}
// A delegate's files are already in the external manager's library,
// tagged by it and at paths it chose. The pipeline must record them in
// place rather than moving them out from under a system that is still
// managing them.
func TestManagerDelegateReconcilesInPlace(t *testing.T) {
t.Parallel()
f := newManagerFixture(t)
f.manager.delegatePoll = time.Millisecond
delegate := NewFakeProvider(1, "lidarr", Caps{
CanSearch: true,
CanDelegate: true,
})
c := candidateFor("delegate-cand", allTitles(), ".flac", 30_000_000)
c.ProviderID = 1
delegate.Candidates = []Candidate{c}
external := []string{
"/external/library/Radiohead/OK Computer/01 Airbag.flac",
"/external/library/Radiohead/OK Computer/02 Paranoid Android.flac",
}
delegate.DelegateStatuses = []DelegateStatus{
{State: StateGrabbing, Progress: 0.5},
{State: StateComplete, Progress: 1, ImportedPaths: external},
}
f.manager.installProvider(Config{ID: 1, Priority: 50}, delegate)
req := fourTrackRequest()
if _, err := f.manager.Start(context.Background(), req); err != nil {
t.Fatalf("Start: %v", err)
}
waitForRequestState(t, f.store, req.ID, StateComplete)
if delegate.DelegateCalls != 1 {
t.Errorf("delegate calls = %d, want 1", delegate.DelegateCalls)
}
// Nothing was tagged or written into our library root — the files
// belong to the external manager.
entries, err := os.ReadDir(f.root)
if err != nil {
t.Fatalf("read library root: %v", err)
}
if len(entries) != 0 {
t.Errorf("delegate import wrote %d entries into our library", len(entries))
}
f.tags.mu.Lock()
writes := len(f.tags.writes)
f.tags.mu.Unlock()
if writes != 0 {
t.Errorf("tagged %d files, want 0 for a delegated import", writes)
}
// The external paths were recorded against the item.
items, err := f.store.ListItemsForRequest(context.Background(), req.ID)
if err != nil {
t.Fatalf("ListItemsForRequest: %v", err)
}
if len(items) != 1 {
t.Fatalf("got %d items, want 1", len(items))
}
if len(items[0].Imported) != len(external) {
t.Errorf(
"recorded %v, want the external manager's paths %v",
items[0].Imported, external,
)
}
}