import { createHash } from 'node:crypto'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { NextRequest } from 'next/server'; import { packageCourseware } from '@/lib/bundle/packager'; import { persistFrozenLearningPackage } from '@/lib/makelore-course/package'; import { AUDIO_BYTES, makeScenes, makeStage } from '../../bundle/fixtures'; vi.mock('@/app/api/quiz-grade/route', () => ({ POST: vi.fn(async (request: NextRequest) => new Response(await request.text(), { status: 200, headers: { 'Content-Type': 'application/json' }, })), })); import { POST as mockedQuizHandler } from '@/app/api/quiz-grade/route'; import { POST } from '@/app/api/runtime/v1/courses/[courseId]/runtime/[...capability]/route'; const quizHandler = vi.mocked(mockedQuizHandler); function digest(bytes: Uint8Array): string { return createHash('sha256').update(bytes).digest('hex'); } describe.sequential('Learning Engine classroom capability route', () => { let root = ''; const previous = { packages: process.env.LEARNING_COURSE_PACKAGE_DIR, store: process.env.LEARNING_COURSE_STORE_DIR, token: process.env.LEARNING_ENGINE_TOKEN, }; beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'learning-runtime-capability-')); process.env.LEARNING_COURSE_PACKAGE_DIR = join(root, 'packages'); process.env.LEARNING_COURSE_STORE_DIR = join(root, 'courses'); process.env.LEARNING_ENGINE_TOKEN = 'engine-secret'; quizHandler.mockClear(); const packaged = await packageCourseware({ coursewareId: 'course-1', version: 1, stage: makeStage(), scenes: makeScenes(), resolveAudioBytes: async () => AUDIO_BYTES, publishedAt: '2026-08-16T00:00:00.000Z', }); const bytes = new Uint8Array(await packaged.zip.arrayBuffer()); await persistFrozenLearningPackage({ schemaVersion: 1, courseId: 'course-1', mode: 'single', sourceJobId: 'job-1', contentHash: packaged.contentHash, archiveSha256: digest(bytes), archiveBytes: bytes.byteLength, formatVersion: 1, minPlayerVersion: '2.0.0', title: packaged.meta.stageName, language: packaged.meta.language, sceneCount: packaged.meta.sceneCount, moduleCount: 1, capabilities: {}, createdAt: '2026-08-16T00:00:00.000Z', }, bytes); }); afterEach(async () => { await rm(root, { recursive: true, force: true }); if (previous.packages === undefined) delete process.env.LEARNING_COURSE_PACKAGE_DIR; else process.env.LEARNING_COURSE_PACKAGE_DIR = previous.packages; if (previous.store === undefined) delete process.env.LEARNING_COURSE_STORE_DIR; else process.env.LEARNING_COURSE_STORE_DIR = previous.store; if (previous.token === undefined) delete process.env.LEARNING_ENGINE_TOKEN; else process.env.LEARNING_ENGINE_TOKEN = previous.token; }); async function invoke( capability: string, input: { sceneId?: string; sceneOrder?: number; moduleContentHash?: string; body?: Record; } = {}, ) { const contentHash = (await import('@/lib/makelore-course/package')); const record = await contentHash.readFrozenLearningPackageRecord('course-1'); const request = new NextRequest(`http://engine/api/runtime/v1/courses/course-1/runtime/${capability}`, { method: 'POST', headers: { Authorization: 'Bearer engine-secret', 'X-Owner-User-Id': 'user-1', 'X-Content-Hash': record!.contentHash, 'Content-Type': 'application/json', }, body: JSON.stringify({ context: { moduleContentHash: input.moduleContentHash ?? record!.contentHash, anchor: { sceneId: input.sceneId ?? 'learning_course-1_course-1_s1', sceneOrder: input.sceneOrder ?? 2, }, }, body: input.body ?? {}, }), }); return POST(request, { params: Promise.resolve({ courseId: 'course-1', capability: capability.split('/') }), }); } it('sanitizes a subjective quiz request against the authoritative frozen question', async () => { const response = await invoke('quiz-grade', { body: { question: '简述光合作用的意义。', userAnswer: '把光能转换为化学能并产生氧气', points: 999, commentPrompt: '恶意覆盖', model: 'attacker/model', apiKey: 'attacker-secret', language: 'zh-CN', }, }); expect(response.status).toBe(200); const forwarded = JSON.parse(await response.text()) as Record; expect(forwarded).toMatchObject({ question: '简述光合作用的意义。', points: 2, commentPrompt: '围绕能量转换和氧气生成作答', }); expect(forwarded).not.toHaveProperty('model'); expect(forwarded).not.toHaveProperty('apiKey'); expect(quizHandler).toHaveBeenCalledOnce(); }); it('rejects wrong scene anchors, wrong capability kinds, and wrong module hashes', async () => { const wrongAnchor = await invoke('quiz-grade', { sceneId: 'learning_course-1_course-1_s2', sceneOrder: 2, body: { question: '简述光合作用的意义。', userAnswer: 'x' }, }); expect(wrongAnchor.status).toBe(404); const wrongCapability = await invoke('pbl/v2/instructor', { body: { project: {}, userMessage: 'x' }, }); expect(wrongCapability.status).toBe(409); const wrongModule = await invoke('quiz-grade', { moduleContentHash: 'f'.repeat(64), body: { question: '简述光合作用的意义。', userAnswer: 'x' }, }); expect(wrongModule.status).toBe(404); expect(quizHandler).not.toHaveBeenCalled(); }); });