Add an append-only ledger of entitlement set rule changes with per-pool effect rows, a preview-and-commit rule change flow, and an automatic drain that settles deferred recomputations. Rules gain a tier reduction policy, resource keys declare over-limit behavior, and the materializer now lowers limits when a rule stops applying. Add entitlement set rule change ledger and preview flow Add an append-only ledger of entitlement set rule changes with a preview-and-commit operator flow. Rule writes now go through an enclosed `core.commit_rule_change` function that files an act row and one obligation per carrying pool, with a drain workflow settling deferred recomputations. The preview dry-runs the materializer with a rule overlay and renders per-pool buckets, reduction-policy disclosures, and provider over-limit consequences. Materializing transactions take a shared advisory rendezvous that rule changes hold exclusively, enforced by a possession assertion. Add History and Entitlement changes surfaces, a rule-less warning on five product-selection surfaces, and a `tier_reduction_policy` column that gates FedWiki parking.
223 lines
8.9 KiB
Go
223 lines
8.9 KiB
Go
// 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/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/entitlements"
|
|
"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 "" },
|
|
"helpIcon": helpIcon,
|
|
})
|
|
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 = "projected from Stripe, which is not 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
|
|
mechanismNote string // ACC-1: names the mechanism that would fill the view, configured-only
|
|
}{
|
|
{
|
|
name: "accounts",
|
|
template: "operator_billing_accounts.html",
|
|
unconfigured: BillingAccountsData{StripeConfigured: false},
|
|
configured: BillingAccountsData{StripeConfigured: true},
|
|
eventlessCopy: "No billing accounts yet.",
|
|
mechanismNote: "Billing accounts appear here as Stripe issues invoices or subscriptions for an organization.",
|
|
},
|
|
{
|
|
name: "subscriptions",
|
|
template: "operator_subscriptions.html",
|
|
unconfigured: SubscriptionsData{StripeConfigured: false},
|
|
configured: SubscriptionsData{StripeConfigured: true},
|
|
eventlessCopy: "No subscriptions yet.",
|
|
mechanismNote: "Subscriptions appear here as Stripe reports them.",
|
|
},
|
|
{
|
|
name: "invoices",
|
|
template: "operator_invoices.html",
|
|
unconfigured: InvoicesData{StripeConfigured: false},
|
|
configured: InvoicesData{StripeConfigured: true},
|
|
eventlessCopy: "No invoices yet.",
|
|
mechanismNote: "Invoices appear here as Stripe issues them.",
|
|
},
|
|
{
|
|
name: "payments",
|
|
template: "operator_payments.html",
|
|
unconfigured: PaymentsData{StripeConfigured: false},
|
|
configured: PaymentsData{StripeConfigured: true},
|
|
eventlessCopy: "No payments yet.",
|
|
mechanismNote: "Payments appear here as Stripe processes them.",
|
|
},
|
|
}
|
|
|
|
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 !strings.Contains(configuredOut, tc.mechanismNote) {
|
|
t.Errorf("%s: configured render missing the mechanism note %q:\n%s", tc.name, tc.mechanismNote, configuredOut)
|
|
}
|
|
if strings.Contains(unconfiguredOut, tc.mechanismNote) {
|
|
t.Errorf("%s: unconfigured render must not carry the configured-only mechanism note", 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 := entitlements.BeginMaterializing(ctx, database, 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")
|
|
})
|
|
}
|