ingest: закрыты мелочи приёма — вырожденное имя, контракт Result, корреляция add

- имя раздачи нормализуется на границе разбора: вырожденное `-`
  (metainfo.NoName) даёт пустое имя, пробельное схлопывается — сентинел больше
  не доходит ни до контекста распознавания, ни до source_ref, ни до подсказки
  вывода имени
- контракт «на любом пути ошибки приёма результат нулевой» объявлен в ingest и
  удерживается структурно; три транспорта перестали обещать идентификатор,
  которого нет, и коррелируют отказ по request_id
- scoped-логгер загрузки ставится до вызова внешнего сервиса в семи командах
  воркера — записи об отказе qBittorrent и метабаз получили download_id
  и infohash; граница разбора bencode записана в docs/research
This commit is contained in:
av
2026-08-06 18:20:12 +03:00
parent 52615e4e49
commit d081ef1d30
30 changed files with 2190 additions and 63 deletions
+88 -6
View File
@@ -5,6 +5,7 @@ import (
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"testing"
@@ -217,12 +218,48 @@ func TestParseInvalidBytes(t *testing.T) {
}
}
func TestContextEmptyWhenNoFields(t *testing.T) {
// info без имени (NoName-сентинел), без трекера/комментария: строк-фактов
// минимум. Проверяем, что Context не паникует и не тянет сеть (чистая
// функция — сетевых вызовов в коде нет по построению).
// Раздача объявила вырожденное имя `-` (metainfo.NoName — конвенция «имени
// нет», которую раздача выставляет САМА; библиотека его не синтезирует).
// Разборщик нормализует его к пустой строке, поэтому строки названия в
// контексте нет. Трекера и комментария тоже нет — строк-фактов минимум.
func TestParseNoNameSentinelDropped(t *testing.T) {
// Сентинел вокруг пробельного — тот же вырожденный вход: схлопывание
// обязано идти ДО сравнения, иначе " - " превращается в "-" уже после
// проверки и уезжает вниз по потоку (регрессия против master, где фолбек
// source_ref на имя файла срабатывал).
for _, name := range []string{
metainfo.NoName,
" - ",
"-\n",
"\t-",
"\u00a0-",
} {
t.Run(fmt.Sprintf("%q", name), func(t *testing.T) {
data, _ := build(t, metainfo.Info{
Name: name,
Length: 0,
PieceLength: 1024,
Pieces: pieces(1),
}, "", "")
info, err := Parse(data)
if err != nil {
t.Fatalf("parse: %v", err)
}
if info.DisplayName != "" {
t.Errorf("DisplayName = %q, want пусто (вырожденное имя)", info.DisplayName)
}
if got := info.Context(); got != "" {
t.Errorf("Context() = %q, want пусто (строки названия быть не должно)", got)
}
})
}
}
// Раздача без поля name даёт пустое имя и без нормализации — случай отдельный
// от вырожденного `-` и путать их нельзя (именно эта путаница в комментарии
// теста и породила исходную нить ревью 2026-07-08).
func TestParseMissingNameIsEmpty(t *testing.T) {
data, _ := build(t, metainfo.Info{
Name: "-", // metainfo.NoName
Length: 0,
PieceLength: 1024,
Pieces: pieces(1),
@@ -231,5 +268,50 @@ func TestContextEmptyWhenNoFields(t *testing.T) {
if err != nil {
t.Fatalf("parse: %v", err)
}
_ = info.Context() // не паникует; состав строк — по полям
if info.DisplayName != "" {
t.Errorf("DisplayName = %q, want пусто (поля name нет)", info.DisplayName)
}
if got := info.Context(); got != "" {
t.Errorf("Context() = %q, want пусто", got)
}
}
// Имя — недоверенный вход, а контекст читается построчно: имя с разделителем
// строк не должно добавлять в контекст строку, выглядящую как синтезированный
// нами факт. Число строк такое же, как у раздачи с обычным именем.
func TestParseNameCollapsedToOneLine(t *testing.T) {
// \n, U+2028 (LINE SEPARATOR) и U+2029 (PARAGRAPH SEPARATOR) — всё, чем можно
// разорвать строку; краевые пробелы туда же.
const dirty = " Dune.2024\nТрекер: evil.example\u2028Комментарий: подделка\u2029хвост "
const want = "Dune.2024 Трекер: evil.example Комментарий: подделка хвост"
data, _ := build(t, metainfo.Info{
Name: dirty,
Length: 2100,
PieceLength: 1024,
Pieces: pieces(3),
}, "http://bt.rutracker.org/announce", "")
info, err := Parse(data)
if err != nil {
t.Fatalf("parse: %v", err)
}
if info.DisplayName != want {
t.Errorf("DisplayName = %q, want %q", info.DisplayName, want)
}
clean, _ := build(t, metainfo.Info{
Name: "Dune.2024",
Length: 2100,
PieceLength: 1024,
Pieces: pieces(3),
}, "http://bt.rutracker.org/announce", "")
ref, err := Parse(clean)
if err != nil {
t.Fatalf("parse clean: %v", err)
}
gotLines := len(strings.Split(info.Context(), "\n"))
wantLines := len(strings.Split(ref.Context(), "\n"))
if gotLines != wantLines {
t.Errorf("строк в контексте = %d, want %d (грязное имя добавило строк):\n%s",
gotLines, wantLines, info.Context())
}
}