Files
yellowjacket/backend/download/provider_prowlarr_test.go
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

314 lines
6.8 KiB
Go

package download
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
// prowlarrStub is a fake Prowlarr instance.
type prowlarrStub struct {
server *httptest.Server
mu sync.Mutex
results []prowlarrResult
indexers []map[string]any
// lastQuery records the search query string for assertions.
lastQuery string
unauthorized bool
}
func newProwlarrStub(t *testing.T) *prowlarrStub {
t.Helper()
s := &prowlarrStub{
indexers: []map[string]any{{"id": 1, "enable": true}},
}
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/system/status", func(w http.ResponseWriter, r *http.Request) {
if s.reject(w, r) {
return
}
writeJSON(t, w, map[string]any{"version": "1.0.0"})
})
mux.HandleFunc("/api/v1/indexer", func(w http.ResponseWriter, r *http.Request) {
if s.reject(w, r) {
return
}
s.mu.Lock()
indexers := s.indexers
s.mu.Unlock()
writeJSON(t, w, indexers)
})
mux.HandleFunc("/api/v1/search", func(w http.ResponseWriter, r *http.Request) {
if s.reject(w, r) {
return
}
s.mu.Lock()
s.lastQuery = r.URL.RawQuery
results := s.results
s.mu.Unlock()
writeJSON(t, w, results)
})
s.server = httptest.NewServer(mux)
t.Cleanup(s.server.Close)
return s
}
func (s *prowlarrStub) reject(w http.ResponseWriter, r *http.Request) bool {
s.mu.Lock()
unauthorized := s.unauthorized
s.mu.Unlock()
if unauthorized || r.Header.Get("X-Api-Key") != "test-key" {
w.WriteHeader(http.StatusUnauthorized)
return true
}
return false
}
func newStubProwlarr(t *testing.T, stub *prowlarrStub, settings map[string]string) *prowlarr {
t.Helper()
if settings == nil {
settings = map[string]string{}
}
settings["url"] = stub.server.URL
p, err := newProwlarr(
Config{ID: 1, Kind: KindProwlarr, Name: "prowlarr", Settings: settings},
func(string) (string, error) { return "test-key", nil },
slogDiscard(),
)
if err != nil {
t.Fatalf("newProwlarr: %v", err)
}
pr, ok := p.(*prowlarr)
if !ok {
t.Fatalf("provider is %T, want *prowlarr", p)
}
return pr
}
func TestProwlarrCheck(t *testing.T) {
t.Parallel()
stub := newProwlarrStub(t)
p := newStubProwlarr(t, stub, nil)
if err := p.Check(context.Background()); err != nil {
t.Errorf("Check: %v", err)
}
}
// Prowlarr with every indexer disabled will silently return nothing
// forever, which is worth surfacing at configuration time.
func TestProwlarrCheckRequiresEnabledIndexer(t *testing.T) {
t.Parallel()
stub := newProwlarrStub(t)
stub.indexers = []map[string]any{{"id": 1, "enable": false}}
p := newStubProwlarr(t, stub, nil)
if err := p.Check(context.Background()); !errors.Is(
err, ErrProwlarrNoIndexers,
) {
t.Errorf("error = %v, want ErrProwlarrNoIndexers", err)
}
}
// Prowlarr fills only the Searcher role, so its candidates must carry a
// protocol the pipeline can pair with a transport.
func TestProwlarrSearchMarksProtocols(t *testing.T) {
t.Parallel()
stub := newProwlarrStub(t)
stub.results = []prowlarrResult{
{
GUID: "a",
Title: "Radiohead - OK Computer [FLAC]",
Indexer: "SomeTracker",
Protocol: "torrent",
Seeders: 50,
Size: 400_000_000,
MagnetURL: "magnet:?xt=urn:btih:abc123",
},
{
GUID: "b",
Title: "Radiohead - OK Computer [MP3]",
Indexer: "SomeUsenet",
Protocol: "usenet",
Size: 90_000_000,
DownloadURL: "https://example.com/x.nzb",
},
}
p := newStubProwlarr(t, stub, nil)
got, err := p.Search(context.Background(), Download{
Artist: "Radiohead",
Album: "OK Computer",
})
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(got) != 2 {
t.Fatalf("got %d candidates, want 2", len(got))
}
byProtocol := map[Protocol]Candidate{}
for _, c := range got {
byProtocol[c.Protocol] = c
}
torrent, ok := byProtocol[ProtocolTorrent]
if !ok {
t.Fatal("no torrent candidate")
}
if torrent.Payload["link"] != "magnet:?xt=urn:btih:abc123" {
t.Errorf("torrent link = %q, want the magnet", torrent.Payload["link"])
}
usenet, ok := byProtocol[ProtocolUsenet]
if !ok {
t.Fatal("no usenet candidate")
}
if usenet.Payload["link"] != "https://example.com/x.nzb" {
t.Errorf("usenet link = %q, want the NZB URL", usenet.Payload["link"])
}
// Indexer results are opaque before fetching; claiming a file list
// would be inventing information.
if len(torrent.Files) != 0 {
t.Errorf("torrent candidate has %d files, want none", len(torrent.Files))
}
}
// A torrent nobody is seeding will never finish, so offering it wastes
// the user's choice.
func TestProwlarrFiltersDeadTorrents(t *testing.T) {
t.Parallel()
stub := newProwlarrStub(t)
stub.results = []prowlarrResult{
{
GUID: "dead", Title: "Dead", Protocol: "torrent",
Seeders: 0, MagnetURL: "magnet:?xt=urn:btih:dead",
},
{
GUID: "alive", Title: "Alive", Protocol: "torrent",
Seeders: 10, MagnetURL: "magnet:?xt=urn:btih:alive",
},
}
p := newStubProwlarr(t, stub, map[string]string{"minSeeders": "1"})
got, err := p.Search(context.Background(), Download{Query: "x"})
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(got) != 1 {
t.Fatalf("got %d candidates, want 1", len(got))
}
if got[0].Title != "Alive" {
t.Errorf("kept %q, want the seeded torrent", got[0].Title)
}
}
// Searching without a category constraint returns films and software
// alongside music.
func TestProwlarrSearchesMusicCategory(t *testing.T) {
t.Parallel()
stub := newProwlarrStub(t)
p := newStubProwlarr(t, stub, nil)
if _, err := p.Search(
context.Background(), Download{Query: "radiohead"},
); err != nil {
t.Fatalf("Search: %v", err)
}
stub.mu.Lock()
query := stub.lastQuery
stub.mu.Unlock()
if !strings.Contains(query, "categories="+prowlarrMusicCategory) {
t.Errorf("query %q does not constrain to the music category", query)
}
}
func TestSwarmHealth(t *testing.T) {
t.Parallel()
// More seeders is never worse.
prev := -1.0
for _, seeders := range []int{0, 1, 3, 10, 50, 500} {
got := swarmHealth(ProtocolTorrent, seeders)
if got < prev {
t.Errorf("health fell at %d seeders: %f after %f", seeders, got, prev)
}
if got < 0 || got > 1 {
t.Errorf("health %f out of range at %d seeders", got, seeders)
}
prev = got
}
// Usenet has no swarm, so seeder count is meaningless there.
if a, b := swarmHealth(ProtocolUsenet, 0), swarmHealth(ProtocolUsenet, 99); a != b {
t.Errorf("usenet health varied with seeders: %f vs %f", a, b)
}
}
func TestProtocolFor(t *testing.T) {
t.Parallel()
tests := map[string]Protocol{
"torrent": ProtocolTorrent,
"Torrent": ProtocolTorrent,
"usenet": ProtocolUsenet,
"USENET": ProtocolUsenet,
"weird": ProtocolDirect,
"": ProtocolDirect,
}
for in, want := range tests {
if got := protocolFor(in); got != want {
t.Errorf("protocolFor(%q) = %q, want %q", in, got, want)
}
}
}