381 lines
15 KiB
TypeScript
381 lines
15 KiB
TypeScript
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<Uint8Array> {
|
|
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<Uint8Array> {
|
|
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<Uint8Array> {
|
|
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<void> }) => Promise<void>,
|
|
) => {
|
|
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();
|
|
});
|
|
});
|