web-ui: добавлена страница группового удаления загрузок

- выбор → поимённое подтверждение → отчёт: пачка до 20 загрузок, гарды входа
  на обеих границах, потолок времени и остановка после трёх подряд отказов
  внешнего сервиса
- допуск полного удаления сведён в единую точку store.State.CanDelete() —
  worker, страница загрузки и Telegram больше не держат своих перечней
This commit is contained in:
av
2026-08-10 17:43:36 +03:00
parent a7c1efd8eb
commit 288be8ec34
35 changed files with 3185 additions and 31 deletions
+289
View File
@@ -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 + "."
}
+611
View File
@@ -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)
}
}
}
+1 -2
View File
@@ -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,
}
+41 -3
View File
@@ -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, "внутренняя ошибка"
}
+3
View File
@@ -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()
+17 -1
View File
@@ -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
+17
View File
@@ -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
+19
View File
@@ -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
View File
@@ -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)
+36
View File
@@ -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)
}
}
}
+17 -7
View File
@@ -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): ссылка в веб (опц.) + «Закрыть» (стоп-кран, лишь
// статус) + «Удалить» (снос раздачи+файлов). Обе команды двухшаговые — кнопка
+43
View File
@@ -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)
}
}
}
+1 -3
View File
@@ -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())