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());