Files
openmaic/OpenMAIC/tests/app/api/learning-runtime-large-course-control.test.ts

288 lines
11 KiB
TypeScript

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