Files
jellybit/internal/httpapi/action_swap_test.go
T
avandClaude Opus 4.8 612344bab3 конвенции: перенести механизируемое в golangci-lint и internal/archrules
Правило, которое проверяет машина, не должно оставаться прозой: файл конвенций
на сотни строк размазывает внимание по тривиальному — модель добросовестно
проверит именование полей лога и не дойдёт до формы решения.

Включены sloglint (константный msg, стиль ключ-значение), forbidigo (fmt.Print*,
os.Getenv, time.Now мимо store.Now), errorlint (сравнение ошибок), depguard
(сторонние пакеты ошибок). internal/archrules — сканеры на то, что линтером не
выражается: направление зависимостей ядро↔транспорты, AUTOINCREMENT и серверное
время в новых миграциях, матчинг ошибки по тексту.

Код приведён к правилам: logging.StartCall как единая точка отсчёта длительности
внешних вызовов, store.Now вместо time.Now в httpapi и часах воркера,
slog.DiscardHandler в тестах.

Перенесённое вычеркнуто из docs/conventions/* и openspec/config.yaml — прозой
осталось только то, что правилом не выражается.

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

355 lines
15 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
import (
"context"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/recognize"
"git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker"
)
// stubCommander — Commander-заглушка с инъекцией ошибок cancel/retry.
type stubCommander struct{ cancelErr, retryErr error }
func (s stubCommander) Cancel(context.Context, string) error { return s.cancelErr }
func (s stubCommander) Retry(context.Context, string) error { return s.retryErr }
// actionReviewer — Reviewer-заглушка с инъекцией ошибок петлевых/своп-действий
// и захватом подсказки refine.
type actionReviewer struct {
stubReviewer
undoErr error
deleteErr error
dismissErr error
relinkErr error
rerecognizeErr error
refineErr error
gotHint *string // если не nil — сюда пишется hint из Refine
}
func (a actionReviewer) Undo(context.Context, string) error { return a.undoErr }
func (a actionReviewer) Delete(context.Context, string) error { return a.deleteErr }
func (a actionReviewer) Dismiss(context.Context, string) error { return a.dismissErr }
func (a actionReviewer) Relink(context.Context, string) error { return a.relinkErr }
func (a actionReviewer) Rerecognize(context.Context, string) error { return a.rerecognizeErr }
func (a actionReviewer) Refine(_ context.Context, _ string, hint string) error {
if a.gotHint != nil {
*a.gotHint = hint
}
return a.refineErr
}
func testRouterAction(t *testing.T, r stubReader, rv Reviewer, cmd Commander, lv stubLive) http.Handler {
t.Helper()
h, err := NewRouter(Deps{
Logger: slog.New(slog.DiscardHandler),
Reader: r,
Reviewer: rv,
Commander: cmd,
Live: lv,
})
if err != nil {
t.Fatalf("NewRouter: %v", err)
}
return h
}
// post отправляет POST-форму; htmx=true добавляет заголовок HX-Request.
func post(t *testing.T, h http.Handler, path string, form url.Values, htmx bool) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if htmx {
req.Header.Set("HX-Request", "true")
}
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
return rr
}
func dlState(s store.State) store.Download {
return store.Download{
ID: testULID,
SourceRef: "Fargo.S02",
Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ihswap", Kind: store.HashV1}},
State: s,
}
}
// TestUndoListHTMXSwapsCard: откат из списка (surface=list) при htmx → 200 и
// фрагмент карточки с новым состоянием, не редирект.
func TestUndoListHTMXSwapsCard(t *testing.T) {
dl := dlState(store.StateReverted) // GetDownload после действия отдаёт новое состояние
h := testRouterAction(t, stubReader{one: &dl}, actionReviewer{}, stubCommander{}, stubLive{})
rr := post(t, h, "/ui/downloads/"+testULID+"/undo", url.Values{"surface": {"list"}}, true)
if rr.Code != http.StatusOK {
t.Fatalf("undo (htmx) = %d, want 200", rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, `id="card-`+testULID+`"`) {
t.Errorf("ответ не содержит фрагмент карточки: %s", body)
}
if !strings.Contains(body, "st-reverted") {
t.Errorf("карточка без нового состояния (st-reverted): %s", body)
}
}
// TestUndoNoHTMXRedirects: без htmx откат деградирует до PRG-редиректа на список.
func TestUndoNoHTMXRedirects(t *testing.T) {
dl := dlState(store.StateReverted)
h := testRouterAction(t, stubReader{one: &dl}, actionReviewer{}, stubCommander{}, stubLive{})
rr := post(t, h, "/ui/downloads/"+testULID+"/undo", nil, false)
if rr.Code != http.StatusSeeOther {
t.Fatalf("undo (no htmx) = %d, want 303", rr.Code)
}
if loc := rr.Header().Get("Location"); loc != "/" {
t.Errorf("redirect на %q, want /", loc)
}
}
// TestDownloadSurfaceHTMXSwapsMain: действие со страницы загрузки (surface=download)
// при htmx → 200 и фрагмент download_main.
func TestDownloadSurfaceHTMXSwapsMain(t *testing.T) {
dl := dlState(store.StateReverted)
rv := actionReviewer{stubReviewer: stubReviewer{data: &worker.ReviewData{Download: dl}}}
h := testRouterAction(t, stubReader{one: &dl}, rv, stubCommander{}, stubLive{})
rr := post(t, h, "/ui/downloads/"+testULID+"/undo", url.Values{"surface": {"download"}}, true)
if rr.Code != http.StatusOK {
t.Fatalf("undo download (htmx) = %d, want 200", rr.Code)
}
if !strings.Contains(rr.Body.String(), `id="download-main"`) {
t.Errorf("ответ не содержит фрагмент download_main: %s", rr.Body.String())
}
}
// TestRerecognizeHTMXSwapsReviewMain: перераспознавание при htmx → 200 и тело
// ревью review_main в состоянии recognizing с поллером.
func TestRerecognizeHTMXSwapsReviewMain(t *testing.T) {
dl := dlState(store.StateRecognizing)
rv := actionReviewer{stubReviewer: stubReviewer{data: &worker.ReviewData{Download: dl}}}
h := testRouterAction(t, stubReader{one: &dl}, rv, stubCommander{}, stubLive{})
rr := post(t, h, "/ui/downloads/"+testULID+"/rerecognize", nil, true)
if rr.Code != http.StatusOK {
t.Fatalf("rerecognize (htmx) = %d, want 200", rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, `id="review-main"`) {
t.Errorf("ответ не содержит фрагмент review_main: %s", body)
}
if !strings.Contains(body, `hx-trigger="every 2s"`) {
t.Errorf("recognizing без поллера (hx-trigger every 2s): %s", body)
}
}
// TestRerecognizeNoHTMXRedirects: без htmx перераспознавание → редирект на ревью.
func TestRerecognizeNoHTMXRedirects(t *testing.T) {
dl := dlState(store.StateRecognizing)
rv := actionReviewer{stubReviewer: stubReviewer{data: &worker.ReviewData{Download: dl}}}
h := testRouterAction(t, stubReader{one: &dl}, rv, stubCommander{}, stubLive{})
rr := post(t, h, "/ui/downloads/"+testULID+"/rerecognize", nil, false)
if rr.Code != http.StatusSeeOther {
t.Fatalf("rerecognize (no htmx) = %d, want 303", rr.Code)
}
if loc := rr.Header().Get("Location"); loc != "/review/"+testULID {
t.Errorf("redirect на %q, want /review/{id}", loc)
}
}
// TestFragReviewPollerStops: фрагмент тела ревью несёт поллер в recognizing и не
// несёт его в review (опрос сам прекращается).
func TestFragReviewPollerStops(t *testing.T) {
recDL := dlState(store.StateRecognizing)
hRec := testRouterAction(t, stubReader{one: &recDL},
actionReviewer{stubReviewer: stubReviewer{data: &worker.ReviewData{Download: recDL}}}, stubCommander{}, stubLive{})
if rr := get(t, hRec, "/fragments/downloads/"+testULID+"/review"); !strings.Contains(rr.Body.String(), `hx-trigger="every 2s"`) {
t.Errorf("recognizing: фрагмент без поллера")
}
revDL := dlState(store.StateReview)
hRev := testRouterAction(t, stubReader{one: &revDL},
actionReviewer{stubReviewer: stubReviewer{data: &worker.ReviewData{Download: revDL}}}, stubCommander{}, stubLive{})
if rr := get(t, hRev, "/fragments/downloads/"+testULID+"/review"); strings.Contains(rr.Body.String(), `hx-trigger="every 2s"`) {
t.Errorf("review: поллер не прекратился")
}
}
// TestActionErrorHTMX200: ошибка действия на htmx-пути → 200 с сообщением в
// фрагменте (иначе htmx не свопит DOM).
func TestActionErrorHTMX200(t *testing.T) {
dl := dlState(store.StateReview)
cmd := stubCommander{cancelErr: worker.ErrConflict}
h := testRouterAction(t, stubReader{one: &dl}, actionReviewer{}, cmd, stubLive{})
rr := post(t, h, "/ui/downloads/"+testULID+"/cancel", url.Values{"surface": {"list"}}, true)
if rr.Code != http.StatusOK {
t.Fatalf("cancel с ошибкой (htmx) = %d, want 200", rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, `id="card-`+testULID+`"`) {
t.Errorf("ответ не содержит фрагмент карточки")
}
if !strings.Contains(body, "действие недоступно") {
t.Errorf("нет сообщения об ошибке в фрагменте: %s", body)
}
}
// TestRefineHTMXSwapsReviewMain: уточнить (refine) при htmx → 200 + review_main,
// подсказка из формы доходит до доменного вызова.
func TestRefineHTMXSwapsReviewMain(t *testing.T) {
dl := dlState(store.StateRecognizing)
var hint string
rv := actionReviewer{stubReviewer: stubReviewer{data: &worker.ReviewData{Download: dl}}, gotHint: &hint}
h := testRouterAction(t, stubReader{one: &dl}, rv, stubCommander{}, stubLive{})
rr := post(t, h, "/ui/downloads/"+testULID+"/refine", url.Values{"hint": {"это Fargo 2014"}}, true)
if rr.Code != http.StatusOK {
t.Fatalf("refine (htmx) = %d, want 200", rr.Code)
}
if !strings.Contains(rr.Body.String(), `id="review-main"`) {
t.Errorf("refine не отдал review_main")
}
if hint != "это Fargo 2014" {
t.Errorf("hint = %q, want «это Fargo 2014» (форма не разобрана)", hint)
}
}
// TestDownloadSurfaceNoHTMXRedirects: действие со страницы загрузки без htmx →
// 303 (деградация на редирект, как и для списка).
func TestDownloadSurfaceNoHTMXRedirects(t *testing.T) {
dl := dlState(store.StateReverted)
rv := actionReviewer{stubReviewer: stubReviewer{data: &worker.ReviewData{Download: dl}}}
h := testRouterAction(t, stubReader{one: &dl}, rv, stubCommander{}, stubLive{})
rr := post(t, h, "/ui/downloads/"+testULID+"/undo", url.Values{"surface": {"download"}}, false)
if rr.Code != http.StatusSeeOther {
t.Fatalf("undo download (no htmx) = %d, want 303", rr.Code)
}
}
// TestReviewCancelNoHTMXNavigates: отклонение из ревью (форма без hx-*/surface) —
// навигация на список (выход из ревью), а не своп.
func TestReviewCancelNoHTMXNavigates(t *testing.T) {
dl := dlState(store.StateReview)
h := testRouterAction(t, stubReader{one: &dl}, actionReviewer{}, stubCommander{}, stubLive{})
rr := post(t, h, "/ui/downloads/"+testULID+"/cancel", nil, false)
if rr.Code != http.StatusSeeOther {
t.Fatalf("cancel из ревью (no htmx) = %d, want 303", rr.Code)
}
if loc := rr.Header().Get("Location"); loc != "/" {
t.Errorf("redirect на %q, want / (выход из ревью)", loc)
}
}
// TestActionErrorDownloadSurface: ошибка действия на странице загрузки (htmx) →
// 200 + фрагмент download_main с сообщением.
func TestActionErrorDownloadSurface(t *testing.T) {
dl := dlState(store.StateDone)
rv := actionReviewer{stubReviewer: stubReviewer{data: &worker.ReviewData{Download: dl}}, undoErr: worker.ErrConflict}
h := testRouterAction(t, stubReader{one: &dl}, rv, stubCommander{}, stubLive{})
rr := post(t, h, "/ui/downloads/"+testULID+"/undo", url.Values{"surface": {"download"}}, true)
if rr.Code != http.StatusOK {
t.Fatalf("undo download с ошибкой (htmx) = %d, want 200", rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, `id="download-main"`) {
t.Errorf("ответ не содержит download_main")
}
if !strings.Contains(body, "действие недоступно") {
t.Errorf("нет сообщения об ошибке в download_main: %s", body)
}
}
// reviewDataWithSource собирает ReviewData ревью с одним нейронка-источником;
// withLinks управляет наличием превью раскладки (HasLinks).
func reviewDataWithSource(withLinks bool) *worker.ReviewData {
dl := dlState(store.StateReview)
plan := recognize.Plan{Type: "movie", Title: "Fargo", Year: 1996,
Files: []recognize.PlanFile{{Src: "fargo.mkv"}}}
rd := &worker.ReviewData{
Download: dl,
Recognition: &store.Recognition{},
Plan: plan,
Sources: []worker.SourceOption{
{Kind: worker.SourceNeural, Provider: "none", Title: "Fargo", Year: 1996, Active: true},
},
}
if withLinks {
rd.Preview = []layout.Link{{Src: "fargo.mkv", Dst: "/movies/Fargo (1996)/fargo.mkv"}}
}
return rd
}
// TestSourceSwapUpdatesActionBarOOB: своп выбора источника отдаёт свежий
// #source-block и oob-обновление панели действий (#action-bar с hx-swap-oob),
// чтобы кнопка «Применить» синхронно отражала актуальный HasLinks.
func TestSourceSwapUpdatesActionBarOOB(t *testing.T) {
t.Run("с превью → есть кнопка Применить", func(t *testing.T) {
rd := reviewDataWithSource(true)
rv := actionReviewer{stubReviewer: stubReviewer{data: rd}}
h := testRouterAction(t, stubReader{one: &rd.Download}, rv, stubCommander{}, stubLive{})
rr := post(t, h, "/ui/downloads/"+testULID+"/nobase", nil, true)
if rr.Code != http.StatusOK {
t.Fatalf("nobase (htmx) = %d, want 200", rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, `id="source-block"`) {
t.Errorf("ответ без свежего #source-block: %s", body)
}
if !strings.Contains(body, `id="action-bar"`) || !strings.Contains(body, `hx-swap-oob="true"`) {
t.Errorf("ответ без oob-панели действий: %s", body)
}
if !strings.Contains(body, "/apply") {
t.Errorf("HasLinks=true, но в панели нет кнопки «Применить»: %s", body)
}
})
t.Run("без превью → кнопки Применить нет", func(t *testing.T) {
rd := reviewDataWithSource(false)
rv := actionReviewer{stubReviewer: stubReviewer{data: rd}}
h := testRouterAction(t, stubReader{one: &rd.Download}, rv, stubCommander{}, stubLive{})
rr := post(t, h, "/ui/downloads/"+testULID+"/nobase", nil, true)
if rr.Code != http.StatusOK {
t.Fatalf("nobase (htmx) = %d, want 200", rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, `id="action-bar"`) || !strings.Contains(body, `hx-swap-oob="true"`) {
t.Errorf("ответ без oob-панели действий: %s", body)
}
if strings.Contains(body, "/apply") {
t.Errorf("HasLinks=false, но в панели осталась кнопка «Применить»: %s", body)
}
})
}
// TestRetryListShowsProgress: retry из списка → карточка downloading с
// прогресс-поллером.
func TestRetryListShowsProgress(t *testing.T) {
dl := dlState(store.StateDownloading)
lv := stubLive{m: map[string]worker.Live{"ihswap": {Progress: 0.42, DlSpeed: 6400000, ETA: 720}}}
h := testRouterAction(t, stubReader{one: &dl}, actionReviewer{}, stubCommander{}, lv)
rr := post(t, h, "/ui/downloads/"+testULID+"/retry", url.Values{"surface": {"list"}}, true)
if rr.Code != http.StatusOK {
t.Fatalf("retry (htmx) = %d, want 200", rr.Code)
}
if !strings.Contains(rr.Body.String(), "/fragments/downloads/"+testULID+"/progress") {
t.Errorf("карточка downloading без прогресс-поллера: %s", rr.Body.String())
}
}