Files
openmaic/OpenMAIC/tests/bundle/learner-load.test.ts
2026-08-16 14:58:47 +08:00

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();
});
});