Derive Stripe test/live mode from the API key prefix at boot, failing on unrecognized prefixes, and drop the separate `stripe-mode` config key. Refine disabled controls to render through the shared `disabledControl` part with the not-allowed cursor, and add a lint rule refusing hand-rolled disabled buttons. Adjust plan cards to offer no purchase control on free rungs, fix bound checkbox Bool handling, and rename "Public/Private" to "Listed/Unlisted" with enhanced readiness verdicts.
333 lines
14 KiB
Go
333 lines
14 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
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/forms"
|
|
"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 "" },
|
|
"helpIcon": helpIcon,
|
|
"fieldErr": func(activeForm, expectedForm, activeScope, expectedScope string, errs web.FieldErrors, field string) string {
|
|
return ""
|
|
},
|
|
})
|
|
tmpl, err = web.ParseUIPartials(template.Must(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.",
|
|
},
|
|
})
|
|
// design D20 (round 4): the create form lives on its own page; the list
|
|
// carries the filled "New product" link to it, and the empty state says
|
|
// only that the list is empty.
|
|
if strings.Contains(out, `id="createProductForm"`) {
|
|
t.Errorf("the create form belongs to /operator/products/new, not the list, got:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, `href="/operator/products/new" class="btn btn-sm btn-primary"`) {
|
|
t.Errorf("expected the list header's filled New product link, 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)
|
|
}
|
|
}
|
|
|
|
// TestProductNewPartial_NoSetIsNotBlocked pins the requirement the
|
|
// forms-library change removed (product-management, design D14): a
|
|
// deployment with no entitlement set is no longer a blocked state, because
|
|
// the select's first option creates the set with the product. The form
|
|
// renders, with that option as its only offered choice.
|
|
func TestProductNewPartial_NoSetIsNotBlocked(t *testing.T) {
|
|
out := renderOperatorPartial(t, "operator_product_new.html", ProductNewData{
|
|
Form: productCreateForm(nil, forms.NewValues(), nil),
|
|
})
|
|
if strings.Contains(out, "Products need an entitlement set") {
|
|
t.Errorf("the create page no longer blocks on a missing set, got:\n%s", out)
|
|
}
|
|
for _, want := range []string{
|
|
`data-form="operator.product"`,
|
|
`New set named after this product`,
|
|
} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("product create page missing %q, got:\n%s", want, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestProductNewPartial_Form pins the create page's own shape: the trail,
|
|
// the one filled commit, the Cancel link back to the list, the Visibility
|
|
// checkbox, and the three edit-only fields' absence (design D6, D8, D14).
|
|
func TestProductNewPartial_Form(t *testing.T) {
|
|
out := renderOperatorPartial(t, "operator_product_new.html", ProductNewData{
|
|
Form: productCreateForm([]EntitlementSetOption{{SetID: "s1", Name: "Standard"}}, forms.NewValues(), nil),
|
|
})
|
|
for _, want := range []string{
|
|
`<h1 class="h2 mb-0">New product</h1>`,
|
|
`<li class="breadcrumb-item"><a href="/operator/products">Products</a></li>`,
|
|
`<li class="breadcrumb-item active" aria-current="page">New product</li>`,
|
|
`id="form-operator.product"`,
|
|
`data-form-kind="create"`,
|
|
`<option value="new" selected>New set named after this product</option>`,
|
|
`Create product`,
|
|
`<a href="/operator/products" class="btn btn-outline-secondary">Cancel</a>`,
|
|
`type="checkbox" id="form-operator.product-visibility-control" name="visibility" value="public"`,
|
|
`aria-label="Help: Listed"`,
|
|
} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("product create page missing %q, got:\n%s", want, out)
|
|
}
|
|
}
|
|
// The three edit-only fields render neither control nor gap on the
|
|
// create side (product-management, "The three edit-only fields explain
|
|
// themselves").
|
|
for _, absent := range []string{`name="description"`, `name="features"`, `name="is_active"`} {
|
|
if strings.Contains(out, absent) {
|
|
t.Errorf("create page renders the edit-only field %q, got:\n%s", absent, out)
|
|
}
|
|
}
|
|
// One filled button on the page: the commit (design D19).
|
|
if n := strings.Count(out, "btn-primary"); n != 1 {
|
|
t.Errorf("expected exactly one filled button on the create page, found %d, got:\n%s", n, out)
|
|
}
|
|
// The radios the checkbox replaced are gone.
|
|
if strings.Contains(out, `type="radio" name="visibility"`) {
|
|
t.Errorf("Visibility is one checkbox now, not radios, 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"}},
|
|
})
|
|
// entitlement-set-management "The surface's copy says what a set
|
|
// provides" (design D21): the lead defines the noun; "unlocks" appears
|
|
// nowhere on the surface.
|
|
if !strings.Contains(out, "and defines what a product provides") {
|
|
t.Errorf("expected the always-on header explanation of what a set provides, got:\n%s", out)
|
|
}
|
|
if strings.Contains(out, "unlocks") {
|
|
t.Errorf("expected \"provides\", not \"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.",
|
|
},
|
|
})
|
|
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)
|
|
}
|
|
// design D20 (round 4): the create action is the list header's filled
|
|
// link to the create page, not an anchor on the empty state.
|
|
if !strings.Contains(out, `href="/operator/entitlement-sets/new" class="btn btn-sm btn-primary"`) {
|
|
t.Errorf("expected the list header's filled New entitlement set link, got:\n%s", out)
|
|
}
|
|
if strings.Contains(out, `id="createEntitlementSetForm"`) {
|
|
t.Errorf("the create form belongs to /operator/entitlement-sets/new, not the list, got:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// --- 3.3: Grants ---
|
|
|
|
func TestGrantsPartial_BlockedOnZeroPublishedProducts(t *testing.T) {
|
|
out := renderOperatorPartial(t, "operator_grants.html", GrantsData{})
|
|
if !strings.Contains(out, "alert alert-warning") {
|
|
t.Errorf("expected the blocked empty state to render as a warning alert (page-anatomy emptyState), 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)
|
|
}
|
|
// design D20 (round 4): the create form lives on its own page; the
|
|
// ladder list's section header carries the filled link to it, and the
|
|
// empty state carries no anchor action of its own.
|
|
if strings.Contains(out, `id="createPlanLadderForm"`) {
|
|
t.Errorf("the create form belongs to /operator/plan-ladders/new, not the list, got:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, `href="/operator/plan-ladders/new" class="btn btn-sm btn-primary"`) {
|
|
t.Errorf("expected the ladder list header's filled New plan ladder link, got:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// --- 4.2: Org Types card ---
|
|
|
|
func TestOrgTypeCardPartial_ZeroLaddersShowsPrerequisite(t *testing.T) {
|
|
data := OrgTypeCardData{
|
|
OrgTypeViewModel: OrgTypeViewModel{OrgType: "personal", DisplayName: "Personal", IsActive: true},
|
|
}
|
|
data.Form = orgTypeDefaultFormView(nil, data)
|
|
out := renderOperatorPartial(t, "operator_org_type_card.html", data)
|
|
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) {
|
|
data := OrgTypeCardData{
|
|
OrgTypeViewModel: OrgTypeViewModel{OrgType: "personal", DisplayName: "Personal", IsActive: true},
|
|
PlanLadders: []PlanLadderOption{{PlanLadderID: "l1", LadderName: "Standard", RankZeroProductName: "Free", HasRankZero: true}},
|
|
}
|
|
data.Form = orgTypeDefaultFormView(nil, data)
|
|
out := renderOperatorPartial(t, "operator_org_type_card.html", data)
|
|
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)
|
|
}
|
|
}
|