Files
ttrpg-initiative-tracker/src/storage/contract.js
T

200 lines
7.8 KiB
JavaScript
Raw Normal View History

// 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 };