288 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|