Files
zpan/cmd/internal/client/client_test.go
T
Jasper VanandClaude Opus 4.8 399609b121 fix(downloader): drive stop-control from the task poll, make reporting pure (#464)
The progress-report path was making control decisions by string-matching error
bodies — and missed the suspended case entirely, so a billing-suspended task
kept downloading while the UI showed Suspended.

Reporting is now pure telemetry: updateTask just syncs progress/status and a
failed report is logged, never acted on. All stop-control (pause/cancel/suspend)
flows through the existing task poll:

- AssignedControlTasks now also queries 'suspended'; cancelRunning cancels the
  running download with errTaskSuspended and leaves the server-owned suspended
  status untouched.
- Removed errBillingPaused, the insufficient_credits string-match, and the
  isControlledTaskUpdateError/resolveControlledTaskUpdate error-string matching.
  pause/cancel already flowed through the poll; the report-path handling was a
  redundant, brittle second mechanism.

Worker-only; no server change (status=suspended is already pollable and keeps
assignedDownloaderId). Tests cover the suspended poll cause and the control
status query.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:01:16 -04:00

263 lines
8.9 KiB
Go

package client
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"sort"
"testing"
)
func downloadTaskFixture(id string, status string) DownloadTask {
return DownloadTask{
ID: id,
Spec: DownloadTaskSpec{
Source: DownloadTaskSource{Type: "http", URI: "https://example.com/file.bin"},
Destination: DownloadTaskDestination{Name: "file.bin"},
Labels: DownloadTaskLabels{Tags: []string{}},
},
Status: DownloadTaskStatus{
State: status,
Assignment: &DownloadTaskAssignment{
DownloaderID: "downloader-1",
UploadToken: "upload-token",
},
Progress: DownloadTaskProgress{
Download: DownloadTaskTransferProgress{Bytes: 1024, BytesPerSecond: 10},
Upload: DownloadTaskTransferProgress{},
},
},
}
}
func TestCreateObjectUsesRenameConflictStrategy(t *testing.T) {
var body map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/objects" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
w.Header().Set("Content-Type", "application/json")
// POST /api/objects always returns 201 Created.
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(ObjectDraft{ID: "object-1", Name: "movie (1).mkv"})
}))
defer server.Close()
_, err := mustClient(t, server.URL, "token").CreateObject(context.Background(), "upload-token", "movie.mkv", 1024, "Downloads")
if err != nil {
t.Fatal(err)
}
if body["onConflict"] != "rename" {
t.Fatalf("expected onConflict rename, got %#v", body["onConflict"])
}
}
func TestAssignedTasksFetchesRunnableStatuses(t *testing.T) {
var statuses []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/downloads/tasks" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
status := r.URL.Query().Get("status")
statuses = append(statuses, status)
_ = json.NewEncoder(w).Encode(Page[DownloadTask]{
Items: []DownloadTask{downloadTaskFixture("task-"+status, status)},
})
}))
defer server.Close()
tasks, err := mustClient(t, server.URL, "token").AssignedTasks(context.Background())
if err != nil {
t.Fatal(err)
}
sort.Strings(statuses)
expected := []string{"assigned", "downloading", "interrupted", "uploading"}
if !reflect.DeepEqual(statuses, expected) {
t.Fatalf("expected runnable statuses %v, got %v", expected, statuses)
}
if len(tasks) != 4 {
t.Fatalf("expected four tasks, got %d", len(tasks))
}
}
func TestAssignedControlTasksFetchesControlStatuses(t *testing.T) {
var statuses []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
statuses = append(statuses, r.URL.Query().Get("status"))
_ = json.NewEncoder(w).Encode(Page[DownloadTask]{Items: []DownloadTask{}})
}))
defer server.Close()
if _, err := mustClient(t, server.URL, "token").AssignedControlTasks(context.Background()); err != nil {
t.Fatal(err)
}
sort.Strings(statuses)
expected := []string{"canceling", "pausing", "suspended"}
if !reflect.DeepEqual(statuses, expected) {
t.Fatalf("expected control statuses %v, got %v", expected, statuses)
}
}
func TestSeedingTasksFiltersCompletedSeedingPhase(t *testing.T) {
var status string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
status = r.URL.Query().Get("status")
w.Header().Set("Content-Type", "application/json")
seeding := downloadTaskFixture("seed-1", "completed")
seeding.Status.Runtime = &DownloadTaskRuntime{Phase: "seeding"}
plain := downloadTaskFixture("done-1", "completed")
plain.Status.Runtime = &DownloadTaskRuntime{Phase: "completed"}
_ = json.NewEncoder(w).Encode(Page[DownloadTask]{Items: []DownloadTask{seeding, plain}})
}))
defer server.Close()
tasks, err := mustClient(t, server.URL, "token").SeedingTasks(context.Background())
if err != nil {
t.Fatal(err)
}
if status != "completed" {
t.Fatalf("expected status=completed query, got %q", status)
}
if len(tasks) != 1 || tasks[0].ID != "seed-1" {
t.Fatalf("expected only the seeding-phase task, got %v", tasks)
}
}
func TestUpdateTaskUsesGeneratedRequestShape(t *testing.T) {
var body map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPatch || r.URL.Path != "/api/downloads/tasks/task-1" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(downloadTaskFixture("task-1", "downloading"))
}))
defer server.Close()
downloadedBytes := int64(1024)
totalBytes := int64(2048)
etaSeconds := int64(30)
task, err := mustClient(t, server.URL, "token").UpdateTask(context.Background(), "task-1", TaskPatch{
Status: "downloading",
Progress: &DownloadTaskProgressPatch{
Download: &DownloadTaskTransferProgress{Bytes: downloadedBytes, TotalBytes: &totalBytes},
},
Runtime: &DownloadTaskRuntime{
Engine: "builtin",
Phase: "downloading",
ETASeconds: &etaSeconds,
Files: []DownloadTaskFile{{Path: "file.bin", Size: 2048, CompletedBytes: &downloadedBytes}},
},
})
if err != nil {
t.Fatal(err)
}
if task.ID != "task-1" || task.State() != "downloading" {
t.Fatalf("unexpected task: %#v", task)
}
if body["status"] != "downloading" {
t.Fatalf("unexpected patch body: %#v", body)
}
progress, ok := body["progress"].(map[string]any)
if !ok {
t.Fatalf("unexpected progress body: %#v", body["progress"])
}
download, ok := progress["download"].(map[string]any)
if !ok || download["bytes"] != float64(1024) || download["totalBytes"] != float64(2048) {
t.Fatalf("unexpected progress body: %#v", body["progress"])
}
runtime, ok := body["runtime"].(map[string]any)
if !ok || runtime["engine"] != "builtin" || runtime["phase"] != "downloading" || runtime["etaSeconds"] != float64(30) {
t.Fatalf("unexpected runtime body: %#v", body["runtime"])
}
}
func TestMultipartUploadSessionClientMethods(t *testing.T) {
var presignBody map[string]any
var completeBody map[string]any
var abortCalled bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodPost && r.URL.Path == "/api/objects/object-1/uploads/session-1/parts":
if err := json.NewDecoder(r.Body).Decode(&presignBody); err != nil {
t.Fatal(err)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"uploadId": "upload-1",
"partSize": 67108864,
"parts": []map[string]any{
{"partNumber": 1, "url": "https://s3/part-1"},
{"partNumber": 2, "url": "https://s3/part-2"},
},
})
case r.Method == http.MethodPost && r.URL.Path == "/api/objects/object-1/uploads/session-1/completions":
if err := json.NewDecoder(r.Body).Decode(&completeBody); err != nil {
t.Fatal(err)
}
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "object-1",
"name": "movie.mkv",
})
case r.Method == http.MethodDelete && r.URL.Path == "/api/objects/object-1/uploads/session-1":
abortCalled = true
w.WriteHeader(http.StatusNoContent)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
api := mustClient(t, server.URL, "token")
parts, err := api.PresignObjectUploadParts(context.Background(), "upload-token", "object-1", "session-1", []int{1, 2})
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(parts, []PresignedObjectUploadPart{{PartNumber: 1, URL: "https://s3/part-1"}, {PartNumber: 2, URL: "https://s3/part-2"}}) {
t.Fatalf("unexpected presigned parts: %#v", parts)
}
err = api.CompleteObjectUpload(context.Background(), "upload-token", "object-1", "session-1", []CompletedObjectUploadPart{{PartNumber: 1, ETag: `"etag-1"`}})
if err != nil {
t.Fatal(err)
}
err = api.AbortObjectUploadSession(context.Background(), "upload-token", "object-1", "session-1")
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(presignBody["partNumbers"], []any{float64(1), float64(2)}) {
t.Fatalf("expected presign part numbers, got %#v", presignBody)
}
completeParts, ok := completeBody["parts"].([]any)
if !ok || len(completeParts) != 1 {
t.Fatalf("unexpected complete body: %#v", completeBody)
}
part, ok := completeParts[0].(map[string]any)
if !ok || part["partNumber"] != float64(1) || part["etag"] != `"etag-1"` {
t.Fatalf("unexpected complete part: %#v", completeBody)
}
if !abortCalled {
t.Fatalf("abort was not called")
}
}
func mustClient(t *testing.T, baseURL string, token string) *Client {
t.Helper()
api, err := New(baseURL, token)
if err != nil {
t.Fatal(err)
}
return api
}