172 lines
6.2 KiB
TypeScript
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();
|
|
});
|
|
});
|