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 := 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", 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{ 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, }) 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", 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.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) } }