- Add deployment-name branding to titles, mastheads, and OG tags - Share one grant delivery-state query with lineage across grants surfaces - Show pool status/usage, org owners, and config readiness - Make billing views projection-aware with recency and sync vocabulary - Guard FedWiki creation without domains and render route-aware 404s
295 lines
11 KiB
Go
295 lines
11 KiB
Go
package server
|
|
|
|
import (
|
|
"database/sql"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
|
|
)
|
|
|
|
// product builds a billing.Product with the fields buildProductReadinessVM reads.
|
|
func product(lifecycle string, active, public bool) billing.Product {
|
|
return billing.Product{
|
|
LifecycleStatus: lifecycle,
|
|
IsActive: active,
|
|
IsPublic: public,
|
|
}
|
|
}
|
|
|
|
// productAddon is product() plus display_category=addon, for member-catalog-
|
|
// visibility cases: the catalog renders a product either because it is a
|
|
// ladder tier (shapeForLadder) or because it carries this category.
|
|
func productAddon(lifecycle string, active, public bool) billing.Product {
|
|
p := product(lifecycle, active, public)
|
|
p.DisplayCategory = sql.NullString{String: displayCategoryAddon, Valid: true}
|
|
return p
|
|
}
|
|
|
|
// shapeFor builds the billing.CoreProductShape counterpart: whether an
|
|
// entitlement set is present and how the product is priced. ladder_count
|
|
// defaults to 0 (off-ladder); use shapeForLadder for an on-ladder shape.
|
|
func shapeFor(setPresent bool, billingShape string) billing.CoreProductShape {
|
|
return billing.CoreProductShape{SetPresent: setPresent, BillingShape: billingShape}
|
|
}
|
|
|
|
// shapeForLadder is shapeFor with an explicit ladder_count, for cases where
|
|
// plan-ladder membership (member-catalog findability) matters.
|
|
func shapeForLadder(setPresent bool, billingShape string, ladderCount int64) billing.CoreProductShape {
|
|
s := shapeFor(setPresent, billingShape)
|
|
s.LadderCount = ladderCount
|
|
return s
|
|
}
|
|
|
|
// rowState returns the State of the named precondition row, or "" if absent.
|
|
func rowState(vm ProductReadinessVM, label string) string {
|
|
for _, r := range vm.Rows {
|
|
if r.Label == label {
|
|
return r.State
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func TestBuildProductReadinessVM(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
product billing.Product
|
|
shape billing.CoreProductShape
|
|
pr PriceReadiness
|
|
wantPurchasabl bool
|
|
wantVerdict string
|
|
// optional: assert a specific row's state
|
|
rowLabel string
|
|
rowState string
|
|
}{
|
|
{
|
|
name: "fully configured public product is purchasable",
|
|
product: product("published", true, true),
|
|
shape: shapeForLadder(true, "recurring", 1), // on-ladder: findable, no qualifier
|
|
pr: PriceReadiness{HasActivePrice: true, PriceID: "p1", StripeMapped: true},
|
|
wantPurchasabl: true,
|
|
wantVerdict: "Purchasable",
|
|
},
|
|
{
|
|
name: "public product missing a price is incomplete",
|
|
product: product("published", true, true),
|
|
shape: shapeFor(true, "unpriced"),
|
|
pr: PriceReadiness{},
|
|
wantPurchasabl: false,
|
|
rowLabel: "Active price",
|
|
rowState: "unmet",
|
|
},
|
|
{
|
|
name: "price present but stripe sync pending",
|
|
product: product("published", true, true),
|
|
shape: shapeFor(true, "recurring"),
|
|
pr: PriceReadiness{HasActivePrice: true, PriceID: "p1", SyncPending: true},
|
|
wantPurchasabl: false,
|
|
rowLabel: "Payment processing",
|
|
rowState: "pending",
|
|
},
|
|
{
|
|
name: "missing entitlement set is incomplete",
|
|
product: product("published", true, true),
|
|
shape: shapeFor(false, "recurring"),
|
|
pr: PriceReadiness{HasActivePrice: true, PriceID: "p1", StripeMapped: true},
|
|
wantPurchasabl: false,
|
|
rowLabel: "Entitlement set present",
|
|
rowState: "unmet",
|
|
},
|
|
{
|
|
name: "internal wrap product needs no price and is ready",
|
|
product: product("published", true, false),
|
|
shape: shapeFor(true, "unpriced"),
|
|
pr: PriceReadiness{},
|
|
wantPurchasabl: true,
|
|
wantVerdict: "Ready (internal)",
|
|
rowLabel: "Active price",
|
|
rowState: "n/a",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
vm := buildProductReadinessVM(tt.product, tt.shape, tt.pr, false, "")
|
|
if vm.Purchasable != tt.wantPurchasabl {
|
|
t.Errorf("Purchasable = %v, want %v (verdict: %q)", vm.Purchasable, tt.wantPurchasabl, vm.Verdict)
|
|
}
|
|
if tt.wantVerdict != "" && vm.Verdict != tt.wantVerdict {
|
|
t.Errorf("Verdict = %q, want %q", vm.Verdict, tt.wantVerdict)
|
|
}
|
|
if !tt.wantPurchasabl && len(vm.Missing) == 0 {
|
|
t.Errorf("expected Missing to be non-empty for an incomplete product")
|
|
}
|
|
if tt.rowLabel != "" {
|
|
if got := rowState(vm, tt.rowLabel); got != tt.rowState {
|
|
t.Errorf("row %q state = %q, want %q", tt.rowLabel, got, tt.rowState)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestBuildProductReadinessVM_MemberCatalogVisibility pins UX-8: the readiness
|
|
// panel's member-catalog-visibility row and verdict qualifier. It never flips
|
|
// Purchasable (Decision 6) — only a ladder tier or a display_category=addon
|
|
// product is "shown"; everything else public is "hidden" and, when otherwise
|
|
// purchasable, earns a qualifier so the verdict cannot be misread as findable.
|
|
func TestBuildProductReadinessVM_MemberCatalogVisibility(t *testing.T) {
|
|
fullPrice := PriceReadiness{HasActivePrice: true, PriceID: "p1", StripeMapped: true}
|
|
|
|
t.Run("ladder tier is shown, verdict carries no qualifier", func(t *testing.T) {
|
|
vm := buildProductReadinessVM(product("published", true, true), shapeForLadder(true, "recurring", 1), fullPrice, false, "")
|
|
if got := rowState(vm, "Member catalog visibility"); got != "shown" {
|
|
t.Errorf("row state = %q, want shown", got)
|
|
}
|
|
if vm.CatalogQualifier != "" {
|
|
t.Errorf("CatalogQualifier = %q, want empty for a findable product", vm.CatalogQualifier)
|
|
}
|
|
if !vm.Purchasable || vm.Verdict != "Purchasable" {
|
|
t.Errorf("Purchasable=%v Verdict=%q, want true/\"Purchasable\"", vm.Purchasable, vm.Verdict)
|
|
}
|
|
})
|
|
|
|
t.Run("addon category is shown, verdict carries no qualifier", func(t *testing.T) {
|
|
vm := buildProductReadinessVM(productAddon("published", true, true), shapeFor(true, "recurring"), fullPrice, false, "")
|
|
if got := rowState(vm, "Member catalog visibility"); got != "shown" {
|
|
t.Errorf("row state = %q, want shown", got)
|
|
}
|
|
if vm.CatalogQualifier != "" {
|
|
t.Errorf("CatalogQualifier = %q, want empty for an addon", vm.CatalogQualifier)
|
|
}
|
|
})
|
|
|
|
t.Run("published off-ladder untyped product is an ordinary shape, purchasable but hidden", func(t *testing.T) {
|
|
// ladder_count=0, no display_category: an otherwise fully configured
|
|
// public product. Spec: this is an ordinary shape, not limbo — the
|
|
// verdict stays Purchasable but gains the catalog-visibility qualifier.
|
|
vm := buildProductReadinessVM(product("published", true, true), shapeFor(true, "recurring"), fullPrice, false, "")
|
|
if got := rowState(vm, "Member catalog visibility"); got != "hidden" {
|
|
t.Errorf("row state = %q, want hidden", got)
|
|
}
|
|
if !vm.Purchasable {
|
|
t.Errorf("Purchasable = false, want true (catalog visibility must never flip the verdict)")
|
|
}
|
|
if vm.CatalogQualifier == "" {
|
|
t.Error("CatalogQualifier empty, want a qualifier naming the product not shown in the member catalog")
|
|
}
|
|
if !strings.Contains(vm.Verdict, "not shown in the member catalog") {
|
|
t.Errorf("Verdict = %q, want it to carry the catalog-visibility qualifier", vm.Verdict)
|
|
}
|
|
// Diagnostic, not a violation: it must not appear in Missing.
|
|
for _, m := range vm.Missing {
|
|
if m == "Member catalog visibility" {
|
|
t.Error("Member catalog visibility must never appear in Missing — it is a diagnostic, not a precondition")
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("incomplete off-ladder product carries no qualifier (verdict already says incomplete)", func(t *testing.T) {
|
|
vm := buildProductReadinessVM(product("published", true, true), shapeFor(true, "unpriced"), PriceReadiness{}, false, "")
|
|
if vm.Purchasable {
|
|
t.Fatal("expected Purchasable = false for an unpriced product")
|
|
}
|
|
if vm.CatalogQualifier != "" {
|
|
t.Errorf("CatalogQualifier = %q, want empty when the product is not purchasable", vm.CatalogQualifier)
|
|
}
|
|
})
|
|
|
|
t.Run("wrap product's catalog visibility is not applicable", func(t *testing.T) {
|
|
vm := buildProductReadinessVM(product("published", true, false), shapeFor(true, "unpriced"), PriceReadiness{}, false, "")
|
|
if got := rowState(vm, "Member catalog visibility"); got != "n/a" {
|
|
t.Errorf("row state = %q, want n/a for a wrap product", got)
|
|
}
|
|
if vm.CatalogQualifier != "" {
|
|
t.Errorf("CatalogQualifier = %q, want empty for a wrap product", vm.CatalogQualifier)
|
|
}
|
|
})
|
|
}
|
|
|
|
// rowDetail returns the Detail of the named precondition row, or "" if absent.
|
|
func rowDetail(vm ProductReadinessVM, label string) string {
|
|
for _, r := range vm.Rows {
|
|
if r.Label == label {
|
|
return r.Detail
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// TestBuildProductReadinessVM_StripeSyncAffordance pins the Sync-to-Stripe
|
|
// action gating: CanSyncStripe is true only when Stripe is configured, there is
|
|
// an active price, and it is neither mapped nor a sync already pending. When
|
|
// Stripe is unconfigured the row shows an explanatory state and offers no action.
|
|
func TestBuildProductReadinessVM_StripeSyncAffordance(t *testing.T) {
|
|
base := product("published", true, true) // published public product
|
|
|
|
cases := []struct {
|
|
name string
|
|
pr PriceReadiness
|
|
wantCanSync bool
|
|
wantConfigured bool
|
|
wantStripeState string
|
|
wantDetailHas string
|
|
}{
|
|
{
|
|
name: "configured + active price + unmapped → can sync",
|
|
pr: PriceReadiness{StripeConfigured: true, HasActivePrice: true, PriceID: "p1"},
|
|
wantCanSync: true,
|
|
wantConfigured: true,
|
|
wantStripeState: "unmet",
|
|
wantDetailHas: "Sync to Stripe",
|
|
},
|
|
{
|
|
name: "not configured → no action, explanatory detail",
|
|
pr: PriceReadiness{StripeConfigured: false, HasActivePrice: true, PriceID: "p1"},
|
|
wantCanSync: false,
|
|
wantConfigured: false,
|
|
wantStripeState: "unmet",
|
|
wantDetailHas: "not configured",
|
|
},
|
|
{
|
|
name: "sync pending → no action",
|
|
pr: PriceReadiness{StripeConfigured: true, HasActivePrice: true, PriceID: "p1", SyncPending: true},
|
|
wantCanSync: false,
|
|
wantConfigured: true,
|
|
wantStripeState: "pending",
|
|
},
|
|
{
|
|
name: "already mapped → no action",
|
|
pr: PriceReadiness{StripeConfigured: true, HasActivePrice: true, PriceID: "p1", StripeMapped: true},
|
|
wantCanSync: false,
|
|
wantConfigured: true,
|
|
wantStripeState: "met",
|
|
},
|
|
{
|
|
name: "configured but no active price → no action",
|
|
pr: PriceReadiness{StripeConfigured: true},
|
|
wantCanSync: false,
|
|
wantConfigured: true,
|
|
wantStripeState: "unmet",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
vm := buildProductReadinessVM(base, shapeFor(true, "recurring"), tc.pr, false, "")
|
|
if vm.CanSyncStripe != tc.wantCanSync {
|
|
t.Errorf("CanSyncStripe = %v, want %v", vm.CanSyncStripe, tc.wantCanSync)
|
|
}
|
|
if vm.StripeConfigured != tc.wantConfigured {
|
|
t.Errorf("StripeConfigured = %v, want %v", vm.StripeConfigured, tc.wantConfigured)
|
|
}
|
|
if got := rowState(vm, "Payment processing"); got != tc.wantStripeState {
|
|
t.Errorf("Stripe row state = %q, want %q", got, tc.wantStripeState)
|
|
}
|
|
if tc.wantDetailHas != "" {
|
|
if d := rowDetail(vm, "Payment processing"); !strings.Contains(d, tc.wantDetailHas) {
|
|
t.Errorf("Stripe row detail = %q, want substring %q", d, tc.wantDetailHas)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|