diff --git a/cmd/workflow/list.go b/cmd/workflow/list.go index 34accf9..6f9b621 100644 --- a/cmd/workflow/list.go +++ b/cmd/workflow/list.go @@ -32,17 +32,147 @@ func workflowStatus(enabled bool) string { return "paused" } +// maxListPageSize is the per-request page size cap GET /api/workflows +// enforces (requests above this are rejected with a 400 invalid_input). The +// endpoint supports real offset-based pagination via &offset=, with a page +// shorter than the requested limit as the authoritative end-of-list signal, +// so "kh workflow list" pages through it internally rather than being +// limited to a single request. +// +// This value is hand-copied from the API's MAX_PAGE_SIZE in +// lib/pagination.ts (keeperhub/keeperhub, imported by +// app/api/workflows/route.ts) and nothing here detects drift: if the server +// lowers the cap, every "kh wf ls" request 400s until this is updated to +// match; if it raises the cap, this just costs more round trips than +// necessary. Check that file if list requests start failing unexpectedly. +const maxListPageSize = 200 + +// fetchWorkflowPage performs one GET /api/workflows request at the given +// offset and decodes the result. The caller must keep limit within +// [1, maxListPageSize]; fetchWorkflows, the only caller, already guarantees +// this. limit is intentionally not clamped here: fetchWorkflows decides +// end-of-list by comparing the page it gets back against the pageSize it +// asked for, so silently sending a smaller limit than the caller requested +// would make a truncated page indistinguishable from the real end of the +// list. An out-of-range limit should surface as the 400 the API already +// returns for it, not be masked. +func fetchWorkflowPage(client *khhttp.Client, host, project, tag string, limit, offset int) ([]Workflow, error) { + query := url.Values{} + query.Set("limit", strconv.Itoa(limit)) + if offset > 0 { + query.Set("offset", strconv.Itoa(offset)) + } + if project != "" { + query.Set("projectId", project) + } + if tag != "" { + query.Set("tagId", tag) + } + reqURL := khhttp.BuildBaseURL(host) + "/api/workflows?" + query.Encode() + + req, err := client.NewRequest(http.MethodGet, reqURL, nil) + if err != nil { + return nil, fmt.Errorf("building request: %w", err) + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("executing request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, cmdutil.NotFoundError{Err: errors.New("workflows not found")} + } + if resp.StatusCode == http.StatusTooManyRequests { + return nil, cmdutil.RateLimitError{Err: errors.New("rate limit exceeded")} + } + if resp.StatusCode != http.StatusOK { + return nil, khhttp.NewAPIError(resp) + } + + var workflows []Workflow + if err := json.NewDecoder(resp.Body).Decode(&workflows); err != nil { + return nil, fmt.Errorf("decoding response: %w", err) + } + return workflows, nil +} + +// fetchWorkflows pages through GET /api/workflows via &offset=, accumulating +// results until either limit results have been collected (limit <= 0 means +// unlimited, i.e. --all) or a page comes back shorter than requested, which +// the API guarantees means there is nothing left. +// +// When the loop stops because limit was reached rather than because the +// list ended, it issues one more 1-row probe request so hasMore reports +// accurately whether more workflows exist, instead of guessing from a full +// final page. +func fetchWorkflows(client *khhttp.Client, host, project, tag string, limit int) (result []Workflow, hasMore bool, err error) { + offset := 0 + for { + pageSize := maxListPageSize + if limit > 0 { + remaining := limit - len(result) + if remaining <= 0 { + break + } + if remaining < pageSize { + pageSize = remaining + } + } + + page, err := fetchWorkflowPage(client, host, project, tag, pageSize, offset) + if err != nil { + return nil, false, err + } + result = append(result, page...) + offset += len(page) + + if len(page) < pageSize { + // A short page is the API's own end-of-list signal. + return result, false, nil + } + } + + if limit > 0 { + probe, err := fetchWorkflowPage(client, host, project, tag, 1, offset) + if err != nil { + return nil, false, err + } + hasMore = len(probe) > 0 + } + return result, hasMore, nil +} + func NewListCmd(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "List workflows", Aliases: []string{"ls"}, Args: cobra.NoArgs, + Long: fmt.Sprintf(`List workflows. + +GET /api/workflows caps each underlying request at %d results, but supports +real pagination via &offset=, so "kh wf ls" pages through it internally. +With --limit N, it requests up to %d results at a time, only as many times +as needed to collect N total, with the final request sized to whatever +remains rather than a full page (or fewer results overall if the org runs +out first). If more results exist beyond --limit, a note is printed to +stderr. + +Pass --all to fetch every matching workflow: it drops the default --limit of +30 and pages until the API reports the end of the list. An explicit --limit +passed alongside --all still bounds the result to that count, same as +without --all. --project and --tag can be combined with either form to +scope the (possibly paginated) query to one project or tag.`, maxListPageSize, maxListPageSize), Example: ` # List workflows kh wf ls - # List with a higher limit - kh wf ls --limit 5 + # List with a higher limit (paginates internally, up to 200 per request) + kh wf ls --limit 500 + + # List every workflow in the org, paginating past the API's page cap + kh wf ls --all # List workflows in a project or with a tag kh wf ls --project proj_123 @@ -62,6 +192,9 @@ func NewListCmd(f *cmdutil.Factory) *cobra.Command { if err != nil { return err } + if limit < 1 { + return cmdutil.FlagError{Err: fmt.Errorf("--limit must be at least 1, got %d", limit)} + } project, err := cmd.Flags().GetString("project") if err != nil { @@ -73,46 +206,31 @@ func NewListCmd(f *cmdutil.Factory) *cobra.Command { return err } - host := cmdutil.ResolveHost(cmd, cfg) - query := url.Values{} - query.Set("limit", strconv.Itoa(limit)) - if project != "" { - query.Set("projectId", project) - } - if tag != "" { - query.Set("tagId", tag) - } - reqURL := khhttp.BuildBaseURL(host) + "/api/workflows?" + query.Encode() - - req, err := client.NewRequest(http.MethodGet, reqURL, nil) + all, err := cmd.Flags().GetBool("all") if err != nil { - return fmt.Errorf("building request: %w", err) + return err } - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("executing request: %w", err) + // --all means "every workflow"; don't let the --limit default + // (meant to bound a single page) cap the paginated result unless + // the caller explicitly asked for a limit too. + effectiveLimit := limit + if all && !cmd.Flags().Changed("limit") { + effectiveLimit = 0 } - defer resp.Body.Close() - if resp.StatusCode == http.StatusNotFound { - return cmdutil.NotFoundError{Err: errors.New("workflows not found")} - } - if resp.StatusCode == http.StatusTooManyRequests { - return cmdutil.RateLimitError{Err: errors.New("rate limit exceeded")} - } - if resp.StatusCode != http.StatusOK { - return khhttp.NewAPIError(resp) - } + host := cmdutil.ResolveHost(cmd, cfg) - var workflows []Workflow - if err := json.NewDecoder(resp.Body).Decode(&workflows); err != nil { - return fmt.Errorf("decoding response: %w", err) + workflows, hasMore, err := fetchWorkflows(client, host, project, tag, effectiveLimit) + if err != nil { + return err } - - // Apply limit client-side (server does not support ?limit yet) - if limit > 0 && limit < len(workflows) { - workflows = workflows[:limit] + if hasMore { + if all { + fmt.Fprintf(f.IOStreams.ErrOut, "note: more workflows exist beyond --limit %d; remove --limit for the complete list, or increase --limit.\n", effectiveLimit) + } else { + fmt.Fprintf(f.IOStreams.ErrOut, "note: more workflows exist beyond --limit %d; pass --all for the complete list, or increase --limit.\n", effectiveLimit) + } } p := output.NewPrinter(f.IOStreams, cmd) @@ -132,9 +250,10 @@ func NewListCmd(f *cmdutil.Factory) *cobra.Command { }, } - cmd.Flags().Int("limit", 30, "Maximum number of workflows to list") + cmd.Flags().Int("limit", 30, "Maximum number of workflows to list (paginates internally past the API's per-request cap)") cmd.Flags().String("project", "", "Filter workflows by project ID") cmd.Flags().String("tag", "", "Filter workflows by tag ID") + cmd.Flags().Bool("all", false, "List every matching workflow, dropping the default --limit (an explicit --limit still bounds the result) and paginating until the list ends") return cmd } diff --git a/cmd/workflow/list_test.go b/cmd/workflow/list_test.go index 379c4ef..e7a20f3 100644 --- a/cmd/workflow/list_test.go +++ b/cmd/workflow/list_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strconv" "strings" "testing" @@ -38,6 +39,82 @@ func makeWorkflowsServer(t *testing.T, workflows []map[string]interface{}) *http })) } +func makeWF(id, projectID string) map[string]interface{} { + m := map[string]interface{}{ + "id": id, "name": id, "enabled": true, "visibility": "private", + "createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-01T00:00:00Z", + } + if projectID != "" { + m["projectId"] = projectID + } + return m +} + +func makeWFs(n int) []map[string]interface{} { + out := make([]map[string]interface{}, n) + for i := range out { + out[i] = makeWF("wf-"+strconv.Itoa(i), "") + } + return out +} + +// makePaginatedWorkflowsServer serves GET /api/workflows against the given +// backing dataset the way app/api/workflows/route.ts (in the keeperhub repo) +// actually behaves: it slices by &limit=&offset=, and optionally filters by +// &projectId=/&tagId= first. requests accumulates each request's raw query +// string, for assertions on how many pages a test caused. +func makePaginatedWorkflowsServer(t *testing.T, all []map[string]interface{}) (server *httptest.Server, requests *[]string) { + t.Helper() + reqs := []string{} + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/api/workflows" { + http.Error(w, "not found", http.StatusNotFound) + return + } + reqs = append(reqs, r.URL.RawQuery) + q := r.URL.Query() + + filtered := all + if pid := q.Get("projectId"); pid != "" { + var f []map[string]interface{} + for _, wf := range filtered { + if wf["projectId"] == pid { + f = append(f, wf) + } + } + filtered = f + } + if tid := q.Get("tagId"); tid != "" { + var f []map[string]interface{} + for _, wf := range filtered { + if wf["tagId"] == tid { + f = append(f, wf) + } + } + filtered = f + } + + limit, _ := strconv.Atoi(q.Get("limit")) + offset, _ := strconv.Atoi(q.Get("offset")) + start := offset + if start > len(filtered) { + start = len(filtered) + } + end := start + limit + if end > len(filtered) { + end = len(filtered) + } + page := filtered[start:end] + if page == nil { + page = []map[string]interface{}{} + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(page) + })) + return server, &reqs +} + func TestListCmd_SendsGETWorkflows(t *testing.T) { called := false server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -164,6 +241,161 @@ func TestListCmd_EmptyResponsePrintsEmptyTable(t *testing.T) { assert.NoError(t, err, "empty list should not return error") } +func TestListCmd_PaginatesPastAPICapToSatisfyLimit(t *testing.T) { + all := makeWFs(250) + server, requests := makePaginatedWorkflowsServer(t, all) + defer server.Close() + + ios, outBuf, _, _ := iostreams.Test() + f := newWFListFactory(server, ios) + + wfCmd := workflow.NewWorkflowCmd(f) + wfCmd.SetArgs([]string{"ls", "--limit", "220", "--json"}) + err := wfCmd.Execute() + require.NoError(t, err) + + var got []map[string]interface{} + require.NoError(t, json.Unmarshal(outBuf.Bytes(), &got)) + assert.Len(t, got, 220, "expected pagination to satisfy a --limit above the API's 200-per-request cap") + assert.GreaterOrEqual(t, len(*requests), 2, "expected more than one request to page past the 200 cap") +} + +func TestListCmd_NotesWhenMoreExistBeyondLimit(t *testing.T) { + all := makeWFs(10) + server, _ := makePaginatedWorkflowsServer(t, all) + defer server.Close() + + ios, _, errBuf, _ := iostreams.Test() + f := newWFListFactory(server, ios) + + wfCmd := workflow.NewWorkflowCmd(f) + wfCmd.SetArgs([]string{"ls", "--limit", "3"}) + err := wfCmd.Execute() + require.NoError(t, err) + + assert.Contains(t, errBuf.String(), "note: more workflows exist", "expected a note when more workflows exist beyond --limit") +} + +func TestListCmd_NoNoteWhenLimitExactlyCoversAll(t *testing.T) { + all := makeWFs(3) + server, _ := makePaginatedWorkflowsServer(t, all) + defer server.Close() + + ios, _, errBuf, _ := iostreams.Test() + f := newWFListFactory(server, ios) + + wfCmd := workflow.NewWorkflowCmd(f) + wfCmd.SetArgs([]string{"ls", "--limit", "3"}) + err := wfCmd.Execute() + require.NoError(t, err) + + assert.Empty(t, errBuf.String(), "expected no note when --limit exactly covers every workflow") +} + +func TestListCmd_AllPaginatesUntilExhausted(t *testing.T) { + all := makeWFs(250) + server, requests := makePaginatedWorkflowsServer(t, all) + defer server.Close() + + ios, outBuf, _, _ := iostreams.Test() + f := newWFListFactory(server, ios) + + wfCmd := workflow.NewWorkflowCmd(f) + wfCmd.SetArgs([]string{"ls", "--all", "--json"}) + err := wfCmd.Execute() + require.NoError(t, err) + + var got []map[string]interface{} + require.NoError(t, json.Unmarshal(outBuf.Bytes(), &got)) + assert.Len(t, got, 250, "expected --all to page through every workflow past the 200-per-request cap") + assert.GreaterOrEqual(t, len(*requests), 2, "expected more than one request to page past the 200 cap") +} + +func TestListCmd_AllIgnoresDefaultLimit(t *testing.T) { + all := makeWFs(35) + server, _ := makePaginatedWorkflowsServer(t, all) + defer server.Close() + + ios, outBuf, _, _ := iostreams.Test() + f := newWFListFactory(server, ios) + + wfCmd := workflow.NewWorkflowCmd(f) + // No --limit passed: the default of 30 must not cap --all's result. + wfCmd.SetArgs([]string{"ls", "--all", "--json"}) + err := wfCmd.Execute() + require.NoError(t, err) + + var got []map[string]interface{} + require.NoError(t, json.Unmarshal(outBuf.Bytes(), &got)) + assert.Len(t, got, 35, "expected --all to return every workflow, not the default --limit of 30") +} + +func TestListCmd_AllRespectsExplicitLimit(t *testing.T) { + all := makeWFs(35) + server, _ := makePaginatedWorkflowsServer(t, all) + defer server.Close() + + ios, outBuf, _, _ := iostreams.Test() + f := newWFListFactory(server, ios) + + wfCmd := workflow.NewWorkflowCmd(f) + wfCmd.SetArgs([]string{"ls", "--all", "--limit", "2", "--json"}) + err := wfCmd.Execute() + require.NoError(t, err) + + var got []map[string]interface{} + require.NoError(t, json.Unmarshal(outBuf.Bytes(), &got)) + assert.Len(t, got, 2, "expected an explicit --limit to still cap --all's result") +} + +func TestListCmd_RejectsLimitBelowOne(t *testing.T) { + for _, limit := range []string{"0", "-5"} { + t.Run(limit, func(t *testing.T) { + called := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]interface{}{}) + })) + defer server.Close() + + ios, _, _, _ := iostreams.Test() + f := newWFListFactory(server, ios) + + wfCmd := workflow.NewWorkflowCmd(f) + wfCmd.SetArgs([]string{"ls", "--limit", limit}) + err := wfCmd.Execute() + + require.Error(t, err, "expected --limit %s to be rejected", limit) + assert.False(t, called, "expected no request to be sent for an invalid --limit") + }) + } +} + +func TestListCmd_AllCanBeScopedToProject(t *testing.T) { + var all []map[string]interface{} + for i := 0; i < 5; i++ { + all = append(all, makeWF("proj1-wf-"+strconv.Itoa(i), "proj-1")) + } + for i := 0; i < 2; i++ { + all = append(all, makeWF("proj2-wf-"+strconv.Itoa(i), "proj-2")) + } + server, _ := makePaginatedWorkflowsServer(t, all) + defer server.Close() + + ios, outBuf, _, _ := iostreams.Test() + f := newWFListFactory(server, ios) + + wfCmd := workflow.NewWorkflowCmd(f) + wfCmd.SetArgs([]string{"ls", "--all", "--project", "proj-1", "--json"}) + err := wfCmd.Execute() + require.NoError(t, err) + + var got []map[string]interface{} + require.NoError(t, json.Unmarshal(outBuf.Bytes(), &got)) + assert.Len(t, got, 5, "expected --all combined with --project to page through only that project's workflows") +} + func TestListCmd_DisabledWorkflowShowsPaused(t *testing.T) { workflows := []map[string]interface{}{ {"id": "wf-002", "name": "Paused One", "enabled": false, "visibility": "private", "createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-01T00:00:00Z"}, diff --git a/docs/kh_workflow_list.md b/docs/kh_workflow_list.md index a45cdbd..d801d2a 100644 --- a/docs/kh_workflow_list.md +++ b/docs/kh_workflow_list.md @@ -2,6 +2,24 @@ List workflows +### Synopsis + +List workflows. + +GET /api/workflows caps each underlying request at 200 results, but supports +real pagination via &offset=, so "kh wf ls" pages through it internally. +With --limit N, it requests up to 200 results at a time, only as many times +as needed to collect N total, with the final request sized to whatever +remains rather than a full page (or fewer results overall if the org runs +out first). If more results exist beyond --limit, a note is printed to +stderr. + +Pass --all to fetch every matching workflow: it drops the default --limit of +30 and pages until the API reports the end of the list. An explicit --limit +passed alongside --all still bounds the result to that count, same as +without --all. --project and --tag can be combined with either form to +scope the (possibly paginated) query to one project or tag. + ``` kh workflow list [flags] ``` @@ -12,8 +30,11 @@ kh workflow list [flags] # List workflows kh wf ls - # List with a higher limit - kh wf ls --limit 5 + # List with a higher limit (paginates internally, up to 200 per request) + kh wf ls --limit 500 + + # List every workflow in the org, paginating past the API's page cap + kh wf ls --all # List workflows in a project or with a tag kh wf ls --project proj_123 @@ -23,8 +44,9 @@ kh workflow list [flags] ### Options ``` + --all List every matching workflow, dropping the default --limit (an explicit --limit still bounds the result) and paginating until the list ends -h, --help help for list - --limit int Maximum number of workflows to list (default 30) + --limit int Maximum number of workflows to list (paginates internally past the API's per-request cap) (default 30) --project string Filter workflows by project ID --tag string Filter workflows by tag ID ``` diff --git a/internal/http/errors.go b/internal/http/errors.go index 2f7fd78..4e26e26 100644 --- a/internal/http/errors.go +++ b/internal/http/errors.go @@ -12,15 +12,26 @@ type APIError struct { StatusCode int Body []byte Message string + RequestID string + Hint string } // Error implements the error interface. func (e *APIError) Error() string { - return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Message) + msg := fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Message) + if e.Hint != "" { + msg += fmt.Sprintf(" (hint: %s)", e.Hint) + } + if e.RequestID != "" { + msg += fmt.Sprintf(" (request_id: %s)", e.RequestID) + } + return msg } // NewAPIError reads the response body and constructs an APIError. -// It attempts to extract a JSON "error" or "message" field for the message. +// It attempts to extract JSON "error"/"message" and "detail" fields for the +// message, plus "hint" and "request_id" fields for support and remediation +// purposes. func NewAPIError(resp *http.Response) *APIError { defer resp.Body.Close() @@ -33,7 +44,7 @@ func NewAPIError(resp *http.Response) *APIError { } } - message := extractJSONMessage(body) + message, hint, requestID := extractJSONMessage(body) if message == "" { message = string(body) } @@ -45,19 +56,39 @@ func NewAPIError(resp *http.Response) *APIError { StatusCode: resp.StatusCode, Body: body, Message: message, + RequestID: requestID, + Hint: hint, } } -func extractJSONMessage(body []byte) string { +// extractJSONMessage returns a human-readable message built from the +// "error"/"message" and "detail" fields of a JSON error body, along with the +// "hint" and "request_id" fields if present. The "detail" field carries the +// actionable, human-readable explanation the API sends; without it, callers +// only ever see an opaque error code like "invalid_input". "hint" carries a +// suggested remediation (e.g. "POST /api/integrations/wallet to provision"). +func extractJSONMessage(body []byte) (message, hint, requestID string) { var payload struct { - Error string `json:"error"` - Message string `json:"message"` + Error string `json:"error"` + Message string `json:"message"` + Detail string `json:"detail"` + Hint string `json:"hint"` + RequestID string `json:"request_id"` } if err := json.Unmarshal(body, &payload); err != nil { - return "" + return "", "", "" + } + + message = payload.Error + if message == "" { + message = payload.Message } - if payload.Error != "" { - return payload.Error + if payload.Detail != "" { + if message != "" { + message += ": " + payload.Detail + } else { + message = payload.Detail + } } - return payload.Message + return message, payload.Hint, payload.RequestID } diff --git a/internal/http/errors_test.go b/internal/http/errors_test.go new file mode 100644 index 0000000..2d02cc7 --- /dev/null +++ b/internal/http/errors_test.go @@ -0,0 +1,74 @@ +package khhttp_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + khhttp "github.com/keeperhub/cli/internal/http" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newErrorResponse(t *testing.T, status int, body string) *http.Response { + t.Helper() + rec := httptest.NewRecorder() + rec.WriteHeader(status) + _, err := rec.WriteString(body) + require.NoError(t, err) + return rec.Result() +} + +func TestNewAPIError_IncludesDetail(t *testing.T) { + resp := newErrorResponse(t, http.StatusBadRequest, `{"error":"invalid_input","detail":"limit must be <= 200","request_id":"8d848585-2dc0-4f83-9bb6-ab6d12fe1e70"}`) + + apiErr := khhttp.NewAPIError(resp) + + assert.Equal(t, "invalid_input: limit must be <= 200", apiErr.Message) + assert.Equal(t, "8d848585-2dc0-4f83-9bb6-ab6d12fe1e70", apiErr.RequestID) + assert.Contains(t, apiErr.Error(), "invalid_input: limit must be <= 200") + assert.Contains(t, apiErr.Error(), "8d848585-2dc0-4f83-9bb6-ab6d12fe1e70") +} + +func TestNewAPIError_NoDetailFallsBackToErrorField(t *testing.T) { + resp := newErrorResponse(t, http.StatusBadRequest, `{"error":"invalid_input"}`) + + apiErr := khhttp.NewAPIError(resp) + + assert.Equal(t, "invalid_input", apiErr.Message) + assert.Empty(t, apiErr.RequestID) + assert.Equal(t, "HTTP 400: invalid_input", apiErr.Error()) +} + +func TestNewAPIError_IncludesHint(t *testing.T) { + resp := newErrorResponse(t, http.StatusPreconditionFailed, `{"error":"wallet_not_configured","detail":"No wallet provisioned for chain 8453 in org X","hint":"POST /api/integrations/wallet to provision","request_id":"req_abc"}`) + + apiErr := khhttp.NewAPIError(resp) + + assert.Equal(t, "POST /api/integrations/wallet to provision", apiErr.Hint) + assert.Contains(t, apiErr.Error(), "hint: POST /api/integrations/wallet to provision") +} + +func TestNewAPIError_DetailOnlyBody(t *testing.T) { + resp := newErrorResponse(t, http.StatusBadRequest, `{"detail":"something went wrong"}`) + + apiErr := khhttp.NewAPIError(resp) + + assert.Equal(t, "something went wrong", apiErr.Message) +} + +func TestNewAPIError_NonJSONBody(t *testing.T) { + resp := newErrorResponse(t, http.StatusInternalServerError, "internal server error") + + apiErr := khhttp.NewAPIError(resp) + + assert.Equal(t, "internal server error", apiErr.Message) +} + +func TestNewAPIError_EmptyBodyUsesStatusText(t *testing.T) { + resp := newErrorResponse(t, http.StatusInternalServerError, "") + + apiErr := khhttp.NewAPIError(resp) + + assert.Equal(t, http.StatusText(http.StatusInternalServerError), apiErr.Message) +}