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 = ` Daft Punk - Random Access Memories (2013) [FLAC] magnet:?xt=urn:btih:` + ramHash + `400000000 Daft Punk Random Access Memories (Drumless Edition 2023) [Flac 24-88] magnet:?xt=urn:btih:fedcba9876543210fedcba9876543210fedcba98900000000 ` // 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) } } }