466 lines
14 KiB
Go
466 lines
14 KiB
Go
package httpapi_test
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.vakhrushev.me/av/jellybit/internal/httpapi"
|
|
"git.vakhrushev.me/av/jellybit/internal/ingest"
|
|
"git.vakhrushev.me/av/jellybit/internal/layout"
|
|
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
|
"git.vakhrushev.me/av/jellybit/internal/worker"
|
|
)
|
|
|
|
type fakeIngestor struct {
|
|
res ingest.Result
|
|
err error
|
|
lastReq ingest.Request
|
|
}
|
|
|
|
func (f *fakeIngestor) Ingest(_ context.Context, req ingest.Request) (ingest.Result, error) {
|
|
f.lastReq = req
|
|
return f.res, f.err
|
|
}
|
|
|
|
type fakeCommander struct {
|
|
cancelled []int64
|
|
retried []int64
|
|
err error
|
|
}
|
|
|
|
func (f *fakeCommander) Cancel(_ context.Context, id int64) error {
|
|
if f.err != nil {
|
|
return f.err
|
|
}
|
|
f.cancelled = append(f.cancelled, id)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeCommander) Retry(_ context.Context, id int64) error {
|
|
if f.err != nil {
|
|
return f.err
|
|
}
|
|
f.retried = append(f.retried, id)
|
|
return nil
|
|
}
|
|
|
|
type fakeReader struct {
|
|
list []store.Download
|
|
get *store.Download
|
|
}
|
|
|
|
func (f *fakeReader) ListDownloads(_ context.Context) ([]store.Download, error) { return f.list, nil }
|
|
|
|
func (f *fakeReader) GetDownload(_ context.Context, id int64) (*store.Download, error) {
|
|
if f.get != nil {
|
|
return f.get, nil
|
|
}
|
|
return &store.Download{ID: id, State: store.StateCancelled}, nil
|
|
}
|
|
|
|
func newServer(t *testing.T, d httpapi.Deps) *httptest.Server {
|
|
t.Helper()
|
|
if d.Logger == nil {
|
|
d.Logger = slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
}
|
|
h, err := httpapi.NewRouter(d)
|
|
if err != nil {
|
|
t.Fatalf("NewRouter: %v", err)
|
|
}
|
|
srv := httptest.NewServer(h)
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
func TestAPIAdd(t *testing.T) {
|
|
ing := &fakeIngestor{res: ingest.Result{DownloadID: 1, Infohash: "abc", State: store.StateDownloading}}
|
|
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
|
|
|
|
resp, err := http.Post(srv.URL+"/api/downloads", "application/json",
|
|
strings.NewReader(`{"source":"magnet:?xt=urn:btih:abc","context":"Дюна"}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("status = %d, want 201", resp.StatusCode)
|
|
}
|
|
var got map[string]any
|
|
_ = json.NewDecoder(resp.Body).Decode(&got)
|
|
if got["id"].(float64) != 1 || got["state"] != "downloading" {
|
|
t.Errorf("body = %v", got)
|
|
}
|
|
if ing.lastReq.Context != "Дюна" {
|
|
t.Errorf("контекст не проброшен: %q", ing.lastReq.Context)
|
|
}
|
|
}
|
|
|
|
func TestAPIAddBadInput(t *testing.T) {
|
|
ing := &fakeIngestor{err: ingestErr("bad magnet")}
|
|
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
|
|
|
|
resp, err := http.Post(srv.URL+"/api/downloads", "application/json", strings.NewReader(`{"source":"x"}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestAPIList(t *testing.T) {
|
|
reader := &fakeReader{list: []store.Download{
|
|
{ID: 2, SourceType: store.SourceMagnet, State: store.StateCompleted, Infohash: store.NullString("abc")},
|
|
{ID: 1, SourceType: store.SourceMagnet, State: store.StateDownloading},
|
|
}}
|
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})
|
|
|
|
resp, err := http.Get(srv.URL + "/api/downloads")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
var got []map[string]any
|
|
_ = json.NewDecoder(resp.Body).Decode(&got)
|
|
if len(got) != 2 {
|
|
t.Fatalf("len = %d, want 2", len(got))
|
|
}
|
|
if got[0]["state"] != "completed" || got[0]["infohash"] != "abc" {
|
|
t.Errorf("first = %v", got[0])
|
|
}
|
|
}
|
|
|
|
func TestAPICancel(t *testing.T) {
|
|
cmd := &fakeCommander{}
|
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})
|
|
|
|
resp, err := http.Post(srv.URL+"/api/downloads/5/cancel", "", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
|
}
|
|
if len(cmd.cancelled) != 1 || cmd.cancelled[0] != 5 {
|
|
t.Errorf("cancel вызван неверно: %v", cmd.cancelled)
|
|
}
|
|
}
|
|
|
|
func TestIndexRenders(t *testing.T) {
|
|
reader := &fakeReader{list: []store.Download{
|
|
{ID: 1, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc", State: store.StateDownloading},
|
|
}}
|
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})
|
|
|
|
resp, err := http.Get(srv.URL + "/")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d", resp.StatusCode)
|
|
}
|
|
if !strings.Contains(string(body), "jellybit") || !strings.Contains(string(body), "downloading") {
|
|
t.Error("страница не содержит ожидаемого контента")
|
|
}
|
|
}
|
|
|
|
type ingestErr string
|
|
|
|
func (e ingestErr) Error() string { return string(e) }
|
|
|
|
// --- Ревью ---
|
|
|
|
type fakeReviewer struct {
|
|
data *worker.ReviewData
|
|
applyErr error
|
|
refined map[int64]string
|
|
typed map[int64]string
|
|
ignored map[int64]string
|
|
chosen map[int64]int64
|
|
providerSet map[int64]string
|
|
applied []int64
|
|
deferred []int64
|
|
undone []int64
|
|
relinked []int64
|
|
cleared []int64
|
|
}
|
|
|
|
func (f *fakeReviewer) ReviewData(_ context.Context, _ int64) (*worker.ReviewData, error) {
|
|
return f.data, nil
|
|
}
|
|
func (f *fakeReviewer) Apply(_ context.Context, id int64) error {
|
|
if f.applyErr != nil {
|
|
return f.applyErr
|
|
}
|
|
f.applied = append(f.applied, id)
|
|
return nil
|
|
}
|
|
func (f *fakeReviewer) Refine(_ context.Context, id int64, hint string) error {
|
|
if f.refined == nil {
|
|
f.refined = map[int64]string{}
|
|
}
|
|
f.refined[id] = hint
|
|
return nil
|
|
}
|
|
func (f *fakeReviewer) SetType(_ context.Context, id int64, t string) error {
|
|
if f.typed == nil {
|
|
f.typed = map[int64]string{}
|
|
}
|
|
f.typed[id] = t
|
|
return nil
|
|
}
|
|
func (f *fakeReviewer) IgnoreFile(_ context.Context, id int64, src string) error {
|
|
if f.ignored == nil {
|
|
f.ignored = map[int64]string{}
|
|
}
|
|
f.ignored[id] = src
|
|
return nil
|
|
}
|
|
func (f *fakeReviewer) Defer(_ context.Context, id int64) error {
|
|
f.deferred = append(f.deferred, id)
|
|
return nil
|
|
}
|
|
func (f *fakeReviewer) Undo(_ context.Context, id int64) error {
|
|
f.undone = append(f.undone, id)
|
|
return nil
|
|
}
|
|
func (f *fakeReviewer) Relink(_ context.Context, id int64) error {
|
|
f.relinked = append(f.relinked, id)
|
|
return nil
|
|
}
|
|
func (f *fakeReviewer) ChooseCandidate(_ context.Context, id, candidateID int64) error {
|
|
if f.chosen == nil {
|
|
f.chosen = map[int64]int64{}
|
|
}
|
|
f.chosen[id] = candidateID
|
|
return nil
|
|
}
|
|
func (f *fakeReviewer) SetProviderID(_ context.Context, id int64, provider, providerID string) error {
|
|
if f.providerSet == nil {
|
|
f.providerSet = map[int64]string{}
|
|
}
|
|
f.providerSet[id] = provider + ":" + providerID
|
|
return nil
|
|
}
|
|
func (f *fakeReviewer) ClearProvider(_ context.Context, id int64) error {
|
|
f.cleared = append(f.cleared, id)
|
|
return nil
|
|
}
|
|
|
|
func seriesReviewData() *worker.ReviewData {
|
|
s, e := 2, 1
|
|
return &worker.ReviewData{
|
|
Download: store.Download{ID: 1, State: store.StateReview, SourceRef: "magnet:?xt=urn:btih:abc"},
|
|
Recognition: &store.Recognition{
|
|
ID: 1, DownloadID: 1, IsCurrent: true, Reasons: `["нет матча в базе"]`,
|
|
},
|
|
Plan: recognize.Plan{
|
|
Type: recognize.MediaSeries, Title: "Фарго", Year: 2015,
|
|
Files: []recognize.PlanFile{
|
|
{Src: "Fargo/e1.mkv", Role: recognize.RoleEpisode, Season: &s, Episode: &e},
|
|
},
|
|
},
|
|
Preview: []layout.Link{
|
|
{Src: "Fargo/e1.mkv", Dst: "/srv/media/series/Фарго (2015)/Season 02/Фарго (2015) S02E01.mkv"},
|
|
},
|
|
Candidates: []store.MetadataCandidate{
|
|
{ID: 10, Provider: "tvdb", ProviderID: "269613", Title: store.NullString("Fargo"),
|
|
Year: sql.NullInt64{Int64: 2014, Valid: true}},
|
|
{ID: 11, Provider: "tmdb", ProviderID: "60622", Title: store.NullString("Fargo")},
|
|
},
|
|
Hints: []string{"второй сезон"},
|
|
}
|
|
}
|
|
|
|
// noRedirectClient — не следует за 3xx, чтобы проверять Location.
|
|
func noRedirectClient() *http.Client {
|
|
return &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
}}
|
|
}
|
|
|
|
func TestReviewRenders(t *testing.T) {
|
|
rv := &fakeReviewer{data: seriesReviewData()}
|
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
|
Reader: &fakeReader{}, Reviewer: rv})
|
|
|
|
resp, err := http.Get(srv.URL + "/review/1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d", resp.StatusCode)
|
|
}
|
|
for _, want := range []string{"Фарго", "нет матча в базе", "Fargo/e1.mkv",
|
|
"Season 02", "Применить", "Уточнить",
|
|
"База метаданных", "269613", "выбрать", "Без базы"} {
|
|
if !strings.Contains(string(body), want) {
|
|
t.Errorf("страница ревью не содержит %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestChooseCandidate(t *testing.T) {
|
|
rv := &fakeReviewer{data: seriesReviewData()}
|
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
|
Reader: &fakeReader{}, Reviewer: rv})
|
|
|
|
resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads/1/candidate",
|
|
map[string][]string{"candidate_id": {"10"}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if rv.chosen[1] != 10 {
|
|
t.Errorf("ChooseCandidate получил %d", rv.chosen[1])
|
|
}
|
|
if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/review/1") {
|
|
t.Errorf("Location = %q", loc)
|
|
}
|
|
}
|
|
|
|
func TestSetProviderAndNoBase(t *testing.T) {
|
|
rv := &fakeReviewer{data: seriesReviewData()}
|
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
|
Reader: &fakeReader{}, Reviewer: rv})
|
|
cl := noRedirectClient()
|
|
|
|
if _, err := cl.PostForm(srv.URL+"/ui/downloads/1/provider",
|
|
map[string][]string{"provider": {"tvdb"}, "provider_id": {"269613"}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rv.providerSet[1] != "tvdb:269613" {
|
|
t.Errorf("SetProviderID получил %q", rv.providerSet[1])
|
|
}
|
|
|
|
if _, err := cl.Post(srv.URL+"/ui/downloads/1/nobase", "", nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rv.cleared) != 1 || rv.cleared[0] != 1 {
|
|
t.Errorf("ClearProvider = %v", rv.cleared)
|
|
}
|
|
}
|
|
|
|
func TestApplyRedirectsToIndex(t *testing.T) {
|
|
rv := &fakeReviewer{data: seriesReviewData()}
|
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
|
Reader: &fakeReader{}, Reviewer: rv})
|
|
|
|
resp, err := noRedirectClient().Post(srv.URL+"/ui/downloads/1/apply", "", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusSeeOther {
|
|
t.Fatalf("status = %d, want 303", resp.StatusCode)
|
|
}
|
|
if loc := resp.Header.Get("Location"); loc != "/" {
|
|
t.Errorf("Location = %q, want /", loc)
|
|
}
|
|
if len(rv.applied) != 1 {
|
|
t.Errorf("Apply не вызван: %v", rv.applied)
|
|
}
|
|
}
|
|
|
|
func TestApplyCollisionRedirectsToReview(t *testing.T) {
|
|
rv := &fakeReviewer{data: seriesReviewData(), applyErr: ingestErr("collision")}
|
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
|
Reader: &fakeReader{}, Reviewer: rv})
|
|
|
|
resp, err := noRedirectClient().Post(srv.URL+"/ui/downloads/1/apply", "", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/review/1") {
|
|
t.Errorf("Location = %q, want /review/1?err=...", loc)
|
|
}
|
|
}
|
|
|
|
func TestRefinePostsHint(t *testing.T) {
|
|
rv := &fakeReviewer{data: seriesReviewData()}
|
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
|
Reader: &fakeReader{}, Reviewer: rv})
|
|
|
|
resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads/1/refine",
|
|
map[string][]string{"hint": {"это второй сезон"}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if rv.refined[1] != "это второй сезон" {
|
|
t.Errorf("Refine получил %q", rv.refined[1])
|
|
}
|
|
if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/review/1") {
|
|
t.Errorf("Location = %q", loc)
|
|
}
|
|
}
|
|
|
|
func TestIgnoreAndType(t *testing.T) {
|
|
rv := &fakeReviewer{data: seriesReviewData()}
|
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
|
Reader: &fakeReader{}, Reviewer: rv})
|
|
cl := noRedirectClient()
|
|
|
|
if _, err := cl.PostForm(srv.URL+"/ui/downloads/1/ignore",
|
|
map[string][]string{"src": {"Fargo/sample.mkv"}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rv.ignored[1] != "Fargo/sample.mkv" {
|
|
t.Errorf("IgnoreFile получил %q", rv.ignored[1])
|
|
}
|
|
|
|
if _, err := cl.PostForm(srv.URL+"/ui/downloads/1/type",
|
|
map[string][]string{"type": {"movie"}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rv.typed[1] != "movie" {
|
|
t.Errorf("SetType получил %q", rv.typed[1])
|
|
}
|
|
}
|
|
|
|
func TestUndoAndDefer(t *testing.T) {
|
|
rv := &fakeReviewer{data: seriesReviewData()}
|
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
|
Reader: &fakeReader{}, Reviewer: rv})
|
|
cl := noRedirectClient()
|
|
|
|
if _, err := cl.Post(srv.URL+"/ui/downloads/1/undo", "", nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := cl.Post(srv.URL+"/ui/downloads/1/defer", "", nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rv.undone) != 1 || len(rv.deferred) != 1 {
|
|
t.Errorf("undo=%v defer=%v", rv.undone, rv.deferred)
|
|
}
|
|
}
|
|
|
|
func TestRelink(t *testing.T) {
|
|
rv := &fakeReviewer{data: seriesReviewData()}
|
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
|
Reader: &fakeReader{}, Reviewer: rv})
|
|
cl := noRedirectClient()
|
|
|
|
if _, err := cl.Post(srv.URL+"/ui/downloads/1/relink", "", nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rv.relinked) != 1 || rv.relinked[0] != 1 {
|
|
t.Errorf("relinked = %v, want [1]", rv.relinked)
|
|
}
|
|
}
|