diff --git a/internal/daytona/client.go b/internal/daytona/client.go index 26968dc..254df8a 100644 --- a/internal/daytona/client.go +++ b/internal/daytona/client.go @@ -213,24 +213,38 @@ func (c *Client) GetSnapshot(ctx context.Context, id string) (*Snapshot, error) return &s, json.NewDecoder(resp.Body).Decode(&s) } -// ListSnapshots returns all Daytona snapshots. +// ListSnapshots returns all Daytona snapshots. v0.189 paginates GET /api/snapshots +// as {items, total, page, totalPages} with a default page size of 100, so walk every +// page rather than returning only the first — otherwise the list silently truncates +// once the registry holds more than one page of snapshots. func (c *Client) ListSnapshots(ctx context.Context) ([]Snapshot, error) { - resp, err := c.do(ctx, http.MethodGet, "/api/snapshots", nil) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("daytona list snapshots: %s", b) - } - var page struct { - Items []Snapshot `json:"items"` - } - if err := json.NewDecoder(resp.Body).Decode(&page); err != nil { - return nil, fmt.Errorf("decode snapshots: %w", err) + var all []Snapshot + for page := 1; ; page++ { + path := fmt.Sprintf("/api/snapshots?limit=100&page=%d", page) + resp, err := c.do(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, err + } + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("daytona list snapshots: %s", body) + } + var paged struct { + Items []Snapshot `json:"items"` + TotalPages int `json:"totalPages"` + } + if err := json.Unmarshal(body, &paged); err != nil { + return nil, fmt.Errorf("decode snapshots: %w", err) + } + all = append(all, paged.Items...) + if len(paged.Items) == 0 || page >= paged.TotalPages { + return all, nil + } } - return page.Items, nil } // BaseURL returns the configured base URL (used by reverse proxy). diff --git a/internal/daytona/client_test.go b/internal/daytona/client_test.go index 59f490c..b4a2fc1 100644 --- a/internal/daytona/client_test.go +++ b/internal/daytona/client_test.go @@ -153,6 +153,66 @@ func TestListSandboxes_SetsAuthHeader(t *testing.T) { } } +// ── ListSnapshots ───────────────────────────────────────────────────────────── + +func TestListSnapshots_WalksAllPages(t *testing.T) { + var gotPages []string + srv := mockServer(t, func(w http.ResponseWriter, r *http.Request) { + gotPages = append(gotPages, r.URL.Query().Get("page")) + w.Header().Set("Content-Type", "application/json") + switch r.URL.Query().Get("page") { + case "1": + w.Write([]byte(`{"items":[{"id":"s1"},{"id":"s2"}],"total":3,"page":1,"totalPages":2}`)) + case "2": + w.Write([]byte(`{"items":[{"id":"s3"}],"total":3,"page":2,"totalPages":2}`)) + default: + t.Errorf("unexpected page request: %q", r.URL.Query().Get("page")) + } + }) + + c := NewClient(srv.URL, "key") + got, err := c.ListSnapshots(context.Background()) + if err != nil { + t.Fatalf("ListSnapshots: %v", err) + } + if len(got) != 3 { + t.Fatalf("length: got %d want 3 (pagination truncated?)", len(got)) + } + if got[0].ID != "s1" || got[2].ID != "s3" { + t.Errorf("snapshot IDs: got %v", got) + } + if len(gotPages) != 2 || gotPages[0] != "1" || gotPages[1] != "2" { + t.Errorf("expected page requests [1 2], got %v", gotPages) + } +} + +func TestListSnapshots_SinglePage(t *testing.T) { + srv := mockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"items":[{"id":"only"}],"total":1,"page":1,"totalPages":1}`)) + }) + + c := NewClient(srv.URL, "key") + got, err := c.ListSnapshots(context.Background()) + if err != nil { + t.Fatalf("ListSnapshots: %v", err) + } + if len(got) != 1 || got[0].ID != "only" { + t.Errorf("got %v want single snapshot 'only'", got) + } +} + +func TestListSnapshots_NonOK_ReturnsError(t *testing.T) { + srv := mockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + }) + + c := NewClient(srv.URL, "key") + if _, err := c.ListSnapshots(context.Background()); err == nil { + t.Fatal("expected error for non-200, got nil") + } +} + // ── StopSandbox ─────────────────────────────────────────────────────────────── func TestStopSandbox_OK(t *testing.T) {