test(01-03): add failing tests for IDB DB + snapshots + persist API [RED]

- db.test.ts (4 tests): IDB-primary path opens both stores + round-trips
  saves and save_snapshots; localStorage-fallback path via vi.doMock('idb')
  asserts LocalStorageDBAdapter is returned and tlg.saves.main is written
- snapshots.test.ts (4 tests): basic put + listSnapshots, empty store
  returns [], CORE-08 5-then-3 retention with newest-first ordering, and
  pruned entries are oldest by savedAt
- persist.test.ts (4 tests): all 4 navigator.storage scenarios per
  CORE-05 + RESEARCH Pitfall 2 (granted true / false / throws / missing)

RED phase per TDD plan-level gate. Tests fail because db.ts / snapshots.ts /
persist.ts / db-localstorage-adapter.ts do not exist yet.
This commit is contained in:
2026-05-08 23:30:02 -04:00
parent b6cc9000c3
commit e2d82ffa90
3 changed files with 187 additions and 0 deletions
+84
View File
@@ -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();
});
});
+49
View File
@@ -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,
});
});
});
+54
View File
@@ -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);
});
});