- 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
207 lines
8.0 KiB
Go
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")
|
|
})
|
|
}
|