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();
|
||||
});
|
||||
});
|
||||
70
OpenMAIC/tests/app/api/learning-runtime-finalize.test.ts
Normal file
70
OpenMAIC/tests/app/api/learning-runtime-finalize.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
readJob: vi.fn(),
|
||||
readClassroom: vi.fn(),
|
||||
finalizeSingle: vi.fn(),
|
||||
finalizeLarge: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/server/classroom-job-store', () => ({
|
||||
isValidClassroomJobId: () => true,
|
||||
readClassroomGenerationJob: mocks.readJob,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/server/classroom-storage', () => ({
|
||||
buildRequestOrigin: () => 'https://engine.example',
|
||||
readClassroom: mocks.readClassroom,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/course-framework/store', () => ({
|
||||
readCourseRecordReconciled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/makelore-course/finalize', () => ({
|
||||
finalizeSingleLearningCourse: mocks.finalizeSingle,
|
||||
finalizeLargeLearningCourse: mocks.finalizeLarge,
|
||||
frozenLearningFinalizeResponse: vi.fn(),
|
||||
}));
|
||||
|
||||
import { FrozenLearningCourseMutationError } from '@/lib/makelore-course/immutability';
|
||||
import { POST } from '@/app/api/runtime/v1/courses/jobs/[jobId]/finalize/route';
|
||||
|
||||
describe('private Learning Engine finalize route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubEnv('LEARNING_ENGINE_TOKEN', 'engine-secret');
|
||||
mocks.readJob.mockResolvedValue({
|
||||
id: 'job-1',
|
||||
ownerPrincipalId: 'owner-1',
|
||||
status: 'succeeded',
|
||||
result: { classroomId: 'course-1' },
|
||||
input: { interactiveMode: true, enableTTS: true },
|
||||
});
|
||||
mocks.readClassroom.mockResolvedValue({ id: 'course-1', stage: {}, scenes: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('returns a stable conflict when an immutable course source no longer matches', async () => {
|
||||
mocks.finalizeSingle.mockRejectedValue(new FrozenLearningCourseMutationError('course-1'));
|
||||
const request = new NextRequest(
|
||||
'https://engine.example/api/runtime/v1/courses/jobs/job-1/finalize',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer engine-secret',
|
||||
'X-Owner-User-Id': 'owner-1',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const response = await POST(request, { params: Promise.resolve({ jobId: 'job-1' }) });
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
await expect(response.json()).resolves.toMatchObject({ error: 'course_frozen' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
after: vi.fn(),
|
||||
readCourseRecordReconciled: vi.fn(),
|
||||
updateCourseRecord: vi.fn(),
|
||||
isCourseGenerationRunning: vi.fn(),
|
||||
cancelCourseGenerationJob: vi.fn(),
|
||||
runCourseFrameworkGeneration: vi.fn(),
|
||||
runCourseModuleGeneration: vi.fn(),
|
||||
runCourseGenerationJob: vi.fn(),
|
||||
regenerateCourseFramework: vi.fn(),
|
||||
isCoursePublishing: vi.fn(),
|
||||
record: null as null | Record<string, unknown>,
|
||||
activeCourseIds: new Set<string>(),
|
||||
assertLearningCourseMutable: vi.fn(),
|
||||
FrozenLearningCourseMutationError: class FrozenLearningCourseMutationError extends Error {},
|
||||
}));
|
||||
|
||||
vi.mock('next/server', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('next/server')>();
|
||||
return { ...actual, after: mocks.after };
|
||||
});
|
||||
|
||||
vi.mock('@/lib/course-framework/store', () => ({
|
||||
isValidCourseId: (courseId: string) => /^[a-zA-Z0-9_-]+$/.test(courseId),
|
||||
readCourseRecordReconciled: mocks.readCourseRecordReconciled,
|
||||
updateCourseRecord: mocks.updateCourseRecord,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/course-framework/runner', () => ({
|
||||
isCourseGenerationRunning: mocks.isCourseGenerationRunning,
|
||||
cancelCourseGenerationJob: mocks.cancelCourseGenerationJob,
|
||||
runCourseFrameworkGeneration: mocks.runCourseFrameworkGeneration,
|
||||
runCourseModuleGeneration: mocks.runCourseModuleGeneration,
|
||||
runCourseGenerationJob: mocks.runCourseGenerationJob,
|
||||
regenerateCourseFramework: mocks.regenerateCourseFramework,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/course-framework/publish-state', () => {
|
||||
class CourseMutationInProgressError extends Error {}
|
||||
return {
|
||||
CourseMutationInProgressError,
|
||||
isCoursePublishing: mocks.isCoursePublishing,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/lib/makelore-course/immutability', () => ({
|
||||
assertLearningCourseMutable: mocks.assertLearningCourseMutable,
|
||||
FrozenLearningCourseMutationError: mocks.FrozenLearningCourseMutationError,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/server/classroom-storage', () => ({
|
||||
buildRequestOrigin: () => 'https://engine.example',
|
||||
}));
|
||||
|
||||
import { POST } from '@/app/api/runtime/v1/courses/jobs/[jobId]/control/route';
|
||||
|
||||
function framework() {
|
||||
return {
|
||||
courseTitle: '测试大课',
|
||||
languageDirective: '使用中文',
|
||||
targetAudience: '学习者',
|
||||
summary: '课程摘要',
|
||||
courseGoals: ['完成学习'],
|
||||
continuityContract: {
|
||||
terminology: [],
|
||||
teachingStyle: '循序渐进',
|
||||
difficultyProgression: '从易到难',
|
||||
assessmentStrategy: '形成性评估',
|
||||
},
|
||||
modules: [],
|
||||
};
|
||||
}
|
||||
|
||||
function courseRecord(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
id: 'course-1',
|
||||
ownerPrincipalId: 'owner-1',
|
||||
requirement: '生成一门大课',
|
||||
status: 'framework_ready',
|
||||
framework: framework(),
|
||||
modules: [
|
||||
{ index: 1, title: '模块一', description: '入门', status: 'pending' },
|
||||
{ index: 2, title: '模块二', description: '进阶', status: 'pending' },
|
||||
],
|
||||
createdAt: '2026-08-16T00:00:00.000Z',
|
||||
updatedAt: '2026-08-16T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function invoke(
|
||||
body: unknown,
|
||||
options: {
|
||||
token?: string | null;
|
||||
owner?: string | null;
|
||||
jobId?: string;
|
||||
rawBody?: string;
|
||||
} = {},
|
||||
) {
|
||||
const headers = new Headers({ 'Content-Type': 'application/json' });
|
||||
if (options.token !== null) {
|
||||
headers.set('Authorization', `Bearer ${options.token ?? 'engine-secret'}`);
|
||||
}
|
||||
if (options.owner !== null) {
|
||||
headers.set('X-Owner-User-Id', options.owner ?? 'owner-1');
|
||||
}
|
||||
const request = new NextRequest(
|
||||
`https://engine.example/api/runtime/v1/courses/jobs/${options.jobId ?? 'course-1'}/control`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: options.rawBody ?? JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
return POST(request, {
|
||||
params: Promise.resolve({ jobId: options.jobId ?? 'course-1' }),
|
||||
});
|
||||
}
|
||||
|
||||
describe('private Learning Engine large-course control route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubEnv('LEARNING_ENGINE_TOKEN', 'engine-secret');
|
||||
mocks.activeCourseIds.clear();
|
||||
mocks.record = courseRecord();
|
||||
|
||||
mocks.readCourseRecordReconciled.mockImplementation(async () => mocks.record);
|
||||
mocks.updateCourseRecord.mockImplementation(
|
||||
async (_courseId: string, patch: Record<string, unknown>) => {
|
||||
mocks.record = { ...mocks.record, ...patch };
|
||||
return mocks.record;
|
||||
},
|
||||
);
|
||||
mocks.isCourseGenerationRunning.mockImplementation((courseId: string) =>
|
||||
mocks.activeCourseIds.has(courseId),
|
||||
);
|
||||
mocks.cancelCourseGenerationJob.mockImplementation((courseId: string) =>
|
||||
mocks.activeCourseIds.delete(courseId),
|
||||
);
|
||||
mocks.isCoursePublishing.mockReturnValue(false);
|
||||
mocks.assertLearningCourseMutable.mockResolvedValue(undefined);
|
||||
|
||||
const beginRun = (courseId: string) => {
|
||||
mocks.activeCourseIds.add(courseId);
|
||||
if (mocks.record?.id === courseId) {
|
||||
mocks.record = { ...mocks.record, status: 'generating' };
|
||||
}
|
||||
return Promise.resolve();
|
||||
};
|
||||
mocks.runCourseFrameworkGeneration.mockImplementation(beginRun);
|
||||
mocks.runCourseModuleGeneration.mockImplementation(beginRun);
|
||||
mocks.runCourseGenerationJob.mockImplementation(beginRun);
|
||||
mocks.regenerateCourseFramework.mockImplementation(beginRun);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('requires the configured engine bearer token before touching course state', async () => {
|
||||
const missing = await invoke({ action: 'start' }, { token: null });
|
||||
const wrong = await invoke({ action: 'start' }, { token: 'wrong-secret' });
|
||||
|
||||
expect(missing.status).toBe(401);
|
||||
expect(await missing.json()).toEqual({ error: 'unauthorized' });
|
||||
expect(wrong.status).toBe(401);
|
||||
expect(await wrong.json()).toEqual({ error: 'unauthorized' });
|
||||
expect(mocks.readCourseRecordReconciled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ action: 'START' }],
|
||||
[{ action: 'start', moduleIndex: 1 }],
|
||||
[{ action: 'start', unexpected: true }],
|
||||
[{ action: 'module-regenerate' }],
|
||||
[{ action: 'module-regenerate', moduleIndex: 0 }],
|
||||
[{ action: 'module-regenerate', moduleIndex: 1.5 }],
|
||||
[{ action: 'module-regenerate', moduleIndex: '1' }],
|
||||
])('rejects non-contract control bodies: %j', async (body) => {
|
||||
const response = await invoke(body);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({ error: 'invalid_request' });
|
||||
expect(mocks.readCourseRecordReconciled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hides an existing course when the owner does not match', async () => {
|
||||
const response = await invoke({ action: 'start' }, { owner: 'owner-2' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toMatchObject({
|
||||
error: 'job_not_found',
|
||||
message: 'Course generation job not found',
|
||||
});
|
||||
expect(mocks.runCourseModuleGeneration).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('validates start and resume against the persisted course phase', async () => {
|
||||
mocks.record = courseRecord({ status: 'queued', framework: undefined });
|
||||
const noFramework = await invoke({ action: 'start' });
|
||||
expect(noFramework.status).toBe(409);
|
||||
expect(await noFramework.json()).toMatchObject({ error: 'invalid_course_state' });
|
||||
|
||||
mocks.record = courseRecord({ status: 'generating' });
|
||||
const alreadyStarted = await invoke({ action: 'start' });
|
||||
expect(alreadyStarted.status).toBe(409);
|
||||
expect(await alreadyStarted.json()).toMatchObject({ error: 'invalid_course_state' });
|
||||
|
||||
mocks.record = courseRecord({ status: 'framework_ready' });
|
||||
const requiresReview = await invoke({ action: 'resume' });
|
||||
expect(requiresReview.status).toBe(409);
|
||||
expect(await requiresReview.json()).toMatchObject({
|
||||
error: 'review_required',
|
||||
reviewUrl: '/learning-ops/courses/course-1',
|
||||
});
|
||||
|
||||
mocks.record = courseRecord({ status: 'completed' });
|
||||
const completed = await invoke({ action: 'resume' });
|
||||
expect(completed.status).toBe(409);
|
||||
expect(await completed.json()).toMatchObject({ error: 'job_not_resumable' });
|
||||
});
|
||||
|
||||
it('requires a real 1-based module and forwards its exact index to the runner', async () => {
|
||||
const missing = await invoke({ action: 'module-regenerate', moduleIndex: 3 });
|
||||
expect(missing.status).toBe(404);
|
||||
expect(await missing.json()).toMatchObject({ error: 'module_not_found' });
|
||||
|
||||
const response = await invoke({ action: 'module-regenerate', moduleIndex: 2 });
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({
|
||||
jobId: 'course-1',
|
||||
mode: 'large',
|
||||
action: 'module-regenerate',
|
||||
moduleIndex: 2,
|
||||
status: 'generating',
|
||||
done: false,
|
||||
});
|
||||
expect(mocks.runCourseGenerationJob).toHaveBeenCalledWith(
|
||||
'course-1',
|
||||
'https://engine.example',
|
||||
{ onlyModuleIndex: 2 },
|
||||
);
|
||||
expect(mocks.after).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('maps an immutable course to a stable 409 without blocking cancellation', async () => {
|
||||
mocks.assertLearningCourseMutable.mockRejectedValue(
|
||||
new mocks.FrozenLearningCourseMutationError('course-1'),
|
||||
);
|
||||
const start = await invoke({ action: 'start' });
|
||||
expect(start.status).toBe(409);
|
||||
expect(await start.json()).toMatchObject({ error: 'course_frozen' });
|
||||
expect(mocks.runCourseModuleGeneration).not.toHaveBeenCalled();
|
||||
|
||||
mocks.activeCourseIds.add('course-1');
|
||||
const cancel = await invoke({ action: 'cancel' });
|
||||
expect(cancel.status).toBe(200);
|
||||
expect(mocks.assertLearningCourseMutable).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.cancelCourseGenerationJob).toHaveBeenCalledWith('course-1');
|
||||
});
|
||||
|
||||
it('runs start, cancel, and resume through one shared runner state', async () => {
|
||||
const started = await invoke({ action: 'start' });
|
||||
expect(started.status).toBe(200);
|
||||
expect(await started.json()).toMatchObject({ action: 'start', status: 'generating' });
|
||||
expect(mocks.activeCourseIds.has('course-1')).toBe(true);
|
||||
expect(mocks.runCourseModuleGeneration).toHaveBeenCalledTimes(1);
|
||||
|
||||
const cancelled = await invoke({ action: 'cancel' });
|
||||
expect(cancelled.status).toBe(200);
|
||||
expect(await cancelled.json()).toMatchObject({ action: 'cancel', status: 'cancelling' });
|
||||
expect(mocks.cancelCourseGenerationJob).toHaveBeenCalledWith('course-1');
|
||||
expect(mocks.activeCourseIds.has('course-1')).toBe(false);
|
||||
expect(mocks.record).toMatchObject({ status: 'cancelled' });
|
||||
|
||||
const resumed = await invoke({ action: 'resume' });
|
||||
expect(resumed.status).toBe(200);
|
||||
expect(await resumed.json()).toMatchObject({ action: 'resume', status: 'generating' });
|
||||
expect(mocks.activeCourseIds.has('course-1')).toBe(true);
|
||||
expect(mocks.runCourseModuleGeneration).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.runCourseFrameworkGeneration).not.toHaveBeenCalled();
|
||||
expect(mocks.after).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
60
OpenMAIC/tests/app/api/makelore-runtime-binding.test.ts
Normal file
60
OpenMAIC/tests/app/api/makelore-runtime-binding.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { POST } from '@/app/api/runtime/v1/bindings/validate/route';
|
||||
|
||||
describe('Makelore Learning Runtime binding', () => {
|
||||
let root = '';
|
||||
const previousStore = process.env.MAKELORE_COURSE_STORE_DIR;
|
||||
const previousToken = process.env.MAKELORE_RUNTIME_TOKEN;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'makelore-runtime-'));
|
||||
process.env.MAKELORE_COURSE_STORE_DIR = root;
|
||||
process.env.MAKELORE_RUNTIME_TOKEN = 'runtime-secret';
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
if (previousStore === undefined) delete process.env.MAKELORE_COURSE_STORE_DIR;
|
||||
else process.env.MAKELORE_COURSE_STORE_DIR = previousStore;
|
||||
if (previousToken === undefined) delete process.env.MAKELORE_RUNTIME_TOKEN;
|
||||
else process.env.MAKELORE_RUNTIME_TOKEN = previousToken;
|
||||
});
|
||||
|
||||
it('accepts only a retained course with the exact immutable hash', async () => {
|
||||
const hash = 'a'.repeat(64);
|
||||
await mkdir(root, { recursive: true });
|
||||
await writeFile(join(root, 'course-1.json'), JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
courseId: 'course-1',
|
||||
contentHash: hash,
|
||||
title: 'Python 基础入门',
|
||||
classroom: { stage: { id: 'stage-1', name: 'Python 基础入门' }, scenes: [] },
|
||||
}));
|
||||
const request = new NextRequest('http://localhost/api/runtime/v1/bindings/validate', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer runtime-secret', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_id: 'session-1', owner_user_id: 'user-1', runtime: 'learning', runtime_version: 'v1',
|
||||
binding: { kind: 'learning-course', key: `course-1:${hash}` },
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
type: 'learning.course.snapshot',
|
||||
payload: { courseId: 'course-1', contentHash: hash, sceneCount: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects calls without the private runtime token', async () => {
|
||||
const response = await POST(new NextRequest('http://localhost/api/runtime/v1/bindings/validate', {
|
||||
method: 'POST', body: '{}', headers: { 'Content-Type': 'application/json' },
|
||||
}));
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
});
|
||||
171
OpenMAIC/tests/app/api/ops-large-course-control-proxy.test.ts
Normal file
171
OpenMAIC/tests/app/api/ops-large-course-control-proxy.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
requireOpsAccess: vi.fn(),
|
||||
readCourse: vi.fn(),
|
||||
isRunning: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
runFramework: vi.fn(),
|
||||
runModules: vi.fn(),
|
||||
regenerateFramework: vi.fn(),
|
||||
regenerateModule: vi.fn(),
|
||||
isPublishing: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/server/ops-access', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/lib/server/ops-access')>()),
|
||||
requireOpsAccess: mocks.requireOpsAccess,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/course-framework/store', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/lib/course-framework/store')>()),
|
||||
readCourseRecordReconciled: mocks.readCourse,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/course-framework/runner', () => ({
|
||||
isCourseGenerationRunning: mocks.isRunning,
|
||||
cancelCourseGenerationJob: mocks.cancel,
|
||||
runCourseFrameworkGeneration: mocks.runFramework,
|
||||
runCourseModuleGeneration: mocks.runModules,
|
||||
regenerateCourseFramework: mocks.regenerateFramework,
|
||||
runCourseGenerationJob: mocks.regenerateModule,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/course-framework/publish-state', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/lib/course-framework/publish-state')>()),
|
||||
isCoursePublishing: mocks.isPublishing,
|
||||
}));
|
||||
|
||||
import { POST as startCourse } from '@/app/api/courses/[courseId]/start/route';
|
||||
import { POST as cancelCourse } from '@/app/api/courses/[courseId]/cancel/route';
|
||||
import { POST as resumeCourse } from '@/app/api/courses/[courseId]/resume/route';
|
||||
import { POST as regenerateCourseFramework } from '@/app/api/courses/[courseId]/framework/regenerate/route';
|
||||
import { POST as regenerateCourseModule } from '@/app/api/courses/[courseId]/modules/[index]/regenerate/route';
|
||||
|
||||
const COURSE = {
|
||||
id: 'large-course-1',
|
||||
ownerPrincipalId: 'works-owner-1',
|
||||
status: 'framework_ready',
|
||||
requirement: 'Build a course',
|
||||
framework: { courseTitle: 'Course', modules: [] },
|
||||
modules: [{ index: 2, title: 'Module 2', description: 'Two', status: 'failed' }],
|
||||
createdAt: '2026-08-16T00:00:00.000Z',
|
||||
updatedAt: '2026-08-16T00:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('Learning Ops large-course runner proxy', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
vi.stubEnv('OPENMAIC_DEPLOYMENT_ROLE', 'ops');
|
||||
vi.stubEnv('LEARNING_ENGINE_BASE_URL', 'http://learning-engine:3000/api/runtime/v1');
|
||||
vi.stubEnv('COURSE_PUBLISH_SERVER_BASE_URL', '');
|
||||
vi.stubEnv('LEARNING_ENGINE_TOKEN', 'engine-control-secret');
|
||||
mocks.requireOpsAccess.mockResolvedValue(null);
|
||||
mocks.readCourse.mockResolvedValue(COURSE);
|
||||
vi.stubGlobal('fetch', vi.fn(async () => Response.json(
|
||||
{ contractVersion: 2, jobId: COURSE.id, mode: 'large', status: 'generating' },
|
||||
{ status: 202 },
|
||||
)));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const cases = [
|
||||
{
|
||||
action: 'start',
|
||||
call: (request: NextRequest) => startCourse(request, {
|
||||
params: Promise.resolve({ courseId: COURSE.id }),
|
||||
}),
|
||||
},
|
||||
{
|
||||
action: 'cancel',
|
||||
call: (request: NextRequest) => cancelCourse(request, {
|
||||
params: Promise.resolve({ courseId: COURSE.id }),
|
||||
}),
|
||||
},
|
||||
{
|
||||
action: 'resume',
|
||||
call: (request: NextRequest) => resumeCourse(request, {
|
||||
params: Promise.resolve({ courseId: COURSE.id }),
|
||||
}),
|
||||
},
|
||||
{
|
||||
action: 'framework-regenerate',
|
||||
call: (request: NextRequest) => regenerateCourseFramework(request, {
|
||||
params: Promise.resolve({ courseId: COURSE.id }),
|
||||
}),
|
||||
},
|
||||
{
|
||||
action: 'module-regenerate',
|
||||
moduleIndex: 2,
|
||||
call: (request: NextRequest) => regenerateCourseModule(request, {
|
||||
params: Promise.resolve({ courseId: COURSE.id, index: '2' }),
|
||||
}),
|
||||
},
|
||||
] as const;
|
||||
|
||||
it.each(cases)('proxies $action to Engine without starting an Ops-local runner', async ({
|
||||
action,
|
||||
call,
|
||||
...testCase
|
||||
}) => {
|
||||
const response = await call(new NextRequest(
|
||||
`https://ops.example/learning-ops/api/courses/${COURSE.id}/${action}`,
|
||||
{ method: 'POST' },
|
||||
));
|
||||
|
||||
expect(response.status).toBe(202);
|
||||
expect(fetch).toHaveBeenCalledOnce();
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0]!;
|
||||
expect(url).toBe(
|
||||
`http://learning-engine:3000/api/runtime/v1/courses/jobs/${COURSE.id}/control`,
|
||||
);
|
||||
const headers = new Headers(init?.headers);
|
||||
expect(headers.get('Authorization')).toBe('Bearer engine-control-secret');
|
||||
expect(headers.get('X-Owner-User-Id')).toBe('works-owner-1');
|
||||
expect(JSON.parse(String(init?.body))).toEqual({
|
||||
action,
|
||||
...('moduleIndex' in testCase ? { moduleIndex: testCase.moduleIndex } : {}),
|
||||
});
|
||||
expect(mocks.runFramework).not.toHaveBeenCalled();
|
||||
expect(mocks.runModules).not.toHaveBeenCalled();
|
||||
expect(mocks.regenerateFramework).not.toHaveBeenCalled();
|
||||
expect(mocks.regenerateModule).not.toHaveBeenCalled();
|
||||
expect(mocks.cancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails closed instead of using a local runner when an Ops deployment has no Engine URL', async () => {
|
||||
vi.stubEnv('LEARNING_ENGINE_BASE_URL', '');
|
||||
vi.stubEnv('COURSE_PUBLISH_SERVER_BASE_URL', '');
|
||||
|
||||
const response = await startCourse(
|
||||
new NextRequest(`https://ops.example/api/courses/${COURSE.id}/start`, { method: 'POST' }),
|
||||
{ params: Promise.resolve({ courseId: COURSE.id }) },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(mocks.runModules).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reuses the configured publish-server origin for existing deployments', async () => {
|
||||
vi.stubEnv('LEARNING_ENGINE_BASE_URL', '');
|
||||
vi.stubEnv('COURSE_PUBLISH_SERVER_BASE_URL', 'http://learning-engine:3000');
|
||||
|
||||
const response = await cancelCourse(
|
||||
new NextRequest(`https://ops.example/api/courses/${COURSE.id}/cancel`, { method: 'POST' }),
|
||||
{ params: Promise.resolve({ courseId: COURSE.id }) },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(202);
|
||||
expect(vi.mocked(fetch).mock.calls[0]?.[0]).toBe(
|
||||
`http://learning-engine:3000/api/runtime/v1/courses/jobs/${COURSE.id}/control`,
|
||||
);
|
||||
expect(mocks.cancel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user