Files
minstrel/internal/exchange/exchange_test.go
T
bvandeusenandClaude Opus 5.5 a2e1890d4c
release / govulncheck (push) Successful in 34s
release / web (push) Successful in 1m13s
release / go (push) Successful in 2m12s
release / integration (push) Successful in 4m6s
release / Build signed APK (releases and dev) (push) Successful in 8m41s
release / android (push) Successful in 9m47s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 59s
release / Verify release artifacts (tag releases only) (push) Skipped
feat: indexers and download clients you add and configure (M502 #5518)
Admin → Integrations gains "Indexers and download clients": two lists of
connections the operator adds, Fabled Librarian's model. Nothing is seeded
or copied from Lidarr.

- Indexers: Prowlarr (its native /api/v1/search, which asks every indexer
  at once; Prowlarr has no aggregate Torznab feed) and any Torznab feed.
- Download clients: qBittorrent (WebUI v2, with re-login on an expired
  session) and FabledExchange, one entry with a switch per job:
  downloads (/api/downloads), search (/api/search) and release details
  (/api/torrents/{infohash}). All three start on; its key is optional.
- Each entry: enable, name, address, a write-only key or password, Test.
  A download client's Test lists its torrents and checks the downloads
  folder is visible and writable from Minstrel; an Exchange's Test
  reports each job switched on.
- Download clients carry a path mapping (Minstrel's /downloads vs the
  client's own name for it); search sources carry searches per hour.

Migration 0079 adds acquisition_services with its CHECKs. Ported from
Fabled Librarian: internal/indexer (Torznab, 429 Retry-After, magnet
redirects), internal/prowlarr, internal/exchange, internal/downloader,
and internal/acquire's one builder per kind, which Test already uses
so a passing Test vouches for the path a grab will take.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:34:51 -04:00

109 lines
3.8 KiB
Go

package exchange
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
)
func TestTorrent_SendsTheKeyOnlyWhenSet(t *testing.T) {
auth := make(chan string, 2)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth <- r.Header.Get("Authorization")
_, _ = w.Write([]byte(`{"infohash": "` + strings.Repeat("a", 40) + `", "files_count": 12}`))
}))
defer srv.Close()
for _, key := range []string{"", "k"} {
c := &Client{Config: Config{BaseURL: srv.URL, APIKey: key}}
d, err := c.Torrent(context.Background(), strings.Repeat("A", 40))
if err != nil || d.FilesCount != 12 {
t.Fatalf("key %q: detail %+v err %v", key, d, err)
}
}
if without, with := <-auth, <-auth; without != "" || with != "Bearer k" {
t.Errorf("Authorization = %q then %q, want none without a key and a bearer with one", without, with)
}
}
func TestTorrent_TellsAnUncrawledTorrentFromAnotherService(t *testing.T) {
for name, tc := range map[string]struct {
status int
body string
want error
}{
"Exchange has not crawled it": {http.StatusNotFound, `{"error": "unknown torrent"}`, ErrUnknown},
"not FabledExchange": {http.StatusNotFound, `<html>404</html>`, ErrUnreachable},
"a refused key": {http.StatusUnauthorized, `{"error": "bad key"}`, ErrAuth},
} {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(tc.status)
_, _ = w.Write([]byte(tc.body))
}))
_, err := (&Client{Config: Config{BaseURL: srv.URL}}).Torrent(context.Background(), strings.Repeat("0", 40))
srv.Close()
if !errors.Is(err, tc.want) {
t.Errorf("%s: err = %v, want %v", name, err, tc.want)
}
}
}
func TestSearch_PagesAndKeepsMeasuredSwarmsOnly(t *testing.T) {
queries := make(chan string, 4)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
queries <- r.URL.RawQuery
offset := r.URL.Query().Get("offset")
var rows []string
if offset == "0" {
for i := 0; i < SearchLimit; i++ {
rows = append(rows, fmt.Sprintf(`{"infohash": "%040x", "name": "r%d", "content_type": "music", "seeders": 5, "swarm_known": %t}`, i, i, i == 0))
}
} else {
// A repeat of page one's first hash, which must not be listed twice.
rows = append(rows, fmt.Sprintf(`{"infohash": "%040x", "name": "again"}`, 0))
}
_, _ = fmt.Fprintf(w, `{"total": %d, "results": [%s]}`, SearchLimit+1, strings.Join(rows, ","))
}))
defer srv.Close()
c := &Client{Code: "exchange", Config: Config{BaseURL: srv.URL}}
results, err := c.Search(context.Background(), indexer.Query{Text: "x", Categories: indexer.MusicCategories})
if err != nil {
t.Fatalf("search: %v", err)
}
close(queries)
var asked []string
for q := range queries {
asked = append(asked, q)
}
if len(asked) != 2 || !strings.Contains(asked[0], "type=music") {
t.Errorf("queries = %v, want two pages asking for music", asked)
}
if len(results) != SearchLimit {
t.Errorf("results = %d, want %d (the repeat dropped)", len(results), SearchLimit)
}
if r := results[0]; !r.SwarmKnown || r.Seeders != 5 || r.Category != 3000 || r.Source != "exchange" || !strings.HasPrefix(r.Magnet, "magnet:") {
t.Errorf("first = %+v", r)
}
if r := results[1]; r.SwarmKnown || r.Seeders != 0 {
t.Errorf("an unmeasured swarm reported seeders: %+v", r)
}
}
func TestSearch_ARefusedKeyReadsAsAuth(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer srv.Close()
_, err := (&Client{Config: Config{BaseURL: srv.URL}}).Search(context.Background(), indexer.Query{Text: "x"})
if !errors.Is(err, indexer.ErrAuth) {
t.Fatalf("err = %v, want indexer.ErrAuth", err)
}
}