Files
jellybit/internal/httpapi/httpapi_test.go
T
avandClaude Opus 4.8 1d6f8f3449 review: убрать явный переключатель типа movie↔series из всех транспортов
Кнопка «Тип» была только в Telegram (доменная SetType = жёсткий override
media_type + хинт + перераспознавание), в вебе её нет. Это создавало
расхождение поверхностей и внутреннюю противоречивость спеки review.
Решение: смена типа — редкий случай, для него достаточно «Уточнить»
(перераспознавание с явным указанием типа). Явный переключатель не нужен
ни на одной поверхности.

Снято: Telegram-кнопка и callback type:, worker.SetType, ставший мёртвым
override-плумбинг media_type (ovrMediaType, ветка applyOverrides, хелпер
oppositeType) и стейл-хвосты в тестах httpapi. Спека review — три MODIFIED
требования (запрет на все поверхности, «фиксация типа» убрана из команд и
из быстрых действий Telegram, иллюстрация override заменена на закрепление
источника). Синхронизирован docs/specs/review-ux.md.

Change заархивирован: openspec/changes/archive/2026-07-18-review-remove-type-switch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 16:50:43 +03:00

1101 lines
40 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"
"net/url"
"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 (f *fakeReader) LayoutSizeByDownload(_ context.Context, _ []string) (map[string]int64, error) {
return nil, 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)
}
}
// F7: oversized `.torrent` — ingest.ErrTorrentTooLarge классифицируется как 400
// (промах ввода), а не 500.
func TestAPIAddTorrentTooLarge(t *testing.T) {
ing := &fakeIngestor{err: fmt.Errorf("ingest: torrent too large: %w", ingest.ErrTorrentTooLarge)}
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 TestAPICommandNotReady(t *testing.T) {
// Недокачанный источник (worker.ErrNotReady) → 409 с конкретным сообщением
// «торрент ещё качается», а не генерик «действие недоступно».
cmd := &fakeCommander{err: fmt.Errorf("apply: торрент ещё качается: %w", worker.ErrNotReady)}
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 msg, _ := got["error"].(string); !strings.Contains(msg, "качается") {
t.Errorf("error = %q, want содержащее «качается»", msg)
}
}
func TestAPICommandInvalidInput(t *testing.T) {
// Промах пользователя (worker.ErrInvalidInput) → 400, не 500.
cmd := &fakeCommander{err: fmt.Errorf("choose candidate: invalid id %q: %w", "foo", worker.ErrInvalidInput)}
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.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
func TestAPICommandCollision(t *testing.T) {
// Коллизия цели (layout.ErrCollision) → 409 (штатно ушло в review), не 500.
cmd := &fakeCommander{err: fmt.Errorf("apply: %w", layout.ErrCollision)}
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 msg, _ := got["error"].(string); !strings.Contains(msg, "уже существует") {
t.Errorf("error = %q, want содержащее «уже существует»", msg)
}
}
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("страница не содержит ожидаемого контента")
}
}
// Карточка catched несёт самозавершающийся htmx-поллинг (обновится без
// перезагрузки при переходе в downloading) и человекочитаемый бейдж.
func TestCatchedCardSelfPolls(t *testing.T) {
reader := &fakeReader{get: &store.Download{
ID: tid, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc",
State: store.StateCatched,
}}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})
resp, err := http.Get(srv.URL + "/fragments/downloads/" + tid + "/card")
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)
}
s := string(body)
if !strings.Contains(s, `hx-get="/fragments/downloads/`+tid+`/card"`) {
t.Errorf("карточка catched без самополлинга: %s", s)
}
if !strings.Contains(s, "принято, добавляется") {
t.Errorf("нет подписи бейджа catched: %s", s)
}
}
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
ignored map[string]string
chosen map[string]string
providerSet map[string]string
sourceAdded map[string]string
applied []string
deferred []string
undone []string
deleted []string
dismissed []string
relinked []string
rerecognized []string
cleared []string
nameRefreshed []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) 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) Delete(_ context.Context, id string) error {
f.deleted = append(f.deleted, id)
return nil
}
func (f *fakeReviewer) Dismiss(_ context.Context, id string) error {
f.dismissed = append(f.dismissed, 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 (f *fakeReviewer) RefreshDisplayName(_ context.Context, id string) error {
f.nameRefreshed = append(f.nameRefreshed, 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
}}
}
// Веб-приём при дедупе (в т.ч. на «спящую» desync-запись) ведёт на страницу
// существующей записи, а не на список — пользователь видит, что нового не завели.
func TestUIAddDeduplicatedRedirectsToRecord(t *testing.T) {
ing := &fakeIngestor{res: ingest.Result{DownloadID: tid, State: store.StateTargetMissing, Deduplicated: true}}
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/ui/downloads",
strings.NewReader("source=magnet:?xt=urn:btih:abc"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := noRedirectClient().Do(req)
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 != "/download/"+tid {
t.Errorf("Location = %q, want /download/%s", loc, tid)
}
}
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", "распознано нейронкой", "Добавить",
"Сезон 2"} {
if !strings.Contains(string(body), want) {
t.Errorf("страница ревью не содержит %q", want)
}
}
// Кнопки «выбрать» больше нет — выбор одним кликом по радио.
if strings.Contains(string(body), ">выбрать<") {
t.Error("страница ревью всё ещё содержит кнопку «выбрать»")
}
}
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 TestRefreshNameNonHTMXRedirectsToDownload(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+"/refresh-name", nil)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if len(rv.nameRefreshed) != 1 || rv.nameRefreshed[0] != tid {
t.Errorf("RefreshDisplayName вызван = %v", rv.nameRefreshed)
}
// Без htmx — PRG на страницу загрузки (увидеть новое имя здесь же), не на список.
if loc := resp.Header.Get("Location"); loc != "/download/"+tid {
t.Errorf("Location = %q, want /download/%s", loc, tid)
}
}
func TestRefreshNameHTMXSwapsMain(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/ui/downloads/"+tid+"/refresh-name", nil)
req.Header.Set("HX-Request", "true")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), `id="download-main"`) {
t.Error("htmx-ответ не содержит фрагмент download-main")
}
if len(rv.nameRefreshed) != 1 {
t.Errorf("RefreshDisplayName вызван = %v", rv.nameRefreshed)
}
}
// Стоп-кран «Закрыть» рендерится в danger-зоне для терминального состояния
// (target_missing) и постит на /dismiss.
func TestDownloadPageShowsDismissButtonOnTerminal(t *testing.T) {
rd := seriesReviewData()
rd.Download.State = store.StateTargetMissing
rv := &fakeReviewer{data: rd}
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), "/dismiss") || !strings.Contains(string(body), "Закрыть загрузку") {
t.Error("кнопка «Закрыть» не показана в danger-зоне для target_missing")
}
}
// POST /ui/downloads/{id}/dismiss вызывает Reviewer.Dismiss.
func TestUIDismiss(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
resp, err := http.Post(srv.URL+"/ui/downloads/"+tid+"/dismiss", "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(rv.dismissed) != 1 || rv.dismissed[0] != tid {
t.Errorf("Dismiss вызван неверно: %v", rv.dismissed)
}
}
func TestDownloadPageShowsRefreshNameButtonOnDone(t *testing.T) {
// Кнопка гейтится наличием распознавания, не состоянием ревью: на терминальном
// done (есть план) она всё равно доступна.
rd := seriesReviewData()
rd.Download.State = store.StateDone
rv := &fakeReviewer{data: rd}
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), "/refresh-name") {
t.Error("кнопка «Обновить имя» не показана на done при наличии распознавания")
}
}
func TestDownloadPageHidesRefreshNameWithoutRecognition(t *testing.T) {
rv := &fakeReviewer{data: &worker.ReviewData{
Download: store.Download{ID: tid, State: store.StateDownloading, SourceRef: "magnet:?xt=urn:btih:abc"},
}}
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), "/refresh-name") {
t.Error("кнопка «Обновить имя» показана без распознавания")
}
}
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)
}
}
// TestAddManualSource_HTMXError: невалидный ручной ввод на htmx-пути возвращает
// партиал блока с ошибкой (не редирект, не «внутренняя ошибка»), источник не
// добавлен.
func TestAddManualSource_HTMXError(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
form := url.Values{"provider": {"tvdb"}, "provider_id": {"https://www.thetvdb.com/series/fargo"}}
req, err := http.NewRequest(http.MethodPost, srv.URL+"/ui/downloads/"+tid+"/source",
strings.NewReader(form.Encode()))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
resp, err := noRedirectClient().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (партиал с ошибкой)", resp.StatusCode)
}
if _, called := rv.sourceAdded[tid]; called {
t.Errorf("невалидный ввод не должен вызывать AddManualSource: %v", rv.sourceAdded)
}
if !strings.Contains(string(body), `id="source-block"`) {
t.Error("htmx-ответ не содержит блок источника")
}
if !strings.Contains(string(body), "block-error") {
t.Error("htmx-ответ не содержит баннер ошибки блока")
}
if strings.Contains(string(body), "внутренняя ошибка") {
t.Error("ошибка ввода подана как внутренняя")
}
}
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 TestIgnoreFile(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])
}
}
// TestChooseCandidateHTMX: на htmx-запрос выбор возвращает партиал блока
// источника (а не полную страницу и не редирект), обновлённый под выбор.
func TestChooseCandidateHTMX(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
req, err := http.NewRequest(http.MethodPost, srv.URL+"/ui/downloads/"+tid+"/candidate",
strings.NewReader("candidate_id="+cid))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
resp, err := noRedirectClient().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (партиал, не редирект)", resp.StatusCode)
}
if rv.chosen[tid] != cid {
t.Errorf("ChooseCandidate получил %q", rv.chosen[tid])
}
// Ответ — партиал блока, а не полная страница.
if strings.Contains(string(body), "<!doctype html>") {
t.Error("htmx-ответ должен быть партиалом, а не полной страницей")
}
if !strings.Contains(string(body), `id="source-block"`) {
t.Error("htmx-ответ не содержит корневой контейнер блока #source-block")
}
}
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)
}
}