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" ) // 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, }) if tmpl, err = 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; openspec change ux-first-run). 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: the first two // required steps done, the last required step (ladder) still open, and both // conditional steps open with their conditions and — for the price step — a // Stripe-not-configured note. func mixedSetupState() SetupState { return SetupState{ Steps: []SetupStep{ { Key: "entitlement-set", Label: "Create an entitlement set", Description: "Entitlement sets define what a product grants; every product needs one.", Required: true, 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 one uses an entitlement set and is published as soon as it is created.", Required: true, 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.", Required: false, Condition: "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", }, { Key: "ladder", Label: "Build a plan ladder with this product as a tier", Description: "Plan ladders group products members move between; the rank-0 tier is the deployment's base plan and is what completes this step.", Required: true, 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.", Required: false, Condition: "Needed only when signup should confer a plan automatically.", Complete: false, Href: "/operator/org-types", LinkText: "Go to org types", }, }, } } // allRequiredCompleteSetupState is the same checklist with every REQUIRED // step done and both conditional steps still open — the case where the // landing region must stop rendering while /operator/setup must keep // showing the two outstanding conditional steps with their conditions. func allRequiredCompleteSetupState() SetupState { state := mixedSetupState() for i := range state.Steps { if state.Steps[i].Required { state.Steps[i].Complete = true } } return state } func TestSetupChecklistPageRendersAllSteps(t *testing.T) { state := mixedSetupState() state.FullPage = true out := renderChecklistPage(t, OperatorPageData{ CSRFToken: "csrf", IAPosition: "runtime:setup", BodyTemplate: "operator_setup.html", BodyData: state, }) for _, want := range []string{ "

Setup checklist

", "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", // Conditions state why a conditional step applies, since the system // cannot judge the deployment's business model from data. "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: 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/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"`, // Status badges. `Done`, `Incomplete`, `Optional`, } { if !strings.Contains(out, want) { t.Errorf("setup checklist page missing %q", want) } } // 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 (required and // conditional) is complete — it is persistent and revisitable, never a // wizard that disappears once done. func TestSetupChecklistPageRendersWhenEverythingComplete(t *testing.T) { state := mixedSetupState() state.FullPage = true for i := range state.Steps { state.Steps[i].Complete = true } out := renderChecklistPage(t, OperatorPageData{ CSRFToken: "csrf", BodyTemplate: "operator_setup.html", BodyData: state, }) if !strings.Contains(out, "Setup checklist") { t.Errorf("fully-complete checklist page dropped its own heading") } if n := strings.Count(out, `Done`); n != 5 { t.Errorf("fully-complete checklist page shows %d Done badges, want 5", n) } if strings.Contains(out, "Unlocks:") { t.Errorf("fully-complete checklist page still names an unlock (every step is done)") } } // The landing region renders ahead of the "At a glance" tiles while any // REQUIRED step is incomplete. func TestLandingRegionRendersAheadOfTilesWhenRequiredIncomplete(t *testing.T) { data := populatedOverview() data.Setup = mixedSetupState() // FullPage defaults false: landing rendering out := landingRegion(t, renderOperator(t, data)) setupIdx := strings.Index(out, `id="overview-setup-heading"`) glanceIdx := strings.Index(out, `id="overview-glance-heading"`) if setupIdx < 0 { t.Fatal("landing surface did not render the setup region") } if glanceIdx < 0 { t.Fatal("landing surface did not render the At a glance region") } if setupIdx > glanceIdx { t.Errorf("setup region (%d) did not render ahead of At a glance (%d)", setupIdx, glanceIdx) } if !strings.Contains(out, "Build a plan ladder with this product as a tier") { t.Errorf("landing setup region dropped an incomplete step") } if !strings.Contains(out, `href="/operator/setup"`) { t.Errorf("landing setup region dropped its link to the full checklist") } } // Once every REQUIRED step is complete the landing region stops rendering, // even though conditional steps remain outstanding — but the tiles below it // still render normally. func TestLandingRegionAbsentWhenOnlyConditionalStepsRemain(t *testing.T) { data := populatedOverview() data.Setup = allRequiredCompleteSetupState() out := landingRegion(t, renderOperator(t, data)) if strings.Contains(out, `id="overview-setup-heading"`) { t.Errorf("landing surface rendered the setup region though every required step is complete") } if strings.Contains(out, "Build a plan ladder with this product as a tier") { t.Errorf("landing surface leaked setup-step content though the region should be absent") } if !strings.Contains(out, `id="overview-glance-heading"`) { t.Errorf("landing surface dropped the At a glance tiles when the setup region was omitted") } } // The checklist page itself is unaffected by the landing region's // visibility rule: it keeps showing incomplete conditional steps with their // conditions even once every required step is done. func TestSetupChecklistPageKeepsConditionalStepsAfterRequiredComplete(t *testing.T) { state := allRequiredCompleteSetupState() state.FullPage = true out := renderChecklistPage(t, OperatorPageData{ CSRFToken: "csrf", BodyTemplate: "operator_setup.html", BodyData: state, }) for _, want := range []string{ "Add an active price and sync it to Stripe", "Needed only when products are sold for money.", "Choose an org-type default plan", "Needed only when signup should confer a plan automatically.", } { if !strings.Contains(out, want) { t.Errorf("checklist page dropped outstanding conditional step content %q", want) } } } // 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) { state := mixedSetupState() state.FullPage = true out := renderChecklistPage(t, OperatorPageData{ CSRFToken: "csrf", BodyTemplate: "operator_setup.html", BodyData: state, }) if regexp.MustCompile(`(?i)]*)?>[^<]`).MatchString(out) { t.Errorf("setup checklist contains an inline