The Rules section is one record table grouped by kind, Limit then Boolean, on fixed columns, edited in place: Edit opens a row's controls in their columns, Add rule opens a dense row above the table, and every change is staged into a tray that lists the deltas with Undo and applies them as one rule-change act. The reduction policy is a column of the rule beside its limit. History shows counts only. Group rows are a quiet heading rather than a divider, the maintainer's pick from four rounds of outside-model ideation. Dense rows align to the top and render each error under its control in every form family (design D16), replacing the below-row error block; the forms library gains the batch form (rows plus one tray) and the RowField dense and label-hidden options. Migration 00019 records the governing reduction policy on effect rows. Archive staged-rule-changes with its spec updates (entitlement-set- management, entitlement-set-history, entitlements, form-library, form-conventions, ui-quality-gate). Screens accepted 2026-09-19.
473 lines
35 KiB
Go
473 lines
35 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"html/template"
|
|
"io"
|
|
"io/fs"
|
|
"log/slog"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/domains"
|
|
"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"
|
|
)
|
|
|
|
// anatomySet parses the operator partials with the shared parts, the way
|
|
// the operator partials set does.
|
|
func anatomySet(t *testing.T) *template.Template {
|
|
t.Helper()
|
|
partialsSub, err := fs.Sub(embeds.Templates, "templates/partials")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tmpl := template.New("anatomy").Funcs(template.FuncMap{
|
|
"routeURL": web.RouteURL,
|
|
"renderBody": func(string, any) (template.HTML, error) { return "", nil },
|
|
"fieldErr": func(_, _, _, _ string, _, _ any) string { return "" },
|
|
"stripeEntityURL": func(string, string) string { return "" },
|
|
"helpIcon": helpIcon,
|
|
})
|
|
tmpl = template.Must(web.ParseUIPartials(template.Must(tmpl.ParseFS(partialsSub, "operator_*.html"))))
|
|
// The operator surface's root crumb (design D18); see server.go's own
|
|
// override for why this must run after ParseUIPartials.
|
|
return tmpl.Funcs(template.FuncMap{"surfaceRoot": OperatorSurfaceRoot})
|
|
}
|
|
|
|
func renderAnatomy(t *testing.T, name string, data any) string {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
if err := anatomySet(t).ExecuteTemplate(&buf, name, data); err != nil {
|
|
t.Fatalf("render %s: %v", name, err)
|
|
}
|
|
return buf.String()
|
|
}
|
|
|
|
// rawBadgeRe matches a badge the parts did not render: the part's own
|
|
// output always starts with `badge text-bg-`.
|
|
var rawBadgeRe = regexp.MustCompile(`class="badge (?:bg-|[a-z-]*\{\{)`)
|
|
|
|
// anatomyCase is one page body rebuilt on the parts (spec page-anatomy
|
|
// "Every page is built from the shared parts"). Every swept page joins
|
|
// this table; the checks are the floor the lint enforces statically.
|
|
type anatomyCase struct {
|
|
name string
|
|
template string
|
|
data any
|
|
want []string
|
|
banned []string
|
|
// noTitle marks a fragment rendered inside another page's header.
|
|
noTitle bool
|
|
}
|
|
|
|
func checkAnatomy(t *testing.T, c anatomyCase) {
|
|
t.Helper()
|
|
out := renderAnatomy(t, c.template, c.data)
|
|
if !c.noTitle && !strings.Contains(out, `<h1 class="h2 mb-0">`) {
|
|
t.Errorf("%s: title must render through pageHeader (h1.h2)", c.name)
|
|
}
|
|
if strings.Count(out, "<h1") > 1 {
|
|
t.Errorf("%s: more than one h1", c.name)
|
|
}
|
|
if rawBadgeRe.MatchString(out) {
|
|
t.Errorf("%s: a badge is composed outside statusBadge", c.name)
|
|
}
|
|
for _, banned := range append([]string{"border rounded", "hx-confirm", "spinner-border text-primary", `class="badge {{`}, c.banned...) {
|
|
if strings.Contains(out, banned) {
|
|
t.Errorf("%s: still renders %q", c.name, banned)
|
|
}
|
|
}
|
|
for _, want := range c.want {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("%s: missing %q", c.name, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPagesUseTheParts(t *testing.T) {
|
|
cases := []anatomyCase{
|
|
{
|
|
name: "integrations", template: "operator_integrations.html",
|
|
data: IntegrationsData{
|
|
Integrations: []IntegrationRow{
|
|
{Key: "acme", DisplayName: "Acme", Kind: "provisioning", Status: "active", Operations: []string{"create"}, MissingKeysText: "acme-key"},
|
|
{Key: "widget", DisplayName: "Widget", Kind: "provisioning", Status: "inactive", Configured: true},
|
|
},
|
|
Orphans: []OrphanOverrideRow{{Key: "k", Value: "v"}},
|
|
},
|
|
want: []string{
|
|
`<h1 class="h2 mb-0">Integrations</h1>`, `>2 integrations</span>`,
|
|
`<table class="table table-hover table-sm align-middle table-record">`,
|
|
`<th scope="row">`,
|
|
`<span class="badge text-bg-warning" title="Missing: acme-key">Not configured</span>`,
|
|
`<span class="badge text-bg-secondary">Disabled</span>`,
|
|
`<code class="small me-1">create</code>`,
|
|
`<h2 class="h5 mb-0">Unrecognized overrides</h2>`,
|
|
// design D18: a rail-entry page (no Crumbs of its own)
|
|
// still carries the trail, rooted at the surface.
|
|
`<li class="breadcrumb-item"><a href="/operator">Operator</a></li>`,
|
|
`<li class="breadcrumb-item active" aria-current="page">Integrations</li>`,
|
|
},
|
|
banned: []string{`<h1 class="mb-1">`, "text-bg-light border me-1", "integration(s) · driven by", ">Manage<", "Registry status", ">Configuration<"},
|
|
},
|
|
{
|
|
name: "integrations empty", template: "operator_integrations.html", data: IntegrationsData{},
|
|
want: []string{`<p class="text-muted mb-2">No integrations registered.</p>`},
|
|
banned: []string{"py-5"},
|
|
},
|
|
{
|
|
name: "domains", template: "operator_domains.html",
|
|
data: OperatorDomainsData{
|
|
Claims: []OperatorDomainClaimRow{{ClaimID: "c1", Root: "a.example", Kind: domains.KindExternal, Status: domains.StatusPending, OrgID: "o1", OrgName: "Org", Evidence: true, Releasable: true}},
|
|
Placements: []OperatorPlacementRow{{Name: "a.example", Provider: "fedwiki", ResourceRef: "site-1", Servable: true}},
|
|
TerminalClaims: []OperatorTerminalClaimRow{{Root: "b.example", Kind: domains.KindOperatorRoot, Status: domains.StatusExpired, Evidence: true}},
|
|
Policy: OperatorDomainPolicyView{ClaimWindow: "72h"},
|
|
// The header count is the governed list's true total now
|
|
// (operator-list-scale), not len(Claims).
|
|
Nav: ListNav{BasePath: "/operator/domains", Total: 1},
|
|
},
|
|
want: []string{
|
|
`<h1 class="h2 mb-0">Domains</h1>`, `>1 live claim</span>`,
|
|
// task 10, round 2: Effective claim policy and Claim history
|
|
// are ordinary sections now, not <details>/sectionSummary
|
|
// disclosures.
|
|
`<h2 class="h5 mb-0">Effective claim policy</h2>`,
|
|
`Name policy governs carving inside operator roots only`,
|
|
`<span class="badge text-bg-light border">External</span>`,
|
|
`<span class="badge text-bg-warning">Pending</span>`,
|
|
`<h2 class="h5 mb-0">Placements</h2>`, `>1 served name</span>`,
|
|
`<span class="badge text-bg-success">Servable</span>`,
|
|
`<h2 class="h5 mb-0">Claim history</h2>`, `>1 terminal claim</span>`,
|
|
`<span class="badge text-bg-light border">Operator root</span>`,
|
|
`<span class="badge text-bg-secondary">Expired</span>`,
|
|
// Evidence lives in claim history only now (design D15): the
|
|
// live claim above carries Evidence too, but its "Seen" badge
|
|
// must come from the history row, not a live-table column.
|
|
`<span class="badge text-bg-info">Seen</span>`,
|
|
},
|
|
banned: []string{`h1 class="h4`, "operator_root<", ">servable<", "<details", "sectionSummary", "app-section-header mb-2\">"},
|
|
},
|
|
{
|
|
name: "domains empty", template: "operator_domains.html", data: OperatorDomainsData{},
|
|
want: []string{
|
|
`<p class="text-muted mb-2">No live domain claims.</p>`,
|
|
`<p class="text-muted mb-2">No placements.</p>`,
|
|
`<p class="text-muted small mb-2">A placement is a site or service served at a claimed name.</p>`,
|
|
`<p class="text-muted mb-2">No expired, canceled, or released claims yet.</p>`,
|
|
},
|
|
banned: []string{"border"},
|
|
},
|
|
// Group 1 of the sweep.
|
|
{
|
|
name: "setup page", template: "operator_setup.html",
|
|
data: SetupState{Steps: []SetupStep{
|
|
{Key: "a", Label: "Done step", Complete: true, Href: "/x", LinkText: "Go"},
|
|
{Key: "b", Label: "Open step", Complete: false, Href: "/y", LinkText: "Go"},
|
|
}},
|
|
// design D12, round 2: every step renders alike — "Done" in the
|
|
// success tone, "Incomplete" in the neutral tone; no "Required"
|
|
// label, no required/conditional split.
|
|
want: []string{
|
|
`<h1 class="h2 mb-0">Setup checklist</h1>`,
|
|
`<span class="badge text-bg-success">Done</span>`,
|
|
`<span class="badge text-bg-secondary">Incomplete</span>`,
|
|
},
|
|
banned: []string{`badge text-bg-warning`, `badge text-bg-light text-muted`, `>Optional<`, `>Required<`, `>Not yet<`},
|
|
},
|
|
// Group 2 of the sweep.
|
|
{
|
|
name: "people", template: "operator_persons.html",
|
|
data: PersonsData{Persons: []PersonListViewModel{{PersonID: "p1", DisplayName: "Ada", Email: "ada@example.test", EmailVerified: true, Status: "active", JoinedAt: "Aug 1"}, {PersonID: "p2", DisplayName: "Bo", Email: "bo@example.test", Status: "active"}}, Nav: ListNav{Total: 2}},
|
|
want: []string{`<h1 class="h2 mb-0">People</h1>`, `>2 people</span>`, `<span class="badge text-bg-warning">Unverified</span>`, `<span class="badge text-bg-success">Active</span>`, `<th>First seen</th>`},
|
|
banned: []string{`>Verified<`, `<th>Joined</th>`},
|
|
},
|
|
{
|
|
name: "people empty", template: "operator_persons.html", data: PersonsData{},
|
|
want: []string{`<p class="text-muted mb-2">No one has signed in yet.</p>`, `Person records are created automatically the first time someone logs in.`},
|
|
},
|
|
{
|
|
name: "person detail", template: "operator_person_detail.html",
|
|
data: PersonDetailData{Person: PersonViewModel{PersonID: "p1", DisplayName: "Ada Lovelace", Email: "ada@example.test", CreatedAt: "Aug 1"}, Memberships: []PersonMembershipViewModel{{OrgID: "o1", OrgName: "Org", RoleName: "owner", JoinedAt: "Aug 2"}}},
|
|
want: []string{`<li class="breadcrumb-item"><a href="/operator/persons">People</a></li>`, `aria-current="page">Ada Lovelace</li>`, `<h1 class="h2 mb-0">Ada Lovelace</h1>`, `<p class="text-body-secondary mb-0">ada@example.test</p>`, `<h2 class="h5 mb-0">Org memberships</h2>`, `>1 organization</span>`, `<td>owner</td>`, `<dt class="col-sm-3">First seen</dt>`, `<dt class="col-sm-3">Name<span class="text-body-secondary ms-1" title="Managed by the identity provider">`, `<dd class="col-sm-9">Ada Lovelace</dd>`, `class="app-managed-icon"`},
|
|
banned: []string{`badge bg-secondary`, `<h2 class="mt-4 h6">`, `<dt class="col-sm-3">Joined</dt>`, `Managed by the identity provider</h2>`, `Name and email come from the identity provider and change there.`},
|
|
},
|
|
{
|
|
name: "person detail no memberships", template: "operator_person_detail.html",
|
|
data: PersonDetailData{Person: PersonViewModel{DisplayName: "Ada"}},
|
|
want: []string{`<p class="text-muted mb-2">Not a member of any organization.</p>`},
|
|
},
|
|
// Group 3 of the sweep.
|
|
{
|
|
name: "organizations", template: "operator_organizations.html",
|
|
data: OrganizationsData{Organizations: []OrganizationViewModel{
|
|
{OrgID: "o1", Name: "Acme", OrgType: "team", Status: "active", MemberCount: 3, OwnerName: "Ada", CreatedAt: "Aug 1"},
|
|
{OrgID: "o2", Name: "Guild", OrgType: "coop", Status: "active", OwnerName: "Bo"},
|
|
{OrgID: "o3", Name: "System", OrgType: "system", Status: "active", OwnerName: "System", IsSystemOrg: true},
|
|
}, Nav: ListNav{Total: 3}},
|
|
want: []string{`<h1 class="h2 mb-0">Organizations</h1>`, `href="/operator/org-types" class="btn btn-sm btn-outline-secondary flex-shrink-0">Organization types</a>`, `<a href="/operator/organizations/o1">Acme</a>`, `<span class="badge text-bg-light border">Team</span>`, `<span class="badge text-bg-light border">coop</span>`, `<span class="badge text-bg-light border" title="Reserved system tenant, not a member organization">System tenant</span>`, `<span class="badge text-bg-success">Active</span>`},
|
|
banned: []string{`badge bg-primary`, `text-bg-info`, `No organizations found`, `>Manage<`, `No actions`, `<a href="/operator/organizations/o3">`},
|
|
},
|
|
{
|
|
name: "organizations empty", template: "operator_organizations.html", data: OrganizationsData{},
|
|
want: []string{`<p class="text-muted mb-2">No organizations yet.</p>`, `A personal organization is created for each person the first time they sign in.`},
|
|
},
|
|
{
|
|
name: "organization types", template: "operator_org_types.html",
|
|
data: func() OrgTypesData {
|
|
d := OrgTypeCardData{OrgTypeViewModel: OrgTypeViewModel{OrgType: "team", DisplayName: "Team", Description: "Shared by several people.", IsActive: true, DefaultPlanLadderID: "l1", DefaultPlanLadderName: "Hosting", DefaultRankZeroProductName: "Free", DefaultHasRankZero: true}, PlanLadders: []PlanLadderOption{{PlanLadderID: "l1", LadderName: "Hosting", RankZeroProductName: "Free", HasRankZero: true}}}
|
|
d.Form = orgTypeDefaultFormView(nil, d)
|
|
return OrgTypesData{OrgTypes: []OrgTypeCardData{d}}
|
|
}(),
|
|
// design D22 as amended by overview-consistency D9: each type
|
|
// is a section titled outside its card through sectionHeader,
|
|
// the display name as the title and the key in the header's
|
|
// Key slot; the description and the current default fold into
|
|
// the card's muted details line; the select is "Change default
|
|
// to".
|
|
// The type's only ladder is its own current default, so
|
|
// SelectableLadders is empty and the placeholder is the sole
|
|
// option left selected (design D10/D22, form-library's Select
|
|
// "present" rule).
|
|
want: []string{`<h1 class="h2 mb-0">Organization types</h1>`, `id="org-type-card-team"`, `<h2 class="h5 mb-0">Team</h2>`, `Key <code class="text-muted small">team</code>`, `Shared by several people.`, `Default plan: Hosting (Free).`, `<span class="badge text-bg-success">Active</span>`, `Change default to`, `rank-0 tier of the default plan ladder`, `<option value="__choose__" disabled selected>Choose a new default</option>`},
|
|
banned: []string{`Org Types`, `Default Plan`, `card-title`, `Current:`, `<h2 class="h6`, `New organizations of a type start`, `Team <code`, `New organizations can be created with this type.`},
|
|
},
|
|
{
|
|
name: "organization types empty", template: "operator_org_types.html", data: OrgTypesData{},
|
|
want: []string{`<p class="text-muted mb-2">No organization types exist.</p>`},
|
|
},
|
|
{
|
|
name: "organization type card preview", template: "operator_org_type_card.html", noTitle: true,
|
|
data: func() OrgTypeCardData {
|
|
d := OrgTypeCardData{OrgTypeViewModel: OrgTypeViewModel{OrgType: "team", DisplayName: "Team"}, PlanLadders: []PlanLadderOption{{PlanLadderID: "l1", LadderName: "Hosting"}, {PlanLadderID: "l2", LadderName: "Studio", RankZeroProductName: "Basic", HasRankZero: true}}, Candidate: "l2", HasCandidate: true, Preview: &OrgTypeChangePreview{CandidateLadderID: "l2", WouldInitiate: []OrgRef{{OrgID: "o1", Name: "Acme"}}, OutgoingDefault: []OrgRef{{OrgID: "o2", Name: "Guild"}}, AlreadyAtDefault: 1, OtherSource: []OrgSourceRef{{OrgRef: OrgRef{OrgID: "o3", Name: "Sol"}, Source: "a grant"}}, WouldFail: []OrgTypeChangeFailure{{OrgID: "o4", OrgName: "Nix", Reason: "no default pool"}}}}
|
|
d.Form = orgTypeDefaultFormView(NewSafeTemplates(anatomySet(t), slog.New(slog.NewTextHandler(io.Discard, nil))), d)
|
|
return d
|
|
}(),
|
|
// design D22: "No default plan." replaces the round-1 "Current:
|
|
// none" line; the explanation moves inside the preview panel
|
|
// (rendered as the form's own Message, design D10); the
|
|
// outgoing-default bucket has no chosen disposition, so
|
|
// "Apply change" renders disabled through the shared part.
|
|
want: []string{`<span class="badge text-bg-secondary">Inactive</span>`, `Preview: nothing changed yet.`, `Applies to organizations created after the change. Existing organizations are classified below.`, `Grandfather: keep their plan as legacy grants`, `Migrate: move them to the new default`, `Hosting: no rank-0 tier`, `Studio: Basic`, `class="alert alert-secondary`, `No default plan.`, `Sol</a> (a grant)`, `Nix</a></strong>: no default pool`, `1 organization holds the outgoing default`, `disabled aria-describedby="apply-change-reason-team"`, `Choose a disposition for every bucket first.`, `<option value="__choose__" disabled>Choose a new default</option>`},
|
|
banned: []string{" — ", "bg-body-tertiary", "Current:", `>None (no default plan)</option>`, `btn-primary btn-sm">Apply change</button>` /* the enabled variant — no bucket2 disposition chosen yet, so only the disabled variant renders */, `value="__choose__" selected` /* HasCandidate is true (l2 chosen), so the placeholder is not the selected option */},
|
|
},
|
|
{
|
|
name: "organization type card result", template: "operator_org_type_card.html", noTitle: true,
|
|
data: func() OrgTypeCardData {
|
|
d := OrgTypeCardData{OrgTypeViewModel: OrgTypeViewModel{OrgType: "team", DisplayName: "Team", IsActive: true}, PlanLadders: []PlanLadderOption{{PlanLadderID: "l1", LadderName: "Hosting", RankZeroProductName: "Free", HasRankZero: true}}, Result: &OrgTypeChangeResult{Failures: []OrgTypeChangeFailure{{OrgID: "o4", OrgName: "Nix", Reason: "no default pool"}}}}
|
|
d.Form = orgTypeDefaultFormView(nil, d)
|
|
return d
|
|
}(),
|
|
want: []string{`class="alert alert-danger`, `Default change committed:`, `Failed organizations were skipped; fix the cause and commit again.`, `No default plan.`},
|
|
},
|
|
{
|
|
name: "enrollment composite", template: "operator_enrollment.html",
|
|
data: func() OrgEnrollmentData {
|
|
d := OrgEnrollmentData{OrgID: "o1", OrgName: "Acme", OrgOwnerPersonID: "p1", OrgOwnerName: "Ada", OrgKey: "acme",
|
|
Pools: []PoolEnrollmentViewModel{{PoolID: "pl1", PoolName: "default", PoolType: "default", Status: "active", HasAttachment: true,
|
|
Deliveries: []PoolDeliveryViewModel{{ProvisionID: "pr1", ProductName: "Pro", GrantBacked: true, Rungs: []PoolRungViewModel{{LadderName: "Hosting", Rank: 0, ActivatedAt: "Jan 1"}}}}}},
|
|
Grants: []GrantViewModel{{GrantID: "g1", ProductName: "Pro", GrantReason: "manual", Status: "active", Issuance: "Active", DeliveryState: "live", GrantedByPersonID: "p2", GrantedByName: "Bo"}},
|
|
Members: []MemberRowViewModel{{PersonID: "p1", DisplayName: "Ada", Email: "ada@example.test", RoleName: "owner", JoinedAt: "Jan 1"}},
|
|
MembersTotal: 1,
|
|
Transitions: []TransitionHistoryViewModel{{TransitionID: "t1", LadderName: "Hosting", TransitionType: "upgrade", FromLabel: "Basic", ToLabel: "Pro", ActorType: "operator", EffectiveAt: "Jan 2"}}}
|
|
// No issuance products here, so the Issue grant form stays
|
|
// gated off (the template's own len(.IssuanceProducts)>0
|
|
// guard); the one grant-backed delivery still needs its own
|
|
// ExtendForm built the way loadOrgEnrollmentData builds it.
|
|
d.Pools[0].Deliveries[0].ExtendForm = extendGrantFormView(d.OrgID, d.Pools[0].PoolID, "pr1", forms.ModeRecord, extendGrantRestValues("pr1"), nil)
|
|
return d
|
|
}(),
|
|
want: []string{`<h1 class="h2 mb-0">Acme</h1>`, `<li class="breadcrumb-item"><a href="/operator/organizations">Organizations</a></li>`, `<dt class="col-sm-3">Owner</dt>`, `<code class="text-muted small">acme</code>`, `<h2 class="h5 mb-0">Billing</h2>`, `No billing account exists for this organization yet.`, `<h2 class="h5 mb-0">Pools <button`, `<span class="badge text-bg-light border">Default</span>`, `<span class="badge text-bg-success">Active</span>`, `Hosting, rank 0`, `<h2 class="h5 mb-0">Plan and grants</h2>`, `<span class="badge text-bg-success">Live</span>`, `<th>Issuance `, `<th>Granted by</th>`, `<a href="/operator/persons/p2">Bo</a>`, `<h2 class="h5 mb-0">Members</h2>`, `>1 member</span>`, `<h2 class="h5 mb-0">Tier changes`, `<span class="badge text-bg-light border">Upgraded</span>`},
|
|
banned: []string{`badge bg-primary`, `badge bg-secondary`, `text-bg-info`, `→`, `<h2 class="mt-4 h6">`, `Full billing`, `<th>Ledger</th>`},
|
|
},
|
|
// Group 4 of the sweep.
|
|
{
|
|
name: "grants", template: "operator_grants.html",
|
|
data: GrantsData{Grants: []GrantViewModel{
|
|
{GrantID: "g1", OrgID: "o1", OrgName: "Acme", ProductName: "Pro", GrantReason: "manual", Status: "active", Issuance: "Active", DeliveryState: "live", Quantity: 1, CreatedAt: "Jan 1", GrantedByPersonID: "p2", GrantedByName: "Bo"},
|
|
{GrantID: "g2", OrgID: "o1", OrgName: "Acme", EntitlementSetName: "Bundle", GrantReason: "default", Status: "revoked", Issuance: "Revoked", DeliveryState: "inactive", Quantity: 1},
|
|
}, Products: []ProductViewModel{{ProductID: "p1", Name: "Pro"}}, Nav: ListNav{Total: 2}},
|
|
want: []string{`<h1 class="h2 mb-0">Grants</h1>`, `>2 grants</span>`, `<span class="badge text-bg-light border">Product</span>`, `<span class="badge text-bg-light border">Set</span>`, `<span class="badge text-bg-success">Live</span>`, `<span class="badge text-bg-secondary">Inactive</span>`, `>Grant target</th>`, `<th>Issuance `, `<th>Granted by</th>`, `<a href="/operator/organizations/o1#plan-grants-panel">Acme</a>`, `<a href="/operator/persons/p2">Bo</a>`, `<em class="text-muted">System</em>`},
|
|
banned: []string{`badge bg-secondary`, `text-bg-info`, `No grants found`, `<th>Ledger</th>`},
|
|
},
|
|
{
|
|
name: "grants empty blocked", template: "operator_grants.html", data: GrantsData{},
|
|
want: []string{`no product has been published yet`, `>Go to Products</a>`, `alert alert-warning`},
|
|
},
|
|
{
|
|
name: "grants empty", template: "operator_grants.html", data: GrantsData{Products: []ProductViewModel{{ProductID: "p1", Name: "Pro"}}},
|
|
want: []string{`No grants issued yet.`, `Grants are issued from each organization's detail page, not from here.`},
|
|
},
|
|
// Group 5 of the sweep.
|
|
{
|
|
name: "billing invoices view", template: "operator_invoices.html", noTitle: true,
|
|
data: InvoicesData{StripeConfigured: true, Invoices: []InvoiceViewModel{
|
|
{InvoiceID: "i1", InvoiceNumber: "0001", OrgID: "o1", OrgName: "Acme", Status: "open", Overdue: true, StatusState: "overdue", AmountDue: "USD 10.00", AmountPaid: "USD 0.00", DueDate: "Jan 15, 2026", StripeSyncStatus: "not_mapped", CreatedAt: "Jan 1, 2026"},
|
|
{InvoiceID: "i2", InvoiceNumber: "0002", OrgID: "o1", OrgName: "Acme", Status: "paid", StatusState: "paid", AmountDue: "USD 10.00", AmountPaid: "USD 10.00", StripeSyncStatus: "synced", CreatedAt: "Jan 1, 2026"},
|
|
}, Nav: ListNav{Total: 2}},
|
|
want: []string{`<a href="/operator/billing/invoices/i1">0001</a>`, `<span class="badge text-bg-danger">Overdue</span>`, `<span class="badge text-bg-success">Paid</span>`, `<span class="badge text-bg-secondary">Not mapped</span>`, `<span class="badge text-bg-success">Synced</span>`, `>Amount due</th>`},
|
|
banned: []string{`>View</a>`, `>Currency</th>`, `Not Mapped`, `Default`},
|
|
},
|
|
{
|
|
name: "billing invoices empty blocked", template: "operator_invoices.html", noTitle: true, data: InvoicesData{},
|
|
want: []string{`projected from Stripe, which is not configured`, `>Configure Stripe</a>`, `alert alert-warning`},
|
|
},
|
|
{
|
|
name: "billing invoice detail", template: "operator_billing_invoice_detail.html", noTitle: true,
|
|
data: OperatorInvoiceDetailData{InvoiceID: "i1", InvoiceNumber: "0001", OrgID: "o1", OrgName: "Acme", Status: "open", StatusState: "open", AmountDue: "USD 10.00", AmountPaid: "USD 0.00", Period: "Jan 1, 2026 to Feb 1, 2026", StripeSyncStatus: "not_mapped"},
|
|
want: []string{`<span class="badge text-bg-warning">Open</span>`, `<h2 class="h5 mb-0">Line items</h2>`, `No line items recorded for this invoice.`},
|
|
banned: []string{`← Back`, `alert-secondary`, `Not mapped`, `Stripe Sync`},
|
|
},
|
|
// Group 6 of the sweep.
|
|
{
|
|
name: "products", template: "operator_products.html",
|
|
data: OperatorProductsData{Products: []OperatorProductViewModel{
|
|
{ProductID: "p1", Name: "Pro", EntitlementSetID: "s1", EntitlementSetName: "Base", DisplayCategory: "addon", IsActive: true, IsPublic: true, LifecycleStatus: "published", CreatedAt: "Jan 1, 2026", VerdictState: "purchasable"},
|
|
{ProductID: "p2", Name: "Old", IsActive: false, LifecycleStatus: "retired", CreatedAt: "Jan 1, 2026", VerdictState: "retired", Lifecycle: "Retired"},
|
|
}, EntitlementSets: []EntitlementSetOption{{SetID: "s1", Name: "Base"}}, Nav: ListNav{Total: 2}},
|
|
want: []string{`<h1 class="h2 mb-0">Products</h1>`, `>2 products</span>`, `<h2 class="h5 mb-0">Catalog overview</h2>`, `<h2 class="h5 mb-0">Products</h2>`, `<a href="/operator/products/new" class="btn btn-sm btn-primary">New product</a>`, `<th scope="row"><a href="/operator/products/p1">Pro</a></th>`, `<a href="/operator/entitlement-sets/s1">Base</a>`, `<span class="badge text-bg-success">Purchasable</span>`, `<span class="badge text-bg-secondary">Retired</span>`, `<span class="badge text-bg-light border">addon</span>`, `nav-pills`, `<td>Listed</td>`, `<td>Unlisted</td>`},
|
|
// design D20 (round 4): creation has a page of its own, so the
|
|
// list holds no create panel and no create form.
|
|
banned: []string{`>Manage<`, `card-title`, `Total: `, `Create Product`, `Entitlement Set<`, `btn-outline-secondary btn-sm">Entitlement sets`, `Internal wrap`, `>Internal<`, `id="productCreatePanel"`, `id="createProductForm"`},
|
|
},
|
|
{
|
|
name: "product detail", template: "operator_product_detail.html",
|
|
data: ProductDetailData{
|
|
Edit: OperatorProductEditData{Product: OperatorProductViewModel{ProductID: "p1", Name: "Pro", Key: "pro"},
|
|
LadderMemberships: []ProductLadderMembership{{LadderID: "l1", LadderName: "Hosting", Rank: 1}}},
|
|
Prices: ProductPricesData{ProductID: "p1", ProductName: "Pro", StripeConfigured: true, Prices: []PriceViewModel{
|
|
{PriceID: "pr1", ProductID: "p1", UnitAmount: "$10.00 USD", IsRecurring: true, RecurringInterval: "month", IsActive: true, IsDefault: true, StripeSyncStatus: "not_mapped", CreatedAt: "Jan 1, 2026"},
|
|
{PriceID: "pr2", ProductID: "p1", UnitAmount: "$20.00 USD", IsActive: true, StripeSyncStatus: "not_mapped", CreatedAt: "Jan 1, 2026"},
|
|
}}},
|
|
want: []string{`<h1 class="h2 mb-0">Pro</h1>`, `<li class="breadcrumb-item"><a href="/operator/products">Products</a></li>`, `<h2 class="h5 mb-0">Details</h2>`, `Key <code class="text-muted small">pro</code>`, `<h2 class="h5 mb-0">Plan ladders`, `<th scope="row"><a href="/operator/plan-ladders/l1">Hosting</a></th>`, `<h2 class="h5 mb-0">Purchasability</h2>`, `<h2 class="h5 mb-0">Prices`, `<span class="badge text-bg-light border">Default</span>`, `<span class="badge text-bg-light border">Recurring</span>`, `data-action-title="Deactivate price"`},
|
|
banned: []string{`← Back`, `Edit Product:`, `hx-confirm`, `card-title`, `Prices for `, `>View Ladder<`},
|
|
},
|
|
// Group 7 of the sweep.
|
|
{
|
|
name: "entitlement sets", template: "operator_entitlement_sets.html",
|
|
data: EntitlementSetsData{EntitlementSets: []EntitlementSetViewModel{
|
|
{SetID: "s1", Name: "Base", Description: "Core capabilities.", IsActive: true, RuleCount: 2, CreatedAt: "Jan 1, 2026"},
|
|
{SetID: "s2", Name: "Hidden", IsActive: false, CreatedAt: "Jan 1, 2026"},
|
|
}},
|
|
want: []string{`<h1 class="h2 mb-0">Entitlement sets</h1>`, `and defines what a product provides`, `<li class="breadcrumb-item"><a href="/operator/products">Products</a></li>`, `nav-pills`, `<h2 class="h5 mb-0">Entitlement sets</h2>`, `<a href="/operator/entitlement-sets/new" class="btn btn-sm btn-primary">New entitlement set</a>`, `<th scope="row"><a href="/operator/entitlement-sets/s1">Base</a></th>`, `<span class="badge text-bg-success">Active</span>`, `<span class="badge text-bg-secondary">Inactive</span>`, `>2 sets</span>`},
|
|
banned: []string{`>Manage<`, `card-title`, `Total: `, `>Shown<`, `>Hidden</span>`, `Create Entitlement Set`, `Entitlement Sets`, `In pickers`, `unlocks`, `id="entitlementSetCreatePanel"`, `id="createEntitlementSetForm"`},
|
|
},
|
|
{
|
|
name: "entitlement set detail", template: "operator_entitlement_set_detail.html",
|
|
data: EntitlementSetDetailData{
|
|
Edit: EntitlementSetEditData{
|
|
EntitlementSet: EntitlementSetViewModel{SetID: "s1", Name: "Base", Key: "base"},
|
|
Form: entitlementSetEditForm("s1", entitlementSetEditValues(EntitlementSetViewModel{SetID: "s1", Name: "Base"}), nil),
|
|
},
|
|
Rules: rulesDataForRender(t, anatomySet(t), EntitlementSetRulesData{EntitlementSet: EntitlementSetViewModel{SetID: "s1", Name: "Base"}, Rules: []RuleViewModel{
|
|
{RuleID: "r1", Instance: "r1", RuleType: "boolean", ResourceKey: "posting", DisplayName: "Posting", IsActive: true},
|
|
{RuleID: "r2", Instance: "r2", RuleType: "limit", ResourceKey: "seats", DisplayName: "Seats", ResourceValue: 5, Policy: "Clamp", IsActive: true},
|
|
}})},
|
|
// task 9: the set edit form's Active control is a plain checkbox
|
|
// (form-check, no form-switch, no role="switch"), like the
|
|
// product edit form's Active — a switch implies immediate
|
|
// effect, and this control is committed by Save changes.
|
|
want: []string{`<h1 class="h2 mb-0">Base</h1>`, `<li class="breadcrumb-item"><a href="/operator/entitlement-sets">Entitlement sets</a></li>`, `<h2 class="h5 mb-0">Details</h2>`, `Key <code class="text-muted small">base</code>`, `<h2 class="h5 mb-0">Rules`, `>Add rule</button>`, `data-form="operator.entitlement-set.rules"`, `class="table table-hover table-sm table-record align-middle app-rules-table"`, `<tr class="app-rules-group"><th scope="rowgroup" colspan="5" class="small fw-semibold text-body-secondary">Limit</th></tr>`, `<tr class="app-rules-group"><th scope="rowgroup" colspan="5" class="small fw-semibold text-body-secondary">Boolean</th></tr>`, `<th scope="row" colspan="4">Posting`, `<td>Clamp</td>`, `<div class="form-check">`, `<input class="form-check-input" type="checkbox" id="form-operator.entitlement-set-is_active-control" name="is_active"`},
|
|
banned: []string{`← Back`, `Edit Entitlement Set`, `Rules: `, `card-title`, `card-subtitle`, `bg-light`, `colspan="3"`, `>Stacking</th>`, `>Active</th>`, `form-switch`, `role="switch"`},
|
|
},
|
|
// Group 8 of the sweep.
|
|
{
|
|
name: "plan ladders", template: "operator_plan_ladders.html",
|
|
data: PlanLaddersData{Ladders: []PlanLadderViewModel{
|
|
{PlanLadderID: "l1", Name: "Hosting", TierCount: 2, ActiveAttachmentCount: 3},
|
|
{PlanLadderID: "l2", Name: "Support"},
|
|
}, Topology: PlanTopologyData{
|
|
Ladders: []TopologyLadderColumn{{PlanLadderID: "l1", Name: "Hosting", IsActive: true}},
|
|
OrgTypes: []OrgTypeProvisionViewModel{{OrgType: "personal", DisplayName: "Personal"}},
|
|
}},
|
|
want: []string{`<h1 class="h2 mb-0">Plan ladders</h1>`, `>2 ladders</span>`, `<li class="breadcrumb-item"><a href="/operator/products">Products</a></li>`, `nav-pills`, `<h2 class="h5 mb-0">Plan topology</h2>`, `<h2 class="h5 mb-0">Ladders</h2>`, `<a href="/operator/plan-ladders/new" class="btn btn-sm btn-primary">New plan ladder</a>`, `<th scope="row"><a href="/operator/plan-ladders/l1">Hosting</a></th>`, `<h2 class="h5 mb-0">Off-ladder products`, `<h2 class="h5 mb-0">What new organizations get</h2>`, `No default (off-ladder)`, `table-record`, `Open a ladder to reorder its tiers.`},
|
|
// design D8: a single topology ladder column renders no drag
|
|
// handle (operator-topology-overview "One ladder has no handle").
|
|
// The structural-validation action and page are retired
|
|
// (acceptance-fixes round 2): no more "Structural validation"
|
|
// link. design D20 (round 4): ladder creation has a page of its
|
|
// own, so no create panel and no create form ride the list.
|
|
banned: []string{`>Manage<`, `card-title`, `Total: `, `Create Plan Ladder`, `Validate Structure`, `Structural validation`, `>Structural validation</a>`, `plan-ladders/validation`, `<h2 class="h5 mb-0">Create ladder</h2>`, `Plan Ladders`, `text-bg-info`, `Display Name`, `class="sortable-handle`, `id="ladderCreatePanel"`, `id="createPlanLadderForm"`},
|
|
},
|
|
{
|
|
name: "plan ladder detail", template: "operator_plan_ladder_detail.html",
|
|
data: PlanLadderDetailData{
|
|
Edit: PlanLadderEditData{Ladder: PlanLadderViewModel{PlanLadderID: "l1", Name: "Hosting", Key: "hosting"}},
|
|
Tiers: PlanLadderTiersData{
|
|
Ladder: PlanLadderViewModel{PlanLadderID: "l1", Name: "Hosting"},
|
|
Tiers: []PlanLadderTierViewModel{{PlanLadderID: "l1", ProductID: "p1", ProductName: "Basic", Rank: 0, HasActiveAttachments: true, ActiveAttachmentCount: 2}},
|
|
DefaultForOrgTypeNames: []string{"Personal"},
|
|
}},
|
|
want: []string{`<h1 class="h2 mb-0">Hosting</h1>`, `<li class="breadcrumb-item"><a href="/operator/plan-ladders">Plan ladders</a></li>`, `<h2 class="h5 mb-0">Details</h2>`, `Key <code class="text-muted small">hosting</code>`, `<h2 class="h5 mb-0">Tiers</h2>`, `>Add tier</button>`, `>Add tier</button>`, `id="tierAddPanel"`, `<h2 class="h5 mb-0">Tiers`, `<a href="/operator/products/p1">Basic</a>`, `>Personal default</span>`, `table-record`},
|
|
// design D8 / operator-topology-overview (acceptance-fixes round
|
|
// 2): the structural-validation strip this page once carried was
|
|
// retired outright (the one check it ran is impossible under the
|
|
// database's exclusion constraint); a single tier renders no
|
|
// drag handle ("One ladder has no handle" applies the same way
|
|
// to tier rows).
|
|
banned: []string{`← Back`, `Edit Plan Ladder`, `Manage Tiers`, `card-title`, `Save Changes`, `Display Name`, `Add Tier`, `Structural validation`, `No structural issues for this ladder.`, `class="sortable-handle`},
|
|
},
|
|
// Group 9 of the sweep. Rebuilt on the shared form parts since
|
|
// (forms-library lane D, spec integration-settings), and rebuilt
|
|
// again as one table in round 4 (design D21): one declaration
|
|
// renders every key, secret ones as static rows, so this fixture
|
|
// builds .Form and its cells the way the real handler does. The
|
|
// effective value and the source badge are columns again, the
|
|
// "Currently: X." hint and the "(optional)" marker are gone, and
|
|
// there is no second table below the form.
|
|
{
|
|
name: "integration settings", template: "operator_integration_settings.html",
|
|
data: func() IntegrationSettingsData {
|
|
rows := []SettingRow{
|
|
{Key: "acme-url", Usage: "Service URL", Kind: "string", Required: true, Effective: "https://a.example", Source: "environment"},
|
|
{Key: "acme-token", Usage: "Admin token", Secret: true, SecretSet: true},
|
|
}
|
|
st := NewSafeTemplates(anatomySet(t), slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
return IntegrationSettingsData{
|
|
Key: "acme", DisplayName: "Acme", SurfacePath: "/operator/integrations/acme",
|
|
Form: forms.Render(settingsFormFor(rows), forms.Binding{
|
|
Mode: forms.ModeRecord,
|
|
Action: "/operator/integrations/acme/settings",
|
|
Values: settingsValues(rows),
|
|
Cells: settingsCells(st, rows),
|
|
}),
|
|
}
|
|
}(),
|
|
want: []string{`<h1 class="h2 mb-0">Acme settings</h1>`, `<li class="breadcrumb-item"><a href="/operator/integrations">Integrations</a></li>`, `<li class="breadcrumb-item"><a href="/operator/integrations/acme">Acme</a></li>`, `<code>https://a.example</code>`, `<th scope="row"`, `text-bg-light border">Environment`, `Set acme-token (or acme-token-file) in the environment.`},
|
|
banned: []string{`← Back`, `badge text-bg-light text-muted`, `text-bg-info">Environment`, `Managed via environment<`, `text-bg-warning">Override`, `Currently:`, `(optional)`, `Secret keys are managed via the environment and never shown.`},
|
|
},
|
|
{
|
|
// design D24: the Stripe provider page, distinct from its
|
|
// settings page — the trail reads "Operator / Integrations /
|
|
// Stripe" and the Delivery queue section moved here.
|
|
name: "stripe provider page", template: "operator_integration_stripe.html",
|
|
data: StripeIntegrationData{
|
|
Configured: true, ModeLabel: "Test mode",
|
|
DeliveryQueueHeader: SectionHeader{Title: "Delivery queue"},
|
|
DeliveryQueue: DeliveryQueue{Pending: 1, DeadLetter: 1, Available: true},
|
|
DeliveryQueueEntries: []DeadLetterEntry{
|
|
{OperationType: "create_stripe_price", Attempts: 3, UpdatedAt: "Jan 2, 2026 3:04 PM"},
|
|
},
|
|
},
|
|
want: []string{`<h1 class="h2 mb-0">Stripe</h1>`, `<li class="breadcrumb-item"><a href="/operator/integrations">Integrations</a></li>`, `>Stripe settings</a>`, `<h2 class="h5 mb-0">Details</h2>`, `<span class="badge text-bg-success">Configured</span>`, `<h2 class="h5 mb-0">Delivery queue</h2>`, `<code class="text-nowrap">create_stripe_price</code>`},
|
|
},
|
|
{
|
|
// Design D18: only the two surface roots (the operator
|
|
// overview, the member dashboard) carry no trail — the
|
|
// not-found page is not one of them, so it gets the surface's
|
|
// root crumb like every other page.
|
|
name: "not found", template: "operator_not_found.html", data: OperatorNotFoundData{Path: "/operator/nope"},
|
|
want: []string{`<h1 class="h2 mb-0">Page not found</h1>`, `Go to the Operator overview</a>`, `<code>/operator/nope</code>`,
|
|
`<li class="breadcrumb-item"><a href="/operator">Operator</a></li>`, `<li class="breadcrumb-item active" aria-current="page">Page not found</li>`},
|
|
},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) { checkAnatomy(t, c) })
|
|
}
|
|
}
|