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.
294 lines
10 KiB
Go
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")
|
|
}
|
|
}
|