Reworked the test builder

This commit is contained in:
Convly 2021-01-04 11:32:43 +01:00
parent c3a2c7024d
commit 579e4c32e9
6 changed files with 137 additions and 340 deletions

View File

@ -55,7 +55,9 @@ describe('Migration - draft and publish', () => {
['with table modifications', { town: { type: 'string' } }, { color: { type: 'string' } }],
])('%p', (testName, tableModification1, tableModification2) => {
beforeAll(async () => {
builder = await createTestBuilder()
builder = createTestBuilder();
await builder
.addContentType(dogModel)
.addFixtures(dogModel.name, dogs)
.build();

View File

@ -0,0 +1,98 @@
'use strict';
const { concat, merge, isFunction, map } = require('lodash/fp');
const modelsUtils = require('../models');
const stringifyDates = object =>
JSON.parse(
JSON.stringify(object, (key, value) => {
if (this[key] instanceof Date) {
return this[key].toUTCString();
}
return value;
})
);
const formatFixtures = map(stringifyDates);
module.exports = {
ct: {
create: contentType => {
let createdModel;
return {
async build(state) {
createdModel = await modelsUtils.createContentType(contentType);
return { ...state, models: [...state.models, createdModel] };
},
cleanup: () => modelsUtils.deleteContentType(createdModel.modelName),
};
},
createBatch: contentTypes => {
let createdModels = [];
return {
async build(state) {
createdModels = await modelsUtils.createContentTypes(contentTypes);
return { ...state, models: concat(state.models, createdModels) };
},
async cleanup() {
for (const model of createdModels) {
await modelsUtils.deleteContentType(model.modelName);
}
},
};
},
createMany: contentTypes => {
const createdModels = [];
return {
async build(state) {
for (const contentType of contentTypes) {
createdModels.push(await modelsUtils.createContentType(contentType));
}
return { ...state, models: concat(state.models, createdModels) };
},
async cleanup() {
for (const model of createdModels) {
await modelsUtils.deleteContentType(model.modelName);
}
},
};
},
},
comp: {
create: component => {
let createdModel;
return {
async build(state) {
createdModel = await modelsUtils.createComponent(component);
return { ...state, models: [...state.models, createdModel] };
},
cleanup: () => modelsUtils.deleteComponent(createdModel.uid),
};
},
},
fixtures: {
create(modelName, entries, getFixtures) {
let createdEntries = [];
return {
async build(state) {
createdEntries = formatFixtures(
await modelsUtils.createFixturesFor(
modelName,
isFunction(entries) ? entries(getFixtures()) : entries
)
);
return { ...state, fixtures: merge(state.fixtures, { [modelName]: createdEntries }) };
},
cleanup: () => modelsUtils.deleteFixturesFor(modelName, createdEntries),
};
},
},
};

View File

@ -1,120 +0,0 @@
'use strict';
const { prop, isArray, curry, isNil, has, isFunction } = require('lodash/fp');
const modelsUtils = require('../models');
const bindEvent = ({ event, fn, batch = false, metadata = {} } = {}) => {
Object.assign(fn, {
emitInfos: {
event,
batch,
metadata,
},
});
return fn;
};
const registry = {
ct: {
create: bindEvent({
event: 'model.created',
metadata: { type: 'ct' },
fn: modelsUtils.createContentType,
}),
createBatch: bindEvent({
event: 'model.created',
metadata: { type: 'ct' },
fn: modelsUtils.createContentTypes,
batch: true,
}),
createMany: bindEvent({
event: 'model.created',
metadata: { type: 'ct' },
fn: async cts => {
const createdModels = [];
for (const ct of cts) {
createdModels.push(await modelsUtils.createContentType(ct));
}
return createdModels;
},
}),
},
comp: {
create: bindEvent({
event: 'model.created',
metadata: { type: 'comp' },
fn: modelsUtils.createComponent,
}),
},
fixture: {
create: bindEvent({
event: 'fixture.created',
batch: true,
fn: async (model, entries, getFixtures) => {
const entriesToCreate = isFunction(entries) ? entries(getFixtures()) : entries;
const result = await modelsUtils.createFixturesFor(model, entriesToCreate);
return { entries: result, model };
},
}),
},
};
const getActionByCode = code => prop(code, registry);
const createAction = (code, ...params) => {
const _state = {
emitter: null,
params,
code,
fn: getActionByCode(code),
};
return {
get params() {
return _state.params;
},
get code() {
return _state.code;
},
get fn() {
return _state.fn;
},
setEmitter(emitter) {
_state.emitter = emitter;
return this;
},
async execute() {
const { fn, params, emitter } = _state;
const res = await fn(...params);
if (isNil(emitter) || !has('emitInfos', fn)) {
return res;
}
const { event, batch, metadata } = fn.emitInfos;
const emitEvent = curry(emitter.emit)(event);
const bindMetadata = result => ({ result, metadata });
if (isArray(res) && !batch) {
res.map(bindMetadata).forEach(emitEvent);
} else {
emitEvent(bindMetadata(res));
}
return res;
},
};
};
module.exports = {
createAction,
};

View File

@ -1,72 +0,0 @@
'use strict';
// eslint-disable-next-line node/no-extraneous-require
const { contains, reduce, over } = require('lodash/fp');
const createEvent = (eventName, data) => ({ name: eventName, data, emittedAt: new Date() });
const createEventsManager = ({ allowedEvents = [] }) => {
const toEventsMap = reduce((acc, eventName) => ({ ...acc, [eventName]: [] }), {});
const _state = {
events: toEventsMap(allowedEvents),
callbacks: toEventsMap(allowedEvents),
allowedEvents,
};
const pushEvent = event => _state.events[event.name].push(event.data);
const notify = ({ name, data }) => over(_state.callbacks[name])(data);
const validateEventName = eventName => {
if (!contains(eventName, _state.allowedEvents)) {
throw new Error(`"${eventName}" is not a valid event name.`);
}
}
return {
get eventsMap() {
return _state.events;
},
get allowedEvents() {
return _state.allowedEvents;
},
getEventsByName(name) {
validateEventName(name);
return _state.events[name];
},
register(eventName, callback) {
validateEventName(eventName);
_state.callbacks[eventName].push(callback);
return this;
},
unregister(eventName, callback) {
validateEventName(eventName);
_state.callbacks[eventName] = _state.callbacks[eventName].filter(cb => cb !== callback);
return this;
},
emit(eventName, data) {
validateEventName(eventName);
const event = createEvent(eventName, data);
const executeOperations = over([pushEvent, notify]);
executeOperations(event);
return this;
}
}
}
module.exports = {
createEvent,
createEventsManager,
};

View File

@ -1,108 +1,30 @@
'use strict';
// eslint-disable-next-line node/no-extraneous-require
const { omit, get } = require('lodash/fp');
const _ = require('lodash');
// eslint-disable-next-line node/no-extraneous-require
const { map, prop } = require('lodash/fp');
const modelsUtils = require('../models');
const { sanitizeEntity } = require('../../../packages/strapi-utils');
const { createAction } = require('./action');
const { createEventsManager } = require('./event');
const actionRegistry = require('./action-registry');
const events = {
MODEL_CREATED: 'model.created',
MODEL_DELETED: 'model.deleted',
FIXTURES_CREATED: 'fixture.created',
};
const stringifyDates = object =>
JSON.parse(
JSON.stringify(object, (key, value) => {
if (this[key] instanceof Date) {
return this[key].toUTCString();
}
return value;
})
);
const formatFixtures = map(stringifyDates);
/**
* Create a test builder from the given args.
*
* A builder allows to create resources (content-types, components, fixtures) that can be cleaned-up easily.
*
* @param options The builder's constructor options
* @param options.initialState {State} The builder's initial state
*
* @example ```
* // Setup the builder
* const builder = createTestBuilder()
* .addContentType(articleModel)
* .addComponent(myComponent);
*
* // Run the build operations
* await builder.build();
*
* // ...
*
* // Cleanup created resources
* await builder.cleanup();
* ```
*/
const createTestBuilder = (options = {}) => {
const { initialState } = options;
const omitActions = omit('actions');
const getDefaultState = () => ({ actions: [], models: [], fixtures: {}, ...initialState });
const state = getDefaultState();
const addAction = (code, ...params) => {
const action = createAction(code, ...params).setEmitter(_eventsManager);
_state.actions.push(action);
return action;
const action = get(code, actionRegistry);
state.actions.push(action(...params));
};
const getDefaultState = () => ({
actions: [],
deleted: [],
created: [],
fixtures: {},
...initialState,
});
const getModelsMap = (type = null) =>
_.difference(_state.created, _state.deleted)
// Keep the models with the wanted type (all, content-types, or components)
.filter(event => _.isNull(type) || _.get(event, 'metadata.type') === type)
// Flatten the data property to obtain an array of model
.flatMap(_.property('result'))
// Transform the array into a map where the key is the model's name
.reduce((acc, model) => _.merge(acc, { [model.modelName]: model }), {});
const _state = getDefaultState();
const _eventsManager = createEventsManager({ allowedEvents: Object.values(events) });
_eventsManager
.register(events.MODEL_CREATED, event => _state.created.push(event))
.register(events.MODEL_DELETED, event => _state.deleted.push(event))
.register(events.FIXTURES_CREATED, ({ result }) => {
const existingFixtures = _.get(_state.fixtures, result.model, []);
_state.fixtures[result.model] = existingFixtures.concat(formatFixtures(result.entries));
});
return {
get models() {
return getModelsMap();
},
get contentTypes() {
return getModelsMap('ct');
},
get components() {
return getModelsMap('comp');
return state.models;
},
get fixtures() {
return _state.fixtures;
return state.fixtures;
},
sanitizedFixtures(strapi) {
@ -135,73 +57,32 @@ const createTestBuilder = (options = {}) => {
return this;
},
addFixtures(model, entries, { onCreated = null } = {}) {
addAction('fixture.create', model, entries, () => this.fixtures);
if (_.isFunction(onCreated)) {
_eventsManager.register(events.FIXTURES_CREATED, e => {
const { result } = e;
// Filters fixture.created events triggered for other models
if (result.model === model) {
onCreated(formatFixtures(result.entries));
}
});
}
addFixtures(model, entries) {
addAction('fixtures.create', model, entries, () => this.fixtures);
return this;
},
/**
* Execute every registered step
*/
async build() {
for (const action of _state.actions) {
await action.execute();
for (const action of state.actions) {
const newState = await action.build(omitActions(state));
Object.assign(state, newState);
}
return this;
},
/**
* Cleanup and delete every model (content-types / components) created by the builder
*/
async cleanup() {
// The first model to be created should be the last one deleted
const createdEvents = _state.created.reverse();
async cleanup(options = {}) {
const { enableTestDataAutoCleanup = true } = options;
for (const event of createdEvents) {
const {
result,
metadata: { type },
} = event;
// Helpers for the given model
const isBatchOperation = _.isArray(result);
const modelType = type === 'ct' ? 'ContentType' : 'Component';
const pluralSuffix = isBatchOperation ? 's' : '';
// Pluralized & Type related methods name
const cleanupMethod = `cleanupModel${pluralSuffix}`;
const deleteMethod = `delete${modelType}${pluralSuffix}`;
// Format params
const uidAttribute = type === 'ct' ? 'modelName' : 'uid';
const params = isBatchOperation
? result.map(prop(uidAttribute))
: prop(uidAttribute, result);
// Execute cleanup & delete operations
await modelsUtils[cleanupMethod](params);
await modelsUtils[deleteMethod](params);
// Notify the builder that the created model has been deleted
_eventsManager.emit(events.MODEL_DELETED, event);
if (enableTestDataAutoCleanup) {
for (const model of state.models.reverse()) {
await modelsUtils.cleanupModel(model.uid || model.modelName);
}
}
for (const action of state.actions.reverse()) {
await action.cleanup();
}
return this;
},
};
};
module.exports = {
createTestBuilder,
};
module.exports = { createTestBuilder };

View File

@ -1,7 +1,6 @@
'use strict';
// eslint-disable-next-line node/no-extraneous-require
const { isFunction, isNil } = require('lodash/fp');
const { isFunction, isNil, prop } = require('lodash/fp');
const { createStrapiInstance } = require('./strapi');
const createHelpers = async ({ strapi: strapiInstance = null, ...options } = {}) => {
@ -172,6 +171,14 @@ async function createFixturesFor(model, entries, { strapi: strapiIst } = {}) {
return results;
}
async function deleteFixturesFor(model, entries, { strapi: strapiIst } = {}) {
const { strapi, cleanup } = await createHelpers({ strapi: strapiIst });
await strapi.query(model).delete({ id_in: entries.map(prop('id')) });
await cleanup();
}
async function modifyContentType(data, { strapi } = {}) {
const { contentTypeService, cleanup } = await createHelpers({ strapi });
@ -223,6 +230,7 @@ module.exports = {
// Fixtures
createFixtures,
createFixturesFor,
deleteFixturesFor,
// Update Content-Types
modifyContentType,
// Misc