feat(strapi): entity validator

This commit is contained in:
Jamie Howard 2022-10-24 12:59:38 +01:00
parent d0958eec24
commit 00d6962b5e
5 changed files with 143 additions and 167 deletions

View File

@ -5,15 +5,16 @@ const entityValidator = require('../entity-validator');
describe('Entity validator', () => {
describe('Published input', () => {
describe('General Errors', () => {
it('Throws a badRequest error on invalid input', async () => {
global.strapi = {
errors: {
badRequest: jest.fn(),
},
getModel: () => ({}),
};
let model;
global.strapi = {
errors: {
badRequest: jest.fn(),
},
getModel: () => model,
};
const model = {
it('Throws a badRequest error on invalid input', async () => {
model = {
attributes: {
title: {
type: 'string',
@ -45,7 +46,7 @@ describe('Entity validator', () => {
});
it('Returns data on valid input', async () => {
const model = {
model = {
attributes: {
title: {
type: 'string',
@ -62,7 +63,7 @@ describe('Entity validator', () => {
});
it('Returns casted data when possible', async () => {
const model = {
model = {
attributes: {
title: {
type: 'string',
@ -85,14 +86,7 @@ describe('Entity validator', () => {
});
test('Throws on required not respected', async () => {
global.strapi = {
errors: {
badRequest: jest.fn(),
},
getModel: () => ({}),
};
const model = {
model = {
attributes: {
title: {
type: 'string',
@ -141,7 +135,7 @@ describe('Entity validator', () => {
});
it('Supports custom field types', async () => {
const model = {
model = {
attributes: {
uuid: {
type: 'uuid',
@ -166,7 +160,7 @@ describe('Entity validator', () => {
errors: {
badRequest: jest.fn(),
},
getModel: () => ({}),
getModel: () => model,
};
const model = {
@ -202,13 +196,6 @@ describe('Entity validator', () => {
});
test('Throws on max length not respected', async () => {
global.strapi = {
errors: {
badRequest: jest.fn(),
},
getModel: () => ({}),
};
const model = {
attributes: {
title: {
@ -333,7 +320,7 @@ describe('Entity validator', () => {
errors: {
badRequest: jest.fn(),
},
getModel: () => ({}),
getModel: () => model,
};
const model = {
@ -461,6 +448,13 @@ describe('Entity validator', () => {
},
};
global.strapi = {
errors: {
badRequest: jest.fn(),
},
getModel: () => model,
};
const input = { title: 'tooSmall' };
expect.hasAssertions();
@ -470,13 +464,6 @@ describe('Entity validator', () => {
});
test('Throws on max length not respected', async () => {
global.strapi = {
errors: {
badRequest: jest.fn(),
},
getModel: () => ({}),
};
const model = {
attributes: {
title: {

View File

@ -4,28 +4,22 @@ const createEntityService = require('..');
const entityValidator = require('../../entity-validator');
describe('Entity service triggers webhooks', () => {
global.strapi = {
getModel: () => ({}),
config: {
get: () => [],
},
};
let instance;
const eventHub = { emit: jest.fn() };
let entity = { attr: 'value' };
beforeAll(() => {
const model = {
kind: 'singleType',
modelName: 'test-model',
privateAttributes: [],
attributes: {
attr: { type: 'string' },
},
};
instance = createEntityService({
strapi: {
getModel: () => ({
kind: 'singleType',
modelName: 'test-model',
privateAttributes: [],
attributes: {
attr: { type: 'string' },
},
}),
getModel: () => model,
},
db: {
query: () => ({
@ -41,6 +35,13 @@ describe('Entity service triggers webhooks', () => {
eventHub,
entityValidator,
});
global.strapi = {
getModel: () => model,
config: {
get: () => [],
},
};
});
test('Emit event: Create', async () => {

View File

@ -157,16 +157,15 @@ describe('Entity service', () => {
},
};
const fakeQuery = (uid) => ({
count: jest.fn(() => 0),
create: jest.fn(({ data }) => data),
findWithCount: jest.fn(({ where }) => {
const ret = [];
count: jest.fn(({ where }) => {
let ret = 0;
where.id.$in.forEach((id) => {
const entity = fakeEntities[uid][id];
if (!entity) return;
ret.push(entity);
ret += 1;
});
return [ret, ret.length];
return ret;
}),
});
@ -372,14 +371,14 @@ describe('Entity service', () => {
beforeAll(() => {
const fakeQuery = (key) => ({
findOne: jest.fn(({ where }) => fakeEntities[key][where.id]),
findWithCount: jest.fn(({ where }) => {
const ret = [];
count: jest.fn(({ where }) => {
let ret = 0;
where.id.$in.forEach((id) => {
const entity = fakeEntities[key][id];
if (!entity) return;
ret.push(entity);
ret += 1;
});
return [ret, ret.length];
return ret;
}),
update: jest.fn(({ where }) => ({
...fakeEntities[key][where.id],

View File

@ -205,107 +205,6 @@ const createModelValidator =
return yup.object().shape(schema);
};
/**
* Builds a map containing all the media and relations being associated with an entity
* @param {String} uid of the model
* @param {Object} data
* @param {Map} relationsMap to be updated and returned
* @returns
*/
const buildRelationsMap = (uid, data, relationsMap = new Map()) => {
const currentModel = strapi.getModel(uid);
if (isEmpty(currentModel)) return;
Object.keys(currentModel.attributes).forEach((attributeName) => {
const attribute = currentModel.attributes[attributeName];
const value = data[attributeName];
if (isEmpty(value) || isNil(value)) {
return;
}
switch (attribute.type) {
case 'relation': {
if (!attribute.target) {
break;
}
// If the attribute type is a relation keep track of all
// associations being made with relations. These will later be checked
// against the DB to confirm they exist
let directValue = [];
if (Array.isArray(value)) {
directValue = value.map((v) => ({ id: v }));
}
relationsMap.set(
attribute.target,
(relationsMap.get(attribute.target) || []).concat(
...(value.connect || value.set || directValue)
)
);
break;
}
case 'media': {
// For media attribute types keep track of all media associated with
// this entity. These will later be checked against the DB to confirm
// they exist
const mediaUID = 'plugin::upload.file';
castArray(value).forEach((v) =>
relationsMap.set(mediaUID, [...(relationsMap.get(mediaUID) || []), { id: v.id || v }])
);
break;
}
case 'component': {
return castArray(value).forEach((componentValue) =>
buildRelationsMap(attribute.component, componentValue, relationsMap)
);
}
case 'dynamiczone': {
return value.forEach((dzValue) =>
buildRelationsMap(dzValue.__component, dzValue, relationsMap)
);
}
default:
break;
}
});
return relationsMap;
};
/**
* Iterate through the relations map and validate that every relation or media
* mentioned exists
*/
const checkRelationsExist = async (relationsMap = new Map()) => {
const promises = [];
for (const [key, value] of relationsMap) {
const evaluate = async () => {
const uniqueValues = uniqBy(value, `id`);
// eslint-disable-next-line no-unused-vars
const [entities, count] = await strapi.db.query(key).findWithCount({
where: {
id: {
$in: uniqueValues.map((v) => Number(v.id)),
},
},
});
if (count !== uniqueValues.length) {
const missingEntities = uniqueValues.filter(
(value) => !entities.find((entity) => entity.id === value.id)
);
throw new ValidationError(
`Relations of type ${key} associated with this entity do not exist. IDs: ${missingEntities
.map((entity) => entity.id)
.join(',')}`
);
}
};
promises.push(evaluate());
}
return Promise.all(promises);
};
const createValidateEntity =
(createOrUpdate) =>
async (model, data, { isDraft = false } = {}, entity = null) => {
@ -327,7 +226,7 @@ const createValidateEntity =
)
.test('relations-test', 'check that all relations exist', async function (data) {
try {
await checkRelationsExist(buildRelationsMap(model.uid, data) || new Map());
await checkRelationsExist(buildRelationsStore(model.uid, data) || {});
} catch (e) {
return this.createError({
path: this.path,
@ -341,6 +240,100 @@ const createValidateEntity =
return validateYupSchema(validator, { strict: false, abortEarly: false })(data);
};
/**
* Builds an object containing all the media and relations being associated with an entity
* @param {String} uid of the model
* @param {Object} data
* @param {Object} relationsStore to be updated and returned
* @returns
*/
const buildRelationsStore = (uid, data, relationsStore = {}) => {
const currentModel = strapi.getModel(uid);
Object.keys(currentModel?.attributes || {}).forEach((attributeName) => {
const attribute = currentModel.attributes[attributeName];
const value = data[attributeName];
if (isEmpty(value) || isNil(value)) {
return;
}
switch (attribute.type) {
case 'relation': {
if (!attribute.target) {
return;
}
// If the attribute type is a relation keep track of all
// associations being made with relations.
let directValue = [];
if (Array.isArray(value)) {
directValue = value.map((v) => ({ id: v }));
}
relationsStore[attribute.target] = relationsStore[attribute.target] || [];
relationsStore[attribute.target].push(...(value.connect || value.set || directValue));
break;
}
case 'media': {
// For media attribute types keep track of all media associated with
// this entity.
const mediaUID = 'plugin::upload.file';
castArray(value).forEach((v) => {
relationsStore[mediaUID] = relationsStore[mediaUID] || [];
relationsStore[mediaUID].push({ id: v.id || v });
});
break;
}
case 'component': {
return castArray(value).forEach((componentValue) =>
buildRelationsStore(attribute.component, componentValue, relationsStore)
);
}
case 'dynamiczone': {
return value.forEach((dzValue) =>
buildRelationsStore(dzValue.__component, dzValue, relationsStore)
);
}
default:
break;
}
});
return relationsStore;
};
/**
* Iterate through the relations store and validates that every relation or media
* mentioned exists
*/
const checkRelationsExist = async (relationsStore = {}) => {
const promises = [];
for (const [key, value] of Object.entries(relationsStore)) {
const evaluate = async () => {
const uniqueValues = uniqBy(value, `id`);
// eslint-disable-next-line no-unused-vars
const count = await strapi.db.query(key).count({
where: {
id: {
$in: uniqueValues.map((v) => Number(v.id)),
},
},
});
if (count !== uniqueValues.length) {
throw new ValidationError(
`${
uniqueValues.length - count
} relation(s) of type ${key} associated with this entity do not exist`
);
}
};
promises.push(evaluate());
}
return Promise.all(promises);
};
module.exports = {
validateEntityCreation: createValidateEntity('creation'),
validateEntityUpdate: createValidateEntity('update'),

View File

@ -163,9 +163,7 @@ describe('Create Strapi API End to End', () => {
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.error.text).error.message).toContain(
`Relations of type api::tag.tag associated with this entity do not exist. IDs: ${entry.tags.join(
','
)}`
`1 relation(s) of type api::tag.tag associated with this entity do not exist`
);
});
@ -243,9 +241,7 @@ describe('Create Strapi API End to End', () => {
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.error.text).error.message).toContain(
`Relations of type api::tag.tag associated with this entity do not exist. IDs: ${entry.tags.join(
','
)}`
`3 relation(s) of type api::tag.tag associated with this entity do not exist`
);
});