Files
member-console/internal/server/setup_state_test.go
T
cgalo5758 3727ff31d8 Add entitlement set rule change ledger and preview flow
Add an append-only ledger of entitlement set rule changes with per-pool
effect rows, a preview-and-commit rule change flow, and an automatic
drain that settles deferred recomputations. Rules gain a tier reduction
policy, resource keys declare over-limit behavior, and the materializer
now lowers limits when a rule stops applying.
Add entitlement set rule change ledger and preview flow

Add an append-only ledger of entitlement set rule changes with a
preview-and-commit operator flow. Rule writes now go through an enclosed
`core.commit_rule_change` function that files an act row and one
obligation per carrying pool, with a drain workflow settling deferred
recomputations. The preview dry-runs the materializer with a rule
overlay and renders per-pool buckets, reduction-policy disclosures, and
provider over-limit consequences. Materializing transactions take a
shared advisory rendezvous that rule changes hold exclusively, enforced
by a possession assertion. Add History and Entitlement changes surfaces,
a rule-less warning on five product-selection surfaces, and a
`tier_reduction_policy` column that gates FedWiki parking.
2026-09-15 03:53:28 -05:00

436 lines
16 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server_test
import (
"context"
"database/sql"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/alexedwards/scs/v2"
"github.com/google/uuid"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/instance"
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"
)
// DB-backed derivation coverage for the operator setup checklist (design
// D12 / operator-setup-checklist). 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.
// InstanceSettings is left nil, matching production's degrade-to-
// "never dismissed" behavior for a handler that doesn't wire one up.
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
// overview banner's visibility predicate agrees (design D12: "Steps SHALL
// be equal" — no required/conditional split, so the banner shows whenever
// any step at all is outstanding).
func TestSetupStateFreshDatabaseAllStepsIncomplete(t *testing.T) {
database := testDB(t)
cleanSetupSlate(t, database)
ctx := context.Background()
tx, err := entitlements.BeginMaterializing(ctx, database, nil)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
state := setupHandler(tx).DeriveSetupState(ctx)
if len(state.Steps) != 6 {
t.Fatalf("got %d steps, want 6 (integrations, entitlement-set, product, price-sync, ladder, org-type-default)", len(state.Steps))
}
if state.Steps[0].Key != "integrations" {
t.Errorf("Steps[0].Key = %q, want %q (design D12: integrations is first)", state.Steps[0].Key, "integrations")
}
for _, s := range state.Steps {
if s.Complete {
t.Errorf("step %q reported complete on a fresh database", s.Key)
}
}
if state.DoneCount() != 0 {
t.Errorf("DoneCount() = %d, want 0 on a fresh database", state.DoneCount())
}
if state.Total() != 6 {
t.Errorf("Total() = %d, want 6", state.Total())
}
if !state.ShowBanner() {
t.Error("ShowBanner() = false on a fresh database, want true (nothing dismissed, every step incomplete)")
}
}
// operator-setup-checklist, "The entitlement-set step names the product
// route": with no set yet, the step names the second route to one and
// links the product create page, and neither it nor the Products step
// tells the operator to do the other first (design D14).
func TestSetupStateEntitlementSetStepNamesTheProductRoute(t *testing.T) {
database := testDB(t)
cleanSetupSlate(t, database)
ctx := context.Background()
tx, err := entitlements.BeginMaterializing(ctx, database, nil)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
state := setupHandler(tx).DeriveSetupState(ctx)
var set, product server.SetupStep
for _, s := range state.Steps {
switch s.Key {
case "entitlement-set":
set = s
case "product":
product = s
}
}
if set.Note != "Or create one with your first product." {
t.Errorf("entitlement-set Note = %q, want the second route named", set.Note)
}
if set.NoteHref != "/operator/products/new" {
t.Errorf("entitlement-set NoteHref = %q, want the product create page", set.NoteHref)
}
if set.NoteLinkText == "" {
t.Error("the note's link carries no text")
}
for _, copy := range []string{set.Description, set.Unlocks, product.Description, product.Unlocks, product.Note} {
if strings.Contains(strings.ToLower(copy), "first create") || strings.Contains(strings.ToLower(copy), "before creating") {
t.Errorf("no step's copy may tell the operator to complete the other first, got %q", copy)
}
}
}
// 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. This
// fixture never configures an integration, so the "integrations" step
// stays incomplete throughout (by design: with zero configured integrations
// the step cannot derive complete), which the DoneCount checks below
// account for.
func TestSetupStateDerivationFlipsPerStep(t *testing.T) {
database := testDB(t)
cleanSetupSlate(t, database)
ctx := context.Background()
tx, err := entitlements.BeginMaterializing(ctx, database, 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: 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).
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)
// Four of six steps are done now (entitlement-set, product, price-sync,
// ladder); integrations (never configured in this fixture) and
// org-type-default remain outstanding. Every step counts equally now
// (design D12, round 2: no required/conditional split), so DoneCount
// reflects exactly that, not a "required steps only" subset.
if got, want := state.DoneCount(), 4; got != want {
t.Errorf("DoneCount() = %d, want %d after entitlement-set/product/price-sync/ladder", got, want)
}
assertStep(t, state, "org-type-default", false)
// 6. Org-type default plan (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)
// Five of six steps are done; "integrations" alone stays incomplete
// because this fixture never configures one — the checklist honestly
// reflects that rather than crediting a step nothing satisfied.
if got, want := state.DoneCount(), 5; got != want {
t.Errorf("DoneCount() = %d, want %d once every step but integrations is complete", got, want)
}
assertStep(t, state, "integrations", false)
}
// 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 := entitlements.BeginMaterializing(ctx, database, 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)
}
// TestDismissSetupBannerWritesInstanceSetting is a DB-backed handler test
// for the setup banner's dismiss route (design D12/D28,
// POST /partials/operator/setup/dismiss): the handler writes the instance
// setting deployment-wide, recording the signed-in operator as the actor,
// and responds with an empty 200 body — the banner's own
// hx-swap="outerHTML" removes it with nothing left in its place. Runs
// against the real (non-transactional) test database, like
// internal/instance's own settings_db_test.go, because
// OperatorPartialsHandler.Database is a concrete *sql.DB the handler opens
// its own instance.Store from; the write is cleaned up afterward.
func TestDismissSetupBannerWritesInstanceSetting(t *testing.T) {
database := testDB(t)
ctx := context.Background()
t.Cleanup(func() {
if _, err := database.ExecContext(ctx,
`DELETE FROM core.instance_settings WHERE key = $1`, string(instance.SetupBannerDismissed)); err != nil {
t.Errorf("cleanup instance setting: %v", err)
}
})
sm := scs.New()
authCfg := &auth.Config{SessionManager: sm}
handler, err := server.NewOperatorPartialsHandler(server.OperatorPartialsConfig{
Database: database,
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
AuthConfig: authCfg,
})
if err != nil {
t.Fatalf("NewOperatorPartialsHandler: %v", err)
}
sessionCtx, err := sm.Load(ctx, "")
if err != nil {
t.Fatalf("session Load: %v", err)
}
sm.Put(sessionCtx, "authenticated", true)
sm.Put(sessionCtx, "email", "op@example.com")
sm.Put(sessionCtx, "roles", []string{server.OperatorRole})
req := httptest.NewRequestWithContext(sessionCtx, http.MethodPost, "/partials/operator/setup/dismiss", nil)
rec := httptest.NewRecorder()
handler.DismissSetupBanner(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("DismissSetupBanner status = %d, want 200, body: %s", rec.Code, rec.Body.String())
}
if body := rec.Body.String(); body != "" {
t.Errorf("DismissSetupBanner body = %q, want empty", body)
}
store := instance.NewStore(database)
dismissed, err := store.GetBool(ctx, instance.SetupBannerDismissed)
if err != nil {
t.Fatalf("GetBool after dismiss: %v", err)
}
if !dismissed {
t.Error("instance setting not recorded true after DismissSetupBanner")
}
var updatedBy sql.NullString
if err := database.QueryRowContext(ctx,
`SELECT updated_by FROM core.instance_settings WHERE key = $1`, string(instance.SetupBannerDismissed),
).Scan(&updatedBy); err != nil {
t.Fatalf("read updated_by: %v", err)
}
if !updatedBy.Valid || updatedBy.String != "op@example.com" {
t.Errorf("updated_by = %+v, want op@example.com", updatedBy)
}
}