Files
member-console/internal/server/operator_topology_test.go
T
cgalo5758 3727ff31d8 Add entitlement set rule change ledger and preview flow
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.
2026-09-15 03:53:28 -05:00

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
}