M2 (TDD): storage contract test + memory impl
- src/storage/contract.test.js: storage interface spec (19 assertions) - src/storage/memory.js: in-process impl (Map + EventEmitter) - src/storage/storage.test.js: runner, memory first TDD: contract RED first, memory built to satisfy, 19/19 green. Next impls (ws, firebase) run same contract.
This commit is contained in:
@@ -0,0 +1,199 @@
|
|||||||
|
// 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());
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// 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?}]
|
||||||
|
// 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', async () => {
|
||||||
|
await storage.setDoc('campaigns/a', { name: 'Alpha' });
|
||||||
|
const doc = await storage.getDoc('campaigns/a');
|
||||||
|
expect(doc).toEqual({ 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({ name: 'Beta' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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({ 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({ 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({ 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('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({ name: 'B' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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({ 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({ 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// microtask flush so async subscribers settle.
|
||||||
|
function flush() {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { runStorageContract, flush };
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
// memory.js — in-process storage impl. Test seed.
|
||||||
|
// Map<docPath, data>. EventEmitter for subscribe.
|
||||||
|
// Mirrors firebase semantics: setDoc=replace, updateDoc=shallow merge, addDoc=auto-id.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { EventEmitter } = require('events');
|
||||||
|
|
||||||
|
function createMemoryStorage() {
|
||||||
|
const docs = new Map(); // path -> data obj
|
||||||
|
const bus = new EventEmitter();
|
||||||
|
bus.setMaxListeners(1000);
|
||||||
|
|
||||||
|
// ---- path helpers ----
|
||||||
|
// collection path = path with even number of segments OR known collection.
|
||||||
|
// doc path = odd segments (coll/doc, coll/doc/subcoll/subdoc).
|
||||||
|
// getCollection(path) returns all docs whose path === path/id for any single id segment.
|
||||||
|
function isCollectionPath(p) {
|
||||||
|
return p.split('/').length % 2 === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitDoc(path, data) { bus.emit('doc:' + path, data); }
|
||||||
|
function emitCollection(collPath) {
|
||||||
|
const children = collectionDocs(collPath);
|
||||||
|
bus.emit('coll:' + collPath, children);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectionDocs(collPath) {
|
||||||
|
const out = [];
|
||||||
|
const segLen = collPath.split('/').length + 1;
|
||||||
|
for (const [p, data] of docs) {
|
||||||
|
const segs = p.split('/');
|
||||||
|
if (segs.length !== segLen) continue;
|
||||||
|
const parent = segs.slice(0, -1).join('/');
|
||||||
|
if (parent === collPath) out.push(data);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function genId() {
|
||||||
|
return (typeof crypto !== 'undefined' && crypto.randomUUID)
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `id_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = {
|
||||||
|
async getDoc(path) {
|
||||||
|
return docs.has(path) ? deepClone(docs.get(path)) : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async setDoc(path, data) {
|
||||||
|
docs.set(path, deepClone(data));
|
||||||
|
emitDoc(path, deepClone(data));
|
||||||
|
// notify parent collection
|
||||||
|
const segs = path.split('/');
|
||||||
|
if (segs.length >= 2) emitCollection(segs.slice(0, -1).join('/'));
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateDoc(path, patch) {
|
||||||
|
const existing = docs.has(path) ? docs.get(path) : {};
|
||||||
|
const merged = { ...existing, ...patch };
|
||||||
|
docs.set(path, merged);
|
||||||
|
emitDoc(path, deepClone(merged));
|
||||||
|
const segs = path.split('/');
|
||||||
|
if (segs.length >= 2) emitCollection(segs.slice(0, -1).join('/'));
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteDoc(path) {
|
||||||
|
docs.delete(path);
|
||||||
|
emitDoc(path, null);
|
||||||
|
const segs = path.split('/');
|
||||||
|
if (segs.length >= 2) emitCollection(segs.slice(0, -1).join('/'));
|
||||||
|
},
|
||||||
|
|
||||||
|
async addDoc(collectionPath, data) {
|
||||||
|
const id = genId();
|
||||||
|
const path = `${collectionPath}/${id}`;
|
||||||
|
docs.set(path, deepClone(data));
|
||||||
|
emitDoc(path, deepClone(data));
|
||||||
|
emitCollection(collectionPath);
|
||||||
|
return { id, path };
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCollection(collPath) {
|
||||||
|
return collectionDocs(collPath).map(deepClone);
|
||||||
|
},
|
||||||
|
|
||||||
|
async batchWrite(ops) {
|
||||||
|
for (const op of ops) {
|
||||||
|
if (op.type === 'set') await storage.setDoc(op.path, op.data);
|
||||||
|
else if (op.type === 'delete') await storage.deleteDoc(op.path);
|
||||||
|
else if (op.type === 'update') await storage.updateDoc(op.path, op.data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
subscribeDoc(path, cb) {
|
||||||
|
// fire immediately with current value
|
||||||
|
const cur = docs.has(path) ? deepClone(docs.get(path)) : null;
|
||||||
|
Promise.resolve().then(() => cb(cur));
|
||||||
|
const handler = (data) => cb(data);
|
||||||
|
bus.on('doc:' + path, handler);
|
||||||
|
return () => bus.off('doc:' + path, handler);
|
||||||
|
},
|
||||||
|
|
||||||
|
subscribeCollection(collPath, cb) {
|
||||||
|
Promise.resolve().then(() => cb(collectionDocs(collPath).map(deepClone)));
|
||||||
|
const handler = (docs) => cb(docs);
|
||||||
|
bus.on('coll:' + collPath, handler);
|
||||||
|
return () => bus.off('coll:' + collPath, handler);
|
||||||
|
},
|
||||||
|
|
||||||
|
dispose() { bus.removeAllListeners(); docs.clear(); },
|
||||||
|
|
||||||
|
// test/debug
|
||||||
|
_docs: docs,
|
||||||
|
};
|
||||||
|
|
||||||
|
return storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deepClone(v) {
|
||||||
|
if (v === null || v === undefined) return v;
|
||||||
|
return JSON.parse(JSON.stringify(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createMemoryStorage };
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// Runner: executes storage contract against each impl.
|
||||||
|
// TDD: contract = spec. Run against memory first. RED until memory.js built.
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { runStorageContract } = require('./contract.test');
|
||||||
|
const { createMemoryStorage } = require('./memory');
|
||||||
|
|
||||||
|
runStorageContract('memory', () => createMemoryStorage());
|
||||||
Reference in New Issue
Block a user