Files
cgalo5758 71818de0bd Add setup checklist and empty-state guidance
Implement the ux-first-run change: a state-derived setup checklist on
/operator/setup with a landing region that recedes once required steps
are done, and empty states that distinguish blocked from empty across
operator and member surfaces. Also add production deployment and
environment reference docs, plus a config-key completeness test.
2026-08-23 03:06:11 -05:00

295 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{
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")
}
}