Files
member-console/internal/server/operator_topology_test.go
T
cgalo5758 71818de0bd Add setup checklist and empty-state guidance
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.
2026-08-23 03:06:11 -05:00

525 lines
19 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{OrphanProducts: []ProductOption{{ProductID: "x", Name: "Orphan"}}}
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("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
}