import { describe, it, expect, vi, beforeEach } from 'vitest'; import { NextRequest } from 'next/server'; const { mockSelect, mockInsert } = vi.hoisted(() => ({ mockSelect: vi.fn(), mockInsert: vi.fn(), })); vi.mock('@/lib/db', () => ({ db: { select: mockSelect, insert: mockInsert } })); vi.mock('@/lib/site-config', () => ({ isSignupsEnabled: vi.fn().mockResolvedValue(true) })); vi.mock('bcryptjs', () => ({ default: { hash: vi.fn().mockResolvedValue('hashed_pw'), compare: vi.fn() }, })); import { POST } from '../route'; import bcrypt from 'bcryptjs'; const VALID_BODY = { email: 'user@example.com', name: 'Test User', password: 'securepassword123' }; function makeRequest(body: unknown) { return new NextRequest('http://localhost/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); } function setupDefault({ existingUser = false } = {}) { mockSelect.mockImplementation((fields: Record | undefined) => { if (fields && 'total' in fields) { // count() query — first user, so total = 0 return { from: vi.fn().mockResolvedValue([{ total: 0 }]) }; } // email duplicate check const rows = existingUser ? [{ id: 'existing' }] : []; return { from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue(rows) }) }) }; }); mockInsert.mockReturnValue({ values: vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([{ id: 'new-user-uuid' }]) }), }); } describe('POST /api/auth/register', () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(bcrypt.hash).mockResolvedValue('hashed_pw' as never); setupDefault(); }); it('returns 400 on bad JSON', async () => { const req = new NextRequest('http://localhost/api/auth/register', { method: 'POST', body: '{not json', headers: { 'Content-Type': 'application/json' }, }); const res = await POST(req); expect(res.status).toBe(400); }); it('returns 400 when email missing', async () => { const res = await POST(makeRequest({ name: 'Test', password: 'password123' })); expect(res.status).toBe(400); }); it('returns 400 when email invalid', async () => { const res = await POST(makeRequest({ ...VALID_BODY, email: 'not-an-email' })); expect(res.status).toBe(400); }); it('returns 400 when name missing', async () => { const res = await POST(makeRequest({ email: 'a@b.com', password: 'password123' })); expect(res.status).toBe(400); }); it('returns 400 when name empty', async () => { const res = await POST(makeRequest({ ...VALID_BODY, name: '' })); expect(res.status).toBe(400); }); it('returns 400 when password too short', async () => { const res = await POST(makeRequest({ ...VALID_BODY, password: 'short' })); expect(res.status).toBe(400); }); it('returns 400 when password over 128 chars', async () => { const res = await POST(makeRequest({ ...VALID_BODY, password: 'a'.repeat(129) })); expect(res.status).toBe(400); }); it('returns 409 when email already registered', async () => { setupDefault({ existingUser: true }); const res = await POST(makeRequest(VALID_BODY)); expect(res.status).toBe(409); const body = await res.json(); expect(body.error).toMatch(/already registered/i); }); it('returns 201 with userId on success', async () => { const res = await POST(makeRequest(VALID_BODY)); expect(res.status).toBe(201); const body = await res.json(); expect(body.userId).toBe('new-user-uuid'); }); it('hashes password with cost 12', async () => { await POST(makeRequest(VALID_BODY)); expect(bcrypt.hash).toHaveBeenCalledWith(VALID_BODY.password, 12); }); it('stores hashed password not plaintext', async () => { await POST(makeRequest(VALID_BODY)); const valuesMock = vi.mocked(mockInsert).mock.results[0]?.value?.values; expect(valuesMock).toHaveBeenCalledWith(expect.objectContaining({ passwordHash: 'hashed_pw' })); }); it('normalizes email to lowercase', async () => { await POST(makeRequest({ ...VALID_BODY, email: 'USER@EXAMPLE.COM' })); const valuesMock = vi.mocked(mockInsert).mock.results[0]?.value?.values; expect(valuesMock).toHaveBeenCalledWith(expect.objectContaining({ email: 'user@example.com' })); }); });