import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { promises as fs } from 'fs'; import os from 'os'; import path from 'path'; import type { CourseManifestRecord } from '@/lib/course-manifest-repo/types'; import type { CoursewareRecord } from '@/lib/courseware-repo/types'; const HASH_V1 = 'ab'.repeat(32); const HASH_V2 = 'cd'.repeat(32); let manifestsDir: string; let recordsDir: string; beforeEach(async () => { manifestsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'course-manifests-')); recordsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'courseware-records-')); vi.resetModules(); vi.stubEnv('COURSE_MANIFEST_DIR', manifestsDir); vi.stubEnv('COURSEWARE_DATA_DIR', recordsDir); vi.stubEnv('COURSEWARE_PUBLISH_TOKEN', 'secret-token'); }); afterEach(async () => { vi.unstubAllEnvs(); await fs.rm(manifestsDir, { recursive: true, force: true }); await fs.rm(recordsDir, { recursive: true, force: true }); }); function coursewareRecord( coursewareId: string, options: { version?: number; contentHash?: string } = {}, ): CoursewareRecord { const version = options.version ?? 1; return { coursewareId, version, title: coursewareId, status: 'published', publishedAt: '2026-08-15T00:00:00.000Z', contentHash: options.contentHash ?? HASH_V1, byteSize: 100, entryCount: 5, sceneCount: 3, quizSceneCount: 1, knowledgeVersion: 1, complete: true, bundleUrl: `http://localhost/api/coursewares/${coursewareId}/bundles/${version}/download`, storageKey: `${coursewareId}/v${version}.zip`, }; } async function seedPublishedCourseware( coursewareId: string, options: { version?: number; contentHash?: string } = {}, ) { const { createFileCoursewareRepo } = await import('@/lib/courseware-repo/store'); await createFileCoursewareRepo(recordsDir).saveRecord(coursewareRecord(coursewareId, options)); } const MODULES = [ { index: 1, title: '模块一', description: '基础', coursewareId: 'cw-a' }, { index: 2, title: '模块二', description: '进阶', coursewareId: 'cw-b' }, ]; describe('course manifest repo + publish gate', () => { test('rejects a missing or invalid token', async () => { const { publishCourse } = await import('@/lib/course-manifest-repo'); const bad = await publishCourse({ courseId: 'course-1', token: 'wrong', title: 't', summary: 's', modules: MODULES, }); expect(bad.ok).toBe(false); if (!bad.ok) expect(bad.errorCode).toBe('UNAUTHORIZED'); const none = await publishCourse({ courseId: 'course-1', token: null, title: 't', summary: 's', modules: MODULES, }); expect(none.ok).toBe(false); }); test('rejects a course with unpublished module coursewares', async () => { await seedPublishedCourseware('cw-a'); // cw-b missing const { publishCourse } = await import('@/lib/course-manifest-repo'); const result = await publishCourse({ courseId: 'course-1', token: 'secret-token', title: 't', summary: 's', modules: MODULES, }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.errorCode).toBe('MODULE_NOT_PUBLISHED'); expect(result.error).toContain('cw-b'); } }); test('publishes schema v2 pins, preserves manifest v1, and selects a later module version only on re-publish', async () => { await seedPublishedCourseware('cw-a'); await seedPublishedCourseware('cw-b'); const { publishCourse } = await import('@/lib/course-manifest-repo'); const { courseManifestRepo } = await import('@/lib/course-manifest-repo/store'); const first = await publishCourse({ courseId: 'course-1', token: 'secret-token', title: '机器学习入门', summary: '导论', modules: MODULES, }); expect(first.ok).toBe(true); if (first.ok) { expect(first.record.schemaVersion).toBe(2); expect(first.record.version).toBe(1); expect(first.record.modules).toHaveLength(2); expect(first.record.modules[0]).toMatchObject({ index: 1, coursewareId: 'cw-a', coursewareVersion: 1, contentHash: HASH_V1, }); } // A newly published module version must not mutate the already committed // course manifest. It can only be selected by publishing a new manifest. await seedPublishedCourseware('cw-a', { version: 2, contentHash: HASH_V2 }); const frozenV1BeforeRepublish = await courseManifestRepo.getRecord('course-1', 1); expect(frozenV1BeforeRepublish?.modules[0]).toMatchObject({ coursewareVersion: 1, contentHash: HASH_V1, }); const second = await publishCourse({ courseId: 'course-1', token: 'secret-token', title: '机器学习入门 v2', summary: '导论', modules: MODULES, }); expect(second.ok).toBe(true); if (second.ok) { expect(second.record.version).toBe(2); expect(second.record.modules[0]).toMatchObject({ coursewareVersion: 2, contentHash: HASH_V2, }); } const latest = await courseManifestRepo.getLatestRecord('course-1'); expect(latest?.version).toBe(2); expect(latest?.title).toBe('机器学习入门 v2'); const frozenV1AfterRepublish = await courseManifestRepo.getRecord('course-1', 1); expect(frozenV1AfterRepublish).toEqual(frozenV1BeforeRepublish); expect(frozenV1AfterRepublish?.title).toBe('机器学习入门'); }); test('reads a legacy singleton and upgrades it to history without losing the old version', async () => { const legacy: CourseManifestRecord = { courseId: 'legacy-course', version: 4, title: '旧版连续课程', summary: '尚未锁定模块版本', modules: [ { index: 1, title: '旧模块', description: '旧版说明', coursewareId: 'cw-a', }, ], publishedAt: '2026-08-14T00:00:00.000Z', }; await fs.writeFile( path.join(manifestsDir, 'legacy-course.json'), JSON.stringify(legacy, null, 2), 'utf-8', ); await seedPublishedCourseware('cw-a'); const { publishCourse } = await import('@/lib/course-manifest-repo'); const { courseManifestRepo } = await import('@/lib/course-manifest-repo/store'); expect(await courseManifestRepo.getLatestRecord('legacy-course')).toEqual(legacy); const upgraded = await publishCourse({ courseId: 'legacy-course', token: 'secret-token', title: '新版连续课程', summary: '已锁定模块版本', modules: [MODULES[0]], }); expect(upgraded.ok).toBe(true); if (upgraded.ok) { expect(upgraded.record).toMatchObject({ version: 5, schemaVersion: 2 }); expect(upgraded.record.modules[0]).toMatchObject({ coursewareVersion: 1, contentHash: HASH_V1, }); } expect(await courseManifestRepo.getRecord('legacy-course', 4)).toEqual(legacy); const stored = JSON.parse( await fs.readFile(path.join(manifestsDir, 'legacy-course.json'), 'utf-8'), ) as { courseId: string; versions: CourseManifestRecord[] }; expect(stored.courseId).toBe('legacy-course'); expect(stored.versions.map((record) => record.version)).toEqual([4, 5]); expect(stored.versions[0]).toEqual(legacy); }); test('refuses an empty module list', async () => { const { publishCourse } = await import('@/lib/course-manifest-repo'); const result = await publishCourse({ courseId: 'course-1', token: 'secret-token', title: 't', summary: 's', modules: [], }); expect(result.ok).toBe(false); }); });