From cc4b360d47e34d8227a16601a5afd02d8f76e21e Mon Sep 17 00:00:00 2001 From: Alexandre Bodin Date: Tue, 5 Sep 2023 22:10:20 +0200 Subject: [PATCH] Entity service tests --- packages/core/strapi/src/Strapi.ts | 8 +- ....test.js => entity-service-events.test.ts} | 30 ++- ...service.test.js => entity-service.test.ts} | 185 +++++++++++------- .../src/services/entity-service/index.ts | 20 +- .../src/services/entity-validator/index.ts | 24 +-- .../src/types/core/attributes/enumeration.ts | 2 +- .../strapi/src/types/core/attributes/media.ts | 2 +- .../strapi/src/types/core/schemas/index.ts | 4 + packages/core/utils/src/content-types.ts | 4 +- packages/core/utils/src/types.ts | 6 +- 10 files changed, 171 insertions(+), 114 deletions(-) rename packages/core/strapi/src/services/entity-service/__tests__/{entity-service-events.test.js => entity-service-events.test.ts} (78%) rename packages/core/strapi/src/services/entity-service/__tests__/{entity-service.test.js => entity-service.test.ts} (78%) diff --git a/packages/core/strapi/src/Strapi.ts b/packages/core/strapi/src/Strapi.ts index 9f9659521a..609fdb7b33 100644 --- a/packages/core/strapi/src/Strapi.ts +++ b/packages/core/strapi/src/Strapi.ts @@ -53,7 +53,7 @@ import convertCustomFieldType from './utils/convert-custom-field-type'; // TODO: move somewhere else import * as draftAndPublishSync from './migrations/draft-publish'; -import type { Common, Shared } from './types'; +import type { Common, Schema, Shared } from './types'; /** * Resolve the working directories based on the instance options. @@ -635,7 +635,11 @@ class Strapi { } } - getModel(uid: string) { + getModel(uid: Common.UID.ContentType): Schema.ContentType; + getModel(uid: Common.UID.Component): Schema.Component; + getModel( + uid: Common.UID.Component | Common.UID.ContentType + ): Schema.Component | Schema.ContentType { return ( this.contentTypes[uid as Common.UID.ContentType] || this.components[uid as Common.UID.Component] diff --git a/packages/core/strapi/src/services/entity-service/__tests__/entity-service-events.test.js b/packages/core/strapi/src/services/entity-service/__tests__/entity-service-events.test.ts similarity index 78% rename from packages/core/strapi/src/services/entity-service/__tests__/entity-service-events.test.js rename to packages/core/strapi/src/services/entity-service/__tests__/entity-service-events.test.ts index 59b6ece954..0c41b597bd 100644 --- a/packages/core/strapi/src/services/entity-service/__tests__/entity-service-events.test.js +++ b/packages/core/strapi/src/services/entity-service/__tests__/entity-service-events.test.ts @@ -1,12 +1,10 @@ -'use strict'; - -const createEntityService = require('..'); -const entityValidator = require('../../entity-validator'); +import createEntityService from '..'; +import entityValidator from '../../entity-validator'; describe('Entity service triggers webhooks', () => { - let instance; + let instance: any; const eventHub = { emit: jest.fn() }; - let entity = { attr: 'value' }; + let entity: unknown = { attr: 'value' }; beforeAll(() => { const model = { @@ -25,11 +23,11 @@ describe('Entity service triggers webhooks', () => { }, }, db: { - transaction: (cb) => cb(), + transaction: (cb: any) => cb(), query: () => ({ count: () => 0, - create: ({ data }) => data, - update: ({ data }) => data, + create: ({ data }: any) => data, + update: ({ data }: any) => data, findOne: () => entity, findMany: () => [entity, entity], delete: () => ({}), @@ -38,16 +36,16 @@ describe('Entity service triggers webhooks', () => { }, eventHub, entityValidator, - }); + } as any); global.strapi = { getModel: () => model, - }; + } as any; }); test('Emit event: Create', async () => { // Create entity - await instance.create('test-model', { data: entity }); + await instance.create('api::test.test-model', { data: entity }); // Expect entry.create event to be emitted expect(eventHub.emit).toHaveBeenCalledWith('entry.create', { @@ -61,7 +59,7 @@ describe('Entity service triggers webhooks', () => { test('Emit event: Update', async () => { // Update entity - await instance.update('test-model', 'entity-id', { data: entity }); + await instance.update('api::test.test-model', 'entity-id', { data: entity }); // Expect entry.update event to be emitted expect(eventHub.emit).toHaveBeenCalledWith('entry.update', { @@ -75,7 +73,7 @@ describe('Entity service triggers webhooks', () => { test('Emit event: Delete', async () => { // Delete entity - await instance.delete('test-model', 'entity-id', {}); + await instance.delete('api::test.test-model', 'entity-id', {}); // Expect entry.create event to be emitted expect(eventHub.emit).toHaveBeenCalledWith('entry.delete', { @@ -89,7 +87,7 @@ describe('Entity service triggers webhooks', () => { test('Emit event: Delete Many', async () => { // Delete entity - await instance.deleteMany('test-model', {}); + await instance.deleteMany('api::test.test-model', {}); // Expect entry.create event to be emitted expect(eventHub.emit).toHaveBeenCalledWith('entry.delete', { @@ -106,7 +104,7 @@ describe('Entity service triggers webhooks', () => { test('Do not emit event when no deleted entity', async () => { entity = null; // Delete non existent entity - await instance.delete('test-model', 'entity-id', {}); + await instance.delete('api::test.test-model', 'entity-id', {}); // Expect entry.create event to be emitted expect(eventHub.emit).toHaveBeenCalledTimes(0); diff --git a/packages/core/strapi/src/services/entity-service/__tests__/entity-service.test.js b/packages/core/strapi/src/services/entity-service/__tests__/entity-service.test.ts similarity index 78% rename from packages/core/strapi/src/services/entity-service/__tests__/entity-service.test.js rename to packages/core/strapi/src/services/entity-service/__tests__/entity-service.test.ts index 24058c8351..c307706c2a 100644 --- a/packages/core/strapi/src/services/entity-service/__tests__/entity-service.test.js +++ b/packages/core/strapi/src/services/entity-service/__tests__/entity-service.test.ts @@ -1,15 +1,18 @@ -'use strict'; +import { EventEmitter } from 'events'; +import { errors } from '@strapi/utils'; +import createEntityService from '..'; +import entityValidator from '../../entity-validator'; +import createEventHub from '../../event-hub'; +import type { Schema, Utils } from '../../../types'; +import uploadFiles from '../../utils/upload-files'; jest.mock('bcryptjs', () => ({ hashSync: () => 'secret-password' })); -const { EventEmitter } = require('events'); -const { ValidationError } = require('@strapi/utils').errors; -const createEntityService = require('..'); -const entityValidator = require('../../entity-validator'); - jest.mock('../../utils/upload-files', () => jest.fn(() => Promise.resolve())); describe('Entity service', () => { + const eventHub = createEventHub(); + global.strapi = { getModel: jest.fn(() => ({})), config: { @@ -22,27 +25,28 @@ describe('Entity service', () => { allowedEvents: new Map([['ENTRY_CREATE', 'entry.create']]), addAllowedEvent: jest.fn(), }, - }; + } as any; describe('Decorator', () => { - test.each(['create', 'update', 'findMany', 'findOne', 'delete', 'count', 'findPage'])( + test.each(['create', 'update', 'findMany', 'findOne', 'delete', 'count', 'findPage'] as const)( 'Can decorate', async (method) => { const instance = createEntityService({ strapi: global.strapi, - db: {}, - eventHub: new EventEmitter(), + db: {} as any, + eventHub, + entityValidator, }); const methodFn = jest.fn(); - const decorator = () => ({ - [method]: methodFn, - }); - instance.decorate(decorator); + instance.decorate((old) => ({ + ...old, + [method]: methodFn, + })); const args = [{}, {}]; - await instance[method](...args); + await (instance[method] as Utils.Function.Any)(...args); expect(methodFn).toHaveBeenCalled(); } ); @@ -61,28 +65,30 @@ describe('Entity service', () => { const fakeDB = { query: jest.fn(() => fakeQuery), - transaction: (cb) => cb(), + transaction: (cb: Utils.Function.Any) => cb(), }; const fakeStrapi = { ...global.strapi, + query: fakeQuery, getModel: jest.fn(() => { return { kind: 'singleType' }; }), }; const instance = createEntityService({ - strapi: fakeStrapi, - db: fakeDB, - eventHub: new EventEmitter(), + strapi: fakeStrapi as any, + db: fakeDB as any, + eventHub, + entityValidator, }); - const result = await instance.findMany('test-model'); + const result = await instance.findMany('api::test.test-model'); expect(fakeStrapi.getModel).toHaveBeenCalledTimes(1); - expect(fakeStrapi.getModel).toHaveBeenCalledWith('test-model'); + expect(fakeStrapi.getModel).toHaveBeenCalledWith('api::test.test-model'); - expect(fakeDB.query).toHaveBeenCalledWith('test-model'); + expect(fakeDB.query).toHaveBeenCalledWith('api::test.test-model'); expect(fakeQuery.findOne).toHaveBeenCalledWith({}); expect(result).toEqual(data); }); @@ -97,11 +103,14 @@ describe('Entity service', () => { })), findOne: jest.fn(), }; - const fakeModels = {}; + const fakeModels: Record = {}; beforeAll(() => { - global.strapi.getModel.mockImplementation((modelName) => fakeModels[modelName]); - global.strapi.query.mockImplementation(() => fakeQuery); + jest + .mocked(global.strapi.getModel) + .mockImplementation((modelName: string) => fakeModels[modelName] as any); + + jest.mocked(global.strapi.query).mockImplementation(() => fakeQuery as any); }); beforeEach(() => { @@ -109,16 +118,16 @@ describe('Entity service', () => { }); afterAll(() => { - global.strapi.getModel.mockImplementation(() => ({})); + jest.mocked(global.strapi.getModel).mockImplementation(() => ({} as any)); }); describe('assign default values', () => { - let instance; + let instance: any; const entityUID = 'api::entity.entity'; const relationUID = 'api::relation.relation'; beforeAll(() => { - const fakeEntities = { + const fakeEntities: Record> = { [relationUID]: { 1: { id: 1, @@ -138,9 +147,16 @@ describe('Entity service', () => { }; fakeModels[entityUID] = { + modelType: 'contentType', uid: entityUID, - kind: 'contentType', + kind: 'collectionType', modelName: 'test-model', + globalId: 'test-model', + info: { + singularName: 'entity', + pluralName: 'entities', + displayName: 'ENTITY', + }, options: {}, attributes: { attrStringDefaultRequired: { @@ -173,9 +189,17 @@ describe('Entity service', () => { }, }, }; + fakeModels[relationUID] = { uid: relationUID, - kind: 'contentType', + modelType: 'contentType', + globalId: 'relation', + info: { + displayName: 'RELATION', + singularName: 'relation', + pluralName: 'relations', + }, + kind: 'collectionType', modelName: 'relation', attributes: { Name: { @@ -185,32 +209,43 @@ describe('Entity service', () => { }, }, }; - const fakeQuery = (uid) => ({ - create: jest.fn(({ data }) => data), - count: jest.fn(({ where }) => { - return where.id.$in.filter((id) => Boolean(fakeEntities[uid][id])).length; - }), - }); + const fakeQuery = (uid: string) => + ({ + create: jest.fn(({ data }) => data), + count: jest.fn(({ where }) => { + return where.id.$in.filter((id: string) => Boolean(fakeEntities[uid][id])).length; + }), + } as any); const fakeDB = { - transaction: (cb) => cb(), + transaction: (cb: Utils.Function.Any) => cb(), query: jest.fn((uid) => fakeQuery(uid)), - }; + } as any; - global.strapi.db = fakeDB; + global.strapi = { + ...global.strapi, + db: fakeDB, + query: jest.fn((uid) => fakeQuery(uid)), + } as any; instance = createEntityService({ strapi: global.strapi, db: fakeDB, - eventHub: new EventEmitter(), + eventHub, entityValidator, }); }); + afterAll(() => { - global.strapi.db = { + global.strapi = { + ...global.strapi, + db: { + query: jest.fn(() => fakeQuery), + }, query: jest.fn(() => fakeQuery), - }; + } as any; }); + test('should create record with all default attributes', async () => { const data = {}; @@ -315,7 +350,7 @@ describe('Entity service', () => { const res = instance.create(entityUID, { data }); await expect(res).rejects.toThrowError( - new ValidationError( + new errors.ValidationError( `1 relation(s) of type api::relation.relation associated with this entity do not exist` ) ); @@ -323,19 +358,24 @@ describe('Entity service', () => { }); describe('with files', () => { - let instance; + let instance: any; + beforeAll(() => { - fakeModels['test-model'] = { - uid: 'test-model', + fakeModels['api::test.test-model'] = { + uid: 'api::test.test-model', kind: 'collectionType', collectionName: 'test-model', + info: { + displayName: 'test-model', + singularName: 'test-model', + pluralName: 'test-models', + }, options: {}, attributes: { name: { type: 'string', }, activity: { - displayName: 'activity', type: 'component', repeatable: true, component: 'basic.activity', @@ -343,12 +383,11 @@ describe('Entity service', () => { }, modelType: 'contentType', modelName: 'test-model', + globalId: 'test-model', }; + fakeModels['basic.activity'] = { collectionName: 'components_basic_activities', - info: { - displayName: 'activity', - }, options: {}, attributes: { docs: { @@ -375,6 +414,7 @@ describe('Entity service', () => { getModel: jest.fn((modelName) => fakeModels[modelName]), query: jest.fn(() => fakeQuery), db: { + ...fakeDB, dialect: { client: 'sqlite', }, @@ -384,17 +424,17 @@ describe('Entity service', () => { global.strapi = { ...global.strapi, ...fakeStrapi, - }; + } as any; instance = createEntityService({ strapi: global.strapi, db: fakeDB, - eventHub: new EventEmitter(), + eventHub, entityValidator, - }); + } as any); }); - test('should create record with attached files', async () => { - const uploadFiles = require('../../utils/upload-files'); + + test.only('should create record with attached files', async () => { const data = { name: 'demoEvent', activity: [{ name: 'Powering the Aviation of the Future' }], @@ -411,13 +451,13 @@ describe('Entity service', () => { fakeQuery.findOne.mockResolvedValue({ id: 1, ...data }); - await instance.create('test-model', { data, files }); + await instance.create('api::test.test-model', { data, files }); expect(global.strapi.getModel).toBeCalled(); expect(uploadFiles).toBeCalled(); expect(uploadFiles).toBeCalledTimes(1); expect(uploadFiles).toBeCalledWith( - 'test-model', + 'api::test.test-model', { id: 1, name: 'demoEvent', @@ -439,12 +479,12 @@ describe('Entity service', () => { describe('Update', () => { describe('assign default values', () => { - let instance; + let instance: any; const entityUID = 'api::entity.entity'; const relationUID = 'api::relation.relation'; - const fakeEntities = { + const fakeEntities: Record> = { [entityUID]: { 0: { id: 0, @@ -471,10 +511,12 @@ describe('Entity service', () => { }, }, }; - const fakeModels = { + const fakeModels: Record = { [entityUID]: { kind: 'collectionType', modelName: 'entity', + globalId: 'entity', + modelType: 'contentType', collectionName: 'entity', uid: entityUID, options: {}, @@ -496,8 +538,16 @@ describe('Entity service', () => { }, }, [relationUID]: { - kind: 'contentType', + kind: 'collectionType', + globalId: 'entity', + modelType: 'contentType', modelName: 'relation', + uid: relationUID, + info: { + singularName: 'relation', + pluralName: 'relations', + displayName: 'RELATION', + }, attributes: { Name: { type: 'string', @@ -509,11 +559,11 @@ describe('Entity service', () => { }; beforeAll(() => { - const fakeQuery = (key) => ({ + const fakeQuery = (key: string) => ({ findOne: jest.fn(({ where }) => fakeEntities[key][where.id]), count: jest.fn(({ where }) => { let ret = 0; - where.id.$in.forEach((id) => { + where.id.$in.forEach((id: string) => { const entity = fakeEntities[key][id]; if (!entity) return; ret += 1; @@ -534,18 +584,19 @@ describe('Entity service', () => { global.strapi = { ...global.strapi, - getModel: jest.fn((uid) => { + getModel: jest.fn((uid: string) => { return fakeModels[uid]; }), + query: jest.fn((key) => fakeQuery(key)), db: fakeDB, - }; + } as any; instance = createEntityService({ strapi: global.strapi, db: fakeDB, eventHub: new EventEmitter(), entityValidator, - }); + } as any); }); test(`should fail if the entity doesn't exist`, async () => { @@ -587,7 +638,7 @@ describe('Entity service', () => { const res = instance.update(entityUID, 0, { data }); await expect(res).rejects.toThrowError( - new ValidationError( + new errors.ValidationError( `1 relation(s) of type api::relation.relation associated with this entity do not exist` ) ); diff --git a/packages/core/strapi/src/services/entity-service/index.ts b/packages/core/strapi/src/services/entity-service/index.ts index 22f9e36c59..b621f2d63f 100644 --- a/packages/core/strapi/src/services/entity-service/index.ts +++ b/packages/core/strapi/src/services/entity-service/index.ts @@ -34,6 +34,14 @@ export * from './types'; const { transformParamsToQuery } = convertQueryParams; +type Decoratable = T & { + decorate( + decorator: (old: Types.EntityService) => Types.EntityService & { + [key: string]: unknown; + } + ): void; +}; + type Context = { contentType: Schema.ContentType; }; @@ -89,11 +97,11 @@ const createDefaultImplementation = ({ */ uploadFiles, - async wrapParams(options: any) { + async wrapParams(options: any = {}) { return options; }, - async wrapResult(result: any) { + async wrapResult(result: any = {}) { return result; }, @@ -237,10 +245,6 @@ const createDefaultImplementation = ({ }); const { data, files } = wrappedParams; - if (!data) { - throw new Error('cannot update'); - } - const model = strapi.getModel(uid); const entityToUpdate = await db.query(uid).findOne({ where: { id: entityId } }); @@ -455,7 +459,7 @@ export default (ctx: { db: Database; eventHub: EventHub; entityValidator: EntityValidator; -}): Types.EntityService => { +}): Decoratable => { Object.entries(ALLOWED_WEBHOOK_EVENTS).forEach(([key, value]) => { ctx.strapi.webhookStore?.addAllowedEvent(key, value); }); @@ -507,5 +511,5 @@ export default (ctx: { return newService; }); - return service as unknown as Types.EntityService; + return service as unknown as Decoratable; }; diff --git a/packages/core/strapi/src/services/entity-validator/index.ts b/packages/core/strapi/src/services/entity-validator/index.ts index f5990e45b9..0c6cf1b424 100644 --- a/packages/core/strapi/src/services/entity-validator/index.ts +++ b/packages/core/strapi/src/services/entity-validator/index.ts @@ -7,7 +7,7 @@ import { uniqBy, castArray, isNil, isArray, mergeWith } from 'lodash'; import { has, prop, isObject, isEmpty } from 'lodash/fp'; import strapiUtils from '@strapi/utils'; import validators from './validators'; -import { Common, Schema, Attribute, UID, Shared } from '../../types'; +import { Common, Schema, Attribute, Shared } from '../../types'; import type * as Types from '../entity-service/types'; type CreateOrUpdate = 'creation' | 'update'; @@ -37,12 +37,12 @@ interface ValidatorContext { interface AttributeValidatorMetas { attr: Attribute.Any; updatedAttribute: { name: string; value: unknown }; - model: Schema.ContentType; + model: Schema.ContentType | Schema.Component; entity?: Entity; } interface ModelValidatorMetas { - model: Schema.ContentType; + model: Schema.ContentType | Schema.Component; data: Record; entity?: Entity; } @@ -299,7 +299,7 @@ const createModelValidator = const createValidateEntity = (createOrUpdate: CreateOrUpdate) => { return async >( model: Shared.ContentTypes[TUID], - data: TData | Partial, + data: TData | Partial | undefined, options?: { isDraft?: boolean }, entity?: Entity ): Promise => { @@ -312,11 +312,7 @@ const createValidateEntity = (createOrUpdate: CreateOrUpdate) => { } const validator = createModelValidator(createOrUpdate)( - { - model, - data, - entity, - }, + { model, data, entity }, { isDraft: options?.isDraft ?? false } ) .test('relations-test', 'check that all relations exist', async function (data) { @@ -342,11 +338,11 @@ const createValidateEntity = (createOrUpdate: CreateOrUpdate) => { /** * Builds an object containing all the media and relations being associated with an entity */ -const buildRelationsStore = ({ +const buildRelationsStore = ({ uid, data, }: { - uid: Common.UID.ContentType | Common.UID.Component; + uid: TUID; data: Record | null; }): Record => { if (!uid) { @@ -357,7 +353,7 @@ const buildRelationsStore = ({ return {}; } - const currentModel: Schema.ContentType = strapi.getModel(uid); + const currentModel: Common.Schemas[TUID] = strapi.getModel(uid); return Object.keys(currentModel.attributes).reduce((result, attributeName: string) => { const attribute = currentModel.attributes[attributeName]; @@ -470,7 +466,7 @@ const checkRelationsExist = async (relationsStore: Record = {}) => for (const [key, value] of Object.entries(relationsStore)) { const evaluate = async () => { const uniqueValues = uniqBy(value, `id`); - const count = await strapi.query(key as UID.ContentType).count({ + const count = await strapi.query(key as Common.UID.Schema).count({ where: { id: { $in: uniqueValues.map((v) => v.id), @@ -500,7 +496,7 @@ export interface EntityValidator { ) => Promise>; validateEntityUpdate: ( model: Shared.ContentTypes[TUID], - data: Partial>, + data: Partial> | undefined, options?: { isDraft?: boolean }, entity?: Entity ) => Promise>; diff --git a/packages/core/strapi/src/types/core/attributes/enumeration.ts b/packages/core/strapi/src/types/core/attributes/enumeration.ts index e07667213f..4c818cd100 100644 --- a/packages/core/strapi/src/types/core/attributes/enumeration.ts +++ b/packages/core/strapi/src/types/core/attributes/enumeration.ts @@ -9,7 +9,7 @@ export type Enumeration = Attribute.OfType<'enume EnumerationProperties & // Options Attribute.ConfigurableOption & - Attribute.DefaultOption & + Attribute.DefaultOption & Attribute.PrivateOption & Attribute.RequiredOption & Attribute.WritableOption & diff --git a/packages/core/strapi/src/types/core/attributes/media.ts b/packages/core/strapi/src/types/core/attributes/media.ts index 1e77e41689..73a3daff08 100644 --- a/packages/core/strapi/src/types/core/attributes/media.ts +++ b/packages/core/strapi/src/types/core/attributes/media.ts @@ -8,7 +8,7 @@ export interface MediaProperties< TKind extends MediaKind | undefined = undefined, TMultiple extends Utils.Expression.BooleanValue = Utils.Expression.False > { - allowedTypes?: TKind; + allowedTypes?: TKind | TKind[]; multiple?: TMultiple; } diff --git a/packages/core/strapi/src/types/core/schemas/index.ts b/packages/core/strapi/src/types/core/schemas/index.ts index 58759bdc23..16b12f8329 100644 --- a/packages/core/strapi/src/types/core/schemas/index.ts +++ b/packages/core/strapi/src/types/core/schemas/index.ts @@ -141,4 +141,8 @@ export interface SingleType extends ContentType { */ export interface Component extends Schema { modelType: 'component'; + + uid: Common.UID.Component; + + category: string; } diff --git a/packages/core/utils/src/content-types.ts b/packages/core/utils/src/content-types.ts index 3658a84ea3..4ff48d6b38 100644 --- a/packages/core/utils/src/content-types.ts +++ b/packages/core/utils/src/content-types.ts @@ -195,8 +195,8 @@ const isTypedAttribute = (attribute: Attribute, type: string) => { */ const getContentTypeRoutePrefix = (contentType: Model) => { return isSingleType(contentType) - ? _.kebabCase(contentType.info.singularName) - : _.kebabCase(contentType.info.pluralName); + ? _.kebabCase(contentType.info?.singularName) + : _.kebabCase(contentType.info?.pluralName); }; export { diff --git a/packages/core/utils/src/types.ts b/packages/core/utils/src/types.ts index e873837063..442274ed01 100644 --- a/packages/core/utils/src/types.ts +++ b/packages/core/utils/src/types.ts @@ -67,10 +67,10 @@ export type AnyAttribute = export type Kind = 'singleType' | 'collectionType'; export interface Model { - modelType: 'contentType'; + modelType: 'contentType' | 'component'; uid: string; - kind: Kind; - info: { + kind?: Kind; + info?: { displayName: string; singularName: string; pluralName: string;