674 lines
27 KiB
Go
674 lines
27 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package server
|
|
|
|
import (
|
|
"database/sql"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/config"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
|
"github.com/spf13/viper"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// buildVM is buildProductReadinessVM with the pre-readinessInputs positional
|
|
// signature the tests below were written against (design D4's refactor
|
|
// packaged product/shape/price/providers into readinessInputs; syncFailed
|
|
// and syncError stay separate, detail-page-only args). Keeping the call
|
|
// sites' argument order and the verdict-text assertions themselves pinned
|
|
// unchanged is the point of this indirection.
|
|
// An attached set holds one active rule unless a case says otherwise
|
|
// (buildVMRules): a rule-less set is its own state (design D2) and every
|
|
// case written before it existed meant a populated set.
|
|
func buildVM(product billing.Product, shape billing.CoreProductShape, pr PriceReadiness, syncFailed bool, syncError string, providerRows []ProductReadinessRow) ProductReadinessVM {
|
|
return buildProductReadinessVM(readinessInputs{product: product, shape: shape, price: pr, providers: providerRows, activeRules: 1}, syncFailed, syncError)
|
|
}
|
|
|
|
// buildVMRules is buildVM with the set's active-rule count and the Stripe
|
|
// mode spelled out: the two inputs design D2 and D3 added.
|
|
func buildVMRules(product billing.Product, shape billing.CoreProductShape, pr PriceReadiness, activeRules int, stripeMode string) ProductReadinessVM {
|
|
return buildProductReadinessVM(readinessInputs{product: product, shape: shape, price: pr, activeRules: activeRules, stripeMode: stripeMode}, false, "")
|
|
}
|
|
|
|
// 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",
|
|
rowState: "unmet",
|
|
},
|
|
{
|
|
name: "unlisted product needs no price and is ready to grant",
|
|
product: product("published", true, false),
|
|
shape: shapeFor(true, "unpriced"),
|
|
pr: PriceReadiness{},
|
|
wantPurchasabl: true,
|
|
wantVerdict: "Ready to grant",
|
|
rowLabel: "Active price",
|
|
rowState: "n/a",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
vm := buildVM(tt.product, tt.shape, tt.pr, false, "", nil)
|
|
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 := buildVM(product("published", true, true), shapeForLadder(true, "recurring", 1), fullPrice, false, "", nil)
|
|
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 := buildVM(productAddon("published", true, true), shapeFor(true, "recurring"), fullPrice, false, "", nil)
|
|
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 := buildVM(product("published", true, true), shapeFor(true, "recurring"), fullPrice, false, "", nil)
|
|
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 := buildVM(product("published", true, true), shapeFor(true, "unpriced"), PriceReadiness{}, false, "", nil)
|
|
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 := buildVM(product("published", true, false), shapeFor(true, "unpriced"), PriceReadiness{}, false, "", nil)
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestBuildProductReadinessVM_RuleLessSet pins design D2: an entitlement set
|
|
// with no active rule confers nothing, so the Entitlement set row reads "No
|
|
// rules" and the verdict names rules as what is missing — never "Ready to
|
|
// grant" or "Purchasable".
|
|
func TestBuildProductReadinessVM_RuleLessSet(t *testing.T) {
|
|
// Unlisted so the price and payment-processing rows are not applicable
|
|
// and the missing list is the one thing under test.
|
|
vm := buildVMRules(product("published", true, false), shapeFor(true, "unpriced"), PriceReadiness{}, 0, "")
|
|
if got := rowState(vm, "Entitlement set"); got != "no_rules" {
|
|
t.Errorf("Entitlement set row state = %q, want no_rules", got)
|
|
}
|
|
if vm.Verdict != "Incomplete; missing: rules" {
|
|
t.Errorf("Verdict = %q, want %q", vm.Verdict, "Incomplete; missing: rules")
|
|
}
|
|
if vm.Purchasable {
|
|
t.Error("a product whose set holds no active rule must never be Purchasable")
|
|
}
|
|
|
|
// A listed, fully priced and mapped product is held back the same way.
|
|
listed := buildVMRules(product("published", true, true), shapeForLadder(true, "recurring", 1),
|
|
PriceReadiness{HasActivePrice: true, PriceID: "p1", StripeMapped: true, StripeConfigured: true}, 0, "live")
|
|
if listed.Verdict != "Incomplete; missing: rules" {
|
|
t.Errorf("listed product Verdict = %q, want %q", listed.Verdict, "Incomplete; missing: rules")
|
|
}
|
|
}
|
|
|
|
// TestBuildProductReadinessVM_InactiveAndListed pins design D3's verdict
|
|
// precedence and the Listed vocabulary: is_active is a precondition for every
|
|
// product, an inactive one reads "Inactive" rather than being called ready,
|
|
// and an unlisted product's Listed row is a state, not a gap.
|
|
func TestBuildProductReadinessVM_InactiveAndListed(t *testing.T) {
|
|
mapped := PriceReadiness{HasActivePrice: true, PriceID: "p1", StripeMapped: true, StripeConfigured: true}
|
|
|
|
t.Run("inactive unlisted product reads Inactive", func(t *testing.T) {
|
|
vm := buildVMRules(product("published", false, false), shapeFor(true, "unpriced"), PriceReadiness{}, 1, "")
|
|
if vm.Verdict != "Inactive" {
|
|
t.Errorf("Verdict = %q, want %q", vm.Verdict, "Inactive")
|
|
}
|
|
if strings.Contains(vm.Verdict, "Ready to grant") || strings.Contains(vm.Verdict, "Purchasable") {
|
|
t.Errorf("Verdict = %q must not call an inactive product ready", vm.Verdict)
|
|
}
|
|
if vm.Purchasable {
|
|
t.Error("an inactive product is offered nowhere and must not be Purchasable")
|
|
}
|
|
if got := rowState(vm, "Active"); got != "unmet" {
|
|
t.Errorf("Active row state = %q, want unmet", got)
|
|
}
|
|
// The grant form offers only products ListActiveProducts returns
|
|
// (is_active = TRUE), so this product is absent from it; that filter
|
|
// lives in SQL and has no DB-free builder to assert on here.
|
|
})
|
|
|
|
t.Run("inactive listed product reads Inactive", func(t *testing.T) {
|
|
vm := buildVMRules(product("published", false, true), shapeForLadder(true, "recurring", 1), mapped, 1, "live")
|
|
if vm.Verdict != "Inactive" {
|
|
t.Errorf("Verdict = %q, want %q", vm.Verdict, "Inactive")
|
|
}
|
|
if vm.Purchasable {
|
|
t.Error("an inactive product must not be Purchasable")
|
|
}
|
|
})
|
|
|
|
t.Run("active unlisted product with a rule reads Ready to grant", func(t *testing.T) {
|
|
vm := buildVMRules(product("published", true, false), shapeFor(true, "unpriced"), PriceReadiness{}, 1, "")
|
|
if vm.Verdict != "Ready to grant" {
|
|
t.Errorf("Verdict = %q, want %q", vm.Verdict, "Ready to grant")
|
|
}
|
|
if strings.Contains(vm.Verdict, "Purchasable") {
|
|
t.Errorf("Verdict = %q must not read Purchasable for an unlisted product", vm.Verdict)
|
|
}
|
|
if got := rowState(vm, "Listed"); got != "unlisted" {
|
|
t.Errorf("Listed row state = %q, want unlisted (an unlisted product is not unmet)", got)
|
|
}
|
|
for _, m := range vm.Missing {
|
|
if m == "Listed" {
|
|
t.Error("Listed must never appear in Missing for an unlisted product")
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("Payment processing detail names the Stripe mode", func(t *testing.T) {
|
|
for mode, want := range map[string]string{"live": "Synced, live", "test": "Synced, test", "": "Synced"} {
|
|
vm := buildVMRules(product("published", true, true), shapeForLadder(true, "recurring", 1), verifiedIn(mode), 1, mode)
|
|
if got := rowDetail(vm, "Payment processing"); got != want {
|
|
t.Errorf("stripe-mode %q: Payment processing detail = %q, want %q", mode, got, want)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// stampedMapping is a mapped PriceReadiness carrying one environment
|
|
// stamp: livemode as recorded (nil for a row written before the stamp)
|
|
// and verifiedAt set only where the environment check has read the id
|
|
// back (stripe-environment-stamp D2).
|
|
func stampedMapping(livemode *bool, verified bool, syncStatus string) PriceReadiness {
|
|
pr := PriceReadiness{
|
|
HasActivePrice: true,
|
|
PriceID: "p1",
|
|
StripeMapped: true,
|
|
StripeConfigured: true,
|
|
SyncStatus: syncStatus,
|
|
}
|
|
if livemode != nil {
|
|
pr.Livemode = sql.NullBool{Bool: *livemode, Valid: true}
|
|
}
|
|
if verified {
|
|
pr.VerifiedAt = sql.NullTime{Time: time.Now(), Valid: true}
|
|
}
|
|
return pr
|
|
}
|
|
|
|
// verifiedIn is a mapping recorded in mode and read back under it; the
|
|
// settled state of a product that has been synced and checked.
|
|
func verifiedIn(mode string) PriceReadiness {
|
|
if mode == "" {
|
|
return stampedMapping(nil, false, "synced")
|
|
}
|
|
live := mode == "live"
|
|
return stampedMapping(&live, true, "synced")
|
|
}
|
|
|
|
// TestBuildProductReadinessVM_MappingEnvironment pins the five Payment
|
|
// processing details of stripe-environment-stamp D5, which of them leave
|
|
// the row met, and what the two unmet ones do to the verdict, the Sync
|
|
// control's label and the affordance.
|
|
func TestBuildProductReadinessVM_MappingEnvironment(t *testing.T) {
|
|
live, test := true, false
|
|
pub := product("published", true, true)
|
|
shape := shapeForLadder(true, "recurring", 1)
|
|
|
|
cases := []struct {
|
|
name string
|
|
pr PriceReadiness
|
|
mode string
|
|
wantDetail string
|
|
wantState string
|
|
wantVerdict string
|
|
wantSyncable bool
|
|
wantLabel string
|
|
}{
|
|
{
|
|
name: "no stamp is unverified and met",
|
|
pr: stampedMapping(nil, false, "synced"),
|
|
mode: "live",
|
|
wantDetail: "Synced, unverified", wantState: "met",
|
|
wantVerdict: "Purchasable", wantLabel: "Sync to Stripe",
|
|
},
|
|
{
|
|
name: "agreeing but never read back is unverified and met",
|
|
pr: stampedMapping(&live, false, "synced"),
|
|
mode: "live",
|
|
wantDetail: "Synced, unverified", wantState: "met",
|
|
wantVerdict: "Purchasable", wantLabel: "Sync to Stripe",
|
|
},
|
|
{
|
|
name: "agreeing and read back names the environment",
|
|
pr: stampedMapping(&live, true, "synced"),
|
|
mode: "live",
|
|
wantDetail: "Synced, live", wantState: "met",
|
|
wantVerdict: "Purchasable", wantLabel: "Sync to Stripe",
|
|
},
|
|
{
|
|
name: "agreeing and read back under a test key",
|
|
pr: stampedMapping(&test, true, "synced"),
|
|
mode: "test",
|
|
wantDetail: "Synced, test", wantState: "met",
|
|
wantVerdict: "Purchasable", wantLabel: "Sync to Stripe",
|
|
},
|
|
{
|
|
name: "recorded in test under a live key is unmet",
|
|
pr: stampedMapping(&test, true, "synced"),
|
|
mode: "live",
|
|
wantDetail: "Synced in test, key is live", wantState: "unmet",
|
|
wantVerdict: "Incomplete; missing: payment processing",
|
|
wantSyncable: true, wantLabel: "Create in live",
|
|
},
|
|
{
|
|
name: "recorded in live under a test key is unmet",
|
|
pr: stampedMapping(&live, true, "synced"),
|
|
mode: "test",
|
|
wantDetail: "Synced in live, key is test", wantState: "unmet",
|
|
wantVerdict: "Incomplete; missing: payment processing",
|
|
wantSyncable: true, wantLabel: "Create in test",
|
|
},
|
|
{
|
|
name: "stale names the key's environment, not the mapping's",
|
|
pr: stampedMapping(&live, true, "stale"),
|
|
mode: "live",
|
|
wantDetail: "Not found in live mode", wantState: "unmet",
|
|
wantVerdict: "Incomplete; missing: payment processing",
|
|
wantSyncable: true, wantLabel: "Create in live",
|
|
},
|
|
{
|
|
name: "stale under a test key",
|
|
pr: stampedMapping(&test, true, "stale"),
|
|
mode: "test",
|
|
wantDetail: "Not found in test mode", wantState: "unmet",
|
|
wantVerdict: "Incomplete; missing: payment processing",
|
|
wantSyncable: true, wantLabel: "Create in test",
|
|
},
|
|
{
|
|
name: "an unclassified key compares nothing",
|
|
pr: stampedMapping(&test, true, "stale"),
|
|
mode: "",
|
|
wantDetail: "Synced", wantState: "met",
|
|
wantVerdict: "Purchasable", wantLabel: "Sync to Stripe",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
vm := buildVMRules(pub, shape, tc.pr, 1, tc.mode)
|
|
if got := rowDetail(vm, "Payment processing"); got != tc.wantDetail {
|
|
t.Errorf("Payment processing detail = %q, want %q", got, tc.wantDetail)
|
|
}
|
|
if got := rowState(vm, "Payment processing"); got != tc.wantState {
|
|
t.Errorf("Payment processing state = %q, want %q", got, tc.wantState)
|
|
}
|
|
if vm.Verdict != tc.wantVerdict {
|
|
t.Errorf("Verdict = %q, want %q", vm.Verdict, tc.wantVerdict)
|
|
}
|
|
if vm.Purchasable != (tc.wantState == "met") {
|
|
t.Errorf("Purchasable = %v for a %q row", vm.Purchasable, tc.wantState)
|
|
}
|
|
if vm.CanSyncStripe != tc.wantSyncable {
|
|
t.Errorf("CanSyncStripe = %v, want %v", vm.CanSyncStripe, tc.wantSyncable)
|
|
}
|
|
if vm.SyncLabel != tc.wantLabel {
|
|
t.Errorf("SyncLabel = %q, want %q", vm.SyncLabel, tc.wantLabel)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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 := buildVM(base, shapeFor(true, "recurring"), tc.pr, false, "", nil)
|
|
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)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ruleOn builds an active entitlement-set rule referencing resourceKey, the
|
|
// only field resolveProviderReadinessRows reads.
|
|
func ruleOn(resourceKey string) entitlements.EntitlementSetRule {
|
|
return entitlements.EntitlementSetRule{
|
|
IsActive: true,
|
|
ResourceKey: sql.NullString{String: resourceKey, Valid: true},
|
|
}
|
|
}
|
|
|
|
// resourceKeyOn attributes resourceKey to provider ("" leaves it
|
|
// platform-owned/pooled, per core.resource_keys' NULL-provider convention).
|
|
func resourceKeyOn(resourceKey, provider string) entitlements.ResourceKey {
|
|
rk := entitlements.ResourceKey{ResourceKey: resourceKey}
|
|
if provider != "" {
|
|
rk.Provider = sql.NullString{String: provider, Valid: true}
|
|
}
|
|
return rk
|
|
}
|
|
|
|
// TestResolveProviderReadinessRows covers product-management "Readiness
|
|
// includes the delivering provider's configuration" (ACC-3): one row per
|
|
// distinct provider an entitlement set's rules reference, deduped, skipping
|
|
// platform-owned (no-provider) keys, unresolved providers linking to their
|
|
// settings page.
|
|
func TestResolveProviderReadinessRows(t *testing.T) {
|
|
viper.Reset()
|
|
defer viper.Reset()
|
|
|
|
keys := map[string]entitlements.ResourceKey{
|
|
"fedwiki_sites": resourceKeyOn("fedwiki_sites", "fedwiki"),
|
|
"discourse_forum": resourceKeyOn("discourse_forum", "discourse"),
|
|
"pooled_seats": resourceKeyOn("pooled_seats", ""), // platform-owned, no provider
|
|
}
|
|
configs := []IntegrationConfigInfo{
|
|
{Key: "fedwiki", DisplayName: "FedWiki"}, // no declared config keys: vacuously configured
|
|
}
|
|
|
|
t.Run("no rules referencing a provider-owned key yields no rows", func(t *testing.T) {
|
|
rows := resolveProviderReadinessRows([]entitlements.EntitlementSetRule{ruleOn("pooled_seats")}, keys, configs)
|
|
if len(rows) != 0 {
|
|
t.Errorf("rows = %+v, want none for a platform-owned key", rows)
|
|
}
|
|
})
|
|
|
|
t.Run("a configured provider earns a met row", func(t *testing.T) {
|
|
rows := resolveProviderReadinessRows([]entitlements.EntitlementSetRule{ruleOn("fedwiki_sites")}, keys, configs)
|
|
if len(rows) != 1 {
|
|
t.Fatalf("rows = %+v, want exactly 1", rows)
|
|
}
|
|
if rows[0].Label != "FedWiki" || rows[0].State != "met" || rows[0].Href != "" {
|
|
t.Errorf("row = %+v, want Label=FedWiki State=met Href=\"\"", rows[0])
|
|
}
|
|
})
|
|
|
|
// unresolvedConfigs declares fedwiki with one unresolved required key —
|
|
// registered (unlike a provider absent from configs, which
|
|
// configurationReadiness treats as vacuously configured: "nothing an
|
|
// operator could set") but not configured.
|
|
unresolvedConfigs := []IntegrationConfigInfo{
|
|
{Key: "fedwiki", DisplayName: "FedWiki", Keys: []config.ConfigKey{
|
|
{Name: "fedwiki-farm-api-url", RequiredGroup: "FedWiki"},
|
|
}},
|
|
}
|
|
|
|
t.Run("a provider whose required keys are unresolved earns an unmet row naming and linking it", func(t *testing.T) {
|
|
viper.Reset()
|
|
rows := resolveProviderReadinessRows([]entitlements.EntitlementSetRule{ruleOn("fedwiki_sites")}, keys, unresolvedConfigs)
|
|
if len(rows) != 1 {
|
|
t.Fatalf("rows = %+v, want exactly 1", rows)
|
|
}
|
|
r := rows[0]
|
|
if r.Label != "FedWiki" {
|
|
t.Errorf("Label = %q, want the registered display name", r.Label)
|
|
}
|
|
if r.State != "unmet" {
|
|
t.Errorf("State = %q, want unmet", r.State)
|
|
}
|
|
if r.Href != "/operator/integrations/fedwiki/settings" {
|
|
t.Errorf("Href = %q, want the provider's settings page", r.Href)
|
|
}
|
|
if !strings.Contains(r.Detail, "not configured") || !strings.Contains(r.Detail, "missing") || !strings.Contains(r.Detail, "fedwiki-farm-api-url") {
|
|
t.Errorf("Detail = %q, want it to say not configured and name the missing key", r.Detail)
|
|
}
|
|
})
|
|
|
|
t.Run("two rules on the same provider dedupe to one row", func(t *testing.T) {
|
|
rows := resolveProviderReadinessRows([]entitlements.EntitlementSetRule{ruleOn("fedwiki_sites"), ruleOn("fedwiki_sites")}, keys, configs)
|
|
if len(rows) != 1 {
|
|
t.Errorf("rows = %+v, want exactly 1 (deduped)", rows)
|
|
}
|
|
})
|
|
|
|
t.Run("a rule referencing an unknown resource key is skipped", func(t *testing.T) {
|
|
rows := resolveProviderReadinessRows([]entitlements.EntitlementSetRule{ruleOn("no-such-key")}, keys, configs)
|
|
if len(rows) != 0 {
|
|
t.Errorf("rows = %+v, want none for an unresolvable resource key", rows)
|
|
}
|
|
})
|
|
|
|
t.Run("an unmet provider row blocks the verdict", func(t *testing.T) {
|
|
viper.Reset()
|
|
pr := PriceReadiness{HasActivePrice: true, PriceID: "p1", StripeMapped: true}
|
|
vm := buildVM(product("published", true, true), shapeFor(true, "recurring"), pr, false, "",
|
|
resolveProviderReadinessRows([]entitlements.EntitlementSetRule{ruleOn("fedwiki_sites")}, keys, unresolvedConfigs))
|
|
if vm.Purchasable {
|
|
t.Errorf("Purchasable = true, want false with an unconfigured delivering provider")
|
|
}
|
|
found := false
|
|
for _, m := range vm.Missing {
|
|
if m == "FedWiki" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("Missing = %v, want it to include the unconfigured provider", vm.Missing)
|
|
}
|
|
})
|
|
}
|