// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo package server import ( "bytes" "html/template" "io/fs" "regexp" "strings" "testing" "git.coopcloud.tech/wiki-cafe/member-console/internal/config" "git.coopcloud.tech/wiki-cafe/member-console/internal/embeds" "git.coopcloud.tech/wiki-cafe/member-console/internal/web" ) // checklistPageTemplate parses the same template set NewOperatorHandler // builds, renderBody included — unlike overviewTemplate (in // operator_overview_render_test.go), whose renderBody is a landing-only // no-op stub. GetSetupPage dispatches through BodyTemplate, so exercising it // needs the real dispatcher. func checklistPageTemplate(t *testing.T) *template.Template { t.Helper() sub, err := fs.Sub(embeds.Templates, "templates") if err != nil { t.Fatalf("fs.Sub: %v", err) } var tmpl *template.Template tmpl = template.New("operator").Funcs(template.FuncMap{ "renderBody": func(name string, data any) (template.HTML, error) { var buf bytes.Buffer if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil { return "", err } return template.HTML(buf.String()), nil }, "deploymentName": config.DeploymentName, "pageTitle": pageTitle, }) if tmpl, err = web.ParseUIPartials(template.Must(tmpl.ParseFS(sub, "operator.html", "partials/operator_lookup_result.html", "partials/operator_setup.html"))); err != nil { t.Fatalf("ParseFS: %v", err) } return tmpl } func renderChecklistPage(t *testing.T, data OperatorPageData) string { t.Helper() var buf bytes.Buffer if err := checklistPageTemplate(t).ExecuteTemplate(&buf, "operator.html", data); err != nil { t.Fatalf("ExecuteTemplate: %v", err) } return buf.String() } // Render coverage for the setup checklist (setup_state.go, operator_setup.go, // partials/operator_setup.html; design D12 / operator-setup-checklist) and // the operator overview's setup banner (operator.html; design D12's "The // overview carries a dismissible setup banner"). Template-level only, // executed against hand-built OperatorPageData/SetupState the same way // operator_overview_render_test.go covers the landing surface's other // regions — reuses that file's overviewTemplate/renderOperator/ // landingRegion/populatedOverview helpers (same package, same test binary). // Live-state derivation is covered separately by setup_state_test.go's // DB-backed tests. // mixedSetupState is a representative checklist mid-progress: two steps // done (entitlement-set, product), four still open, including the two // whose relevance depends on the deployment's model (price-sync, // org-type-default) and the price step's Stripe-not-configured note. Every // step is equal now (design D12, round 2): no Required/Condition fields, // no "Required" label, one tone per completion state. func mixedSetupState() SetupState { return SetupState{ Steps: []SetupStep{ { Key: "integrations", Label: "Configure integrations", Description: "Integrations deliver what a product confers through a connected service. Needed when a product delivers through a connected service.", Complete: false, Href: "/operator/integrations", LinkText: "Go to integrations", Unlocks: "Selling or granting a product that delivers through that service.", }, { Key: "entitlement-set", Label: "Create an entitlement set", Description: "Entitlement sets define what a product grants; every product needs one.", Complete: true, Href: "/operator/entitlement-sets", LinkText: "Go to entitlement sets", Unlocks: "Creating a product that uses it.", }, { Key: "product", Label: "Create a product", Description: "Products are what this deployment grants or sells; each product uses an entitlement set and is published as soon as it is created.", Complete: true, Href: "/operator/products", LinkText: "Go to products", Unlocks: "Adding the product to a plan ladder, and selling it.", }, { Key: "price-sync", Label: "Add an active price and sync it to Stripe", Description: "A price makes a product chargeable; syncing it creates the matching Stripe price. Needed only when products are sold for money.", Complete: false, Href: "/operator/products", LinkText: "Go to products", Unlocks: "Charging members for this product.", Note: "Stripe integration is not configured yet; missing stripe-api-key.", NoteHref: "/operator/integrations/stripe/settings", NoteLinkText: "Go to Stripe settings", }, { Key: "ladder", Label: "Build a plan ladder with this product as a tier", Description: "Plan ladders group the products members move between; the rank-0 tier is the deployment's base plan and is what completes this step.", Complete: false, Href: "/operator/plan-ladders", LinkText: "Go to plan ladders", Unlocks: "Choosing an org-type default plan.", }, { Key: "org-type-default", Label: "Choose an org-type default plan", Description: "An org-type default plan is conferred automatically on signup. Needed only when signup should confer a plan automatically.", Complete: false, Href: "/operator/org-types", LinkText: "Go to org types", }, }, } } func TestSetupChecklistPageRendersAllSteps(t *testing.T) { out := renderChecklistPage(t, OperatorPageData{ IAPosition: "runtime:setup", BodyTemplate: "operator_setup.html", BodyData: mixedSetupState(), }) for _, want := range []string{ "

Setup checklist

", "Configure integrations", "Create an entitlement set", "Create a product", "Add an active price and sync it to Stripe", "Build a plan ladder with this product as a tier", "Choose an org-type default plan", // A step whose relevance depends on the deployment's model says so // in its own copy (design D12, round 2: the Condition class is // gone). "Needed when a product delivers through a connected service.", "Needed only when products are sold for money.", "Needed only when signup should confer a plan automatically.", // Each incomplete step names the downstream step it feeds. "Unlocks: Selling or granting a product that delivers through that service.", "Unlocks: Charging members for this product.", "Unlocks: Choosing an org-type default plan.", // Links go straight to the surface where the step is completed. `href="/operator/integrations"`, `href="/operator/entitlement-sets"`, `href="/operator/products"`, `href="/operator/plan-ladders"`, `href="/operator/org-types"`, // The Stripe-configuration note on the price step, with its own link. "Stripe integration is not configured yet", `href="/operator/integrations/stripe/settings"`, // design D12 round 2: one tone per completion state, no exceptions. `Done`, `Incomplete`, } { if !strings.Contains(out, want) { t.Errorf("setup checklist page missing %q", want) } } // The retired required/conditional vocabulary must not appear. for _, forbidden := range []string{"Required", "Optional", "Not yet"} { if strings.Contains(out, forbidden) { t.Errorf("setup checklist page still uses the retired %q marker", forbidden) } } // Two steps done (entitlement-set, product), four incomplete. if n := strings.Count(out, `Done`); n != 2 { t.Errorf("setup checklist page shows %d Done badges, want 2", n) } if n := strings.Count(out, `Incomplete`); n != 4 { t.Errorf("setup checklist page shows %d Incomplete badges, want 4", n) } // A completed step's Unlocks line is not shown — it has nothing left to // unlock. if strings.Contains(out, "Unlocks: Creating a product that uses it.") { t.Errorf("completed entitlement-set step still rendered its Unlocks line") } if strings.Contains(out, "Unlocks: Adding the product to a plan ladder, and selling it.") { t.Errorf("completed product step still rendered its Unlocks line") } } // /operator/setup always renders, including when every step is complete — // it is persistent and revisitable, never a wizard that disappears once // done. func TestSetupChecklistPageRendersWhenEverythingComplete(t *testing.T) { state := mixedSetupState() for i := range state.Steps { state.Steps[i].Complete = true } out := renderChecklistPage(t, OperatorPageData{ BodyTemplate: "operator_setup.html", BodyData: state, }) if !strings.Contains(out, "Setup checklist") { t.Errorf("fully-complete checklist page dropped its own heading") } // design D12 round 2: every step renders "Done" in the SAME success // tone — no split between a required and a conditional completion. if n := strings.Count(out, `Done`); n != 6 { t.Errorf("fully-complete checklist page shows %d success-tone Done badges, want 6 (every step)", n) } if strings.Contains(out, `text-bg-secondary">Done`) { t.Errorf("fully-complete checklist page still renders a neutral-tone Done badge") } if strings.Contains(out, "Unlocks:") { t.Errorf("fully-complete checklist page still names an unlock (every step is done)") } } // Strict CSP: nothing on this surface may rely on an inline script, an // inline event handler, or a style attribute. func TestSetupChecklistIsCSPClean(t *testing.T) { out := renderChecklistPage(t, OperatorPageData{ BodyTemplate: "operator_setup.html", BodyData: mixedSetupState(), }) if regexp.MustCompile(`(?i)]*)?>[^<]`).MatchString(out) { t.Errorf("setup checklist contains an inline