Files
member-console/internal/server/operator_topology_test.go
T
cgalo5758 257955c9d3 Add operator list-scale contract and People directory
Governed operator lists (organizations, grants, people, billing×4) gain
server-side search, status filters, and 50-row pages with true totals
from count(*) OVER(); state is URL-addressable, out-of-range pages
clamp,
and no-match is distinct from true-empty.

People is the eighth flat sidebar entry: /operator/persons lists persons
newest-joined first (excluding the reserved system person), rows linking
to the existing detail.

Billing gains an operator invoice detail at
/operator/billing/invoices/{invoiceID} reusing the member projection;
open invoices past due present as Overdue (derived, filterable, stored
status untouched); all four views lead with the linked organization and
mute object IDs.

Grants filter over the derived Live/Superseded/Inactive state, the SQL
HAVING predicate pinned to the Go derivation by test. Embedded lists
(org composite ledger, Tier changes) adopt the shared controls under
namespaced params with sibling-state-preserving URLs and scoped htmx
swaps that hold the viewport.

Review corrections: blocked ladder Delete renders disabled with tooltip
and mutations fire toasts; collapse triggers paint their open state;
sections use outside headings; plan topology drops the orphan-product
check; domains policy collapses behind a disclosure.
2026-08-24 03:58:18 -05:00

545 lines
20 KiB
Go

package server
import (
"bytes"
"context"
"database/sql"
"html/template"
"io"
"io/fs"
"log/slog"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"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/identity"
"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"
)
// 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 (ragged empty cells, shared/lifecycle badges), the
// add-on strip, the shared-product reverse index, the org-type provisioning
// summary, and the structural-validation health strip (both branches).
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 "" },
})
if tmpl, err = tmpl.ParseFS(partialsSub, "operator_*.html"); err != nil {
t.Fatalf("ParseFS partials: %v", err)
}
base := PlanTopologyData{
Ladders: []TopologyLadderColumn{
{PlanLadderID: "lad-b", LadderKey: "support", Name: "Support", IsActive: true},
{PlanLadderID: "lad-a", LadderKey: "hosting", Name: "Hosting", IsActive: false},
},
Rows: []TopologyRow{
{Rank: 1, Cells: []TopologyCell{
{Present: false},
{Present: true, ProductID: "p-shared", ProductName: "Shared Plan", Shared: true, LifecycleStatus: "published", LadderID: "lad-a"},
}},
{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"},
}},
},
Addons: []TopologyAddonViewModel{
{ProductID: "p-add", Name: "Extra Site", LifecycleStatus: "published", Category: "addon"},
},
SharedProducts: []SharedProductViewModel{
{ProductID: "p-shared", ProductName: "Shared Plan", Memberships: []SharedProductMembership{
{LadderKey: "hosting", LadderName: "Hosting", Rank: 1},
{LadderKey: "support", LadderName: "Support", Rank: 0},
}},
},
OrgTypes: []OrgTypeProvisionViewModel{
{OrgType: "personal", DisplayName: "Personal", HasDefault: true, LadderName: "Hosting", 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("issues present", func(t *testing.T) {
data := base
data.Health = PlanLadderValidationData{MultiActivePools: []MultiActivePoolViewModel{{PoolID: "p1"}}}
out := render(t, data)
for _, want := range []string{
"Support", "Hosting", // both axes
"Shared Plan", "Basic", "Extra Site", // products
"shared", // M:N badge
"draft", // lifecycle badge
"—", // ragged empty cell
"No default (off-ladder)", // org-type without default
"Structural issues", // health warning branch
"plan-ladders/validation", // health drill-in
} {
if !strings.Contains(out, want) {
t.Errorf("rendered topology missing %q", want)
}
}
// org-type resolution renders the rank-0 product name.
if !strings.Contains(out, "rank-0") {
t.Errorf("rendered topology missing org-type rank-0 resolution")
}
})
t.Run("off-ladder products are not structural issues", func(t *testing.T) {
// Off-ladder published products are legitimate catalog shape (Doc 41
// / Decision 134; the member catalog's non-plan section is exactly
// these), so they must not trip the warning strip (maintainer
// 2026-08-23; operator-topology-overview retired the orphan check
// from the structural summary).
data := base
data.Health = PlanLadderValidationData{OrphanProducts: []ProductOption{{ProductID: "x", Name: "Orphan"}}}
out := render(t, data)
if strings.Contains(out, "Structural issues") {
t.Errorf("off-ladder products alone must not render the warning strip, got:\n%s", out)
}
if !strings.Contains(out, "No structural issues detected") {
t.Errorf("off-ladder products alone must render the healthy strip")
}
if strings.Contains(out, "off-ladder published product") {
t.Errorf("the banner must not count off-ladder products at all")
}
})
t.Run("clean health", func(t *testing.T) {
out := render(t, base) // Health zero-valued
if !strings.Contains(out, "No structural issues detected") {
t.Errorf("clean health did not render the healthy strip")
}
if strings.Contains(out, "Structural issues") {
t.Errorf("clean health unexpectedly rendered the warning strip")
}
})
t.Run("nothing to validate", func(t *testing.T) {
// Even with issues that would otherwise trip the warning branch, an
// empty validation domain (no active pool-to-ladder attachments)
// takes precedence: the strip must not claim healthy (nothing was
// checked) nor warn (there is nothing to warn about).
data := base
data.Health = PlanLadderValidationData{OrphanProducts: []ProductOption{{ProductID: "x", Name: "Orphan"}}}
data.NothingToValidate = true
out := render(t, data)
if !strings.Contains(out, "Nothing to validate yet") {
t.Errorf("nothing-to-validate did not render the neutral strip")
}
if strings.Contains(out, "No structural issues detected") {
t.Errorf("nothing-to-validate unexpectedly rendered the healthy strip")
}
if strings.Contains(out, "Structural issues") {
t.Errorf("nothing-to-validate unexpectedly rendered the warning strip")
}
})
}
// 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 := database.BeginTx(ctx, 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{
LadderKey: "topo-host-" + uuid.New().String()[:8], Name: "Topo Hosting", IsActive: true,
})
if err != nil {
t.Fatalf("create ladderA: %v", err)
}
ladderB, err := bq.CreatePlanLadder(ctx, billing.CreatePlanLadderParams{
LadderKey: "topo-supp-" + uuid.New().String()[:8], Name: "Topo Support", 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)
}
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)
}
}
}
// TestLoadPlanTopologyData_NothingToValidate exercises the empty-validation-
// domain gate end to end: AnyActiveLadderAttachment (internal/entitlements)
// wired through loadPlanTopologyData's NothingToValidate field. With no
// active pool-to-ladder attachments anywhere, the gate is true; once one
// exists (conferred the same way the member catalog would produce one), it
// flips false and the normal healthy/issue-checking branches apply.
func TestLoadPlanTopologyData_NothingToValidate(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; wipe the table
// defensively (inside this rolled-back tx, so nothing persists) so the
// "nothing yet" assertion below does not depend on what any other test
// run left active. Row-level DELETEs (not TRUNCATE): computePlanLadderValidation
// below reads this table over h.Database, a separate pooled connection
// outside this tx — TRUNCATE's table-level lock would block that read
// until this tx ends, deadlocking against ourselves. child table first
// to satisfy fk_pool_provision_transitions_occupancy.
if _, err := tx.ExecContext(ctx, `DELETE FROM core.pool_provision_transitions`); err != nil {
t.Fatalf("clean pool_provision_transitions: %v", err)
}
if _, err := tx.ExecContext(ctx, `DELETE FROM core.pool_provision_ladders`); err != nil {
t.Fatalf("clean pool_provision_ladders: %v", err)
}
bq := billing.New(tx)
eq := entitlements.New(tx)
iq := identity.New(tx)
oq := organization.New(tx)
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)
t.Run("empty domain", func(t *testing.T) {
data := h.loadPlanTopologyData(req)
if !data.NothingToValidate {
t.Errorf("expected NothingToValidate = true with no active pool-to-ladder attachments")
}
})
// Build the minimal fixture an active attachment needs: a published,
// ladder-tiered product; an org with a resource pool; a manual grant;
// and a Confer onto that pool (the same enclosure the member catalog and
// operator grant flows use — writes to core.pool_provision_ladders go
// through it alone).
es, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
Name: "topo-ntv-set-" + uuid.New().String()[:8],
})
if err != nil {
t.Fatalf("create entitlement set: %v", err)
}
product, err := bq.CreateProduct(ctx, billing.CreateProductParams{
Name: "Topo NTV Product",
Description: sql.NullString{String: "nothing-to-validate fixture", Valid: true},
IsActive: true,
IsPublic: true,
EntitlementSetID: uuid.NullUUID{UUID: uuid.MustParse(es.SetID), Valid: true},
LifecycleStatus: "published",
})
if err != nil {
t.Fatalf("create product: %v", err)
}
ladder, err := bq.CreatePlanLadder(ctx, billing.CreatePlanLadderParams{
LadderKey: "topo-ntv-" + uuid.New().String()[:8], Name: "Topo NTV Ladder", IsActive: true,
})
if err != nil {
t.Fatalf("create ladder: %v", err)
}
if _, err := bq.CreatePlanLadderTier(ctx, billing.CreatePlanLadderTierParams{
PlanLadderID: ladder.PlanLadderID, ProductID: product.ProductID,
}); err != nil {
t.Fatalf("create tier: %v", err)
}
user, err := iq.CreateUser(ctx, "u-"+uuid.New().String())
if err != nil {
t.Fatalf("create user: %v", err)
}
person, err := iq.CreatePerson(ctx, identity.CreatePersonParams{
UserID: user.UserID,
DisplayName: "Topo NTV Member",
PrimaryEmail: "topo-ntv-" + uuid.New().String()[:8] + "@example.com",
PrimaryEmailVerified: true,
})
if err != nil {
t.Fatalf("create person: %v", err)
}
// Own org_type fixture rather than assuming "personal" pre-exists: this
// scratch DB is shared across this package's whole test run, and another
// test's cleanup can leave core.org_types empty between runs.
orgType := "ntv-" + uuid.New().String()[:8]
if _, err := tx.ExecContext(ctx,
`INSERT INTO core.org_types (org_type, display_name) VALUES ($1, $2)`,
orgType, "Topo NTV Org Type"); err != nil {
t.Fatalf("create org_type: %v", err)
}
org, err := oq.CreateOrganization(ctx, organization.CreateOrganizationParams{
Name: "Topo NTV Org", Slug: "topo-ntv-org-" + uuid.New().String()[:8],
OrgType: orgType, OwnerPersonID: person.PersonID,
})
if err != nil {
t.Fatalf("create org: %v", err)
}
pool, err := eq.CreateResourcePool(ctx, entitlements.CreateResourcePoolParams{
OrgID: org.OrgID, Name: "default", Slug: "default", PoolType: "default", IsAutoManaged: true,
})
if err != nil {
t.Fatalf("create pool: %v", err)
}
grant, err := eq.CreateGrant(ctx, entitlements.CreateGrantParams{
ProductID: product.ProductID,
GrantedToOrgID: uuid.NullUUID{UUID: uuid.MustParse(org.OrgID), Valid: true},
GrantedByPersonID: uuid.NullUUID{UUID: uuid.MustParse(person.PersonID), Valid: true},
GrantReason: "manual",
Quantity: 1,
ValidFrom: time.Now(),
})
if err != nil {
t.Fatalf("create grant: %v", err)
}
if _, _, err := eq.Confer(ctx, entitlements.ConferParams{
PoolID: pool.PoolID,
ProductID: product.ProductID,
GrantID: uuid.NullUUID{UUID: uuid.MustParse(grant.GrantID), Valid: true},
Quantity: 1,
ActorType: "system",
}); err != nil {
t.Fatalf("confer: %v", err)
}
t.Run("active attachment exists", func(t *testing.T) {
data := h.loadPlanTopologyData(req)
if data.NothingToValidate {
t.Errorf("expected NothingToValidate = false once an active attachment exists")
}
})
}
// 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
}