Приём/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:
@@ -2,6 +2,7 @@ package ingest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
@@ -53,6 +54,30 @@ func (f *fakeStore) UpgradeCatchedMagnetToTorrent(_ context.Context, id string,
|
||||
return f.upgradeUp, nil
|
||||
}
|
||||
|
||||
// raceStore моделирует гонку F8: пред-рид FindReingestBlockingByInfohash видит
|
||||
// активную запись (blocking), но create-гард CreateDownloadIfNoActive её уже не
|
||||
// находит (в параллели отменена) и заводит свежую задачу.
|
||||
type raceStore struct {
|
||||
blocking *store.Download
|
||||
created []store.Download
|
||||
}
|
||||
|
||||
func (r *raceStore) FindReingestBlockingByInfohash(_ context.Context, _ ...string) (*store.Download, error) {
|
||||
return r.blocking, nil
|
||||
}
|
||||
|
||||
func (r *raceStore) CreateDownloadIfNoActive(_ context.Context, d *store.Download, _ []string, _ []byte) (*store.Download, error) {
|
||||
d.ID = ident.NewID()
|
||||
r.created = append(r.created, *d)
|
||||
return nil, nil // активной уже нет — создаём новую
|
||||
}
|
||||
|
||||
func (r *raceStore) AddInfohashes(_ context.Context, _ string, _ []string) error { return nil }
|
||||
|
||||
func (r *raceStore) UpgradeCatchedMagnetToTorrent(_ context.Context, _ string, _ []byte) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func newService(st Store) *Service {
|
||||
return New(st, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
}
|
||||
@@ -207,6 +232,66 @@ func TestIngestDedupTopsUpHashes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// F8: пред-рид FindReingestBlockingByInfohash увидел активную задачу, но к моменту
|
||||
// создания она отменена (гонка с cancel). Активную запись пред-рид НЕ
|
||||
// короткозамыкает — авторитетное дедуп-решение принимает CreateDownloadIfNoActive
|
||||
// под BEGIN IMMEDIATE: активной больше нет → заводим свежую задачу, а не
|
||||
// возвращаем stale Deduplicated «уже в работе».
|
||||
func TestIngestActivePreReadNotShortCircuited(t *testing.T) {
|
||||
stale := &store.Download{ID: "01hzzzstale00000000000000000", State: store.StateCatched}
|
||||
fs := &raceStore{blocking: stale} // пред-рид видит активную; create-гард — уже нет
|
||||
res, err := newService(fs).Ingest(context.Background(), Request{Source: sampleMagnet})
|
||||
if err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
if res.Deduplicated {
|
||||
t.Errorf("активный пред-рид не должен коротко замыкать дедуп: %+v", res)
|
||||
}
|
||||
if res.DownloadID == stale.ID || res.State != store.StateCatched {
|
||||
t.Errorf("ожидалась свежая задача, а не stale: %+v", res)
|
||||
}
|
||||
if len(fs.created) != 1 {
|
||||
t.Errorf("должна быть создана новая задача, created=%d", len(fs.created))
|
||||
}
|
||||
}
|
||||
|
||||
// F7: oversized `.torrent` — доменная ошибка размера класса ErrTorrentTooLarge
|
||||
// (транспорт транслирует в 400, а не 500). Задача не заводится.
|
||||
func TestIngestRejectsOversizedTorrent(t *testing.T) {
|
||||
fs := &fakeStore{}
|
||||
big := make([]byte, MaxTorrentSize+1)
|
||||
_, err := newService(fs).Ingest(context.Background(), Request{TorrentData: big})
|
||||
if !errors.Is(err, ErrTorrentTooLarge) {
|
||||
t.Fatalf("err = %v, want ErrTorrentTooLarge", err)
|
||||
}
|
||||
if len(fs.created) != 0 {
|
||||
t.Error("не должно быть записи задачи")
|
||||
}
|
||||
}
|
||||
|
||||
// F10: контекст из веб-формы может быть огромным (multipart-бюджет на всё тело) —
|
||||
// Ingest режет его до MaxContextSize по границе руны (без U+FFFD) и метит маркером.
|
||||
func TestIngestCapsContext(t *testing.T) {
|
||||
// «Ё» — 2 байта; ASCII-префикс сдвигает границу MaxContextSize на нечётный
|
||||
// байт, чтобы обрезка s[:MaxContextSize] пришлась ВНУТРЬ двухбайтовой руны —
|
||||
// тогда trimToRune реально срабатывает (иначе граница попадёт между рунами).
|
||||
huge := "x" + strings.Repeat("Ё", MaxContextSize)
|
||||
fs := &fakeStore{}
|
||||
if _, err := newService(fs).Ingest(context.Background(), Request{Source: sampleMagnet, Context: huge}); err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
got := fs.created[0].Context
|
||||
if len(got) > MaxContextSize+len(contextTruncMarker) {
|
||||
t.Errorf("контекст не ограничен: %d байт", len(got))
|
||||
}
|
||||
if !strings.HasSuffix(got, contextTruncMarker) {
|
||||
t.Errorf("нет маркера усечения: …%q", got[max(0, len(got)-40):])
|
||||
}
|
||||
if strings.ContainsRune(got, '�') {
|
||||
t.Error("обрезка порвала руну (U+FFFD)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestRejectsNonMagnet(t *testing.T) {
|
||||
fs := &fakeStore{}
|
||||
if _, err := newService(fs).Ingest(context.Background(), Request{Source: "https://example.com/x.torrent"}); err == nil {
|
||||
|
||||
Reference in New Issue
Block a user