Реализация, фаза 1: добавление данных в qbittorrent
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
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) 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
|
||||
}
|
||||
|
||||
type fakeQbt struct {
|
||||
torrents []qbt.Torrent
|
||||
added []qbt.AddRequest
|
||||
}
|
||||
|
||||
func (f *fakeQbt) Torrents(_ context.Context, _ string) ([]qbt.Torrent, error) {
|
||||
return f.torrents, nil
|
||||
}
|
||||
|
||||
func (f *fakeQbt) Add(_ context.Context, ar qbt.AddRequest) error {
|
||||
f.added = append(f.added, ar)
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTestWorker(st *fakeStore, qb *fakeQbt) *Worker {
|
||||
w := New(st, qb, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user