Files
jellybit/internal/httpapi/action_swap_test.go
T
avandClaude Opus 4.8 3f1a928000 Единое окно: полное пользовательское удаление загрузки (delete)
Вторая половина «единого окна»: команда «Удалить» снимает наши библиотечные
хардлинки (гард последней копии осознанно выключен, в отличие от Undo) и сносит
раздачу с файлами из qBittorrent (deleteFiles=true) → терминальный deleted.
Доступна из done/orphaned/target_missing, идемпотентна к отсутствующей стороне;
инициатор различается через error_code=user_delete. Подтверждение обязательно:
веб — danger-секция внизу страницы (hx-confirm + details), Telegram — двухшаговый
inline-confirm. qbt.Delete + layout.Remove (unlink без ErrLastCopy, только свои
ссылки под movies/series). Граф переходов не менялся — рёбра уже были.

OpenSpec: state-reconciliation +1 требование; синк workflow.md; беклог закрыт.

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

288 lines
12 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"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"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
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) 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.NewTextHandler(io.Discard, nil)),
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)
}
}
// 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())
}
}