Files
jellybit/internal/httpapi/action_swap_test.go
T
av a5d873b62d web-ui: карточка и страница обновляются, пока задачу может двигать фон
- условие самообновления — доменный предикат store.State.IsObservable() вместо
  фазы catched; один поллер на поверхность, интервалы 5 с и 15 с
- отказ тика отвечает 200 и самозавершающимся фрагментом с корневым id цели
  вместо 404/500, который htmx не свопит
- заведён ADR-2026-08-10-observability-is-not-terminality, переписан раздел
  «Живой поллинг» в конвенции веб-UI
2026-08-10 14:02:38 +03:00

444 lines
20 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"
"errors"
"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)
}
body := rr.Body.String()
for _, want := range []string{"width:42%", "/fragments/downloads/" + testULID + "/card"} {
if !strings.Contains(body, want) {
t.Errorf("карточка downloading без %q: %s", want, body)
}
}
}
// TestActionBarNamesReasonWithoutPreview: при пустом предпросмотре панель
// действий печатает записанную причину, а не общее «Подтверди источник» —
// иначе экран советует подтвердить уже подтверждённое и молчит о настоящей
// причине (см. spec review, «Панель действий при пустом предпросмотре»).
func TestActionBarNamesReasonWithoutPreview(t *testing.T) {
t.Run("причина показа старше записанной", func(t *testing.T) {
rd := reviewDataWithSource(false)
rd.PreviewError = `layout: имя не помещается: "Очень длинное" — 400 байт при пределе 255`
rd.Download.ErrorMsg = store.NullString("устаревшая причина прошлого перехода")
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, "не помещается") {
t.Errorf("панель не назвала причину: %s", body)
}
if strings.Contains(body, "Подтверди источник") {
t.Errorf("панель печатает общий текст вместо причины: %s", body)
}
if strings.Contains(body, "устаревшая") {
t.Errorf("записанная причина перебила посчитанную на показе: %s", body)
}
})
t.Run("записанная причина, когда посчитанной нет", func(t *testing.T) {
rd := reviewDataWithSource(false)
rd.Download.ErrorMsg = store.NullString("целевой файл уже существует")
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 !strings.Contains(rr.Body.String(), "уже существует") {
t.Errorf("панель не назвала записанную причину: %s", rr.Body.String())
}
})
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)
}
if !strings.Contains(rr.Body.String(), "Подтверди источник") {
t.Errorf("без причины ожидался общий текст: %s", rr.Body.String())
}
})
}
// TestActionSwapErrorKeepsSwapRoot: действие человека, упавшее на чтении задачи,
// отвечает 200 и фрагментом с корнем своей поверхности — иначе своп унёс бы
// якорь (#card-{id} у списка, #download-main у страницы) и следующие действия
// целились бы в несуществующий узел. Ретрая у действия нет, поэтому уровень лога
// здесь ERROR, а не WARN, как у повторяющегося тика.
func TestActionSwapErrorKeepsSwapRoot(t *testing.T) {
cases := []struct{ surface, root string }{
{"list", `id="card-` + testULID + `"`},
{"download", `id="download-main"`},
}
for _, c := range cases {
rd := stubReader{getErr: errors.New("db is gone")}
h := testRouterAction(t, rd, actionReviewer{}, stubCommander{}, stubLive{})
rr := post(t, h, "/ui/downloads/"+testULID+"/cancel", url.Values{"surface": {c.surface}}, true)
if rr.Code != http.StatusOK {
t.Errorf("surface=%s: status = %d, want 200", c.surface, rr.Code)
continue
}
body := rr.Body.String()
if !strings.Contains(body, c.root) {
t.Errorf("surface=%s: фрагмент отказа без корня %s:\n%s", c.surface, c.root, body)
}
if strings.Contains(body, "hx-trigger") {
t.Errorf("surface=%s: фрагмент отказа не самозавершается:\n%s", c.surface, body)
}
}
}