import { beforeEach, describe, expect, test, vi } from 'vitest'; import { fetchCoursewareDetail, learnerStageId, loadLearnerCourseware, } from '@/lib/bundle/learner-load'; import { packageSampleBundle } from './fixtures'; import { computeBundleContentHash } from '@/lib/bundle/packager'; const HASH_A = 'ab'.repeat(32); const HASH_B = 'cd'.repeat(32); const mocks = vi.hoisted(() => ({ accessDocument: vi.fn(), mutateDocument: vi.fn(), mediaPut: vi.fn(), audioPut: vi.fn(), mediaDelete: vi.fn(), audioDelete: vi.fn(), putAsset: vi.fn(), removeAsset: vi.fn(), fetchImpl: vi.fn(), })); vi.mock('@/lib/document-store', () => ({ accessDocument: (...args: unknown[]) => mocks.accessDocument(...args), mutateDocument: (...args: unknown[]) => mocks.mutateDocument(...args), canonicalizeLegacyScene: (scene: unknown) => scene, })); vi.mock('@/lib/utils/database', () => ({ mediaFileKey: (stageId: string, ref: string) => `${stageId}:${ref}`, db: { mediaFiles: { put: mocks.mediaPut, delete: mocks.mediaDelete, where: () => ({ equals: () => ({ delete: mocks.mediaDelete }) }), }, audioFiles: { put: mocks.audioPut, delete: mocks.audioDelete, where: () => ({ equals: () => ({ delete: mocks.audioDelete }) }), }, }, })); vi.mock('@/lib/media/asset-pool', () => ({ putAsset: (...args: unknown[]) => mocks.putAsset(...args), removeAsset: (...args: unknown[]) => mocks.removeAsset(...args), })); const DETAIL = { coursewareId: 'cw-1', version: 3, title: '示例课件:光合作用', language: 'zh-CN', bundleUrl: 'http://localhost/api/coursewares/cw-1/bundles/3/download', sceneCount: 3, quizSceneCount: 1, contentHash: HASH_A, }; function registryResponse(detail = DETAIL): Response { return new Response(JSON.stringify({ success: true, courseware: detail })); } async function tamperPayload(zipBlob: Blob): Promise { const JSZip = (await import('jszip')).default; const zip = await JSZip.loadAsync(await zipBlob.arrayBuffer()); zip.file('manifest.json', JSON.stringify({ tampered: true })); return zip.generateAsync({ type: 'uint8array' }); } async function tamperInternalHash(zipBlob: Blob): Promise { const JSZip = (await import('jszip')).default; const zip = await JSZip.loadAsync(await zipBlob.arrayBuffer()); const bundleDocument = JSON.parse(await zip.file('bundle.json')!.async('string')) as { meta: { contentHash: string }; }; bundleDocument.meta.contentHash = HASH_B; zip.file('bundle.json', JSON.stringify(bundleDocument)); return zip.generateAsync({ type: 'uint8array' }); } async function tamperInternalVersion(zipBlob: Blob, version: number): Promise { const JSZip = (await import('jszip')).default; const zip = await JSZip.loadAsync(await zipBlob.arrayBuffer()); const bundleDocument = JSON.parse(await zip.file('bundle.json')!.async('string')) as { meta: { version: number }; }; bundleDocument.meta.version = version; zip.file('bundle.json', JSON.stringify(bundleDocument)); return zip.generateAsync({ type: 'uint8array' }); } async function makeLegacyVersionTemplate(zipBlob: Blob): Promise<{ bytes: Uint8Array; contentHash: string; }> { const JSZip = (await import('jszip')).default; const zip = await JSZip.loadAsync(await zipBlob.arrayBuffer()); const bundleDocument = JSON.parse(await zip.file('bundle.json')!.async('string')) as { meta: { version: number; contentHash: string; contentHashVersion?: number }; }; bundleDocument.meta.version = 1; delete bundleDocument.meta.contentHashVersion; zip.file('bundle.json', JSON.stringify(bundleDocument)); const template = await zip.generateAsync({ type: 'uint8array' }); const contentHash = await computeBundleContentHash(template); bundleDocument.meta.contentHash = contentHash; zip.file('bundle.json', JSON.stringify(bundleDocument)); return { bytes: await zip.generateAsync({ type: 'uint8array' }), contentHash }; } describe('learnerStageId', () => { test('is deterministic and keyed by both version and content hash', () => { expect(learnerStageId('cw-1', 3)).toBe('learn_cw-1_v3'); expect(learnerStageId('cw-1', 3, HASH_A)).toBe(`learn_cw-1_v3_${HASH_A}`); expect(learnerStageId('cw-1', 3, HASH_A)).toBe(learnerStageId('cw-1', 3, HASH_A)); expect(learnerStageId('cw-1', 3, HASH_B)).not.toBe(learnerStageId('cw-1', 3, HASH_A)); expect(learnerStageId('cw-1', 4, HASH_A)).not.toBe(learnerStageId('cw-1', 3, HASH_A)); }); }); describe('fetchCoursewareDetail', () => { test('requests and returns one exact published version', async () => { mocks.fetchImpl.mockResolvedValueOnce(registryResponse()); const detail = await fetchCoursewareDetail('cw-1', mocks.fetchImpl, 3); expect(detail?.version).toBe(3); expect(detail?.bundleUrl).toBe(DETAIL.bundleUrl); expect(mocks.fetchImpl).toHaveBeenCalledWith('/api/coursewares/cw-1?version=3'); }); test('returns null on registry miss', async () => { mocks.fetchImpl.mockResolvedValueOnce(new Response('{}', { status: 404 })); expect(await fetchCoursewareDetail('missing', mocks.fetchImpl)).toBeNull(); }); }); describe('loadLearnerCourseware', () => { beforeEach(() => { vi.clearAllMocks(); mocks.accessDocument.mockReset(); mocks.mutateDocument.mockReset(); mocks.fetchImpl.mockReset(); mocks.putAsset.mockReset(); mocks.mediaPut.mockReset().mockResolvedValue(undefined); mocks.audioPut.mockReset().mockResolvedValue(undefined); }); test('keeps the standalone no-pin path on latest while using a hash-keyed cache', async () => { const stageId = `learn_cw-1_v3_${HASH_A}`; mocks.fetchImpl.mockResolvedValueOnce(registryResponse()); mocks.accessDocument.mockResolvedValue({ document: { stage: { id: stageId } } }); const result = await loadLearnerCourseware('cw-1', { fetchImpl: mocks.fetchImpl }); expect(result).toMatchObject({ stageId, cached: true }); expect(mocks.fetchImpl).toHaveBeenCalledWith('/api/coursewares/cw-1'); expect(mocks.accessDocument).toHaveBeenCalledWith(stageId); // Standalone latest still resolves once, but a cache hit never downloads the bundle. expect(mocks.fetchImpl).toHaveBeenCalledTimes(1); }); test.each([ { name: 'version', registryDetail: { ...DETAIL, version: 4 }, expectedError: /version mismatch/i, }, { name: 'content hash', registryDetail: { ...DETAIL, contentHash: HASH_B }, expectedError: /content hash changed/i, }, ])( 'rejects a registry $name mismatch before bundle download', async ({ registryDetail, expectedError }) => { mocks.fetchImpl.mockResolvedValueOnce(registryResponse(registryDetail)); await expect( loadLearnerCourseware('cw-1', { fetchImpl: mocks.fetchImpl, version: 3, expectedContentHash: HASH_A, }), ).rejects.toThrow(expectedError); expect(mocks.fetchImpl).toHaveBeenCalledTimes(1); expect(mocks.fetchImpl).toHaveBeenCalledWith('/api/coursewares/cw-1?version=3'); expect(mocks.accessDocument).not.toHaveBeenCalled(); }, ); test('downloads, materializes, and commits a fresh document (no outlines)', async () => { // 1. registry detail, 2. bundle download const packaged = await packageSampleBundle({ coursewareId: 'cw-1', version: 3 }); const zipBytes = new Uint8Array(await packaged.zip.arrayBuffer()); const detail = { ...DETAIL, contentHash: packaged.contentHash }; mocks.fetchImpl .mockResolvedValueOnce(registryResponse(detail)) .mockResolvedValueOnce( new Response(zipBytes, { headers: { 'Content-Type': 'application/zip' } }), ); mocks.accessDocument.mockResolvedValue({ document: null }); let savedDocument: unknown; mocks.mutateDocument.mockImplementation( async ( _stageId: string, fn: (doc: unknown, store: { saveDocument: (d: unknown) => Promise }) => Promise, ) => { const store = { saveDocument: async (d: unknown) => { savedDocument = d; }, }; await fn({ document: null }, store); }, ); // putAsset allocates sequential ids; the pool mirrors rows via db.put. mocks.putAsset.mockImplementation(async (_blob: Blob, meta: { mediaType: string }) => { const kind = meta.mediaType === 'audio' ? 'aud' : 'med'; return `asset-${kind}-${mocks.putAsset.mock.calls.length}`; }); const result = await loadLearnerCourseware('cw-1', { fetchImpl: mocks.fetchImpl, version: 3, expectedContentHash: packaged.contentHash, }); const stageId = `learn_cw-1_v3_${packaged.contentHash}`; expect(result).toMatchObject({ stageId, cached: false }); expect(mocks.fetchImpl).toHaveBeenCalledTimes(2); expect(mocks.fetchImpl.mock.calls[0][0]).toBe('/api/coursewares/cw-1?version=3'); expect(mocks.fetchImpl.mock.calls[1][0]).toBe(detail.bundleUrl); expect(mocks.accessDocument).toHaveBeenCalledWith(stageId); // The committed document: learner stage id, all scenes, no outlines. const doc = savedDocument as { stage: { id: string; name: string; generatedAgentConfigs?: unknown[] }; scenes: Array<{ id: string; title: string; actions?: Array<{ type: string; audioId?: string; audioRef?: string }>; outlineId?: string; }>; }; expect(doc.stage.id).toBe(stageId); expect(doc.stage.name).toBe('示例课件:光合作用'); expect(doc.scenes).toHaveLength(3); expect(doc.scenes.map((s) => s.outlineId).filter(Boolean)).toEqual([]); // Speech action audioId is rewritten to the allocated pool id. const speech = doc.scenes[0].actions?.find((a) => a.type === 'speech'); expect(speech?.audioId).toMatch(/^asset-aud-/); expect(speech?.audioRef).toBeUndefined(); // Audio + media rows mirrored to IndexedDB. expect(mocks.audioPut).toHaveBeenCalled(); expect(mocks.mediaPut).toHaveBeenCalled(); // Poster bytes mirrored for the video entry. expect(mocks.mediaPut.mock.calls.length).toBeGreaterThanOrEqual(2); }); test('rejects a bundle whose payload no longer matches the pinned hash', async () => { const packaged = await packageSampleBundle({ coursewareId: 'cw-1', version: 3 }); const detail = { ...DETAIL, contentHash: packaged.contentHash }; const tamperedBytes = await tamperPayload(packaged.zip); mocks.fetchImpl .mockResolvedValueOnce(registryResponse(detail)) .mockResolvedValueOnce(new Response(tamperedBytes as unknown as BodyInit)); mocks.accessDocument.mockResolvedValue({ document: null }); await expect( loadLearnerCourseware('cw-1', { fetchImpl: mocks.fetchImpl, version: 3, expectedContentHash: packaged.contentHash, }), ).rejects.toThrow(/identity or completeness/i); expect(mocks.fetchImpl).toHaveBeenCalledTimes(2); expect(mocks.putAsset).not.toHaveBeenCalled(); }); test('rejects a bundle whose internal courseware id differs from the pin', async () => { const packaged = await packageSampleBundle({ coursewareId: 'other-courseware', version: 3 }); const detail = { ...DETAIL, contentHash: packaged.contentHash }; const zipBytes = new Uint8Array(await packaged.zip.arrayBuffer()); mocks.fetchImpl .mockResolvedValueOnce(registryResponse(detail)) .mockResolvedValueOnce(new Response(zipBytes)); mocks.accessDocument.mockResolvedValue({ document: null }); await expect( loadLearnerCourseware('cw-1', { fetchImpl: mocks.fetchImpl, version: 3, expectedContentHash: packaged.contentHash, }), ).rejects.toThrow(/identity or completeness/i); expect(mocks.putAsset).not.toHaveBeenCalled(); }); test('rejects a bundle whose internal content hash differs from the registry pin', async () => { const packaged = await packageSampleBundle({ coursewareId: 'cw-1', version: 3 }); const detail = { ...DETAIL, contentHash: packaged.contentHash }; const tamperedBytes = await tamperInternalHash(packaged.zip); mocks.fetchImpl .mockResolvedValueOnce(registryResponse(detail)) .mockResolvedValueOnce(new Response(tamperedBytes as unknown as BodyInit)); mocks.accessDocument.mockResolvedValue({ document: null }); await expect( loadLearnerCourseware('cw-1', { fetchImpl: mocks.fetchImpl, version: 3, expectedContentHash: packaged.contentHash, }), ).rejects.toThrow(/identity or completeness/i); expect(mocks.putAsset).not.toHaveBeenCalled(); }); test('rejects a new hash-version bundle whose internal version differs from the registry pin', async () => { const packaged = await packageSampleBundle({ coursewareId: 'cw-1', version: 3 }); const detail = { ...DETAIL, contentHash: packaged.contentHash }; const mismatchedBytes = await tamperInternalVersion(packaged.zip, 1); mocks.fetchImpl .mockResolvedValueOnce(registryResponse(detail)) .mockResolvedValueOnce(new Response(mismatchedBytes as unknown as BodyInit)); mocks.accessDocument.mockResolvedValue({ document: null }); await expect( loadLearnerCourseware('cw-1', { fetchImpl: mocks.fetchImpl, version: 3, expectedContentHash: packaged.contentHash, }), ).rejects.toThrow(/identity or completeness/i); expect(mocks.putAsset).not.toHaveBeenCalled(); }); test('loads a pre-normalization v1 template when the exact registry hash still matches', async () => { const packaged = await packageSampleBundle({ coursewareId: 'cw-1', version: 3 }); const legacy = await makeLegacyVersionTemplate(packaged.zip); const detail = { ...DETAIL, contentHash: legacy.contentHash }; mocks.fetchImpl .mockResolvedValueOnce(registryResponse(detail)) .mockResolvedValueOnce(new Response(legacy.bytes as unknown as BodyInit)); mocks.accessDocument.mockResolvedValue({ document: null }); await expect( loadLearnerCourseware('cw-1', { fetchImpl: mocks.fetchImpl, version: 3, expectedContentHash: legacy.contentHash, }), ).resolves.toMatchObject({ detail: { version: 3, contentHash: legacy.contentHash } }); }); test('rolls back partial materialization on failure', async () => { const stageId = `learn_cw-1_v3_${HASH_A}`; mocks.fetchImpl .mockResolvedValueOnce(registryResponse()) .mockResolvedValueOnce(new Response(new Uint8Array([1, 2, 3]))); // junk zip → parse fails mocks.accessDocument.mockResolvedValue({ document: null }); await expect(loadLearnerCourseware('cw-1', { fetchImpl: mocks.fetchImpl })).rejects.toThrow(); // Rollback compensates the partial load: document + media/audio rows. expect(mocks.mutateDocument).toHaveBeenCalledWith(stageId, expect.any(Function)); expect(mocks.mediaDelete).toHaveBeenCalled(); expect(mocks.audioDelete).toHaveBeenCalled(); }); });