feat: initial commit — Curio mastery tutor
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>
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-helpers';
|
||||
|
||||
const mockTx = {
|
||||
delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||
};
|
||||
|
||||
const mockDb = {
|
||||
transaction: vi.fn().mockImplementation(async (fn: (tx: typeof mockTx) => Promise<void>) => fn(mockTx)),
|
||||
};
|
||||
|
||||
vi.mock('@/lib/db', () => ({ db: mockDb }));
|
||||
|
||||
vi.mock('@/lib/db/schema', () => ({
|
||||
users: {},
|
||||
responses: {},
|
||||
mastery: {},
|
||||
journeyInstances: {},
|
||||
}));
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
eq: vi.fn((col, val) => ({ col, val })),
|
||||
}));
|
||||
|
||||
function makeRequest(): NextRequest {
|
||||
return new NextRequest('http://localhost/api/user/account', { method: 'DELETE' });
|
||||
}
|
||||
|
||||
describe('DELETE /api/user/account', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockTx.delete.mockReturnValue({ where: vi.fn().mockResolvedValue([]) });
|
||||
mockDb.transaction.mockImplementation(async (fn: (tx: typeof mockTx) => Promise<void>) => fn(mockTx));
|
||||
});
|
||||
|
||||
it('returns 200 {ok:true} on happy path', async () => {
|
||||
const { DELETE } = await import('../route');
|
||||
const res = await DELETE();
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(body).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('runs transaction and deletes all four tables', async () => {
|
||||
const { DELETE } = await import('../route');
|
||||
await DELETE();
|
||||
|
||||
expect(mockDb.transaction).toHaveBeenCalledOnce();
|
||||
expect(mockTx.delete).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('returns 401 when requireAuth throws', async () => {
|
||||
vi.mocked(requireAuth).mockRejectedValueOnce(new Error('redirect'));
|
||||
|
||||
const { DELETE } = await import('../route');
|
||||
await expect(DELETE()).rejects.toThrow('redirect');
|
||||
});
|
||||
|
||||
it('propagates transaction errors', async () => {
|
||||
mockDb.transaction.mockRejectedValueOnce(new Error('db error'));
|
||||
|
||||
const { DELETE } = await import('../route');
|
||||
await expect(DELETE()).rejects.toThrow('db error');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '@/lib/db';
|
||||
import { users, responses, mastery, journeyInstances } from '@/lib/db/schema';
|
||||
import { requireAuth } from '@/lib/auth-helpers';
|
||||
|
||||
export async function DELETE(): Promise<NextResponse> {
|
||||
const session = await requireAuth();
|
||||
const userId = session.user!.id!;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(responses).where(eq(responses.userId, userId));
|
||||
await tx.delete(mastery).where(eq(mastery.userId, userId));
|
||||
await tx.delete(journeyInstances).where(eq(journeyInstances.userId, userId));
|
||||
// accounts and sessions ON DELETE CASCADE from users FK
|
||||
await tx.delete(users).where(eq(users.id, userId));
|
||||
});
|
||||
|
||||
// Session cookies become invalid once the session row is gone (cascaded above).
|
||||
// Client must call signOut() after this response.
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
const { mockSelect, mockUpdate } = vi.hoisted(() => ({
|
||||
mockSelect: vi.fn(),
|
||||
mockUpdate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({ db: { select: mockSelect, update: mockUpdate } }));
|
||||
|
||||
vi.mock('bcryptjs', () => ({
|
||||
default: { hash: vi.fn().mockResolvedValue('new_hash'), compare: vi.fn().mockResolvedValue(true) },
|
||||
}));
|
||||
|
||||
import { POST } from '../route';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { requireAuth } from '@/lib/auth-helpers';
|
||||
|
||||
function makeRequest(body: unknown) {
|
||||
return new NextRequest('http://localhost/api/user/change-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function setupDefault() {
|
||||
mockSelect.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([{ passwordHash: 'existing_hash' }]) }),
|
||||
}),
|
||||
});
|
||||
mockUpdate.mockReturnValue({ set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) });
|
||||
}
|
||||
|
||||
describe('POST /api/user/change-password', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(bcrypt.hash).mockResolvedValue('new_hash' as never);
|
||||
vi.mocked(bcrypt.compare).mockResolvedValue(true as never);
|
||||
setupDefault();
|
||||
});
|
||||
|
||||
it('returns 200 {ok:true} on success', async () => {
|
||||
const res = await POST(makeRequest({ oldPassword: 'old123456', newPassword: 'new123456' }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('calls bcrypt.compare with old password and existing hash', async () => {
|
||||
await POST(makeRequest({ oldPassword: 'old123456', newPassword: 'new123456' }));
|
||||
expect(bcrypt.compare).toHaveBeenCalledWith('old123456', 'existing_hash');
|
||||
});
|
||||
|
||||
it('calls bcrypt.hash on new password', async () => {
|
||||
await POST(makeRequest({ oldPassword: 'old123456', newPassword: 'new123456' }));
|
||||
expect(bcrypt.hash).toHaveBeenCalledWith('new123456', 12);
|
||||
});
|
||||
|
||||
it('returns 400 on bad JSON', async () => {
|
||||
const req = new NextRequest('http://localhost/api/user/change-password', {
|
||||
method: 'POST',
|
||||
body: '{bad',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
expect((await POST(req)).status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 when oldPassword missing', async () => {
|
||||
expect((await POST(makeRequest({ newPassword: 'new123456' }))).status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 when newPassword missing', async () => {
|
||||
expect((await POST(makeRequest({ oldPassword: 'old123456' }))).status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 when newPassword too short', async () => {
|
||||
expect((await POST(makeRequest({ oldPassword: 'old123456', newPassword: 'short' }))).status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 when user has no password (OAuth account)', async () => {
|
||||
mockSelect.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([{ passwordHash: null }]) }),
|
||||
}),
|
||||
});
|
||||
const res = await POST(makeRequest({ oldPassword: 'old123456', newPassword: 'new123456' }));
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/no password/i);
|
||||
});
|
||||
|
||||
it('returns 400 when user not found', async () => {
|
||||
mockSelect.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }),
|
||||
}),
|
||||
});
|
||||
const res = await POST(makeRequest({ oldPassword: 'old123456', newPassword: 'new123456' }));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 when old password wrong', async () => {
|
||||
vi.mocked(bcrypt.compare).mockResolvedValueOnce(false as never);
|
||||
const res = await POST(makeRequest({ oldPassword: 'wrong', newPassword: 'new123456' }));
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/incorrect/i);
|
||||
});
|
||||
|
||||
it('propagates requireAuth rejection', async () => {
|
||||
vi.mocked(requireAuth).mockRejectedValueOnce(new Error('redirect'));
|
||||
await expect(POST(makeRequest({ oldPassword: 'old', newPassword: 'new12345' }))).rejects.toThrow('redirect');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '@/lib/db';
|
||||
import { users } from '@/lib/db/schema';
|
||||
import { requireAuth } from '@/lib/auth-helpers';
|
||||
|
||||
const Schema = z.object({
|
||||
oldPassword: z.string().min(1),
|
||||
newPassword: z.string().min(8).max(128),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
const session = await requireAuth();
|
||||
const userId = session.user!.id!;
|
||||
|
||||
let body: unknown;
|
||||
try { body = await req.json(); } catch { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); }
|
||||
|
||||
const parsed = Schema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, { status: 400 });
|
||||
|
||||
const [user] = await db.select({ passwordHash: users.passwordHash }).from(users).where(eq(users.id, userId)).limit(1);
|
||||
if (!user?.passwordHash) return NextResponse.json({ error: 'No password set on this account' }, { status: 400 });
|
||||
|
||||
const valid = await bcrypt.compare(parsed.data.oldPassword, user.passwordHash);
|
||||
if (!valid) return NextResponse.json({ error: 'Current password is incorrect' }, { status: 400 });
|
||||
|
||||
const newHash = await bcrypt.hash(parsed.data.newPassword, 12);
|
||||
await db.update(users).set({ passwordHash: newHash }).where(eq(users.id, userId));
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
db: {
|
||||
update: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { PATCH } from '../route';
|
||||
import { db } from '@/lib/db';
|
||||
import { requireAuth } from '@/lib/auth-helpers';
|
||||
|
||||
function makeRequest(body: unknown, options: RequestInit = {}): NextRequest {
|
||||
return new NextRequest('http://localhost/api/user/profile', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
function makeBadJsonRequest(): NextRequest {
|
||||
return new NextRequest('http://localhost/api/user/profile', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: 'not valid json{{{',
|
||||
});
|
||||
}
|
||||
|
||||
function setupUpdateMock() {
|
||||
vi.mocked(db).update = vi.fn().mockReturnValue({
|
||||
set: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
describe('PATCH /api/user/profile', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setupUpdateMock();
|
||||
});
|
||||
|
||||
it('returns 200 with {ok:true} on valid name update', async () => {
|
||||
const res = await PATCH(makeRequest({ name: 'New Name' }));
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('returns 200 with {ok:true} on valid image update', async () => {
|
||||
const res = await PATCH(makeRequest({ image: 'https://example.com/avatar.png' }));
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('returns 200 with {ok:true} on updating both name and image', async () => {
|
||||
const res = await PATCH(makeRequest({ name: 'Jane', image: 'https://example.com/jane.png' }));
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('calls db.update with the parsed data and correct user id', async () => {
|
||||
const mockWhere = vi.fn().mockResolvedValue([]);
|
||||
const mockSet = vi.fn().mockReturnValue({ where: mockWhere });
|
||||
vi.mocked(db).update = vi.fn().mockReturnValue({ set: mockSet });
|
||||
|
||||
await PATCH(makeRequest({ name: 'Alice' }));
|
||||
|
||||
expect(vi.mocked(db).update).toHaveBeenCalledOnce();
|
||||
expect(mockSet).toHaveBeenCalledWith({ name: 'Alice' });
|
||||
expect(mockWhere).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('returns 400 on invalid JSON body', async () => {
|
||||
const res = await PATCH(makeBadJsonRequest());
|
||||
expect(res.status).toBe(400);
|
||||
const json = await res.json();
|
||||
expect(json).toHaveProperty('error');
|
||||
expect(json.error).toMatch(/invalid json/i);
|
||||
});
|
||||
|
||||
it('returns 400 when name is empty string', async () => {
|
||||
const res = await PATCH(makeRequest({ name: '' }));
|
||||
expect(res.status).toBe(400);
|
||||
const json = await res.json();
|
||||
expect(json).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('returns 400 when image is not a valid URL', async () => {
|
||||
const res = await PATCH(makeRequest({ image: 'not-a-url' }));
|
||||
expect(res.status).toBe(400);
|
||||
const json = await res.json();
|
||||
expect(json).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('returns 400 when name exceeds max length', async () => {
|
||||
const res = await PATCH(makeRequest({ name: 'a'.repeat(256) }));
|
||||
expect(res.status).toBe(400);
|
||||
const json = await res.json();
|
||||
expect(json).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('returns 400 when image URL exceeds max length', async () => {
|
||||
const res = await PATCH(makeRequest({ image: 'https://example.com/' + 'a'.repeat(2048) }));
|
||||
expect(res.status).toBe(400);
|
||||
const json = await res.json();
|
||||
expect(json).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('returns 401 when requireAuth throws (unauthenticated)', async () => {
|
||||
vi.mocked(requireAuth).mockRejectedValueOnce(new Error('redirect'));
|
||||
|
||||
await expect(PATCH(makeRequest({ name: 'Test' }))).rejects.toThrow('redirect');
|
||||
});
|
||||
|
||||
it('does not call db.update when validation fails', async () => {
|
||||
await PATCH(makeRequest({ name: '' }));
|
||||
expect(vi.mocked(db).update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call db.update when JSON is invalid', async () => {
|
||||
await PATCH(makeBadJsonRequest());
|
||||
expect(vi.mocked(db).update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates db errors', async () => {
|
||||
vi.mocked(db).update = vi.fn().mockReturnValue({
|
||||
set: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockRejectedValue(new Error('DB failure')),
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(PATCH(makeRequest({ name: 'Bob' }))).rejects.toThrow('DB failure');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '@/lib/db';
|
||||
import { users } from '@/lib/db/schema';
|
||||
import { requireAuth } from '@/lib/auth-helpers';
|
||||
|
||||
const ProfileSchema = z.object({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
image: z.string().url().max(2048).optional(),
|
||||
});
|
||||
|
||||
export async function PATCH(req: NextRequest): Promise<NextResponse> {
|
||||
const session = await requireAuth();
|
||||
const userId = session.user!.id!;
|
||||
|
||||
let body: unknown;
|
||||
try { body = await req.json(); } catch { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); }
|
||||
|
||||
const parsed = ProfileSchema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, { status: 400 });
|
||||
|
||||
await db.update(users).set(parsed.data).where(eq(users.id, userId));
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
Reference in New Issue
Block a user