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
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>
181 lines
6.9 KiB
Go
181 lines
6.9 KiB
Go
package acquire
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/downloader"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/exchange"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/prowlarr"
|
|
)
|
|
|
|
func validUpdate() Update {
|
|
return Update{Label: "Prowlarr", BaseURL: "http://prowlarr:9696", TimeoutMs: 30000, LocalPath: "/downloads", SearchesPerHour: 120}
|
|
}
|
|
|
|
func TestUpdateValidate(t *testing.T) {
|
|
if err := validUpdate().validate(); err != nil {
|
|
t.Fatalf("a valid form: %v", err)
|
|
}
|
|
for name, tc := range map[string]struct {
|
|
edit func(*Update)
|
|
field string
|
|
}{
|
|
"no name": {func(u *Update) { u.Label = "" }, "label"},
|
|
"switched on with no address": {func(u *Update) { u.Enabled, u.BaseURL = true, "" }, "base_url"},
|
|
"an address with no scheme": {func(u *Update) { u.BaseURL = "prowlarr:9696" }, "base_url"},
|
|
"a timeout too short": {func(u *Update) { u.TimeoutMs = 10 }, "timeout_ms"},
|
|
"a relative downloads folder": {func(u *Update) { u.LocalPath = "downloads" }, "local_path"},
|
|
"a relative client folder": {func(u *Update) { u.RemotePath = "data" }, "remote_path"},
|
|
"too many searches": {func(u *Update) { u.SearchesPerHour = 4000 }, "searches_per_hour"},
|
|
} {
|
|
u := validUpdate()
|
|
tc.edit(&u)
|
|
var invalid *InvalidError
|
|
if err := u.validate(); !errors.As(err, &invalid) || invalid.Field != tc.field {
|
|
t.Errorf("%s: err = %v, want an InvalidError on %s", name, err, tc.field)
|
|
}
|
|
}
|
|
// Switched off, an empty address is a row still being set up.
|
|
u := validUpdate()
|
|
u.BaseURL = ""
|
|
if err := u.validate(); err != nil {
|
|
t.Errorf("switched off with no address: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSourceFor_BuildsTheClientTheProtocolNames(t *testing.T) {
|
|
for protocol, check := range map[string]func(Source) bool{
|
|
ProtocolProwlarr: func(s Source) bool { _, ok := s.Search.(*prowlarr.Client); return ok && s.Fetch != nil },
|
|
ProtocolTorznab: func(s Source) bool { _, ok := s.Search.(*indexer.Torznab); return ok && s.Fetch != nil },
|
|
// Exchange's results are all magnets: nothing to fetch through it.
|
|
ProtocolExchange: func(s Source) bool { _, ok := s.Search.(*exchange.Client); return ok && s.Fetch == nil },
|
|
} {
|
|
src := SourceFor(dbq.AcquisitionService{Code: protocol, Protocol: protocol, SearchesPerHour: 7})
|
|
if !check(src) || src.Code != protocol || src.PerHour != 7 {
|
|
t.Errorf("%s: source %+v", protocol, src)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestJobs_AnExchangeDoesWhatItsSwitchesAllow(t *testing.T) {
|
|
ex := dbq.AcquisitionService{Kind: KindDownload, Protocol: ProtocolExchange, ServesSearch: true}
|
|
if !Searches(ex) || Fetches(ex) {
|
|
t.Error("an exchange with only search on should search and not fetch")
|
|
}
|
|
// A qBittorrent's switches mean nothing: it fetches, whatever they say.
|
|
qb := dbq.AcquisitionService{Kind: KindDownload, Protocol: ProtocolQBittorrent, ServesSearch: true}
|
|
if Searches(qb) || !Fetches(qb) {
|
|
t.Error("a qBittorrent fetches and never searches")
|
|
}
|
|
}
|
|
|
|
func TestTest_NoAddressIsNothingToReach(t *testing.T) {
|
|
r := Test(context.Background(), dbq.AcquisitionService{Protocol: ProtocolProwlarr, Kind: KindSearch})
|
|
if r.OK || !strings.Contains(r.Detail, "No address") {
|
|
t.Fatalf("result = %+v", r)
|
|
}
|
|
}
|
|
|
|
func TestTest_SearchReportsResultsAndARefusedKey(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get("X-Api-Key") != "good" {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(`[{"title": "Radiohead - OK Computer"}]`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
row := dbq.AcquisitionService{Kind: KindSearch, Protocol: ProtocolProwlarr, BaseUrl: srv.URL, ApiKey: "good", TimeoutMs: 5000}
|
|
if r := Test(context.Background(), row); !r.OK || !strings.Contains(r.Detail, "1 results") {
|
|
t.Errorf("a working key: %+v", r)
|
|
}
|
|
row.ApiKey = "bad"
|
|
if r := Test(context.Background(), row); r.OK || !strings.Contains(r.Detail, "refused") {
|
|
t.Errorf("a refused key: %+v", r)
|
|
}
|
|
}
|
|
|
|
// fakeExchange answers the three native APIs.
|
|
func fakeExchange(t *testing.T) *httptest.Server {
|
|
t.Helper()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case r.URL.Path == "/api/downloads":
|
|
_, _ = w.Write([]byte(`{"enabled": true, "running": true, "inbound_conns": 3, "downloads": []}`))
|
|
case r.URL.Path == "/api/search":
|
|
_, _ = w.Write([]byte(`{"total": 0, "results": []}`))
|
|
case strings.HasPrefix(r.URL.Path, "/api/torrents/"):
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"error": "unknown torrent"}`))
|
|
default:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
func TestTest_AnExchangeReportsEachJobSwitchedOn(t *testing.T) {
|
|
srv := fakeExchange(t)
|
|
row := dbq.AcquisitionService{
|
|
Kind: KindDownload, Protocol: ProtocolExchange, BaseUrl: srv.URL, TimeoutMs: 5000,
|
|
ServesDownloads: true, ServesSearch: true, ServesEnrichment: true, LocalPath: t.TempDir(),
|
|
}
|
|
r := Test(context.Background(), row)
|
|
if !r.OK {
|
|
t.Fatalf("all three jobs: %+v", r)
|
|
}
|
|
for _, job := range []string{"Downloads:", "Search:", "Release details:"} {
|
|
if !strings.Contains(r.Detail, job) {
|
|
t.Errorf("detail %q does not report %s", r.Detail, job)
|
|
}
|
|
}
|
|
row.ServesDownloads, row.ServesEnrichment = false, false
|
|
if r := Test(context.Background(), row); strings.Contains(r.Detail, "Downloads:") || !strings.Contains(r.Detail, "Search:") {
|
|
t.Errorf("only search on: %+v", r)
|
|
}
|
|
row.ServesSearch = false
|
|
if r := Test(context.Background(), row); r.OK || !strings.Contains(r.Detail, "switched off") {
|
|
t.Errorf("every job off: %+v", r)
|
|
}
|
|
}
|
|
|
|
func TestTest_ADownloadClientNeedsAFolderMinstrelCanSee(t *testing.T) {
|
|
srv := fakeExchange(t)
|
|
row := dbq.AcquisitionService{
|
|
Kind: KindDownload, Protocol: ProtocolExchange, BaseUrl: srv.URL, TimeoutMs: 5000,
|
|
ServesDownloads: true, Category: DefaultCategory,
|
|
LocalPath: filepath.Join(t.TempDir(), "not-mounted"),
|
|
}
|
|
r := Test(context.Background(), row)
|
|
if r.OK || !strings.Contains(r.Detail, "cannot see its downloads folder") {
|
|
t.Fatalf("a missing folder: %+v", r)
|
|
}
|
|
row.LocalPath = t.TempDir()
|
|
if r := Test(context.Background(), row); !r.OK || !strings.Contains(r.Detail, `"minstrel" category`) {
|
|
t.Errorf("a visible, writable folder: %+v", r)
|
|
}
|
|
}
|
|
|
|
func TestDownloadClient_WrapsThePathMapping(t *testing.T) {
|
|
d, err := DownloadClient(dbq.AcquisitionService{Protocol: ProtocolQBittorrent, LocalPath: "/downloads", RemotePath: "/data"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, plain := d.(*downloader.Client); plain {
|
|
t.Error("a row naming two folders should come back wrapped in its mapping")
|
|
}
|
|
if _, err := DownloadClient(dbq.AcquisitionService{Protocol: ProtocolProwlarr}); err == nil {
|
|
t.Error("a search protocol is not a download client")
|
|
}
|
|
}
|