Skip to content

Commit 3265ca0

Browse files
test(coverage): drive plans + quota to ≥95% via error-path tests
- internal/plans: cover Rank() (was 0%) — total-order, unknown-tier sentinel, yearly-not-auto-normalised, strict-increasing ladder invariant. Package now at 100.0% (was 92.9%). - internal/quota: cover the error/fail-open branches that the integration suite skips — Redis-down pipeline error in CheckAndIncrementToken, redis-error in GetThroughputCount, closed-DB error in CheckStorageQuota (fail-open) and UpdateStorageBytes (does NOT fail open). Also lock the LimitBytes MB→bytes conversion + unlimited sentinel (P2 regression surface 2026-05-17). Package now at 95.0% (was 82.5%). Both use miniredis (already in go.mod) for offline Redis-down simulation; no new deps. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3308465 commit 3265ca0

2 files changed

Lines changed: 184 additions & 0 deletions

File tree

internal/plans/rank_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package plans_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
8+
"instant.dev/internal/plans"
9+
)
10+
11+
// TestRank_TotalOrder asserts the totally-ordered rank for every known tier.
12+
// Plan ladder is anchored to plans.yaml pricing: anonymous=0, free=1, hobby=2,
13+
// hobby_plus=3, pro=4, growth=5, team=6 (pro $49 < growth $99 < team $199).
14+
func TestRank_TotalOrder(t *testing.T) {
15+
cases := []struct {
16+
tier string
17+
want int
18+
}{
19+
{"anonymous", 0},
20+
{"free", 1},
21+
{"hobby", 2},
22+
{"hobby_plus", 3},
23+
{"pro", 4},
24+
{"growth", 5},
25+
{"team", 6},
26+
}
27+
for _, c := range cases {
28+
assert.Equal(t, c.want, plans.Rank(c.tier), "Rank(%q)", c.tier)
29+
}
30+
}
31+
32+
// TestRank_UnknownTier_ReturnsSentinel guards CLAUDE.md rule 22: a typo or
33+
// new-but-not-registered tier must NOT silently rank as 0 (anonymous) — it
34+
// must return -1 so transition-direction callers can refuse to compare.
35+
func TestRank_UnknownTier_ReturnsSentinel(t *testing.T) {
36+
for _, tier := range []string{"", "enterprise", "ultra", "garbage"} {
37+
assert.Equal(t, -1, plans.Rank(tier), "Rank(%q) must be -1", tier)
38+
}
39+
}
40+
41+
// TestRank_YearlyVariants_NotAutoNormalised documents the contract: yearly
42+
// variants do NOT auto-collapse to their base rank. Callers must pass them
43+
// through CanonicalTier first if they want "pro_yearly" to rank as "pro".
44+
func TestRank_YearlyVariants_NotAutoNormalised(t *testing.T) {
45+
// pro_yearly is a distinct registry entry, NOT auto-normalised.
46+
// Whatever its rank is, after CanonicalTier it must match "pro".
47+
assert.Equal(t, plans.Rank("pro"), plans.Rank(plans.CanonicalTier("pro_yearly")))
48+
assert.Equal(t, plans.Rank("hobby"), plans.Rank(plans.CanonicalTier("hobby_yearly")))
49+
assert.Equal(t, plans.Rank("hobby_plus"), plans.Rank(plans.CanonicalTier("hobby_plus_yearly")))
50+
assert.Equal(t, plans.Rank("team"), plans.Rank(plans.CanonicalTier("team_yearly")))
51+
}
52+
53+
// TestRank_StrictlyIncreasing locks the price ladder invariant: each higher
54+
// tier outranks every lower one. A future PR that re-orders the ladder must
55+
// update this test in the same commit (rule 22).
56+
func TestRank_StrictlyIncreasing(t *testing.T) {
57+
ladder := []string{"anonymous", "free", "hobby", "hobby_plus", "pro", "growth", "team"}
58+
for i := 1; i < len(ladder); i++ {
59+
assert.Greater(t, plans.Rank(ladder[i]), plans.Rank(ladder[i-1]),
60+
"Rank(%q) must be > Rank(%q)", ladder[i], ladder[i-1])
61+
}
62+
}

internal/quota/quota_edges_test.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package quota_test
2+
3+
import (
4+
"context"
5+
"database/sql"
6+
"testing"
7+
8+
"github.com/alicebob/miniredis/v2"
9+
"github.com/google/uuid"
10+
"github.com/redis/go-redis/v9"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
14+
"instant.dev/internal/quota"
15+
"instant.dev/internal/testhelpers"
16+
)
17+
18+
// ── LimitBytes ────────────────────────────────────────────────────────────────
19+
20+
// TestLimitBytes_Conversion locks the single MB→bytes conversion point so the
21+
// dashboard number matches the enforcement wall (P2 regression 2026-05-17).
22+
func TestLimitBytes_Conversion(t *testing.T) {
23+
assert.Equal(t, int64(1024*1024), quota.LimitBytes(1))
24+
assert.Equal(t, int64(10*1024*1024), quota.LimitBytes(10))
25+
assert.Equal(t, int64(5120*1024*1024), quota.LimitBytes(5120))
26+
assert.Equal(t, int64(0), quota.LimitBytes(0))
27+
}
28+
29+
// TestLimitBytes_UnlimitedSentinel guards the -1 → UnlimitedLimitBytes mapping.
30+
func TestLimitBytes_UnlimitedSentinel(t *testing.T) {
31+
assert.Equal(t, quota.UnlimitedLimitBytes, quota.LimitBytes(-1))
32+
assert.Equal(t, int64(-1), quota.LimitBytes(-1))
33+
}
34+
35+
// ── CheckAndIncrementToken: Redis-down fail-open ─────────────────────────────
36+
37+
// TestCheckAndIncrementToken_RedisDown_FailsOpen exercises the pipeline-error
38+
// branch in quota.go. When Redis is unreachable we must return (0, false, err)
39+
// — the customer's request must not be blocked by an infra outage.
40+
func TestCheckAndIncrementToken_RedisDown_FailsOpen(t *testing.T) {
41+
mr, err := miniredis.Run()
42+
require.NoError(t, err)
43+
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
44+
// Slam Redis closed BEFORE the call so the pipeline errors.
45+
mr.Close()
46+
47+
count, exceeded, err := quota.CheckAndIncrementToken(
48+
context.Background(), rdb, uuid.New().String(), "redis", 10,
49+
)
50+
assert.Error(t, err, "Redis-down must surface an error")
51+
assert.False(t, exceeded, "fail-open: exceeded must be false")
52+
assert.Equal(t, int64(0), count)
53+
}
54+
55+
// ── GetThroughputCount: Redis error ───────────────────────────────────────────
56+
57+
// TestGetThroughputCount_RedisDown_ReturnsError covers the non-redis.Nil error
58+
// branch in GetThroughputCount.
59+
func TestGetThroughputCount_RedisDown_ReturnsError(t *testing.T) {
60+
mr, err := miniredis.Run()
61+
require.NoError(t, err)
62+
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
63+
mr.Close()
64+
65+
count, err := quota.GetThroughputCount(
66+
context.Background(), rdb, uuid.New().String(), "redis",
67+
)
68+
assert.Error(t, err)
69+
assert.Equal(t, int64(0), count)
70+
}
71+
72+
// ── CheckStorageQuota: DB error branch ────────────────────────────────────────
73+
74+
// TestCheckStorageQuota_DBClosed_FailsOpen exercises the non-ErrNoRows DB-error
75+
// branch. A closed *sql.DB returns an error that is NOT sql.ErrNoRows, which
76+
// must fail open per the docstring contract.
77+
func TestCheckStorageQuota_DBClosed_FailsOpen(t *testing.T) {
78+
db, cleanDB := testhelpers.SetupTestDB(t)
79+
cleanDB() // close the DB BEFORE the call so QueryRowContext errors.
80+
81+
used, exceeded, err := quota.CheckStorageQuota(
82+
context.Background(), db, uuid.New(), 10,
83+
)
84+
assert.Error(t, err, "closed DB must surface an error")
85+
assert.False(t, exceeded, "fail-open: exceeded must be false on DB error")
86+
assert.Equal(t, int64(0), used)
87+
}
88+
89+
// TestCheckStorageQuota_ContextCancelled_FailsOpen covers the same error branch
90+
// via a cancelled context — independent failure mode.
91+
func TestCheckStorageQuota_ContextCancelled_FailsOpen(t *testing.T) {
92+
db, cleanDB := testhelpers.SetupTestDB(t)
93+
defer cleanDB()
94+
95+
ctx, cancel := context.WithCancel(context.Background())
96+
cancel()
97+
98+
_, exceeded, err := quota.CheckStorageQuota(ctx, db, uuid.New(), 10)
99+
// Either ErrNoRows is masked by cancellation (err returned + exceeded false)
100+
// or the row genuinely doesn't exist (no err, exceeded false). Both satisfy
101+
// fail-open. The contract: exceeded must be false.
102+
assert.False(t, exceeded)
103+
_ = err
104+
}
105+
106+
// ── UpdateStorageBytes: DB error branch ───────────────────────────────────────
107+
108+
// TestUpdateStorageBytes_DBClosed_ReturnsError covers the wrapped-error branch
109+
// of UpdateStorageBytes. Unlike the read path, this one does NOT fail open —
110+
// the caller (the storage-bytes worker) is expected to retry.
111+
func TestUpdateStorageBytes_DBClosed_ReturnsError(t *testing.T) {
112+
db, cleanDB := testhelpers.SetupTestDB(t)
113+
cleanDB()
114+
115+
err := quota.UpdateStorageBytes(context.Background(), db, uuid.New(), 1024)
116+
assert.Error(t, err, "closed DB must surface an error for UpdateStorageBytes")
117+
assert.ErrorContains(t, err, "UpdateStorageBytes")
118+
}
119+
120+
// Compile-time guard so the unused sql import is not flagged if the test
121+
// matrix above ever shrinks past the only sql.* reference.
122+
var _ = sql.ErrNoRows

0 commit comments

Comments
 (0)