package indexer
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
)
const sampleFeed = `
-
Radiohead - OK Computer (1997) [FLAC]
https://tracker.example/details/1
https://prowlarr.example/1/download?link=abc
512000000
Tue, 07 Oct 2026 10:00:00 +0000
-
Radiohead - Kid A (2000) [MP3 320]
0123456789abcdef0123456789abcdef01234567
magnet:?xt=urn:btih:FFEEDDCCBBAA99887766554433221100FFEEDDCC&dn=kid+a
-
`
func TestTorznabSearch_ParsesTheFeedAndStampsTheSource(t *testing.T) {
reqs := make(chan *http.Request, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqs <- r
_, _ = w.Write([]byte(sampleFeed))
}))
defer srv.Close()
c := &Torznab{Config: TorznabConfig{Code: "torznab-2", FeedURL: srv.URL + "/12/api", APIKey: "k"}}
results, err := c.Search(context.Background(), Query{Text: "radiohead", Categories: MusicCategories})
if err != nil {
t.Fatalf("search: %v", err)
}
got := <-reqs
if got.URL.Path != "/12/api" || got.URL.Query().Get("t") != "search" || got.URL.Query().Get("apikey") != "k" {
t.Errorf("request = %s, want the feed URL with t=search and the key", got.URL)
}
if cats := got.URL.Query()["cat"]; len(cats) != 3 || cats[0] != "3000" {
t.Errorf("categories = %v, want the music categories", cats)
}
if len(results) != 2 {
t.Fatalf("results = %d, want 2 (the untitled item dropped)", len(results))
}
first := results[0]
if first.InfoHash != "aabbccddeeff00112233445566778899aabbccdd" || first.Seeders != 12 ||
first.Category != 3040 || first.Indexer != "RED" || first.DownloadURL == "" || first.Source != "torznab-2" {
t.Errorf("first = %+v", first)
}
if first.Published.IsZero() {
t.Error("pubDate was not parsed")
}
// The magnet's hash wins over a GUID shaped like another hash.
if results[1].Magnet == "" || results[1].InfoHash != "ffeeddccbbaa99887766554433221100ffeeddcc" {
t.Errorf("second = %+v, want the magnet's hash", results[1])
}
}
func TestTorznabSearch_KeepsAQueryAlreadyInTheFeedURL(t *testing.T) {
reqs := make(chan *http.Request, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqs <- r
_, _ = w.Write([]byte(``))
}))
defer srv.Close()
c := &Torznab{Config: TorznabConfig{FeedURL: srv.URL + "/api?passkey=p"}}
if _, err := c.Search(context.Background(), Query{Text: "x"}); err != nil {
t.Fatalf("search: %v", err)
}
got := <-reqs
if got.URL.Query().Get("passkey") != "p" || got.URL.Query().Get("q") != "x" {
t.Errorf("request = %s, want the pasted query kept and ours added", got.URL)
}
}
// Newznab reports a refused key as HTTP 200 with an document. Read
// as a feed, that is an empty result set: search would quietly find nothing.
func TestTorznabSearch_ReadsAnErrorDocument(t *testing.T) {
for name, tc := range map[string]struct {
doc string
want error
}{
"a wrong key": {``, ErrAuth},
"anything else": {``, ErrUnreachable},
} {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`` + tc.doc))
}))
c := &Torznab{Config: TorznabConfig{FeedURL: srv.URL + "/api"}}
_, err := c.Search(context.Background(), Query{Text: "x"})
srv.Close()
if !errors.Is(err, tc.want) {
t.Errorf("%s: err = %v, want %v", name, err, tc.want)
}
}
}
func TestTorznabSearch_A429IsRateLimitedWithItsRetryAfter(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Retry-After", "90")
w.WriteHeader(http.StatusTooManyRequests)
}))
defer srv.Close()
c := &Torznab{Config: TorznabConfig{FeedURL: srv.URL + "/api"}}
_, err := c.Search(context.Background(), Query{Text: "x"})
rl, ok := AsRateLimited(err)
if !ok || rl.RetryAfter != 90*time.Second {
t.Fatalf("err = %v, want RateLimited for 90s", err)
}
if !errors.Is(err, ErrUnreachable) {
t.Error("a 429 should still read as unreachable to everything else")
}
}
func TestTorznabSearch_NoAddressIsUnconfigured(t *testing.T) {
_, err := (&Torznab{}).Search(context.Background(), Query{Text: "x"})
if !errors.Is(err, ErrUnconfigured) {
t.Fatalf("err = %v, want ErrUnconfigured", err)
}
}
func TestTorznabDownload_RefusesWhatIsNotATorrent(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/login":
_, _ = w.Write([]byte(`please log in`))
case "/magnet":
_, _ = w.Write([]byte("magnet:?xt=urn:btih:abc"))
case "/redirect":
http.Redirect(w, r, "magnet:?xt=urn:btih:def", http.StatusFound)
default:
_, _ = w.Write([]byte("d8:announce0:e"))
}
}))
defer srv.Close()
c := &Torznab{Config: TorznabConfig{FeedURL: srv.URL + "/api"}}
ctx := context.Background()
if _, err := c.Download(ctx, Result{Title: "t", DownloadURL: srv.URL + "/login"}); !errors.Is(err, ErrUnreachable) {
t.Errorf("an HTML page: err = %v, want ErrUnreachable", err)
}
for _, path := range []string{"/magnet", "/redirect"} {
_, err := c.Download(ctx, Result{Title: "t", DownloadURL: srv.URL + path})
if _, ok := AsMagnet(err); !ok {
t.Errorf("%s: err = %v, want a MagnetLink", path, err)
}
}
body, err := c.Download(ctx, Result{Title: "t", DownloadURL: srv.URL + "/file"})
if err != nil || string(body) != "d8:announce0:e" {
t.Errorf("a torrent: body %q err %v", body, err)
}
}
func TestRetryAfter(t *testing.T) {
now := time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC)
for v, want := range map[string]time.Duration{
"": 0,
"30": 30 * time.Second,
"soon": 0,
"Sat, 10 Oct 2026 00:02:00 GMT": 2 * time.Minute,
} {
h := http.Header{}
if v != "" {
h.Set("Retry-After", v)
}
if got := RetryAfter(h, now); got != want {
t.Errorf("RetryAfter(%q) = %v, want %v", v, got, want)
}
}
}