Files
member-console/internal/server/operator_enrollment_render_test.go
T
cgalo5758 8e3c68c6be Make UI surfaces honestly reflect system state
- Add deployment-name branding to titles, mastheads, and OG tags
- Share one grant delivery-state query with lineage across grants
  surfaces
- Show pool status/usage, org owners, and config readiness
- Make billing views projection-aware with recency and sync vocabulary
- Guard FedWiki creation without domains and render route-aware 404s
2026-08-23 01:45:52 -05:00

380 lines
15 KiB
Go

package server
import (
"bytes"
"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()
// Give the Extend form's guards (`$pool.HasAttachment` for the card,
// `$pool.HasGrantDelivery` for the form) something to render against so
// both forms are present in this pass.
data.Pools[0].HasAttachment = true
data.Pools[0].HasGrantDelivery = true
data.Pools[0].TierName = "Standard"
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 extend form renders
// only for pools with an active grant-backed provision — the handler's
// precondition. A pool whose delivery is subscription-backed or ended gets
// the precondition note instead, so form presence is a truthful signal that
// submitting can succeed.
func TestOrgEnrollmentExtendForm_RequiresGrantDelivery(t *testing.T) {
data := onePoolFixture()
data.Pools[0].HasAttachment = true
data.Pools[0].TierName = "Standard"
out := renderOrgEnrollment(t, data)
if strings.Contains(out, "/grant/extend") {
t.Errorf("extend form rendered for a pool without grant-backed delivery:\n%s", out)
}
if !strings.Contains(out, "Nothing to extend right now") {
t.Errorf("expected the extend precondition note for a non-extendable pool, got:\n%s", out)
}
data.Pools[0].HasGrantDelivery = true
out = renderOrgEnrollment(t, data)
if !strings.Contains(out, "/grant/extend") {
t.Errorf("extend form missing for a pool with grant-backed delivery:\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 position 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, "<em>none</em>") {
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"},
}
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)
}
if !strings.Contains(out, "issuance ledger") {
t.Errorf("expected the ledger-vs-delivery explanation 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)
}
}
}