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 in the issue form") } if !strings.Contains(out, `