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.
1209 lines
57 KiB
Go
1209 lines
57 KiB
Go
// 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+Org&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 "+" in an href; both decode back to "Test Org".
|
||
if !strings.Contains(out, `<a href="/operator/billing/invoices?q=Test+Org&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+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+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'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 1–10 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)
|
||
}
|
||
}
|