package entitlements_test import ( "context" "database/sql" "fmt" "os" "testing" "time" "git.coopcloud.tech/wiki-cafe/member-console/internal/db" "git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements" "git.coopcloud.tech/wiki-cafe/member-console/internal/identity" fwmod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/store" "git.coopcloud.tech/wiki-cafe/member-console/internal/migrate" "git.coopcloud.tech/wiki-cafe/member-console/internal/organization" "git.coopcloud.tech/wiki-cafe/member-console/internal/provisioning" "github.com/google/uuid" _ "github.com/jackc/pgx/v5/stdlib" ) func testDB(t *testing.T) *sql.DB { t.Helper() dsn := os.Getenv("TEST_DATABASE_URL") if dsn == "" { t.Skip("TEST_DATABASE_URL not set, skipping integration test") } database, err := sql.Open("pgx", dsn) if err != nil { t.Fatalf("failed to open database: %v", err) } // Run migrations sources := migrate.Sources() if err := db.RunMigrations(database, sources); err != nil { t.Fatalf("failed to run migrations: %v", err) } t.Cleanup(func() { database.Close() }) return database } func TestMaterializeSingleProvision(t *testing.T) { database := testDB(t) ctx := context.Background() tx, err := database.BeginTx(ctx, nil) if err != nil { t.Fatal(err) } defer tx.Rollback() q := entitlements.New(tx) orgQ := organization.New(tx) idQ := identity.New(tx) // Setup: create user, person, org, workspace user, err := idQ.CreateUser(ctx, "test-"+uuid.New().String()) if err != nil { t.Fatalf("create user: %v", err) } person, err := idQ.CreatePerson(ctx, identity.CreatePersonParams{ UserID: user.UserID, DisplayName: "Test User", PrimaryEmail: "test@example.com", PrimaryEmailVerified: true, }) if err != nil { t.Fatalf("create person: %v", err) } org, err := orgQ.CreateOrganization(ctx, organization.CreateOrganizationParams{ Name: "Test Org", OrgType: "personal", OwnerPersonID: person.PersonID, }) if err != nil { t.Fatalf("create org: %v", err) } // Create pool and provision pool, err := q.CreateResourcePool(ctx, entitlements.CreateResourcePoolParams{ OrgID: org.OrgID, Name: "Default", PoolType: "default", IsAutoManaged: true, }) if err != nil { t.Fatalf("create pool: %v", err) } // Create a test product with entitlement set (5 sites, not per-unit) tp := createTestProduct(t, ctx, tx, "TestProduct", 5, false) // Create a grant grant, err := q.CreateGrant(ctx, entitlements.CreateGrantParams{ ProductID: tp.productID, GrantedToOrgID: uuid.NullUUID{ UUID: uuid.MustParse(org.OrgID), Valid: true, }, GrantedByPersonID: uuid.NullUUID{UUID: uuid.MustParse(person.PersonID), Valid: true}, GrantReason: "promotional", Quantity: 1, ValidFrom: time.Now(), }) if err != nil { t.Fatalf("create grant: %v", err) } // Confer the grant onto the pool (core.confer is the only legal writer // of pool_provisions). _, outcome, err := q.Confer(ctx, entitlements.ConferParams{ PoolID: pool.PoolID, ProductID: tp.productID, GrantID: uuid.NullUUID{UUID: uuid.MustParse(grant.GrantID), Valid: true}, Quantity: 1, }) if err != nil { t.Fatalf("confer: %v", err) } if outcome != "created" { t.Fatalf("confer outcome = %q, want created", outcome) } // Materialize err = entitlements.MaterializePoolEntitlements(ctx, q, pool.PoolID) if err != nil { t.Fatalf("materialize: %v", err) } // Verify entitlement was created ent, err := q.GetNumericEntitlementByPoolAndResource(ctx, entitlements.GetNumericEntitlementByPoolAndResourceParams{ PoolID: pool.PoolID, ResourceKey: "fedwiki_sites", }) if err != nil { t.Fatalf("get entitlement: %v", err) } if ent.ResourceLimit != 5 { t.Errorf("expected resource_limit=5, got %d", ent.ResourceLimit) } // Verify usage record was created usage, err := q.GetUsageByPoolAndResource(ctx, entitlements.GetUsageByPoolAndResourceParams{ PoolID: pool.PoolID, ResourceKey: "fedwiki_sites", }) if err != nil { t.Fatalf("get usage: %v", err) } if usage.CurrentUsage != 0 { t.Errorf("expected current_usage=0, got %d", usage.CurrentUsage) } } // setupTestOrg is a helper that creates user, person, org, workspace, pool, and pool assignment. // Returns all the created records needed for entitlement tests. type testOrg struct { person identity.Person org organization.Organization workspace organization.Workspace pool entitlements.ResourcePool } func setupTestOrg(t *testing.T, ctx context.Context, tx *sql.Tx) testOrg { t.Helper() q := entitlements.New(tx) orgQ := organization.New(tx) idQ := identity.New(tx) user, err := idQ.CreateUser(ctx, "test-"+uuid.New().String()) if err != nil { t.Fatalf("create user: %v", err) } person, err := idQ.CreatePerson(ctx, identity.CreatePersonParams{ UserID: user.UserID, DisplayName: "Test User", PrimaryEmail: fmt.Sprintf("test-%s@example.com", uuid.New().String()[:8]), PrimaryEmailVerified: true, }) if err != nil { t.Fatalf("create person: %v", err) } org, err := orgQ.CreateOrganization(ctx, organization.CreateOrganizationParams{ Name: "Test Org", OrgType: "personal", OwnerPersonID: person.PersonID, }) if err != nil { t.Fatalf("create org: %v", err) } workspace, err := orgQ.CreateWorkspace(ctx, organization.CreateWorkspaceParams{ OrgID: org.OrgID, Name: "Default", }) if err != nil { t.Fatalf("create workspace: %v", err) } pool, err := q.CreateResourcePool(ctx, entitlements.CreateResourcePoolParams{ OrgID: org.OrgID, Name: "Default", PoolType: "default", IsAutoManaged: true, }) if err != nil { t.Fatalf("create pool: %v", err) } _, err = q.CreatePoolAssignment(ctx, entitlements.CreatePoolAssignmentParams{ PoolID: pool.PoolID, WorkspaceID: workspace.WorkspaceID, IsPrimary: true, }) if err != nil { t.Fatalf("create pool assignment: %v", err) } return testOrg{person: person, org: org, workspace: workspace, pool: pool} } // testProduct holds identifiers for a product created by createTestProduct. type testProduct struct { productID string setID string } // createTestProduct creates a billing product with an entitlement set containing a "fedwiki_sites" limit rule. // sitesLimit is the base limit, perUnit controls whether it scales with quantity. func createTestProduct(t *testing.T, ctx context.Context, tx *sql.Tx, name string, sitesLimit int64, perUnit bool) 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) } _, err = q.CreateEntitlementSetRule(ctx, entitlements.CreateEntitlementSetRuleParams{ SetID: set.SetID, RuleType: "limit", ResourceKey: sql.NullString{String: "fedwiki_sites", Valid: true}, ResourceValue: sql.NullInt64{Int64: sitesLimit, Valid: true}, ResourcePerUnit: sql.NullBool{Bool: perUnit, Valid: true}, StackingPolicy: sql.NullString{String: "additive", Valid: true}, }) if err != nil { t.Fatalf("create set rule for %s: %v", name, err) } // display_category stays NULL (presentation-only label per Doc 41 // Decision 136); lifecycle_status must be 'published' because core.confer // refuses draft products. var productID string 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) if err != nil { t.Fatalf("create product %s: %v", name, err) } return testProduct{productID: productID, setID: set.SetID} } // createGrantAndProvision records a grant for the given product and quantity, // confers it onto the org's pool via core.confer, then materializes. func createGrantAndProvision(t *testing.T, ctx context.Context, tx *sql.Tx, q *entitlements.Queries, to testOrg, productID string, quantity int32) entitlements.Grant { t.Helper() grant, err := q.CreateGrant(ctx, entitlements.CreateGrantParams{ ProductID: productID, GrantedToOrgID: uuid.NullUUID{ UUID: uuid.MustParse(to.org.OrgID), Valid: true, }, GrantedByPersonID: uuid.NullUUID{UUID: uuid.MustParse(to.person.PersonID), Valid: true}, GrantReason: "manual", Quantity: quantity, ValidFrom: time.Now(), }) if err != nil { t.Fatalf("create grant: %v", err) } _, outcome, err := q.Confer(ctx, entitlements.ConferParams{ PoolID: to.pool.PoolID, ProductID: productID, GrantID: uuid.NullUUID{UUID: uuid.MustParse(grant.GrantID), Valid: true}, Quantity: quantity, }) if err != nil { t.Fatalf("confer: %v", err) } if outcome != "created" { t.Fatalf("confer outcome = %q, want created", outcome) } err = entitlements.MaterializePoolEntitlements(ctx, q, to.pool.PoolID) if err != nil { t.Fatalf("materialize: %v", err) } return grant } func getEntitlementLimit(t *testing.T, ctx context.Context, q *entitlements.Queries, poolID string) int64 { t.Helper() ent, err := q.GetNumericEntitlementByPoolAndResource(ctx, entitlements.GetNumericEntitlementByPoolAndResourceParams{ PoolID: poolID, ResourceKey: "fedwiki_sites", }) if err != nil { t.Fatalf("get entitlement: %v", err) } return ent.ResourceLimit } func getUsage(t *testing.T, ctx context.Context, q *entitlements.Queries, poolID string) int64 { t.Helper() usage, err := q.GetUsageByPoolAndResource(ctx, entitlements.GetUsageByPoolAndResourceParams{ PoolID: poolID, ResourceKey: "fedwiki_sites", }) if err != nil { t.Fatalf("get usage: %v", err) } return usage.CurrentUsage } // 10.2 Test materialization: additive stacking with multiple provisions func TestMaterializeAdditiveStacking(t *testing.T) { database := testDB(t) ctx := context.Background() tx, err := database.BeginTx(ctx, nil) if err != nil { t.Fatal(err) } defer tx.Rollback() q := entitlements.New(tx) to := setupTestOrg(t, ctx, tx) // Test product: 5 sites per grant (not per-unit) product := createTestProduct(t, ctx, tx, "TestProduct", 5, false) // First grant: should get limit=5 createGrantAndProvision(t, ctx, tx, q, to, product.productID, 1) if limit := getEntitlementLimit(t, ctx, q, to.pool.PoolID); limit != 5 { t.Errorf("after first grant: expected limit=5, got %d", limit) } // Second grant: additive stacking should give limit=10 createGrantAndProvision(t, ctx, tx, q, to, product.productID, 1) if limit := getEntitlementLimit(t, ctx, q, to.pool.PoolID); limit != 10 { t.Errorf("after second grant: expected limit=10, got %d", limit) } // Also test per-unit product: 1 site × quantity siteCreditProduct := createTestProduct(t, ctx, tx, "TestSiteCredit", 1, true) createGrantAndProvision(t, ctx, tx, q, to, siteCreditProduct.productID, 3) // 5 + 5 + 3 = 13 if limit := getEntitlementLimit(t, ctx, q, to.pool.PoolID); limit != 13 { t.Errorf("after site credit grant (qty=3): expected limit=13, got %d", limit) } } // 10.3 Test materialization: provision removal recomputes limit correctly func TestMaterializeProvisionRemoval(t *testing.T) { database := testDB(t) ctx := context.Background() tx, err := database.BeginTx(ctx, nil) if err != nil { t.Fatal(err) } defer tx.Rollback() q := entitlements.New(tx) to := setupTestOrg(t, ctx, tx) product := createTestProduct(t, ctx, tx, "TestProduct", 5, false) // Create two grants grant1 := createGrantAndProvision(t, ctx, tx, q, to, product.productID, 1) createGrantAndProvision(t, ctx, tx, q, to, product.productID, 1) // Limit should be 10 if limit := getEntitlementLimit(t, ctx, q, to.pool.PoolID); limit != 10 { t.Fatalf("expected limit=10, got %d", limit) } // Revoke first grant (the decree), end its conferral (the enactment), // re-materialize _, err = q.RevokeGrant(ctx, entitlements.RevokeGrantParams{ GrantID: grant1.GrantID, }) if err != nil { t.Fatalf("revoke grant: %v", err) } ended, err := q.EndConferral(ctx, entitlements.EndConferralParams{ GrantID: uuid.NullUUID{UUID: uuid.MustParse(grant1.GrantID), Valid: true}, }) if err != nil { t.Fatalf("end conferral: %v", err) } if len(ended) != 1 { t.Fatalf("expected 1 ended provision, got %d", len(ended)) } err = entitlements.MaterializePoolEntitlements(ctx, q, to.pool.PoolID) if err != nil { t.Fatalf("re-materialize: %v", err) } // Limit should drop to 5 if limit := getEntitlementLimit(t, ctx, q, to.pool.PoolID); limit != 5 { t.Errorf("after revocation: expected limit=5, got %d", limit) } } // 10.4 Test materialization: idempotency (repeated calls produce same result) func TestMaterializeIdempotency(t *testing.T) { database := testDB(t) ctx := context.Background() tx, err := database.BeginTx(ctx, nil) if err != nil { t.Fatal(err) } defer tx.Rollback() q := entitlements.New(tx) to := setupTestOrg(t, ctx, tx) product := createTestProduct(t, ctx, tx, "TestProduct", 5, false) createGrantAndProvision(t, ctx, tx, q, to, product.productID, 1) // Call materialize multiple times for i := 0; i < 3; i++ { err = entitlements.MaterializePoolEntitlements(ctx, q, to.pool.PoolID) if err != nil { t.Fatalf("materialize iteration %d: %v", i, err) } } // Limit should still be 5 if limit := getEntitlementLimit(t, ctx, q, to.pool.PoolID); limit != 5 { t.Errorf("expected limit=5 after repeated materialize, got %d", limit) } if usage := getUsage(t, ctx, q, to.pool.PoolID); usage != 0 { t.Errorf("expected usage=0 after repeated materialize, got %d", usage) } } // 10.5 Test grant flow: create grant → entitlements materialize → site creation succeeds func TestGrantFlowSiteCreation(t *testing.T) { database := testDB(t) ctx := context.Background() tx, err := database.BeginTx(ctx, nil) if err != nil { t.Fatal(err) } defer tx.Rollback() q := entitlements.New(tx) fwQ := fwmod.New(tx) to := setupTestOrg(t, ctx, tx) product := createTestProduct(t, ctx, tx, "TestProduct", 5, false) // Grant gives 5 sites createGrantAndProvision(t, ctx, tx, q, to, product.productID, 1) // Atomic increment should succeed (usage 0 < limit 5) result, err := q.AtomicIncrementUsage(ctx, entitlements.AtomicIncrementUsageParams{ WorkspaceID: to.workspace.WorkspaceID, ResourceKey: "fedwiki_sites", }) if err != nil { t.Fatalf("atomic increment: %v", err) } rows, _ := result.RowsAffected() if rows != 1 { t.Fatalf("expected 1 row affected, got %d", rows) } // Create a site site, err := fwQ.CreateSite(ctx, fwmod.CreateSiteParams{ SiteID: uuid.New().String(), WorkspaceID: to.workspace.WorkspaceID, Domain: "test-" + uuid.New().String()[:8] + ".example.com", }) if err != nil { t.Fatalf("create site: %v", err) } if site.WorkspaceID != to.workspace.WorkspaceID { t.Errorf("site workspace mismatch") } // Usage should be 1 if usage := getUsage(t, ctx, q, to.pool.PoolID); usage != 1 { t.Errorf("expected usage=1 after increment, got %d", usage) } } // 10.6 Test grant revocation: revoke grant → limit reduced → site creation blocked at new limit func TestGrantRevocationBlocksSiteCreation(t *testing.T) { database := testDB(t) ctx := context.Background() tx, err := database.BeginTx(ctx, nil) if err != nil { t.Fatal(err) } defer tx.Rollback() q := entitlements.New(tx) to := setupTestOrg(t, ctx, tx) product := createTestProduct(t, ctx, tx, "TestProduct", 5, false) // Two grants: limit=10 grant1 := createGrantAndProvision(t, ctx, tx, q, to, product.productID, 1) createGrantAndProvision(t, ctx, tx, q, to, product.productID, 1) // Use 6 sites (within limit of 10) for i := 0; i < 6; i++ { result, err := q.AtomicIncrementUsage(ctx, entitlements.AtomicIncrementUsageParams{ WorkspaceID: to.workspace.WorkspaceID, ResourceKey: "fedwiki_sites", }) if err != nil { t.Fatalf("increment %d: %v", i, err) } rows, _ := result.RowsAffected() if rows != 1 { t.Fatalf("increment %d: expected 1 row, got %d", i, rows) } } // Revoke first grant and end its conferral → limit drops to 5, but usage is 6 _, err = q.RevokeGrant(ctx, entitlements.RevokeGrantParams{GrantID: grant1.GrantID}) if err != nil { t.Fatalf("revoke: %v", err) } ended, err := q.EndConferral(ctx, entitlements.EndConferralParams{ GrantID: uuid.NullUUID{UUID: uuid.MustParse(grant1.GrantID), Valid: true}, }) if err != nil { t.Fatalf("end conferral: %v", err) } if len(ended) != 1 { t.Fatalf("expected 1 ended provision, got %d", len(ended)) } err = entitlements.MaterializePoolEntitlements(ctx, q, to.pool.PoolID) if err != nil { t.Fatalf("re-materialize: %v", err) } // Limit is now 5, usage is 6 → increment should fail (0 rows) if limit := getEntitlementLimit(t, ctx, q, to.pool.PoolID); limit != 5 { t.Errorf("expected limit=5, got %d", limit) } result, err := q.AtomicIncrementUsage(ctx, entitlements.AtomicIncrementUsageParams{ WorkspaceID: to.workspace.WorkspaceID, ResourceKey: "fedwiki_sites", }) if err != nil { t.Fatalf("atomic increment: %v", err) } rows, _ := result.RowsAffected() if rows != 0 { t.Errorf("expected 0 rows (blocked), got %d", rows) } } // 10.7 Test entitlement check: atomic increment rejects when at limit func TestAtomicIncrementRejectsAtLimit(t *testing.T) { database := testDB(t) ctx := context.Background() tx, err := database.BeginTx(ctx, nil) if err != nil { t.Fatal(err) } defer tx.Rollback() q := entitlements.New(tx) to := setupTestOrg(t, ctx, tx) product := createTestProduct(t, ctx, tx, "TestProduct", 5, false) // Grant gives limit=5 createGrantAndProvision(t, ctx, tx, q, to, product.productID, 1) // Use all 5 for i := 0; i < 5; i++ { result, err := q.AtomicIncrementUsage(ctx, entitlements.AtomicIncrementUsageParams{ WorkspaceID: to.workspace.WorkspaceID, ResourceKey: "fedwiki_sites", }) if err != nil { t.Fatalf("increment %d: %v", i, err) } rows, _ := result.RowsAffected() if rows != 1 { t.Fatalf("increment %d: expected 1 row, got %d", i, rows) } } // 6th increment should fail result, err := q.AtomicIncrementUsage(ctx, entitlements.AtomicIncrementUsageParams{ WorkspaceID: to.workspace.WorkspaceID, ResourceKey: "fedwiki_sites", }) if err != nil { t.Fatalf("6th increment: %v", err) } rows, _ := result.RowsAffected() if rows != 0 { t.Errorf("expected 0 rows (at limit), got %d", rows) } // Usage should still be 5 if usage := getUsage(t, ctx, q, to.pool.PoolID); usage != 5 { t.Errorf("expected usage=5, got %d", usage) } } // 10.7b Test clamp: when a downgrade reduces the limit below current usage, // existing usage is retained and new creation is blocked (the 8c downgrade contract). func TestAtomicIncrementClampedWhenOverLimit(t *testing.T) { database := testDB(t) ctx := context.Background() tx, err := database.BeginTx(ctx, nil) if err != nil { t.Fatal(err) } defer tx.Rollback() q := entitlements.New(tx) to := setupTestOrg(t, ctx, tx) // Member starts on a higher tier (limit 5) and uses 3 sites. product := createTestProduct(t, ctx, tx, "TestProductStandard", 5, false) createGrantAndProvision(t, ctx, tx, q, to, product.productID, 1) for i := 0; i < 3; i++ { result, err := q.AtomicIncrementUsage(ctx, entitlements.AtomicIncrementUsageParams{ WorkspaceID: to.workspace.WorkspaceID, ResourceKey: "fedwiki_sites", }) if err != nil { t.Fatalf("increment %d: %v", i, err) } if rows, _ := result.RowsAffected(); rows != 1 { t.Fatalf("increment %d: expected 1 row, got %d", i, rows) } } // Downgrade reduces the sites limit below current usage (5 -> 1). if _, err := tx.ExecContext(ctx, "UPDATE core.numeric_entitlements SET resource_limit = 1 WHERE pool_id = $1 AND resource_key = 'fedwiki_sites'", to.pool.PoolID, ); err != nil { t.Fatalf("reduce limit: %v", err) } // Over the limit (usage 3 > limit 1): creation must be refused (clamp). result, err := q.AtomicIncrementUsage(ctx, entitlements.AtomicIncrementUsageParams{ WorkspaceID: to.workspace.WorkspaceID, ResourceKey: "fedwiki_sites", }) if err != nil { t.Fatalf("over-limit increment: %v", err) } if rows, _ := result.RowsAffected(); rows != 0 { t.Errorf("expected 0 rows (clamped over limit), got %d", rows) } // Existing usage is retained — nothing is deleted or reset. if usage := getUsage(t, ctx, q, to.pool.PoolID); usage != 3 { t.Errorf("expected usage retained at 3, got %d", usage) } } // 10.8 Test site deletion: usage decremented, new site creation succeeds func TestSiteDeletionDecrementsUsage(t *testing.T) { database := testDB(t) ctx := context.Background() tx, err := database.BeginTx(ctx, nil) if err != nil { t.Fatal(err) } defer tx.Rollback() q := entitlements.New(tx) fwQ := fwmod.New(tx) to := setupTestOrg(t, ctx, tx) product := createTestProduct(t, ctx, tx, "TestProduct", 5, false) // Grant gives limit=5, use all 5 createGrantAndProvision(t, ctx, tx, q, to, product.productID, 1) sites := make([]fwmod.Site, 5) for i := 0; i < 5; i++ { result, err := q.AtomicIncrementUsage(ctx, entitlements.AtomicIncrementUsageParams{ WorkspaceID: to.workspace.WorkspaceID, ResourceKey: "fedwiki_sites", }) if err != nil { t.Fatalf("increment %d: %v", i, err) } rows, _ := result.RowsAffected() if rows != 1 { t.Fatalf("increment %d failed", i) } sites[i], err = fwQ.CreateSite(ctx, fwmod.CreateSiteParams{ SiteID: uuid.New().String(), WorkspaceID: to.workspace.WorkspaceID, Domain: fmt.Sprintf("site-%d-%s.example.com", i, uuid.New().String()[:8]), }) if err != nil { t.Fatalf("create site %d: %v", i, err) } } // At limit — increment should fail result, err := q.AtomicIncrementUsage(ctx, entitlements.AtomicIncrementUsageParams{ WorkspaceID: to.workspace.WorkspaceID, ResourceKey: "fedwiki_sites", }) if err != nil { t.Fatalf("increment at limit: %v", err) } rows, _ := result.RowsAffected() if rows != 0 { t.Fatalf("expected blocked at limit, got %d rows", rows) } // Delete a site and decrement usage err = fwQ.DeleteSite(ctx, sites[0].SiteID) if err != nil { t.Fatalf("delete site: %v", err) } _, err = q.AtomicDecrementUsage(ctx, entitlements.AtomicDecrementUsageParams{ WorkspaceID: to.workspace.WorkspaceID, ResourceKey: "fedwiki_sites", }) if err != nil { t.Fatalf("decrement: %v", err) } // Usage should be 4, and increment should now succeed if usage := getUsage(t, ctx, q, to.pool.PoolID); usage != 4 { t.Errorf("expected usage=4 after delete, got %d", usage) } result, err = q.AtomicIncrementUsage(ctx, entitlements.AtomicIncrementUsageParams{ WorkspaceID: to.workspace.WorkspaceID, ResourceKey: "fedwiki_sites", }) if err != nil { t.Fatalf("increment after delete: %v", err) } rows, _ = result.RowsAffected() if rows != 1 { t.Errorf("expected increment to succeed after delete, got %d rows", rows) } } // 10.9 Test auto-provisioning: new user gets pool and pool assignment func TestAutoProvisioningCreatesPoolAndAssignment(t *testing.T) { database := testDB(t) ctx := context.Background() claims := provisioning.OIDCClaims{ Subject: "test-" + uuid.New().String(), Email: fmt.Sprintf("test-%s@example.com", uuid.New().String()[:8]), EmailVerified: true, Name: "Auto Test User", PreferredUsername: "autotest-" + uuid.New().String()[:8], } result, err := provisioning.AutoProvision(ctx, database, claims) if err != nil { t.Fatalf("auto-provision: %v", err) } // Verify pool was created if result.Pool.PoolID == "" { t.Error("expected pool to be created") } if result.Pool.PoolType != "default" { t.Errorf("expected pool_type=default, got %s", result.Pool.PoolType) } if !result.Pool.IsAutoManaged { t.Error("expected pool to be auto-managed") } // Verify pool assignment was created if result.PoolAssignment.AssignmentID == "" { t.Error("expected pool assignment to be created") } if result.PoolAssignment.PoolID != result.Pool.PoolID { t.Error("pool assignment should reference the created pool") } if result.PoolAssignment.WorkspaceID != result.Workspace.WorkspaceID { t.Error("pool assignment should reference the created workspace") } if !result.PoolAssignment.IsPrimary { t.Error("expected pool assignment to be primary") } } // 10.10 Test full migration sequence on fresh database func TestFullMigrationSequence(t *testing.T) { dsn := os.Getenv("TEST_DATABASE_URL") if dsn == "" { t.Skip("TEST_DATABASE_URL not set, skipping integration test") } database, err := sql.Open("pgx", dsn) if err != nil { t.Fatalf("failed to open database: %v", err) } defer database.Close() // Run all migrations in dependency order sources := migrate.Sources() if err := db.RunMigrations(database, sources); err != nil { t.Fatalf("migration sequence failed: %v", err) } ctx := context.Background() // Verify core schema structures exist after migrations // (Products and entitlement sets are created at runtime, not seeded) // Verify org_types table was seeded with 'personal' orgQ := organization.New(database) orgType, err := orgQ.GetOrgType(ctx, "personal") if err != nil { t.Fatalf("personal org type not found after migrations: %v", err) } if orgType.DisplayName != "Personal" { t.Errorf("expected display_name='Personal', got %q", orgType.DisplayName) } // Verify entitlement tables are queryable entQ := entitlements.New(database) _, err = entQ.ListEntitlementSets(ctx) if err != nil { t.Fatalf("list entitlement sets: %v", err) } }