From eeb3fad393f5aa0f02e862fd5a295e430c2adf07 Mon Sep 17 00:00:00 2001 From: Alexandre Bergere Date: Sat, 10 Oct 2026 18:49:14 +0200 Subject: [PATCH] feat: send the entitlement window, warning threshold and grant allowance EntitlementInput gains ResetPeriod, ResetAnchor, WarningThresholdPercent and the sale-unit trio, with constants for the reset periods and anchors. Licenses.AssociateEntitlement and UpdateEntitlement take an optional WithOveragePercent. The two bodies declare these fields and internal/gen already reads them back, but no input could send them. Both PUTs replace rather than patch, so the gap was more than missing features: - Entitlements.Update failed on every periodic entitlement: once a reset period is set, the API refuses an update that does not carry it back, 400 UpdateEntitlement.ImmutableResetPeriod. - Entitlements.Update turned the warning threshold off and cleared the sale unit on every call. - Licenses.UpdateEntitlement turned every soft limit into a hard one: an omitted allowance is the server's default, 0 for a finite value. Without the option the grant bodies are byte for byte what they were, so every call site compiles and behaves as before. The doc comments say what each PUT does with an omitted field. TestEntitlementAndGrantWritesReachEveryWritableKey reads the body keys off the generated request types, so a field the spec adds to these bodies fails the tests until an input can send it. Signed-off-by: Alexandre Bergere Co-Authored-By: Claude Opus 5.5 --- README.md | 34 +++++++ entitlements.go | 5 + entitlements_test.go | 128 +++++++++++++++++++++++++ inputs.go | 223 ++++++++++++++++++++++++++++++++++--------- inputs_test.go | 219 ++++++++++++++++++++++++++++++++++++++++++ licenses.go | 36 ++++--- licenses_test.go | 96 +++++++++++++++++++ types.go | 24 +++++ 8 files changed, 707 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 5c78f1f..e6ca377 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,40 @@ moves only through `Licenses.Publish`, `Licenses.Archive` and `Licenses.Unarchiv `sdk.BooleanLicenseValue` and `sdk.ConfigLicenseValue` build the other two value kinds. +An entitlement says how usage is measured, and a grant how much of it a license allows +and how strictly. A periodic entitlement counts per window, and a grant may tolerate +usage past its value: + +```go +// Orders count per calendar month, with a warning at 80% of what each license grants. +slug, number, sum := "monthly-orders", sdk.EntitlementType("NUMBER"), sdk.EntitlementAggregationMethod("SUM") +month, calendar, warning := sdk.EntitlementResetPeriodMonth, sdk.EntitlementResetAnchorCalendar, int32(80) +if _, err := client.Entitlements.Create(ctx, sdk.EntitlementInput{ + Name: "Monthly orders", + Slug: &slug, + Type: &number, + AggregationMethod: &sum, + ResetPeriod: &month, + ResetAnchor: &calendar, + WarningThresholdPercent: &warning, +}); err != nil { + return err +} + +// 1000 orders a month, refused past 1200: a soft limit with a 20% allowance. +orders, err := sdk.NumberLicenseValue(1000) +if err != nil { + return err +} +err = client.Licenses.AssociateEntitlement(ctx, "team-v1", slug, orders, sdk.WithOveragePercent(20)) +``` + +`Entitlements.Update` and `Licenses.UpdateEntitlement` replace rather than patch. A +field left out is reset -- a warning threshold to 0, a grant's allowance to a hard +limit -- and a periodic entitlement's reset period and anchor have to be sent back as +they are, or the update is refused. Read the resource first and carry back what should +stay. + ### Usage metering ```go diff --git a/entitlements.go b/entitlements.go index 17fae0b..1e5ccc1 100644 --- a/entitlements.go +++ b/entitlements.go @@ -42,6 +42,11 @@ func (s *Entitlements) Create(ctx context.Context, input EntitlementInput) (Enti } // Update updates the entitlement identified by entitlementSlug. +// +// It replaces the entitlement rather than patching it: an optional field left nil is +// reset, and a periodic entitlement's ResetPeriod and ResetAnchor have to be sent back +// as they are. EntitlementInput says which fields that covers; reading the entitlement +// with Get and carrying its fields over is the safe way to change one of them. func (s *Entitlements) Update(ctx context.Context, entitlementSlug string, input EntitlementInput) error { body, err := jsonBody(input.updatePayload()) if err != nil { diff --git a/entitlements_test.go b/entitlements_test.go index 2758528..870bf56 100644 --- a/entitlements_test.go +++ b/entitlements_test.go @@ -88,3 +88,131 @@ func TestCreateEntitlementOmitsUnsetPresentationFields(t *testing.T) { t.Error(`request body carries "meterType", which the Core API rejects outright`) } } + +// TestEntitlementWritesRenderTheWindowAndThresholdOnlyWhenSet pins both halves of the +// usage window and the warning threshold on the wire. Absence means something on each -- +// no period is a lifetime counter, no anchor is CALENDAR, no threshold is 0 -- and none +// of them is nullable, so a key rendered from an unset field would be a null the bodies +// refuse. A zero threshold a caller does set has to reach the API, though: it is how a +// warning is switched off on purpose. Whole bodies are compared, because "unchanged for +// callers who set none of them" is a claim about the bytes, and the same body is +// expected from Create and Update, whose payloads differ in nothing here. +func TestEntitlementWritesRenderTheWindowAndThresholdOnlyWhenSet(t *testing.T) { + t.Parallel() + + month := EntitlementResetPeriodMonth + licenseStart := EntitlementResetAnchorLicenseStart + off := int32(0) + + lifetime := EntitlementInput{Name: "Locations"} + + monthly := lifetime + monthly.ResetPeriod = &month + + anchored := monthly + anchored.ResetAnchor = &licenseStart + + silenced := lifetime + silenced.WarningThresholdPercent = &off + + tests := []struct { + name string + input EntitlementInput + expected string + }{ + { + name: "nothing set renders none of them", + input: lifetime, + expected: `{"name":"Locations","description":null}`, + }, + { + name: "a period alone leaves the anchor to the server", + input: monthly, + expected: `{"name":"Locations","description":null,"resetPeriod":"MONTH"}`, + }, + { + name: "a period and its anchor", + input: anchored, + expected: `{"name":"Locations","description":null,"resetPeriod":"MONTH","resetAnchor":"LICENSE_START"}`, + }, + { + name: "a zero threshold is sent, not dropped", + input: silenced, + expected: `{"name":"Locations","description":null,"warningThresholdPercent":0}`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + client, sent := givenClient(t, respondToWrite) + + if _, err := client.Entitlements.Create(t.Context(), test.input); err != nil { + t.Fatalf("Create() error = %v", err) + } + if err := client.Entitlements.Update(t.Context(), "locations", test.input); err != nil { + t.Fatalf("Update() error = %v", err) + } + + for i, want := range []struct{ method, path string }{ + {http.MethodPost, "/api/entitlements"}, + {http.MethodPut, "/api/entitlements/locations"}, + } { + request := sent.at(t, i) + if request.method != want.method || request.path != want.path { + t.Errorf("request %d = %s %s, want %s %s", i, request.method, request.path, want.method, want.path) + } + if got := string(request.body); got != test.expected { + t.Errorf("%s body = %s, want %s", want.method, got, test.expected) + } + } + }) + } +} + +// TestEntitlementUpdateCarriesThePeriodBack pins the read-modify-write the API requires +// of a periodic entitlement. Once an entitlement has a reset period, an update that does +// not send it back is refused -- the API reads the omission as an attempt to remove it -- +// and an update that leaves the warning threshold out turns the warning off. Both are +// fields of Entitlement with the types EntitlementInput declares, so what Get returns +// can be carried over as it is, which is what this test does. +func TestEntitlementUpdateCarriesThePeriodBack(t *testing.T) { + t.Parallel() + + client, sent := givenClient(t, func(req *http.Request) (*http.Response, error) { + if req.Method == http.MethodGet { + return jsonResponse(req, http.StatusOK, `{"id":"e1","name":"Monthly Orders","description":null,"slug":"monthly-orders","type":"NUMBER","aggregationMethod":"SUM","resetPeriod":"MONTH","resetAnchor":"CALENDAR","warningThresholdPercent":80}`), nil + } + + return jsonResponse(req, http.StatusNoContent, ""), nil + }) + + current, err := client.Entitlements.Get(t.Context(), "monthly-orders") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + + if err := client.Entitlements.Update(t.Context(), "monthly-orders", EntitlementInput{ + Name: "Orders per month", + Description: current.Description, + Slug: current.Slug, + Type: current.Type, + AggregationMethod: current.AggregationMethod, + ResetPeriod: current.ResetPeriod, + ResetAnchor: current.ResetAnchor, + WarningThresholdPercent: current.WarningThresholdPercent, + }); err != nil { + t.Fatalf("Update() error = %v", err) + } + + update := sent.at(t, 1) + if update.method != http.MethodPut || update.path != "/api/entitlements/monthly-orders" { + t.Errorf("request = %s %s, want PUT /api/entitlements/monthly-orders", update.method, update.path) + } + + expected := `{"name":"Orders per month","description":null,"type":"NUMBER","aggregationMethod":"SUM","slug":"monthly-orders","resetPeriod":"MONTH","resetAnchor":"CALENDAR","warningThresholdPercent":80}` + if got := string(update.body); got != expected { + t.Errorf("request body = %s, want %s", got, expected) + } +} diff --git a/inputs.go b/inputs.go index e012356..b727531 100644 --- a/inputs.go +++ b/inputs.go @@ -116,13 +116,21 @@ type EntitlementGroupInput struct { // EntitlementInput is the input for creating or updating an entitlement. // -// The presentation fields -- Icon, UnitSingular, UnitPlural, UserFacing and DisplayOrder -- -// are what customer-facing components render: a pricing table shows the user-facing +// The presentation fields -- Icon, the unit labels, UserFacing and DisplayOrder -- are +// what customer-facing components render: a pricing table shows the user-facing // entitlements in DisplayOrder with their unit labels, and hides the rest. They are // optional because most entitlements are internal counters that should stay hidden, which // is exactly what leaving them nil produces. // // The API validates the unit labels as an all-or-none pair, so set both or neither. +// +// Update replaces the entitlement rather than patching it. The API resets an optional +// field the body leaves out instead of keeping the stored value, so an update built from +// scratch clears Description, Icon, the unit labels, UserFacing, DisplayOrder and +// WarningThresholdPercent -- and an update of a periodic entitlement that does not carry +// its ResetPeriod and ResetAnchor back is refused. Read the entitlement first and carry +// back what should stay: an Entitlement's fields have the same types as these. GroupSlugs +// is the exception, where nil leaves the groups as they are. type EntitlementInput struct { Name string Description *string @@ -144,12 +152,47 @@ type EntitlementInput struct { UnitSingular *string UnitPlural *string + // SaleUnitSingular, SaleUnitPlural and SaleUnitFactor name the unit the entitlement + // is sold in when it differs from the base unit: a "pack" of SaleUnitFactor seats. + // All three or none, and only together with UnitSingular and UnitPlural -- anything + // else is a 400, .InvalidUnitsConfiguration. + SaleUnitSingular *string + SaleUnitPlural *string + SaleUnitFactor *float64 + // UserFacing exposes the entitlement in customer-facing components. Defaults to false // server-side, which keeps raw counters out of a pricing page. UserFacing *bool // DisplayOrder sorts the entitlement within customer-facing components, ascending. DisplayOrder *int32 + + // ResetPeriod makes a NUMBER entitlement periodic: its usage starts again from zero + // every period, and a grant's value caps the usage of the current window rather than + // a lifetime total. Nil keeps a lifetime counter. Only NUMBER and NUMBER_AI_CREDIT + // entitlements take one, and not with the LATEST aggregation method, whose reports + // already state a current count -- either is a 400, + // .InvalidResetConfiguration. + // + // It is a one-way door. Once an entitlement has a period, every Update has to carry + // it back: nil, or a different period, is a 400, UpdateEntitlement.ImmutableResetPeriod, + // because the API reads an omitted period as an attempt to remove it. An Update may + // still give a period to an entitlement that has none, once. + ResetPeriod *EntitlementResetPeriod + + // ResetAnchor is what the windows align on. It goes with ResetPeriod -- sent without + // one it is a 400 -- and nil with a ResetPeriod is EntitlementResetAnchorCalendar. + // Once set it is immutable the same way, refused with + // UpdateEntitlement.ImmutableResetAnchor. + ResetAnchor *EntitlementResetAnchor + + // WarningThresholdPercent is the share of a grant's value, from 0 to 100, at which + // usage raises an early warning -- the INSTANCE_ENTITLEMENT_USAGE_WARNING_THRESHOLD_REACHED + // event -- before it reaches the cap. Zero, the server's default, disables it. NUMBER + // and NUMBER_AI_CREDIT only: on another type it is a 400, + // .InvalidEnforcementPolicyConfiguration. Update resets it like any + // optional field, so nil there turns the warning off. + WarningThresholdPercent *int32 } // InstanceInput is the input for creating or updating an instance. @@ -358,6 +401,45 @@ type UsageReportInput struct { Metadata map[string]any } +// LicenseEntitlementOption sets an optional field of the grant that +// Licenses.AssociateEntitlement or Licenses.UpdateEntitlement writes. +type LicenseEntitlementOption func(*licenseEntitlementOptions) + +// licenseEntitlementOptions accumulates what a LicenseEntitlementOption sets. +type licenseEntitlementOptions struct { + overagePercent *int32 +} + +// WithOveragePercent sets how far usage may exceed a numeric grant's value before the +// API refuses a report. It is sent as limitCapExceededOveragePercent and read back on +// LicenseEntitlement.LimitCapExceededOveragePercent. 0 is a hard limit. A positive +// percentage is a soft one: 20 on a value of 1000 accepts usage up to 1200. -1 is +// unlimited, and is required exactly when the value is the unlimited sentinel -1. +// +// Without it the server derives the allowance from the value: -1 for an unlimited +// value, 0 for any other. Licenses.UpdateEntitlement replaces the grant, so leaving the +// option out there turns a soft limit back into a hard one -- pass the grant's current +// percentage to keep it. +// +// Only a numeric grant has an allowance. Set on a BOOLEAN or CONFIG value, or out of +// range, it is a 400 whose code ends in .InvalidLimitCapExceededOveragePercent. +func WithOveragePercent(percent int32) LicenseEntitlementOption { + return func(o *licenseEntitlementOptions) { + o.overagePercent = &percent + } +} + +func newLicenseEntitlementOptions(opts []LicenseEntitlementOption) licenseEntitlementOptions { + var options licenseEntitlementOptions + for _, opt := range opts { + if opt != nil { + opt(&options) + } + } + + return options +} + type componentCreatePayload struct { Name string `json:"name"` Version string `json:"version"` @@ -472,63 +554,95 @@ func (in EntitlementGroupInput) updatePayload() entitlementGroupUpdatePayload { return entitlementGroupUpdatePayload(in) } +// The usage window and the warning threshold are omitempty like the presentation fields, +// and absence means the same thing on both bodies: no period is a lifetime counter, no +// anchor is CALENDAR, no threshold is 0. A nil pointer must render no key rather than a +// null -- none of the three is nullable. A zero WarningThresholdPercent still renders, +// since omitempty only drops a nil pointer, so a caller can send 0 on purpose. type entitlementPayload struct { - Name string `json:"name"` - Description *string `json:"description"` - Type *EntitlementType `json:"type,omitempty"` - AggregationMethod *EntitlementAggregationMethod `json:"aggregationMethod,omitempty"` - GroupSlugs *[]string `json:"groupSlugs,omitempty"` - Slug *string `json:"slug,omitempty"` - Icon *string `json:"icon,omitempty"` - UnitSingular *string `json:"unitSingular,omitempty"` - UnitPlural *string `json:"unitPlural,omitempty"` - UserFacing *bool `json:"userFacing,omitempty"` - DisplayOrder *int32 `json:"displayOrder,omitempty"` + Name string `json:"name"` + Description *string `json:"description"` + Type *EntitlementType `json:"type,omitempty"` + AggregationMethod *EntitlementAggregationMethod `json:"aggregationMethod,omitempty"` + GroupSlugs *[]string `json:"groupSlugs,omitempty"` + Slug *string `json:"slug,omitempty"` + Icon *string `json:"icon,omitempty"` + UnitSingular *string `json:"unitSingular,omitempty"` + UnitPlural *string `json:"unitPlural,omitempty"` + SaleUnitSingular *string `json:"saleUnitSingular,omitempty"` + SaleUnitPlural *string `json:"saleUnitPlural,omitempty"` + SaleUnitFactor *float64 `json:"saleUnitFactor,omitempty"` + UserFacing *bool `json:"userFacing,omitempty"` + DisplayOrder *int32 `json:"displayOrder,omitempty"` + ResetPeriod *EntitlementResetPeriod `json:"resetPeriod,omitempty"` + ResetAnchor *EntitlementResetAnchor `json:"resetAnchor,omitempty"` + WarningThresholdPercent *int32 `json:"warningThresholdPercent,omitempty"` } func (in EntitlementInput) createPayload() entitlementPayload { return entitlementPayload{ - Name: in.Name, - Description: in.Description, - Type: in.Type, - AggregationMethod: in.AggregationMethod, - GroupSlugs: cloneStringSlice(in.GroupSlugs), - Slug: in.Slug, - Icon: in.Icon, - UnitSingular: in.UnitSingular, - UnitPlural: in.UnitPlural, - UserFacing: in.UserFacing, - DisplayOrder: in.DisplayOrder, + Name: in.Name, + Description: in.Description, + Type: in.Type, + AggregationMethod: in.AggregationMethod, + GroupSlugs: cloneStringSlice(in.GroupSlugs), + Slug: in.Slug, + Icon: in.Icon, + UnitSingular: in.UnitSingular, + UnitPlural: in.UnitPlural, + SaleUnitSingular: in.SaleUnitSingular, + SaleUnitPlural: in.SaleUnitPlural, + SaleUnitFactor: in.SaleUnitFactor, + UserFacing: in.UserFacing, + DisplayOrder: in.DisplayOrder, + ResetPeriod: in.ResetPeriod, + ResetAnchor: in.ResetAnchor, + WarningThresholdPercent: in.WarningThresholdPercent, } } +// entitlementUpdatePayload renders the usage window whenever it is set, not only the +// first time: once an entitlement has a period, the API refuses an update that does not +// carry it back, so dropping it here would fail every update of a periodic entitlement. type entitlementUpdatePayload struct { - Name string `json:"name"` - Description *string `json:"description"` - Type *EntitlementType `json:"type,omitempty"` - AggregationMethod *EntitlementAggregationMethod `json:"aggregationMethod,omitempty"` - GroupSlugs *[]string `json:"groupSlugs,omitempty"` - Slug *string `json:"slug,omitempty"` - Icon *string `json:"icon,omitempty"` - UnitSingular *string `json:"unitSingular,omitempty"` - UnitPlural *string `json:"unitPlural,omitempty"` - UserFacing *bool `json:"userFacing,omitempty"` - DisplayOrder *int32 `json:"displayOrder,omitempty"` + Name string `json:"name"` + Description *string `json:"description"` + Type *EntitlementType `json:"type,omitempty"` + AggregationMethod *EntitlementAggregationMethod `json:"aggregationMethod,omitempty"` + GroupSlugs *[]string `json:"groupSlugs,omitempty"` + Slug *string `json:"slug,omitempty"` + Icon *string `json:"icon,omitempty"` + UnitSingular *string `json:"unitSingular,omitempty"` + UnitPlural *string `json:"unitPlural,omitempty"` + SaleUnitSingular *string `json:"saleUnitSingular,omitempty"` + SaleUnitPlural *string `json:"saleUnitPlural,omitempty"` + SaleUnitFactor *float64 `json:"saleUnitFactor,omitempty"` + UserFacing *bool `json:"userFacing,omitempty"` + DisplayOrder *int32 `json:"displayOrder,omitempty"` + ResetPeriod *EntitlementResetPeriod `json:"resetPeriod,omitempty"` + ResetAnchor *EntitlementResetAnchor `json:"resetAnchor,omitempty"` + WarningThresholdPercent *int32 `json:"warningThresholdPercent,omitempty"` } func (in EntitlementInput) updatePayload() entitlementUpdatePayload { return entitlementUpdatePayload{ - Name: in.Name, - Description: in.Description, - Type: in.Type, - AggregationMethod: in.AggregationMethod, - GroupSlugs: cloneStringSlice(in.GroupSlugs), - Slug: in.Slug, - Icon: in.Icon, - UnitSingular: in.UnitSingular, - UnitPlural: in.UnitPlural, - UserFacing: in.UserFacing, - DisplayOrder: in.DisplayOrder, + Name: in.Name, + Description: in.Description, + Type: in.Type, + AggregationMethod: in.AggregationMethod, + GroupSlugs: cloneStringSlice(in.GroupSlugs), + Slug: in.Slug, + Icon: in.Icon, + UnitSingular: in.UnitSingular, + UnitPlural: in.UnitPlural, + SaleUnitSingular: in.SaleUnitSingular, + SaleUnitPlural: in.SaleUnitPlural, + SaleUnitFactor: in.SaleUnitFactor, + UserFacing: in.UserFacing, + DisplayOrder: in.DisplayOrder, + ResetPeriod: in.ResetPeriod, + ResetAnchor: in.ResetAnchor, + WarningThresholdPercent: in.WarningThresholdPercent, } } @@ -666,6 +780,23 @@ func (in LicenseInput) updatePayload() licenseUpdatePayload { } } +// limitCapExceededOveragePercent is omitempty on both grant bodies: absence is how a body +// asks for the allowance the server derives from the value, and the field is not +// nullable. So a grant written without WithOveragePercent sends the bytes it sent before +// the option existed, while WithOveragePercent(0) still renders its zero. +type licenseEntitlementCreatePayload struct { + EntitlementSlug string `json:"entitlementSlug"` + Value LicenseEntitlementValue `json:"value"` + LimitCapExceededOveragePercent *int32 `json:"limitCapExceededOveragePercent,omitempty"` +} + +// licenseEntitlementUpdatePayload carries no entitlementSlug: the path names the grant, +// and the body accepts the slug only as a repeat of the path's. +type licenseEntitlementUpdatePayload struct { + Value LicenseEntitlementValue `json:"value"` + LimitCapExceededOveragePercent *int32 `json:"limitCapExceededOveragePercent,omitempty"` +} + type metadataFieldPayload struct { ResourceType MetadataFieldResourceType `json:"resourceType"` Key string `json:"key"` diff --git a/inputs_test.go b/inputs_test.go index b996ec5..16939ce 100644 --- a/inputs_test.go +++ b/inputs_test.go @@ -10,9 +10,13 @@ import ( "fmt" "net/http" "path" + "reflect" "slices" + "strings" "testing" "time" + + "github.com/kaitencloud/sdk-go/internal/gen" ) // TestRequestBodiesMatchTheirSpecBodies pins the exact key set every write renders. @@ -37,6 +41,12 @@ func TestRequestBodiesMatchTheirSpecBodies(t *testing.T) { name := "pinned" scopes := []string{"read:instances"} draft := LicenseLifecycleDraft + periodic := periodicEntitlementInput(name) + + grantValue, err := NumberLicenseValue(1000) + if err != nil { + t.Fatalf("NumberLicenseValue() error = %v", err) + } tests := []struct { name string @@ -120,6 +130,51 @@ func TestRequestBodiesMatchTheirSpecBodies(t *testing.T) { }, expected: []string{"description", "groupSlugs", "name", "slug"}, }, + { + name: "create periodic entitlement", + call: func(ctx context.Context, c *Client) error { + _, err := c.Entitlements.Create(ctx, periodic) + return err + }, + expected: []string{"aggregationMethod", "description", "name", "resetAnchor", "resetPeriod", "saleUnitFactor", "saleUnitPlural", "saleUnitSingular", "slug", "type", "unitPlural", "unitSingular", "warningThresholdPercent"}, + }, + { + name: "update periodic entitlement", + call: func(ctx context.Context, c *Client) error { + return c.Entitlements.Update(ctx, name, periodic) + }, + // The window renders on update too: once set, the API refuses an update + // that does not carry it back. + expected: []string{"aggregationMethod", "description", "name", "resetAnchor", "resetPeriod", "saleUnitFactor", "saleUnitPlural", "saleUnitSingular", "slug", "type", "unitPlural", "unitSingular", "warningThresholdPercent"}, + }, + { + name: "associate license entitlement", + call: func(ctx context.Context, c *Client) error { + return c.Licenses.AssociateEntitlement(ctx, name, name, grantValue) + }, + expected: []string{"entitlementSlug", "value"}, + }, + { + name: "associate license entitlement with an allowance", + call: func(ctx context.Context, c *Client) error { + return c.Licenses.AssociateEntitlement(ctx, name, name, grantValue, WithOveragePercent(20)) + }, + expected: []string{"entitlementSlug", "limitCapExceededOveragePercent", "value"}, + }, + { + name: "update license entitlement", + call: func(ctx context.Context, c *Client) error { + return c.Licenses.UpdateEntitlement(ctx, name, name, grantValue) + }, + expected: []string{"value"}, + }, + { + name: "update license entitlement with an allowance", + call: func(ctx context.Context, c *Client) error { + return c.Licenses.UpdateEntitlement(ctx, name, name, grantValue, WithOveragePercent(20)) + }, + expected: []string{"limitCapExceededOveragePercent", "value"}, + }, { name: "create instance", call: func(ctx context.Context, c *Client) error { @@ -232,6 +287,170 @@ func TestRequestBodiesMatchTheirSpecBodies(t *testing.T) { } } +// TestEntitlementAndGrantWritesReachEveryWritableKey fails when the spec gives the +// entitlement or grant bodies a field no input can send. +// +// TestRequestBodiesMatchTheirSpecBodies cannot catch that: its expected sets are +// transcribed by hand, so a field the spec adds is missing from the expectation exactly +// as it is missing from the input. That is how resetPeriod, resetAnchor, +// warningThresholdPercent, the sale-unit trio and limitCapExceededOveragePercent stayed +// out of reach while internal/gen already declared them -- and because both PUTs replace +// rather than patch, every Update reset what it could not send. This test reads the +// body's keys off the generated request type instead, so a regenerated client with a new +// key fails here until the input renders it or the key is listed below as read-only. +func TestEntitlementAndGrantWritesReachEveryWritableKey(t *testing.T) { + t.Parallel() + + name := "pinned" + complete := completeEntitlementInput(name) + + grantValue, err := NumberLicenseValue(1000) + if err != nil { + t.Fatalf("NumberLicenseValue() error = %v", err) + } + + // The fields the spec marks readOnly: they are in the generated request type because + // the bodies reuse the resource schemas, and the API ignores them on a write. + entitlementReadOnly := []string{"createdAt", "entitlementGroups", "id", "updatedAt"} + grantReadOnly := []string{"createdAt", "createdBy", "entitlementGroups", "entitlementName", "entitlementType", "licenseId", "licenseSlug", "updatedAt", "updatedBy"} + + tests := []struct { + name string + body any + readOnly []string + // unsent lists writable keys an input leaves out on purpose, each with its + // reason next to the payload type. + unsent []string + call func(context.Context, *Client) error + }{ + { + name: "create entitlement", + body: gen.CreateEntitlementJSONRequestBody{}, + readOnly: entitlementReadOnly, + call: func(ctx context.Context, c *Client) error { + _, err := c.Entitlements.Create(ctx, complete) + return err + }, + }, + { + name: "update entitlement", + body: gen.UpdateEntitlementJSONRequestBody{}, + readOnly: entitlementReadOnly, + call: func(ctx context.Context, c *Client) error { + return c.Entitlements.Update(ctx, name, complete) + }, + }, + { + name: "associate license entitlement", + body: gen.AssociateEntitlementWithLicenseJSONRequestBody{}, + readOnly: grantReadOnly, + call: func(ctx context.Context, c *Client) error { + return c.Licenses.AssociateEntitlement(ctx, name, name, grantValue, WithOveragePercent(20)) + }, + }, + { + name: "update license entitlement", + body: gen.UpdateLicenseEntitlementJSONRequestBody{}, + readOnly: grantReadOnly, + // See licenseEntitlementUpdatePayload: the path names the grant. + unsent: []string{"entitlementSlug"}, + call: func(ctx context.Context, c *Client) error { + return c.Licenses.UpdateEntitlement(ctx, name, name, grantValue, WithOveragePercent(20)) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + var writable []string + for _, key := range jsonKeys(t, reflect.TypeOf(test.body)) { + if !slices.Contains(test.readOnly, key) && !slices.Contains(test.unsent, key) { + writable = append(writable, key) + } + } + + client, sent := givenClient(t, respondToWrite) + + if err := test.call(t.Context(), client); err != nil { + t.Fatalf("call() error = %v", err) + } + + if got := sent.only(t).bodyKeys(t); !slices.Equal(got, writable) { + t.Errorf("request body keys = %v, want every writable key of %T: %v", got, test.body, writable) + } + }) + } +} + +// periodicEntitlementInput is a NUMBER entitlement counted per calendar month, with a +// warning threshold and a sale unit, and none of the presentation fields. +func periodicEntitlementInput(name string) EntitlementInput { + number := EntitlementType("NUMBER") + sum := EntitlementAggregationMethod("SUM") + month := EntitlementResetPeriodMonth + calendar := EntitlementResetAnchorCalendar + singular, plural := "order", "orders" + saleSingular, salePlural := "pack", "packs" + factor := 10.0 + warning := int32(80) + + return EntitlementInput{ + Name: name, + Slug: &name, + Type: &number, + AggregationMethod: &sum, + UnitSingular: &singular, + UnitPlural: &plural, + SaleUnitSingular: &saleSingular, + SaleUnitPlural: &salePlural, + SaleUnitFactor: &factor, + ResetPeriod: &month, + ResetAnchor: &calendar, + WarningThresholdPercent: &warning, + } +} + +// completeEntitlementInput sets every field of EntitlementInput. +func completeEntitlementInput(name string) EntitlementInput { + input := periodicEntitlementInput(name) + + description, icon := "Orders placed in the month", "lucide:receipt" + userFacing := true + displayOrder := int32(2) + + input.Description = &description + input.GroupSlugs = []string{"restaurant-operations"} + input.Icon = &icon + input.UserFacing = &userFacing + input.DisplayOrder = &displayOrder + + return input +} + +// jsonKeys returns the JSON names of a generated struct's fields, sorted. +func jsonKeys(t *testing.T, structType reflect.Type) []string { + t.Helper() + + if structType.Kind() != reflect.Struct { + t.Fatalf("%s is a %s, want a struct", structType, structType.Kind()) + } + + keys := make([]string, 0, structType.NumField()) + for i := range structType.NumField() { + name, _, _ := strings.Cut(structType.Field(i).Tag.Get("json"), ",") + if name == "" || name == "-" { + continue + } + keys = append(keys, name) + } + + slices.Sort(keys) + + return keys +} + // TestUpdateLetsTheAPIRefuseARename pins the six updates that never rename. // // Their PUTs check a slug in the body against the path: the current one is accepted and diff --git a/licenses.go b/licenses.go index e22243e..fc5d40b 100644 --- a/licenses.go +++ b/licenses.go @@ -149,13 +149,18 @@ func (s *Licenses) ListEntitlements(ctx context.Context, licenseSlug string) ([] } // AssociateEntitlement grants the entitlement identified by entitlementSlug to the license identified by licenseSlug with the given value. -func (s *Licenses) AssociateEntitlement(ctx context.Context, licenseSlug, entitlementSlug string, value LicenseEntitlementValue) error { - body, err := jsonBody(struct { - EntitlementSlug *string `json:"entitlementSlug,omitempty"` - Value LicenseEntitlementValue `json:"value"` - }{ - EntitlementSlug: &entitlementSlug, - Value: value, +// +// A numeric grant enforces its value as a hard limit unless WithOveragePercent gives it +// an allowance; an unlimited value is never enforced. A license grants an entitlement +// once: a second grant is a 409, AssociateEntitlementToLicense.AlreadyAssociated, and +// UpdateEntitlement is how a grant changes. +func (s *Licenses) AssociateEntitlement(ctx context.Context, licenseSlug, entitlementSlug string, value LicenseEntitlementValue, opts ...LicenseEntitlementOption) error { + options := newLicenseEntitlementOptions(opts) + + body, err := jsonBody(licenseEntitlementCreatePayload{ + EntitlementSlug: entitlementSlug, + Value: value, + LimitCapExceededOveragePercent: options.overagePercent, }) if err != nil { return fmt.Errorf("encode license entitlement request: %w", err) @@ -181,11 +186,18 @@ func (s *Licenses) GetEntitlement(ctx context.Context, licenseSlug, entitlementS } // UpdateEntitlement updates the value of the entitlement identified by entitlementSlug granted by the license identified by licenseSlug. -func (s *Licenses) UpdateEntitlement(ctx context.Context, licenseSlug, entitlementSlug string, value LicenseEntitlementValue) error { - body, err := jsonBody(struct { - Value LicenseEntitlementValue `json:"value"` - }{ - Value: value, +// +// It replaces the grant rather than patching it. A numeric grant written without +// WithOveragePercent gets the allowance the server derives from the value -- none, for a +// finite one -- so changing only the value of a soft limit makes it a hard one. To keep +// the allowance, pass back the LimitCapExceededOveragePercent that GetEntitlement reads, +// unless the new value is unlimited, which takes -1. +func (s *Licenses) UpdateEntitlement(ctx context.Context, licenseSlug, entitlementSlug string, value LicenseEntitlementValue, opts ...LicenseEntitlementOption) error { + options := newLicenseEntitlementOptions(opts) + + body, err := jsonBody(licenseEntitlementUpdatePayload{ + Value: value, + LimitCapExceededOveragePercent: options.overagePercent, }) if err != nil { return fmt.Errorf("encode license entitlement request: %w", err) diff --git a/licenses_test.go b/licenses_test.go index 2b401ec..705bb8d 100644 --- a/licenses_test.go +++ b/licenses_test.go @@ -310,3 +310,99 @@ func TestLifecycleTransitionsPostToTheirOwnOperation(t *testing.T) { }) } } + +// TestGrantWritesRenderTheOveragePercentOnlyWhenSet pins the allowance on both grant +// bodies. Without WithOveragePercent the bytes are the ones the two methods sent before +// the option existed, so the server keeps deriving the allowance from the value. With it, +// the percentage reaches the wire whatever it is: 0, a hard limit and what the server +// would have derived for a finite value, as much as -1, which an unlimited value requires. +func TestGrantWritesRenderTheOveragePercentOnlyWhenSet(t *testing.T) { + t.Parallel() + + finite, err := NumberLicenseValue(1000) + if err != nil { + t.Fatalf("NumberLicenseValue() error = %v", err) + } + unlimited, err := NumberLicenseValue(-1) + if err != nil { + t.Fatalf("NumberLicenseValue() error = %v", err) + } + + tests := []struct { + name string + value LicenseEntitlementValue + opts []LicenseEntitlementOption + associateBody string + updateBody string + }{ + { + name: "no option leaves the allowance to the server", + value: finite, + associateBody: `{"entitlementSlug":"monthly-orders","value":{"type":"number","value":1000}}`, + updateBody: `{"value":{"type":"number","value":1000}}`, + }, + { + name: "a soft limit", + value: finite, + opts: []LicenseEntitlementOption{WithOveragePercent(20)}, + associateBody: `{"entitlementSlug":"monthly-orders","value":{"type":"number","value":1000},"limitCapExceededOveragePercent":20}`, + updateBody: `{"value":{"type":"number","value":1000},"limitCapExceededOveragePercent":20}`, + }, + { + name: "a hard limit is sent, not dropped", + value: finite, + opts: []LicenseEntitlementOption{WithOveragePercent(0)}, + associateBody: `{"entitlementSlug":"monthly-orders","value":{"type":"number","value":1000},"limitCapExceededOveragePercent":0}`, + updateBody: `{"value":{"type":"number","value":1000},"limitCapExceededOveragePercent":0}`, + }, + { + name: "unlimited", + value: unlimited, + opts: []LicenseEntitlementOption{WithOveragePercent(-1)}, + associateBody: `{"entitlementSlug":"monthly-orders","value":{"type":"number","value":-1},"limitCapExceededOveragePercent":-1}`, + updateBody: `{"value":{"type":"number","value":-1},"limitCapExceededOveragePercent":-1}`, + }, + { + name: "a nil option is skipped", + value: finite, + opts: []LicenseEntitlementOption{nil}, + associateBody: `{"entitlementSlug":"monthly-orders","value":{"type":"number","value":1000}}`, + updateBody: `{"value":{"type":"number","value":1000}}`, + }, + { + name: "the last option wins", + value: finite, + opts: []LicenseEntitlementOption{WithOveragePercent(10), WithOveragePercent(20)}, + associateBody: `{"entitlementSlug":"monthly-orders","value":{"type":"number","value":1000},"limitCapExceededOveragePercent":20}`, + updateBody: `{"value":{"type":"number","value":1000},"limitCapExceededOveragePercent":20}`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + client, sent := givenClient(t, respondNoContent()) + + if err := client.Licenses.AssociateEntitlement(t.Context(), "premium", "monthly-orders", test.value, test.opts...); err != nil { + t.Fatalf("AssociateEntitlement() error = %v", err) + } + if err := client.Licenses.UpdateEntitlement(t.Context(), "premium", "monthly-orders", test.value, test.opts...); err != nil { + t.Fatalf("UpdateEntitlement() error = %v", err) + } + + for i, want := range []struct{ method, path, body string }{ + {http.MethodPost, "/api/licenses/premium/entitlements", test.associateBody}, + {http.MethodPut, "/api/licenses/premium/entitlements/monthly-orders", test.updateBody}, + } { + request := sent.at(t, i) + if request.method != want.method || request.path != want.path { + t.Errorf("request %d = %s %s, want %s %s", i, request.method, request.path, want.method, want.path) + } + if got := string(request.body); got != want.body { + t.Errorf("%s body = %s, want %s", want.method, got, want.body) + } + } + }) + } +} diff --git a/types.go b/types.go index fa47d9b..2a41522 100644 --- a/types.go +++ b/types.go @@ -69,6 +69,30 @@ type EntitlementGroupRef = gen.EntitlementGroupSummary // for the same reason as EntitlementGroupRef above. type EntitlementGroupUsageItem = gen.EntitlementGroupUsage +// EntitlementResetPeriod is how often a NUMBER entitlement's usage starts again from +// zero. An entitlement without one counts for its whole lifetime. +type EntitlementResetPeriod = gen.EntitlementResetPeriod + +// Periods a NUMBER entitlement's usage can reset on. +const ( + EntitlementResetPeriodHour EntitlementResetPeriod = "HOUR" + EntitlementResetPeriodDay EntitlementResetPeriod = "DAY" + EntitlementResetPeriodWeek EntitlementResetPeriod = "WEEK" + EntitlementResetPeriodMonth EntitlementResetPeriod = "MONTH" + EntitlementResetPeriodYear EntitlementResetPeriod = "YEAR" +) + +// EntitlementResetAnchor is what a periodic entitlement's windows align on. +type EntitlementResetAnchor = gen.EntitlementResetAnchor + +// Anchors a periodic entitlement's windows can align on: the UTC calendar, so a +// monthly window runs from the first of the month, or each instance's license start +// date. +const ( + EntitlementResetAnchorCalendar EntitlementResetAnchor = "CALENDAR" + EntitlementResetAnchorLicenseStart EntitlementResetAnchor = "LICENSE_START" +) + // EntitlementType describes the value type of an entitlement. type EntitlementType = gen.EntitlementType