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()), requireOpsAccess: mocks.requireOpsAccess, })); vi.mock('@/lib/course-framework/store', async (importOriginal) => ({ ...(await importOriginal()), 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()), 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(); }); });