Skip to content
Merged
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
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions entitlements.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
128 changes: 128 additions & 0 deletions entitlements_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading
Loading