Rework backend #1

Merged
robert merged 86 commits from rework-backend into main 2026-07-01 19:29:34 -04:00
8 changed files with 273 additions and 1 deletions
Showing only changes of commit 84dd17e174 - Show all commits
+69
View File
@@ -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(<App />);
// 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(<App />);
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/');
});
});
+69
View File
@@ -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<cb>
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));
}
+5
View File
@@ -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];
+11
View File
@@ -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 () => {};
}
+96
View File
@@ -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));
}
+22
View File
@@ -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();
});
+1 -1
View File
@@ -2,7 +2,7 @@
// TDD: contract = spec. Run against memory first. RED until memory.js built. // TDD: contract = spec. Run against memory first. RED until memory.js built.
'use strict'; 'use strict';
const { runStorageContract } = require('./contract.test'); const { runStorageContract } = require('./contract');
const { createMemoryStorage } = require('./memory'); const { createMemoryStorage } = require('./memory');
runStorageContract('memory', () => createMemoryStorage()); runStorageContract('memory', () => createMemoryStorage());