Files
member-console/internal/server/operator_billing_empty_state_test.go
T
cgalo5758 408fa6f5a6 Add page anatomy parts and UI quality gate
- Add shared ui_*.html parts (pageHeader, sectionHeader, statusBadge,
  emptyState) parsed into every template set
- Add anatomy lint rules with a shrinking allowlist and screen-coverage
  check
- Add make screens capture harness with contact sheets and baseline diff
- Compose member and FedWiki regions server-side so pages arrive
  complete
- Rebuild Domains and Integrations on the parts as pilots
2026-08-30 04:05:31 -05:00

207 lines
8.0 KiB
Go

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 = web.ParseUIPartials(template.Must(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{
{Key: "stripe", Keys: []config.ConfigKey{{Name: unconfiguredKey, RequiredGroup: "stripe"}}},
}
configured := []IntegrationConfigInfo{
{Key: "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")
})
}