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[string]*store.Download{}} } // findByInfohash возвращает усыновлённую задачу по infohash. func findByInfohash(st *fakeStore, infohash string) *store.Download { for _, d := range st.downloads { if hasAnyHash(d, []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 len(d.Infohashes) != 1 || d.Infohashes[0].Kind != store.HashV1 { t.Errorf("infohashes = %+v", d.Infohashes) } // Усыновление берёт заголовок из имени торрента 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, Infohashes: hashesOf("1", 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[string]*store.Download{ "1": {ID: "1", State: store.StateDownloading, SourceType: store.SourceMagnet, Infohashes: hashesOf("1", 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 TestTorrentHashes(t *testing.T) { got := torrentHashes(qbt.Torrent{Hash: "ABC", InfohashV1: "abc", InfohashV2: "DEF"}) if len(got) != 2 || got[0] != "abc" || got[1] != "def" { t.Errorf("got %v, want [abc def] (lowercase, без дублей, v1 первым)", got) } if got := torrentHashes(qbt.Torrent{}); len(got) != 0 { t.Errorf("got %v, want empty", got) } // Старый qBittorrent без infohash_v1/v2 — берём hash. if got := torrentHashes(qbt.Torrent{Hash: "ABC"}); len(got) != 1 || got[0] != "abc" { t.Errorf("legacy hash: got %v, want [abc]", got) } // v2-only: t.Hash — УСЕЧЁННЫЙ v2 (40 hex), хранить его нельзя. const v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" got = torrentHashes(qbt.Torrent{Hash: v2[:40], InfohashV2: v2}) if len(got) != 1 || got[0] != v2 { t.Errorf("v2-only: got %v, want только полный v2", got) } } // Усыновление v2-only раздачи: SourceRef — валидный btmh-magnet из полного // v2-хеша (не битый btih из усечённого), kind в БД — v2. func TestDiscover_AdoptsV2Only(t *testing.T) { const v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" st := emptyStore() w := newTestWorker(st, &fakeQbt{}) w.discover(context.Background(), []qbt.Torrent{ {Hash: v2[:40], InfohashV2: v2, Name: "V2Only", Category: "jellybit", State: "downloading"}, }) d := findByInfohash(st, v2) if d == nil { t.Fatal("v2-only раздача не усыновлена") } if d.SourceRef != "magnet:?xt=urn:btmh:1220"+v2 { t.Errorf("SourceRef = %q, want btmh с полным v2", d.SourceRef) } if len(d.Infohashes) != 1 || d.Infohashes[0].Kind != store.HashV2 { t.Errorf("infohashes = %+v, want один v2 (усечённый не хранится)", d.Infohashes) } } // Усыновление гибридного торрента записывает оба хеша. func TestDiscover_AdoptsBothHashes(t *testing.T) { const v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" st := emptyStore() w := newTestWorker(st, &fakeQbt{}) w.discover(context.Background(), []qbt.Torrent{ {Hash: ihDisc, InfohashV1: ihDisc, InfohashV2: v2, Name: "Hybrid", Category: "jellybit", State: "downloading"}, }) d := findByInfohash(st, v2) if d == nil { t.Fatal("гибридная раздача не находится по v2-хешу") } if len(d.Infohashes) != 2 { t.Errorf("infohashes = %+v, want v1+v2", d.Infohashes) } }