feat: productionize learning engine and classroom
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user