Implement the ux-first-run change: a state-derived setup checklist on /operator/setup with a landing region that recedes once required steps are done, and empty states that distinguish blocked from empty across operator and member surfaces. Also add production deployment and environment reference docs, plus a config-key completeness test.
207 lines
7.9 KiB
Go
207 lines
7.9 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 = 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")
|
|
})
|
|
}
|