import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; const imageFilesMock = vi.hoisted(() => ({ get: vi.fn(), put: vi.fn(), delete: vi.fn(), })); vi.mock('@/lib/utils/database', () => ({ db: { imageFiles: imageFilesMock, }, })); import { extractOriginalImageId, loadImageMapping, loadPdfBlob, storeImages, storePdfBlob, } from '@/lib/utils/image-storage'; class TestFileReader { result: string | ArrayBuffer | null = null; onloadend: FileReader['onloadend'] = null; onerror: FileReader['onerror'] = null; readAsDataURL(blob: Blob): void { void blob.arrayBuffer().then( (buffer) => { this.result = `data:${blob.type};base64,${Buffer.from(buffer).toString('base64')}`; this.onloadend?.call(this as unknown as FileReader, {} as ProgressEvent); }, () => { this.onerror?.call(this as unknown as FileReader, {} as ProgressEvent); }, ); } } function bytesOf(buffer: ArrayBuffer): number[] { return Array.from(new Uint8Array(buffer)); } beforeAll(() => { vi.stubGlobal('FileReader', TestFileReader); }); afterAll(() => { vi.unstubAllGlobals(); }); beforeEach(() => { imageFilesMock.get.mockReset(); imageFilesMock.put.mockReset(); imageFilesMock.delete.mockReset(); }); describe('image file persistence', () => { it('stores a PDF as an ArrayBuffer with its exact metadata', async () => { const bytes = [0x25, 0x50, 0x44, 0x46, 0x01, 0xff]; const file = new File([Uint8Array.from(bytes)], 'lesson.pdf', { type: 'application/pdf', }); const storageKey = await storePdfBlob(file); expect(imageFilesMock.put).toHaveBeenCalledOnce(); const record = imageFilesMock.put.mock.calls[0][0]; expect(record.id).toBe(storageKey); expect(record.blob).toBeInstanceOf(ArrayBuffer); expect(bytesOf(record.blob)).toEqual(bytes); expect(record.mimeType).toBe('application/pdf'); expect(record.filename).toBe('lesson.pdf'); expect(record.size).toBe(record.blob.byteLength); }); it('stores a page image as an ArrayBuffer with its decoded bytes and MIME type', async () => { const bytes = [0x00, 0x01, 0x02, 0xfd, 0xfe]; const src = `data:image/jpeg;base64,${Buffer.from(bytes).toString('base64')}`; await storeImages([{ id: 'img_1', src, pageNumber: 1 }]); expect(imageFilesMock.put).toHaveBeenCalledOnce(); const record = imageFilesMock.put.mock.calls[0][0]; expect(record.blob).toBeInstanceOf(ArrayBuffer); expect(bytesOf(record.blob)).toEqual(bytes); expect(record.mimeType).toBe('image/jpeg'); expect(record.size).toBe(record.blob.byteLength); }); it('loads an ArrayBuffer PDF record as a Blob with exact bytes and MIME type', async () => { const bytes = [0x25, 0x50, 0x44, 0x46, 0x02]; imageFilesMock.get.mockResolvedValue({ blob: Uint8Array.from(bytes).buffer, mimeType: 'application/pdf', }); const blob = await loadPdfBlob('pdf-key'); expect(blob).toBeInstanceOf(Blob); expect(blob?.type).toBe('application/pdf'); expect(bytesOf(await blob!.arrayBuffer())).toEqual(bytes); }); it('returns a legacy typed Blob unchanged', async () => { const legacyBlob = new Blob([Uint8Array.from([1, 2, 3])], { type: 'application/pdf' }); imageFilesMock.get.mockResolvedValue({ blob: legacyBlob, mimeType: 'application/pdf', }); await expect(loadPdfBlob('legacy-pdf')).resolves.toBe(legacyBlob); }); it('reconstructs a legacy empty-type Blob with the record MIME type and exact bytes', async () => { const bytes = [3, 1, 4, 1, 5]; const legacyBlob = new Blob([Uint8Array.from(bytes)]); imageFilesMock.get.mockResolvedValue({ blob: legacyBlob, mimeType: 'application/pdf', }); const blob = await loadPdfBlob('legacy-empty-type-pdf'); expect(blob).toBeInstanceOf(Blob); expect(blob).not.toBe(legacyBlob); expect(blob?.type).toBe('application/pdf'); expect(bytesOf(await blob!.arrayBuffer())).toEqual(bytes); }); it('loads an ArrayBuffer page-image record as an exact data URL mapping', async () => { const bytes = [0xde, 0xad, 0xbe, 0xef]; imageFilesMock.get.mockResolvedValue({ blob: Uint8Array.from(bytes).buffer, mimeType: 'image/webp', }); await expect(loadImageMapping(['session_abc123def4_img_1'])).resolves.toEqual({ img_1: `data:image/webp;base64,${Buffer.from(bytes).toString('base64')}`, }); }); it('rolls back every earlier image when a later write fails and preserves the cause', async () => { const writeFailure = new Error('IndexedDB write failed'); imageFilesMock.put .mockResolvedValueOnce(undefined) .mockResolvedValueOnce(undefined) .mockRejectedValueOnce(writeFailure); imageFilesMock.delete.mockResolvedValue(undefined); const images = [ { id: 'img_1', src: 'data:image/png;base64,AQ==' }, { id: 'img_2', src: 'data:image/png;base64,Ag==' }, { id: 'img_3', src: 'data:image/png;base64,Aw==' }, ]; const result = storeImages(images); await expect(result).rejects.toMatchObject({ message: 'Failed to store image bundle at image img_3', cause: writeFailure, }); const successfulIds = imageFilesMock.put.mock.calls.slice(0, 2).map(([record]) => record.id); expect(imageFilesMock.delete.mock.calls.map(([id]) => id)).toEqual(successfulIds); }); }); describe('extractOriginalImageId', () => { it('extracts an image id from a normal session storage id', () => { expect(extractOriginalImageId('session_abc123def4_img_15')).toBe('img_15'); }); it('does not truncate a nanoid containing underscores', () => { expect(extractOriginalImageId('session_ab_cd_efgh_img_20')).toBe('img_20'); }); it('preserves nonnumeric image ids', () => { expect(extractOriginalImageId('session_abc123def4_img_data')).toBe('img_data'); }); it('preserves image ids containing an image-like delimiter', () => { expect(extractOriginalImageId('session_abc123def4_hero_img_1')).toBe('hero_img_1'); }); it('rejects malformed storage ids', () => { expect(extractOriginalImageId('session_abc123')).toBeUndefined(); expect(extractOriginalImageId('session_abc123def4_')).toBeUndefined(); expect(extractOriginalImageId('other_abc123def4_img_1')).toBeUndefined(); }); });