diff --git a/src/lib/server/ai/rate-limit.ts b/src/lib/server/ai/rate-limit.ts new file mode 100644 index 0000000..b64fccb --- /dev/null +++ b/src/lib/server/ai/rate-limit.ts @@ -0,0 +1,21 @@ +export type RateLimiter = { check: (key: string) => boolean }; + +export function createRateLimiter(opts: { + windowMs: number; + max: number; +}): RateLimiter { + const store = new Map(); + return { + check(key: string): boolean { + const now = Date.now(); + const entry = store.get(key); + if (!entry || entry.resetAt <= now) { + store.set(key, { count: 1, resetAt: now + opts.windowMs }); + return true; + } + if (entry.count >= opts.max) return false; + entry.count += 1; + return true; + } + }; +} diff --git a/tests/unit/rate-limit.test.ts b/tests/unit/rate-limit.test.ts new file mode 100644 index 0000000..46419e9 --- /dev/null +++ b/tests/unit/rate-limit.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createRateLimiter } from '../../src/lib/server/ai/rate-limit'; + +describe('rate-limit', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('allows first 10 requests, rejects 11th', () => { + const limiter = createRateLimiter({ windowMs: 60_000, max: 10 }); + for (let i = 0; i < 10; i++) expect(limiter.check('1.2.3.4')).toBe(true); + expect(limiter.check('1.2.3.4')).toBe(false); + }); + + it('tracks per-IP independently', () => { + const limiter = createRateLimiter({ windowMs: 60_000, max: 2 }); + expect(limiter.check('a')).toBe(true); + expect(limiter.check('a')).toBe(true); + expect(limiter.check('a')).toBe(false); + expect(limiter.check('b')).toBe(true); + }); + + it('resets after window elapses', () => { + const limiter = createRateLimiter({ windowMs: 1000, max: 1 }); + expect(limiter.check('x')).toBe(true); + expect(limiter.check('x')).toBe(false); + vi.advanceTimersByTime(1001); + expect(limiter.check('x')).toBe(true); + }); +});