From 3189ce489d0a1aff1475961bc7ba828bd77ea164 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Fri, 12 Sep 2025 02:04:23 +0000 Subject: [PATCH] CodeRabbit Generated Unit Tests: Add Jest/Vitest tests for IndexedDB storage and build_library --- tests/build_indexedDB.test.js | 274 +++++++++++++ tests/build_library.test.js | 568 +++++++++++++++++++++++++++ tests/helpers/indexeddb-mock.spec.js | 261 ++++++++++++ 3 files changed, 1103 insertions(+) create mode 100644 tests/build_indexedDB.test.js create mode 100644 tests/build_library.test.js create mode 100644 tests/helpers/indexeddb-mock.spec.js diff --git a/tests/build_indexedDB.test.js b/tests/build_indexedDB.test.js new file mode 100644 index 0000000..87c9588 --- /dev/null +++ b/tests/build_indexedDB.test.js @@ -0,0 +1,274 @@ +/** + * Test runner/framework: Jest or Vitest (describe/it/expect). + * These tests avoid external deps by installing a minimal in-memory IndexedDB mock. + * They validate the public interfaces: + * - storeLibraryHandle(handle): Promise + * - getStoredLibraryHandle(): Promise + */ + +const defer = (fn) => (typeof queueMicrotask === 'function' ? queueMicrotask(fn) : Promise.resolve().then(fn)); + +function installIndexedDBMock() { + class FakeRequest { + constructor() { + this.result = undefined; + this.error = null; + this.onsuccess = null; + this.onerror = null; + } + + succeed(result) { + this.result = result; + if (typeof this.onsuccess === 'function') { + this.onsuccess({ target: { result } }); + } + } + + fail(error) { + this.error = error; + if (typeof this.onerror === 'function') { + this.onerror({ target: { error } }); + } + } + } + + class FakeObjectStore { + constructor(db, name) { + this.db = db; + this.name = name; + if (!this.db.data[this.name]) { + this.db.data[this.name] = new Map(); + } + } + + put(record) { + const req = new FakeRequest(); + defer(() => { + if (this.db.control.failNextPut) { + this.db.control.failNextPut = false; + return req.fail(new Error('put failed')); + } + // Mimic keyPath: "name" + if (!record || typeof record.name === 'undefined') { + return req.fail(new Error('KeyPath "name" missing')); + } + this.db.data[this.name].set(record.name, record); + req.succeed(undefined); + }); + return req; + } + + get(key) { + const req = new FakeRequest(); + defer(() => { + if (this.db.control.failNextGet) { + this.db.control.failNextGet = false; + return req.fail(new Error('get failed')); + } + const value = this.db.data[this.name].get(key); + req.succeed(value); + }); + return req; + } + } + + class FakeDB { + constructor(control) { + this.control = control; + this.data = {}; // { storeName: Map(key -> record) } + this.stores = new Set(); + } + + createObjectStore(name, _options) { + this.stores.add(name); + this.data[name] = this.data[name] || new Map(); + return new FakeObjectStore(this, name); + } + + transaction(storeName, _mode) { + // Minimal transaction mock returning an objectStore + return { + objectStore: (name) => new FakeObjectStore(this, name || storeName), + }; + } + } + + const control = { + upgradeCalls: 0, + failNextOpen: false, + failNextPut: false, + failNextGet: false, + // Resets DB instance and error flags + reset() { + db = null; + initialized = false; + this.failNextOpen = false; + this.failNextPut = false; + this.failNextGet = false; + this.upgradeCalls = 0; + }, + // Clears only the data, preserving initialization state + clearData() { + if (db) { + Object.keys(db.data).forEach((k) => { + db.data[k] = new Map(); + }); + } + }, + // For introspection in a couple of assertions + _getDB() { + return db; + }, + }; + + let db = null; + let initialized = false; + + const indexedDBMock = { + open(name, version) { + const req = new FakeRequest(); + defer(() => { + if (control.failNextOpen) { + control.failNextOpen = false; + return req.fail(new Error('open failed')); + } + if (!db) { + db = new FakeDB(control); + } + // Simulate initial upgrade path + if (!initialized) { + if (typeof req.onupgradeneeded === 'function') { + control.upgradeCalls += 1; + req.onupgradeneeded({ target: { result: db } }); + } + initialized = true; + } + if (typeof req.onsuccess === 'function') { + req.onsuccess({ target: { result: db } }); + } + }); + return req; + }, + }; + + Object.defineProperty(globalThis, 'indexedDB', { + configurable: true, + enumerable: true, + writable: true, + value: indexedDBMock, + }); + Object.defineProperty(globalThis, '__idbMock', { + configurable: true, + enumerable: false, + writable: true, + value: control, + }); +} + +let storeLibraryHandle; +let getStoredLibraryHandle; + +describe('IndexedDB library handle persistence', () => { + beforeAll(async () => { + installIndexedDBMock(); + + // Dynamically import the module under test from likely locations. + // This keeps tests flexible without adding config changes. + async function loadModule() { + const candidates = [ + '../src/build_indexedDB.js', + '../build_indexedDB.js', + '../src/utils/build_indexedDB.js', + '../app/build_indexedDB.js', + ]; + let lastErr; + for (const p of candidates) { + try { + // eslint-disable-next-line no-await-in-loop + const mod = await import(p); + return mod; + } catch (e) { + lastErr = e; + } + } + throw lastErr || new Error('Could not locate module under test'); + } + + const mod = await loadModule(); + storeLibraryHandle = mod.storeLibraryHandle; + getStoredLibraryHandle = mod.getStoredLibraryHandle; + if (typeof storeLibraryHandle !== 'function' || typeof getStoredLibraryHandle !== 'function') { + throw new Error('Module does not export expected functions'); + } + }); + + beforeEach(() => { + // Fresh state for every test + globalThis.__idbMock.reset(); + }); + + it('returns null when no library handle has been stored', async () => { + const handle = await getStoredLibraryHandle(); + expect(handle).toBeNull(); + }); + + it('stores and retrieves the same handle object (happy path)', async () => { + const toStore = { id: 123, name: 'Main Library' }; + await storeLibraryHandle(toStore); + const retrieved = await getStoredLibraryHandle(); + expect(retrieved).toEqual(toStore); + }); + + it('overwrites existing handle on subsequent store operations', async () => { + await storeLibraryHandle({ id: 1, name: 'Old' }); + await storeLibraryHandle({ id: 2, name: 'New' }); + const retrieved = await getStoredLibraryHandle(); + expect(retrieved).toEqual({ id: 2, name: 'New' }); + }); + + it('supports falsy handle values (e.g., 0) without coercing to null', async () => { + await storeLibraryHandle(0); + const retrieved = await getStoredLibraryHandle(); + expect(retrieved).toBe(0); + }); + + it('supports undefined handle value and returns undefined (not null) when present', async () => { + await storeLibraryHandle(undefined); + const retrieved = await getStoredLibraryHandle(); + expect(retrieved).toBeUndefined(); + }); + + it('propagates open errors when establishing DB connection (store path)', async () => { + globalThis.__idbMock.failNextOpen = true; + await expect(storeLibraryHandle({ a: 1 })).rejects.toThrow(/open failed/); + }); + + it('propagates open errors when establishing DB connection (get path)', async () => { + globalThis.__idbMock.failNextOpen = true; + await expect(getStoredLibraryHandle()).rejects.toThrow(/open failed/); + }); + + it('rejects when objectStore.put fails', async () => { + globalThis.__idbMock.failNextPut = true; + await expect(storeLibraryHandle({ id: 9 })).rejects.toThrow(/put failed/); + }); + + it('rejects when objectStore.get fails', async () => { + globalThis.__idbMock.failNextGet = true; + await expect(getStoredLibraryHandle()).rejects.toThrow(/get failed/); + }); + + it('invokes onupgradeneeded exactly once across multiple opens (no re-creation)', async () => { + // Do not reset between calls within this test + globalThis.__idbMock.reset(); + expect(globalThis.__idbMock.upgradeCalls).toBe(0); + + // First call should create store via onupgradeneeded + await getStoredLibraryHandle(); + expect(globalThis.__idbMock.upgradeCalls).toBe(1); + + // Subsequent call should reuse DB without triggering upgrade + await getStoredLibraryHandle(); + expect(globalThis.__idbMock.upgradeCalls).toBe(1); + }); +}); \ No newline at end of file diff --git a/tests/build_library.test.js b/tests/build_library.test.js new file mode 100644 index 0000000..dfe415a --- /dev/null +++ b/tests/build_library.test.js @@ -0,0 +1,568 @@ +/** + * Tests for library UI builder module: + * - openLibrary: happy path (stored handle), prompt path (showDirectoryPicker), error path + * - handleLibraryFiles: populates grid and toggles library + * - toggleLibrary: opens, closes, toggles classes on container and overlay + * - Indirectly validates displayLibraryGrid and createLibraryItem via DOM mutations + * + * Framework note: + * These tests are written to run under Jest or Vitest with a jsdom-like environment. + * - If using Jest: expect/jest.fn/jest.mock are available and testEnvironment is jsdom (default). + * - If using Vitest: expect/vi.fn/vi.mock are available and environment: 'jsdom' is typical; we alias jest->vi when needed. + */ + + // Lightweight compatibility shim between Jest and Vitest + // If vi is defined (Vitest), alias jest to vi for mocks/timers usage in the test file. + // This avoids adding new dependencies and keeps tests portable. + // eslint-disable-next-line no-undef + +const isVitest = typeof vi !== 'undefined'; +// eslint-disable-next-line no-undef + +const jestLike = isVitest ? vi : jest; + + +// We dynamically require the module under test after setting up DOM and mocks +// so that it reads the correct document.getElementById references. + +// Resolve import path heuristically: +// Most projects place this module in src/ or root. Try to require with multiple fallbacks. + +function loadModule() { + const candidates = [ + './build_library.js', + './src/build_library.js', + './lib/build_library.js', + './app/build_library.js', + './frontend/build_library.js', + './client/build_library.js', + ]; + for (const p of candidates) { + + try { + + // Use require to allow reloading between tests + + // eslint-disable-next-line global-require, import/no-dynamic-require + + return { mod: require(p), path: p }; + + } catch (e) { + + // continue + + } + + } + + throw new Error('Could not locate build_library module. Ensure the file is named build_library.js and is in the project root or src/.'); +} + +// Mocks for side-effect imports +// We need to mock: ./indexedDB, ./book, ./main, and epubjs +// Because the module under test will resolve these relative to its file location, +// We will use jest/vi moduleNameMapper-style runtime mocks via jestLike.mock with factory. + + +let mockStoreLibraryHandle; +let mockGetStoredLibraryHandle; +let mockOpenBookFromEntry; +let mockShowError; +let mockEPubCtor; +let mockEPubInstance; + + +// Will hold the loaded module's exports + +let openLibrary, handleLibraryFiles, toggleLibrary; + + +// Utilities to build a minimal DOM environment expected by the module +function setupDOM() { + document.body.innerHTML = ` +
+
+
+ + `; +} + + +// Helper to reset and (re)load the module under test with fresh mocks and DOM +async function reloadModule() { + if (jestLike.resetModules) jestLike.resetModules(); + + // Reset DOM + setupDOM(); + + + mockStoreLibraryHandle = jestLike.fn(async () => {}); + mockGetStoredLibraryHandle = jestLike.fn(async () => null); + mockOpenBookFromEntry = jestLike.fn(); + mockShowError = jestLike.fn(); + + + mockEPubInstance = { + + coverUrl: jestLike.fn(async () => 'https://example.test/cover.png'), + + loaded: { metadata: Promise.resolve({ title: 'Mock Book Title' }) }, + + }; + mockEPubCtor = jestLike.fn(() => mockEPubInstance); + + + // Because the module uses relative imports like "./indexedDB" relative to its own path, + + // we create runtime mocks with paths matching whichever candidate resolved. + const { path } = loadModule(); // We only need path to compute neighbors; actual require deferred after mocks. + + const lastSlash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); + const baseDir = lastSlash === -1 ? '' : path.slice(0, lastSlash + 1); // directory containing build_library.js + + // Build relative specifiers used by the module + const idxDBPath = baseDir + 'indexedDB'; + const bookPath = baseDir + 'book'; + const mainPath = baseDir + 'main'; + const epubjsPath = 'epubjs'; + + // Apply mocks + if (jestLike.doMock) { + jestLike.doMock(idxDBPath, () => ({ + storeLibraryHandle: mockStoreLibraryHandle, + getStoredLibraryHandle: mockGetStoredLibraryHandle, + }), { virtual: true }); + + jestLike.doMock(bookPath, () => ({ + openBookFromEntry: mockOpenBookFromEntry, + }), { virtual: true }); + + jestLike.doMock(mainPath, () => ({ + showError: mockShowError, + }), { virtual: true }); + + jestLike.doMock(epubjsPath, () => { + const e = function(...args) { return mockEPubCtor(...args); }; + e.default = e; // default export for transpiled ESM interop + return e; + }, { virtual: true }); + } + + // Now actually require the module + const { mod } = loadModule(); + openLibrary = mod.openLibrary; + handleLibraryFiles = mod.handleLibraryFiles; + toggleLibrary = mod.toggleLibrary; + + + if ([openLibrary, handleLibraryFiles, toggleLibrary].some(fn => typeof fn !== 'function')) { + + throw new Error('build_library module does not export expected functions: openLibrary, handleLibraryFiles, toggleLibrary'); + + } +} + + +// Utility to create a mock File-like object +function makeFile(name, content = 'dummy', type = 'application/epub+zip') { + + // In jsdom, File may not be fully implemented; mimic enough for .arrayBuffer() + + return { + + name, + + type, + + async arrayBuffer() { return new TextEncoder().encode(content).buffer; }, + + }; +} + + +// Utility to create a FileSystemFileHandle-like entry +function makeFileEntry(name, content = 'dummy') { + + const f = makeFile(name, content); + + return { + + kind: 'file', + + name, + + async getFile() { return f; }, + + }; +} + +describe('build_library module', () => { + + beforeEach(async () => { + + await reloadModule(); + + }); + + + describe('toggleLibrary', () => { + + test('forces open when true', () => { + + const lib = document.getElementById('library-container'); + + const ov = document.getElementById('overlay'); + + expect(lib.classList.contains('open')).toBe(false); + + expect(ov.classList.contains('open')).toBe(false); + + + toggleLibrary(true); + + + expect(lib.classList.contains('open')).toBe(true); + + expect(ov.classList.contains('open')).toBe(true); + + }); + + + test('forces close when false', () => { + + const lib = document.getElementById('library-container'); + + const ov = document.getElementById('overlay'); + + lib.classList.add('open'); + + ov.classList.add('open'); + + + toggleLibrary(false); + + + expect(lib.classList.contains('open')).toBe(false); + + expect(ov.classList.contains('open')).toBe(false); + + }); + + + test('toggles when no arg', () => { + + const lib = document.getElementById('library-container'); + + const ov = document.getElementById('overlay'); + + expect(lib.classList.contains('open')).toBe(false); + + expect(ov.classList.contains('open')).toBe(false); + + + toggleLibrary(); + + + expect(lib.classList.contains('open')).toBe(true); + + expect(ov.classList.contains('open')).toBe(true); + + + toggleLibrary(); + + + expect(lib.classList.contains('open')).toBe(false); + + expect(ov.classList.contains('open')).toBe(false); + + }); + }); + + + describe('openLibrary', () => { + + test('uses stored directory handle when available (happy path)', async () => { + + const entries = [ + + makeFileEntry('a.epub', 'A'), + + makeFileEntry('b.txt', 'B'), // should be filtered out + + makeFileEntry('c.epub', 'C'), + + ]; + + + // Mock stored handle and its async iterator + + const dirHandle = { + + async *values() { + + for (const e of entries) yield e; + + }, + + }; + mockGetStoredLibraryHandle.mockResolvedValueOnce(dirHandle); + + + await openLibrary(); + + + // Should not prompt user + + expect(window.showDirectoryPicker).toBeUndefined(); + + + // Library toggled open + + const lib = document.getElementById('library-container'); + + const ov = document.getElementById('overlay'); + + expect(lib.classList.contains('open')).toBe(true); + + expect(ov.classList.contains('open')).toBe(true); + + + // Library content populated with only .epub items + + const content = document.getElementById('library-content'); + + const items = content.querySelectorAll('.library-item'); + + expect(items.length).toBe(2); + + + // Each item should have title from metadata override and cover src set + + items.forEach((item) => { + + const img = item.querySelector('img.library-cover'); + + const title = item.querySelector('.library-title'); + + expect(img).toBeTruthy(); + + expect(img.src).toContain('https://example.test/cover.png'); + + expect(title.textContent).toBe('Mock Book Title'); + + }); + + }); + + + test('prompts user with showDirectoryPicker if no stored handle and stores it', async () => { + + // First call returns null to force prompt + mockGetStoredLibraryHandle.mockResolvedValueOnce(null); + + const entries = [ makeFileEntry('only.epub', 'X') ]; + + const dirHandle = { + async *values() { yield* entries; } + }; + + + // Provide a stub for directory picker + + // jsdom does not define it; define on window + + // eslint-disable-next-line no-undef + + global.window = global.window || {}; + + // eslint-disable-next-line no-undef + + window.showDirectoryPicker = jestLike.fn(async () => dirHandle); + + + await openLibrary(); + + + expect(window.showDirectoryPicker).toHaveBeenCalledTimes(1); + + expect(mockStoreLibraryHandle).toHaveBeenCalledWith(dirHandle); + + + const content = document.getElementById('library-content'); + + expect(content.querySelectorAll('.library-item').length).toBe(1); + + }); + + + test('handles errors by calling showError', async () => { + + // Force getStoredLibraryHandle to throw + + const error = new Error('boom'); + + mockGetStoredLibraryHandle.mockRejectedValueOnce(error); + + + await openLibrary(); + + + expect(mockShowError).toHaveBeenCalledTimes(1); + + expect(mockShowError.mock.calls[0][0]).toMatch(/Failed to open library: boom/); + + }); + + + test('displays placeholder cover when no coverUrl', async () => { + + mockGetStoredLibraryHandle.mockResolvedValueOnce({ + async *values() { yield makeFileEntry('x.epub', 'X'); } + }); + + + // For this test, no coverUrl + + mockEPubInstance.coverUrl.mockResolvedValueOnce(null); + + + await openLibrary(); + + + const img = document.querySelector('.library-item img.library-cover'); + + expect(img).toBeTruthy(); + + // data URL placeholder should be set; just assert it starts with data:image/png + + expect(img.src.startsWith('data:image/png')).toBe(true); + + }); + + + test('logs error but still creates item if cover/metadata loading fails', async () => { + + mockGetStoredLibraryHandle.mockResolvedValueOnce({ + async *values() { yield makeFileEntry('err.epub', 'E'); } + }); + + + // Make ePub constructor throw to simulate parsing failure + + mockEPubCtor.mockImplementationOnce(() => { throw new Error('parse-fail'); }); + + + // Spy on console.error without polluting output + + const origError = console.error; + + const errorSpy = jestLike.spyOn(console, 'error').mockImplementation(() => {}); + + + await openLibrary(); + + + expect(errorSpy).toHaveBeenCalled(); + + const items = document.querySelectorAll('.library-item'); + + expect(items.length).toBe(1); // item still exists with fallback title (file name) + + const title = items[0].querySelector('.library-title').textContent; + + expect(title).toBe('err.epub'); + + + errorSpy.mockRestore(); + + console.error = origError; + + }); + }); + + + describe('handleLibraryFiles', () => { + + test('renders provided FileList entries and toggles library', async () => { + + const files = [ + + makeFile('local.epub', 'L'), + + makeFile('skip.txt', 'S'), + + ]; + // e.target.files should be array-like; we provide directly as Array with target.files + + const evt = { target: { files } }; + + + await handleLibraryFiles(evt); + + + // Should toggle open + + const lib = document.getElementById('library-container'); + + const ov = document.getElementById('overlay'); + + expect(lib.classList.contains('open')).toBe(true); + + expect(ov.classList.contains('open')).toBe(true); + + + // displayLibraryGrid should accept all passed entries (not filtering by extension here), + // but createLibraryItem will still work; verify items render count equals files.length + + const items = document.querySelectorAll('.library-item'); + + expect(items.length).toBe(files.length); + + }); + + + test('shows "No EPUB files found." message when given empty list', async () => { + + const evt = { target: { files: [] } }; + + + await handleLibraryFiles(evt); + + + const content = document.getElementById('library-content'); + + expect(content.textContent).toMatch(/No EPUB files found\./); + + }); + }); + + + describe('interaction', () => { + + test('clicking a library item calls openBookFromEntry with the associated entry', async () => { + + mockGetStoredLibraryHandle.mockResolvedValueOnce({ + async *values() { yield makeFileEntry('clickme.epub', 'C'); } + }); + + + await openLibrary(); + + + const item = document.querySelector('.library-item'); + + expect(item).toBeTruthy(); + + + item.click(); + + + expect(mockOpenBookFromEntry).toHaveBeenCalledTimes(1); + + const arg = mockOpenBookFromEntry.mock.calls[0][0]; + + expect(arg && arg.name).toBe('clickme.epub'); + + }); + }); +}); \ No newline at end of file diff --git a/tests/helpers/indexeddb-mock.spec.js b/tests/helpers/indexeddb-mock.spec.js new file mode 100644 index 0000000..5de3996 --- /dev/null +++ b/tests/helpers/indexeddb-mock.spec.js @@ -0,0 +1,261 @@ +/** + * Tests for tests/helpers/indexeddb-mock.js + * + * Testing framework: Jest (describe/it/expect). If the repository uses Vitest, these tests + * should still run with minimal/no changes as APIs are compatible for this usage. + * + * We provide a minimal IndexedDB fake to cover: + * - DB creation with onupgradeneeded and object store 'handles' with keyPath 'name' + * - Successful put/get flows + * - Error propagation from open/put/get + * - Overwrite behavior on put with same key "library" + */ +import { storeLibraryHandle, getStoredLibraryHandle } from '../helpers/indexeddb-mock'; + +// Minimal in-memory IndexedDB fake sufficient for this module's usage +class FakeIDBRequest { + constructor() { + this.onsuccess = null; + this.onerror = null; + } + succeed(result) { + this.result = result; + if (typeof this.onsuccess === 'function') this.onsuccess({ target: this }); + } + fail(error) { + this.error = error; + if (typeof this.onerror === 'function') this.onerror({ target: this }); + } +} + +class FakeObjectStore { + constructor(state) { + this._state = state; // Map-like object keyed by primary key + } + put(value) { + const req = new FakeIDBRequest(); + queueMicrotask(() => { + try { + const key = value?.name; + if (typeof key === 'undefined') { + throw new Error('KeyPath name missing'); + } + this._state.set(key, value); + req.succeed(undefined); + } catch (e) { + req.fail(e); + } + }); + return req; + } + get(key) { + const req = new FakeIDBRequest(); + queueMicrotask(() => { + try { + const val = this._state.get(key) ?? undefined; + req.succeed(val); + } catch (e) { + req.fail(e); + } + }); + return req; + } +} + +class FakeTransaction { + constructor(state) { + this._state = state; + this.mode = null; + } + objectStore(name) { + if (name !== 'handles') throw new Error('Unknown store ' + name); + return new FakeObjectStore(this._state); + } +} + +class FakeDB { + constructor(state) { + this._state = state; + this._stores = new Map(); + } + createObjectStore(name, opts) { + // respect keyPath but our store class enforces 'name' anyway + if (name !== 'handles' || !opts || opts.keyPath !== 'name') { + throw new Error('Unexpected store definition'); + } + // no-op: we use a shared state map + return new FakeObjectStore(this._state); + } + transaction(name, mode) { + const tx = new FakeTransaction(this._state); + tx.mode = mode; + return tx; + } +} + +class FakeIDBFactory { + constructor(options = {}) { + this.shouldOpenFail = options.shouldOpenFail ?? false; + this.openError = options.openError ?? new Error('open failed'); + this.instances = new Map(); // dbName -> { version, stateMap, db } + } + open(name, version) { + const req = new FakeIDBRequest(); + queueMicrotask(() => { + if (this.shouldOpenFail) { + req.fail(this.openError); + return; + } + let rec = this.instances.get(name); + const firstOpen = !rec; + if (!rec) { + rec = { version: version != null ? version : 1, stateMap: new Map(), db: null }; + this.instances.set(name, rec); + } + // Simulate upgrade needed only if first open or version increased + const db = new FakeDB(rec.stateMap); + rec.db = db; + if (firstOpen) { + if (typeof req.onupgradeneeded === 'function') { + req.onupgradeneeded({ target: { result: db } }); + } + } + req.succeed(db); + }); + return req; + } +} + +function installIndexedDBFake(options) { + const factory = new FakeIDBFactory(options); + global.indexedDB = factory; + return factory; +} + +describe('indexeddb-mock storeLibraryHandle/getStoredLibraryHandle', () => { + beforeEach(() => { + // Fresh fake for each test + installIndexedDBFake(); + }); + + afterEach(() => { + // Cleanup global + delete global.indexedDB; + }); + + it('creates the database and object store on first open (happy path)', async () => { + await expect(storeLibraryHandle({ id: 123, kind: 'fs-handle' })).resolves.toBeUndefined(); + await expect(getStoredLibraryHandle()).resolves.toEqual({ id: 123, kind: 'fs-handle' }); + }); + + it('returns null when no handle has been stored yet', async () => { + await expect(getStoredLibraryHandle()).resolves.toBeNull(); + }); + + it('overwrites existing handle when storing again with the same key "library"', async () => { + await storeLibraryHandle({ id: 1 }); + await storeLibraryHandle({ id: 2, extra: true }); + await expect(getStoredLibraryHandle()).resolves.toEqual({ id: 2, extra: true }); + }); + + it('propagates open errors from indexedDB.open()', async () => { + installIndexedDBFake({ shouldOpenFail: true, openError: new Error('boom') }); + await expect(storeLibraryHandle({})).rejects.toThrow('boom'); + }); + + it('propagates put errors (e.g., missing keyPath name)', async () => { + // Patch FakeObjectStore to throw on put if value missing name; our fake already does that. + // Call internal through exported API: storeLibraryHandle wraps value as { name: "library", handle } + // To induce error, we will temporarily monkey-patch storeLibraryHandle to bypass the wrapper. + // Instead, simulate by temporarily breaking FakeObjectStore's put via a custom fake. + const originalIndexedDB = global.indexedDB; + + class ThrowOnPutStore extends FakeObjectStore { + put(value) { + const req = new FakeIDBRequest(); + queueMicrotask(() => req.fail(new Error('put failed'))); + return req; + } + } + class ThrowOnPutDB extends FakeDB { + createObjectStore(name, opts) { return new ThrowOnPutStore(this._state); } + transaction(name, mode) { return new ThrowOnPutStore(this._state); } + } + class ThrowOnPutFactory extends FakeIDBFactory { + open(name, version) { + const req = new FakeIDBRequest(); + queueMicrotask(() => { + const rec = { version, stateMap: new Map(), db: new ThrowOnPutDB(new Map()) }; + this.instances.set(name, rec); + if (typeof req.onupgradeneeded === 'function') { + req.onupgradeneeded({ target: { result: rec.db } }); + } + req.succeed(rec.db); + }); + return req; + } + } + global.indexedDB = new ThrowOnPutFactory(); + + await expect(storeLibraryHandle({ id: 7 })).rejects.toThrow('put failed'); + + global.indexedDB = originalIndexedDB; + }); + + it('propagates get errors', async () => { + // Custom store that throws on get + const originalIndexedDB = global.indexedDB; + + class ThrowOnGetStore extends FakeObjectStore { + get(key) { + const req = new FakeIDBRequest(); + queueMicrotask(() => req.fail(new Error('get failed'))); + return req; + } + } + class ThrowOnGetDB extends FakeDB { + createObjectStore() { return new ThrowOnGetStore(this._state); } + transaction() { return new ThrowOnGetStore(this._state); } + } + class ThrowOnGetFactory extends FakeIDBFactory { + open(name, version) { + const req = new FakeIDBRequest(); + queueMicrotask(() => { + const rec = { version, stateMap: new Map(), db: new ThrowOnGetDB(new Map()) }; + this.instances.set(name, rec); + if (typeof req.onupgradeneeded === 'function') { + req.onupgradeneeded({ target: { result: rec.db } }); + } + req.succeed(rec.db); + }); + return req; + } + } + global.indexedDB = new ThrowOnGetFactory(); + + await expect(getStoredLibraryHandle()).rejects.toThrow('get failed'); + + global.indexedDB = originalIndexedDB; + }); + + it('stores and retrieves complex handle objects by reference', async () => { + const complex = { id: 42, nested: { a: 1 }, arr: [1, 2, 3] }; + await storeLibraryHandle(complex); + const result = await getStoredLibraryHandle(); + expect(result).toEqual(complex); + // Ensure deep equality holds + expect(result.nested.a).toBe(1); + + expect(Array.isArray(result.arr)).toBe(true); + }); + + it('handles rapid consecutive writes and ensures last-write-wins', async () => { + const writes = []; + for (let i = 0; i < 10; i++) { + writes.push(storeLibraryHandle({ seq: i })); + } + await Promise.all(writes); + const result = await getStoredLibraryHandle(); + expect(result).toEqual({ seq: 9 }); + }); +}); \ No newline at end of file