Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions internal/api/cursor_spend_limit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package api

import (
"encoding/json"
"testing"
)

func TestCursorSpendLimitUsage_AcceptsStringsAndNumbers(t *testing.T) {
cases := []struct {
name string
in string
want CursorSpendLimitUsage
}{
{"numbers", `{"totalSpend":150,"pooledLimit":2000,"pooledUsed":150,"pooledRemaining":1850,"limitType":"user"}`,
CursorSpendLimitUsage{TotalSpend: 150, PooledLimit: 2000, PooledUsed: 150, PooledRemaining: 1850, LimitType: "user"}},
{"numeric strings", `{"pooledLimit":"2000","pooledUsed":"0","individualLimit":"500"}`,
CursorSpendLimitUsage{PooledLimit: 2000, IndividualLimit: 500}},
{"empty and null", `{"pooledLimit":"","pooledUsed":null,"limitType":null}`,
CursorSpendLimitUsage{}},
{"float", `{"totalSpend":12.0}`, CursorSpendLimitUsage{TotalSpend: 12}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var got CursorSpendLimitUsage
if err := json.Unmarshal([]byte(tc.in), &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got != tc.want {
t.Fatalf("got %+v, want %+v", got, tc.want)
}
})
}
}

func TestCursorSpendLimitUsage_NestedInUsageResponse(t *testing.T) {
var wrapper struct {
SpendLimitUsage CursorSpendLimitUsage `json:"spendLimitUsage"`
}
if err := json.Unmarshal([]byte(`{"spendLimitUsage":{"pooledLimit":"0","totalSpend":0}}`), &wrapper); err != nil {
t.Fatalf("unmarshal nested: %v", err)
}
}
60 changes: 60 additions & 0 deletions internal/api/cursor_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -508,3 +508,63 @@ func buildEnterpriseQuotas(usage *CursorUsageResponse, requestUsage *CursorReque

return quotas
}

// UnmarshalJSON accepts the integer fields of spendLimitUsage either as JSON
// numbers or as numeric strings. Cursor returns some of them as strings
// (e.g. "pooledLimit":"0" on free accounts), which broke decoding of the whole
// usage response. Empty or non-numeric strings decode as 0.
func (u *CursorSpendLimitUsage) UnmarshalJSON(data []byte) error {
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
ints := map[string]*int{
"totalSpend": &u.TotalSpend,
"pooledLimit": &u.PooledLimit,
"pooledUsed": &u.PooledUsed,
"pooledRemaining": &u.PooledRemaining,
"individualLimit": &u.IndividualLimit,
"individualUsed": &u.IndividualUsed,
"individualRemaining": &u.IndividualRemaining,
}
for key, dst := range ints {
v, ok := raw[key]
if !ok {
continue
}
n, err := flexibleInt(v)
if err != nil {
return fmt.Errorf("cursor: spendLimitUsage.%s: %w", key, err)
}
*dst = n
}
if v, ok := raw["limitType"]; ok && string(v) != "null" {
if err := json.Unmarshal(v, &u.LimitType); err != nil {
return fmt.Errorf("cursor: spendLimitUsage.limitType: %w", err)
}
}
return nil
}

// flexibleInt decodes a JSON number, numeric string, empty string or null as int.
func flexibleInt(v json.RawMessage) (int, error) {
s := strings.TrimSpace(string(v))
if s == "" || s == "null" {
return 0, nil
}
if s[0] == 0x22 {
var str string
if err := json.Unmarshal(v, &str); err != nil {
return 0, err
}
s = strings.TrimSpace(str)
if s == "" {
return 0, nil
}
}
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0, nil
}
return int(f), nil
}
Loading