Приём (Ingest) стал быстрым: синхронно только парс magnet, синтез контекста из полей ссылки, атомарный дедуп и запись загрузки в новое состояние `catched` — ответ клиенту сразу. Медленный вывод имени (LLM) и добавление в qBittorrent вынесены в асинхронный шаг машины состояний, который двигает worker. - store: состояние `catched` (нетерминальное, активная группа); атомарный переход PromoteCatched (catched → downloading + display_name) с гардом state='catched' (ре-валидация после сетевых вызовов вне блокировки) - ingest: убраны namer/qbt из пути приёма; пишем `catched`, отвечаем сразу - worker.processCatched: вне w.mu выводит имя и qbt.Add, под w.mu — короткий переход; сбой add оставляет catched (ретрай тиком); предохранитель catch_timeout → failed(qbit_add)+notify; catched исключён из проверок пропажи - config: worker.catch_timeout (дефолт 10m) - веб-UI: бейдж catched, активная группа, самозавершающийся htmx-поллинг карточки/страницы до перехода в downloading; Telegram-текст без сырого catched - OpenSpec: дельты ingest/download-tracking/web-ui влиты в спеки, change заархивирован Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
153 lines
5.4 KiB
Go
153 lines
5.4 KiB
Go
package worker
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||
)
|
||
|
||
// fakeNamer — вывод имени для шага добавления. onCall позволяет вклиниться в
|
||
// момент (медленного) вывода имени, симулируя параллельную отмену.
|
||
type fakeNamer struct {
|
||
name string
|
||
gotContext string
|
||
onCall func()
|
||
}
|
||
|
||
func (f *fakeNamer) DeriveName(_ context.Context, contextText, _ string) string {
|
||
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)
|
||
}
|
||
}
|
||
|
||
// Транзиентный сбой 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)
|
||
}
|
||
}
|