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.
545 lines
20 KiB
Go
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
|
|
}
|