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