Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 9 additions & 55 deletions internal/handlers/billing_usage_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ package handlers_test
// - GetUsage with no team local → 401 unauthorized.
// - computeUsage tier-lookup error → 500 usage_failed (propagated through
// the cache GetOrSet loader).
// - mbToBytes unlimited (-1) path via the team tier whose storage limit is
// unlimited.
// - mbToBytes unlimited (-1) + finite paths: exercised directly in
// mb_to_bytes_internal_test.go (no real tier carries -1 post strict-margin
// redesign).

import (
"database/sql"
Expand All @@ -27,7 +28,6 @@ import (
"github.com/stretchr/testify/require"

"instant.dev/internal/handlers"
"instant.dev/internal/middleware"
"instant.dev/internal/plans"
)

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

// TestBillingUsage_UnlimitedTier_MbToBytesNegative covers mbToBytes(-1) → -1:
// the team tier has unlimited storage, so each storage metric's limit_bytes
// must render as -1 (the dashboard's "∞").
func TestBillingUsage_UnlimitedTier_MbToBytesNegative(t *testing.T) {
db, mock, err := sqlmock.New()
require.NoError(t, err)
defer db.Close()
mr, err := miniredis.Run()
require.NoError(t, err)
defer mr.Close()
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
defer rdb.Close()

teamID := uuid.New()
// team tier → unlimited storage (-1) → mbToBytes(-1) path.
mock.ExpectQuery(`SELECT.*FROM teams WHERE id`).
WithArgs(teamID).
WillReturnRows(sqlmock.NewRows([]string{
"id", "name", "plan_tier", "stripe_customer_id", "created_at", "default_deployment_ttl_policy",
}).AddRow(teamID, sql.NullString{}, "team", sql.NullString{}, time.Now(), "auto_24h"))
for range []string{"postgres", "redis", "mongodb"} {
mock.ExpectQuery(`SELECT COALESCE\(SUM\(storage_bytes\)`).
WillReturnRows(sqlmock.NewRows([]string{"sum"}).AddRow(int64(0)))
}
mock.ExpectQuery(`(?i)SELECT count\(\*\)\s+FROM deployments`).
WithArgs(teamID).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
mock.ExpectQuery(`SELECT COUNT\(\*\)\s+FROM resources`).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
mock.ExpectQuery(`SELECT COUNT\(DISTINCT key\) FROM vault_secrets`).
WithArgs(teamID).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM users WHERE team_id`).
WithArgs(teamID).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))

app := newUsageApp(t, db, rdb, teamID)
req := httptest.NewRequest(http.MethodGet, "/api/v1/billing/usage", nil)
resp, err := app.Test(req, 5000)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)

var body struct {
Usage map[string]struct {
LimitBytes int64 `json:"limit_bytes"`
} `json:"usage"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
assert.Equal(t, int64(-1), body.Usage["postgres"].LimitBytes, "unlimited tier storage limit must serialise as -1")
_ = middleware.LocalKeyTeamID
}
// NOTE: the unlimited (mbToBytes(-1) → -1) path is exercised directly with
// synthetic inputs in mb_to_bytes_internal_test.go (package handlers). Post
// strict-80%-margin redesign no real tier carries an unlimited (-1) storage
// limit, so the -1 path can no longer be reached via the team tier through
// the HTTP usage handler; testing the helper directly keeps the defensive
// "-1 → ∞" rendering covered.
33 changes: 33 additions & 0 deletions internal/handlers/mb_to_bytes_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package handlers

// mb_to_bytes_internal_test.go — directly exercises the unexported mbToBytes
// helper. Lives in package handlers (not handlers_test) because the symbol is
// unexported.
//
// History: the unlimited (-1 → -1, "∞") path used to be covered by routing the
// team tier (whose storage limit was -1) through the HTTP usage handler in
// billing_usage_coverage_test.go. The strict-≥80%-margin tier redesign
// (2026-06-05) retired every real -1 storage limit to a finite cap, so that
// path can no longer be reached via any tier. The defensive "-1 → ∞" branch
// still ships (a negative value may arrive from non-storage limits such as
// provisions_per_day, or from a future tier), so it is exercised here with
// synthetic inputs instead.

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestMbToBytes_UnlimitedAndFinite(t *testing.T) {
// Unlimited sentinel: any negative input renders -1 ("∞" on the dashboard).
assert.Equal(t, int64(-1), mbToBytes(-1), "unlimited (-1) limit must serialise as -1")
assert.Equal(t, int64(-1), mbToBytes(-9999), "any negative limit is treated as unlimited (-1)")

// Finite conversions: MB → bytes (×1024×1024).
assert.Equal(t, int64(0), mbToBytes(0))
assert.Equal(t, int64(1024*1024), mbToBytes(1))
// Team's new finite postgres cap (51200 MB = 50 GiB) — the value the
// retired HTTP test wrongly expected as -1 now serialises as real bytes.
assert.Equal(t, int64(51200)*1024*1024, mbToBytes(51200))
}
12 changes: 7 additions & 5 deletions internal/handlers/misc_routes_block_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -277,19 +277,21 @@ func TestMiscBlock_UsageWall_RealDBContract(t *testing.T) {
assert.Equal(t, float64(87), body["percent_used"])
})

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

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

t.Run("cross-team isolation: team A session never sees team B's wall", func(t *testing.T) {
Expand Down
4 changes: 2 additions & 2 deletions internal/handlers/openapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -1318,7 +1318,7 @@ const openAPISpec = `{
"/api/v1/billing/checkout": {
"post": {
"summary": "Create a Razorpay subscription and return its hosted-page URL",
"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.",
"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.",
"security": [{ "bearerAuth": [] }],
"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." } } } } } },
"responses": {
Expand Down Expand Up @@ -2529,7 +2529,7 @@ const openAPISpec = `{
"/api/v1/usage/wall": {
"get": {
"summary": "Quota-wall nudge state (dashboard upgrade banner)",
"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.",
"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.",
"security": [{ "bearerAuth": [] }],
"responses": {
"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"] } } } },
Expand Down
10 changes: 5 additions & 5 deletions internal/handlers/small_handlers_final_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,16 +64,16 @@ func TestWhoamiFinal_Enrichment(t *testing.T) {
assert.Equal(t, email, m["email"])
}

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

app := newUsageWallApp(t, openFaultDB(t, 1), teamID)
app := newUsageWallApp(t, openFaultDB(t, 0), teamID)
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/api/v1/usage/wall", nil), 5000)
require.NoError(t, err)
defer resp.Body.Close()
Expand Down
19 changes: 13 additions & 6 deletions internal/handlers/team_coverage_mock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,20 +230,27 @@ func TestTeamMembers_InviteMember_LegacyMemberSuccess(t *testing.T) {
mock.ExpectQuery(`SELECT COALESCE\(role, 'member'\) FROM users WHERE id`).
WithArgs(userID, teamID).
WillReturnRows(sqlmock.NewRows([]string{"role"}).AddRow("owner"))
// 5. withinMemberLimit — team tier is unlimited (limit<0) so the model
// skips the count query; but to be robust we allow an optional count.
// The "team" tier member_limit is unlimited (-1) so withinMemberLimit
// returns early without querying. Next is the existing-member COUNT.
// 5. withinMemberLimit — post strict-80%-margin redesign the "team" tier
// member limit is FINITE (25, was -1/unlimited), so withinMemberLimit
// now queries teamSeatTotal = CountTeamMembers + CountPendingInvitations.
// 1 member + 0 pending = 1 seat < 25 → within limit.
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM users WHERE team_id = \$1$`).
WithArgs(teamID).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM team_invitations WHERE team_id = \$1 AND status = 'pending'`).
WithArgs(teamID).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
// 6. existing-member COUNT (the email dedup check).
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM users WHERE team_id = \$1 AND lower\(email\)`).
WithArgs(teamID, sqlmock.AnyArg()).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
// 6. INSERT ... RETURNING the invitation row
// 7. INSERT ... RETURNING the invitation row
invID := uuid.New()
mock.ExpectQuery(`INSERT INTO team_invitations`).
WillReturnRows(sqlmock.NewRows([]string{
"id", "team_id", "email", "role", "status", "invited_by", "created_at", "expires_at",
}).AddRow(invID, teamID, "x@y.com", "member", "pending", userID, time.Now(), time.Now().Add(7*24*time.Hour)))
// 7. best-effort audit insert — accept any exec.
// 8. best-effort audit insert — accept any exec.
mock.ExpectExec(`INSERT INTO audit_log`).WillReturnResult(sqlmock.NewResult(1, 1))

app := teamCoverageApp(t, db, nil, userID.String(), teamID.String())
Expand Down
Loading
Loading