Skip to content

Commit 804bffe

Browse files
test(billing): W3 billing-block integration suite — purchasable-set, no self-serve cancel/downgrade, webhook tier transitions (#247)
Closes the matrix's biggest gap (USER-FLOW-INVENTORY-AND-TEST-MATRIX.md §E): the revenue-critical billing block had near-zero real-backend integration coverage. New DB-backed tests (handlers package, TEST_DATABASE_URL), all NEW files — no edits to billing.go or existing billing_*_test.go (avoids #246 W0). - Registry-iterating purchasable-set assertion (§E3): drives EVERY plans.Registry tier through the real CreateCheckoutAPI handler; asserts the set that reaches the Razorpay CreateSubscription seam is EXACTLY {hobby, hobby_plus, pro}. Reds if Team is re-enabled or a new tier silently becomes chargeable (rule 18). Team → 400 tier_not_yet_available; growth → invalid_plan; both checkout + change-plan surfaces gated. - No self-serve cancel/downgrade (§E10): router.go source-scan negative assertion (no non-admin cancel/downgrade route) + ChangePlanAPI rejects every lower/equal-tier target with downgrade_not_self_serve + support agent_action, asserting the team tier is left UNCHANGED. same_plan edge covered. - Webhook tier transitions (§E4/E5/E6/E7): subscription.charged upgrade elevates plan_tier AND promotes all active resources (rule 5); subscription.cancelled downgrade drops plan_tier to the courtesy floor but LEAVES resource tiers (user-benefit asymmetry); bad signature → 400 invalid_signature, tier unchanged; unknown team → 404 team_not_found (rows-affected-0 / ErrTeamNotFound). - Checkout graceful failure: unconfigured plan_id → 503 billing_not_configured; live-key-in-nonprod → 503 billing_misconfigured (CreateSubscription never called). Reuses existing helpers (seedVerifiedTeamUser, cov2CheckoutApp, changePlanAppReal/Req, postCheckoutReq, cov2WebhookAppReal, signRazorpayPayload, makeSubscriptionChargedPayloadWithPlan, makeSubscriptionCancelledPayload) — none redefined. Handlers package green; the 20 unrelated handlers/models failures in full ./... are pre-existing local-env flakes (NATS/customer-DB/GitHub creds), verified to reproduce identically on clean origin/master — CI is authoritative. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 262d10d commit 804bffe

4 files changed

Lines changed: 852 additions & 0 deletions
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package handlers_test
2+
3+
// billing_block_helpers_test.go — shared helpers for the W3 billing-block
4+
// integration suite (billing_block_*_test.go). These are W3-local helpers
5+
// (prefixed billingBlock*) so they do not collide with the existing cov2*/
6+
// billing* helpers this suite also reuses. NOTHING here redefines an existing
7+
// helper — seedVerifiedTeamUser, cov2CheckoutApp, changePlanAppReal,
8+
// changePlanReq, postCheckoutReq, signRazorpayPayload,
9+
// makeSubscriptionChargedPayloadWithPlan, makeSubscriptionCancelledPayload and
10+
// cov2WebhookAppReal all already exist in the package and are used as-is.
11+
12+
import (
13+
"context"
14+
"database/sql"
15+
"os"
16+
"testing"
17+
18+
"github.com/google/uuid"
19+
"github.com/stretchr/testify/require"
20+
21+
"instant.dev/internal/models"
22+
"instant.dev/internal/testhelpers"
23+
)
24+
25+
// billingBlockJWTSecret is the ≥32-byte HMAC secret the W3 suite stamps onto
26+
// every test cfg. Identical value to testhelpers.TestJWTSecret; named locally
27+
// so the intent ("any valid secret, never a real one") is explicit at call
28+
// sites.
29+
const billingBlockJWTSecret = testhelpers.TestJWTSecret
30+
31+
// billingBlockSkipNoDB skips a W3 test when no test Postgres is configured.
32+
// The billing block is a real-backend integration surface — these tests
33+
// assert on actual rows in teams/resources/audit_log, so a missing DB is a
34+
// loud skip, never a false green.
35+
func billingBlockSkipNoDB(t *testing.T) bool {
36+
t.Helper()
37+
if os.Getenv("TEST_DATABASE_URL") == "" {
38+
t.Skip("W3 billing-block integration: TEST_DATABASE_URL not set")
39+
return true
40+
}
41+
return false
42+
}
43+
44+
// billingBlockDB opens a fresh migrated test DB and returns it with its
45+
// cleanup. Thin wrapper over testhelpers.SetupTestDB so every W3 test reads
46+
// the same way.
47+
func billingBlockDB(t *testing.T) (*sql.DB, func()) {
48+
t.Helper()
49+
return testhelpers.SetupTestDB(t)
50+
}
51+
52+
// mustSeedTeam creates a team row at the given plan tier and registers a
53+
// cleanup. Returns the team id as a string (the shape changePlanAppReal +
54+
// the webhook payload builders consume).
55+
func mustSeedTeam(t *testing.T, db *sql.DB, tier string) string {
56+
t.Helper()
57+
id := testhelpers.MustCreateTeamDB(t, db, tier)
58+
t.Cleanup(func() {
59+
db.Exec(`DELETE FROM teams WHERE id = $1::uuid`, id)
60+
})
61+
return id
62+
}
63+
64+
// billingBlockSeedResource inserts an active resource owned by teamID at the
65+
// given tier and returns its id. Used by the webhook-transition tests to
66+
// prove that an upgrade ELEVATES existing resources and a downgrade LEAVES
67+
// them. expires_at is left NULL (a claimed, permanent resource) so the
68+
// reaper-race guard in the elevation UPDATE never excludes it.
69+
func billingBlockSeedResource(t *testing.T, db *sql.DB, teamID uuid.UUID, resourceType, tier string) uuid.UUID {
70+
t.Helper()
71+
res, err := models.CreateResource(context.Background(), db, models.CreateResourceParams{
72+
TeamID: &teamID,
73+
ResourceType: resourceType,
74+
Name: "w3-" + resourceType + "-" + uuid.NewString()[:8],
75+
Tier: tier,
76+
Env: "production",
77+
})
78+
require.NoError(t, err, "seed resource (%s/%s)", resourceType, tier)
79+
// CreateResource inserts a 'pending' row; the tier-elevation UPDATE only
80+
// touches active/paused/suspended rows. Flip to 'active' so the resource
81+
// is in the state a real claimed resource would be in when an upgrade
82+
// webhook fires.
83+
require.NoError(t, models.MarkResourceActive(context.Background(), db, res.ID),
84+
"activate seeded resource (%s/%s)", resourceType, tier)
85+
t.Cleanup(func() {
86+
db.Exec(`DELETE FROM resources WHERE id = $1`, res.ID)
87+
})
88+
return res.ID
89+
}
90+
91+
// billingBlockResourceTier reads back the current tier of a resource row so a
92+
// test can assert whether a webhook elevated or left it.
93+
func billingBlockResourceTier(t *testing.T, db *sql.DB, id uuid.UUID) string {
94+
t.Helper()
95+
var tier string
96+
require.NoError(t,
97+
db.QueryRow(`SELECT tier FROM resources WHERE id = $1`, id).Scan(&tier),
98+
"read resource tier")
99+
return tier
100+
}
101+
102+
// billingBlockTeamTier reads back the current plan_tier of a team row.
103+
func billingBlockTeamTier(t *testing.T, db *sql.DB, teamID string) string {
104+
t.Helper()
105+
var tier string
106+
require.NoError(t,
107+
db.QueryRow(`SELECT plan_tier FROM teams WHERE id = $1::uuid`, teamID).Scan(&tier),
108+
"read team plan_tier")
109+
return tier
110+
}
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
package handlers_test
2+
3+
// billing_block_no_cancel_downgrade_test.go — W3 §E10: there is NO self-serve
4+
// cancel or downgrade path.
5+
//
6+
// Policy (memory: project_no_self_serve_cancel_downgrade): cancellation and
7+
// downgrade are SUPPORT-ONLY. Downgrade flows through the Razorpay
8+
// subscription.cancelled / .updated webhook or a support agent; a paying team
9+
// must NOT be able to drop itself to a cheaper tier or cancel via any
10+
// session-authenticated endpoint. The self-serve POST /billing/cancel was
11+
// REMOVED (router.go documents the removal next to /billing/change-plan).
12+
//
13+
// Two complementary assertions:
14+
// 1. ROUTE NEGATIVE: string-parse the live router.go and prove no route
15+
// registers a self-serve cancel/downgrade verb. This is the same
16+
// source-scan technique the OpenAPI route-parity test uses
17+
// (extractRouterRoutes) so it tracks the real registration table, not a
18+
// stale mental model. If someone re-adds POST /billing/cancel, this reds.
19+
// 2. HANDLER NEGATIVE: drive the real ChangePlanAPI with a lower-or-equal
20+
// target tier and assert it is rejected with downgrade_not_self_serve +
21+
// a mailto:support agent_action — the exact policy in
22+
// billing.go:ChangePlanAPI. This is verified against the code, not
23+
// assumed: a downgrade returns 400 downgrade_not_self_serve, NOT a
24+
// silent tier drop.
25+
26+
import (
27+
"net/http"
28+
"os"
29+
"path/filepath"
30+
"regexp"
31+
"strings"
32+
"testing"
33+
34+
"github.com/stretchr/testify/assert"
35+
"github.com/stretchr/testify/require"
36+
37+
"instant.dev/internal/config"
38+
)
39+
40+
// blockRouterRoute is a (method, path, isAdmin) tuple parsed from router.go.
41+
// Local to this W3 file because the OpenAPI test's identically-shaped parser
42+
// lives in the white-box `handlers` test package and is not reachable from
43+
// this black-box `handlers_test` package.
44+
type blockRouterRoute struct {
45+
method string
46+
path string
47+
isAdmin bool
48+
}
49+
50+
// blockExtractRouterRoutes string-parses router.go and returns every literal
51+
// route registration. Same conservative technique the OpenAPI route-parity
52+
// test uses: it expects a literal "(" after the verb and a quoted path as the
53+
// first arg, skipping any dynamic registration (router.go uses only literal
54+
// paths today). Groups carry their URL prefix so the returned path is fully
55+
// qualified.
56+
func blockExtractRouterRoutes(src string) []blockRouterRoute {
57+
patterns := []struct {
58+
groupRe *regexp.Regexp
59+
urlPrefix string
60+
isAdmin bool
61+
}{
62+
{regexp.MustCompile(`\bapp\.(Get|Post|Put|Patch|Delete)\("([^"]+)"`), "", false},
63+
{regexp.MustCompile(`\bapi\.(Get|Post|Put|Patch|Delete)\("([^"]+)"`), "/api/v1", false},
64+
{regexp.MustCompile(`\badminGroup\.(Get|Post|Put|Patch|Delete)\("([^"]+)"`), "/api/v1/<admin>", true},
65+
{regexp.MustCompile(`\bdeployGroup\.(Get|Post|Put|Patch|Delete)\("([^"]+)"`), "/deploy", false},
66+
{regexp.MustCompile(`\binternal\.(Get|Post|Put|Patch|Delete)\("([^"]+)"`), "/internal", false},
67+
}
68+
var out []blockRouterRoute
69+
for _, p := range patterns {
70+
for _, m := range p.groupRe.FindAllStringSubmatch(src, -1) {
71+
path := m[2]
72+
if p.urlPrefix != "" {
73+
if !strings.HasPrefix(path, "/") {
74+
path = "/" + path
75+
}
76+
path = p.urlPrefix + path
77+
}
78+
out = append(out, blockRouterRoute{method: strings.ToUpper(m[1]), path: path, isAdmin: p.isAdmin})
79+
}
80+
}
81+
return out
82+
}
83+
84+
// forbiddenSelfServeBillingPaths is the set of route SUFFIXES that, if they
85+
// ever appear as a registered self-serve (session-authenticated, non-admin,
86+
// non-webhook) route, would constitute a self-serve cancel/downgrade surface
87+
// the policy forbids. Matched as a suffix against the parsed router path so
88+
// both the legacy alias and the /api/v1 group form are caught.
89+
var forbiddenSelfServeBillingPaths = []string{
90+
"/billing/cancel",
91+
"/billing/downgrade",
92+
"/billing/subscription/cancel",
93+
"/subscription/cancel",
94+
}
95+
96+
// TestBillingBlock_NoSelfServeCancelOrDowngradeRoute parses router.go and
97+
// asserts none of the forbidden self-serve cancel/downgrade paths are
98+
// registered on a non-admin route. Admin routes (e.g. an operator demote) are
99+
// allowed and excluded — cancellation IS supported, just support/operator-side.
100+
//
101+
// This does not require a DB — it reads the router source, the same way
102+
// TestOpenAPI route-parity does, so it runs even in the -short unit lane.
103+
func TestBillingBlock_NoSelfServeCancelOrDowngradeRoute(t *testing.T) {
104+
routerPath := filepath.Join("..", "router", "router.go")
105+
src, err := os.ReadFile(routerPath)
106+
require.NoError(t, err, "read router.go")
107+
108+
routes := blockExtractRouterRoutes(string(src))
109+
require.NotEmpty(t, routes,
110+
"blockExtractRouterRoutes returned 0 — parser is out of sync with router.go (the negative assertion would pass vacuously)")
111+
112+
// Guard against a vacuous pass: confirm the parser actually sees the
113+
// billing block by requiring the legitimate change-plan route to be
114+
// present. If the parser silently broke, this trips before the negative
115+
// assertion can give a false green.
116+
var sawChangePlan bool
117+
for _, r := range routes {
118+
if strings.HasSuffix(r.path, "/billing/change-plan") {
119+
sawChangePlan = true
120+
break
121+
}
122+
}
123+
require.True(t, sawChangePlan,
124+
"expected the router parser to see POST /billing/change-plan — if it doesn't, the no-cancel negative assertion is meaningless")
125+
126+
for _, r := range routes {
127+
if r.isAdmin {
128+
continue // operator/support-side cancellation is allowed.
129+
}
130+
for _, forbidden := range forbiddenSelfServeBillingPaths {
131+
assert.Falsef(t, strings.HasSuffix(r.path, forbidden),
132+
"self-serve cancel/downgrade is support-only (§E10, memory project_no_self_serve_cancel_downgrade) — "+
133+
"router.go must not register a non-admin route ending in %q, but found %s %s",
134+
forbidden, r.method, r.path)
135+
}
136+
}
137+
}
138+
139+
// TestBillingBlock_ChangePlanRejectsDowngrade pins the handler-level policy: a
140+
// paying team requesting a LOWER or EQUAL tier via the in-app change-plan path
141+
// is rejected with downgrade_not_self_serve and routed to support, NOT
142+
// silently dropped. Verified against billing.go:ChangePlanAPI (it returns 400
143+
// downgrade_not_self_serve + a mailto:support@instanode.dev agent_action for
144+
// any target whose rank ≤ the current tier's rank).
145+
func TestBillingBlock_ChangePlanRejectsDowngrade(t *testing.T) {
146+
if billingBlockSkipNoDB(t) {
147+
return
148+
}
149+
150+
cases := []struct {
151+
name string
152+
startTier string
153+
target string
154+
}{
155+
{"pro → hobby is a downgrade", "pro", "hobby"},
156+
{"pro → hobby_plus is a downgrade", "pro", "hobby_plus"},
157+
{"hobby_plus → hobby is a downgrade", "hobby_plus", "hobby"},
158+
}
159+
for _, tc := range cases {
160+
t.Run(tc.name, func(t *testing.T) {
161+
db, clean := billingBlockDB(t)
162+
defer clean()
163+
teamID := mustSeedTeam(t, db, tc.startTier)
164+
cfg := &config.Config{
165+
JWTSecret: billingBlockJWTSecret,
166+
RazorpayKeyID: "rzp_test_k",
167+
RazorpayKeySecret: "s",
168+
RazorpayPlanIDHobby: "plan_hobby",
169+
RazorpayPlanIDHobbyPlus: "plan_hobby_plus",
170+
RazorpayPlanIDPro: "plan_pro",
171+
}
172+
app := changePlanAppReal(t, db, cfg, teamID)
173+
code, body := changePlanReq(t, app, map[string]any{"target_plan": tc.target})
174+
175+
assert.Equal(t, http.StatusBadRequest, code, "downgrade must be a 400, body=%v", body)
176+
assert.Equal(t, "downgrade_not_self_serve", body["error"],
177+
"%s must be rejected as a support-only downgrade, not applied", tc.name)
178+
// The agent_action must route the user to support so an agent does
179+
// not retry or invent a different path.
180+
action, _ := body["agent_action"].(string)
181+
assert.Contains(t, strings.ToLower(action), "support",
182+
"downgrade rejection must carry a support-routing agent_action (got %q)", action)
183+
184+
// And CRITICALLY: the team's tier must be UNCHANGED — a downgrade
185+
// rejection that still mutated the row would be the worst outcome.
186+
assert.Equal(t, tc.startTier, billingBlockTeamTier(t, db, teamID),
187+
"a rejected downgrade must not mutate the team's plan_tier")
188+
})
189+
}
190+
}
191+
192+
// TestBillingBlock_ChangePlanSamePlanRejected covers the lateral/no-op edge:
193+
// requesting the tier the team already holds is rejected with same_plan (not
194+
// treated as a downgrade, not a no-op success that churns the Razorpay
195+
// subscription). Part of the §E10 surface — no self-serve tier mutation that
196+
// isn't a genuine upgrade.
197+
func TestBillingBlock_ChangePlanSamePlanRejected(t *testing.T) {
198+
if billingBlockSkipNoDB(t) {
199+
return
200+
}
201+
db, clean := billingBlockDB(t)
202+
defer clean()
203+
teamID := mustSeedTeam(t, db, "pro")
204+
cfg := &config.Config{
205+
JWTSecret: billingBlockJWTSecret,
206+
RazorpayKeyID: "rzp_test_k",
207+
RazorpayKeySecret: "s",
208+
RazorpayPlanIDPro: "plan_pro",
209+
}
210+
app := changePlanAppReal(t, db, cfg, teamID)
211+
code, body := changePlanReq(t, app, map[string]any{"target_plan": "pro"})
212+
assert.Equal(t, http.StatusBadRequest, code, "body=%v", body)
213+
assert.Equal(t, "same_plan", body["error"],
214+
"requesting the current tier must return same_plan, not a no-op success")
215+
assert.Equal(t, "pro", billingBlockTeamTier(t, db, teamID))
216+
}

0 commit comments

Comments
 (0)