package worker import ( "context" "errors" "fmt" "io" "log/slog" "testing" "time" "git.vakhrushev.me/av/jellybit/internal/qbt" "git.vakhrushev.me/av/jellybit/internal/store" ) // фиксированные метки времени для детерминированных таймаут-тестов. const ( timeNow = "2026-06-14T10:00:00Z" timeOld = "2026-06-14T08:00:00Z" // 2 часа назад timeRecent = "2026-06-14T09:59:00Z" // 1 минута назад ) type fakeStore struct { downloads map[string]*store.Download transitions []transition } type transition struct { id string 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) ListRecoverable(_ context.Context, codes ...string) ([]store.Download, error) { var out []store.Download for _, d := range f.downloads { if d.State != store.StateFailed && d.State != store.StateStuck { continue } for _, c := range codes { if d.ErrorCode.Valid && d.ErrorCode.String == c { out = append(out, *d) break } } } return out, nil } func (f *fakeStore) GetDownload(_ context.Context, id string) (*store.Download, error) { d, ok := f.downloads[id] if !ok { return nil, fmt.Errorf("download %s not found", id) } cp := *d return &cp, nil } // hasAnyHash сообщает, владеет ли загрузка любым из hashes. func hasAnyHash(d *store.Download, hashes []string) bool { for _, own := range d.Infohashes { for _, h := range hashes { if own.Infohash == store.NormalizeHash(h) { return true } } } return false } func (f *fakeStore) ExistsByInfohash(_ context.Context, hashes ...string) (bool, error) { for _, d := range f.downloads { if hasAnyHash(d, hashes) { return true, nil } } return false, nil } func (f *fakeStore) FindActiveByInfohash(_ context.Context, hashes ...string) (*store.Download, error) { for _, d := range f.downloads { if hasAnyHash(d, hashes) && !d.State.IsTerminal() { cp := *d return &cp, nil } } return nil, nil } func (f *fakeStore) CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string) (*store.Download, error) { if existing, _ := f.FindActiveByInfohash(ctx, hashes...); existing != nil { return existing, nil } id := fmt.Sprintf("%d", len(f.downloads)+1) cp := *d cp.ID = id for _, h := range hashes { h = store.NormalizeHash(h) cp.Infohashes = append(cp.Infohashes, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)}) } f.downloads[id] = &cp d.ID = id d.Infohashes = cp.Infohashes return nil, nil } func (f *fakeStore) ActivateIfNoOtherActive(ctx context.Context, id string, st store.State, code, msg string) error { d, ok := f.downloads[id] if !ok { return fmt.Errorf("download %s not found", id) } for _, other := range f.downloads { if other.ID != id && !other.State.IsTerminal() && hasAnyHash(other, hashList(d)) { return fmt.Errorf("activate %s: %w", id, store.ErrInfohashTaken) } } return f.SetDownloadState(ctx, id, st, code, msg) } func hashList(d *store.Download) []string { out := make([]string, len(d.Infohashes)) for i, h := range d.Infohashes { out[i] = h.Infohash } return out } func (f *fakeStore) AddInfohashes(_ context.Context, id string, hashes []string) error { d, ok := f.downloads[id] if !ok { return fmt.Errorf("download %s not found", id) } for _, h := range hashes { h = store.NormalizeHash(h) if !hasAnyHash(d, []string{h}) { d.Infohashes = append(d.Infohashes, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)}) } } return nil } func (f *fakeStore) SetDownloadState(_ context.Context, id string, st store.State, code, msg string) error { d, ok := f.downloads[id] if !ok { return fmt.Errorf("download %s 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 } func (f *fakeStore) SetSourceMissCount(_ context.Context, id string, n int) error { d, ok := f.downloads[id] if !ok { return fmt.Errorf("download %s not found", id) } d.SourceMissCount = n return nil } func (f *fakeStore) SetSourceAddedAt(_ context.Context, id string, t time.Time) error { d, ok := f.downloads[id] if !ok { return fmt.Errorf("download %s not found", id) } if !d.SourceAddedAt.Valid { // гард как в store: пишем однократно d.SourceAddedAt = store.NullString(store.FormatTime(t)) } return nil } // --- Ф3-методы Store (заглушки; переопределяются в review_test.go) --- func (f *fakeStore) CreateRecognition(_ context.Context, _ *store.Recognition, _ []string) (string, error) { return "", nil } func (f *fakeStore) GetCurrentRecognition(_ context.Context, _ string) (*store.Recognition, error) { return nil, nil } func (f *fakeStore) AddHint(_ context.Context, _ string, _ string) error { return nil } func (f *fakeStore) ListHints(_ context.Context, _ string) ([]string, error) { return nil, nil } func (f *fakeStore) SetOverride(_ context.Context, _ string, _, _ string) error { return nil } func (f *fakeStore) ListOverrides(_ context.Context, _ string) (map[string]string, error) { return nil, nil } func (f *fakeStore) CreateFileLinks(_ context.Context, _ []store.FileLink) error { return nil } func (f *fakeStore) SupersedeForeignLinks(_ context.Context, _ string, _ []string) error { return nil } func (f *fakeStore) LatestBatchID(_ context.Context, _ string) (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, _ string) ([]store.MetadataCandidate, error) { return nil, nil } func (f *fakeStore) GetCandidate(_ context.Context, _ string) (*store.MetadataCandidate, error) { return nil, nil } func (f *fakeStore) SetCandidateChosen(_ context.Context, _, _ string) error { return nil } type fakeQbt struct { torrents []qbt.Torrent added []qbt.AddRequest addErr error 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 { if f.addErr != nil { return f.addErr } 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[string]*store.Download{ "1": { ID: "1", State: store.StateDownloading, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:" + infohash, Infohashes: hashesOf("1", infohash), CreatedAt: createdAt, }, }} } // hashesOf — срез хешей загрузки для литералов фикстур. func hashesOf(id string, hashes ...string) []store.Infohash { out := make([]store.Infohash, 0, len(hashes)) for _, h := range hashes { h = store.NormalizeHash(h) out = append(out, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)}) } return out } 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)) } } // Retry при занятом хеше отклоняется ДО побочного эффекта: торрент не // добавляется в qBittorrent повторно. func TestRetryConflictNoAdd(t *testing.T) { const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6" st := oneDownloading(ih, timeRecent) st.downloads["1"].State = store.StateFailed st.downloads["1"].ErrorCode = store.NullString("magnet_timeout") // Хешем владеет другая активная задача. st.downloads["2"] = &store.Download{ ID: "2", State: store.StateDownloading, SourceType: store.SourceMagnet, Infohashes: hashesOf("2", ih), CreatedAt: timeRecent, } qb := &fakeQbt{} // торрента в qBittorrent нет — без гарда был бы Add w := newTestWorker(st, qb) if err := w.Retry(context.Background(), "1"); !errors.Is(err, ErrConflict) { t.Fatalf("ожидался ErrConflict, получили %v", err) } if len(qb.added) != 0 { t.Errorf("торрент добавлен побочным эффектом отклонённого retry: %d Add", len(qb.added)) } if st.downloads["1"].State != store.StateFailed { t.Errorf("state = %s, want failed", st.downloads["1"].State) } } // Если после активации повторный Add в qBittorrent упал — задача // откатывается в прежнее состояние, а не остаётся «качающейся» без раздачи. func TestRetryRollsBackOnAddFailure(t *testing.T) { const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6" st := oneDownloading(ih, timeRecent) st.downloads["1"].State = store.StateFailed st.downloads["1"].ErrorCode = store.NullString("magnet_timeout") qb := &fakeQbt{addErr: fmt.Errorf("connection refused")} w := newTestWorker(st, qb) if err := w.Retry(context.Background(), "1"); err == nil { t.Fatal("ожидалась ошибка Add") } if st.downloads["1"].State != store.StateFailed { t.Errorf("state = %s, want failed (откат)", st.downloads["1"].State) } if st.downloads["1"].ErrorCode.String != "magnet_timeout" { t.Errorf("error_code = %q, want magnet_timeout (восстановлен)", st.downloads["1"].ErrorCode.String) } } 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) } } }