Files
openmaic/OpenMAIC/tests/course-framework/large-course-publish-boundary.test.ts
2026-08-16 14:58:47 +08:00

414 lines
15 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { promises as fs } from 'fs';
import os from 'os';
import path from 'path';
import { NextRequest } from 'next/server';
import { buildCourseModuleOutputDigest } from '@/lib/course-framework/module-digest';
import type { CourseFramework, CourseRecord } from '@/lib/course-framework/types';
import type { PersistedClassroomData } from '@/lib/server/classroom-storage';
import type { GeneratedAgentConfig, Scene, Stage } from '@/lib/types/stage';
const COURSE_ID = 'boundary-course';
const PUBLISH_TOKEN = 'boundary-publish-token';
const BASE_URL = 'http://localhost:3000';
const publicModuleId = (moduleIndex: number) => `course_${COURSE_ID}_module_${moduleIndex}`;
const FRAMEWORK: CourseFramework = {
courseTitle: '两模块连续课程',
languageDirective: '使用中文教学。',
targetAudience: '零基础学习者。',
summary: '从基础概念进阶到综合实践。',
courseGoals: ['理解基础概念', '完成综合实践'],
continuityContract: {
terminology: ['始终使用“核心概念”'],
teachingStyle: '先讲解再互动。',
difficultyProgression: '从识别进阶到应用。',
assessmentStrategy: '每个模块完成一次形成性检查。',
},
modules: [
{
index: 1,
title: '模块一',
description: '建立基础。',
learningObjectives: ['理解基础概念'],
generationPrompt: '用互动实验建立基础概念。',
incomingKnowledge: [],
outgoingKnowledge: ['基础概念'],
excludedTopics: ['综合实践'],
estimatedMinutes: 15,
},
{
index: 2,
title: '模块二',
description: '承接基础并完成实践。',
learningObjectives: ['应用基础概念'],
generationPrompt: '承接前序实际产出完成综合实践。',
prerequisites: '模块一',
incomingKnowledge: ['基础概念'],
outgoingKnowledge: ['综合实践能力'],
excludedTopics: [],
estimatedMinutes: 20,
},
],
};
let tempRoot: string;
let frameworksDir: string;
let classroomsDir: string;
let coursewaresDir: string;
let bundlesDir: string;
let manifestsDir: string;
beforeEach(async () => {
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'large-course-publish-boundary-'));
frameworksDir = path.join(tempRoot, 'frameworks');
classroomsDir = path.join(tempRoot, 'classrooms');
coursewaresDir = path.join(tempRoot, 'coursewares');
bundlesDir = path.join(tempRoot, 'bundles');
manifestsDir = path.join(tempRoot, 'manifests');
vi.resetModules();
vi.stubEnv('COURSE_FRAMEWORK_DIR', frameworksDir);
vi.stubEnv('CLASSROOM_DATA_DIR', classroomsDir);
vi.stubEnv('CLASSROOM_JOBS_DIR', path.join(tempRoot, 'classroom-jobs'));
vi.stubEnv('COURSEWARE_DATA_DIR', coursewaresDir);
vi.stubEnv('COURSEWARE_BUNDLE_DIR', bundlesDir);
vi.stubEnv('COURSE_MANIFEST_DIR', manifestsDir);
vi.stubEnv('COURSEWARE_PUBLISH_TOKEN', PUBLISH_TOKEN);
vi.stubEnv('OPENMAIC_DEPLOYMENT_ROLE', 'ops');
vi.stubEnv('ACCESS_CODE', '');
});
afterEach(async () => {
vi.unstubAllEnvs();
await fs.rm(tempRoot, { recursive: true, force: true });
});
function teacherAgent(): GeneratedAgentConfig {
return {
id: 'teacher-agent',
name: '麦老师',
role: 'teacher',
persona: '耐心且清晰。',
avatar: '/avatars/teacher.png',
color: '#2563eb',
priority: 10,
};
}
function classroom(
moduleIndex: number,
options: { missingNarration?: boolean } = {},
): PersistedClassroomData {
const id = `boundary-module-${moduleIndex}`;
const agent = teacherAgent();
const stage: Stage = {
id,
name: `实际模块 ${moduleIndex}`,
description: `连续课程的第 ${moduleIndex} 个实际课堂。`,
languageDirective: 'zh-CN',
createdAt: 1_700_000_000_000 + moduleIndex,
updatedAt: 1_700_000_000_100 + moduleIndex,
interactiveMode: true,
agentIds: [agent.id],
generatedAgentConfigs: [agent],
};
const scene: Scene = {
id: `scene-${moduleIndex}`,
stageId: id,
order: 1,
title: `互动场景 ${moduleIndex}`,
type: 'interactive',
content: {
type: 'interactive',
widgetType: 'simulation',
widgetConfig: {
type: 'simulation',
concept: `实际知识 ${moduleIndex}`,
description: '通过互动练习理解实际知识。',
variables: [],
},
html: `<main id="module-${moduleIndex}"><button type="button">练习 ${moduleIndex}</button></main>`,
},
actions: options.missingNarration
? [
{
id: `speech-${moduleIndex}`,
type: 'speech',
text: '这段旁白故意没有可冻结的音频字节。',
audioId: `missing-narration-${moduleIndex}`,
},
]
: [],
};
return {
id,
stage,
scenes: [scene],
createdAt: `2026-08-15T0${moduleIndex}:00:00.000Z`,
};
}
async function seedSucceededCourse(options: { secondMissingNarration?: boolean } = {}) {
const classrooms = [
classroom(1),
classroom(2, { missingNarration: options.secondMissingNarration }),
];
const digests = classrooms.map((entry) => buildCourseModuleOutputDigest(entry));
const record: CourseRecord = {
id: COURSE_ID,
status: 'completed',
requirement: '生成一门两模块连续课程',
framework: FRAMEWORK,
modules: FRAMEWORK.modules.map((moduleSpec, position) => ({
index: moduleSpec.index,
title: moduleSpec.title,
description: moduleSpec.description,
status: 'succeeded',
classroomId: classrooms[position].id,
outputDigest: digests[position],
continuityInputRefs: digests.slice(0, position).map((digest, priorPosition) => ({
moduleIndex: priorPosition + 1,
classroomId: digest.classroomId,
semanticHash: digest.semanticHash,
})),
completedAt: `2026-08-15T0${position + 1}:30:00.000Z`,
})),
createdAt: '2026-08-15T00:00:00.000Z',
updatedAt: '2026-08-15T03:00:00.000Z',
};
await fs.mkdir(frameworksDir, { recursive: true });
await fs.mkdir(classroomsDir, { recursive: true });
await fs.writeFile(
path.join(frameworksDir, `${COURSE_ID}.json`),
JSON.stringify(record),
'utf-8',
);
await Promise.all(
classrooms.map((entry) =>
fs.writeFile(path.join(classroomsDir, `${entry.id}.json`), JSON.stringify(entry), 'utf-8'),
),
);
return classrooms;
}
async function publishCourseRoute() {
const { POST } = await import('@/app/api/courses/[courseId]/publish/route');
return POST(new NextRequest(`${BASE_URL}/api/courses/${COURSE_ID}/publish`), {
params: Promise.resolve({ courseId: COURSE_ID }),
});
}
async function readLearnerManifest(version?: number) {
const { GET } = await import('@/app/api/learn/courses/[courseId]/route');
const query = version === undefined ? '' : `?version=${version}`;
return GET(new NextRequest(`${BASE_URL}/api/learn/courses/${COURSE_ID}${query}`), {
params: Promise.resolve({ courseId: COURSE_ID }),
});
}
async function readCourseware(coursewareId: string, version?: number) {
const { GET } = await import('@/app/api/coursewares/[id]/route');
const query = version === undefined ? '' : `?version=${version}`;
return GET(new NextRequest(`${BASE_URL}/api/coursewares/${coursewareId}${query}`), {
params: Promise.resolve({ id: coursewareId }),
});
}
describe('large-course publish -> learner boundary', () => {
test('freezes both succeeded classrooms and exposes one exact pinned learner manifest', async () => {
const classrooms = await seedSucceededCourse();
const response = await publishCourseRoute();
expect(response.status).toBe(200);
const body = (await response.json()) as {
success: true;
record: {
version: number;
schemaVersion: number;
modules: Array<{
coursewareId: string;
coursewareVersion: number;
contentHash: string;
}>;
};
};
expect(body.record).toMatchObject({ version: 1, schemaVersion: 2 });
expect(body.record.modules.map((moduleRecord) => moduleRecord.coursewareId)).toEqual([
publicModuleId(1),
publicModuleId(2),
]);
expect(body.record.modules.map((moduleRecord) => moduleRecord.coursewareId)).not.toEqual(
classrooms.map((entry) => entry.id),
);
const { readCourseRecord } = await import('@/lib/course-framework/store');
const opsRecord = await readCourseRecord(COURSE_ID);
expect(opsRecord?.publication).toMatchObject({
receiptVersion: 1,
manifestVersion: body.record.version,
modules: body.record.modules.map((pin, position) => ({
index: position + 1,
sourceClassroomId: classrooms[position].id,
coursewareId: pin.coursewareId,
coursewareVersion: pin.coursewareVersion,
contentHash: pin.contentHash,
})),
});
const repeatedResponse = await publishCourseRoute();
expect(repeatedResponse.status).toBe(200);
await expect(repeatedResponse.json()).resolves.toMatchObject({
success: true,
record: { version: 1, modules: body.record.modules },
});
const { createFileCoursewareRepo } = await import('@/lib/courseware-repo/store');
const { createFileBundleByteStore } = await import('@/lib/courseware-repo/bundle-store');
const { courseManifestRepo } = await import('@/lib/course-manifest-repo/store');
const { computeBundleContentHash } = await import('@/lib/bundle/packager');
const records = createFileCoursewareRepo(coursewaresDir);
const bytes = createFileBundleByteStore(bundlesDir);
expect(await records.listRecords()).toHaveLength(2);
expect(await courseManifestRepo.listRecords()).toHaveLength(1);
for (const pin of body.record.modules) {
const frozen = await records.getRecord(pin.coursewareId, pin.coursewareVersion);
expect(frozen).toMatchObject({
coursewareId: pin.coursewareId,
sourceClassroomId: classrooms[body.record.modules.indexOf(pin)]?.id,
version: pin.coursewareVersion,
status: 'published',
complete: true,
contentHash: pin.contentHash,
});
const archive = await bytes.read(pin.coursewareId, pin.coursewareVersion);
expect(archive).not.toBeNull();
expect(await computeBundleContentHash(archive!)).toBe(pin.contentHash);
}
const learnerResponse = await readLearnerManifest();
expect(learnerResponse.status).toBe(200);
await expect(learnerResponse.json()).resolves.toMatchObject({
success: true,
course: {
courseId: COURSE_ID,
version: 1,
modules: body.record.modules,
},
});
const publicDetail = await readCourseware(body.record.modules[0].coursewareId, 1);
const publicDetailBody = (await publicDetail.json()) as {
courseware: Record<string, unknown>;
};
expect(publicDetailBody.courseware).not.toHaveProperty('sourceClassroomId');
// A learner who knows or guesses an operations-side classroom id still
// cannot bypass the manifest pin and retrieve the mutable source.
vi.stubEnv('OPENMAIC_DEPLOYMENT_ROLE', 'learner');
const { GET: readRawClassroom } = await import('@/app/api/classroom/route');
for (const source of classrooms) {
const rawResponse = await readRawClassroom(
new NextRequest(`${BASE_URL}/api/classroom?id=${source.id}`),
);
expect(rawResponse.status).toBe(404);
}
});
test('keeps the staged batch unpublished and creates no learner manifest when module two cannot freeze', async () => {
await seedSucceededCourse({ secondMissingNarration: true });
const response = await publishCourseRoute();
expect(response.status).toBe(409);
const { createFileCoursewareRepo } = await import('@/lib/courseware-repo/store');
const { courseManifestRepo } = await import('@/lib/course-manifest-repo/store');
const records = createFileCoursewareRepo(coursewaresDir);
expect(await records.getRecord(publicModuleId(1), 1)).toBeNull();
expect(await records.getRecord(publicModuleId(2), 1)).toBeNull();
expect(await records.listRecords({ status: 'published' })).toEqual([]);
expect(await courseManifestRepo.getLatestRecord(COURSE_ID)).toBeNull();
const manifestResponse = await readLearnerManifest();
expect(manifestResponse.status).toBe(404);
const stagedCoursewareResponse = await readCourseware(publicModuleId(1), 1);
expect(stagedCoursewareResponse.status).toBe(404);
});
test('keeps manifest v1 pinned after one module is independently published as v2', async () => {
const classrooms = await seedSucceededCourse();
const publishResponse = await publishCourseRoute();
expect(publishResponse.status).toBe(200);
const publishBody = (await publishResponse.json()) as {
record: {
modules: Array<{
coursewareId: string;
coursewareVersion: number;
contentHash: string;
}>;
};
};
const originalPin = publishBody.record.modules[0];
const changedClassroom = structuredClone(classrooms[0]);
const changedScene = changedClassroom.scenes[0];
if (changedScene.content.type !== 'interactive') {
throw new Error('Expected the boundary fixture to remain interactive');
}
changedScene.content.html = '<main><button type="button">v2 changed exercise</button></main>';
changedClassroom.stage.updatedAt += 1;
const { publishPersistedClassroom } = await import('@/lib/server/classroom-courseware-publish');
const { createFileCoursewareRepo } = await import('@/lib/courseware-repo/store');
const { createFileBundleByteStore } = await import('@/lib/courseware-repo/bundle-store');
const records = createFileCoursewareRepo(coursewaresDir);
const bytes = createFileBundleByteStore(bundlesDir);
const secondModuleVersion = await publishPersistedClassroom(changedClassroom, {
coursewareId: originalPin.coursewareId,
baseUrl: BASE_URL,
classroomsDir,
publishedAt: '2026-08-15T09:00:00.000Z',
repos: { records, bytes },
});
expect(secondModuleVersion.record).toMatchObject({
version: 2,
status: 'published',
});
expect(secondModuleVersion.record.contentHash).not.toBe(originalPin.contentHash);
const exactManifestResponse = await readLearnerManifest(1);
expect(exactManifestResponse.status).toBe(200);
const exactManifestBody = (await exactManifestResponse.json()) as {
course: {
version: number;
modules: Array<{
coursewareId: string;
coursewareVersion: number;
contentHash: string;
}>;
};
};
expect(exactManifestBody.course.version).toBe(1);
expect(exactManifestBody.course.modules[0]).toEqual(originalPin);
const exactV1Response = await readCourseware(originalPin.coursewareId, 1);
await expect(exactV1Response.json()).resolves.toMatchObject({
success: true,
courseware: {
version: 1,
contentHash: originalPin.contentHash,
},
});
const latestResponse = await readCourseware(originalPin.coursewareId);
await expect(latestResponse.json()).resolves.toMatchObject({
success: true,
courseware: {
version: 2,
contentHash: secondModuleVersion.record.contentHash,
},
});
});
});