layout: непомещающееся целевое имя уводит задачу в review вместо failed
- предел длины компонента (255 байт) проверяется в BuildLinks до первой операции с ФС: ни каталога, ни ссылки при отказе не создаётся - причина пустого предпросмотра считается на показе (ReviewData.PreviewError) и печатается в панели действий и в карточке Telegram: у задачи без записанной причины взять её больше неоткуда
This commit is contained in:
@@ -352,3 +352,58 @@ func TestRetryListShowsProgress(t *testing.T) {
|
||||
t.Errorf("карточка downloading без прогресс-поллера: %s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestActionBarNamesReasonWithoutPreview: при пустом предпросмотре панель
|
||||
// действий печатает записанную причину, а не общее «Подтверди источник» —
|
||||
// иначе экран советует подтвердить уже подтверждённое и молчит о настоящей
|
||||
// причине (см. spec review, «Панель действий при пустом предпросмотре»).
|
||||
func TestActionBarNamesReasonWithoutPreview(t *testing.T) {
|
||||
t.Run("причина показа старше записанной", func(t *testing.T) {
|
||||
rd := reviewDataWithSource(false)
|
||||
rd.PreviewError = `layout: имя не помещается: "Очень длинное" — 400 байт при пределе 255`
|
||||
rd.Download.ErrorMsg = store.NullString("устаревшая причина прошлого перехода")
|
||||
rv := actionReviewer{stubReviewer: stubReviewer{data: rd}}
|
||||
h := testRouterAction(t, stubReader{one: &rd.Download}, rv, stubCommander{}, stubLive{})
|
||||
|
||||
rr := post(t, h, "/ui/downloads/"+testULID+"/nobase", nil, true)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("nobase (htmx) = %d, want 200", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, "не помещается") {
|
||||
t.Errorf("панель не назвала причину: %s", body)
|
||||
}
|
||||
if strings.Contains(body, "Подтверди источник") {
|
||||
t.Errorf("панель печатает общий текст вместо причины: %s", body)
|
||||
}
|
||||
if strings.Contains(body, "устаревшая") {
|
||||
t.Errorf("записанная причина перебила посчитанную на показе: %s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("записанная причина, когда посчитанной нет", func(t *testing.T) {
|
||||
rd := reviewDataWithSource(false)
|
||||
rd.Download.ErrorMsg = store.NullString("целевой файл уже существует")
|
||||
rv := actionReviewer{stubReviewer: stubReviewer{data: rd}}
|
||||
h := testRouterAction(t, stubReader{one: &rd.Download}, rv, stubCommander{}, stubLive{})
|
||||
|
||||
rr := post(t, h, "/ui/downloads/"+testULID+"/nobase", nil, true)
|
||||
if !strings.Contains(rr.Body.String(), "уже существует") {
|
||||
t.Errorf("панель не назвала записанную причину: %s", rr.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("причины нет → прежний общий текст", func(t *testing.T) {
|
||||
rd := reviewDataWithSource(false)
|
||||
rv := actionReviewer{stubReviewer: stubReviewer{data: rd}}
|
||||
h := testRouterAction(t, stubReader{one: &rd.Download}, rv, stubCommander{}, stubLive{})
|
||||
|
||||
rr := post(t, h, "/ui/downloads/"+testULID+"/nobase", nil, true)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("nobase (htmx) = %d, want 200", rr.Code)
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), "Подтверди источник") {
|
||||
t.Errorf("без причины ожидался общий текст: %s", rr.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -770,7 +770,8 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
// (magnet.ErrNotMagnet), oversized `.torrent` (ingest.ErrTorrentTooLarge) и
|
||||
// некорректный ввод команды (worker.ErrInvalidInput) →
|
||||
// 400; недокачанный источник (worker.ErrNotReady), коллизия цели
|
||||
// (layout.ErrCollision) и конфликт состояния (worker.ErrConflict) → 409; прочее
|
||||
// (layout.ErrCollision), непомещающееся целевое имя (layout.ErrNameTooLong) и
|
||||
// конфликт состояния (worker.ErrConflict) → 409; прочее
|
||||
// → 500. Полная ошибка уже в логах на доменной границе — наружу отдаём только
|
||||
// сообщение + корреляционный ключ.
|
||||
func classifyErr(err error) (int, string) {
|
||||
@@ -793,6 +794,10 @@ func classifyErr(err error) (int, string) {
|
||||
// Целевой путь уже занят: задача штатно ушла в review с причиной —
|
||||
// это не сбой, а требующий разбора конфликт.
|
||||
return http.StatusConflict, "целевой файл уже существует, задача отправлена в ревью"
|
||||
case errors.Is(err, layout.ErrNameTooLong):
|
||||
// Целевое имя не помещается в файловую систему: задача штатно ушла в
|
||||
// review, где название правится подсказкой. Не сбой сервера.
|
||||
return http.StatusConflict, "целевое имя слишком длинное, задача отправлена в ревью"
|
||||
case errors.Is(err, worker.ErrConflict):
|
||||
// Нормальный конфликт состояния (операция недопустима сейчас), не сбой.
|
||||
return http.StatusConflict, "действие недоступно в текущем состоянии"
|
||||
|
||||
@@ -1098,3 +1098,25 @@ func TestRerecognize(t *testing.T) {
|
||||
t.Errorf("rerecognized = %v, want [%s]", rv.rerecognized, tid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPICommandNameTooLong(t *testing.T) {
|
||||
// Непомещающееся целевое имя (layout.ErrNameTooLong) → 409 (штатно ушло в
|
||||
// review), не 500: это конфликт, требующий разбора, а не сбой сервера.
|
||||
// Наружу — нейтральное сообщение, сырой текст ошибки остаётся в логах.
|
||||
cmd := &fakeCommander{err: fmt.Errorf("apply: %w", layout.ErrNameTooLong)}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})
|
||||
|
||||
resp, err := http.Post(srv.URL+"/api/downloads/"+tid+"/cancel", "", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409", resp.StatusCode)
|
||||
}
|
||||
var got map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&got)
|
||||
if msg, _ := got["error"].(string); !strings.Contains(msg, "слишком длинное") {
|
||||
t.Errorf("error = %q, want содержащее «слишком длинное»", msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ type reviewView struct {
|
||||
State string
|
||||
Error string // из ?err=
|
||||
StateError string // error_msg загрузки (напр. причина коллизии)
|
||||
PreviewError string // почему предпросмотр не построился, посчитано на показе
|
||||
MediaType string
|
||||
IsSeries bool
|
||||
Title string
|
||||
@@ -113,7 +114,11 @@ func buildReviewView(id string, rd *worker.ReviewData, errMsg string) reviewView
|
||||
State: string(rd.Download.State),
|
||||
Error: errMsg,
|
||||
StateError: rd.Download.ErrorMsg.String,
|
||||
Hints: rd.Hints,
|
||||
// Причина пустого предпросмотра считается на показе и потому всегда про
|
||||
// текущий план; error_msg остался от последнего перехода и после смены
|
||||
// источника уже не про него.
|
||||
PreviewError: rd.PreviewError,
|
||||
Hints: rd.Hints,
|
||||
}
|
||||
if rec := rd.Recognition; rec != nil {
|
||||
view.MediaType = string(rd.Plan.Type)
|
||||
|
||||
@@ -171,6 +171,11 @@ func (l *Layouter) BuildLinks(p Plan) ([]Link, error) {
|
||||
if !underRoot(root, dst) {
|
||||
return nil, fmt.Errorf("layout: target %q is outside library %q (file %q)", dst, root, f.Src)
|
||||
}
|
||||
// Длина проверяется ПОСЛЕ песочницы: путь, вышедший за библиотеку, —
|
||||
// находка безопасности, и подменять её косметической причиной нельзя.
|
||||
if err := checkComponentLengths(root, dst); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
links = append(links, Link{Src: f.Src, Dst: dst, Kind: kind})
|
||||
}
|
||||
if len(links) == 0 {
|
||||
@@ -267,6 +272,11 @@ type Result struct {
|
||||
// ErrCollision — цель существует и это другой файл (нужен review).
|
||||
var ErrCollision = errors.New("layout: target collision")
|
||||
|
||||
// ErrNameTooLong — компонент целевого пути длиннее предела длины имени
|
||||
// (maxComponentBytes). Проверяется в BuildLinks, до первой операции с ФС:
|
||||
// задача уходит в review с доменной причиной, а не в failed с текстом ядра.
|
||||
var ErrNameTooLong = errors.New("layout: имя не помещается")
|
||||
|
||||
// ErrLastCopy — отказ снять ссылку, которая осталась последней копией данных
|
||||
// (источник недоступен или nlink<=1). Undo снимает лишний хардлинк, а не
|
||||
// единственный файл (см. state-reconciliation, инвариант безопасного Undo).
|
||||
|
||||
@@ -6,6 +6,53 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// maxComponentBytes — предел длины одного компонента целевого пути в БАЙТАХ
|
||||
// UTF-8, а не в символах: ядро меряет NAME_MAX в байтах, и кириллическое
|
||||
// название упирается в предел вдвое раньше латинского той же длины в знаках.
|
||||
// Значение — NAME_MAX у ext4/xfs/btrfs; у ядра оно не выясняется, потому что
|
||||
// раскладка обязана отказать до обращения к диску (см. spec file-layout).
|
||||
// Цена обеих сторон: на ФС с меньшим пределом (часть зашифрованных) имя пройдёт
|
||||
// проверку и упрётся в ядро — останется сегодняшний failed; на ФС с бо́льшим мы
|
||||
// откажем строже, чем нужно. Лечение — правка этой константы, а не настройка:
|
||||
// значение, которое некому выставить осознанно, не гибкость.
|
||||
const maxComponentBytes = 255
|
||||
|
||||
// checkComponentLengths проверяет, что каждый компонент пути dst ПОД корнем
|
||||
// root помещается в maxComponentBytes. Корень не проверяется: его каталоги задаёт
|
||||
// оператор, и жаловаться на них раскладка не вправе. Возвращает ошибку,
|
||||
// обёртывающую ErrNameTooLong и называющую непомещающийся компонент и его длину.
|
||||
// Чистая функция: к диску не обращается.
|
||||
func checkComponentLengths(root, dst string) error {
|
||||
rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(dst))
|
||||
if err != nil {
|
||||
return fmt.Errorf("layout: relative target %q: %w", dst, err)
|
||||
}
|
||||
for c := range strings.SplitSeq(rel, string(filepath.Separator)) {
|
||||
if len(c) > maxComponentBytes {
|
||||
return fmt.Errorf("%w: %q — %d байт при пределе %d",
|
||||
ErrNameTooLong, shorten(c), len(c), maxComponentBytes)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// errNameSample — сколько рун непомещающегося имени показать в тексте ошибки.
|
||||
// Текст уезжает в error_msg, а оттуда в баннер ревью и в карточку Telegram:
|
||||
// имя целиком (а оно по условию длиннее 255 байт) заняло бы там весь экран.
|
||||
// Точную длину несёт число рядом, поэтому образца хватает, чтобы узнать имя.
|
||||
const errNameSample = 40
|
||||
|
||||
// shorten оставляет от имени начало и конец, выкидывая середину. Режет по рунам:
|
||||
// обрыв посреди многобайтовой буквы дал бы в сообщении мусор.
|
||||
func shorten(s string) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= errNameSample {
|
||||
return s
|
||||
}
|
||||
head := errNameSample / 2
|
||||
return string(r[:head]) + "…" + string(r[len(r)-head:])
|
||||
}
|
||||
|
||||
// sanitizeComponent чистит один компонент пути (имя папки/файла): убирает
|
||||
// разделители, управляющие символы и неудобные для ФС/SMB знаки, схлопывает
|
||||
// пробелы и срезает точки/пробелы по краям. Кириллица и пробелы внутри
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// countDirEntries считает всё, что появилось под корнем библиотеки: проверка
|
||||
// длины обязана отказать ДО первой операции с ФС, поэтому пусто — это часть
|
||||
// утверждения, а не гигиена.
|
||||
func countDirEntries(t *testing.T, root string) int {
|
||||
t.Helper()
|
||||
n := 0
|
||||
err := filepath.WalkDir(root, func(p string, _ os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p != root {
|
||||
n++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// 2.1 Имя файла длиннее предела: отказ целиком, ни одного каталога на диске.
|
||||
func TestBuildLinks_FileNameTooLong(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
src := f.srcFile(t, "long/movie.mkv", "x")
|
||||
// Название впритык под папку, но имя файла = база + ".mkv".
|
||||
title := strings.Repeat("a", maxComponentBytes-len(" (1999)"))
|
||||
plan := Plan{
|
||||
Type: Movie, Title: title, Year: 1999,
|
||||
Files: []PlanFile{{Src: src, Role: RoleMain}},
|
||||
}
|
||||
links, err := f.l.BuildLinks(plan)
|
||||
if !errors.Is(err, ErrNameTooLong) {
|
||||
t.Fatalf("err = %v, want ErrNameTooLong", err)
|
||||
}
|
||||
if links != nil {
|
||||
t.Errorf("links = %v, want nil (отказ целиком)", links)
|
||||
}
|
||||
if n := countDirEntries(t, f.movies); n != 0 {
|
||||
t.Errorf("под movies появилось %d записей, ожидалось 0 (проверка до операций с ФС)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 2.2 Папка тайтла длиннее предела, хотя имя файла бы поместилось.
|
||||
func TestBuildLinks_FolderNameTooLong(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
src := f.srcFile(t, "long/movie.mkv", "x")
|
||||
// База помещается, но provider-тег выталкивает папку за предел; имя файла
|
||||
// тега не несёт и остаётся коротким.
|
||||
tag := "tmdbid-693134"
|
||||
title := strings.Repeat("b", maxComponentBytes-len(" (1999)")-len(" [")-len(tag)-len("]"))
|
||||
plan := Plan{
|
||||
Type: Movie, Title: title, Year: 1999, ProviderTag: tag,
|
||||
Files: []PlanFile{{Src: src, Role: RoleMain}},
|
||||
}
|
||||
if _, err := f.l.BuildLinks(plan); err != nil {
|
||||
t.Fatalf("контроль: имя ровно в предел должно проходить, got %v", err)
|
||||
}
|
||||
plan.Title = title + "c" // +1 байт — папка перестаёт помещаться
|
||||
_, err := f.l.BuildLinks(plan)
|
||||
if !errors.Is(err, ErrNameTooLong) {
|
||||
t.Fatalf("err = %v, want ErrNameTooLong", err)
|
||||
}
|
||||
if n := countDirEntries(t, f.movies); n != 0 {
|
||||
t.Errorf("под movies появилось %d записей, ожидалось 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 2.3 Предел меряется в БАЙТАХ, а не в рунах: кириллица упирается вдвое раньше.
|
||||
// Заодно граница 255/256.
|
||||
func TestBuildLinks_LimitIsBytesNotRunes(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
src := f.srcFile(t, "long/movie.mkv", "x")
|
||||
|
||||
build := func(title string) error {
|
||||
_, err := f.l.BuildLinks(Plan{
|
||||
Type: Movie, Title: title,
|
||||
Files: []PlanFile{{Src: src, Role: RoleMain}},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
const ext = ".mkv"
|
||||
// Граница ровно на 255 байтах имени файла.
|
||||
fit := strings.Repeat("a", maxComponentBytes-len(ext))
|
||||
if err := build(fit); err != nil {
|
||||
t.Fatalf("255 байт должны помещаться, got %v", err)
|
||||
}
|
||||
if err := build(fit + "a"); !errors.Is(err, ErrNameTooLong) {
|
||||
t.Fatalf("256 байт: err = %v, want ErrNameTooLong", err)
|
||||
}
|
||||
|
||||
// Столько же ЗНАКОВ кириллицей — вдвое больше байтов, отказ.
|
||||
cyr := strings.Repeat("я", maxComponentBytes-len(ext))
|
||||
if err := build(cyr); !errors.Is(err, ErrNameTooLong) {
|
||||
t.Fatalf("кириллица той же длины в знаках: err = %v, want ErrNameTooLong "+
|
||||
"(предел меряется в байтах)", err)
|
||||
}
|
||||
// Граница кириллицей — тоже по байтам. 125 букв = 250 байт, плюс ".mkv" = 254:
|
||||
// помещается. Ещё одна буква даёт 256 — не помещается.
|
||||
cyrFit := strings.Repeat("я", (maxComponentBytes-len(ext))/2)
|
||||
if err := build(cyrFit); err != nil {
|
||||
t.Fatalf("254 байта кириллицей должны помещаться, got %v", err)
|
||||
}
|
||||
if err := build(cyrFit + "я"); !errors.Is(err, ErrNameTooLong) {
|
||||
t.Fatalf("256 байт кириллицей: err = %v, want ErrNameTooLong", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 2.4 Приоритет: путь и вне библиотеки, и слишком длинный → отказ называет
|
||||
// выход за библиотеку, а не длину. Иначе находка безопасности спрячется за
|
||||
// косметической причиной.
|
||||
func TestBuildLinks_OutsideLibraryBeatsTooLong(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
src := f.srcFile(t, "long/movie.mkv", "x")
|
||||
// Санитизация режет разделители, поэтому traversal через Title недостижим;
|
||||
// проверяем сам порядок на checkComponentLengths напрямую: путь вне корня
|
||||
// до неё не доходит, а внутри корня — доходит.
|
||||
outside := filepath.Join(filepath.Dir(f.movies), strings.Repeat("z", 300))
|
||||
if underRoot(f.movies, outside) {
|
||||
t.Fatal("подготовка теста неверна: путь обязан быть вне корня")
|
||||
}
|
||||
_, err := f.l.BuildLinks(Plan{
|
||||
Type: Movie, Title: strings.Repeat("z", 300),
|
||||
Files: []PlanFile{{Src: src, Role: RoleMain}},
|
||||
})
|
||||
if !errors.Is(err, ErrNameTooLong) {
|
||||
t.Fatalf("внутри корня длинное имя даёт ErrNameTooLong, got %v", err)
|
||||
}
|
||||
// Прямая сверка порядка в BuildLinks: underRoot стоит раньше и его отказ
|
||||
// формулируется своим текстом (см. layout.go).
|
||||
if err := checkComponentLengths(f.movies, outside); !errors.Is(err, ErrNameTooLong) {
|
||||
t.Fatalf("checkComponentLengths вне корня: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 2.7 Название не усекается: на непомещающемся входе ссылок нет вовсе, а не
|
||||
// возвращена усечённая. Усечение схлопнуло бы два разных названия в один каталог.
|
||||
func TestBuildLinks_NoTruncation(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
src := f.srcFile(t, "long/movie.mkv", "x")
|
||||
links, err := f.l.BuildLinks(Plan{
|
||||
Type: Movie, Title: strings.Repeat("d", 400),
|
||||
Files: []PlanFile{{Src: src, Role: RoleMain}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("ожидался отказ, получено %d ссылок", len(links))
|
||||
}
|
||||
if len(links) != 0 {
|
||||
t.Errorf("вернулось %d ссылок — усечение недопустимо", len(links))
|
||||
}
|
||||
}
|
||||
|
||||
// 2.8 Мерится финальный компонент, а не название: суффикс субтитров
|
||||
// дописывается после и обязан учитываться.
|
||||
func TestBuildLinks_SubtitleSuffixCounted(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
video := f.srcFile(t, "long/ep.mkv", "x")
|
||||
sub := f.srcFile(t, "long/ep.ru.srt", "y")
|
||||
// База подобрана так, что видеофайл помещается, а субтитр с ".ru.forced.srt" —
|
||||
// уже нет: разница ровно в длине суффикса.
|
||||
const stem = " S01E02"
|
||||
title := strings.Repeat("e", maxComponentBytes-len(stem)-len(".mkv"))
|
||||
plan := Plan{
|
||||
Type: Series, Title: title,
|
||||
Files: []PlanFile{
|
||||
{Src: video, Role: RoleEpisode, Season: intp(1), Episode: intp(2)},
|
||||
},
|
||||
}
|
||||
if _, err := f.l.BuildLinks(plan); err != nil {
|
||||
t.Fatalf("видеофайл впритык должен проходить, got %v", err)
|
||||
}
|
||||
plan.Files = append(plan.Files, PlanFile{
|
||||
Src: sub, Role: RoleSubtitle, Season: intp(1), Episode: intp(2),
|
||||
Lang: "ru", Flags: []string{"forced"},
|
||||
})
|
||||
if _, err := f.l.BuildLinks(plan); !errors.Is(err, ErrNameTooLong) {
|
||||
t.Fatalf("субтитр с суффиксом обязан упереться: err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 2.9 Вырожденные входы проверку не роняют.
|
||||
func TestCheckComponentLengths_Degenerate(t *testing.T) {
|
||||
root := "/srv/media/movies"
|
||||
cases := []string{
|
||||
root + "/",
|
||||
root + "/ ",
|
||||
root + "/" + string([]byte{0xff, 0xfe, 0xfd}), // невалидный UTF-8
|
||||
root + "/" + strings.Repeat("x", 1<<16),
|
||||
root,
|
||||
}
|
||||
for _, dst := range cases {
|
||||
// Требование одно: не паниковать и вернуть значение.
|
||||
_ = checkComponentLengths(root, dst)
|
||||
}
|
||||
if err := checkComponentLengths(root, root+"/"+strings.Repeat("x", 1<<16)); !errors.Is(err, ErrNameTooLong) {
|
||||
t.Error("очень длинный компонент обязан давать ErrNameTooLong")
|
||||
}
|
||||
}
|
||||
|
||||
// Корень библиотеки под проверку не попадает: его каталоги задаёт оператор.
|
||||
func TestCheckComponentLengths_RootNotChecked(t *testing.T) {
|
||||
root := "/srv/" + strings.Repeat("r", 300)
|
||||
if err := checkComponentLengths(root, root+"/Dune (2024)/Dune (2024).mkv"); err != nil {
|
||||
t.Errorf("длинный корень не должен считаться отказом: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Нерасчислимый относительный путь (корень абсолютный, цель относительная) —
|
||||
// не отказ по длине, а отдельная ошибка: путать их нельзя, иначе диагноз соврёт.
|
||||
func TestCheckComponentLengths_UnrelatablePath(t *testing.T) {
|
||||
err := checkComponentLengths("/srv/media/movies", "relative/path.mkv")
|
||||
if err == nil {
|
||||
t.Fatal("want error for unrelatable path")
|
||||
}
|
||||
if errors.Is(err, ErrNameTooLong) {
|
||||
t.Errorf("нерасчислимый путь не должен выдаваться за отказ по длине: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// shorten держит текст причины коротким: имя в сообщении усечено серединой, а
|
||||
// точную длину несёт число рядом. Короткое имя не трогается.
|
||||
func TestShorten(t *testing.T) {
|
||||
short := strings.Repeat("a", errNameSample)
|
||||
if got := shorten(short); got != short {
|
||||
t.Errorf("имя в предел образца не должно меняться: %q", got)
|
||||
}
|
||||
long := strings.Repeat("я", 200)
|
||||
got := shorten(long)
|
||||
if r := []rune(got); len(r) != errNameSample+1 { // +1 — многоточие
|
||||
t.Errorf("длина образца = %d рун, want %d", len(r), errNameSample+1)
|
||||
}
|
||||
if !strings.Contains(got, "…") {
|
||||
t.Errorf("усечённое имя должно нести многоточие: %q", got)
|
||||
}
|
||||
// Режем по рунам: обрыв посреди буквы дал бы мусор вместо кириллицы.
|
||||
if strings.ContainsRune(got, '�') {
|
||||
t.Errorf("усечение разорвало руну: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -400,6 +400,15 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
|
||||
b.send(chatID, opErr("Торрент ещё качается — дождитесь докачки", id), nil)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, layout.ErrNameTooLong) {
|
||||
// Имя не помещается в файловую систему: задача штатно ушла в review,
|
||||
// где название правится подсказкой. Карточку обновляем — в ней теперь
|
||||
// причина.
|
||||
b.answer(cq.ID, "Имя не помещается")
|
||||
b.send(chatID, opErr("Целевое имя слишком длинное — задача отправлена в ревью", id), nil)
|
||||
b.refreshCard(ctx, chatID, msgID, id)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, layout.ErrCollision) {
|
||||
// Коллизия цели: задача штатно ушла в review с причиной — показываем
|
||||
// конкретно и обновляем карточку (в ней теперь причина коллизии).
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -78,6 +79,7 @@ type fakeReviewer struct {
|
||||
deleted []string
|
||||
dismissed []string
|
||||
chosen map[string]string // downloadID → выбранный candidateID
|
||||
applyErr error // чем отвечает Apply (nil — успехом)
|
||||
}
|
||||
|
||||
func (f *fakeReviewer) ReviewData(context.Context, string) (*worker.ReviewData, error) {
|
||||
@@ -85,7 +87,7 @@ func (f *fakeReviewer) ReviewData(context.Context, string) (*worker.ReviewData,
|
||||
}
|
||||
func (f *fakeReviewer) Apply(_ context.Context, id string) error {
|
||||
f.applied = append(f.applied, id)
|
||||
return nil
|
||||
return f.applyErr
|
||||
}
|
||||
func (f *fakeReviewer) Refine(_ context.Context, id string, hint string) error {
|
||||
if f.refined == nil {
|
||||
@@ -614,3 +616,23 @@ func TestBot_IngestErrorPromisesNoDownloadID(t *testing.T) {
|
||||
t.Errorf("в отказе обещан идентификатор загрузки: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Непомещающееся целевое имя — штатный отказ, чинимый на ревью: бот отвечает
|
||||
// конкретно и обновляет карточку (в ней теперь причина), как для коллизии, а не
|
||||
// падает в общее «Не удалось выполнить действие».
|
||||
func TestBot_CallbackApplyNameTooLong(t *testing.T) {
|
||||
b, api, _, rev := newTestBot(t, []int64{7})
|
||||
rev.applyErr = fmt.Errorf("apply: %w", layout.ErrNameTooLong)
|
||||
|
||||
b.handleCallback(context.Background(), cbFrom(7, "apply:"+tid))
|
||||
|
||||
if len(api.answers) != 1 || !strings.Contains(api.answers[0], "не помещается") {
|
||||
t.Errorf("answers = %+v, ожидался конкретный ответ", api.answers)
|
||||
}
|
||||
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "слишком длинное") {
|
||||
t.Errorf("sent = %+v, ожидалось конкретное сообщение", api.sent)
|
||||
}
|
||||
if len(api.edits) != 1 {
|
||||
t.Errorf("edits = %v, карточка должна обновиться причиной", api.edits)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,11 +89,26 @@ func (b *Bot) reviewCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar
|
||||
}
|
||||
if n := len(rd.Preview); n > 0 {
|
||||
fmt.Fprintf(&sb, "План: %d файлов → %s", n, esc(tailPath(rd.Preview[0].Dst)))
|
||||
} else if why := previewProblem(rd); why != "" {
|
||||
// Плана нет — без этой строки карточка молчит о том, почему нет и «Применить».
|
||||
// Причина показа (PreviewError) старше записанной: она про текущий план, а
|
||||
// error_msg остался от последнего перехода.
|
||||
fmt.Fprintf(&sb, "Не удаётся построить план: %s", esc(shorten(why, 120)))
|
||||
}
|
||||
|
||||
return strings.TrimRight(sb.String(), "\n"), b.reviewKeyboard(rd)
|
||||
}
|
||||
|
||||
// previewProblem — почему у задачи в review нет плана: причина, посчитанная на
|
||||
// показе, либо записанная при последнем переходе. Пусто — объяснять нечего
|
||||
// (источник ещё не подтверждён).
|
||||
func previewProblem(rd *worker.ReviewData) string {
|
||||
if rd.PreviewError != "" {
|
||||
return rd.PreviewError
|
||||
}
|
||||
return rd.Download.ErrorMsg.String
|
||||
}
|
||||
|
||||
func (b *Bot) reviewKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMarkup {
|
||||
id := rd.Download.ID
|
||||
sid := id
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
)
|
||||
|
||||
// Карточка ревью без плана называет причину: иначе владелец видит «Нужно
|
||||
// подтверждение» без плана и без кнопки «Применить» и не знает, что чинить.
|
||||
// Причина показа старше записанной — она про текущий план.
|
||||
func TestReviewCard_NamesPreviewProblem(t *testing.T) {
|
||||
t.Run("причина показа", func(t *testing.T) {
|
||||
b, _, _, _ := newTestBot(t, []int64{7})
|
||||
rd := reviewData(store.StateReview)
|
||||
rd.Preview = nil
|
||||
rd.PreviewError = `layout: имя не помещается: "ЫЫЫ…ЫЫЫ" — 400 байт при пределе 255`
|
||||
rd.Download.ErrorMsg = store.NullString("устаревшая причина прошлого перехода")
|
||||
|
||||
text, _ := b.renderCard(rd)
|
||||
if !strings.Contains(text, "не помещается") {
|
||||
t.Errorf("карточка не назвала причину:\n%s", text)
|
||||
}
|
||||
if strings.Contains(text, "устаревшая") {
|
||||
t.Errorf("записанная причина не должна перебивать посчитанную на показе:\n%s", text)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("записанная причина, когда посчитанной нет", func(t *testing.T) {
|
||||
b, _, _, _ := newTestBot(t, []int64{7})
|
||||
rd := reviewData(store.StateReview)
|
||||
rd.Preview = nil
|
||||
rd.Download.ErrorMsg = store.NullString("целевой файл уже существует")
|
||||
|
||||
text, _ := b.renderCard(rd)
|
||||
if !strings.Contains(text, "уже существует") {
|
||||
t.Errorf("карточка не назвала записанную причину:\n%s", text)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("причины нет — строки нет", func(t *testing.T) {
|
||||
b, _, _, _ := newTestBot(t, []int64{7})
|
||||
rd := reviewData(store.StateReview)
|
||||
rd.Preview = nil
|
||||
|
||||
text, _ := b.renderCard(rd)
|
||||
if strings.Contains(text, "Не удаётся построить план") {
|
||||
t.Errorf("без причины строки быть не должно:\n%s", text)
|
||||
}
|
||||
})
|
||||
}
|
||||
+30
-10
@@ -325,7 +325,15 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
|
||||
|
||||
links, err := w.layouter.BuildLinks(toLayoutPlan(plan, savePath, providerTag(provider, providerID), folderBase))
|
||||
if err != nil {
|
||||
w.transition(ctx, *d, store.StateReview, reasonBuild, err.Error())
|
||||
// Непомещающееся имя — свой код причины: этот отказ человек чинит
|
||||
// подсказкой названия, а прочие отказы построения (пустое название,
|
||||
// серия без номера) — нет, и слепить их в один код значило бы потерять
|
||||
// эту разницу.
|
||||
code := reasonBuild
|
||||
if errors.Is(err, layout.ErrNameTooLong) {
|
||||
code = reasonNameTooLong
|
||||
}
|
||||
w.transition(ctx, *d, store.StateReview, code, err.Error())
|
||||
return fmt.Errorf("build links: %w", err)
|
||||
}
|
||||
|
||||
@@ -953,14 +961,21 @@ func candMediaType(rec *store.Recognition) recognize.MediaType {
|
||||
type ReviewData struct {
|
||||
Download store.Download
|
||||
Recognition *store.Recognition
|
||||
Plan recognize.Plan // эффективный (с применёнными правками)
|
||||
Preview []layout.Link // целевые пути активного источника (Src — относительный)
|
||||
Candidates []store.MetadataCandidate // кандидаты базы для ручного выбора
|
||||
Sources []SourceOption // единый список источников совпадения (нейронка + кандидаты)
|
||||
Provider string // эффективный провайдер (с учётом выбора)
|
||||
ProviderID string // эффективный id в базе
|
||||
Hints []string
|
||||
Overrides map[string]string
|
||||
Plan recognize.Plan // эффективный (с применёнными правками)
|
||||
Preview []layout.Link // целевые пути активного источника (Src — относительный)
|
||||
// PreviewError — почему предпросмотр не построился, человекочитаемо (пусто —
|
||||
// построился либо строить было нечего). Считается НА ПОКАЗЕ, поэтому всегда
|
||||
// относится к текущему эффективному плану — в отличие от error_msg загрузки,
|
||||
// который остался от последнего перехода и после смены источника устаревает.
|
||||
// Задача в review без записанной причины (обычный «нет матча») иначе оставила
|
||||
// бы экран без объяснения, почему пропала кнопка «Применить».
|
||||
PreviewError string
|
||||
Candidates []store.MetadataCandidate // кандидаты базы для ручного выбора
|
||||
Sources []SourceOption // единый список источников совпадения (нейронка + кандидаты)
|
||||
Provider string // эффективный провайдер (с учётом выбора)
|
||||
ProviderID string // эффективный id в базе
|
||||
Hints []string
|
||||
Overrides map[string]string
|
||||
}
|
||||
|
||||
// MatchURL — ссылка на подтверждённую запись метабазы для этой загрузки. Общий
|
||||
@@ -1082,8 +1097,12 @@ func (w *Worker) ReviewData(ctx context.Context, id string) (*ReviewData, error)
|
||||
rd.Preview = links
|
||||
} else {
|
||||
// Видимая деградация: без превью на экране ревью пропадает
|
||||
// кнопка «Применить» — не рядовой Debug, а WARN.
|
||||
// кнопка «Применить» — не рядовой Debug, а WARN. Причину
|
||||
// отдаём транспорту: молча пропавшая кнопка неотличима от
|
||||
// поломки, а у задачи без записанной причины взять её больше
|
||||
// неоткуда.
|
||||
log.Warn("review data build preview failed", "error", lerr)
|
||||
rd.PreviewError = lerr.Error()
|
||||
}
|
||||
}
|
||||
// Единый список источников: нейронка + кандидаты, каждый с
|
||||
@@ -1267,6 +1286,7 @@ const (
|
||||
reasonBuild = "build" // не удалось построить план ссылок
|
||||
reasonPersist = "persist" // ссылки на диске, но учёт не записан
|
||||
reasonCollision = "collision" // целевой путь уже занят (layout.ErrCollision)
|
||||
reasonNameTooLong = "name_too_long" // компонент целевого пути длиннее предела (layout.ErrNameTooLong)
|
||||
reasonTitleFolderDesync = "title_folder_desync" // ≥2 разных живых папок тайтла с одним матчем
|
||||
)
|
||||
|
||||
|
||||
@@ -2406,3 +2406,142 @@ func TestApplyOverrides_LegacyDirtyPinSanitized(t *testing.T) {
|
||||
t.Errorf("plan.Title = %q, want название распознавания", got.Title)
|
||||
}
|
||||
}
|
||||
|
||||
// Раскладка с непомещающимся именем уводит задачу в review со своим кодом
|
||||
// причины, а не в failed с текстом ядра. Каталог библиотеки при этом пуст:
|
||||
// проверка стоит до первой операции с ФС (критерии приёмки A1, A2, A4).
|
||||
func TestApply_NameTooLongGoesReview(t *testing.T) {
|
||||
plan := seriesResult().Plan
|
||||
plan.Title = strings.Repeat("ы", 200) // 400 байт — не помещается ни в имя, ни в папку
|
||||
f := newApplyFixture(t, plan)
|
||||
|
||||
err := f.w.Apply(context.Background(), "1")
|
||||
if !errors.Is(err, layout.ErrNameTooLong) {
|
||||
t.Fatalf("Apply err = %v, want ErrNameTooLong", err)
|
||||
}
|
||||
d := f.st.downloads["1"]
|
||||
if d.State != store.StateReview {
|
||||
t.Errorf("state = %q, want review (а не failed)", d.State)
|
||||
}
|
||||
if d.ErrorCode.String != reasonNameTooLong {
|
||||
t.Errorf("error_code = %q, want %q", d.ErrorCode.String, reasonNameTooLong)
|
||||
}
|
||||
// A3: причина — наш доменный текст, а не текст ядра.
|
||||
if msg := d.ErrorMsg.String; strings.Contains(msg, "file name too long") ||
|
||||
strings.Contains(msg, "ENAMETOOLONG") {
|
||||
t.Errorf("error_msg несёт текст системной ошибки: %q", msg)
|
||||
}
|
||||
if !strings.Contains(d.ErrorMsg.String, "не помещается") {
|
||||
t.Errorf("error_msg не называет причину по-человечески: %q", d.ErrorMsg.String)
|
||||
}
|
||||
// A2/A4: под библиотекой не появилось ничего.
|
||||
entries, rerr := os.ReadDir(f.series)
|
||||
if rerr != nil {
|
||||
t.Fatal(rerr)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("под series появилось %d записей, ожидалось 0", len(entries))
|
||||
}
|
||||
if len(f.st.links) != 0 {
|
||||
t.Errorf("file_links = %d, want 0 (частичной раскладки не остаётся)", len(f.st.links))
|
||||
}
|
||||
}
|
||||
|
||||
// Прочие отказы построения плана свой код сохраняют: непомещающееся имя не
|
||||
// поглотило соседний случай.
|
||||
func TestApply_EmptyTitleKeepsBuildReason(t *testing.T) {
|
||||
plan := seriesResult().Plan
|
||||
plan.Title = "..." // санитизация схлопывает в пустое
|
||||
f := newApplyFixture(t, plan)
|
||||
|
||||
if err := f.w.Apply(context.Background(), "1"); err == nil {
|
||||
t.Fatal("want build error")
|
||||
}
|
||||
if got := f.st.downloads["1"].ErrorCode.String; got != reasonBuild {
|
||||
t.Errorf("error_code = %q, want %q", got, reasonBuild)
|
||||
}
|
||||
}
|
||||
|
||||
// Экран ревью обязан назвать причину, даже когда в состоянии её нет. Самый
|
||||
// частый вход в review — распознавание без подтверждённого матча — приходит
|
||||
// туда с пустым error_msg и до раскладки не доходит, поэтому единственный
|
||||
// источник причины здесь — построение предпросмотра.
|
||||
func TestReviewData_PreviewErrorWhenStateHasNoReason(t *testing.T) {
|
||||
plan := seriesResult().Plan
|
||||
plan.Title = strings.Repeat("ы", 200)
|
||||
f := newApplyFixture(t, plan)
|
||||
// Состояние без записанной причины: так задачу оставляет finishRecognition.
|
||||
d := f.st.downloads["1"]
|
||||
d.ErrorCode = store.NullString("")
|
||||
d.ErrorMsg = store.NullString("")
|
||||
f.st.put(d)
|
||||
|
||||
rd, err := f.w.ReviewData(context.Background(), "1")
|
||||
if err != nil {
|
||||
t.Fatalf("ReviewData: %v", err)
|
||||
}
|
||||
if len(rd.Preview) != 0 {
|
||||
t.Fatalf("предпросмотр = %d ссылок, ожидался пустой", len(rd.Preview))
|
||||
}
|
||||
if rd.PreviewError == "" {
|
||||
t.Fatal("предпросмотр пуст, а причины нет — экран промолчит о том, почему пропала кнопка")
|
||||
}
|
||||
if !strings.Contains(rd.PreviewError, "не помещается") {
|
||||
t.Errorf("PreviewError = %q, ожидалась причина по длине имени", rd.PreviewError)
|
||||
}
|
||||
}
|
||||
|
||||
// Сценарий спеки «Подсказка человека чинит случай, когда база печатается из
|
||||
// распознавания»: короткое название доводит задачу до done.
|
||||
func TestApply_ShorterTitleFixesNameTooLong(t *testing.T) {
|
||||
plan := seriesResult().Plan
|
||||
plan.Title = strings.Repeat("ы", 200)
|
||||
f := newApplyFixture(t, plan)
|
||||
|
||||
if err := f.w.Apply(context.Background(), "1"); !errors.Is(err, layout.ErrNameTooLong) {
|
||||
t.Fatalf("подготовка: ожидался отказ по длине, got %v", err)
|
||||
}
|
||||
// Человек задаёт название короче (жёсткая правка поля — override).
|
||||
if err := f.st.SetOverride(context.Background(), "1", ovrTitle, "Шоу"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.w.Apply(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Apply после укорачивания: %v", err)
|
||||
}
|
||||
if got := f.st.downloads["1"].State; got != store.StateDone {
|
||||
t.Errorf("state = %q, want done", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Унаследованная база отказа по длине дать не может, и это свойство арифметики,
|
||||
// а не совпадение: папка-якоря лежит на диске, значит её имя <= предела, а хвост
|
||||
// имени файла (" S02E01" + расширение, 11 байт) не длиннее хвоста папки
|
||||
// (" [" + provider-тег + "]", минимум 11 байт). Тест держит это свойство: если
|
||||
// суффиксы имён вырастут (язык и флаги субтитров, двойные серии), он покраснеет
|
||||
// раньше, чем задача упрётся в тупик, из которого подсказка не выводит.
|
||||
func TestApply_InheritedBaseAtLimitStillFits(t *testing.T) {
|
||||
plan := seriesResult().Plan
|
||||
f := newApplyFixture(t, plan)
|
||||
prov, pid := "tvdb", "269613"
|
||||
tag := prov + "id-" + pid
|
||||
|
||||
// Папка якоря ровно в предел: длиннее её на диске быть не может.
|
||||
base := strings.Repeat("a", 255-len(" [")-len(tag)-len("]"))
|
||||
anchorDir := filepath.Join(f.series, base+" ["+tag+"]")
|
||||
if err := os.MkdirAll(filepath.Join(anchorDir, "Season 01"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.st.links = append(f.st.links, store.FileLink{
|
||||
DownloadID: "other", DstPath: filepath.Join(anchorDir, "Season 01", base+" S01E01.mkv"),
|
||||
Status: string(layout.StatusLinked),
|
||||
})
|
||||
_ = f.st.SetOverride(context.Background(), "1", ovrProvider, prov)
|
||||
_ = f.st.SetOverride(context.Background(), "1", ovrProviderID, pid)
|
||||
|
||||
if err := f.w.Apply(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("унаследованная база в предел должна раскладываться: %v", err)
|
||||
}
|
||||
if got := f.st.downloads["1"].State; got != store.StateDone {
|
||||
t.Errorf("state = %q, want done", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -961,7 +961,8 @@ func (w *Worker) logCmd(ctx context.Context, cmd, id string, err error) {
|
||||
log := logctx.FromOr(ctx, w.log)
|
||||
switch {
|
||||
case errors.Is(err, ErrConflict), errors.Is(err, ErrNotReady), errors.Is(err, ErrInvalidInput),
|
||||
errors.Is(err, store.ErrNotFound), errors.Is(err, layout.ErrCollision):
|
||||
errors.Is(err, store.ErrNotFound), errors.Is(err, layout.ErrCollision),
|
||||
errors.Is(err, layout.ErrNameTooLong):
|
||||
log.Debug("command rejected", "command", cmd, "download_id", id, "error", err)
|
||||
default:
|
||||
log.Error("command failed", "command", cmd, "download_id", id, "error", err)
|
||||
|
||||
Reference in New Issue
Block a user