package server_test import ( "context" "database/sql" "io" "log/slog" "testing" "git.coopcloud.tech/wiki-cafe/member-console/internal/billing" "git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements" stripestore "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store" "git.coopcloud.tech/wiki-cafe/member-console/internal/organization" "git.coopcloud.tech/wiki-cafe/member-console/internal/server" "github.com/google/uuid" ) // DB-backed derivation coverage for the operator setup checklist // (setup_state.go, openspec change ux-first-run). Point TEST_DATABASE_URL at // a dedicated scratch database before running these — the derivation // predicates are whole-table EXISTS checks, so they are not safe to run // against a database other tests are concurrently writing real rows into. // // Reuses testDB, defined once in operator_plan_ladders_test.go (same // package). Every test's fixtures live inside a transaction rolled back via // defer, matching this package's existing DB-backed tests, so nothing here // commits; cleanSetupSlate still runs first as a safety net against a stale // committed row surviving an earlier interrupted run. func cleanSetupSlate(t *testing.T, db *sql.DB) { t.Helper() stmts := []string{ // Reset org_types' default-plan reference before dropping plan // ladders — fk_org_types_default_plan_ladder blocks it otherwise. // org_types itself must never be truncated: its seeded rows are a // boot invariant, not fixture data this file owns, and CASCADE // truncating core.plan_ladders would sweep it in (org_types is the // dependent side of that one FK) — so plan_ladders' own dependents // are cleared explicitly below instead of via CASCADE on it. `UPDATE core.org_types SET default_plan_ladder_id = NULL WHERE default_plan_ladder_id IS NOT NULL`, `DELETE FROM core.pool_provision_transitions`, `DELETE FROM core.pool_provision_ladders`, `DELETE FROM core.plan_ladder_tiers`, `DELETE FROM core.plan_ladders`, // CASCADE here only reaches tables that reference products/ // entitlement_sets (prices, Stripe mappings, grants, pool // provisions, subscription items, entitlement_set_rules, ...) — // verified against pg_constraint that org_types is not among them. `TRUNCATE core.entitlement_sets, core.products CASCADE`, } for _, stmt := range stmts { if _, err := db.Exec(stmt); err != nil { t.Fatalf("clean setup slate (%s): %v", stmt, err) } } } // setupHandler builds an OperatorHandler backed by tx-scoped queriers plus a // discard logger — enough to exercise DeriveSetupState, nothing else. func setupHandler(tx *sql.Tx) *server.OperatorHandler { return &server.OperatorHandler{ Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), BillingQ: billing.New(tx), EntitlementsQ: entitlements.New(tx), OrgQ: organization.New(tx), StripeQ: stripestore.New(tx), } } func stepComplete(state server.SetupState, key string) (complete, found bool) { for _, s := range state.Steps { if s.Key == key { return s.Complete, true } } return false, false } func assertStep(t *testing.T, state server.SetupState, key string, want bool) { t.Helper() got, found := stepComplete(state, key) if !found { t.Fatalf("no setup step with key %q", key) } if got != want { t.Errorf("step %q Complete = %v, want %v", key, got, want) } } // A freshly migrated, empty database: every step is incomplete, and the // landing region's visibility predicate agrees. func TestSetupStateFreshDatabaseAllStepsIncomplete(t *testing.T) { database := testDB(t) cleanSetupSlate(t, database) ctx := context.Background() tx, err := database.BeginTx(ctx, nil) if err != nil { t.Fatal(err) } defer tx.Rollback() state := setupHandler(tx).DeriveSetupState(ctx) if len(state.Steps) != 5 { t.Fatalf("got %d steps, want 5 (entitlement-set, product, price-sync, ladder, org-type-default)", len(state.Steps)) } for _, s := range state.Steps { if s.Complete { t.Errorf("step %q reported complete on a fresh database", s.Key) } } if !state.RequiredIncomplete() { t.Error("RequiredIncomplete() = false on a fresh database, want true") } } // Minimal fixtures flip each predicate one by one, in chain order. Each // assertion checks the step just built AND that steps further down the // chain have not moved — the derivation must not cross-credit. func TestSetupStateDerivationFlipsPerStep(t *testing.T) { database := testDB(t) cleanSetupSlate(t, database) 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) sq := stripestore.New(tx) h := setupHandler(tx) // 1. Entitlement set. es, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{ Name: "setup-test-set-" + uuid.New().String()[:8], IsActive: true, }) if err != nil { t.Fatalf("create entitlement set: %v", err) } state := h.DeriveSetupState(ctx) assertStep(t, state, "entitlement-set", true) assertStep(t, state, "product", false) // 2. A draft product does NOT complete the product step: the step is // keyed on a published product, because the operator UI's create path // publishes at creation (operator_products.go) and a draft-only catalog // delivers nothing to members. product, err := bq.CreateProduct(ctx, billing.CreateProductParams{ Name: "Setup Test Product", IsActive: true, IsPublic: true, EntitlementSetID: uuid.NullUUID{UUID: uuid.MustParse(es.SetID), Valid: true}, LifecycleStatus: "draft", }) if err != nil { t.Fatalf("create product: %v", err) } state = h.DeriveSetupState(ctx) assertStep(t, state, "product", false) assertStep(t, state, "price-sync", false) assertStep(t, state, "ladder", false) // 3. A published product completes it (a second product created straight // to "published" — no lifecycle-transition query is generated to flip the // first in place). _, err = bq.CreateProduct(ctx, billing.CreateProductParams{ Name: "Setup Test Product (published)", IsActive: true, IsPublic: true, EntitlementSetID: uuid.NullUUID{UUID: uuid.MustParse(es.SetID), Valid: true}, LifecycleStatus: "published", }) if err != nil { t.Fatalf("create published product: %v", err) } state = h.DeriveSetupState(ctx) assertStep(t, state, "product", true) assertStep(t, state, "price-sync", false) // 4. Price + Stripe sync (conditional): a price alone is not enough — // the step is the composed pair (AnyActivePrice && AnyMappedPrice). price, err := bq.CreatePrice(ctx, billing.CreatePriceParams{ ProductID: product.ProductID, Currency: "usd", UnitAmount: 500, }) if err != nil { t.Fatalf("create price: %v", err) } state = h.DeriveSetupState(ctx) assertStep(t, state, "price-sync", false) if _, err := sq.UpsertPriceMapping(ctx, stripestore.UpsertPriceMappingParams{ PriceID: price.PriceID, StripePriceID: sql.NullString{String: "price_setup_test", Valid: true}, SyncStatus: "synced", }); err != nil { t.Fatalf("upsert price mapping: %v", err) } state = h.DeriveSetupState(ctx) assertStep(t, state, "price-sync", true) // 5. Plan ladder with a rank-0 tier — a ladder alone is not enough; the // step needs the tier (AnyRankZeroTier), the chain's last required step. ladder, err := bq.CreatePlanLadder(ctx, billing.CreatePlanLadderParams{ LadderKey: "setup-test-ladder-" + uuid.New().String()[:8], Name: "Setup Test Ladder", IsActive: true, }) if err != nil { t.Fatalf("create plan ladder: %v", err) } state = h.DeriveSetupState(ctx) assertStep(t, state, "ladder", false) if _, err := bq.CreatePlanLadderTier(ctx, billing.CreatePlanLadderTierParams{ PlanLadderID: ladder.PlanLadderID, ProductID: product.ProductID, }); err != nil { t.Fatalf("create plan ladder tier: %v", err) } state = h.DeriveSetupState(ctx) assertStep(t, state, "ladder", true) // All four required steps are complete now; RequiredIncomplete flips, // even though the org-type-default conditional step is still open. if state.RequiredIncomplete() { t.Error("RequiredIncomplete() = true after all required steps completed") } assertStep(t, state, "org-type-default", false) // 6. Org-type default plan (conditional; last step in the chain). orgType := "setup-test-" + uuid.New().String()[:8] if _, 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, "Setup Test Org Type", ladder.PlanLadderID, ); err != nil { t.Fatalf("seed org type: %v", err) } state = h.DeriveSetupState(ctx) assertStep(t, state, "org-type-default", true) if state.RequiredIncomplete() { t.Error("RequiredIncomplete() = true once every required and conditional step is complete") } } // Entitlement sets have no hard delete in this app: "deleting" one is // UpdateEntitlementSet with IsActive=false, the same action the operator // entitlement-sets page's Hidden toggle performs. AnyActiveEntitlementSet // reads is_active directly, so hiding the last one must un-complete the // step on the very next render — completion is derived, never sticky. func TestSetupStateEntitlementSetDeactivationUncompletesStep(t *testing.T) { database := testDB(t) cleanSetupSlate(t, database) ctx := context.Background() tx, err := database.BeginTx(ctx, nil) if err != nil { t.Fatal(err) } defer tx.Rollback() eq := entitlements.New(tx) h := setupHandler(tx) es, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{ Name: "setup-test-deactivate-set-" + uuid.New().String()[:8], IsActive: true, }) if err != nil { t.Fatalf("create entitlement set: %v", err) } state := h.DeriveSetupState(ctx) assertStep(t, state, "entitlement-set", true) if _, err := eq.UpdateEntitlementSet(ctx, entitlements.UpdateEntitlementSetParams{ SetID: es.SetID, Name: es.Name, IsActive: false, }); err != nil { t.Fatalf("deactivate entitlement set: %v", err) } state = h.DeriveSetupState(ctx) assertStep(t, state, "entitlement-set", false) if !state.RequiredIncomplete() { t.Error("RequiredIncomplete() = false after the last entitlement set was deactivated") } }