Entity service tests

This commit is contained in:
Alexandre Bodin 2023-09-05 22:10:20 +02:00
parent c4c570fe3b
commit cc4b360d47
10 changed files with 171 additions and 114 deletions

View File

@ -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]

View File

@ -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);

View File

@ -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<string, Schema.ContentType | Schema.Component> = {};
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<string, Record<string, unknown>> = {
[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) => ({
const fakeQuery = (uid: string) =>
({
create: jest.fn(({ data }) => data),
count: jest.fn(({ where }) => {
return where.id.$in.filter((id) => Boolean(fakeEntities[uid][id])).length;
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<string, Record<string, any>> = {
[entityUID]: {
0: {
id: 0,
@ -471,10 +511,12 @@ describe('Entity service', () => {
},
},
};
const fakeModels = {
const fakeModels: Record<string, Schema.ContentType> = {
[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`
)
);

View File

@ -34,6 +34,14 @@ export * from './types';
const { transformParamsToQuery } = convertQueryParams;
type Decoratable<T> = 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<Types.EntityService> => {
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<Types.EntityService>;
};

View File

@ -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<string, unknown>;
entity?: Entity;
}
@ -299,7 +299,7 @@ const createModelValidator =
const createValidateEntity = (createOrUpdate: CreateOrUpdate) => {
return async <TUID extends Common.UID.ContentType, TData extends Types.Params.Data.Input<TUID>>(
model: Shared.ContentTypes[TUID],
data: TData | Partial<TData>,
data: TData | Partial<TData> | undefined,
options?: { isDraft?: boolean },
entity?: Entity
): Promise<TData> => {
@ -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 = <TUID extends Common.UID.ContentType | Common.UID.Component>({
uid,
data,
}: {
uid: Common.UID.ContentType | Common.UID.Component;
uid: TUID;
data: Record<string, unknown> | null;
}): Record<string, ID[]> => {
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<string, ID[]> = {}) =>
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<Types.Params.Data.Input<TUID>>;
validateEntityUpdate: <TUID extends Common.UID.ContentType>(
model: Shared.ContentTypes[TUID],
data: Partial<Types.Params.Data.Input<TUID>>,
data: Partial<Types.Params.Data.Input<TUID>> | undefined,
options?: { isDraft?: boolean },
entity?: Entity
) => Promise<Types.Params.Data.Input<TUID>>;

View File

@ -9,7 +9,7 @@ export type Enumeration<TValues extends string[] = []> = Attribute.OfType<'enume
EnumerationProperties<TValues> &
// Options
Attribute.ConfigurableOption &
Attribute.DefaultOption<TValues> &
Attribute.DefaultOption<TValues[number]> &
Attribute.PrivateOption &
Attribute.RequiredOption &
Attribute.WritableOption &

View File

@ -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;
}

View File

@ -141,4 +141,8 @@ export interface SingleType extends ContentType {
*/
export interface Component extends Schema {
modelType: 'component';
uid: Common.UID.Component;
category: string;
}

View File

@ -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 {

View File

@ -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;