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 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) 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()) } }