From 29d5d696073ea017d4864cafe58ae9f11acd4a21 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:28:38 +0000 Subject: [PATCH 1/3] Fix project list pagination --- README.md | 4 +++ cmd/projects.go | 54 +++++++++++++++++++++++++++++++++++----- cmd/projects_test.go | 59 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 66cc048..2e19665 100644 --- a/README.md +++ b/README.md @@ -473,6 +473,10 @@ Per-category updates are partial — only categories you name are changed; other ### Projects +- `kernel projects list` - List projects (up to 100 by default) + - `--limit ` - Maximum number of projects to return (1-100, default 100) + - `--offset ` - Number of projects to skip; table indexes match this offset + - When more projects are available, the CLI prints the exact command to fetch the next page - `kernel projects update ` - Update a project's name or status - `--name ` - New project name (1-255 characters) - `--status ` - New project status: `active` or `archived` diff --git a/cmd/projects.go b/cmd/projects.go index 28cedeb..fbc910b 100644 --- a/cmd/projects.go +++ b/cmd/projects.go @@ -3,6 +3,8 @@ package cmd import ( "context" "fmt" + "net/http" + "strconv" "strings" "github.com/kernel/cli/pkg/util" @@ -37,7 +39,10 @@ type ProjectsCmd struct { limits ProjectLimitsService } -type ProjectsListInput struct{} +type ProjectsListInput struct { + Limit int + Offset int +} type ProjectsCreateInput struct { Name string @@ -87,7 +92,18 @@ func resolveProjectArg(ctx context.Context, projects ProjectListService, val str } func (c ProjectsCmd) List(ctx context.Context, in ProjectsListInput) error { - projects, err := c.projects.List(ctx, kernel.ProjectListParams{}) + if in.Limit < 1 || in.Limit > 100 { + return fmt.Errorf("--limit must be between 1 and 100") + } + if in.Offset < 0 { + return fmt.Errorf("--offset must be non-negative") + } + + var response *http.Response + projects, err := c.projects.List(ctx, kernel.ProjectListParams{ + Limit: param.NewOpt(int64(in.Limit)), + Offset: param.NewOpt(int64(in.Offset)), + }, option.WithResponseInto(&response)) if err != nil { return util.CleanedUpSdkError{Err: err} } @@ -97,14 +113,35 @@ func (c ProjectsCmd) List(ctx context.Context, in ProjectsListInput) error { return nil } - table := pterm.TableData{{"ID", "Name", "Status", "Created At"}} - for _, p := range projects.Items { - table = append(table, []string{p.ID, p.Name, string(p.Status), util.FormatLocal(p.CreatedAt)}) + table := pterm.TableData{{"ID", "Name", "Status", "Created At", "idx"}} + for i, p := range projects.Items { + table = append(table, []string{ + p.ID, + p.Name, + string(p.Status), + util.FormatLocal(p.CreatedAt), + strconv.Itoa(in.Offset + i), + }) } PrintTableNoPad(table, true) + + if nextOffset, ok := projectListNextOffset(response); ok { + pterm.Warning.Printfln( + "Output truncated after index %d. Continue with: kernel projects list --limit %d --offset %d", + in.Offset+len(projects.Items)-1, in.Limit, nextOffset, + ) + } return nil } +func projectListNextOffset(response *http.Response) (int, bool) { + if response == nil { + return 0, false + } + nextOffset, err := strconv.Atoi(response.Header.Get("X-Next-Offset")) + return nextOffset, err == nil && nextOffset > 0 +} + func (c ProjectsCmd) Create(ctx context.Context, in ProjectsCreateInput) error { project, err := c.projects.New(ctx, kernel.ProjectNewParams{ CreateProjectRequest: kernel.CreateProjectRequestParam{ @@ -323,7 +360,9 @@ func getProjectsHandler(cmd *cobra.Command) ProjectsCmd { func runProjectsList(cmd *cobra.Command, args []string) error { c := getProjectsHandler(cmd) - return c.List(cmd.Context(), ProjectsListInput{}) + limit, _ := cmd.Flags().GetInt("limit") + offset, _ := cmd.Flags().GetInt("offset") + return c.List(cmd.Context(), ProjectsListInput{Limit: limit, Offset: offset}) } func runProjectsCreate(cmd *cobra.Command, args []string) error { @@ -477,6 +516,9 @@ var projectsSetLimitsCompatCmd = &cobra.Command{ } func init() { + projectsListCmd.Flags().Int("limit", 100, "Maximum number of projects to return (1-100)") + projectsListCmd.Flags().Int("offset", 0, "Number of projects to skip (for pagination)") + projectsUpdateCmd.Flags().String("name", "", "New project name (1-255 characters)") projectsUpdateCmd.Flags().String("status", "", "New project status: active or archived") addJSONOutputFlag(projectsUpdateCmd) diff --git a/cmd/projects_test.go b/cmd/projects_test.go index 8cc2de5..f015e79 100644 --- a/cmd/projects_test.go +++ b/cmd/projects_test.go @@ -3,6 +3,7 @@ package cmd import ( "context" "errors" + "net/http" "testing" "github.com/kernel/kernel-go-sdk" @@ -74,6 +75,64 @@ func (f *FakeProjectLimitsService) Update(ctx context.Context, id string, body k return &kernel.ProjectLimits{}, nil } +func TestProjectsList_ForwardsPaginationAndShowsAbsoluteIndexes(t *testing.T) { + buf := capturePtermOutput(t) + fakeProjects := &FakeProjectsService{ + ListFunc: func(ctx context.Context, query kernel.ProjectListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.Project], error) { + assert.True(t, query.Limit.Valid()) + assert.Equal(t, int64(2), query.Limit.Value) + assert.True(t, query.Offset.Valid()) + assert.Equal(t, int64(20), query.Offset.Value) + return &pagination.OffsetPagination[kernel.Project]{ + Items: []kernel.Project{ + {ID: "proj_20", Name: "twenty", Status: kernel.ProjectStatusActive}, + {ID: "proj_21", Name: "twenty-one", Status: kernel.ProjectStatusArchived}, + }, + }, nil + }, + } + c := ProjectsCmd{projects: fakeProjects, limits: &FakeProjectLimitsService{}} + + err := c.List(context.Background(), ProjectsListInput{Limit: 2, Offset: 20}) + assert.NoError(t, err) + out := buf.String() + assert.Contains(t, out, "idx") + assert.Contains(t, out, "proj_20") + assert.Contains(t, out, "20") + assert.Contains(t, out, "proj_21") + assert.Contains(t, out, "21") +} + +func TestProjectsList_RejectsInvalidPagination(t *testing.T) { + fakeProjects := &FakeProjectsService{ + ListFunc: func(ctx context.Context, query kernel.ProjectListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.Project], error) { + t.Fatal("List should not be called") + return nil, nil + }, + } + c := ProjectsCmd{projects: fakeProjects, limits: &FakeProjectLimitsService{}} + + for _, in := range []ProjectsListInput{ + {Limit: 0}, + {Limit: 101}, + {Limit: 100, Offset: -1}, + } { + assert.Error(t, c.List(context.Background(), in)) + } +} + +func TestProjectListNextOffset(t *testing.T) { + response := &http.Response{Header: http.Header{"X-Next-Offset": []string{"120"}}} + nextOffset, ok := projectListNextOffset(response) + assert.True(t, ok) + assert.Equal(t, 120, nextOffset) + + _, ok = projectListNextOffset(&http.Response{Header: http.Header{}}) + assert.False(t, ok) + _, ok = projectListNextOffset(nil) + assert.False(t, ok) +} + func TestProjectsLimitsGet_DefaultOutput(t *testing.T) { buf := capturePtermOutput(t) limits := &kernel.ProjectLimits{ From f281717325cff3a47a525fc73408c2537e7e1705 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:38:22 +0000 Subject: [PATCH 2/3] Add paginated project JSON output --- README.md | 1 + cmd/projects.go | 48 +++++++++++++++++++++++++++++++++++++++----- cmd/projects_test.go | 15 ++++++++++++++ 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 2e19665..3db24c1 100644 --- a/README.md +++ b/README.md @@ -476,6 +476,7 @@ Per-category updates are partial — only categories you name are changed; other - `kernel projects list` - List projects (up to 100 by default) - `--limit ` - Maximum number of projects to return (1-100, default 100) - `--offset ` - Number of projects to skip; table indexes match this offset + - `--output json`, `-o json` - Output `{ "projects": [...], "next_offset": }`; `next_offset` is omitted on the last page - When more projects are available, the CLI prints the exact command to fetch the next page - `kernel projects update ` - Update a project's name or status - `--name ` - New project name (1-255 characters) diff --git a/cmd/projects.go b/cmd/projects.go index fbc910b..9a5dc66 100644 --- a/cmd/projects.go +++ b/cmd/projects.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "encoding/json" "fmt" "net/http" "strconv" @@ -42,6 +43,7 @@ type ProjectsCmd struct { type ProjectsListInput struct { Limit int Offset int + Output string } type ProjectsCreateInput struct { @@ -92,6 +94,9 @@ func resolveProjectArg(ctx context.Context, projects ProjectListService, val str } func (c ProjectsCmd) List(ctx context.Context, in ProjectsListInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } if in.Limit < 1 || in.Limit > 100 { return fmt.Errorf("--limit must be between 1 and 100") } @@ -108,13 +113,28 @@ func (c ProjectsCmd) List(ctx context.Context, in ProjectsListInput) error { return util.CleanedUpSdkError{Err: err} } - if projects == nil || len(projects.Items) == 0 { + items := make([]kernel.Project, 0) + if projects != nil { + items = projects.Items + } + nextOffset, hasMore := projectListNextOffset(response) + + if in.Output == "json" { + data, err := marshalProjectsListJSON(items, nextOffset) + if err != nil { + return err + } + fmt.Println(string(data)) + return nil + } + + if len(items) == 0 { pterm.Info.Println("No projects found") return nil } table := pterm.TableData{{"ID", "Name", "Status", "Created At", "idx"}} - for i, p := range projects.Items { + for i, p := range items { table = append(table, []string{ p.ID, p.Name, @@ -125,10 +145,10 @@ func (c ProjectsCmd) List(ctx context.Context, in ProjectsListInput) error { } PrintTableNoPad(table, true) - if nextOffset, ok := projectListNextOffset(response); ok { + if hasMore { pterm.Warning.Printfln( "Output truncated after index %d. Continue with: kernel projects list --limit %d --offset %d", - in.Offset+len(projects.Items)-1, in.Limit, nextOffset, + in.Offset+len(items)-1, in.Limit, nextOffset, ) } return nil @@ -142,6 +162,22 @@ func projectListNextOffset(response *http.Response) (int, bool) { return nextOffset, err == nil && nextOffset > 0 } +func marshalProjectsListJSON(projects []kernel.Project, nextOffset int) ([]byte, error) { + rawProjects := make([]json.RawMessage, 0, len(projects)) + for _, project := range projects { + raw := project.RawJSON() + if raw == "" { + raw = "{}" + } + rawProjects = append(rawProjects, json.RawMessage(raw)) + } + payload := struct { + Projects []json.RawMessage `json:"projects"` + NextOffset int `json:"next_offset,omitempty"` + }{Projects: rawProjects, NextOffset: nextOffset} + return json.MarshalIndent(payload, "", " ") +} + func (c ProjectsCmd) Create(ctx context.Context, in ProjectsCreateInput) error { project, err := c.projects.New(ctx, kernel.ProjectNewParams{ CreateProjectRequest: kernel.CreateProjectRequestParam{ @@ -362,7 +398,8 @@ func runProjectsList(cmd *cobra.Command, args []string) error { c := getProjectsHandler(cmd) limit, _ := cmd.Flags().GetInt("limit") offset, _ := cmd.Flags().GetInt("offset") - return c.List(cmd.Context(), ProjectsListInput{Limit: limit, Offset: offset}) + output, _ := cmd.Flags().GetString("output") + return c.List(cmd.Context(), ProjectsListInput{Limit: limit, Offset: offset, Output: output}) } func runProjectsCreate(cmd *cobra.Command, args []string) error { @@ -518,6 +555,7 @@ var projectsSetLimitsCompatCmd = &cobra.Command{ func init() { projectsListCmd.Flags().Int("limit", 100, "Maximum number of projects to return (1-100)") projectsListCmd.Flags().Int("offset", 0, "Number of projects to skip (for pagination)") + addJSONOutputFlag(projectsListCmd) projectsUpdateCmd.Flags().String("name", "", "New project name (1-255 characters)") projectsUpdateCmd.Flags().String("status", "", "New project status: active or archived") diff --git a/cmd/projects_test.go b/cmd/projects_test.go index f015e79..9c9ada1 100644 --- a/cmd/projects_test.go +++ b/cmd/projects_test.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "encoding/json" "errors" "net/http" "testing" @@ -116,6 +117,7 @@ func TestProjectsList_RejectsInvalidPagination(t *testing.T) { {Limit: 0}, {Limit: 101}, {Limit: 100, Offset: -1}, + {Limit: 100, Output: "yaml"}, } { assert.Error(t, c.List(context.Background(), in)) } @@ -133,6 +135,19 @@ func TestProjectListNextOffset(t *testing.T) { assert.False(t, ok) } +func TestMarshalProjectsListJSON_IncludesNextOffset(t *testing.T) { + var project kernel.Project + assert.NoError(t, json.Unmarshal([]byte(`{"id":"proj_1","name":"one","status":"active","created_at":"2026-08-08T12:00:00Z","updated_at":"2026-08-08T12:00:00Z"}`), &project)) + + data, err := marshalProjectsListJSON([]kernel.Project{project}, 100) + assert.NoError(t, err) + assert.JSONEq(t, `{"projects":[{"id":"proj_1","name":"one","status":"active","created_at":"2026-08-08T12:00:00Z","updated_at":"2026-08-08T12:00:00Z"}],"next_offset":100}`, string(data)) + + data, err = marshalProjectsListJSON([]kernel.Project{}, 0) + assert.NoError(t, err) + assert.JSONEq(t, `{"projects":[]}`, string(data)) +} + func TestProjectsLimitsGet_DefaultOutput(t *testing.T) { buf := capturePtermOutput(t) limits := &kernel.ProjectLimits{ From 17ae47b4b1d2c1fcd1645b641ff75afc30438540 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:34:01 +0000 Subject: [PATCH 3/3] Harden project pagination handling --- cmd/projects.go | 58 +++++++++++++------ cmd/projects_test.go | 134 +++++++++++++++++++++++++++++-------------- 2 files changed, 131 insertions(+), 61 deletions(-) diff --git a/cmd/projects.go b/cmd/projects.go index 9a5dc66..68d05fd 100644 --- a/cmd/projects.go +++ b/cmd/projects.go @@ -117,10 +117,13 @@ func (c ProjectsCmd) List(ctx context.Context, in ProjectsListInput) error { if projects != nil { items = projects.Items } - nextOffset, hasMore := projectListNextOffset(response) + pagination, err := parseProjectListPagination(response) + if err != nil { + return err + } if in.Output == "json" { - data, err := marshalProjectsListJSON(items, nextOffset) + data, err := marshalProjectsListJSON(projects, pagination.NextOffset) if err != nil { return err } @@ -145,35 +148,54 @@ func (c ProjectsCmd) List(ctx context.Context, in ProjectsListInput) error { } PrintTableNoPad(table, true) - if hasMore { + if pagination.HasMore { pterm.Warning.Printfln( "Output truncated after index %d. Continue with: kernel projects list --limit %d --offset %d", - in.Offset+len(items)-1, in.Limit, nextOffset, + in.Offset+len(items)-1, in.Limit, pagination.NextOffset, ) } return nil } -func projectListNextOffset(response *http.Response) (int, bool) { +type projectListPagination struct { + HasMore bool + NextOffset int +} + +func parseProjectListPagination(response *http.Response) (projectListPagination, error) { if response == nil { - return 0, false + return projectListPagination{}, fmt.Errorf("project list response is missing pagination headers") + } + + hasMoreValue := response.Header.Get("X-Has-More") + hasMore, err := strconv.ParseBool(hasMoreValue) + if err != nil { + return projectListPagination{}, fmt.Errorf("invalid X-Has-More header %q", hasMoreValue) + } + + nextOffsetValue := response.Header.Get("X-Next-Offset") + nextOffset, err := strconv.Atoi(nextOffsetValue) + if err != nil || nextOffset < 0 { + return projectListPagination{}, fmt.Errorf("invalid X-Next-Offset header %q", nextOffsetValue) + } + if hasMore && nextOffset == 0 { + return projectListPagination{}, fmt.Errorf("X-Has-More is true but X-Next-Offset is not positive") } - nextOffset, err := strconv.Atoi(response.Header.Get("X-Next-Offset")) - return nextOffset, err == nil && nextOffset > 0 + if !hasMore && nextOffset != 0 { + return projectListPagination{}, fmt.Errorf("X-Has-More is false but X-Next-Offset is %d", nextOffset) + } + + return projectListPagination{HasMore: hasMore, NextOffset: nextOffset}, nil } -func marshalProjectsListJSON(projects []kernel.Project, nextOffset int) ([]byte, error) { - rawProjects := make([]json.RawMessage, 0, len(projects)) - for _, project := range projects { - raw := project.RawJSON() - if raw == "" { - raw = "{}" - } - rawProjects = append(rawProjects, json.RawMessage(raw)) +func marshalProjectsListJSON(projects *pagination.OffsetPagination[kernel.Project], nextOffset int) ([]byte, error) { + rawProjects := json.RawMessage("[]") + if projects != nil && len(projects.Items) > 0 { + rawProjects = json.RawMessage(projects.RawJSON()) } payload := struct { - Projects []json.RawMessage `json:"projects"` - NextOffset int `json:"next_offset,omitempty"` + Projects json.RawMessage `json:"projects"` + NextOffset int `json:"next_offset,omitempty"` }{Projects: rawProjects, NextOffset: nextOffset} return json.MarshalIndent(payload, "", " ") } diff --git a/cmd/projects_test.go b/cmd/projects_test.go index 9c9ada1..bce3d9c 100644 --- a/cmd/projects_test.go +++ b/cmd/projects_test.go @@ -2,16 +2,18 @@ package cmd import ( "context" - "encoding/json" "errors" "net/http" + "net/http/httptest" "testing" "github.com/kernel/kernel-go-sdk" "github.com/kernel/kernel-go-sdk/option" "github.com/kernel/kernel-go-sdk/packages/pagination" "github.com/kernel/kernel-go-sdk/packages/respjson" + "github.com/pterm/pterm" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) type FakeProjectsService struct { @@ -76,32 +78,39 @@ func (f *FakeProjectLimitsService) Update(ctx context.Context, id string, body k return &kernel.ProjectLimits{}, nil } -func TestProjectsList_ForwardsPaginationAndShowsAbsoluteIndexes(t *testing.T) { - buf := capturePtermOutput(t) - fakeProjects := &FakeProjectsService{ - ListFunc: func(ctx context.Context, query kernel.ProjectListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.Project], error) { - assert.True(t, query.Limit.Valid()) - assert.Equal(t, int64(2), query.Limit.Value) - assert.True(t, query.Offset.Valid()) - assert.Equal(t, int64(20), query.Offset.Value) - return &pagination.OffsetPagination[kernel.Project]{ - Items: []kernel.Project{ - {ID: "proj_20", Name: "twenty", Status: kernel.ProjectStatusActive}, - {ID: "proj_21", Name: "twenty-one", Status: kernel.ProjectStatusArchived}, - }, - }, nil - }, - } - c := ProjectsCmd{projects: fakeProjects, limits: &FakeProjectLimitsService{}} +func TestProjectsList_UsesSDKResponsePaginationMetadata(t *testing.T) { + const responseBody = `[ + {"id":"project-alpha","name":"alpha","status":"active","created_at":"2026-08-08T12:00:00Z","updated_at":"2026-08-08T12:00:00Z"}, + {"id":"project-beta","name":"beta","status":"archived","created_at":"2026-08-08T12:01:00Z","updated_at":"2026-08-08T12:01:00Z"} + ]` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/org/projects", r.URL.Path) + assert.Equal(t, "2", r.URL.Query().Get("limit")) + assert.Equal(t, "20", r.URL.Query().Get("offset")) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Has-More", "true") + w.Header().Set("X-Next-Offset", "22") + _, _ = w.Write([]byte(responseBody)) + })) + defer server.Close() + + client := kernel.NewClient(option.WithBaseURL(server.URL), option.WithAPIKey("test")) + c := ProjectsCmd{projects: &client.Projects, limits: &client.Projects.Limits} + buf := capturePtermOutput(t) err := c.List(context.Background(), ProjectsListInput{Limit: 2, Offset: 20}) - assert.NoError(t, err) - out := buf.String() + require.NoError(t, err) + out := pterm.RemoveColorFromString(buf.String()) assert.Contains(t, out, "idx") - assert.Contains(t, out, "proj_20") - assert.Contains(t, out, "20") - assert.Contains(t, out, "proj_21") - assert.Contains(t, out, "21") + assert.Regexp(t, `(?m)^project-alpha\s+\| alpha\s+\| active\s+\| [^|]+\| 20\s*$`, out) + assert.Regexp(t, `(?m)^project-beta\s+\| beta\s+\| archived\s+\| [^|]+\| 21\s*$`, out) + assert.Contains(t, out, "kernel projects list --limit 2 --offset 22") + + jsonOutput := captureStdout(t, func() { + err = c.List(context.Background(), ProjectsListInput{Limit: 2, Offset: 20, Output: "json"}) + }) + require.NoError(t, err) + assert.JSONEq(t, `{"projects":`+responseBody+`,"next_offset":22}`, jsonOutput) } func TestProjectsList_RejectsInvalidPagination(t *testing.T) { @@ -123,28 +132,67 @@ func TestProjectsList_RejectsInvalidPagination(t *testing.T) { } } -func TestProjectListNextOffset(t *testing.T) { - response := &http.Response{Header: http.Header{"X-Next-Offset": []string{"120"}}} - nextOffset, ok := projectListNextOffset(response) - assert.True(t, ok) - assert.Equal(t, 120, nextOffset) +func TestParseProjectListPagination(t *testing.T) { + tests := []struct { + name string + response *http.Response + want projectListPagination + wantErr string + }{ + { + name: "more results", + response: &http.Response{Header: http.Header{"X-Has-More": []string{"true"}, "X-Next-Offset": []string{"120"}}}, + want: projectListPagination{HasMore: true, NextOffset: 120}, + }, + { + name: "terminal page", + response: &http.Response{Header: http.Header{"X-Has-More": []string{"false"}, "X-Next-Offset": []string{"0"}}}, + want: projectListPagination{}, + }, + {name: "missing response", wantErr: "missing pagination headers"}, + { + name: "missing has more", + response: &http.Response{Header: http.Header{"X-Next-Offset": []string{"120"}}}, + wantErr: "invalid X-Has-More", + }, + { + name: "has more with missing cursor", + response: &http.Response{Header: http.Header{"X-Has-More": []string{"true"}}}, + wantErr: "invalid X-Next-Offset", + }, + { + name: "has more with malformed cursor", + response: &http.Response{Header: http.Header{"X-Has-More": []string{"true"}, "X-Next-Offset": []string{"next"}}}, + wantErr: "invalid X-Next-Offset", + }, + { + name: "has more with terminal cursor", + response: &http.Response{Header: http.Header{"X-Has-More": []string{"true"}, "X-Next-Offset": []string{"0"}}}, + wantErr: "X-Next-Offset is not positive", + }, + { + name: "terminal page with cursor", + response: &http.Response{Header: http.Header{"X-Has-More": []string{"false"}, "X-Next-Offset": []string{"120"}}}, + wantErr: "X-Has-More is false", + }, + } - _, ok = projectListNextOffset(&http.Response{Header: http.Header{}}) - assert.False(t, ok) - _, ok = projectListNextOffset(nil) - assert.False(t, ok) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseProjectListPagination(tt.response) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } } -func TestMarshalProjectsListJSON_IncludesNextOffset(t *testing.T) { - var project kernel.Project - assert.NoError(t, json.Unmarshal([]byte(`{"id":"proj_1","name":"one","status":"active","created_at":"2026-08-08T12:00:00Z","updated_at":"2026-08-08T12:00:00Z"}`), &project)) - - data, err := marshalProjectsListJSON([]kernel.Project{project}, 100) - assert.NoError(t, err) - assert.JSONEq(t, `{"projects":[{"id":"proj_1","name":"one","status":"active","created_at":"2026-08-08T12:00:00Z","updated_at":"2026-08-08T12:00:00Z"}],"next_offset":100}`, string(data)) - - data, err = marshalProjectsListJSON([]kernel.Project{}, 0) - assert.NoError(t, err) +func TestMarshalProjectsListJSON_EmptyPage(t *testing.T) { + data, err := marshalProjectsListJSON(nil, 0) + require.NoError(t, err) assert.JSONEq(t, `{"projects":[]}`, string(data)) }