| |
| |
| package openmeter_test |
|
|
| import ( |
| "context" |
| "errors" |
| "fmt" |
| "net/http" |
| "strings" |
| "testing" |
|
|
| openmeter "github.com/openmeterio/openmeter/api/v3/client" |
| ) |
|
|
| func TestAPIErrorParsesRFC7807(t *testing.T) { |
| t.Parallel() |
|
|
| body := `{"status":404,"type":"https://openmeter.io/problems/not-found","title":"Not Found","detail":"meter not found","instance":"kong:trace:abc123"}` |
| rec := &requestRecorder{} |
| om := newTestClient(t, rec.handler(http.StatusNotFound, body)) |
|
|
| _, err := om.Meters.Get(t.Context(), "m-1") |
| apiErr, ok := openmeter.AsAPIError(err) |
| if !ok { |
| t.Fatalf("error %v is not an *APIError", err) |
| } |
|
|
| if apiErr.StatusCode != http.StatusNotFound { |
| t.Errorf("StatusCode = %d, want 404", apiErr.StatusCode) |
| } |
| if apiErr.Status != 404 { |
| t.Errorf("Status = %d, want 404", apiErr.Status) |
| } |
| if apiErr.Type != "https://openmeter.io/problems/not-found" { |
| t.Errorf("Type = %q, want the problem type", apiErr.Type) |
| } |
| if apiErr.Title != "Not Found" { |
| t.Errorf("Title = %q, want %q", apiErr.Title, "Not Found") |
| } |
| if apiErr.Detail != "meter not found" { |
| t.Errorf("Detail = %q, want %q", apiErr.Detail, "meter not found") |
| } |
| if apiErr.Instance != "kong:trace:abc123" { |
| t.Errorf("Instance = %q, want %q", apiErr.Instance, "kong:trace:abc123") |
| } |
| if string(apiErr.RawBody) != body { |
| t.Errorf("RawBody = %q, want the undecoded body", apiErr.RawBody) |
| } |
| if want := "openmeter: 404 Not Found: meter not found"; apiErr.Error() != want { |
| t.Errorf("Error() = %q, want %q", apiErr.Error(), want) |
| } |
| } |
|
|
| func TestAPIErrorTitleOnly(t *testing.T) { |
| t.Parallel() |
|
|
| om := newTestClient(t, (&requestRecorder{}).handler(http.StatusTeapot, `{"status":418,"title":"Teapot"}`)) |
|
|
| _, err := om.Meters.Get(t.Context(), "m-1") |
| apiErr, ok := openmeter.AsAPIError(err) |
| if !ok { |
| t.Fatalf("error %v is not an *APIError", err) |
| } |
| if want := "openmeter: 418 Teapot"; apiErr.Error() != want { |
| t.Errorf("Error() = %q, want %q", apiErr.Error(), want) |
| } |
| } |
|
|
| func TestAPIErrorNonJSONBodyFallsBackToRawEcho(t *testing.T) { |
| t.Parallel() |
|
|
| t.Run("short body echoed in full", func(t *testing.T) { |
| body := "<html>bad gateway</html>" |
| om := newTestClient(t, (&requestRecorder{}).handler(http.StatusBadGateway, body)) |
|
|
| _, err := om.Meters.Get(t.Context(), "m-1") |
| apiErr, ok := openmeter.AsAPIError(err) |
| if !ok { |
| t.Fatalf("error %v is not an *APIError", err) |
| } |
| if apiErr.Title != "" { |
| t.Errorf("Title = %q, want empty for a non-problem body", apiErr.Title) |
| } |
| if want := "openmeter: unexpected status 502: " + body; apiErr.Error() != want { |
| t.Errorf("Error() = %q, want %q", apiErr.Error(), want) |
| } |
| }) |
|
|
| t.Run("long body truncated at 512 bytes", func(t *testing.T) { |
| body := strings.Repeat("x", 600) |
| om := newTestClient(t, (&requestRecorder{}).handler(http.StatusBadGateway, body)) |
|
|
| _, err := om.Meters.Get(t.Context(), "m-1") |
| apiErr, ok := openmeter.AsAPIError(err) |
| if !ok { |
| t.Fatalf("error %v is not an *APIError", err) |
| } |
|
|
| want := "openmeter: unexpected status 502: " + strings.Repeat("x", 512) + "… (truncated)" |
| if apiErr.Error() != want { |
| t.Errorf("Error() = %q, want %q", apiErr.Error(), want) |
| } |
| |
| if len(apiErr.RawBody) != 600 { |
| t.Errorf("len(RawBody) = %d, want the full 600 bytes", len(apiErr.RawBody)) |
| } |
| }) |
| } |
|
|
| func TestAsAPIError(t *testing.T) { |
| t.Parallel() |
|
|
| om := newTestClient(t, (&requestRecorder{}).handler(http.StatusInternalServerError, `{"status":500,"title":"Boom"}`)) |
|
|
| _, err := om.Meters.Get(t.Context(), "m-1") |
| if err == nil { |
| t.Fatal("Meters.Get returned nil error for a 500 response") |
| } |
|
|
| wrapped := fmt.Errorf("listing usage: %w", err) |
| apiErr, ok := openmeter.AsAPIError(wrapped) |
| if !ok { |
| t.Fatalf("AsAPIError did not find the APIError inside %v", wrapped) |
| } |
| if apiErr.StatusCode != http.StatusInternalServerError { |
| t.Errorf("StatusCode = %d, want 500", apiErr.StatusCode) |
| } |
|
|
| if got, ok := openmeter.AsAPIError(errors.New("plain")); ok || got != nil { |
| t.Errorf("AsAPIError(plain error) = (%v, %t), want (nil, false)", got, ok) |
| } |
| } |
|
|
| func TestDecodeAPIError(t *testing.T) { |
| t.Parallel() |
|
|
| type validationProblem struct { |
| Status int `json:"status"` |
| Title string `json:"title"` |
| Errors []struct { |
| Field string `json:"field"` |
| } `json:"errors"` |
| } |
|
|
| t.Run("decodes a typed error body", func(t *testing.T) { |
| body := `{"status":400,"title":"Bad Request","errors":[{"field":"key"},{"field":"name"}]}` |
| om := newTestClient(t, (&requestRecorder{}).handler(http.StatusBadRequest, body)) |
|
|
| _, err := om.Meters.Get(t.Context(), "m-1") |
| problem, ok, decodeErr := openmeter.DecodeAPIError[validationProblem](err) |
| if decodeErr != nil { |
| t.Fatalf("DecodeAPIError: %v", decodeErr) |
| } |
| if !ok { |
| t.Fatal("DecodeAPIError reported the error is not an APIError") |
| } |
| if problem.Status != 400 || problem.Title != "Bad Request" || len(problem.Errors) != 2 || problem.Errors[1].Field != "name" { |
| t.Errorf("decoded problem = %+v, want the typed body", problem) |
| } |
| }) |
|
|
| t.Run("non-API errors are reported as not decodable", func(t *testing.T) { |
| problem, ok, decodeErr := openmeter.DecodeAPIError[validationProblem](errors.New("dial tcp: refused")) |
| if ok || decodeErr != nil { |
| t.Errorf("DecodeAPIError(plain error) = (%+v, %t, %v), want ok=false with nil error", problem, ok, decodeErr) |
| } |
| }) |
|
|
| t.Run("undecodable body surfaces the decode error", func(t *testing.T) { |
| om := newTestClient(t, (&requestRecorder{}).handler(http.StatusInternalServerError, "not json")) |
|
|
| _, err := om.Meters.Get(t.Context(), "m-1") |
| _, ok, decodeErr := openmeter.DecodeAPIError[validationProblem](err) |
| if !ok { |
| t.Fatal("DecodeAPIError reported the error is not an APIError") |
| } |
| if decodeErr == nil { |
| t.Error("DecodeAPIError returned nil error for a non-JSON body") |
| } |
| }) |
| } |
|
|
| func TestEmptyIDGuard(t *testing.T) { |
| t.Parallel() |
|
|
| |
| om := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| t.Errorf("unexpected HTTP request %s %s for an empty-ID call", r.Method, r.URL) |
| })) |
|
|
| cases := []struct { |
| name string |
| wantParam string |
| call func(ctx context.Context) error |
| }{ |
| { |
| name: "meters get", |
| wantParam: "meterID", |
| call: func(ctx context.Context) error { |
| _, err := om.Meters.Get(ctx, "") |
| return err |
| }, |
| }, |
| { |
| name: "meters update", |
| wantParam: "meterID", |
| call: func(ctx context.Context) error { |
| _, err := om.Meters.Update(ctx, "", openmeter.UpdateMeterRequest{}) |
| return err |
| }, |
| }, |
| { |
| name: "meters delete", |
| wantParam: "meterID", |
| call: func(ctx context.Context) error { return om.Meters.Delete(ctx, "") }, |
| }, |
| { |
| name: "customers get", |
| wantParam: "customerID", |
| call: func(ctx context.Context) error { |
| _, err := om.Customers.Get(ctx, "") |
| return err |
| }, |
| }, |
| { |
| name: "invoices get", |
| wantParam: "invoiceID", |
| call: func(ctx context.Context) error { |
| _, err := om.Invoices.Get(ctx, "") |
| return err |
| }, |
| }, |
| } |
|
|
| for _, tc := range cases { |
| t.Run(tc.name, func(t *testing.T) { |
| err := tc.call(t.Context()) |
| if err == nil { |
| t.Fatal("call with empty ID returned nil error") |
| } |
| if !errors.Is(err, openmeter.ErrEmptyID) { |
| t.Errorf("errors.Is(err, ErrEmptyID) = false for %v", err) |
| } |
| if !strings.Contains(err.Error(), tc.wantParam) { |
| t.Errorf("error %q does not name the parameter %q", err, tc.wantParam) |
| } |
| }) |
| } |
| } |
|
|