Files
member-console/internal/server/product_readiness_test.go
T
cgalo5758 8e3c68c6be Make UI surfaces honestly reflect system state
- 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
2026-08-23 01:45:52 -05:00

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)
}
}
})
}
}