Files
jellybit/internal/worker/catched_test.go
T
avandClaude Opus 4.8 b8657120fe Приём: усыновление присутствующего в qBittorrent торрента вместо дубль-Add (409)
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>
2026-07-10 18:36:47 +03:00

234 lines
9.1 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"
"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)
}
}