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`) { 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"`, ``, ``, "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"); 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{ `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) } } // 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, `