Files
yellowjacket/backend/download/manager_test.go
T
yonluandClaude Sonnet 5 65333857e2
Build & publish Arch package / arch-package (push) Successful in 2m2s
Search index maintenance / maintain-index (push) Successful in 7s
refactor(download): rename Want/Request to Request/Download, unify downloads flow, add auto-download guardrails
The durable "I asked for this" record was called Want, and the one-shot
search-and-grab attempt was called Request — names that didn't match
what either actually did. Want is now Request, and the old Request/Item
is now Download/DownloadItem, with a table-rename migration
(download_wants -> download_requests, old download_requests ->
download_downloads) safe against both fresh installs and existing data.

Every anchored manual download now upserts/reuses a durable Request
before running, so a "download now" that finds nothing is picked up by
the background reconciler automatically instead of just failing with
no trace — the gap that caused this session's repeated "no candidates
found" failures on the same album.

Also adds auto-download guardrails (file-size min/max with a preferred
target, allowed file types) that gate what the pipeline may grab
unattended, live-editable from a new settings section. The frontend's
wanted-view becomes downloads-view, with a new Downloads tab showing
attempt/transfer history that previously had no UI at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
2026-08-10 14:35:57 -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()
root := t.TempDir()
lib := &stubLibrary{path: root}
imp := NewImporter(slogDiscard(), staging, tags, lib)
m := NewManager(
slogDiscard(), store, NewMemSecretStore(), staging, imp, lib,
)
m.SetImportOptions(ImportOptions{})
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(), fourTrackDownload())
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(), fourTrackDownload())
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(), fourTrackDownload())
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(), fourTrackDownload())
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)
dl := fourTrackDownload()
ranked, err := f.manager.Start(context.Background(), dl)
if err != nil {
t.Fatalf("Start: %v", err)
}
if !f.manager.AutoPickable(dl, ranked) {
t.Fatalf(
"expected a clear winner to auto-pick; best match %f quality %f",
ranked[0].Match.Overall, ranked[0].Quality.Overall,
)
}
waitForDownloadState(t, f.store, dl.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)
dl := fourTrackDownload()
ranked, err := f.manager.Start(context.Background(), dl)
if err != nil {
t.Fatalf("Start: %v", err)
}
if f.manager.AutoPickable(dl, 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.GetDownload(context.Background(), dl.ID)
if err != nil {
t.Fatalf("GetDownload: %v", err)
}
if stored.ID != dl.ID {
t.Errorf("stored request id = %s, want %s", stored.ID, dl.ID)
}
// The user picks the second one explicitly.
if err := f.manager.Pick(
context.Background(), dl.ID, ranked[1].ID,
); err != nil {
t.Fatalf("Pick: %v", err)
}
waitForDownloadState(t, f.store, dl.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)
dl := fourTrackDownload()
dl.Expected = nil // free text: never auto-picks
if _, err := f.manager.Start(context.Background(), dl); err != nil {
t.Fatalf("Start: %v", err)
}
err := f.manager.Pick(context.Background(), dl.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)
dl := fourTrackDownload()
if _, err := f.manager.Start(context.Background(), dl); err != nil {
t.Fatalf("Start: %v", err)
}
waitForDownloadState(t, f.store, dl.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)
dl := fourTrackDownload()
if _, err := f.manager.Start(context.Background(), dl); err != nil {
t.Fatalf("Start: %v", err)
}
waitForDownloadState(t, f.store, dl.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)
dl := fourTrackDownload()
if _, err := f.manager.Start(context.Background(), dl); err != nil {
t.Fatalf("Start: %v", err)
}
waitForDownloadState(t, f.store, dl.ID, StateFailed)
}
// waitForDownloadState polls until a download 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 waitForDownloadState(
t *testing.T,
store *Store,
downloadID string,
want State,
) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
var last State
for time.Now().Before(deadline) {
state, _, err := store.GetDownloadState(context.Background(), downloadID)
if err == nil {
last = state
if last == want {
return
}
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("download 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)
dl := fourTrackDownload()
if _, err := f.manager.Start(context.Background(), dl); err != nil {
t.Fatalf("Start: %v", err)
}
waitForDownloadState(t, f.store, dl.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.ListItemsForDownload(context.Background(), dl.ID)
if err != nil {
t.Fatalf("ListItemsForDownload: %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,
)
}
}