Files
jellybit/internal/worker/catched_test.go
T
avandClaude Opus 4.8 0d263270cb Быстрый приём: сохранение в catched, добавление в qBittorrent — шаг worker'а
Приём (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>
2026-07-07 21:29:28 +03:00

153 lines
5.4 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/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)
}
}