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) } }