- предел длины компонента (255 байт) проверяется в BuildLinks до первой операции с ФС: ни каталога, ни ссылки при отказе не создаётся - причина пустого предпросмотра считается на показе (ReviewData.PreviewError) и печатается в панели действий и в карточке Telegram: у задачи без записанной причины взять её больше неоткуда
251 lines
11 KiB
Go
251 lines
11 KiB
Go
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)
|
||
}
|
||
}
|