Три мелких фикса из 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>
242 lines
9.0 KiB
Go
242 lines
9.0 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[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)
|
||
}
|
||
}
|