Два дубля-близнеца на один инфохэш рождались, когда повторный приём
попадал на запись в 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>
290 lines
12 KiB
Go
290 lines
12 KiB
Go
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
|
||
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.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())
|
||
}
|
||
}
|