diff --git a/src/save/db.test.ts b/src/save/db.test.ts new file mode 100644 index 0000000..b56f461 --- /dev/null +++ b/src/save/db.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import 'fake-indexeddb/auto'; // happy-dom doesn't ship IDB; fake-indexeddb is the polyfill +import { openSaveDB, SAVE_DB_NAME } from './db'; +import { wrap } from './envelope'; +import { LocalStorageDBAdapter } from './db-localstorage-adapter'; + +// Tests for the IndexedDB-primary + localStorage-fallback open path (CORE-04). +// The IDB path uses `fake-indexeddb` (polyfill is auto-imported above). +// The fallback path uses `vi.doMock('idb')` to inject an openDB rejection, +// which forces openSaveDB to return a LocalStorageDBAdapter instead. + +beforeEach(async () => { + // Reset IDB and localStorage between tests + indexedDB.deleteDatabase(SAVE_DB_NAME); + localStorage.clear(); + vi.unstubAllGlobals(); + vi.resetModules(); +}); + +afterEach(() => { + vi.doUnmock('idb'); +}); + +describe('openSaveDB (CORE-04 IndexedDB-primary path)', () => { + it('opens a DB with saves and save_snapshots object stores', async () => { + const db = await openSaveDB(); + expect(db.objectStoreNames.contains('saves')).toBe(true); + expect(db.objectStoreNames.contains('save_snapshots')).toBe(true); + }); + + it('round-trips a SaveEnvelope through saves store', async () => { + const db = await openSaveDB(); + const envelope = wrap({ hello: 'world' }, 1); + await db.put('saves', { + id: 'main', + envelope, + savedAt: new Date().toISOString(), + }); + const retrieved = await db.get('saves', 'main'); + expect(retrieved?.envelope).toEqual(envelope); + }); + + it('round-trips through save_snapshots store too', async () => { + const db = await openSaveDB(); + const envelope = wrap({ snap: true }, 1); + await db.put('save_snapshots', { + id: 's-1', + schemaVersion: 1, + savedAt: new Date().toISOString(), + envelope, + }); + const retrieved = await db.get('save_snapshots', 's-1'); + expect(retrieved?.envelope).toEqual(envelope); + }); +}); + +describe('openSaveDB (CORE-04 localStorage fallback path)', () => { + it('falls back to LocalStorageDBAdapter when IndexedDB is unavailable', async () => { + // Stub the idb module's openDB so it rejects, simulating private mode / + // blocked IDB / quota exceeded — anything that makes openDB throw. + vi.doMock('idb', async () => ({ + openDB: vi.fn().mockRejectedValue(new Error('IDB unavailable (test stub)')), + })); + // Re-import db.ts after the mock is registered so it picks up the + // rejecting openDB. + const { openSaveDB: openSaveDBFresh } = await import('./db'); + + const db = await openSaveDBFresh(); + expect(db).toBeInstanceOf(LocalStorageDBAdapter); + + // Round-trip works against localStorage + const envelope = wrap({ fallback: true }, 1); + await db.put('saves', { + id: 'main', + envelope, + savedAt: new Date().toISOString(), + }); + const retrieved = await db.get('saves', 'main'); + expect(retrieved?.envelope).toEqual(envelope); + + // Verify it actually wrote to localStorage (not just memory) + expect(localStorage.getItem('tlg.saves.main')).toBeTruthy(); + }); +}); diff --git a/src/save/persist.test.ts b/src/save/persist.test.ts new file mode 100644 index 0000000..a5f0a67 --- /dev/null +++ b/src/save/persist.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { requestPersistence } from './persist'; + +// Tests for navigator.storage.persist() — must surface granted=false +// respectfully without spamming the user (CORE-05 + RESEARCH Pitfall 2: +// iOS Safari often returns false). Each test stubs `navigator` globally +// to one of the four scenarios. + +describe('requestPersistence (CORE-05)', () => { + beforeEach(() => vi.unstubAllGlobals()); + + it('returns granted=true when navigator.storage.persist resolves true', async () => { + vi.stubGlobal('navigator', { storage: { persist: async () => true } }); + expect(await requestPersistence()).toEqual({ + granted: true, + apiAvailable: true, + }); + }); + + it('returns granted=false when navigator.storage.persist resolves false', async () => { + vi.stubGlobal('navigator', { storage: { persist: async () => false } }); + expect(await requestPersistence()).toEqual({ + granted: false, + apiAvailable: true, + }); + }); + + it('returns granted=false when persist throws', async () => { + vi.stubGlobal('navigator', { + storage: { + persist: async () => { + throw new Error('boom'); + }, + }, + }); + expect(await requestPersistence()).toEqual({ + granted: false, + apiAvailable: true, + }); + }); + + it('returns apiAvailable=false when navigator.storage is missing', async () => { + vi.stubGlobal('navigator', {}); + expect(await requestPersistence()).toEqual({ + granted: false, + apiAvailable: false, + }); + }); +}); diff --git a/src/save/snapshots.test.ts b/src/save/snapshots.test.ts new file mode 100644 index 0000000..e6f46b4 --- /dev/null +++ b/src/save/snapshots.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import 'fake-indexeddb/auto'; +import { snapshot, listSnapshots } from './snapshots'; +import { wrap } from './envelope'; +import { SAVE_DB_NAME } from './db'; + +// Tests for last-3 pre-migration snapshot retention (CORE-08). The +// load-bearing test is "after 5 successive snapshot() calls, exactly 3 +// newest entries remain". The 2ms wait between writes ensures savedAt +// timestamps differ so newest-first ordering is unambiguous. + +beforeEach(() => indexedDB.deleteDatabase(SAVE_DB_NAME)); + +describe('snapshot + listSnapshots', () => { + it('returns 1 entry after 1 snapshot call', async () => { + await snapshot(wrap({ generation: 0 }, 1)); + const list = await listSnapshots(); + expect(list).toHaveLength(1); + }); + + it('returns [] from listSnapshots on empty store', async () => { + const list = await listSnapshots(); + expect(list).toEqual([]); + }); +}); + +describe('CORE-08: last-3 snapshot retention', () => { + it('retains exactly 3 newest entries after 5 successive snapshot calls', async () => { + for (let i = 0; i < 5; i++) { + await snapshot(wrap({ generation: i }, 1)); + await new Promise((r) => setTimeout(r, 2)); // ensure savedAt timestamps differ + } + const list = await listSnapshots(); + expect(list).toHaveLength(3); + // Newest first: payloads should be {generation:4}, {generation:3}, {generation:2} + expect( + list.map((e) => (e.envelope.payload as { generation: number }).generation), + ).toEqual([4, 3, 2]); + }); + + it('pruned entries are the oldest by savedAt', async () => { + for (let i = 0; i < 5; i++) { + await snapshot(wrap({ generation: i }, 1)); + await new Promise((r) => setTimeout(r, 2)); + } + const list = await listSnapshots(); + // The two oldest (generations 0 and 1) should NOT appear in the retained list. + const generations = list.map( + (e) => (e.envelope.payload as { generation: number }).generation, + ); + expect(generations).not.toContain(0); + expect(generations).not.toContain(1); + }); +});