diff --git a/src/lib/unified-storage.test.ts b/src/lib/unified-storage.test.ts new file mode 100644 index 0000000..276c9af --- /dev/null +++ b/src/lib/unified-storage.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + callOrder, + mockFlaskGet, + mockIndexedGet, + mockSQLiteGet, + mockSparkGet, + MockFlaskBackendAdapter, + MockIndexedDBAdapter, + MockSQLiteAdapter, + MockSparkKVAdapter +} = vi.hoisted(() => { + const callOrder: string[] = [] + const mockFlaskGet = vi.fn<[], Promise>() + const mockIndexedGet = vi.fn<[], Promise>() + const mockSQLiteGet = vi.fn<[], Promise>() + const mockSparkGet = vi.fn<[], Promise>() + + class MockFlaskBackendAdapter { + constructor() { + callOrder.push('flask') + } + + get = mockFlaskGet + } + + class MockIndexedDBAdapter { + constructor() { + callOrder.push('indexeddb') + } + + get = mockIndexedGet + } + + class MockSQLiteAdapter { + constructor() { + callOrder.push('sqlite') + } + + get = mockSQLiteGet + } + + class MockSparkKVAdapter { + constructor() { + callOrder.push('sparkkv') + } + + get = mockSparkGet + } + + return { + callOrder, + mockFlaskGet, + mockIndexedGet, + mockSQLiteGet, + mockSparkGet, + MockFlaskBackendAdapter, + MockIndexedDBAdapter, + MockSQLiteAdapter, + MockSparkKVAdapter + } +}) + +vi.mock('./unified-storage-adapters', () => ({ + FlaskBackendAdapter: MockFlaskBackendAdapter, + IndexedDBAdapter: MockIndexedDBAdapter, + SQLiteAdapter: MockSQLiteAdapter, + SparkKVAdapter: MockSparkKVAdapter +})) + +const createLocalStorageMock = () => { + const store = new Map() + + return { + getItem: vi.fn((key: string) => store.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => { + store.set(key, value) + }), + removeItem: vi.fn((key: string) => { + store.delete(key) + }), + clear: vi.fn(() => { + store.clear() + }) + } +} + +describe('UnifiedStorage.detectAndInitialize', () => { + let localStorageMock: ReturnType + + beforeEach(() => { + vi.resetModules() + callOrder.length = 0 + mockFlaskGet.mockReset() + mockIndexedGet.mockReset() + mockSQLiteGet.mockReset() + mockSparkGet.mockReset() + + localStorageMock = createLocalStorageMock() + vi.stubGlobal('localStorage', localStorageMock) + vi.stubGlobal('window', { spark: undefined }) + + if (!(import.meta as { env?: Record }).env) { + ;(import.meta as { env?: Record }).env = {} + } + }) + + it('tries Flask before IndexedDB when prefer-flask is set', async () => { + localStorageMock.setItem('codeforge-prefer-flask', 'true') + mockFlaskGet.mockRejectedValue(new Error('flask down')) + mockIndexedGet.mockResolvedValue(undefined) + vi.stubGlobal('indexedDB', {}) + + const { unifiedStorage } = await import('./unified-storage') + await unifiedStorage.getBackend() + + expect(callOrder[0]).toBe('flask') + expect(callOrder).toContain('indexeddb') + }) + + it('falls back to IndexedDB when Flask initialization fails', async () => { + localStorageMock.setItem('codeforge-prefer-flask', 'true') + mockFlaskGet.mockRejectedValue(new Error('flask down')) + mockIndexedGet.mockResolvedValue(undefined) + vi.stubGlobal('indexedDB', {}) + + const { unifiedStorage } = await import('./unified-storage') + const backend = await unifiedStorage.getBackend() + + expect(backend).toBe('indexeddb') + }) + + it('honors prefer-sqlite when configured', async () => { + localStorageMock.setItem('codeforge-prefer-sqlite', 'true') + mockSQLiteGet.mockResolvedValue(undefined) + delete (globalThis as { indexedDB?: unknown }).indexedDB + + const { unifiedStorage } = await import('./unified-storage') + const backend = await unifiedStorage.getBackend() + + expect(backend).toBe('sqlite') + expect(callOrder).toContain('sqlite') + }) +}) diff --git a/src/lib/unified-storage.ts b/src/lib/unified-storage.ts index d430886..2ddd8a2 100644 --- a/src/lib/unified-storage.ts +++ b/src/lib/unified-storage.ts @@ -19,6 +19,23 @@ class UnifiedStorage { const flaskEnvUrl = import.meta.env.VITE_FLASK_BACKEND_URL const preferSQLite = localStorage.getItem('codeforge-prefer-sqlite') === 'true' + if (preferFlask || flaskEnvUrl) { + try { + console.log('[Storage] Flask backend explicitly configured, attempting to initialize...') + const flaskAdapter = new FlaskBackendAdapter(flaskEnvUrl) + await Promise.race([ + flaskAdapter.get('_health_check'), + new Promise((_, reject) => setTimeout(() => reject(new Error('Flask connection timeout')), 2000)) + ]) + this.adapter = flaskAdapter + this.backend = 'flask' + console.log('[Storage] ✓ Using Flask backend') + return + } catch (error) { + console.warn('[Storage] Flask backend not available, falling back to IndexedDB:', error) + } + } + if (typeof indexedDB !== 'undefined') { try { console.log('[Storage] Initializing default IndexedDB backend...') @@ -33,26 +50,6 @@ class UnifiedStorage { } } - if (preferFlask || flaskEnvUrl) { - try { - console.log('[Storage] Flask backend explicitly configured, attempting to initialize...') - const flaskAdapter = new FlaskBackendAdapter(flaskEnvUrl) - const testResponse = await Promise.race([ - flaskAdapter.get('_health_check'), - new Promise((_, reject) => setTimeout(() => reject(new Error('Flask connection timeout')), 2000)) - ]) - this.adapter = flaskAdapter - this.backend = 'flask' - console.log('[Storage] ✓ Using Flask backend') - return - } catch (error) { - console.warn('[Storage] Flask backend not available, already using IndexedDB:', error) - if (this.adapter && this.backend === 'indexeddb') { - return - } - } - } - if (preferSQLite) { try { console.log('[Storage] SQLite fallback, attempting to initialize...')