package worker import ( "context" "fmt" "io" "log/slog" "testing" "time" "git.vakhrushev.me/av/jellybit/internal/qbt" "git.vakhrushev.me/av/jellybit/internal/store" ) // фиксированные метки времени для детерминированных таймаут-тестов. const ( timeNow = "2026-06-14 10:00:00" timeOld = "2026-06-14 08:00:00" // 2 часа назад timeRecent = "2026-06-14 09:59:00" // 1 минута назад ) type fakeStore struct { downloads map[int64]*store.Download transitions []transition } type transition struct { id int64 state store.State } func (f *fakeStore) ListDownloadsByState(_ context.Context, states ...store.State) ([]store.Download, error) { var out []store.Download for _, d := range f.downloads { for _, s := range states { if d.State == s { out = append(out, *d) break } } } return out, nil } func (f *fakeStore) GetDownload(_ context.Context, id int64) (*store.Download, error) { d, ok := f.downloads[id] if !ok { return nil, fmt.Errorf("download %d not found", id) } cp := *d return &cp, nil } func (f *fakeStore) ExistsByInfohash(_ context.Context, infohash string) (bool, error) { for _, d := range f.downloads { if d.Infohash.Valid && d.Infohash.String == infohash { return true, nil } } return false, nil } func (f *fakeStore) FindActiveByInfohash(_ context.Context, infohash string) (*store.Download, error) { for _, d := range f.downloads { if d.Infohash.Valid && d.Infohash.String == infohash && !d.State.IsTerminal() { cp := *d return &cp, nil } } return nil, nil } func (f *fakeStore) CreateDownload(_ context.Context, d *store.Download) (int64, error) { id := int64(len(f.downloads) + 1) cp := *d cp.ID = id f.downloads[id] = &cp return id, nil } func (f *fakeStore) SetDownloadState(_ context.Context, id int64, st store.State, code, msg string) error { d, ok := f.downloads[id] if !ok { return fmt.Errorf("download %d not found", id) } d.State = st d.ErrorCode = store.NullString(code) d.ErrorMsg = store.NullString(msg) f.transitions = append(f.transitions, transition{id, st}) return nil } // --- Ф3-методы Store (заглушки; переопределяются в review_test.go) --- func (f *fakeStore) CreateRecognition(_ context.Context, _ *store.Recognition, _ []string) (int64, error) { return 0, nil } func (f *fakeStore) GetCurrentRecognition(_ context.Context, _ int64) (*store.Recognition, error) { return nil, nil } func (f *fakeStore) AddHint(_ context.Context, _ int64, _ string) error { return nil } func (f *fakeStore) ListHints(_ context.Context, _ int64) ([]string, error) { return nil, nil } func (f *fakeStore) SetOverride(_ context.Context, _ int64, _, _ string) error { return nil } func (f *fakeStore) ListOverrides(_ context.Context, _ int64) (map[string]string, error) { return nil, nil } func (f *fakeStore) CreateFileLinks(_ context.Context, _ []store.FileLink) error { return nil } func (f *fakeStore) LatestBatchID(_ context.Context, _ int64) (string, error) { return "", nil } func (f *fakeStore) ListFileLinksByBatch(_ context.Context, _ string) ([]store.FileLink, error) { return nil, nil } func (f *fakeStore) DeleteFileLinksByBatch(_ context.Context, _ string) error { return nil } func (f *fakeStore) CreateCandidates(_ context.Context, _ []store.MetadataCandidate) error { return nil } func (f *fakeStore) ListCandidatesByRecognition(_ context.Context, _ int64) ([]store.MetadataCandidate, error) { return nil, nil } func (f *fakeStore) GetCandidate(_ context.Context, _ int64) (*store.MetadataCandidate, error) { return nil, nil } func (f *fakeStore) SetCandidateChosen(_ context.Context, _, _ int64) error { return nil } type fakeQbt struct { torrents []qbt.Torrent added []qbt.AddRequest files []qbt.File } // Torrents имитирует /torrents/info: пустая категория — все торренты, иначе // только торренты этой категории (как реальный qBittorrent). Это важно для // регрессии: раздача, усыновлённая по тегу, имеет чужую категорию и не должна // теряться при поиске по infohash. func (f *fakeQbt) Torrents(_ context.Context, category string) ([]qbt.Torrent, error) { if category == "" { return f.torrents, nil } var out []qbt.Torrent for _, t := range f.torrents { if t.Category == category { out = append(out, t) } } return out, nil } func (f *fakeQbt) Add(_ context.Context, ar qbt.AddRequest) error { f.added = append(f.added, ar) return nil } func (f *fakeQbt) Files(_ context.Context, _ string) ([]qbt.File, error) { return f.files, nil } func newTestWorker(st *fakeStore, qb *fakeQbt) *Worker { w := New(st, qb, nil, nil, Config{ Category: "jellybit", SavePath: "/srv/media/downloads", MagnetTimeout: 30 * time.Minute, StuckAfter: time.Hour, }, slog.New(slog.NewTextHandler(io.Discard, nil))) w.now = func() time.Time { return time.Date(2026, 6, 14, 10, 0, 0, 0, time.UTC) } return w } func oneDownloading(infohash, createdAt string) *fakeStore { return &fakeStore{downloads: map[int64]*store.Download{ 1: { ID: 1, State: store.StateDownloading, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:" + infohash, Infohash: store.NullString(infohash), CreatedAt: createdAt, }, }} } func TestPollTransitions(t *testing.T) { const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6" tests := []struct { name string qbitState string createdAt string want store.State }{ {"готов → completed", "uploading", timeRecent, store.StateCompleted}, {"stalledUP → completed", "stalledUP", timeRecent, store.StateCompleted}, {"ошибка → failed", "error", timeRecent, store.StateFailed}, {"missingFiles → failed", "missingFiles", timeRecent, store.StateFailed}, {"metaDL долго → failed", "metaDL", timeOld, store.StateFailed}, {"stalledDL долго → stuck", "stalledDL", timeOld, store.StateStuck}, {"свежий downloading → остаётся", "downloading", timeRecent, store.StateDownloading}, {"moving → остаётся", "moving", timeRecent, store.StateDownloading}, {"свежий metaDL → остаётся", "metaDL", timeRecent, store.StateDownloading}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { st := oneDownloading(ih, tc.createdAt) qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: tc.qbitState}}} w := newTestWorker(st, qb) if err := w.Poll(context.Background()); err != nil { t.Fatalf("Poll: %v", err) } if got := st.downloads[1].State; got != tc.want { t.Errorf("state = %q, want %q", got, tc.want) } }) } } func TestPollMatchesByInfohashV2(t *testing.T) { const v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" st := oneDownloading(v2, timeRecent) qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: "deadbeef", InfohashV2: v2, State: "uploading"}}} w := newTestWorker(st, qb) if err := w.Poll(context.Background()); err != nil { t.Fatal(err) } if st.downloads[1].State != store.StateCompleted { t.Errorf("сопоставление по infohash_v2 не сработало: %q", st.downloads[1].State) } } func TestPollIgnoresMissingTorrent(t *testing.T) { st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent) qb := &fakeQbt{torrents: nil} // торрента в qBittorrent нет w := newTestWorker(st, qb) if err := w.Poll(context.Background()); err != nil { t.Fatal(err) } if st.downloads[1].State != store.StateDownloading { t.Errorf("без торрента состояние не должно меняться, got %q", st.downloads[1].State) } } func TestCancel(t *testing.T) { st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent) w := newTestWorker(st, &fakeQbt{}) if err := w.Cancel(context.Background(), 1); err != nil { t.Fatalf("Cancel: %v", err) } if st.downloads[1].State != store.StateCancelled { t.Errorf("state = %q, want cancelled", st.downloads[1].State) } // Повторная отмена терминальной задачи — ошибка. if err := w.Cancel(context.Background(), 1); err == nil { t.Error("ожидалась ошибка при отмене терминальной задачи") } } func TestRetry(t *testing.T) { st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent) st.downloads[1].State = store.StateStuck qb := &fakeQbt{} w := newTestWorker(st, qb) if err := w.Retry(context.Background(), 1); err != nil { t.Fatalf("Retry: %v", err) } if st.downloads[1].State != store.StateDownloading { t.Errorf("state = %q, want downloading", st.downloads[1].State) } if len(qb.added) != 1 { t.Errorf("ожидалось повторное добавление в qBittorrent, got %d", len(qb.added)) } } func TestRetryRejectsActive(t *testing.T) { st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent) w := newTestWorker(st, &fakeQbt{}) if err := w.Retry(context.Background(), 1); err == nil { t.Error("retry активной (downloading) задачи должен отклоняться") } } func TestClassify(t *testing.T) { cases := map[string]class{ "uploading": classReady, "stalledUP": classReady, "stoppedUP": classReady, "error": classErrored, "missingFiles": classErrored, "moving": classBusy, "checkingUP": classBusy, "downloading": classDownloading, "metaDL": classDownloading, "stalledDL": classDownloading, } for state, want := range cases { if got := classify(state); got != want { t.Errorf("classify(%q) = %d, want %d", state, got, want) } } }