Files
member-console/internal/server/operator_enrollment_render_test.go
cgalo5758 257955c9d3 Add operator list-scale contract and People directory
Governed operator lists (organizations, grants, people, billing×4) gain
server-side search, status filters, and 50-row pages with true totals
from count(*) OVER(); state is URL-addressable, out-of-range pages
clamp,
and no-match is distinct from true-empty.

People is the eighth flat sidebar entry: /operator/persons lists persons
newest-joined first (excluding the reserved system person), rows linking
to the existing detail.

Billing gains an operator invoice detail at
/operator/billing/invoices/{invoiceID} reusing the member projection;
open invoices past due present as Overdue (derived, filterable, stored
status untouched); all four views lead with the linked organization and
mute object IDs.

Grants filter over the derived Live/Superseded/Inactive state, the SQL
HAVING predicate pinned to the Go derivation by test. Embedded lists
(org composite ledger, Tier changes) adopt the shared controls under
namespaced params with sibling-state-preserving URLs and scoped htmx
swaps that hold the viewport.

Review corrections: blocked ladder Delete renders disabled with tooltip
and mutations fire toasts; collapse triggers paint their open state;
sections use outside headings; plan topology drops the orphan-product
check; domains policy collapses behind a disclosure.
2026-08-24 03:58:18 -05:00

570 lines
25 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"bytes"
"fmt"
"html/template"
"io/fs"
"strings"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/embeds"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
)
// 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 "" },
})
tmpl, err = 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.
func onePoolFixture() OrgEnrollmentData {
return OrgEnrollmentData{
OrgID: "org-1",
OrgName: "Test Org",
OrgSlug: "test-org",
Pools: []PoolEnrollmentViewModel{
{PoolID: "pool-1", PoolName: "default", PoolType: "default", Status: "active"},
},
IssuanceProducts: []IssuanceProductOption{
{ProductID: "prod-plan", Name: "Standard Plan"},
{ProductID: "prod-addon", Name: "Extra Seats", DisplayCategory: "addon"},
},
GrantReasons: grantReasonDomain,
}
}
// TestOrgEnrollmentBillingSummary_NoRawCents covers finding #6: the billing
// summary must render pre-formatted currency strings, not raw integer cents
// glued to a bare currency code.
func TestOrgEnrollmentBillingSummary_NoRawCents(t *testing.T) {
data := onePoolFixture()
data.BillingSummary = BillingSummaryViewModel{
HasAccount: true,
HasInvoice: true,
LatestInvoiceAmountDue: "USD 10.00",
LatestInvoiceDate: "Jan 1, 2026",
OutstandingBalance: 500,
OutstandingBalanceFormatted: "USD 5.00",
Currency: "usd",
}
out := renderOrgEnrollment(t, data)
if !strings.Contains(out, "USD 10.00") {
t.Error("expected formatted latest invoice amount USD 10.00 in output")
}
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)
}
}
// TestOrgEnrollmentIssueGrant_MultiPoolGuardRendersBanner covers finding #32:
// when the handler 422s a multi-pool org via the "" field key on the "issue"
// form, the composite must render that banner (scoped to the issue form,
// which carries FormPoolID "").
func TestOrgEnrollmentIssueGrant_MultiPoolGuardRendersBanner(t *testing.T) {
data := onePoolFixture()
data.FormName = "issue"
data.FormPoolID = ""
data.FieldErrors = web.FieldErrors{"": "This organization has more than one resource pool."}
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-disabled-elt 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{{LadderKey: "hosting", Rank: 1}}}}
out := renderOrgEnrollment(t, data)
if n := strings.Count(out, `hx-disabled-elt="find button[type=submit]"`); n != 2 {
t.Errorf("expected 2 forms (issue, extend) with hx-disabled-elt, 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{{LadderKey: "hosting", Rank: 1}}}}
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)
}
if !strings.Contains(out, `disabled>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{{LadderKey: "hosting", Rank: 1}}},
{ProvisionID: "prov-b", ProductName: "Priority", GrantBacked: true, Rungs: []PoolRungViewModel{{LadderKey: "support", Rank: 2}}},
}
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"`,
`<input type="hidden" name="provision_id" value="prov-a">`,
`<input type="hidden" 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{
{LadderKey: "hosting", Rank: 1, ActivatedAt: "Mar 1, 2026 9:00 AM"},
{LadderKey: "support", Rank: 3, ActivatedAt: "Mar 1, 2026 9:00 AM"},
}},
}
data.Pools[0].Deliveries = shared
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)
}
if n := strings.Count(out, `data-bs-target="#extendPanel-prov-shared"`); n != 1 {
t.Errorf("a shared delivery must render exactly one Extend control, found %d buttons", 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{
`<span class="badge bg-primary">hosting</span> rank 1`,
`<span class="badge bg-primary">support</span> 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)
}
}
// 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 description textarea.
func TestOrgEnrollmentDescriptionInput_HasMaxLength(t *testing.T) {
out := renderOrgEnrollment(t, onePoolFixture())
if !strings.Contains(out, `id="issue-description" name="description" rows="1" maxlength="500"`) {
t.Errorf("expected issue-description textarea 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="issue-reason"`) {
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},
}
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", DeliveryState: "superseded", ReplacedByGrantID: "g-new", ReplacedByLabel: "Standard Plan"},
{GrantID: "g-new", ProductName: "Standard Plan", GrantReason: "manual", Status: "active", DeliveryState: "live", ExtendsGrantID: "g-old", ExtendsLabel: "Legacy Plan"},
}
// 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("expected the live row to name what it extends, 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)
}
}
// 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), LadderKey: "hosting", 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-info">Upgraded</span>`); n != embeddedListDefaultPerPage {
t.Errorf("expected exactly %d rendered tier-change rows (one default page), got %d in:\n%s", embeddedListDefaultPerPage, n, out)
}
if n := strings.Count(out, "Showing 110 of 60"); n != 2 {
t.Errorf("expected the pager's true-total line twice (grants, tier changes), got %d in:\n%s", n, out)
}
// The page-size picker renders on both lists: the active size as
// text, the others as links carrying the list's per param.
if n := strings.Count(out, "Per page:"); n != 2 {
t.Errorf("expected the page-size picker twice, got %d in:\n%s", n, out)
}
if !strings.Contains(out, "per=25") || !strings.Contains(out, "tc_per=25") {
t.Errorf("expected per-page links with namespaced params, got:\n%s", out)
}
if strings.Contains(out, "Showing the latest 16 of 60") {
t.Errorf("the flat 16-cap line must not render for paginated lists, got:\n%s", out)
}
// The tier-changes pager namespaces its page param so it cannot
// clobber the grants tab/search state on the shared URL.
if !strings.Contains(out, "tc_page=2") {
t.Errorf("expected the tier-changes pager to use its tc_ prefixed page param, got:\n%s", out)
}
// Embedded controls issue scoped htmx swaps of their own panel
// (maintainer 2026-08-24: interacting with a box must not scroll
// to the top): each list's links carry hx-get with
// hx-target/hx-select on the panel wrapper, and the wrappers
// render so the selector resolves.
for _, want := range []string{
`id="plan-grants-panel"`,
`id="tier-changes-panel"`,
`hx-select="#plan-grants-panel"`,
`hx-select="#tier-changes-panel"`,
} {
if !strings.Contains(out, want) {
t.Errorf("expected scoped-swap marker %q in:\n%s", want, out)
}
}
if !strings.Contains(out, `hx-push-url="true"`) {
t.Errorf("expected scoped controls to push their URL for deep-linking, got:\n%s", out)
}
// Each list's controls refresh the SIBLING panel out-of-band so
// its links never carry stale sibling URL state (the tc pager
// once pushed a URL missing tab=history).
if !strings.Contains(out, `hx-select-oob="#tier-changes-panel"`) || !strings.Contains(out, `hx-select-oob="#plan-grants-panel"`) {
t.Errorf("expected cross-panel hx-select-oob sync on embedded controls, got:\n%s", out)
}
})
}
// TestOrgEnrollmentGrantsTabs_ActiveVersusHistory: the ledger intro
// explains supersession only where history renders, and the tabs both
// render as links carrying the tab param.
func TestOrgEnrollmentGrantsTabs_ActiveVersusHistory(t *testing.T) {
data := onePoolFixture()
data.GrantsNav = ListNav{BasePath: "/operator/organizations/org-1", FacetParam: "tab",
FacetOptions: []FacetOption{{Value: "", Label: "Active"}, {Value: "history", Label: "History"}}}
out := renderOrgEnrollment(t, data)
if strings.Contains(out, "One row per grant ever issued") {
t.Errorf("the ledger intro belongs to the History tab only, got:\n%s", out)
}
if !strings.Contains(out, "No grants are delivering right now") {
t.Errorf("expected the Active tab's empty state, got:\n%s", out)
}
for _, want := range []string{">Active</a>", ">History</a>", "tab=history"} {
if !strings.Contains(out, want) {
t.Errorf("expected tab link %q in:\n%s", want, out)
}
}
data.GrantsNav.Facet = "history"
out = renderOrgEnrollment(t, data)
if !strings.Contains(out, "One row per grant ever issued") {
t.Errorf("expected the ledger intro on the History tab, got:\n%s", out)
}
if !strings.Contains(out, "No grants issued yet.") {
t.Errorf("expected the History tab's true-empty state, got:\n%s", out)
}
}