// 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, "
0
") { 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, `
Outstanding balance
`) subsIdx := strings.Index(out, `
Subscriptions
`) invoiceIdx := strings.Index(out, `
Latest invoice
`) 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, `0007`) { t.Errorf("expected the invoice number to link to the invoice as plain text, 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 in the issue form") } if !strings.Contains(out, `