Files
commerce/api/billing/plans_authority_test.go

202 lines
6.5 KiB
Go

package billing
import (
"testing"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/models/plan"
"github.com/hanzoai/commerce/util/nscontext"
"github.com/hanzoai/commerce/util/test/ae"
)
// TestSeedPlans_SeedsAndIdempotent: first boot seeds all embed plans; a
// second boot is a no-op (count-gated).
func TestSeedPlans_SeedsAndIdempotent(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("first seed created %d, want %d (17 subscription + 3 dns)", created, len(catalog))
}
created2, _, err := SeedPlans(c)
if err != nil {
t.Fatalf("re-seed: %v", err)
}
if created2 != 0 {
t.Fatalf("re-seed created %d, want 0 (idempotent)", created2)
}
}
// TestSeededPricesEqualEmbed: every seeded row's typed money fields equal the
// embed's, so seeding changes NO charge (the whole point — it only makes prices
// editable).
func TestSeededPricesEqualEmbed(t *testing.T) {
c := ae.NewContext()
defer c.Close()
if _, _, err := SeedPlans(c); err != nil {
t.Fatalf("seed: %v", err)
}
adb := plan.AuthorityDB(c)
for _, embed := range catalog {
p := plan.New(adb)
ok, err := p.Query().Filter("Slug=", embed.Slug).Get()
if err != nil || !ok {
t.Fatalf("seeded plan %q missing: ok=%v err=%v", embed.Slug, ok, err)
}
if int64(p.Price) != embed.Price || int64(p.PriceAnnual) != embed.annual() {
t.Errorf("seeded %q price %d/%d != embed %d/%d (seed must not change charge)", embed.Slug, p.Price, p.PriceAnnual, embed.Price, embed.annual())
}
if p.ContactSales != embed.ContactSales {
t.Errorf("seeded %q contactSales %v != embed %v (null-vs-0 preserved)", embed.Slug, p.ContactSales, embed.ContactSales)
}
}
}
// TestEditPrice_ResolveReflects is THE control proof: an admin edit to the plan
// authority's Price is what resolveSubscriptionPlan (→ the internal-ledger charge)
// returns — this is how admin.hanzo.ai governs subscription pricing.
func TestEditPrice_ResolveReflects(t *testing.T) {
c := ae.NewContext()
defer c.Close()
if _, _, err := SeedPlans(c); err != nil {
t.Fatalf("seed: %v", err)
}
// Admin raises "dev" from $19 to $99 in the authority.
adb := plan.AuthorityDB(c)
p := plan.New(adb)
if ok, _ := p.Query().Filter("Slug=", "dev").Get(); !ok {
t.Fatal("dev not seeded")
}
p.Price = 9900
if err := p.Update(); err != nil {
t.Fatalf("edit: %v", err)
}
// resolveSubscriptionPlan — called with ANY org db — reads the NEW price.
orgDB := datastore.New(nscontext.WithNamespace(c, "acme"))
got, err := resolveSubscriptionPlan(orgDB, "dev")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if got.Price != 9900 {
t.Fatalf("resolveSubscriptionPlan(dev).Price = %d, want 9900 (admin edit MUST reflect in the charge)", got.Price)
}
}
// TestResolveSeededEqualsEmbed: the seed itself changes no charge — resolve
// returns the SAME price before (embed fallback) and after (DB row) seeding.
func TestResolveSeededEqualsEmbed(t *testing.T) {
c := ae.NewContext()
defer c.Close()
orgDB := datastore.New(nscontext.WithNamespace(c, "acme"))
embed := lookupPlan("dev")
if embed == nil {
t.Fatal("embed dev missing")
}
// Before seed: authority empty → resolve falls back to the embed VALUE.
before, err := resolveSubscriptionPlan(orgDB, "dev")
if err != nil {
t.Fatalf("resolve pre-seed: %v", err)
}
if int64(before.Price) != embed.Price {
t.Fatalf("pre-seed resolve price %d != embed %d", before.Price, embed.Price)
}
// After seed: resolve reads the DB row — SAME price (seed == embed).
if _, _, err := SeedPlans(c); err != nil {
t.Fatalf("seed: %v", err)
}
after, err := resolveSubscriptionPlan(orgDB, "dev")
if err != nil {
t.Fatalf("resolve post-seed: %v", err)
}
if int64(after.Price) != embed.Price {
t.Fatalf("post-seed resolve price %d != embed %d (seed must not change charge)", after.Price, embed.Price)
}
}
// TestPlanAuthorityRows_LoudFallbackThenAuthority: the read edge signals fallback
// on an empty authority (ListPlans then serves the embed, never blank), and reads
// the DB — with embed-equal content — once seeded.
func TestPlanAuthorityRows_LoudFallbackThenAuthority(t *testing.T) {
c := ae.NewContext()
defer c.Close()
if _, ok := planAuthorityRows(c); ok {
t.Fatal("empty authority must signal fallback (ok=false) so ListPlans serves the embed")
}
if _, _, err := SeedPlans(c); err != nil {
t.Fatalf("seed: %v", err)
}
rows, ok := planAuthorityRows(c)
if !ok {
t.Fatal("seeded authority must read ok")
}
if len(rows) != len(listedCatalog()) {
t.Fatalf("authority rows = %d, want the %d the catalog puts on sale", len(rows), len(listedCatalog()))
}
bySlug := map[string]staticPlan{}
for _, r := range rows {
bySlug[r.Slug] = r
}
for _, embed := range listedCatalog() {
got := bySlug[embed.Slug]
if got.Price != embed.Price || got.Category != embed.Category || !sameCents(got.PriceAnnual, embed.PriceAnnual) {
t.Errorf("projected %q = %d/%s/%s, want %d/%s/%s", embed.Slug, got.Price, centsText(got.PriceAnnual), got.Category, embed.Price, centsText(embed.PriceAnnual), embed.Category)
}
}
}
// TestSyncStripeUntouchedByDBEdit: StaticPlans() — the source SyncStripe reads —
// is the EMBED and is unaffected by a DB authority edit. Confirms SyncStripe is
// untouched (deferred Step 3b): editing a plan changes the charge (resolve) but
// NOT the Stripe-dashboard projection source.
func TestSyncStripeUntouchedByDBEdit(t *testing.T) {
c := ae.NewContext()
defer c.Close()
if _, _, err := SeedPlans(c); err != nil {
t.Fatalf("seed: %v", err)
}
before := staticMonth(StaticPlans(), "dev")
adb := plan.AuthorityDB(c)
p := plan.New(adb)
if ok, _ := p.Query().Filter("Slug=", "dev").Get(); !ok {
t.Fatal("pro missing")
}
p.Price = 12345
if err := p.Update(); err != nil {
t.Fatalf("edit: %v", err)
}
after := staticMonth(StaticPlans(), "dev")
// The property is that the DB edit did not reach the embed — not what the
// embed happens to charge. Pinning the price here made a reprice look like a
// broken authority boundary, which is a different fault entirely.
if before != after {
t.Fatalf("StaticPlans(pro) changed by a DB edit: before=%d after=%d (SyncStripe must read the untouched embed)", before, after)
}
if after == 12345 {
t.Fatalf("StaticPlans(pro) = %d, the value written to the DB — the embed is being read through the authority", after)
}
}
func staticMonth(plans []Plan, slug string) int64 {
for _, p := range plans {
if p.Slug == slug {
return p.PriceMonth
}
}
return -1
}