From 84dd17e174cd0257247ccc4bf018fb113b2bc859 Mon Sep 17 00:00:00 2001
From: david raistrick <1108844+keen99@users.noreply.github.com>
Date: Sun, 28 Jun 2026 17:59:50 -0400
Subject: [PATCH] test: Firebase mock harness + createCampaign characterization
- src/__mocks__/firebase/*: jest manual mocks (app/auth/firestore)
- src/__mocks__/firebase/_mock-db.js: in-memory DB + call recorder
- src/setupTests.js: jest-dom, env stubs, crypto polyfill, DB reset
- src/App.characterization.test.js: createCampaign -> setDoc path/payload locked
- src/storage/contract.js (renamed from .test.js, helper not suite)
21 tests green (memory 19 + createCampaign 2).
---
src/App.characterization.test.js | 69 +++++++++++++
src/__mocks__/firebase/_mock-db.js | 69 +++++++++++++
src/__mocks__/firebase/app.js | 5 +
src/__mocks__/firebase/auth.js | 11 +++
src/__mocks__/firebase/firestore.js | 96 +++++++++++++++++++
src/setupTests.js | 22 +++++
src/storage/{contract.test.js => contract.js} | 0
src/storage/storage.test.js | 2 +-
8 files changed, 273 insertions(+), 1 deletion(-)
create mode 100644 src/App.characterization.test.js
create mode 100644 src/__mocks__/firebase/_mock-db.js
create mode 100644 src/__mocks__/firebase/app.js
create mode 100644 src/__mocks__/firebase/auth.js
create mode 100644 src/__mocks__/firebase/firestore.js
create mode 100644 src/setupTests.js
rename src/storage/{contract.test.js => contract.js} (100%)
diff --git a/src/App.characterization.test.js b/src/App.characterization.test.js
new file mode 100644
index 0000000..80c538e
--- /dev/null
+++ b/src/App.characterization.test.js
@@ -0,0 +1,69 @@
+// App.characterization.test.js
+// Characterize App -> Firebase calls. Lock path + payload shape per action.
+// Mock SDK, render AdminView, fire action, assert recorded calls.
+// Purpose: refactor (path-shape rewrite) must not change these calls.
+
+import React from 'react';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { getCalls, MOCK_DB } from './__mocks__/firebase/_mock-db';
+
+// import AFTER mocks resolve (jest auto-uses __mocks__/firebase/* via moduleNameMapper)
+import App from './App';
+
+// Helper: find first setDoc call matching path substring.
+function findCall(fn, pathSub) {
+ return getCalls().find(c => c.fn === fn && (pathSub ? c.path.includes(pathSub) : true));
+}
+
+beforeEach(() => {
+ // App reads window.location at mount; ensure clean.
+ window.history.replaceState({}, '', '/');
+ window.open = jest.fn();
+ global.alert = jest.fn();
+});
+
+describe('App -> Firebase characterization: createCampaign', () => {
+ test('setDoc called with campaign path + correct payload shape', async () => {
+ render();
+
+ // Wait past auth (mock fires instantly) and campaign list load.
+ await waitFor(() => screen.getByText(/Create Campaign/i));
+
+ fireEvent.click(screen.getByRole('button', { name: /Create Campaign/i }));
+
+ // Modal: name input + create.
+ await waitFor(() => screen.getByLabelText(/Campaign Name/i));
+ fireEvent.change(screen.getByLabelText(/Campaign Name/i), { target: { value: 'Test Campaign' } });
+ fireEvent.click(screen.getByRole('button', { name: /^Create$/i }));
+
+ await waitFor(() => {
+ const call = findCall('setDoc', '/campaigns/');
+ expect(call).toBeDefined();
+ });
+
+ const call = findCall('setDoc', '/campaigns/');
+ expect(call.path).toMatch(/campaigns\/.+$/);
+ expect(call.data).toMatchObject({
+ name: 'Test Campaign',
+ playerDisplayBackgroundUrl: '',
+ players: [],
+ });
+ expect(call.data).toHaveProperty('ownerId');
+ expect(call.data).toHaveProperty('createdAt');
+ });
+
+ test('campaign path includes APP_ID namespace', async () => {
+ render();
+ await waitFor(() => screen.getByText(/Create Campaign/i));
+ fireEvent.click(screen.getByRole('button', { name: /Create Campaign/i }));
+ await waitFor(() => screen.getByLabelText(/Campaign Name/i));
+ fireEvent.change(screen.getByLabelText(/Campaign Name/i), { target: { value: 'NS Test' } });
+ fireEvent.click(screen.getByRole('button', { name: /^Create$/i }));
+
+ await waitFor(() => findCall('setDoc', '/campaigns/'));
+ const call = findCall('setDoc', '/campaigns/');
+ expect(call.path).toContain('artifacts/');
+ expect(call.path).toContain('/public/data/');
+ });
+});
diff --git a/src/__mocks__/firebase/_mock-db.js b/src/__mocks__/firebase/_mock-db.js
new file mode 100644
index 0000000..72539d1
--- /dev/null
+++ b/src/__mocks__/firebase/_mock-db.js
@@ -0,0 +1,69 @@
+// Mock in-memory Firestore for jest tests.
+// Reset via resetMockDb() in setupTests.js beforeEach.
+
+const state = {
+ docs: new Map(), // path -> data
+ subscribers: new Map(), // path -> Set
+ counter: 0,
+ calls: [], // recorded SDK calls
+};
+
+export const MOCK_DB = {
+ get(path) { return state.docs.has(path) ? clone(state.docs.get(path)) : null; },
+ set(path, data) {
+ state.docs.set(path, clone(data));
+ this._notify(path);
+ },
+ merge(path, patch) {
+ const cur = state.docs.has(path) ? state.docs.get(path) : {};
+ const next = { ...cur, ...clone(patch) };
+ state.docs.set(path, next);
+ this._notify(path);
+ },
+ delete(path) {
+ state.docs.delete(path);
+ this._notify(path);
+ },
+ collection(collPath) {
+ const out = [];
+ for (const [p, data] of state.docs) {
+ const parent = p.split('/').slice(0, -1).join('/');
+ if (parent === collPath) out.push({ id: p.split('/').pop(), data: clone(data) });
+ }
+ return out;
+ },
+ subscribe(path, cb) {
+ if (!state.subscribers.has(path)) state.subscribers.set(path, new Set());
+ state.subscribers.get(path).add(cb);
+ return () => state.subscribers.get(path)?.delete(cb);
+ },
+ _notify(path) {
+ // notify exact doc path subscribers
+ if (state.subscribers.has(path)) state.subscribers.get(path).forEach(cb => cb());
+ // notify parent collection subscribers
+ const parent = path.split('/').slice(0, -1).join('/');
+ if (parent && state.subscribers.has(parent)) state.subscribers.get(parent).forEach(cb => cb());
+ },
+ nextId() { state.counter += 1; return String(state.counter).padStart(3, '0'); },
+ _state: state,
+};
+
+export function recordCall(entry) {
+ state.calls.push({ ...entry, ts: Date.now() });
+}
+
+export function resetMockDb() {
+ state.docs.clear();
+ state.subscribers.clear();
+ state.calls.length = 0;
+ state.counter = 0;
+}
+
+export function getCalls() {
+ return [...state.calls];
+}
+
+function clone(v) {
+ if (v === null || v === undefined) return v;
+ return JSON.parse(JSON.stringify(v));
+}
diff --git a/src/__mocks__/firebase/app.js b/src/__mocks__/firebase/app.js
new file mode 100644
index 0000000..4763a7f
--- /dev/null
+++ b/src/__mocks__/firebase/app.js
@@ -0,0 +1,5 @@
+// jest manual mock: firebase/app
+const fakeApp = { name: '[fake-firebase-app]', options: {} };
+export function initializeApp(config) { return fakeApp; }
+export const getApp = () => fakeApp;
+export const getApps = () => [fakeApp];
diff --git a/src/__mocks__/firebase/auth.js b/src/__mocks__/firebase/auth.js
new file mode 100644
index 0000000..cee2acc
--- /dev/null
+++ b/src/__mocks__/firebase/auth.js
@@ -0,0 +1,11 @@
+// jest manual mock: firebase/auth
+const fakeUser = { uid: 'test-user-123', isAnonymous: true };
+const fakeAuth = { currentUser: fakeUser };
+
+export function getAuth() { return fakeAuth; }
+export function signInAnonymously(auth) { return Promise.resolve({ user: fakeUser }); }
+export function signInWithCustomToken(auth, token) { return Promise.resolve({ user: fakeUser }); }
+export function onAuthStateChanged(auth, cb) {
+ cb(fakeUser);
+ return () => {};
+}
diff --git a/src/__mocks__/firebase/firestore.js b/src/__mocks__/firebase/firestore.js
new file mode 100644
index 0000000..cd62191
--- /dev/null
+++ b/src/__mocks__/firebase/firestore.js
@@ -0,0 +1,96 @@
+// jest manual mock: firebase/firestore
+// Records all calls so tests can assert path/payload/semantics.
+// Global __firestoreCalls reset per test (see setupTests.js).
+
+import { MOCK_DB, recordCall } from './_mock-db.js';
+
+const ref = (path) => ({ __ref: true, path, id: path.split('/').pop() });
+
+export function getFirestore() { return { __db: true }; }
+export function doc(db, path, extra) {
+ const p = extra ? `${path}/${extra}` : path;
+ return ref(p);
+}
+export function collection(db, path) { return ref(path); }
+export function query(refOrColl, ...constraints) { return { ref: refOrColl, constraints }; }
+export function orderBy(field, dir) { return { __type: 'orderBy', field, dir }; }
+export function limit(n) { return { __type: 'limit', n }; }
+
+// writes
+export async function setDoc(docRef, data, opts) {
+ recordCall({ fn: 'setDoc', path: docRef.path, data: clone(data), opts: opts || null });
+ MOCK_DB.set(docRef.path, clone(data));
+ return undefined;
+}
+export async function updateDoc(docRef, patch) {
+ recordCall({ fn: 'updateDoc', path: docRef.path, data: clone(patch) });
+ MOCK_DB.merge(docRef.path, clone(patch));
+ return undefined;
+}
+export async function deleteDoc(docRef) {
+ recordCall({ fn: 'deleteDoc', path: docRef.path });
+ MOCK_DB.delete(docRef.path);
+ return undefined;
+}
+export async function addDoc(collRef, data) {
+ const id = `auto_${MOCK_DB.nextId()}`;
+ const path = `${collRef.path}/${id}`;
+ recordCall({ fn: 'addDoc', path: collRef.path, data: clone(data) });
+ MOCK_DB.set(path, clone(data));
+ return { id, path };
+}
+export async function writeBatch(db) {
+ const ops = [];
+ return {
+ set: (r, d) => ops.push({ op: 'set', path: r.path, data: clone(d) }),
+ update: (r, d) => ops.push({ op: 'update', path: r.path, data: clone(d) }),
+ delete: (r) => ops.push({ op: 'delete', path: r.path }),
+ commit: async () => {
+ ops.forEach(o => {
+ recordCall({ fn: `batch.${o.op}`, path: o.path, data: o.data });
+ if (o.op === 'set') MOCK_DB.set(o.path, o.data);
+ else if (o.op === 'update') MOCK_DB.merge(o.path, o.data);
+ else if (o.op === 'delete') MOCK_DB.delete(o.path);
+ });
+ },
+ };
+}
+
+// reads (return from in-memory mock DB)
+export async function getDoc(docRef) {
+ const data = MOCK_DB.get(docRef.path);
+ return { exists: () => data !== null, id: docRef.id, data: () => data };
+}
+export async function getDocs(collRefOrQuery) {
+ const collPath = collRefOrQuery.ref ? collRefOrQuery.ref.path : collRefOrQuery.path;
+ const docs = MOCK_DB.collection(collPath);
+ return { docs: docs.map(d => ({ id: d.id, data: () => d.data })) };
+}
+
+// realtime — emit from mock DB, capture unsub
+export function onSnapshot(refOrQuery, onSuccess, onError) {
+ const path = refOrQuery.path || (refOrQuery.ref && refOrQuery.ref.path);
+ // fire immediately with current state
+ const emit = () => {
+ if (refOrQuery.__ref && refOrQuery.path && path.split('/').length % 2 === 0) {
+ const data = MOCK_DB.get(path);
+ onSuccess({
+ exists: () => data !== null,
+ id: path.split('/').pop(),
+ data: () => data,
+ });
+ } else {
+ const docs = MOCK_DB.collection(path);
+ onSuccess({ docs: docs.map(d => ({ id: d.id, data: () => d.data })) });
+ }
+ };
+ emit();
+ // register for future changes on this path
+ const unsub = MOCK_DB.subscribe(path, emit);
+ return unsub;
+}
+
+function clone(v) {
+ if (v === null || v === undefined) return v;
+ return JSON.parse(JSON.stringify(v));
+}
diff --git a/src/setupTests.js b/src/setupTests.js
new file mode 100644
index 0000000..d9d977c
--- /dev/null
+++ b/src/setupTests.js
@@ -0,0 +1,22 @@
+// jest setup: RTL jest-dom + mock DB reset per test.
+import '@testing-library/jest-dom';
+import { resetMockDb } from './__mocks__/firebase/_mock-db';
+
+// polyfill crypto.randomUUID for jsdom (used by generateId in App.js).
+if (!global.crypto) global.crypto = {};
+if (!global.crypto.randomUUID) {
+ global.crypto.randomUUID = () => 'test-uuid-' + Math.random().toString(36).slice(2, 10);
+}
+
+// Stub Firebase env vars so initializeFirebase() succeeds under test.
+// Real SDK calls are mocked via __mocks__/firebase/*.
+process.env.REACT_APP_FIREBASE_API_KEY = 'test-api-key';
+process.env.REACT_APP_FIREBASE_AUTH_DOMAIN = 'test.firebaseapp.com';
+process.env.REACT_APP_FIREBASE_PROJECT_ID = 'test-project';
+process.env.REACT_APP_FIREBASE_STORAGE_BUCKET = 'test.appspot.com';
+process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID = '1234567890';
+process.env.REACT_APP_FIREBASE_APP_ID = '1:1234567890:web:abcdef';
+
+beforeEach(() => {
+ resetMockDb();
+});
diff --git a/src/storage/contract.test.js b/src/storage/contract.js
similarity index 100%
rename from src/storage/contract.test.js
rename to src/storage/contract.js
diff --git a/src/storage/storage.test.js b/src/storage/storage.test.js
index 9fe30bf..fa4be45 100644
--- a/src/storage/storage.test.js
+++ b/src/storage/storage.test.js
@@ -2,7 +2,7 @@
// TDD: contract = spec. Run against memory first. RED until memory.js built.
'use strict';
-const { runStorageContract } = require('./contract.test');
+const { runStorageContract } = require('./contract');
const { createMemoryStorage } = require('./memory');
runStorageContract('memory', () => createMemoryStorage());