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: `
`, }, 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; }; 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 = '
'; 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, }, }); }); });