Add an append-only ledger of entitlement set rule changes with per-pool effect rows, a preview-and-commit rule change flow, and an automatic drain that settles deferred recomputations. Rules gain a tier reduction policy, resource keys declare over-limit behavior, and the materializer now lowers limits when a rule stops applying. Add entitlement set rule change ledger and preview flow Add an append-only ledger of entitlement set rule changes with a preview-and-commit operator flow. Rule writes now go through an enclosed `core.commit_rule_change` function that files an act row and one obligation per carrying pool, with a drain workflow settling deferred recomputations. The preview dry-runs the materializer with a rule overlay and renders per-pool buckets, reduction-policy disclosures, and provider over-limit consequences. Materializing transactions take a shared advisory rendezvous that rule changes hold exclusively, enforced by a possession assertion. Add History and Entitlement changes surfaces, a rule-less warning on five product-selection surfaces, and a `tier_reduction_policy` column that gates FedWiki parking.
111 lines
3.8 KiB
Go
111 lines
3.8 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package entitlements_test
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// TestTimedSyncCap is the timed run of task 11.2, which sets
|
|
// rematerializeSyncCap. It seeds a population of carrying pools in steps and
|
|
// times one atomic-path commit and one exact preview at each size, so the
|
|
// cap can be read off the size whose commit still holds the exclusive
|
|
// rendezvous for an acceptable time. It seeds hundreds of organizations and
|
|
// runs only when ENTITLEMENT_CAP_TIMING is set:
|
|
//
|
|
// ENTITLEMENT_CAP_TIMING=1 go test -run TestTimedSyncCap -v ./internal/entitlements/
|
|
func TestTimedSyncCap(t *testing.T) {
|
|
if os.Getenv("ENTITLEMENT_CAP_TIMING") == "" {
|
|
t.Skip("ENTITLEMENT_CAP_TIMING not set; this is the task 11.2 timed run, not a suite test")
|
|
}
|
|
database := testDB(t)
|
|
ctx := context.Background()
|
|
|
|
setupTx, err := entitlements.BeginRuleChange(ctx, database)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
product := createTestProduct(t, ctx, setupTx, "Cap timing "+uuid.New().String()[:8], 6, false)
|
|
rule := firstActiveRule(t, ctx, entitlements.New(setupTx), product.setID)
|
|
if err := setupTx.Commit(); err != nil {
|
|
t.Fatalf("commit product: %v", err)
|
|
}
|
|
|
|
sizes := []int{50, 100, 200, 400, 800}
|
|
seeded := 0
|
|
value := int64(6)
|
|
t.Logf("%8s %12s %12s %12s", "pools", "commit", "per pool", "preview")
|
|
for _, size := range sizes {
|
|
seedCarryingPools(t, ctx, database, product.productID, size-seeded)
|
|
seeded = size
|
|
|
|
previewStart := time.Now()
|
|
preview, err := entitlements.RuleChangePreviewDB(ctx, database, entitlements.RuleChangeInput{
|
|
SetID: product.setID,
|
|
ChangeKind: entitlements.ChangeKindRuleModified,
|
|
RuleID: rule.RuleID,
|
|
Rule: entitlements.RuleFields{ResourceValue: sql.NullInt64{Int64: value + 1, Valid: true}},
|
|
ActorType: entitlements.ActorTypeSystem,
|
|
SyncCap: int32(size),
|
|
})
|
|
previewTook := time.Since(previewStart)
|
|
if err != nil {
|
|
t.Fatalf("preview at %d pools: %v", size, err)
|
|
}
|
|
if !preview.Exact || preview.PoolCount != int64(size) {
|
|
t.Fatalf("preview at %d pools: exact=%v pool_count=%d", size, preview.Exact, preview.PoolCount)
|
|
}
|
|
|
|
value++
|
|
commitStart := time.Now()
|
|
result, err := entitlements.RuleChangeCommit(ctx, database, entitlements.RuleChangeInput{
|
|
SetID: product.setID,
|
|
ChangeKind: entitlements.ChangeKindRuleModified,
|
|
RuleID: rule.RuleID,
|
|
Rule: entitlements.RuleFields{ResourceValue: sql.NullInt64{Int64: value, Valid: true}},
|
|
ActorType: entitlements.ActorTypeSystem,
|
|
SyncCap: int32(size),
|
|
})
|
|
commitTook := time.Since(commitStart)
|
|
if err != nil {
|
|
t.Fatalf("commit at %d pools: %v", size, err)
|
|
}
|
|
if result.SyncPath != "atomic" || result.Recomputed != size {
|
|
t.Fatalf("commit at %d pools took path %q with %d recomputed", size, result.SyncPath, result.Recomputed)
|
|
}
|
|
t.Logf("%8d %12s %12s %12s", size, commitTook.Round(time.Millisecond),
|
|
(commitTook / time.Duration(size)).Round(10*time.Microsecond), previewTook.Round(time.Millisecond))
|
|
}
|
|
}
|
|
|
|
// seedCarryingPools adds n organizations whose default pools carry the
|
|
// product, in batches so one transaction never holds hundreds of pool
|
|
// materializations.
|
|
func seedCarryingPools(t *testing.T, ctx context.Context, database *sql.DB, productID string, n int) {
|
|
t.Helper()
|
|
const batch = 50
|
|
for done := 0; done < n; {
|
|
tx, err := entitlements.BeginMaterializing(ctx, database, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
q := entitlements.New(tx)
|
|
for i := 0; i < batch && done < n; i++ {
|
|
org := setupTestOrg(t, ctx, tx)
|
|
createGrantAndProvision(t, ctx, tx, q, org, productID, 1)
|
|
done++
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
t.Fatalf("commit seed batch: %v", err)
|
|
}
|
|
}
|
|
}
|