Files
member-console/internal/server/operator_enrollment_test.go
T
cgalo5758 3727ff31d8 Add entitlement set rule change ledger and preview flow
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.
2026-09-15 03:53:28 -05:00

224 lines
7.1 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server_test
import (
"context"
"database/sql"
"testing"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/identity"
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
"github.com/google/uuid"
)
// TestTrialGrantLifecycle verifies that conferring a trial grant moves the
// pool to the trial tier, and that ending the conferral (simulating expiry)
// plus re-applying defaults returns the pool to the default tier.
func TestTrialGrantLifecycle(t *testing.T) {
database := testDB(t)
ctx := context.Background()
tx, err := entitlements.BeginMaterializing(ctx, database, nil)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
bq := billing.New(tx)
eq := entitlements.New(tx)
iq := identity.New(tx)
oq := organization.New(tx)
// 1. Set up two-tier ladder with a default product at rank 0
es0, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
Name: "trial-test-set-0-" + uuid.New().String()[:8],
})
if err != nil {
t.Fatalf("create entitlement set 0: %v", err)
}
es1, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
Name: "trial-test-set-1-" + uuid.New().String()[:8],
})
if err != nil {
t.Fatalf("create entitlement set 1: %v", err)
}
defProd, err := bq.CreateProduct(ctx, billing.CreateProductParams{
Name: "Default Plan",
IsActive: true,
IsPublic: true,
EntitlementSetID: uuid.NullUUID{UUID: uuid.MustParse(es0.SetID), Valid: true},
LifecycleStatus: "published",
})
if err != nil {
t.Fatalf("create default product: %v", err)
}
trialProd, err := bq.CreateProduct(ctx, billing.CreateProductParams{
Name: "Trial Plan",
IsActive: true,
IsPublic: true,
EntitlementSetID: uuid.NullUUID{UUID: uuid.MustParse(es1.SetID), Valid: true},
LifecycleStatus: "published",
})
if err != nil {
t.Fatalf("create trial product: %v", err)
}
ladder, err := bq.CreatePlanLadder(ctx, billing.CreatePlanLadderParams{
Name: "Trial Ladder " + uuid.New().String()[:8],
IsActive: true,
})
if err != nil {
t.Fatalf("create ladder: %v", err)
}
_, err = bq.CreatePlanLadderTier(ctx, billing.CreatePlanLadderTierParams{
PlanLadderID: ladder.PlanLadderID,
ProductID: defProd.ProductID,
})
if err != nil {
t.Fatalf("create tier 0: %v", err)
}
_, err = bq.CreatePlanLadderTier(ctx, billing.CreatePlanLadderTierParams{
PlanLadderID: ladder.PlanLadderID,
ProductID: trialProd.ProductID,
})
if err != nil {
t.Fatalf("create tier 1: %v", err)
}
// 2. Create org type with default plan, org, person, pool
orgType := "tr-" + uuid.New().String()[:4]
_, err = tx.ExecContext(ctx,
`INSERT INTO core.org_types (org_type, display_name, is_active, default_plan_ladder_id)
VALUES ($1, $2, true, $3)`,
orgType, "Trial Type", ladder.PlanLadderID,
)
if err != nil {
t.Fatalf("create org type: %v", err)
}
user, err := iq.CreateUser(ctx, "u-"+uuid.New().String())
if err != nil {
t.Fatalf("create user: %v", err)
}
person, err := iq.CreatePerson(ctx, identity.CreatePersonParams{
UserID: user.UserID,
DisplayName: "Trial User",
PrimaryEmail: "trial-" + uuid.New().String()[:8] + "@example.com",
PrimaryEmailVerified: true,
})
if err != nil {
t.Fatalf("create person: %v", err)
}
org, err := oq.CreateOrganization(ctx, organization.CreateOrganizationParams{
Name: "Trial Org",
OrgType: orgType,
OwnerPersonID: person.PersonID,
})
if err != nil {
t.Fatalf("create org: %v", err)
}
pool, err := eq.CreateResourcePool(ctx, entitlements.CreateResourcePoolParams{
OrgID: org.OrgID,
Name: "default",
PoolType: "default",
IsAutoManaged: true,
})
if err != nil {
t.Fatalf("create pool: %v", err)
}
actor := entitlements.Actor{
ActorType: "operator",
ActorID: uuid.NullUUID{UUID: uuid.MustParse(person.PersonID), Valid: true},
Reason: "test",
}
// 3. Apply default → pool delivers the rank-0 default product
res0, err := entitlements.ReapplyDefaultsForPool(ctx, tx, pool.PoolID, actor)
if err != nil {
t.Fatalf("reapply defaults: %v", err)
}
if res0.AlreadyAtTier || res0.Outcome != "created" {
t.Fatalf("expected first default to create a provision, got %+v", res0)
}
// 4. Confer a trial grant for the rank-1 product — the conferral
// primitive derives the ladder move from the product's shape, superseding
// the default position.
res1, err := entitlements.ConferGrantTx(ctx, tx, entitlements.ConferGrantInput{
ProductID: trialProd.ProductID,
OrgID: org.OrgID,
GrantedByPersonID: person.PersonID,
GrantReason: "evaluation",
Description: "trial grant",
Quantity: 1,
ValidUntil: sql.NullTime{Time: time.Now().Add(24 * time.Hour), Valid: true},
ActorType: "operator",
ActorID: uuid.NullUUID{UUID: uuid.MustParse(person.PersonID), Valid: true},
TransitionReason: "test",
})
if err != nil {
t.Fatalf("confer trial grant: %v", err)
}
if res1.Outcome != "created" {
t.Fatalf("expected trial conferral outcome 'created', got %q", res1.Outcome)
}
if res1.PoolID != pool.PoolID {
t.Fatalf("expected conferral on pool %s, got %s", pool.PoolID, res1.PoolID)
}
attachments, err := eq.GetActiveAttachmentsByPool(ctx, pool.PoolID)
if err != nil {
t.Fatalf("get active attachments after trial: %v", err)
}
if len(attachments) != 1 || attachments[0].ProductID != trialProd.ProductID {
t.Fatalf("expected the trial product to be the sole active attachment, got %+v", attachments)
}
// 5. Simulate expiry: end the grant-backed conferral, then re-apply the
// org default (the expiry path's two steps).
ended, err := eq.EndConferral(ctx, entitlements.EndConferralParams{
GrantID: uuid.NullUUID{UUID: uuid.MustParse(res1.Grant.GrantID), Valid: true},
ActorType: "system",
Reason: sql.NullString{String: "grant-expiration:" + res1.Grant.GrantID, Valid: true},
EffectiveAt: time.Now(),
})
if err != nil {
t.Fatalf("end trial conferral: %v", err)
}
if len(ended) != 1 {
t.Fatalf("expected 1 ended provision, got %d", len(ended))
}
res2, err := entitlements.ReapplyDefaultsForPool(ctx, tx, pool.PoolID,
entitlements.Actor{ActorType: "system", Reason: "grant-expiration:" + res1.Grant.GrantID})
if err != nil {
t.Fatalf("reapply defaults after expiry: %v", err)
}
if res2.AlreadyAtTier {
t.Fatal("expected the default to be re-conferred after the trial ended")
}
// 6. Verify the pool is back at rank 0 (default)
attachments, err = eq.GetActiveAttachmentsByPool(ctx, pool.PoolID)
if err != nil {
t.Fatalf("get active attachments: %v", err)
}
if len(attachments) != 1 {
t.Fatalf("expected 1 active attachment after expiry, got %d", len(attachments))
}
if attachments[0].ProductID != defProd.ProductID {
t.Fatalf("expected default product %s after expiry, got %s", defProd.ProductID, attachments[0].ProductID)
}
}