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
62 changes: 62 additions & 0 deletions internal/plans/rank_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package plans_test

import (
"testing"

"github.com/stretchr/testify/assert"

"instant.dev/internal/plans"
)

// TestRank_TotalOrder asserts the totally-ordered rank for every known tier.
// Plan ladder is anchored to plans.yaml pricing: anonymous=0, free=1, hobby=2,
// hobby_plus=3, pro=4, growth=5, team=6 (pro $49 < growth $99 < team $199).
func TestRank_TotalOrder(t *testing.T) {
cases := []struct {
tier string
want int
}{
{"anonymous", 0},
{"free", 1},
{"hobby", 2},
{"hobby_plus", 3},
{"pro", 4},
{"growth", 5},
{"team", 6},
}
for _, c := range cases {
assert.Equal(t, c.want, plans.Rank(c.tier), "Rank(%q)", c.tier)
}
}

// TestRank_UnknownTier_ReturnsSentinel guards CLAUDE.md rule 22: a typo or
// new-but-not-registered tier must NOT silently rank as 0 (anonymous) — it
// must return -1 so transition-direction callers can refuse to compare.
func TestRank_UnknownTier_ReturnsSentinel(t *testing.T) {
for _, tier := range []string{"", "enterprise", "ultra", "garbage"} {
assert.Equal(t, -1, plans.Rank(tier), "Rank(%q) must be -1", tier)
}
}

// TestRank_YearlyVariants_NotAutoNormalised documents the contract: yearly
// variants do NOT auto-collapse to their base rank. Callers must pass them
// through CanonicalTier first if they want "pro_yearly" to rank as "pro".
func TestRank_YearlyVariants_NotAutoNormalised(t *testing.T) {
// pro_yearly is a distinct registry entry, NOT auto-normalised.
// Whatever its rank is, after CanonicalTier it must match "pro".
assert.Equal(t, plans.Rank("pro"), plans.Rank(plans.CanonicalTier("pro_yearly")))
assert.Equal(t, plans.Rank("hobby"), plans.Rank(plans.CanonicalTier("hobby_yearly")))
assert.Equal(t, plans.Rank("hobby_plus"), plans.Rank(plans.CanonicalTier("hobby_plus_yearly")))
assert.Equal(t, plans.Rank("team"), plans.Rank(plans.CanonicalTier("team_yearly")))
}

// TestRank_StrictlyIncreasing locks the price ladder invariant: each higher
// tier outranks every lower one. A future PR that re-orders the ladder must
// update this test in the same commit (rule 22).
func TestRank_StrictlyIncreasing(t *testing.T) {
ladder := []string{"anonymous", "free", "hobby", "hobby_plus", "pro", "growth", "team"}
for i := 1; i < len(ladder); i++ {
assert.Greater(t, plans.Rank(ladder[i]), plans.Rank(ladder[i-1]),
"Rank(%q) must be > Rank(%q)", ladder[i], ladder[i-1])
}
}
122 changes: 122 additions & 0 deletions internal/quota/quota_edges_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package quota_test

import (
"context"
"database/sql"
"testing"

"github.com/alicebob/miniredis/v2"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"instant.dev/internal/quota"
"instant.dev/internal/testhelpers"
)

// ── LimitBytes ────────────────────────────────────────────────────────────────

// TestLimitBytes_Conversion locks the single MB→bytes conversion point so the
// dashboard number matches the enforcement wall (P2 regression 2026-05-17).
func TestLimitBytes_Conversion(t *testing.T) {
assert.Equal(t, int64(1024*1024), quota.LimitBytes(1))
assert.Equal(t, int64(10*1024*1024), quota.LimitBytes(10))
assert.Equal(t, int64(5120*1024*1024), quota.LimitBytes(5120))
assert.Equal(t, int64(0), quota.LimitBytes(0))
}

// TestLimitBytes_UnlimitedSentinel guards the -1 → UnlimitedLimitBytes mapping.
func TestLimitBytes_UnlimitedSentinel(t *testing.T) {
assert.Equal(t, quota.UnlimitedLimitBytes, quota.LimitBytes(-1))
assert.Equal(t, int64(-1), quota.LimitBytes(-1))
}

// ── CheckAndIncrementToken: Redis-down fail-open ─────────────────────────────

// TestCheckAndIncrementToken_RedisDown_FailsOpen exercises the pipeline-error
// branch in quota.go. When Redis is unreachable we must return (0, false, err)
// — the customer's request must not be blocked by an infra outage.
func TestCheckAndIncrementToken_RedisDown_FailsOpen(t *testing.T) {
mr, err := miniredis.Run()
require.NoError(t, err)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
// Slam Redis closed BEFORE the call so the pipeline errors.
mr.Close()

count, exceeded, err := quota.CheckAndIncrementToken(
context.Background(), rdb, uuid.New().String(), "redis", 10,
)
assert.Error(t, err, "Redis-down must surface an error")
assert.False(t, exceeded, "fail-open: exceeded must be false")
assert.Equal(t, int64(0), count)
}

// ── GetThroughputCount: Redis error ───────────────────────────────────────────

// TestGetThroughputCount_RedisDown_ReturnsError covers the non-redis.Nil error
// branch in GetThroughputCount.
func TestGetThroughputCount_RedisDown_ReturnsError(t *testing.T) {
mr, err := miniredis.Run()
require.NoError(t, err)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
mr.Close()

count, err := quota.GetThroughputCount(
context.Background(), rdb, uuid.New().String(), "redis",
)
assert.Error(t, err)
assert.Equal(t, int64(0), count)
}

// ── CheckStorageQuota: DB error branch ────────────────────────────────────────

// TestCheckStorageQuota_DBClosed_FailsOpen exercises the non-ErrNoRows DB-error
// branch. A closed *sql.DB returns an error that is NOT sql.ErrNoRows, which
// must fail open per the docstring contract.
func TestCheckStorageQuota_DBClosed_FailsOpen(t *testing.T) {
db, cleanDB := testhelpers.SetupTestDB(t)
cleanDB() // close the DB BEFORE the call so QueryRowContext errors.

used, exceeded, err := quota.CheckStorageQuota(
context.Background(), db, uuid.New(), 10,
)
assert.Error(t, err, "closed DB must surface an error")
assert.False(t, exceeded, "fail-open: exceeded must be false on DB error")
assert.Equal(t, int64(0), used)
}

// TestCheckStorageQuota_ContextCancelled_FailsOpen covers the same error branch
// via a cancelled context — independent failure mode.
func TestCheckStorageQuota_ContextCancelled_FailsOpen(t *testing.T) {
db, cleanDB := testhelpers.SetupTestDB(t)
defer cleanDB()

ctx, cancel := context.WithCancel(context.Background())
cancel()

_, exceeded, err := quota.CheckStorageQuota(ctx, db, uuid.New(), 10)
// Either ErrNoRows is masked by cancellation (err returned + exceeded false)
// or the row genuinely doesn't exist (no err, exceeded false). Both satisfy
// fail-open. The contract: exceeded must be false.
assert.False(t, exceeded)
_ = err
}

// ── UpdateStorageBytes: DB error branch ───────────────────────────────────────

// TestUpdateStorageBytes_DBClosed_ReturnsError covers the wrapped-error branch
// of UpdateStorageBytes. Unlike the read path, this one does NOT fail open —
// the caller (the storage-bytes worker) is expected to retry.
func TestUpdateStorageBytes_DBClosed_ReturnsError(t *testing.T) {
db, cleanDB := testhelpers.SetupTestDB(t)
cleanDB()

err := quota.UpdateStorageBytes(context.Background(), db, uuid.New(), 1024)
assert.Error(t, err, "closed DB must surface an error for UpdateStorageBytes")
assert.ErrorContains(t, err, "UpdateStorageBytes")
}

// Compile-time guard so the unused sql import is not flagged if the test
// matrix above ever shrinks past the only sql.* reference.
var _ = sql.ErrNoRows
Loading