Merge branch 'chore/fix-iso-locales' of https://github.com/strapi/strapi into chore/fix-iso-locales

This commit is contained in:
ivanThePleasant 2022-11-15 11:24:21 +02:00
commit 2addba9fe3
28 changed files with 1327 additions and 332 deletions

View File

@ -2,6 +2,7 @@ module.exports = {
rootDir: __dirname,
setupFilesAfterEnv: ['<rootDir>/test/unit.setup.js'],
modulePathIgnorePatterns: ['.cache'],
testPathIgnorePatterns: ['.testdata.js'],
testMatch: ['/**/__tests__/**/*.[jt]s?(x)'],
// Use `jest-watch-typeahead` version 0.6.5. Newest version 1.0.0 does not support jest@26
// Reference: https://github.com/jest-community/jest-watch-typeahead/releases/tag/v1.0.0

View File

@ -1,22 +0,0 @@
import React from 'react';
const Bold = () => {
return (
<svg width="9" height="10" xmlns="http://www.w3.org/2000/svg">
<text
transform="translate(-12 -10)"
fill="#333740"
fillRule="evenodd"
fontSize="13"
fontFamily="Baskerville-SemiBold, Baskerville"
fontWeight="500"
>
<tspan x="12" y="20">
B
</tspan>
</text>
</svg>
);
};
export default Bold;

View File

@ -1,13 +0,0 @@
import React from 'react';
const Code = () => {
return (
<svg width="12" height="8" xmlns="http://www.w3.org/2000/svg">
<g fill="#333740" fillRule="evenodd">
<path d="M3.653 7.385a.632.632 0 0 1-.452-.191L.214 4.154a.66.66 0 0 1 0-.922L3.201.19a.632.632 0 0 1 .905 0 .66.66 0 0 1 0 .921l-2.534 2.58 2.534 2.58a.66.66 0 0 1 0 .922.632.632 0 0 1-.453.19zM8.347 7.385a.632.632 0 0 0 .452-.191l2.987-3.04a.66.66 0 0 0 0-.922L8.799.19a.632.632 0 0 0-.905 0 .66.66 0 0 0 0 .921l2.534 2.58-2.534 2.58a.66.66 0 0 0 0 .922c.125.127.289.19.453.19z" />
</g>
</svg>
);
};
export default Code;

View File

@ -1,28 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
const Cross = ({ fill, height, width, ...rest }) => {
return (
<svg {...rest} width={width} height={height} xmlns="http://www.w3.org/2000/svg">
<path
d="M7.78 6.72L5.06 4l2.72-2.72a.748.748 0 0 0 0-1.06.748.748 0 0 0-1.06 0L4 2.94 1.28.22a.748.748 0 0 0-1.06 0 .748.748 0 0 0 0 1.06L2.94 4 .22 6.72a.748.748 0 0 0 0 1.06.748.748 0 0 0 1.06 0L4 5.06l2.72 2.72a.748.748 0 0 0 1.06 0 .752.752 0 0 0 0-1.06z"
fill={fill}
fillRule="evenodd"
/>
</svg>
);
};
Cross.defaultProps = {
fill: '#b3b5b9',
height: '8',
width: '8',
};
Cross.propTypes = {
fill: PropTypes.string,
height: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
};
export default Cross;

View File

@ -1,23 +0,0 @@
import React from 'react';
const Italic = () => {
return (
<svg width="6" height="9" xmlns="http://www.w3.org/2000/svg">
<text
transform="translate(-13 -11)"
fill="#333740"
fillRule="evenodd"
fontWeight="500"
fontSize="13"
fontFamily="Baskerville-SemiBoldItalic, Baskerville"
fontStyle="italic"
>
<tspan x="13" y="20">
I
</tspan>
</text>
</svg>
);
};
export default Italic;

View File

@ -1,21 +0,0 @@
import React from 'react';
const Link = () => {
return (
<svg width="12" height="6" xmlns="http://www.w3.org/2000/svg">
<g fill="none" fillRule="evenodd">
<path d="M6.063 1.5H6h.063z" fill="#000" />
<path
d="M9.516 0H8s.813.531.988 1.5h.528c.55 0 .984.434.984.984v1c0 .55-.434 1.016-.984 1.016h-3.5A1.03 1.03 0 0 1 5 3.484V2.5H3.5v.984A2.518 2.518 0 0 0 6.016 6h3.5C10.896 6 12 4.866 12 3.484v-1A2.473 2.473 0 0 0 9.516 0z"
fill="#333740"
/>
<path
d="M8.3 1.5A2.473 2.473 0 0 0 6.016 0h-3.5C1.134 0 0 1.103 0 2.484v1A2.526 2.526 0 0 0 2.516 6H4s-.806-.531-1.003-1.5h-.481A1.03 1.03 0 0 1 1.5 3.484v-1c0-.55.466-.984 1.016-.984h3.5c.55 0 .984.434.984.984V3.5h1.5V2.484c0-.35-.072-.684-.2-.984z"
fill="#333740"
/>
</g>
</svg>
);
};
export default Link;

View File

@ -1,14 +0,0 @@
import React from 'react';
const Media = () => {
return (
<svg width="12" height="11" xmlns="http://www.w3.org/2000/svg">
<g fill="#333740" fillRule="evenodd">
<path d="M9 4.286a1.286 1.286 0 1 0 0-2.572 1.286 1.286 0 0 0 0 2.572z" />
<path d="M11.25 0H.75C.332 0 0 .34 0 .758v8.77c0 .418.332.758.75.758h10.5c.418 0 .75-.34.75-.758V.758A.752.752 0 0 0 11.25 0zM8.488 5.296a.46.46 0 0 0-.342-.167c-.137 0-.234.065-.343.153l-.501.423c-.105.075-.188.126-.308.126a.443.443 0 0 1-.295-.11 3.5 3.5 0 0 1-.115-.11L5.143 4.054a.59.59 0 0 0-.897.008L.857 8.148V1.171a.353.353 0 0 1 .351-.314h9.581a.34.34 0 0 1 .346.322l.008 6.975-2.655-2.858z" />
</g>
</svg>
);
};
export default Media;

View File

@ -1,39 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
const Na = ({ fill, fontFamily, fontSize, fontWeight, height, textFill, width, ...rest }) => {
return (
<svg {...rest} width={width} height={height} xmlns="http://www.w3.org/2000/svg">
<g fill="none" fillRule="evenodd">
<rect fill={fill} width={width} height={height} rx="17.5" />
<text fontFamily={fontFamily} fontSize={fontSize} fontWeight={fontWeight} fill={textFill}>
<tspan x="6" y="22">
N/A
</tspan>
</text>
</g>
</svg>
);
};
Na.defaultProps = {
fill: '#fafafb',
fontFamily: 'Lato-Medium, Lato',
fontSize: '12',
fontWeight: '400',
height: '35',
textFill: '#838383',
width: '35',
};
Na.propTypes = {
fill: PropTypes.string,
fontFamily: PropTypes.string,
fontSize: PropTypes.string,
fontWeight: PropTypes.string,
height: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
textFill: PropTypes.string,
width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
};
export default Na;

View File

@ -1,13 +0,0 @@
import React from 'react';
const Ol = () => {
return (
<svg width="12" height="8" xmlns="http://www.w3.org/2000/svg">
<g fill="#333740" fillRule="evenodd">
<path d="M2.4 3H.594v-.214h.137c.123 0 .212-.01.266-.032.053-.022.086-.052.1-.092a.67.67 0 0 0 .018-.188V.74a.46.46 0 0 0-.03-.194C1.064.504 1.021.476.955.46A1.437 1.437 0 0 0 .643.435H.539V.23c.332-.035.565-.067.7-.096.135-.03.258-.075.37-.134h.275v2.507c0 .104.023.177.07.218.047.04.14.061.278.061H2.4V3zM2.736 6.695l-.132.528h-.246a.261.261 0 0 0 .015-.074c0-.058-.049-.087-.146-.087H.293v-.198c.258-.173.511-.367.76-.581.25-.215.457-.437.623-.667.166-.23.249-.447.249-.653a.49.49 0 0 0-.321-.478.794.794 0 0 0-.582-.006.482.482 0 0 0-.196.138.284.284 0 0 0-.07.182c0 .074.04.17.12.289.006.008.009.015.009.02 0 .012-.041.03-.123.053l-.19.057a.693.693 0 0 1-.115.03c-.031 0-.067-.038-.108-.114a.516.516 0 0 1 .071-.586.899.899 0 0 1 .405-.238c.18-.058.4-.087.657-.087.317 0 .566.044.749.132.183.087.306.187.37.3a.64.64 0 0 1 .094.312c0 .197-.089.389-.266.575a5.296 5.296 0 0 1-.916.74 62.947 62.947 0 0 1-.62.413h1.843zM4 0h8v1H4zM4 2h8v1H4zM4 4h8v1H4zM4 6h8v1H4z" />
</g>
</svg>
);
};
export default Ol;

View File

@ -1,13 +0,0 @@
import React from 'react';
const Quote = () => {
return (
<svg width="9" height="9" xmlns="http://www.w3.org/2000/svg">
<g fill="#333740" fillRule="evenodd">
<path d="M3 0C2.047 0 1.301.263.782.782.263 1.302 0 2.047 0 3v6h3.75V3H1.5c0-.54.115-.93.343-1.157C2.07 1.615 2.46 1.5 3 1.5M8.25 0c-.953 0-1.699.263-2.218.782-.519.52-.782 1.265-.782 2.218v6H9V3H6.75c0-.54.115-.93.343-1.157.227-.228.617-.343 1.157-.343" />
</g>
</svg>
);
};
export default Quote;

View File

@ -1,24 +0,0 @@
import React from 'react';
const Striked = () => {
return (
<svg width="19" height="10" xmlns="http://www.w3.org/2000/svg">
<g fill="none" fillRule="evenodd">
<text
fontFamily="Lato-Semibold, Lato"
fontSize="11"
fontWeight="500"
fill="#41464E"
transform="translate(0 -2)"
>
<tspan x="1" y="11">
abc
</tspan>
</text>
<path d="M.5 6.5h18" stroke="#2C3039" strokeLinecap="square" />
</g>
</svg>
);
};
export default Striked;

View File

@ -1,15 +0,0 @@
import React from 'react';
const Ul = () => {
return (
<svg width="13" height="7" xmlns="http://www.w3.org/2000/svg">
<g fill="none" fillRule="evenodd">
<path fill="#333740" d="M5 0h8v1H5zM5 2h8v1H5zM5 4h8v1H5zM5 6h8v1H5z" />
<rect stroke="#333740" x=".5" y=".5" width="2" height="2" rx="1" />
<rect stroke="#333740" x=".5" y="4.5" width="2" height="2" rx="1" />
</g>
</svg>
);
};
export default Ul;

View File

@ -1,22 +0,0 @@
import React from 'react';
const Underline = () => {
return (
<svg width="10" height="10" xmlns="http://www.w3.org/2000/svg">
<text
transform="translate(-10 -11)"
fill="#101622"
fillRule="evenodd"
fontSize="13"
fontFamily="Baskerville-SemiBold, Baskerville"
fontWeight="500"
>
<tspan x="10" y="20">
U
</tspan>
</text>
</svg>
);
};
export default Underline;

View File

@ -32,7 +32,7 @@ const LogoContainer = styled(Box)`
`;
const HomePage = () => {
// // Temporary until we develop the menu API
// Temporary until we develop the menu API
const { collectionTypes, singleTypes, isLoading: isLoadingForModels } = useModels();
const { guidedTourState, isGuidedTourVisible, isSkipped } = useGuidedTour();

View File

@ -271,7 +271,7 @@ describe('CM API - Basic + dz + draftAndPublish', () => {
error: {
status: 400,
name: 'ValidationError',
message: 'dz[0].__component is a required field',
message: '2 errors occurred',
details: {
errors: [
{
@ -279,6 +279,11 @@ describe('CM API - Basic + dz + draftAndPublish', () => {
message: 'dz[0].__component is a required field',
name: 'ValidationError',
},
{
message: "Cannot read properties of undefined (reading 'attributes')",
name: 'ValidationError',
path: [],
},
],
},
},

View File

@ -301,7 +301,7 @@ describe('CM API - Basic + dz', () => {
error: {
status: 400,
name: 'ValidationError',
message: 'dz[0].__component is a required field',
message: '2 errors occurred',
details: {
errors: [
{
@ -309,6 +309,11 @@ describe('CM API - Basic + dz', () => {
message: 'dz[0].__component is a required field',
name: 'ValidationError',
},
{
message: "Cannot read properties of undefined (reading 'attributes')",
name: 'ValidationError',
path: [],
},
],
},
},

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

@ -3,6 +3,7 @@
jest.mock('bcryptjs', () => ({ hashSync: () => 'secret-password' }));
const { EventEmitter } = require('events');
const { ValidationError } = require('@strapi/utils').errors;
const createEntityService = require('..');
const entityValidator = require('../../entity-validator');
@ -81,50 +82,106 @@ describe('Entity service', () => {
describe('Create', () => {
describe('assign default values', () => {
let instance;
const entityUID = 'api::entity.entity';
const relationUID = 'api::relation.relation';
beforeAll(() => {
const fakeQuery = {
count: jest.fn(() => 0),
create: jest.fn(({ data }) => data),
};
const fakeModel = {
kind: 'contentType',
modelName: 'test-model',
privateAttributes: [],
options: {},
attributes: {
attrStringDefaultRequired: { type: 'string', default: 'default value', required: true },
attrStringDefault: { type: 'string', default: 'default value' },
attrBoolDefaultRequired: { type: 'boolean', default: true, required: true },
attrBoolDefault: { type: 'boolean', default: true },
attrIntDefaultRequired: { type: 'integer', default: 1, required: true },
attrIntDefault: { type: 'integer', default: 1 },
attrEnumDefaultRequired: {
type: 'enumeration',
enum: ['a', 'b', 'c'],
default: 'a',
required: true,
const fakeEntities = {
[relationUID]: {
1: {
id: 1,
Name: 'TestRelation',
createdAt: '2022-09-28T15:11:22.995Z',
updatedAt: '2022-09-29T09:01:02.949Z',
publishedAt: null,
},
attrEnumDefault: {
type: 'enumeration',
enum: ['a', 'b', 'c'],
default: 'b',
2: {
id: 2,
Name: 'TestRelation2',
createdAt: '2022-09-28T15:11:22.995Z',
updatedAt: '2022-09-29T09:01:02.949Z',
publishedAt: null,
},
attrPassword: { type: 'password' },
},
};
const fakeModels = {
[entityUID]: {
uid: entityUID,
kind: 'contentType',
modelName: 'test-model',
privateAttributes: [],
options: {},
attributes: {
attrStringDefaultRequired: {
type: 'string',
default: 'default value',
required: true,
},
attrStringDefault: { type: 'string', default: 'default value' },
attrBoolDefaultRequired: { type: 'boolean', default: true, required: true },
attrBoolDefault: { type: 'boolean', default: true },
attrIntDefaultRequired: { type: 'integer', default: 1, required: true },
attrIntDefault: { type: 'integer', default: 1 },
attrEnumDefaultRequired: {
type: 'enumeration',
enum: ['a', 'b', 'c'],
default: 'a',
required: true,
},
attrEnumDefault: {
type: 'enumeration',
enum: ['a', 'b', 'c'],
default: 'b',
},
attrPassword: { type: 'password' },
attrRelation: {
type: 'relation',
relation: 'oneToMany',
target: relationUID,
mappedBy: 'entity',
},
},
},
[relationUID]: {
uid: relationUID,
kind: 'contentType',
modelName: 'relation',
attributes: {
Name: {
type: 'string',
default: 'default value',
required: true,
},
},
},
};
const fakeQuery = (uid) => ({
create: jest.fn(({ data }) => data),
count: jest.fn(({ where }) => {
let ret = 0;
where.id.$in.forEach((id) => {
const entity = fakeEntities[uid][id];
if (!entity) return;
ret += 1;
});
return ret;
}),
});
const fakeDB = {
query: jest.fn(() => fakeQuery),
query: jest.fn((uid) => fakeQuery(uid)),
};
const fakeStrapi = {
getModel: jest.fn(() => fakeModel),
global.strapi = {
getModel: jest.fn((uid) => {
return fakeModels[uid];
}),
db: fakeDB,
};
instance = createEntityService({
strapi: fakeStrapi,
strapi: global.strapi,
db: fakeDB,
eventHub: new EventEmitter(),
entityValidator,
@ -134,7 +191,7 @@ describe('Entity service', () => {
test('should create record with all default attributes', async () => {
const data = {};
await expect(instance.create('test-model', { data })).resolves.toMatchObject({
await expect(instance.create(entityUID, { data })).resolves.toMatchObject({
attrStringDefaultRequired: 'default value',
attrStringDefault: 'default value',
attrBoolDefaultRequired: true,
@ -154,7 +211,7 @@ describe('Entity service', () => {
attrEnumDefault: 'c',
};
await expect(instance.create('test-model', { data })).resolves.toMatchObject({
await expect(instance.create(entityUID, { data })).resolves.toMatchObject({
attrStringDefault: 'my value',
attrBoolDefault: false,
attrIntDefault: 2,
@ -179,11 +236,225 @@ describe('Entity service', () => {
attrPassword: 'fooBar',
};
await expect(instance.create('test-model', { data })).resolves.toMatchObject({
await expect(instance.create(entityUID, { data })).resolves.toMatchObject({
...data,
attrPassword: 'secret-password',
});
});
test('should create record with valid relation', async () => {
const data = {
attrStringDefaultRequired: 'my value',
attrStringDefault: 'my value',
attrBoolDefaultRequired: true,
attrBoolDefault: true,
attrIntDefaultRequired: 10,
attrIntDefault: 10,
attrEnumDefaultRequired: 'c',
attrEnumDefault: 'a',
attrPassword: 'fooBar',
attrRelation: {
connect: [
{
id: 1,
},
],
},
};
const res = instance.create(entityUID, { data });
await expect(res).resolves.toMatchObject({
...data,
attrPassword: 'secret-password',
});
});
test('should fail to create a record with an invalid relation', async () => {
const data = {
attrStringDefaultRequired: 'my value',
attrStringDefault: 'my value',
attrBoolDefaultRequired: true,
attrBoolDefault: true,
attrIntDefaultRequired: 10,
attrIntDefault: 10,
attrEnumDefaultRequired: 'c',
attrEnumDefault: 'a',
attrPassword: 'fooBar',
attrRelation: {
connect: [
{
id: 3,
},
],
},
};
const res = instance.create(entityUID, { data });
await expect(res).rejects.toThrowError(
new ValidationError(
`1 relation(s) of type api::relation.relation associated with this entity do not exist`
)
);
});
});
});
describe('Update', () => {
describe('assign default values', () => {
let instance;
const entityUID = 'api::entity.entity';
const relationUID = 'api::relation.relation';
const fakeEntities = {
[entityUID]: {
0: {
id: 0,
Name: 'TestEntity',
createdAt: '2022-09-28T15:11:22.995Z',
updatedAt: '2022-09-29T09:01:02.949Z',
publishedAt: null,
},
},
[relationUID]: {
1: {
id: 1,
Name: 'TestRelation',
createdAt: '2022-09-28T15:11:22.995Z',
updatedAt: '2022-09-29T09:01:02.949Z',
publishedAt: null,
},
2: {
id: 2,
Name: 'TestRelation2',
createdAt: '2022-09-28T15:11:22.995Z',
updatedAt: '2022-09-29T09:01:02.949Z',
publishedAt: null,
},
},
};
const fakeModels = {
[entityUID]: {
kind: 'collectionType',
modelName: 'entity',
collectionName: 'entity',
uid: entityUID,
privateAttributes: [],
options: {},
info: {
singularName: 'entity',
pluralName: 'entities',
displayName: 'ENTITY',
},
attributes: {
Name: {
type: 'string',
},
addresses: {
type: 'relation',
relation: 'oneToMany',
target: relationUID,
mappedBy: 'entity',
},
},
},
[relationUID]: {
kind: 'contentType',
modelName: 'relation',
attributes: {
Name: {
type: 'string',
default: 'default value',
required: true,
},
},
},
};
beforeAll(() => {
const fakeQuery = (key) => ({
findOne: jest.fn(({ where }) => fakeEntities[key][where.id]),
count: jest.fn(({ where }) => {
let ret = 0;
where.id.$in.forEach((id) => {
const entity = fakeEntities[key][id];
if (!entity) return;
ret += 1;
});
return ret;
}),
update: jest.fn(({ where }) => ({
...fakeEntities[key][where.id],
addresses: {
count: 1,
},
})),
});
const fakeDB = {
query: jest.fn((key) => fakeQuery(key)),
};
global.strapi = {
getModel: jest.fn((uid) => {
return fakeModels[uid];
}),
db: fakeDB,
};
instance = createEntityService({
strapi: global.strapi,
db: fakeDB,
eventHub: new EventEmitter(),
entityValidator,
});
});
test(`should fail if the entity doesn't exist`, async () => {
expect(
await instance.update(entityUID, Math.random() * (10000 - 100) + 100, {})
).toBeNull();
});
test('should successfully update an existing relation', async () => {
const data = {
Name: 'TestEntry',
addresses: {
connect: [
{
id: 1,
},
],
},
};
expect(await instance.update(entityUID, 0, { data })).toMatchObject({
...fakeEntities[entityUID][0],
addresses: {
count: 1,
},
});
});
test('should throw an error when trying to associate a relation that does not exist', async () => {
const data = {
Name: 'TestEntry',
addresses: {
connect: [
{
id: 3,
},
],
},
};
const res = instance.update(entityUID, 0, { data });
await expect(res).rejects.toThrowError(
new ValidationError(
`1 relation(s) of type api::relation.relation associated with this entity do not exist`
)
);
});
});
});
});

View File

@ -1,18 +1,20 @@
'use strict';
const entityValidator = require('../entity-validator');
const entityValidator = require('..');
describe('Entity validator', () => {
describe('Published input', () => {
describe('General Errors', () => {
it('Throws a badRequest error on invalid input', async () => {
global.strapi = {
errors: {
badRequest: jest.fn(),
},
};
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',
@ -44,7 +46,7 @@ describe('Entity validator', () => {
});
it('Returns data on valid input', async () => {
const model = {
model = {
attributes: {
title: {
type: 'string',
@ -61,7 +63,7 @@ describe('Entity validator', () => {
});
it('Returns casted data when possible', async () => {
const model = {
model = {
attributes: {
title: {
type: 'string',
@ -84,13 +86,7 @@ describe('Entity validator', () => {
});
test('Throws on required not respected', async () => {
global.strapi = {
errors: {
badRequest: jest.fn(),
},
};
const model = {
model = {
attributes: {
title: {
type: 'string',
@ -139,7 +135,7 @@ describe('Entity validator', () => {
});
it('Supports custom field types', async () => {
const model = {
model = {
attributes: {
uuid: {
type: 'uuid',
@ -164,6 +160,7 @@ describe('Entity validator', () => {
errors: {
badRequest: jest.fn(),
},
getModel: () => model,
};
const model = {
@ -199,12 +196,6 @@ describe('Entity validator', () => {
});
test('Throws on max length not respected', async () => {
global.strapi = {
errors: {
badRequest: jest.fn(),
},
};
const model = {
attributes: {
title: {
@ -329,9 +320,11 @@ describe('Entity validator', () => {
errors: {
badRequest: jest.fn(),
},
getModel: () => model,
};
const model = {
uid: 'api::test.test',
attributes: {
title: {
type: 'string',
@ -456,6 +449,13 @@ describe('Entity validator', () => {
},
};
global.strapi = {
errors: {
badRequest: jest.fn(),
},
getModel: () => model,
};
const input = { title: 'tooSmall' };
expect.hasAssertions();
@ -465,12 +465,6 @@ describe('Entity validator', () => {
});
test('Throws on max length not respected', async () => {
global.strapi = {
errors: {
badRequest: jest.fn(),
},
};
const model = {
attributes: {
title: {

View File

@ -0,0 +1,123 @@
'use strict';
const { ValidationError } = require('@strapi/utils').errors;
const entityValidator = require('../..');
const { models, existentIDs, nonExistentIds } = require('./utils/relations.testdata');
/**
* Test that relations can be successfully validated and non existent relations
* can be detected at the Attribute level.
*/
describe('Entity validator | Relations | Attribute', () => {
const strapi = {
components: {
'basic.dev-compo': {},
},
db: {
query() {
return {
count: ({
where: {
id: { $in },
},
}) => existentIDs.filter((value) => $in.includes(value)).length,
};
},
},
errors: {
badRequest: jest.fn(),
},
getModel: (uid) => models.get(uid),
};
describe('Success', () => {
const testData = [
[
'Connect',
{
categories: {
disconnect: [],
connect: [
{
id: existentIDs[0],
},
],
},
},
],
[
'Set',
{
categories: {
set: [
{
id: existentIDs[0],
},
],
},
},
],
[
'Number',
{
categories: existentIDs[0],
},
],
[
'Array',
{
categories: existentIDs.slice(-Math.floor(existentIDs.length / 2)),
},
],
];
test.each(testData)('%s', async (__, input = {}) => {
global.strapi = strapi;
const res = entityValidator.validateEntityCreation(models.get('api::dev.dev'), input, {
isDraft: true,
});
await expect(res).resolves.not.toThrowError();
});
});
describe('Error', () => {
const expectError = new ValidationError(
`2 relation(s) of type api::category.category associated with this entity do not exist`
);
const testData = [
[
'Connect',
{
categories: {
disconnect: [],
connect: [existentIDs[0], ...nonExistentIds.slice(-2)].map((id) => ({
id,
})),
},
},
],
[
'Set',
{
categories: {
set: [existentIDs[0], ...nonExistentIds.slice(-2)].map((id) => ({ id })),
},
},
],
[
'Number',
{
categories: nonExistentIds.slice(-2),
},
],
];
test.each(testData)('%s', async (__, input = {}) => {
global.strapi = strapi;
const res = entityValidator.validateEntityCreation(models.get('api::dev.dev'), input, {
isDraft: true,
});
await expect(res).rejects.toThrowError(expectError);
});
});
});

View File

@ -0,0 +1,275 @@
'use strict';
const { ValidationError } = require('@strapi/utils').errors;
const entityValidator = require('../..');
const { models, nonExistentIds, existentIDs } = require('./utils/relations.testdata');
/**
* Test that relations can be successfully validated and non existent relations
* can be detected at the Component level.
*/
describe('Entity validator | Relations | Component Level', () => {
const strapi = {
components: {
'basic.dev-compo': {},
},
db: {
query() {
return {
count: ({
where: {
id: { $in },
},
}) => existentIDs.filter((value) => $in.includes(value)).length,
};
},
},
errors: {
badRequest: jest.fn(),
},
getModel: (uid) => models.get(uid),
};
describe('Single Component', () => {
describe('Success', () => {
const testData = [
[
'Connect',
{
sCom: {
categories: {
disconnect: [],
connect: [
{
id: existentIDs[0],
},
],
},
},
},
],
[
'Set',
{
sCom: {
categories: {
set: [
{
id: existentIDs[0],
},
],
},
},
},
],
[
'Number',
{
sCom: {
categories: existentIDs[0],
},
},
],
[
'Array',
{
sCom: {
categories: existentIDs.slice(-3),
},
},
],
];
test.each(testData)('%s', async (__, input = {}) => {
global.strapi = strapi;
const res = entityValidator.validateEntityCreation(models.get('api::dev.dev'), input, {
isDraft: true,
});
await expect(res).resolves.not.toThrowError();
});
});
describe('Error', () => {
const expectedError = new ValidationError(
`1 relation(s) of type api::category.category associated with this entity do not exist`
);
const testData = [
[
'Connect',
{
sCom: {
categories: {
disconnect: [],
connect: [
{
id: nonExistentIds[0],
},
],
},
},
},
],
[
'Set',
{
sCom: {
categories: {
set: [
{
id: nonExistentIds[0],
},
],
},
},
},
],
[
'Number',
{
sCom: {
categories: nonExistentIds[0],
},
},
],
[
'Array',
{
sCom: {
categories: [nonExistentIds[0]],
},
},
],
];
test.each(testData)('%s', async (__, input = {}) => {
global.strapi = strapi;
const res = entityValidator.validateEntityCreation(models.get('api::dev.dev'), input, {
isDraft: true,
});
await expect(res).rejects.toThrowError(expectedError);
});
});
});
describe('Repeatable Component', () => {
describe('Success', () => {
const testData = [
[
'Connect',
{
rCom: [
{
categories: {
disconnect: [],
connect: [
{
id: existentIDs[0],
},
],
},
},
],
},
],
[
'Set',
{
rCom: [
{
categories: {
set: existentIDs.slice(-Math.floor(existentIDs.length / 2)).map((id) => ({
id,
})),
},
},
],
},
],
[
'Number',
{
rCom: [
{
categories: existentIDs[0],
},
],
},
],
[
'Array',
{
rCom: [
{
categories: existentIDs.slice(-Math.floor(existentIDs.length / 2)),
},
],
},
],
];
test.each(testData)('%s', async (__, input = {}) => {
global.strapi = strapi;
const res = entityValidator.validateEntityCreation(models.get('api::dev.dev'), input, {
isDraft: true,
});
await expect(res).resolves.not.toThrowError();
});
});
describe('Error', () => {
const expectedError = new ValidationError(
`4 relation(s) of type api::category.category associated with this entity do not exist`
);
const testData = [
[
'Connect',
{
rCom: [
{
categories: {
disconnect: [],
connect: [existentIDs[0], ...nonExistentIds.slice(-4)].map((id) => ({
id,
})),
},
},
],
},
],
[
'Set',
{
rCom: [
{
categories: {
set: [existentIDs[0], ...nonExistentIds.slice(-4)].map((id) => ({
id,
})),
},
},
],
},
],
[
'Array',
{
rCom: [
{
categories: nonExistentIds.slice(-4),
},
],
},
],
];
test.each(testData)('%s', async (__, input = {}) => {
global.strapi = strapi;
const res = entityValidator.validateEntityCreation(models.get('api::dev.dev'), input, {
isDraft: true,
});
await expect(res).rejects.toThrowError(expectedError);
});
});
});
});

View File

@ -0,0 +1,159 @@
'use strict';
const { ValidationError } = require('@strapi/utils').errors;
const entityValidator = require('../..');
const { models, nonExistentIds, existentIDs } = require('./utils/relations.testdata');
/**
* Test that relations can be successfully validated and non existent relations
* can be detected at the Dynamic Zone level.
*/
describe('Entity validator | Relations | Dynamic Zone', () => {
const strapi = {
components: {
'basic.dev-compo': {},
},
db: {
query() {
return {
count: ({
where: {
id: { $in },
},
}) => existentIDs.filter((value) => $in.includes(value)).length,
};
},
},
errors: {
badRequest: jest.fn(),
},
getModel: (uid) => models.get(uid),
};
describe('Success', () => {
const testData = [
[
'Connect',
{
DZ: [
{
__component: 'basic.dev-compo',
categories: {
disconnect: [],
connect: existentIDs.slice(-3).map((id) => ({
id,
})),
},
},
],
},
],
[
'Set',
{
DZ: [
{
__component: 'basic.dev-compo',
categories: {
set: existentIDs.slice(-3).map((id) => ({
id,
})),
},
},
],
},
],
[
'Number',
{
DZ: [
{
__component: 'basic.dev-compo',
categories: existentIDs[0],
},
],
},
],
[
'Array',
{
DZ: [
{
__component: 'basic.dev-compo',
categories: existentIDs.slice(-3),
},
],
},
],
];
test.each(testData)('%s', async (__, input = {}) => {
global.strapi = strapi;
const res = entityValidator.validateEntityCreation(models.get('api::dev.dev'), input, {
isDraft: true,
});
await expect(res).resolves.not.toThrowError();
});
});
describe('Error', () => {
const expectedError = new ValidationError(
`2 relation(s) of type api::category.category associated with this entity do not exist`
);
const testData = [
[
'Connect',
{
DZ: [
{
__component: 'basic.dev-compo',
categories: {
disconnect: [],
connect: [existentIDs[0], ...nonExistentIds.slice(-2)].map((id) => ({
id,
})),
},
},
],
},
],
[
'Set',
{
DZ: [
{
__component: 'basic.dev-compo',
categories: {
set: [existentIDs[0], ...nonExistentIds.slice(-2)].map((id) => ({
id,
})),
},
},
],
},
],
[
'Array',
{
DZ: [
{
__component: 'basic.dev-compo',
categories: [existentIDs[0], ...nonExistentIds.slice(-2)].map((id) => ({
id,
})),
},
],
},
],
];
test.each(testData)('%s', async (__, input = {}) => {
global.strapi = strapi;
const res = entityValidator.validateEntityCreation(models.get('api::dev.dev'), input, {
isDraft: true,
});
await expect(res).rejects.toThrowError(expectedError);
});
});
});

View File

@ -0,0 +1,74 @@
'use strict';
const { ValidationError } = require('@strapi/utils').errors;
const entityValidator = require('../..');
const { models, existentIDs, nonExistentIds } = require('./utils/relations.testdata');
/**
* Test that relations can be successfully validated and non existent relations
* can be detected at the Media level.
*/
describe('Entity validator | Relations | Media', () => {
const strapi = {
components: {
'basic.dev-compo': {},
},
db: {
query() {
return {
count: ({
where: {
id: { $in },
},
}) => existentIDs.filter((value) => $in.includes(value)).length,
};
},
},
errors: {
badRequest: jest.fn(),
},
getModel: (uid) => models.get(uid),
};
it('Success', async () => {
global.strapi = strapi;
const input = {
media: [
{
id: existentIDs[0],
name: 'img.jpeg',
},
],
};
const res = entityValidator.validateEntityCreation(models.get('api::dev.dev'), input, {
isDraft: true,
});
await expect(res).resolves.not.toThrowError();
});
it('Error', async () => {
global.strapi = strapi;
const expectedError = new ValidationError(
`1 relation(s) of type plugin::upload.file associated with this entity do not exist`
);
const input = {
media: [
{
id: nonExistentIds[0],
name: 'img.jpeg',
},
{
id: existentIDs[0],
name: 'img.jpeg',
},
],
};
const res = entityValidator.validateEntityCreation(models.get('api::dev.dev'), input, {
isDraft: true,
});
await expect(res).rejects.toThrowError(expectedError);
});
});

View File

@ -0,0 +1,153 @@
'use strict';
const models = new Map();
models.set('api::dev.dev', {
kind: 'collectionType',
collectionName: 'devs',
modelType: 'contentType',
modelName: 'dev',
connection: 'default',
uid: 'api::dev.dev',
apiName: 'dev',
globalId: 'Dev',
info: {
singularName: 'dev',
pluralName: 'devs',
displayName: 'Dev',
description: '',
},
attributes: {
categories: {
type: 'relation',
relation: 'manyToMany',
target: 'api::category.category',
inversedBy: 'devs',
},
sCom: {
type: 'component',
repeatable: false,
component: 'basic.dev-compo',
},
rCom: {
type: 'component',
repeatable: true,
component: 'basic.dev-compo',
},
DZ: {
type: 'dynamiczone',
components: ['basic.dev-compo'],
},
media: {
allowedTypes: ['images', 'files', 'videos', 'audios'],
type: 'media',
multiple: true,
},
createdAt: {
type: 'datetime',
},
updatedAt: {
type: 'datetime',
},
publishedAt: {
type: 'datetime',
configurable: false,
writable: true,
visible: false,
},
createdBy: {
type: 'relation',
relation: 'oneToOne',
target: 'admin::user',
configurable: false,
writable: false,
visible: false,
useJoinTable: false,
private: true,
},
updatedBy: {
type: 'relation',
relation: 'oneToOne',
target: 'admin::user',
configurable: false,
writable: false,
visible: false,
useJoinTable: false,
private: true,
},
},
});
models.set('api::category.category', {
kind: 'collectionType',
collectionName: 'categories',
modelType: 'contentType',
modelName: 'category',
connection: 'default',
uid: 'api::category.category',
apiName: 'category',
globalId: 'Category',
info: {
displayName: 'Category',
singularName: 'category',
pluralName: 'categories',
description: '',
name: 'Category',
},
attributes: {
name: {
type: 'string',
pluginOptions: {
i18n: {
localized: true,
},
},
},
},
});
models.set('basic.dev-compo', {
collectionName: 'components_basic_dev_compos',
uid: 'basic.dev-compo',
category: 'basic',
modelType: 'component',
modelName: 'dev-compo',
globalId: 'ComponentBasicDevCompo',
info: {
displayName: 'DevCompo',
icon: 'allergies',
},
attributes: {
categories: {
type: 'relation',
relation: 'oneToMany',
target: 'api::category.category',
},
},
});
models.set('plugin::upload.file', {
collectionName: 'files',
info: {
singularName: 'file',
pluralName: 'files',
displayName: 'File',
description: '',
},
attributes: {
name: {
type: 'string',
configurable: false,
required: true,
},
},
kind: 'collectionType',
modelType: 'contentType',
modelName: 'file',
connection: 'default',
uid: 'plugin::upload.file',
plugin: 'upload',
globalId: 'UploadFile',
});
module.exports = {
models,
existentIDs: [1, 2, 3, 4, 5, 6],
nonExistentIds: [10, 11, 12, 13, 14, 15, 16],
};

View File

@ -5,7 +5,8 @@
'use strict';
const { has, assoc, prop, isObject } = require('lodash/fp');
const { uniqBy, castArray, isNil } = require('lodash');
const { has, assoc, prop, isObject, isEmpty, merge } = require('lodash/fp');
const strapiUtils = require('@strapi/utils');
const validators = require('./validators');
@ -222,10 +223,136 @@ const createValidateEntity =
entity,
},
{ isDraft }
).required();
)
.test('relations-test', 'check that all relations exist', async function (data) {
try {
await checkRelationsExist(buildRelationsStore({ uid: model.uid, data }));
} catch (e) {
return this.createError({
path: this.path,
message: e.message,
});
}
return true;
})
.required();
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
* @returns {Object}
*/
const buildRelationsStore = ({ uid, data }) => {
if (isEmpty(data)) {
return {};
}
const currentModel = strapi.getModel(uid);
return Object.keys(currentModel.attributes).reduce((result, attributeName) => {
const attribute = currentModel.attributes[attributeName];
const value = data[attributeName];
if (isNil(value)) {
return result;
}
switch (attribute.type) {
case 'relation':
case 'media': {
if (attribute.relation === 'morphToMany' || attribute.relation === 'morphToOne') {
// TODO: handle polymorphic relations
break;
}
const target = attribute.type === 'media' ? 'plugin::upload.file' : attribute.target;
// As there are multiple formats supported for associating relations
// with an entity, the value here can be an: array, object or number.
let source;
if (Array.isArray(value)) {
source = value;
} else if (isObject(value)) {
source = value.connect ?? value.set ?? [];
} else {
source = castArray(value);
}
const idArray = source.map((v) => ({ id: v.id || v }));
// Update the relationStore to keep track of all associations being made
// with relations and media.
result[target] = result[target] || [];
result[target].push(...idArray);
break;
}
case 'component': {
return castArray(value).reduce(
(relationsStore, componentValue) =>
merge(
relationsStore,
buildRelationsStore({
uid: attribute.component,
data: componentValue,
})
),
result
);
}
case 'dynamiczone': {
return value.reduce(
(relationsStore, dzValue) =>
merge(
relationsStore,
buildRelationsStore({
uid: dzValue.__component,
data: dzValue,
})
),
result
);
}
default:
break;
}
return result;
}, {});
};
/**
* 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`);
const count = await strapi.db.query(key).count({
where: {
id: {
$in: uniqueValues.map((v) => 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

@ -341,7 +341,7 @@ describe('Core API - Basic + dz', () => {
error: {
status: 400,
name: 'ValidationError',
message: 'dz[0].__component is a required field',
message: '2 errors occurred',
details: {
errors: [
{
@ -349,6 +349,11 @@ describe('Core API - Basic + dz', () => {
message: 'dz[0].__component is a required field',
name: 'ValidationError',
},
{
message: "Cannot read properties of undefined (reading 'attributes')",
name: 'ValidationError',
path: [],
},
],
},
},

View File

@ -143,6 +143,30 @@ describe('Create Strapi API End to End', () => {
expect(body.data.attributes.tags.data[0].id).toBe(data.tags[0].id);
});
test('Create article with non existent tag', async () => {
const entry = {
title: 'Article 3',
content: 'Content 3',
tags: [1000],
};
const res = await rq({
url: '/articles',
method: 'POST',
body: {
data: entry,
},
qs: {
populate: ['tags'],
},
});
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.error.text).error.message).toContain(
`1 relation(s) of type api::tag.tag associated with this entity do not exist`
);
});
test('Update article1 add tag2', async () => {
const { id, attributes } = data.articles[0];
const entry = { ...attributes, tags: [data.tags[1].id] };
@ -197,6 +221,30 @@ describe('Create Strapi API End to End', () => {
expect(body.data.attributes.tags.data.length).toBe(3);
});
test('Error when updating article1 with some non existent tags', async () => {
const { id, attributes } = data.articles[0];
const entry = { ...attributes };
entry.tags = [1000, 1001, 1002, ...data.tags.slice(-1).map((t) => t.id)];
cleanDate(entry);
const res = await rq({
url: `/articles/${id}`,
method: 'PUT',
body: {
data: entry,
},
qs: {
populate: ['tags'],
},
});
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.error.text).error.message).toContain(
`3 relation(s) of type api::tag.tag associated with this entity do not exist`
);
});
test('Update article1 remove one tag', async () => {
const { id, attributes } = data.articles[0];

View File

@ -209,6 +209,7 @@ describe('File', () => {
data.files[1] = file;
});
});
describe('Move a file from root level to a folder', () => {
test('when replacing the file', async () => {
const res = await rq({