import { DSL_VERSION } from '@openmaic/dsl'; import { BrowserDocumentStore, HttpDocumentStore, type DocumentStore, type KVScope, type KVStore, } from '@openmaic/storage'; import { IDBFactory } from 'fake-indexeddb'; import { describe, expect, test, vi } from 'vitest'; import { accessDocument, migrateDocumentForVerification, type LegacyDocumentSnapshot, type LegacyDocumentStore, } from '@/lib/document-store/migration'; import type { AppDocument } from '@/lib/document-store/persistence-types'; import type { AppScene } from '@/lib/types/stage'; import { validateAppScene, validateAppStage } from '@/lib/document-store/validators'; class MemoryKv implements KVStore { readonly values = new Map(); failMarker = false; async get(key: string, scope: KVScope = 'account'): Promise { return (this.values.get(`${scope}:${key}`) as T | undefined) ?? null; } async set(key: string, value: T, scope: KVScope = 'account'): Promise { if (this.failMarker && key.startsWith('document-migration:')) throw new Error('marker failed'); this.values.set(`${scope}:${key}`, structuredClone(value)); } async remove(key: string, scope: KVScope = 'account'): Promise { this.values.delete(`${scope}:${key}`); } async keys(prefix = '', scope: KVScope = 'account'): Promise { const fullPrefix = `${scope}:${prefix}`; return [...this.values.keys()] .filter((key) => key.startsWith(fullPrefix)) .map((key) => key.slice(scope.length + 1)); } } function snapshot(name = 'Legacy'): LegacyDocumentSnapshot { return { stage: { id: 'stage-1', name, createdAt: 100, updatedAt: 200, currentSceneId: 'scene-1', }, scenes: [ { id: 'scene-1', stageId: 'stage-1', type: 'quiz', title: 'Scene', order: 0, content: { type: 'slide', canvas: { id: 'canvas-1', elements: [] } as never }, whiteboard: [{ id: 'whiteboard-1', elements: [] } as never], createdAt: 100, updatedAt: 200, }, ], outline: { stageId: 'stage-1', outlines: [], generationComplete: true, createdAt: 100, updatedAt: 200, }, }; } function legacy(value: LegacyDocumentSnapshot | null): LegacyDocumentStore { return { read: vi.fn().mockResolvedValue(value), listStages: vi.fn().mockResolvedValue(value ? [value.stage] : []), }; } async function indexedLegacyStore( idb: IDBFactory, value: LegacyDocumentSnapshot, ): Promise { const database = await new Promise((resolve, reject) => { const request = idb.open('MAIC-Database', 1); request.onupgradeneeded = () => { request.result.createObjectStore('stages', { keyPath: 'id' }); const scenes = request.result.createObjectStore('scenes', { keyPath: 'id' }); scenes.createIndex('stageId', 'stageId'); request.result.createObjectStore('stageOutlines', { keyPath: 'stageId' }); }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); await new Promise((resolve, reject) => { const tx = database.transaction(['stages', 'scenes', 'stageOutlines'], 'readwrite'); tx.objectStore('stages').put(value.stage); for (const scene of value.scenes) tx.objectStore('scenes').put(scene); if (value.outline) tx.objectStore('stageOutlines').put(value.outline); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); const requestValue = (request: IDBRequest): Promise => new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); return { async read(stageId) { const tx = database.transaction(['stages', 'scenes', 'stageOutlines'], 'readonly'); const [stage, scenes, outline] = await Promise.all([ requestValue(tx.objectStore('stages').get(stageId)), requestValue(tx.objectStore('scenes').index('stageId').getAll(stageId)), requestValue(tx.objectStore('stageOutlines').get(stageId)), ]); return stage ? { stage, scenes, outline, } : null; }, async listStages() { const tx = database.transaction('stages', 'readonly'); return requestValue(tx.objectStore('stages').getAll()); }, }; } function lockManager(): LockManager { let tail = Promise.resolve(); return { request: vi.fn((_name, _options, callback) => { const result = tail.then(() => callback({ name: _name, mode: 'exclusive' } as Lock)); tail = result.then( () => undefined, () => undefined, ); return result; }), query: vi.fn(), } as unknown as LockManager; } function store(idb = new IDBFactory()): DocumentStore { return new BrowserDocumentStore({ indexedDB: idb, dbName: 'maic-documents', validateScene: () => ({ valid: true }), }); } describe('legacy document migration', () => { test('keeps the opaque outline outside the migration verification baseline', () => { const outline = { outlines: [], generationComplete: true, createdAt: 100, updatedAt: 200, }; const document: AppDocument = { dslVersion: DSL_VERSION, stage: { id: 'stage-1', name: 'Legacy', createdAt: 100, updatedAt: 200 }, scenes: [], outline, }; const migrateDsl = vi.fn((value: unknown) => { const candidate = value as AppDocument; return 'outline' in candidate ? { ...candidate, stage: { ...candidate.stage, name: 'Corrupted' } } : candidate; }); const migrated = migrateDocumentForVerification(document, migrateDsl); expect(migrateDsl).toHaveBeenCalledOnce(); expect(migrateDsl.mock.calls[0]![0]).not.toHaveProperty('outline'); expect(migrated).toEqual(document); expect(migrated.outline).toBe(outline); }); test('returns null when neither store has a document', async () => { await expect( accessDocument('missing', { store: store(), kv: new MemoryKv(), legacyStore: legacy(null), lockManager: lockManager(), }), ).resolves.toEqual({ document: null, readOnlyLegacy: false }); }); test('migrates, canonicalizes, verifies, and records device state', async () => { const idb = new IDBFactory(); const documentStore = store(idb); const kv = new MemoryKv(); const result = await accessDocument('stage-1', { store: documentStore, kv, legacyStore: await indexedLegacyStore(idb, snapshot()), lockManager: lockManager(), }); expect(result.document).toMatchObject({ dslVersion: DSL_VERSION, stage: { id: 'stage-1', name: 'Legacy' }, scenes: [{ id: 'scene-1', type: 'slide', whiteboards: [{ id: 'whiteboard-1' }] }], outline: { generationComplete: true }, }); expect(result.document!.stage).not.toHaveProperty('currentSceneId'); expect(await kv.get('editor-current-scene:stage-1', 'device')).toMatchObject({ sceneId: 'scene-1', }); expect(await kv.get('document-migration:stage-1', 'device')).toMatchObject({ sourceUpdatedAt: 200, }); }); test('uses the injected DSL migration to verify a fresh destination', async () => { const migrateDsl = vi.fn((value: unknown) => ({ ...(value as AppDocument), dslVersion: DSL_VERSION, })); await expect( accessDocument('stage-1', { store: store(), kv: new MemoryKv(), legacyStore: legacy(snapshot()), lockManager: lockManager(), migrateDsl, }), ).resolves.toMatchObject({ document: { dslVersion: DSL_VERSION, stage: { id: 'stage-1' } }, readOnlyLegacy: false, }); expect(migrateDsl).toHaveBeenCalledOnce(); expect(migrateDsl.mock.calls[0]![0]).toMatchObject({ stage: { id: 'stage-1' } }); }); test('uses the injected DSL migration to verify an existing destination', async () => { const documentStore = store(); const kv = new MemoryKv(); const destination: AppDocument = { dslVersion: DSL_VERSION, stage: { id: 'stage-1', name: 'Migrated', createdAt: 100, updatedAt: 200 }, scenes: [], outline: { outlines: [], generationComplete: true, createdAt: 100, updatedAt: 200, }, }; await documentStore.saveDocument(destination); const migrateDsl = vi.fn((value: unknown) => { const candidate = value as AppDocument; return { ...candidate, dslVersion: DSL_VERSION, stage: { ...candidate.stage, name: 'Migrated' }, scenes: [], }; }); await expect( accessDocument('stage-1', { store: documentStore, kv, legacyStore: legacy(snapshot()), lockManager: lockManager(), migrateDsl, }), ).resolves.toMatchObject({ document: destination, readOnlyLegacy: false }); expect(migrateDsl).toHaveBeenCalledOnce(); expect(await kv.get('document-migration:stage-1', 'device')).toMatchObject({ sourceUpdatedAt: 200, }); }); test('normalizes optional undefined members for an injected HTTP store', async () => { const source = snapshot(); source.stage.description = undefined; source.scenes[0]!.content = { type: 'pbl', projectConfig: {}, projectV2: { roles: [], milestones: [{ microtasks: [{ internalAssessment: undefined }] }], submissions: [], evaluations: [], threads: [], engagementEvents: [], }, } as never; const kv = new MemoryKv(); let persisted: AppDocument | null = null; const fetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { if (init?.method === 'PUT') { persisted = JSON.parse(init.body as string) as AppDocument; return new Response(null, { status: 204 }); } if (persisted) return Response.json(persisted); return Response.json( { error: { code: 'DOCUMENT_NOT_FOUND', message: 'missing' } }, { status: 404 }, ); }) as typeof globalThis.fetch; const documentStore = new HttpDocumentStore({ baseUrl: 'https://documents.test', fetch, validateScene: validateAppScene, validateStage: validateAppStage, }); const result = await accessDocument('stage-1', { store: documentStore, kv, legacyStore: legacy(source), lockManager: lockManager(), }); expect(result.document?.stage).not.toHaveProperty('description'); expect( ( persisted!.scenes[0]!.content as unknown as { projectV2: { milestones: Array<{ microtasks: Array> }> }; } ).projectV2.milestones[0]!.microtasks[0], ).not.toHaveProperty('internalAssessment'); expect(await kv.get('document-migration:stage-1', 'device')).toMatchObject({ sourceUpdatedAt: 200, }); }); test('exposes legacy read-only when Web Locks are unavailable', async () => { vi.stubGlobal('window', {}); try { const result = await accessDocument('stage-1', { store: store(), legacyStore: legacy(snapshot()), lockManager: null, }); expect(result).toMatchObject({ readOnlyLegacy: true, legacyCurrentSceneId: 'scene-1', document: { stage: { name: 'Legacy' } }, }); } finally { vi.unstubAllGlobals(); } }); test('keeps a divergent destination authoritative without certifying the legacy snapshot', async () => { const documentStore = store(); const kv = new MemoryKv(); const legacyStore = legacy(snapshot('Legacy V2')); const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); await documentStore.saveDocument({ stage: { id: 'stage-1', name: 'Destination V1', createdAt: 1, updatedAt: 2 }, scenes: [], }); const result = await accessDocument('stage-1', { store: documentStore, kv, legacyStore, lockManager: lockManager(), }); expect(result.document!.stage.name).toBe('Destination V1'); expect(await kv.get('document-migration:stage-1', 'device')).toBeNull(); expect(warn).toHaveBeenCalledWith(expect.stringContaining('stage stage-1')); await expect(legacyStore.read('stage-1')).resolves.toMatchObject({ stage: { name: 'Legacy V2' }, }); warn.mockRestore(); }); test('fails loud for a future-versioned destination instead of falling back', async () => { const future = { stage: { id: 'stage-1', name: 'Future', createdAt: 1, updatedAt: 2 }, scenes: [], dslVersion: '99.0.0', } as AppDocument; const futureStore = { loadDocument: vi.fn().mockResolvedValue(future), } as unknown as DocumentStore; await expect( accessDocument('stage-1', { store: futureStore, kv: new MemoryKv(), legacyStore: legacy(snapshot()), lockManager: lockManager(), }), ).rejects.toThrow('unsupported DSL version'); }); test('resumes metadata after destination commit when the marker write failed', async () => { const documentStore = store(); const kv = new MemoryKv(); kv.failMarker = true; const deps = { store: documentStore, kv, legacyStore: legacy(snapshot()), lockManager: lockManager(), }; await expect(accessDocument('stage-1', deps)).rejects.toThrow('marker failed'); expect(await documentStore.loadDocument('stage-1')).not.toBeNull(); kv.failMarker = false; await expect(accessDocument('stage-1', deps)).resolves.toMatchObject({ readOnlyLegacy: false }); expect(await kv.get('document-migration:stage-1', 'device')).not.toBeNull(); }); test('fails loud when post-write verification differs', async () => { const realStore = store(); let loads = 0; const corruptingStore = new Proxy(realStore, { get(target, property) { if (property === 'loadDocument') { return async (stageId: string): Promise => { const loaded = (await target.loadDocument(stageId)) as AppDocument | null; loads += 1; return loads > 1 && loaded ? { ...loaded, scenes: loaded.scenes.map((scene) => ({ ...scene, order: 99 })) } : loaded; }; } const value = Reflect.get(target, property, target) as unknown; return typeof value === 'function' ? value.bind(target) : value; }, }) as DocumentStore; await expect( accessDocument('stage-1', { store: corruptingStore, kv: new MemoryKv(), legacyStore: legacy(snapshot()), lockManager: lockManager(), }), ).rejects.toThrow('verification failed'); }); test('serializes concurrent migrations from two stores over one IndexedDB', async () => { const idb = new IDBFactory(); const locks = lockManager(); const source = legacy(snapshot()); const kv = new MemoryKv(); const [first, second] = await Promise.all([ accessDocument('stage-1', { store: store(idb), kv, legacyStore: source, lockManager: locks }), accessDocument('stage-1', { store: store(idb), kv, legacyStore: source, lockManager: locks }), ]); expect(first.document).toEqual(second.document); expect(locks.request).toHaveBeenCalledTimes(2); }); test('does not clobber a newer current-scene KV value', async () => { const kv = new MemoryKv(); await kv.set( 'editor-current-scene:stage-1', { sceneId: 'newer-scene', updatedAt: '2030-01-01T00:00:00.000Z' }, 'device', ); await accessDocument('stage-1', { store: store(), kv, legacyStore: legacy(snapshot()), lockManager: lockManager(), }); expect(await kv.get('editor-current-scene:stage-1', 'device')).toMatchObject({ sceneId: 'newer-scene', }); }); });