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) } } }