Экран ревью теперь показывает единый список источников совпадения: строка «распознано нейронкой» наравне с кандидатами баз; выбор/переключение/снятие в пользу нейронки; ручное добавление по 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>
778 lines
26 KiB
Go
778 lines
26 KiB
Go
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)
|
||
}
|
||
}
|