import { afterAll, beforeAll, describe, expect, test } from 'vitest'; import { promises as fs } from 'fs'; import os from 'os'; import path from 'path'; import { migrateAllClassrooms, migrateClassroomToCourseware } from '@/lib/courseware-repo/migrate'; import { createFileCoursewareRepo } from '@/lib/courseware-repo/store'; import { createFileBundleByteStore } from '@/lib/courseware-repo/bundle-store'; import { writeJsonFileAtomic } from '@/lib/server/classroom-storage'; import { readFrozenBundleDocuments } from '@/lib/bundle/packager'; import { AUDIO_BYTES, makeScenes, makeStage } from '../bundle/fixtures'; import type { PersistedClassroomData } from '@/lib/server/classroom-storage'; let recordsDir: string; let bytesDir: string; let classroomsDir: string; beforeAll(async () => { recordsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mig-records-')); bytesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mig-bytes-')); classroomsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mig-classrooms-')); }); afterAll(async () => { await fs.rm(recordsDir, { recursive: true, force: true }); await fs.rm(bytesDir, { recursive: true, force: true }); await fs.rm(classroomsDir, { recursive: true, force: true }); }); function makeClassroom(id: string): PersistedClassroomData { return { id, stage: makeStage(), scenes: makeScenes(), createdAt: '2026-08-15T00:00:00.000Z', }; } const resolvers = { resolveAudioBytes: async (audioId: string) => (audioId === 'aud-1' ? AUDIO_BYTES : null), resolveMediaBytes: async (record: { id: string }) => record.id.includes('img') ? new Blob([new Uint8Array([7, 7, 7])], { type: 'image/png' }) : new Blob([new Uint8Array([8, 8, 8])], { type: 'video/mp4' }), }; function repos() { return { records: createFileCoursewareRepo(recordsDir), bytes: createFileBundleByteStore(bytesDir), }; } describe('migrateClassroomToCourseware', () => { test('publishes a complete classroom (audio missing → media still resolves?)', async () => { // The fixture classroom's scenes reference audio aud-1; media records are // derived from the resolvers' record list below. const classroom = makeClassroom('legacy-1'); const result = await migrateClassroomToCourseware(classroom, { coursewareId: 'legacy-1', baseUrl: 'http://localhost:3000', resolvers: { resolveAudioBytes: resolvers.resolveAudioBytes, resolveMediaBytes: async () => null, // no media records supplied → none required }, repos: repos(), }); expect(result.status).toBe('published'); if (result.status === 'published') { expect(result.version).toBe(1); // The published bundle round-trips as a frozen bundle. const stored = await repos().bytes.read('legacy-1', 1); const documents = await readFrozenBundleDocuments(stored!); expect(documents.meta.coursewareId).toBe('legacy-1'); expect(documents.meta.stageName).toBe('示例课件:光合作用'); expect(documents.knowledge.scenes).toHaveLength(3); } }); test('reports incomplete instead of publishing when audio is missing', async () => { const result = await migrateClassroomToCourseware(makeClassroom('legacy-2'), { coursewareId: 'legacy-2', baseUrl: 'http://localhost:3000', resolvers: { resolveAudioBytes: async () => null }, repos: repos(), }); expect(result.status).toBe('incomplete'); if (result.status === 'incomplete') { expect(result.missing.some((m) => m.includes('audio:aud-1'))).toBe(true); } // Nothing was published for it. const record = await repos().records.getLatestRecord('legacy-2'); expect(record).toBeNull(); }); test('writes an explicit migration version into both registry and frozen ZIP', async () => { const coursewareId = 'legacy-explicit-version'; const result = await migrateClassroomToCourseware(makeClassroom(coursewareId), { coursewareId, version: 7, baseUrl: 'http://localhost:3000', resolvers: { resolveAudioBytes: resolvers.resolveAudioBytes, resolveMediaBytes: async () => null, }, repos: repos(), }); expect(result.status).toBe('published'); if (result.status === 'published') { expect(result.record.version).toBe(7); const stored = await repos().bytes.read(coursewareId, 7); expect(stored).not.toBeNull(); expect((await readFrozenBundleDocuments(stored!)).meta.version).toBe(7); } }); }); describe('migrateAllClassrooms', () => { test('scans the classrooms dir and reports published/incomplete/skipped', async () => { // legacy-ok: full resolvers (audio + media records driven by stage content) await writeJsonFileAtomic( path.join(classroomsDir, 'legacy-ok.json'), makeClassroom('legacy-ok'), ); // legacy-broken: references an audio id the resolvers cannot provide const broken = makeClassroom('legacy-broken'); broken.scenes = broken.scenes.map((scene) => ({ ...scene, actions: (scene.actions ?? []).map((action) => action.type === 'speech' ? { ...action, audioId: 'aud-missing' } : action, ), })); await writeJsonFileAtomic(path.join(classroomsDir, 'legacy-broken.json'), broken); const report = await migrateAllClassrooms({ baseUrl: 'http://localhost:3000', classroomsDir, resolvers: { resolveAudioBytes: async (audioId) => (audioId === 'aud-1' ? AUDIO_BYTES : null), resolveMediaBytes: async () => null, }, repos: repos(), }); expect(report.published.map((r) => (r.status === 'published' ? r.classroomId : ''))).toContain( 'legacy-ok', ); expect( report.incomplete.map((r) => (r.status === 'incomplete' ? r.classroomId : '')), ).toContain('legacy-broken'); expect(report.skipped).toEqual([]); }); });