Files
jellybit/internal/httpapi/bulkdelete_test.go
T
av 288be8ec34 web-ui: добавлена страница группового удаления загрузок
- выбор → поимённое подтверждение → отчёт: пачка до 20 загрузок, гарды входа
  на обеих границах, потолок времени и остановка после трёх подряд отказов
  внешнего сервиса
- допуск полного удаления сведён в единую точку store.State.CanDelete() —
  worker, страница загрузки и Telegram больше не держат своих перечней
2026-08-10 17:43:36 +03:00

612 lines
27 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"
"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, `<form method="post" action="/ui/delete/confirm">`) {
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)
}
}
}