161 lines
5.8 KiB
TypeScript
161 lines
5.8 KiB
TypeScript
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<string, unknown>;
|
|
} = {},
|
|
) {
|
|
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<string, unknown>;
|
|
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();
|
|
});
|
|
});
|