Files
yellowjacket/backend/download/provider_prowlarr_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

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(), Request{
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(), Request{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(), Request{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)
}
}
}