import { describe, it, expect, beforeEach, vi } from 'vitest'; import { Request, Response } from 'express'; import { perEmailCooldown } from '../../src/api/middlewares/rate-limiters'; import { AppError } from '../../src/utils/errors'; describe('Rate Limit Middleware', () => { describe('perEmailCooldown', () => { let req: Partial; let res: Partial; let next: ReturnType; beforeEach(() => { req = { body: {}, }; res = {}; next = vi.fn(); }); it('allows first request for an email', () => { req.body = { email: 'test@example.com' }; const middleware = perEmailCooldown(60000); middleware(req as Request, res as Response, next); expect(next).toHaveBeenCalledOnce(); expect(next).toHaveBeenCalledWith(); }); it('blocks second request within cooldown period', () => { req.body = { email: 'test2@example.com' }; const middleware = perEmailCooldown(60000); // First request should pass middleware(req as Request, res as Response, next); expect(next).toHaveBeenCalledOnce(); // Second request should be blocked expect(() => { middleware(req as Request, res as Response, next); }).toThrow(AppError); expect(() => { middleware(req as Request, res as Response, next); }).toThrow(/Please wait.*seconds before requesting another code/); }); it('allows request after cooldown period expires', async () => { req.body = { email: 'test3@example.com' }; const shortCooldown = 100; // 100ms cooldown const middleware = perEmailCooldown(shortCooldown); // First request middleware(req as Request, res as Response, next); expect(next).toHaveBeenCalledTimes(1); // Wait for cooldown to expire await new Promise((resolve) => setTimeout(resolve, shortCooldown + 10)); // Second request after cooldown should pass middleware(req as Request, res as Response, next); expect(next).toHaveBeenCalledTimes(2); }); it('treats emails case-insensitively', () => { const middleware = perEmailCooldown(60000); const uniqueEmail = `case-test-${Date.now()}@example.com`; // First request with mixed case req.body = { email: uniqueEmail.toUpperCase() }; middleware(req as Request, res as Response, next); expect(next).toHaveBeenCalledOnce(); // Second request with lowercase should be blocked req.body = { email: uniqueEmail.toLowerCase() }; expect(() => { middleware(req as Request, res as Response, next); }).toThrow(AppError); }); it('allows requests for different emails', () => { const middleware = perEmailCooldown(60000); // Request for first email req.body = { email: 'user1@example.com' }; middleware(req as Request, res as Response, next); expect(next).toHaveBeenCalledTimes(1); // Request for second email should also pass req.body = { email: 'user2@example.com' }; middleware(req as Request, res as Response, next); expect(next).toHaveBeenCalledTimes(2); }); it('passes through when no email in body', () => { req.body = {}; // No email const middleware = perEmailCooldown(60000); middleware(req as Request, res as Response, next); expect(next).toHaveBeenCalledOnce(); expect(next).toHaveBeenCalledWith(); }); it('calculates remaining cooldown time correctly', () => { req.body = { email: 'timing@example.com' }; const cooldownMs = 60000; const middleware = perEmailCooldown(cooldownMs); // First request middleware(req as Request, res as Response, next); // Try second request immediately try { middleware(req as Request, res as Response, next); } catch (error) { if (error instanceof AppError) { // Should show approximately 60 seconds remaining expect(error.message).toMatch(/wait (\d+) seconds/); const match = error.message.match(/wait (\d+) seconds/); if (match) { const seconds = parseInt(match[1]); expect(seconds).toBeGreaterThanOrEqual(59); expect(seconds).toBeLessThanOrEqual(60); } } } }); it('uses custom cooldown duration', () => { req.body = { email: 'custom@example.com' }; const customCooldown = 30000; // 30 seconds const middleware = perEmailCooldown(customCooldown); // First request middleware(req as Request, res as Response, next); // Second request should show 30 second cooldown try { middleware(req as Request, res as Response, next); } catch (error) { if (error instanceof AppError) { expect(error.message).toMatch(/wait \d+ seconds/); const match = error.message.match(/wait (\d+) seconds/); if (match) { const seconds = parseInt(match[1]); expect(seconds).toBeGreaterThanOrEqual(29); expect(seconds).toBeLessThanOrEqual(30); } } } }); }); });