package httpapi_test import ( "context" "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 applied []int64 deferred []int64 undone []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 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"}, }, 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", "Применить", "Уточнить"} { if !strings.Contains(string(body), want) { t.Errorf("страница ревью не содержит %q", want) } } } 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) } }