Implement the ux-first-run change: a state-derived setup checklist on /operator/setup with a landing region that recedes once required steps are done, and empty states that distinguish blocked from empty across operator and member surfaces. Also add production deployment and environment reference docs, plus a config-key completeness test.
291 lines
11 KiB
Go
291 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"
|
|
)
|
|
|
|
// 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{
|
|
"<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")
|
|
}
|
|
}
|