137 lines
3.8 KiB
TypeScript
137 lines
3.8 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
|
|
type RateLimitResult = {
|
|
allowed: boolean;
|
|
remaining: number;
|
|
resetAt: number;
|
|
};
|
|
|
|
const mockCheckRateLimit = vi.fn<[], Promise<RateLimitResult>>();
|
|
const mockGetClientIp = vi.fn<[], string>();
|
|
|
|
vi.mock('../../../apps/website/lib/rate-limit', () => ({
|
|
checkRateLimit: (...args: unknown[]) => mockCheckRateLimit(...(args as [])),
|
|
getClientIp: (..._args: unknown[]) => mockGetClientIp(),
|
|
}));
|
|
|
|
async function getPostHandler() {
|
|
const routeModule = (await import(
|
|
'../../../apps/website/app/api/signup/route'
|
|
)) as { POST: (request: Request) => Promise<Response> };
|
|
return routeModule.POST;
|
|
}
|
|
|
|
function createJsonRequest(body: unknown): Request {
|
|
return new Request('http://localhost/api/signup', {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
describe('/api/signup POST', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
mockCheckRateLimit.mockReset();
|
|
mockGetClientIp.mockReset();
|
|
|
|
mockGetClientIp.mockReturnValue('127.0.0.1');
|
|
mockCheckRateLimit.mockResolvedValue({
|
|
allowed: true,
|
|
remaining: 4,
|
|
resetAt: Date.now() + 60 * 60 * 1000,
|
|
});
|
|
});
|
|
|
|
it('accepts a valid email within rate limits and returns success payload', async () => {
|
|
const POST = await getPostHandler();
|
|
|
|
const response = await POST(
|
|
createJsonRequest({
|
|
email: 'user@example.com',
|
|
}),
|
|
);
|
|
|
|
expect(response.status).toBeGreaterThanOrEqual(200);
|
|
expect(response.status).toBeLessThan(300);
|
|
|
|
const data = (await response.json()) as { message: unknown; ok: unknown };
|
|
|
|
expect(data).toHaveProperty('message');
|
|
expect(typeof data.message).toBe('string');
|
|
expect(data).toHaveProperty('ok', true);
|
|
});
|
|
|
|
it('rejects an invalid email with 400 and error message', async () => {
|
|
const POST = await getPostHandler();
|
|
|
|
const response = await POST(
|
|
createJsonRequest({
|
|
email: 'not-an-email',
|
|
}),
|
|
);
|
|
|
|
expect(response.status).toBe(400);
|
|
|
|
const data = (await response.json()) as { error: unknown };
|
|
expect(typeof data.error).toBe('string');
|
|
expect(data.error.toLowerCase()).toContain('email');
|
|
});
|
|
|
|
it('rejects disposable email domains with 400 and error message', async () => {
|
|
const POST = await getPostHandler();
|
|
|
|
const response = await POST(
|
|
createJsonRequest({
|
|
email: 'foo@mailinator.com',
|
|
}),
|
|
);
|
|
|
|
expect(response.status).toBe(400);
|
|
|
|
const data = (await response.json()) as { error: unknown };
|
|
expect(typeof data.error).toBe('string');
|
|
});
|
|
|
|
it('returns 409 and friendly message when email is already subscribed', async () => {
|
|
const POST = await getPostHandler();
|
|
|
|
const email = 'duplicate@example.com';
|
|
|
|
const first = await POST(createJsonRequest({ email }));
|
|
expect(first.status).toBeGreaterThanOrEqual(200);
|
|
expect(first.status).toBeLessThan(300);
|
|
|
|
const second = await POST(createJsonRequest({ email }));
|
|
|
|
expect(second.status).toBe(409);
|
|
|
|
const data = (await second.json()) as { error: unknown };
|
|
expect(typeof data.error).toBe('string');
|
|
expect(data.error.toLowerCase()).toContain('already');
|
|
});
|
|
|
|
it('returns 429 with retryAfter when rate limit is exceeded', async () => {
|
|
mockCheckRateLimit.mockResolvedValueOnce({
|
|
allowed: false,
|
|
remaining: 0,
|
|
resetAt: Date.now() + 30_000,
|
|
});
|
|
|
|
const POST = await getPostHandler();
|
|
|
|
const response = await POST(
|
|
createJsonRequest({
|
|
email: 'limited@example.com',
|
|
}),
|
|
);
|
|
|
|
expect(response.status).toBe(429);
|
|
|
|
const data = (await response.json()) as { error: unknown; retryAfter?: unknown };
|
|
expect(typeof data.error).toBe('string');
|
|
expect(data).toHaveProperty('retryAfter');
|
|
});
|
|
}); |