Fix repeated logic caused by updateOne & deleteOne overrides (#16433)

* fix updateOne & deleteOne overrides repeated logic

* replace remaining jest usages

* fix notifications service env mock

* simplify mockImplementation with mockResolvedValue

* fix types in test

Co-authored-by: ian <licitdev@gmail.com>
This commit is contained in:
Azri Kahar
2022-11-30 21:51:11 +08:00
committed by GitHub
parent 1c93cc661e
commit a4de019ead
13 changed files with 1203 additions and 154 deletions

View File

@@ -0,0 +1,138 @@
import knex, { Knex } from 'knex';
import { getTracker, MockClient, Tracker } from 'knex-mock-client';
import { afterEach, beforeAll, beforeEach, describe, expect, it, SpyInstance, vi } from 'vitest';
import { FlowsService } from '.';
import { getFlowManager } from '../flows';
vi.mock('../../src/database/index', () => {
return { __esModule: true, default: vi.fn(), getDatabaseClient: vi.fn().mockReturnValue('postgres') };
});
vi.mock('../flows', () => {
return { getFlowManager: vi.fn().mockReturnValue({ reload: vi.fn() }) };
});
describe('Integration Tests', () => {
let db: Knex;
let tracker: Tracker;
beforeAll(async () => {
db = knex({ client: MockClient });
tracker = getTracker();
});
beforeEach(() => {
tracker.on.any('directus_flows').response({});
tracker.on.update('directus_operations').response({});
});
afterEach(() => {
tracker.reset();
});
describe('Services / Flows', () => {
let service: FlowsService;
let flowManagerReloadSpy: SpyInstance;
beforeEach(() => {
service = new FlowsService({
knex: db,
schema: {
collections: {
directus_flows: {
collection: 'directus_flows',
primary: 'id',
singleton: false,
sortField: null,
note: null,
accountability: null,
fields: {
id: {
field: 'id',
defaultValue: null,
nullable: false,
generated: true,
type: 'integer',
dbType: 'integer',
precision: null,
scale: null,
special: [],
note: null,
validation: null,
alias: false,
},
},
},
},
relations: [],
},
});
flowManagerReloadSpy = vi.spyOn(getFlowManager(), 'reload');
});
afterEach(() => {
flowManagerReloadSpy.mockClear();
});
describe('createOne', () => {
it('should reload flows once', async () => {
await service.createOne({});
expect(flowManagerReloadSpy).toBeCalledTimes(1);
});
});
describe('createMany', () => {
it('should reload flows once', async () => {
await service.createMany([{}]);
expect(flowManagerReloadSpy).toBeCalledTimes(1);
});
});
describe('updateOne', () => {
it('should reload flows once', async () => {
await service.updateOne(1, {});
expect(flowManagerReloadSpy).toBeCalledTimes(1);
});
});
describe('updateBatch', () => {
it('should reload flows once', async () => {
await service.updateBatch([{ id: 1 }]);
expect(flowManagerReloadSpy).toBeCalledTimes(1);
});
});
describe('updateMany', () => {
it('should reload flows once', async () => {
await service.updateMany([1], {});
expect(flowManagerReloadSpy).toBeCalledTimes(1);
});
});
describe('deleteOne', () => {
it('should update operations once and reload flows once', async () => {
await service.deleteOne(1);
const updateOperationsCalls = tracker.history.update.filter((query) =>
query.sql.includes(`update "directus_operations" set "resolve" = ?, "reject" = ? where "flow"`)
).length;
expect(updateOperationsCalls).toBe(1);
expect(flowManagerReloadSpy).toBeCalledTimes(1);
});
});
describe('deleteMany', () => {
it('should update operations once and reload flows once', async () => {
await service.deleteMany([1]);
const updateOperationsCalls = tracker.history.update.filter((query) =>
query.sql.includes(`update "directus_operations" set "resolve" = ?, "reject" = ? where "flow"`)
).length;
expect(updateOperationsCalls).toBe(1);
expect(flowManagerReloadSpy).toBeCalledTimes(1);
});
});
});
});

View File

@@ -26,15 +26,6 @@ export class FlowsService extends ItemsService<FlowRaw> {
return result;
}
async updateOne(key: PrimaryKey, data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey> {
const flowManager = getFlowManager();
const result = await super.updateOne(key, data, opts);
await flowManager.reload();
return result;
}
async updateBatch(data: Partial<Item>[], opts?: MutationOptions): Promise<PrimaryKey[]> {
const flowManager = getFlowManager();
@@ -53,18 +44,6 @@ export class FlowsService extends ItemsService<FlowRaw> {
return result;
}
async deleteOne(key: PrimaryKey, opts?: MutationOptions): Promise<PrimaryKey> {
const flowManager = getFlowManager();
// this is to prevent foreign key constraint error on directus_operations resolve/reject during cascade deletion
await this.knex('directus_operations').update({ resolve: null, reject: null }).where('flow', key);
const result = await super.deleteOne(key, opts);
await flowManager.reload();
return result;
}
async deleteMany(keys: PrimaryKey[], opts?: MutationOptions): Promise<PrimaryKey[]> {
const flowManager = getFlowManager();

View File

@@ -0,0 +1,125 @@
import knex, { Knex } from 'knex';
import { getTracker, MockClient, Tracker } from 'knex-mock-client';
import { afterEach, beforeAll, beforeEach, describe, expect, it, SpyInstance, vi } from 'vitest';
import { OperationsService } from '.';
import { getFlowManager } from '../flows';
vi.mock('../../src/database/index', () => {
return { __esModule: true, default: vi.fn(), getDatabaseClient: vi.fn().mockReturnValue('postgres') };
});
vi.mock('../flows', () => {
return { getFlowManager: vi.fn().mockReturnValue({ reload: vi.fn() }) };
});
describe('Integration Tests', () => {
let db: Knex;
let tracker: Tracker;
beforeAll(async () => {
db = knex({ client: MockClient });
tracker = getTracker();
});
beforeEach(() => {
tracker.on.any('directus_operations').response({});
});
afterEach(() => {
tracker.reset();
});
describe('Services / Operations', () => {
let service: OperationsService;
let flowManagerReloadSpy: SpyInstance;
beforeEach(() => {
service = new OperationsService({
knex: db,
schema: {
collections: {
directus_operations: {
collection: 'directus_operations',
primary: 'id',
singleton: false,
sortField: null,
note: null,
accountability: null,
fields: {
id: {
field: 'id',
defaultValue: null,
nullable: false,
generated: true,
type: 'integer',
dbType: 'integer',
precision: null,
scale: null,
special: [],
note: null,
validation: null,
alias: false,
},
},
},
},
relations: [],
},
});
flowManagerReloadSpy = vi.spyOn(getFlowManager(), 'reload');
});
afterEach(() => {
flowManagerReloadSpy.mockClear();
});
describe('createOne', () => {
it('should reload flows once', async () => {
await service.createOne({});
expect(flowManagerReloadSpy).toBeCalledTimes(1);
});
});
describe('createMany', () => {
it('should reload flows once', async () => {
await service.createMany([{}]);
expect(flowManagerReloadSpy).toBeCalledTimes(1);
});
});
describe('updateOne', () => {
it('should reload flows once', async () => {
await service.updateOne(1, {});
expect(flowManagerReloadSpy).toBeCalledTimes(1);
});
});
describe('updateBatch', () => {
it('should reload flows once', async () => {
await service.updateBatch([{ id: 1 }]);
expect(flowManagerReloadSpy).toBeCalledTimes(1);
});
});
describe('updateMany', () => {
it('should reload flows once', async () => {
await service.updateMany([1], {});
expect(flowManagerReloadSpy).toBeCalledTimes(1);
});
});
describe('deleteOne', () => {
it('should reload flows once', async () => {
await service.deleteOne(1);
expect(flowManagerReloadSpy).toBeCalledTimes(1);
});
});
describe('deleteMany', () => {
it('should reload flows once', async () => {
await service.deleteMany([1]);
expect(flowManagerReloadSpy).toBeCalledTimes(1);
});
});
});
});

View File

@@ -26,15 +26,6 @@ export class OperationsService extends ItemsService<OperationRaw> {
return result;
}
async updateOne(key: PrimaryKey, data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey> {
const flowManager = getFlowManager();
const result = await super.updateOne(key, data, opts);
await flowManager.reload();
return result;
}
async updateBatch(data: Partial<Item>[], opts?: MutationOptions): Promise<PrimaryKey[]> {
const flowManager = getFlowManager();
@@ -53,15 +44,6 @@ export class OperationsService extends ItemsService<OperationRaw> {
return result;
}
async deleteOne(key: PrimaryKey, opts?: MutationOptions): Promise<PrimaryKey> {
const flowManager = getFlowManager();
const result = await super.deleteOne(key, opts);
await flowManager.reload();
return result;
}
async deleteMany(keys: PrimaryKey[], opts?: MutationOptions): Promise<PrimaryKey[]> {
const flowManager = getFlowManager();

View File

@@ -0,0 +1,158 @@
import knex, { Knex } from 'knex';
import { getTracker, MockClient, Tracker } from 'knex-mock-client';
import { afterEach, beforeAll, beforeEach, describe, expect, it, SpyInstance, vi } from 'vitest';
import { ItemsService, PermissionsService } from '.';
import * as cache from '../cache';
vi.mock('../../src/database/index', () => {
return { __esModule: true, default: vi.fn(), getDatabaseClient: vi.fn().mockReturnValue('postgres') };
});
describe('Integration Tests', () => {
let db: Knex;
let tracker: Tracker;
beforeAll(async () => {
db = knex({ client: MockClient });
tracker = getTracker();
});
beforeEach(() => {
tracker.on.any('directus_permissions').response({});
tracker.on
.select(
/"directus_permissions"."id" from "directus_permissions" order by "directus_permissions"."id" asc limit .*/
)
.response([]);
});
afterEach(() => {
tracker.reset();
});
describe('Services / Permissions', () => {
let service: PermissionsService;
let clearSystemCacheSpy: SpyInstance;
beforeEach(() => {
service = new PermissionsService({
knex: db,
schema: {
collections: {
directus_permissions: {
collection: 'directus_permissions',
primary: 'id',
singleton: false,
sortField: null,
note: null,
accountability: null,
fields: {
id: {
field: 'id',
defaultValue: null,
nullable: false,
generated: true,
type: 'integer',
dbType: 'integer',
precision: null,
scale: null,
special: [],
note: null,
validation: null,
alias: false,
},
},
},
},
relations: [],
},
});
clearSystemCacheSpy = vi.spyOn(cache, 'clearSystemCache').mockResolvedValue();
});
afterEach(() => {
clearSystemCacheSpy.mockClear();
});
describe('createOne', () => {
it('should clearSystemCache once', async () => {
await service.createOne({});
expect(clearSystemCacheSpy).toBeCalledTimes(1);
});
});
describe('createMany', () => {
it('should clearSystemCache once', async () => {
await service.createMany([{}]);
expect(clearSystemCacheSpy).toBeCalledTimes(1);
});
});
describe('updateOne', () => {
it('should clearSystemCache once', async () => {
await service.updateOne(1, {});
expect(clearSystemCacheSpy).toBeCalledTimes(1);
});
});
describe('updateMany', () => {
it('should clearSystemCache once', async () => {
await service.updateMany([1], {});
expect(clearSystemCacheSpy).toBeCalledTimes(1);
});
});
describe('updateBatch', () => {
it('should clearSystemCache once', async () => {
await service.updateBatch([{ id: 1 }]);
expect(clearSystemCacheSpy).toBeCalledTimes(1);
});
});
describe('updateByQuery', () => {
it('should clearSystemCache once', async () => {
// mock return value for following empty query
vi.spyOn(ItemsService.prototype, 'getKeysByQuery').mockResolvedValue([1]);
await service.updateByQuery({}, {});
expect(clearSystemCacheSpy).toBeCalledTimes(1);
});
});
describe('upsertOne', () => {
it('should clearSystemCache once', async () => {
await service.upsertOne({});
expect(clearSystemCacheSpy).toBeCalledTimes(1);
});
});
describe('upsertMany', () => {
it('should clearSystemCache once', async () => {
await service.upsertMany([{ id: 1 }]);
expect(clearSystemCacheSpy).toBeCalledTimes(1);
});
});
describe('deleteOne', () => {
it('should clearSystemCache once', async () => {
await service.deleteOne(1);
expect(clearSystemCacheSpy).toBeCalledTimes(1);
});
});
describe('deleteMany', () => {
it('should clearSystemCache once', async () => {
await service.deleteMany([1]);
expect(clearSystemCacheSpy).toBeCalledTimes(1);
});
});
describe('deleteByQuery', () => {
it('should clearSystemCache once', async () => {
// mock return value for following empty query
vi.spyOn(ItemsService.prototype, 'getKeysByQuery').mockResolvedValue([1]);
await service.deleteByQuery({});
expect(clearSystemCacheSpy).toBeCalledTimes(1);
});
});
});
});

View File

@@ -90,18 +90,6 @@ export class PermissionsService extends ItemsService {
return res;
}
async updateByQuery(query: Query, data: Partial<Item>, opts?: MutationOptions) {
const res = await super.updateByQuery(query, data, opts);
await clearSystemCache();
return res;
}
async updateOne(key: PrimaryKey, data: Partial<Item>, opts?: MutationOptions) {
const res = await super.updateOne(key, data, opts);
await clearSystemCache();
return res;
}
async updateBatch(data: Partial<Item>[], opts?: MutationOptions) {
const res = await super.updateBatch(data, opts);
await clearSystemCache();
@@ -114,30 +102,12 @@ export class PermissionsService extends ItemsService {
return res;
}
async upsertOne(payload: Partial<Item>, opts?: MutationOptions) {
const res = await super.upsertOne(payload, opts);
await clearSystemCache();
return res;
}
async upsertMany(payloads: Partial<Item>[], opts?: MutationOptions) {
const res = await super.upsertMany(payloads, opts);
await clearSystemCache();
return res;
}
async deleteByQuery(query: Query, opts?: MutationOptions) {
const res = await super.deleteByQuery(query, opts);
await clearSystemCache();
return res;
}
async deleteOne(key: PrimaryKey, opts?: MutationOptions) {
const res = await super.deleteOne(key, opts);
await clearSystemCache();
return res;
}
async deleteMany(keys: PrimaryKey[], opts?: MutationOptions) {
const res = await super.deleteMany(keys, opts);
await clearSystemCache();

View File

@@ -0,0 +1,183 @@
import knex, { Knex } from 'knex';
import { getTracker, MockClient, Tracker } from 'knex-mock-client';
import { afterEach, beforeAll, beforeEach, describe, expect, it, SpyInstance, vi } from 'vitest';
import { ItemsService, PermissionsService, PresetsService, RolesService, UsersService } from '.';
vi.mock('../../src/database/index', () => {
return { __esModule: true, default: vi.fn(), getDatabaseClient: vi.fn().mockReturnValue('postgres') };
});
describe('Integration Tests', () => {
let db: Knex;
let tracker: Tracker;
beforeAll(async () => {
db = knex({ client: MockClient });
tracker = getTracker();
});
beforeEach(() => {
tracker.on.any('directus_roles').response({});
tracker.on
.select(/"directus_roles"."id" from "directus_roles" order by "directus_roles"."id" asc limit .*/)
.response([]);
});
afterEach(() => {
tracker.reset();
});
describe('Services / Roles', () => {
let service: RolesService;
let checkForOtherAdminRolesSpy: SpyInstance;
let checkForOtherAdminUsersSpy: SpyInstance;
beforeEach(() => {
service = new RolesService({
knex: db,
schema: {
collections: {
directus_roles: {
collection: 'directus_roles',
primary: 'id',
singleton: false,
sortField: null,
note: null,
accountability: null,
fields: {
id: {
field: 'id',
defaultValue: null,
nullable: false,
generated: true,
type: 'integer',
dbType: 'integer',
precision: null,
scale: null,
special: [],
note: null,
validation: null,
alias: false,
},
},
},
},
relations: [],
},
});
vi.spyOn(PermissionsService.prototype, 'deleteByQuery').mockResolvedValue([]);
vi.spyOn(PresetsService.prototype, 'deleteByQuery').mockResolvedValue([]);
vi.spyOn(UsersService.prototype, 'updateByQuery').mockResolvedValue([]);
vi.spyOn(UsersService.prototype, 'deleteByQuery').mockResolvedValue([]);
// "as any" are needed since these are private methods
checkForOtherAdminRolesSpy = vi
.spyOn(RolesService.prototype as any, 'checkForOtherAdminRoles')
.mockImplementation(() => vi.fn());
checkForOtherAdminUsersSpy = vi
.spyOn(RolesService.prototype as any, 'checkForOtherAdminUsers')
.mockImplementation(() => vi.fn());
});
afterEach(() => {
checkForOtherAdminRolesSpy.mockClear();
checkForOtherAdminUsersSpy.mockClear();
});
describe('createOne', () => {
it('should not checkForOtherAdminRoles', async () => {
await service.createOne({});
expect(checkForOtherAdminRolesSpy).not.toBeCalled();
});
});
describe('createMany', () => {
it('should not checkForOtherAdminRoles', async () => {
await service.createMany([{}]);
expect(checkForOtherAdminRolesSpy).not.toBeCalled();
});
});
describe('updateOne', () => {
it('should not checkForOtherAdminRoles', async () => {
await service.updateOne(1, {});
expect(checkForOtherAdminRolesSpy).not.toBeCalled();
});
it('should checkForOtherAdminRoles once and not checkForOtherAdminUsersSpy', async () => {
await service.updateOne(1, { admin_access: false });
expect(checkForOtherAdminRolesSpy).toBeCalledTimes(1);
expect(checkForOtherAdminUsersSpy).not.toBeCalled();
});
it('should checkForOtherAdminRoles and checkForOtherAdminUsersSpy once', async () => {
await service.updateOne(1, { admin_access: false, users: [1] });
expect(checkForOtherAdminRolesSpy).toBeCalledTimes(1);
expect(checkForOtherAdminUsersSpy).toBeCalledTimes(1);
});
});
describe('updateMany', () => {
it('should not checkForOtherAdminRoles', async () => {
await service.updateMany([1], {});
expect(checkForOtherAdminRolesSpy).not.toBeCalled();
});
it('should checkForOtherAdminRoles once', async () => {
await service.updateMany([1], { admin_access: false });
expect(checkForOtherAdminRolesSpy).toBeCalledTimes(1);
});
});
describe('updateBatch', () => {
it('should not checkForOtherAdminRoles', async () => {
await service.updateBatch([{ id: 1 }]);
expect(checkForOtherAdminRolesSpy).not.toBeCalled();
});
it('should checkForOtherAdminRoles once', async () => {
await service.updateBatch([{ id: 1, admin_access: false }]);
expect(checkForOtherAdminRolesSpy).toBeCalledTimes(1);
});
});
describe('updateByQuery', () => {
it('should not checkForOtherAdminRoles', async () => {
// mock return value for the following empty query
vi.spyOn(ItemsService.prototype, 'getKeysByQuery').mockResolvedValue([1]);
await service.updateByQuery({}, {});
expect(checkForOtherAdminRolesSpy).not.toBeCalled();
});
it('should checkForOtherAdminRoles once', async () => {
// mock return value for the following empty query
vi.spyOn(ItemsService.prototype, 'getKeysByQuery').mockResolvedValue([1]);
await service.updateByQuery({}, { admin_access: false });
expect(checkForOtherAdminRolesSpy).toBeCalledTimes(1);
});
});
describe('deleteOne', () => {
it('should checkForOtherAdminRoles once', async () => {
await service.deleteOne(1);
expect(checkForOtherAdminRolesSpy).toBeCalledTimes(1);
});
});
describe('deleteMany', () => {
it('should checkForOtherAdminRoles once', async () => {
await service.deleteMany([1]);
expect(checkForOtherAdminRolesSpy).toBeCalledTimes(1);
});
});
describe('deleteByQuery', () => {
it('should checkForOtherAdminRoles once', async () => {
// mock return value for the following empty query
vi.spyOn(ItemsService.prototype, 'getKeysByQuery').mockResolvedValue([1]);
await service.deleteByQuery({});
expect(checkForOtherAdminRolesSpy).toBeCalledTimes(1);
});
});
});
});

View File

@@ -68,10 +68,6 @@ export class RolesService extends ItemsService {
}
async updateOne(key: PrimaryKey, data: Record<string, any>, opts?: MutationOptions): Promise<PrimaryKey> {
if ('admin_access' in data && data.admin_access === false) {
await this.checkForOtherAdminRoles([key]);
}
if ('users' in data) {
await this.checkForOtherAdminUsers(key, data.users);
}

View File

@@ -1,7 +1,8 @@
import knex, { Knex } from 'knex';
import { getTracker, MockClient, Tracker } from 'knex-mock-client';
import { afterEach, beforeAll, beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest';
import { CollectionsService, FieldsService, RelationsService, SpecificationService } from '../../src/services';
import { describe, beforeAll, afterEach, it, expect, vi, beforeEach, MockedFunction } from 'vitest';
import { Collection } from '../types';
class Client_PG extends MockClient {}
@@ -34,49 +35,121 @@ describe('Integration Tests', () => {
describe('schema', () => {
it('returns untyped schema for json fields', async () => {
vi.spyOn(CollectionsService.prototype, 'readByQuery').mockImplementation(
vi.fn().mockReturnValue([
{
vi.spyOn(CollectionsService.prototype, 'readByQuery').mockResolvedValue([
{
collection: 'test_table',
meta: {
accountability: 'all',
collection: 'test_table',
meta: {
accountability: 'all',
collection: 'test_table',
group: null,
hidden: false,
icon: null,
item_duplication_fields: null,
note: null,
singleton: false,
translations: null,
},
schema: {
name: 'test_table',
},
group: null,
hidden: false,
icon: null,
item_duplication_fields: null,
note: null,
singleton: false,
translations: null,
},
])
);
schema: {
name: 'test_table',
},
},
] as any[]);
vi.spyOn(FieldsService.prototype, 'readAll').mockImplementation(
vi.fn().mockReturnValue([
{
vi.spyOn(FieldsService.prototype, 'readAll').mockResolvedValue([
{
collection: 'test_table',
field: 'id',
name: 'id',
type: 'integer',
meta: {
id: 1,
collection: 'test_table',
conditions: null,
display: null,
display_options: null,
field: 'id',
type: 'integer',
schema: {
is_nullable: false,
},
group: null,
hidden: true,
interface: null,
note: null,
options: null,
readonly: false,
required: false,
sort: null,
special: null,
translations: null,
validation: null,
validation_message: null,
width: 'full',
},
{
schema: {
comment: null,
data_type: 'integer',
default_value: null,
foreign_key_column: null,
foreign_key_schema: null,
foreign_key_table: null,
generation_expression: null,
has_auto_increment: false,
is_generated: false,
is_nullable: false,
is_primary_key: true,
is_unique: true,
max_length: null,
name: 'id',
numeric_precision: null,
numeric_scale: null,
table: 'test_table',
},
},
{
collection: 'test_table',
field: 'blob',
name: 'blob',
type: 'json',
meta: {
id: 2,
collection: 'test_table',
conditions: null,
display: null,
display_options: null,
field: 'blob',
type: 'json',
schema: {
is_nullable: true,
},
group: null,
hidden: true,
interface: null,
note: null,
options: null,
readonly: false,
required: false,
sort: null,
special: null,
translations: null,
validation: null,
validation_message: null,
width: 'full',
},
])
);
vi.spyOn(RelationsService.prototype, 'readAll').mockImplementation(vi.fn().mockReturnValue([]));
schema: {
comment: null,
data_type: 'json',
default_value: null,
foreign_key_column: null,
foreign_key_schema: null,
foreign_key_table: null,
generation_expression: null,
has_auto_increment: false,
is_generated: false,
is_nullable: true,
is_primary_key: false,
is_unique: false,
max_length: null,
name: 'blob',
numeric_precision: null,
numeric_scale: null,
table: 'test_table',
},
},
]);
vi.spyOn(RelationsService.prototype, 'readAll').mockResolvedValue([]);
const spec = await service.oas.generate();
expect(spec.components?.schemas).toEqual({
@@ -99,7 +172,7 @@ describe('Integration Tests', () => {
describe('path', () => {
it('requestBody for CreateItems POST path should not have type in schema', async () => {
const collection = {
const collection: Collection = {
collection: 'test_table',
meta: {
accountability: 'all',
@@ -110,30 +183,64 @@ describe('Integration Tests', () => {
item_duplication_fields: null,
note: null,
singleton: false,
translations: null,
translations: {},
},
schema: {
name: 'test_table',
},
};
vi.spyOn(CollectionsService.prototype, 'readByQuery').mockImplementation(
vi.fn().mockReturnValue([collection])
);
vi.spyOn(CollectionsService.prototype, 'readByQuery').mockResolvedValue([collection]);
vi.spyOn(FieldsService.prototype, 'readAll').mockImplementation(
vi.fn().mockReturnValue([
{
collection: collection.collection,
vi.spyOn(FieldsService.prototype, 'readAll').mockResolvedValue([
{
collection: collection.collection,
field: 'id',
name: 'id',
type: 'integer',
meta: {
id: 1,
collection: 'test_table',
conditions: null,
display: null,
display_options: null,
field: 'id',
type: 'integer',
schema: {
is_nullable: false,
},
group: null,
hidden: true,
interface: null,
note: null,
options: null,
readonly: false,
required: false,
sort: null,
special: null,
translations: null,
validation: null,
validation_message: null,
width: 'full',
},
])
);
vi.spyOn(RelationsService.prototype, 'readAll').mockImplementation(vi.fn().mockReturnValue([]));
schema: {
comment: null,
data_type: 'integer',
default_value: null,
foreign_key_column: null,
foreign_key_schema: null,
foreign_key_table: null,
generation_expression: null,
has_auto_increment: false,
is_generated: false,
is_nullable: false,
is_primary_key: true,
is_unique: true,
max_length: null,
name: 'id',
numeric_precision: null,
numeric_scale: null,
table: 'test_table',
},
},
]);
vi.spyOn(RelationsService.prototype, 'readAll').mockResolvedValue([]);
const spec = await service.oas.generate();

View File

@@ -4,12 +4,15 @@ import { getTracker, MockClient, Tracker } from 'knex-mock-client';
import { afterEach, beforeAll, beforeEach, describe, expect, it, MockedFunction, SpyInstance, vi } from 'vitest';
import { ItemsService, UsersService } from '.';
import { InvalidPayloadException } from '../exceptions';
import { RecordNotUniqueException } from '../exceptions/database/record-not-unique';
vi.mock('../../src/database/index', () => ({
default: vi.fn(),
getDatabaseClient: vi.fn().mockReturnValue('postgres'),
}));
const testRoleId = '4ccdb196-14b3-4ed1-b9da-c1978be07ca2';
const testSchema = {
collections: {
directus_users: {
@@ -49,18 +52,174 @@ describe('Integration Tests', () => {
tracker = getTracker();
});
beforeEach(() => {
tracker.on.any('directus_users').response({});
// mock notifications update query in deleteOne/deleteMany/deleteByQuery methods
tracker.on.update('directus_notifications').response({});
});
afterEach(() => {
tracker.reset();
});
describe('Services / Users', () => {
let service: UsersService;
let superUpdateManySpy: SpyInstance;
let checkUniqueEmailsSpy: SpyInstance;
let checkPasswordPolicySpy: SpyInstance;
let checkRemainingAdminExistenceSpy: SpyInstance;
let checkRemainingActiveAdminSpy: SpyInstance;
beforeEach(() => {
service = new UsersService({
knex: db,
schema: {
collections: {
directus_users: {
collection: 'directus_users',
primary: 'id',
singleton: false,
sortField: null,
note: null,
accountability: null,
fields: {
id: {
field: 'id',
defaultValue: null,
nullable: false,
generated: true,
type: 'integer',
dbType: 'integer',
precision: null,
scale: null,
special: [],
note: null,
validation: null,
alias: false,
},
},
},
},
relations: [],
},
});
superUpdateManySpy = vi.spyOn(ItemsService.prototype, 'updateMany');
// "as any" are needed since these are private methods
checkUniqueEmailsSpy = vi
.spyOn(UsersService.prototype as any, 'checkUniqueEmails')
.mockImplementation(() => vi.fn());
checkPasswordPolicySpy = vi
.spyOn(UsersService.prototype as any, 'checkPasswordPolicy')
.mockResolvedValue(() => vi.fn());
checkRemainingAdminExistenceSpy = vi
.spyOn(UsersService.prototype as any, 'checkRemainingAdminExistence')
.mockResolvedValue(() => vi.fn());
checkRemainingActiveAdminSpy = vi
.spyOn(UsersService.prototype as any, 'checkRemainingActiveAdmin')
.mockResolvedValue(() => vi.fn());
});
afterEach(() => {
checkUniqueEmailsSpy.mockClear();
checkPasswordPolicySpy.mockClear();
checkRemainingAdminExistenceSpy.mockClear();
checkRemainingActiveAdminSpy.mockClear();
});
describe('createOne', () => {
it('should not checkUniqueEmails', async () => {
await service.createOne({});
expect(checkUniqueEmailsSpy).not.toBeCalled();
});
it('should checkUniqueEmails once', async () => {
await service.createOne({ email: 'test@example.com' });
expect(checkUniqueEmailsSpy).toBeCalledTimes(1);
});
it('should not checkPasswordPolicy', async () => {
await service.createOne({});
expect(checkPasswordPolicySpy).not.toBeCalled();
});
it('should checkPasswordPolicy once', async () => {
await service.createOne({ password: 'testpassword' });
expect(checkPasswordPolicySpy).toBeCalledTimes(1);
});
});
describe('createMany', () => {
it('should not checkUniqueEmails', async () => {
await service.createMany([{}]);
expect(checkUniqueEmailsSpy).not.toBeCalled();
});
it('should checkUniqueEmails once', async () => {
await service.createMany([{ email: 'test@example.com' }]);
expect(checkUniqueEmailsSpy).toBeCalledTimes(1);
});
it('should not checkPasswordPolicy', async () => {
await service.createMany([{}]);
expect(checkPasswordPolicySpy).not.toBeCalled();
});
it('should checkPasswordPolicy once', async () => {
await service.createMany([{ password: 'testpassword' }]);
expect(checkPasswordPolicySpy).toBeCalledTimes(1);
});
});
describe('updateOne', () => {
it('should not checkRemainingAdminExistence', async () => {
// mock newRole query in updateMany (called by ItemsService updateOne)
tracker.on.select(/select "admin_access" from "directus_roles"/).response({ admin_access: true });
await service.updateOne(1, { role: testRoleId });
expect(checkRemainingAdminExistenceSpy).not.toBeCalled();
});
it('should checkRemainingAdminExistence once', async () => {
// mock newRole query in updateMany (called by ItemsService updateOne)
tracker.on.select(/select "admin_access" from "directus_roles"/).response({ admin_access: false });
await service.updateOne(1, { role: testRoleId });
expect(checkRemainingAdminExistenceSpy).toBeCalledTimes(1);
});
it('should not checkRemainingActiveAdmin', async () => {
await service.updateOne(1, {});
expect(checkRemainingActiveAdminSpy).not.toBeCalled();
});
it('should checkRemainingActiveAdmin once', async () => {
await service.updateOne(1, { status: 'inactive' });
expect(checkRemainingActiveAdminSpy).toBeCalledTimes(1);
});
it('should not checkUniqueEmails', async () => {
await service.updateOne(1, {});
expect(checkUniqueEmailsSpy).not.toBeCalled();
});
it('should checkUniqueEmails once', async () => {
await service.updateOne(1, { email: 'test@example.com' });
expect(checkUniqueEmailsSpy).toBeCalledTimes(1);
});
it('should not checkPasswordPolicy', async () => {
await service.updateOne(1, {});
expect(checkPasswordPolicySpy).not.toBeCalled();
});
it('should checkPasswordPolicy once', async () => {
await service.updateOne(1, { password: 'testpassword' });
expect(checkPasswordPolicySpy).toBeCalledTimes(1);
});
it.each(['provider', 'external_identifier'])(
'should throw InvalidPayloadException for non-admin users when updating "%s" field',
async (field) => {
@@ -113,6 +272,63 @@ describe('Integration Tests', () => {
});
describe('updateMany', () => {
it('should not checkRemainingAdminExistence', async () => {
// mock newRole query in updateMany
tracker.on.select(/select "admin_access" from "directus_roles"/).response({ admin_access: true });
await service.updateMany([1], { role: testRoleId });
expect(checkRemainingAdminExistenceSpy).not.toBeCalled();
});
it('should checkRemainingAdminExistence once', async () => {
// mock newRole query in updateMany
tracker.on.select(/select "admin_access" from "directus_roles"/).response({ admin_access: false });
await service.updateMany([1], { role: testRoleId });
expect(checkRemainingAdminExistenceSpy).toBeCalledTimes(1);
});
it('should not checkRemainingActiveAdmin', async () => {
await service.updateMany([1], {});
expect(checkRemainingActiveAdminSpy).not.toBeCalled();
});
it('should checkRemainingActiveAdmin once', async () => {
await service.updateMany([1], { status: 'inactive' });
expect(checkRemainingActiveAdminSpy).toBeCalledTimes(1);
});
it('should not checkUniqueEmails', async () => {
await service.updateMany([1], {});
expect(checkUniqueEmailsSpy).not.toBeCalled();
});
it('should checkUniqueEmails once', async () => {
await service.updateMany([1], { email: 'test@example.com' });
expect(checkUniqueEmailsSpy).toBeCalledTimes(1);
});
it('should throw RecordNotUniqueException for multiple keys with same email', async () => {
expect.assertions(2); // to ensure both assertions in the catch block are reached
try {
await service.updateMany([1, 2], { email: 'test@example.com' });
} catch (err: any) {
expect(err.message).toBe(`Field "email" has to be unique.`);
expect(err).toBeInstanceOf(RecordNotUniqueException);
}
});
it('should not checkPasswordPolicy', async () => {
await service.updateMany([1], {});
expect(checkPasswordPolicySpy).not.toBeCalled();
});
it('should checkPasswordPolicy once', async () => {
await service.updateMany([1], { password: 'testpassword' });
expect(checkPasswordPolicySpy).toBeCalledTimes(1);
});
it.each(['provider', 'external_identifier'])(
'should throw InvalidPayloadException for non-admin users when updating "%s" field',
async (field) => {
@@ -165,6 +381,81 @@ describe('Integration Tests', () => {
});
describe('updateByQuery', () => {
it('should not checkRemainingAdminExistence', async () => {
// mock newRole query in updateMany (called by ItemsService updateByQuery)
tracker.on.select(/select "admin_access" from "directus_roles"/).response({ admin_access: true });
vi.spyOn(ItemsService.prototype, 'getKeysByQuery').mockResolvedValue([1]);
await service.updateByQuery({}, { role: testRoleId });
expect(checkRemainingAdminExistenceSpy).not.toBeCalled();
});
it('should checkRemainingAdminExistence once', async () => {
// mock newRole query in updateMany (called by ItemsService updateByQuery)
tracker.on.select(/select "admin_access" from "directus_roles"/).response({ admin_access: false });
vi.spyOn(ItemsService.prototype, 'getKeysByQuery').mockResolvedValue([1]);
await service.updateByQuery({}, { role: testRoleId });
expect(checkRemainingAdminExistenceSpy).toBeCalledTimes(1);
});
it('should not checkRemainingActiveAdmin', async () => {
vi.spyOn(ItemsService.prototype, 'getKeysByQuery').mockResolvedValue([1]);
await service.updateByQuery({}, {});
expect(checkRemainingActiveAdminSpy).not.toBeCalled();
});
it('should checkRemainingActiveAdmin once', async () => {
vi.spyOn(ItemsService.prototype, 'getKeysByQuery').mockResolvedValue([1]);
await service.updateByQuery({}, { status: 'inactive' });
expect(checkRemainingActiveAdminSpy).toBeCalledTimes(1);
});
it('should not checkUniqueEmails', async () => {
vi.spyOn(ItemsService.prototype, 'getKeysByQuery').mockResolvedValue([1]);
await service.updateByQuery({}, {});
expect(checkUniqueEmailsSpy).not.toBeCalled();
});
it('should checkUniqueEmails once', async () => {
vi.spyOn(ItemsService.prototype, 'getKeysByQuery').mockResolvedValue([1]);
await service.updateByQuery({}, { email: 'test@example.com' });
expect(checkUniqueEmailsSpy).toBeCalledTimes(1);
});
it('should throw RecordNotUniqueException for multiple keys with same email', async () => {
vi.spyOn(ItemsService.prototype, 'getKeysByQuery').mockResolvedValue([1, 2]);
expect.assertions(2); // to ensure both assertions in the catch block are reached
try {
await service.updateByQuery({}, { email: 'test@example.com' });
} catch (err: any) {
expect(err.message).toBe(`Field "email" has to be unique.`);
expect(err).toBeInstanceOf(RecordNotUniqueException);
}
});
it('should not checkPasswordPolicy', async () => {
vi.spyOn(ItemsService.prototype, 'getKeysByQuery').mockResolvedValue([1]);
await service.updateByQuery({}, {});
expect(checkPasswordPolicySpy).not.toBeCalled();
});
it('should checkPasswordPolicy once', async () => {
vi.spyOn(ItemsService.prototype, 'getKeysByQuery').mockResolvedValue([1]);
await service.updateByQuery({}, { password: 'testpassword' });
expect(checkPasswordPolicySpy).toBeCalledTimes(1);
});
it.each(['provider', 'external_identifier'])(
'should throw InvalidPayloadException for non-admin users when updating "%s" field',
async (field) => {
@@ -174,7 +465,7 @@ describe('Integration Tests', () => {
accountability: { role: 'test', admin: false },
});
vi.spyOn(ItemsService.prototype, 'getKeysByQuery').mockImplementation(vi.fn(() => Promise.resolve([1])));
vi.spyOn(ItemsService.prototype, 'getKeysByQuery').mockResolvedValue([1]);
const promise = service.updateByQuery({}, { [field]: 'test' });
@@ -196,7 +487,7 @@ describe('Integration Tests', () => {
accountability: { role: 'admin', admin: true },
});
vi.spyOn(ItemsService.prototype, 'getKeysByQuery').mockImplementation(vi.fn(() => Promise.resolve([1])));
vi.spyOn(ItemsService.prototype, 'getKeysByQuery').mockResolvedValue([1]);
const promise = service.updateByQuery({}, { [field]: 'test' });
@@ -212,7 +503,7 @@ describe('Integration Tests', () => {
schema: testSchema,
});
vi.spyOn(ItemsService.prototype, 'getKeysByQuery').mockImplementation(vi.fn(() => Promise.resolve([1])));
vi.spyOn(ItemsService.prototype, 'getKeysByQuery').mockResolvedValue([1]);
const promise = service.updateByQuery({}, { [field]: 'test' });
@@ -221,5 +512,29 @@ describe('Integration Tests', () => {
}
);
});
describe('deleteOne', () => {
it('should checkRemainingAdminExistence once', async () => {
await service.deleteOne(1);
expect(checkRemainingAdminExistenceSpy).toBeCalledTimes(1);
});
});
describe('deleteMany', () => {
it('should checkRemainingAdminExistence once', async () => {
await service.deleteMany([1]);
expect(checkRemainingAdminExistenceSpy).toBeCalledTimes(1);
});
});
describe('deleteByQuery', () => {
it('should checkRemainingAdminExistence once', async () => {
// mock return value for the following empty query
vi.spyOn(ItemsService.prototype, 'readByQuery').mockResolvedValue([{ id: 1 }]);
await service.deleteByQuery({});
expect(checkRemainingAdminExistenceSpy).toBeCalledTimes(1);
});
});
});
});

View File

@@ -0,0 +1,118 @@
import knex, { Knex } from 'knex';
import { getTracker, MockClient, Tracker } from 'knex-mock-client';
import { afterEach, beforeAll, beforeEach, describe, expect, it, SpyInstance, vi } from 'vitest';
import { WebhooksService } from '.';
import { getMessenger } from '../messenger';
vi.mock('../../src/database/index', () => {
return { __esModule: true, default: vi.fn(), getDatabaseClient: vi.fn().mockReturnValue('postgres') };
});
vi.mock('../messenger', () => {
return { getMessenger: vi.fn().mockReturnValue({ publish: vi.fn() }) };
});
describe('Integration Tests', () => {
let db: Knex;
let tracker: Tracker;
beforeAll(async () => {
db = knex({ client: MockClient });
tracker = getTracker();
});
beforeEach(() => {
tracker.on.any('directus_webhooks').response({});
});
afterEach(() => {
tracker.reset();
});
describe('Services / Webhooks', () => {
let service: WebhooksService;
let messengerPublishSpy: SpyInstance;
beforeEach(() => {
service = new WebhooksService({
knex: db,
schema: {
collections: {
directus_webhooks: {
collection: 'directus_webhooks',
primary: 'id',
singleton: false,
sortField: null,
note: null,
accountability: null,
fields: {
id: {
field: 'id',
defaultValue: null,
nullable: false,
generated: true,
type: 'integer',
dbType: 'integer',
precision: null,
scale: null,
special: [],
note: null,
validation: null,
alias: false,
},
},
},
},
relations: [],
},
});
messengerPublishSpy = vi.spyOn(getMessenger(), 'publish');
});
afterEach(() => {
messengerPublishSpy.mockClear();
});
describe('createOne', () => {
it('should publish webhooks reload message once', async () => {
await service.createOne({});
expect(messengerPublishSpy).toBeCalledTimes(1);
});
});
describe('createMany', () => {
it('should publish webhooks reload message once', async () => {
await service.createMany([{}]);
expect(messengerPublishSpy).toBeCalledTimes(1);
});
});
describe('updateOne', () => {
it('should publish webhooks reload message once', async () => {
await service.updateOne(1, {});
expect(messengerPublishSpy).toBeCalledTimes(1);
});
});
describe('updateMany', () => {
it('should publish webhooks reload message once', async () => {
await service.updateMany([1], {});
expect(messengerPublishSpy).toBeCalledTimes(1);
});
});
describe('deleteOne', () => {
it('should publish webhooks reload message once', async () => {
await service.deleteOne(1);
expect(messengerPublishSpy).toBeCalledTimes(1);
});
});
describe('deleteMany', () => {
it('should publish webhooks reload message once', async () => {
await service.deleteMany([1]);
expect(messengerPublishSpy).toBeCalledTimes(1);
});
});
});
});

View File

@@ -22,24 +22,12 @@ export class WebhooksService extends ItemsService<Webhook> {
return result;
}
async updateOne(key: PrimaryKey, data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey> {
const result = await super.updateOne(key, data, opts);
this.messenger.publish('webhooks', { type: 'reload' });
return result;
}
async updateMany(keys: PrimaryKey[], data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey[]> {
const result = await super.updateMany(keys, data, opts);
this.messenger.publish('webhooks', { type: 'reload' });
return result;
}
async deleteOne(key: PrimaryKey, opts?: MutationOptions): Promise<PrimaryKey> {
const result = await super.deleteOne(key, opts);
this.messenger.publish('webhooks', { type: 'reload' });
return result;
}
async deleteMany(keys: PrimaryKey[], opts?: MutationOptions): Promise<PrimaryKey[]> {
const result = await super.deleteMany(keys, opts);
this.messenger.publish('webhooks', { type: 'reload' });

View File

@@ -103,12 +103,8 @@ describe('applySnapshot', () => {
// Stop call to db later on in apply-snapshot
vi.spyOn(getSchema, 'getSchema').mockReturnValue(Promise.resolve(snapshotApplyTestSchema));
// We are not actually testing that createOne works, just that is is called correctly
const createOneCollectionSpy = vi
.spyOn(CollectionsService.prototype, 'createOne')
.mockImplementation(vi.fn().mockReturnValue([]));
const createFieldSpy = vi
.spyOn(FieldsService.prototype, 'createField')
.mockImplementation(vi.fn().mockReturnValue([]));
const createOneCollectionSpy = vi.spyOn(CollectionsService.prototype, 'createOne').mockResolvedValue('test');
const createFieldSpy = vi.spyOn(FieldsService.prototype, 'createField').mockResolvedValue();
await applySnapshot(snapshotCreateCollectionNotNested, {
database: db,
@@ -254,12 +250,8 @@ describe('applySnapshot', () => {
// Stop call to db later on in apply-snapshot
vi.spyOn(getSchema, 'getSchema').mockReturnValue(Promise.resolve(snapshotApplyTestSchema));
// We are not actually testing that createOne works, just that is is called correctly
const createOneCollectionSpy = vi
.spyOn(CollectionsService.prototype, 'createOne')
.mockImplementation(vi.fn().mockReturnValue([]));
const createFieldSpy = vi
.spyOn(FieldsService.prototype, 'createField')
.mockImplementation(vi.fn().mockReturnValue([]));
const createOneCollectionSpy = vi.spyOn(CollectionsService.prototype, 'createOne').mockResolvedValue('test');
const createFieldSpy = vi.spyOn(FieldsService.prototype, 'createField').mockResolvedValue();
await applySnapshot(snapshotCreateCollection, {
database: db,
@@ -290,9 +282,7 @@ describe('applySnapshot', () => {
// Stop call to db later on in apply-snapshot
vi.spyOn(getSchema, 'getSchema').mockReturnValue(Promise.resolve(snapshotApplyTestSchema));
// We are not actually testing that deleteOne works, just that is is called correctly
const deleteOneCollectionSpy = vi
.spyOn(CollectionsService.prototype, 'deleteOne')
.mockImplementation(vi.fn().mockReturnValue([]));
const deleteOneCollectionSpy = vi.spyOn(CollectionsService.prototype, 'deleteOne').mockResolvedValue('test');
await applySnapshot(snapshotToApply, {
database: db,