Files
jellybit/internal/httpapi/httpapi_test.go
T
avandClaude Opus 4.8 aac3ced262 Реализовал выбор источника и предпросмотр в ревью (review-source-selection)
Экран ревью теперь показывает единый список источников совпадения: строка
«распознано нейронкой» наравне с кандидатами баз; выбор/переключение/снятие
в пользу нейронки; ручное добавление по id или URL (TMDB/IMDb — по URL,
TVDB — по числовому id); предпросмотр полей и целевых путей каждого источника
до применения (место под режиссёра зарезервировано). «Раскладка» осталась
отдельной секцией для активного источника, инлайн-превью неактивных — по клику.

Ядро: единая деривация «источник → overrides» (sourcePins), общая для превью
и коммита → preview == apply; заодно чинит латентный залипший override
title/year при переключении источника. Превью считается эфемерно, без записи
в БД; пользовательский URL только парсится (SSRF нет).

Ревью дизайна и кода пройдены; правки ревьюеров учтены (сообщение об ошибке
ручного ввода доходит до пользователя, URL без схемы принимается).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 10:29:27 +03:00

778 lines
26 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 httpapi_test
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"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/magnet"
"git.vakhrushev.me/av/jellybit/internal/recognize"
"git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker"
)
// Валидные lowercase-ULID для маршрутов (pathID валидирует формат).
const (
tid = "01arz3ndektsv4rrffq69g5fav"
tid2 = "01arz3ndektsv4rrffq69g5fb0"
cid = "01arz3ndektsv4rrffq69g5fc0"
cid2 = "01arz3ndektsv4rrffq69g5fd0"
)
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 []string
retried []string
err error
}
func (f *fakeCommander) Cancel(_ context.Context, id string) error {
if f.err != nil {
return f.err
}
f.cancelled = append(f.cancelled, id)
return nil
}
func (f *fakeCommander) Retry(_ context.Context, id string) 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
pageErr error
total int // общее число под фильтром (для пагинации); 0 → len(list)
lastFilter store.ListFilter // последний переданный фильтр
}
func (f *fakeReader) ListDownloads(_ context.Context) ([]store.Download, error) { return f.list, nil }
func (f *fakeReader) ListDownloadsPage(_ context.Context, flt store.ListFilter) ([]store.Download, int, error) {
f.lastFilter = flt
if f.pageErr != nil {
return nil, 0, f.pageErr
}
total := f.total
if total == 0 {
total = len(f.list)
}
return f.list, total, nil
}
func (f *fakeReader) GetDownload(_ context.Context, id string) (*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: tid, Infohashes: []string{"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"] != tid || got["state"] != "downloading" {
t.Errorf("body = %v", got)
}
if ing.lastReq.Context != "Дюна" {
t.Errorf("контекст не проброшен: %q", ing.lastReq.Context)
}
}
func TestAPIAddBadInput(t *testing.T) {
// Источник не magnet → ingest оборачивает magnet.ErrNotMagnet; транспорт
// классифицирует это как 400 (некорректный источник).
ing := &fakeIngestor{err: fmt.Errorf("ingest: parse source: %w", magnet.ErrNotMagnet)}
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: tid2, SourceType: store.SourceMagnet, State: store.StateCompleted,
Infohashes: []store.Infohash{{DownloadID: tid2, Infohash: "abc", Kind: store.HashV1}}},
{ID: tid, 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))
}
hashes, _ := got[0]["infohashes"].([]any)
if got[0]["state"] != "completed" || len(hashes) != 1 || hashes[0] != "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/"+tid+"/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] != tid {
t.Errorf("cancel вызван неверно: %v", cmd.cancelled)
}
}
func TestUIRetry(t *testing.T) {
cmd := &fakeCommander{}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})
resp, err := http.Post(srv.URL+"/ui/downloads/"+tid+"/retry", "application/x-www-form-urlencoded", nil)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { // 303 → редирект на / → 200
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if len(cmd.retried) != 1 || cmd.retried[0] != tid {
t.Errorf("retry вызван неверно: %v", cmd.retried)
}
}
func TestAPICommandConflict(t *testing.T) {
// Конфликт состояния (worker.ErrConflict) → 409, не 500.
cmd := &fakeCommander{err: fmt.Errorf("cancel: download 5 in wrong state: %w", worker.ErrConflict)}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})
resp, err := http.Post(srv.URL+"/api/downloads/"+tid+"/cancel", "", nil)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusConflict {
t.Fatalf("status = %d, want 409", resp.StatusCode)
}
var got map[string]any
_ = json.NewDecoder(resp.Body).Decode(&got)
if got["download_id"] != tid {
t.Errorf("download_id корреляции нет: %v", got)
}
}
func TestIndexRenders(t *testing.T) {
reader := &fakeReader{list: []store.Download{
{ID: tid, 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) }
func TestIndexTitleFromDisplayName(t *testing.T) {
// Заголовок карточки — имя раздачи (display_name), а не сырой magnet.
reader := &fakeReader{list: []store.Download{{
ID: tid, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc",
DisplayName: "Дюна: Часть вторая (2024)", 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 !strings.Contains(string(body), "Дюна: Часть вторая (2024)") {
t.Error("заголовок карточки не из display_name")
}
if strings.Contains(string(body), "magnet:?xt=urn:btih:abc") {
t.Error("сырой magnet просочился в список вместо имени раздачи")
}
}
func TestIndexListFilterParams(t *testing.T) {
// GET-параметры списка транслируются в store.ListFilter (серверная выборка).
reader := &fakeReader{total: 100}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})
resp, err := http.Get(srv.URL + "/?f=review&q=дюна&page=2&all=1")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
f := reader.lastFilter
if f.Group != store.GroupReview {
t.Errorf("Group = %q, want review", f.Group)
}
if f.Query != "дюна" {
t.Errorf("Query = %q", f.Query)
}
if !f.IncludeDeleted {
t.Errorf("IncludeDeleted = false, want true (all=1)")
}
if f.Limit != 25 || f.Offset != 25 { // page 2, размер 25
t.Errorf("Limit/Offset = %d/%d, want 25/25", f.Limit, f.Offset)
}
}
func TestIndexPageClamp(t *testing.T) {
reader := &fakeReader{}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})
resp, err := http.Get(srv.URL + "/?page=0")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if reader.lastFilter.Offset != 0 {
t.Errorf("page=0 → Offset %d, want 0 (клэмп на 1)", reader.lastFilter.Offset)
}
}
// detailReviewData — ReviewData для страницы /download/{id} с матчем метабазы.
func detailReviewData(provider, providerID, chosenID, chosenURL string) *worker.ReviewData {
rd := &worker.ReviewData{
Download: store.Download{
ID: tid, State: store.StateReview, SourceType: store.SourceMagnet,
SourceRef: "magnet:?xt=urn:btih:deadbeef", DisplayName: "Дюна",
Infohashes: []store.Infohash{{DownloadID: tid, Infohash: "deadbeef", Kind: store.HashV1}},
},
Recognition: &store.Recognition{ID: "1", DownloadID: tid, IsCurrent: true},
Plan: recognize.Plan{
Type: recognize.MediaMovie, Title: "Дюна", Year: 2024,
Files: []recognize.PlanFile{{Src: "dune.mkv", Role: recognize.RoleMain}},
},
Preview: []layout.Link{{Src: "dune.mkv", Dst: "/srv/media/movies/Дюна (2024)/Дюна (2024).mkv"}},
Provider: provider,
ProviderID: providerID,
}
if chosenID != "" {
rd.Candidates = []store.MetadataCandidate{{
ID: cid, Provider: "tmdb", ProviderID: chosenID, Chosen: true,
URL: store.NullString(chosenURL),
}}
}
return rd
}
func TestDownloadMatchLinkFromCandidate(t *testing.T) {
// Выбранный кандидат совпадает с эффективным provider+id → ссылка кандидата.
rv := &fakeReviewer{data: detailReviewData("tmdb", "693134", "693134", "https://custom.example/x")}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
resp, err := http.Get(srv.URL + "/download/" + tid)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "https://custom.example/x") {
t.Error("ссылка на запись метабазы (URL кандидата) не показана")
}
}
func TestDownloadMatchLinkMismatchUsesBuilt(t *testing.T) {
// Эффективный id (999, ручной override) отличается от выбранного кандидата
// (693134) → ссылка строится по эффективному id, а не берётся у кандидата.
rv := &fakeReviewer{data: detailReviewData("tmdb", "999", "693134", "https://custom.example/x")}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
resp, err := http.Get(srv.URL + "/download/" + tid)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "https://www.themoviedb.org/movie/999") {
t.Error("ссылка не построена по эффективному provider_id (movie/999)")
}
if strings.Contains(string(body), "https://custom.example/x") {
t.Error("взят URL кандидата, не совпадающего с эффективным id")
}
}
func TestDownloadTorrentInfoBlock(t *testing.T) {
rv := &fakeReviewer{data: detailReviewData("tmdb", "693134", "693134", "https://x/y")}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
resp, err := http.Get(srv.URL + "/download/" + tid)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
for _, want := range []string{"Информация о торренте", "magnet:?xt=urn:btih:deadbeef", "Дюна"} {
if !strings.Contains(string(body), want) {
t.Errorf("страница загрузки не содержит %q", want)
}
}
}
// --- Ревью ---
type fakeReviewer struct {
data *worker.ReviewData
applyErr error
refined map[string]string
typed map[string]string
ignored map[string]string
chosen map[string]string
providerSet map[string]string
sourceAdded map[string]string
applied []string
deferred []string
undone []string
relinked []string
rerecognized []string
cleared []string
}
func (f *fakeReviewer) ReviewData(_ context.Context, _ string) (*worker.ReviewData, error) {
return f.data, nil
}
func (f *fakeReviewer) Apply(_ context.Context, id string) error {
if f.applyErr != nil {
return f.applyErr
}
f.applied = append(f.applied, id)
return nil
}
func (f *fakeReviewer) Refine(_ context.Context, id string, hint string) error {
if f.refined == nil {
f.refined = map[string]string{}
}
f.refined[id] = hint
return nil
}
func (f *fakeReviewer) SetType(_ context.Context, id string, t string) error {
if f.typed == nil {
f.typed = map[string]string{}
}
f.typed[id] = t
return nil
}
func (f *fakeReviewer) IgnoreFile(_ context.Context, id string, src string) error {
if f.ignored == nil {
f.ignored = map[string]string{}
}
f.ignored[id] = src
return nil
}
func (f *fakeReviewer) Defer(_ context.Context, id string) error {
f.deferred = append(f.deferred, id)
return nil
}
func (f *fakeReviewer) Undo(_ context.Context, id string) error {
f.undone = append(f.undone, id)
return nil
}
func (f *fakeReviewer) Relink(_ context.Context, id string) error {
f.relinked = append(f.relinked, id)
return nil
}
func (f *fakeReviewer) Rerecognize(_ context.Context, id string) error {
f.rerecognized = append(f.rerecognized, id)
return nil
}
func (f *fakeReviewer) ChooseCandidate(_ context.Context, id, candidateID string) error {
if f.chosen == nil {
f.chosen = map[string]string{}
}
f.chosen[id] = candidateID
return nil
}
func (f *fakeReviewer) SetProviderID(_ context.Context, id string, provider, providerID string) error {
if f.providerSet == nil {
f.providerSet = map[string]string{}
}
f.providerSet[id] = provider + ":" + providerID
return nil
}
func (f *fakeReviewer) AddManualSource(_ context.Context, id, provider, providerID string) error {
if f.sourceAdded == nil {
f.sourceAdded = map[string]string{}
}
f.sourceAdded[id] = provider + ":" + providerID
return nil
}
func (f *fakeReviewer) ClearProvider(_ context.Context, id string) error {
f.cleared = append(f.cleared, id)
return nil
}
func seriesReviewData() *worker.ReviewData {
s, e := 2, 1
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"},
}
return &worker.ReviewData{
Download: store.Download{ID: tid, State: store.StateReview, SourceRef: "magnet:?xt=urn:btih:abc"},
Recognition: &store.Recognition{
ID: "1", DownloadID: tid, IsCurrent: true, Reasons: `["нет матча в базе"]`,
},
Plan: plan,
Preview: preview,
Candidates: []store.MetadataCandidate{
{ID: cid, Provider: "tvdb", ProviderID: "269613", Title: store.NullString("Fargo"),
Year: sql.NullInt64{Int64: 2014, Valid: true}},
{ID: cid2, Provider: "tmdb", ProviderID: "60622", Title: store.NullString("Fargo")},
},
// Единый список источников: нейронка активна (матча нет) + кандидаты.
Sources: []worker.SourceOption{
{Kind: worker.SourceNeural, Provider: "none", Title: "Фарго", Year: 2015,
Type: "series", Active: true, Plan: plan, Preview: preview},
{Kind: worker.SourceCandidate, CandidateID: cid, Provider: "tvdb", ProviderID: "269613",
URL: "https://www.thetvdb.com/dereferrer/series/269613", Title: "Fargo", Year: 2014,
Type: "series", Plan: plan, Preview: preview},
{Kind: worker.SourceCandidate, CandidateID: cid2, Provider: "tmdb", ProviderID: "60622",
Title: "Fargo", Type: "series", Plan: plan, Preview: preview},
},
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/" + tid)
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 TestReviewShowsMatchLink(t *testing.T) {
// Подтверждённый матч в списке источников — ссылкой на запись.
rd := seriesReviewData()
rd.Provider = "tvdb"
rd.ProviderID = "269613"
// Активен кандидат tvdb (индекс 1: нейронка, tvdb, tmdb).
rd.Sources[0].Active = false
rd.Sources[1].Active = true
rv := &fakeReviewer{data: rd}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
resp, err := http.Get(srv.URL + "/review/" + tid)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "https://www.thetvdb.com/dereferrer/series/269613") {
t.Error("строка «Выбрано» не содержит ссылку на запись метабазы")
}
}
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/"+tid+"/candidate",
map[string][]string{"candidate_id": {cid}})
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if rv.chosen[tid] != cid {
t.Errorf("ChooseCandidate получил %q", rv.chosen[tid])
}
if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/review/"+tid) {
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/"+tid+"/provider",
map[string][]string{"provider": {"tvdb"}, "provider_id": {"269613"}}); err != nil {
t.Fatal(err)
}
if rv.providerSet[tid] != "tvdb:269613" {
t.Errorf("SetProviderID получил %q", rv.providerSet[tid])
}
if _, err := cl.Post(srv.URL+"/ui/downloads/"+tid+"/nobase", "", nil); err != nil {
t.Fatal(err)
}
if len(rv.cleared) != 1 || rv.cleared[0] != tid {
t.Errorf("ClearProvider = %v", rv.cleared)
}
}
func TestAddManualSource(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
cl := noRedirectClient()
// URL записи → провайдер+id разбираются на границе, воркер получает их.
if _, err := cl.PostForm(srv.URL+"/ui/downloads/"+tid+"/source",
map[string][]string{"provider": {"tmdb"}, "provider_id": {"https://www.themoviedb.org/tv/60622-fargo"}}); err != nil {
t.Fatal(err)
}
if rv.sourceAdded[tid] != "tmdb:60622" {
t.Errorf("AddManualSource получил %q, want tmdb:60622", rv.sourceAdded[tid])
}
}
func TestAddManualSource_RejectsBadURL(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
// URL TVDB со slug (без числового id) не разбирается → отклонён, источник
// не добавлен, пользователю уходит сообщение (не «внутренняя ошибка»).
resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads/"+tid+"/source",
map[string][]string{"provider": {"tvdb"}, "provider_id": {"https://www.thetvdb.com/series/fargo"}})
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if _, called := rv.sourceAdded[tid]; called {
t.Errorf("невалидный ввод не должен вызывать AddManualSource: %v", rv.sourceAdded)
}
loc := resp.Header.Get("Location")
if !strings.HasPrefix(loc, "/review/"+tid) || !strings.Contains(loc, "err=") {
t.Errorf("Location = %q, want редирект на ревью с ?err=", loc)
}
if strings.Contains(loc, "%D0%B2%D0%BD%D1%83%D1%82%D1%80%D0%B5%D0%BD") { // «внутрен…»
t.Errorf("ошибка ввода подана как внутренняя: %q", loc)
}
}
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/"+tid+"/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/"+tid+"/apply", "", nil)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/review/"+tid) {
t.Errorf("Location = %q, want /review/{id}?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/"+tid+"/refine",
map[string][]string{"hint": {"это второй сезон"}})
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if rv.refined[tid] != "это второй сезон" {
t.Errorf("Refine получил %q", rv.refined[tid])
}
if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/review/"+tid) {
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/"+tid+"/ignore",
map[string][]string{"src": {"Fargo/sample.mkv"}}); err != nil {
t.Fatal(err)
}
if rv.ignored[tid] != "Fargo/sample.mkv" {
t.Errorf("IgnoreFile получил %q", rv.ignored[tid])
}
if _, err := cl.PostForm(srv.URL+"/ui/downloads/"+tid+"/type",
map[string][]string{"type": {"movie"}}); err != nil {
t.Fatal(err)
}
if rv.typed[tid] != "movie" {
t.Errorf("SetType получил %q", rv.typed[tid])
}
}
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/"+tid+"/undo", "", nil); err != nil {
t.Fatal(err)
}
if _, err := cl.Post(srv.URL+"/ui/downloads/"+tid+"/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/"+tid+"/relink", "", nil); err != nil {
t.Fatal(err)
}
if len(rv.relinked) != 1 || rv.relinked[0] != tid {
t.Errorf("relinked = %v, want [%s]", rv.relinked, tid)
}
}
func TestRerecognize(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/"+tid+"/rerecognize", "", nil); err != nil {
t.Fatal(err)
}
if len(rv.rerecognized) != 1 || rv.rerecognized[0] != tid {
t.Errorf("rerecognized = %v, want [%s]", rv.rerecognized, tid)
}
}