Files
member-console/internal/server/operator_enrollment_render_test.go
T
cgalo5758 3727ff31d8 Add entitlement set rule change ledger and preview flow
Add an append-only ledger of entitlement set rule changes with per-pool
effect rows, a preview-and-commit rule change flow, and an automatic
drain that settles deferred recomputations. Rules gain a tier reduction
policy, resource keys declare over-limit behavior, and the materializer
now lowers limits when a rule stops applying.
Add entitlement set rule change ledger and preview flow

Add an append-only ledger of entitlement set rule changes with a
preview-and-commit operator flow. Rule writes now go through an enclosed
`core.commit_rule_change` function that files an act row and one
obligation per carrying pool, with a drain workflow settling deferred
recomputations. The preview dry-runs the materializer with a rule
overlay and renders per-pool buckets, reduction-policy disclosures, and
provider over-limit consequences. Materializing transactions take a
shared advisory rendezvous that rule changes hold exclusively, enforced
by a possession assertion. Add History and Entitlement changes surfaces,
a rule-less warning on five product-selection surfaces, and a
`tier_reduction_policy` column that gates FedWiki parking.
2026-09-15 03:53:28 -05:00

1209 lines
57 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
import (
"bytes"
"database/sql"
"fmt"
"html/template"
"io/fs"
"strings"
"testing"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/embeds"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/forms"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
"github.com/google/uuid"
)
// renderOrgEnrollment executes the real operator_enrollment.html template
// with the given data, mirroring how NewOperatorPartialsHandler loads the
// operator partials. Unlike the smoke test's stubbed fieldErr, this uses the
// real fieldErr scoping logic (copied from operator_partials.go) so
// form-scoped banners and field errors actually exercise their branches.
func renderOrgEnrollment(t *testing.T, data OrgEnrollmentData) 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,
"fieldErr": func(activeForm, expectedForm, activeScope, expectedScope string, errs web.FieldErrors, field string) string {
if activeForm != expectedForm {
return ""
}
if activeScope != expectedScope {
return ""
}
return errs.Get(field)
},
"stripeEntityURL": func(string, string) string { return "" },
"helpIcon": helpIcon,
})
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, "operator_enrollment.html", data); err != nil {
t.Fatalf("ExecuteTemplate: %v", err)
}
return buf.String()
}
// onePoolFixture is the baseline single-pool, single-tier org the composite
// renders against most of the time. IssueGrantForm and, per delivery,
// ExtendForm are built the same way loadOrgEnrollmentData builds them, so a
// fixture's markup matches what the handler actually renders (design D9).
func onePoolFixture() OrgEnrollmentData {
orgID := "org-1"
products := []IssuanceProductOption{
{ProductID: "prod-plan", Name: "Standard Plan"},
{ProductID: "prod-addon", Name: "Extra Seats", DisplayCategory: "addon"},
}
return OrgEnrollmentData{
OrgID: orgID,
OrgName: "Test Org",
Pools: []PoolEnrollmentViewModel{
{PoolID: "pool-1", PoolName: "default", PoolType: "default", Status: "active"},
},
IssuanceProducts: products,
IssueGrantForm: issueGrantFormView(orgID, products, forms.ModeRecord, forms.NewValues(), nil, ""),
}
}
// withExtendForms builds each delivery's ExtendForm the way
// loadOrgEnrollmentData does, for a fixture whose test sets
// data.Pools[0].Deliveries directly rather than through the loader.
func withExtendForms(data OrgEnrollmentData) OrgEnrollmentData {
for pi := range data.Pools {
for di := range data.Pools[pi].Deliveries {
d := &data.Pools[pi].Deliveries[di]
if d.GrantBacked {
d.ExtendForm = extendGrantFormView(data.OrgID, data.Pools[pi].PoolID, d.ProvisionID, forms.ModeRecord, extendGrantRestValues(d.ProvisionID), nil)
}
}
}
return data
}
// TestOrgEnrollmentRendersNoDerivedOrgIdentifier covers entity-keys: the
// organization composite renders the organization name under the org
// heading, never an identifier derived from that name.
func TestOrgEnrollmentRendersNoDerivedOrgIdentifier(t *testing.T) {
out := renderOrgEnrollment(t, onePoolFixture())
if strings.Contains(out, "test-org") {
t.Errorf("expected no derived organization identifier on the org composite, got:\n%s", out)
}
}
// TestOrgEnrollmentBillingSummary_NoRawCents covers finding #6: the billing
// summary must render pre-formatted currency strings, not raw integer cents
// glued to a bare currency code. Round 5 adds the zero balance, which is
// money like any other amount and never the bare digit the old template
// fell back to.
func TestOrgEnrollmentBillingSummary_NoRawCents(t *testing.T) {
data := onePoolFixture()
data.BillingSummary = BillingSummaryViewModel{
HasAccount: true,
AccountStatus: "active",
HasInvoice: true,
LatestInvoiceNumber: "0007",
LatestInvoiceDate: "Jan 1, 2026",
InvoiceState: "paid",
OutstandingBalance: 500,
OutstandingBalanceFormatted: "USD 5.00",
Currency: "usd",
}
out := renderOrgEnrollment(t, data)
if !strings.Contains(out, "USD 5.00") {
t.Error("expected formatted outstanding balance USD 5.00 in output")
}
// The old bug rendered the raw int (1000) with no formatting; guard
// against that literal reappearing next to a bare currency code.
if strings.Contains(out, ">1000 usd<") || strings.Contains(out, ">500 usd<") {
t.Errorf("found raw unformatted cents in output:\n%s", out)
}
zero := onePoolFixture()
zero.BillingSummary = BillingSummaryViewModel{
HasAccount: true,
AccountStatus: "active",
OutstandingBalance: 0,
OutstandingBalanceFormatted: "USD 0.00",
Currency: "usd",
}
out = renderOrgEnrollment(t, zero)
if !strings.Contains(out, "USD 0.00") {
t.Errorf("a zero balance must read as money, got:\n%s", out)
}
if strings.Contains(out, "<div>0</div>") {
t.Errorf("a zero balance must not render as a bare digit, got:\n%s", out)
}
}
// TestOrgEnrollmentBillingCard_RowsAndOrder covers design D5 round 5: three
// rows in triage order (outstanding balance, subscriptions, latest
// invoice), each leading with its state where there is one; the invoice
// cell reads as one line: badge, then the number, then a muted middot and
// the date. The invoice's own amount is not shown on the card (round 6:
// "remove the balance, and put the bottom row invoice and date where the
// balance is now").
func TestOrgEnrollmentBillingCard_RowsAndOrder(t *testing.T) {
data := onePoolFixture()
data.BillingSummary = BillingSummaryViewModel{
HasAccount: true,
AccountStatus: "active",
SubscriptionCount: 1,
SubscriptionState: "active",
SubscriptionNote: "renews Mar 1, 2026",
HasInvoice: true,
LatestInvoiceID: "inv-1",
LatestInvoiceNumber: "0007",
LatestInvoiceDate: "Jan 1, 2026",
InvoiceState: "paid",
OutstandingBalance: 0,
OutstandingBalanceFormatted: "USD 0.00",
Currency: "usd",
}
out := renderOrgEnrollment(t, data)
balanceIdx := strings.Index(out, `<div class="text-muted small">Outstanding balance</div>`)
subsIdx := strings.Index(out, `<div class="text-muted small">Subscriptions</div>`)
invoiceIdx := strings.Index(out, `<div class="text-muted small">Latest invoice</div>`)
if balanceIdx < 0 || subsIdx < 0 || invoiceIdx < 0 || !(balanceIdx < subsIdx && subsIdx < invoiceIdx) {
t.Errorf("expected the rows to read balance, subscriptions, latest invoice, got:\n%s", out)
}
// The invoice cell: badge, then the number, as a plain link.
if !strings.Contains(out, `<a href="/operator/billing/invoices/inv-1">0007</a>`) {
t.Errorf("expected the invoice number to link to the invoice as plain text, got:\n%s", out)
}
if strings.Contains(out, "<code>0007</code>") {
t.Errorf("the invoice number must not be wrapped in <code>, got:\n%s", out)
}
if strings.Contains(out, "USD 10.00") {
t.Errorf("the invoice's own amount must not render in the Latest invoice cell, got:\n%s", out)
}
invBadgeIdx := strings.Index(out, `text-bg-success">Paid`)
numberIdx := strings.Index(out, ">0007</a>")
dateIdx := strings.Index(out, `<small class="text-muted">· Jan 1, 2026</small>`)
if invBadgeIdx < 0 || numberIdx < 0 || dateIdx < 0 || !(invBadgeIdx < numberIdx && numberIdx < dateIdx) {
t.Errorf("expected the invoice cell to read badge, number, then the muted middot and date, got:\n%s", out)
}
// A zero balance is a plain amount: no badge, no link, no caption.
if strings.Contains(out, `<a href="/operator/billing/invoices?q=Test&#43;Org&amp;status=open"`) {
t.Errorf("a zero balance must not link to open invoices, got:\n%s", out)
}
if strings.Contains(out, "across open invoices") {
t.Errorf("the outstanding balance's caption is retired (design D5, round 2), got:\n%s", out)
}
// The healthy account says nothing about its own status.
if strings.Contains(out, `<div class="text-muted small">Account</div>`) {
t.Errorf("an active account must not render an Account row, got:\n%s", out)
}
if !strings.Contains(out, `1 subscription</a>`) {
t.Errorf("expected the subscription count as a link, got:\n%s", out)
}
if !strings.Contains(out, "· renews Mar 1, 2026") {
t.Errorf("expected the single subscription's period clause, got:\n%s", out)
}
}
// TestOrgEnrollmentBillingCard_OwedAndSeveral covers the states the card
// exists for: money owed leads with Unpaid and links the amount to this
// organization's open invoices; several subscriptions read as a count with
// the worst state and a muted exception note; an abnormal account status
// gets its own row.
func TestOrgEnrollmentBillingCard_OwedAndSeveral(t *testing.T) {
data := onePoolFixture()
data.BillingSummary = BillingSummaryViewModel{
HasAccount: true,
AccountStatus: "suspended",
SubscriptionCount: 2,
SubscriptionState: "past_due",
SubscriptionNote: "1 past due",
HasInvoice: true,
LatestInvoiceID: "inv-2",
LatestInvoiceNumber: "0002",
LatestInvoiceDate: "Feb 1, 2026",
InvoiceState: "overdue",
OutstandingBalance: 24000,
OutstandingBalanceFormatted: "USD 240.00",
Currency: "usd",
}
out := renderOrgEnrollment(t, data)
// url.Values.Encode writes the space as "+", which html/template then
// escapes as "&#43;" in an href; both decode back to "Test Org".
if !strings.Contains(out, `<a href="/operator/billing/invoices?q=Test&#43;Org&amp;status=open"><strong>USD 240.00</strong></a>`) {
t.Errorf("expected the owed amount to link to this organization's open invoices, got:\n%s", out)
}
unpaidIdx := strings.Index(out, `text-bg-warning">Unpaid`)
owedIdx := strings.Index(out, `<strong>USD 240.00</strong>`)
if unpaidIdx < 0 || owedIdx < 0 || unpaidIdx > owedIdx {
t.Errorf("expected the Unpaid badge to lead the owed amount, got:\n%s", out)
}
if !strings.Contains(out, `<a href="/operator/billing/subscriptions?q=Test&#43;Org">2 subscriptions</a>`) {
t.Errorf("expected the subscription count to link to this organization's subscriptions, got:\n%s", out)
}
if !strings.Contains(out, `text-bg-danger">Past due`) {
t.Errorf("expected the worst subscription state to lead the count, got:\n%s", out)
}
if !strings.Contains(out, "· 1 past due") {
t.Errorf("expected the muted exception note, got:\n%s", out)
}
if !strings.Contains(out, `text-bg-danger">Overdue`) {
t.Errorf("expected the invoice's derived Overdue state, got:\n%s", out)
}
accountIdx := strings.Index(out, `<div class="text-muted small">Account</div>`)
if accountIdx < 0 || !strings.Contains(out, `text-bg-danger">Suspended`) {
t.Errorf("expected an abnormal account status to render its own row, got:\n%s", out)
}
if accountIdx > strings.Index(out, `<div class="text-muted small">Outstanding balance</div>`) {
t.Errorf("expected the Account row above the balance, got:\n%s", out)
}
// "Full billing" navigates to this organization's account row, not the
// deployment's whole accounts list.
if !strings.Contains(out, `href="/operator/billing/accounts?q=Test&#43;Org"`) {
t.Errorf("expected the header action to be scoped to the organization, got:\n%s", out)
}
}
// TestOrgEnrollmentBillingCard_Empties covers the two in-card empties: an
// account with no subscriptions and none with no invoices. Neither mentions
// grants; Plan and grants below is where provenance lives.
func TestOrgEnrollmentBillingCard_Empties(t *testing.T) {
data := onePoolFixture()
data.BillingSummary = BillingSummaryViewModel{
HasAccount: true,
AccountStatus: "active",
OutstandingBalance: 0,
OutstandingBalanceFormatted: "USD 0.00",
}
out := renderOrgEnrollment(t, data)
if !strings.Contains(out, "No subscriptions") {
t.Errorf("expected the no-subscriptions empty, got:\n%s", out)
}
if !strings.Contains(out, "No invoices issued") {
t.Errorf("expected the no-invoices empty, got:\n%s", out)
}
if strings.Contains(out, "No subscription<") {
t.Errorf("the singular subscription empty is retired, got:\n%s", out)
}
// The card ends where the Pools section header begins (the template's
// HTML comments never reach the output; html/template strips them).
billingCard := out[strings.Index(out, "Outstanding balance"):strings.Index(out, `<h2 class="h5 mb-0">Pools`)]
if strings.Contains(billingCard, "grant") {
t.Errorf("the billing card must not editorialize about grants, got:\n%s", billingCard)
}
}
// TestOrgBillingListURLs pins the three links the card produces against the
// parameters the billing lists actually parse: the shared search parameter
// "q" (ParseListParamsNS) and the invoices view's "status" facet.
func TestOrgBillingListURLs(t *testing.T) {
data := OrgEnrollmentData{OrgName: "Test Org", BillingSummary: BillingSummaryViewModel{HasAccount: true}}
if got, want := data.OpenInvoicesURL(), "/operator/billing/invoices?q=Test+Org&status=open"; got != want {
t.Errorf("OpenInvoicesURL = %q, want %q", got, want)
}
if got, want := data.SubscriptionsURL(), "/operator/billing/subscriptions?q=Test+Org"; got != want {
t.Errorf("SubscriptionsURL = %q, want %q", got, want)
}
header := data.BillingHeader()
if header.Action == nil {
t.Fatal("expected a Full billing action on an org with an account")
}
if got, want := header.Action.URL, "/operator/billing/accounts?q=Test+Org"; got != want {
t.Errorf("Full billing URL = %q, want %q", got, want)
}
if header.Action.Filled {
t.Error("Full billing is navigation and stays outline-secondary (design D19)")
}
// "open" is a value the invoices view's own facet accepts.
if ValidFacet("open", invoiceStatusFacets) != "open" {
t.Error("the open status facet the balance link uses is not one the invoices view accepts")
}
// No account, no navigation.
if (OrgEnrollmentData{OrgName: "Test Org"}).BillingHeader().Action != nil {
t.Error("expected no Full billing action without a billing account")
}
}
// TestSummarizeSubscriptions pins the aggregate the card renders: the count,
// the worst state across every subscription (past_due > canceling > active >
// trialing > everything else), the exception note when the worst state is
// not the whole account's state, and the single subscription's period
// clause.
func TestSummarizeSubscriptions(t *testing.T) {
end := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC)
at := func(status string, canceling bool, periodEnd bool) billing.Subscription {
s := billing.Subscription{Status: status, CancelAtPeriodEnd: canceling}
if periodEnd {
s.CurrentPeriodEnd = sql.NullTime{Time: end, Valid: true}
}
return s
}
cases := []struct {
name string
subs []billing.Subscription
wantCount int
wantState string
wantNote string
}{
{name: "none", subs: nil},
{name: "one active", subs: []billing.Subscription{at("active", false, true)}, wantCount: 1, wantState: "active", wantNote: "renews Mar 1, 2026"},
{name: "one canceling", subs: []billing.Subscription{at("active", true, true)}, wantCount: 1, wantState: "canceling", wantNote: "ends Mar 1, 2026"},
{name: "one active without a period end", subs: []billing.Subscription{at("active", false, false)}, wantCount: 1, wantState: "active"},
{name: "one canceled says nothing about renewal", subs: []billing.Subscription{at("canceled", false, true)}, wantCount: 1, wantState: "canceled"},
{name: "past due outranks canceling", subs: []billing.Subscription{at("active", true, true), at("past_due", false, true)}, wantCount: 2, wantState: "past_due", wantNote: "1 past due"},
{name: "canceling outranks active", subs: []billing.Subscription{at("active", false, true), at("active", true, true)}, wantCount: 2, wantState: "canceling", wantNote: "1 canceling"},
{name: "active outranks trialing", subs: []billing.Subscription{at("trialing", false, true), at("active", false, true)}, wantCount: 2, wantState: "active", wantNote: "1 active"},
{name: "trialing outranks canceled", subs: []billing.Subscription{at("canceled", false, true), at("trialing", false, true)}, wantCount: 2, wantState: "trialing", wantNote: "1 trialing"},
{name: "uniform states need no note", subs: []billing.Subscription{at("active", false, true), at("active", false, true)}, wantCount: 2, wantState: "active"},
{name: "cancel at period end on a past-due subscription reads past due", subs: []billing.Subscription{at("past_due", true, true)}, wantCount: 1, wantState: "past_due", wantNote: "renews Mar 1, 2026"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
count, state, note := summarizeSubscriptions(tc.subs)
if count != tc.wantCount || state != tc.wantState || note != tc.wantNote {
t.Errorf("summarizeSubscriptions = (%d, %q, %q), want (%d, %q, %q)", count, state, note, tc.wantCount, tc.wantState, tc.wantNote)
}
})
}
// The note reads the badge's own word, so the count and the badge can
// never name the state differently.
if got := StatusBadge("past_due").Label; strings.ToLower(got) != "past due" {
t.Errorf("the exception note is built from the badge label, which is now %q", got)
}
if b := (BillingSummaryViewModel{SubscriptionState: "canceling"}).SubscriptionsBadge(); b.Title == "" {
t.Error("the canceling badge keeps its tooltip")
}
}
// TestOrgEnrollmentIssueGrant_MultiPoolGuardRendersBanner covers finding #32:
// a multi-pool org's refusal is the Issue grant form's own form-level error
// (design D9: a refusal renders through the same declared form, scoped to
// it, not a page-level banner).
func TestOrgEnrollmentIssueGrant_MultiPoolGuardRendersBanner(t *testing.T) {
data := onePoolFixture()
errs := forms.NewErrors()
errs.Form("This organization has more than one resource pool.")
data.IssueGrantForm = issueGrantFormView(data.OrgID, data.IssuanceProducts, forms.ModeSubmission, forms.NewValues(), errs, "")
out := renderOrgEnrollment(t, data)
if !strings.Contains(out, "more than one resource pool") {
t.Errorf("expected multi-pool guard banner in output, got:\n%s", out)
}
}
// TestOrgEnrollmentIssuanceForms_DoubleSubmitGuard covers finding #27: the
// merged Issue grant form needs the hx-disable double-submit guard the
// Extend form already has.
func TestOrgEnrollmentIssuanceForms_DoubleSubmitGuard(t *testing.T) {
data := onePoolFixture()
// A grant-backed delivery makes the extend panel render, so both
// forms are present in this pass.
data.Pools[0].HasAttachment = true
data.Pools[0].Deliveries = []PoolDeliveryViewModel{{ProvisionID: "prov-1", ProductName: "Standard", GrantBacked: true, Rungs: []PoolRungViewModel{{LadderName: "Hosting", Rank: 1}}}}
data = withExtendForms(data)
out := renderOrgEnrollment(t, data)
if n := strings.Count(out, `hx-disable="find button[type=submit]"`); n != 2 {
t.Errorf("expected 2 forms (issue, extend) with hx-disable, found %d in:\n%s", n, out)
}
}
// TestOrgEnrollmentExtendForm_RequiresGrantDelivery: the pool card groups
// by DELIVERY (maintainer design round 2026-08-24): the product renders
// once with the single Extend control, its ladder placements render as
// rung pills, and the extend form is pre-scoped via a hidden
// provision_id. A non-grant-backed delivery gets a disabled control with
// the reason and no form; two separately-provisioned deliveries each get
// their own control and panel; a shared product (one provision on several
// ladders) is ONE block with one control, one panel, several rung pills,
// and panel copy stating the multi-ladder scope. No "Same delivery"
// marker exists anywhere; the grouping conveys it.
func TestOrgEnrollmentExtendForm_RequiresGrantDelivery(t *testing.T) {
data := onePoolFixture()
data.Pools[0].HasAttachment = true
data.Pools[0].Deliveries = []PoolDeliveryViewModel{{ProvisionID: "prov-sub", ProductName: "Standard", GrantBacked: false, Rungs: []PoolRungViewModel{{LadderName: "Hosting", Rank: 1}}}}
data = withExtendForms(data)
out := renderOrgEnrollment(t, data)
if strings.Contains(out, "/grant/extend") {
t.Errorf("extend form rendered for a delivery without grant backing:\n%s", out)
}
if !strings.Contains(out, "Nothing to extend: this delivery is not grant-backed") {
t.Errorf("expected the disabled Extend button's tooltip reason, got:\n%s", out)
}
// design D10 / ACC-8: the disabled Extend control renders through the
// shared disabledControl part (ui_disabled_control.html), which adds
// aria-describedby beside disabled.
if !strings.Contains(out, `disabled aria-describedby="extend-reason-prov-sub">Extend tier</button>`) {
t.Errorf("expected a disabled Extend button for a non-extendable delivery, got:\n%s", out)
}
data.Pools[0].Deliveries = []PoolDeliveryViewModel{
{ProvisionID: "prov-a", ProductName: "Standard", GrantBacked: true, Rungs: []PoolRungViewModel{{LadderName: "Hosting", Rank: 1}}},
{ProvisionID: "prov-b", ProductName: "Priority", GrantBacked: true, Rungs: []PoolRungViewModel{{LadderName: "Support", Rank: 2}}},
}
data = withExtendForms(data)
out = renderOrgEnrollment(t, data)
for _, want := range []string{
`data-bs-target="#extendPanel-prov-a"`,
`data-bs-target="#extendPanel-prov-b"`,
`id="extendPanel-prov-a"`,
`id="extendPanel-prov-b"`,
`name="provision_id" value="prov-a"`,
`name="provision_id" value="prov-b"`,
"Standard", "Priority",
} {
if !strings.Contains(out, want) {
t.Errorf("two-delivery pool missing %q in:\n%s", want, out)
}
}
if n := strings.Count(out, "/grant/extend"); n != 2 {
t.Errorf("expected one extend form per grant-backed delivery (2), found %d", n)
}
shared := []PoolDeliveryViewModel{
{ProvisionID: "prov-shared", ProductName: "Everything Tier", GrantBacked: true, Rungs: []PoolRungViewModel{
{LadderName: "Hosting", Rank: 1, ActivatedAt: "Mar 1, 2026 9:00 AM"},
{LadderName: "Support", Rank: 3, ActivatedAt: "Mar 1, 2026 9:00 AM"},
}},
}
data.Pools[0].Deliveries = shared
data = withExtendForms(data)
out = renderOrgEnrollment(t, data)
if n := strings.Count(out, "Everything Tier</strong>"); n < 1 {
t.Errorf("expected the shared product's block, got:\n%s", out)
}
// Two buttons legitimately toggle the one panel: the opener and the
// form's own declared WayOut (Cancel, WayOutClosePanel), which renders
// through the same data-bs-toggle="collapse" data-bs-target mechanism
// (ui_form.html). A shared delivery still gets exactly one panel and
// one form, asserted below.
if n := strings.Count(out, `data-bs-target="#extendPanel-prov-shared"`); n != 2 {
t.Errorf("a shared delivery must render exactly one opener and one Cancel targeting its panel, found %d", n)
}
if n := strings.Count(out, `id="extendPanel-prov-shared"`); n != 1 {
t.Errorf("a shared delivery must render exactly one panel, found %d", n)
}
for _, want := range []string{
`Hosting, rank 1`,
`Support, rank 3`,
"One extension refreshes all 2 ladders shown",
} {
if !strings.Contains(out, want) {
t.Errorf("shared-delivery block missing %q in:\n%s", want, out)
}
}
if strings.Contains(out, "Same delivery") {
t.Errorf("the Same delivery marker was removed by the grouping; it must not render:\n%s", out)
}
// entity-keys (ui-vocabulary "Machine keys are called keys and
// never lead a presentation"): the ladder key must never render on the
// composite; only the display name stands for the ladder here.
if strings.Contains(out, "hosting") || strings.Contains(out, "support") {
t.Errorf("ladder key must not render anywhere on the composite, got:\n%s", out)
}
}
// TestOrgEnrollmentIssueGrantPanel_TerseCopy covers design D5 / the
// plan-enrollment-administration spec's "The composite's grant forms use
// imperative copy and help icons": the Issue grant panel's lead is terse,
// the tier sentence survives, the quantity and Valid until fields carry
// help icons instead of inline glosses or a sentence under the field, and
// no other explanatory paragraph renders in the panel.
func TestOrgEnrollmentIssueGrantPanel_TerseCopy(t *testing.T) {
out := renderOrgEnrollment(t, onePoolFixture())
if !strings.Contains(out, "Issue a grant to update this organization's entitlements.") {
t.Errorf("expected the terse panel lead, got:\n%s", out)
}
if !strings.Contains(out, "A tier product moves the pool to that tier, up or down.") {
t.Errorf("expected the tier sentence to survive, got:\n%s", out)
}
if strings.Contains(out, "Leave <em>Valid until</em> blank") || strings.Contains(out, "Records the grant and updates") {
t.Errorf("the retired panel lead must not render, got:\n%s", out)
}
if !strings.Contains(out, `data-bs-content="Seats, for per-seat products."`) {
t.Errorf("expected the Quantity field's help icon, got:\n%s", out)
}
if strings.Contains(out, `<span title="Seats, for per-seat products.">`) {
t.Errorf("the hand-rolled Quantity gloss must be replaced by the help-icon part, got:\n%s", out)
}
if !strings.Contains(out, `data-bs-content="Leave blank for a grant with no end date."`) {
t.Errorf("expected the Valid until field's help icon, got:\n%s", out)
}
}
// TestOrgEnrollmentExtendPanel_NoteLabel covers findings FA-28/FA-32: the
// Extend panel's free-text field is the shared forms.GrantNote, labelled
// "Note" identically to the Issue form's own note field, and Extend carries
// no "reason" field at all (Issue's Reason category select has no
// counterpart on Extend).
func TestOrgEnrollmentExtendPanel_NoteLabel(t *testing.T) {
data := onePoolFixture()
data.Pools[0].HasAttachment = true
data.Pools[0].Deliveries = []PoolDeliveryViewModel{{ProvisionID: "prov-1", ProductName: "Standard", GrantBacked: true, Rungs: []PoolRungViewModel{{LadderName: "Hosting", Rank: 1}}}}
data = withExtendForms(data)
out := renderOrgEnrollment(t, data)
if !strings.Contains(out, `for="form-operator.enrollment.grant.extend-prov-1-description-control">Note`) {
t.Errorf("expected the Extend panel's free-text field labelled Note, got:\n%s", out)
}
if strings.Contains(out, "form-operator.enrollment.grant.extend-prov-1-reason") {
t.Errorf("Extend must carry no reason field (FA-28), got:\n%s", out)
}
}
// TestOrgEnrollmentExtendPanel_MatchesIssueGrantSize covers task 5: the
// Extend form's controls match the Issue grant form's small size
// (form-control-sm / form-label-sm) — both are Dense-family forms, which
// forms.Render sizes uniformly, rather than rendering at the default size
// while sharing the same card.
func TestOrgEnrollmentExtendPanel_MatchesIssueGrantSize(t *testing.T) {
data := onePoolFixture()
data.Pools[0].HasAttachment = true
data.Pools[0].Deliveries = []PoolDeliveryViewModel{{ProvisionID: "prov-1", ProductName: "Standard", GrantBacked: true, Rungs: []PoolRungViewModel{{LadderName: "Hosting", Rank: 1}}}}
data = withExtendForms(data)
out := renderOrgEnrollment(t, data)
for _, want := range []string{
`for="form-operator.enrollment.grant.extend-prov-1-description-control">Note`,
`for="form-operator.enrollment.grant.extend-prov-1-valid_until-control">Valid until`,
`id="form-operator.enrollment.grant.extend-prov-1-description-control" name="description"`,
`id="form-operator.enrollment.grant.extend-prov-1-valid_until-control" name="valid_until"`,
`class="form-control form-control-sm`,
`class="btn btn-primary btn-sm">`,
} {
if !strings.Contains(out, want) {
t.Errorf("expected the Extend form to match the Issue grant form's small size (%q), got:\n%s", want, out)
}
}
}
// TestOrgEnrollmentPoolsHeader_HelpIcon covers design D5: the Pools section
// header carries the one explanation of what a pool is, and no paragraph on
// the page repeats it.
func TestOrgEnrollmentPoolsHeader_HelpIcon(t *testing.T) {
out := renderOrgEnrollment(t, onePoolFixture())
if !strings.Contains(out, `data-bs-content="A pool holds what an organization&#39;s plan and grants deliver; its sites and services draw from it."`) {
t.Errorf("expected the Pools header's help icon, got:\n%s", out)
}
if strings.Count(out, "A pool holds what an organization") != 1 {
t.Errorf("expected the pools explanation to render exactly once (the header help icon only), got:\n%s", out)
}
}
// TestOrgEnrollmentQuantityInput_HasMaxAttribute covers finding #31: the
// quantity input needs a max attribute matching the server-side cap.
func TestOrgEnrollmentQuantityInput_HasMaxAttribute(t *testing.T) {
out := renderOrgEnrollment(t, onePoolFixture())
if !strings.Contains(out, `max="1000000"`) {
t.Errorf("expected quantity input to carry max=\"1000000\", got:\n%s", out)
}
}
// TestOrgEnrollmentDescriptionInput_HasMaxLength covers finding #48's
// surviving surface: reason is now a closed <select> over the grant-reason
// domain, so the free-text length guard lives on the shared note field
// (forms.GrantNote, a Text control, not a textarea).
func TestOrgEnrollmentDescriptionInput_HasMaxLength(t *testing.T) {
out := renderOrgEnrollment(t, onePoolFixture())
if !strings.Contains(out, `id="form-operator.enrollment.grant.issue-description-control" name="description"`) {
t.Errorf("expected the issue description control by id, got:\n%s", out)
}
if !strings.Contains(out, `maxlength="500"`) {
t.Errorf("expected the issue description control to carry maxlength=\"500\", got:\n%s", out)
}
}
// TestOrgEnrollmentProductsSection covers finding #52: a product-catalog
// query failure must render an inline alert (not a vanished form), and a
// genuinely empty catalog must say so instead of silently omitting the
// issuance form card.
func TestOrgEnrollmentProductsSection(t *testing.T) {
t.Run("query failure renders an alert, not a blank section", func(t *testing.T) {
data := onePoolFixture()
data.IssuanceProducts = nil
data.ProductsError = "Failed to load the product catalog. Issuance forms are unavailable until this is resolved."
out := renderOrgEnrollment(t, data)
if !strings.Contains(out, "Failed to load the product catalog") {
t.Errorf("expected ProductsError alert in output, got:\n%s", out)
}
if strings.Contains(out, "No published products yet") {
t.Error("a query failure must not be reported as a genuinely-empty catalog")
}
})
t.Run("genuinely empty catalog says so instead of vanishing", func(t *testing.T) {
data := onePoolFixture()
data.IssuanceProducts = nil
out := renderOrgEnrollment(t, data)
if !strings.Contains(out, "No published products yet") {
t.Errorf("expected the genuinely-empty-catalog message, got:\n%s", out)
}
})
t.Run("non-empty catalog renders the single issuance form", func(t *testing.T) {
out := renderOrgEnrollment(t, onePoolFixture())
if !strings.Contains(out, "Issue grant") {
t.Error("expected the merged Issue grant card")
}
if !strings.Contains(out, `id="form-operator.enrollment.grant.issue-reason-control"`) {
t.Error("expected the reason <select> in the issue form")
}
if !strings.Contains(out, `<option value="manual"`) {
t.Error("expected grant-reason domain options in the reason <select>")
}
if strings.Contains(out, "Create Grant") {
t.Error("the dissolved second issuance form must not render")
}
})
t.Run("internal and supersession markers render on product options", func(t *testing.T) {
data := onePoolFixture()
data.IssuanceProducts = []IssuanceProductOption{
{ProductID: "prod-wrap", Name: "Wrap Product", IsInternal: true},
{ProductID: "prod-plan", Name: "Standard Plan", SupersedesSubscription: true},
}
data.IssueGrantForm = issueGrantFormView(data.OrgID, data.IssuanceProducts, forms.ModeRecord, forms.NewValues(), nil, "")
out := renderOrgEnrollment(t, data)
if !strings.Contains(out, "Wrap Product [internal]") {
t.Error("expected the [internal] marker on internal wrap product options")
}
if !strings.Contains(out, "Standard Plan ⚠") {
t.Error("expected the ⚠ marker on superseding product options")
}
if !strings.Contains(out, "supersede a tier currently held by a subscription") {
t.Error("expected the supersession advisory when a ⚠ option is present")
}
})
}
// TestOrgEnrollmentHeader_ShowsOwner covers the organization delta's owner
// visibility requirement on the org-detail surface (owner_person_id is
// NOT NULL, so a healthy render always links to the owner's person page);
// a resolution failure (defensive-only, the column itself is never NULL)
// must still show something rather than a blank "Owner:" line.
func TestOrgEnrollmentHeader_ShowsOwner(t *testing.T) {
data := onePoolFixture()
data.OrgOwnerPersonID = "person-1"
data.OrgOwnerName = "Jordan Lee"
out := renderOrgEnrollment(t, data)
if !strings.Contains(out, `href="/operator/persons/person-1"`) {
t.Errorf("expected the owner to link to their person page, got:\n%s", out)
}
if !strings.Contains(out, "Jordan Lee") {
t.Errorf("expected the owner's display name, got:\n%s", out)
}
data2 := onePoolFixture()
out2 := renderOrgEnrollment(t, data2)
if !strings.Contains(out2, `<span class="text-muted">—</span>`) {
t.Errorf("expected the defensive none-owner fallback, got:\n%s", out2)
}
}
// TestOrgEnrollmentPoolMissing_RendersBreakageAndBlocksIssueGrant covers
// ux-honest-surfaces UX-11 ("a pool-less organization is presented as
// broken, not empty"): an org with zero resource pools must render an
// explicit breakage warning and must not offer a submittable Issue Grant
// form, even when the product catalog is otherwise non-empty.
func TestOrgEnrollmentPoolMissing_RendersBreakageAndBlocksIssueGrant(t *testing.T) {
data := onePoolFixture()
data.Pools = nil
data.PoolMissing = true
out := renderOrgEnrollment(t, data)
if !strings.Contains(out, "Missing default resource pool") {
t.Errorf("expected the pool-missing breakage warning, got:\n%s", out)
}
if !strings.Contains(out, "Issue grant unavailable") {
t.Errorf("expected the Issue Grant form to be blocked with a reason, got:\n%s", out)
}
if strings.Contains(out, `hx-post="/partials/operator/organizations/org-1/grant/create"`) {
t.Errorf("Issue Grant form must not render (submittable) for a pool-less org:\n%s", out)
}
if strings.Contains(out, "No pools for this organization") || strings.Contains(out, "No active pools") {
t.Errorf("the neutral empty-pools state must not render for the pool-missing case:\n%s", out)
}
}
// TestOrgEnrollmentPoolStatus_NonActiveRendersDistinctly covers
// ux-honest-surfaces UX-11: a pool's actual status must be visible and a
// non-active pool must be visually distinct from a healthy one.
func TestOrgEnrollmentPoolStatus_NonActiveRendersDistinctly(t *testing.T) {
data := onePoolFixture()
data.Pools[0].Status = "suspended"
out := renderOrgEnrollment(t, data)
if !strings.Contains(out, "text-bg-danger") {
t.Errorf("expected a non-active pool status to render with distinct (danger) styling, got:\n%s", out)
}
if !strings.Contains(out, "not delivering entitlements") {
t.Errorf("expected a non-active pool to state it is not delivering, got:\n%s", out)
}
}
// TestOrgEnrollmentPoolUsage_RendersResourceCounters covers
// ux-honest-surfaces UX-11: per-resource usage counters (used/limit) must
// be visible on the org-detail pools panel.
func TestOrgEnrollmentPoolUsage_RendersResourceCounters(t *testing.T) {
data := onePoolFixture()
data.Pools[0].Usage = []PoolUsageViewModel{
{ResourceKey: "fedwiki_sites", Used: 3, Limit: 5},
}
out := renderOrgEnrollment(t, data)
for _, want := range []string{"fedwiki_sites", ">3<", ">5<"} {
if !strings.Contains(out, want) {
t.Errorf("expected usage counter output to contain %q, got:\n%s", want, out)
}
}
}
// TestOrgEnrollmentGrantsTable_DeliveryStateAndLineage covers
// ux-honest-surfaces UX-3: the Delivery column renders the derived state
// (never a colored grants.status ledger badge), and a superseded grant
// names its replacement while the replacing grant names its ancestor.
func TestOrgEnrollmentGrantsTable_DeliveryStateAndLineage(t *testing.T) {
data := onePoolFixture()
data.Grants = []GrantViewModel{
{GrantID: "g-old", ProductName: "Legacy Plan", GrantReason: "manual", Status: "active", Issuance: "Active", DeliveryState: "superseded", ReplacedByGrantID: "g-new", ReplacedByLabel: "Standard Plan", GrantedByPersonID: "p-1", GrantedByName: "Jamie Rivera"},
{GrantID: "g-new", ProductName: "Standard Plan", GrantReason: "manual", Status: "active", Issuance: "Active", DeliveryState: "live", ExtendsGrantID: "g-old", GrantedByPersonID: "p-1", GrantedByName: "Jamie Rivera"},
}
// Superseded rows and the ledger intro render on the History tab.
data.GrantsNav = ListNav{BasePath: "/operator/organizations/org-1", FacetParam: "tab", Facet: "history",
FacetOptions: []FacetOption{{Value: "", Label: "Active"}, {Value: "history", Label: "History"}},
Page: 1, Total: 2}
out := renderOrgEnrollment(t, data)
if !strings.Contains(out, "text-bg-success") || !strings.Contains(out, ">Live<") {
t.Errorf("expected a Live delivery badge, got:\n%s", out)
}
if !strings.Contains(out, ">Superseded<") {
t.Errorf("expected a Superseded delivery badge, got:\n%s", out)
}
if !strings.Contains(out, "replaced by Standard Plan") {
t.Errorf("expected the superseded row to name its replacement, got:\n%s", out)
}
if strings.Contains(out, "extends Legacy Plan") {
t.Errorf("the live row must not name what it extends; the line only repeated its product (maintainer, 2026-09-05), got:\n%s", out)
}
// grants.status ("active") must not render as a colored ledger badge --
// it may appear as plain reason/product text, but not inside a
// "badge ..." class attribute paired with the raw status word.
if strings.Contains(out, `badge bg-success">active<`) {
t.Errorf("grants.status must not render as a green ledger badge:\n%s", out)
}
// The intro was trimmed (maintainer 2026-08-23) but must still separate
// the ledger from live delivery.
if !strings.Contains(out, "One row per grant ever issued") || !strings.Contains(out, "superseded and inactive rows") {
t.Errorf("expected the trimmed ledger-vs-delivery line in place, got:\n%s", out)
}
// design D5: the composite carries the same Issuance/Delivery vocabulary,
// explanation, and Granted-by attribution as the grants index.
if strings.Contains(out, "<th>Ledger</th>") {
t.Errorf("the 'Ledger' header must be renamed 'Issuance' on the composite too:\n%s", out)
}
if !strings.Contains(out, "<th>Issuance ") || !strings.Contains(out, "<th>Granted by</th>") {
t.Errorf("expected Issuance and Granted by column headers on the composite ledger, got:\n%s", out)
}
// Round 2 (design D23): the standing explanation sentence is replaced by
// help icons on the Issuance and Delivery column headers.
if strings.Contains(out, "Issuance is what the record says happened to the grant.") {
t.Errorf("the standing Issuance-vs-Delivery paragraph must be replaced by header help icons, got:\n%s", out)
}
if !strings.Contains(out, `data-bs-content="What the record says happened to the grant: active, revoked, or expired."`) {
t.Errorf("expected the Issuance column header's help icon, got:\n%s", out)
}
if !strings.Contains(out, `data-bs-content="Whether the grant is delivering into the pool now."`) {
t.Errorf("expected the Delivery column header's help icon, got:\n%s", out)
}
if !strings.Contains(out, `<a href="/operator/persons/p-1">Jamie Rivera</a>`) {
t.Errorf("expected the Granted by cell to name and link the grantor, got:\n%s", out)
}
}
// TestOrgEnrollmentGrantsTable_SystemDefaultActionsCellEmpty covers ACC-32 /
// chrome-conventions "A verb-less row leaves its Actions cell empty": a live
// system-default grant carries no Revoke control (it is owned by org-type
// config, not per-grant operator action) and must not render replacement
// text ("System-managed") in its place.
func TestOrgEnrollmentGrantsTable_SystemDefaultActionsCellEmpty(t *testing.T) {
data := onePoolFixture()
data.Grants = []GrantViewModel{
{GrantID: "g-default", ProductName: "Starter", GrantReason: "default", Status: "active", Issuance: "Active", DeliveryState: "live"},
}
out := renderOrgEnrollment(t, data)
if strings.Contains(out, "System-managed") {
t.Errorf("the 'System-managed' Actions-cell text must not render (chrome-conventions: empty Actions cell), got:\n%s", out)
}
if strings.Contains(out, ">Revoke<") {
t.Errorf("a system-default grant must not carry a Revoke control, got:\n%s", out)
}
if !strings.Contains(out, `<em class="text-muted">System</em>`) {
t.Errorf("expected the system marker in the Granted by cell for a system-issued grant, got:\n%s", out)
}
}
// TestOrgEnrollmentGrantsTable_NoteLine covers design D7: a grant's audit
// note renders as a muted line under its reason on the composite's ledger
// too, and a grant with no note carries no such line.
func TestOrgEnrollmentGrantsTable_NoteLine(t *testing.T) {
data := onePoolFixture()
data.Grants = []GrantViewModel{
{GrantID: "g-note", ProductName: "Pro", GrantReason: "manual", Note: "Comped for the pilot cohort.", Status: "active", Issuance: "Active", DeliveryState: "live"},
{GrantID: "g-bare", ProductName: "Starter", GrantReason: "default", Status: "active", Issuance: "Active", DeliveryState: "live"},
}
out := renderOrgEnrollment(t, data)
if !strings.Contains(out, `<small class="text-muted">manual</small><div class="text-muted small">Comped for the pilot cohort.</div>`) {
t.Errorf("expected the noted grant's reason cell to carry the muted note line, got:\n%s", out)
}
if !strings.Contains(out, `<small class="text-muted">default</small></td>`) {
t.Errorf("expected the note-less grant's reason cell to carry no note line, got:\n%s", out)
}
}
// TestOrgEnrollmentTierChangesTable_ReasonCell covers design D3/D4: a
// grant-backed row's Reason cell shows the grant's reason label with its
// note as a muted line beneath, a non-grant-backed row shows the machine
// reason's sentence with no note, and the raw reason rides as the cell's
// title in both cases (the grants ledger's own idiom, design D7): body
// size, no <small> wrapper.
func TestOrgEnrollmentTierChangesTable_ReasonCell(t *testing.T) {
data := onePoolFixture()
data.Transitions = []TransitionHistoryViewModel{
{TransitionID: "t-grant", LadderName: "Hosting", TransitionType: "upgrade", FromLabel: "Basic", ToLabel: "Pro", ActorType: "operator", ReasonLabel: "Evaluation", ReasonNote: "30-day pilot", ReasonRaw: "30-day pilot", EffectiveAt: "Jan 2, 2026"},
{TransitionID: "t-default", LadderName: "Hosting", TransitionType: "initiate", FromLabel: "—", ToLabel: "Basic", ActorType: "system", ReasonLabel: "Default restored after a grant expired", ReasonRaw: "grant-expiration:g-1 default restoration", EffectiveAt: "Jan 3, 2026"},
}
out := renderOrgEnrollment(t, data)
if !strings.Contains(out, `<td title="30-day pilot">Evaluation<div class="text-muted small">30-day pilot</div></td>`) {
t.Errorf("expected the grant-backed Reason cell (label, note, raw-reason title), got:\n%s", out)
}
if !strings.Contains(out, `<td title="grant-expiration:g-1 default restoration">Default restored after a grant expired</td>`) {
t.Errorf("expected the machine-reason cell with no note and the raw string as its title, got:\n%s", out)
}
if strings.Contains(out, `<td><small>`) {
t.Errorf("the Tier changes Reason cell must not wrap in <small>, got:\n%s", out)
}
}
// TestOrgEnrollmentSectionErrors covers finding #52's remaining sections:
// Members, Grants, and Transitions must each surface their own query error
// as an inline alert rather than rendering an indistinguishable empty state.
func TestOrgEnrollmentSectionErrors(t *testing.T) {
data := onePoolFixture()
data.MembersError = "Failed to load members."
data.GrantsError = "Failed to load grants."
data.TransitionsError = "Failed to load transition history for one or more pools."
out := renderOrgEnrollment(t, data)
for _, want := range []string{
"Failed to load members.",
"Failed to load grants.",
"Failed to load transition history for one or more pools.",
} {
if !strings.Contains(out, want) {
t.Errorf("expected %q in output, got:\n%s", want, out)
}
}
}
// TestOrgEnrollmentEmbeddedLists_CapAndPager: Members keeps the honest
// 16-cap; the grants ledger and Tier changes graduated to the shared
// pager (maintainer design round 2026-08-24), windowing through the
// production pageSliceClamped and rendering listPager's true totals.
func TestOrgEnrollmentEmbeddedLists_CapAndPager(t *testing.T) {
t.Run("members over cap: 16 rows and the honest line once", func(t *testing.T) {
data := onePoolFixture()
rawMembers := make([]MemberRowViewModel, 20)
for i := range rawMembers {
rawMembers[i] = MemberRowViewModel{PersonID: fmt.Sprintf("person-%02d", i), DisplayName: fmt.Sprintf("Member-Fixture-%02d", i), RoleName: "member"}
}
data.Members, data.MembersTotal, data.MembersCapped = capEnrollmentList(rawMembers)
out := renderOrgEnrollment(t, data)
if n := strings.Count(out, `href="/operator/persons/person-`); n != 16 {
t.Errorf("expected exactly 16 rendered member rows, got %d in:\n%s", n, out)
}
if n := strings.Count(out, "Showing the latest 16 of 20."); n != 1 {
t.Errorf("expected the honest capped-list line once (members only), got %d in:\n%s", n, out)
}
})
t.Run("grants and tier changes paginate with true totals", func(t *testing.T) {
data := onePoolFixture()
tabOptions := []FacetOption{{Value: "", Label: "Active"}, {Value: "history", Label: "History"}}
rawGrants := make([]GrantViewModel, 60)
for i := range rawGrants {
rawGrants[i] = GrantViewModel{GrantID: fmt.Sprintf("g-%02d", i), ProductName: fmt.Sprintf("Grant-Fixture-%02d", i), GrantReason: "manual", DeliveryState: "inactive"}
}
gp := ListParams{Page: 1, PerPage: embeddedListDefaultPerPage}
data.Grants, data.GrantsTotal = pageSliceClamped(rawGrants, &gp)
data.GrantsNav = ListNav{BasePath: "/operator/organizations/org-1", FacetParam: "tab", Facet: "history",
FacetOptions: tabOptions, Page: gp.Page, Total: int64(data.GrantsTotal),
PerPage: gp.PerPage, DefaultPerPage: embeddedListDefaultPerPage, PerPageOptions: perPageOptions,
Target: "#plan-grants-panel", SyncSelect: "#tier-changes-panel"}
rawTransitions := make([]TransitionHistoryViewModel, 60)
for i := range rawTransitions {
rawTransitions[i] = TransitionHistoryViewModel{TransitionID: fmt.Sprintf("t-%02d", i), LadderName: "Hosting", TransitionType: "upgrade", ChangeLabel: "Upgraded", FromLabel: "Basic", ToLabel: "Standard", ActorType: "operator"}
}
tp := ListParams{Page: 1, PerPage: embeddedListDefaultPerPage}
data.Transitions, data.TransitionsTotal = pageSliceClamped(rawTransitions, &tp)
data.TierChangesNav = ListNav{BasePath: "/operator/organizations/org-1", ParamPrefix: "tc_", Page: tp.Page, Total: int64(data.TransitionsTotal),
PerPage: tp.PerPage, DefaultPerPage: embeddedListDefaultPerPage, PerPageOptions: perPageOptions,
Target: "#tier-changes-panel", SyncSelect: "#plan-grants-panel"}
out := renderOrgEnrollment(t, data)
if n := strings.Count(out, `<tr class="text-muted">`); n != embeddedListDefaultPerPage {
t.Errorf("expected exactly %d rendered grant rows (one default page), got %d in:\n%s", embeddedListDefaultPerPage, n, out)
}
if n := strings.Count(out, `<span class="badge text-bg-light border">Upgraded</span>`); n != embeddedListDefaultPerPage {
t.Errorf("expected exactly %d rendered tier-change rows (one default page), got %d in:\n%s", embeddedListDefaultPerPage, n, out)
}
// entity-keys (plan-enrollment-administration "Tier changes is
// the audit list, in operator vocabulary"): rows carry the ladder's
// display name, never its key.
if n := strings.Count(out, `<th scope="row">Hosting</th>`); n != embeddedListDefaultPerPage {
t.Errorf("expected exactly %d tier-change rows to show the ladder name, got %d in:\n%s", embeddedListDefaultPerPage, n, out)
}
if strings.Contains(out, "hosting") {
t.Errorf("ladder key must not render anywhere on the composite, got:\n%s", out)
}
if n := strings.Count(out, "Showing 110 of 60"); n != 2 {
t.Errorf("expected the pager's true-total line twice (grants, tier changes), got %d in:\n%s", n, out)
}
// The page-size picker renders on both lists: the active size as
// text, the others as links carrying the list's per param.
if n := strings.Count(out, "Per page:"); n != 2 {
t.Errorf("expected the page-size picker twice, got %d in:\n%s", n, out)
}
if !strings.Contains(out, "per=25") || !strings.Contains(out, "tc_per=25") {
t.Errorf("expected per-page links with namespaced params, got:\n%s", out)
}
if strings.Contains(out, "Showing the latest 16 of 60") {
t.Errorf("the flat 16-cap line must not render for paginated lists, got:\n%s", out)
}
// The tier-changes pager namespaces its page param so it cannot
// clobber the grants tab/search state on the shared URL.
if !strings.Contains(out, "tc_page=2") {
t.Errorf("expected the tier-changes pager to use its tc_ prefixed page param, got:\n%s", out)
}
// Embedded controls issue scoped htmx swaps of their own panel
// (maintainer 2026-08-24: interacting with a box must not scroll
// to the top): each list's links carry hx-get with
// hx-target/hx-select on the panel wrapper, and the wrappers
// render so the selector resolves.
for _, want := range []string{
`id="plan-grants-panel"`,
`id="tier-changes-panel"`,
`hx-select="#plan-grants-panel"`,
`hx-select="#tier-changes-panel"`,
} {
if !strings.Contains(out, want) {
t.Errorf("expected scoped-swap marker %q in:\n%s", want, out)
}
}
if !strings.Contains(out, `hx-push-url="true"`) {
t.Errorf("expected scoped controls to push their URL for deep-linking, got:\n%s", out)
}
// Each list's controls refresh the SIBLING panel out-of-band so
// its links never carry stale sibling URL state (the tc pager
// once pushed a URL missing tab=history).
if !strings.Contains(out, `hx-select-oob="#tier-changes-panel"`) || !strings.Contains(out, `hx-select-oob="#plan-grants-panel"`) {
t.Errorf("expected cross-panel hx-select-oob sync on embedded controls, got:\n%s", out)
}
})
}
// TestOrgEnrollmentGrantsTabs_ActiveVersusHistory: the ledger intro
// explains supersession only where history renders, and the tabs both
// render as links carrying the tab param.
func TestOrgEnrollmentGrantsTabs_ActiveVersusHistory(t *testing.T) {
data := onePoolFixture()
data.GrantsNav = ListNav{BasePath: "/operator/organizations/org-1", FacetParam: "tab",
FacetOptions: []FacetOption{{Value: "", Label: "Active"}, {Value: "history", Label: "History"}}}
out := renderOrgEnrollment(t, data)
if strings.Contains(out, "One row per grant ever issued") {
t.Errorf("the ledger intro belongs to the History tab only, got:\n%s", out)
}
if !strings.Contains(out, "No grants are delivering right now") {
t.Errorf("expected the Active tab's empty state, got:\n%s", out)
}
for _, want := range []string{">Active</a>", ">History</a>", "tab=history"} {
if !strings.Contains(out, want) {
t.Errorf("expected tab link %q in:\n%s", want, out)
}
}
data.GrantsNav.Facet = "history"
out = renderOrgEnrollment(t, data)
if !strings.Contains(out, "One row per grant ever issued") {
t.Errorf("expected the ledger intro on the History tab, got:\n%s", out)
}
if !strings.Contains(out, "No grants issued yet.") {
t.Errorf("expected the History tab's true-empty state, got:\n%s", out)
}
}
// TestFoldSupersessions pins design D2: fold exactly core.confer()'s own
// pair (an `end` row and a typed row with a non-NULL from_rank, the same
// effective_at, actor, and reason), and nothing else.
func TestFoldSupersessions(t *testing.T) {
actor := uuid.NullUUID{UUID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), Valid: true}
t1 := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)
t2 := time.Date(2026, 9, 2, 8, 0, 0, 0, time.UTC)
t.Run("supersession pair folds to the typed row", func(t *testing.T) {
rows := []entitlements.ListTransitionsByPoolRow{
{TransitionID: "end-1", TransitionType: "end", ActorType: "operator", ActorID: actor, Reason: sql.NullString{String: "manual", Valid: true}, EffectiveAt: t1},
{TransitionID: "upgrade-1", TransitionType: "upgrade", FromRank: sql.NullInt32{Int32: 0, Valid: true}, ActorType: "operator", ActorID: actor, Reason: sql.NullString{String: "manual", Valid: true}, EffectiveAt: t1},
}
got := foldSupersessions(rows)
if len(got) != 1 || got[0].TransitionID != "upgrade-1" {
t.Fatalf("expected exactly the typed row to survive, got %+v", got)
}
})
t.Run("expiry pair keeps both rows", func(t *testing.T) {
rows := []entitlements.ListTransitionsByPoolRow{
{TransitionID: "end-2", TransitionType: "end", ActorType: "system", Reason: sql.NullString{String: "grant-expiration:g-123", Valid: true}, EffectiveAt: t2},
{TransitionID: "initiate-2", TransitionType: "initiate", ActorType: "system", Reason: sql.NullString{String: "grant-expiration:g-123 default restoration", Valid: true}, EffectiveAt: t2},
}
got := foldSupersessions(rows)
if len(got) != 2 {
t.Fatalf("expected both expiry rows to survive (the initiate's from_rank is NULL and its reason differs), got %+v", got)
}
})
t.Run("a lone end row stays", func(t *testing.T) {
rows := []entitlements.ListTransitionsByPoolRow{
{TransitionID: "end-3", TransitionType: "end", ActorType: "operator", ActorID: actor, Reason: sql.NullString{String: "operator_revocation", Valid: true}, EffectiveAt: t1},
}
got := foldSupersessions(rows)
if len(got) != 1 || got[0].TransitionID != "end-3" {
t.Fatalf("expected the lone end row to survive unchanged, got %+v", got)
}
})
t.Run("a typed row with from_rank NULL never folds anything", func(t *testing.T) {
rows := []entitlements.ListTransitionsByPoolRow{
{TransitionID: "end-4", TransitionType: "end", ActorType: "operator", ActorID: actor, Reason: sql.NullString{String: "manual", Valid: true}, EffectiveAt: t1},
{TransitionID: "upgrade-4", TransitionType: "upgrade", ActorType: "operator", ActorID: actor, Reason: sql.NullString{String: "manual", Valid: true}, EffectiveAt: t1},
}
got := foldSupersessions(rows)
if len(got) != 2 {
t.Fatalf("expected both rows to survive since the typed row's from_rank is NULL, got %+v", got)
}
})
}
// TestHumanizeTransitionReason pins D3's table, one case per row, plus an
// unknown string.
func TestHumanizeTransitionReason(t *testing.T) {
cases := []struct {
raw string
label string
}{
{"grant-expiration:g-1", "Grant expired"},
{"grant-expiration:g-1 default restoration", "Default restored after a grant expired"},
{"operator_revocation", "Revoked by an operator"},
{"operator_extension", "Extended by an operator"},
{"post-revocation default restoration", "Default restored after a revocation"},
{"post-cancellation default restoration", "Default restored after a subscription ended"},
{"auto-provisioning on org creation", "Default on organization creation"},
{"demoseed: floor personal pool at org-type default", "Seeded default"},
{"org-type default change (team)", "Org-type default changed"},
{"tier added to ladder l-1", "Tier added to the ladder"},
{"plan-ladder tier reorder (Hosting)", "Ladder tiers reordered"},
{"tier removal (Pro from Hosting)", "Tier removed from the ladder"},
}
for _, c := range cases {
t.Run(c.raw, func(t *testing.T) {
label, known := humanizeTransitionReason(c.raw)
if !known {
t.Errorf("expected %q to be known", c.raw)
}
if label != c.label {
t.Errorf("humanizeTransitionReason(%q) = %q, want %q", c.raw, label, c.label)
}
})
}
t.Run("an unknown reason renders raw", func(t *testing.T) {
label, known := humanizeTransitionReason("some future writer's reason")
if known {
t.Errorf("expected an unrecognized reason to report known=false")
}
if label != "some future writer's reason" {
t.Errorf("expected the raw string back, got %q", label)
}
})
}
// TestOrgEnrollmentActionErrorKeepsThePage pins the split between a load
// failure and an action outcome: an action's error message renders above
// the composite, which stays on the page, whereas a load failure renders
// alone. Before the split a grant whose expiry scheduling failed came back
// as a bare alert with no page to act on (2026-09-06).
func TestOrgEnrollmentActionErrorKeepsThePage(t *testing.T) {
data := onePoolFixture()
data.ActionError = "Grant issued, but expiration scheduling FAILED; it will not auto-expire on its own. Retry, or revoke it manually."
out := renderOrgEnrollment(t, data)
if !strings.Contains(out, "expiration scheduling FAILED") {
t.Fatalf("action error not rendered:\n%s", out)
}
if !strings.Contains(out, `id="plan-grants-panel"`) {
t.Fatalf("action error replaced the page instead of riding above it:\n%s", out)
}
failed := onePoolFixture()
failed.Error = "Organization not found"
out = renderOrgEnrollment(t, failed)
if strings.Contains(out, `id="plan-grants-panel"`) {
t.Fatalf("load failure still rendered the page:\n%s", out)
}
}
// TestChangeBadgeExtension pins the verb an extension's transfer renders:
// Extended, with no funding-source tooltip (tier-changes-ledger D7).
func TestChangeBadgeExtension(t *testing.T) {
plain := TransitionHistoryViewModel{TransitionType: "transfer", ChangeTooltip: "Same tier, different funding source."}.ChangeBadge()
if plain.Label != "Transferred" || plain.Title == "" {
t.Errorf("plain transfer badge = %+v, want Transferred with its tooltip", plain)
}
ext := TransitionHistoryViewModel{TransitionType: "transfer", ChangeKind: "extension"}.ChangeBadge()
if ext.Label != "Extended" || ext.Title != "" {
t.Errorf("extension badge = %+v, want Extended with no tooltip", ext)
}
}