Files
member-console/internal/server/operator_billing_render_test.go
T
cgalo5758 782ca8f326 Derive Stripe mode from API key and refine disabled controls
Derive Stripe test/live mode from the API key prefix at boot, failing on
unrecognized prefixes, and drop the separate `stripe-mode` config key.

Refine disabled controls to render through the shared `disabledControl`
part with the not-allowed cursor, and add a lint rule refusing
hand-rolled disabled buttons.

Adjust plan cards to offer no purchase control on free rungs, fix bound
checkbox Bool handling, and rename "Public/Private" to "Listed/Unlisted"
with enhanced readiness verdicts.
2026-09-13 16:59:12 -05:00

271 lines
11 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
import (
"bytes"
"context"
"html/template"
"io"
"io/fs"
"log/slog"
"net/http/httptest"
"strings"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/embeds"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
"github.com/alexedwards/scs/v2"
)
// 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 "" },
"helpIcon": helpIcon,
})
tmpl, err = web.ParseUIPartials(template.Must(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)
}
// An instance page (the invoice detail sets Detail via its
// WrapperHeader) renders no pill row: its trail already names
// Billing / Invoices (maintainer, 2026-08-31).
detail := renderOperatorBilling(t, OperatorBillingWrapperData{
ActiveSection: "invoices",
InnerTemplate: "operator_invoices.html",
DataAsOf: "Jan 2, 2026 15:04 MST",
Detail: true,
})
if strings.Contains(detail, "nav-pills") {
t.Errorf("an instance page must not render the view pills, got:\n%s", detail)
}
}
// TestOperatorBillingTestModeBanner covers operator-billing-views ("Billing
// views mark Stripe test mode", design D14; round 2 turns the round-1
// header badge into a banner under the page header, above the billing
// pills, review items 22-23; a maintainer round-2 review note moved it
// below the title so the page's own heading always leads): the wrapper
// renders one alert-warning banner with the sentence and the Stripe
// settings link, whenever the caller sets TestMode (renderBillingPage
// derives it from the mode the console derives from the API key), on both a plain view and an instance page
// (the invoice detail), and renders nothing when TestMode is unset (live
// mode).
func TestOperatorBillingTestModeBanner(t *testing.T) {
out := renderOperatorBilling(t, OperatorBillingWrapperData{
ActiveSection: "invoices",
InnerTemplate: "operator_invoices.html",
Header: PageHeader{Title: "Billing"},
TestMode: true,
})
if !strings.Contains(out, `alert alert-warning`) {
t.Errorf("expected the test-mode banner to render as an alert-warning, got:\n%s", out)
}
if !strings.Contains(out, "Stripe is in test mode. Figures on these pages are test data.") {
t.Errorf("expected the test-mode sentence, got:\n%s", out)
}
if !strings.Contains(out, `href="/operator/integrations/stripe/settings">Stripe settings</a>`) {
t.Errorf("expected the Stripe settings link, got:\n%s", out)
}
headerAt := strings.Index(out, `<h1 class="h2 mb-0">Billing</h1>`)
bannerAt := strings.Index(out, "alert-warning")
pillsAt := strings.Index(out, "nav-pills")
if headerAt < 0 || bannerAt < 0 || pillsAt < 0 || !(headerAt < bannerAt && bannerAt < pillsAt) {
t.Errorf("expected the banner to render under the page header, above the billing pills, got:\n%s", out)
}
detail := renderOperatorBilling(t, OperatorBillingWrapperData{
ActiveSection: "invoices",
InnerTemplate: "operator_invoices.html",
Header: PageHeader{Title: "0001"},
Detail: true,
TestMode: true,
})
if !strings.Contains(detail, "Stripe is in test mode. Figures on these pages are test data.") {
t.Errorf("expected the test-mode banner to render on the invoice detail, got:\n%s", detail)
}
live := renderOperatorBilling(t, OperatorBillingWrapperData{
ActiveSection: "invoices",
InnerTemplate: "operator_invoices.html",
Header: PageHeader{Title: "Billing"},
})
if strings.Contains(live, "alert-warning") || strings.Contains(live, "test mode") {
t.Errorf("live mode must render no banner, got:\n%s", live)
}
}
// TestOperatorBillingBannerFollowsDerivedMode covers operator-billing-views
// ("Billing views mark Stripe test mode") at the seam the requirement
// names: the banner follows the mode derived from the API key at boot
// (OperatorPartialsHandler.StripeMode), not a setting, so a live-keyed
// deployment can never show it.
func TestOperatorBillingBannerFollowsDerivedMode(t *testing.T) {
database := newRollbackTestDB(t)
for _, tc := range []struct {
mode string
wantBanner bool
}{
{"test", true},
{"live", false},
{"", false},
} {
sm := scs.New()
h, err := NewOperatorPartialsHandler(OperatorPartialsConfig{
Database: database,
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
AuthConfig: &auth.Config{SessionManager: sm},
StripeMode: tc.mode,
})
if err != nil {
t.Fatalf("NewOperatorPartialsHandler: %v", err)
}
ctx, err := sm.Load(context.Background(), "")
if err != nil {
t.Fatalf("session Load: %v", err)
}
r := httptest.NewRequestWithContext(ctx, "GET", "/operator/billing/invoices", nil)
w := httptest.NewRecorder()
h.renderBillingPage(w, r, "invoices", "operator_invoices.html", "billing-invoices", InvoicesData{})
got := strings.Contains(w.Body.String(), "Stripe is in test mode.")
if got != tc.wantBanner {
t.Errorf("mode %q: banner rendered = %v, want %v", tc.mode, got, tc.wantBanner)
}
}
}
// TestOperatorBillingRecencyStamp covers the operator-billing-views delta's
// projection-recency requirement (design.md Decision 3; task 4.2) as
// amended in the anatomy-sweep maintainer round (2026-08-30): a billing
// sub-page states when data was last projected; a stream that has never
// delivered says nothing here (the retired zero-events banner conflated
// the provider stream with the records below it; stream health is the
// Stripe integration settings page's job).
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("a quiet stream says nothing", func(t *testing.T) {
out := renderOperatorBilling(t, OperatorBillingWrapperData{
ActiveSection: "payments",
InnerTemplate: "operator_payments.html",
Header: PageHeader{Title: "Billing"},
})
if strings.Contains(out, "No billing events have been received") {
t.Error("the retired zero-events banner still renders")
}
if strings.Contains(out, "Last updated at") {
t.Error("last-updated copy rendered with no processed events")
}
})
}