processCatched перед Add проверяет присутствие торрента в qBittorrent (один листинг на тик): если раздача уже есть — усыновляем (promote catched→downloading без повторного Add и без LLM-namer, имя из раздачи), иначе добавляем как раньше. Это убирает бесконечный цикл дубль-Add → 409 → ретрай и лишние вызовы LLM. Инвариант приёма «одна активная на infohash» делает различие «наш/чужой» ненужным. source_type перечитывается под замком (сужение гонки апгрейда F6); при недоступности qBittorrent тик пропускается без вызова LLM. Дедуп на приёме (дубль на уже активную задачу) теперь отражается явным ответом бота «дубль уже активной #id — добавление отменено». Спека download-tracking обновлена (OpenSpec change заархивирован); закрыта задача беклога review-f2-promote-without-add. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
234 lines
9.1 KiB
Go
234 lines
9.1 KiB
Go
package worker
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||
)
|
||
|
||
// fakeNamer — вывод имени для шага добавления. onCall позволяет вклиниться в
|
||
// момент (медленного) вывода имени, симулируя параллельную отмену.
|
||
type fakeNamer struct {
|
||
name string
|
||
gotContext string
|
||
calls int
|
||
onCall func()
|
||
}
|
||
|
||
func (f *fakeNamer) DeriveName(_ context.Context, contextText, _ string) string {
|
||
f.calls++
|
||
f.gotContext = contextText
|
||
if f.onCall != nil {
|
||
f.onCall()
|
||
}
|
||
return f.name
|
||
}
|
||
|
||
func catchedStore(id, infohash, createdAt, ctxText string) *fakeStore {
|
||
return &fakeStore{downloads: map[string]*store.Download{
|
||
id: {
|
||
ID: id,
|
||
State: store.StateCatched,
|
||
SourceType: store.SourceMagnet,
|
||
SourceRef: "magnet:?xt=urn:btih:" + infohash + "&dn=Dune",
|
||
Infohashes: hashesOf(id, infohash),
|
||
Context: ctxText,
|
||
CreatedAt: createdAt,
|
||
},
|
||
}}
|
||
}
|
||
|
||
const catchedIH = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||
|
||
// now воркера в тестах — 2026-06-14 10:00 UTC (см. newTestWorker).
|
||
var nowStr = store.FormatTime(time.Date(2026, 6, 14, 10, 0, 0, 0, time.UTC))
|
||
|
||
// Успех: выводим имя, добавляем в qBit с rename, переводим catched → downloading
|
||
// и сохраняем display_name.
|
||
func TestProcessCatchedAddsToQbit(t *testing.T) {
|
||
st := catchedStore("1", catchedIH, nowStr, "Дюна 2")
|
||
qb := &fakeQbt{}
|
||
w := newTestWorker(st, qb)
|
||
nm := &fakeNamer{name: "Дюна: Часть вторая (2024)"}
|
||
w.SetNamer(nm)
|
||
|
||
w.processCatched(context.Background())
|
||
|
||
if len(qb.added) != 1 {
|
||
t.Fatalf("qbt.Add calls = %d, want 1", len(qb.added))
|
||
}
|
||
add := qb.added[0]
|
||
if add.Rename != "Дюна: Часть вторая (2024)" {
|
||
t.Errorf("rename = %q", add.Rename)
|
||
}
|
||
if add.Category != "jellybit" || add.URLs[0] != st.downloads["1"].SourceRef {
|
||
t.Errorf("add = %+v", add)
|
||
}
|
||
if nm.gotContext != "Дюна 2" {
|
||
t.Errorf("namer получил контекст %q", nm.gotContext)
|
||
}
|
||
d := st.downloads["1"]
|
||
if d.State != store.StateDownloading {
|
||
t.Errorf("state = %q, want downloading", d.State)
|
||
}
|
||
if d.DisplayName != "Дюна: Часть вторая (2024)" {
|
||
t.Errorf("display_name = %q", d.DisplayName)
|
||
}
|
||
}
|
||
|
||
// Торрент пойманной загрузки уже присутствует в qBittorrent (добавлен ранее
|
||
// вручную/другим клиентом) → усыновляем: promote в downloading без повторного
|
||
// Add (и без 409) и без namer; имя берём из раздачи снимка.
|
||
func TestProcessCatchedPresentAdopts(t *testing.T) {
|
||
st := catchedStore("1", catchedIH, nowStr, "Дюна 2")
|
||
qb := &fakeQbt{torrents: []qbt.Torrent{
|
||
{Hash: catchedIH, Name: "Dune.2024.1080p"}, // без нашей категории — добавлен вручную
|
||
}}
|
||
w := newTestWorker(st, qb)
|
||
nm := &fakeNamer{name: "не должно вызваться"}
|
||
w.SetNamer(nm)
|
||
|
||
w.processCatched(context.Background())
|
||
|
||
if len(qb.added) != 0 {
|
||
t.Errorf("qbt.Add не должен вызываться для уже присутствующего торрента, calls = %d", len(qb.added))
|
||
}
|
||
if nm.calls != 0 {
|
||
t.Errorf("namer (LLM) не должен вызываться, calls = %d", nm.calls)
|
||
}
|
||
d := st.downloads["1"]
|
||
if d.State != store.StateDownloading {
|
||
t.Errorf("state = %q, want downloading", d.State)
|
||
}
|
||
if d.DisplayName != "Dune.2024.1080p" {
|
||
t.Errorf("display_name = %q, want имя раздачи из снимка", d.DisplayName)
|
||
}
|
||
}
|
||
|
||
// Гонка F6: список catched снят как magnet, но апгрейд до .torrent случился
|
||
// между снимком и re-read под замком. processCatched перечитывает source_type
|
||
// под w.mu, поэтому добавляет файлом (Torrents), а не magnet-ссылкой.
|
||
func TestProcessCatchedReReadsSourceTypeUnderLock(t *testing.T) {
|
||
st := catchedStore("1", catchedIH, nowStr, "ctx") // снят как magnet
|
||
st.torrents = map[string][]byte{}
|
||
qb := &fakeQbt{} // раздачи нет → absent-ветка (обычное добавление)
|
||
// Апгрейд «под носом»: между листингом (снимок magnet) и re-read под замком
|
||
// БД уже стала torrent с сохранёнными байтами.
|
||
qb.onTorrents = func() {
|
||
st.downloads["1"].SourceType = store.SourceTorrent
|
||
st.torrents["1"] = []byte("d4:infod-fake-torrent-bytes-ee")
|
||
}
|
||
w := newTestWorker(st, qb)
|
||
w.SetNamer(&fakeNamer{name: "X"})
|
||
|
||
w.processCatched(context.Background())
|
||
|
||
if len(qb.added) != 1 {
|
||
t.Fatalf("qbt.Add calls = %d, want 1", len(qb.added))
|
||
}
|
||
if len(qb.added[0].Torrents) != 1 {
|
||
t.Errorf("после апгрейда ожидалось добавление файлом (Torrents), got Torrents=%v URLs=%v",
|
||
qb.added[0].Torrents, qb.added[0].URLs)
|
||
}
|
||
if len(qb.added[0].URLs) != 0 {
|
||
t.Errorf("magnet-ссылка не должна использоваться после апгрейда в torrent: %v", qb.added[0].URLs)
|
||
}
|
||
}
|
||
|
||
// Листинг qBittorrent провалился (недоступен) → пойманную не трогаем: остаётся
|
||
// catched, ни namer, ни Add не вызываются (повтор на следующем тике).
|
||
func TestProcessCatchedListErrorKeepsCatched(t *testing.T) {
|
||
st := catchedStore("1", catchedIH, nowStr, "ctx")
|
||
qb := &fakeQbt{torrentsErr: errors.New("connection refused")}
|
||
w := newTestWorker(st, qb)
|
||
nm := &fakeNamer{name: "X"}
|
||
w.SetNamer(nm)
|
||
|
||
w.processCatched(context.Background())
|
||
|
||
if st.downloads["1"].State != store.StateCatched {
|
||
t.Errorf("state = %q, want catched (повтор)", st.downloads["1"].State)
|
||
}
|
||
if nm.calls != 0 || len(qb.added) != 0 {
|
||
t.Errorf("при недоступности qBit namer/Add не должны вызываться: namer=%d add=%d", nm.calls, len(qb.added))
|
||
}
|
||
}
|
||
|
||
// Транзиентный сбой add — остаёмся в catched для повтора на следующем тике.
|
||
func TestProcessCatchedTransientFailureKeepsCatched(t *testing.T) {
|
||
st := catchedStore("1", catchedIH, nowStr, "ctx")
|
||
qb := &fakeQbt{addErr: errors.New("connection refused")}
|
||
w := newTestWorker(st, qb)
|
||
w.SetNamer(&fakeNamer{name: "X"})
|
||
|
||
w.processCatched(context.Background())
|
||
|
||
if st.downloads["1"].State != store.StateCatched {
|
||
t.Errorf("state = %q, want catched (повтор)", st.downloads["1"].State)
|
||
}
|
||
}
|
||
|
||
// Предохранитель: catched старше catch_timeout → failed (qbit_add) + уведомление;
|
||
// add при этом не вызывается.
|
||
func TestProcessCatchedTimeoutFails(t *testing.T) {
|
||
old := store.FormatTime(time.Date(2026, 6, 14, 9, 0, 0, 0, time.UTC)) // 1 час до now
|
||
st := catchedStore("1", catchedIH, old, "ctx")
|
||
qb := &fakeQbt{}
|
||
w := newTestWorker(st, qb)
|
||
w.cfg.CatchTimeout = 10 * time.Minute
|
||
n := &recordingNotifier{ch: make(chan notifyEvent, 1)}
|
||
w.SetNotifier(n)
|
||
|
||
w.processCatched(context.Background())
|
||
|
||
d := st.downloads["1"]
|
||
if d.State != store.StateFailed || d.ErrorCode.String != errCodeQbitAdd {
|
||
t.Errorf("state = %q code = %q, want failed/qbit_add", d.State, d.ErrorCode.String)
|
||
}
|
||
if len(qb.added) != 0 {
|
||
t.Errorf("add не должен вызываться при таймауте, calls = %d", len(qb.added))
|
||
}
|
||
if e := waitNotify(t, n); e.ev != EventFailed {
|
||
t.Errorf("событие = %q, want failed", e.ev)
|
||
}
|
||
}
|
||
|
||
// Ре-валидация: если во время сетевых вызовов (вне блокировки) задачу отменили,
|
||
// переход в downloading не применяется — состояние остаётся cancelled.
|
||
func TestProcessCatchedCancelledDuringAddSkipsPromote(t *testing.T) {
|
||
st := catchedStore("1", catchedIH, nowStr, "ctx")
|
||
qb := &fakeQbt{}
|
||
w := newTestWorker(st, qb)
|
||
// namer имитирует параллельную отмену во время (медленного) вывода имени.
|
||
nm := &fakeNamer{name: "X", onCall: func() { st.downloads["1"].State = store.StateCancelled }}
|
||
w.SetNamer(nm)
|
||
|
||
w.processCatched(context.Background())
|
||
|
||
if len(qb.added) != 1 {
|
||
t.Fatal("add должен был вызваться (сеть идёт вне замка)")
|
||
}
|
||
if st.downloads["1"].State != store.StateCancelled {
|
||
t.Errorf("ре-валидация не сработала: state = %q, want cancelled", st.downloads["1"].State)
|
||
}
|
||
}
|
||
|
||
// Поллинг активных (downloading) не трогает catched: раздачи в qBittorrent у
|
||
// пойманной загрузки ещё нет по дизайну, это не «пропажа».
|
||
func TestPollIgnoresCatched(t *testing.T) {
|
||
st := catchedStore("1", catchedIH, nowStr, "ctx")
|
||
qb := &fakeQbt{} // раздач нет
|
||
w := newTestWorker(st, qb)
|
||
|
||
if err := w.Poll(context.Background()); err != nil {
|
||
t.Fatalf("Poll: %v", err)
|
||
}
|
||
if st.downloads["1"].State != store.StateCatched {
|
||
t.Errorf("catched тронут поллингом: %q", st.downloads["1"].State)
|
||
}
|
||
}
|