151 lines
6.5 KiB
TypeScript
151 lines
6.5 KiB
TypeScript
import type { createClient } from '@supabase/supabase-js'
|
|
import { checkQuota, consumeQuota } from './quota.ts'
|
|
import { isQuotaAllowed, parseQuotaConsumeResponse, quotaWindowStart } from './quota-policy.ts'
|
|
|
|
function assert(condition: boolean, message: string): asserts condition {
|
|
if (!condition) throw new Error(message)
|
|
}
|
|
|
|
type Row = Record<string, unknown>
|
|
type ServiceClient = ReturnType<typeof createClient>
|
|
|
|
/** Minimal in-memory stand-in for the supabase-js query builder used by quota.ts. */
|
|
class FakeQuery implements PromiseLike<{ data: unknown; error: null }> {
|
|
private filters: Array<(row: Row) => boolean> = []
|
|
private singleRow = false
|
|
constructor(private rows: Row[]) {}
|
|
select(_columns: string): this {
|
|
return this
|
|
}
|
|
eq(column: string, value: unknown): this {
|
|
this.filters.push((row) => row[column] === value)
|
|
return this
|
|
}
|
|
gte(column: string, value: string): this {
|
|
this.filters.push((row) => String(row[column]) >= value)
|
|
return this
|
|
}
|
|
lte(column: string, value: string): this {
|
|
this.filters.push((row) => String(row[column]) <= value)
|
|
return this
|
|
}
|
|
single(): this {
|
|
this.singleRow = true
|
|
return this
|
|
}
|
|
then<T1 = { data: unknown; error: null }, T2 = never>(
|
|
onfulfilled?: ((value: { data: unknown; error: null }) => T1 | PromiseLike<T1>) | null,
|
|
onrejected?: ((reason: unknown) => T2 | PromiseLike<T2>) | null,
|
|
): PromiseLike<T1 | T2> {
|
|
const matched = this.rows.filter((row) => this.filters.every((f) => f(row)))
|
|
const data = this.singleRow ? (matched[0] ?? null) : matched
|
|
return Promise.resolve({ data, error: null }).then(onfulfilled, onrejected)
|
|
}
|
|
}
|
|
|
|
class FakeQuotaClient {
|
|
tables: Record<string, Row[]> = { subscriptions: [], daily_usage: [] }
|
|
rpcCalls: Array<{ name: string; args: Record<string, unknown> }> = []
|
|
rpcResult: unknown = {
|
|
allowed: true,
|
|
current: 1,
|
|
limit: 250,
|
|
overage_credits: 0,
|
|
consumed_from: 'overage',
|
|
}
|
|
from(table: string): FakeQuery {
|
|
return new FakeQuery(this.tables[table] ?? [])
|
|
}
|
|
rpc(name: string, args: Record<string, unknown>): Promise<{ data: unknown; error: null }> {
|
|
this.rpcCalls.push({ name, args })
|
|
return Promise.resolve({ data: this.rpcResult, error: null })
|
|
}
|
|
asClient(): ServiceClient {
|
|
return this as unknown as ServiceClient
|
|
}
|
|
}
|
|
|
|
const NOW = new Date('2026-09-28T12:00:00Z')
|
|
const USER = 'user-1'
|
|
|
|
function freeUserWithUsage(rows: Array<[string, number]>, overageCredits = 0): FakeQuotaClient {
|
|
const client = new FakeQuotaClient()
|
|
client.tables.subscriptions.push({ user_id: USER, tier: 'free', overage_credits: overageCredits })
|
|
for (const [date, count] of rows) {
|
|
client.tables.daily_usage.push({ user_id: USER, feature: 'llm_haiku', date, count })
|
|
}
|
|
return client
|
|
}
|
|
|
|
Deno.test('weekly window is today-6..today (7 calendar days), same as the SQL quota functions', () => {
|
|
assert(quotaWindowStart('weekly', NOW) === '2026-09-22', `weekly start ${quotaWindowStart('weekly', NOW)}`)
|
|
assert(quotaWindowStart('daily', NOW) === '2026-09-28', 'daily start is today')
|
|
// Month boundary in UTC.
|
|
assert(quotaWindowStart('weekly', new Date('2026-10-03T00:30:00Z')) === '2026-09-27', 'month rollover')
|
|
})
|
|
|
|
Deno.test('usage from 7 days ago no longer blocks a free user (8-day window regression)', async () => {
|
|
const client = freeUserWithUsage([['2026-09-21', 250], ['2026-09-27', 10]])
|
|
const check = await checkQuota(USER, 'llm_haiku', client.asClient(), NOW)
|
|
assert(check.current === 10, `stale usage counted: current=${check.current}`)
|
|
assert(check.allowed, 'free user blocked by usage that already rolled off')
|
|
assert(check.period === 'weekly' && check.limit === 250, 'free haiku policy')
|
|
})
|
|
|
|
Deno.test('250 uses spread across 6 days reach the weekly limit; a credit keeps the call allowed', async () => {
|
|
const days: Array<[string, number]> = [
|
|
['2026-09-22', 45], ['2026-09-23', 41], ['2026-09-24', 41],
|
|
['2026-09-25', 41], ['2026-09-26', 41], ['2026-09-27', 41],
|
|
]
|
|
const noCredit = await checkQuota(USER, 'llm_haiku', freeUserWithUsage(days).asClient(), NOW)
|
|
assert(noCredit.current === 250 && !noCredit.allowed, `limit not enforced: ${JSON.stringify(noCredit)}`)
|
|
const withCredit = await checkQuota(USER, 'llm_haiku', freeUserWithUsage(days, 1).asClient(), NOW)
|
|
assert(withCredit.allowed && withCredit.overageCredits === 1, 'credit should allow the call')
|
|
})
|
|
|
|
Deno.test('consumeQuota asks the RPC to consume over the policy period', async () => {
|
|
const client = freeUserWithUsage([])
|
|
const result = await consumeQuota(USER, 'llm_haiku', client.asClient(), 250)
|
|
const call = client.rpcCalls[0]
|
|
assert(call?.name === 'consume_quota', 'consume_quota not called')
|
|
assert(call.args.p_period === 'weekly', `free haiku must consume weekly, got ${String(call.args.p_period)}`)
|
|
assert(call.args.p_base_limit === 250 && call.args.p_feature === 'llm_haiku', 'limit/feature passed through')
|
|
assert(result.consumedFrom === 'overage' && result.overageCredits === 0, 'response mapped')
|
|
|
|
const explicit = new FakeQuotaClient()
|
|
await consumeQuota(USER, 'realtime_session', explicit.asClient(), 30, 'daily')
|
|
assert(explicit.rpcCalls[0]?.args.p_period === 'daily', 'explicit period passed as-is')
|
|
})
|
|
|
|
Deno.test('paid daily policies consume over a daily window', async () => {
|
|
const client = new FakeQuotaClient()
|
|
client.tables.subscriptions.push({ user_id: USER, tier: 'pro', overage_credits: 0 })
|
|
await consumeQuota(USER, 'llm_sonnet', client.asClient(), 300)
|
|
assert(client.rpcCalls[0]?.args.p_period === 'daily', 'pro sonnet is daily')
|
|
})
|
|
|
|
Deno.test('allow decision: 0 = unavailable even with credits, -1 = unlimited, credits extend the base', () => {
|
|
assert(!isQuotaAllowed(0, 0, 5), 'unavailable feature allowed by credits')
|
|
assert(isQuotaAllowed(-1, 10_000, 0), 'unlimited denied')
|
|
assert(isQuotaAllowed(250, 249, 0), 'under limit denied')
|
|
assert(!isQuotaAllowed(250, 250, 0), 'at limit without credit allowed')
|
|
assert(isQuotaAllowed(250, 250, 1), 'at limit with credit denied')
|
|
})
|
|
|
|
Deno.test('RPC response parser rejects malformed payloads', () => {
|
|
const ok = parseQuotaConsumeResponse({
|
|
allowed: false, current: 250, limit: 250, overage_credits: 0, consumed_from: 'none',
|
|
})
|
|
assert(!ok.allowed && ok.consumedFrom === 'none' && ok.current === 250, 'valid payload mapped')
|
|
for (const bad of [null, [], { allowed: true }, {
|
|
allowed: true, current: 1, limit: 1, overage_credits: 0, consumed_from: 'bogus',
|
|
}]) {
|
|
let threw = false
|
|
try {
|
|
parseQuotaConsumeResponse(bad)
|
|
} catch {
|
|
threw = true
|
|
}
|
|
assert(threw, `accepted ${JSON.stringify(bad)}`)
|
|
}
|
|
})
|