4e38b5a791
Full Next.js App Router application with: - AI-graded lesson checkpoints (BKT/Elo mastery tracking) - Auth.js v5: credentials, Google, GitHub, generic OIDC - Anonymous-session-first with migrate-on-signin - Admin panel: users, blueprints, reports, site settings - Password reset + email verification (nodemailer/SMTP) - Site config: require_auth + signups_enabled flags - server-only guards on all DB/generation/verification modules - PostgreSQL 16 + pgvector, Redis cache, Drizzle ORM - 271 unit tests (Vitest), golden-eval harness, Playwright e2e stubs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
123 lines
4.3 KiB
TypeScript
123 lines
4.3 KiB
TypeScript
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<string, unknown> | 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' }));
|
|
});
|
|
});
|