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, activeCourseIds: new Set(), assertLearningCourseMutable: vi.fn(), FrozenLearningCourseMutationError: class FrozenLearningCourseMutationError extends Error {}, })); vi.mock('next/server', async (importOriginal) => { const actual = await importOriginal(); 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 = {}): Record { 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) => { 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); }); });