Files
jellybit/internal/httpapi/httpapi_test.go
T
avandClaude Fable 5 37f2f6481a Идентичность на ULID: download_infohash, guarded-дедуп, миграция (ulid-identity)
Все сущности переехали с INTEGER AUTOINCREMENT на TEXT ULID (lowercase,
internal/ident — единая точка генерации и разбора; oklog/ulid). Инфохэши
загрузки — множество (download_infohash, v1/v2 гибридных торрентов): дедуп
и сопоставление в поллинге по любому из хешей, magnet-парсер отдаёт оба
хеша гибридной ссылки, усечённый v2-хеш v2-only раздач не хранится.

Инвариант «не более одной активной загрузки на infohash» вместо снятого
unique-индекса держат guarded-методы store в одной write-транзакции
(_txlock=immediate): CreateDownloadIfNoActive (приём/adopt, с доносом
недостающих хешей), ActivateIfNoOtherActive (retry/recovery/relink, отказ
до побочных эффектов), guarded AddInfohashes; SetDownloadState отклоняет
терминал→активное как механический бэкстоп.

Миграция 0006 — первая Go-миграция goose: пересоздание таблиц при
включённых FK, backfill ULID с timestamp из created_at (хронология id
сохранена), разнос infohash, удаление idempotency_key. BREAKING: формат id
в URL/логах/Telegram, REST-поля id (string) и infohashes (список).

Новая конвенция docs/conventions/database.md (без числовых PK), корреляция
в логах grep'ом по голому ULID, ER-схема обновлена. Спеки: новая capability
identity, MODIFIED в state-reconciliation; change заархивирован. Пройдены
ревью дизайна и кода (по 8 углов), все находки исправлены с
регрессионными тестами.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:25:00 +03:00

716 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpapi_test
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.vakhrushev.me/av/jellybit/internal/httpapi"
"git.vakhrushev.me/av/jellybit/internal/ingest"
"git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/magnet"
"git.vakhrushev.me/av/jellybit/internal/recognize"
"git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker"
)
// Валидные lowercase-ULID для маршрутов (pathID валидирует формат).
const (
tid = "01arz3ndektsv4rrffq69g5fav"
tid2 = "01arz3ndektsv4rrffq69g5fb0"
cid = "01arz3ndektsv4rrffq69g5fc0"
cid2 = "01arz3ndektsv4rrffq69g5fd0"
)
type fakeIngestor struct {
res ingest.Result
err error
lastReq ingest.Request
}
func (f *fakeIngestor) Ingest(_ context.Context, req ingest.Request) (ingest.Result, error) {
f.lastReq = req
return f.res, f.err
}
type fakeCommander struct {
cancelled []string
retried []string
err error
}
func (f *fakeCommander) Cancel(_ context.Context, id string) error {
if f.err != nil {
return f.err
}
f.cancelled = append(f.cancelled, id)
return nil
}
func (f *fakeCommander) Retry(_ context.Context, id string) error {
if f.err != nil {
return f.err
}
f.retried = append(f.retried, id)
return nil
}
type fakeReader struct {
list []store.Download
get *store.Download
pageErr error
total int // общее число под фильтром (для пагинации); 0 → len(list)
lastFilter store.ListFilter // последний переданный фильтр
}
func (f *fakeReader) ListDownloads(_ context.Context) ([]store.Download, error) { return f.list, nil }
func (f *fakeReader) ListDownloadsPage(_ context.Context, flt store.ListFilter) ([]store.Download, int, error) {
f.lastFilter = flt
if f.pageErr != nil {
return nil, 0, f.pageErr
}
total := f.total
if total == 0 {
total = len(f.list)
}
return f.list, total, nil
}
func (f *fakeReader) GetDownload(_ context.Context, id string) (*store.Download, error) {
if f.get != nil {
return f.get, nil
}
return &store.Download{ID: id, State: store.StateCancelled}, nil
}
func newServer(t *testing.T, d httpapi.Deps) *httptest.Server {
t.Helper()
if d.Logger == nil {
d.Logger = slog.New(slog.NewTextHandler(io.Discard, nil))
}
h, err := httpapi.NewRouter(d)
if err != nil {
t.Fatalf("NewRouter: %v", err)
}
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
return srv
}
func TestAPIAdd(t *testing.T) {
ing := &fakeIngestor{res: ingest.Result{DownloadID: tid, Infohashes: []string{"abc"}, State: store.StateDownloading}}
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
resp, err := http.Post(srv.URL+"/api/downloads", "application/json",
strings.NewReader(`{"source":"magnet:?xt=urn:btih:abc","context":"Дюна"}`))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status = %d, want 201", resp.StatusCode)
}
var got map[string]any
_ = json.NewDecoder(resp.Body).Decode(&got)
if got["id"] != tid || got["state"] != "downloading" {
t.Errorf("body = %v", got)
}
if ing.lastReq.Context != "Дюна" {
t.Errorf("контекст не проброшен: %q", ing.lastReq.Context)
}
}
func TestAPIAddBadInput(t *testing.T) {
// Источник не magnet → ingest оборачивает magnet.ErrNotMagnet; транспорт
// классифицирует это как 400 (некорректный источник).
ing := &fakeIngestor{err: fmt.Errorf("ingest: parse source: %w", magnet.ErrNotMagnet)}
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
resp, err := http.Post(srv.URL+"/api/downloads", "application/json", strings.NewReader(`{"source":"x"}`))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
func TestAPIList(t *testing.T) {
reader := &fakeReader{list: []store.Download{
{ID: tid2, SourceType: store.SourceMagnet, State: store.StateCompleted,
Infohashes: []store.Infohash{{DownloadID: tid2, Infohash: "abc", Kind: store.HashV1}}},
{ID: tid, SourceType: store.SourceMagnet, State: store.StateDownloading},
}}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})
resp, err := http.Get(srv.URL + "/api/downloads")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var got []map[string]any
_ = json.NewDecoder(resp.Body).Decode(&got)
if len(got) != 2 {
t.Fatalf("len = %d, want 2", len(got))
}
hashes, _ := got[0]["infohashes"].([]any)
if got[0]["state"] != "completed" || len(hashes) != 1 || hashes[0] != "abc" {
t.Errorf("first = %v", got[0])
}
}
func TestAPICancel(t *testing.T) {
cmd := &fakeCommander{}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})
resp, err := http.Post(srv.URL+"/api/downloads/"+tid+"/cancel", "", nil)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if len(cmd.cancelled) != 1 || cmd.cancelled[0] != tid {
t.Errorf("cancel вызван неверно: %v", cmd.cancelled)
}
}
func TestUIRetry(t *testing.T) {
cmd := &fakeCommander{}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})
resp, err := http.Post(srv.URL+"/ui/downloads/"+tid+"/retry", "application/x-www-form-urlencoded", nil)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { // 303 → редирект на / → 200
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if len(cmd.retried) != 1 || cmd.retried[0] != tid {
t.Errorf("retry вызван неверно: %v", cmd.retried)
}
}
func TestAPICommandConflict(t *testing.T) {
// Конфликт состояния (worker.ErrConflict) → 409, не 500.
cmd := &fakeCommander{err: fmt.Errorf("cancel: download 5 in wrong state: %w", worker.ErrConflict)}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})
resp, err := http.Post(srv.URL+"/api/downloads/"+tid+"/cancel", "", nil)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusConflict {
t.Fatalf("status = %d, want 409", resp.StatusCode)
}
var got map[string]any
_ = json.NewDecoder(resp.Body).Decode(&got)
if got["download_id"] != tid {
t.Errorf("download_id корреляции нет: %v", got)
}
}
func TestIndexRenders(t *testing.T) {
reader := &fakeReader{list: []store.Download{
{ID: tid, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc", State: store.StateDownloading},
}}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})
resp, err := http.Get(srv.URL + "/")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
if !strings.Contains(string(body), "jellybit") || !strings.Contains(string(body), "downloading") {
t.Error("страница не содержит ожидаемого контента")
}
}
type ingestErr string
func (e ingestErr) Error() string { return string(e) }
func TestIndexTitleFromDisplayName(t *testing.T) {
// Заголовок карточки — имя раздачи (display_name), а не сырой magnet.
reader := &fakeReader{list: []store.Download{{
ID: tid, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc",
DisplayName: "Дюна: Часть вторая (2024)", State: store.StateDownloading,
}}}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})
resp, err := http.Get(srv.URL + "/")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "Дюна: Часть вторая (2024)") {
t.Error("заголовок карточки не из display_name")
}
if strings.Contains(string(body), "magnet:?xt=urn:btih:abc") {
t.Error("сырой magnet просочился в список вместо имени раздачи")
}
}
func TestIndexListFilterParams(t *testing.T) {
// GET-параметры списка транслируются в store.ListFilter (серверная выборка).
reader := &fakeReader{total: 100}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})
resp, err := http.Get(srv.URL + "/?f=review&q=дюна&page=2&all=1")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
f := reader.lastFilter
if f.Group != store.GroupReview {
t.Errorf("Group = %q, want review", f.Group)
}
if f.Query != "дюна" {
t.Errorf("Query = %q", f.Query)
}
if !f.IncludeDeleted {
t.Errorf("IncludeDeleted = false, want true (all=1)")
}
if f.Limit != 25 || f.Offset != 25 { // page 2, размер 25
t.Errorf("Limit/Offset = %d/%d, want 25/25", f.Limit, f.Offset)
}
}
func TestIndexPageClamp(t *testing.T) {
reader := &fakeReader{}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})
resp, err := http.Get(srv.URL + "/?page=0")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if reader.lastFilter.Offset != 0 {
t.Errorf("page=0 → Offset %d, want 0 (клэмп на 1)", reader.lastFilter.Offset)
}
}
// detailReviewData — ReviewData для страницы /download/{id} с матчем метабазы.
func detailReviewData(provider, providerID, chosenID, chosenURL string) *worker.ReviewData {
rd := &worker.ReviewData{
Download: store.Download{
ID: tid, State: store.StateReview, SourceType: store.SourceMagnet,
SourceRef: "magnet:?xt=urn:btih:deadbeef", DisplayName: "Дюна",
Infohashes: []store.Infohash{{DownloadID: tid, Infohash: "deadbeef", Kind: store.HashV1}},
},
Recognition: &store.Recognition{ID: "1", DownloadID: tid, IsCurrent: true},
Plan: recognize.Plan{
Type: recognize.MediaMovie, Title: "Дюна", Year: 2024,
Files: []recognize.PlanFile{{Src: "dune.mkv", Role: recognize.RoleMain}},
},
Preview: []layout.Link{{Src: "dune.mkv", Dst: "/srv/media/movies/Дюна (2024)/Дюна (2024).mkv"}},
Provider: provider,
ProviderID: providerID,
}
if chosenID != "" {
rd.Candidates = []store.MetadataCandidate{{
ID: cid, Provider: "tmdb", ProviderID: chosenID, Chosen: true,
URL: store.NullString(chosenURL),
}}
}
return rd
}
func TestDownloadMatchLinkFromCandidate(t *testing.T) {
// Выбранный кандидат совпадает с эффективным provider+id → ссылка кандидата.
rv := &fakeReviewer{data: detailReviewData("tmdb", "693134", "693134", "https://custom.example/x")}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
resp, err := http.Get(srv.URL + "/download/" + tid)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "https://custom.example/x") {
t.Error("ссылка на запись метабазы (URL кандидата) не показана")
}
}
func TestDownloadMatchLinkMismatchUsesBuilt(t *testing.T) {
// Эффективный id (999, ручной override) отличается от выбранного кандидата
// (693134) → ссылка строится по эффективному id, а не берётся у кандидата.
rv := &fakeReviewer{data: detailReviewData("tmdb", "999", "693134", "https://custom.example/x")}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
resp, err := http.Get(srv.URL + "/download/" + tid)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "https://www.themoviedb.org/movie/999") {
t.Error("ссылка не построена по эффективному provider_id (movie/999)")
}
if strings.Contains(string(body), "https://custom.example/x") {
t.Error("взят URL кандидата, не совпадающего с эффективным id")
}
}
func TestDownloadTorrentInfoBlock(t *testing.T) {
rv := &fakeReviewer{data: detailReviewData("tmdb", "693134", "693134", "https://x/y")}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
resp, err := http.Get(srv.URL + "/download/" + tid)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
for _, want := range []string{"Информация о торренте", "magnet:?xt=urn:btih:deadbeef", "Дюна"} {
if !strings.Contains(string(body), want) {
t.Errorf("страница загрузки не содержит %q", want)
}
}
}
// --- Ревью ---
type fakeReviewer struct {
data *worker.ReviewData
applyErr error
refined map[string]string
typed map[string]string
ignored map[string]string
chosen map[string]string
providerSet map[string]string
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) ClearProvider(_ context.Context, id string) error {
f.cleared = append(f.cleared, id)
return nil
}
func seriesReviewData() *worker.ReviewData {
s, e := 2, 1
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: 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"},
},
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")},
},
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"
rd.Candidates[0].Chosen = true // tvdb 269613 в seriesReviewData
rd.Candidates[0].URL = store.NullString("https://www.thetvdb.com/dereferrer/series/269613")
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 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)
}
}