Skip to content

Commit f358aaf

Browse files
test(plans): fix tier-assumption tests for strict-80%-margin redesign
common #46 retired every unlimited (-1) resource limit to finite caps (Team/Growth now finite). Four api handler tests still assumed the old "team/growth = unlimited" reality and reded build-and-test, which broke api master (every PR checks out common@master with the new numbers). Fixes (TESTS only — the -1-handling production code is preserved as correct defensive utilities, still reachable via provisions_per_day: -1 and future tiers): - billing_usage_coverage: TestBillingUsage_UnlimitedTier_MbToBytesNegative routed the team tier (was -1) through the HTTP usage handler to hit mbToBytes(-1). No real tier carries -1 now, so the -1 path can't be reached via a tier. Moved to a direct internal test (mb_to_bytes_internal_test.go) that feeds synthetic -1/finite inputs — keeps the defensive "-1 -> ∞" branch covered. - misc_routes_block_integration: the "team tier short-circuits to near_wall=false" subtest assumed the unlimited early-return. That early-return was removed (Team is finite -> walls apply); subtest now asserts near_wall=true with a seeded wall row. - small_handlers_final: TestUsageWallFinal_DBError_503 used failAfter=1 (team-tier pre-query #1 then audit query #2). With the early-return gone the audit query is the FIRST DB call -> failAfter=0. - team_coverage_mock: TestTeamMembers_InviteMember_LegacyMemberSuccess assumed team_members=-1 so withinMemberLimit skipped the count query. Team is now 25 -> added the teamSeatTotal (members + pending) mock expectations; 1 seat < 25 -> within limit -> 201. New rule-18 guard (strict_margin_finite_limits_test.go): iterates the LIVE plans.yaml registry and fails if any tier ships a -1 on any costed resource limit (only provisions_per_day may be -1). Re-introducing an unlimited resource cap — or a new tier with one — now reds here instead of silently shipping unbounded COGS. Team stays GATED (no checkout change). Unblocks api master. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5da930a commit f358aaf

6 files changed

Lines changed: 149 additions & 71 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/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())
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package plans_test
2+
3+
// strict_margin_finite_limits_test.go — rule-18 registry-iterating guard for
4+
// the strict-≥80%-margin tier redesign (2026-06-05, PR #262 / common #46).
5+
//
6+
// The redesign retired every "unlimited" (-1) RESOURCE limit to a finite,
7+
// worst-case-costed cap so saturated COGS stays ≥80% margin. The ONLY limit
8+
// field intentionally left at -1 is ProvisionsPerDay (a per-day rate gate on
9+
// paid tiers, not a resource-cost dimension).
10+
//
11+
// This test iterates the LIVE plans.yaml registry (not a hand-typed tier
12+
// list) so that re-introducing a -1 on any costed limit — or adding a new
13+
// tier that ships an unlimited resource cap — fails here rather than silently
14+
// shipping an unbounded-COGS tier. Hand-typed slices would themselves be the
15+
// single-site fallacy rule 18 warns against.
16+
17+
import (
18+
"os"
19+
"path/filepath"
20+
"testing"
21+
22+
"instant.dev/internal/plans"
23+
)
24+
25+
func TestStrictMargin_NoUnlimitedResourceLimits(t *testing.T) {
26+
repoRoot := filepath.Join("..", "..", "plans.yaml")
27+
if _, err := os.Stat(repoRoot); os.IsNotExist(err) {
28+
t.Skip("plans.yaml not found in repo root — skipping finite-limit guard")
29+
}
30+
r, err := plans.Load(repoRoot)
31+
if err != nil {
32+
t.Fatalf("load plans.yaml: %v", err)
33+
}
34+
35+
all := r.All()
36+
if len(all) == 0 {
37+
t.Fatal("registry is empty — plans.yaml failed to populate any tier")
38+
}
39+
40+
for name, p := range all {
41+
l := p.Limits
42+
// Every integer RESOURCE limit must be finite (>= 0). -1 (unlimited)
43+
// is forbidden post strict-margin redesign because an unbounded cap
44+
// breaks the worst-case COGS / ≥80%-margin guarantee. (0 is allowed:
45+
// it means "feature not available on this tier", e.g. deployments_apps
46+
// on anonymous/free, vault on free.)
47+
checks := map[string]int{
48+
"postgres_storage_mb": l.PostgresStorageMB,
49+
"postgres_connections": l.PostgresConnections,
50+
"vector_storage_mb": l.VectorStorageMB,
51+
"vector_connections": l.VectorConnections,
52+
"redis_memory_mb": l.RedisMemoryMB,
53+
"redis_commands_per_day": l.RedisCommandsPerDay,
54+
"mongodb_storage_mb": l.MongoStorageMB,
55+
"mongodb_connections": l.MongoConnections,
56+
"mongodb_ops_per_minute": l.MongoOpsPerMinute,
57+
"queue_storage_mb": l.QueueStorageMB,
58+
"queue_count": l.QueueCount,
59+
"storage_storage_mb": l.StorageStorageMB,
60+
"webhook_requests_stored": l.WebhookRequestsStored,
61+
"team_members": l.TeamMembers,
62+
"vault_max_entries": l.VaultMaxEntries,
63+
"deployments_apps": l.DeploymentsApps,
64+
"custom_domains_max": l.CustomDomainsMax,
65+
"manual_backups_per_day": l.ManualBackupsPerDay,
66+
}
67+
for field, v := range checks {
68+
if v < 0 {
69+
t.Errorf("tier %q: %s = %d — unlimited (-1) resource limits were retired in the strict-80%%-margin redesign; every resource cap must be finite (>= 0)",
70+
name, field, v)
71+
}
72+
}
73+
74+
// ProvisionsPerDay is the ONLY field intentionally allowed at -1
75+
// (per-day rate gate, not a resource-cost dimension). Assert it is
76+
// either finite (>= 0) or exactly -1 — never some other negative.
77+
if l.ProvisionsPerDay < -1 {
78+
t.Errorf("tier %q: provisions_per_day = %d — must be -1 (unlimited) or finite (>= 0)",
79+
name, l.ProvisionsPerDay)
80+
}
81+
}
82+
}

0 commit comments

Comments
 (0)