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/content-manager/server/tests/content-manager/relations.test.api.js b/packages/core/content-manager/server/tests/content-manager/relations.test.api.js deleted file mode 100644 index cc7dbadcde..0000000000 --- a/packages/core/content-manager/server/tests/content-manager/relations.test.api.js +++ /dev/null @@ -1,75 +0,0 @@ -'use strict'; - -// Helpers. -const { createTestBuilder } = require('../../../../../../test/helpers/builder'); -const { createStrapiInstance } = require('../../../../../../test/helpers/strapi'); -const form = require('../../../../../../test/helpers/generators'); -const { createAuthRequest } = require('../../../../../../test/helpers/request'); - -const builder = createTestBuilder(); -let strapi; -let rq; - -const restart = async () => { - await strapi.destroy(); - strapi = await createStrapiInstance(); - rq = await createAuthRequest({ strapi }); -}; - -describe('Content Manager - Hide relations', () => { - beforeAll(async () => { - await builder.addContentTypes([form.article]).build(); - - strapi = await createStrapiInstance(); - rq = await createAuthRequest({ strapi }); - }); - - afterAll(async () => { - await strapi.destroy(); - await builder.cleanup(); - }); - - test('Hide relations', async () => { - await rq({ - url: '/content-manager/content-types/api::article.article/configuration', - method: 'PUT', - body: { - layouts: { - edit: [], - editRelations: [], - list: [], - }, - }, - }); - - const { body } = await rq({ - url: '/content-manager/content-types/api::article.article/configuration', - method: 'GET', - }); - - expect(body.data.contentType.layouts.editRelations).toStrictEqual([]); - }); - - test('Hide relations after server restart', async () => { - await rq({ - url: '/content-manager/content-types/api::article.article/configuration', - method: 'PUT', - body: { - layouts: { - edit: [], - editRelations: [], - list: [], - }, - }, - }); - - await restart(); - - const { body } = await rq({ - url: '/content-manager/content-types/api::article.article/configuration', - method: 'GET', - }); - - expect(body.data.contentType.layouts.editRelations).toStrictEqual([]); - }); -}); diff --git a/packages/core/content-manager/server/tests/relation-list.test.api.js b/packages/core/content-manager/server/tests/relation-list.test.api.js deleted file mode 100644 index ef0e7c65af..0000000000 --- a/packages/core/content-manager/server/tests/relation-list.test.api.js +++ /dev/null @@ -1,196 +0,0 @@ -'use strict'; - -// Test a simple default API with no relations - -const { omit, pick } = require('lodash/fp'); - -const { createTestBuilder } = require('../../../../../test/helpers/builder'); -const { createStrapiInstance } = require('../../../../../test/helpers/strapi'); -const { createAuthRequest } = require('../../../../../test/helpers/request'); - -let strapi; -let rq; -const data = { - products: [], - shops: [], -}; - -const productModel = { - attributes: { - name: { - type: 'string', - }, - }, - displayName: 'Product', - singularName: 'product', - pluralName: 'products', - description: '', - collectionName: '', -}; - -const productWithDPModel = { - attributes: { - name: { - type: 'string', - }, - }, - displayName: 'Product', - singularName: 'product', - pluralName: 'products', - draftAndPublish: true, - description: '', - collectionName: '', -}; - -const shopModel = { - attributes: { - name: { - type: 'string', - }, - products: { - type: 'relation', - relation: 'manyToMany', - target: 'api::product.product', - targetAttribute: 'shops', - }, - }, - displayName: 'Shop', - singularName: 'shop', - pluralName: 'shops', -}; - -const shops = [ - { - name: 'market', - }, -]; - -const products = - ({ withPublished = false }) => - ({ shop }) => { - const shops = [shop[0].id]; - - const entries = [ - { - name: 'tomato', - shops, - publishedAt: new Date(), - }, - { - name: 'apple', - shops, - publishedAt: null, - }, - ]; - - if (withPublished) { - return entries; - } - - return entries.map(omit('publishedAt')); - }; - -describe('Relation-list route', () => { - describe('without draftAndPublish', () => { - const builder = createTestBuilder(); - - beforeAll(async () => { - await builder - .addContentTypes([productModel, shopModel]) - .addFixtures(shopModel.singularName, shops) - .addFixtures(productModel.singularName, products({ withPublished: false })) - .build(); - - strapi = await createStrapiInstance(); - rq = await createAuthRequest({ strapi }); - - data.shops = await builder.sanitizedFixturesFor(shopModel.singularName, strapi); - data.products = await builder.sanitizedFixturesFor(productModel.singularName, strapi); - }); - - afterAll(async () => { - await strapi.destroy(); - await builder.cleanup(); - }); - - test('Can get relation-list for products of a shop', async () => { - const res = await rq({ - method: 'POST', - url: '/content-manager/relations/api::shop.shop/products', - }); - - expect(res.body).toHaveLength(data.products.length); - data.products.forEach((product, index) => { - expect(res.body[index]).toStrictEqual(pick(['_id', 'id', 'name'], product)); - }); - }); - - test('Can get relation-list for products of a shop and omit some results', async () => { - const res = await rq({ - method: 'POST', - url: '/content-manager/relations/api::shop.shop/products', - body: { - idsToOmit: [data.products[0].id], - }, - }); - - expect(res.body).toHaveLength(1); - expect(res.body[0]).toStrictEqual(pick(['_id', 'id', 'name'], data.products[1])); - }); - }); - - describe('with draftAndPublish', () => { - const builder = createTestBuilder(); - - beforeAll(async () => { - await builder - .addContentTypes([productWithDPModel, shopModel]) - .addFixtures(shopModel.singularName, shops) - .addFixtures(productWithDPModel.singularName, products({ withPublished: true })) - .build(); - - strapi = await createStrapiInstance(); - rq = await createAuthRequest({ strapi }); - - data.shops = await builder.sanitizedFixturesFor(shopModel.singularName, strapi); - data.products = await builder.sanitizedFixturesFor(productWithDPModel.singularName, strapi); - }); - - afterAll(async () => { - await strapi.destroy(); - await builder.cleanup(); - }); - - test('Can get relation-list for products of a shop', async () => { - const res = await rq({ - method: 'POST', - url: '/content-manager/relations/api::shop.shop/products', - }); - - expect(res.body).toHaveLength(data.products.length); - - const tomatoProductRes = res.body.find((p) => p.name === 'tomato'); - const appleProductRes = res.body.find((p) => p.name === 'apple'); - - expect(tomatoProductRes).toMatchObject(pick(['_id', 'id', 'name'], data.products[0])); - expect(tomatoProductRes.publishedAt).toBeISODate(); - expect(appleProductRes).toStrictEqual({ - ...pick(['_id', 'id', 'name'], data.products[1]), - publishedAt: null, - }); - }); - - test('Can get relation-list for products of a shop and omit some results', async () => { - const res = await rq({ - method: 'POST', - url: '/content-manager/relations/api::shop.shop/products', - body: { - idsToOmit: [data.products[1].id], - }, - }); - - expect(res.body).toHaveLength(1); - expect(res.body[0]).toMatchObject(pick(['_id', 'id', 'name'], data.products[0])); - }); - }); -}); 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({ diff --git a/packages/plugins/users-permissions/server/controllers/validation/user.js b/packages/plugins/users-permissions/server/controllers/validation/user.js index e0407f138f..d62f3f77bd 100644 --- a/packages/plugins/users-permissions/server/controllers/validation/user.js +++ b/packages/plugins/users-permissions/server/controllers/validation/user.js @@ -18,7 +18,7 @@ const createUserBodySchema = yup.object().shape({ connect: yup .array() .of(yup.object().shape({ id: yup.strapiID().required() })) - .min(1) + .min(1, 'Users must have a role') .required(), }) .required() @@ -36,7 +36,16 @@ const updateUserBodySchema = yup.object().shape({ connect: yup .array() .of(yup.object().shape({ id: yup.strapiID().required() })) - .min(1) + .required(), + disconnect: yup + .array() + .test('CheckDisconnect', 'Cannot remove role', function test(disconnectValue) { + if (value.connect.length === 0 && disconnectValue.length > 0) { + return false; + } + + return true; + }) .required(), }) : yup.strapiID()