Files
member-console/internal/server/operator_empty_states_render_test.go
cgalo5758 71818de0bd Add setup checklist and empty-state guidance
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.
2026-08-23 03:06:11 -05:00

242 lines
9.5 KiB
Go

package server
// Render tests for the ux-first-run empty/blocked-state work: the shared
// "emptyState" define (operator_empty_state.html, task 3.1) plus every
// screen that consumes it or its equivalent inline markup (3.2-3.5, 4.1-4.3).
// No DB needed -- these execute the real templates against literal view
// models, mirroring the existing renderGrantsIndex / renderOrgTypesPageBody
// / renderPlanLaddersPageBody pattern (each in this package's own
// *_render_test.go file).
import (
"bytes"
"html/template"
"io/fs"
"strings"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/embeds"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
)
// renderOperatorPartial executes one named template from the real operator
// partials set (which auto-loads operator_empty_state.html alongside every
// other operator_*.html file) against data.
func renderOperatorPartial(t *testing.T, name string, data any) string {
t.Helper()
partialsSub, err := fs.Sub(embeds.Templates, "templates/partials")
if err != nil {
t.Fatalf("fs.Sub partials: %v", err)
}
tmpl := template.New("operator").Funcs(template.FuncMap{
"renderBody": func(string, any) (template.HTML, error) { return "", nil },
"routeURL": web.RouteURL,
"stripeEntityURL": func(string, string) string { return "" },
"fieldErr": func(activeForm, expectedForm, activeScope, expectedScope string, errs web.FieldErrors, field string) string {
return ""
},
})
tmpl, err = tmpl.ParseFS(partialsSub, "operator_*.html")
if err != nil {
t.Fatalf("ParseFS: %v", err)
}
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
t.Fatalf("ExecuteTemplate %s: %v", name, err)
}
return buf.String()
}
// --- 3.1: the shared define itself ---
func TestEmptyStateDefine_Blocked(t *testing.T) {
out := renderOperatorPartial(t, "emptyState", EmptyStateParams{
Blocked: true,
BlockerCopy: "A widget must exist first.",
PrerequisiteURL: "/operator/widgets",
PrerequisiteLabel: "Go to Widgets",
})
if !strings.Contains(out, "alert-warning") {
t.Errorf("expected the blocked branch to use the warning alert styling, got:\n%s", out)
}
if !strings.Contains(out, "A widget must exist first.") {
t.Errorf("expected the blocker copy, got:\n%s", out)
}
if !strings.Contains(out, `href="/operator/widgets"`) || !strings.Contains(out, "Go to Widgets") {
t.Errorf("expected the prerequisite link, got:\n%s", out)
}
}
func TestEmptyStateDefine_NotBlocked(t *testing.T) {
out := renderOperatorPartial(t, "emptyState", EmptyStateParams{
Headline: "No widgets yet.",
Note: "Widgets are optional.",
ActionURL: "#createWidgetForm",
ActionLabel: "Create Widget",
})
if strings.Contains(out, "alert-warning") {
t.Errorf("must not use the blocked styling when not blocked, got:\n%s", out)
}
if !strings.Contains(out, "No widgets yet.") || !strings.Contains(out, "Widgets are optional.") {
t.Errorf("expected the headline and note, got:\n%s", out)
}
if !strings.Contains(out, `href="#createWidgetForm"`) || !strings.Contains(out, "Create Widget") {
t.Errorf("expected the primary action, got:\n%s", out)
}
}
// --- 3.2 / 4.1: Products ---
func TestProductsPartial_Blocked(t *testing.T) {
out := renderOperatorPartial(t, "operator_products.html", OperatorProductsData{
EntitlementSetGate: EmptyStateParams{
Blocked: true,
BlockerCopy: "Products need an entitlement set to define what they deliver. Create one before creating a product.",
PrerequisiteURL: "/operator/entitlement-sets",
PrerequisiteLabel: "Go to Entitlement Sets",
},
})
if strings.Contains(out, `id="createProductForm"`) {
t.Errorf("must not render the enabled form with no entitlement set to pick, got:\n%s", out)
}
if strings.Count(out, "Products need an entitlement set") == 0 {
t.Errorf("expected the blocked explanation, got:\n%s", out)
}
if !strings.Contains(out, `href="/operator/entitlement-sets"`) {
t.Errorf("expected a link to Entitlement Sets, got:\n%s", out)
}
}
func TestProductsPartial_EmptyButUnblocked(t *testing.T) {
out := renderOperatorPartial(t, "operator_products.html", OperatorProductsData{
EntitlementSets: []EntitlementSetOption{{SetID: "s1", Name: "Standard"}},
EntitlementSetGate: EmptyStateParams{
Headline: "No products yet.",
ActionURL: "#createProductForm",
ActionLabel: "Create Product",
},
})
if !strings.Contains(out, `id="createProductForm"`) {
t.Errorf("expected the enabled create-product form, got:\n%s", out)
}
if !strings.Contains(out, "No products yet.") {
t.Errorf("expected the actionable empty-list state, got:\n%s", out)
}
if strings.Contains(out, "Products need an entitlement set") {
t.Errorf("must not render blocked copy once a set exists, got:\n%s", out)
}
}
func TestProductsPartial_NonEmptyListSkipsEmptyState(t *testing.T) {
out := renderOperatorPartial(t, "operator_products.html", OperatorProductsData{
EntitlementSets: []EntitlementSetOption{{SetID: "s1", Name: "Standard"}},
Products: []OperatorProductViewModel{{ProductID: "p1", Name: "Widget Plan", EntitlementSetName: "Standard"}},
})
if strings.Contains(out, "No products yet.") {
t.Errorf("must not render the empty state when products exist, got:\n%s", out)
}
if !strings.Contains(out, "Widget Plan") {
t.Errorf("expected the product row, got:\n%s", out)
}
}
// --- 3.4 / 4.3: Entitlement Sets ---
func TestEntitlementSetsPartial_HeaderExplanationSurvivesPastEmptyState(t *testing.T) {
out := renderOperatorPartial(t, "operator_entitlement_sets.html", EntitlementSetsData{
EntitlementSets: []EntitlementSetViewModel{{SetID: "s1", Name: "Standard"}},
})
if !strings.Contains(out, "every product must reference exactly one set") {
t.Errorf("expected the always-on header explanation of what a set unlocks, got:\n%s", out)
}
}
func TestEntitlementSetsPartial_EmptyStateTwoEndedCopy(t *testing.T) {
out := renderOperatorPartial(t, "operator_entitlement_sets.html", EntitlementSetsData{
Empty: EmptyStateParams{
Headline: "No entitlement sets yet.",
Note: "Rules feed into a set, and products consume it. Creating a set is the first step toward a sellable product.",
ActionURL: "#createEntitlementSetForm",
ActionLabel: "Create Entitlement Set",
},
})
if !strings.Contains(out, "Rules feed into a set") || !strings.Contains(out, "products consume it") {
t.Errorf("expected the two-ended dependency copy, got:\n%s", out)
}
if !strings.Contains(out, `href="#createEntitlementSetForm"`) {
t.Errorf("expected the create action, got:\n%s", out)
}
}
// --- 3.3: Grants ---
func TestGrantsPartial_BlockedOnZeroPublishedProducts(t *testing.T) {
out := renderOperatorPartial(t, "operator_grants.html", GrantsData{})
if !strings.Contains(out, "No grants found.") {
t.Errorf("expected the base empty-state line to still render (pre-existing contract), got:\n%s", out)
}
if !strings.Contains(out, "no product has been published yet") {
t.Errorf("expected the blocked explanation, got:\n%s", out)
}
if !strings.Contains(out, `href="/operator/products"`) {
t.Errorf("expected a link to Products, got:\n%s", out)
}
}
func TestGrantsPartial_EmptyPointsAtIssueFlow(t *testing.T) {
out := renderOperatorPartial(t, "operator_grants.html", GrantsData{
Products: []ProductViewModel{{ProductID: "p1", Name: "Standard"}},
})
if !strings.Contains(out, "issued from each organization's detail page") {
t.Errorf("expected the empty state to point at the issue flow, got:\n%s", out)
}
if strings.Contains(out, "no product has been published yet") {
t.Errorf("must not render the blocked copy once a product exists, got:\n%s", out)
}
}
// --- 3.5: Plan Ladders ---
func TestPlanLaddersPartial_EmptyStateActionable(t *testing.T) {
out := renderOperatorPartial(t, "operator_plan_ladders.html", PlanLaddersData{})
if !strings.Contains(out, "No plan ladders yet.") {
t.Errorf("expected the empty headline, got:\n%s", out)
}
if !strings.Contains(out, "its tiers get attached to a product") {
t.Errorf("expected the tiers-need-a-product dependency note, got:\n%s", out)
}
if !strings.Contains(out, `href="#createPlanLadderForm"`) {
t.Errorf("expected an actionable Create Plan Ladder link, got:\n%s", out)
}
}
// --- 4.2: Org Types card ---
func TestOrgTypeCardPartial_ZeroLaddersShowsPrerequisite(t *testing.T) {
out := renderOperatorPartial(t, "operator_org_type_card.html", OrgTypeCardData{
OrgTypeViewModel: OrgTypeViewModel{OrgType: "personal", DisplayName: "Personal", IsActive: true},
})
if strings.Contains(out, `name="default_plan_ladder_id"`) {
t.Errorf("must not render a None-only dropdown when no plan ladders exist, got:\n%s", out)
}
if !strings.Contains(out, "must exist before this type can have a default plan") {
t.Errorf("expected the prerequisite note, got:\n%s", out)
}
if !strings.Contains(out, `href="/operator/plan-ladders"`) {
t.Errorf("expected a link to Plan Ladders, got:\n%s", out)
}
}
func TestOrgTypeCardPartial_WithLaddersShowsDropdown(t *testing.T) {
out := renderOperatorPartial(t, "operator_org_type_card.html", OrgTypeCardData{
OrgTypeViewModel: OrgTypeViewModel{OrgType: "personal", DisplayName: "Personal", IsActive: true},
PlanLadders: []PlanLadderOption{{PlanLadderID: "l1", LadderName: "Standard", RankZeroProductName: "Free", HasRankZero: true}},
})
if !strings.Contains(out, `name="default_plan_ladder_id"`) {
t.Errorf("expected the dropdown once plan ladders exist, got:\n%s", out)
}
if strings.Contains(out, "must exist before this type can have a default plan") {
t.Errorf("must not render the prerequisite note once plan ladders exist, got:\n%s", out)
}
}