fluxer/packages/errors/src/__tests__/RateLimitError.test.tsx
2026-02-17 12:22:36 +00:00

298 lines
8.1 KiB
TypeScript

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
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();
});
describe('valid inputs', () => {
it('should create error with valid values', () => {
const error = new RateLimitError({
retryAfter: 5,
limit: 10,
resetTime: new Date(Date.now() + 5000),
});
expect(error.code).toBe('RATE_LIMITED');
expect(error.status).toBe(429);
expect(error.data?.retry_after).toBe(5);
expect(error.headers?.['Retry-After']).toBe('5');
expect(error.headers?.['X-RateLimit-Limit']).toBe('10');
});
it('should use retryAfterDecimal when provided', () => {
const error = new RateLimitError({
retryAfter: 5,
retryAfterDecimal: 4.5,
limit: 10,
resetTime: new Date(Date.now() + 5000),
});
expect(error.data?.retry_after).toBe(4.5);
expect(error.headers?.['Retry-After']).toBe('5');
});
it('should set global flag correctly', () => {
const globalError = new RateLimitError({
global: true,
retryAfter: 1,
limit: 50,
resetTime: new Date(Date.now() + 1000),
});
expect(globalError.data?.global).toBe(true);
expect(globalError.headers?.['X-RateLimit-Global']).toBe('true');
const bucketError = new RateLimitError({
global: false,
retryAfter: 1,
limit: 10,
resetTime: new Date(Date.now() + 1000),
});
expect(bucketError.data?.global).toBe(false);
expect(bucketError.headers?.['X-RateLimit-Global']).toBe('false');
});
});
describe('sanitization of invalid inputs', () => {
it('should handle undefined retryAfter', () => {
const error = new RateLimitError({
retryAfter: undefined as unknown as number,
limit: 10,
resetTime: new Date(Date.now() + 5000),
});
expect(error.headers?.['Retry-After']).toBe('1');
expect(Number.isNaN(Number(error.headers?.['Retry-After']))).toBe(false);
});
it('should handle NaN retryAfter', () => {
const error = new RateLimitError({
retryAfter: NaN,
limit: 10,
resetTime: new Date(Date.now() + 5000),
});
expect(error.headers?.['Retry-After']).toBe('1');
expect(error.data?.retry_after).toBe(1);
});
it('should handle Infinity retryAfter', () => {
const error = new RateLimitError({
retryAfter: Infinity,
limit: 10,
resetTime: new Date(Date.now() + 5000),
});
expect(error.headers?.['Retry-After']).toBe('1');
});
it('should handle negative retryAfter', () => {
const error = new RateLimitError({
retryAfter: -5,
limit: 10,
resetTime: new Date(Date.now() + 5000),
});
expect(error.headers?.['Retry-After']).toBe('1');
});
it('should handle zero retryAfter', () => {
const error = new RateLimitError({
retryAfter: 0,
limit: 10,
resetTime: new Date(Date.now() + 5000),
});
expect(error.headers?.['Retry-After']).toBe('1');
});
it('should handle invalid resetTime', () => {
const error = new RateLimitError({
retryAfter: 5,
limit: 10,
resetTime: new Date(NaN),
});
const resetTimestamp = Number(error.headers?.['X-RateLimit-Reset']);
const nowTimestamp = Math.floor(Date.now() / 1000);
expect(Number.isFinite(resetTimestamp)).toBe(true);
expect(resetTimestamp).toBeGreaterThan(nowTimestamp);
});
it('should handle resetTime in the past', () => {
const error = new RateLimitError({
retryAfter: 5,
limit: 10,
resetTime: new Date(Date.now() - 10000),
});
const resetTimestamp = Number(error.headers?.['X-RateLimit-Reset']);
const nowTimestamp = Math.floor(Date.now() / 1000);
expect(resetTimestamp).toBeGreaterThan(nowTimestamp);
});
it('should handle invalid limit', () => {
const error = new RateLimitError({
retryAfter: 5,
limit: NaN,
resetTime: new Date(Date.now() + 5000),
});
expect(error.headers?.['X-RateLimit-Limit']).toBe('1');
});
it('should handle zero limit', () => {
const error = new RateLimitError({
retryAfter: 5,
limit: 0,
resetTime: new Date(Date.now() + 5000),
});
expect(error.headers?.['X-RateLimit-Limit']).toBe('1');
});
it('should handle NaN retryAfterDecimal', () => {
const error = new RateLimitError({
retryAfter: 5,
retryAfterDecimal: NaN,
limit: 10,
resetTime: new Date(Date.now() + 5000),
});
expect(error.data?.retry_after).toBe(5);
});
});
describe('response format', () => {
it('should produce valid JSON response', async () => {
const error = new RateLimitError({
retryAfter: 10,
limit: 50,
resetTime: new Date(Date.now() + 10000),
});
const response = error.getResponse();
const body = (await response.json()) as RateLimitResponseBody;
expect(body.code).toBe('RATE_LIMITED');
expect(body.retry_after).toBe(10);
expect(body.global).toBe(false);
expect(typeof body.retry_after).toBe('number');
});
it('should never produce null retry_after in response', async () => {
const error = new RateLimitError({
retryAfter: undefined as unknown as number,
retryAfterDecimal: undefined,
limit: 10,
resetTime: new Date(Date.now() + 5000),
});
const response = error.getResponse();
const body = (await response.json()) as RateLimitResponseBody;
expect(body.retry_after).not.toBeNull();
expect(typeof body.retry_after).toBe('number');
expect(Number.isFinite(body.retry_after)).toBe(true);
});
it('should set correct status code', () => {
const error = new RateLimitError({
retryAfter: 5,
limit: 10,
resetTime: new Date(Date.now() + 5000),
});
const response = error.getResponse();
expect(response.status).toBe(429);
});
it('should include all required headers', () => {
const error = new RateLimitError({
retryAfter: 5,
limit: 10,
resetTime: new Date(Date.now() + 5000),
global: true,
});
const response = error.getResponse();
expect(response.headers.get('Retry-After')).toBe('5');
expect(response.headers.get('X-RateLimit-Global')).toBe('true');
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')).not.toBeNull();
});
});
describe('edge cases', () => {
it('should handle all inputs being invalid simultaneously', () => {
const error = new RateLimitError({
retryAfter: NaN,
retryAfterDecimal: Infinity,
limit: -1,
resetTime: new Date(NaN),
});
expect(error.headers?.['Retry-After']).toBe('1');
expect(error.headers?.['X-RateLimit-Limit']).toBe('1');
expect(Number.isFinite(Number(error.headers?.['X-RateLimit-Reset']))).toBe(true);
expect(Number.isFinite(error.data?.retry_after as number)).toBe(true);
});
it('should handle very large retryAfter values', () => {
const error = new RateLimitError({
retryAfter: 999999999,
limit: 10,
resetTime: new Date(Date.now() + 999999999000),
});
expect(error.headers?.['Retry-After']).toBe('999999999');
});
it('should ceil fractional retryAfter for header', () => {
const error = new RateLimitError({
retryAfter: 1.1,
limit: 10,
resetTime: new Date(Date.now() + 2000),
});
expect(error.headers?.['Retry-After']).toBe('2');
});
});
});