Files
member-console/internal/server/product_readiness_test.go
T

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