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/billing" "git.coopcloud.tech/wiki-cafe/member-console/internal/config" "git.coopcloud.tech/wiki-cafe/member-console/internal/embeds" "git.coopcloud.tech/wiki-cafe/member-console/internal/web" "github.com/google/uuid" "github.com/spf13/viper" ) // TestBillingEmptyState_TemplateRendering pins the empty-view copy for all // four billing sub-pages (ux-first-run spec: "empty operator billing views // distinguish Stripe-unconfigured from configured-but-no-events"): with an // empty result set, a deployment where Stripe fails the required-key // completeness check must read as blocked on the operator (blocker copy + // a link to the Stripe settings surface), while a deployment where Stripe is // configured (just eventless so far) keeps the original, unrelated empty // copy — the page-level "no events processed" statement already carries // that story. The two renders must never be identical. func TestBillingEmptyState_TemplateRendering(t *testing.T) { partialsSub, err := fs.Sub(embeds.Templates, "templates/partials") if err != nil { t.Fatalf("fs.Sub partials: %v", err) } tmpl := template.New("billing-empty").Funcs(template.FuncMap{ "routeURL": web.RouteURL, "stripeEntityURL": func(string, string) string { return "" }, // renderBody: unused by the four tab partials under test, but // ParseFS globs every operator_*.html in the partials directory, // which includes the wrapper (operator_billing.html) that calls it. "renderBody": func(string, any) (template.HTML, error) { return "", nil }, "fieldErr": func(_, _, _, _ string, _, _ any) string { return "" }, }) if tmpl, err = tmpl.ParseFS(partialsSub, "operator_*.html"); err != nil { t.Fatalf("ParseFS partials: %v", err) } render := func(t *testing.T, name string, data any) string { t.Helper() var buf bytes.Buffer if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil { t.Fatalf("ExecuteTemplate %s: %v", name, err) } return buf.String() } const blockerFragment = "requires Stripe to be configured" const settingsLinkFragment = "/operator/integrations/stripe/settings" cases := []struct { name string template string unconfigured any configured any eventlessCopy string // copy that must survive unchanged in the configured-but-empty branch }{ { name: "accounts", template: "operator_billing_accounts.html", unconfigured: BillingAccountsData{StripeConfigured: false}, configured: BillingAccountsData{StripeConfigured: true}, eventlessCopy: "No billing accounts have been projected yet.", }, { name: "subscriptions", template: "operator_subscriptions.html", unconfigured: SubscriptionsData{StripeConfigured: false}, configured: SubscriptionsData{StripeConfigured: true}, eventlessCopy: "No subscriptions have been projected yet.", }, { name: "invoices", template: "operator_invoices.html", unconfigured: InvoicesData{StripeConfigured: false}, configured: InvoicesData{StripeConfigured: true}, eventlessCopy: "No invoices have been projected yet.", }, { name: "payments", template: "operator_payments.html", unconfigured: PaymentsData{StripeConfigured: false}, configured: PaymentsData{StripeConfigured: true}, eventlessCopy: "No payments have been projected yet.", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { unconfiguredOut := render(t, tc.template, tc.unconfigured) configuredOut := render(t, tc.template, tc.configured) if !strings.Contains(unconfiguredOut, blockerFragment) { t.Errorf("%s: unconfigured render missing blocker copy %q:\n%s", tc.name, blockerFragment, unconfiguredOut) } if !strings.Contains(unconfiguredOut, settingsLinkFragment) { t.Errorf("%s: unconfigured render missing Stripe settings link %q:\n%s", tc.name, settingsLinkFragment, unconfiguredOut) } if strings.Contains(unconfiguredOut, tc.eventlessCopy) { t.Errorf("%s: unconfigured render unexpectedly kept the configured-but-eventless copy %q", tc.name, tc.eventlessCopy) } if !strings.Contains(configuredOut, tc.eventlessCopy) { t.Errorf("%s: configured render missing the existing empty-view copy %q:\n%s", tc.name, tc.eventlessCopy, configuredOut) } if strings.Contains(configuredOut, blockerFragment) { t.Errorf("%s: configured render unexpectedly rendered the blocker copy", tc.name) } if unconfiguredOut == configuredOut { t.Errorf("%s: unconfigured and configured-but-eventless empty views rendered identical copy", tc.name) } }) } } // TestLoadBillingData_StripeConfigured exercises the four loadXData handlers // (operator_billing.go) against a real, empty result set, proving each one // plumbs configurationReadiness(h.IntegrationConfigs, "stripe") into its // StripeConfigured field for both branches — the field the four templates // branch the empty-view copy on. func TestLoadBillingData_StripeConfigured(t *testing.T) { database := topoTestDB(t) ctx := context.Background() tx, err := database.BeginTx(ctx, nil) if err != nil { t.Fatal(err) } defer tx.Rollback() // This scratch DB is private to this lane's tests but may carry // committed fixture rows from earlier runs; wipe the tables these // loaders read (inside this rolled-back tx, so nothing persists) so the // empty-view branch these tests exercise is actually empty. if _, err := tx.ExecContext(ctx, `TRUNCATE core.accounts, core.subscriptions, core.invoices, core.payments CASCADE`); err != nil { t.Fatalf("clean billing tables: %v", err) } bq := billing.New(tx) logger := slog.New(slog.NewTextHandler(io.Discard, nil)) unconfiguredKey := "ux-l3-stripe-unconfigured-" + uuid.New().String()[:8] configuredKey := "ux-l3-stripe-configured-" + uuid.New().String()[:8] viper.Set(configuredKey, "sk_test_dummy_ux_l3") t.Cleanup(func() { viper.Set(configuredKey, "") }) unconfigured := []IntegrationConfigInfo{ {Slug: "stripe", Keys: []config.ConfigKey{{Name: unconfiguredKey, RequiredGroup: "stripe"}}}, } configured := []IntegrationConfigInfo{ {Slug: "stripe", Keys: []config.ConfigKey{{Name: configuredKey, RequiredGroup: "stripe"}}}, } newHandler := func(cfgs []IntegrationConfigInfo) *OperatorPartialsHandler { return &OperatorPartialsHandler{ BillingQ: bq, Database: database, Logger: logger, IntegrationConfigs: cfgs, } } req := httptest.NewRequest("GET", "/operator/billing/accounts", nil).WithContext(ctx) assertStripeConfigured := func(t *testing.T, got, want bool, label string) { t.Helper() if got != want { t.Errorf("%s: StripeConfigured = %v, want %v", label, got, want) } } t.Run("accounts", func(t *testing.T) { u := newHandler(unconfigured).loadBillingAccountsData(req) assertStripeConfigured(t, u.StripeConfigured, false, "unconfigured") c := newHandler(configured).loadBillingAccountsData(req) assertStripeConfigured(t, c.StripeConfigured, true, "configured") }) t.Run("subscriptions", func(t *testing.T) { u := newHandler(unconfigured).loadSubscriptionsData(req) assertStripeConfigured(t, u.StripeConfigured, false, "unconfigured") c := newHandler(configured).loadSubscriptionsData(req) assertStripeConfigured(t, c.StripeConfigured, true, "configured") }) t.Run("invoices", func(t *testing.T) { u := newHandler(unconfigured).loadInvoicesData(req) assertStripeConfigured(t, u.StripeConfigured, false, "unconfigured") c := newHandler(configured).loadInvoicesData(req) assertStripeConfigured(t, c.StripeConfigured, true, "configured") }) t.Run("payments", func(t *testing.T) { u := newHandler(unconfigured).loadPaymentsData(req) assertStripeConfigured(t, u.StripeConfigured, false, "unconfigured") c := newHandler(configured).loadPaymentsData(req) assertStripeConfigured(t, c.StripeConfigured, true, "configured") }) }