package worker import ( "context" "encoding/json" "errors" "testing" "time" "git.vakhrushev.me/av/jellybit/internal/naming" "git.vakhrushev.me/av/jellybit/internal/qbt" "git.vakhrushev.me/av/jellybit/internal/store" ) // fakeNamer — вывод имени для шага добавления. onCall позволяет вклиниться в // момент (медленного) вывода имени, симулируя параллельную отмену. type fakeNamer struct { name string fields naming.Fields // извлечённая структура (ok=true, если Title непуст) gotContext string calls int onCall func() } func (f *fakeNamer) Derive(_ context.Context, contextText, _ string) (string, naming.Fields, bool) { f.calls++ f.gotContext = contextText if f.onCall != nil { f.onCall() } return f.name, f.fields, f.fields.Title != "" } 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)", fields: naming.Fields{Type: "movie", Title: "Дюна: Часть вторая", Director: "Дени Вильнёв", Year: 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) } // Извлечённая структура сохранена как parsed_context (базовый слой полей имени). if d.ParsedContext == "" { t.Error("parsed_context пуст, ожидалась сохранённая структура") } var pf naming.Fields if err := json.Unmarshal([]byte(d.ParsedContext), &pf); err != nil { t.Fatalf("parsed_context не JSON: %v", err) } if pf.Director != "Дени Вильнёв" { t.Errorf("parsed_context.director = %q, want «Дени Вильнёв»", pf.Director) } } // Торрент пойманной загрузки уже присутствует в 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) } }