match main firebase behavior; fix contract + mismatches

Contract rewritten to match main prod truth:
- require id injection in all doc/collection results
- honor setDoc({merge:true}) (main L1624 + 4 activeDisplay sites)
- drop invented bare<->prefixed cross-lookup tests (main never does this)
- add setDoc{merge}, batch set-only/update-only contract coverage

Fix mismatches vs main:
- subscribeCollection hook now forwards queryConstraints (was dropped;
  LOG_QUERY sort+limit honored again). Mock onSnapshot honors orderBy+limit.
  2 new contract tests prove the chain.
- subscribeDoc/subscribeCollection adapters now forward errCb. App hooks +
  DisplayView propagate subscribe errors to UI (match main onSnapshot 3rd arg).
- ws adapter signatures accept queryConstraints + errCb (interface match).

activeDisplay writes match main:
- 5 unguarded sites -> setDoc({merge:true}) (create-if-missing, not updateDoc)
- 3 guarded sites stay updateDoc

Delete memory adapter: third storage system added complexity, zero value.
firebase-mock covers fast tests, ws covers server path. Factory throws on
unknown mode now.

Delete phantom 'Phase A/B' comment in firebase.js header.

Tests updated to assert setDoc{merge} (not updateDoc) for activeDisplay writes.
83/83 green.
This commit is contained in:
david raistrick
2026-07-04 12:22:19 -04:00
parent b12ac904a6
commit 79af61cb8b
17 changed files with 240 additions and 251 deletions
+44 -32
View File
@@ -27,10 +27,10 @@ function runStorageContract(name, factory) {
afterEach(async () => { if (storage && storage.dispose) await storage.dispose(); });
describe('getDoc / setDoc', () => {
test('setDoc then getDoc returns the doc', async () => {
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({ name: 'Alpha' });
expect(doc).toEqual({ id: 'a', name: 'Alpha' });
});
test('getDoc on missing path returns null', async () => {
@@ -42,7 +42,15 @@ function runStorageContract(name, factory) {
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' });
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] });
});
});
@@ -51,13 +59,13 @@ function runStorageContract(name, factory) {
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] });
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({ name: 'Alpha' });
expect(doc).toEqual({ id: 'a', name: 'Alpha' });
});
});
@@ -79,7 +87,7 @@ function runStorageContract(name, factory) {
expect(id).toBeTruthy();
expect(path).toBe(`campaigns/a/encounters/${id}`);
const doc = await storage.getDoc(path);
expect(doc).toEqual({ name: 'E1' });
expect(doc).toEqual({ id, name: 'E1' });
});
test('two addDocs produce distinct ids', async () => {
@@ -91,33 +99,15 @@ function runStorageContract(name, factory) {
describe('firebase-prefixed path identity', () => {
// App passes firebase-prefixed paths (artifacts/{APP_ID}/public/data/...).
// Adapter must normalize internally so write+read at prefixed path round-trips
// AND collection queries at bare canonical path find prefixed-written docs.
// Catches replay-script bug (wrote prefixed, adapter reads bare, missed).
// 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 getCollection bare finds it', async () => {
await storage.setDoc(`${PREFIX}/campaigns/c1`, { name: 'P1' });
const docs = await storage.getCollection('campaigns');
expect(docs.some(d => d.name === 'P1')).toBe(true);
});
test('setDoc prefixed then getDoc same prefixed path returns it', async () => {
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({ name: 'P2' });
});
test('setDoc prefixed then getDoc bare path returns it', async () => {
await storage.setDoc(`${PREFIX}/campaigns/c3`, { name: 'P3' });
const doc = await storage.getDoc('campaigns/c3');
expect(doc).toEqual({ name: 'P3' });
});
test('setDoc bare then getCollection prefixed finds it', async () => {
await storage.setDoc('campaigns/c4', { name: 'P4' });
const docs = await storage.getCollection(`${PREFIX}/campaigns`);
expect(docs.some(d => d.name === 'P4')).toBe(true);
expect(doc).toEqual({ id: 'c2', name: 'P2' });
});
});
@@ -165,7 +155,29 @@ function runStorageContract(name, factory) {
{ type: 'delete', path: 'campaigns/a' },
]);
expect(await storage.getDoc('campaigns/a')).toBeNull();
expect(await storage.getDoc('campaigns/b')).toEqual({ name: 'B' });
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 });
});
});
@@ -176,7 +188,7 @@ function runStorageContract(name, factory) {
storage.subscribeDoc('campaigns/a', (doc) => calls.push(doc));
await flush();
expect(calls).toHaveLength(1);
expect(calls[0]).toEqual({ name: 'Alpha' });
expect(calls[0]).toEqual({ id: 'a', name: 'Alpha' });
});
test('fires cb on subsequent change', async () => {
@@ -186,7 +198,7 @@ function runStorageContract(name, factory) {
await storage.setDoc('campaigns/a', { name: 'Alpha' });
await flush();
const last = calls[calls.length - 1];
expect(last).toEqual({ name: 'Alpha' });
expect(last).toEqual({ id: 'a', name: 'Alpha' });
});
test('unsubscribe stops callbacks', async () => {