Files
jellybit/internal/worker/discover_test.go
T
avandClaude Opus 4.8 bcc7b2d76b Жизненный цикл: claim-токен распознавания, индексация хешей, retry сломанного торрента
Три мелких фикса из docs/backlog/review-lifecycle-minor.md (ревью Fable
2026-07-08). MINOR-9 (I/O под глобальным w.mu) осознанно waive для
one-user home-сервера — не трогаем.

MINOR-8: claim-токен распознавания. recognizeOne фиксирует updated_at на
момент claim (перечитывая запись после перехода в recognizing), а
finishRecognition коммитит результат, только если токен совпал. Иначе за
время LLM-вызова задачу увели из recognizing и вернули обратно
(cancel → relink revive) — это уже другой эпизод, устаревший результат
отбрасываем, задача остаётся в recognizing для перезапуска поллингом.

NIT-11: lookup-мапы (byHash/live/torrentByInfohash) больше не индексируют
усечённый 40-hex t.Hash v2-only торрентов. Новый хелпер torrentIndexHashes
зеркалит выбор torrentHashes: t.Hash берём только при отсутствии обоих
infohash_v1/v2. Убирает теоретический ложный матч по коллизии длины.

NIT-12: retry живого, но сломанного торрента (error/missingFiles) теперь
отклоняется с подсказкой починить раздачу (recheck) в qBittorrent, вместо
бессмысленной переотдачи источника (сверка тут же вернула бы задачу в
failed). Повторный Add — только когда раздачи в qBittorrent нет. Меняет
спеку state-reconciliation → дельта openspec/changes/2026-07-17-retry-reject-broken-torrent
(не архивировал).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 20:35:14 +03:00

242 lines
9.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
// TestTorrentIndexHashes — NIT-11: lookup-мапы индексируют раздачу по её хешам,
// но усечённый 40-hex t.Hash v2-only торрента исключаем (по длине неотличим от v1).
func TestTorrentIndexHashes(t *testing.T) {
// Гибрид: индексируем оба infohash, t.Hash игнорируем.
got := torrentIndexHashes(qbt.Torrent{Hash: "trunc", InfohashV1: "v1", InfohashV2: "v2"})
if len(got) != 2 || got[0] != "v1" || got[1] != "v2" {
t.Errorf("гибрид: got %v, want [v1 v2] (t.Hash исключён)", got)
}
// Старый qBittorrent без infohash_v1/v2 — берём hash (это полный v1).
if got := torrentIndexHashes(qbt.Torrent{Hash: "legacy"}); len(got) != 1 || got[0] != "legacy" {
t.Errorf("legacy: got %v, want [legacy]", got)
}
// v2-only: усечённый t.Hash не индексируем, только полный v2.
const v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
if got := torrentIndexHashes(qbt.Torrent{Hash: v2[:40], InfohashV2: v2}); len(got) != 1 || got[0] != v2 {
t.Errorf("v2-only: got %v, want [%s] (усечённый t.Hash исключён)", got, v2)
}
if got := torrentIndexHashes(qbt.Torrent{}); len(got) != 0 {
t.Errorf("пустой: got %v, want empty", 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)
}
}