diff --git a/jest.base-config.js b/jest.base-config.js index 17f346681b..ea542b67ee 100644 --- a/jest.base-config.js +++ b/jest.base-config.js @@ -2,6 +2,7 @@ module.exports = { rootDir: __dirname, setupFilesAfterEnv: ['/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 diff --git a/packages/core/admin/admin/src/content-manager/icons/Bold/index.js b/packages/core/admin/admin/src/content-manager/icons/Bold/index.js deleted file mode 100644 index b55992e6e2..0000000000 --- a/packages/core/admin/admin/src/content-manager/icons/Bold/index.js +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; - -const Bold = () => { - return ( - - - - B - - - - ); -}; - -export default Bold; diff --git a/packages/core/admin/admin/src/content-manager/icons/Code/index.js b/packages/core/admin/admin/src/content-manager/icons/Code/index.js deleted file mode 100644 index 4837b6fd2a..0000000000 --- a/packages/core/admin/admin/src/content-manager/icons/Code/index.js +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; - -const Code = () => { - return ( - - - - - - ); -}; - -export default Code; diff --git a/packages/core/admin/admin/src/content-manager/icons/Cross/index.js b/packages/core/admin/admin/src/content-manager/icons/Cross/index.js deleted file mode 100644 index 72e5c9fdd7..0000000000 --- a/packages/core/admin/admin/src/content-manager/icons/Cross/index.js +++ /dev/null @@ -1,28 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -const Cross = ({ fill, height, width, ...rest }) => { - return ( - - - - ); -}; - -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; diff --git a/packages/core/admin/admin/src/content-manager/icons/Italic/index.js b/packages/core/admin/admin/src/content-manager/icons/Italic/index.js deleted file mode 100644 index 9295a7f5ab..0000000000 --- a/packages/core/admin/admin/src/content-manager/icons/Italic/index.js +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react'; - -const Italic = () => { - return ( - - - - I - - - - ); -}; - -export default Italic; diff --git a/packages/core/admin/admin/src/content-manager/icons/Link/index.js b/packages/core/admin/admin/src/content-manager/icons/Link/index.js deleted file mode 100644 index 79b461a819..0000000000 --- a/packages/core/admin/admin/src/content-manager/icons/Link/index.js +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react'; - -const Link = () => { - return ( - - - - - - - - ); -}; - -export default Link; diff --git a/packages/core/admin/admin/src/content-manager/icons/Media/index.js b/packages/core/admin/admin/src/content-manager/icons/Media/index.js deleted file mode 100644 index 80841145a7..0000000000 --- a/packages/core/admin/admin/src/content-manager/icons/Media/index.js +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; - -const Media = () => { - return ( - - - - - - - ); -}; - -export default Media; diff --git a/packages/core/admin/admin/src/content-manager/icons/Na/index.js b/packages/core/admin/admin/src/content-manager/icons/Na/index.js deleted file mode 100644 index 90157de9e2..0000000000 --- a/packages/core/admin/admin/src/content-manager/icons/Na/index.js +++ /dev/null @@ -1,39 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -const Na = ({ fill, fontFamily, fontSize, fontWeight, height, textFill, width, ...rest }) => { - return ( - - - - - - N/A - - - - - ); -}; - -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; diff --git a/packages/core/admin/admin/src/content-manager/icons/Ol/index.js b/packages/core/admin/admin/src/content-manager/icons/Ol/index.js deleted file mode 100644 index 1fe01741ae..0000000000 --- a/packages/core/admin/admin/src/content-manager/icons/Ol/index.js +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; - -const Ol = () => { - return ( - - - - - - ); -}; - -export default Ol; diff --git a/packages/core/admin/admin/src/content-manager/icons/Quote/index.js b/packages/core/admin/admin/src/content-manager/icons/Quote/index.js deleted file mode 100644 index 830f5a75c6..0000000000 --- a/packages/core/admin/admin/src/content-manager/icons/Quote/index.js +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; - -const Quote = () => { - return ( - - - - - - ); -}; - -export default Quote; diff --git a/packages/core/admin/admin/src/content-manager/icons/Striked/index.js b/packages/core/admin/admin/src/content-manager/icons/Striked/index.js deleted file mode 100644 index 534e61cfc9..0000000000 --- a/packages/core/admin/admin/src/content-manager/icons/Striked/index.js +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; - -const Striked = () => { - return ( - - - - - abc - - - - - - ); -}; - -export default Striked; diff --git a/packages/core/admin/admin/src/content-manager/icons/Ul/index.js b/packages/core/admin/admin/src/content-manager/icons/Ul/index.js deleted file mode 100644 index 6a3284b2a2..0000000000 --- a/packages/core/admin/admin/src/content-manager/icons/Ul/index.js +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; - -const Ul = () => { - return ( - - - - - - - - ); -}; - -export default Ul; diff --git a/packages/core/admin/admin/src/content-manager/icons/Underline/index.js b/packages/core/admin/admin/src/content-manager/icons/Underline/index.js deleted file mode 100644 index ac02a6306e..0000000000 --- a/packages/core/admin/admin/src/content-manager/icons/Underline/index.js +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; - -const Underline = () => { - return ( - - - - U - - - - ); -}; - -export default Underline; diff --git a/packages/core/admin/admin/src/pages/HomePage/index.js b/packages/core/admin/admin/src/pages/HomePage/index.js index e6ab63e382..68a2188142 100644 --- a/packages/core/admin/admin/src/pages/HomePage/index.js +++ b/packages/core/admin/admin/src/pages/HomePage/index.js @@ -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(); diff --git a/packages/core/content-manager/server/tests/api/basic-dp-dz.test.api.js b/packages/core/content-manager/server/tests/api/basic-dp-dz.test.api.js index 6802a743f3..74ecb55b5b 100644 --- a/packages/core/content-manager/server/tests/api/basic-dp-dz.test.api.js +++ b/packages/core/content-manager/server/tests/api/basic-dp-dz.test.api.js @@ -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: [], + }, ], }, }, diff --git a/packages/core/content-manager/server/tests/api/basic-dz.test.api.js b/packages/core/content-manager/server/tests/api/basic-dz.test.api.js index a49a73ce6c..7ba785cde5 100644 --- a/packages/core/content-manager/server/tests/api/basic-dz.test.api.js +++ b/packages/core/content-manager/server/tests/api/basic-dz.test.api.js @@ -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: [], + }, ], }, }, diff --git a/packages/core/strapi/lib/services/entity-service/__tests__/entity-service-events.test.js b/packages/core/strapi/lib/services/entity-service/__tests__/entity-service-events.test.js index dca0c8ffdd..ae7c4e8570 100644 --- a/packages/core/strapi/lib/services/entity-service/__tests__/entity-service-events.test.js +++ b/packages/core/strapi/lib/services/entity-service/__tests__/entity-service-events.test.js @@ -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 () => { diff --git a/packages/core/strapi/lib/services/entity-service/__tests__/entity-service.test.js b/packages/core/strapi/lib/services/entity-service/__tests__/entity-service.test.js index 3866b8780a..3d713e2778 100644 --- a/packages/core/strapi/lib/services/entity-service/__tests__/entity-service.test.js +++ b/packages/core/strapi/lib/services/entity-service/__tests__/entity-service.test.js @@ -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` + ) + ); + }); }); }); }); diff --git a/packages/core/strapi/lib/services/__tests__/entity-validator.test.js b/packages/core/strapi/lib/services/entity-validator/__tests__/index.test.js similarity index 96% rename from packages/core/strapi/lib/services/__tests__/entity-validator.test.js rename to packages/core/strapi/lib/services/entity-validator/__tests__/index.test.js index 0111fdb4fb..720fb2e795 100644 --- a/packages/core/strapi/lib/services/__tests__/entity-validator.test.js +++ b/packages/core/strapi/lib/services/entity-validator/__tests__/index.test.js @@ -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: { diff --git a/packages/core/strapi/lib/services/entity-validator/__tests__/relations/attribute-level.test.js b/packages/core/strapi/lib/services/entity-validator/__tests__/relations/attribute-level.test.js new file mode 100644 index 0000000000..39041f972f --- /dev/null +++ b/packages/core/strapi/lib/services/entity-validator/__tests__/relations/attribute-level.test.js @@ -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); + }); + }); +}); diff --git a/packages/core/strapi/lib/services/entity-validator/__tests__/relations/component-level.test.js b/packages/core/strapi/lib/services/entity-validator/__tests__/relations/component-level.test.js new file mode 100644 index 0000000000..ac03644ea8 --- /dev/null +++ b/packages/core/strapi/lib/services/entity-validator/__tests__/relations/component-level.test.js @@ -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); + }); + }); + }); +}); diff --git a/packages/core/strapi/lib/services/entity-validator/__tests__/relations/dynamic-zone-level.test.js b/packages/core/strapi/lib/services/entity-validator/__tests__/relations/dynamic-zone-level.test.js new file mode 100644 index 0000000000..4e838838bc --- /dev/null +++ b/packages/core/strapi/lib/services/entity-validator/__tests__/relations/dynamic-zone-level.test.js @@ -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); + }); + }); +}); diff --git a/packages/core/strapi/lib/services/entity-validator/__tests__/relations/media-level.test.js b/packages/core/strapi/lib/services/entity-validator/__tests__/relations/media-level.test.js new file mode 100644 index 0000000000..0a08552027 --- /dev/null +++ b/packages/core/strapi/lib/services/entity-validator/__tests__/relations/media-level.test.js @@ -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); + }); +}); diff --git a/packages/core/strapi/lib/services/entity-validator/__tests__/relations/utils/relations.testdata.js b/packages/core/strapi/lib/services/entity-validator/__tests__/relations/utils/relations.testdata.js new file mode 100644 index 0000000000..1131c91150 --- /dev/null +++ b/packages/core/strapi/lib/services/entity-validator/__tests__/relations/utils/relations.testdata.js @@ -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], +}; diff --git a/packages/core/strapi/lib/services/entity-validator/index.js b/packages/core/strapi/lib/services/entity-validator/index.js index 9473366198..b9603146a7 100644 --- a/packages/core/strapi/lib/services/entity-validator/index.js +++ b/packages/core/strapi/lib/services/entity-validator/index.js @@ -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'), diff --git a/packages/core/strapi/tests/api/basic-dz.test.api.js b/packages/core/strapi/tests/api/basic-dz.test.api.js index 6791f4c8c2..868041c9be 100644 --- a/packages/core/strapi/tests/api/basic-dz.test.api.js +++ b/packages/core/strapi/tests/api/basic-dz.test.api.js @@ -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: [], + }, ], }, }, diff --git a/packages/core/strapi/tests/endpoint.test.api.js b/packages/core/strapi/tests/endpoint.test.api.js index 6739f2e977..c60d94c94c 100644 --- a/packages/core/strapi/tests/endpoint.test.api.js +++ b/packages/core/strapi/tests/endpoint.test.api.js @@ -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]; diff --git a/packages/core/upload/tests/admin/file-folder.test.api.js b/packages/core/upload/tests/admin/file-folder.test.api.js index 800b684a89..a39e02bd9b 100644 --- a/packages/core/upload/tests/admin/file-folder.test.api.js +++ b/packages/core/upload/tests/admin/file-folder.test.api.js @@ -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({