Files
commerce/api/billing/plans_prodpath_test.go

204 lines
7.5 KiB
Go

package billing
import (
"testing"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/models/plan"
"github.com/hanzoai/commerce/models/types/currency"
"github.com/hanzoai/commerce/util/nscontext"
"github.com/hanzoai/commerce/util/test/ae"
)
func minSeatsOf(p *staticPlan) int {
if p.Limits == nil || p.Limits.MinSeats == nil {
return -1
}
return *p.Limits.MinSeats
}
// TestProdPath_SeedReadbackAllFields is the prod-path guard the in-memory
// TestSeededPricesEqualEmbed missed: it seeds via the REAL path into a real
// datastore, then reads EVERY plan back through the SAME projections prod serves
// (planAuthorityRows→staticPlanFromModel for GET /v1/billing/plans, and
// resolveSubscriptionPlan for the charge) and asserts Price/PriceAnnual/Category/
// minSeats/perSeat/limits == embed for ALL 20. It FAILS on the Metadata_
// round-trip bug (envelope/minSeats dropped) and passes once persisted.
func TestProdPath_SeedReadbackAllFields(t *testing.T) {
c := ae.NewContext()
defer c.Close()
created, _, err := SeedPlans(c)
if err != nil {
t.Fatalf("seed: %v", err)
}
if created != len(catalog) {
t.Fatalf("seed created %d, want %d", created, len(catalog))
}
rows, ok := planAuthorityRows(c)
if !ok {
t.Fatal("authority empty after seed")
}
list := map[string]staticPlan{}
for _, r := range rows {
list[r.Slug] = r
}
orgDB := datastore.New(nscontext.WithNamespace(c, "acme"))
for i := range catalog {
e := catalog[i]
g, present := list[e.Slug]
if e.Status == plan.StatusDraft {
if present {
t.Errorf("%s: a draft is on sale in ListPlans", e.Slug)
}
continue
}
if !present {
t.Errorf("%s: missing from ListPlans", e.Slug)
continue
}
// Display projection (GET /v1/billing/plans).
if g.Price != e.Price || !sameCents(g.PriceAnnual, e.PriceAnnual) {
t.Errorf("%s: LIST price=%d/%s want %d/%s", e.Slug, g.Price, centsText(g.PriceAnnual), e.Price, centsText(e.PriceAnnual))
}
if g.Category != e.Category {
t.Errorf("%s: LIST category=%q want %q", e.Slug, g.Category, e.Category)
}
if g.PerSeat != e.PerSeat {
t.Errorf("%s: LIST perSeat=%v want %v", e.Slug, g.PerSeat, e.PerSeat)
}
if minSeatsOf(&g) != minSeatsOf(&e) {
t.Errorf("%s: LIST minSeats=%d want %d (envelope must round-trip)", e.Slug, minSeatsOf(&g), minSeatsOf(&e))
}
if (g.Limits == nil) != (e.Limits == nil) {
t.Errorf("%s: LIST limits nil=%v want nil=%v", e.Slug, g.Limits == nil, e.Limits == nil)
}
// Charge path.
rp, rerr := resolveSubscriptionPlan(orgDB, e.Slug)
if rerr != nil {
t.Errorf("%s: resolve err %v", e.Slug, rerr)
continue
}
if int64(rp.Price) != e.Price || int64(rp.PriceAnnual) != e.annual() {
t.Errorf("%s: RESOLVE price=%d/%d want %d/%d (CHARGE)", e.Slug, rp.Price, rp.PriceAnnual, e.Price, e.annual())
}
}
}
// TestProdPath_CorrectsPreexistingBadRows proves fix #3: the corrective seed
// repairs PRE-EXISTING unmanaged partial rows on the (persistent) store — not
// just an empty DB. It plants the exact prod corruption (world-pro Price=0 like
// the bundle write; a row with no Category; team with no minSeats), then runs
// SeedPlans and asserts the charge + display are corrected to the embed.
func TestProdPath_CorrectsPreexistingBadRows(t *testing.T) {
c := ae.NewContext()
defer c.Close()
adb := plan.AuthorityDB(c)
plant := func(slug string, price int64, category string) {
p := plan.New(adb)
p.Slug = slug
p.Price = currency.Cents(price)
p.Category = category
p.Managed = false // written by a subscription-flow path, not the seed
if err := p.Create(); err != nil {
t.Fatalf("plant %s: %v", slug, err)
}
}
// The prod corruption, re-planted on CURRENT slugs: a subscription-flow path
// wrote a $0 or category-less row where the catalog has a real one.
plant("dev", 0, "") // wrote $0 (the under-charge)
plant("max-5x", 0, "") // wrote $0
plant("team", 2500, "") // a stale $25 seat price, no Category/PriceAnnual/envelope
plant("free", 900, "") // wrote a charge over a rung that is genuinely free
if _, corrected, err := SeedPlans(c); err != nil {
t.Fatalf("seed: %v", err)
} else if corrected < 4 {
t.Fatalf("corrected=%d, want >=4 (the planted bad rows)", corrected)
}
orgDB := datastore.New(nscontext.WithNamespace(c, "acme"))
// The charge path resolves the CATALOG price — never the $0 under-charge,
// never the stale 2500 planted over team, and never the 900 planted over a
// free rung.
for _, slug := range []string{"free", "dev", "max-5x", "team"} {
want := lookupPlan(slug).Price
rp, err := resolveSubscriptionPlan(orgDB, slug)
if err != nil {
t.Fatalf("resolve %s: %v", slug, err)
}
if int64(rp.Price) != want {
t.Fatalf("RESOLVE %s price=%d, want %d (bad row NOT corrected → under-charge)", slug, rp.Price, want)
}
}
// Display projection is corrected too (category + minSeats restored).
rows, ok := planAuthorityRows(c)
if !ok {
t.Fatal("authority empty")
}
byslug := map[string]staticPlan{}
for _, r := range rows {
byslug[r.Slug] = r
}
if byslug["dev"].Category != "personal" {
t.Fatalf("dev category=%q, want personal (not corrected)", byslug["dev"].Category)
}
if ms := minSeatsOf(ptr(byslug["team"])); ms != 2 {
t.Fatalf("team minSeats=%d, want 2 (not corrected)", ms)
}
if want := lookupPlan("team").PriceAnnual; !sameCents(byslug["team"].PriceAnnual, want) {
t.Fatalf("team priceAnnual=%s, want %s (not corrected)", centsText(byslug["team"].PriceAnnual), centsText(want))
}
}
// TestProdPath_BundleSubscriptionNoAuthorityCorruption proves fix B: subscribing
// to a bundle PARENT must NOT write a partial $0 child row into the authority.
// Before the fix, the bundle expansion created the child at Price=0, so a DIRECT
// subscription to that child then resolved $0 and under-charged.
//
// The published ladder carries no bundles, so the parent/child pair is CREATED
// here rather than borrowed from the catalog. The bundle-expansion code is still
// live and still reachable by any plan an admin gives a `bundles` list, so the
// coverage belongs to the mechanism — not to whatever the catalog happens to sell.
func TestProdPath_BundleSubscriptionNoAuthorityCorruption(t *testing.T) {
c := ae.NewContext()
defer c.Close()
org := moneyOrg("acme")
adb := plan.AuthorityDB(c)
child := plan.New(adb)
child.Slug, child.Name, child.Category, child.Price, child.Managed = "bundle-child", "Bundle Child", "personal", 2900, true
if err := child.Create(); err != nil {
t.Fatalf("create child: %v", err)
}
parent := plan.New(adb)
parent.Slug, parent.Name, parent.Category, parent.Price, parent.Managed = "bundle-parent", "Bundle Parent", "personal", 4900, true
parent.Bundles = []string{"bundle-child"}
if err := parent.Create(); err != nil {
t.Fatalf("create parent: %v", err)
}
// A mint principal subscribes to the PARENT (a paid tier), which expands the
// bundle and touches the child row.
w := invokeSub(org, c, c1MintPrincipal, CreateBillingSubscription, `{"userId":"acme/owner","planId":"bundle-parent"}`)
if w.StatusCode != 201 {
t.Fatalf("create parent sub: status=%d body=%s", w.StatusCode, bodyOf(w))
}
// A direct child resolution must still be its real price, never a $0 partial
// the bundle expansion overwrote it with.
orgDB := datastore.New(nscontext.WithNamespace(c, "acme"))
rp, err := resolveSubscriptionPlan(orgDB, "bundle-child")
if err != nil {
t.Fatalf("resolve bundle-child: %v", err)
}
if int64(rp.Price) != 2900 {
t.Fatalf("bundle-child resolves %d after a parent bundle sub, want 2900 (bundle corrupted the authority)", rp.Price)
}
}
func ptr(p staticPlan) *staticPlan { return &p }