feat: productionize learning engine and classroom

This commit is contained in:
inman
2026-08-16 21:44:52 +08:00
parent 2d04197f3f
commit ba35adfbfa
124 changed files with 9071 additions and 796 deletions

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

View 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' });
});
});

View File

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

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

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