UNPKG

@taraai/read-write

Version:

Synchronous NoSQL/Firestore for React

648 lines (580 loc) 18.9 kB
/** * @jest-environment jsdom */ /* istanbul ignore file */ import { dispatchActual, mutationWriteOutputActual, } from './__mock__/shouldPassFail'; import firebase from 'firebase/compat/app'; import 'firebase/compat/firestore'; import 'firebase/auth'; import createFirestoreInstance, { getFirestore, } from '../createFirestoreInstance'; import firestoreReducer from '../reducer'; import mutate from '../utils/mutate'; import thunk from 'redux-thunk'; import { configureStore, unwrapResult } from '@reduxjs/toolkit'; import isEmpty from 'lodash/isEmpty'; import isFunction from 'lodash/isFunction'; import merge from 'lodash/merge'; import pick from 'lodash/pick'; import kebabCase from 'lodash/kebabCase'; import startCase from 'lodash/startCase'; import { Provider } from 'react-redux'; import React from 'react'; import { prettyDOM, render } from '@testing-library/react'; import { getQueryConfig, getQueryName } from '../utils/query'; import { actionTypes } from '../constants'; import { writeFile } from 'fs'; import { performance } from 'perf_hooks'; import debug from 'debug'; const { useFirestore } = require('../redux-firebase/useFirebase'); const { mutationWriteOutput } = require('../reducers/utils/mutate'); const { wrapInDispatch } = require('../utils/actions'); const info = debug('readwrite:*'); const verbose = debug('readwrite:debug'); // const __non_webpack_require__ = module[`require`].bind(module); const removeColors = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g; const noop = () => null; function setupFirestore(databaseURL, enhancers, sideEffects, preload = []) { const store = configureStore({ reducer: { firestore: firestoreReducer, }, middleware: [ thunk.withExtraArgument({ getFirestore, ...enhancers, }), ], preloadedState: { firestore: { cache: { database: preload.reduce( (normalized, data) => merge(normalized, { [data.path]: { [data.id]: data } }), {}, ), databaseOverrides: {}, }, }, }, }); const wasStarted = firebase.apps.length > 0; const app = function () { // binded function ensures only explicit configs are used return wasStarted ? firebase.apps[0] : firebase.initializeApp({ projectId: 'demo-read-write', ...(databaseURL ? { authDomain: 'localhost:9099', databaseURL } : {}), }); }.bind(this)(); const extendedFirestoreInstance = createFirestoreInstance( app, { userProfile: 'users', useFirestoreForProfile: true }, store.dispatch, ); // route useRead request to cache reducer bypassing firestore extendedFirestoreInstance.setListeners = (queryOpts) => { const unsubscribe = () => null; queryOpts.forEach((query) => { const meta = getQueryConfig(query); store.dispatch({ type: actionTypes.SET_LISTENER, meta, payload: { name: getQueryName(meta) }, }); }); return unsubscribe; }; useFirestore.mockReturnValue(extendedFirestoreInstance); if (!wasStarted) firebase.firestore().useEmulator('localhost', 8080); // spy on mutations wrapInDispatch.mockImplementation((dispatcher, action) => { // console.log('action', action); return sideEffects(dispatcher, store.getState, action); }); return [extendedFirestoreInstance, store, app]; } // send data to firestore directly async function loadCollection(firestore, messages) { let fragment = {}; await Promise.all( messages.map(({ path }) => firestore.collection(path).get()), ).then((collection) => { const frag = collection.map((snap) => { return snap.docs.reduce( (db, doc) => ({ ...db, [doc.ref.parent.path]: { [doc.id]: { id: doc.id, path: doc.ref.parent.path, ...doc.data(), }, }, }), {}, ); }); fragment = { ...fragment, ...frag[0] }; }); return fragment; } async function cleanFirestore(firestore, messages) { const batch = firestore.batch(); messages.filter(Boolean).forEach(({ path, id }) => { batch.delete(firestore.doc(`${path}/${id}`)); }); return batch.commit(); } /** */ /** * Autotest unit + integration * * @param {Function} actionCreatorFn The createMutate action * @param {Boolean} [useEmulator=false] run as integration test * @returns {Array<string, Function>} Jest Test * * ## Basic Example Usage: * #### Action Creator * ```js * const archiveTask = createMutate({ * action: 'archiveTask', * read: ({ id: taskId }) => ({ myTaskId: () => taskId }), * write: ({ myTaskId }) => ({ id: myTaskId, path: 'tasks', archived: true }), * }); * ``` * #### Action Creator Test * ```js * it.each([{ * payload: { id: '999' }, * results: [{ id: '999', path: 'tasks', archived: true }], * }])(...shouldPass(archiveTask)); * ``` * * //** * Autotest unit+integration * * @param {string} testName Custom test suite name * @param {Function} actionCreatorFn The createMutate action * @param {Boolean} [useEmulator=false] run as integration test * @returns {Array<string, Function>} Jest Test * * ## Advanced Example Usage: * #### Action Creator * ```js * const archiveTask = createMutate({ * action: 'archiveTask', * read: ({ id: taskId }, { orgId }) => ({ * myTask: { path: `orgs/${orgId}/tasks`, id: taskId }, * taskId: () => taskId, * }), * write: ({ myTask, taskId }) => ({ * id: myTask?.id || taskId, * path: myTask?.path || 'orgs/my-org/tasks', * archived: true, * }), * }); * ``` * #### Action Creator Test * ```js * it.each([{ * setup: [{ path: 'orgs/my-org/tasks', id: 'task-one', archived: false, title: 'sample' }], * globals: { orgId: () => 'my-org' }, * component: './path/to/component.tsx or functionalComponent', * payload: { id: '999' }, * writes: { path: 'orgs/my-org/tasks', id: 'task-one', archived: true }, * results: [{ id: '999', path: 'tasks', archived: true }], * returned: undefined, * }])(...shouldPass('Advanced integration test for archiving tasks', archiveTask, true)); * ``` */ function shouldPass(actionCreatorFn, useEmulator = false) { // useEmulator = false will run everything through redux-firestore but skip sending to the firestore DB const isIntegration = process.env.READWRITE_INTEGRATION || (typeof useEmulator === 'boolean' ? useEmulator : arguments[2] || false); const type = isIntegration ? '[integration]' : '[unit]'; const testSuiteName = typeof actionCreatorFn === 'string' ? `${type}: ${actionCreatorFn}` : `${type}: ${ actionCreatorFn.typePrefix || '' } %# $testname should pass.`; const actionCreator = isFunction(useEmulator) ? useEmulator : actionCreatorFn; return [ testSuiteName, async ({ payload, writes: writesExpected, results: resultsExpected, returned: returnExpected, component, globals, setup, testname, }) => { const profiles = [ { name: 'start', time: performance.now(), delta: 0, }, ]; if ( setup && !( Array.isArray(setup) && setup.every(({ path, id }) => !isEmpty(path) && !isEmpty(id)) ) ) { throw new Error( `'setup' must be an { path:string; id: string; ...any}[] but received ${JSON.stringify( setup, )}.`, ); } const databaseURL = typeof isIntegration === 'string' ? isIntegration : (isIntegration && 'localhost:8080') || null; const log = []; // wrap dispatch to spy on cache before mutation. let cache = {}; let customDispatcher = (dispatcher, getState, action) => { const mutationPromise = dispatchActual(dispatcher, action, { extras: globals, }); cache = getState().firestore.cache; log.push({ event: 'cache', data: getState().firestore.cache }); return mutationPromise; }; // connect real firestore server or jest in memory const [firestore, store, firebaseApp] = setupFirestore( databaseURL, globals, customDispatcher, setup, ); profiles.push({ name: 'firestore-init', time: performance.now(), delta: performance.now() - profiles[profiles.length - 1].time, }); // --- preload data --- if (setup) { await mutate({ firestore: () => firestore }, setup); } profiles.push({ name: 'firestore-preload', time: performance.now(), delta: performance.now() - profiles[profiles.length - 1].time, }); // --- setup component --- let element; let elementName; let preComponent; if (component) { // const UI = // typeof component === 'string' // ? __non_webpack_require__(component).default // : component; // elementName = component // .split('/') // .pop() // .replace(/\.(tsx|jsx|js|ts)/, ''); // element = render( // <Provider store={store}> // <UI /> // </Provider>, // ); // profiles.push({ // name: 'component-rendered', // time: performance.now(), // delta: performance.now() - profiles[profiles.length - 1].time, // }); // preComponent = prettyDOM(element.container).replace(removeColors, ''); } // spy on results returned from mutation let writeReceived = null; mutationWriteOutput.mockImplementation((writes, db) => { writeReceived = Array.isArray(writesExpected) ? writes : writes[0]; verbose.enabled && verbose( `test-name: "${testname}"\nwrite: ${JSON.stringify( writeReceived, null, 2, )}`, ); return mutationWriteOutputActual(writes, db); }); // send the test action verbose.enabled && verbose( `test-name: "${testname}"\npayload: ${JSON.stringify( payload, null, 2, )}`, ); const dispatched = store .dispatch(actionCreator(payload)) .then(unwrapResult); // Action Creator should not throw errors const returnRevived = await expect(dispatched).resolves.not.toThrow(); profiles.push({ name: 'action-dispatched', time: performance.now(), delta: performance.now() - profiles[profiles.length - 1].time, }); const postComponent = element && element.container && prettyDOM(element.container).replace(removeColors, ''); writeFile( `stories/${startCase(elementName)}_${kebabCase(testname)}.stories.tsx`, `import React from 'react';\nexport default {\n\t` + `title: '${startCase(elementName)}',\n};\n\n` + `export const Default = () => (${preComponent});\n\n` + `export const After = () => (${postComponent});`, (done, err) => null, ); if (element && info.enabled) { info( `\nstories/${snakeCase(testname)}_mutation.stories.tsx`, `${prettyFormat.format(element.toJSON(), { plugins: [prettyFormat.plugins.ReactTestComponent], printFunctionName: false, highlight: false, })}`, (done, err) => null, ); // TODO: export visual check } if (writesExpected !== undefined) { // Validates the expected results from the writes expect(writeReceived).toStrictEqual(writesExpected); } // validate outputs in redux store & firestore if (resultsExpected !== undefined) { const { firestore: diskExpected = resultsExpected, cache: memoryExpected = resultsExpected, } = resultsExpected; verbose.enabled && verbose( `test-name: "${testname}"\nstore: ${JSON.stringify( memoryExpected, null, 2, )}`, ); Object.keys(memoryExpected).forEach((path) => Object.keys(memoryExpected[path]).forEach((id) => { const documentExpected = memoryExpected[path][id]; const keys = Object.keys(documentExpected); const optimistic = { ...((cache.database && cache.database[path] && cache.database[path][id]) || {}), ...((cache.databaseOverrides && cache.databaseOverrides[path] && cache.databaseOverrides[path][id]) || {}), }; const documentCached = pick(optimistic, keys); // Validates each document's synchronous, optimistic results in Redux expect(documentCached).toStrictEqual(documentExpected); }), ); profiles.push({ name: 'cache-validated', time: performance.now(), delta: performance.now() - profiles[profiles.length - 1].time, }); // when useEmulator = true, validate documents in firestore if (databaseURL) { const queries = []; Object.keys(diskExpected).forEach((path) => { const collection = diskExpected[path]; Object.keys(collection).forEach((id) => { queries.push({ path, id }); }); }); // load the actual document from the firestore emulator const database = await loadCollection(firestore, queries); Object.keys(diskExpected).forEach((path) => Object.keys(diskExpected[path]).forEach((id) => { const keys = Object.keys(diskExpected[path][id]); const documentSavedToFirestore = pick( database[path][id] || {}, keys, ); const documentExpected = pick(diskExpected[path][id] || {}, keys); // Validates each final document saved to firestore expect(documentExpected).toStrictEqual(documentSavedToFirestore); }), ); } profiles.push({ name: 'firestore-validated', time: performance.now(), delta: performance.now() - profiles[profiles.length - 1].time, }); } if (returnExpected !== undefined) { // Validate the return from the async thunk payload expect(returnExpected).toStrictEqual(returnRevived); } await cleanFirestore(firestore, [ ...(setup || []), ...(Array.isArray(writeReceived) ? writeReceived : [writeReceived]), ]); profiles.push({ name: 'firestore-cleaned', time: performance.now(), delta: performance.now() - profiles[profiles.length - 1].time, }); await firebaseApp.delete(); for (var i in firestore) { firestore[i] = null; } if (info.enabled) { info( `\ntest-name: "${testname}"\n`, profiles .slice(1) .map(({ name, delta }) => `${name}: ${delta.toFixed(2)}ms `) .join('\n'), '\n', ); } }, ]; } /** * shouldFail * * it.each([{ * payload: { id: '' }, * returned: new Error('`id` is empty string.'), * }])(...shouldFail(archiveTask)); * * @param {Function} actionCreator * @returns {Array<string, Function>} - Test Name and Test Function */ // const shouldFail = (...actionCreator) => { // const [testname, actionCreatorFnc] = // actionCreator.length === 1 // ? [ // `${actionCreator[0].typePrefix || ''} $payload fails properly.`, // actionCreator[0], // ] // : [actionCreator[0], actionCreator[1]]; // return [ // testname, // async ({ payload, mutation, returned, globals }) => { // const mutate = jest.fn(); // const getFirestore = jest.fn().mockReturnValue({ // mutate, // }); // const thunk = [noop, noop, { ...globals, getFirestore }]; // const dispatched = actionCreatorFnc(payload)(...thunk).then(unwrapResult); // await expect(dispatched).rejects.not.toBeUndefined(); // if (mutation !== undefined) { // expect(mutate).toHaveBeenCalledWith(mutation); // } // try { // await dispatched; // } catch (error) { // if (returned === undefined) { // if (!(error instanceof Error) && error.stack) { // return expect(error).not.toBeNull(); // } // return expect(error).toBeInstanceOf(Error); // } // if (returned instanceof Error) { // return expect(error).toStrictEqual(returned); // } // Object.keys(returned).map((key) => { // expect(error[key]).toStrictEqual(returned[key]); // }); // } // }, // ]; // }; // // export { shouldFail, shouldPass }; /**************** * setCache ****************/ export default function setCache( { firebaseAuth = { isEmpty: true, isLoaded: false, }, firebaseProfile = { isEmpty: true, isLoaded: false, }, ...documents }, middlewares = [], ) { // TODO: alias are dynamic coming from the useRead calls const keys = Object.keys(aliases); //--- can add all the docs but don't know the queries, // need to let store getting intercept the useRead // and return the preprocess results // useRead's `setListeners` can be proxied over to get the query documents.map(); const normalizedDocuments = keys.reduce((obj, key) => { const list = aliases[key]; const { path } = list[0]; list.forEach((item) => { obj[list[0].path] = { [item.id]: item }; }); return obj; }, {}); const initialState = { firebase: { auth: firebaseAuth, profile: firebaseProfile, }, firestore: { cache: { database: normalizedDocuments, databaseOverrides: {}, ...keys.reduce( (obj, alias) => ({ ...obj, [alias]: { ordered: aliases[alias].map(({ path, id }) => [path, id]), path: (aliases[alias] && aliases[alias][0] && aliases[alias][0].path) || 'unset', via: 'memory', }, }), {}, ), }, }, }; // getQueryName; // initialState.firestore.cache; const store = configureStore(middlewares); return store(initialState); } export { shouldPass };