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.
397 lines
15 KiB
Go
397 lines
15 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"html/template"
|
|
"io"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/http/httptest"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/db"
|
|
"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/organization"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
|
|
"github.com/google/uuid"
|
|
_ "github.com/jackc/pgx/v5/stdlib"
|
|
)
|
|
|
|
// topWordRe matches the standalone word "top" (design D8 "the word 'top'
|
|
// never appears in copy"), case-insensitively, without tripping on
|
|
// substrings like "topology" or the "topology-rank-col" CSS token.
|
|
var topWordRe = regexp.MustCompile(`(?i)\btop\b`)
|
|
|
|
// TestPlanTopologyTemplateRendering renders operator_plan_topology.html
|
|
// against a hand-built view model — no DB — to lock the rendering of every
|
|
// section: the cross-ladder grid (ranks ascending, ragged empty cells,
|
|
// shared/lifecycle badges), the add-on strip, the shared-product reverse
|
|
// index, and the org-type provisioning summary. The structural-validation
|
|
// health strip this page once carried was retired outright in
|
|
// acceptance-fixes round 2 (design D8, operator-topology-overview "Overview
|
|
// surfaces structural-validation health inline"): the one check it ran is
|
|
// impossible under the database's exclusion constraint.
|
|
func TestPlanTopologyTemplateRendering(t *testing.T) {
|
|
partialsSub, err := fs.Sub(embeds.Templates, "templates/partials")
|
|
if err != nil {
|
|
t.Fatalf("fs.Sub partials: %v", err)
|
|
}
|
|
tmpl := template.New("topology").Funcs(template.FuncMap{
|
|
"renderBody": func(string, any) (template.HTML, error) { return "", nil },
|
|
"routeURL": web.RouteURL,
|
|
"fieldErr": func(_, _, _, _ string, _, _ any) string { return "" },
|
|
"stripeEntityURL": func(string, string) string { return "" },
|
|
"helpIcon": helpIcon,
|
|
})
|
|
if tmpl, err = web.ParseUIPartials(template.Must(tmpl.ParseFS(partialsSub, "operator_*.html"))); err != nil {
|
|
t.Fatalf("ParseFS partials: %v", err)
|
|
}
|
|
|
|
base := PlanTopologyData{
|
|
Ladders: []TopologyLadderColumn{
|
|
{PlanLadderID: "lad-b", Name: "Support", IsActive: true},
|
|
{PlanLadderID: "lad-a", Name: "Hosting", IsActive: false},
|
|
},
|
|
// design D8 "Rank ascending on both surfaces": rank 0 first, the
|
|
// order the template renders the slice in with no reordering.
|
|
Rows: []TopologyRow{
|
|
{Rank: 0, Cells: []TopologyCell{
|
|
{Present: true, ProductID: "p-shared", ProductName: "Shared Plan", Shared: true, LifecycleStatus: "published", LadderID: "lad-b"},
|
|
{Present: true, ProductID: "p1", ProductName: "Basic", Shared: false, LifecycleStatus: "draft", LadderID: "lad-a"},
|
|
}},
|
|
{Rank: 1, Cells: []TopologyCell{
|
|
{Present: false},
|
|
{Present: true, ProductID: "p-shared", ProductName: "Shared Plan", Shared: true, LifecycleStatus: "published", LadderID: "lad-a"},
|
|
}},
|
|
},
|
|
Addons: []TopologyAddonViewModel{
|
|
{ProductID: "p-add", Name: "Extra Site", LifecycleStatus: "published", Category: "addon"},
|
|
},
|
|
SharedProducts: []SharedProductViewModel{
|
|
{ProductID: "p-shared", ProductName: "Shared Plan", Memberships: []SharedProductMembership{
|
|
{LadderName: "Hosting", Rank: 1},
|
|
{LadderName: "Support", Rank: 0},
|
|
}},
|
|
},
|
|
OrgTypes: []OrgTypeProvisionViewModel{
|
|
{OrgType: "personal", DisplayName: "Personal", HasDefault: true, LadderID: "lad-a", LadderName: "Hosting", RankZeroProductID: "p1", RankZeroProduct: "Basic"},
|
|
{OrgType: "coop", DisplayName: "Cooperative", HasDefault: false},
|
|
},
|
|
}
|
|
|
|
render := func(t *testing.T, data PlanTopologyData) string {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
if err := tmpl.ExecuteTemplate(&buf, "operator_plan_topology.html", data); err != nil {
|
|
t.Fatalf("ExecuteTemplate: %v", err)
|
|
}
|
|
return buf.String()
|
|
}
|
|
|
|
t.Run("grid renders both axes, ranks ascending, and no health strip", func(t *testing.T) {
|
|
out := render(t, base)
|
|
for _, want := range []string{
|
|
"Support", "Hosting", // both axes
|
|
"Shared Plan", "Basic", "Extra Site", // products
|
|
">Shared</span>", // M:N badge (statusBadge part, title case)
|
|
">Draft</span>", // lifecycle badge
|
|
"—", // ragged empty cell
|
|
"No default (off-ladder)", // org-type without default
|
|
"Open a ladder to reorder its tiers.", // design D8 / operator-topology-overview
|
|
} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("rendered topology missing %q", want)
|
|
}
|
|
}
|
|
// design D8 "Rank ascending on both surfaces": rank 0's row (Basic)
|
|
// renders before rank 1's row (the ragged empty cell / Shared Plan
|
|
// only), and "top" never appears in the copy.
|
|
if i0, i1 := strings.Index(out, "Basic"), strings.Index(out, `<th scope="row" class="text-muted">1</th>`); i0 == -1 || i1 == -1 || i0 > i1 {
|
|
t.Errorf("expected rank 0 (Basic) to render before the rank-1 row, got Basic at %d, rank-1 row at %d", i0, i1)
|
|
}
|
|
// Word-boundary match: "topology" and CSS tokens like
|
|
// "topology-rank-col" legitimately contain "top" as a substring and
|
|
// must not trip this — design D8 bans the standalone word.
|
|
if topWordRe.MatchString(out) {
|
|
t.Errorf(`the word "top" must not appear in the topology copy, got:\n%s`, out)
|
|
}
|
|
// design D8 / operator-topology-overview (acceptance-fixes round 2):
|
|
// the structural-validation health strip and its drill-in are gone.
|
|
for _, banned := range []string{"Structural issues", "No structural issues detected", "plan-ladders/validation", "Nothing to validate yet"} {
|
|
if strings.Contains(out, banned) {
|
|
t.Errorf("topology must not render the retired health strip, got %q", banned)
|
|
}
|
|
}
|
|
// org-type resolution cross-links the default ladder and its
|
|
// rank-0 product (chrome-conventions: cross-refs link identifiers).
|
|
if !strings.Contains(out, `<a href="/operator/plan-ladders/lad-a">Hosting</a> <span class="text-muted">@ rank 0</span> <a href="/operator/products/p1">Basic</a>`) {
|
|
t.Errorf("rendered topology missing org-type rank-0 resolution")
|
|
}
|
|
})
|
|
|
|
t.Run("shared product line names each ladder by its display name", func(t *testing.T) {
|
|
// entity-keys: the ladder key is retired, so the
|
|
// shared-product reverse index names each ladder by the only thing
|
|
// it has left, its display name.
|
|
out := render(t, base)
|
|
if !strings.Contains(out, `Hosting @ rank 1`) ||
|
|
!strings.Contains(out, `Support @ rank 0`) {
|
|
t.Errorf("expected the shared-product line to name each ladder by its display name, got: %s", out)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestLoadPlanTopologyData exercises the view-model builder against a real DB:
|
|
// column ordering by sort_order, ragged rows with empty cells, shared-product
|
|
// (M:N) detection + reverse index, and org-type default resolution.
|
|
func TestLoadPlanTopologyData(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()
|
|
|
|
bq := billing.New(tx)
|
|
eq := entitlements.New(tx)
|
|
oq := organization.New(tx)
|
|
|
|
es, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
|
|
Name: "topo-set-" + uuid.New().String()[:8],
|
|
Description: sql.NullString{String: "topology test set", Valid: true},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create entitlement set: %v", err)
|
|
}
|
|
|
|
newPlanProduct := func(name string) billing.Product {
|
|
t.Helper()
|
|
p, err := bq.CreateProduct(ctx, billing.CreateProductParams{
|
|
Name: name,
|
|
Description: sql.NullString{String: name, Valid: true},
|
|
DisplayCategory: sql.NullString{}, // blank — presentation-only label (doc 41)
|
|
IsActive: true,
|
|
IsPublic: true,
|
|
EntitlementSetID: uuid.NullUUID{UUID: uuid.MustParse(es.SetID), Valid: true},
|
|
LifecycleStatus: "published",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create product %s: %v", name, err)
|
|
}
|
|
return p
|
|
}
|
|
|
|
pBasic := newPlanProduct("Topo Basic")
|
|
pShared := newPlanProduct("Topo Shared")
|
|
pAddon, err := bq.CreateProduct(ctx, billing.CreateProductParams{
|
|
Name: "Topo Addon",
|
|
Description: sql.NullString{String: "addon", Valid: true},
|
|
DisplayCategory: sql.NullString{String: "addon", Valid: true},
|
|
IsActive: true,
|
|
IsPublic: true,
|
|
EntitlementSetID: uuid.NullUUID{UUID: uuid.MustParse(es.SetID), Valid: true},
|
|
LifecycleStatus: "published",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create addon: %v", err)
|
|
}
|
|
|
|
// Two ladders; ladderB gets the lower sort_order so it must sort FIRST even
|
|
// though ladderA is created first — proving sort_order drives column order.
|
|
ladderA, err := bq.CreatePlanLadder(ctx, billing.CreatePlanLadderParams{
|
|
Name: "Topo Hosting " + uuid.New().String()[:8], IsActive: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create ladderA: %v", err)
|
|
}
|
|
ladderB, err := bq.CreatePlanLadder(ctx, billing.CreatePlanLadderParams{
|
|
Name: "Topo Support " + uuid.New().String()[:8], IsActive: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create ladderB: %v", err)
|
|
}
|
|
if err := bq.SetPlanLadderSortOrder(ctx, billing.SetPlanLadderSortOrderParams{
|
|
PlanLadderID: ladderA.PlanLadderID, SortOrder: 1,
|
|
}); err != nil {
|
|
t.Fatalf("set ladderA sort_order: %v", err)
|
|
}
|
|
if err := bq.SetPlanLadderSortOrder(ctx, billing.SetPlanLadderSortOrderParams{
|
|
PlanLadderID: ladderB.PlanLadderID, SortOrder: 0,
|
|
}); err != nil {
|
|
t.Fatalf("set ladderB sort_order: %v", err)
|
|
}
|
|
|
|
// Rank self-assigns append-at-end, so creation order sets the rank.
|
|
addTier := func(ladderID, productID string) {
|
|
t.Helper()
|
|
if _, err := bq.CreatePlanLadderTier(ctx, billing.CreatePlanLadderTierParams{
|
|
PlanLadderID: ladderID, ProductID: productID,
|
|
}); err != nil {
|
|
t.Fatalf("add tier: %v", err)
|
|
}
|
|
}
|
|
// ladderA: rank0 Basic, rank1 Shared. ladderB: rank0 Shared only (ragged).
|
|
addTier(ladderA.PlanLadderID, pBasic.ProductID)
|
|
addTier(ladderA.PlanLadderID, pShared.ProductID)
|
|
addTier(ladderB.PlanLadderID, pShared.ProductID)
|
|
|
|
// Point one org type's default at ladderA (rank-0 = Basic), if any exist.
|
|
var defaultedOrgType string
|
|
if orgTypes, err := oq.ListOrgTypes(ctx); err == nil && len(orgTypes) > 0 {
|
|
defaultedOrgType = orgTypes[0].OrgType
|
|
if _, err := oq.UpdateOrgTypeDefaultPlanLadder(ctx, organization.UpdateOrgTypeDefaultPlanLadderParams{
|
|
OrgType: defaultedOrgType,
|
|
DefaultPlanLadderID: uuid.NullUUID{UUID: uuid.MustParse(ladderA.PlanLadderID), Valid: true},
|
|
}); err != nil {
|
|
t.Fatalf("set org-type default: %v", err)
|
|
}
|
|
}
|
|
|
|
h := &OperatorPartialsHandler{
|
|
BillingQ: bq,
|
|
EntitlementsQ: eq,
|
|
OrgQ: oq,
|
|
Database: database,
|
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
}
|
|
req := httptest.NewRequest("GET", "/operator/plan-topology", nil).WithContext(ctx)
|
|
data := h.loadPlanTopologyData(req)
|
|
|
|
// The shared test DB carries committed ladders from prior runs, so assert on
|
|
// THIS fixture as a subset rather than on global structure.
|
|
idxOf := func(id string) int {
|
|
for i, l := range data.Ladders {
|
|
if l.PlanLadderID == id {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
ia, ib := idxOf(ladderA.PlanLadderID), idxOf(ladderB.PlanLadderID)
|
|
if ia < 0 || ib < 0 {
|
|
t.Fatalf("fixture ladders missing from columns (ia=%d ib=%d)", ia, ib)
|
|
}
|
|
// Column order: ladderB (sort_order 0) sorts before ladderA (sort_order 1).
|
|
if ib >= ia {
|
|
t.Errorf("sort_order ordering wrong: expected ladderB column before ladderA (ib=%d ia=%d)", ib, ia)
|
|
}
|
|
|
|
// design D8 "Rank ascending on both surfaces": the whole row set is rank
|
|
// ascending, not just this fixture's ranks 0/1 — the shared test DB
|
|
// carries other ranks from prior runs, so this checks the general
|
|
// invariant rather than two specific indices.
|
|
for i := 1; i < len(data.Rows); i++ {
|
|
if data.Rows[i-1].Rank > data.Rows[i].Rank {
|
|
t.Fatalf("expected data.Rows rank ascending, got %d before %d at index %d", data.Rows[i-1].Rank, data.Rows[i].Rank, i)
|
|
}
|
|
}
|
|
|
|
rowByRank := func(rank int32) *TopologyRow {
|
|
for i := range data.Rows {
|
|
if data.Rows[i].Rank == rank {
|
|
return &data.Rows[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
r1, r0 := rowByRank(1), rowByRank(0)
|
|
if r1 == nil || r0 == nil {
|
|
t.Fatalf("expected rows for ranks 0 and 1")
|
|
}
|
|
// Rank-1: ladderA = Shared (present, shared); ladderB has no rank-1 (ragged).
|
|
if c := r1.Cells[ia]; !c.Present || c.ProductID != pShared.ProductID || !c.Shared {
|
|
t.Errorf("rank-1 ladderA cell wrong: %+v", c)
|
|
}
|
|
if r1.Cells[ib].Present {
|
|
t.Errorf("expected empty cell for ladderB at rank 1 (ragged)")
|
|
}
|
|
// Rank-0: ladderB = Shared (shared); ladderA = Basic (not shared).
|
|
if c := r0.Cells[ib]; !c.Present || c.ProductID != pShared.ProductID || !c.Shared {
|
|
t.Errorf("rank-0 ladderB cell wrong: %+v", c)
|
|
}
|
|
if c := r0.Cells[ia]; !c.Present || c.ProductID != pBasic.ProductID || c.Shared {
|
|
t.Errorf("rank-0 ladderA cell wrong: %+v", c)
|
|
}
|
|
|
|
// Shared reverse index: pShared appears with both of its memberships.
|
|
var sp *SharedProductViewModel
|
|
for i := range data.SharedProducts {
|
|
if data.SharedProducts[i].ProductID == pShared.ProductID {
|
|
sp = &data.SharedProducts[i]
|
|
}
|
|
}
|
|
if sp == nil {
|
|
t.Fatalf("shared product %s missing from reverse index", pShared.ProductID)
|
|
}
|
|
if len(sp.Memberships) != 2 {
|
|
t.Errorf("expected 2 memberships for shared product, got %d", len(sp.Memberships))
|
|
}
|
|
|
|
// Add-on strip includes the addon product.
|
|
foundAddon := false
|
|
for _, a := range data.Addons {
|
|
if a.ProductID == pAddon.ProductID {
|
|
foundAddon = true
|
|
}
|
|
}
|
|
if !foundAddon {
|
|
t.Errorf("addon product missing from topology add-on strip")
|
|
}
|
|
|
|
// Org-type resolution: the defaulted org type resolves to ladderA rank-0 = Basic.
|
|
if defaultedOrgType != "" {
|
|
var found *OrgTypeProvisionViewModel
|
|
for i := range data.OrgTypes {
|
|
if data.OrgTypes[i].OrgType == defaultedOrgType {
|
|
found = &data.OrgTypes[i]
|
|
}
|
|
}
|
|
if found == nil {
|
|
t.Fatalf("defaulted org type %s missing from summary", defaultedOrgType)
|
|
}
|
|
if !found.HasDefault || found.RankZeroProduct != pBasic.Name {
|
|
t.Errorf("org-type resolution wrong: %+v", found)
|
|
}
|
|
}
|
|
}
|
|
|
|
// topoTestDB mirrors the external testDB helper for this internal-package test
|
|
// (package server cannot import the package server_test helper). It migrates
|
|
// only db.BaseSources() (the core schema), not the full internal/migrate.Sources()
|
|
// integration-registry set: this file is an internal test (package server),
|
|
// and internal/migrate imports internal/integrations, whose registered
|
|
// integrations (e.g. internal/integrations/fedwiki) import internal/server to
|
|
// construct their still-in-core handlers — importing internal/migrate here
|
|
// would cycle back through this very package. This test only exercises core
|
|
// schemas (billing, entitlements, organization), so the core-only source set
|
|
// is sufficient.
|
|
func topoTestDB(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
dsn := os.Getenv("TEST_DATABASE_URL")
|
|
if dsn == "" {
|
|
t.Skip("TEST_DATABASE_URL not set, skipping integration test")
|
|
}
|
|
database, err := sql.Open("pgx", dsn)
|
|
if err != nil {
|
|
t.Fatalf("open database: %v", err)
|
|
}
|
|
sources := db.BaseSources()
|
|
if err := db.RunMigrations(database, sources); err != nil {
|
|
t.Fatalf("run migrations: %v", err)
|
|
}
|
|
t.Cleanup(func() { database.Close() })
|
|
return database
|
|
}
|