2026-06-28 17:18:14 -04:00
|
|
|
// 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<obj|null>
|
|
|
|
|
// setDoc(path, data) -> Promise<void> (replace)
|
|
|
|
|
// updateDoc(path, patch) -> Promise<void> (shallow merge)
|
|
|
|
|
// deleteDoc(path) -> Promise<void>
|
|
|
|
|
// addDoc(collectionPath, data) -> Promise<{id, path}> (auto-gen id)
|
|
|
|
|
// getCollection(path) -> Promise<arr> (immediate child docs)
|
|
|
|
|
// batchWrite(ops) -> Promise<void> ops: [{type, path, data?}]
|
2026-07-06 10:31:46 -04:00
|
|
|
// undo({logPath, undo, redo}) -> Promise<void> tx: apply updates + flip undone
|
2026-06-28 17:18:14 -04:00
|
|
|
// 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', () => {
|
2026-07-04 12:22:04 -04:00
|
|
|
test('setDoc then getDoc returns the doc (with id)', async () => {
|
2026-06-28 17:18:14 -04:00
|
|
|
await storage.setDoc('campaigns/a', { name: 'Alpha' });
|
|
|
|
|
const doc = await storage.getDoc('campaigns/a');
|
2026-07-04 12:22:04 -04:00
|
|
|
expect(doc).toEqual({ id: 'a', name: 'Alpha' });
|
2026-06-28 17:18:14 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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');
|
2026-07-04 12:22:04 -04:00
|
|
|
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] });
|
2026-06-28 17:18:14 -04:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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');
|
2026-07-04 12:22:04 -04:00
|
|
|
expect(doc).toEqual({ id: 'a', name: 'Alpha', players: [1, 2] });
|
2026-06-28 17:18:14 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('updateDoc on missing doc creates it', async () => {
|
|
|
|
|
await storage.updateDoc('campaigns/a', { name: 'Alpha' });
|
|
|
|
|
const doc = await storage.getDoc('campaigns/a');
|
2026-07-04 12:22:04 -04:00
|
|
|
expect(doc).toEqual({ id: 'a', name: 'Alpha' });
|
2026-06-28 17:18:14 -04:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
2026-07-04 12:22:04 -04:00
|
|
|
expect(doc).toEqual({ id, name: 'E1' });
|
2026-06-28 17:18:14 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-29 15:13:03 -04:00
|
|
|
describe('firebase-prefixed path identity', () => {
|
|
|
|
|
// App passes firebase-prefixed paths (artifacts/{APP_ID}/public/data/...).
|
2026-07-04 12:22:04 -04:00
|
|
|
// 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.
|
2026-06-29 15:13:03 -04:00
|
|
|
const PREFIX = 'artifacts/test-app/public/data';
|
|
|
|
|
|
2026-07-04 12:22:04 -04:00
|
|
|
test('setDoc prefixed then getDoc same prefixed path returns it (id)', async () => {
|
2026-06-29 15:13:03 -04:00
|
|
|
await storage.setDoc(`${PREFIX}/campaigns/c2`, { name: 'P2' });
|
|
|
|
|
const doc = await storage.getDoc(`${PREFIX}/campaigns/c2`);
|
2026-07-04 12:22:04 -04:00
|
|
|
expect(doc).toEqual({ id: 'c2', name: 'P2' });
|
2026-06-29 15:13:03 -04:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-28 17:18:14 -04:00
|
|
|
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();
|
2026-07-04 12:22:04 -04:00
|
|
|
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 });
|
2026-06-28 17:18:14 -04:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-06 19:06:52 -04:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-28 17:18:14 -04:00
|
|
|
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);
|
2026-07-04 12:22:04 -04:00
|
|
|
expect(calls[0]).toEqual({ id: 'a', name: 'Alpha' });
|
2026-06-28 17:18:14 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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];
|
2026-07-04 12:22:04 -04:00
|
|
|
expect(last).toEqual({ id: 'a', name: 'Alpha' });
|
2026-06-28 17:18:14 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-07-04 16:06:28 -04:00
|
|
|
|
|
|
|
|
// 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 });
|
2026-07-06 22:50:29 -04:00
|
|
|
const offsetC = (n) => ({ __type: 'offset', offset: n });
|
2026-07-04 16:06:28 -04:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
2026-07-06 22:50:29 -04:00
|
|
|
|
|
|
|
|
// 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([]);
|
|
|
|
|
});
|
2026-07-04 16:06:28 -04:00
|
|
|
});
|
2026-06-28 17:18:14 -04:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-29 13:00:24 -04:00
|
|
|
// flush so async subscribers settle. WS roundtrip needs real delay (network),
|
|
|
|
|
// memory fires near-instant. 50ms covers localhost WS comfortably.
|
2026-06-28 17:18:14 -04:00
|
|
|
function flush() {
|
2026-06-29 13:00:24 -04:00
|
|
|
return new Promise((resolve) => setTimeout(resolve, 50));
|
2026-06-28 17:18:14 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
module.exports = { runStorageContract, flush };
|