Files
member-console/internal/entitlements/rule_change_batch_test.go
T
cgalo5758 f8a3478f2a Rebuild the entitlement set Rules surface as a staged batch
The Rules section is one record table grouped by kind, Limit then
Boolean, on fixed columns, edited in place: Edit opens a row's controls
in their columns, Add rule opens a dense row above the table, and every
change is staged into a tray that lists the deltas with Undo and applies
them as one rule-change act. The reduction policy is a column of the
rule beside its limit. History shows counts only. Group rows are a quiet
heading rather than a divider, the maintainer's pick from four rounds of
outside-model ideation.

Dense rows align to the top and render each error under its control in
every form family (design D16), replacing the below-row error block; the
forms library gains the batch form (rows plus one tray) and the RowField
dense and label-hidden options. Migration 00019 records the governing
reduction policy on effect rows.

Archive staged-rule-changes with its spec updates (entitlement-set-
management, entitlement-set-history, entitlements, form-library,
form-conventions, ui-quality-gate). Screens accepted 2026-09-19.
2026-09-19 19:46:09 -05:00

653 lines
25 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package entitlements_test
// A staged batch applied as one commit (entitlement-set-management, "A rule
// change commit reaches every carrying pool"): one act per rule, one
// recomputation per pool, each key's effect under the act that changed it and
// a movement on a key no delta names under the batch's first act, on both the
// atomic and the deferred path. The batch dry run is checked against the same
// fixture, so the two answer one question.
import (
"context"
"database/sql"
"strings"
"testing"
"github.com/google/uuid"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
)
// batchFixture is a pool funded by two sets. The batch's set carries all three
// keys; the other set funds A and C too, so A's governing policy is not the
// changed rule's own and C stacks across both sets.
type batchFixture struct {
keyA, keyB, keyC string
other testProduct
batched testProduct
ruleA, ruleB string
orgs []testOrg
}
// newBatchFixture seeds pools pools carrying both sets and returns the keys
// and the rules the batch edits. Every pool starts at A = 3, B = 3, C = 9.
func newBatchFixture(t *testing.T, ctx context.Context, database *sql.DB, pools int) batchFixture {
t.Helper()
tx, err := entitlements.BeginRuleChange(ctx, database)
if err != nil {
t.Fatal(err)
}
q := entitlements.New(tx)
suffix := uuid.New().String()[:8]
fx := batchFixture{
keyA: "alpha.a-" + suffix,
keyB: "alpha.b-" + suffix,
keyC: "alpha.c-" + suffix,
}
for _, key := range []string{fx.keyA, fx.keyB, fx.keyC} {
if _, err := tx.ExecContext(ctx,
`INSERT INTO core.resource_keys (resource_key, display_name, unit) VALUES ($1, $1, 'items')`, key,
); err != nil {
t.Fatalf("create resource key %s: %v", key, err)
}
}
fx.other = createRuledProduct(t, ctx, tx, "Other "+suffix, []entitlements.RuleFields{
limitRule(fx.keyA, 2, "force_reduce"),
limitRule(fx.keyC, 5, "clamp"),
})
fx.batched = createRuledProduct(t, ctx, tx, "Batched "+suffix, []entitlements.RuleFields{
limitRule(fx.keyA, 1, "clamp"),
limitRule(fx.keyB, 3, "defer"),
limitRule(fx.keyC, 4, "clamp"),
})
fx.ruleA = ruleForKey(t, ctx, q, fx.batched.setID, fx.keyA)
fx.ruleB = ruleForKey(t, ctx, q, fx.batched.setID, fx.keyB)
for i := 0; i < pools; i++ {
org := setupTestOrg(t, ctx, tx)
createGrantAndProvision(t, ctx, tx, q, org, fx.other.productID, 1)
createGrantAndProvision(t, ctx, tx, q, org, fx.batched.productID, 1)
// C's stored limit is put out of step with its rules, so the batch's
// one recomputation moves a key no delta names.
if _, err := tx.ExecContext(ctx,
`UPDATE core.numeric_entitlements SET resource_limit = 99 WHERE pool_id = $1 AND resource_key = $2`,
org.pool.PoolID, fx.keyC,
); err != nil {
t.Fatalf("make key C stale: %v", err)
}
fx.orgs = append(fx.orgs, org)
}
if err := tx.Commit(); err != nil {
t.Fatalf("commit fixture: %v", err)
}
return fx
}
// edits is the batch both paths apply: A to 6 on the pool, B to 1.
func (fx batchFixture) edits(syncCap int32) []entitlements.RuleChangeInput {
return []entitlements.RuleChangeInput{
{
SetID: fx.batched.setID,
ChangeKind: entitlements.ChangeKindRuleModified,
RuleID: fx.ruleB,
Rule: entitlements.RuleFields{ResourceValue: sql.NullInt64{Int64: 1, Valid: true}},
ActorType: entitlements.ActorTypeSystem,
SyncCap: syncCap,
},
{
SetID: fx.batched.setID,
ChangeKind: entitlements.ChangeKindRuleModified,
RuleID: fx.ruleA,
Rule: entitlements.RuleFields{ResourceValue: sql.NullInt64{Int64: 4, Valid: true}},
ActorType: entitlements.ActorTypeSystem,
SyncCap: syncCap,
},
}
}
func limitRule(resourceKey string, value int64, policy string) entitlements.RuleFields {
return entitlements.RuleFields{
RuleType: "limit",
ResourceKey: sql.NullString{String: resourceKey, Valid: true},
ResourceValue: sql.NullInt64{Int64: value, Valid: true},
ResourcePerUnit: sql.NullBool{Bool: false, Valid: true},
StackingPolicy: sql.NullString{String: "additive", Valid: true},
TierReductionPolicy: policy,
}
}
// createRuledProduct is createTestProduct with the caller's rules instead of
// the one fedwiki_sites rule, so a fixture can state each rule's key and
// reduction policy.
func createRuledProduct(t *testing.T, ctx context.Context, tx *sql.Tx, name string, rules []entitlements.RuleFields) testProduct {
t.Helper()
q := entitlements.New(tx)
set, err := q.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{Name: name + " Set", IsActive: true})
if err != nil {
t.Fatalf("create entitlement set for %s: %v", name, err)
}
for _, rule := range rules {
addSetRule(t, ctx, q, set.SetID, rule)
}
var productID string
if err := tx.QueryRowContext(ctx,
`INSERT INTO core.products (name, is_active, is_public, lifecycle_status, entitlement_set_id)
VALUES ($1, TRUE, TRUE, 'published', $2) RETURNING product_id`, name, set.SetID,
).Scan(&productID); err != nil {
t.Fatalf("create product %s: %v", name, err)
}
return testProduct{productID: productID, setID: set.SetID}
}
func ruleForKey(t *testing.T, ctx context.Context, q *entitlements.Queries, setID, resourceKey string) string {
t.Helper()
rules, err := q.GetActiveRulesBySetID(ctx, setID)
if err != nil {
t.Fatalf("active rules of set %s: %v", setID, err)
}
for _, rule := range rules {
if rule.ResourceKey.String == resourceKey {
return rule.RuleID
}
}
t.Fatalf("set %s has no active rule for %s", setID, resourceKey)
return ""
}
func poolLimitFor(t *testing.T, ctx context.Context, database *sql.DB, poolID, resourceKey string) int64 {
t.Helper()
var limit int64
if err := database.QueryRowContext(ctx,
`SELECT resource_limit FROM core.numeric_entitlements WHERE pool_id = $1 AND resource_key = $2`,
poolID, resourceKey,
).Scan(&limit); err != nil {
t.Fatalf("read limit of pool %s for %s: %v", poolID, resourceKey, err)
}
return limit
}
// assertEffect checks one effect row's movement and its two policies: the
// policy the changed rule held and the one governing the pool and key.
func assertEffect(t *testing.T, where string, effect effectRow, before, after int64, rulePolicy, governing string) {
t.Helper()
if effect.limitBefore.Int64 != before || effect.limitAfter.Int64 != after {
t.Errorf("%s: limit %d to %d, want %d to %d", where, effect.limitBefore.Int64, effect.limitAfter.Int64, before, after)
}
if effect.reductionPolicy.String != rulePolicy {
t.Errorf("%s: reduction_policy %q, want %q", where, effect.reductionPolicy.String, rulePolicy)
}
if effect.governingPolicy.String != governing {
t.Errorf("%s: governing_policy %q, want %q", where, effect.governingPolicy.String, governing)
}
}
// Scenario: a batch of two deltas, on keys A and B, applied below the cap to a
// pool that also moves on key C. Each pool is recomputed once, A's effect
// hangs off A's act, B's off B's act and C's off the batch's first act, no
// effect is written twice, and every effect carries the governing policy,
// which on A is the other set's force_reduce rather than the changed rule's
// own clamp. The batch dry run of the same inputs said the same numbers.
func TestRuleChangeBatchCommitRoutesEffectsByKey(t *testing.T) {
database := testDB(t)
ctx := context.Background()
fx := newBatchFixture(t, ctx, database, 1)
pool := fx.orgs[0].pool.PoolID
in := fx.edits(0)
preview, err := entitlements.RuleChangeBatchPreview(ctx, entitlements.New(database), in)
if err != nil {
t.Fatalf("batch preview: %v", err)
}
if !preview.Exact || preview.PoolCount != 1 {
t.Fatalf("preview: exact=%v over %d pools, want exact over 1", preview.Exact, preview.PoolCount)
}
if len(preview.Keys) != 2 || preview.Keys[0].ResourceKey != fx.keyA {
t.Fatalf("preview keys = %v, want A first", previewKeys(preview))
}
previewA, previewB := preview.ForKey(fx.keyA), preview.ForKey(fx.keyB)
if previewA.Buckets.Up.Count != 1 || previewA.Buckets.Up.Rows[0].LimitAfter != 6 {
t.Errorf("preview of A = %+v, want one pool up to 6", previewA.Buckets.Up)
}
if previewB.Buckets.Down.Count != 1 || previewB.Buckets.Down.Rows[0].LimitAfter != 1 {
t.Errorf("preview of B = %+v, want one pool down to 1", previewB.Buckets.Down)
}
if previewA.Buckets.AlsoOther.Count != 1 {
t.Fatalf("preview of A named %d pools moving on another key, want 1", previewA.Buckets.AlsoOther.Count)
}
if others := previewA.Buckets.AlsoOther.Rows[0].OtherKeys; len(others) != 1 || others[0].ResourceKey != fx.keyC {
t.Errorf("the other keys moving = %+v, want C alone", others)
}
result, err := entitlements.RuleChangeBatchCommit(ctx, database, in)
if err != nil {
t.Fatalf("batch commit: %v", err)
}
if result.SyncPath != "atomic" || result.RulesChanged != 2 || len(result.ChangeIDs) != 2 {
t.Fatalf("result = %s path, %d rules, %d acts, want atomic / 2 / 2",
result.SyncPath, result.RulesChanged, len(result.ChangeIDs))
}
if result.Recomputed != 1 || result.Changed != 1 {
t.Errorf("result = %d recomputed / %d changed, want 1 / 1", result.Recomputed, result.Changed)
}
if want := "2 rules changed. 1 pool recomputed, 1 changed."; result.Summary() != want {
t.Errorf("summary = %q, want %q", result.Summary(), want)
}
for key, want := range map[string]int64{fx.keyA: 6, fx.keyB: 1, fx.keyC: 9} {
if got := poolLimitFor(t, ctx, database, pool, key); got != want {
t.Errorf("committed %s at %d, want %d", key, got, want)
}
}
// The acts are filed in resource-key order, so the first is A's.
actA, actB := result.ChangeIDs[0], result.ChangeIDs[1]
effectsA, effectsB := readEffects(t, ctx, database, actA), readEffects(t, ctx, database, actB)
if len(effectsA[pool]) != 2 {
t.Errorf("A's act wrote %d effects for the pool, want A and C", len(effectsA[pool]))
}
if len(effectsB[pool]) != 1 {
t.Errorf("B's act wrote %d effects for the pool, want B alone", len(effectsB[pool]))
}
assertEffect(t, "A under A's act", effectsA[pool][fx.keyA], 3, 6, "clamp", "force_reduce")
assertEffect(t, "C under A's act", effectsA[pool][fx.keyC], 99, 9, "", "clamp")
assertEffect(t, "B under B's act", effectsB[pool][fx.keyB], 3, 1, "defer", "defer")
if _, written := effectsB[pool][fx.keyC]; written {
t.Errorf("C's effect was written under both acts of the batch")
}
// Both obligations for the pool were settled from the one recomputation.
var pending int
if err := database.QueryRowContext(ctx,
`SELECT COUNT(*) FROM core.entitlement_set_change_obligations
WHERE change_id = ANY($1::uuid[]) AND status <> 'settled'`,
uuidArray(result.ChangeIDs),
).Scan(&pending); err != nil {
t.Fatalf("count obligations: %v", err)
}
if pending != 0 {
t.Errorf("unsettled obligations = %d, want 0", pending)
}
// The acts of one batch are related by one request identifier and by
// nothing else; the ledger keeps one row per rule.
var requests int
if err := database.QueryRowContext(ctx,
`SELECT COUNT(DISTINCT request_id) FROM core.entitlement_set_changes WHERE change_id = ANY($1::uuid[])`,
uuidArray(result.ChangeIDs),
).Scan(&requests); err != nil {
t.Fatalf("count request ids: %v", err)
}
if requests != 1 {
t.Errorf("the batch's acts carry %d request ids, want 1", requests)
}
}
func previewKeys(preview *entitlements.BatchPreviewResult) []string {
keys := make([]string, 0, len(preview.Keys))
for _, result := range preview.Keys {
keys = append(keys, result.ResourceKey)
}
return keys
}
// uuidArray renders change ids for a Postgres uuid[] parameter.
func uuidArray(ids []string) string {
out := "{"
for i, id := range ids {
if i > 0 {
out += ","
}
out += id
}
return out + "}"
}
// Scenario: the same batch above the cap. The rules, both acts and every
// obligation commit together; the request's own drain then settles each pool
// once under both acts, with the same routing, and writes each key's effect
// exactly once.
func TestRuleChangeBatchDeferredDrainsOncePerPool(t *testing.T) {
database := testDB(t)
ctx := context.Background()
fx := newBatchFixture(t, ctx, database, 2)
result, err := entitlements.RuleChangeBatchCommit(ctx, database, fx.edits(1))
if err != nil {
t.Fatalf("batch commit: %v", err)
}
if result.SyncPath != "deferred" {
t.Fatalf("sync_path = %q, want deferred", result.SyncPath)
}
if result.Recomputed != 2 {
t.Errorf("drained %d pools, want 2: a pool owed under both acts is recomputed once", result.Recomputed)
}
if want := "2 rules changed. 2 of 2 pools recomputed."; result.Summary() != want {
t.Errorf("summary = %q, want %q", result.Summary(), want)
}
actA, actB := result.ChangeIDs[0], result.ChangeIDs[1]
effectsA, effectsB := readEffects(t, ctx, database, actA), readEffects(t, ctx, database, actB)
for _, org := range fx.orgs {
pool := org.pool.PoolID
assertEffect(t, "A under A's act", effectsA[pool][fx.keyA], 3, 6, "clamp", "force_reduce")
assertEffect(t, "C under A's act", effectsA[pool][fx.keyC], 99, 9, "", "clamp")
assertEffect(t, "B under B's act", effectsB[pool][fx.keyB], 3, 1, "defer", "defer")
if len(effectsA[pool]) != 2 || len(effectsB[pool]) != 1 {
t.Errorf("pool %s: %d effects under A and %d under B, want 2 and 1", pool, len(effectsA[pool]), len(effectsB[pool]))
}
// One recomputation writes every effect of one pool in one
// transaction, so they share the transaction's clock; a second
// recomputation per act would stamp two moments.
if effectsA[pool][fx.keyA].createdAt != effectsB[pool][fx.keyB].createdAt {
t.Errorf("pool %s was settled in two transactions: %s and %s",
pool, effectsA[pool][fx.keyA].createdAt, effectsB[pool][fx.keyB].createdAt)
}
}
var pending int
if err := database.QueryRowContext(ctx,
`SELECT COUNT(*) FROM core.entitlement_set_change_obligations
WHERE change_id = ANY($1::uuid[]) AND status <> 'settled'`, uuidArray(result.ChangeIDs),
).Scan(&pending); err != nil {
t.Fatalf("count obligations: %v", err)
}
if pending != 0 {
t.Errorf("unsettled obligations = %d after the request's drain, want 0", pending)
}
}
// Scenario: the refusals of a batch. A pool that cannot be recomputed rolls
// back every delta; a delta naming a rule another operator deactivated
// refuses the whole batch on that delta's line; and applying the same batch
// twice reports Already applied. on the delta that is already there.
func TestRuleChangeBatchRefusalsRollBackEverything(t *testing.T) {
database := testDB(t)
ctx := context.Background()
fx := newBatchFixture(t, ctx, database, 2)
t.Run("a pool that cannot be recomputed", func(t *testing.T) {
in := fx.edits(0)
in[0].TestOnlyFailOnPool = 2
in[1].TestOnlyFailOnPool = 2
_, err := entitlements.RuleChangeBatchCommit(ctx, database, in)
if err == nil {
t.Fatal("a batch with an injected failure succeeded, want a refusal")
}
refusal, ok := entitlements.AsRefusal(err)
if !ok || refusal.Kind != entitlements.RefusalPoolRecompute {
t.Fatalf("error = %v, want a pool-recompute refusal", err)
}
assertBatchUnapplied(t, ctx, database, fx)
})
t.Run("a rule that is no longer active", func(t *testing.T) {
deactivateRule(t, ctx, database, fx.batched.setID, fx.ruleB)
in := fx.edits(0)
_, err := entitlements.RuleChangeBatchCommit(ctx, database, in)
refusal, ok := entitlements.AsRefusal(err)
if !ok {
t.Fatalf("error = %v, want a refusal", err)
}
if refusal.Message != "That rule is no longer active." || refusal.Kind != entitlements.RefusalInactiveRule {
t.Errorf("refusal = %q (%s), want the inactive-rule sentence", refusal.Message, refusal.Kind)
}
if refusal.DeltaIndex != 0 || refusal.ResourceKey != fx.keyB {
t.Errorf("refusal names delta %d for %q, want delta 0 for B", refusal.DeltaIndex, refusal.ResourceKey)
}
// A's rule is untouched: the batch is one transaction.
if got := currentRuleValue(t, ctx, database, fx.ruleA); got != 1 {
t.Errorf("A's rule = %d after a refused batch, want 1", got)
}
})
t.Run("already applied", func(t *testing.T) {
only := []entitlements.RuleChangeInput{fx.edits(0)[1]}
if _, err := entitlements.RuleChangeBatchCommit(ctx, database, only); err != nil {
t.Fatalf("apply A alone: %v", err)
}
_, err := entitlements.RuleChangeBatchCommit(ctx, database, only)
refusal, ok := entitlements.AsRefusal(err)
if !ok {
t.Fatalf("error = %v, want a refusal", err)
}
if refusal.Message != "Already applied." || refusal.DeltaIndex != 0 {
t.Errorf("refusal = %q on delta %d, want Already applied. on delta 0", refusal.Message, refusal.DeltaIndex)
}
})
}
// assertBatchUnapplied is the atomic promise: neither rule moved and the
// ledger holds no act for the refused batch.
func assertBatchUnapplied(t *testing.T, ctx context.Context, database *sql.DB, fx batchFixture) {
t.Helper()
if got := currentRuleValue(t, ctx, database, fx.ruleA); got != 1 {
t.Errorf("A's rule = %d after a refused batch, want 1", got)
}
if got := currentRuleValue(t, ctx, database, fx.ruleB); got != 3 {
t.Errorf("B's rule = %d after a refused batch, want 3", got)
}
var acts int
if err := database.QueryRowContext(ctx,
`SELECT COUNT(*) FROM core.entitlement_set_changes WHERE set_id = $1 AND change_kind = 'rule_modified'`,
fx.batched.setID,
).Scan(&acts); err != nil {
t.Fatalf("count acts: %v", err)
}
if acts != 0 {
t.Errorf("rule_modified acts = %d after a refused batch, want 0", acts)
}
}
func currentRuleValue(t *testing.T, ctx context.Context, database *sql.DB, ruleID string) int64 {
t.Helper()
var value int64
if err := database.QueryRowContext(ctx,
`SELECT resource_value FROM core.entitlement_set_rules WHERE rule_id = $1`, ruleID).Scan(&value); err != nil {
t.Fatalf("read rule %s: %v", ruleID, err)
}
return value
}
// deactivateRule takes a rule out from under a staged delta, the way another
// operator would, through the enclosed function that is the only writer.
func deactivateRule(t *testing.T, ctx context.Context, database *sql.DB, setID, ruleID string) {
t.Helper()
if _, err := entitlements.RuleChangeCommit(ctx, database, entitlements.RuleChangeInput{
SetID: setID,
ChangeKind: entitlements.ChangeKindRuleDeactivated,
RuleID: ruleID,
ActorType: entitlements.ActorTypeSystem,
}); err != nil {
t.Fatalf("deactivate rule %s: %v", ruleID, err)
}
}
// Scenario: the effect row's audit projection carries both policies
// (entitlement-set-history, "The change row and every effect row project onto
// the audit view shape"). The organization's trail reads the pool as the
// resource, the act's actor and occurrence through the foreign key, and a
// payload naming the rule's policy and the governing one.
func TestEffectAuditProjectionCarriesBothPolicies(t *testing.T) {
database := testDB(t)
ctx := context.Background()
fx := newBatchFixture(t, ctx, database, 1)
result, err := entitlements.RuleChangeBatchCommit(ctx, database, fx.edits(0))
if err != nil {
t.Fatalf("batch commit: %v", err)
}
rows, err := entitlements.New(database).ListEntitlementSetChangeEffectsByOrg(ctx,
entitlements.ListEntitlementSetChangeEffectsByOrgParams{
OrgID: fx.orgs[0].org.OrgID,
Limit: 50,
})
if err != nil {
t.Fatalf("project the org's effects: %v", err)
}
projected := map[string]entitlements.ListEntitlementSetChangeEffectsByOrgRow{}
for _, row := range rows {
projected[row.ResourceKey] = row
}
if len(projected) != 3 {
t.Fatalf("the org's trail names %d keys, want A, B and C", len(projected))
}
for key, want := range map[string][2]string{
fx.keyA: {"clamp", "force_reduce"},
fx.keyB: {"defer", "defer"},
fx.keyC: {"", "clamp"},
} {
row := projected[key]
if row.ReductionPolicy.String != want[0] || row.GoverningPolicy.String != want[1] {
t.Errorf("%s projects %q / %q, want %q / %q", key,
row.ReductionPolicy.String, row.GoverningPolicy.String, want[0], want[1])
}
if row.PoolID != fx.orgs[0].pool.PoolID || row.ActorType != entitlements.ActorTypeSystem {
t.Errorf("%s projects pool %s and actor %s, want the batch's pool and actor", key, row.PoolID, row.ActorType)
}
}
if len(result.ChangeIDs) != 2 {
t.Errorf("acts = %d, want 2", len(result.ChangeIDs))
}
}
// Scenario: one batch dry run agrees with the batch commit on every key and
// every pool, which is what lets the tray promise what Apply changes then
// does.
func TestRuleChangeBatchPreviewEqualsBatchCommit(t *testing.T) {
database := testDB(t)
ctx := context.Background()
fx := newBatchFixture(t, ctx, database, 2)
in := fx.edits(0)
preview, err := entitlements.RuleChangeBatchPreview(ctx, entitlements.New(database), in)
if err != nil {
t.Fatalf("batch preview: %v", err)
}
promised := map[string]map[string]int64{}
for _, result := range preview.Keys {
promised[result.ResourceKey] = map[string]int64{}
for _, bucket := range []entitlements.PreviewBucket{result.Buckets.Up, result.Buckets.Down, result.Buckets.Unchanged} {
for _, row := range bucket.Rows {
promised[result.ResourceKey][row.PoolID] = row.LimitAfter
}
}
}
result, err := entitlements.RuleChangeBatchCommit(ctx, database, in)
if err != nil {
t.Fatalf("batch commit: %v", err)
}
if result.Recomputed != 2 {
t.Fatalf("recomputed %d pools, want 2", result.Recomputed)
}
for _, key := range []string{fx.keyA, fx.keyB} {
for _, org := range fx.orgs {
want, ok := promised[key][org.pool.PoolID]
if !ok {
t.Fatalf("the dry run put pool %s in no bucket for %s", org.pool.PoolID, key)
}
if got := poolLimitFor(t, ctx, database, org.pool.PoolID, key); got != want {
t.Errorf("%s on pool %s: committed %d, the dry run said %d", key, org.pool.PoolID, got, want)
}
}
}
}
// Scenario: two independent commits arrive carrying one caller request id,
// which an inbound X-Request-ID header can make happen. The engine mints the
// identifier that relates a batch's acts, so the two acts are related by
// nothing and neither drain can settle the other's obligations from its own
// recomputation.
func TestSeparateCommitsAreNotOneBatch(t *testing.T) {
database := testDB(t)
ctx := context.Background()
fx := newBatchFixture(t, ctx, database, 1)
in := fx.edits(0)
const shared = "x-request-id-sent-by-the-caller"
in[0].RequestID, in[1].RequestID = shared, shared
first, err := entitlements.RuleChangeBatchCommit(ctx, database, []entitlements.RuleChangeInput{in[0]})
if err != nil {
t.Fatalf("first commit: %v", err)
}
second, err := entitlements.RuleChangeBatchCommit(ctx, database, []entitlements.RuleChangeInput{in[1]})
if err != nil {
t.Fatalf("second commit: %v", err)
}
ids := map[string]string{}
for _, changeID := range []string{first.ChangeID, second.ChangeID} {
var requestID sql.NullString
if err := database.QueryRowContext(ctx,
`SELECT request_id FROM core.entitlement_set_changes WHERE change_id = $1`, changeID,
).Scan(&requestID); err != nil {
t.Fatalf("read request id of %s: %v", changeID, err)
}
if requestID.String == shared {
t.Errorf("act %s stored the caller's request id", changeID)
}
ids[changeID] = requestID.String
}
if ids[first.ChangeID] == ids[second.ChangeID] {
t.Errorf("two separate commits share the request id %q, so a drain would read them as one batch", ids[first.ChangeID])
}
// Each act settled its own key alone, which is what the separation buys.
firstEffects := readEffects(t, ctx, database, first.ChangeID)
if _, crossed := firstEffects[fx.orgs[0].pool.PoolID][fx.keyA]; crossed {
t.Errorf("B's act wrote an effect for A's key")
}
}
// Scenario: two deltas on one resource key. The tray stages at most one per
// key; a second is a defect, and the engine refuses it rather than filing two
// acts whose effects would contend for one key.
func TestRuleChangeBatchRefusesTwoDeltasOnOneKey(t *testing.T) {
database := testDB(t)
ctx := context.Background()
fx := newBatchFixture(t, ctx, database, 1)
in := fx.edits(0)
_, err := entitlements.RuleChangeBatchCommit(ctx, database, []entitlements.RuleChangeInput{in[1], in[1]})
if err == nil {
t.Fatal("a batch with two deltas on one key succeeded")
}
if !strings.Contains(err.Error(), "one delta per resource key") {
t.Errorf("error = %v, want the one-delta-per-key refusal", err)
}
if got := currentRuleValue(t, ctx, database, fx.ruleA); got != 1 {
t.Errorf("A's rule = %d after the refusal, want 1", got)
}
}
// Scenario: a dry run of two overlays on one resource key. The fold answers
// per key, so a second overlay for one key has no meaning and is refused
// rather than silently applied in list order.
func TestDryRunRefusesTwoOverlaysOnOneKey(t *testing.T) {
database := testDB(t)
ctx := context.Background()
fx := newBatchFixture(t, ctx, database, 1)
overlay := entitlements.RuleOverlay{
SetID: fx.batched.setID,
RuleType: "limit",
ResourceKey: sql.NullString{String: fx.keyA, Valid: true},
ResourceValue: sql.NullInt64{Int64: 4, Valid: true},
ResourcePerUnit: sql.NullBool{Bool: false, Valid: true},
StackingPolicy: sql.NullString{String: "additive", Valid: true},
IsActive: true,
}
_, err := entitlements.DryRunPoolEntitlements(ctx, entitlements.New(database), fx.orgs[0].pool.PoolID,
[]entitlements.RuleOverlay{overlay, overlay})
if err == nil {
t.Fatal("a dry run with two overlays for one key succeeded")
}
if !strings.Contains(err.Error(), "two overlays for resource key") {
t.Errorf("error = %v, want the two-overlays refusal", err)
}
}