- Add shared ui_*.html parts (pageHeader, sectionHeader, statusBadge, emptyState) parsed into every template set - Add anatomy lint rules with a shrinking allowlist and screen-coverage check - Add make screens capture harness with contact sheets and baseline diff - Compose member and FedWiki regions server-side so pages arrive complete - Rebuild Domains and Integrations on the parts as pilots
292 lines
11 KiB
Go
292 lines
11 KiB
Go
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,
|
|
})
|
|
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; 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{
|
|
"<h1 class=\"h2 mb-1\">Setup checklist</h1>",
|
|
"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.
|
|
`<span class="badge text-bg-success">Done</span>`,
|
|
`<span class="badge text-bg-warning">Incomplete</span>`,
|
|
`<span class="badge text-bg-light text-muted ms-1">Optional</span>`,
|
|
} {
|
|
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, `<span class="badge text-bg-success">Done</span>`); 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)<script(\s[^>]*)?>[^<]`).MatchString(out) {
|
|
t.Errorf("setup checklist contains an inline <script> body")
|
|
}
|
|
if m := regexp.MustCompile(`(?i)\son(click|load|change|submit|input|focus|error)\s*=`).FindString(out); m != "" {
|
|
t.Errorf("setup checklist contains an inline event handler: %q", strings.TrimSpace(m))
|
|
}
|
|
if regexp.MustCompile(`(?i)\sstyle\s*=\s*"`).MatchString(out) {
|
|
t.Errorf("setup checklist contains an inline style attribute")
|
|
}
|
|
}
|