Files
member-console/internal/server/setup_state_test.go
T
cgalo5758 dd3962990b Adopt entity keys and add invoice numbers
Replace the entity slugs on organizations, workspaces, resource pools,
and
plan ladders with nullable `key` columns and add keys to products,
prices,
and entitlement sets. Rename `providers.slug` to `provider` and add
partial
unique indexes for system and org role names.

Assign invoice numbers per billing account from a gapless transactional
counter; Stripe's number moves to the invoice mapping as an external
reference.

Seeds, fixtures, and the operator lookup address rows by key, and the
returning-login resync no longer blanks a display name when the IdP
sends
no `name` claim.
2026-08-29 20:12:04 -05:00

294 lines
10 KiB
Go

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{
Name: "Setup Test Ladder " + uuid.New().String()[:8],
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")
}
}