release / govulncheck (push) Successful in 22s
release / web (push) Successful in 1m16s
release / go (push) Successful in 1m36s
release / integration (push) Successful in 4m53s
release / android (push) Successful in 4m54s
release / Build signed APK (releases and dev) (push) Successful in 5m4s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
214 lines
7.1 KiB
Go
214 lines
7.1 KiB
Go
package wanted
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/acquire"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
const ramHash = "0123456789abcdef0123456789abcdef01234567"
|
|
|
|
const ramFeed = `<?xml version="1.0"?>
|
|
<rss xmlns:torznab="http://torznab.com/schemas/2015/feed"><channel>
|
|
<item><title>Daft Punk - Random Access Memories (2013) [FLAC]</title>
|
|
<link>magnet:?xt=urn:btih:` + ramHash + `</link><size>400000000</size>
|
|
<torznab:attr name="seeders" value="12"/><torznab:attr name="indexer" value="Knaben"/></item>
|
|
<item><title>Daft Punk Random Access Memories (Drumless Edition 2023) [Flac 24-88]</title>
|
|
<link>magnet:?xt=urn:btih:fedcba9876543210fedcba9876543210fedcba98</link><size>900000000</size>
|
|
<torznab:attr name="seeders" value="40"/></item>
|
|
</channel></rss>`
|
|
|
|
// searchStack is a fake indexer feed and a fake FabledExchange download
|
|
// client, configured the way the operator would in Integrations.
|
|
func searchStack(t *testing.T, ctx context.Context, store *acquire.Store) *atomic.Int32 {
|
|
t.Helper()
|
|
var added atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case r.URL.Path == "/feed":
|
|
if q := r.URL.Query().Get("q"); q != "Daft Punk Random Access Memories" {
|
|
t.Errorf("searched for %q", q)
|
|
}
|
|
_, _ = io.WriteString(w, ramFeed)
|
|
case r.URL.Path == "/api/downloads" && r.Method == http.MethodPost:
|
|
var body struct {
|
|
Magnet string `json:"magnet"`
|
|
SavePath string `json:"save_path"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
if !strings.Contains(body.Magnet, ramHash) || body.SavePath != "/downloads/"+ramHash {
|
|
t.Errorf("added %+v", body)
|
|
}
|
|
added.Add(1)
|
|
_, _ = io.WriteString(w, "{}")
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
feed, err := store.Create(ctx, acquire.ProtocolTorznab)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := store.Save(ctx, feed.Code, acquire.Update{
|
|
Label: "Feed", Enabled: true, BaseURL: srv.URL + "/feed", TimeoutMs: 5000, LocalPath: "/downloads",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ex, err := store.Create(ctx, acquire.ProtocolExchange)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := store.Save(ctx, ex.Code, acquire.Update{
|
|
Label: "Exchange", Enabled: true, BaseURL: srv.URL, TimeoutMs: 5000, ServesDownloads: true, LocalPath: "/downloads",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return &added
|
|
}
|
|
|
|
func TestSearcher_FindsAndTakesAWant_Integration(t *testing.T) {
|
|
ctx := context.Background()
|
|
pool := newPool(t)
|
|
q := dbq.New(pool)
|
|
added := searchStack(t, ctx, acquire.NewStore(pool))
|
|
w, err := WantWith(ctx, q, WantParams{
|
|
GroupMBID: "rg-ram", ArtistMBID: "mb-daft-punk", ArtistName: "Daft Punk", Title: "Random Access Memories",
|
|
PrimaryType: "Album", FirstReleaseDate: "2013-05-17", Reason: ReasonRequest,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s := NewSearcher(pool, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
|
|
rep := s.Pass(ctx)
|
|
if rep.Looked != 1 || rep.Grabbed != 1 || !rep.AutoGrab || added.Load() != 1 {
|
|
t.Fatalf("pass = %+v, added %d", rep, added.Load())
|
|
}
|
|
got, err := q.GetWantByReleaseGroup(ctx, "rg-ram")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.SearchOutcome != OutcomeGrabbed || got.SearchConsidered != 2 || !strings.Contains(got.BestRelease, "(2013) [FLAC]") {
|
|
t.Errorf("want after the pass: %s %d %q", got.SearchOutcome, got.SearchConsidered, got.BestRelease)
|
|
}
|
|
cands, err := q.ListWantCandidates(ctx, w.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(cands) != 2 || cands[0].Verdict != "grab" || cands[1].Verdict != "rejected" || !strings.Contains(cands[1].Reason, "drumless") {
|
|
t.Errorf("candidates = %+v", cands)
|
|
}
|
|
g, err := q.GetLiveGrab(ctx, w.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if g.InfoHash != ramHash || g.Client != "exchange" || g.Origin != acquire.OriginSchedule || g.Quality != "lossless" {
|
|
t.Errorf("grab = %+v", g)
|
|
}
|
|
settings, _ := q.GetAcquisitionSettings(ctx)
|
|
var last Report
|
|
if err := json.Unmarshal(settings.LastPass, &last); err != nil || !settings.LastPassAt.Valid || last.Grabbed != 1 {
|
|
t.Errorf("last pass = %s (%v)", settings.LastPass, err)
|
|
}
|
|
|
|
// A want with a download running is not searched again.
|
|
if rep := s.Pass(ctx); rep.Looked != 0 {
|
|
t.Errorf("searched a want already downloading: %+v", rep)
|
|
}
|
|
|
|
// Setting the drumless edition aside marks it on every kept candidate;
|
|
// allowing it again leaves it for a person until the next search.
|
|
mem := acquire.NewMemory(pool)
|
|
if err := mem.Block(ctx, dbq.BlockReleaseParams{
|
|
InfoHash: cands[1].InfoHash, Name: cands[1].Name, Reason: acquire.BlockOperator, WantID: w.ID,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c, _ := q.GetWantCandidate(ctx, dbq.GetWantCandidateParams{WantID: w.ID, Key: cands[1].Key})
|
|
if c.Verdict != "blocked" {
|
|
t.Errorf("blocked candidate = %s", c.Verdict)
|
|
}
|
|
if ok, err := mem.Unblock(ctx, cands[1].InfoHash); err != nil || !ok {
|
|
t.Fatalf("unblock: %v %v", ok, err)
|
|
}
|
|
c, _ = q.GetWantCandidate(ctx, dbq.GetWantCandidateParams{WantID: w.ID, Key: cands[1].Key})
|
|
if c.Verdict != "review" {
|
|
t.Errorf("unblocked candidate = %s", c.Verdict)
|
|
}
|
|
}
|
|
|
|
func TestSearcher_OffAndUnconfigured_Integration(t *testing.T) {
|
|
ctx := context.Background()
|
|
pool := newPool(t)
|
|
s := NewSearcher(pool, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
if rep := s.Pass(ctx); !rep.Unconfigured {
|
|
t.Errorf("no indexer: %+v", rep)
|
|
}
|
|
if _, err := dbq.New(pool).UpdateAcquisitionSettings(ctx, dbq.UpdateAcquisitionSettingsParams{
|
|
AutoGrab: true, SearchIntervalHours: 0, AllowedQualities: []string{"lossless"},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rep := s.Pass(ctx); !rep.Off {
|
|
t.Errorf("interval 0: %+v", rep)
|
|
}
|
|
}
|
|
|
|
func TestGrabs_OneLivePerWant_Integration(t *testing.T) {
|
|
ctx := context.Background()
|
|
pool := newPool(t)
|
|
q := dbq.New(pool)
|
|
w, err := WantWith(ctx, q, WantParams{GroupMBID: "rg-x", ArtistMBID: "mb-x", ArtistName: "X", Title: "Y", Reason: ReasonFollow})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mem := acquire.NewMemory(pool)
|
|
grab := func(hash string) dbq.InsertGrabParams {
|
|
return dbq.InsertGrabParams{WantID: w.ID, InfoHash: hash, Name: hash, Key: hash, Client: "qbittorrent",
|
|
SavePath: "/downloads/" + hash, Origin: acquire.OriginSchedule}
|
|
}
|
|
first, err := mem.Grabbed(ctx, grab("aaaa"), dbq.Grab{}.ID, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := mem.Grabbed(ctx, grab("bbbb"), dbq.Grab{}.ID, ""); err == nil {
|
|
t.Error("a second live grab for one want was recorded")
|
|
}
|
|
second, err := mem.Grabbed(ctx, grab("bbbb"), first.ID, "Replaced by bbbb.")
|
|
if err != nil {
|
|
t.Fatalf("replacing: %v", err)
|
|
}
|
|
live, ok, err := mem.LiveGrab(ctx, w.ID)
|
|
if err != nil || !ok || live.ID != second.ID {
|
|
t.Errorf("live = %+v %v %v", live, ok, err)
|
|
}
|
|
}
|
|
|
|
func TestReport_StateNamesWhyAPassWasQuiet(t *testing.T) {
|
|
for _, c := range []struct {
|
|
rep Report
|
|
want string
|
|
}{
|
|
{Report{Off: true}, "off"},
|
|
{Report{Unconfigured: true}, "unconfigured"},
|
|
{Report{Error: "boom"}, "failed"},
|
|
{Report{}, "idle"},
|
|
{Report{Looked: 2, Grabbed: 1}, "searched"},
|
|
} {
|
|
if got := c.rep.state(); got != c.want {
|
|
t.Errorf("%+v: state = %q, want %q", c.rep, got, c.want)
|
|
}
|
|
}
|
|
}
|