package httpapi import ( "net/http" "strings" "testing" "time" "git.vakhrushev.me/av/jellybit/internal/store" "git.vakhrushev.me/av/jellybit/internal/worker" ) 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("контекст всё ещё показан в карточке списка") } }