Files
openmaic/OpenMAIC/tests/app/api/ops-large-course-control-proxy.test.ts

172 lines
6.2 KiB
TypeScript

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