diff --git a/README.md b/README.md index 2a42aef..733bcd2 100644 --- a/README.md +++ b/README.md @@ -143,8 +143,23 @@ Feature flags carry targeting rules and variants, so `feature-flags create` and kaiten licenses entitlements associate team-v1 seats --number 25 kaiten licenses entitlements associate team-v1 sso --boolean=true kaiten licenses entitlements associate team-v1 limits --object-payload '{"max": 10}' + +# A soft limit: 1000 orders a month, refused past 1200 +kaiten entitlements create --name "Monthly orders" --slug monthly-orders --type NUMBER \ + --aggregation-method SUM --reset-period MONTH --warning-threshold-percent 80 +kaiten licenses entitlements associate team-v1 monthly-orders --number 1000 --overage-percent 20 + +# Change the value; the grant keeps its 20 % allowance +kaiten licenses entitlements update team-v1 monthly-orders --number 1500 ``` +The API replaces an entitlement or a grant with what it is sent, resetting what the request +leaves out. So `entitlements update` and `licenses entitlements update` read the resource +first when given inline flags, and send back everything the flags do not change: a +periodic entitlement keeps its window, which the API refuses to see dropped, and a grant +keeps its allowance unless `--overage-percent` says otherwise. `--file` and `--payload` are +sent as written. + ### Usage metering ```shell diff --git a/go.mod b/go.mod index 1757be6..eb47993 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/kaitencloud/cli go 1.25.0 require ( - github.com/kaitencloud/sdk-go v0.0.1 + github.com/kaitencloud/sdk-go v0.1.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index 9634a97..22bc578 100644 --- a/go.sum +++ b/go.sum @@ -11,8 +11,8 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= -github.com/kaitencloud/sdk-go v0.0.1 h1:WfR1qFNG++WkcGu55pOcT1nIt520VPMT0a81aOSj03M= -github.com/kaitencloud/sdk-go v0.0.1/go.mod h1:KE77iZ8i+SfjjhUf+qPaQrLHq3/EpFuz4+UPtZ7WFeM= +github.com/kaitencloud/sdk-go v0.1.0 h1:r75JTSnJAxrzGsMRTTt2qxJ3wWpKrAgV5wLZ2CnZycU= +github.com/kaitencloud/sdk-go v0.1.0/go.mod h1:KE77iZ8i+SfjjhUf+qPaQrLHq3/EpFuz4+UPtZ7WFeM= github.com/oapi-codegen/runtime v1.3.1 h1:RgDY6J4OGQLbRXhG/Xpt3vSVqYpHQS7hN4m85+5xB9g= github.com/oapi-codegen/runtime v1.3.1/go.mod h1:kOdeacKy7t40Rclb1je37ZLFboFxh+YLy0zaPCMibPY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/internal/cmd/entitlements.go b/internal/cmd/entitlements.go index 3c67830..1bc1ca8 100644 --- a/internal/cmd/entitlements.go +++ b/internal/cmd/entitlements.go @@ -111,19 +111,52 @@ func newEntitlementsUpdateCommand() *cobra.Command { cmd := &cobra.Command{ Use: "update ", Short: "Update an entitlement from --file, --payload, or inline flags", - Args: cobra.ExactArgs(1), + Long: "Update an entitlement. The API replaces an entitlement with what it is sent: a field\n" + + "left out is reset, and a periodic entitlement's reset period and anchor have to be sent\n" + + "back as they are. Inline flags change only the fields they name: the command reads the\n" + + "entitlement first and sends every other field back unchanged. --file and --payload are\n" + + "sent as written, so they carry every field the entitlement keeps.", + Args: cobra.ExactArgs(1), + Example: ` kaiten entitlements update monthly-orders --warning-threshold-percent 80 + kaiten entitlements update menu-items --description "Dishes on the menu" --display-order 1 + kaiten entitlements update locations --icon ""`, RunE: func(cmd *cobra.Command, args []string) error { - inputValue, err := resolveInput(file, payload, inline.changed(cmd), inline.build(cmd)) + ctx, cancel := commandContext(cmd) + defer cancel() + + // The client is built on first use, so that a usage error -- no input + // source, or two -- is reported before a configuration one. + var client *sdk.Client + connect := func() (*sdk.Client, error) { + if client == nil { + built, _, err := newClient(cmd) + if err != nil { + return nil, err + } + client = built + } + return client, nil + } + + inputValue, err := resolveInput(file, payload, inline.changed(cmd), func() (sdk.EntitlementInput, error) { + connected, err := connect() + if err != nil { + return sdk.EntitlementInput{}, err + } + current, err := connected.Entitlements.Get(ctx, args[0]) + if err != nil { + return sdk.EntitlementInput{}, err + } + return inline.applyTo(cmd, current), nil + }) if err != nil { return err } - client, _, err := newClient(cmd) + connected, err := connect() if err != nil { return err } - ctx, cancel := commandContext(cmd) - defer cancel() - if err := client.Entitlements.Update(ctx, args[0], inputValue); err != nil { + if err := connected.Entitlements.Update(ctx, args[0], inputValue); err != nil { return err } return writeMessage(cmd, "Entitlement updated") @@ -162,12 +195,31 @@ func newEntitlementsDeleteCommand() *cobra.Command { // entitlementInputFlags is the inline form of an entitlement payload, shared by create and // update. The type flag is stored as kind because type is a Go keyword. type entitlementInputFlags struct { - name string - description string - kind string - aggregationMethod string - groupSlugs []string - slug string + name string + description string + kind string + aggregationMethod string + groupSlugs []string + slug string + resetPeriod string + resetAnchor string + warningThresholdPercent int32 + icon string + unitSingular string + unitPlural string + saleUnitSingular string + saleUnitPlural string + saleUnitFactor float64 + userFacing bool + displayOrder int32 +} + +// optionalFlags are the inline flags of the optional fields, the ones build and applyTo both +// set only when the invocation names them. +var optionalFlags = []string{ + "reset-period", "reset-anchor", "warning-threshold-percent", + "icon", "unit-singular", "unit-plural", "sale-unit-singular", "sale-unit-plural", "sale-unit-factor", + "user-facing", "display-order", } func (f *entitlementInputFlags) register(cmd *cobra.Command) { @@ -176,6 +228,20 @@ func (f *entitlementInputFlags) register(cmd *cobra.Command) { cmd.Flags().StringVar(&f.kind, "type", "", "Entitlement type") cmd.Flags().StringVar(&f.aggregationMethod, "aggregation-method", "", "Entitlement aggregation method") cmd.Flags().StringArrayVar(&f.groupSlugs, "group-slug", nil, "Entitlement group slug, repeat for multiple values") + cmd.Flags().StringVar(&f.resetPeriod, "reset-period", "", + "How often a NUMBER entitlement's usage resets: HOUR, DAY, WEEK, MONTH or YEAR (set once, never changed)") + cmd.Flags().StringVar(&f.resetAnchor, "reset-anchor", "", + "What the reset windows align on: CALENDAR (the default) or LICENSE_START (set once, never changed)") + cmd.Flags().Int32Var(&f.warningThresholdPercent, "warning-threshold-percent", 0, + "Share of a grant's value, 0 to 100, at which usage raises an early warning; 0 disables it") + cmd.Flags().StringVar(&f.icon, "icon", "", `Icon token such as "lucide:rocket"; an empty value removes it`) + cmd.Flags().StringVar(&f.unitSingular, "unit-singular", "", "Base unit label, singular; an empty value removes it") + cmd.Flags().StringVar(&f.unitPlural, "unit-plural", "", "Base unit label, plural; an empty value removes it") + cmd.Flags().StringVar(&f.saleUnitSingular, "sale-unit-singular", "", "Sale unit label, singular; an empty value removes it") + cmd.Flags().StringVar(&f.saleUnitPlural, "sale-unit-plural", "", "Sale unit label, plural; an empty value removes it") + cmd.Flags().Float64Var(&f.saleUnitFactor, "sale-unit-factor", 0, "Base units in one sale unit") + cmd.Flags().BoolVar(&f.userFacing, "user-facing", false, "Show the entitlement in customer-facing components") + cmd.Flags().Int32Var(&f.displayOrder, "display-order", 0, "Sort order in customer-facing components, ascending") } // registerSlug adds --slug to create only. The API never renames an entitlement: @@ -186,7 +252,8 @@ func (f *entitlementInputFlags) registerSlug(cmd *cobra.Command) { } func (f *entitlementInputFlags) changed(cmd *cobra.Command) bool { - return anyFlagChanged(cmd, "name", "description", "type", "aggregation-method", "group-slug", "slug") + return anyFlagChanged(cmd, append([]string{"name", "description", "type", "aggregation-method", "group-slug", "slug"}, + optionalFlags...)...) } func (f *entitlementInputFlags) build(cmd *cobra.Command) func() (sdk.EntitlementInput, error) { @@ -195,13 +262,99 @@ func (f *entitlementInputFlags) build(cmd *cobra.Command) func() (sdk.Entitlemen return sdk.EntitlementInput{}, err } - return sdk.EntitlementInput{ + input := sdk.EntitlementInput{ Name: f.name, Description: optionalString(cmd, "description", f.description), Type: optionalEnum[sdk.EntitlementType](cmd, "type", f.kind), AggregationMethod: optionalEnum[sdk.EntitlementAggregationMethod](cmd, "aggregation-method", f.aggregationMethod), GroupSlugs: f.groupSlugs, Slug: optionalString(cmd, "slug", f.slug), - }, nil + } + f.setOptional(cmd, &input) + return input, nil + } +} + +// applyTo is the update counterpart of build: the entitlement as it stands, with only the +// fields the invocation names changed. The API replaces an entitlement with what it is sent, +// resetting a field left out and refusing an update that drops a periodic entitlement's +// window, so every field the operator did not mention is sent back as it was read. Groups +// are the exception: left out, they stay as they are. +func (f *entitlementInputFlags) applyTo(cmd *cobra.Command, current sdk.Entitlement) sdk.EntitlementInput { + input := sdk.EntitlementInput{ + Name: current.Name, + Description: current.Description, + Type: current.Type, + AggregationMethod: current.AggregationMethod, + ResetPeriod: current.ResetPeriod, + ResetAnchor: current.ResetAnchor, + WarningThresholdPercent: current.WarningThresholdPercent, + Icon: current.Icon, + UnitSingular: current.UnitSingular, + UnitPlural: current.UnitPlural, + SaleUnitSingular: current.SaleUnitSingular, + SaleUnitPlural: current.SaleUnitPlural, + SaleUnitFactor: current.SaleUnitFactor, + UserFacing: current.UserFacing, + DisplayOrder: current.DisplayOrder, + } + + if flagChanged(cmd, "name") { + input.Name = f.name + } + if flagChanged(cmd, "description") { + input.Description = &f.description + } + if flagChanged(cmd, "type") { + input.Type = optionalEnum[sdk.EntitlementType](cmd, "type", f.kind) + } + if flagChanged(cmd, "aggregation-method") { + input.AggregationMethod = optionalEnum[sdk.EntitlementAggregationMethod](cmd, "aggregation-method", f.aggregationMethod) + } + if flagChanged(cmd, "group-slug") { + input.GroupSlugs = f.groupSlugs + } + f.setOptional(cmd, &input) + + return input +} + +// setOptional writes onto input the optional fields the invocation names, for create and +// update alike. An empty label or icon removes it: the field is left out of the request, +// which is how the API is told it has no value. +func (f *entitlementInputFlags) setOptional(cmd *cobra.Command, input *sdk.EntitlementInput) { + label := func(flag, value string, field **string) { + if !flagChanged(cmd, flag) { + return + } + if value == "" { + *field = nil + return + } + *field = &value + } + label("icon", f.icon, &input.Icon) + label("unit-singular", f.unitSingular, &input.UnitSingular) + label("unit-plural", f.unitPlural, &input.UnitPlural) + label("sale-unit-singular", f.saleUnitSingular, &input.SaleUnitSingular) + label("sale-unit-plural", f.saleUnitPlural, &input.SaleUnitPlural) + + if flagChanged(cmd, "reset-period") { + input.ResetPeriod = optionalEnum[sdk.EntitlementResetPeriod](cmd, "reset-period", f.resetPeriod) + } + if flagChanged(cmd, "reset-anchor") { + input.ResetAnchor = optionalEnum[sdk.EntitlementResetAnchor](cmd, "reset-anchor", f.resetAnchor) + } + if flagChanged(cmd, "warning-threshold-percent") { + input.WarningThresholdPercent = &f.warningThresholdPercent + } + if flagChanged(cmd, "sale-unit-factor") { + input.SaleUnitFactor = &f.saleUnitFactor + } + if flagChanged(cmd, "user-facing") { + input.UserFacing = &f.userFacing + } + if flagChanged(cmd, "display-order") { + input.DisplayOrder = &f.displayOrder } } diff --git a/internal/cmd/entitlements_test.go b/internal/cmd/entitlements_test.go new file mode 100644 index 0000000..932b2c3 --- /dev/null +++ b/internal/cmd/entitlements_test.go @@ -0,0 +1,213 @@ +package cmd + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "reflect" + "sync" + "testing" +) + +// periodicEntitlementBody is monthly-orders as the API reads it back: a NUMBER entitlement +// with a monthly window, a warning threshold, its presentation and a sale unit -- every field +// an update built from scratch would reset, or, for the window, be refused for omitting. +const periodicEntitlementBody = `{ + "id": "ent_1", "name": "Monthly Orders", "slug": "monthly-orders", + "description": "Orders a restaurant can process per month.", + "type": "NUMBER", "aggregationMethod": "SUM", + "resetPeriod": "MONTH", "resetAnchor": "CALENDAR", "warningThresholdPercent": 80, + "icon": "lucide:shopping-cart", "unitSingular": "order", "unitPlural": "orders", + "saleUnitSingular": "pack", "saleUnitPlural": "packs", "saleUnitFactor": 10, + "userFacing": true, "displayOrder": 2, + "entitlementGroups": [{"id": "grp_1", "name": "Restaurant Operations", "slug": "restaurant-operations"}] +}` + +// TestEntitlementsUpdateKeepsWhatItDoesNotChange pins the read-modify-write behind inline +// update flags. The API replaces an entitlement with what it is sent, so an update that +// named only --description used to reset the threshold, the presentation and the sale unit, +// and to be refused outright on a periodic entitlement, whose window it did not send back. +// The command now reads the entitlement first: the body it sends is what it read, with the +// named fields changed, and no groupSlugs -- left out, the groups stay as they are. +func TestEntitlementsUpdateKeepsWhatItDoesNotChange(t *testing.T) { + api := givenRoutes(t, map[string]route{ + "GET /entitlements/monthly-orders": {status: http.StatusOK, body: periodicEntitlementBody}, + "PUT /entitlements/monthly-orders": {status: http.StatusNoContent}, + }) + givenIsolatedConfig(t) + + runSuccessfully(t, "--base-url", api.URL, "entitlements", "update", "monthly-orders", + "--description", "Orders a restaurant can take per month.", "--warning-threshold-percent", "90") + + expectCalls(t, api.calls(), "GET /entitlements/monthly-orders", "PUT /entitlements/monthly-orders") + expectBody(t, api.calls()[1].body, map[string]any{ + "name": "Monthly Orders", + "description": "Orders a restaurant can take per month.", + "type": "NUMBER", + "aggregationMethod": "SUM", + "resetPeriod": "MONTH", + "resetAnchor": "CALENDAR", + "warningThresholdPercent": float64(90), + "icon": "lucide:shopping-cart", + "unitSingular": "order", + "unitPlural": "orders", + "saleUnitSingular": "pack", + "saleUnitPlural": "packs", + "saleUnitFactor": float64(10), + "userFacing": true, + "displayOrder": float64(2), + }) +} + +// TestEntitlementsUpdateRemovesALabelGivenEmpty pins how a label is removed inline: an empty +// value leaves the field out of the request, which the API reads as no value. +func TestEntitlementsUpdateRemovesALabelGivenEmpty(t *testing.T) { + api := givenRoutes(t, map[string]route{ + "GET /entitlements/monthly-orders": {status: http.StatusOK, body: periodicEntitlementBody}, + "PUT /entitlements/monthly-orders": {status: http.StatusNoContent}, + }) + givenIsolatedConfig(t) + + runSuccessfully(t, "--base-url", api.URL, "entitlements", "update", "monthly-orders", "--icon", "") + + body := api.calls()[1].body + if _, ok := body["icon"]; ok { + t.Fatalf("request body = %v, want no icon", body) + } + if body["unitPlural"] != "orders" { + t.Fatalf("request body = %v, want the unit labels kept", body) + } +} + +// TestEntitlementsUpdateSendsAPayloadAsWritten keeps --payload a full replacement: it is the +// body the operator wrote, sent without reading the entitlement first. +func TestEntitlementsUpdateSendsAPayloadAsWritten(t *testing.T) { + api := givenRoutes(t, map[string]route{ + "PUT /entitlements/menu-items": {status: http.StatusNoContent}, + }) + givenIsolatedConfig(t) + + runSuccessfully(t, "--base-url", api.URL, "entitlements", "update", "menu-items", + "--payload", `{"name": "Menu Items", "warningThresholdPercent": 80}`) + + expectCalls(t, api.calls(), "PUT /entitlements/menu-items") + expectBody(t, api.calls()[0].body, map[string]any{ + "name": "Menu Items", + "description": nil, + "warningThresholdPercent": float64(80), + }) +} + +// TestEntitlementsCreateSendsTheWindowThresholdAndPresentation pins the inline flags of the +// fields the API accepts on create beyond the name, type and aggregation. +func TestEntitlementsCreateSendsTheWindowThresholdAndPresentation(t *testing.T) { + api := givenRoutes(t, map[string]route{ + "POST /entitlements": {status: http.StatusCreated, body: periodicEntitlementBody}, + }) + givenIsolatedConfig(t) + + runSuccessfully(t, "--base-url", api.URL, "entitlements", "create", + "--name", "Monthly Orders", "--slug", "monthly-orders", "--type", "NUMBER", "--aggregation-method", "SUM", + "--reset-period", "MONTH", "--reset-anchor", "CALENDAR", "--warning-threshold-percent", "80", + "--icon", "lucide:shopping-cart", "--unit-singular", "order", "--unit-plural", "orders", + "--user-facing", "--display-order", "2") + + expectBody(t, api.calls()[0].body, map[string]any{ + "name": "Monthly Orders", + "description": nil, + "slug": "monthly-orders", + "type": "NUMBER", + "aggregationMethod": "SUM", + "resetPeriod": "MONTH", + "resetAnchor": "CALENDAR", + "warningThresholdPercent": float64(80), + "icon": "lucide:shopping-cart", + "unitSingular": "order", + "unitPlural": "orders", + "userFacing": true, + "displayOrder": float64(2), + }) +} + +// route is one canned answer of the fake API. +type route struct { + status int + body string +} + +// routedAPI is a fake API that answers each "METHOD path" with its route and records every +// call, in order, with its decoded body. +type routedAPI struct { + *httptest.Server + + mu sync.Mutex + recorded []recordedCall +} + +type recordedCall struct { + call string + body map[string]any +} + +func (a *routedAPI) calls() []recordedCall { + a.mu.Lock() + defer a.mu.Unlock() + return append([]recordedCall(nil), a.recorded...) +} + +func givenRoutes(t *testing.T, routes map[string]route) *routedAPI { + t.Helper() + + api := &routedAPI{} + api.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + call := r.Method + " " + r.URL.Path + raw, _ := io.ReadAll(r.Body) + var body map[string]any + if len(raw) > 0 { + if err := json.Unmarshal(raw, &body); err != nil { + t.Errorf("%s: undecodable body %q: %v", call, raw, err) + } + } + api.mu.Lock() + api.recorded = append(api.recorded, recordedCall{call: call, body: body}) + api.mu.Unlock() + + answer, ok := routes[call] + if !ok { + t.Errorf("unexpected request %s", call) + w.WriteHeader(http.StatusInternalServerError) + return + } + if answer.body == "" { + w.WriteHeader(answer.status) + return + } + writeTestJSON(w, answer.status, answer.body) + })) + t.Cleanup(api.Close) + + return api +} + +func expectCalls(t *testing.T, got []recordedCall, want ...string) { + t.Helper() + + names := make([]string, len(got)) + for i, call := range got { + names[i] = call.call + } + if !reflect.DeepEqual(names, want) { + t.Fatalf("calls = %v, want %v", names, want) + } +} + +func expectBody(t *testing.T, got, want map[string]any) { + t.Helper() + + if !reflect.DeepEqual(got, want) { + gotJSON, _ := json.Marshal(got) + wantJSON, _ := json.Marshal(want) + t.Fatalf("request body = %s, want %s", gotJSON, wantJSON) + } +} diff --git a/internal/cmd/licenses.go b/internal/cmd/licenses.go index 01cf5a5..c0003ef 100644 --- a/internal/cmd/licenses.go +++ b/internal/cmd/licenses.go @@ -238,6 +238,7 @@ func newLicensesEntitlementsAssociateCommand() *cobra.Command { "--number, --boolean, --object-file or --object-payload carries the value.", Args: cobra.ExactArgs(2), Example: ` kaiten licenses entitlements associate enterprise seats --number 50 + kaiten licenses entitlements associate enterprise seats --number 50 --overage-percent 20 kaiten licenses entitlements associate enterprise sso --boolean=true kaiten licenses entitlements associate enterprise limits --object-payload '{"max":10}'`, RunE: func(cmd *cobra.Command, args []string) error { @@ -251,7 +252,7 @@ func newLicensesEntitlementsAssociateCommand() *cobra.Command { } ctx, cancel := commandContext(cmd) defer cancel() - if err := client.Licenses.AssociateEntitlement(ctx, args[0], args[1], value); err != nil { + if err := client.Licenses.AssociateEntitlement(ctx, args[0], args[1], value, inline.options(cmd)...); err != nil { return err } return writeMessage(cmd, "License entitlement associated") @@ -266,7 +267,13 @@ func newLicensesEntitlementsUpdateCommand() *cobra.Command { cmd := &cobra.Command{ Use: "update ", Short: "Update an entitlement value on a license", - Args: cobra.ExactArgs(2), + Long: "Update an entitlement value on a license. The API replaces the grant: without an\n" + + "allowance it derives one from the value, none for a finite one. So unless\n" + + "--overage-percent is given, the command reads the grant first and keeps its allowance\n" + + "when the new value is a finite number.", + Args: cobra.ExactArgs(2), + Example: ` kaiten licenses entitlements update premium-v2 monthly-orders --number 1500 + kaiten licenses entitlements update premium-v2 monthly-orders --number 1500 --overage-percent 0`, RunE: func(cmd *cobra.Command, args []string) error { value, err := inline.resolve(cmd) if err != nil { @@ -278,7 +285,17 @@ func newLicensesEntitlementsUpdateCommand() *cobra.Command { } ctx, cancel := commandContext(cmd) defer cancel() - if err := client.Licenses.UpdateEntitlement(ctx, args[0], args[1], value); err != nil { + options := inline.options(cmd) + if options == nil { + current, err := client.Licenses.GetEntitlement(ctx, args[0], args[1]) + if err != nil { + return err + } + if percent, ok := keptOveragePercent(current, value); ok { + options = append(options, sdk.WithOveragePercent(percent)) + } + } + if err := client.Licenses.UpdateEntitlement(ctx, args[0], args[1], value, options...); err != nil { return err } return writeMessage(cmd, "License entitlement updated") diff --git a/internal/cmd/licenses_test.go b/internal/cmd/licenses_test.go new file mode 100644 index 0000000..54f12f1 --- /dev/null +++ b/internal/cmd/licenses_test.go @@ -0,0 +1,123 @@ +package cmd + +import ( + "net/http" + "testing" +) + +// TestLicensesEntitlementsUpdateKeepsTheAllowance pins what an update does with a grant's +// allowance. The API replaces the grant and derives an omitted allowance from the value -- +// none, for a finite one -- so an update that changed only the value used to turn a soft +// limit into a hard one. Without --overage-percent the command now reads the grant first +// and keeps its allowance when it still fits the new value; with it, it sends the one given +// and reads nothing. +func TestLicensesEntitlementsUpdateKeepsTheAllowance(t *testing.T) { + const softGrant = `{"entitlementSlug": "monthly-orders", "value": {"type": "number", "value": 1000}, "limitCapExceededOveragePercent": 20}` + const unlimitedGrant = `{"entitlementSlug": "locations", "value": {"type": "number", "value": -1}, "limitCapExceededOveragePercent": -1}` + const booleanGrant = `{"entitlementSlug": "delivery-tracking", "value": {"type": "boolean", "value": true}}` + + tests := []struct { + name string + current string + args []string + wantCalls []string + want map[string]any + }{ + { + name: "a new finite value keeps the soft limit", + current: softGrant, + args: []string{"--number", "1500"}, + wantCalls: []string{"GET", "PUT"}, + want: map[string]any{"value": numberGrant(1500), "limitCapExceededOveragePercent": float64(20)}, + }, + { + name: "--overage-percent is sent as given, without reading the grant", + current: softGrant, + args: []string{"--number", "1500", "--overage-percent", "0"}, + wantCalls: []string{"PUT"}, + want: map[string]any{"value": numberGrant(1500), "limitCapExceededOveragePercent": float64(0)}, + }, + { + name: "an unlimited value leaves the allowance to the API", + current: softGrant, + args: []string{"--number", "-1"}, + wantCalls: []string{"GET", "PUT"}, + want: map[string]any{"value": numberGrant(-1)}, + }, + { + name: "an unlimited grant has no allowance to keep", + current: unlimitedGrant, + args: []string{"--number", "5"}, + wantCalls: []string{"GET", "PUT"}, + want: map[string]any{"value": numberGrant(5)}, + }, + { + name: "a boolean grant has none either", + current: booleanGrant, + args: []string{"--boolean=false"}, + wantCalls: []string{"GET", "PUT"}, + want: map[string]any{"value": map[string]any{"type": "boolean", "value": false}}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + const path = "/licenses/premium-v2/entitlements/monthly-orders" + api := givenRoutes(t, map[string]route{ + "GET " + path: {status: http.StatusOK, body: test.current}, + "PUT " + path: {status: http.StatusNoContent}, + }) + givenIsolatedConfig(t) + + args := append([]string{"--base-url", api.URL, "licenses", "entitlements", "update", "premium-v2", "monthly-orders"}, test.args...) + runSuccessfully(t, args...) + + calls := api.calls() + wantCalls := make([]string, len(test.wantCalls)) + for i, method := range test.wantCalls { + wantCalls[i] = method + " " + path + } + expectCalls(t, calls, wantCalls...) + expectBody(t, calls[len(calls)-1].body, test.want) + }) + } +} + +// TestLicensesEntitlementsAssociateSendsTheAllowance pins --overage-percent on a new grant: +// sent when given, absent otherwise, so the API derives it from the value as before. +func TestLicensesEntitlementsAssociateSendsTheAllowance(t *testing.T) { + tests := []struct { + name string + args []string + want map[string]any + }{ + { + name: "with an allowance", + args: []string{"--number", "100", "--overage-percent", "10"}, + want: map[string]any{"entitlementSlug": "monthly-orders", "value": numberGrant(100), "limitCapExceededOveragePercent": float64(10)}, + }, + { + name: "without one", + args: []string{"--number", "100"}, + want: map[string]any{"entitlementSlug": "monthly-orders", "value": numberGrant(100)}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + api := givenRoutes(t, map[string]route{ + "POST /licenses/standard/entitlements": {status: http.StatusNoContent}, + }) + givenIsolatedConfig(t) + + args := append([]string{"--base-url", api.URL, "licenses", "entitlements", "associate", "standard", "monthly-orders"}, test.args...) + runSuccessfully(t, args...) + + expectBody(t, api.calls()[0].body, test.want) + }) + } +} + +func numberGrant(value float64) map[string]any { + return map[string]any{"type": "number", "value": value} +} diff --git a/internal/cmd/value_flags.go b/internal/cmd/value_flags.go index de71f9c..3c61267 100644 --- a/internal/cmd/value_flags.go +++ b/internal/cmd/value_flags.go @@ -1,6 +1,8 @@ package cmd import ( + "encoding/json" + "github.com/kaitencloud/cli/internal/input" "github.com/kaitencloud/sdk-go" "github.com/spf13/cobra" @@ -10,12 +12,13 @@ import ( // exclusive shapes the CLI accepts for it: a whole document from a file or an inline // payload, a bare number or boolean, or an object from a file or an inline payload. type licenseValueFlags struct { - file string - payload string - number float64 - boolean bool - objectFile string - objectValue string + file string + payload string + number float64 + boolean bool + objectFile string + objectValue string + overagePercent int32 } func (f *licenseValueFlags) register(cmd *cobra.Command) { @@ -25,6 +28,55 @@ func (f *licenseValueFlags) register(cmd *cobra.Command) { cmd.Flags().BoolVar(&f.boolean, "boolean", false, "Boolean entitlement value") cmd.Flags().StringVar(&f.objectFile, "object-file", "", "JSON or YAML object entitlement value file") cmd.Flags().StringVar(&f.objectValue, "object-payload", "", "Inline JSON or YAML object entitlement value") + cmd.Flags().Int32Var(&f.overagePercent, "overage-percent", 0, + "How far usage may exceed a numeric value before reports are refused: 0 is a hard limit, "+ + "20 accepts up to 20% more, -1 goes with the unlimited value -1") +} + +// options returns the grant options the invocation set: the allowance, when --overage-percent +// was given, and nothing otherwise. +func (f *licenseValueFlags) options(cmd *cobra.Command) []sdk.LicenseEntitlementOption { + if !flagChanged(cmd, "overage-percent") { + return nil + } + return []sdk.LicenseEntitlementOption{sdk.WithOveragePercent(f.overagePercent)} +} + +// keptOveragePercent is the allowance an update carries over from the grant it replaces. The +// API derives an omitted allowance from the value -- none, for a finite one -- so an update +// that only changes the value would turn a soft limit into a hard one. The current allowance +// is kept when it still fits the new value: a finite number. An unlimited value takes -1, +// which the API derives itself, and a grant that was unlimited has no allowance to keep. +func keptOveragePercent(current sdk.LicenseEntitlement, value sdk.LicenseEntitlementValue) (int32, bool) { + percent := current.LimitCapExceededOveragePercent + if percent == nil || *percent < 0 { + return 0, false + } + number, ok := numberValue(value) + if !ok || number == unlimitedValue { + return 0, false + } + return *percent, true +} + +// unlimitedValue is the numeric grant value the API reads as "no limit". +const unlimitedValue = -1 + +// numberValue returns the number a grant value holds, and false for a boolean or an object. +func numberValue(value sdk.LicenseEntitlementValue) (float64, bool) { + raw, err := json.Marshal(value) + if err != nil { + return 0, false + } + var typed struct { + Type string `json:"type"` + Value any `json:"value"` + } + if err := json.Unmarshal(raw, &typed); err != nil || typed.Type != "number" { + return 0, false + } + number, ok := typed.Value.(float64) + return number, ok } // resolve turns the single value flag the invocation used into an entitlement value.