Add default_plan_ladder_id with a forward data migration and update the runtime to resolve the ladder's rank-0 tier at use-time. Regenerate sqlc, update auto-provisioning, ReapplyDefaultsForPool, operator UI and tests; add GetTierByLadderRank and pool/provision query helpers. Add a CSP-safe confirm-action modal and wire operator actions to it. Close plan-sole-writer safety gaps and serialize IssueGrant with a FOR UPDATE pool lock to prevent ladder races.
222 lines
7.0 KiB
Go
222 lines
7.0 KiB
Go
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 a trial grant creates an upgrade
|
|
// transition, and that ending it (simulating expiry) reactivates the default.
|
|
func TestTrialGrantLifecycle(t *testing.T) {
|
|
database := testDB(t)
|
|
ctx := context.Background()
|
|
|
|
tx, err := database.BeginTx(ctx, 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",
|
|
ProductType: sql.NullString{},
|
|
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",
|
|
ProductType: sql.NullString{},
|
|
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{
|
|
LadderKey: "trial-ladder-" + uuid.New().String()[:8],
|
|
Name: "Trial Ladder",
|
|
IsActive: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create ladder: %v", err)
|
|
}
|
|
|
|
_, err = bq.CreatePlanLadderTier(ctx, billing.CreatePlanLadderTierParams{
|
|
PlanLadderID: ladder.PlanLadderID,
|
|
ProductID: defProd.ProductID,
|
|
Rank: 0,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create tier 0: %v", err)
|
|
}
|
|
_, err = bq.CreatePlanLadderTier(ctx, billing.CreatePlanLadderTierParams{
|
|
PlanLadderID: ladder.PlanLadderID,
|
|
ProductID: trialProd.ProductID,
|
|
Rank: 1,
|
|
})
|
|
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 organization.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",
|
|
Slug: "trial-org-" + uuid.New().String()[:8],
|
|
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",
|
|
Slug: "default",
|
|
PoolType: "default",
|
|
IsAutoManaged: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create pool: %v", err)
|
|
}
|
|
|
|
actor := entitlements.TransitionActor{
|
|
ActorType: "operator",
|
|
ActorID: uuid.NullUUID{UUID: uuid.MustParse(person.PersonID), Valid: true},
|
|
Reason: "test",
|
|
}
|
|
|
|
// 3. Apply default → rank 0 (initiate)
|
|
res0, err := entitlements.ReapplyDefaultsForPool(ctx, tx, pool.PoolID, actor)
|
|
if err != nil {
|
|
t.Fatalf("reapply defaults: %v", err)
|
|
}
|
|
if res0.AlreadyAtTier {
|
|
t.Fatal("expected first default to create provision")
|
|
}
|
|
if res0.TransitionRow.TransitionType != "initiate" {
|
|
t.Fatalf("expected initiate, got %s", res0.TransitionRow.TransitionType)
|
|
}
|
|
|
|
// 4. Issue trial grant → rank 1 (upgrade)
|
|
grant, err := eq.CreateGrant(ctx, entitlements.CreateGrantParams{
|
|
ProductID: uuid.NullUUID{UUID: uuid.MustParse(trialProd.ProductID), Valid: true},
|
|
GrantedToOrgID: uuid.NullUUID{UUID: uuid.MustParse(org.OrgID), Valid: true},
|
|
GrantedByPersonID: uuid.NullUUID{UUID: uuid.MustParse(person.PersonID), Valid: true},
|
|
GrantReason: "trial",
|
|
Quantity: 1,
|
|
ValidUntil: sql.NullTime{Time: time.Now().Add(24 * time.Hour), Valid: true},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create trial grant: %v", err)
|
|
}
|
|
|
|
res1, err := entitlements.Transition(ctx, tx, pool.PoolID, entitlements.TransitionTarget{
|
|
LadderID: ladder.PlanLadderID,
|
|
ProductID: trialProd.ProductID,
|
|
Source: entitlements.TransitionSource{
|
|
GrantID: uuid.NullUUID{UUID: uuid.MustParse(grant.GrantID), Valid: true},
|
|
EntitlementSetID: es1.SetID,
|
|
Quantity: 1,
|
|
},
|
|
}, actor)
|
|
if err != nil {
|
|
t.Fatalf("trial grant transition: %v", err)
|
|
}
|
|
if res1.TransitionRow == nil || res1.TransitionRow.TransitionType != "upgrade" {
|
|
t.Fatalf("expected upgrade transition, got %+v", res1.TransitionRow)
|
|
}
|
|
|
|
// 5. Simulate expiry by invoking Transition(End) — this ends the active
|
|
// trial provision and re-applies the default.
|
|
res2, err := entitlements.Transition(ctx, tx, pool.PoolID, entitlements.TransitionTarget{End: true},
|
|
entitlements.TransitionActor{ActorType: "system", Reason: "grant-expiration:" + grant.GrantID})
|
|
if err != nil {
|
|
t.Fatalf("expiry transition: %v", err)
|
|
}
|
|
if res2.TransitionRow == nil || res2.TransitionRow.TransitionType != "downgrade" {
|
|
t.Fatalf("expected downgrade transition after expiry, got %+v", res2.TransitionRow)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 7. Verify transition history shows the full trajectory
|
|
transitions, err := eq.ListTransitionsByPool(ctx, pool.PoolID)
|
|
if err != nil {
|
|
t.Fatalf("list transitions: %v", err)
|
|
}
|
|
if len(transitions) != 3 {
|
|
t.Fatalf("expected 3 transitions (initiate, upgrade, downgrade), got %d", len(transitions))
|
|
}
|
|
}
|