Files

296 lines
9.7 KiB
Go

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