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,76 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
vi.mock('@/lib/db/queries', () => ({
|
||||
getMasteryMap: vi.fn(),
|
||||
}));
|
||||
|
||||
import { GET } from '../route';
|
||||
import { getMasteryMap } from '@/lib/db/queries';
|
||||
|
||||
const mockGet = vi.mocked(getMasteryMap);
|
||||
|
||||
const NOW = new Date('2026-06-21T10:00:00Z');
|
||||
const LATER = new Date('2026-06-28T10:00:00Z');
|
||||
|
||||
const ENTRIES = [
|
||||
{
|
||||
conceptId: 'c1',
|
||||
conceptName: 'Closures',
|
||||
blueprintTitle: 'JavaScript',
|
||||
score: 0.8,
|
||||
lastSeen: NOW,
|
||||
nextReview: LATER,
|
||||
},
|
||||
{
|
||||
conceptId: 'c2',
|
||||
conceptName: 'Promises',
|
||||
blueprintTitle: 'JavaScript',
|
||||
score: 0.45,
|
||||
lastSeen: NOW,
|
||||
nextReview: NOW,
|
||||
},
|
||||
];
|
||||
|
||||
function makeRequest(userId?: string) {
|
||||
const url = userId
|
||||
? `http://localhost:3000/api/mastery?userId=${userId}`
|
||||
: 'http://localhost:3000/api/mastery';
|
||||
return new NextRequest(url);
|
||||
}
|
||||
|
||||
describe('GET /api/mastery', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('returns 400 when userId is missing', async () => {
|
||||
const res = await GET(makeRequest());
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 200 with empty groups when no mastery', async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
const res = await GET(makeRequest('u1'));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.groups).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('groups entries by blueprintTitle', async () => {
|
||||
mockGet.mockResolvedValue(ENTRIES);
|
||||
const res = await GET(makeRequest('u1'));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.groups).toHaveLength(1);
|
||||
expect(body.groups[0].blueprintTitle).toBe('JavaScript');
|
||||
expect(body.groups[0].concepts).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('includes score in each concept entry', async () => {
|
||||
mockGet.mockResolvedValue(ENTRIES);
|
||||
const res = await GET(makeRequest('u1'));
|
||||
const body = await res.json();
|
||||
const concepts = body.groups[0].concepts;
|
||||
expect(concepts[0].score).toBe(0.8);
|
||||
expect(concepts[1].score).toBe(0.45);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getMasteryMap } from '@/lib/db/queries';
|
||||
import { getOptionalSession } from '@/lib/auth-helpers';
|
||||
|
||||
/**
|
||||
* GET /api/mastery?userId=<userId>
|
||||
* Returns all mastery records for a user, grouped by blueprint.
|
||||
*/
|
||||
export async function GET(req: NextRequest): Promise<NextResponse> {
|
||||
const session = await getOptionalSession();
|
||||
const userId = session?.user?.id ?? req.nextUrl.searchParams.get('userId');
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Missing ?userId parameter' }, { status: 400 });
|
||||
}
|
||||
|
||||
const entries = await getMasteryMap(userId);
|
||||
|
||||
// Group by blueprintTitle for the map view
|
||||
const grouped: Record<string, { blueprintTitle: string; concepts: typeof entries }> = {};
|
||||
for (const entry of entries) {
|
||||
if (!grouped[entry.blueprintTitle]) {
|
||||
grouped[entry.blueprintTitle] = { blueprintTitle: entry.blueprintTitle, concepts: [] };
|
||||
}
|
||||
grouped[entry.blueprintTitle].concepts.push(entry);
|
||||
}
|
||||
|
||||
return NextResponse.json({ groups: Object.values(grouped) });
|
||||
}
|
||||
Reference in New Issue
Block a user