Files
jellybit/internal/httpapi/httpapi_test.go
T
avandClaude Opus 4.8 1369a9cabe Приём: дедуп по target_missing/orphaned + стоп-кран «Закрыть»
Два дубля-близнеца на один инфохэш рождались, когда повторный приём
попадал на запись в target_missing: дедуп искал только активную задачу,
а target_missing терминален → заводилась новая загрузка, воркер усыновлял
уже присутствующий торрент и раскладывал его.

- Приём: критерий дедупа расширен до «блокирующей повторный приём» =
  активные ∪ {target_missing, orphaned}. Повторный приём такого инфохэша
  привязывается к существующей записи (спящей, без обращения к qBittorrent),
  а не плодит близнеца. Прочие терминальные (done/cancelled/failed/reverted/
  deleted) повторный приём не блокируют — осознанная свежая попытка. Новый
  read-метод FindReingestBlockingByInfohash (приоритет активной над desync);
  общий active-гард не тронут.
- Команда «Закрыть» (Dismiss) — универсальный стоп-кран из любого состояния,
  кроме deleted → cancelled (error_code=user_dismiss). Только меняет статус:
  файлы (в т.ч. хардлинки done/orphaned) и раздачу qBittorrent не трогает,
  в отличие от «Удалить». Веб — danger-зона внизу страницы; Telegram —
  кнопка с подтверждением; из cancelled — идемпотентный no-op.
- Транспорты при дедупе на desync-запись сообщают адресно (target_missing —
  привязать заново/закрыть; orphaned — закрыть и добавить заново); веб при
  дедупе ведёт на страницу существующей записи.

Спеки: ingest (дедуп), state-reconciliation (стоп-кран); граф переходов
допополнен рёбрами <терминал>→cancelled. OpenSpec change
dedup-target-missing-and-dismiss заархивирован.

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

1085 lines
39 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)
}
}
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("set type: invalid type %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)
}
}