// 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, "
0007") {
t.Errorf("the invoice number must not be wrapped in , 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")
dateIdx := strings.Index(out, `· Jan 1, 2026`)
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, `Account`) {
t.Errorf("an active account must not render an Account row, got:\n%s", out)
}
if !strings.Contains(out, `1 subscription`) {
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, `USD 240.00`) {
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, `USD 240.00`)
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, `2 subscriptions`) {
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, `Account`)
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, `Outstanding balance`) {
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, `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`) {
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"); 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 Valid until 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, ``) {
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