Files
fluxer/packages/errors/src/__tests__/RateLimitError.test.ts
T

103 lines
3.5 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
interface RateLimitResponseBody {
code: string;
retry_after: number;
global: boolean;
}
describe('RateLimitError', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-27T12:00:00.000Z'));
});
afterEach(() => {
vi.useRealTimers();
});
it('returns bucket headers for route limits', async () => {
const error = new RateLimitError({
retryAfter: 5,
retryAfterDecimal: 4.5,
limit: 10,
resetTime: new Date('2026-01-27T12:00:05.000Z'),
resetAfterDecimal: 4.5,
bucketHash: 'bucket-hash',
scope: 'user',
});
const response = error.getResponse();
const body = (await response.json()) as RateLimitResponseBody;
expect(response.status).toBe(429);
expect(body).toMatchObject({
code: 'RATE_LIMITED',
retry_after: 4.5,
global: false,
});
expect(response.headers.get('Retry-After')).toBe('5');
expect(response.headers.get('X-RateLimit-Limit')).toBe('10');
expect(response.headers.get('X-RateLimit-Remaining')).toBe('0');
expect(response.headers.get('X-RateLimit-Reset')).toBe(
Math.floor(new Date('2026-01-27T12:00:05.000Z').getTime() / 1000).toString(),
);
expect(response.headers.get('X-RateLimit-Reset-After')).toBe('4.5');
expect(response.headers.get('X-RateLimit-Bucket')).toBe('bucket-hash');
expect(response.headers.get('X-RateLimit-Scope')).toBe('user');
expect(response.headers.get('X-RateLimit-Global')).toBeNull();
});
it('returns global headers without bucket metadata', async () => {
const error = new RateLimitError({
global: true,
retryAfter: 2,
retryAfterDecimal: 1.5,
limit: 50,
resetTime: new Date('2026-01-27T12:00:02.000Z'),
scope: 'global',
});
const response = error.getResponse();
const body = (await response.json()) as RateLimitResponseBody;
expect(body).toMatchObject({
code: 'RATE_LIMITED',
retry_after: 1.5,
global: true,
});
expect(response.headers.get('Retry-After')).toBe('2');
expect(response.headers.get('X-RateLimit-Global')).toBe('true');
expect(response.headers.get('X-RateLimit-Scope')).toBe('global');
expect(response.headers.get('X-RateLimit-Limit')).toBeNull();
expect(response.headers.get('X-RateLimit-Bucket')).toBeNull();
expect(response.headers.get('X-RateLimit-Reset-After')).toBeNull();
});
it('sanitizes invalid retry-after and reset values', () => {
const error = new RateLimitError({
retryAfter: NaN,
retryAfterDecimal: Number.POSITIVE_INFINITY,
limit: NaN,
resetTime: new Date(NaN),
});
const response = error.getResponse();
const resetTimestamp = Number(response.headers.get('X-RateLimit-Reset'));
expect(response.headers.get('Retry-After')).toBe('1');
expect(response.headers.get('X-RateLimit-Limit')).toBe('1');
expect(response.headers.get('X-RateLimit-Reset-After')).toBe('1');
expect(Number.isFinite(resetTimestamp)).toBe(true);
expect(resetTimestamp).toBeGreaterThan(Math.floor(Date.now() / 1000));
});
it('defaults scope to global or user when omitted', () => {
const globalError = new RateLimitError({
global: true,
retryAfter: 1,
limit: 10,
resetTime: new Date('2026-01-27T12:00:01.000Z'),
});
const userError = new RateLimitError({
retryAfter: 1,
limit: 10,
resetTime: new Date('2026-01-27T12:00:01.000Z'),
});
expect(globalError.headers?.['X-RateLimit-Scope']).toBe('global');
expect(userError.headers?.['X-RateLimit-Scope']).toBe('user');
});
});