// Storage interface contract. // This is the SPEC. Runs against any storage impl (memory, ws, firebase). // TDD: written first (RED), impl built to satisfy (GREEN). // // Usage: // const { runStorageContract } = require('./contract.test'); // runStorageContract('memory', () => createMemoryStorage()); // Each impl factory returns a fresh storage instance (async-creatable is fine). // Interface every impl MUST provide: // getDoc(path) -> Promise // setDoc(path, data) -> Promise (replace) // updateDoc(path, patch) -> Promise (shallow merge) // deleteDoc(path) -> Promise // addDoc(collectionPath, data) -> Promise<{id, path}> (auto-gen id) // getCollection(path) -> Promise (immediate child docs) // batchWrite(ops) -> Promise ops: [{type, path, data?}] // undo({logPath, undo, redo}) -> Promise tx: apply updates + flip undone // subscribeDoc(path, cb) -> unsubscribe fn cb(doc|null) // subscribeCollection(path, cb) -> unsubscribe fn cb(arr) function runStorageContract(name, factory) { describe(`storage contract: ${name}`, () => { let storage; beforeEach(async () => { storage = await factory(); }); afterEach(async () => { if (storage && storage.dispose) await storage.dispose(); }); describe('getDoc / setDoc', () => { test('setDoc then getDoc returns the doc (with id)', async () => { await storage.setDoc('campaigns/a', { name: 'Alpha' }); const doc = await storage.getDoc('campaigns/a'); expect(doc).toEqual({ id: 'a', name: 'Alpha' }); }); test('getDoc on missing path returns null', async () => { const doc = await storage.getDoc('campaigns/missing'); expect(doc).toBeNull(); }); test('setDoc overwrites entirely (not merge)', async () => { await storage.setDoc('campaigns/a', { name: 'Alpha', players: [] }); await storage.setDoc('campaigns/a', { name: 'Beta' }); const doc = await storage.getDoc('campaigns/a'); expect(doc).toEqual({ id: 'a', name: 'Beta' }); }); // main L1624: setDoc(path, data, {merge:true}) — merge flag. test('setDoc with {merge:true} merges into existing doc', async () => { await storage.setDoc('campaigns/a', { name: 'Alpha', players: [] }); await storage.setDoc('campaigns/a', { players: [1] }, { merge: true }); const doc = await storage.getDoc('campaigns/a'); expect(doc).toEqual({ id: 'a', name: 'Alpha', players: [1] }); }); }); describe('updateDoc (shallow merge)', () => { test('merges patch into existing doc', async () => { await storage.setDoc('campaigns/a', { name: 'Alpha', players: [1] }); await storage.updateDoc('campaigns/a', { players: [1, 2] }); const doc = await storage.getDoc('campaigns/a'); expect(doc).toEqual({ id: 'a', name: 'Alpha', players: [1, 2] }); }); test('updateDoc on missing doc creates it', async () => { await storage.updateDoc('campaigns/a', { name: 'Alpha' }); const doc = await storage.getDoc('campaigns/a'); expect(doc).toEqual({ id: 'a', name: 'Alpha' }); }); }); describe('deleteDoc', () => { test('removes doc', async () => { await storage.setDoc('campaigns/a', { name: 'Alpha' }); await storage.deleteDoc('campaigns/a'); expect(await storage.getDoc('campaigns/a')).toBeNull(); }); test('delete missing doc is no-op (no throw)', async () => { await expect(storage.deleteDoc('campaigns/none')).resolves.toBeUndefined(); }); }); describe('addDoc', () => { test('auto-generates id and stores doc at collection/id', async () => { const { id, path } = await storage.addDoc('campaigns/a/encounters', { name: 'E1' }); expect(id).toBeTruthy(); expect(path).toBe(`campaigns/a/encounters/${id}`); const doc = await storage.getDoc(path); expect(doc).toEqual({ id, name: 'E1' }); }); test('two addDocs produce distinct ids', async () => { const r1 = await storage.addDoc('logs', { m: 'one' }); const r2 = await storage.addDoc('logs', { m: 'two' }); expect(r1.id).not.toBe(r2.id); }); }); describe('firebase-prefixed path identity', () => { // App passes firebase-prefixed paths (artifacts/{APP_ID}/public/data/...). // main prod truth: ALWAYS prefixed. Write+read same prefixed round-trips. // Cross bare<->prefixed lookup is NOT required by main (App never does it). // Kept as same-prefix roundtrip only — matches prod. const PREFIX = 'artifacts/test-app/public/data'; test('setDoc prefixed then getDoc same prefixed path returns it (id)', async () => { await storage.setDoc(`${PREFIX}/campaigns/c2`, { name: 'P2' }); const doc = await storage.getDoc(`${PREFIX}/campaigns/c2`); expect(doc).toEqual({ id: 'c2', name: 'P2' }); }); }); describe('getCollection', () => { test('returns immediate child docs only (not nested)', async () => { await storage.setDoc('campaigns/a', { name: 'A' }); await storage.setDoc('campaigns/b', { name: 'B' }); await storage.setDoc('campaigns/a/encounters/e1', { name: 'E1' }); const docs = await storage.getCollection('campaigns'); expect(docs).toHaveLength(2); const names = docs.map(d => d.name).sort(); expect(names).toEqual(['A', 'B']); }); test('empty collection returns []', async () => { const docs = await storage.getCollection('campaigns'); expect(docs).toEqual([]); }); test('subcollection returns only its direct children', async () => { await storage.setDoc('campaigns/a/encounters/e1', { name: 'E1' }); await storage.setDoc('campaigns/a/encounters/e2', { name: 'E2' }); await storage.setDoc('campaigns/a/encounters/e1/participants/p1', { name: 'P1' }); const docs = await storage.getCollection('campaigns/a/encounters'); expect(docs).toHaveLength(2); }); }); describe('batchWrite', () => { test('applies multiple deletes atomically', async () => { await storage.setDoc('campaigns/a', { name: 'A' }); await storage.setDoc('campaigns/b', { name: 'B' }); await storage.batchWrite([ { type: 'delete', path: 'campaigns/a' }, { type: 'delete', path: 'campaigns/b' }, ]); expect(await storage.getDoc('campaigns/a')).toBeNull(); expect(await storage.getDoc('campaigns/b')).toBeNull(); }); test('applies set + delete mixed', async () => { await storage.setDoc('campaigns/a', { name: 'A' }); await storage.batchWrite([ { type: 'set', path: 'campaigns/b', data: { name: 'B' } }, { type: 'delete', path: 'campaigns/a' }, ]); expect(await storage.getDoc('campaigns/a')).toBeNull(); expect(await storage.getDoc('campaigns/b')).toEqual({ id: 'b', name: 'B' }); }); // set-only batch (not used in main currently, but interface allows). test('applies set-only batch', async () => { await storage.batchWrite([ { type: 'set', path: 'campaigns/a', data: { name: 'A' } }, { type: 'set', path: 'campaigns/b', data: { name: 'B' } }, ]); expect(await storage.getDoc('campaigns/a')).toEqual({ id: 'a', name: 'A' }); expect(await storage.getDoc('campaigns/b')).toEqual({ id: 'b', name: 'B' }); }); // update-only batch (interface allows). test('applies update-only batch', async () => { await storage.setDoc('campaigns/a', { name: 'A', hp: 10 }); await storage.setDoc('campaigns/b', { name: 'B', hp: 5 }); await storage.batchWrite([ { type: 'update', path: 'campaigns/a', data: { hp: 8 } }, { type: 'update', path: 'campaigns/b', data: { hp: 2 } }, ]); expect(await storage.getDoc('campaigns/a')).toEqual({ id: 'a', name: 'A', hp: 8 }); expect(await storage.getDoc('campaigns/b')).toEqual({ id: 'b', name: 'B', hp: 2 }); }); }); describe('undo', () => { test('undo applies updates + flips undone true', async () => { await storage.setDoc('encounters/e1', { round: 2, name: 'Enc' }); await storage.setDoc('logs/l1', { message: 'x', undone: false }); await storage.undo({ logPath: 'logs/l1', undo: { encounterPath: 'encounters/e1', updates: { round: 1 }, redo: { round: 2 } }, }); const enc = await storage.getDoc('encounters/e1'); const log = await storage.getDoc('logs/l1'); expect(enc.round).toBe(1); expect(log.undone).toBe(true); }); test('redo applies redo patch + flips undone false', async () => { await storage.setDoc('encounters/e1', { round: 1 }); await storage.setDoc('logs/l1', { message: 'x', undone: true }); await storage.undo({ logPath: 'logs/l1', undo: { encounterPath: 'encounters/e1', updates: { round: 1 }, redo: { round: 2 } }, redo: true, }); const enc = await storage.getDoc('encounters/e1'); const log = await storage.getDoc('logs/l1'); expect(enc.round).toBe(2); expect(log.undone).toBe(false); }); }); describe('deleteCollection', () => { test('deletes all docs in collection', async () => { await storage.addDoc('logs', { type: 'a' }); await storage.addDoc('logs', { type: 'b' }); const deleted = await storage.deleteCollection('logs'); expect(deleted).toBe(2); const remaining = await storage.getCollection('logs'); expect(remaining).toHaveLength(0); }); test('honors whereField filter', async () => { await storage.addDoc('logs', { type: 'keep', n: 1 }); await storage.addDoc('logs', { type: 'drop', n: 2 }); await storage.addDoc('logs', { type: 'drop', n: 3 }); const deleted = await storage.deleteCollection('logs', 'type', 'drop'); expect(deleted).toBe(2); const remaining = await storage.getCollection('logs'); expect(remaining).toHaveLength(1); }); test('empty collection returns 0', async () => { const deleted = await storage.deleteCollection('logs'); expect(deleted).toBe(0); }); }); describe('subscribeDoc', () => { test('fires cb immediately with current value', async () => { await storage.setDoc('campaigns/a', { name: 'Alpha' }); const calls = []; storage.subscribeDoc('campaigns/a', (doc) => calls.push(doc)); await flush(); expect(calls).toHaveLength(1); expect(calls[0]).toEqual({ id: 'a', name: 'Alpha' }); }); test('fires cb on subsequent change', async () => { const calls = []; storage.subscribeDoc('campaigns/a', (doc) => calls.push(doc)); await flush(); await storage.setDoc('campaigns/a', { name: 'Alpha' }); await flush(); const last = calls[calls.length - 1]; expect(last).toEqual({ id: 'a', name: 'Alpha' }); }); test('unsubscribe stops callbacks', async () => { const calls = []; const unsub = storage.subscribeDoc('campaigns/a', (doc) => calls.push(doc)); await flush(); unsub(); await storage.setDoc('campaigns/a', { name: 'X' }); await flush(); expect(calls.filter(Boolean)).toHaveLength(0); }); }); describe('subscribeCollection', () => { test('fires cb with current docs', async () => { await storage.setDoc('campaigns/a', { name: 'A' }); const calls = []; storage.subscribeCollection('campaigns', (docs) => calls.push(docs)); await flush(); expect(calls).toHaveLength(1); expect(calls[0]).toHaveLength(1); }); test('fires on add to collection', async () => { const calls = []; storage.subscribeCollection('campaigns', (docs) => calls.push(docs)); await flush(); await storage.setDoc('campaigns/a', { name: 'A' }); await flush(); const last = calls[calls.length - 1]; expect(last).toHaveLength(1); }); }); // queryConstraints: orderBy + limit. App's combat log uses // [orderBy('timestamp','desc'), limit(500)] — newest 500 entries. // Adapter MUST honor these. Constraint shape = neutral {__type} objects // (what firebase mock produces; what App passes via shared builders). describe('subscribeCollection queryConstraints', () => { const orderByC = (field, dir) => ({ __type: 'orderBy', field, dir }); const limitC = (n) => ({ __type: 'limit', n }); const offsetC = (n) => ({ __type: 'offset', offset: n }); beforeEach(async () => { // seed 5 log docs, timestamps out of order await storage.setDoc('logs/l1', { msg: 'one', timestamp: 1 }); await storage.setDoc('logs/l2', { msg: 'two', timestamp: 3 }); await storage.setDoc('logs/l3', { msg: 'three', timestamp: 5 }); await storage.setDoc('logs/l4', { msg: 'four', timestamp: 2 }); await storage.setDoc('logs/l5', { msg: 'five', timestamp: 4 }); }); test('orderBy desc sorts newest-first', async () => { const result = await new Promise((resolve) => { storage.subscribeCollection('logs', resolve, [orderByC('timestamp', 'desc')]); }); expect(result.map(d => d.msg)).toEqual(['three', 'five', 'two', 'four', 'one']); }); test('limit returns only first N after ordering', async () => { const result = await new Promise((resolve) => { storage.subscribeCollection('logs', resolve, [orderByC('timestamp', 'desc'), limitC(2)]); }); expect(result.map(d => d.msg)).toEqual(['three', 'five']); }); test('no constraints returns all (insertion/id order)', async () => { const result = await new Promise((resolve) => { storage.subscribeCollection('logs', resolve, []); }); expect(result).toHaveLength(5); }); // Log page pagination (LogsView pageQuery): orderBy + limit + offset. // Server pushes offset to SQL; firebase adapter emulates (widen limit, // slice). Both MUST return the same page. test('offset skips first N after ordering (pagination)', async () => { const result = await new Promise((resolve) => { storage.subscribeCollection('logs', resolve, [orderByC('timestamp', 'desc'), limitC(2), offsetC(2)]); }); expect(result.map(d => d.msg)).toEqual(['two', 'four']); }); test('offset past end returns empty page', async () => { const result = await new Promise((resolve) => { storage.subscribeCollection('logs', resolve, [orderByC('timestamp', 'desc'), limitC(2), offsetC(10)]); }); expect(result).toEqual([]); }); }); }); } // flush so async subscribers settle. WS roundtrip needs real delay (network), // memory fires near-instant. 50ms covers localhost WS comfortably. function flush() { return new Promise((resolve) => setTimeout(resolve, 50)); } module.exports = { runStorageContract, flush };