Files
jellybit/internal/worker/discover_test.go
T
avandClaude Opus 4.8 c0b5ab7295 UI/UX списка и карточки загрузки: серверные фильтр/поиск/пагинация, матч-ссылка, имя раздачи (web-ui-list-detail)
- Список: серверные фильтр по группе состояний, поиск и пагинация (GET
  f/q/page/all, по 25), сортировка по времени добавления в qBittorrent
  (added_on) с фолбеком на created_at и tie-break по id.
- Заголовок загрузки = имя раздачи (display_name) → распознанное название →
  усечённый источник; сырой magnet вынесен в блок «Информация о торренте».
- Матч метабазы показан ссылкой на запись (страница загрузки и ревью);
  URL берётся у выбранного кандидата либо строится по provider+id и типу.
- Полировка вёрстки; клиентская JS-фильтрация убрана (всё серверное, без JS).
- Миграция 0005 (display_name, source_added_at); воркер однократно
  фиксирует source_added_at при поллинге/усыновлении; ER-схема обновлена.
- OpenSpec: дельты влиты в specs/{web-ui,ingest}, change заархивирован.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 09:50:24 +03:00

174 lines
5.6 KiB
Go

package worker
import (
"context"
"testing"
"git.vakhrushev.me/av/jellybit/internal/qbt"
"git.vakhrushev.me/av/jellybit/internal/store"
)
const ihDisc = "7931aa3ed6666746012f5739d099b5bc64d72a16"
func emptyStore() *fakeStore {
return &fakeStore{downloads: map[int64]*store.Download{}}
}
// findByInfohash возвращает усыновлённую задачу по infohash.
func findByInfohash(st *fakeStore, infohash string) *store.Download {
for _, d := range st.downloads {
if d.Infohash.String == infohash {
return d
}
}
return nil
}
func TestDiscover_AdoptsByCategory(t *testing.T) {
st := emptyStore()
w := newTestWorker(st, &fakeQbt{})
w.discover(context.Background(), []qbt.Torrent{
{Hash: ihDisc, Name: "Avatar", Category: "jellybit", State: "stalledUP", AddedOn: 1_700_000_000},
})
d := findByInfohash(st, ihDisc)
if d == nil {
t.Fatal("раздача с категорией jellybit не усыновлена")
}
if d.State != store.StateDownloading || d.SourceType != store.SourceMagnet {
t.Errorf("adopted = %+v", d)
}
if d.IdempotencyKey.String != ihDisc {
t.Errorf("idempotency_key = %q", d.IdempotencyKey.String)
}
// Усыновление берёт заголовок из имени торрента qBittorrent и фиксирует
// время добавления (added_on) как базис сортировки.
if d.DisplayName != "Avatar" {
t.Errorf("display_name = %q, want Avatar", d.DisplayName)
}
if !d.SourceAddedAt.Valid {
t.Errorf("source_added_at не зафиксирован при усыновлении")
}
}
func TestDiscover_AdoptsByTag(t *testing.T) {
st := emptyStore()
w := newTestWorker(st, &fakeQbt{})
w.cfg.Tag = "jellybit"
// Категория чужая, но тег наш — усыновляем (не трогая категорию).
w.discover(context.Background(), []qbt.Torrent{
{Hash: ihDisc, Name: "Fargo", Category: "movies", Tags: "hd, jellybit, rus", State: "uploading"},
})
if findByInfohash(st, ihDisc) == nil {
t.Fatal("раздача с тегом jellybit не усыновлена")
}
}
func TestDiscover_SkipsUntracked(t *testing.T) {
st := emptyStore()
w := newTestWorker(st, &fakeQbt{})
w.cfg.Tag = "jellybit"
w.discover(context.Background(), []qbt.Torrent{
{Hash: ihDisc, Category: "movies", Tags: "hd, rus"},
})
if len(st.downloads) != 0 {
t.Errorf("чужая раздача не должна усыновляться: %+v", st.downloads)
}
}
func TestDiscover_SkipsExisting(t *testing.T) {
st := emptyStore()
// Уже есть задача (напр. терминальная done) — не переусыновляем.
st.downloads[1] = &store.Download{
ID: 1, State: store.StateDone, Infohash: store.NullString(ihDisc),
}
w := newTestWorker(st, &fakeQbt{})
w.discover(context.Background(), []qbt.Torrent{
{Hash: ihDisc, Category: "jellybit"},
})
if len(st.downloads) != 1 {
t.Errorf("существующий infohash не должен порождать новую задачу: %d", len(st.downloads))
}
}
func TestDiscover_SkipsNoInfohash(t *testing.T) {
st := emptyStore()
w := newTestWorker(st, &fakeQbt{})
w.discover(context.Background(), []qbt.Torrent{{Category: "jellybit"}})
if len(st.downloads) != 0 {
t.Error("без infohash усыновлять нечего")
}
}
// TestPoll_AdoptsAndCompletes — сценарий пользователя целиком: помеченная и
// уже скачанная раздача за один тик усыновляется и доходит до completed.
func TestPoll_CapturesSourceAddedAt(t *testing.T) {
ih := "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"
st := &fakeStore{downloads: map[int64]*store.Download{
1: {ID: 1, State: store.StateDownloading, SourceType: store.SourceMagnet,
Infohash: store.NullString(ih), IdempotencyKey: store.NullString(ih)},
}}
qb := &fakeQbt{torrents: []qbt.Torrent{
{Hash: ih, Name: "X", Category: "jellybit", State: "downloading", AddedOn: 1_700_000_000},
}}
w := newTestWorker(st, qb)
if err := w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
if d := st.downloads[1]; !d.SourceAddedAt.Valid {
t.Fatalf("source_added_at не захвачен при поллинге активной задачи")
}
}
func TestPoll_AdoptsAndCompletes(t *testing.T) {
st := emptyStore()
qb := &fakeQbt{torrents: []qbt.Torrent{
{Hash: ihDisc, Name: "Avatar", Category: "other", Tags: "jellybit", State: "stalledUP"},
}}
w := newTestWorker(st, qb)
w.cfg.Tag = "jellybit"
if err := w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
d := findByInfohash(st, ihDisc)
if d == nil {
t.Fatal("не усыновлено")
}
if d.State != store.StateCompleted {
t.Errorf("state = %q, want completed (готовая раздача)", d.State)
}
}
func TestHasTag(t *testing.T) {
cases := []struct {
tags, tag string
want bool
}{
{"jellybit", "jellybit", true},
{"hd, jellybit, rus", "jellybit", true},
{"hd,rus", "jellybit", false},
{"jellybit-extra", "jellybit", false},
{"", "jellybit", false},
{"jellybit", "", false},
}
for _, c := range cases {
if got := hasTag(c.tags, c.tag); got != c.want {
t.Errorf("hasTag(%q,%q) = %v, want %v", c.tags, c.tag, got, c.want)
}
}
}
func TestFirstInfohash(t *testing.T) {
if got := firstInfohash(qbt.Torrent{Hash: "ABC"}); got != "abc" {
t.Errorf("got %q", got)
}
if got := firstInfohash(qbt.Torrent{InfohashV2: "DEF"}); got != "def" {
t.Errorf("got %q", got)
}
if got := firstInfohash(qbt.Torrent{}); got != "" {
t.Errorf("got %q, want empty", got)
}
}