Skip to content

Commit 89e90e5

Browse files
merge master (strict-80 pricing #262) into scale-to-zero
2 parents bf4a60b + 6aefa52 commit 89e90e5

15 files changed

Lines changed: 411 additions & 202 deletions

internal/handlers/billing_usage_coverage_test.go

Lines changed: 9 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ package handlers_test
66
// - GetUsage with no team local → 401 unauthorized.
77
// - computeUsage tier-lookup error → 500 usage_failed (propagated through
88
// the cache GetOrSet loader).
9-
// - mbToBytes unlimited (-1) path via the team tier whose storage limit is
10-
// unlimited.
9+
// - mbToBytes unlimited (-1) + finite paths: exercised directly in
10+
// mb_to_bytes_internal_test.go (no real tier carries -1 post strict-margin
11+
// redesign).
1112

1213
import (
1314
"database/sql"
@@ -27,7 +28,6 @@ import (
2728
"github.com/stretchr/testify/require"
2829

2930
"instant.dev/internal/handlers"
30-
"instant.dev/internal/middleware"
3131
"instant.dev/internal/plans"
3232
)
3333

@@ -112,55 +112,9 @@ func TestBillingUsage_StorageSumError_Returns500(t *testing.T) {
112112
assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
113113
}
114114

115-
// TestBillingUsage_UnlimitedTier_MbToBytesNegative covers mbToBytes(-1) → -1:
116-
// the team tier has unlimited storage, so each storage metric's limit_bytes
117-
// must render as -1 (the dashboard's "∞").
118-
func TestBillingUsage_UnlimitedTier_MbToBytesNegative(t *testing.T) {
119-
db, mock, err := sqlmock.New()
120-
require.NoError(t, err)
121-
defer db.Close()
122-
mr, err := miniredis.Run()
123-
require.NoError(t, err)
124-
defer mr.Close()
125-
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
126-
defer rdb.Close()
127-
128-
teamID := uuid.New()
129-
// team tier → unlimited storage (-1) → mbToBytes(-1) path.
130-
mock.ExpectQuery(`SELECT.*FROM teams WHERE id`).
131-
WithArgs(teamID).
132-
WillReturnRows(sqlmock.NewRows([]string{
133-
"id", "name", "plan_tier", "stripe_customer_id", "created_at", "default_deployment_ttl_policy",
134-
}).AddRow(teamID, sql.NullString{}, "team", sql.NullString{}, time.Now(), "auto_24h"))
135-
for range []string{"postgres", "redis", "mongodb"} {
136-
mock.ExpectQuery(`SELECT COALESCE\(SUM\(storage_bytes\)`).
137-
WillReturnRows(sqlmock.NewRows([]string{"sum"}).AddRow(int64(0)))
138-
}
139-
mock.ExpectQuery(`(?i)SELECT count\(\*\)\s+FROM deployments`).
140-
WithArgs(teamID).
141-
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
142-
mock.ExpectQuery(`SELECT COUNT\(\*\)\s+FROM resources`).
143-
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
144-
mock.ExpectQuery(`SELECT COUNT\(DISTINCT key\) FROM vault_secrets`).
145-
WithArgs(teamID).
146-
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
147-
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM users WHERE team_id`).
148-
WithArgs(teamID).
149-
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
150-
151-
app := newUsageApp(t, db, rdb, teamID)
152-
req := httptest.NewRequest(http.MethodGet, "/api/v1/billing/usage", nil)
153-
resp, err := app.Test(req, 5000)
154-
require.NoError(t, err)
155-
defer resp.Body.Close()
156-
require.Equal(t, http.StatusOK, resp.StatusCode)
157-
158-
var body struct {
159-
Usage map[string]struct {
160-
LimitBytes int64 `json:"limit_bytes"`
161-
} `json:"usage"`
162-
}
163-
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
164-
assert.Equal(t, int64(-1), body.Usage["postgres"].LimitBytes, "unlimited tier storage limit must serialise as -1")
165-
_ = middleware.LocalKeyTeamID
166-
}
115+
// NOTE: the unlimited (mbToBytes(-1) → -1) path is exercised directly with
116+
// synthetic inputs in mb_to_bytes_internal_test.go (package handlers). Post
117+
// strict-80%-margin redesign no real tier carries an unlimited (-1) storage
118+
// limit, so the -1 path can no longer be reached via the team tier through
119+
// the HTTP usage handler; testing the helper directly keeps the defensive
120+
// "-1 → ∞" rendering covered.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package handlers
2+
3+
// mb_to_bytes_internal_test.go — directly exercises the unexported mbToBytes
4+
// helper. Lives in package handlers (not handlers_test) because the symbol is
5+
// unexported.
6+
//
7+
// History: the unlimited (-1 → -1, "∞") path used to be covered by routing the
8+
// team tier (whose storage limit was -1) through the HTTP usage handler in
9+
// billing_usage_coverage_test.go. The strict-≥80%-margin tier redesign
10+
// (2026-06-05) retired every real -1 storage limit to a finite cap, so that
11+
// path can no longer be reached via any tier. The defensive "-1 → ∞" branch
12+
// still ships (a negative value may arrive from non-storage limits such as
13+
// provisions_per_day, or from a future tier), so it is exercised here with
14+
// synthetic inputs instead.
15+
16+
import (
17+
"testing"
18+
19+
"github.com/stretchr/testify/assert"
20+
)
21+
22+
func TestMbToBytes_UnlimitedAndFinite(t *testing.T) {
23+
// Unlimited sentinel: any negative input renders -1 ("∞" on the dashboard).
24+
assert.Equal(t, int64(-1), mbToBytes(-1), "unlimited (-1) limit must serialise as -1")
25+
assert.Equal(t, int64(-1), mbToBytes(-9999), "any negative limit is treated as unlimited (-1)")
26+
27+
// Finite conversions: MB → bytes (×1024×1024).
28+
assert.Equal(t, int64(0), mbToBytes(0))
29+
assert.Equal(t, int64(1024*1024), mbToBytes(1))
30+
// Team's new finite postgres cap (51200 MB = 50 GiB) — the value the
31+
// retired HTTP test wrongly expected as -1 now serialises as real bytes.
32+
assert.Equal(t, int64(51200)*1024*1024, mbToBytes(51200))
33+
}

internal/handlers/misc_routes_block_integration_test.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -277,19 +277,21 @@ func TestMiscBlock_UsageWall_RealDBContract(t *testing.T) {
277277
assert.Equal(t, float64(87), body["percent_used"])
278278
})
279279

280-
t.Run("team tier short-circuits to near_wall=false even with a wall row", func(t *testing.T) {
280+
t.Run("team tier with a wall row returns near_wall=true (no unlimited short-circuit)", func(t *testing.T) {
281281
teamID := testhelpers.MustCreateTeamDB(t, db, "team")
282282
jwt := miscSeedUser(t, db, teamID)
283-
// Even if a row somehow existed, the team-tier gate returns false
284-
// before the audit query — assert the gate, not the absence of a row.
283+
// strict-80%-margin redesign (2026-06-05): Team is no longer unlimited,
284+
// so the prior team-tier early-return was removed. Team now falls through
285+
// to the same audit-row query as every other finite tier — a seeded wall
286+
// row therefore surfaces as near_wall=true.
285287
miscSeedWallRow(t, db, teamID)
286288

287289
resp := miscBlockReq(t, app, http.MethodGet, "/api/v1/usage/wall", jwt)
288290
require.Equal(t, http.StatusOK, resp.StatusCode)
289291
body := miscDecode(t, resp)
290292
assert.Equal(t, true, body["ok"])
291-
assert.Equal(t, false, body["near_wall"],
292-
"team tier is unlimited — no walls, short-circuit before the audit scan")
293+
assert.Equal(t, true, body["near_wall"],
294+
"Team is finite post strict-margin redesign — its wall row must surface")
293295
})
294296

295297
t.Run("cross-team isolation: team A session never sees team B's wall", func(t *testing.T) {

internal/handlers/openapi.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1333,7 +1333,7 @@ const openAPISpec = `{
13331333
"/api/v1/billing/checkout": {
13341334
"post": {
13351335
"summary": "Create a Razorpay subscription and return its hosted-page URL",
1336-
"description": "Mints a Razorpay subscription for the requested plan (hobby, hobby_plus, or pro) tied to the authenticated team. The dashboard redirects the user to the returned short_url to complete payment; on success Razorpay fires subscription.activated AND subscription.charged to /razorpay/webhook — both trigger the same idempotent tier-elevation path so the team is upgraded as soon as the mandate is authorised, even before the first invoice is collected. The Team plan ($199 unlimited) is NOT yet available for self-serve checkout — requesting plan=team returns 400 tier_not_yet_available (contact sales: support@instanode.dev). plan_frequency selects monthly (default) vs yearly billing — yearly returns 503 billing_not_configured until the operator creates the yearly Razorpay plan and sets RAZORPAY_PLAN_ID_*_YEARLY. promotion_code: admin-issued codes are bookmarked in the subscription notes for future discount wiring (no Razorpay Offer is applied yet — codes are not consumed until a real discount is confirmed). IDEMPOTENT: the endpoint never mints a second subscription for a team that already has a live one — if the team already holds the requested tier (or higher) it returns 400 already_on_plan, and if a prior checkout's subscription is still payable at Razorpay (status created/authenticated/pending) it returns that subscription's short_url with reused:true instead of creating a new one. This prevents a confused re-click from producing two parallel subscriptions that both charge the card.",
1336+
"description": "Mints a Razorpay subscription for the requested plan (hobby, hobby_plus, or pro) tied to the authenticated team. The dashboard redirects the user to the returned short_url to complete payment; on success Razorpay fires subscription.activated AND subscription.charged to /razorpay/webhook — both trigger the same idempotent tier-elevation path so the team is upgraded as soon as the mandate is authorised, even before the first invoice is collected. The Team plan ($199, finite high-capacity limits — not unlimited) is NOT yet available for self-serve checkout — requesting plan=team returns 400 tier_not_yet_available (contact sales: support@instanode.dev). Capacity beyond the Team caps is Enterprise (contact sales). plan_frequency selects monthly (default) vs yearly billing — yearly returns 503 billing_not_configured until the operator creates the yearly Razorpay plan and sets RAZORPAY_PLAN_ID_*_YEARLY. promotion_code: admin-issued codes are bookmarked in the subscription notes for future discount wiring (no Razorpay Offer is applied yet — codes are not consumed until a real discount is confirmed). IDEMPOTENT: the endpoint never mints a second subscription for a team that already has a live one — if the team already holds the requested tier (or higher) it returns 400 already_on_plan, and if a prior checkout's subscription is still payable at Razorpay (status created/authenticated/pending) it returns that subscription's short_url with reused:true instead of creating a new one. This prevents a confused re-click from producing two parallel subscriptions that both charge the card.",
13371337
"security": [{ "bearerAuth": [] }],
13381338
"requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["plan"], "properties": { "plan": { "type": "string", "enum": ["hobby", "hobby_plus", "pro"], "description": "Self-serve purchasable plans. The Team plan is NOT yet available for self-serve checkout (contact sales: support@instanode.dev) — plan=team returns 400 tier_not_yet_available." }, "plan_frequency": { "type": "string", "enum": ["monthly", "yearly"], "default": "monthly", "description": "Billing cycle. Empty = monthly. Yearly variants follow the same canonical-tier mapping on the webhook side — teams.plan_tier still stores the bare tier name." } } } } } },
13391339
"responses": {
@@ -2544,7 +2544,7 @@ const openAPISpec = `{
25442544
"/api/v1/usage/wall": {
25452545
"get": {
25462546
"summary": "Quota-wall nudge state (dashboard upgrade banner)",
2547-
"description": "Returns the most recent near_quota_wall row written by the worker's QuotaWallNudgeWorker, scoped to the caller's team and bounded to the last 24h. The dashboard polls this on mount and every 5 minutes to decide whether to render the upgrade banner. Team-tier callers always get near_wall=false (team is unlimited). Fails open — a DB error returns 503 rather than a misleading near_wall=false.",
2547+
"description": "Returns the most recent near_quota_wall row written by the worker's QuotaWallNudgeWorker, scoped to the caller's team and bounded to the last 24h. The dashboard polls this on mount and every 5 minutes to decide whether to render the upgrade banner. As of the 2026-06-05 strict-margin redesign Team has finite limits too, so Team callers can also approach a wall (next step above Team is Enterprise/contact-sales). Fails open — a DB error returns 503 rather than a misleading near_wall=false.",
25482548
"security": [{ "bearerAuth": [] }],
25492549
"responses": {
25502550
"200": { "description": "Usage-wall state", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" }, "near_wall": { "type": "boolean", "description": "True when the team has crossed the 80% quota threshold within the freshness window." }, "at": { "type": "string", "format": "date-time", "description": "When the worker recorded the threshold crossing. Present only when near_wall is true." }, "tier": { "type": "string", "description": "Team plan tier at the time the row was written." }, "axis": { "type": "string", "description": "Which quota axis tripped (e.g. 'storage')." }, "service": { "type": "string", "description": "Which service the axis belongs to (postgres / redis / mongodb / …)." }, "current": { "type": "integer", "description": "Measured usage at the time of the crossing." }, "limit": { "type": "integer", "description": "The tier limit the usage is approaching." }, "percent_used": { "type": "number", "description": "current / limit as a percent." } }, "required": ["ok", "near_wall"] } } } },

internal/handlers/small_handlers_final_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,16 +64,16 @@ func TestWhoamiFinal_Enrichment(t *testing.T) {
6464
assert.Equal(t, email, m["email"])
6565
}
6666

67-
// usage_wall.GetWall: the usage query errors → db_failed (usage_wall.go:118).
68-
// team-tier check(1) errors-or-misses, then the usage query(2) errors. Use a
69-
// non-team tier so the early-return is skipped; failAfter=1 makes the usage
70-
// query error.
67+
// usage_wall.GetWall: the audit-row query errors → db_failed.
68+
// strict-80%-margin redesign (2026-06-05): the team-tier early-return was
69+
// removed, so the audit-row query is now the FIRST DB call in GetWall for
70+
// every tier — failAfter=0 makes that first query error.
7171
func TestUsageWallFinal_DBError_503(t *testing.T) {
7272
seedDB, clean := testhelpers.SetupTestDB(t)
7373
defer clean()
7474
teamID := uuid.MustParse(testhelpers.MustCreateTeamDB(t, seedDB, "pro"))
7575

76-
app := newUsageWallApp(t, openFaultDB(t, 1), teamID)
76+
app := newUsageWallApp(t, openFaultDB(t, 0), teamID)
7777
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/api/v1/usage/wall", nil), 5000)
7878
require.NoError(t, err)
7979
defer resp.Body.Close()

internal/handlers/team_coverage_mock_test.go

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -230,20 +230,27 @@ func TestTeamMembers_InviteMember_LegacyMemberSuccess(t *testing.T) {
230230
mock.ExpectQuery(`SELECT COALESCE\(role, 'member'\) FROM users WHERE id`).
231231
WithArgs(userID, teamID).
232232
WillReturnRows(sqlmock.NewRows([]string{"role"}).AddRow("owner"))
233-
// 5. withinMemberLimit — team tier is unlimited (limit<0) so the model
234-
// skips the count query; but to be robust we allow an optional count.
235-
// The "team" tier member_limit is unlimited (-1) so withinMemberLimit
236-
// returns early without querying. Next is the existing-member COUNT.
233+
// 5. withinMemberLimit — post strict-80%-margin redesign the "team" tier
234+
// member limit is FINITE (25, was -1/unlimited), so withinMemberLimit
235+
// now queries teamSeatTotal = CountTeamMembers + CountPendingInvitations.
236+
// 1 member + 0 pending = 1 seat < 25 → within limit.
237+
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM users WHERE team_id = \$1$`).
238+
WithArgs(teamID).
239+
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
240+
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM team_invitations WHERE team_id = \$1 AND status = 'pending'`).
241+
WithArgs(teamID).
242+
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
243+
// 6. existing-member COUNT (the email dedup check).
237244
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM users WHERE team_id = \$1 AND lower\(email\)`).
238245
WithArgs(teamID, sqlmock.AnyArg()).
239246
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
240-
// 6. INSERT ... RETURNING the invitation row
247+
// 7. INSERT ... RETURNING the invitation row
241248
invID := uuid.New()
242249
mock.ExpectQuery(`INSERT INTO team_invitations`).
243250
WillReturnRows(sqlmock.NewRows([]string{
244251
"id", "team_id", "email", "role", "status", "invited_by", "created_at", "expires_at",
245252
}).AddRow(invID, teamID, "x@y.com", "member", "pending", userID, time.Now(), time.Now().Add(7*24*time.Hour)))
246-
// 7. best-effort audit insert — accept any exec.
253+
// 8. best-effort audit insert — accept any exec.
247254
mock.ExpectExec(`INSERT INTO audit_log`).WillReturnResult(sqlmock.NewResult(1, 1))
248255

249256
app := teamCoverageApp(t, db, nil, userID.String(), teamID.String())

0 commit comments

Comments
 (0)