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)