Governed operator lists (organizations, grants, people, billing×4) gain
server-side search, status filters, and 50-row pages with true totals
from count(*) OVER(); state is URL-addressable, out-of-range pages
clamp,
and no-match is distinct from true-empty.
People is the eighth flat sidebar entry: /operator/persons lists persons
newest-joined first (excluding the reserved system person), rows linking
to the existing detail.
Billing gains an operator invoice detail at
/operator/billing/invoices/{invoiceID} reusing the member projection;
open invoices past due present as Overdue (derived, filterable, stored
status untouched); all four views lead with the linked organization and
mute object IDs.
Grants filter over the derived Live/Superseded/Inactive state, the SQL
HAVING predicate pinned to the Go derivation by test. Embedded lists
(org composite ledger, Tier changes) adopt the shared controls under
namespaced params with sibling-state-preserving URLs and scoped htmx
swaps that hold the viewport.
Review corrections: blocked ladder Delete renders disabled with tooltip
and mutations fire toasts; collapse triggers paint their open state;
sections use outside headings; plan topology drops the orphan-product
check; domains policy collapses behind a disclosure.
153 lines
6.3 KiB
Go
153 lines
6.3 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"
|
|
)
|
|
|
|
// billingTestTemplates parses the operator partial set the way
|
|
// NewOperatorPartialsHandler does, with renderBody stubbed, so both the
|
|
// wrapper and the inner per-view tables can be rendered in isolation.
|
|
func billingTestTemplates(t *testing.T) *template.Template {
|
|
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(_, _, _, _ string, _, _ any) string { return "" },
|
|
"stripeEntityURL": func(string, string) string { return "" },
|
|
})
|
|
tmpl, err = tmpl.ParseFS(partialsSub, "operator_*.html")
|
|
if err != nil {
|
|
t.Fatalf("ParseFS: %v", err)
|
|
}
|
|
return tmpl
|
|
}
|
|
|
|
// renderOperatorBilling executes the real operator_billing.html wrapper
|
|
// template. renderBody is stubbed (as in other isolated partial render
|
|
// tests): it exercises the wrapper's own markup (nav pills, the
|
|
// webhook-recency stamp), not the inner per-view tables.
|
|
func renderOperatorBilling(t *testing.T, data OperatorBillingWrapperData) string {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
if err := billingTestTemplates(t).ExecuteTemplate(&buf, "operator_billing.html", data); err != nil {
|
|
t.Fatalf("ExecuteTemplate: %v", err)
|
|
}
|
|
return buf.String()
|
|
}
|
|
|
|
// TestOperatorBillingNotMappedVocabularyIsDefinedInPlace covers
|
|
// operator-billing-views ("Stripe sync vocabulary is defined where it
|
|
// appears", revised by maintainer 2026-08-23 from an always-on wrapper
|
|
// banner to the dense-surface pattern): each view's "Stripe Sync" column
|
|
// header carries a help icon whose tooltip defines the vocabulary and says
|
|
// what to do about an unmapped record; the wrapper renders no legend banner.
|
|
func TestOperatorBillingNotMappedVocabularyIsDefinedInPlace(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
if err := billingTestTemplates(t).ExecuteTemplate(&buf, "operator_billing_accounts.html", BillingAccountsData{
|
|
Accounts: []BillingAccountViewModel{{BillingAccountID: "ba1", OrgID: "org1", OrgName: "Acme", Name: "Default", Status: "active", StripeSyncStatus: "not_mapped", CreatedAt: "Jan 2, 2026"}},
|
|
Nav: ListNav{BasePath: "/operator/billing/accounts", Page: 1, Total: 1},
|
|
}); err != nil {
|
|
t.Fatalf("ExecuteTemplate: %v", err)
|
|
}
|
|
out := buf.String()
|
|
if !strings.Contains(out, "form-help-icon") {
|
|
t.Error("Stripe Sync header must carry the help icon")
|
|
}
|
|
if !strings.Contains(out, "Not Mapped means none is linked yet") {
|
|
t.Error("tooltip does not state what an unmapped record means")
|
|
}
|
|
if !strings.Contains(out, "Stripe integration settings") {
|
|
t.Error("tooltip does not say what to do about an unmapped record")
|
|
}
|
|
|
|
wrapper := renderOperatorBilling(t, OperatorBillingWrapperData{
|
|
ActiveSection: "accounts",
|
|
InnerTemplate: "operator_billing_accounts.html",
|
|
DataAsOf: "Jan 2, 2026 15:04 MST",
|
|
})
|
|
if strings.Contains(wrapper, "alert-secondary") || strings.Contains(wrapper, "shows whether a row has a linked Stripe object") {
|
|
t.Error("the wrapper must not render the retired always-on legend banner")
|
|
}
|
|
}
|
|
|
|
// TestOperatorBillingPillNav covers operator-billing-views ("Billing views
|
|
// are navigated in-page"; maintainer 2026-08-23 — the sidebar holds one flat
|
|
// Billing entry, so the wrapper's pill row is the section's navigation): all
|
|
// four views render as pills, exactly the active section's pill is marked,
|
|
// and the recency stamp and Stripe Sync legend still render alongside.
|
|
func TestOperatorBillingPillNav(t *testing.T) {
|
|
out := renderOperatorBilling(t, OperatorBillingWrapperData{
|
|
ActiveSection: "invoices",
|
|
InnerTemplate: "operator_invoices.html",
|
|
DataAsOf: "Jan 2, 2026 15:04 MST",
|
|
})
|
|
if !strings.Contains(out, "nav-pills") {
|
|
t.Fatalf("billing wrapper must render the nav-pills section navigation, got:\n%s", out)
|
|
}
|
|
for view, path := range map[string]string{
|
|
"Accounts": "/operator/billing/accounts",
|
|
"Subscriptions": "/operator/billing/subscriptions",
|
|
"Invoices": "/operator/billing/invoices",
|
|
"Payments": "/operator/billing/payments",
|
|
} {
|
|
if !strings.Contains(out, `href="`+path+`">`+view+`</a>`) {
|
|
t.Errorf("billing pill nav missing the %s view link (%s), got:\n%s", view, path, out)
|
|
}
|
|
}
|
|
if got := strings.Count(out, `nav-link active`); got != 1 {
|
|
t.Errorf("exactly one billing pill must be active, got %d in:\n%s", got, out)
|
|
}
|
|
if !strings.Contains(out, `active" href="/operator/billing/invoices"`) {
|
|
t.Errorf("the active pill must be the ActiveSection (invoices), got:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, "Last updated at Jan 2, 2026 15:04 MST") {
|
|
t.Errorf("recency stamp must render alongside the pill nav, got:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// TestOperatorBillingRecencyStamp covers the operator-billing-views delta's
|
|
// projection-recency requirement (design.md Decision 3; task 4.2): every
|
|
// billing sub-page states when data was last projected, or states plainly
|
|
// that nothing has been received yet, rather than presenting rows or
|
|
// emptiness as if current.
|
|
func TestOperatorBillingRecencyStamp(t *testing.T) {
|
|
t.Run("recency stamp renders with data", func(t *testing.T) {
|
|
out := renderOperatorBilling(t, OperatorBillingWrapperData{
|
|
ActiveSection: "invoices",
|
|
InnerTemplate: "operator_invoices.html",
|
|
DataAsOf: "Jan 2, 2026 15:04 MST",
|
|
})
|
|
if !strings.Contains(out, "Last updated at Jan 2, 2026 15:04 MST") {
|
|
t.Errorf("recency stamp missing or malformed: %s", out)
|
|
}
|
|
if strings.Contains(out, "No billing events have been received") {
|
|
t.Error("no-events copy rendered even though DataAsOf was set")
|
|
}
|
|
})
|
|
|
|
t.Run("no events ever processed is stated", func(t *testing.T) {
|
|
out := renderOperatorBilling(t, OperatorBillingWrapperData{
|
|
ActiveSection: "payments",
|
|
InnerTemplate: "operator_payments.html",
|
|
NoEventsProcessed: true,
|
|
})
|
|
if !strings.Contains(out, "No billing events have been received") {
|
|
t.Errorf("missing the no-events-ever statement: %s", out)
|
|
}
|
|
if strings.Contains(out, "Last updated at") {
|
|
t.Error("stale last-updated copy rendered with no processed events")
|
|
}
|
|
})
|
|
}
|