mirror of
https://github.com/strapi/strapi.git
synced 2025-08-01 13:29:01 +00:00
feat(strapi): entity validator
This commit is contained in:
parent
d0958eec24
commit
00d6962b5e
@ -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: {
|
||||
|
@ -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 () => {
|
||||
|
@ -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],
|
||||
|
@ -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'),
|
||||
|
@ -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`
|
||||
);
|
||||
});
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user