Files
openmaic/OpenMAIC/tests/app/api/learning-runtime-capability.test.ts

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();
});
});