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';
|
||||
|
||||
vi.mock('@/lib/verification/socratic', () => ({
|
||||
runSocraticProbe: vi.fn(),
|
||||
}));
|
||||
|
||||
import { POST } from '../route';
|
||||
import { runSocraticProbe } from '@/lib/verification/socratic';
|
||||
|
||||
const mockProbe = vi.mocked(runSocraticProbe);
|
||||
|
||||
const VALID_BODY = {
|
||||
gradeId: '123e4567-e89b-12d3-a456-426614174000',
|
||||
userId: 'user-abc',
|
||||
followUpText: 'I meant that closures hold a reference to the outer scope, not a copy.',
|
||||
};
|
||||
|
||||
function makeRequest(body: unknown) {
|
||||
return new NextRequest('http://localhost:3000/api/socratic', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe('POST /api/socratic', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('returns 400 when gradeId is missing', async () => {
|
||||
const res = await POST(makeRequest({ userId: 'u', followUpText: 'text' }));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 when gradeId is not a UUID', async () => {
|
||||
const res = await POST(makeRequest({ ...VALID_BODY, gradeId: 'not-a-uuid' }));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 when followUpText is empty', async () => {
|
||||
const res = await POST(makeRequest({ ...VALID_BODY, followUpText: '' }));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 200 with reply and upgradedVerdict on success', async () => {
|
||||
mockProbe.mockResolvedValue({ reply: 'Yes, that is correct.', upgradedVerdict: 'mastered' });
|
||||
const res = await POST(makeRequest(VALID_BODY));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.reply).toBe('Yes, that is correct.');
|
||||
expect(body.upgradedVerdict).toBe('mastered');
|
||||
});
|
||||
|
||||
it('returns 404 when grade not found', async () => {
|
||||
mockProbe.mockRejectedValue(
|
||||
new Error(`Grade not found: ${VALID_BODY.gradeId}`),
|
||||
);
|
||||
const res = await POST(makeRequest(VALID_BODY));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 500 on unexpected error', async () => {
|
||||
mockProbe.mockRejectedValue(new Error('DB down'));
|
||||
const res = await POST(makeRequest(VALID_BODY));
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { runSocraticProbe } from '@/lib/verification/socratic';
|
||||
import { BudgetExceededError } from '@/lib/budget';
|
||||
import { getOptionalSession } from '@/lib/auth-helpers';
|
||||
|
||||
const SocraticRequestSchema = z.object({
|
||||
gradeId: z.string().uuid('gradeId must be a UUID'),
|
||||
userId: z.string().min(1).optional(),
|
||||
followUpText: z.string().min(1, 'followUpText required').max(2000),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = SocraticRequestSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues.map((i) => i.message).join('; ') },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const session = await getOptionalSession();
|
||||
const userId = session?.user?.id ?? parsed.data.userId;
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'userId required' }, { status: 400 });
|
||||
}
|
||||
const { gradeId, followUpText } = parsed.data;
|
||||
|
||||
try {
|
||||
const result = await runSocraticProbe({ gradeId, userId, followUpText });
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
if (err instanceof BudgetExceededError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Session limit reached. Start a new session to continue.' },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
const message = err instanceof Error ? err.message : 'Internal error';
|
||||
if (message.startsWith('Grade not found')) {
|
||||
return NextResponse.json({ error: message }, { status: 404 });
|
||||
}
|
||||
console.error('[POST /api/socratic]', err);
|
||||
return NextResponse.json({ error: 'Socratic probe failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user