// 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) } }