Принимаем .torrent как загруженные байты — через файл-пикер в веб-форме и Telegram-документ, наряду с magnet. Файл несёт полные метаданные: работает там, где magnet не резолвится (закрытые трекеры, без DHT), и даёт максимум контекста для распознавания без сети. - internal/torrent: парсер поверх anacrolix/torrent/metainfo — инфохэш(и) (v1 SHA1 исходных байтов info; v2 BEP52 при наличии) + Context() из имени, дерева файлов, размера, трекеров. Извлечение файлов панико-безопасно (недоверенный вход). - Персистентность байтов: таблица-спутник download_torrent (миграция 0009); пишется в транзакции создания загрузки, только на ветке создания (не при дедупе). Байты живут весь срок строки — нужны для повторного добавления при retry. - ingest: Request.TorrentData/TorrentName, диспетч парсера; source_ref — человекочитаемый референс (имя раздачи/файла), не адрес добавления. - worker: общий sourceAddParts ветвит по source_type в ОБОИХ add-путях — processCatched и Retry (torrent добавляется файлом, не magnet-хешем). - Транспорты: multipart-форма с файл-пикером (деградация без JS) и приём Telegram-документа (скачивание с редактированием токена из ошибок — секрет не в логи; обработка до ветки pending/текста). Разработка по OpenSpec (SDD): change torrent-file-ingest, два чекпоинта ревью (дизайн до кода, код до архива) сабагентами; дельты влиты в спеки, change архивирован. Ручная проверка на живом qBittorrent (7.3) — за деплоем. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
266 lines
8.3 KiB
Go
266 lines
8.3 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// hashN — детерминированный валидный 40-hex infohash по номеру.
|
|
func hashN(n int) string { return fmt.Sprintf("%040x", n) }
|
|
|
|
// mkDownload заводит загрузку в заданном состоянии с display_name.
|
|
func mkDownload(t *testing.T, st *Store, n int, state State, display string) string {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
d := newDownloading()
|
|
d.DisplayName = display
|
|
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{hashN(n)}, nil)
|
|
if err != nil {
|
|
t.Fatalf("create #%d: %v", n, err)
|
|
}
|
|
if existing != nil {
|
|
t.Fatalf("create #%d: unexpected dedup", n)
|
|
}
|
|
if state != StateDownloading {
|
|
forceState(t, st, d.ID, state)
|
|
}
|
|
return d.ID
|
|
}
|
|
|
|
func ids(ds []Download) []string {
|
|
out := make([]string, len(ds))
|
|
for i, d := range ds {
|
|
out[i] = d.ID
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestListDownloadsPageFilterAndDeleted(t *testing.T) {
|
|
st := newTestStore(t)
|
|
ctx := context.Background()
|
|
|
|
review := mkDownload(t, st, 1, StateReview, "Дюна")
|
|
active := mkDownload(t, st, 2, StateDownloading, "Fargo")
|
|
done := mkDownload(t, st, 3, StateDone, "Оно")
|
|
deleted := mkDownload(t, st, 4, StateDeleted, "Старьё")
|
|
|
|
// Группа review — только review/deferred.
|
|
page, total, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupReview, Limit: 50})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if total != 1 || len(page) != 1 || page[0].ID != review {
|
|
t.Fatalf("review group = %v (total %d), want [%s]", ids(page), total, review)
|
|
}
|
|
|
|
// all: deleted скрыт по умолчанию.
|
|
page, total, err = st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 50})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if total != 3 {
|
|
t.Fatalf("all (без deleted) total = %d, want 3; ids=%v", total, ids(page))
|
|
}
|
|
for _, d := range page {
|
|
if d.ID == deleted {
|
|
t.Fatalf("deleted не должен быть виден по умолчанию")
|
|
}
|
|
}
|
|
_ = active
|
|
_ = done
|
|
|
|
// all + IncludeDeleted: показываем всё.
|
|
_, total, err = st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, IncludeDeleted: true, Limit: 50})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if total != 4 {
|
|
t.Fatalf("all+deleted total = %d, want 4", total)
|
|
}
|
|
}
|
|
|
|
func TestListDownloadsPageSearch(t *testing.T) {
|
|
st := newTestStore(t)
|
|
ctx := context.Background()
|
|
|
|
mkDownload(t, st, 1, StateReview, "Дюна: Часть вторая")
|
|
mkDownload(t, st, 2, StateReview, "Fargo")
|
|
|
|
// Поиск по display_name (кириллица — как введено, с учётом регистра).
|
|
page, total, err := st.ListDownloadsPage(ctx, ListFilter{Query: "Дюна", Limit: 50})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if total != 1 || len(page) != 1 || page[0].DisplayName != "Дюна: Часть вторая" {
|
|
t.Fatalf("search 'Дюна' = %v (total %d)", ids(page), total)
|
|
}
|
|
|
|
// Латиница ищется регистронезависимо (ASCII case-folding LIKE).
|
|
page, _, err = st.ListDownloadsPage(ctx, ListFilter{Query: "fargo", Limit: 50})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(page) != 1 || page[0].DisplayName != "Fargo" {
|
|
t.Fatalf("search 'fargo' = %v", ids(page))
|
|
}
|
|
|
|
// Поиск по infohash.
|
|
page, _, err = st.ListDownloadsPage(ctx, ListFilter{Query: hashN(2), Limit: 50})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(page) != 1 || page[0].PrimaryInfohash() != hashN(2) {
|
|
t.Fatalf("search by infohash = %v", ids(page))
|
|
}
|
|
}
|
|
|
|
func TestListDownloadsPageOrderAndPagination(t *testing.T) {
|
|
st := newTestStore(t)
|
|
ctx := context.Background()
|
|
|
|
// Три задачи; у #1 и #2 задано source_added_at (разное), у #3 — нет (фолбек
|
|
// на created_at). Проверяем порядок DESC по времени добавления и tie-break.
|
|
id1 := mkDownload(t, st, 1, StateDownloading, "one")
|
|
id2 := mkDownload(t, st, 2, StateDownloading, "two")
|
|
id3 := mkDownload(t, st, 3, StateDownloading, "three")
|
|
|
|
base := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)
|
|
// id1 добавлен раньше, id2 позже → id2 должен идти выше id1.
|
|
if err := st.SetSourceAddedAt(ctx, id1, base); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.SetSourceAddedAt(ctx, id2, base.Add(time.Hour)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
page, total, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 50})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if total != 3 {
|
|
t.Fatalf("total = %d, want 3", total)
|
|
}
|
|
// id3 фолбечит на created_at (~сейчас, 2026-07-01) — свежее, чем добавления
|
|
// id1/id2 в июне → id3 первым; затем id2 (позже добавлен), затем id1.
|
|
got := ids(page)
|
|
want := []string{id3, id2, id1}
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Fatalf("порядок = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
// Пагинация: по 2 на страницу.
|
|
p1, total, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 2, Offset: 0})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if total != 3 || len(p1) != 2 {
|
|
t.Fatalf("page1 len=%d total=%d, want 2/3", len(p1), total)
|
|
}
|
|
p2, _, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 2, Offset: 2})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(p2) != 1 {
|
|
t.Fatalf("page2 len=%d, want 1", len(p2))
|
|
}
|
|
// Страницы не пересекаются и покрывают весь список.
|
|
if p1[0].ID != id3 || p1[1].ID != id2 || p2[0].ID != id1 {
|
|
t.Fatalf("страницы разъехались: p1=%v p2=%v", ids(p1), ids(p2))
|
|
}
|
|
}
|
|
|
|
func TestListDownloadsPageTieBreakByID(t *testing.T) {
|
|
st := newTestStore(t)
|
|
ctx := context.Background()
|
|
|
|
// Одинаковое время добавления у всех → устойчивый порядок по id DESC.
|
|
same := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)
|
|
var idList []string
|
|
for i := 1; i <= 3; i++ {
|
|
id := mkDownload(t, st, i, StateDownloading, fmt.Sprintf("d%d", i))
|
|
if err := st.SetSourceAddedAt(ctx, id, same); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
idList = append(idList, id)
|
|
}
|
|
page, _, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 50})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := ids(page)
|
|
want := []string{idList[2], idList[1], idList[0]} // id DESC
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Fatalf("tie-break порядок = %v, want %v", got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSetSourceAddedAtOnce(t *testing.T) {
|
|
st := newTestStore(t)
|
|
ctx := context.Background()
|
|
|
|
id := mkDownload(t, st, 1, StateDownloading, "x")
|
|
first := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
|
|
second := time.Date(2026, 6, 2, 10, 0, 0, 0, time.UTC)
|
|
|
|
if err := st.SetSourceAddedAt(ctx, id, first); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Повторная запись не должна перезаписать (гард IS NULL).
|
|
if err := st.SetSourceAddedAt(ctx, id, second); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
d, err := st.GetDownload(ctx, id)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !d.SourceAddedAt.Valid || d.SourceAddedAt.String != FormatTime(first) {
|
|
t.Fatalf("source_added_at = %q, want %q (первое значение неизменно)",
|
|
d.SourceAddedAt.String, FormatTime(first))
|
|
}
|
|
}
|
|
|
|
func TestListDownloadsPageRecTitleFallback(t *testing.T) {
|
|
st := newTestStore(t)
|
|
ctx := context.Background()
|
|
|
|
// Загрузка без display_name, но с распознанным названием — RecTitle из join.
|
|
id := mkDownload(t, st, 1, StateReview, "")
|
|
if _, err := st.CreateRecognition(ctx, &Recognition{
|
|
DownloadID: id,
|
|
Title: NullString("Настоящее название"),
|
|
}, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
page, _, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 50})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(page) != 1 {
|
|
t.Fatalf("len = %d", len(page))
|
|
}
|
|
if !page[0].RecTitle.Valid || page[0].RecTitle.String != "Настоящее название" {
|
|
t.Fatalf("RecTitle = %q, want распознанное название", page[0].RecTitle.String)
|
|
}
|
|
}
|
|
|
|
func TestCreateDownloadPersistsDisplayName(t *testing.T) {
|
|
st := newTestStore(t)
|
|
ctx := context.Background()
|
|
|
|
id := mkDownload(t, st, 1, StateDownloading, "Дюна: Часть вторая (2024)")
|
|
d, err := st.GetDownload(ctx, id)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if d.DisplayName != "Дюна: Часть вторая (2024)" {
|
|
t.Fatalf("display_name = %q", d.DisplayName)
|
|
}
|
|
}
|