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