- 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
380 lines
15 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|