feat(server): 添加可插拔速率限制系统 | add pluggable rate limiting system (#388)
* feat(server): 添加可插拔速率限制系统 | add pluggable rate limiting system - 新增令牌桶策略 (TokenBucketStrategy) - 推荐用于一般场景 - 新增滑动窗口策略 (SlidingWindowStrategy) - 精确跟踪 - 新增固定窗口策略 (FixedWindowStrategy) - 简单高效 - 新增房间速率限制 mixin (withRateLimit) - 新增速率限制装饰器 (@rateLimit, @noRateLimit) - 新增按消息类型限流装饰器 (@rateLimitMessage, @noRateLimitMessage) - 支持与认证系统组合使用 - 添加中英文文档 - 导出路径: @esengine/server/ratelimit * docs: 更新 README 添加新模块 | update README with new modules - 添加程序化生成 (procgen) 模块 - 添加 RPC 框架模块 - 添加游戏服务器 (server) 模块 - 添加事务系统 (transaction) 模块 - 添加世界流送 (world-streaming) 模块 - 更新网络模块描述 - 更新项目结构目录
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { RateLimitContext } from '../context';
|
||||
import { TokenBucketStrategy } from '../strategies/TokenBucket';
|
||||
import { FixedWindowStrategy } from '../strategies/FixedWindow';
|
||||
|
||||
describe('RateLimitContext', () => {
|
||||
let globalStrategy: TokenBucketStrategy;
|
||||
let context: RateLimitContext;
|
||||
|
||||
beforeEach(() => {
|
||||
globalStrategy = new TokenBucketStrategy({
|
||||
rate: 10,
|
||||
capacity: 20
|
||||
});
|
||||
context = new RateLimitContext('player-123', globalStrategy);
|
||||
});
|
||||
|
||||
describe('check', () => {
|
||||
it('should check without consuming', () => {
|
||||
const result1 = context.check();
|
||||
const result2 = context.check();
|
||||
|
||||
expect(result1.remaining).toBe(result2.remaining);
|
||||
});
|
||||
|
||||
it('should use global strategy by default', () => {
|
||||
const result = context.check();
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('consume', () => {
|
||||
it('should consume from global strategy', () => {
|
||||
const result = context.consume();
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBe(19);
|
||||
});
|
||||
|
||||
it('should track consecutive limits', () => {
|
||||
for (let i = 0; i < 25; i++) {
|
||||
context.consume();
|
||||
}
|
||||
|
||||
expect(context.consecutiveLimitCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should reset consecutive count on success', () => {
|
||||
// Consume all 20 tokens plus some more to trigger rate limiting
|
||||
for (let i = 0; i < 25; i++) {
|
||||
context.consume();
|
||||
}
|
||||
|
||||
// After consuming 25 tokens (20 capacity), 5 should be rate limited
|
||||
expect(context.consecutiveLimitCount).toBeGreaterThan(0);
|
||||
|
||||
context.reset();
|
||||
const result = context.consume();
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(context.consecutiveLimitCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should reset global strategy', () => {
|
||||
for (let i = 0; i < 15; i++) {
|
||||
context.consume();
|
||||
}
|
||||
|
||||
context.reset();
|
||||
|
||||
const status = context.check();
|
||||
expect(status.remaining).toBe(20);
|
||||
});
|
||||
|
||||
it('should reset specific message type', () => {
|
||||
const msgStrategy = new FixedWindowStrategy({ rate: 5, capacity: 5 });
|
||||
context.setMessageStrategy('Trade', msgStrategy);
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
context.consume('Trade');
|
||||
}
|
||||
|
||||
context.reset('Trade');
|
||||
|
||||
const status = context.check('Trade');
|
||||
expect(status.remaining).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('message strategies', () => {
|
||||
it('should use message-specific strategy', () => {
|
||||
const tradeStrategy = new FixedWindowStrategy({ rate: 1, capacity: 1 });
|
||||
context.setMessageStrategy('Trade', tradeStrategy);
|
||||
|
||||
const result1 = context.consume('Trade');
|
||||
expect(result1.allowed).toBe(true);
|
||||
|
||||
const result2 = context.consume('Trade');
|
||||
expect(result2.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it('should fall back to global strategy for unknown types', () => {
|
||||
const result = context.consume('UnknownType');
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBe(19);
|
||||
});
|
||||
|
||||
it('should check if message strategy exists', () => {
|
||||
expect(context.hasMessageStrategy('Trade')).toBe(false);
|
||||
|
||||
const tradeStrategy = new FixedWindowStrategy({ rate: 1, capacity: 1 });
|
||||
context.setMessageStrategy('Trade', tradeStrategy);
|
||||
|
||||
expect(context.hasMessageStrategy('Trade')).toBe(true);
|
||||
});
|
||||
|
||||
it('should remove message strategy', () => {
|
||||
const tradeStrategy = new FixedWindowStrategy({ rate: 1, capacity: 1 });
|
||||
context.setMessageStrategy('Trade', tradeStrategy);
|
||||
|
||||
context.removeMessageStrategy('Trade');
|
||||
|
||||
expect(context.hasMessageStrategy('Trade')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetConsecutiveCount', () => {
|
||||
it('should reset consecutive limit count', () => {
|
||||
for (let i = 0; i < 25; i++) {
|
||||
context.consume();
|
||||
}
|
||||
|
||||
expect(context.consecutiveLimitCount).toBeGreaterThan(0);
|
||||
|
||||
context.resetConsecutiveCount();
|
||||
|
||||
expect(context.consecutiveLimitCount).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
rateLimit,
|
||||
noRateLimit,
|
||||
rateLimitMessage,
|
||||
noRateLimitMessage,
|
||||
getRateLimitMetadata,
|
||||
RATE_LIMIT_METADATA_KEY
|
||||
} from '../decorators/rateLimit';
|
||||
|
||||
describe('rateLimitMessage decorator', () => {
|
||||
class TestClass {
|
||||
@rateLimitMessage('Trade', { messagesPerSecond: 1, burstSize: 2 })
|
||||
handleTrade() {
|
||||
return 'trade';
|
||||
}
|
||||
|
||||
@rateLimitMessage('Move', { cost: 2 })
|
||||
handleMove() {
|
||||
return 'move';
|
||||
}
|
||||
|
||||
undecorated() {
|
||||
return 'undecorated';
|
||||
}
|
||||
}
|
||||
|
||||
describe('metadata storage', () => {
|
||||
it('should store rate limit metadata on target', () => {
|
||||
const metadata = getRateLimitMetadata(TestClass.prototype, 'Trade');
|
||||
expect(metadata).toBeDefined();
|
||||
expect(metadata?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should store config in metadata', () => {
|
||||
const metadata = getRateLimitMetadata(TestClass.prototype, 'Trade');
|
||||
expect(metadata?.config?.messagesPerSecond).toBe(1);
|
||||
expect(metadata?.config?.burstSize).toBe(2);
|
||||
});
|
||||
|
||||
it('should store cost in metadata', () => {
|
||||
const metadata = getRateLimitMetadata(TestClass.prototype, 'Move');
|
||||
expect(metadata?.config?.cost).toBe(2);
|
||||
});
|
||||
|
||||
it('should return undefined for unregistered message types', () => {
|
||||
const metadata = getRateLimitMetadata(TestClass.prototype, 'Unknown');
|
||||
expect(metadata).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('method behavior', () => {
|
||||
it('should not alter method behavior', () => {
|
||||
const instance = new TestClass();
|
||||
expect(instance.handleTrade()).toBe('trade');
|
||||
expect(instance.handleMove()).toBe('move');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('noRateLimitMessage decorator', () => {
|
||||
class TestClass {
|
||||
@noRateLimitMessage('Heartbeat')
|
||||
handleHeartbeat() {
|
||||
return 'heartbeat';
|
||||
}
|
||||
|
||||
@noRateLimitMessage('Ping')
|
||||
handlePing() {
|
||||
return 'ping';
|
||||
}
|
||||
}
|
||||
|
||||
describe('metadata storage', () => {
|
||||
it('should mark message as exempt', () => {
|
||||
const metadata = getRateLimitMetadata(TestClass.prototype, 'Heartbeat');
|
||||
expect(metadata?.exempt).toBe(true);
|
||||
expect(metadata?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should store for multiple messages', () => {
|
||||
const heartbeatMeta = getRateLimitMetadata(TestClass.prototype, 'Heartbeat');
|
||||
const pingMeta = getRateLimitMetadata(TestClass.prototype, 'Ping');
|
||||
|
||||
expect(heartbeatMeta?.exempt).toBe(true);
|
||||
expect(pingMeta?.exempt).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('method behavior', () => {
|
||||
it('should not alter method behavior', () => {
|
||||
const instance = new TestClass();
|
||||
expect(instance.handleHeartbeat()).toBe('heartbeat');
|
||||
expect(instance.handlePing()).toBe('ping');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('combined decorators', () => {
|
||||
class CombinedTestClass {
|
||||
@rateLimitMessage('SlowAction', { messagesPerSecond: 1 })
|
||||
handleSlow() {
|
||||
return 'slow';
|
||||
}
|
||||
|
||||
@noRateLimitMessage('FastAction')
|
||||
handleFast() {
|
||||
return 'fast';
|
||||
}
|
||||
|
||||
@rateLimitMessage('ExpensiveAction', { cost: 10 })
|
||||
handleExpensive() {
|
||||
return 'expensive';
|
||||
}
|
||||
}
|
||||
|
||||
it('should handle multiple different decorators', () => {
|
||||
const slowMeta = getRateLimitMetadata(CombinedTestClass.prototype, 'SlowAction');
|
||||
const fastMeta = getRateLimitMetadata(CombinedTestClass.prototype, 'FastAction');
|
||||
const expensiveMeta = getRateLimitMetadata(CombinedTestClass.prototype, 'ExpensiveAction');
|
||||
|
||||
expect(slowMeta?.enabled).toBe(true);
|
||||
expect(slowMeta?.config?.messagesPerSecond).toBe(1);
|
||||
|
||||
expect(fastMeta?.exempt).toBe(true);
|
||||
expect(fastMeta?.enabled).toBe(false);
|
||||
|
||||
expect(expensiveMeta?.enabled).toBe(true);
|
||||
expect(expensiveMeta?.config?.cost).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RATE_LIMIT_METADATA_KEY', () => {
|
||||
it('should be a symbol', () => {
|
||||
expect(typeof RATE_LIMIT_METADATA_KEY).toBe('symbol');
|
||||
});
|
||||
|
||||
it('should be used for metadata storage', () => {
|
||||
class TestClass {
|
||||
@rateLimitMessage('Test', {})
|
||||
handleTest() {}
|
||||
}
|
||||
|
||||
const metadataMap = (TestClass.prototype as any)[RATE_LIMIT_METADATA_KEY];
|
||||
expect(metadataMap).toBeInstanceOf(Map);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rateLimit decorator (auto-detect)', () => {
|
||||
it('should be a decorator function', () => {
|
||||
expect(typeof rateLimit).toBe('function');
|
||||
expect(typeof rateLimit()).toBe('function');
|
||||
});
|
||||
|
||||
it('should accept config', () => {
|
||||
class TestClass {
|
||||
@rateLimit({ messagesPerSecond: 5 })
|
||||
someMethod() {
|
||||
return 'test';
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new TestClass();
|
||||
expect(instance.someMethod()).toBe('test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('noRateLimit decorator (auto-detect)', () => {
|
||||
it('should be a decorator function', () => {
|
||||
expect(typeof noRateLimit).toBe('function');
|
||||
expect(typeof noRateLimit()).toBe('function');
|
||||
});
|
||||
|
||||
it('should work as decorator', () => {
|
||||
class TestClass {
|
||||
@noRateLimit()
|
||||
someMethod() {
|
||||
return 'test';
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new TestClass();
|
||||
expect(instance.someMethod()).toBe('test');
|
||||
});
|
||||
});
|
||||
157
packages/framework/server/src/ratelimit/__tests__/mixin.test.ts
Normal file
157
packages/framework/server/src/ratelimit/__tests__/mixin.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { Room } from '../../room/Room';
|
||||
import { Player } from '../../room/Player';
|
||||
import { withRateLimit, getPlayerRateLimitContext } from '../mixin/withRateLimit';
|
||||
import { noRateLimitMessage, rateLimitMessage } from '../decorators/rateLimit';
|
||||
import { onMessage } from '../../room/decorators';
|
||||
|
||||
describe('withRateLimit mixin', () => {
|
||||
let RateLimitedRoom: ReturnType<typeof withRateLimit>;
|
||||
|
||||
beforeEach(() => {
|
||||
RateLimitedRoom = withRateLimit(Room, {
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 20
|
||||
});
|
||||
});
|
||||
|
||||
describe('basic functionality', () => {
|
||||
it('should create a rate limited room class', () => {
|
||||
expect(RateLimitedRoom).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have rateLimitStrategy property', () => {
|
||||
class TestRoom extends RateLimitedRoom {
|
||||
onCreate() {}
|
||||
}
|
||||
|
||||
const room = new TestRoom();
|
||||
expect(room.rateLimitStrategy).toBeDefined();
|
||||
expect(room.rateLimitStrategy.name).toBe('token-bucket');
|
||||
});
|
||||
});
|
||||
|
||||
describe('strategy selection', () => {
|
||||
it('should use token-bucket by default', () => {
|
||||
class TestRoom extends withRateLimit(Room) {
|
||||
onCreate() {}
|
||||
}
|
||||
|
||||
const room = new TestRoom();
|
||||
expect(room.rateLimitStrategy.name).toBe('token-bucket');
|
||||
});
|
||||
|
||||
it('should use sliding-window when specified', () => {
|
||||
class TestRoom extends withRateLimit(Room, { strategy: 'sliding-window' }) {
|
||||
onCreate() {}
|
||||
}
|
||||
|
||||
const room = new TestRoom();
|
||||
expect(room.rateLimitStrategy.name).toBe('sliding-window');
|
||||
});
|
||||
|
||||
it('should use fixed-window when specified', () => {
|
||||
class TestRoom extends withRateLimit(Room, { strategy: 'fixed-window' }) {
|
||||
onCreate() {}
|
||||
}
|
||||
|
||||
const room = new TestRoom();
|
||||
expect(room.rateLimitStrategy.name).toBe('fixed-window');
|
||||
});
|
||||
});
|
||||
|
||||
describe('configuration', () => {
|
||||
it('should use default values', () => {
|
||||
class TestRoom extends withRateLimit(Room) {
|
||||
onCreate() {}
|
||||
}
|
||||
|
||||
const room = new TestRoom();
|
||||
expect(room.rateLimitStrategy).toBeDefined();
|
||||
});
|
||||
|
||||
it('should accept custom messagesPerSecond', () => {
|
||||
class TestRoom extends withRateLimit(Room, { messagesPerSecond: 5 }) {
|
||||
onCreate() {}
|
||||
}
|
||||
|
||||
const room = new TestRoom();
|
||||
expect(room.rateLimitStrategy).toBeDefined();
|
||||
});
|
||||
|
||||
it('should accept custom burstSize', () => {
|
||||
class TestRoom extends withRateLimit(Room, { burstSize: 50 }) {
|
||||
onCreate() {}
|
||||
}
|
||||
|
||||
const room = new TestRoom();
|
||||
expect(room.rateLimitStrategy).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dispose', () => {
|
||||
it('should clean up on dispose', () => {
|
||||
class TestRoom extends RateLimitedRoom {
|
||||
onCreate() {}
|
||||
}
|
||||
|
||||
const room = new TestRoom();
|
||||
room._init({
|
||||
id: 'test-room',
|
||||
sendFn: vi.fn(),
|
||||
broadcastFn: vi.fn(),
|
||||
disposeFn: vi.fn()
|
||||
});
|
||||
|
||||
expect(() => room.dispose()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('withRateLimit with auth', () => {
|
||||
it('should be composable with other mixins', () => {
|
||||
class TestRoom extends withRateLimit(Room, { messagesPerSecond: 10 }) {
|
||||
onCreate() {}
|
||||
}
|
||||
|
||||
const room = new TestRoom();
|
||||
expect(room.rateLimitStrategy).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPlayerRateLimitContext', () => {
|
||||
it('should return null for player without context', () => {
|
||||
const mockPlayer = {
|
||||
id: 'player-1',
|
||||
roomId: 'room-1',
|
||||
data: {},
|
||||
send: vi.fn(),
|
||||
leave: vi.fn()
|
||||
} as unknown as Player;
|
||||
|
||||
const context = getPlayerRateLimitContext(mockPlayer);
|
||||
expect(context).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('decorator metadata', () => {
|
||||
it('rateLimitMessage should set metadata', () => {
|
||||
class TestRoom extends withRateLimit(Room) {
|
||||
@rateLimitMessage('Trade', { messagesPerSecond: 1 })
|
||||
@onMessage('Trade')
|
||||
handleTrade() {}
|
||||
}
|
||||
|
||||
expect(TestRoom).toBeDefined();
|
||||
});
|
||||
|
||||
it('noRateLimitMessage should set exempt metadata', () => {
|
||||
class TestRoom extends withRateLimit(Room) {
|
||||
@noRateLimitMessage('Heartbeat')
|
||||
@onMessage('Heartbeat')
|
||||
handleHeartbeat() {}
|
||||
}
|
||||
|
||||
expect(TestRoom).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { TokenBucketStrategy, createTokenBucketStrategy } from '../strategies/TokenBucket';
|
||||
import { SlidingWindowStrategy, createSlidingWindowStrategy } from '../strategies/SlidingWindow';
|
||||
import { FixedWindowStrategy, createFixedWindowStrategy } from '../strategies/FixedWindow';
|
||||
|
||||
describe('TokenBucketStrategy', () => {
|
||||
let strategy: TokenBucketStrategy;
|
||||
|
||||
beforeEach(() => {
|
||||
strategy = createTokenBucketStrategy({
|
||||
rate: 10,
|
||||
capacity: 20
|
||||
});
|
||||
});
|
||||
|
||||
describe('consume', () => {
|
||||
it('should allow requests when tokens available', () => {
|
||||
const result = strategy.consume('user-1');
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBe(19);
|
||||
});
|
||||
|
||||
it('should consume multiple tokens', () => {
|
||||
const result = strategy.consume('user-1', 5);
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBe(15);
|
||||
});
|
||||
|
||||
it('should deny when not enough tokens', () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
strategy.consume('user-1');
|
||||
}
|
||||
|
||||
const result = strategy.consume('user-1');
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.remaining).toBe(0);
|
||||
expect(result.retryAfter).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should refill tokens over time', async () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
strategy.consume('user-1');
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
|
||||
const result = strategy.consume('user-1');
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle different keys independently', () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
strategy.consume('user-1');
|
||||
}
|
||||
|
||||
const result1 = strategy.consume('user-1');
|
||||
const result2 = strategy.consume('user-2');
|
||||
|
||||
expect(result1.allowed).toBe(false);
|
||||
expect(result2.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatus', () => {
|
||||
it('should return full capacity for new key', () => {
|
||||
const status = strategy.getStatus('new-user');
|
||||
expect(status.remaining).toBe(20);
|
||||
expect(status.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should not consume tokens', () => {
|
||||
strategy.getStatus('user-1');
|
||||
const status = strategy.getStatus('user-1');
|
||||
expect(status.remaining).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should reset key to full capacity', () => {
|
||||
for (let i = 0; i < 15; i++) {
|
||||
strategy.consume('user-1');
|
||||
}
|
||||
|
||||
strategy.reset('user-1');
|
||||
|
||||
const status = strategy.getStatus('user-1');
|
||||
expect(status.remaining).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should clean up full buckets', async () => {
|
||||
strategy.consume('user-1');
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
strategy.cleanup();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SlidingWindowStrategy', () => {
|
||||
let strategy: SlidingWindowStrategy;
|
||||
|
||||
beforeEach(() => {
|
||||
strategy = createSlidingWindowStrategy({
|
||||
rate: 10,
|
||||
capacity: 10
|
||||
});
|
||||
});
|
||||
|
||||
describe('consume', () => {
|
||||
it('should allow requests within capacity', () => {
|
||||
const result = strategy.consume('user-1');
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBe(9);
|
||||
});
|
||||
|
||||
it('should deny when capacity exceeded', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
strategy.consume('user-1');
|
||||
}
|
||||
|
||||
const result = strategy.consume('user-1');
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.remaining).toBe(0);
|
||||
});
|
||||
|
||||
it('should allow after window expires', async () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
strategy.consume('user-1');
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||
|
||||
const result = strategy.consume('user-1');
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatus', () => {
|
||||
it('should return full capacity for new key', () => {
|
||||
const status = strategy.getStatus('new-user');
|
||||
expect(status.remaining).toBe(10);
|
||||
expect(status.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should clear timestamps', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
strategy.consume('user-1');
|
||||
}
|
||||
|
||||
strategy.reset('user-1');
|
||||
|
||||
const status = strategy.getStatus('user-1');
|
||||
expect(status.remaining).toBe(10);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FixedWindowStrategy', () => {
|
||||
let strategy: FixedWindowStrategy;
|
||||
|
||||
beforeEach(() => {
|
||||
strategy = createFixedWindowStrategy({
|
||||
rate: 10,
|
||||
capacity: 10
|
||||
});
|
||||
});
|
||||
|
||||
describe('consume', () => {
|
||||
it('should allow requests within capacity', () => {
|
||||
const result = strategy.consume('user-1');
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBe(9);
|
||||
});
|
||||
|
||||
it('should deny when capacity exceeded', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
strategy.consume('user-1');
|
||||
}
|
||||
|
||||
const result = strategy.consume('user-1');
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.retryAfter).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should reset at window boundary', async () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
strategy.consume('user-1');
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||
|
||||
const result = strategy.consume('user-1');
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBe(9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatus', () => {
|
||||
it('should return full capacity for new key', () => {
|
||||
const status = strategy.getStatus('new-user');
|
||||
expect(status.remaining).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should reset count', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
strategy.consume('user-1');
|
||||
}
|
||||
|
||||
strategy.reset('user-1');
|
||||
|
||||
const status = strategy.getStatus('user-1');
|
||||
expect(status.remaining).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should clean up old windows', async () => {
|
||||
strategy.consume('user-1');
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 2100));
|
||||
|
||||
strategy.cleanup();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Factory functions', () => {
|
||||
it('createTokenBucketStrategy should create TokenBucketStrategy', () => {
|
||||
const strategy = createTokenBucketStrategy({ rate: 5, capacity: 10 });
|
||||
expect(strategy.name).toBe('token-bucket');
|
||||
});
|
||||
|
||||
it('createSlidingWindowStrategy should create SlidingWindowStrategy', () => {
|
||||
const strategy = createSlidingWindowStrategy({ rate: 5, capacity: 5 });
|
||||
expect(strategy.name).toBe('sliding-window');
|
||||
});
|
||||
|
||||
it('createFixedWindowStrategy should create FixedWindowStrategy', () => {
|
||||
const strategy = createFixedWindowStrategy({ rate: 5, capacity: 5 });
|
||||
expect(strategy.name).toBe('fixed-window');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user