web-ui: добавлена страница группового удаления загрузок
- выбор → поимённое подтверждение → отчёт: пачка до 20 загрузок, гарды входа на обеих границах, потолок времени и остановка после трёх подряд отказов внешнего сервиса - допуск полного удаления сведён в единую точку store.State.CanDelete() — worker, страница загрузки и Telegram больше не держат своих перечней
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||
)
|
||||
|
||||
// maxBulkDelete — верхний предел числа загрузок в одной пачке. Подтверждение,
|
||||
// перечисляющее больше, человек не читает — то есть перестаёт быть
|
||||
// подтверждением; плюс один синхронный запрос упирается в столько же
|
||||
// последовательных вызовов qBittorrent. Число названо на самой странице выбора:
|
||||
// предел, о котором узнают только из отказа, отнимает уже сделанную работу.
|
||||
const maxBulkDelete = 20
|
||||
|
||||
// bulkFailThreshold — сколько подряд идущих отказов внешнего сервиса
|
||||
// прекращают проход. Удаление снимает библиотечные ссылки раньше, чем сносит
|
||||
// раздачу: при лежащем qBittorrent каждая единица успевает выполнить
|
||||
// необратимый локальный шаг и упасть на внешнем, оставив тайтл без раскладки и
|
||||
// не освободив места. Счётчик сбрасывается на успехе — одиночная сетевая
|
||||
// ошибка пачку не рвёт.
|
||||
const bulkFailThreshold = 3
|
||||
|
||||
// bulkBudget — потолок времени на один проход пачки. Удаление держит общий
|
||||
// замок воркера на всё время обращения к qBittorrent, поэтому медленно, но
|
||||
// успешно отвечающий сосед останавливает фоновую работу целиком, а порог
|
||||
// отказов такого не ловит — он считает только ошибки. Проверяется МЕЖДУ
|
||||
// единицами, а не отменой контекста: начатое удаление обрывать нельзя, иначе
|
||||
// оно встанет между снятием библиотечных ссылок и сносом раздачи.
|
||||
//
|
||||
// Переменная, а не константа, ровно по одной причине: тест укорачивает её —
|
||||
// иначе проверка потолка стоила бы двух минут прогона.
|
||||
var bulkBudget = 2 * time.Minute
|
||||
|
||||
// Отказы разбора пачки. Текст — публичного канала: он показывается человеку
|
||||
// как есть, как у прочих sentinel'ов транспорта. Трансляция в статус и
|
||||
// сообщение живёт в единой точке `classifyErr`, а не рядом.
|
||||
var (
|
||||
errBatchEmpty = errors.New("ни одна загрузка не выбрана")
|
||||
errBatchTooLarge = errors.New("за один раз можно удалить не больше " +
|
||||
strconv.Itoa(maxBulkDelete) + " загрузок")
|
||||
errBatchBadID = errors.New("некорректный идентификатор загрузки — запрос отклонён целиком")
|
||||
errBatchForm = errors.New("форма запроса не разобрана — запрос отклонён целиком")
|
||||
)
|
||||
|
||||
// bulkRow — строка загрузки на любом из трёх экранов группового удаления.
|
||||
type bulkRow struct {
|
||||
ID string
|
||||
Title string
|
||||
State string
|
||||
Selected bool // отметка сохранена при возврате отказа
|
||||
LastCopy bool // orphaned: библиотечная ссылка осталась последней копией
|
||||
Missing bool // записи в хранилище нет
|
||||
Unread bool // состояние прочитать не удалось (отказ хранилища)
|
||||
Reason string // причина отказа (только на экране результата)
|
||||
}
|
||||
|
||||
// bulkSelectView — страница выбора (`GET /delete`) и она же ответ на отказ
|
||||
// разбора: отметки при этом сохраняются, иначе проверка стирает всю работу.
|
||||
type bulkSelectView struct {
|
||||
Error string
|
||||
Max int
|
||||
Rows []bulkRow
|
||||
}
|
||||
|
||||
// bulkConfirmView — страница подтверждения: выбранные названы поимённо.
|
||||
type bulkConfirmView struct {
|
||||
Rows []bulkRow
|
||||
}
|
||||
|
||||
// bulkResultView — отчёт: обе половины исхода поимённо плюс остаток, если
|
||||
// проход остановлен системным отказом.
|
||||
type bulkResultView struct {
|
||||
Deleted []bulkRow
|
||||
Failed []bulkRow
|
||||
Skipped []bulkRow
|
||||
StopReason string
|
||||
}
|
||||
|
||||
// handleBulkDeletePage — страница выбора. Самообновления не несёт сознательно:
|
||||
// своп разметки унёс бы отметки, и человек подтвердил бы необратимое удаление
|
||||
// по выбору, которого уже не видит (см. openspec/specs/web-ui).
|
||||
func (s *server) handleBulkDeletePage(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderBulkSelect(w, r, "", nil)
|
||||
}
|
||||
|
||||
// renderBulkSelect отрисовывает страницу выбора, помечая отмеченными те строки,
|
||||
// чьи идентификаторы человек уже выбрал (selected). Общий путь для чистого
|
||||
// открытия страницы и для любого отказа разбора.
|
||||
func (s *server) renderBulkSelect(w http.ResponseWriter, r *http.Request, msg string, selected []string) {
|
||||
ds, err := s.deps.Reader.ListDeletableDownloads(r.Context())
|
||||
if err != nil {
|
||||
s.deps.Logger.Error("list deletable downloads", "error", err)
|
||||
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
mark := make(map[string]bool, len(selected))
|
||||
for _, id := range selected {
|
||||
mark[id] = true
|
||||
}
|
||||
view := bulkSelectView{Error: msg, Max: maxBulkDelete}
|
||||
for _, d := range ds {
|
||||
view.Rows = append(view.Rows, bulkRow{
|
||||
ID: d.ID,
|
||||
Title: downloadTitle(d),
|
||||
State: string(d.State),
|
||||
Selected: mark[d.ID],
|
||||
LastCopy: d.State == store.StateOrphaned,
|
||||
})
|
||||
}
|
||||
s.render(w, "delete.html", view)
|
||||
}
|
||||
|
||||
// handleBulkDeleteConfirm — экран подтверждения. Ничего не меняет: разбирает
|
||||
// вход, читает выбранные загрузки и называет каждую поимённо.
|
||||
func (s *server) handleBulkDeleteConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
ids, err := s.parseBulkBatch(r)
|
||||
if err != nil {
|
||||
s.renderBulkSelect(w, r, bulkErrMsg(err), ids)
|
||||
return
|
||||
}
|
||||
s.render(w, "delete_confirm.html", bulkConfirmView{Rows: s.bulkRows(r.Context(), ids)})
|
||||
}
|
||||
|
||||
// handleBulkDelete — исполнение пачки. Признак подтверждения проверяется ДО
|
||||
// разбора и до единого вызова удаления: подтверждение — условие операции, а не
|
||||
// украшение экрана.
|
||||
func (s *server) handleBulkDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil || r.PostForm.Get("confirm") != "1" {
|
||||
s.renderBulkSelect(w, r, "Удаление уходит только со страницы подтверждения.", nil)
|
||||
return
|
||||
}
|
||||
// Исполняющий запрос — самостоятельная входная граница: идентификаторы
|
||||
// приходят формой заново, состояния между шагами сервис не хранит.
|
||||
ids, err := s.parseBulkBatch(r)
|
||||
if err != nil {
|
||||
s.renderBulkSelect(w, r, bulkErrMsg(err), ids)
|
||||
return
|
||||
}
|
||||
|
||||
// Контекст исполнения отвязан от запроса: обрыв связи не вправе оборвать
|
||||
// необратимую операцию на середине — в том числе внутри одной загрузки,
|
||||
// между снятием библиотечных ссылок и сносом раздачи. Строки отчёта читаются
|
||||
// тем же контекстом: собранные отменённым, они превратили бы весь отчёт в
|
||||
// «загрузка не найдена» ровно там, где удаление идёт штатно.
|
||||
ctx := context.WithoutCancel(r.Context())
|
||||
rows := s.bulkRows(ctx, ids)
|
||||
|
||||
var res bulkResultView
|
||||
streak := 0
|
||||
deadline := store.Now().Add(bulkBudget)
|
||||
for i, row := range rows {
|
||||
if res.StopReason != "" {
|
||||
res.Skipped = append(res.Skipped, rows[i])
|
||||
continue
|
||||
}
|
||||
if store.Now().After(deadline) {
|
||||
res.StopReason = "Проход занял дольше отведённого времени и остановлен: " +
|
||||
"пока идёт пачка, остальная работа сервиса ждёт."
|
||||
res.Skipped = append(res.Skipped, rows[i])
|
||||
continue
|
||||
}
|
||||
err := s.deps.Reviewer.Delete(ctx, row.ID)
|
||||
if err == nil {
|
||||
streak = 0
|
||||
res.Deleted = append(res.Deleted, row)
|
||||
continue
|
||||
}
|
||||
row.Reason = userErr(r, err, row.ID)
|
||||
res.Failed = append(res.Failed, row)
|
||||
// Конфликт состояния и отсутствие записи — про саму задачу, а не про
|
||||
// доступность соседа: счётчик системных отказов они не двигают.
|
||||
if errors.Is(err, worker.ErrConflict) || errors.Is(err, store.ErrNotFound) {
|
||||
continue
|
||||
}
|
||||
streak++
|
||||
if streak >= bulkFailThreshold {
|
||||
res.StopReason = "Внешний сервис отказывает подряд — проход остановлен, " +
|
||||
"чтобы не снимать раскладку у остальных без освобождения места."
|
||||
}
|
||||
}
|
||||
// Исход каждой единицы поимённо: ответ мог не дойти (вкладку закрыли), и
|
||||
// журнал — единственное, по чему потом видно, что снесено, что отказало и до
|
||||
// чего проход не дошёл. На воркер полагаться нельзя: отказы по конфликту и
|
||||
// отсутствию записи он пишет на DEBUG.
|
||||
for _, row := range res.Deleted {
|
||||
s.deps.Logger.Info("bulk delete item", "download_id", row.ID, "outcome", "deleted")
|
||||
}
|
||||
for _, row := range res.Failed {
|
||||
s.deps.Logger.Info("bulk delete item", "download_id", row.ID, "outcome", "failed",
|
||||
"reason", row.Reason)
|
||||
}
|
||||
for _, row := range res.Skipped {
|
||||
s.deps.Logger.Info("bulk delete item", "download_id", row.ID, "outcome", "skipped")
|
||||
}
|
||||
s.deps.Logger.Info("bulk delete finished",
|
||||
"requested", len(rows), "deleted", len(res.Deleted),
|
||||
"failed", len(res.Failed), "skipped", len(res.Skipped),
|
||||
"stopped", res.StopReason != "")
|
||||
|
||||
s.render(w, "delete_result.html", res)
|
||||
}
|
||||
|
||||
// bulkRows читает выбранные загрузки для показа поимённо. Идентификатор без
|
||||
// записи в хранилище не выбрасывается молча — он идёт своей строкой: человек
|
||||
// подтверждает пачку, и она обязана совпадать с тем, что он выбрал.
|
||||
func (s *server) bulkRows(ctx context.Context, ids []string) []bulkRow {
|
||||
rows := make([]bulkRow, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
d, err := s.deps.Reader.GetDownload(ctx, id)
|
||||
switch {
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
rows = append(rows, bulkRow{ID: id, Missing: true, Title: "загрузка не найдена"})
|
||||
continue
|
||||
case err != nil || d == nil:
|
||||
// Отказ хранилища — это НЕ «записи нет». Выдав одно за другое, экран
|
||||
// сказал бы «удалять нечего» о загрузке, которую пачка снесёт
|
||||
// по-настоящему, и для orphaned унёс бы отметку последней копии —
|
||||
// единственный оставшийся предохранитель. Приватный канал: пишем
|
||||
// здесь, потому что выше эта ошибка не всплывает.
|
||||
s.deps.Logger.Error("bulk delete: read download", "download_id", id, "error", err)
|
||||
rows = append(rows, bulkRow{ID: id, Unread: true, Title: "состояние прочитать не удалось"})
|
||||
continue
|
||||
}
|
||||
rows = append(rows, bulkRow{
|
||||
ID: d.ID,
|
||||
Title: downloadTitle(*d),
|
||||
State: string(d.State),
|
||||
LastCopy: d.State == store.StateOrphaned,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// parseBulkBatch разбирает пачку идентификаторов с формы. Проверки одинаковы на
|
||||
// обеих границах — подтверждения и исполнения. Возвращает разобранные
|
||||
// идентификаторы даже вместе с отказом: страница выбора возвращает по ним
|
||||
// отметки, чтобы отказ не стирал проделанную работу.
|
||||
func (s *server) parseBulkBatch(r *http.Request) ([]string, error) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
// Приватный канал: выше эта ошибка не всплывает, а человеку про
|
||||
// идентификаторы говорить нечего — тело не прочиталось целиком.
|
||||
s.deps.Logger.Error("bulk delete: parse form", "error", err)
|
||||
return nil, errBatchForm
|
||||
}
|
||||
// Только тело: признак подтверждения и пачка приходят формой, и принимать
|
||||
// их из строки запроса значит принимать подтверждение оттуда, откуда
|
||||
// требование его не заказывало.
|
||||
raw := r.PostForm["id"]
|
||||
seen := make(map[string]bool, len(raw))
|
||||
ids := make([]string, 0, len(raw))
|
||||
for _, v := range raw {
|
||||
id, err := ident.Parse(v)
|
||||
if err != nil {
|
||||
// Молча пропустить нельзя: человек подтвердил удаление поимённо, и
|
||||
// выброшенный идентификатор развёл бы подтверждённое с исполненным.
|
||||
return ids, errBatchBadID
|
||||
}
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, errBatchEmpty
|
||||
}
|
||||
if len(ids) > maxBulkDelete {
|
||||
return ids, errBatchTooLarge
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// bulkErrMsg — сообщение человеку об отказе разбора. Сам текст берётся из
|
||||
// единой точки трансляции (`classifyErr`); здесь добавляется только подсказка,
|
||||
// что делать дальше, — она осмысленна ровно на этой странице.
|
||||
func bulkErrMsg(err error) string {
|
||||
_, msg := classifyErr(err)
|
||||
if errors.Is(err, errBatchTooLarge) {
|
||||
return msg + ". Отметки сохранены — сними лишние."
|
||||
}
|
||||
return msg + "."
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,8 +137,7 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet
|
||||
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled ||
|
||||
d.State == store.StateTargetMissing,
|
||||
Retriable: d.State == store.StateFailed || d.State == store.StateStuck,
|
||||
Deletable: d.State == store.StateDone || d.State == store.StateOrphaned ||
|
||||
d.State == store.StateTargetMissing,
|
||||
Deletable: d.State.CanDelete(),
|
||||
Dismissable: d.State.IsTerminal() &&
|
||||
d.State != store.StateDeleted && d.State != store.StateCancelled,
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -52,6 +53,9 @@ type Reader interface {
|
||||
// LayoutSizeByDownload — суммарный размер разложенных файлов по каждой из
|
||||
// загрузок (фолбэк размера раздачи в карточке, когда торрента нет в снимке).
|
||||
LayoutSizeByDownload(ctx context.Context, ids []string) (map[string]int64, error)
|
||||
// ListDeletableDownloads — загрузки, разрешённые к полному удалению, без
|
||||
// постраничной выдачи (страница группового удаления показывает их разом).
|
||||
ListDeletableDownloads(ctx context.Context) ([]store.Download, error)
|
||||
}
|
||||
|
||||
// Deps — зависимости транспорта.
|
||||
@@ -113,6 +117,9 @@ func NewRouter(d Deps) (http.Handler, error) {
|
||||
// Веб-UI.
|
||||
r.Get("/", s.handleIndex)
|
||||
r.Get("/download/{id}", s.handleDownload)
|
||||
// Групповое удаление: выбор → подтверждение → исполнение. Отдельная
|
||||
// страница, потому что живая перерисовка списка стёрла бы выбор человека.
|
||||
r.Get("/delete", s.handleBulkDeletePage)
|
||||
|
||||
// Партиалы телеметрии без потребителя в новой разметке: оставлены гасителями
|
||||
// вкладок, отрисованных прошлой версией (см. handleFragProgress).
|
||||
@@ -124,6 +131,8 @@ func NewRouter(d Deps) (http.Handler, error) {
|
||||
// Тело ревью для поллинга recognizing (htmx-своп до готового плана).
|
||||
r.Get("/fragments/downloads/{id}/review", s.handleFragReview)
|
||||
r.Post("/ui/downloads", s.handleUIAdd)
|
||||
r.Post("/ui/delete/confirm", s.handleBulkDeleteConfirm)
|
||||
r.Post("/ui/delete", s.handleBulkDelete)
|
||||
r.Post("/ui/downloads/{id}/cancel", s.handleUICancel)
|
||||
r.Post("/ui/downloads/{id}/retry", s.handleUIRetry)
|
||||
|
||||
@@ -692,12 +701,36 @@ func (s *server) toView(d store.Download, now time.Time) downloadView {
|
||||
// несколько строк заголовка.
|
||||
func downloadTitle(d store.Download) string {
|
||||
if d.DisplayName != "" {
|
||||
return d.DisplayName
|
||||
return displaySafe(d.DisplayName)
|
||||
}
|
||||
if d.RecTitle.Valid && d.RecTitle.String != "" {
|
||||
return d.RecTitle.String
|
||||
return displaySafe(d.RecTitle.String)
|
||||
}
|
||||
return shorten(oneLine(d.SourceRef), 80)
|
||||
return displaySafe(shorten(oneLine(d.SourceRef), 80))
|
||||
}
|
||||
|
||||
// displaySafe готовит заголовок к показу: снимает управляющие символы
|
||||
// направления письма (`unicode.Bidi_Control` — с ними строка читается не в том
|
||||
// порядке, в каком хранится) и заменяет управляющие пробелом (перевод строки в
|
||||
// заголовке склеил бы слова). Имя раздачи — недоверенный вход, а
|
||||
// `html/template` экранирует разметку, но эти символы пропускает. Цена высока
|
||||
// на экране подтверждения группового удаления: там поимённое чтение заголовков
|
||||
// и есть предохранитель необратимой операции.
|
||||
//
|
||||
// Снимается ровно этот класс, не весь `unicode.Cf`: в `Cf` лежат и ZWJ/ZWNJ,
|
||||
// без которых рассыпаются составные эмодзи и меняется написание персидских и
|
||||
// индийских имён. Чистим на показе, а не на записи — хранение дословное, и
|
||||
// поиск по списку идёт по сохранённому имени.
|
||||
func displaySafe(s string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if unicode.Is(unicode.Bidi_Control, r) {
|
||||
return -1
|
||||
}
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return ' '
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
}
|
||||
|
||||
// oneLine схлопывает переводы строк и лишние пробелы — сырой источник в
|
||||
@@ -814,6 +847,11 @@ func classifyErr(err error) (int, string) {
|
||||
return http.StatusBadRequest, errManualSource.Error()
|
||||
case errors.Is(err, errInvalidCandidate):
|
||||
return http.StatusBadRequest, errInvalidCandidate.Error()
|
||||
case errors.Is(err, errBatchEmpty), errors.Is(err, errBatchTooLarge),
|
||||
errors.Is(err, errBatchBadID):
|
||||
// Отказы разбора пачки группового удаления — промах ввода, не сбой:
|
||||
// текст sentinel'а показывается человеку как есть.
|
||||
return http.StatusBadRequest, err.Error()
|
||||
default:
|
||||
return http.StatusInternalServerError, "внутренняя ошибка"
|
||||
}
|
||||
|
||||
@@ -94,6 +94,9 @@ func (f *fakeReader) GetDownload(_ context.Context, id string) (*store.Download,
|
||||
func (f *fakeReader) LayoutSizeByDownload(_ context.Context, _ []string) (map[string]int64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeReader) ListDeletableDownloads(_ context.Context) ([]store.Download, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func newServer(t *testing.T, d httpapi.Deps) *httptest.Server {
|
||||
t.Helper()
|
||||
|
||||
@@ -20,21 +20,37 @@ type stubReader struct {
|
||||
sizes map[string]int64 // размеры разложенных файлов по download_id (фолбэк)
|
||||
getErr error // отказ чтения задачи (не ErrNotFound)
|
||||
sizesErr error // отказ чтения размеров раскладки
|
||||
|
||||
// Для страницы группового удаления: список разрешённых к удалению и
|
||||
// поштучное чтение по id (страница подтверждения называет каждую поимённо).
|
||||
deletable []store.Download
|
||||
deletableErr error
|
||||
byID map[string]store.Download
|
||||
}
|
||||
|
||||
func (s stubReader) ListDownloads(context.Context) ([]store.Download, error) { return s.list, nil }
|
||||
func (s stubReader) ListDownloadsPage(context.Context, store.ListFilter) ([]store.Download, int, error) {
|
||||
return s.list, len(s.list), nil
|
||||
}
|
||||
func (s stubReader) GetDownload(context.Context, string) (*store.Download, error) {
|
||||
func (s stubReader) GetDownload(_ context.Context, id string) (*store.Download, error) {
|
||||
if s.getErr != nil {
|
||||
return nil, s.getErr
|
||||
}
|
||||
if s.byID != nil {
|
||||
d, ok := s.byID[id]
|
||||
if !ok {
|
||||
return nil, store.ErrNotFound
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
if s.one == nil {
|
||||
return nil, store.ErrNotFound
|
||||
}
|
||||
return s.one, nil
|
||||
}
|
||||
func (s stubReader) ListDeletableDownloads(context.Context) ([]store.Download, error) {
|
||||
return s.deletable, s.deletableErr
|
||||
}
|
||||
func (s stubReader) LayoutSizeByDownload(context.Context, []string) (map[string]int64, error) {
|
||||
if s.sizesErr != nil {
|
||||
return nil, s.sizesErr
|
||||
|
||||
@@ -84,6 +84,23 @@ func (s State) IsObservable() bool {
|
||||
return !s.IsTerminal() || slices.Contains(selfHealingStates, s)
|
||||
}
|
||||
|
||||
// deletableStates — состояния, из которых человеку доступно полное удаление
|
||||
// (снять библиотечные ссылки + снести раздачу с файлами из qBittorrent).
|
||||
// Единственный дом перечня: на него смотрят и допуск в ядре (worker.Delete), и
|
||||
// все транспорты, решающие, показывать действие или нет.
|
||||
var deletableStates = []State{
|
||||
StateDone, StateOrphaned, StateTargetMissing,
|
||||
}
|
||||
|
||||
// CanDelete сообщает, допускает ли состояние полное удаление загрузки
|
||||
// пользователем. Транспорт спрашивает его, чтобы не завести своего перечня
|
||||
// состояний; проверку в ядре это не заменяет — допуск обязан держаться без
|
||||
// транспорта (см. openspec/specs/state-reconciliation, «Полное удаление
|
||||
// загрузки пользователем»).
|
||||
func (s State) CanDelete() bool {
|
||||
return slices.Contains(deletableStates, s)
|
||||
}
|
||||
|
||||
// allowedTransitions — декларативный граф легальных переходов машины состояний
|
||||
// (from → множество допустимых to). Единственный источник истины о легальности
|
||||
// рёбер: покрывает все переходы, которые worker выполняет по всем capability
|
||||
|
||||
@@ -683,3 +683,22 @@ func TestIsObservable(t *testing.T) {
|
||||
t.Error("failed должно быть терминальным и при этом наблюдаемым")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCanDelete: допуск полного удаления — единый дом перечня состояний, на
|
||||
// который смотрят и ядро, и все транспорты. Перебираем ВСЕ состояния: новое
|
||||
// состояние, попавшее в перечень молча, тест покажет.
|
||||
func TestCanDelete(t *testing.T) {
|
||||
deletable := []State{StateDone, StateOrphaned, StateTargetMissing}
|
||||
|
||||
for _, s := range allStates {
|
||||
want := slices.Contains(deletable, s)
|
||||
if got := s.CanDelete(); got != want {
|
||||
t.Errorf("%s: CanDelete=%v, want %v", s, got, want)
|
||||
}
|
||||
}
|
||||
// deleted терминально и удалению не подлежит — иначе повторная отправка
|
||||
// формы группового удаления сносила бы уже снесённое второй раз.
|
||||
if StateDeleted.CanDelete() {
|
||||
t.Error("deleted не должно допускать удаление")
|
||||
}
|
||||
}
|
||||
|
||||
+36
-4
@@ -89,6 +89,41 @@ func listWhere(f ListFilter) (string, []any) {
|
||||
return " WHERE " + strings.Join(conds, " AND "), args
|
||||
}
|
||||
|
||||
// listSelect и listOrder — общая форма выборки списка загрузок: LEFT JOIN
|
||||
// текущей попытки распознавания (фолбэк заголовка и значок типа) и порядок по
|
||||
// времени добавления с tie-break по id. Держим одним куском, чтобы страница
|
||||
// списка и страница группового удаления не разъехались порядком.
|
||||
const listSelect = `SELECT download.*, r.title AS rec_title, r.media_type AS rec_media_type
|
||||
FROM download
|
||||
LEFT JOIN recognition r ON r.download_id = download.id AND r.is_current = 1`
|
||||
|
||||
const listOrder = `
|
||||
ORDER BY COALESCE(download.source_added_at, download.created_at) DESC, download.id DESC`
|
||||
|
||||
// ListDeletableDownloads возвращает все загрузки, для которых домен допускает
|
||||
// полное удаление, без постраничной выдачи: страница группового удаления
|
||||
// показывает их разом, а разбиение по страницам лишило бы возможности выбрать
|
||||
// пачку. Перечень состояний берётся из единой точки домена — своего списка
|
||||
// запрос не держит.
|
||||
func (s *Store) ListDeletableDownloads(ctx context.Context) ([]Download, error) {
|
||||
ph := make([]string, len(deletableStates))
|
||||
args := make([]any, len(deletableStates))
|
||||
for i, st := range deletableStates {
|
||||
ph[i] = "?"
|
||||
args[i] = string(st)
|
||||
}
|
||||
q := listSelect + " WHERE download.state IN (" + strings.Join(ph, ",") + ")" + listOrder
|
||||
|
||||
var out []Download
|
||||
if err := s.DB.SelectContext(ctx, &out, q, args...); err != nil {
|
||||
return nil, fmt.Errorf("list deletable downloads: %w", err)
|
||||
}
|
||||
if err := s.attachInfohashes(ctx, out); err != nil {
|
||||
return nil, fmt.Errorf("list deletable downloads: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListDownloadsPage возвращает страницу загрузок под фильтром и общее число
|
||||
// строк, удовлетворяющих фильтру (для пагинации). Сортировка — по времени
|
||||
// добавления в источник (source_added_at) с фолбеком на created_at и tie-break
|
||||
@@ -104,10 +139,7 @@ func (s *Store) ListDownloadsPage(ctx context.Context, f ListFilter) ([]Download
|
||||
return nil, 0, fmt.Errorf("list downloads page count: %w", err)
|
||||
}
|
||||
|
||||
q := `SELECT download.*, r.title AS rec_title, r.media_type AS rec_media_type
|
||||
FROM download
|
||||
LEFT JOIN recognition r ON r.download_id = download.id AND r.is_current = 1` + where +
|
||||
` ORDER BY COALESCE(download.source_added_at, download.created_at) DESC, download.id DESC
|
||||
q := listSelect + where + listOrder + `
|
||||
LIMIT ? OFFSET ?`
|
||||
pageArgs := append(append([]any{}, args...), f.Limit, f.Offset)
|
||||
|
||||
|
||||
@@ -333,3 +333,39 @@ func TestCreateDownloadPersistsDisplayName(t *testing.T) {
|
||||
t.Fatalf("display_name = %q", d.DisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
// Выборка для страницы группового удаления отдаёт ровно те состояния, где домен
|
||||
// допускает полное удаление, — и ничего сверх них. Перебираем все состояния:
|
||||
// разъезд запроса с предикатом иначе виден только на проде.
|
||||
func TestListDeletableDownloads(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
all := []State{
|
||||
StateCatched, StateDownloading, StateCompleted, StateRecognizing,
|
||||
StateReview, StateLinking, StateDone, StateDeferred, StateStuck,
|
||||
StateFailed, StateCancelled, StateReverted,
|
||||
StateTargetMissing, StateOrphaned, StateDeleted,
|
||||
}
|
||||
byID := map[string]State{}
|
||||
for i, s := range all {
|
||||
byID[mkDownload(t, st, i+1, s, "задача "+string(s))] = s
|
||||
}
|
||||
|
||||
got, err := st.ListDeletableDownloads(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := map[State]bool{}
|
||||
for _, d := range got {
|
||||
if !d.State.CanDelete() {
|
||||
t.Errorf("в выборке состояние %s, удаление из которого недоступно", d.State)
|
||||
}
|
||||
seen[d.State] = true
|
||||
}
|
||||
for _, s := range all {
|
||||
if s.CanDelete() && !seen[s] {
|
||||
t.Errorf("состояние %s допускает удаление, но в выборку не попало", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,22 +48,20 @@ func (b *Bot) renderCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar
|
||||
case store.StateLinking:
|
||||
return "⏳ Раскладываю #" + idCode(id) + "…", nil
|
||||
case store.StateDone:
|
||||
return b.renderDone(rd), b.deletableKeyboard(id)
|
||||
return b.renderDone(rd), b.stateKeyboard(id, state)
|
||||
default:
|
||||
// state — внутренний enum состояния (не внешний ввод), экранировать не нужно.
|
||||
text := fmt.Sprintf("Задача #%s — %s.", idCode(id), state)
|
||||
if msg := rd.Download.ErrorMsg.String; msg != "" {
|
||||
text += "\n" + esc(msg)
|
||||
}
|
||||
switch state {
|
||||
case store.StateFailed, store.StateStuck:
|
||||
if state == store.StateFailed || state == store.StateStuck {
|
||||
// failed/stuck — даём кнопку повтора.
|
||||
return text, b.retryKeyboard(id)
|
||||
case store.StateOrphaned, store.StateTargetMissing:
|
||||
// Рассинхрон — можно подчистить остатки полным удалением.
|
||||
return text, b.deletableKeyboard(id)
|
||||
}
|
||||
return text, b.webOnly(id)
|
||||
// Рассинхрон (orphaned/target_missing) — можно подчистить остатки полным
|
||||
// удалением; какие именно состояния это допускают, знает домен.
|
||||
return text, b.stateKeyboard(id, state)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,6 +290,18 @@ func (b *Bot) retryKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
|
||||
return &kb
|
||||
}
|
||||
|
||||
// stateKeyboard выбирает клавиатуру по допуску удаления: своего перечня
|
||||
// состояний транспорт не держит, предикат живёт в домене (см.
|
||||
// openspec/specs/state-reconciliation, «Полное удаление загрузки
|
||||
// пользователем»). Допуск в ядре это не заменяет — worker.Delete проверяет его
|
||||
// сам.
|
||||
func (b *Bot) stateKeyboard(id string, state store.State) *tgbotapi.InlineKeyboardMarkup {
|
||||
if state.CanDelete() {
|
||||
return b.deletableKeyboard(id)
|
||||
}
|
||||
return b.webOnly(id)
|
||||
}
|
||||
|
||||
// deletableKeyboard — клавиатура состояний, откуда доступно полное удаление
|
||||
// (done/orphaned/target_missing): ссылка в веб (опц.) + «Закрыть» (стоп-кран, лишь
|
||||
// статус) + «Удалить» (снос раздачи+файлов). Обе команды двухшаговые — кнопка
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
)
|
||||
|
||||
// Кнопка «Удалить» приходит ровно в тех состояниях, где домен допускает полное
|
||||
// удаление. Транспорт своего перечня состояний не держит: разъехавшись с
|
||||
// доменом, он показал бы кнопку, ведущую в конфликт, — или спрятал бы
|
||||
// доступное действие.
|
||||
func TestRenderCard_DeleteButtonFollowsCanDelete(t *testing.T) {
|
||||
b, _, _, _ := newTestBot(t, []int64{7})
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
for _, st := range all {
|
||||
rd := reviewData(st)
|
||||
_, kb := b.renderCard(rd)
|
||||
|
||||
found := false
|
||||
if kb != nil {
|
||||
for _, row := range kb.InlineKeyboard {
|
||||
for _, btn := range row {
|
||||
if btn.CallbackData != nil && strings.HasPrefix(*btn.CallbackData, "delete:") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if want := st.CanDelete(); found != want {
|
||||
t.Errorf("%s: кнопка удаления=%v, CanDelete=%v", st, found, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -628,9 +628,7 @@ func (w *Worker) Delete(ctx context.Context, id string) (err error) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete: %w", err)
|
||||
}
|
||||
switch d.State {
|
||||
case store.StateDone, store.StateOrphaned, store.StateTargetMissing:
|
||||
default:
|
||||
if !d.State.CanDelete() {
|
||||
return fmt.Errorf("delete: download %s is in state %s (expected done/orphaned/target_missing): %w", id, d.State, ErrConflict)
|
||||
}
|
||||
ctx = w.scoped(ctx, capFileLayout, id, d.PrimaryInfohash())
|
||||
|
||||
Reference in New Issue
Block a user