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.
570 lines
25 KiB
Go
570 lines
25 KiB
Go
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 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)
|
||
}
|
||
}
|