mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-19 01:51:11 +08:00
2187 lines
70 KiB
Go
2187 lines
70 KiB
Go
package downloader
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/saltbo/zpan/internal/client"
|
|
"github.com/saltbo/zpan/internal/config"
|
|
)
|
|
|
|
func TestCancelRunningUsesCauseForControlState(t *testing.T) {
|
|
cases := []struct {
|
|
state string
|
|
want error
|
|
}{
|
|
{"pausing", errTaskPausing},
|
|
{"canceling", errTaskCanceling},
|
|
{"suspended", errTaskSuspended},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.state, func(t *testing.T) {
|
|
w := NewTaskRunnerWithAPI(config.Config{MaxConcurrentTasks: 5}, &recordingAPI{})
|
|
w.logger = slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
taskCtx, ok := w.startTask(context.Background(), "task-1")
|
|
if !ok {
|
|
t.Fatal("expected to start task")
|
|
}
|
|
defer w.finish("task-1")
|
|
|
|
if !w.cancelRunning(clientTaskWithStatus("task-1", tc.state)) {
|
|
t.Fatal("expected cancelRunning to act on a running task")
|
|
}
|
|
<-taskCtx.Done()
|
|
if cause := context.Cause(taskCtx); !errors.Is(cause, tc.want) {
|
|
t.Fatalf("expected cancel cause %v, got %v", tc.want, cause)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDownloadThenUploadStopsWhenSuspendedAtStart(t *testing.T) {
|
|
api := &recordingAPI{suspendDownloading: true}
|
|
w := NewTaskRunnerWithAPI(config.Config{}, api)
|
|
w.logger = slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
eng := &recordingEngine{}
|
|
w.downloader = NewManagerWithDownloader(eng)
|
|
|
|
w.downloadThenUpload(context.Background(), w.logger, clientTaskWithStatus("task-1", "assigned"), nil)
|
|
|
|
if eng.downloadCalls != 0 {
|
|
t.Fatalf("expected no download when the task is suspended at start, got %d calls", eng.downloadCalls)
|
|
}
|
|
}
|
|
|
|
func TestCanceledDownloadPreservesRuntimeAndMarksCanceled(t *testing.T) {
|
|
api := &recordingAPI{}
|
|
eng := &recordingEngine{downloadErr: context.Canceled}
|
|
w := NewTaskRunnerWithAPI(config.Config{}, api)
|
|
w.logger = slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
w.downloader = NewManagerWithDownloader(eng)
|
|
ctx, cancel := context.WithCancelCause(context.Background())
|
|
cancel(errTaskCanceling)
|
|
|
|
w.downloadThenUpload(ctx, w.logger, clientTaskWithStatus("task-1", "downloading"), nil)
|
|
|
|
if eng.resetCalls != 0 {
|
|
t.Fatalf("expected canceled task to preserve runtime, got %d reset calls", eng.resetCalls)
|
|
}
|
|
patch := lastPatchWithStatus(t, api.patches, "canceled")
|
|
if patch.State() != "canceled" {
|
|
t.Fatalf("expected canceled patch, got %#v", patch)
|
|
}
|
|
}
|
|
|
|
func TestSuspendedDownloadPreservesRuntimeWithoutStatusChange(t *testing.T) {
|
|
api := &recordingAPI{}
|
|
eng := &recordingEngine{downloadErr: context.Canceled}
|
|
w := NewTaskRunnerWithAPI(config.Config{}, api)
|
|
w.logger = slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
w.downloader = NewManagerWithDownloader(eng)
|
|
ctx, cancel := context.WithCancelCause(context.Background())
|
|
cancel(errTaskSuspended)
|
|
|
|
w.downloadThenUpload(ctx, w.logger, clientTaskWithStatus("task-1", "downloading"), nil)
|
|
|
|
if eng.resetCalls != 0 {
|
|
t.Fatalf("expected suspended task to preserve runtime, got %d reset calls", eng.resetCalls)
|
|
}
|
|
if _, ok := findPatchWithStatus(api.patches, "suspended"); ok {
|
|
t.Fatalf("expected worker not to overwrite server-owned suspended status, got %#v", api.patches)
|
|
}
|
|
for _, patch := range api.patches {
|
|
if patch.Runtime != nil && patch.Runtime.State == localResultRemovedRuntimeState {
|
|
t.Fatalf("expected suspended task not to mark local result removed, got %#v", patch.Runtime)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTickSuspendedControlTaskPreservesRuntime(t *testing.T) {
|
|
api := &recordingAPI{
|
|
controlTasks: []client.DownloadTask{clientTaskWithStatus("task-1", "suspended")},
|
|
}
|
|
eng := &recordingEngine{}
|
|
w := NewTaskRunnerWithAPI(config.Config{}, api)
|
|
w.logger = slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
w.downloader = NewManagerWithDownloader(eng)
|
|
|
|
if err := w.tick(context.Background()); err != nil {
|
|
t.Fatalf("first tick: %v", err)
|
|
}
|
|
if eng.resetCalls != 0 {
|
|
t.Fatalf("expected suspended control poll to preserve runtime, got %d reset calls", eng.resetCalls)
|
|
}
|
|
if len(api.patches) != 0 {
|
|
t.Fatalf("expected suspended control poll not to patch task, got %#v", api.patches)
|
|
}
|
|
|
|
if err := w.tick(context.Background()); err != nil {
|
|
t.Fatalf("second tick: %v", err)
|
|
}
|
|
if eng.resetCalls != 0 {
|
|
t.Fatalf("expected repeated suspended control polls not to clean runtime, got %d reset calls", eng.resetCalls)
|
|
}
|
|
if len(api.patches) != 0 {
|
|
t.Fatalf("expected repeated suspended control polls not to patch task, got %#v", api.patches)
|
|
}
|
|
if got := api.controlTasks[0].State(); got != "suspended" {
|
|
t.Fatalf("expected recorded control task status to stay suspended, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestTickUsesHeartbeatNextPollInterval(t *testing.T) {
|
|
api := &recordingAPI{nextPollAfterSeconds: 17}
|
|
w := NewTaskRunnerWithAPI(config.Config{PollInterval: time.Second}, api)
|
|
w.logger = slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
|
|
nextPoll, err := w.tickAndNextPoll(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("tick: %v", err)
|
|
}
|
|
if nextPoll != 17*time.Second {
|
|
t.Fatalf("expected server-directed poll interval, got %s", nextPoll)
|
|
}
|
|
}
|
|
|
|
func TestDeleteRequestedControlTaskCleansRuntimeAndAcksCanceled(t *testing.T) {
|
|
api := &recordingAPI{
|
|
controlTasks: []client.DownloadTask{
|
|
withRuntime(clientTaskWithStatus("task-1", "canceling"), &client.DownloadTaskRuntime{State: deleteRequestedRuntimeState}),
|
|
},
|
|
}
|
|
eng := &recordingEngine{}
|
|
w := NewTaskRunnerWithAPI(config.Config{}, api)
|
|
w.logger = slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
w.downloader = NewManagerWithDownloader(eng)
|
|
|
|
if err := w.tick(context.Background()); err != nil {
|
|
t.Fatalf("tick: %v", err)
|
|
}
|
|
if eng.resetCalls != 1 {
|
|
t.Fatalf("expected delete-requested task to clean runtime once, got %d reset calls", eng.resetCalls)
|
|
}
|
|
patch := lastPatchWithStatus(t, api.patches, "canceled")
|
|
if patch.State() != "canceled" {
|
|
t.Fatalf("expected delete-requested cleanup to ack canceled, got %#v", patch)
|
|
}
|
|
}
|
|
|
|
func TestCancelingControlTaskWithoutDeleteRequestPreservesRuntime(t *testing.T) {
|
|
api := &recordingAPI{
|
|
controlTasks: []client.DownloadTask{clientTaskWithStatus("task-1", "canceling")},
|
|
}
|
|
eng := &recordingEngine{}
|
|
w := NewTaskRunnerWithAPI(config.Config{}, api)
|
|
w.logger = slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
w.downloader = NewManagerWithDownloader(eng)
|
|
|
|
if err := w.tick(context.Background()); err != nil {
|
|
t.Fatalf("tick: %v", err)
|
|
}
|
|
if eng.resetCalls != 0 {
|
|
t.Fatalf("expected canceling task without delete request to preserve runtime, got %d reset calls", eng.resetCalls)
|
|
}
|
|
patch := lastPatchWithStatus(t, api.patches, "canceled")
|
|
if patch.State() != "canceled" {
|
|
t.Fatalf("expected canceling task to ack canceled, got %#v", patch)
|
|
}
|
|
}
|
|
|
|
func TestFailedDownloadPreservesRuntimeAndMarksFailed(t *testing.T) {
|
|
api := &recordingAPI{}
|
|
eng := &recordingEngine{downloadErr: errors.New("disk write failed")}
|
|
w := NewTaskRunnerWithAPI(config.Config{}, api)
|
|
w.logger = slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
w.downloader = NewManagerWithDownloader(eng)
|
|
|
|
w.downloadThenUpload(context.Background(), w.logger, clientTaskWithStatus("task-1", "downloading"), nil)
|
|
|
|
if eng.resetCalls != 0 {
|
|
t.Fatalf("expected failed task to preserve runtime, got %d reset calls", eng.resetCalls)
|
|
}
|
|
failed := lastPatchWithStatus(t, api.patches, "failed")
|
|
if failed.ErrorMessage == nil || !strings.Contains(*failed.ErrorMessage, "disk write failed") {
|
|
t.Fatalf("expected failure message to be reported, got %#v", failed.ErrorMessage)
|
|
}
|
|
}
|
|
|
|
func TestTerminalDownloadStopsPreservePartialFiles(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
cancelCause error
|
|
failedMessage string
|
|
wantStatus string
|
|
requireStop bool
|
|
}{
|
|
{
|
|
name: "canceled",
|
|
cancelCause: errTaskCanceling,
|
|
wantStatus: "canceled",
|
|
},
|
|
{
|
|
name: "suspended",
|
|
cancelCause: errTaskSuspended,
|
|
requireStop: true,
|
|
},
|
|
{
|
|
name: "failed",
|
|
failedMessage: "disk write failed",
|
|
wantStatus: "failed",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
api := &recordingAPI{}
|
|
downloadDir := t.TempDir()
|
|
partialPath := filepath.Join(downloadDir, "task-1", "payload.bin")
|
|
ready := make(chan struct{}, 1)
|
|
eng := &recordingEngine{
|
|
downloadFunc: func(ctx context.Context, task DownloadTask, progress ProgressReporter) (Result, error) {
|
|
if err := os.MkdirAll(filepath.Dir(partialPath), 0o755); err != nil {
|
|
return Result{}, err
|
|
}
|
|
if err := os.WriteFile(partialPath, []byte("partial"), 0o644); err != nil {
|
|
return Result{}, err
|
|
}
|
|
ready <- struct{}{}
|
|
if tc.failedMessage != "" {
|
|
return Result{}, errors.New(tc.failedMessage)
|
|
}
|
|
<-ctx.Done()
|
|
return Result{}, ctx.Err()
|
|
},
|
|
resetTaskFn: func(context.Context, DownloadTask) error {
|
|
return os.RemoveAll(filepath.Join(downloadDir, "task-1"))
|
|
},
|
|
}
|
|
w := NewTaskRunnerWithAPI(config.Config{DownloadDir: downloadDir}, api)
|
|
w.logger = slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
w.downloader = NewManagerWithDownloader(eng)
|
|
|
|
task := clientHTTPTask("task-1", "downloading", "https://example.com/payload.bin", "payload.bin")
|
|
if tc.cancelCause != nil {
|
|
ctx, cancel := context.WithCancelCause(context.Background())
|
|
done := make(chan struct{})
|
|
go func() {
|
|
w.downloadThenUpload(ctx, w.logger, task, nil)
|
|
close(done)
|
|
}()
|
|
select {
|
|
case <-ready:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("timed out waiting for partial download")
|
|
}
|
|
cancel(tc.cancelCause)
|
|
waitForWorkerTestCompletion(t, done)
|
|
} else {
|
|
w.downloadThenUpload(context.Background(), w.logger, task, nil)
|
|
}
|
|
|
|
if tc.wantStatus != "" {
|
|
lastPatchWithStatus(t, api.patches, tc.wantStatus)
|
|
}
|
|
taskDir := filepath.Join(downloadDir, task.ID)
|
|
if _, err := os.Stat(taskDir); err != nil {
|
|
t.Fatalf("expected %s to remain after %s stop, got err=%v", taskDir, tc.name, err)
|
|
}
|
|
if tc.requireStop {
|
|
for _, forbidden := range []string{"failed", "interrupted", "canceled", "paused"} {
|
|
if _, ok := findPatchWithStatus(api.patches, forbidden); ok {
|
|
t.Fatalf("expected suspended stop not to emit %q, got %#v", forbidden, api.patches)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClearStaleSeedingReportsClearsUntrackedOnly(t *testing.T) {
|
|
api := &recordingAPI{seedingTasks: []client.DownloadTask{
|
|
clientTaskWithStatus("stale-task", "completed"),
|
|
clientTaskWithStatus("live-seed", "completed"),
|
|
}}
|
|
w := NewTaskRunnerWithAPI(config.Config{SeedEnabled: true}, api)
|
|
w.logger = slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
w.downloader = NewManagerWithDownloader(&recordingEngine{})
|
|
w.seeds.retainedSeeds = []retainedSeed{{taskID: "live-seed"}}
|
|
|
|
w.seeds.ClearStaleReports(context.Background())
|
|
|
|
if len(api.patchedIDs) != 1 || api.patchedIDs[0] != "stale-task" {
|
|
t.Fatalf("expected exactly the untracked task to be cleared, got %v", api.patchedIDs)
|
|
}
|
|
patch := api.patches[0]
|
|
if patch.Runtime == nil || patch.Runtime.Phase != "completed" {
|
|
t.Fatalf("expected completed-phase runtime, got %#v", patch.Runtime)
|
|
}
|
|
if patch.Runtime.Seeding == nil || patch.Runtime.Seeding.Active == nil || *patch.Runtime.Seeding.Active {
|
|
t.Fatal("expected seeding.active=false in the stopped report")
|
|
}
|
|
}
|
|
|
|
func TestCleanupRetainedSeedReportsStopped(t *testing.T) {
|
|
api := &recordingAPI{}
|
|
w := NewTaskRunnerWithAPI(config.Config{SeedEnabled: true}, api)
|
|
w.logger = slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
w.downloader = NewManagerWithDownloader(&recordingEngine{})
|
|
cleaned := false
|
|
w.seeds.retainedSeeds = []retainedSeed{{
|
|
taskID: "seed-task",
|
|
engine: "aria2",
|
|
cleanup: func(context.Context) error { cleaned = true; return nil },
|
|
}}
|
|
|
|
w.seeds.cleanupRetainedSeed(context.Background(), w.seeds.retainedSeeds[0], "expired")
|
|
|
|
if !cleaned {
|
|
t.Fatal("expected engine cleanup to run")
|
|
}
|
|
if len(api.patchedIDs) != 1 || api.patchedIDs[0] != "seed-task" {
|
|
t.Fatalf("expected a stopped report for the cleaned seed, got %v", api.patchedIDs)
|
|
}
|
|
if patch := api.patches[0]; patch.Runtime == nil || patch.Runtime.Seeding == nil ||
|
|
patch.Runtime.Seeding.Active == nil || *patch.Runtime.Seeding.Active {
|
|
t.Fatalf("expected seeding cleared in cleanup report, got %#v", patch.Runtime)
|
|
}
|
|
}
|
|
|
|
func TestCleanupRetainedSeedsKeepsFailedUploadLocalResult(t *testing.T) {
|
|
api := &recordingAPI{localResultTasks: []client.DownloadTask{clientTaskWithStatus("task-1", "failed")}}
|
|
w := NewTaskRunnerWithAPI(config.Config{SeedEnabled: true, SeedCacheLimit: 1}, api)
|
|
w.logger = slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
w.downloader = NewManagerWithDownloader(&recordingEngine{})
|
|
cleaned := false
|
|
seedDir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(seedDir, "payload.bin"), []byte("downloaded payload"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
w.seeds.retainedSeeds = []retainedSeed{{
|
|
taskID: "task-1",
|
|
engine: "aria2",
|
|
path: seedDir,
|
|
retainedAt: time.Now().Add(-time.Hour),
|
|
cleanup: func(context.Context) error { cleaned = true; return nil },
|
|
}}
|
|
|
|
w.seeds.Cleanup(context.Background())
|
|
|
|
if cleaned {
|
|
t.Fatal("expected failed upload local result to be kept for retry")
|
|
}
|
|
if len(w.seeds.retainedSeedSnapshot()) != 1 {
|
|
t.Fatalf("expected retained seed to remain tracked, got %d", len(w.seeds.retainedSeedSnapshot()))
|
|
}
|
|
if api.localResultTasksCalls != 1 {
|
|
t.Fatalf("expected local-result task lookup, got %d", api.localResultTasksCalls)
|
|
}
|
|
}
|
|
|
|
func TestReconcileEngineSeedsDoesNotFetchLocalResultTasksWithoutLocalSeeds(t *testing.T) {
|
|
api := &recordingAPI{}
|
|
w := NewTaskRunnerWithAPI(config.Config{SeedEnabled: true}, api)
|
|
w.logger = slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
w.downloader = NewManagerWithDownloader(&recordingEngine{})
|
|
|
|
w.seeds.Reconcile(context.Background())
|
|
|
|
if api.localResultTasksCalls != 0 {
|
|
t.Fatalf("expected no local-result task fetch without local seeds, got %d", api.localResultTasksCalls)
|
|
}
|
|
}
|
|
|
|
func TestReconcileEngineSeedsAdoptsUntrackedOrphans(t *testing.T) {
|
|
root := t.TempDir()
|
|
orphanDir := filepath.Join(root, "orphan-task")
|
|
if err := os.MkdirAll(orphanDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(orphanDir, "file.bin"), make([]byte, 1024), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
trackedDir := filepath.Join(root, "tracked-task")
|
|
runningDir := filepath.Join(root, "running-task")
|
|
assignedDir := filepath.Join(root, "assigned-task")
|
|
failedDir := filepath.Join(root, "failed-task")
|
|
for _, dir := range []string{trackedDir, runningDir, assignedDir, failedDir} {
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
eng := &recordingEngine{listSeeds: []Seed{
|
|
{Engine: "aria2", ID: "g1", InfoHash: "AAAA", Path: orphanDir},
|
|
{Engine: "aria2", ID: "g2", InfoHash: "BBBB", Path: trackedDir},
|
|
{Engine: "aria2", ID: "g3", InfoHash: "CCCC", Path: runningDir},
|
|
{Engine: "aria2", ID: "g4", InfoHash: "DDDD", Path: assignedDir},
|
|
{Engine: "aria2", ID: "g5", InfoHash: "EEEE", Path: failedDir},
|
|
}}
|
|
// 'assigned-task' is still assigned/unfinished — it auto-seeds but hasn't been
|
|
// uploaded yet, so the reconciler must NOT adopt it as a done seed.
|
|
w := NewTaskRunnerWithAPI(config.Config{SeedEnabled: true, SeedDuration: time.Hour}, &recordingAPI{
|
|
localResultTasks: []client.DownloadTask{
|
|
clientTaskWithStatus("assigned-task", "assigned"),
|
|
clientTaskWithStatus("failed-task", "failed"),
|
|
},
|
|
})
|
|
w.logger = slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
w.downloader = NewManagerWithDownloader(eng)
|
|
w.seeds.retainedSeeds = []retainedSeed{{taskID: "tracked-task"}}
|
|
w.running["running-task"] = func(error) {}
|
|
|
|
w.seeds.Reconcile(context.Background())
|
|
|
|
for _, seed := range w.seeds.retainedSeedSnapshot() {
|
|
if seed.taskID == "assigned-task" {
|
|
t.Fatal("expected an assigned (not-yet-uploaded) task's seed to be skipped, not adopted")
|
|
}
|
|
if seed.taskID == "failed-task" {
|
|
t.Fatal("expected a failed upload task's seed to be skipped, not adopted")
|
|
}
|
|
}
|
|
|
|
got := map[string]retainedSeed{}
|
|
trackedCount := 0
|
|
for _, seed := range w.seeds.retainedSeedSnapshot() {
|
|
got[seed.taskID] = seed
|
|
if seed.taskID == "tracked-task" {
|
|
trackedCount++
|
|
}
|
|
}
|
|
orphan, adopted := got["orphan-task"]
|
|
if !adopted {
|
|
t.Fatal("expected the untracked orphan seed to be adopted")
|
|
}
|
|
if orphan.expiresAt.IsZero() {
|
|
t.Fatal("expected the adopted orphan to receive an expiry")
|
|
}
|
|
if orphan.size != 1024 {
|
|
t.Fatalf("expected adopted orphan size 1024, got %d", orphan.size)
|
|
}
|
|
if _, ok := got["running-task"]; ok {
|
|
t.Fatal("expected the in-flight running task to be skipped, not adopted")
|
|
}
|
|
if trackedCount != 1 {
|
|
t.Fatalf("expected the already-tracked seed to stay single, got %d", trackedCount)
|
|
}
|
|
}
|
|
|
|
func TestHeartbeatReportsAggregateTransferSpeeds(t *testing.T) {
|
|
w := NewTaskRunnerWithAPI(config.Config{Engine: "auto", MaxConcurrentTasks: 5, DownloadDir: t.TempDir()}, &recordingAPI{})
|
|
|
|
if _, ok := w.startTask(context.Background(), "task-1"); !ok {
|
|
t.Fatal("expected task-1 to start")
|
|
}
|
|
if _, ok := w.startTask(context.Background(), "task-2"); !ok {
|
|
t.Fatal("expected task-2 to start")
|
|
}
|
|
w.setTaskTransferSpeed("task-1", transferSpeeds{downloadBps: 100, uploadBps: 20})
|
|
w.setTaskTransferSpeed("task-2", transferSpeeds{downloadBps: 300, uploadBps: 40})
|
|
|
|
heartbeat := w.heartbeat()
|
|
if heartbeat.CurrentTasks != 2 {
|
|
t.Fatalf("expected 2 current tasks, got %d", heartbeat.CurrentTasks)
|
|
}
|
|
if heartbeat.DownloadBps != 400 || heartbeat.UploadBps != 60 {
|
|
t.Fatalf("expected aggregate speeds 400/60, got %d/%d", heartbeat.DownloadBps, heartbeat.UploadBps)
|
|
}
|
|
if heartbeat.FreeDiskBytes <= 0 {
|
|
t.Fatalf("expected heartbeat to report free disk bytes, got %d", heartbeat.FreeDiskBytes)
|
|
}
|
|
|
|
w.finish("task-1")
|
|
heartbeat = w.heartbeat()
|
|
if heartbeat.CurrentTasks != 1 {
|
|
t.Fatalf("expected 1 current task after finish, got %d", heartbeat.CurrentTasks)
|
|
}
|
|
if heartbeat.DownloadBps != 300 || heartbeat.UploadBps != 40 {
|
|
t.Fatalf("expected finished task speed to be removed, got %d/%d", heartbeat.DownloadBps, heartbeat.UploadBps)
|
|
}
|
|
w.finish("task-2")
|
|
}
|
|
|
|
func TestUploadFilePartSendsContentLength(t *testing.T) {
|
|
path := writeTempFile(t, "hello world")
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer file.Close()
|
|
|
|
var contentLength string
|
|
var body string
|
|
var uploaded int64
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
contentLength = r.Header.Get("Content-Length")
|
|
if r.TransferEncoding != nil {
|
|
t.Fatalf("expected fixed-length upload, got transfer encoding %v", r.TransferEncoding)
|
|
}
|
|
data, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body = string(data)
|
|
w.Header().Set("ETag", `"etag-1"`)
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer server.Close()
|
|
|
|
etag, err := uploadFilePart(context.Background(), server.URL, file, 0, 11, func(written int64) error {
|
|
uploaded += written
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("uploadFilePart returned error: %v", err)
|
|
}
|
|
if contentLength != "11" {
|
|
t.Fatalf("expected Content-Length 11, got %q", contentLength)
|
|
}
|
|
if body != "hello world" {
|
|
t.Fatalf("expected uploaded body, got %q", body)
|
|
}
|
|
if etag != `"etag-1"` {
|
|
t.Fatalf("expected ETag, got %q", etag)
|
|
}
|
|
if uploaded != 11 {
|
|
t.Fatalf("expected uploaded bytes 11, got %d", uploaded)
|
|
}
|
|
}
|
|
|
|
func TestUploadFilePartIncludesErrorBody(t *testing.T) {
|
|
path := writeTempFile(t, "hello")
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer file.Close()
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
http.Error(w, "signature mismatch", http.StatusForbidden)
|
|
}))
|
|
defer server.Close()
|
|
|
|
_, err = uploadFilePart(context.Background(), server.URL, file, 0, 5, nil)
|
|
if err == nil {
|
|
t.Fatal("expected uploadFilePart error")
|
|
}
|
|
if !strings.Contains(err.Error(), "403 Forbidden") || !strings.Contains(err.Error(), "signature mismatch") {
|
|
t.Fatalf("expected status and response body in error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestUploadFilePartSendsSectionAndReturnsETag(t *testing.T) {
|
|
path := writeTempFile(t, "hello multipart")
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer file.Close()
|
|
|
|
var contentLength string
|
|
var body string
|
|
var uploaded int64
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
contentLength = r.Header.Get("Content-Length")
|
|
data, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body = string(data)
|
|
w.Header().Set("ETag", `"part-etag"`)
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer server.Close()
|
|
|
|
etag, err := uploadFilePart(context.Background(), server.URL, file, 6, 9, func(written int64) error {
|
|
uploaded += written
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("uploadFilePart returned error: %v", err)
|
|
}
|
|
if contentLength != "9" {
|
|
t.Fatalf("expected Content-Length 9, got %q", contentLength)
|
|
}
|
|
if body != "multipart" {
|
|
t.Fatalf("expected section body, got %q", body)
|
|
}
|
|
if etag != `"part-etag"` {
|
|
t.Fatalf("expected ETag, got %q", etag)
|
|
}
|
|
if uploaded != 9 {
|
|
t.Fatalf("expected uploaded bytes 9, got %d", uploaded)
|
|
}
|
|
}
|
|
|
|
func TestCollectDirectoryEntriesSkipsDownloadSidecars(t *testing.T) {
|
|
root := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(root, "movie.mkv"), []byte("movie"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, "fixture.torrent"), []byte("torrent"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, "movie.mkv.aria2"), []byte("control"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, "[METADATA]abc"), []byte("metadata"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
entries, err := collectDirectoryEntries(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(entries) != 1 {
|
|
t.Fatalf("expected one uploaded entry, got %#v", entries)
|
|
}
|
|
if entries[0].name != "movie.mkv" {
|
|
t.Fatalf("expected movie.mkv, got %s", entries[0].name)
|
|
}
|
|
}
|
|
|
|
func TestCleanupDownloadedResultRemovesTaskDirForNestedDirectoryResult(t *testing.T) {
|
|
downloadDir := t.TempDir()
|
|
taskDir := filepath.Join(downloadDir, "task-1")
|
|
resultDir := filepath.Join(taskDir, "payload")
|
|
if err := os.MkdirAll(resultDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(resultDir, "file.txt"), []byte("payload"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(taskDir, "payload.torrent"), []byte("sidecar"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err := cleanupDownloadedResult(context.Background(), clientTaskWithStatus("task-1", "downloading"), Result{
|
|
Path: resultDir,
|
|
Name: "payload",
|
|
IsDir: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := os.Stat(taskDir); !os.IsNotExist(err) {
|
|
t.Fatalf("expected nested directory result cleanup to remove task dir, stat err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestUploadFailurePersistsDownloadCheckpoint(t *testing.T) {
|
|
api := &recordingAPI{createFolderErr: errors.New("unauthorized")}
|
|
w := NewTaskRunnerWithAPI(config.Config{}, api)
|
|
resultPath := t.TempDir()
|
|
result := Result{
|
|
Path: resultPath,
|
|
Name: "album",
|
|
Size: 1234,
|
|
IsDir: true,
|
|
}
|
|
|
|
w.uploadAndComplete(
|
|
context.Background(),
|
|
slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
clientTaskWithUploadToken("task-1", "downloading"),
|
|
result,
|
|
nil,
|
|
)
|
|
|
|
if len(api.patches) < 2 {
|
|
t.Fatalf("expected uploading and failed updates, got %d", len(api.patches))
|
|
}
|
|
failed := api.patches[len(api.patches)-1]
|
|
if failed.State() != "failed" {
|
|
t.Fatalf("expected failed status, got %q", failed.State())
|
|
}
|
|
if failed.Progress == nil || failed.Progress.Download == nil || failed.Progress.Download.Bytes != result.Size {
|
|
t.Fatalf("expected downloaded bytes %d, got %#v", result.Size, failed.Progress)
|
|
}
|
|
if failed.Progress.Download.TotalBytes == nil || *failed.Progress.Download.TotalBytes != result.Size {
|
|
t.Fatalf("expected total bytes %d, got %#v", result.Size, failed.Progress.Download.TotalBytes)
|
|
}
|
|
if failed.Runtime != nil && failed.Runtime.State == localResultRemovedRuntimeState {
|
|
t.Fatalf("expected upload failure to preserve local result runtime, got %#v", failed.Runtime)
|
|
}
|
|
if _, err := os.Stat(resultPath); err != nil {
|
|
t.Fatalf("expected upload failure to preserve local result path, stat err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestDirectoryUploadFailureDeletesRemoteRootFolder(t *testing.T) {
|
|
api := &recordingAPI{
|
|
createFolderDrafts: []client.ObjectDraft{{ID: "root-folder", Name: "album"}},
|
|
createObjectDraft: client.ObjectDraft{ID: "file-object"},
|
|
}
|
|
w := NewTaskRunnerWithAPI(config.Config{}, api)
|
|
resultPath := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(resultPath, "track.flac"), []byte("audio"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
w.uploadAndComplete(
|
|
context.Background(),
|
|
slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
clientTaskWithUploadToken("task-1", "downloading"),
|
|
Result{
|
|
Path: resultPath,
|
|
Name: "album",
|
|
Size: 5,
|
|
IsDir: true,
|
|
},
|
|
nil,
|
|
)
|
|
|
|
if !reflect.DeepEqual(api.deletedObjects, []string{"root-folder"}) {
|
|
t.Fatalf("expected remote root folder cleanup, got %#v", api.deletedObjects)
|
|
}
|
|
failed := api.patches[len(api.patches)-1]
|
|
if failed.State() != "failed" {
|
|
t.Fatalf("expected failed status, got %q", failed.State())
|
|
}
|
|
}
|
|
|
|
func TestWorkerLifecycleUploadFailurePreservesLocalResult(t *testing.T) {
|
|
payloadPath := writeTempFile(t, "downloaded payload")
|
|
payloadSize := int64(len("downloaded payload"))
|
|
uploadRequests := 0
|
|
uploadServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
uploadRequests++
|
|
if _, err := io.Copy(io.Discard, r.Body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
w.Header().Set("ETag", `"etag-1"`)
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer uploadServer.Close()
|
|
|
|
api := &recordingAPI{
|
|
createObjectDraft: client.ObjectDraft{ID: "object-1", Name: "payload.bin", Upload: &client.ObjectUploadInstructions{SessionID: "session-1", PartSize: payloadSize, URLs: []string{uploadServer.URL}}},
|
|
completeErrs: []error{errors.New("unauthorized"), nil},
|
|
}
|
|
eng := &recordingEngine{
|
|
downloadResult: Result{Path: payloadPath, Name: "payload.bin", Size: payloadSize},
|
|
taskSnapshot: TaskSnapshot{
|
|
State: TaskStateCompleted,
|
|
Result: &Result{Path: payloadPath, Name: "payload.bin", Size: payloadSize},
|
|
},
|
|
taskFound: true,
|
|
}
|
|
|
|
first := NewTaskRunnerWithAPI(config.Config{}, api)
|
|
first.downloader = NewManagerWithDownloader(eng)
|
|
first.process(context.Background(), clientHTTPTask("task-1", "assigned", "https://example.com/payload.bin", "payload.bin"))
|
|
failed := lastPatchWithStatus(t, api.patches, "failed")
|
|
if failed.Progress == nil || failed.Progress.Download == nil || failed.Progress.Download.Bytes != payloadSize {
|
|
t.Fatalf("expected failed task to persist downloaded bytes %d, got %#v", payloadSize, failed.Progress)
|
|
}
|
|
if failed.Runtime != nil && failed.Runtime.State == localResultRemovedRuntimeState {
|
|
t.Fatalf("expected failed task to preserve local result runtime, got %#v", failed.Runtime)
|
|
}
|
|
if uploadRequests != 1 {
|
|
t.Fatalf("expected one upload attempt, got %d", uploadRequests)
|
|
}
|
|
if _, err := os.Stat(payloadPath); err != nil {
|
|
t.Fatalf("expected failed upload to preserve local payload, stat err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestWorkerLifecycleHTTPUploadFailurePreservesLocalResult(t *testing.T) {
|
|
payload := "downloaded payload"
|
|
downloadRequests := 0
|
|
downloadServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
downloadRequests++
|
|
w.Header().Set("Content-Length", strconv.Itoa(len(payload)))
|
|
_, _ = w.Write([]byte(payload))
|
|
}))
|
|
defer downloadServer.Close()
|
|
|
|
uploadRequests := 0
|
|
uploadServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
uploadRequests++
|
|
if _, err := io.Copy(io.Discard, r.Body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
w.Header().Set("ETag", `"etag-1"`)
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer uploadServer.Close()
|
|
|
|
payloadSize := int64(len(payload))
|
|
api := &recordingAPI{
|
|
createObjectDraft: client.ObjectDraft{ID: "object-1", Name: "payload.bin", Upload: &client.ObjectUploadInstructions{SessionID: "session-1", PartSize: payloadSize, URLs: []string{uploadServer.URL}}},
|
|
completeErrs: []error{errors.New("unauthorized"), nil},
|
|
}
|
|
downloadDir := t.TempDir()
|
|
|
|
first := NewTaskRunnerWithAPI(config.Config{}, api)
|
|
first.downloader = NewManagerWithDownloader(preservingHTTPDownloadEngine{dir: downloadDir})
|
|
first.process(context.Background(), clientHTTPTask("task-1", "assigned", downloadServer.URL+"/payload.bin", "payload.bin"))
|
|
failed := lastPatchWithStatus(t, api.patches, "failed")
|
|
if failed.Progress == nil || failed.Progress.Download == nil || failed.Progress.Download.Bytes != payloadSize {
|
|
t.Fatalf("expected failed task to persist downloaded bytes %d, got %#v", payloadSize, failed.Progress)
|
|
}
|
|
if downloadRequests != 1 {
|
|
t.Fatalf("expected initial attempt to download once, got %d requests", downloadRequests)
|
|
}
|
|
if uploadRequests != 1 {
|
|
t.Fatalf("expected one upload attempt, got %d", uploadRequests)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(downloadDir, "task-1")); err != nil {
|
|
t.Fatalf("expected failed upload to preserve local task directory, stat err=%v", err)
|
|
}
|
|
|
|
second := NewTaskRunnerWithAPI(config.Config{}, api)
|
|
second.downloader = NewManagerWithDownloader(preservingHTTPDownloadEngine{dir: downloadDir})
|
|
failedRuntime := failed.Runtime
|
|
second.process(context.Background(), withRuntime(
|
|
withDownloadCheckpoint(clientHTTPTask("task-1", "assigned", downloadServer.URL+"/payload.bin", "payload.bin"), payloadSize, &payloadSize),
|
|
failedRuntime,
|
|
))
|
|
|
|
if downloadRequests != 1 {
|
|
t.Fatalf("expected retry after upload failure to reuse local result, got %d download requests", downloadRequests)
|
|
}
|
|
if uploadRequests != 2 {
|
|
t.Fatalf("expected retry to upload preserved file, got %d upload requests", uploadRequests)
|
|
}
|
|
last := api.patches[len(api.patches)-1]
|
|
if last.State() != "completed" {
|
|
t.Fatalf("expected retry to complete task, got last patch %#v", last)
|
|
}
|
|
}
|
|
|
|
func TestUploadExistingResultInspectErrorFailsWithoutRedownloading(t *testing.T) {
|
|
api := &recordingAPI{}
|
|
eng := &recordingEngine{inspectErr: errors.New("runtime state is inconsistent")}
|
|
w := NewTaskRunnerWithAPI(config.Config{}, api)
|
|
w.downloader = NewManagerWithDownloader(eng)
|
|
|
|
total := int64(100)
|
|
w.process(context.Background(), withRuntime(
|
|
withDownloadCheckpoint(clientTaskWithUploadToken("task-1", "assigned"), 0, &total),
|
|
&client.DownloadTaskRuntime{Phase: "uploading"},
|
|
))
|
|
|
|
if eng.downloadCalls != 0 {
|
|
t.Fatalf("expected runtime inspection failure not to restart download, got %d download calls", eng.downloadCalls)
|
|
}
|
|
failed := lastPatchWithStatus(t, api.patches, "failed")
|
|
if failed.ErrorMessage == nil || !strings.Contains(*failed.ErrorMessage, "runtime state is inconsistent") {
|
|
t.Fatalf("expected runtime inspection error to be reported, got %#v", failed.ErrorMessage)
|
|
}
|
|
}
|
|
|
|
func TestUploadExistingResultInspectPanicFailsWithoutRedownloading(t *testing.T) {
|
|
api := &recordingAPI{}
|
|
eng := &recordingEngine{inspectPanic: "runtime invariant violated"}
|
|
w := NewTaskRunnerWithAPI(config.Config{}, api)
|
|
w.downloader = NewManagerWithDownloader(eng)
|
|
|
|
total := int64(100)
|
|
w.process(context.Background(), withRuntime(
|
|
withDownloadCheckpoint(clientTaskWithUploadToken("task-1", "assigned"), 0, &total),
|
|
&client.DownloadTaskRuntime{Phase: "uploading"},
|
|
))
|
|
|
|
if eng.downloadCalls != 0 {
|
|
t.Fatalf("expected runtime inspection panic not to restart download, got %d download calls", eng.downloadCalls)
|
|
}
|
|
failed := lastPatchWithStatus(t, api.patches, "failed")
|
|
if failed.ErrorMessage == nil || !strings.Contains(*failed.ErrorMessage, "panic: runtime invariant violated") {
|
|
t.Fatalf("expected panic to be reported, got %#v", failed.ErrorMessage)
|
|
}
|
|
}
|
|
|
|
func TestUploadExistingResultMissingRuntimeFailsWithoutRedownloading(t *testing.T) {
|
|
api := &recordingAPI{}
|
|
eng := &recordingEngine{taskFound: false}
|
|
w := NewTaskRunnerWithAPI(config.Config{}, api)
|
|
w.downloader = NewManagerWithDownloader(eng)
|
|
|
|
total := int64(100)
|
|
w.process(context.Background(), withRuntime(
|
|
withDownloadCheckpoint(clientTaskWithUploadToken("task-1", "assigned"), 0, &total),
|
|
&client.DownloadTaskRuntime{Phase: "uploading"},
|
|
))
|
|
|
|
if eng.downloadCalls != 0 {
|
|
t.Fatalf("expected missing runtime task not to restart download, got %d download calls", eng.downloadCalls)
|
|
}
|
|
failed := lastPatchWithStatus(t, api.patches, "failed")
|
|
if failed.ErrorMessage == nil || !strings.Contains(*failed.ErrorMessage, "missing from downloader runtime") {
|
|
t.Fatalf("expected missing runtime to be reported, got %#v", failed.ErrorMessage)
|
|
}
|
|
}
|
|
|
|
func TestUploadExistingResultIncompleteRuntimeResumesDownload(t *testing.T) {
|
|
// The server checkpoint can route a task to "upload the finished download"
|
|
// while the engine isn't reporting it complete yet — e.g. aria2 re-checking
|
|
// on-disk files after a restart. That must resume the download path, not
|
|
// panic/fail the task.
|
|
api := &recordingAPI{}
|
|
eng := &recordingEngine{
|
|
taskSnapshot: TaskSnapshot{State: TaskStateDownloading, Downloaded: 10},
|
|
taskFound: true,
|
|
downloadErr: errors.New("resumed via download path"),
|
|
}
|
|
w := NewTaskRunnerWithAPI(config.Config{}, api)
|
|
w.downloader = NewManagerWithDownloader(eng)
|
|
|
|
total := int64(100)
|
|
w.process(context.Background(), withRuntime(
|
|
withDownloadCheckpoint(clientTaskWithUploadToken("task-1", "assigned"), 0, &total),
|
|
&client.DownloadTaskRuntime{Phase: "uploading"},
|
|
))
|
|
|
|
if eng.downloadCalls != 1 {
|
|
t.Fatalf("expected incomplete runtime to resume via the download path, got %d download calls", eng.downloadCalls)
|
|
}
|
|
for _, p := range api.patches {
|
|
if p.Status == "failed" && p.ErrorMessage != nil && strings.Contains(*p.ErrorMessage, "not completed") {
|
|
t.Fatalf("expected no upload-invariant failure, got %q", *p.ErrorMessage)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDownloadShutdownMarksTaskInterrupted(t *testing.T) {
|
|
api := &recordingAPI{}
|
|
eng := &recordingEngine{downloadErr: context.Canceled}
|
|
w := NewTaskRunnerWithAPI(config.Config{}, api)
|
|
w.downloader = NewManagerWithDownloader(eng)
|
|
|
|
w.process(context.Background(), clientTaskWithStatus("task-1", "downloading"))
|
|
|
|
patch := lastPatchWithStatus(t, api.patches, "interrupted")
|
|
if patch.Progress == nil || patch.Progress.Download == nil || patch.Progress.Download.BytesPerSecond != 0 {
|
|
t.Fatalf("expected download speed to be reset, got %#v", patch.Progress)
|
|
}
|
|
if patch.Runtime == nil || patch.Runtime.Message == "" {
|
|
t.Fatalf("expected interrupted detail message, got %#v", patch.Runtime)
|
|
}
|
|
if eng.resetCalls != 0 {
|
|
t.Fatalf("expected interrupted shutdown to preserve resumable runtime data, got %d resets", eng.resetCalls)
|
|
}
|
|
}
|
|
|
|
func TestUploadShutdownMarksTaskInterrupted(t *testing.T) {
|
|
payloadPath := writeTempFile(t, "downloaded payload")
|
|
payloadSize := int64(len("downloaded payload"))
|
|
api := &recordingAPI{
|
|
createObjectDraft: client.ObjectDraft{ID: "object-1", Name: "payload.bin", Upload: &client.ObjectUploadInstructions{SessionID: "session-1", PartSize: payloadSize, URLs: []string{"http://127.0.0.1:1"}}},
|
|
}
|
|
w := NewTaskRunnerWithAPI(config.Config{}, api)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
w.uploadAndComplete(
|
|
ctx,
|
|
slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
clientTaskWithUploadToken("task-1", "downloading"),
|
|
Result{Path: payloadPath, Name: "payload.bin", Size: int64(len("downloaded payload"))},
|
|
nil,
|
|
)
|
|
|
|
patch := lastPatchWithStatus(t, api.patches, "interrupted")
|
|
if patch.Progress == nil || patch.Progress.Download == nil || patch.Progress.Download.Bytes != int64(len("downloaded payload")) {
|
|
t.Fatalf("expected upload shutdown to preserve downloaded checkpoint, got %#v", patch.Progress)
|
|
}
|
|
if patch.Runtime == nil || patch.Runtime.Phase != "uploading" || patch.Runtime.Message == "" {
|
|
t.Fatalf("expected upload shutdown to preserve phase and add interrupted message, got %#v", patch.Runtime)
|
|
}
|
|
if _, ok := findPatchWithStatus(api.patches, "failed"); ok {
|
|
t.Fatalf("expected upload shutdown not to mark failed, got %#v", api.patches)
|
|
}
|
|
}
|
|
|
|
func TestSuspendedUploadPreservesLocalResult(t *testing.T) {
|
|
downloadDir := t.TempDir()
|
|
taskDir := filepath.Join(downloadDir, "task-1")
|
|
if err := os.MkdirAll(taskDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
payloadPath := filepath.Join(taskDir, "payload.bin")
|
|
payload := "downloaded payload"
|
|
if err := os.WriteFile(payloadPath, []byte(payload), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
payloadSize := int64(len(payload))
|
|
api := &recordingAPI{
|
|
createObjectDraft: client.ObjectDraft{ID: "object-1", Name: "payload.bin", Upload: &client.ObjectUploadInstructions{SessionID: "session-1", PartSize: payloadSize, URLs: []string{"http://127.0.0.1:1"}}},
|
|
}
|
|
w := NewTaskRunnerWithAPI(config.Config{}, api)
|
|
|
|
ctx, cancel := context.WithCancelCause(context.Background())
|
|
cancel(errTaskSuspended)
|
|
w.uploadAndComplete(
|
|
ctx,
|
|
slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
clientTaskWithUploadToken("task-1", "downloading"),
|
|
Result{Path: payloadPath, Name: "payload.bin", Size: payloadSize},
|
|
&client.DownloadTaskRuntime{Phase: "uploading"},
|
|
)
|
|
|
|
if _, ok := findPatchWithStatus(api.patches, "suspended"); ok {
|
|
t.Fatalf("expected worker not to overwrite server-owned suspended status, got %#v", api.patches)
|
|
}
|
|
for _, patch := range api.patches {
|
|
if patch.Runtime != nil && patch.Runtime.State == localResultRemovedRuntimeState {
|
|
t.Fatalf("expected suspended upload not to mark local result removed, got %#v", patch.Runtime)
|
|
}
|
|
}
|
|
if _, err := os.Stat(taskDir); err != nil {
|
|
t.Fatalf("expected suspended upload to preserve local task directory, stat err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestProcessRedownloadsWhenLocalResultWasCleaned(t *testing.T) {
|
|
api := &recordingAPI{}
|
|
eng := &recordingEngine{downloadErr: errors.New("redownload missing local result")}
|
|
w := NewTaskRunnerWithAPI(config.Config{}, api)
|
|
w.logger = slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
w.downloader = NewManagerWithDownloader(eng)
|
|
total := int64(100)
|
|
|
|
w.process(context.Background(), withRuntime(
|
|
withDownloadCheckpoint(clientTaskWithStatus("task-1", "assigned"), total, &total),
|
|
&client.DownloadTaskRuntime{Phase: "error", State: localResultRemovedRuntimeState},
|
|
))
|
|
|
|
if eng.inspectCalls != 0 {
|
|
t.Fatalf("expected cleaned local result to skip runtime upload inspection, got %d inspect calls", eng.inspectCalls)
|
|
}
|
|
if eng.downloadCalls != 1 {
|
|
t.Fatalf("expected cleaned local result to resume via download path, got %d download calls", eng.downloadCalls)
|
|
}
|
|
failed := lastPatchWithStatus(t, api.patches, "failed")
|
|
if failed.ErrorMessage == nil || !strings.Contains(*failed.ErrorMessage, "redownload missing local result") {
|
|
t.Fatalf("expected resumed redownload failure to be reported, got %#v", failed.ErrorMessage)
|
|
}
|
|
}
|
|
|
|
func TestPausedAndInterruptedDownloadsPreservePartialFiles(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
cancelCtx func(context.CancelCauseFunc, context.CancelFunc)
|
|
wantStatus string
|
|
}{
|
|
{
|
|
name: "paused",
|
|
cancelCtx: func(cancelCause context.CancelCauseFunc, _ context.CancelFunc) {
|
|
cancelCause(errTaskPausing)
|
|
},
|
|
wantStatus: "paused",
|
|
},
|
|
{
|
|
name: "interrupted",
|
|
cancelCtx: func(_ context.CancelCauseFunc, cancel context.CancelFunc) {
|
|
cancel()
|
|
},
|
|
wantStatus: "interrupted",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
api := &recordingAPI{}
|
|
downloadDir := t.TempDir()
|
|
path := filepath.Join(downloadDir, "task-1", "payload.bin")
|
|
ready := make(chan struct{}, 1)
|
|
eng := &recordingEngine{
|
|
downloadFunc: func(ctx context.Context, task DownloadTask, progress ProgressReporter) (Result, error) {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return Result{}, err
|
|
}
|
|
if err := os.WriteFile(path, []byte("partial"), 0o644); err != nil {
|
|
return Result{}, err
|
|
}
|
|
ready <- struct{}{}
|
|
<-ctx.Done()
|
|
return Result{}, ctx.Err()
|
|
},
|
|
resetTaskFn: func(context.Context, DownloadTask) error {
|
|
return os.RemoveAll(filepath.Join(downloadDir, "task-1"))
|
|
},
|
|
}
|
|
|
|
w := NewTaskRunnerWithAPI(config.Config{DownloadDir: downloadDir}, api)
|
|
w.logger = slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
w.downloader = NewManagerWithDownloader(eng)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
ctxWithCause, cancelCause := context.WithCancelCause(ctx)
|
|
done := make(chan struct{})
|
|
go func() {
|
|
w.downloadThenUpload(ctxWithCause, w.logger, clientHTTPTask("task-1", "downloading", "https://example.com/payload.bin", "payload.bin"), nil)
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-ready:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("timed out waiting for partial download")
|
|
}
|
|
tc.cancelCtx(cancelCause, cancel)
|
|
waitForWorkerTestCompletion(t, done)
|
|
|
|
lastPatchWithStatus(t, api.patches, tc.wantStatus)
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatalf("expected resumable file %s to remain, got %v", path, err)
|
|
}
|
|
if info.Size() == 0 {
|
|
t.Fatalf("expected resumable file %s to keep partial content", path)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUploadETARoundsRemainingSeconds(t *testing.T) {
|
|
eta := uploadETA(&uploadProgress{uploaded: 25, totalBytes: 100}, 20)
|
|
|
|
if eta == nil || *eta != 4 {
|
|
t.Fatalf("expected ETA 4, got %#v", eta)
|
|
}
|
|
}
|
|
|
|
func TestWithDownloadETAAddsFallback(t *testing.T) {
|
|
total := int64(100)
|
|
detail := withDownloadRuntime(&client.DownloadTaskRuntime{Engine: "http"}, 25, &total, 20)
|
|
|
|
if detail == nil || detail.ETASeconds == nil || *detail.ETASeconds != 4 {
|
|
t.Fatalf("expected fallback ETA 4, got %#v", detail)
|
|
}
|
|
if detail.Engine != "http" {
|
|
t.Fatalf("expected existing detail fields to be preserved, got %#v", detail)
|
|
}
|
|
}
|
|
|
|
func TestWithDownloadETAPreservesEngineETA(t *testing.T) {
|
|
total := int64(100)
|
|
existing := int64(9)
|
|
detail := withDownloadRuntime(&client.DownloadTaskRuntime{ETASeconds: &existing}, 25, &total, 20)
|
|
|
|
if detail == nil || detail.ETASeconds == nil || *detail.ETASeconds != 9 {
|
|
t.Fatalf("expected engine ETA to be preserved, got %#v", detail)
|
|
}
|
|
}
|
|
|
|
func TestUploadETAOmitsUnusableValues(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
progress *uploadProgress
|
|
bps int64
|
|
}{
|
|
{name: "missing progress", progress: nil, bps: 1},
|
|
{name: "unknown total", progress: &uploadProgress{uploaded: 25}, bps: 1},
|
|
{name: "complete", progress: &uploadProgress{uploaded: 100, totalBytes: 100}, bps: 1},
|
|
{name: "stalled", progress: &uploadProgress{uploaded: 25, totalBytes: 100}, bps: 0},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if eta := uploadETA(tc.progress, tc.bps); eta != nil {
|
|
t.Fatalf("expected empty ETA, got %#v", eta)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTaskErrorMessageTruncatesToSchemaLimit(t *testing.T) {
|
|
err := errors.New(strings.Repeat("x", maxTaskErrorMessageLength+100))
|
|
|
|
msg := taskErrorMessage(err)
|
|
if len(msg) != maxTaskErrorMessageLength {
|
|
t.Fatalf("expected message length %d, got %d", maxTaskErrorMessageLength, len(msg))
|
|
}
|
|
if !strings.HasSuffix(msg, "...") {
|
|
t.Fatalf("expected truncated message to end with ellipsis, got %q", msg[len(msg)-10:])
|
|
}
|
|
}
|
|
|
|
func TestNextTaskWorkStage(t *testing.T) {
|
|
total := int64(100)
|
|
cases := []struct {
|
|
name string
|
|
task client.DownloadTask
|
|
want taskWorkStage
|
|
}{
|
|
{
|
|
name: "uploading status",
|
|
task: clientTaskWithStatus("task-1", "uploading"),
|
|
want: taskWorkStageUploadExistingResult,
|
|
},
|
|
{
|
|
name: "assigned with upload bytes",
|
|
task: func() client.DownloadTask {
|
|
task := clientTaskWithStatus("task-1", "assigned")
|
|
task.Status.Progress.Upload.Bytes = 1
|
|
return task
|
|
}(),
|
|
want: taskWorkStageUploadExistingResult,
|
|
},
|
|
{
|
|
name: "assigned with uploading phase",
|
|
task: withRuntime(clientTaskWithStatus("task-1", "assigned"), &client.DownloadTaskRuntime{Phase: "uploading"}),
|
|
want: taskWorkStageUploadExistingResult,
|
|
},
|
|
{
|
|
name: "assigned with completed phase",
|
|
task: withRuntime(clientTaskWithStatus("task-1", "assigned"), &client.DownloadTaskRuntime{Phase: "completed"}),
|
|
want: taskWorkStageUploadExistingResult,
|
|
},
|
|
{
|
|
name: "assigned with completed download bytes",
|
|
task: withDownloadCheckpoint(clientTaskWithStatus("task-1", "assigned"), 100, &total),
|
|
want: taskWorkStageUploadExistingResult,
|
|
},
|
|
{
|
|
name: "assigned with removed local result marker",
|
|
task: withRuntime(
|
|
withDownloadCheckpoint(clientTaskWithStatus("task-1", "assigned"), 100, &total),
|
|
&client.DownloadTaskRuntime{Phase: "error", State: localResultRemovedRuntimeState},
|
|
),
|
|
want: taskWorkStageDownload,
|
|
},
|
|
{
|
|
name: "assigned partial download",
|
|
task: withDownloadCheckpoint(clientTaskWithStatus("task-1", "assigned"), 99, &total),
|
|
want: taskWorkStageDownload,
|
|
},
|
|
{
|
|
name: "downloading completed bytes",
|
|
task: withDownloadCheckpoint(clientTaskWithStatus("task-1", "downloading"), 100, &total),
|
|
want: taskWorkStageUploadExistingResult,
|
|
},
|
|
{
|
|
name: "downloading partial download",
|
|
task: withDownloadCheckpoint(clientTaskWithStatus("task-1", "downloading"), 99, &total),
|
|
want: taskWorkStageDownload,
|
|
},
|
|
{
|
|
name: "interrupted with uploading phase",
|
|
task: withRuntime(clientTaskWithStatus("task-1", "interrupted"), &client.DownloadTaskRuntime{Phase: "uploading"}),
|
|
want: taskWorkStageUploadExistingResult,
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := nextTaskWorkStage(tc.task); got != tc.want {
|
|
t.Fatalf("expected %v, got %v", tc.want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResetTaskForRestartAttemptResetsRuntimeAndRecordsAttempt(t *testing.T) {
|
|
stateDir := t.TempDir()
|
|
seedPath := t.TempDir()
|
|
if err := saveSeedLedger(stateDir, seedLedger{Seeds: []seedLedgerEntry{{
|
|
TaskID: "task-1",
|
|
Engine: "aria2",
|
|
SeedID: "gid",
|
|
InfoHash: "abc123",
|
|
Path: seedPath,
|
|
RetainedAt: time.Now().Add(-time.Minute),
|
|
ExpiresAt: time.Now().Add(time.Hour),
|
|
}}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
task := clientTaskWithStatus("task-1", "assigned")
|
|
task.Status.Attempt = 2
|
|
eng := &recordingEngine{}
|
|
w := NewTaskRunnerWithAPI(config.Config{StateDir: stateDir}, nil)
|
|
w.downloader = NewManagerWithDownloader(eng)
|
|
|
|
if err := w.resetTaskForAttempt(context.Background(), task, w.logger); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if eng.resetCalls != 1 {
|
|
t.Fatalf("expected reset once, got %d", eng.resetCalls)
|
|
}
|
|
attempts, err := loadAttemptLedger(stateDir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if attempts.Attempts["task-1"] != 2 {
|
|
t.Fatalf("expected attempt 2 to be recorded, got %#v", attempts.Attempts)
|
|
}
|
|
seedLedger, err := loadSeedLedger(stateDir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(seedLedger.Seeds) != 0 {
|
|
t.Fatalf("expected restart to remove retained seed ledger, got %#v", seedLedger.Seeds)
|
|
}
|
|
}
|
|
|
|
func TestResetTaskForRestartAttemptSkipsAlreadyRecordedAttempt(t *testing.T) {
|
|
stateDir := t.TempDir()
|
|
if err := saveAttemptLedger(stateDir, attemptLedger{Attempts: map[string]int{"task-1": 2}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
task := clientTaskWithStatus("task-1", "assigned")
|
|
task.Status.Attempt = 2
|
|
eng := &recordingEngine{}
|
|
w := NewTaskRunnerWithAPI(config.Config{StateDir: stateDir}, nil)
|
|
w.downloader = NewManagerWithDownloader(eng)
|
|
|
|
if err := w.resetTaskForAttempt(context.Background(), task, w.logger); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if eng.resetCalls != 0 {
|
|
t.Fatalf("expected no reset for recorded attempt, got %d", eng.resetCalls)
|
|
}
|
|
}
|
|
|
|
func TestRetainSeedKeepsDownloadedResult(t *testing.T) {
|
|
dir := t.TempDir()
|
|
stateDir := t.TempDir()
|
|
cleaned := false
|
|
w := NewTaskRunnerWithAPI(config.Config{SeedEnabled: true, SeedDuration: time.Hour, StateDir: stateDir}, nil)
|
|
|
|
retained := w.seeds.Retain(
|
|
context.Background(),
|
|
clientTask("task-1"),
|
|
Result{
|
|
Path: filepath.Join(dir, "result"),
|
|
Size: 123,
|
|
Seed: &Seed{
|
|
Engine: "aria2",
|
|
ID: "gid",
|
|
InfoHash: "infohash",
|
|
Path: dir,
|
|
Snapshot: func(context.Context) (SeedSnapshot, error) {
|
|
return SeedSnapshot{}, nil
|
|
},
|
|
Cleanup: func(context.Context) error {
|
|
cleaned = true
|
|
return nil
|
|
},
|
|
},
|
|
},
|
|
w.logger,
|
|
)
|
|
|
|
if !retained {
|
|
t.Fatal("expected bt result to be retained")
|
|
}
|
|
if cleaned {
|
|
t.Fatal("expected retained seed cleanup to be deferred")
|
|
}
|
|
if len(w.seeds.retainedSeedSnapshot()) != 1 {
|
|
t.Fatalf("expected one retained seed, got %d", len(w.seeds.retainedSeedSnapshot()))
|
|
}
|
|
ledger, err := loadSeedLedger(stateDir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(ledger.Seeds) != 1 || ledger.Seeds[0].TaskID != "task-1" || ledger.Seeds[0].InfoHash != "infohash" {
|
|
t.Fatalf("expected retained seed ledger entry, got %#v", ledger.Seeds)
|
|
}
|
|
}
|
|
|
|
func TestRetainedSeedExpiresWhenLedgerPersistenceFails(t *testing.T) {
|
|
stateFile := filepath.Join(t.TempDir(), "state-file")
|
|
if err := os.WriteFile(stateFile, []byte("not a directory"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cleaned := false
|
|
w := NewTaskRunnerWithAPI(config.Config{SeedEnabled: true, SeedDuration: time.Hour, StateDir: stateFile}, &recordingAPI{})
|
|
w.downloader = NewManagerWithDownloader(&recordingEngine{})
|
|
|
|
retained := w.seeds.Retain(
|
|
context.Background(),
|
|
clientTask("task-1"),
|
|
Result{
|
|
Path: filepath.Join(t.TempDir(), "result"),
|
|
Size: 123,
|
|
Seed: &Seed{
|
|
Engine: "aria2",
|
|
ID: "gid",
|
|
InfoHash: "infohash",
|
|
Path: t.TempDir(),
|
|
Snapshot: func(context.Context) (SeedSnapshot, error) {
|
|
return SeedSnapshot{}, nil
|
|
},
|
|
Cleanup: func(context.Context) error {
|
|
cleaned = true
|
|
return nil
|
|
},
|
|
},
|
|
},
|
|
w.logger,
|
|
)
|
|
|
|
if !retained {
|
|
t.Fatal("expected seed to remain tracked in memory")
|
|
}
|
|
if len(w.seeds.retainedSeedSnapshot()) != 1 {
|
|
t.Fatalf("expected retained seed despite ledger failure, got %d", len(w.seeds.retainedSeedSnapshot()))
|
|
}
|
|
w.seeds.retainedSeeds[0].expiresAt = time.Now().Add(-time.Second)
|
|
|
|
w.seeds.Cleanup(context.Background())
|
|
|
|
if !cleaned {
|
|
t.Fatal("expected in-memory retained seed to expire and clean up")
|
|
}
|
|
if len(w.seeds.retainedSeedSnapshot()) != 0 {
|
|
t.Fatalf("expected expired seed to be removed from memory, got %d", len(w.seeds.retainedSeedSnapshot()))
|
|
}
|
|
}
|
|
|
|
func TestReportRetainedSeedsCleansMissingSeed(t *testing.T) {
|
|
cleaned := false
|
|
w := NewTaskRunnerWithAPI(config.Config{SeedEnabled: true}, &recordingAPI{})
|
|
w.downloader = NewManagerWithDownloader(&recordingEngine{})
|
|
w.seeds.retainedSeeds = []retainedSeed{{
|
|
taskID: "task-1",
|
|
engine: "aria2",
|
|
seedID: "missing-gid",
|
|
snapshot: func(context.Context) (SeedSnapshot, error) {
|
|
return SeedSnapshot{}, errors.New("GID missing-gid is not found")
|
|
},
|
|
cleanup: func(context.Context) error {
|
|
cleaned = true
|
|
return nil
|
|
},
|
|
}}
|
|
|
|
w.seeds.Report(context.Background())
|
|
|
|
if !cleaned {
|
|
t.Fatal("expected missing retained seed to be cleaned")
|
|
}
|
|
if got := len(w.seeds.retainedSeedSnapshot()); got != 0 {
|
|
t.Fatalf("expected missing retained seed to be removed, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestReportRetainedSeedsSendsCompleteSeedingSnapshot(t *testing.T) {
|
|
api := &recordingAPI{}
|
|
w := NewTaskRunnerWithAPI(config.Config{SeedEnabled: true}, api)
|
|
total := int64(100)
|
|
eta := int64(30)
|
|
uploaded := int64(12)
|
|
w.seeds.retainedSeeds = []retainedSeed{{
|
|
taskID: "task-1",
|
|
engine: "aria2",
|
|
seedID: "gid",
|
|
size: total,
|
|
downloaded: total,
|
|
snapshot: func(context.Context) (SeedSnapshot, error) {
|
|
return SeedSnapshot{
|
|
Downloaded: total,
|
|
Total: &total,
|
|
Runtime: &TaskRuntime{
|
|
Engine: "aria2",
|
|
Phase: "seeding",
|
|
ETASeconds: &eta,
|
|
Seeding: &SeedingRuntime{UploadedBytes: &uploaded},
|
|
},
|
|
}, nil
|
|
},
|
|
cleanup: func(context.Context) error { return nil },
|
|
}}
|
|
|
|
w.seeds.Report(context.Background())
|
|
|
|
patch := api.patches[len(api.patches)-1]
|
|
if patch.Runtime == nil || patch.Runtime.ETASeconds != nil {
|
|
t.Fatalf("expected seeding runtime without ETA, got %#v", patch.Runtime)
|
|
}
|
|
if patch.Runtime.Progress == nil ||
|
|
patch.Runtime.Progress.Download.Bytes != total ||
|
|
patch.Runtime.Progress.Upload.Bytes != total {
|
|
t.Fatalf("expected complete transfer progress in seeding snapshot, got %#v", patch.Runtime.Progress)
|
|
}
|
|
if patch.Progress == nil || patch.Progress.Upload == nil || patch.Progress.Upload.Bytes != total {
|
|
t.Fatalf("expected top-level upload progress to stay complete, got %#v", patch.Progress)
|
|
}
|
|
}
|
|
|
|
func TestReportRetainedSeedsSkipsUnchangedSnapshot(t *testing.T) {
|
|
api := &recordingAPI{}
|
|
w := NewTaskRunnerWithAPI(config.Config{SeedEnabled: true}, api)
|
|
total := int64(100)
|
|
uploaded := int64(12)
|
|
w.seeds.retainedSeeds = []retainedSeed{{
|
|
taskID: "task-1",
|
|
engine: "aria2",
|
|
seedID: "gid",
|
|
size: total,
|
|
downloaded: total,
|
|
snapshot: func(context.Context) (SeedSnapshot, error) {
|
|
return SeedSnapshot{
|
|
Downloaded: total,
|
|
Total: &total,
|
|
Runtime: &TaskRuntime{
|
|
Engine: "aria2",
|
|
Phase: "seeding",
|
|
Seeding: &SeedingRuntime{UploadedBytes: &uploaded},
|
|
},
|
|
}, nil
|
|
},
|
|
cleanup: func(context.Context) error { return nil },
|
|
}}
|
|
|
|
w.seeds.Report(context.Background())
|
|
w.seeds.Report(context.Background())
|
|
if len(api.patches) != 1 {
|
|
t.Fatalf("expected unchanged seed snapshot to be reported once, got %d patches", len(api.patches))
|
|
}
|
|
|
|
uploaded = 24
|
|
w.seeds.Report(context.Background())
|
|
if len(api.patches) != 2 {
|
|
t.Fatalf("expected changed seed snapshot to be reported again, got %d patches", len(api.patches))
|
|
}
|
|
}
|
|
|
|
func TestReportRetainedSeedsStoppedClearsSeedingPhase(t *testing.T) {
|
|
api := &recordingAPI{}
|
|
w := NewTaskRunnerWithAPI(config.Config{SeedEnabled: true}, api)
|
|
total := int64(100)
|
|
uploaded := int64(40)
|
|
active := true
|
|
w.seeds.retainedSeeds = []retainedSeed{{
|
|
taskID: "task-1",
|
|
engine: "aria2",
|
|
seedID: "gid",
|
|
size: total,
|
|
downloaded: total,
|
|
snapshot: func(context.Context) (SeedSnapshot, error) {
|
|
return SeedSnapshot{
|
|
Downloaded: total,
|
|
Total: &total,
|
|
Runtime: &TaskRuntime{
|
|
Engine: "aria2",
|
|
Phase: "seeding",
|
|
Seeding: &SeedingRuntime{Active: &active, UploadedBytes: &uploaded},
|
|
},
|
|
}, nil
|
|
},
|
|
cleanup: func(context.Context) error { return nil },
|
|
}}
|
|
|
|
w.seeds.ReportStopped(context.Background())
|
|
|
|
patch := api.patches[len(api.patches)-1]
|
|
if patch.Runtime == nil || patch.Runtime.Phase != "completed" {
|
|
t.Fatalf("expected completed runtime, got %#v", patch.Runtime)
|
|
}
|
|
if patch.Runtime.Seeding == nil || patch.Runtime.Seeding.Active == nil || *patch.Runtime.Seeding.Active {
|
|
t.Fatalf("expected inactive seeding detail, got %#v", patch.Runtime.Seeding)
|
|
}
|
|
if patch.Runtime.Seeding.UploadBytesPerSecond == nil || *patch.Runtime.Seeding.UploadBytesPerSecond != 0 {
|
|
t.Fatalf("expected zero seeding upload speed, got %#v", patch.Runtime.Seeding)
|
|
}
|
|
if patch.Runtime.Progress == nil ||
|
|
patch.Runtime.Progress.Download.Bytes != total ||
|
|
patch.Runtime.Progress.Upload.Bytes != total {
|
|
t.Fatalf("expected complete transfer progress, got %#v", patch.Runtime.Progress)
|
|
}
|
|
}
|
|
|
|
func TestRestoreRetainedSeedsLoadsLedger(t *testing.T) {
|
|
stateDir := t.TempDir()
|
|
seedPath := t.TempDir()
|
|
retainedAt := time.Now().Add(-time.Minute)
|
|
if err := saveSeedLedger(stateDir, seedLedger{Seeds: []seedLedgerEntry{{
|
|
TaskID: "task-1",
|
|
Engine: "aria2",
|
|
SeedID: "old-gid",
|
|
InfoHash: "abc123",
|
|
Path: seedPath,
|
|
Size: 456,
|
|
RetainedAt: retainedAt,
|
|
ExpiresAt: time.Now().Add(time.Hour),
|
|
}}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
eng := &recordingEngine{name: "aria2", restoreSeed: &Seed{
|
|
Engine: "aria2",
|
|
ID: "new-gid",
|
|
InfoHash: "abc123",
|
|
Path: seedPath,
|
|
Snapshot: func(context.Context) (SeedSnapshot, error) {
|
|
return SeedSnapshot{}, nil
|
|
},
|
|
Cleanup: func(context.Context) error {
|
|
return nil
|
|
},
|
|
}}
|
|
w := NewTaskRunnerWithAPI(config.Config{SeedEnabled: true, StateDir: stateDir}, nil)
|
|
w.downloader = NewManagerWithDownloader(eng)
|
|
|
|
w.seeds.Restore(context.Background())
|
|
|
|
seeds := w.seeds.retainedSeedSnapshot()
|
|
if len(seeds) != 1 {
|
|
t.Fatalf("expected one restored seed, got %#v", seeds)
|
|
}
|
|
if seeds[0].seedID != "new-gid" || seeds[0].path != seedPath || !seeds[0].retainedAt.Equal(retainedAt) {
|
|
t.Fatalf("unexpected restored seed: %#v", seeds[0])
|
|
}
|
|
if eng.restoreCalls != 1 {
|
|
t.Fatalf("expected one restore call, got %d", eng.restoreCalls)
|
|
}
|
|
}
|
|
|
|
func TestRestoreRetainedSeedsDoesNotDuplicateAlreadyRestoredSeed(t *testing.T) {
|
|
stateDir := t.TempDir()
|
|
seedPath := t.TempDir()
|
|
if err := saveSeedLedger(stateDir, seedLedger{Seeds: []seedLedgerEntry{{
|
|
TaskID: "task-1",
|
|
Engine: "aria2",
|
|
SeedID: "gid",
|
|
InfoHash: "abc123",
|
|
Path: seedPath,
|
|
Size: 456,
|
|
RetainedAt: time.Now().Add(-time.Minute),
|
|
ExpiresAt: time.Now().Add(time.Hour),
|
|
}}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
eng := &recordingEngine{name: "aria2", restoreSeed: &Seed{
|
|
Engine: "aria2",
|
|
ID: "gid",
|
|
InfoHash: "abc123",
|
|
Path: seedPath,
|
|
Snapshot: func(context.Context) (SeedSnapshot, error) {
|
|
return SeedSnapshot{}, nil
|
|
},
|
|
Cleanup: func(context.Context) error {
|
|
return nil
|
|
},
|
|
}}
|
|
w := NewTaskRunnerWithAPI(config.Config{SeedEnabled: true, StateDir: stateDir}, nil)
|
|
w.downloader = NewManagerWithDownloader(eng)
|
|
|
|
w.seeds.Restore(context.Background())
|
|
w.seeds.Restore(context.Background())
|
|
|
|
if got := len(w.seeds.retainedSeedSnapshot()); got != 1 {
|
|
t.Fatalf("expected one restored seed, got %d", got)
|
|
}
|
|
if eng.restoreCalls != 1 {
|
|
t.Fatalf("expected restore to skip existing seed, got %d calls", eng.restoreCalls)
|
|
}
|
|
}
|
|
|
|
func TestCleanupRetainedSeedsRemovesExpiredSeed(t *testing.T) {
|
|
dir := t.TempDir()
|
|
w := NewTaskRunnerWithAPI(config.Config{SeedEnabled: true, SeedDuration: time.Hour}, &recordingAPI{})
|
|
cleaned := false
|
|
w.seeds.retainedSeeds = []retainedSeed{{
|
|
taskID: "task-1",
|
|
engine: "aria2",
|
|
seedID: "gid",
|
|
path: dir,
|
|
expiresAt: time.Now().Add(-time.Second),
|
|
snapshot: func(context.Context) (SeedSnapshot, error) {
|
|
return SeedSnapshot{}, nil
|
|
},
|
|
cleanup: func(context.Context) error {
|
|
cleaned = true
|
|
return nil
|
|
},
|
|
}}
|
|
|
|
w.seeds.Cleanup(context.Background())
|
|
|
|
if !cleaned {
|
|
t.Fatal("expected expired seed to be cleaned")
|
|
}
|
|
if len(w.seeds.retainedSeedSnapshot()) != 0 {
|
|
t.Fatalf("expected retained seed to be removed, got %d", len(w.seeds.retainedSeedSnapshot()))
|
|
}
|
|
}
|
|
|
|
func TestCleanupRetainedSeedsRemovesSeedAfterRatio(t *testing.T) {
|
|
dir := t.TempDir()
|
|
w := NewTaskRunnerWithAPI(config.Config{SeedEnabled: true, SeedRatio: 1.5}, &recordingAPI{})
|
|
cleaned := false
|
|
uploaded := int64(151)
|
|
w.seeds.retainedSeeds = []retainedSeed{{
|
|
taskID: "task-1",
|
|
engine: "aria2",
|
|
seedID: "gid",
|
|
path: dir,
|
|
downloaded: 100,
|
|
snapshot: func(context.Context) (SeedSnapshot, error) {
|
|
return SeedSnapshot{
|
|
Runtime: &TaskRuntime{
|
|
Seeding: &SeedingRuntime{UploadedBytes: &uploaded},
|
|
},
|
|
}, nil
|
|
},
|
|
cleanup: func(context.Context) error {
|
|
cleaned = true
|
|
return nil
|
|
},
|
|
}}
|
|
|
|
w.seeds.Cleanup(context.Background())
|
|
|
|
if !cleaned {
|
|
t.Fatal("expected ratio seed to be cleaned")
|
|
}
|
|
if len(w.seeds.retainedSeedSnapshot()) != 0 {
|
|
t.Fatalf("expected retained seed to be removed, got %d", len(w.seeds.retainedSeedSnapshot()))
|
|
}
|
|
}
|
|
|
|
func TestCleanupRetainedSeedsRemovesOldestWhenCacheLimitExceeded(t *testing.T) {
|
|
root := t.TempDir()
|
|
oldDir := filepath.Join(root, "old")
|
|
newDir := filepath.Join(root, "new")
|
|
if err := os.MkdirAll(oldDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(newDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(oldDir, "old.bin"), []byte("old"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(newDir, "new.bin"), []byte("new"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var cleaned []string
|
|
w := NewTaskRunnerWithAPI(config.Config{SeedEnabled: true, SeedCacheLimit: 3}, &recordingAPI{})
|
|
w.seeds.retainedSeeds = []retainedSeed{
|
|
{
|
|
taskID: "old",
|
|
engine: "qbittorrent",
|
|
seedID: "old-hash",
|
|
path: oldDir,
|
|
retainedAt: time.Now().Add(-time.Hour),
|
|
snapshot: func(context.Context) (SeedSnapshot, error) {
|
|
return SeedSnapshot{}, nil
|
|
},
|
|
cleanup: func(context.Context) error {
|
|
cleaned = append(cleaned, "old")
|
|
return nil
|
|
},
|
|
},
|
|
{
|
|
taskID: "new",
|
|
engine: "qbittorrent",
|
|
seedID: "new-hash",
|
|
path: newDir,
|
|
retainedAt: time.Now(),
|
|
snapshot: func(context.Context) (SeedSnapshot, error) {
|
|
return SeedSnapshot{}, nil
|
|
},
|
|
cleanup: func(context.Context) error {
|
|
cleaned = append(cleaned, "new")
|
|
return nil
|
|
},
|
|
},
|
|
}
|
|
|
|
w.seeds.Cleanup(context.Background())
|
|
|
|
if len(cleaned) != 1 || cleaned[0] != "old" {
|
|
t.Fatalf("expected oldest seed to be cleaned first, got %v", cleaned)
|
|
}
|
|
seeds := w.seeds.retainedSeedSnapshot()
|
|
if len(seeds) != 1 || seeds[0].taskID != "new" {
|
|
t.Fatalf("expected newest seed to remain, got %+v", seeds)
|
|
}
|
|
}
|
|
|
|
func writeTempFile(t *testing.T, content string) string {
|
|
t.Helper()
|
|
file, err := os.CreateTemp(t.TempDir(), "upload-*")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := file.WriteString(content); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := file.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return file.Name()
|
|
}
|
|
|
|
func waitForWorkerTestCompletion(t *testing.T, done <-chan struct{}) {
|
|
t.Helper()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("timed out waiting for worker completion")
|
|
}
|
|
}
|
|
|
|
func clientTask(id string) client.DownloadTask {
|
|
return client.DownloadTask{
|
|
ID: id,
|
|
Spec: client.DownloadTaskSpec{
|
|
Source: client.DownloadTaskSource{Type: "magnet"},
|
|
Labels: client.DownloadTaskLabels{Tags: []string{}},
|
|
},
|
|
Status: client.DownloadTaskStatus{Attempt: 1},
|
|
}
|
|
}
|
|
|
|
func clientTaskWithStatus(id string, status string) client.DownloadTask {
|
|
task := clientTask(id)
|
|
task.Status.State = status
|
|
return task
|
|
}
|
|
|
|
func clientTaskWithUploadToken(id string, status string) client.DownloadTask {
|
|
task := clientTaskWithStatus(id, status)
|
|
task.Status.Assignment = &client.DownloadTaskAssignment{DownloaderID: "downloader-1", UploadToken: "upload-token"}
|
|
return task
|
|
}
|
|
|
|
func clientHTTPTask(id string, status string, uri string, name string) client.DownloadTask {
|
|
task := clientTaskWithUploadToken(id, status)
|
|
task.Spec.Source = client.DownloadTaskSource{Type: "http", URI: uri}
|
|
task.Spec.Destination.Name = name
|
|
return task
|
|
}
|
|
|
|
func withDownloadCheckpoint(task client.DownloadTask, bytes int64, total *int64) client.DownloadTask {
|
|
task.Status.Progress.Download = client.DownloadTaskTransferProgress{Bytes: bytes, TotalBytes: total}
|
|
return task
|
|
}
|
|
|
|
func withRuntime(task client.DownloadTask, runtime *client.DownloadTaskRuntime) client.DownloadTask {
|
|
task.Status.Runtime = runtime
|
|
return task
|
|
}
|
|
|
|
func lastPatchWithStatus(t *testing.T, patches []client.TaskPatch, status string) client.TaskPatch {
|
|
t.Helper()
|
|
patch, ok := findPatchWithStatus(patches, status)
|
|
if !ok {
|
|
t.Fatalf("expected patch with status %q in %#v", status, patches)
|
|
}
|
|
return patch
|
|
}
|
|
|
|
func findPatchWithStatus(patches []client.TaskPatch, status string) (client.TaskPatch, bool) {
|
|
for i := len(patches) - 1; i >= 0; i-- {
|
|
if patches[i].State() == status {
|
|
return patches[i], true
|
|
}
|
|
}
|
|
return client.TaskPatch{}, false
|
|
}
|
|
|
|
type recordingEngine struct {
|
|
name string
|
|
downloadResult Result
|
|
downloadErr error
|
|
downloadFunc func(context.Context, DownloadTask, ProgressReporter) (Result, error)
|
|
resetErr error
|
|
resetTaskFn func(context.Context, DownloadTask) error
|
|
taskSnapshot TaskSnapshot
|
|
inspectErr error
|
|
inspectPanic any
|
|
taskFound bool
|
|
restoreSeed *Seed
|
|
restoreErr error
|
|
listSeeds []Seed
|
|
listSeedsErr error
|
|
downloadCalls int
|
|
resetCalls int
|
|
inspectCalls int
|
|
restoreCalls int
|
|
listSeedsCalls int
|
|
}
|
|
|
|
type preservingHTTPDownloadEngine struct {
|
|
dir string
|
|
}
|
|
|
|
func (e preservingHTTPDownloadEngine) Name() string {
|
|
return "recording-http"
|
|
}
|
|
|
|
func (e preservingHTTPDownloadEngine) Capabilities() Capabilities {
|
|
return Capabilities{SourceTypes: []string{"http"}}
|
|
}
|
|
|
|
func (e preservingHTTPDownloadEngine) Start(context.Context) error {
|
|
return nil
|
|
}
|
|
|
|
func (e preservingHTTPDownloadEngine) Stop(context.Context) error {
|
|
return nil
|
|
}
|
|
|
|
func (e preservingHTTPDownloadEngine) Check(context.Context) error {
|
|
return nil
|
|
}
|
|
|
|
func (e preservingHTTPDownloadEngine) InspectTask(_ context.Context, task DownloadTask) (TaskSnapshot, bool, error) {
|
|
path := filepath.Join(e.dir, task.ID, task.Name())
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return TaskSnapshot{}, false, nil
|
|
}
|
|
return TaskSnapshot{}, false, err
|
|
}
|
|
if info.IsDir() {
|
|
return TaskSnapshot{}, false, nil
|
|
}
|
|
size := info.Size()
|
|
result := Result{Path: path, Name: task.Name(), Size: size}
|
|
return TaskSnapshot{
|
|
State: TaskStateCompleted,
|
|
Downloaded: size,
|
|
Total: &size,
|
|
Runtime: &TaskRuntime{Engine: "recording-http", Phase: "completed"},
|
|
Result: &result,
|
|
}, true, nil
|
|
}
|
|
|
|
func (e preservingHTTPDownloadEngine) Download(ctx context.Context, task DownloadTask, progress ProgressReporter) (Result, error) {
|
|
res, err := http.Get(task.SourceURI())
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
|
return Result{}, errors.New(res.Status)
|
|
}
|
|
dir := filepath.Join(e.dir, task.ID)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return Result{}, err
|
|
}
|
|
path := filepath.Join(dir, task.Name())
|
|
file, err := os.Create(path)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
size, copyErr := io.Copy(file, res.Body)
|
|
closeErr := file.Close()
|
|
if copyErr != nil {
|
|
return Result{}, copyErr
|
|
}
|
|
if closeErr != nil {
|
|
return Result{}, closeErr
|
|
}
|
|
total := size
|
|
if err := progress(ProgressUpdate{
|
|
Downloaded: size,
|
|
Total: &total,
|
|
Runtime: &TaskRuntime{Engine: "recording-http", Phase: "completed"},
|
|
}); err != nil {
|
|
return Result{}, err
|
|
}
|
|
return Result{Path: path, Name: task.Name(), Size: size}, nil
|
|
}
|
|
|
|
func (e *recordingEngine) Name() string {
|
|
if e.name != "" {
|
|
return e.name
|
|
}
|
|
return "recording"
|
|
}
|
|
|
|
func (e *recordingEngine) Capabilities() Capabilities {
|
|
return Capabilities{SourceTypes: []string{"http", "magnet", "torrent", "torrent_url"}}
|
|
}
|
|
|
|
func (e *recordingEngine) Start(context.Context) error {
|
|
return nil
|
|
}
|
|
|
|
func (e *recordingEngine) Stop(context.Context) error {
|
|
return nil
|
|
}
|
|
|
|
func (e *recordingEngine) Check(context.Context) error {
|
|
return nil
|
|
}
|
|
|
|
func (e *recordingEngine) InspectTask(context.Context, DownloadTask) (TaskSnapshot, bool, error) {
|
|
e.inspectCalls++
|
|
if e.inspectPanic != nil {
|
|
panic(e.inspectPanic)
|
|
}
|
|
if e.inspectErr != nil {
|
|
return TaskSnapshot{}, false, e.inspectErr
|
|
}
|
|
return e.taskSnapshot, e.taskFound, nil
|
|
}
|
|
|
|
func (e *recordingEngine) RestoreSeed(context.Context, SeedRef) (*Seed, error) {
|
|
e.restoreCalls++
|
|
return e.restoreSeed, e.restoreErr
|
|
}
|
|
|
|
func (e *recordingEngine) ListSeeds(context.Context) ([]Seed, error) {
|
|
e.listSeedsCalls++
|
|
return e.listSeeds, e.listSeedsErr
|
|
}
|
|
|
|
func (e *recordingEngine) ResetTask(ctx context.Context, task DownloadTask) error {
|
|
e.resetCalls++
|
|
if e.resetTaskFn != nil {
|
|
return e.resetTaskFn(ctx, task)
|
|
}
|
|
return e.resetErr
|
|
}
|
|
|
|
func (e *recordingEngine) Download(ctx context.Context, task DownloadTask, progress ProgressReporter) (Result, error) {
|
|
e.downloadCalls++
|
|
if e.downloadFunc != nil {
|
|
return e.downloadFunc(ctx, task, progress)
|
|
}
|
|
return e.downloadResult, e.downloadErr
|
|
}
|
|
|
|
type recordingAPI struct {
|
|
patches []client.TaskPatch
|
|
patchedIDs []string
|
|
seedingTasks []client.DownloadTask
|
|
controlTasks []client.DownloadTask
|
|
assignedTasks []client.DownloadTask
|
|
localResultTasks []client.DownloadTask
|
|
heartbeatErr error
|
|
updateErr error
|
|
localResultErr error
|
|
assignedTasksCalls int
|
|
localResultTasksCalls int
|
|
nextPollAfterSeconds int
|
|
suspendDownloading bool
|
|
createFolderErr error
|
|
createFolderDrafts []client.ObjectDraft
|
|
deletedObjects []string
|
|
createObjectDraft client.ObjectDraft
|
|
completeErrs []error
|
|
}
|
|
|
|
func (a *recordingAPI) Heartbeat(context.Context, client.Heartbeat) (client.HeartbeatResult, error) {
|
|
if a.heartbeatErr != nil {
|
|
return client.HeartbeatResult{}, a.heartbeatErr
|
|
}
|
|
nextPoll := a.nextPollAfterSeconds
|
|
if nextPoll == 0 {
|
|
nextPoll = 5
|
|
}
|
|
return client.HeartbeatResult{Assignments: a.assignedTasks, Controls: a.controlTasks, NextPollAfterSeconds: nextPoll}, nil
|
|
}
|
|
|
|
func (a *recordingAPI) AssignedTasks(context.Context) ([]client.DownloadTask, error) {
|
|
a.assignedTasksCalls++
|
|
return a.assignedTasks, nil
|
|
}
|
|
|
|
func (a *recordingAPI) LocalResultTasks(context.Context) ([]client.DownloadTask, error) {
|
|
a.localResultTasksCalls++
|
|
if a.localResultErr != nil {
|
|
return nil, a.localResultErr
|
|
}
|
|
return a.localResultTasks, nil
|
|
}
|
|
|
|
func (a *recordingAPI) SeedingTasks(context.Context) ([]client.DownloadTask, error) {
|
|
return a.seedingTasks, nil
|
|
}
|
|
|
|
func (a *recordingAPI) UpdateTask(_ context.Context, id string, patch client.TaskPatch) (client.DownloadTask, error) {
|
|
if a.updateErr != nil {
|
|
return client.DownloadTask{}, a.updateErr
|
|
}
|
|
a.patches = append(a.patches, patch)
|
|
a.patchedIDs = append(a.patchedIDs, id)
|
|
state := patch.State()
|
|
if a.suspendDownloading && state == "downloading" {
|
|
state = "suspended"
|
|
}
|
|
recordedPatch := patch
|
|
if state != patch.State() {
|
|
recordedPatch.Status = state
|
|
}
|
|
applyRecordedTaskPatch(a.controlTasks, id, recordedPatch)
|
|
applyRecordedTaskPatch(a.assignedTasks, id, recordedPatch)
|
|
task := clientTaskWithStatus(id, state)
|
|
task = applyTaskPatch(task, recordedPatch)
|
|
return task, nil
|
|
}
|
|
|
|
func applyRecordedTaskPatch(tasks []client.DownloadTask, id string, patch client.TaskPatch) {
|
|
for i := range tasks {
|
|
if tasks[i].ID == id {
|
|
tasks[i] = applyTaskPatch(tasks[i], patch)
|
|
}
|
|
}
|
|
}
|
|
|
|
func applyTaskPatch(task client.DownloadTask, patch client.TaskPatch) client.DownloadTask {
|
|
if patch.State() != "" {
|
|
task.Status.State = patch.State()
|
|
}
|
|
if patch.Runtime != nil {
|
|
task.Status.Runtime = patch.Runtime
|
|
}
|
|
if patch.Progress != nil {
|
|
if patch.Progress.Download != nil {
|
|
task.Status.Progress.Download = *patch.Progress.Download
|
|
}
|
|
if patch.Progress.Upload != nil {
|
|
task.Status.Progress.Upload = *patch.Progress.Upload
|
|
}
|
|
}
|
|
return task
|
|
}
|
|
|
|
func (a *recordingAPI) CreateFolder(context.Context, string, string, string) (client.ObjectDraft, error) {
|
|
if a.createFolderErr != nil {
|
|
return client.ObjectDraft{}, a.createFolderErr
|
|
}
|
|
if len(a.createFolderDrafts) > 0 {
|
|
draft := a.createFolderDrafts[0]
|
|
a.createFolderDrafts = a.createFolderDrafts[1:]
|
|
return draft, nil
|
|
}
|
|
return client.ObjectDraft{ID: "folder-1", Name: "folder"}, nil
|
|
}
|
|
|
|
func (a *recordingAPI) CreateObject(context.Context, string, string, int64, string) (client.ObjectDraft, error) {
|
|
return a.createObjectDraft, nil
|
|
}
|
|
|
|
func (a *recordingAPI) CompleteObjectUpload(context.Context, string, string, string, []client.CompletedObjectUploadPart) error {
|
|
if len(a.completeErrs) > 0 {
|
|
err := a.completeErrs[0]
|
|
a.completeErrs = a.completeErrs[1:]
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *recordingAPI) AbortObjectUploadSession(context.Context, string, string, string) error {
|
|
return nil
|
|
}
|
|
|
|
func (a *recordingAPI) DeleteObject(_ context.Context, _ string, id string) error {
|
|
a.deletedObjects = append(a.deletedObjects, id)
|
|
return nil
|
|
}
|