414 lines
15 KiB
TypeScript
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,
|
|
},
|
|
});
|
|
});
|
|
});
|