Приём/UI: пачка фиксов границ и парсинга (F7–F10, N2)
Пять независимых bugfix'ов из ревью приёма (docs/backlog/review-f7-f10-ingest-ui-fixes.md): - F7: oversized .torrent через веб отдавал 500. Введён sentinel ingest.ErrTorrentTooLarge, classifyErr транслирует его в 400. - F8: гонка fast-path attach с cancel. Пред-рид FindReingestBlockingByInfohash больше не короткозамыкает активную запись — авторитетное дедуп-решение принимает CreateDownloadIfNoActive под BEGIN IMMEDIATE; короткозамыкание оставлено только для терминальных desync-записей (target_missing/orphaned). F6-апгрейд сохранён. - F9: magnet — регистронезависимый URN-префикс xt (RFC 2141); tgbot.ParseMessage срезает хвостовую пунктуацию, приклеенную жадным matchем. - F10: cap контекста до 16 KiB в ingest.Ingest (единственное место слияния — покрывает все транспорты), рунобезопасная обрезка + маркер. - N2: httpapi.shorten режет по рунам, не байтам — кириллица не рвётся в U+FFFD. Добавлены юнит-тесты на каждое исправленное поведение. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -140,3 +140,23 @@ func TestIndexCardMeta(t *testing.T) {
|
||||
t.Error("контекст всё ещё показан в карточке списка")
|
||||
}
|
||||
}
|
||||
|
||||
// N2: shorten режет по рунам, не байтам — кириллица (2 байта/руна) не рвётся
|
||||
// посреди символа в U+FFFD.
|
||||
func TestShortenRuneSafe(t *testing.T) {
|
||||
// 50 кириллических рун (100 байт). Обрезка до 40 рун раньше резала s[:40]
|
||||
// посреди руны.
|
||||
s := strings.Repeat("я", 50)
|
||||
got := shorten(s, 40)
|
||||
if strings.ContainsRune(got, '�') {
|
||||
t.Errorf("обрезка порвала руну: %q", got)
|
||||
}
|
||||
// 40 рун + многоточие.
|
||||
if want := strings.Repeat("я", 40) + "…"; got != want {
|
||||
t.Errorf("shorten = %q, want %q", got, want)
|
||||
}
|
||||
// Короткая строка (по рунам) возвращается как есть, без многоточия.
|
||||
if got := shorten("привет", 40); got != "привет" {
|
||||
t.Errorf("короткая строка изменена: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
@@ -724,11 +725,14 @@ func desyncNote(s store.State) string {
|
||||
}
|
||||
}
|
||||
|
||||
// shorten обрезает строку до n рун (не байт), добавляя многоточие. Рунобезопасно:
|
||||
// кириллица (2 байта/руна) иначе резалась бы посреди руны в U+FFFD — частый случай
|
||||
// для source_ref-заголовков.
|
||||
func shorten(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
if utf8.RuneCountInString(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
return string([]rune(s)[:n]) + "…"
|
||||
}
|
||||
|
||||
// pathID валидирует {id} из URL как ULID и нормализует к lowercase — до
|
||||
@@ -761,7 +765,8 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
// classifyErr транслирует доменную ошибку в HTTP-статус и нейтральное
|
||||
// человекочитаемое сообщение публичного канала (без сырого err.Error() и
|
||||
// деталей реализации): ErrNotFound → 404; валидация источника
|
||||
// (magnet.ErrNotMagnet) и некорректный ввод команды (worker.ErrInvalidInput) →
|
||||
// (magnet.ErrNotMagnet), oversized `.torrent` (ingest.ErrTorrentTooLarge) и
|
||||
// некорректный ввод команды (worker.ErrInvalidInput) →
|
||||
// 400; недокачанный источник (worker.ErrNotReady), коллизия цели
|
||||
// (layout.ErrCollision) и конфликт состояния (worker.ErrConflict) → 409; прочее
|
||||
// → 500. Полная ошибка уже в логах на доменной границе — наружу отдаём только
|
||||
@@ -772,6 +777,9 @@ func classifyErr(err error) (int, string) {
|
||||
return http.StatusNotFound, "не найдено"
|
||||
case errors.Is(err, magnet.ErrNotMagnet), errors.Is(err, torrent.ErrNotTorrent):
|
||||
return http.StatusBadRequest, "некорректный источник"
|
||||
case errors.Is(err, ingest.ErrTorrentTooLarge):
|
||||
// Промах ввода (файл больше лимита), не сбой сервера — 400, а не 500.
|
||||
return http.StatusBadRequest, "файл .torrent слишком большой"
|
||||
case errors.Is(err, worker.ErrInvalidInput):
|
||||
// Промах пользователя (пустая подсказка, неизвестный тип/провайдер, …),
|
||||
// не сбой сервера.
|
||||
|
||||
@@ -148,6 +148,22 @@ func TestAPIAddBadInput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// F7: oversized `.torrent` — ingest.ErrTorrentTooLarge классифицируется как 400
|
||||
// (промах ввода), а не 500.
|
||||
func TestAPIAddTorrentTooLarge(t *testing.T) {
|
||||
ing := &fakeIngestor{err: fmt.Errorf("ingest: torrent too large: %w", ingest.ErrTorrentTooLarge)}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
|
||||
|
||||
resp, err := http.Post(srv.URL+"/api/downloads", "application/json", strings.NewReader(`{"source":"x"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIList(t *testing.T) {
|
||||
reader := &fakeReader{list: []store.Download{
|
||||
{ID: tid2, SourceType: store.SourceMagnet, State: store.StateCompleted,
|
||||
|
||||
Reference in New Issue
Block a user