// 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+``) { 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`) { t.Errorf("expected the Stripe settings link, got:\n%s", out) } headerAt := strings.Index(out, `

Billing

`) 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") } }) }