feat: productionize learning engine and classroom
This commit is contained in:
160
OpenMAIC/tests/app/api/learning-runtime-capability.test.ts
Normal file
160
OpenMAIC/tests/app/api/learning-runtime-capability.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user