Drive the catalog and the switch gate through signed-in requests on the kit

TestGetPlansLadderAware and TestSwitchRefusesADraftTierThroughTheHandler
build their products, ladders, prices, mappings and organization type
through world, sign in through authtest, and select with htmltest: ladder
sections by their heading, their order through InOrder, the badge by its
text on a card, disabled controls through the shared part's markup, refusals
from the page's error banner. Absences stay raw over the whole body. world
gains Draft(p), the one statement that returns a product to the draft
lifecycle, which no console act does. The gocognit rules for the two tests
leave .golangci.yml.

No output changes; production code is the same on both sides. Shown by:

- Names and results of internal/server and internal/testkit/...: 1173 names,
  identical results (all pass), taken at the parent and at this commit on a
  reset database.
- Assertion maps, one row per old assertion. Same except these wider rows:
  the disabled reason is tied to each disabled control in the two ladders
  (the old test looked for the words anywhere); the feature line is read on
  every card of the two ladders (anywhere once); the support section lists
  the tiers by card title (a substring of the section); refusals are read
  from the error banner, with the apostrophe parsed (the old preview check
  spelled the escape). The ladder sections are the elements, not the text
  before the first heading; the markup between them states no fact (a
  general weakening, named here once). No row is NARROWER.
- 13 breaks, each caught by the old and the new test. Dropped, each because
  the old test misses it: "an unmapped price offers no enabled Checkout"
  (the fixture has no price, so the price rule answers before the mapping is
  read, and keeps none, for equivalence; the switch-gate fixture of the time
  rolled back. The new test catches it only after another test has committed
  a priced ladder, as the switch-gate test's fixture now does, so its answer
  depends on run order), and "an organization without a subscription gets no
  switch confirmation" (the target gate refuses the same organization before
  the preview's own subscription check is reached). Unprobed, with the
  reason: the support section's one badge (in this fixture the pool is
  current on both ladders, so no single edit changes the support count
  without changing the total), and the absence of "Confirm switch" (two
  checks refuse first). That a refused preview names no tier is probed by
  its own break.
- Fixture equivalence: both fixtures ran in a rolled-back transaction and
  now commit. The parent dump ran inside each test just before the rollback,
  the new one after the test alone on a reset database; seed rows
  subtracted, ids, timestamps and suffixes normalized. Differences, none
  read by an assertion: names and emails carry the world's suffix and
  persons are unverified; the three catalog products have an entitlement set
  of their own, each active and each holding the same limit rule, where the
  old products shared one inactive set with one rule; the organization type
  is the world's own and is named by the suffix; the switch gate's Hidden,
  Open and Elsewhere products each have an active set of their own with no
  rule, where they shared one inactive set; the catalog organization's pool
  is named Default and carries its key; the transitions say "test fixture";
  the stripe price ids of the mapped prices are derived from the price id;
  the draft product is made a draft by Draft.
This commit is contained in:
2026-10-09 04:08:19 -05:00
parent bd225ca085
commit f6b81987be
4 changed files with 141 additions and 302 deletions
-6
View File
@@ -36,12 +36,6 @@ linters:
# Temporary: one rule per finding, pinned, removed by the step or issue
# named in its comment. Grouped by owner in step order.
# #166 step 3
- path: '^internal/server/member_catalog_test\.go$'
linters: [gocognit]
text: 'cognitive complexity 33 of func `TestGetPlansLadderAware`' # #166 step 3
- path: '^internal/server/member_switch_gate_db_test\.go$'
linters: [gocognit]
text: 'cognitive complexity 33 of func `TestSwitchRefusesADraftTierThroughTheHandler`' # #166 step 3
- path: '^internal/server/operator_product_readiness_render_test\.go$'
linters: [gocognit]
text: 'cognitive complexity 69 of func `TestProductEditRendersPurchasabilityPanel`' # #166 step 3
+81 -177
View File
@@ -4,22 +4,20 @@
package server_test
import (
"context"
"database/sql"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"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/server"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/authtest"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
"github.com/alexedwards/scs/v2"
"github.com/google/uuid"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/htmltest"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/world"
)
// TestGetPlansLadderAware exercises the ladder-grouped catalog end to end against
@@ -28,232 +26,138 @@ import (
// product that is a tier in two ladders renders under each with independent
// state. Covers tasks 7.1–7.5.
func TestGetPlansLadderAware(t *testing.T) {
database := dbtest.Open(t)
ctx := context.Background()
tx, err := entitlements.BeginRuleChange(ctx, database)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
bq := billing.New(tx)
eq := entitlements.New(tx)
iq := identity.New(tx)
oq := organization.New(tx)
sfx := uuid.New().String()[:8]
// Shared entitlement set so the rank-0 default product can back a grant
// (ReapplyDefaultsForPool requires the default tier to have one).
es, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
Name: "catalog-set-" + sfx,
})
if err != nil {
t.Fatalf("create entitlement set: %v", err)
}
esID := uuid.NullUUID{UUID: uuid.MustParse(es.SetID), Valid: true}
db := dbtest.Open(t)
w := world.New(t, db)
public, standard, premium := w.Product("Public"), w.Product("Standard"), w.Product("Premium")
// A numeric rule on a seeded resource key: tier cards must render its
// display name ("Wiki Sites", 00002_seed_resource_keys.sql), never the
// raw key (member-product-discovery delta, hardening-polish).
if _, err := entitlements.CommitRuleChangeTx(ctx, eq, entitlements.CommitRuleChangeInput{
SetID: es.SetID,
ChangeKind: entitlements.ChangeKindRuleAdded,
ActorType: entitlements.ActorTypeSystem,
Rule: entitlements.RuleFields{
for _, p := range []world.Product{public, standard, premium} {
w.Rule(p, entitlements.RuleFields{
RuleType: "limit",
ResourceKey: sql.NullString{String: "fedwiki_sites", Valid: true},
ResourceValue: sql.NullInt64{Int64: 16, Valid: true},
StackingPolicy: sql.NullString{String: "additive", Valid: true},
},
}); err != nil {
t.Fatalf("create entitlement rule: %v", err)
}
// Three public plan products.
mkProduct := func(name string) billing.Product {
p, err := bq.CreateProduct(ctx, billing.CreateProductParams{
Name: name + "-" + sfx,
IsActive: true,
IsPublic: true,
EntitlementSetID: esID,
LifecycleStatus: "published",
})
if err != nil {
t.Fatalf("create product %s: %v", name, err)
}
return p
}
public := mkProduct("Public")
standard := mkProduct("Standard")
premium := mkProduct("Premium")
mkLadder := func(key, name string, sortOrder int32) billing.PlanLadder {
l, err := bq.CreatePlanLadder(ctx, billing.CreatePlanLadderParams{
Name: name,
IsActive: true,
})
if err != nil {
t.Fatalf("create ladder %s: %v", key, err)
}
if _, err := tx.ExecContext(ctx,
`UPDATE core.plan_ladders SET sort_order = $1 WHERE plan_ladder_id = $2`,
sortOrder, l.PlanLadderID); err != nil {
t.Fatalf("set sort_order on %s: %v", key, err)
}
return l
}
// hosting: Public(0) -> Standard(1) -> Premium(2). support: Public(0) ->
// Standard(1). Public and Standard are tiers in BOTH ladders (the M:N case).
hosting := w.Ladder("Hosting Services", public, standard, premium)
support := w.Ladder("Support Services", public, standard)
// Support sorts before Hosting (5 < 10) despite the alphabetical order.
hosting := mkLadder("hosting", "Hosting Services", 10)
support := mkLadder("support", "Support Services", 5)
w.SortOrder(hosting, 10)
w.SortOrder(support, 5)
// CreatePlanLadderTier appends at MAX+1; fixtures that need an explicit
// rank place the tier with UpdateTierRank afterwards.
mkTier := func(l billing.PlanLadder, p billing.Product, rank int32) {
if _, err := bq.CreatePlanLadderTier(ctx, billing.CreatePlanLadderTierParams{
PlanLadderID: l.PlanLadderID,
ProductID: p.ProductID,
}); err != nil {
t.Fatalf("create tier: %v", err)
}
if _, err := bq.UpdateTierRank(ctx, billing.UpdateTierRankParams{
PlanLadderID: l.PlanLadderID, ProductID: p.ProductID, Rank: rank,
}); err != nil {
t.Fatalf("set tier rank: %v", err)
}
}
// hosting: Public(0) -> Standard(1) -> Premium(2)
mkTier(hosting, public, 0)
mkTier(hosting, standard, 1)
mkTier(hosting, premium, 2)
// support: Public(0) -> Standard(1). Public and Standard are tiers in BOTH
// ladders (the M:N case).
mkTier(support, public, 0)
mkTier(support, standard, 1)
// Org type defaults to the hosting ladder, so ReapplyDefaults attaches the
// pool to hosting's rank-0 rung (Public).
orgType := "ca-" + uuid.New().String()[:4]
if _, err := tx.ExecContext(ctx,
`INSERT INTO core.org_types (org_type, display_name, is_active, default_plan_ladder_id)
VALUES ($1, $2, true, $3)`,
orgType, "Catalog Type", hosting.PlanLadderID); err != nil {
t.Fatalf("create org type: %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: "Catalog Member",
PrimaryEmail: "cat-" + sfx + "@example.com",
PrimaryEmailVerified: true,
})
if err != nil {
t.Fatalf("create person: %v", err)
}
org, err := oq.CreateOrganization(ctx, organization.CreateOrganizationParams{
Name: "Catalog Org",
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",
PoolType: "default",
IsAutoManaged: true,
})
if err != nil {
t.Fatalf("create pool: %v", err)
}
if _, err := entitlements.ReapplyDefaultsForPool(ctx, tx, pool.PoolID,
entitlements.Actor{ActorType: "system", Reason: "test"}); err != nil {
t.Fatalf("reapply defaults: %v", err)
}
// Authenticated session for this org.
sm := scs.New()
sctx, err := sm.Load(ctx, "")
if err != nil {
t.Fatalf("load session: %v", err)
}
sm.Put(sctx, "authenticated", true)
sm.Put(sctx, "org_id", org.OrgID)
// The org type defaults to the hosting ladder, so the pool is attached to
// hosting's rank-0 rung (Public).
orgType := w.OrgType("catalog")
w.DefaultLadder(orgType, hosting)
member := w.Person("Catalog Member")
org := w.Org("Catalog Org", orgType, member)
w.Floor(org)
sm := authtest.NewSessionManager()
h, err := server.NewMemberProductsHandler(server.MemberProductsConfig{
EntitlementsQ: eq,
BillingQ: bq,
EntitlementsQ: entitlements.New(db),
BillingQ: billing.New(db),
AuthConfig: &auth.Config{SessionManager: sm},
Logger: discardLogger(),
})
if err != nil {
t.Fatalf("new handler: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/partials/member/plans", nil).WithContext(sctx)
rec := httptest.NewRecorder()
h.GetPlans(rec, req)
signIn := member.SignIn()
signIn.OrgID = org.ID
rec := authtest.Get(t, authtest.SignIn(t, sm, signIn), h.GetPlans, "/partials/member/plans")
if rec.Code != 200 {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
page := htmltest.Parse(t, body)
// Both ladder sections render.
hostingIdx := strings.Index(body, "Hosting Services")
supportIdx := strings.Index(body, "Support Services")
if hostingIdx < 0 || supportIdx < 0 {
t.Fatalf("expected both ladder section headers; hosting=%d support=%d", hostingIdx, supportIdx)
}
// 7.5: ladder sections ordered by sort_order (support=5 before hosting=10).
if supportIdx > hostingIdx {
t.Errorf("expected Support Services (sort_order 5) before Hosting Services (sort_order 10)")
}
// Both ladder sections render, and the sections are ordered by sort_order
// (support=5 before hosting=10).
hostingSection := ladderSection(t, page, hosting.Name)
supportSection := ladderSection(t, page, support.Name)
htmltest.InOrder(t, supportSection.One(t, "h3"), hostingSection.One(t, "h3"))
// 7.2 + 7.3: the current rung is marked per (pool, ladder). Public is a
// tier in BOTH ladders, and Doc 41's confer derives the product's full
// shape — ReapplyDefaultsForPool enrolls the pool on hosting AND support
// shape — the default conferral enrolls the pool on hosting AND support
// in one conferral (one provision, one junction row per ladder). "Current"
// is a set — one rung per enrolled ladder — so a badge appears in each
// section, computed from that ladder's own attachment.
if got := strings.Count(body, "Current plan"); got != 2 {
if got := currentBadges(page); got != 2 {
t.Errorf("expected 2 'Current plan' badges (Public holds a rung on both ladders), got %d", got)
}
// 7.3: the support section (rendered first) lists Public and Standard — the
// products it shares with hosting — and marks Public current there too.
supportSection := body[:hostingIdx]
if !strings.Contains(supportSection, "Public-"+sfx) || !strings.Contains(supportSection, "Standard-"+sfx) {
t.Errorf("support section should list the shared Public and Standard tiers")
titles := supportSection.Texts("h4.card-title")
if !slices.Contains(titles, public.Name) || !slices.Contains(titles, standard.Name) {
t.Errorf("support section should list the shared Public and Standard tiers, lists %q", titles)
}
if got := strings.Count(supportSection, "Current plan"); got != 1 {
if got := currentBadges(supportSection); got != 1 {
t.Errorf("support section 'Current plan' badges = %d, want 1 (Public's rung on support)", got)
}
// 7.4: nothing is purchasable (no Stripe mapping, Database nil), so no move
// routes to checkout; builtPath moves render the purchase-unavailable reason.
if got := strings.Count(body, `hx-post="/billing/checkout"`); got != 0 {
if got := page.Count(`[hx-post="/billing/checkout"]`); got != 0 {
t.Errorf("expected 0 enabled checkout controls without a Stripe mapping, got %d", got)
}
if !strings.Contains(body, "Not available for purchase yet") {
disabled := 0
for _, section := range []*htmltest.Node{hostingSection, supportSection} {
for _, button := range section.All("button[disabled]") {
disabled++
id, _ := button.Attr("aria-describedby")
if got := section.One(t, `[id="`+id+`"]`).Text(); got != "Not available for purchase yet" {
t.Errorf("a disabled move states %q, want the purchase-unavailable reason", got)
}
}
}
if disabled == 0 {
t.Errorf("expected the purchase-unavailable reason on the disabled move controls")
}
// Feature lines resolve resource-key display names; the raw key must not
// leak onto the card.
if !strings.Contains(body, "16 FedWiki Sites") {
t.Errorf("expected entitlement feature to render the display name '16 FedWiki Sites'")
for _, section := range []*htmltest.Node{hostingSection, supportSection} {
for _, card := range section.All(".card") {
if !slices.Contains(card.Texts("ul.list-unstyled li"), "· 16 FedWiki Sites") {
t.Errorf("expected entitlement feature to render the display name '16 FedWiki Sites' on %s", card.One(t, "h4.card-title").Text())
}
}
}
if strings.Contains(body, "fedwiki_sites") {
t.Errorf("raw resource key 'fedwiki_sites' leaked into the catalog body")
}
}
// ladderSection returns the group of the ladder named name on the plans page.
func ladderSection(t *testing.T, page *htmltest.Node, name string) *htmltest.Node {
t.Helper()
for _, section := range page.All("div.border-start") {
if section.One(t, "h3").Text() == name {
return section
}
}
t.Fatalf("no ladder section named %q", name)
return nil
}
// currentBadges counts the "Current plan" badges on the cards inside n.
func currentBadges(n *htmltest.Node) int {
count := 0
for _, text := range n.Texts(".card .badge") {
if text == "Current plan" {
count++
}
}
return count
}
// TestMemberEntitlementsPerAxisRendering verifies the entitlement partial lists
// the current tier for every enrolled axis (a set), and shows the off-plan state
// for a pool with no attachment. Covers task 7.6 at the presentation layer; the
+49 -119
View File
@@ -4,10 +4,6 @@
package server_test
import (
"context"
"database/sql"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
@@ -15,13 +11,12 @@ import (
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/identity"
internalstripe "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
"git.coopcloud.tech/wiki-cafe/member-console/internal/server"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/authtest"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
"github.com/alexedwards/scs/v2"
"github.com/google/uuid"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/htmltest"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/world"
)
// TestSwitchRefusesADraftTierThroughTheHandler drives POST
@@ -32,115 +27,62 @@ import (
// posts for a published tier and is refused one precondition later, by
// SwitchPlan's own subscription check, which proves the gate opened.
func TestSwitchRefusesADraftTierThroughTheHandler(t *testing.T) {
database := dbtest.Open(t)
ctx := context.Background()
db := dbtest.Open(t)
w := world.New(t, db)
tx, err := entitlements.BeginMaterializing(ctx, database)
if err != nil {
t.Fatal(err)
// A tier is a fully purchasable product: active, public, priced and
// Stripe-mapped.
tier := func(name string) (world.Product, world.Price) {
p := w.Product(name)
price := w.Price(p, 1000)
w.MapPrice(price)
return p, price
}
defer tx.Rollback()
hidden, draft := tier("Hidden Tier")
w.Draft(hidden)
open, published := tier("Open Tier")
ladder := w.Ladder("Switch Gate Ladder", hidden, open)
bq := billing.New(tx)
eq := entitlements.New(tx)
iq := identity.New(tx)
oq := organization.New(tx)
sq := internalstripe.New(tx)
sfx := uuid.New().String()[:8]
es, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{Name: "switch-gate-set-" + sfx})
if err != nil {
t.Fatalf("create entitlement set: %v", err)
}
esID := uuid.NullUUID{UUID: uuid.MustParse(es.SetID), Valid: true}
ladder, err := bq.CreatePlanLadder(ctx, billing.CreatePlanLadderParams{Name: "Switch Gate Ladder " + sfx, IsActive: true})
if err != nil {
t.Fatalf("create ladder: %v", err)
}
tier := func(name, lifecycle string) billing.Price {
t.Helper()
product, err := bq.CreateProduct(ctx, billing.CreateProductParams{
Name: name + " " + sfx, IsActive: true, IsPublic: true, EntitlementSetID: esID, LifecycleStatus: lifecycle,
})
if err != nil {
t.Fatalf("create %s: %v", name, err)
}
price, err := bq.CreatePrice(ctx, billing.CreatePriceParams{
ProductID: product.ProductID, Currency: "usd", UnitAmount: 1000,
RecurringInterval: sql.NullString{String: "month", Valid: true},
})
if err != nil {
t.Fatalf("create %s price: %v", name, err)
}
if _, err := sq.UpsertPriceMapping(ctx, internalstripe.UpsertPriceMappingParams{
PriceID: price.PriceID, StripePriceID: sql.NullString{String: "price_stripe_" + sfx + "_" + lifecycle, Valid: true}, SyncStatus: "synced",
}); err != nil {
t.Fatalf("%s price mapping: %v", name, err)
}
if _, err := bq.CreatePlanLadderTier(ctx, billing.CreatePlanLadderTierParams{PlanLadderID: ladder.PlanLadderID, ProductID: product.ProductID}); err != nil {
t.Fatalf("create %s tier: %v", name, err)
}
return price
}
draft := tier("Hidden Tier", "draft")
published := tier("Open Tier", "published")
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: "Switch Gate Member",
PrimaryEmail: "switch-gate-" + sfx + "@example.com", PrimaryEmailVerified: true,
person := w.Person("Switch Gate Member")
org, err := organization.New(db).CreateOrganization(w.Ctx, organization.CreateOrganizationParams{
Name: "Switch Gate Org", OrgType: "personal", OwnerPersonID: person.ID,
})
if err != nil {
t.Fatalf("create person: %v", err)
}
org, err := oq.CreateOrganization(ctx, organization.CreateOrganizationParams{Name: "Switch Gate Org", OrgType: "personal", OwnerPersonID: person.PersonID})
if err != nil {
t.Fatalf("create org: %v", err)
}
sm := scs.New()
sctx, err := sm.Load(ctx, "")
if err != nil {
t.Fatalf("load session: %v", err)
}
sm.Put(sctx, "authenticated", true)
sm.Put(sctx, "org_id", org.OrgID)
sm := authtest.NewSessionManager()
h, err := server.NewMemberProductsHandler(server.MemberProductsConfig{
EntitlementsQ: eq, BillingQ: bq, AuthConfig: &auth.Config{SessionManager: sm}, Logger: discardLogger(), Database: database,
EntitlementsQ: entitlements.New(db), BillingQ: billing.New(db), AuthConfig: &auth.Config{SessionManager: sm},
Logger: discardLogger(), Database: db,
})
if err != nil {
t.Fatalf("new handler: %v", err)
}
signIn := person.SignIn()
signIn.OrgID = org.OrgID
ctx := authtest.SignIn(t, sm, signIn)
post := func(priceID string) string {
t.Helper()
form := url.Values{"ladder_id": {ladder.PlanLadderID}, "price_id": {priceID}}
req := httptest.NewRequest(http.MethodPost, "/partials/member/plans/switch", strings.NewReader(form.Encode())).WithContext(sctx)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.PostSwitch(rec, req)
if rec.Code != http.StatusOK {
form := url.Values{"ladder_id": {ladder.ID}, "price_id": {priceID}}
rec := authtest.Post(t, ctx, h.PostSwitch, "/partials/member/plans/switch", form)
if rec.Code != 200 {
t.Fatalf("switch to %s: status %d: %s", priceID, rec.Code, rec.Body.String())
}
return rec.Body.String()
}
const unavailable = "That plan is no longer available."
if body := post(draft.PriceID); !strings.Contains(body, unavailable) {
const noSubscription = "There's no active subscription to change on this plan."
if body := post(draft.ID); !refusedWith(t, body, unavailable) {
t.Errorf("a switch onto a draft tier must be refused as unavailable; body:\n%s", body)
}
body := post(published.PriceID)
body := post(published.ID)
if strings.Contains(body, unavailable) {
t.Errorf("a published tier must pass the gate; body:\n%s", body)
}
if !strings.Contains(body, "no active subscription to change on this plan.") {
if !refusedWith(t, body, noSubscription) {
t.Errorf("a published tier must be refused by SwitchPlan's subscription check, one step past the gate; body:\n%s", body)
}
@@ -148,51 +90,39 @@ func TestSwitchRefusesADraftTierThroughTheHandler(t *testing.T) {
// security-audit-remediation-3 design D6): a draft tier, or a tier of
// another ladder, is refused with the switch's own answer, and the
// target's name never reaches the page. The published on-ladder control
// passes the gate and renders its name in the banner.
otherLadder, err := bq.CreatePlanLadder(ctx, billing.CreatePlanLadderParams{Name: "Other Ladder " + sfx, IsActive: true})
if err != nil {
t.Fatalf("create other ladder: %v", err)
}
offLadderProduct, err := bq.CreateProduct(ctx, billing.CreateProductParams{
Name: "Elsewhere Tier " + sfx, IsActive: true, IsPublic: true, EntitlementSetID: esID, LifecycleStatus: "published",
})
if err != nil {
t.Fatalf("create off-ladder product: %v", err)
}
offLadder, err := bq.CreatePrice(ctx, billing.CreatePriceParams{
ProductID: offLadderProduct.ProductID, Currency: "usd", UnitAmount: 500,
RecurringInterval: sql.NullString{String: "month", Valid: true},
})
if err != nil {
t.Fatalf("create off-ladder price: %v", err)
}
if _, err := bq.CreatePlanLadderTier(ctx, billing.CreatePlanLadderTierParams{PlanLadderID: otherLadder.PlanLadderID, ProductID: offLadderProduct.ProductID}); err != nil {
t.Fatalf("create off-ladder tier: %v", err)
}
// passes the gate and meets the switch's own refusal.
elsewhere := w.Product("Elsewhere Tier")
offLadder := w.Price(elsewhere, 500)
w.Ladder("Other Ladder", elsewhere)
preview := func(priceID string) string {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/partials/member/plans/switch/preview?ladder_id="+url.QueryEscape(ladder.PlanLadderID)+"&price_id="+url.QueryEscape(priceID), nil).WithContext(sctx)
rec := httptest.NewRecorder()
h.GetSwitchPreview(rec, req)
if rec.Code != http.StatusOK {
rec := authtest.Get(t, ctx, h.GetSwitchPreview,
"/partials/member/plans/switch/preview?ladder_id="+url.QueryEscape(ladder.ID)+"&price_id="+url.QueryEscape(priceID))
if rec.Code != 200 {
t.Fatalf("preview of %s: status %d: %s", priceID, rec.Code, rec.Body.String())
}
return rec.Body.String()
}
if body := preview(draft.PriceID); !strings.Contains(body, unavailable) || strings.Contains(body, "Hidden Tier") {
if body := preview(draft.ID); !refusedWith(t, body, unavailable) || strings.Contains(body, "Hidden Tier") {
t.Errorf("a preview of a draft tier must be refused as unavailable and must not name the tier; body:\n%s", body)
}
if body := preview(offLadder.PriceID); !strings.Contains(body, unavailable) || strings.Contains(body, "Elsewhere Tier") {
if body := preview(offLadder.ID); !refusedWith(t, body, unavailable) || strings.Contains(body, "Elsewhere Tier") {
t.Errorf("a preview of a tier from another ladder must be refused as unavailable and must not name the tier; body:\n%s", body)
}
// The published tier passes the target gates and meets the switch's
// own refusal: this org holds no subscription on the ladder, and the
// preview refuses what the switch would (stripe-subscription-management,
// "Live proration preview before a switch") rather than quote a charge.
if body := preview(published.PriceID); strings.Contains(body, unavailable) ||
!strings.Contains(body, "There&#39;s no active subscription to change on this plan.") ||
if body := preview(published.ID); strings.Contains(body, unavailable) ||
!refusedWith(t, body, noSubscription) ||
strings.Contains(body, "Confirm switch") {
t.Errorf("a preview of a published tier for an org without a subscription must pass the target gates and be refused as the switch refuses; body:\n%s", body)
}
}
// refusedWith reports whether the page's error banner carries message.
func refusedWith(t *testing.T, body, message string) bool {
t.Helper()
return strings.Contains(htmltest.Parse(t, body).One(t, ".alert-danger").Text(), message)
}
+11
View File
@@ -171,6 +171,17 @@ func (w *World) Retire(p Product) {
}
}
// Draft returns p to the draft lifecycle. The create form writes published and
// no act moves a product back, so the builder writes the column in one
// statement.
func (w *World) Draft(p Product) {
w.TB.Helper()
if _, err := w.DB.ExecContext(w.Ctx,
`UPDATE core.products SET lifecycle_status = 'draft' WHERE product_id = $1`, p.ID); err != nil {
w.TB.Fatalf("draft %s: %v", p.Name, err)
}
}
// SubOpts shapes a subscription: its period end (12:00 UTC, 29 days out, by
// default; NULL when NullPeriodEnd), and a commitment ending at CommitmentEnd
// under the early-termination Policy ("block", "allow" or "fee"), which the