Files
minstrel/internal/wanted/search_test.go
T
bvandeusenandClaude Opus 5.5 fcf5b7e5a8 docs: wanted, dbtest, recommendation and playlists comments say what the code does (#6277)
The same rewrite as d191a282, for four packages whose comments carried the
most history: tuning stories, quotes from the operator, single-install
measurements in library_scale.go, and 'used to' accounts of bugs. The
constraint each story protected stays as a present-tense reason (why tier 3
is reduced not removed, why the randomised arms take an order seed, why the
seed artist is eligible in Songs-like). Comments naming system_cron.go and
config.RecommendationConfig, neither of which holds what they claimed, now
name the scheduler and internal/recsettings.

Checked: non-comment tokens identical to HEAD; gofmt clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:19:15 -04:00

214 lines
7.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 as an admin would in Admin › Acquisition.
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)
}
}
}