Files
twenty/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts
T
martmull b16ab1b7c9 1518 extensibility front add an application section in settings (#15056)
Protected by IS_APPLICATION_ENABLED featureFlag

Add `Application` section in settings

<img width="301" height="137" alt="image"
src="https://github.com/user-attachments/assets/ee53bdd2-36f6-45c6-8646-17b1e08abf00"
/>


A `settings/applications` route listing all installed applications

<img width="661" height="428" alt="image"
src="https://github.com/user-attachments/assets/69d534c4-4e9e-452a-a3d9-ded0223bb457"
/>

Introduce a new Tag for application managed items

<img width="885" height="759" alt="image"
src="https://github.com/user-attachments/assets/19767be5-61e5-4bd2-a51d-54ed9bfb1923"
/>



A `settings/applications/<application_id>` details setting page listing
all objects, serverlessFunctions and agents created by the application:

<img width="917" height="778" alt="image"
src="https://github.com/user-attachments/assets/7fc056a6-1d73-4242-b2eb-6f8955d8597d"
/>

A `settings/applications/<application_id>/<serverless_function_id>`

<img width="905" height="652" alt="image"
src="https://github.com/user-attachments/assets/56ca0021-26bf-42cb-9abf-34879f16050a"
/>

Add trigger tab in serverless function details (readonly for now)

<img width="899" height="724" alt="image"
src="https://github.com/user-attachments/assets/5eeefa35-f2a4-4fd8-a640-7b5c5891f226"
/>

Set object, serverless and agent setting detail pages readonly for
managed items
<img width="1075" height="859" alt="image"
src="https://github.com/user-attachments/assets/57c73d69-4980-47a2-b752-8dc5ab494530"
/>
<img width="648" height="582" alt="image"
src="https://github.com/user-attachments/assets/5ad5f3f7-3bc3-4e40-870a-4981c6492524"
/>
<img width="982" height="692" alt="image"
src="https://github.com/user-attachments/assets/7ad756c4-5d33-4a0a-9eb8-416c040362b9"
/>
<img width="1077" height="647" alt="image"
src="https://github.com/user-attachments/assets/e086b9f5-4062-4d10-82a9-4023de3cad3f"
/>
2025-10-15 17:13:29 +02:00

378 lines
13 KiB
TypeScript

import { type ObjectsPermissionsDeprecated } from 'twenty-shared/types';
import { EntityManager } from 'typeorm';
import { EntityPersistExecutor } from 'typeorm/persistence/EntityPersistExecutor';
import { PlainObjectToDatabaseEntityTransformer } from 'typeorm/query-builder/transformer/PlainObjectToDatabaseEntityTransformer';
import { type WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/workspace-internal-context.interface';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { type WorkspaceDataSource } from 'src/engine/twenty-orm/datasource/workspace.datasource';
import { validateOperationIsPermittedOrThrow } from 'src/engine/twenty-orm/repository/permissions.utils';
import { WorkspaceEntityManager } from './workspace-entity-manager';
jest.mock('src/engine/twenty-orm/repository/permissions.utils', () => ({
validateOperationIsPermittedOrThrow: jest.fn(),
}));
jest.mock(
'src/engine/twenty-orm/utils/get-object-metadata-from-entity-target.util',
() => ({
getObjectMetadataFromEntityTarget: jest.fn().mockReturnValue({}),
}),
);
jest.mock('src/engine/twenty-orm/utils/format-data.util', () => ({
formatData: jest.fn().mockReturnValue([]),
}));
jest.mock('src/engine/twenty-orm/utils/format-result.util', () => ({
formatResult: jest.fn().mockReturnValue([]),
}));
jest.mock(
'src/engine/twenty-orm/entity-manager/workspace-entity-manager',
() => ({
...jest.requireActual(
'src/engine/twenty-orm/entity-manager/workspace-entity-manager',
),
}),
);
const mockedWorkspaceUpdateQueryBuilder = {
set: jest.fn().mockImplementation(() => ({
where: jest.fn().mockReturnThis(),
whereInIds: jest.fn().mockReturnThis(),
execute: jest
.fn()
.mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }),
returning: jest.fn().mockReturnThis(),
})),
execute: jest
.fn()
.mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }),
};
jest.mock('../repository/workspace-select-query-builder', () => ({
WorkspaceSelectQueryBuilder: jest.fn().mockImplementation(() => ({
where: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([]),
getOne: jest.fn().mockResolvedValue(null),
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
execute: jest
.fn()
.mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }),
setFindOptions: jest.fn().mockReturnThis(),
returning: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnValue(mockedWorkspaceUpdateQueryBuilder),
insert: jest.fn().mockReturnThis(),
})),
}));
describe('WorkspaceEntityManager', () => {
let entityManager: WorkspaceEntityManager;
let mockInternalContext: WorkspaceInternalContext;
let mockDataSource: WorkspaceDataSource;
let mockPermissionOptions: {
shouldBypassPermissionChecks: boolean;
objectRecordsPermissions?: ObjectsPermissionsDeprecated;
};
beforeEach(() => {
mockInternalContext = {
workspaceId: 'test-workspace-id',
objectMetadataMaps: {
byId: {
'test-entity-id': {
id: 'test-entity-id',
nameSingular: 'test-entity',
namePlural: 'test-entities',
labelSingular: 'Test Entity',
labelPlural: 'Test Entities',
workspaceId: 'test-workspace-id',
icon: 'test-icon',
color: 'test-color',
isCustom: false,
isRemote: false,
isAuditLogged: false,
isSearchable: false,
isSystem: false,
isActive: true,
targetTableName: 'test_entity',
indexMetadatas: [],
fieldsById: {
'field-id': {
id: 'field-id',
type: 'TEXT',
name: 'fieldName',
label: 'Field Name',
objectMetadataId: 'test-entity-id',
isNullable: true,
isLabelSyncedWithName: false,
createdAt: new Date(),
updatedAt: new Date(),
},
},
fieldIdByName: { fieldName: 'field-id' },
fieldIdByJoinColumnName: {},
} as unknown as ObjectMetadataItemWithFieldMaps,
},
idByNameSingular: {
'test-entity': 'test-entity-id',
},
},
featureFlagsMap: {
IS_AIRTABLE_INTEGRATION_ENABLED: false,
IS_POSTGRESQL_INTEGRATION_ENABLED: false,
IS_STRIPE_INTEGRATION_ENABLED: false,
IS_UNIQUE_INDEXES_ENABLED: false,
IS_JSON_FILTER_ENABLED: false,
IS_AI_ENABLED: false,
IS_APPLICATION_ENABLED: false,
IS_IMAP_SMTP_CALDAV_ENABLED: false,
IS_MORPH_RELATION_ENABLED: false,
IS_RELATION_CONNECT_ENABLED: false,
IS_CORE_VIEW_SYNCING_ENABLED: false,
IS_CORE_VIEW_ENABLED: false,
IS_WORKSPACE_MIGRATION_V2_ENABLED: false,
IS_PAGE_LAYOUT_ENABLED: false,
IS_MESSAGE_FOLDER_CONTROL_ENABLED: false,
IS_WORKFLOW_ITERATOR_ENABLED: false,
IS_CALENDAR_VIEW_ENABLED: false,
IS_GROUP_BY_ENABLED: true,
IS_PUBLIC_DOMAIN_ENABLED: false,
IS_EMAILING_DOMAIN_ENABLED: false,
IS_DYNAMIC_SEARCH_FIELDS_ENABLED: false,
IS_COMMON_API_ENABLED: false,
},
eventEmitterService: {
emitMutationEvent: jest.fn(),
emitDatabaseBatchEvent: jest.fn(),
emitCustomBatchEvent: jest.fn(),
} as any,
} as WorkspaceInternalContext;
mockDataSource = {
featureFlagMap: {
IS_AIRTABLE_INTEGRATION_ENABLED: false,
IS_POSTGRESQL_INTEGRATION_ENABLED: false,
IS_STRIPE_INTEGRATION_ENABLED: false,
IS_UNIQUE_INDEXES_ENABLED: false,
IS_JSON_FILTER_ENABLED: false,
IS_AI_ENABLED: false,
IS_APPLICATION_ENABLED: false,
IS_IMAP_SMTP_CALDAV_ENABLED: false,
IS_MORPH_RELATION_ENABLED: false,
IS_RELATION_CONNECT_ENABLED: false,
IS_CORE_VIEW_SYNCING_ENABLED: false,
IS_CORE_VIEW_ENABLED: false,
IS_WORKSPACE_MIGRATION_V2_ENABLED: false,
IS_PAGE_LAYOUT_ENABLED: false,
IS_WORKFLOW_ITERATOR_ENABLED: false,
},
permissionsPerRoleId: {},
} as WorkspaceDataSource;
mockPermissionOptions = {
shouldBypassPermissionChecks: false,
objectRecordsPermissions: {
'test-entity': {
canRead: true,
canUpdate: false,
canSoftDelete: false,
canDestroy: false,
restrictedFields: {},
},
},
};
// Mock TypeORM connection methods
const mockWorkspaceDataSource = {
getMetadata: jest.fn().mockReturnValue({
name: 'test-entity',
columns: [],
relations: [],
findInheritanceMetadata: jest.fn(),
findColumnWithPropertyPath: jest.fn(),
}),
createQueryBuilder: jest.fn().mockReturnValue({
delete: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
execute: jest
.fn()
.mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }),
getMany: jest.fn().mockResolvedValue([]),
getOne: jest.fn().mockResolvedValue(null),
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
update: jest.fn().mockReturnThis(),
softDelete: jest.fn().mockReturnThis(),
restore: jest.fn().mockReturnThis(),
}),
createQueryRunner: jest.fn().mockReturnValue({
connect: jest.fn(),
startTransaction: jest.fn(),
commitTransaction: jest.fn(),
rollbackTransaction: jest.fn(),
release: jest.fn(),
clearTable: jest.fn(),
}),
createQueryRunnerForEntityPersistExecutor: jest.fn().mockReturnValue({
connect: jest.fn(),
startTransaction: jest.fn(),
commitTransaction: jest.fn(),
rollbackTransaction: jest.fn(),
release: jest.fn(),
clearTable: jest.fn(),
}),
};
entityManager = new WorkspaceEntityManager(
mockInternalContext,
mockDataSource,
);
Object.defineProperty(entityManager, 'connection', {
get: () => mockWorkspaceDataSource,
});
jest.spyOn(entityManager as any, 'validatePermissions');
jest.spyOn(entityManager as any, 'createQueryBuilder');
jest
.spyOn(entityManager as any, 'getFormattedResultWithoutNonReadableFields')
.mockImplementation(
({ formattedResult }: { formattedResult: string[] }) => formattedResult,
);
jest
.spyOn(entityManager as any, 'extractTargetNameSingularFromEntityTarget')
.mockImplementation((entityName: string) => {
return entityName;
});
// Mock typeORM's EntityManager methods
jest
.spyOn(EntityManager.prototype, 'save')
.mockImplementation(() => Promise.resolve({}));
jest
.spyOn(EntityManager.prototype, 'update')
.mockImplementation(() =>
Promise.resolve({ affected: 1, raw: [], generatedMaps: [] }),
);
jest
.spyOn(EntityManager.prototype, 'restore')
.mockImplementation(() =>
Promise.resolve({ affected: 1, raw: [], generatedMaps: [] }),
);
jest
.spyOn(EntityManager.prototype, 'clear')
.mockImplementation(() => Promise.resolve());
jest
.spyOn(EntityManager.prototype, 'preload')
.mockImplementation(() => Promise.resolve({}));
jest
.spyOn(EntityPersistExecutor.prototype, 'execute')
.mockImplementation(() => Promise.resolve());
jest
.spyOn(PlainObjectToDatabaseEntityTransformer.prototype, 'transform')
.mockImplementation(() => Promise.resolve({}));
// Mock metadata methods
const mockMetadata = {
hasAllPrimaryKeys: jest.fn().mockReturnValue(true),
columns: [],
relations: [],
findInheritanceMetadata: jest.fn(),
findColumnWithPropertyPath: jest.fn(),
};
// Update mockWorkspaceDataSource to include metadata
mockWorkspaceDataSource.getMetadata = jest
.fn()
.mockReturnValue(mockMetadata);
// Reset the mock before each test
jest.clearAllMocks();
});
describe('Query Method', () => {
it('should call validatePermissions and validateOperationIsPermittedOrThrow for find', async () => {
await entityManager.find('test-entity', {}, mockPermissionOptions);
expect(entityManager.createQueryBuilder).toHaveBeenCalledWith(
'test-entity',
undefined,
undefined,
mockPermissionOptions,
);
});
it('should throw error for query', async () => {
expect(() => entityManager.query('SELECT * FROM test')).toThrow(
'Method not allowed.',
);
});
});
describe('Save Methods', () => {
it('should call validatePermissions and validateOperationIsPermittedOrThrow for save', async () => {
await entityManager.save(
'test-entity',
{},
{ reload: false },
mockPermissionOptions,
);
expect(entityManager['validatePermissions']).toHaveBeenCalledWith({
target: 'test-entity',
operationType: 'update',
permissionOptions: mockPermissionOptions,
selectedColumns: [],
updatedColumns: [],
});
expect(validateOperationIsPermittedOrThrow).toHaveBeenCalledWith({
entityName: 'test-entity',
operationType: 'update',
objectMetadataMaps: mockInternalContext.objectMetadataMaps,
objectsPermissions: mockPermissionOptions.objectRecordsPermissions,
selectedColumns: [],
allFieldsSelected: false,
updatedColumns: [],
});
});
});
describe('Update Methods', () => {
it('should call createQueryBuilder with permissionOptions for update', async () => {
await entityManager.update('test-entity', {}, {}, mockPermissionOptions);
expect(entityManager['createQueryBuilder']).toHaveBeenCalledWith(
'test-entity',
undefined,
undefined,
mockPermissionOptions,
);
});
});
describe('Other Methods', () => {
it('should call validatePermissions and validateOperationIsPermittedOrThrow for clear', async () => {
await entityManager.clear('test-entity', mockPermissionOptions);
expect(entityManager['validatePermissions']).toHaveBeenCalledWith({
target: 'test-entity',
operationType: 'delete',
permissionOptions: mockPermissionOptions,
selectedColumns: [],
});
expect(validateOperationIsPermittedOrThrow).toHaveBeenCalledWith({
entityName: 'test-entity',
operationType: 'delete',
objectMetadataMaps: mockInternalContext.objectMetadataMaps,
objectsPermissions: mockPermissionOptions.objectRecordsPermissions,
selectedColumns: [],
allFieldsSelected: false,
updatedColumns: [],
});
});
});
});