Пять независимых 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>
163 lines
6.4 KiB
Go
163 lines
6.4 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
|
"git.vakhrushev.me/av/jellybit/internal/worker"
|
|
)
|
|
|
|
// TestFmtDateZone — дата отображается в переданной таймзоне: полуночное UTC-время
|
|
// сдвигается на следующий день под Europe/Moscow (UTC+3), в UTC остаётся прежним.
|
|
func TestFmtDateZone(t *testing.T) {
|
|
// 2026-06-14 22:30 UTC == 2026-06-15 01:30 MSK.
|
|
ts := time.Date(2026, 6, 14, 22, 30, 0, 0, time.UTC)
|
|
|
|
if got := fmtDate(ts, time.UTC); got != "2026-06-14" {
|
|
t.Fatalf("UTC: got %q, want 2026-06-14", got)
|
|
}
|
|
msk, err := time.LoadLocation("Europe/Moscow")
|
|
if err != nil {
|
|
t.Fatalf("load Europe/Moscow (tzdata встроен): %v", err)
|
|
}
|
|
if got := fmtDate(ts, msk); got != "2026-06-15" {
|
|
t.Fatalf("MSK: got %q, want 2026-06-15", got)
|
|
}
|
|
}
|
|
|
|
func TestHumanizeAge(t *testing.T) {
|
|
now := time.Date(2026, 7, 4, 12, 0, 0, 0, time.UTC)
|
|
cases := []struct {
|
|
ago time.Duration
|
|
want string
|
|
}{
|
|
{30 * time.Second, "только что"},
|
|
{time.Minute, "1 минуту назад"},
|
|
{5 * time.Minute, "5 минут назад"},
|
|
{2 * time.Hour, "2 часа назад"},
|
|
{24 * time.Hour, "1 день назад"},
|
|
{5 * 24 * time.Hour, "5 дней назад"},
|
|
{40 * 24 * time.Hour, "1 месяц назад"},
|
|
{400 * 24 * time.Hour, "1 год назад"},
|
|
}
|
|
for _, c := range cases {
|
|
if got := humanizeAge(now.Add(-c.ago), now); got != c.want {
|
|
t.Errorf("humanizeAge(-%s) = %q, want %q", c.ago, got, c.want)
|
|
}
|
|
}
|
|
// Будущее (рассинхрон часов) не должно давать «-N»: схлопывается в «только что».
|
|
if got := humanizeAge(now.Add(time.Hour), now); got != "только что" {
|
|
t.Errorf("humanizeAge(future) = %q, want «только что»", got)
|
|
}
|
|
}
|
|
|
|
func TestPlural(t *testing.T) {
|
|
cases := []struct {
|
|
n int
|
|
want string
|
|
}{{1, "день"}, {2, "дня"}, {4, "дня"}, {5, "дней"}, {11, "дней"}, {14, "дней"}, {21, "день"}, {22, "дня"}, {25, "дней"}}
|
|
for _, c := range cases {
|
|
if got := plural(c.n, "день", "дня", "дней"); got != c.want {
|
|
t.Errorf("plural(%d) = %q, want %q", c.n, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSizeAndRatioText(t *testing.T) {
|
|
// Снимок есть → размер из total_size, рейтинг из снимка.
|
|
l := worker.Live{TotalSize: 2 << 30, Ratio: 1.42}
|
|
if got := sizeText(l, true, 0); got != fmtBytes(2<<30) {
|
|
t.Errorf("size (снимок) = %q, want %q", got, fmtBytes(2<<30))
|
|
}
|
|
if got := ratioText(l, true); got != "1.42" {
|
|
t.Errorf("ratio (снимок) = %q, want 1.42", got)
|
|
}
|
|
// Снимка нет → размер из фолбэка по файлам, рейтинг «—».
|
|
if got := sizeText(worker.Live{}, false, 512); got != fmtBytes(512) {
|
|
t.Errorf("size (фолбэк) = %q, want %q", got, fmtBytes(512))
|
|
}
|
|
if got := ratioText(worker.Live{}, false); got != "—" {
|
|
t.Errorf("ratio (нет снимка) = %q, want «—»", got)
|
|
}
|
|
// Ни снимка, ни файлов → «—».
|
|
if got := sizeText(worker.Live{}, false, 0); got != "—" {
|
|
t.Errorf("size (нет данных) = %q, want «—»", got)
|
|
}
|
|
}
|
|
|
|
func TestAddedTimeFallback(t *testing.T) {
|
|
// source_added_at приоритетнее created_at.
|
|
added := time.Date(2026, 6, 30, 10, 0, 0, 0, time.UTC)
|
|
created := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
|
|
d := store.Download{
|
|
CreatedAt: store.FormatTime(created),
|
|
SourceAddedAt: store.NullString(store.FormatTime(added)),
|
|
}
|
|
got, ok := addedTime(d)
|
|
if !ok || !got.Equal(added) {
|
|
t.Errorf("addedTime = %v (ok=%v), want %v", got, ok, added)
|
|
}
|
|
// Без source_added_at — фолбэк на created_at.
|
|
d.SourceAddedAt = store.NullString("")
|
|
got, ok = addedTime(d)
|
|
if !ok || !got.Equal(created) {
|
|
t.Errorf("addedTime (фолбэк) = %v (ok=%v), want %v", got, ok, created)
|
|
}
|
|
// Нечего парсить — ok=false.
|
|
if _, ok := addedTime(store.Download{}); ok {
|
|
t.Error("addedTime пустой должен вернуть ok=false")
|
|
}
|
|
}
|
|
|
|
// TestIndexCardMeta: карточка списка несёт обзорную мета-строку (метка ID, дата
|
|
// добавления, размер и рейтинг из снимка) и НЕ показывает контекст.
|
|
func TestIndexCardMeta(t *testing.T) {
|
|
added := time.Date(2026, 6, 30, 10, 0, 0, 0, time.UTC)
|
|
dl := store.Download{
|
|
ID: testULID, SourceRef: "Dune", DisplayName: "Dune (2024)", Context: "секретный контекст",
|
|
Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ihmeta", Kind: store.HashV1}},
|
|
State: store.StateDone,
|
|
SourceAddedAt: store.NullString(store.FormatTime(added)),
|
|
}
|
|
lv := stubLive{m: map[string]worker.Live{"ihmeta": {Seeding: true, TotalSize: 2 << 30, Ratio: 1.42}}}
|
|
h := testRouterLive(t, stubReader{list: []store.Download{dl}}, stubReviewer{}, lv)
|
|
|
|
rr := get(t, h, "/")
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
body := rr.Body.String()
|
|
for _, want := range []string{"ID:", "2026-06-30", "назад", "1.42", fmtBytes(2 << 30)} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("карточка не содержит %q", want)
|
|
}
|
|
}
|
|
// Контекст из карточки убран (доступен на /download/{id}).
|
|
if strings.Contains(body, "секретный контекст") {
|
|
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)
|
|
}
|
|
}
|