package httpapi import ( "context" "errors" "fmt" "net/http" "net/http/httptest" "net/url" "strconv" "strings" "testing" "time" "git.vakhrushev.me/av/jellybit/internal/store" "git.vakhrushev.me/av/jellybit/internal/worker" ) // Валидные lowercase-ULID для форм (parseBulkBatch прогоняет через ident.Parse). const ( bid1 = "01arz3ndektsv4rrffq69g5fa1" bid2 = "01arz3ndektsv4rrffq69g5fa2" bid3 = "01arz3ndektsv4rrffq69g5fa3" bid4 = "01arz3ndektsv4rrffq69g5fa4" ) // bulkReviewer — Reviewer, который считает вызовы удаления и умеет отказать по // конкретному идентификатору. Указатель: тест проверяет «ни одного вызова», а // значение-копия этого не покажет. type bulkReviewer struct { stubReviewer calls *[]string errs map[string]error allErr error // отказ по любому идентификатору (лежащий внешний сервис) sleep time.Duration // медленный, но исправный сосед } func (b bulkReviewer) Delete(ctx context.Context, id string) error { *b.calls = append(*b.calls, id) // Контекст исполнения обязан пережить отмену запроса: необратимую операцию // нельзя обрывать на середине. Проверяем во всех тестах, а не только в том, // что об этом, — гарантия одна на все пути. if err := ctx.Err(); err != nil { return fmt.Errorf("bulk delete stub: %w", err) } time.Sleep(b.sleep) if b.allErr != nil { return b.allErr } return b.errs[id] } func newBulkReviewer(errs map[string]error) (bulkReviewer, *[]string) { calls := &[]string{} return bulkReviewer{calls: calls, errs: errs}, calls } func dl(id string, st store.State, title string) store.Download { return store.Download{ID: id, State: st, DisplayName: title} } // byID собирает карту для поштучного чтения на экранах подтверждения и отчёта. func byID(ds ...store.Download) map[string]store.Download { m := make(map[string]store.Download, len(ds)) for _, d := range ds { m[d.ID] = d } return m } // postCancelled отправляет POST с уже отменённым контекстом запроса — так // выглядит закрытая вкладка или оборванная связь на середине пачки. func postCancelled(t *testing.T, h http.Handler, path string, form url.Values) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") ctx, cancel := context.WithCancel(req.Context()) req = req.WithContext(ctx) cancel() rr := httptest.NewRecorder() h.ServeHTTP(rr, req) return rr } func idForm(ids ...string) url.Values { f := url.Values{} for _, id := range ids { f.Add("id", id) } return f } // П1: страница показывает строки только тех загрузок, для которых удаление // разрешено поштучно. Читатель отдаёт задачи во всех состояниях — на странице // оказываются ровно разрешённые. func TestBulkDeletePageShowsOnlyDeletable(t *testing.T) { all := []store.State{ store.StateCatched, store.StateDownloading, store.StateCompleted, store.StateRecognizing, store.StateReview, store.StateLinking, store.StateDone, store.StateDeferred, store.StateStuck, store.StateFailed, store.StateCancelled, store.StateReverted, store.StateTargetMissing, store.StateOrphaned, store.StateDeleted, } // Читатель отдаёт всё подряд — отбирает страница по домену, а не тест. var rows []store.Download for i, st := range all { if st.CanDelete() { rows = append(rows, dl(fmt.Sprintf("%026d", i), st, "задача "+string(st))) } } rv, _ := newBulkReviewer(nil) h := testRouterAction(t, stubReader{deletable: rows}, rv, stubCommander{}, stubLive{}) body := get(t, h, "/delete").Body.String() for _, st := range all { marker := "задача " + string(st) if got := strings.Contains(body, marker); got != st.CanDelete() { t.Errorf("%s: строка на странице=%v, CanDelete=%v", st, got, st.CanDelete()) } } } // Страница не опрашивает сервер: своп разметки стёр бы отметки, и человек // подтвердил бы необратимое удаление по выбору, которого уже не видит. func TestBulkDeletePageDoesNotSelfPoll(t *testing.T) { rv, _ := newBulkReviewer(nil) h := testRouterAction(t, stubReader{deletable: []store.Download{dl(bid1, store.StateDone, "Дюна")}}, rv, stubCommander{}, stubLive{}) body := get(t, h, "/delete").Body.String() if strings.Contains(body, `hx-trigger="every`) { t.Error("страница выбора не должна самообновляться") } // Предел пачки назван до отправки — иначе отказ по нему отнимает работу. if !strings.Contains(body, strconv.Itoa(maxBulkDelete)) { t.Errorf("предел пачки не назван на странице:\n%s", body) } } // Пустое состояние: разрешённых нет — удаление не предлагается. func TestBulkDeletePageEmpty(t *testing.T) { rv, _ := newBulkReviewer(nil) h := testRouterAction(t, stubReader{}, rv, stubCommander{}, stubLive{}) body := get(t, h, "/delete").Body.String() if strings.Contains(body, `action="/ui/delete/confirm"`) { t.Error("на пустой странице не должно быть формы удаления") } if !strings.Contains(body, "Удалять нечего") { t.Errorf("нет пустого состояния:\n%s", body) } } // П2 (первая половина): подтверждение называет каждую выбранную поимённо и не // делает ни одного вызова удаления. func TestBulkConfirmNamesRowsAndDeletesNothing(t *testing.T) { rv, calls := newBulkReviewer(nil) h := testRouterAction(t, stubReader{byID: byID( dl(bid1, store.StateDone, "Дюна"), dl(bid2, store.StateOrphaned, "Фарго"), )}, rv, stubCommander{}, stubLive{}) body := post(t, h, "/ui/delete/confirm", idForm(bid1, bid2), false).Body.String() for _, want := range []string{"Дюна", "Фарго", bid1, bid2} { if !strings.Contains(body, want) { t.Errorf("подтверждение не называет %q:\n%s", want, body) } } if len(*calls) != 0 { t.Errorf("подтверждение не должно удалять, вызовы: %v", *calls) } } // Строка orphaned на подтверждении предупреждает о последней копии данных: гард // последней копии в удалении выключен сознательно, и осведомлённость человека — // единственный оставшийся предохранитель. func TestBulkConfirmWarnsAboutLastCopy(t *testing.T) { rv, _ := newBulkReviewer(nil) h := testRouterAction(t, stubReader{byID: byID( dl(bid1, store.StateOrphaned, "Фарго"), )}, rv, stubCommander{}, stubLive{}) body := post(t, h, "/ui/delete/confirm", idForm(bid1), false).Body.String() if !strings.Contains(body, "последняя копия данных") { t.Errorf("нет предупреждения о последней копии:\n%s", body) } } // П2 (вторая половина): без признака подтверждения не удаляется ничего. func TestBulkDeleteWithoutConfirmDeletesNothing(t *testing.T) { rv, calls := newBulkReviewer(nil) h := testRouterAction(t, stubReader{byID: byID( dl(bid1, store.StateDone, "Дюна"), )}, rv, stubCommander{}, stubLive{}) body := post(t, h, "/ui/delete", idForm(bid1), false).Body.String() if len(*calls) != 0 { t.Errorf("без подтверждения не должно быть вызовов удаления: %v", *calls) } if !strings.Contains(body, "страницы подтверждения") { t.Errorf("отказ не объяснён:\n%s", body) } } // Гарды разбора одинаковы на обеих границах: исполняющий запрос получает // идентификаторы формой заново и на проверки подтверждения опираться не вправе. func TestBulkBatchGuardsOnBothBoundaries(t *testing.T) { over := make([]string, 0, maxBulkDelete+1) for i := range maxBulkDelete + 1 { over = append(over, fmt.Sprintf("%026d", i)) } cases := []struct { name string form url.Values want string }{ {"неразобранный идентификатор", idForm(bid1, "не-ulid"), "некорректный идентификатор"}, {"пачка сверх предела", idForm(over...), "не больше"}, {"пустой набор", url.Values{}, "ни одна загрузка не выбрана"}, } for _, c := range cases { for _, path := range []string{"/ui/delete/confirm", "/ui/delete"} { t.Run(c.name+" "+path, func(t *testing.T) { rv, calls := newBulkReviewer(nil) h := testRouterAction(t, stubReader{ deletable: []store.Download{dl(bid1, store.StateDone, "Дюна")}, byID: byID(dl(bid1, store.StateDone, "Дюна")), }, rv, stubCommander{}, stubLive{}) form := url.Values{} for k, v := range c.form { form[k] = v } if path == "/ui/delete" { form.Set("confirm", "1") } body := post(t, h, path, form, false).Body.String() if len(*calls) != 0 { t.Errorf("отказ разбора не должен удалять: %v", *calls) } if !strings.Contains(body, c.want) { t.Errorf("нет объяснения %q:\n%s", c.want, body) } }) } } } // Отказ по пределу возвращает страницу выбора с сохранёнными отметками: иначе // проверка отнимает всю проделанную человеком работу. func TestBulkOverLimitKeepsSelection(t *testing.T) { rows := []store.Download{dl(bid1, store.StateDone, "Дюна")} over := []string{bid1} for i := range maxBulkDelete { over = append(over, fmt.Sprintf("%026d", i)) } rv, _ := newBulkReviewer(nil) h := testRouterAction(t, stubReader{deletable: rows}, rv, stubCommander{}, stubLive{}) body := post(t, h, "/ui/delete/confirm", idForm(over...), false).Body.String() if !strings.Contains(body, `value="`+bid1+`" checked`) { t.Errorf("отметка выбора не сохранена:\n%s", body) } } // Дубликаты в пачке схлопываются: повторный вызов по той же задаче дал бы // ложный конфликт во второй строке отчёта. func TestBulkDeleteCollapsesDuplicates(t *testing.T) { rv, calls := newBulkReviewer(nil) h := testRouterAction(t, stubReader{byID: byID( dl(bid1, store.StateDone, "Дюна"), )}, rv, stubCommander{}, stubLive{}) form := idForm(bid1, bid1, bid1) form.Set("confirm", "1") post(t, h, "/ui/delete", form, false) if len(*calls) != 1 { t.Errorf("дубликаты не схлопнуты: %v", *calls) } } // П3: отказ на одной загрузке не отменяет остальных, отчёт называет обе // половины поимённо. func TestBulkDeletePartialFailure(t *testing.T) { rv, calls := newBulkReviewer(map[string]error{ bid2: errors.New("qbittorrent: connection refused"), }) h := testRouterAction(t, stubReader{byID: byID( dl(bid1, store.StateDone, "Дюна"), dl(bid2, store.StateDone, "Фарго"), dl(bid3, store.StateDone, "Оппенгеймер"), )}, rv, stubCommander{}, stubLive{}) form := idForm(bid1, bid2, bid3) form.Set("confirm", "1") body := post(t, h, "/ui/delete", form, false).Body.String() if len(*calls) != 3 { t.Fatalf("удаление должно уйти по всем трём: %v", *calls) } for _, want := range []string{"Дюна", "Фарго", "Оппенгеймер", "Удалено — 2", "Отказ — 1"} { if !strings.Contains(body, want) { t.Errorf("отчёт не называет %q:\n%s", want, body) } } // Сырой текст ошибки внешнего сервиса наружу не идёт — только публичный канал. if strings.Contains(body, "connection refused") { t.Errorf("сырая ошибка просочилась в разметку:\n%s", body) } } // П4: групповой путь прав поштучного не расширяет — недопустимое состояние // отклоняется тем же конфликтом, остальные выбранные удаляются. func TestBulkDeleteConflictDoesNotWidenRights(t *testing.T) { rv, calls := newBulkReviewer(map[string]error{ bid2: fmt.Errorf("delete: download in state downloading: %w", worker.ErrConflict), }) h := testRouterAction(t, stubReader{byID: byID( dl(bid1, store.StateDone, "Дюна"), dl(bid2, store.StateDownloading, "Фарго"), dl(bid3, store.StateDone, "Оппенгеймер"), )}, rv, stubCommander{}, stubLive{}) form := idForm(bid1, bid2, bid3) form.Set("confirm", "1") body := post(t, h, "/ui/delete", form, false).Body.String() if len(*calls) != 3 { t.Fatalf("допуск проверяет ядро — звать надо все три: %v", *calls) } if !strings.Contains(body, "Удалено — 2") || !strings.Contains(body, "Отказ — 1") { t.Errorf("отчёт не разделил исходы:\n%s", body) } } // Все удалены — отчёт называет обе загрузки и отказов не содержит. func TestBulkDeleteAllSucceed(t *testing.T) { rv, _ := newBulkReviewer(nil) h := testRouterAction(t, stubReader{byID: byID( dl(bid1, store.StateDone, "Дюна"), dl(bid2, store.StateOrphaned, "Фарго"), )}, rv, stubCommander{}, stubLive{}) form := idForm(bid1, bid2) form.Set("confirm", "1") body := post(t, h, "/ui/delete", form, false).Body.String() if !strings.Contains(body, "Удалено — 2") || strings.Contains(body, "Отказ — ") { t.Errorf("ожидались две удалённые без отказов:\n%s", body) } } // Идентификатор без записи в хранилище назван строкой и на подтверждении, и в // отчёте: молча выброшенный, он развёл бы подтверждённое с исполненным. func TestBulkMissingDownloadIsNamed(t *testing.T) { rv, _ := newBulkReviewer(map[string]error{ bid2: fmt.Errorf("delete: %w", store.ErrNotFound), }) h := testRouterAction(t, stubReader{byID: byID( dl(bid1, store.StateDone, "Дюна"), dl(bid3, store.StateDone, "Оппенгеймер"), )}, rv, stubCommander{}, stubLive{}) confirm := post(t, h, "/ui/delete/confirm", idForm(bid1, bid2, bid3), false).Body.String() if !strings.Contains(confirm, bid2) || !strings.Contains(confirm, "не найдена") { t.Errorf("подтверждение не назвало ненайденную загрузку:\n%s", confirm) } form := idForm(bid1, bid2, bid3) form.Set("confirm", "1") res := post(t, h, "/ui/delete", form, false).Body.String() if !strings.Contains(res, bid2) { t.Errorf("отчёт не назвал ненайденную загрузку:\n%s", res) } if !strings.Contains(res, "Удалено — 2") { t.Errorf("остальные должны быть удалены:\n%s", res) } } // Системный отказ останавливает пачку: удаление снимает библиотечные ссылки // раньше, чем сносит раздачу, поэтому при лежащем qBittorrent проход без // остановки оставил бы без раскладки все выбранные тайтлы разом. func TestBulkDeleteStopsOnConsecutiveSystemFailures(t *testing.T) { calls := &[]string{} rv := bulkReviewer{calls: calls, allErr: errors.New("qbittorrent: unreachable")} ids := []string{bid1, bid2, bid3, bid4} h := testRouterAction(t, stubReader{byID: byID( dl(bid1, store.StateDone, "Дюна"), dl(bid2, store.StateDone, "Фарго"), dl(bid3, store.StateDone, "Оппенгеймер"), dl(bid4, store.StateDone, "Интерстеллар"), )}, rv, stubCommander{}, stubLive{}) form := idForm(ids...) form.Set("confirm", "1") body := post(t, h, "/ui/delete", form, false).Body.String() if len(*calls) != bulkFailThreshold { t.Fatalf("проход должен остановиться после %d отказов, вызовов: %v", bulkFailThreshold, *calls) } if !strings.Contains(body, "Не выполнено — 1") { t.Errorf("остаток пачки не назван невыполненным:\n%s", body) } if !strings.Contains(body, "проход остановлен") { t.Errorf("причина остановки не названа:\n%s", body) } } // Одиночный отказ пачку не рвёт: счётчик подряд идущих отказов сбрасывается на // каждом успехе, иначе случайная сетевая ошибка обрывала бы всю уборку. func TestBulkDeleteSingleFailureDoesNotStop(t *testing.T) { rv, calls := newBulkReviewer(map[string]error{ bid2: errors.New("qbittorrent: temporary"), }) h := testRouterAction(t, stubReader{byID: byID( dl(bid1, store.StateDone, "Дюна"), dl(bid2, store.StateDone, "Фарго"), dl(bid3, store.StateDone, "Оппенгеймер"), dl(bid4, store.StateDone, "Интерстеллар"), )}, rv, stubCommander{}, stubLive{}) form := idForm(bid1, bid2, bid3, bid4) form.Set("confirm", "1") body := post(t, h, "/ui/delete", form, false).Body.String() if len(*calls) != 4 { t.Fatalf("одиночный отказ не должен останавливать проход: %v", *calls) } if strings.Contains(body, "Не выполнено") { t.Errorf("остановки быть не должно:\n%s", body) } } // Отмена запроса не прекращает необратимую операцию: закрытая вкладка не вправе // оборвать пачку на середине, в том числе внутри одной загрузки. func TestBulkDeleteSurvivesRequestCancel(t *testing.T) { calls := &[]string{} // Reviewer, который проверяет, что контекст исполнения жив, хотя контекст // запроса уже отменён. rv := bulkReviewer{calls: calls, errs: map[string]error{}} h := testRouterAction(t, stubReader{byID: byID( dl(bid1, store.StateDone, "Дюна"), dl(bid2, store.StateDone, "Фарго"), )}, rv, stubCommander{}, stubLive{}) form := idForm(bid1, bid2) form.Set("confirm", "1") rr := postCancelled(t, h, "/ui/delete", form) if len(*calls) != 2 { t.Fatalf("пачка должна дойти до конца при обрыве: %v", *calls) } if !strings.Contains(rr.Body.String(), "Удалено — 2") { t.Errorf("исход не собран:\n%s", rr.Body.String()) } } // Формы страниц удаления работают без JavaScript: обычный POST с рабочим action // и никаких hx-атрибутов на пути к необратимому действию. func TestBulkDeleteWorksWithoutJS(t *testing.T) { rv, _ := newBulkReviewer(nil) h := testRouterAction(t, stubReader{ deletable: []store.Download{dl(bid1, store.StateDone, "Дюна")}, byID: byID(dl(bid1, store.StateDone, "Дюна")), }, rv, stubCommander{}, stubLive{}) sel := get(t, h, "/delete").Body.String() if !strings.Contains(sel, `
`) { t.Errorf("страница выбора должна нести обычную POST-форму:\n%s", sel) } conf := post(t, h, "/ui/delete/confirm", idForm(bid1), false).Body.String() if !strings.Contains(conf, `action="/ui/delete"`) || !strings.Contains(conf, `name="confirm" value="1"`) { t.Errorf("подтверждение должно нести форму исполнения:\n%s", conf) } for _, page := range []string{sel, conf} { if strings.Contains(page, "hx-post") || strings.Contains(page, "hx-get") { t.Error("страницы группового удаления не должны зависеть от htmx") } } } // Р1: исполняется ровно подтверждённое множество — список идентификаторов, а не // предикат «всё, что сейчас разрешено». Задача, ставшая разрешённой уже после // показа подтверждения, в пачку не попадает. func TestBulkDeleteRunsOnlyConfirmedSet(t *testing.T) { rv, calls := newBulkReviewer(nil) // Читатель отдаёт две разрешённые, но подтверждена одна. h := testRouterAction(t, stubReader{ deletable: []store.Download{ dl(bid1, store.StateDone, "Дюна"), dl(bid2, store.StateDone, "Фарго"), }, byID: byID( dl(bid1, store.StateDone, "Дюна"), dl(bid2, store.StateDone, "Фарго"), ), }, rv, stubCommander{}, stubLive{}) form := idForm(bid1) form.Set("confirm", "1") post(t, h, "/ui/delete", form, false) if len(*calls) != 1 || (*calls)[0] != bid1 { t.Errorf("удалено не подтверждённое множество: %v", *calls) } } // Ссылка на страницу группового удаления есть в шапке любой страницы веб-UI. // Без этой проверки удаление пункта из навигации закрыло бы единственный вход // на страницу молча: сама страница жива, тесты зелёные, попасть некуда. func TestHeaderLinksToBulkDelete(t *testing.T) { dl := store.Download{ID: bid1, State: store.StateReview, DisplayName: "Дюна"} rv, _ := newBulkReviewer(nil) h := testRouterAction(t, stubReader{list: []store.Download{dl}, one: &dl}, rv, stubCommander{}, stubLive{}) for _, path := range []string{"/", "/delete"} { body := get(t, h, path).Body.String() if !strings.Contains(body, `href="/delete"`) { t.Errorf("%s: в шапке нет ссылки на групповое удаление:\n%s", path, body) } } } // Отказ хранилища не выдаётся за «записи нет»: строка говорит, что состояние // прочитать не удалось, и отметка о последней копии не теряется молча. Иначе // человек подтверждает пачку, где строка обещала «удалять нечего», а загрузка // сносится с файлами по-настоящему. func TestBulkConfirmDistinguishesReadFailure(t *testing.T) { rv, _ := newBulkReviewer(nil) h := testRouterAction(t, stubReader{ getErr: errors.New("database is locked"), }, rv, stubCommander{}, stubLive{}) body := post(t, h, "/ui/delete/confirm", idForm(bid1), false).Body.String() if strings.Contains(body, "записи нет") || strings.Contains(body, "удалять нечего") { t.Errorf("отказ чтения выдан за отсутствие записи:\n%s", body) } if !strings.Contains(body, "состояние прочитать не удалось") { t.Errorf("отказ чтения не назван строкой:\n%s", body) } } // Потолок времени останавливает проход: пока идёт пачка, общий замок ядра // удерживается на каждом обращении к qBittorrent, и медленный (но рабочий) // сосед иначе остановил бы фон целиком, не дав ни одного отказа. func TestBulkDeleteStopsOnTimeBudget(t *testing.T) { calls := &[]string{} // Каждое удаление «идёт» дольше всего бюджета — второй единице стартовать // уже нельзя. // Бюджет укорочен на время теста — иначе проверка стоила бы двух минут. orig := bulkBudget bulkBudget = time.Millisecond t.Cleanup(func() { bulkBudget = orig }) slow := bulkReviewer{calls: calls, sleep: 3 * time.Millisecond} h := testRouterAction(t, stubReader{byID: byID( dl(bid1, store.StateDone, "Дюна"), dl(bid2, store.StateDone, "Фарго"), dl(bid3, store.StateDone, "Оппенгеймер"), )}, slow, stubCommander{}, stubLive{}) form := idForm(bid1, bid2, bid3) form.Set("confirm", "1") body := post(t, h, "/ui/delete", form, false).Body.String() if len(*calls) != 1 { t.Fatalf("после исчерпания бюджета новых удалений быть не должно: %v", *calls) } if !strings.Contains(body, "Не выполнено — 2") { t.Errorf("остаток не назван невыполненным:\n%s", body) } if !strings.Contains(body, "дольше отведённого времени") { t.Errorf("причина остановки не названа:\n%s", body) } } // Форматирующие символы юникода в имени раздачи не доезжают до экрана: с ними // заголовок читается не так, как хранится, а поимённое чтение заголовков — и // есть предохранитель необратимого группового удаления. func TestBulkTitleStripsFormattingRunes(t *testing.T) { const rtl = "\u202e" // RIGHT-TO-LEFT OVERRIDE d := dl(bid1, store.StateDone, "Дюна"+rtl+"vkm.iso \U0001F468\u200D\U0001F469\u200D\U0001F467") rv, _ := newBulkReviewer(nil) h := testRouterAction(t, stubReader{ deletable: []store.Download{d}, byID: byID(d), }, rv, stubCommander{}, stubLive{}) pages := map[string]string{ "/delete": get(t, h, "/delete").Body.String(), "/ui/delete/confirm": post(t, h, "/ui/delete/confirm", idForm(bid1), false).Body.String(), } for path, body := range pages { if strings.Contains(body, rtl) { t.Errorf("%s: bidi-символ уехал в разметку дословно", path) } if !strings.Contains(body, "Дюна") { t.Errorf("%s: заголовок потерялся целиком:\n%s", path, body) } // Составные эмодзи не рассыпаются: снимается класс bidi, а не весь Cf. if !strings.Contains(body, "\U0001F468\u200D\U0001F469\u200D\U0001F467") { t.Errorf("%s: соединитель составного эмодзи снят вместе с bidi:\n%s", path, body) } } }