Add manifest integration tests (#18203)

- add integration test on sync manifest endpoint
- fix invalid standard uuid + migration command
This commit is contained in:
martmull
2026-02-25 10:37:50 +01:00
committed by GitHub
parent b84a8588be
commit f080b952eb
30 changed files with 1916 additions and 473 deletions
@@ -79,80 +79,6 @@ exports[`Sync application should fail due to object system fields integrity shou
}
`;
exports[`Sync application should fail due to object system fields integrity when object has createdAt field with wrong type (TEXT instead of DATE_TIME) 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"fieldMetadata": [
{
"errors": [
{
"code": "OBJECT_METADATA_NOT_FOUND",
"message": "Object metadata not found",
"userFriendlyMessage": "Field to create related object not found",
},
],
"flatEntityMinimalInformation": {
"name": "title",
"objectMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "fieldMetadata",
"status": "fail",
"type": "create",
},
{
"errors": [
{
"code": "OBJECT_METADATA_NOT_FOUND",
"message": "Object metadata not found",
"userFriendlyMessage": "Field to create related object not found",
},
],
"flatEntityMinimalInformation": {
"name": "createdAt",
"objectMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "fieldMetadata",
"status": "fail",
"type": "create",
},
],
"objectMetadata": [
{
"errors": [
{
"code": "INVALID_OBJECT_INPUT",
"message": "Names are not synced with labels",
"userFriendlyMessage": "Names are not synced with labels",
},
],
"flatEntityMinimalInformation": {
"namePlural": "wrongCreatedAtObjects",
"nameSingular": "wrongCreatedAtObject",
"universalIdentifier": Any<String>,
},
"metadataName": "objectMetadata",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 2 fieldMetadatas, 1 objectMetadata",
"summary": {
"fieldMetadata": 2,
"objectMetadata": 1,
"totalErrors": 3,
},
"userFriendlyMessage": "Metadata validation failed",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
exports[`Sync application should fail due to object system fields integrity when object has id field with wrong type (TEXT instead of UUID) 1`] = `
{
"extensions": {
@@ -389,3 +315,82 @@ exports[`Sync application should fail due to object system fields integrity when
"name": "GraphQLError",
}
`;
exports[`Sync application should fail due to object system fields integrity when object miss default fields 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"objectMetadata": [
{
"errors": [
{
"code": "MISSING_SYSTEM_FIELD",
"message": "System field id is missing",
"userFriendlyMessage": "System field id is missing",
"value": "id",
},
{
"code": "MISSING_SYSTEM_FIELD",
"message": "System field createdAt is missing",
"userFriendlyMessage": "System field createdAt is missing",
"value": "createdAt",
},
{
"code": "MISSING_SYSTEM_FIELD",
"message": "System field updatedAt is missing",
"userFriendlyMessage": "System field updatedAt is missing",
"value": "updatedAt",
},
{
"code": "MISSING_SYSTEM_FIELD",
"message": "System field deletedAt is missing",
"userFriendlyMessage": "System field deletedAt is missing",
"value": "deletedAt",
},
{
"code": "MISSING_SYSTEM_FIELD",
"message": "System field createdBy is missing",
"userFriendlyMessage": "System field createdBy is missing",
"value": "createdBy",
},
{
"code": "MISSING_SYSTEM_FIELD",
"message": "System field updatedBy is missing",
"userFriendlyMessage": "System field updatedBy is missing",
"value": "updatedBy",
},
{
"code": "MISSING_SYSTEM_FIELD",
"message": "System field position is missing",
"userFriendlyMessage": "System field position is missing",
"value": "position",
},
{
"code": "MISSING_SYSTEM_FIELD",
"message": "System field searchVector is missing",
"userFriendlyMessage": "System field searchVector is missing",
"value": "searchVector",
},
],
"flatEntityMinimalInformation": {
"namePlural": "wrongCreatedAtObjects",
"nameSingular": "wrongCreatedAtObject",
"universalIdentifier": Any<String>,
},
"metadataName": "objectMetadata",
"type": "create",
},
],
},
"message": "Validation failed for 1 objectMetadata",
"summary": {
"objectMetadata": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "Metadata validation failed",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
@@ -78,7 +78,7 @@ exports[`syncApplication should return workspace migration actions on initial sy
"isActive": true,
"isAuditLogged": true,
"isCustom": true,
"isLabelSyncedWithName": true,
"isLabelSyncedWithName": false,
"isRemote": false,
"isSearchable": false,
"isSystem": false,
@@ -364,81 +364,6 @@ exports[`syncApplication should return workspace migration actions on initial sy
"metadataName": "role",
"type": "create",
},
],
"applicationUniversalIdentifier": Any<String>,
},
}
`;
exports[`syncApplication should return workspace migration actions on initial sync then on second sync with field rename and new role 2`] = `
{
"syncApplication": {
"actions": [
{
"metadataName": "fieldMetadata",
"type": "update",
"universalIdentifier": Any<String>,
"update": {
"label": "Body",
"name": "body",
},
},
{
"flatEntity": {
"applicationUniversalIdentifier": Any<String>,
"canAccessAllTools": false,
"canBeAssignedToAgents": true,
"canBeAssignedToApiKeys": true,
"canBeAssignedToUsers": true,
"canDestroyAllObjectRecords": false,
"canReadAllObjectRecords": false,
"canSoftDeleteAllObjectRecords": false,
"canUpdateAllObjectRecords": false,
"canUpdateAllSettings": false,
"createdAt": Any<String>,
"description": "A read-only role",
"icon": null,
"isEditable": true,
"label": "Viewer Role",
"universalIdentifier": Any<String>,
"updatedAt": Any<String>,
},
"metadataName": "role",
"type": "create",
},
],
"applicationUniversalIdentifier": Any<String>,
},
}
`;
exports[`syncApplication should sync a skill then update it on second sync 1`] = `
{
"syncApplication": {
"actions": [
{
"flatEntity": {
"applicationUniversalIdentifier": Any<String>,
"canAccessAllTools": false,
"canBeAssignedToAgents": true,
"canBeAssignedToApiKeys": true,
"canBeAssignedToUsers": true,
"canDestroyAllObjectRecords": false,
"canReadAllObjectRecords": false,
"canSoftDeleteAllObjectRecords": false,
"canUpdateAllObjectRecords": false,
"canUpdateAllSettings": false,
"createdAt": Any<String>,
"description": "A test role",
"icon": null,
"isEditable": true,
"label": "Test Role",
"universalIdentifier": Any<String>,
"updatedAt": Any<String>,
},
"metadataName": "role",
"type": "create",
},
{
"flatEntity": {
"applicationUniversalIdentifier": Any<String>,
@@ -463,25 +388,3 @@ This is a test skill.",
},
}
`;
exports[`syncApplication should sync a skill then update it on second sync 2`] = `
{
"syncApplication": {
"actions": [
{
"metadataName": "skill",
"type": "update",
"universalIdentifier": Any<String>,
"update": {
"content": "# Test Skill
This is an updated test skill with more content.",
"description": "An updated skill for testing",
"label": "Test Skill Updated",
},
},
],
"applicationUniversalIdentifier": Any<String>,
},
}
`;
@@ -1,4 +1,5 @@
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { buildDefaultObjectManifest } from 'test/integration/metadata/suites/application/utils/build-default-object-manifest.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
@@ -20,37 +21,12 @@ type TestContext = {
type SyncApplicationTestingContext = EachTestingContext<TestContext>[];
const buildBaseManifest = (
overrides: Pick<Manifest, 'objects' | 'fields'>,
): Manifest => ({
application: {
apiClientChecksum: '',
marketplaceData: undefined,
universalIdentifier: TEST_APP_ID,
defaultRoleUniversalIdentifier: TEST_ROLE_ID,
displayName: 'Test System Fields App',
description: 'App for testing system field validation',
icon: 'IconTestPipe',
applicationVariables: {},
packageJsonChecksum: null,
yarnLockChecksum: null,
},
roles: [
{
universalIdentifier: TEST_ROLE_ID,
label: 'Test Role',
description: 'A test role',
},
],
logicFunctions: [],
frontComponents: [],
publicAssets: [],
views: [],
navigationMenuItems: [],
pageLayouts: [],
skills: [],
...overrides,
});
const buildManifest = (overrides: Pick<Manifest, 'objects' | 'fields'>) =>
buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides,
});
const buildObjectWithLabelField = ({
nameSingular,
@@ -104,7 +80,7 @@ const failingSyncApplicationSystemFieldsTestCases: SyncApplicationTestingContext
title:
'when object is created without any system fields (missing all 8 system fields)',
context: {
manifest: buildBaseManifest(
manifest: buildManifest(
buildObjectWithLabelField({
nameSingular: 'noSystemFieldsObject',
namePlural: 'noSystemFieldsObjects',
@@ -118,7 +94,7 @@ const failingSyncApplicationSystemFieldsTestCases: SyncApplicationTestingContext
{
title: 'when object has id field with wrong type (TEXT instead of UUID)',
context: {
manifest: buildBaseManifest(
manifest: buildManifest(
buildObjectWithLabelField({
nameSingular: 'wrongIdTypeObject',
namePlural: 'wrongIdTypeObjects',
@@ -140,26 +116,15 @@ const failingSyncApplicationSystemFieldsTestCases: SyncApplicationTestingContext
},
},
{
title:
'when object has createdAt field with wrong type (TEXT instead of DATE_TIME)',
title: 'when object miss default fields',
context: {
manifest: buildBaseManifest(
manifest: buildManifest(
buildObjectWithLabelField({
nameSingular: 'wrongCreatedAtObject',
namePlural: 'wrongCreatedAtObjects',
labelSingular: 'Wrong CreatedAt Object',
labelPlural: 'Wrong CreatedAt Objects',
description: 'Object with wrong createdAt field type',
additionalFields: [
{
universalIdentifier: uuidv4(),
type: FieldMetadataType.TEXT,
name: 'createdAt',
label: 'Created At',
description: 'Created at field with wrong type',
icon: 'IconCalendar',
},
],
}),
),
},
@@ -168,7 +133,7 @@ const failingSyncApplicationSystemFieldsTestCases: SyncApplicationTestingContext
title:
'when object has position field with wrong type (TEXT instead of POSITION)',
context: {
manifest: buildBaseManifest(
manifest: buildManifest(
buildObjectWithLabelField({
nameSingular: 'wrongPositionObject',
namePlural: 'wrongPositionObjects',
@@ -240,7 +205,7 @@ describe('Sync application should fail due to object system fields integrity', (
description: 'Object for testing system field deletion',
});
const validManifest = buildBaseManifest({
const validManifest = buildManifest({
objects: [testObject],
fields: [],
});
@@ -250,7 +215,7 @@ describe('Sync application should fail due to object system fields integrity', (
expectToFail: false,
});
const manifestWithDeletedIdField = buildBaseManifest({
const manifestWithDeletedIdField = buildManifest({
objects: [
{
...testObject,
@@ -277,7 +242,7 @@ describe('Sync application should fail due to object system fields integrity', (
description: 'Object for testing system field update',
});
const validManifest = buildBaseManifest({
const validManifest = buildManifest({
objects: [testObject],
fields: [],
});
@@ -287,7 +252,7 @@ describe('Sync application should fail due to object system fields integrity', (
expectToFail: false,
});
const manifestWithUpdatedIdField = buildBaseManifest({
const manifestWithUpdatedIdField = buildManifest({
objects: [
{
...testObject,
@@ -0,0 +1,243 @@
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { buildDefaultObjectManifest } from 'test/integration/metadata/suites/application/utils/build-default-object-manifest.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { uninstallApplication } from 'test/integration/metadata/suites/application/utils/uninstall-application.util';
import { type Manifest } from 'twenty-shared/application';
import { FieldMetadataType } from 'twenty-shared/types';
import { v4 as uuidv4 } from 'uuid';
import { findManyObjectMetadataWithIndexes } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata-with-indexes.util';
const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
const TEST_FIELD_ID = uuidv4();
const TEST_SECOND_FIELD_ID = uuidv4();
const TEST_OBJECT = buildDefaultObjectManifest({
nameSingular: 'ticket',
namePlural: 'tickets',
labelSingular: 'Ticket',
labelPlural: 'Tickets',
description: 'A support ticket',
icon: 'IconTicket',
});
const buildManifest = (overrides?: Partial<Pick<Manifest, 'fields'>>) =>
buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: { objects: [TEST_OBJECT], ...overrides },
});
const findObjectFields = async () => {
const objects = await findManyObjectMetadataWithIndexes({
expectToFail: false,
});
const object = objects.find(
(o) => o.universalIdentifier === TEST_OBJECT.universalIdentifier,
);
return object?.fieldsList ?? [];
};
describe('Manifest update - fields', () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Application',
description: 'App for testing field manifest updates',
sourcePath: 'test-manifest-update-field',
});
}, 60000);
afterEach(async () => {
await uninstallApplication({
universalIdentifier: TEST_APP_ID,
expectToFail: false,
});
});
it('should create a new field when added to manifest on second sync', async () => {
await syncApplication({
manifest: buildManifest({ fields: [] }),
expectToFail: false,
});
const fieldsAfterFirstSync = await findObjectFields();
const descriptionBefore = fieldsAfterFirstSync.find(
(field: { name: string }) => field.name === 'description',
);
expect(descriptionBefore).toBeUndefined();
await syncApplication({
manifest: buildManifest({
fields: [
{
universalIdentifier: TEST_FIELD_ID,
type: FieldMetadataType.TEXT,
name: 'description',
label: 'Description',
description: 'Ticket description',
icon: 'IconFileDescription',
objectUniversalIdentifier: TEST_OBJECT.universalIdentifier,
},
],
}),
expectToFail: false,
});
const fieldsAfterSecondSync = await findObjectFields();
const descriptionAfter = fieldsAfterSecondSync.find(
(field: { name: string }) => field.name === 'description',
);
expect(descriptionAfter).toBeDefined();
expect(descriptionAfter).toMatchObject({
name: 'description',
label: 'Description',
type: FieldMetadataType.TEXT,
description: 'Ticket description',
icon: 'IconFileDescription',
});
}, 60000);
it('should update a field when properties change in manifest on second sync', async () => {
await syncApplication({
manifest: buildManifest({
fields: [
{
universalIdentifier: TEST_FIELD_ID,
type: FieldMetadataType.TEXT,
name: 'description',
label: 'Description',
description: 'Ticket description',
icon: 'IconFileDescription',
objectUniversalIdentifier: TEST_OBJECT.universalIdentifier,
},
],
}),
expectToFail: false,
});
const fieldsAfterFirstSync = await findObjectFields();
const fieldBefore = fieldsAfterFirstSync.find(
(field: { name: string }) => field.name === 'description',
);
expect(fieldBefore).toBeDefined();
expect(fieldBefore).toMatchObject({
name: 'description',
label: 'Description',
});
await syncApplication({
manifest: buildManifest({
fields: [
{
universalIdentifier: TEST_FIELD_ID,
type: FieldMetadataType.TEXT,
name: 'body',
label: 'Body',
description: 'Ticket body content',
icon: 'IconFileDescription',
objectUniversalIdentifier: TEST_OBJECT.universalIdentifier,
},
],
}),
expectToFail: false,
});
const fieldsAfterSecondSync = await findObjectFields();
const renamedField = fieldsAfterSecondSync.find(
(field: { name: string }) => field.name === 'body',
);
expect(renamedField).toBeDefined();
expect(renamedField).toMatchObject({
name: 'body',
label: 'Body',
type: FieldMetadataType.TEXT,
description: 'Ticket body content',
});
const oldField = fieldsAfterSecondSync.find(
(field: { name: string }) => field.name === 'description',
);
expect(oldField).toBeUndefined();
}, 60000);
it('should delete a field when removed from manifest on second sync', async () => {
await syncApplication({
manifest: buildManifest({
fields: [
{
universalIdentifier: TEST_FIELD_ID,
type: FieldMetadataType.TEXT,
name: 'description',
label: 'Description',
description: 'Ticket description',
icon: 'IconFileDescription',
objectUniversalIdentifier: TEST_OBJECT.universalIdentifier,
},
{
universalIdentifier: TEST_SECOND_FIELD_ID,
type: FieldMetadataType.TEXT,
name: 'priority',
label: 'Priority',
description: 'Ticket priority',
icon: 'IconFlag',
objectUniversalIdentifier: TEST_OBJECT.universalIdentifier,
},
],
}),
expectToFail: false,
});
const fieldsAfterFirstSync = await findObjectFields();
expect(
fieldsAfterFirstSync.find(
(field: { name: string }) => field.name === 'description',
),
).toBeDefined();
expect(
fieldsAfterFirstSync.find(
(field: { name: string }) => field.name === 'priority',
),
).toBeDefined();
await syncApplication({
manifest: buildManifest({
fields: [
{
universalIdentifier: TEST_FIELD_ID,
type: FieldMetadataType.TEXT,
name: 'description',
label: 'Description',
description: 'Ticket description',
icon: 'IconFileDescription',
objectUniversalIdentifier: TEST_OBJECT.universalIdentifier,
},
],
}),
expectToFail: false,
});
const fieldsAfterSecondSync = await findObjectFields();
expect(
fieldsAfterSecondSync.find(
(field: { name: string }) => field.name === 'description',
),
).toBeDefined();
expect(
fieldsAfterSecondSync.find(
(field: { name: string }) => field.name === 'priority',
),
).toBeUndefined();
}, 60000);
});
@@ -0,0 +1,218 @@
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { buildDefaultObjectManifest } from 'test/integration/metadata/suites/application/utils/build-default-object-manifest.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { uninstallApplication } from 'test/integration/metadata/suites/application/utils/uninstall-application.util';
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
import { type Manifest } from 'twenty-shared/application';
import { v4 as uuidv4 } from 'uuid';
const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
const buildManifest = (
overrides?: Partial<Pick<Manifest, 'objects' | 'fields'>>,
) => buildBaseManifest({ appId: TEST_APP_ID, roleId: TEST_ROLE_ID, overrides });
const OBJECT_GQL_FIELDS =
'id nameSingular namePlural labelSingular labelPlural description icon isCustom isActive';
const findCustomObjects = async () => {
const { objects } = await findManyObjectMetadata({
input: {
filter: { isCustom: { is: true } },
paging: { first: 100 },
},
gqlFields: OBJECT_GQL_FIELDS,
expectToFail: false,
});
return objects;
};
describe('Manifest update - objects', () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Application',
description: 'App for testing object manifest updates',
sourcePath: 'test-manifest-update-object',
});
}, 60000);
afterEach(async () => {
await uninstallApplication({
universalIdentifier: TEST_APP_ID,
expectToFail: false,
});
});
it('should create a new object when added to manifest on second sync', async () => {
const ticketObject = buildDefaultObjectManifest({
nameSingular: 'ticket',
namePlural: 'tickets',
labelSingular: 'Ticket',
labelPlural: 'Tickets',
description: 'A support ticket',
icon: 'IconTicket',
});
await syncApplication({
manifest: buildManifest({ objects: [ticketObject] }),
expectToFail: false,
});
const objectsAfterFirstSync = await findCustomObjects();
const ticket = objectsAfterFirstSync.find(
(obj) => obj.nameSingular === 'ticket',
);
expect(ticket).toBeDefined();
expect(ticket).toMatchObject({
nameSingular: 'ticket',
labelSingular: 'Ticket',
description: 'A support ticket',
icon: 'IconTicket',
});
const invoiceObject = buildDefaultObjectManifest({
nameSingular: 'invoice',
namePlural: 'invoices',
labelSingular: 'Invoice',
labelPlural: 'Invoices',
description: 'A billing invoice',
icon: 'IconFileInvoice',
});
await syncApplication({
manifest: buildManifest({
objects: [ticketObject, invoiceObject],
}),
expectToFail: false,
});
const objectsAfterSecondSync = await findCustomObjects();
const ticketAfter = objectsAfterSecondSync.find(
(obj) => obj.nameSingular === 'ticket',
);
const invoiceAfter = objectsAfterSecondSync.find(
(obj) => obj.nameSingular === 'invoice',
);
expect(ticketAfter).toBeDefined();
expect(invoiceAfter).toBeDefined();
expect(invoiceAfter).toMatchObject({
nameSingular: 'invoice',
namePlural: 'invoices',
labelSingular: 'Invoice',
labelPlural: 'Invoices',
description: 'A billing invoice',
icon: 'IconFileInvoice',
isCustom: true,
});
}, 60000);
it('should update object properties when changed in manifest on second sync', async () => {
const ticketObject = buildDefaultObjectManifest({
nameSingular: 'ticket',
namePlural: 'tickets',
labelSingular: 'Ticket',
labelPlural: 'Tickets',
description: 'A support ticket',
icon: 'IconTicket',
});
await syncApplication({
manifest: buildManifest({ objects: [ticketObject] }),
expectToFail: false,
});
const objectsAfterFirstSync = await findCustomObjects();
const ticketBefore = objectsAfterFirstSync.find(
(obj) => obj.nameSingular === 'ticket',
);
expect(ticketBefore).toMatchObject({
labelSingular: 'Ticket',
description: 'A support ticket',
icon: 'IconTicket',
});
const updatedTicketObject = {
...ticketObject,
labelSingular: 'Support Ticket',
labelPlural: 'Support Tickets',
description: 'A customer support ticket',
icon: 'IconHeadset',
};
await syncApplication({
manifest: buildManifest({ objects: [updatedTicketObject] }),
expectToFail: false,
});
const objectsAfterSecondSync = await findCustomObjects();
const ticketAfter = objectsAfterSecondSync.find(
(obj) => obj.nameSingular === 'ticket',
);
expect(ticketAfter).toBeDefined();
expect(ticketAfter).toMatchObject({
nameSingular: 'ticket',
labelSingular: 'Support Ticket',
labelPlural: 'Support Tickets',
description: 'A customer support ticket',
icon: 'IconHeadset',
});
}, 60000);
it('should delete an object when removed from manifest on second sync', async () => {
const ticketObject = buildDefaultObjectManifest({
nameSingular: 'ticket',
namePlural: 'tickets',
labelSingular: 'Ticket',
labelPlural: 'Tickets',
description: 'A support ticket',
icon: 'IconTicket',
});
const invoiceObject = buildDefaultObjectManifest({
nameSingular: 'invoice',
namePlural: 'invoices',
labelSingular: 'Invoice',
labelPlural: 'Invoices',
description: 'A billing invoice',
icon: 'IconFileInvoice',
});
await syncApplication({
manifest: buildManifest({
objects: [ticketObject, invoiceObject],
}),
expectToFail: false,
});
const objectsAfterFirstSync = await findCustomObjects();
expect(
objectsAfterFirstSync.find((obj) => obj.nameSingular === 'ticket'),
).toBeDefined();
expect(
objectsAfterFirstSync.find((obj) => obj.nameSingular === 'invoice'),
).toBeDefined();
await syncApplication({
manifest: buildManifest({ objects: [ticketObject] }),
expectToFail: false,
});
const objectsAfterSecondSync = await findCustomObjects();
expect(
objectsAfterSecondSync.find((obj) => obj.nameSingular === 'ticket'),
).toBeDefined();
expect(
objectsAfterSecondSync.find((obj) => obj.nameSingular === 'invoice'),
).toBeUndefined();
}, 60000);
});
@@ -0,0 +1,187 @@
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { uninstallApplication } from 'test/integration/metadata/suites/application/utils/uninstall-application.util';
import { findRoles } from 'test/integration/metadata/suites/role/utils/find-roles.util';
import { type Manifest } from 'twenty-shared/application';
import { v4 as uuidv4 } from 'uuid';
const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
const TEST_SECOND_ROLE_ID = uuidv4();
const buildManifest = (overrides?: Partial<Pick<Manifest, 'roles'>>) =>
buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
roles: [
{
universalIdentifier: TEST_ROLE_ID,
label: 'Editor',
description: 'Can edit',
},
],
...overrides,
},
});
const findAppRoles = async () => {
const { data } = await findRoles({
gqlFields: 'id label description universalIdentifier',
expectToFail: false,
});
return data.getRoles.filter(
(role) =>
role.universalIdentifier === TEST_ROLE_ID ||
role.universalIdentifier === TEST_SECOND_ROLE_ID,
);
};
describe('Manifest update - roles', () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Application',
description: 'App for testing role manifest updates',
sourcePath: 'test-manifest-update-role',
});
}, 60000);
afterEach(async () => {
await uninstallApplication({
universalIdentifier: TEST_APP_ID,
expectToFail: false,
});
});
it('should create a new role when added to manifest on second sync', async () => {
await syncApplication({
manifest: buildManifest({}),
expectToFail: false,
});
const rolesAfterFirstSync = await findAppRoles();
expect(rolesAfterFirstSync).toHaveLength(1);
expect(rolesAfterFirstSync[0]).toMatchObject({
label: 'Editor',
description: 'Can edit',
});
await syncApplication({
manifest: buildManifest({
roles: [
{
universalIdentifier: TEST_ROLE_ID,
label: 'Editor',
description: 'Can edit',
},
{
universalIdentifier: TEST_SECOND_ROLE_ID,
label: 'Viewer',
description: 'Read-only access',
},
],
}),
expectToFail: false,
});
const rolesAfterSecondSync = await findAppRoles();
expect(rolesAfterSecondSync).toHaveLength(2);
expect(
rolesAfterSecondSync.find((r) => r.label === 'Editor'),
).toBeDefined();
expect(
rolesAfterSecondSync.find((r) => r.label === 'Viewer'),
).toBeDefined();
expect(
rolesAfterSecondSync.find((r) => r.label === 'Viewer'),
).toMatchObject({
description: 'Read-only access',
});
}, 60000);
it('should update a role when properties change in manifest on second sync', async () => {
await syncApplication({
manifest: buildManifest({}),
expectToFail: false,
});
const rolesAfterFirstSync = await findAppRoles();
expect(rolesAfterFirstSync).toHaveLength(1);
expect(rolesAfterFirstSync[0]).toMatchObject({
label: 'Editor',
description: 'Can edit',
});
await syncApplication({
manifest: buildManifest({
roles: [
{
universalIdentifier: TEST_ROLE_ID,
label: 'Senior Editor',
description: 'Can edit everything',
},
],
}),
expectToFail: false,
});
const rolesAfterSecondSync = await findAppRoles();
expect(rolesAfterSecondSync).toHaveLength(1);
expect(rolesAfterSecondSync[0]).toMatchObject({
label: 'Senior Editor',
description: 'Can edit everything',
});
}, 60000);
it('should delete a role when removed from manifest on second sync', async () => {
await syncApplication({
manifest: buildManifest({
roles: [
{
universalIdentifier: TEST_ROLE_ID,
label: 'Editor',
description: 'Can edit',
},
{
universalIdentifier: TEST_SECOND_ROLE_ID,
label: 'Viewer',
description: 'Read-only access',
},
],
}),
expectToFail: false,
});
const rolesAfterFirstSync = await findAppRoles();
expect(rolesAfterFirstSync).toHaveLength(2);
await syncApplication({
manifest: buildManifest({
roles: [
{
universalIdentifier: TEST_ROLE_ID,
label: 'Editor',
description: 'Can edit',
},
],
}),
expectToFail: false,
});
const rolesAfterSecondSync = await findAppRoles();
expect(rolesAfterSecondSync).toHaveLength(1);
expect(rolesAfterSecondSync[0]).toMatchObject({
label: 'Editor',
description: 'Can edit',
});
}, 60000);
});
@@ -0,0 +1,202 @@
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { uninstallApplication } from 'test/integration/metadata/suites/application/utils/uninstall-application.util';
import { findSkills } from 'test/integration/metadata/suites/skill/utils/find-skills.util';
import { type Manifest } from 'twenty-shared/application';
import { v4 as uuidv4 } from 'uuid';
const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
const TEST_SKILL_ID = uuidv4();
const TEST_SECOND_SKILL_ID = uuidv4();
const SKILL_GQL_FIELDS = 'id name label description content icon applicationId';
const buildManifest = (overrides?: Partial<Pick<Manifest, 'skills'>>) =>
buildBaseManifest({ appId: TEST_APP_ID, roleId: TEST_ROLE_ID, overrides });
const findAppSkills = async () => {
const { data } = await findSkills({
gqlFields: SKILL_GQL_FIELDS,
expectToFail: false,
input: undefined,
});
return data.skills.filter(
(skill) => skill.name === 'enrichment' || skill.name === 'scoring',
);
};
describe('Manifest update - skills', () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Application',
description: 'App for testing skill manifest updates',
sourcePath: 'test-manifest-update-skill',
});
}, 60000);
afterEach(async () => {
await uninstallApplication({
universalIdentifier: TEST_APP_ID,
expectToFail: false,
});
});
it('should create a new skill when added to manifest on second sync', async () => {
await syncApplication({
manifest: buildManifest({ skills: [] }),
expectToFail: false,
});
const skillsAfterFirstSync = await findAppSkills();
expect(skillsAfterFirstSync).toHaveLength(0);
await syncApplication({
manifest: buildManifest({
skills: [
{
universalIdentifier: TEST_SKILL_ID,
name: 'enrichment',
label: 'Enrichment',
description: 'Enrich contact data',
icon: 'IconSparkles',
content: '# Enrichment\n\nEnrich your data.',
},
],
}),
expectToFail: false,
});
const skillsAfterSecondSync = await findAppSkills();
expect(skillsAfterSecondSync).toHaveLength(1);
expect(skillsAfterSecondSync[0]).toMatchObject({
name: 'enrichment',
label: 'Enrichment',
description: 'Enrich contact data',
icon: 'IconSparkles',
content: '# Enrichment\n\nEnrich your data.',
});
}, 60000);
it('should update a skill when properties change in manifest on second sync', async () => {
await syncApplication({
manifest: buildManifest({
skills: [
{
universalIdentifier: TEST_SKILL_ID,
name: 'enrichment',
label: 'Enrichment',
description: 'Enrich contact data',
icon: 'IconSparkles',
content: '# Enrichment\n\nEnrich your data.',
},
],
}),
expectToFail: false,
});
const skillsAfterFirstSync = await findAppSkills();
expect(skillsAfterFirstSync).toHaveLength(1);
expect(skillsAfterFirstSync[0]).toMatchObject({
label: 'Enrichment',
description: 'Enrich contact data',
});
await syncApplication({
manifest: buildManifest({
skills: [
{
universalIdentifier: TEST_SKILL_ID,
name: 'enrichment',
label: 'Data Enrichment',
description: 'Enrich contact and company data',
icon: 'IconSparkles',
content:
'# Data Enrichment\n\nEnrich your contact and company data from multiple sources.',
},
],
}),
expectToFail: false,
});
const skillsAfterSecondSync = await findAppSkills();
expect(skillsAfterSecondSync).toHaveLength(1);
expect(skillsAfterSecondSync[0]).toMatchObject({
name: 'enrichment',
label: 'Data Enrichment',
description: 'Enrich contact and company data',
content:
'# Data Enrichment\n\nEnrich your contact and company data from multiple sources.',
});
}, 60000);
it('should delete a skill when removed from manifest on second sync', async () => {
await syncApplication({
manifest: buildManifest({
skills: [
{
universalIdentifier: TEST_SKILL_ID,
name: 'enrichment',
label: 'Enrichment',
description: 'Enrich contact data',
icon: 'IconSparkles',
content: '# Enrichment\n\nEnrich your data.',
},
{
universalIdentifier: TEST_SECOND_SKILL_ID,
name: 'scoring',
label: 'Lead Scoring',
description: 'Score your leads',
icon: 'IconChartBar',
content: '# Lead Scoring\n\nScore leads automatically.',
},
],
}),
expectToFail: false,
});
const skillsAfterFirstSync = await findAppSkills();
expect(skillsAfterFirstSync).toHaveLength(2);
expect(
skillsAfterFirstSync.find((s) => s.name === 'enrichment'),
).toBeDefined();
expect(
skillsAfterFirstSync.find((s) => s.name === 'scoring'),
).toBeDefined();
await syncApplication({
manifest: buildManifest({
skills: [
{
universalIdentifier: TEST_SKILL_ID,
name: 'enrichment',
label: 'Enrichment',
description: 'Enrich contact data',
icon: 'IconSparkles',
content: '# Enrichment\n\nEnrich your data.',
},
],
}),
expectToFail: false,
});
const skillsAfterSecondSync = await findAppSkills();
expect(skillsAfterSecondSync).toHaveLength(1);
expect(skillsAfterSecondSync[0]).toMatchObject({
name: 'enrichment',
label: 'Enrichment',
});
expect(
skillsAfterSecondSync.find((s) => s.name === 'scoring'),
).toBeUndefined();
}, 60000);
});
@@ -2,15 +2,19 @@ import { buildDefaultObjectManifest } from 'test/integration/metadata/suites/app
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { uninstallApplication } from 'test/integration/metadata/suites/application/utils/uninstall-application.util';
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
import { findRoles } from 'test/integration/metadata/suites/role/utils/find-roles.util';
import { findSkills } from 'test/integration/metadata/suites/skill/utils/find-skills.util';
import { extractRecordIdsAndDatesAsExpectAny } from 'test/utils/extract-record-ids-and-dates-as-expect-any';
import { type Manifest } from 'twenty-shared/application';
import { type FieldManifest, type Manifest } from 'twenty-shared/application';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { FieldMetadataType } from 'twenty-shared/types';
import { v4 as uuidv4 } from 'uuid';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { findManyObjectMetadataWithIndexes } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata-with-indexes.util';
const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
const TEST_SECOND_ROLE_ID = uuidv4();
const TEST_FIELD_ID = uuidv4();
const TEST_SKILL_ID = uuidv4();
@@ -23,6 +27,39 @@ const TEST_OBJECT = buildDefaultObjectManifest({
icon: 'IconTicket',
});
const TEST_SKILL = {
universalIdentifier: TEST_SKILL_ID,
name: 'test-skill',
label: 'Test Skill',
description: 'A skill for testing',
icon: 'IconBrain',
content: '# Test Skill\n\nThis is a test skill.',
};
const TEST_FIELD: FieldManifest = {
universalIdentifier: TEST_FIELD_ID,
type: FieldMetadataType.TEXT,
name: 'description',
label: 'Description',
description: 'Ticket description',
icon: 'IconFileDescription',
objectUniversalIdentifier: TEST_OBJECT.universalIdentifier,
};
const buildManifest = (
overrides?: Partial<Pick<Manifest, 'fields' | 'skills' | 'objects'>>,
) =>
buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
objects: [TEST_OBJECT],
skills: [TEST_SKILL],
fields: [TEST_FIELD],
...overrides,
},
});
describe('syncApplication', () => {
let appCreated = false;
@@ -49,48 +86,8 @@ describe('syncApplication', () => {
});
it('should return workspace migration actions on initial sync then on second sync with field rename and new role', async () => {
const initialManifest: Manifest = {
application: {
universalIdentifier: TEST_APP_ID,
defaultRoleUniversalIdentifier: TEST_ROLE_ID,
displayName: 'Test Application',
description: 'A test application for workspace migration',
icon: 'IconTestPipe',
applicationVariables: {},
packageJsonChecksum: null,
yarnLockChecksum: null,
apiClientChecksum: null,
},
roles: [
{
universalIdentifier: TEST_ROLE_ID,
label: 'Test Role',
description: 'A test role',
},
],
skills: [],
objects: [TEST_OBJECT],
fields: [
{
universalIdentifier: TEST_FIELD_ID,
type: FieldMetadataType.TEXT,
name: 'description',
label: 'Description',
description: 'Ticket description',
icon: 'IconFileDescription',
objectUniversalIdentifier: TEST_OBJECT.universalIdentifier,
},
],
logicFunctions: [],
frontComponents: [],
publicAssets: [],
views: [],
navigationMenuItems: [],
pageLayouts: [],
};
const { data: firstSyncData } = await syncApplication({
manifest: initialManifest,
manifest: buildManifest(),
expectToFail: false,
});
@@ -98,139 +95,92 @@ describe('syncApplication', () => {
extractRecordIdsAndDatesAsExpectAny(firstSyncData),
);
const updatedManifest: Manifest = {
...initialManifest,
roles: [
{
universalIdentifier: TEST_ROLE_ID,
label: 'Test Role',
description: 'A test role',
},
{
universalIdentifier: TEST_SECOND_ROLE_ID,
label: 'Viewer Role',
description: 'A read-only role',
},
],
fields: [
{
universalIdentifier: TEST_FIELD_ID,
type: FieldMetadataType.TEXT,
name: 'body',
label: 'Body',
description: 'Ticket description',
icon: 'IconFileDescription',
objectUniversalIdentifier: TEST_OBJECT.universalIdentifier,
},
],
};
const { data: secondSyncData } = await syncApplication({
manifest: updatedManifest,
expectToFail: false,
});
expect(secondSyncData).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny(secondSyncData),
);
}, 60000);
it('should sync a skill then update it on second sync', async () => {
const initialManifest: Manifest = {
application: {
universalIdentifier: TEST_APP_ID,
defaultRoleUniversalIdentifier: TEST_ROLE_ID,
displayName: 'Test Application',
description: 'A test application for workspace migration',
icon: 'IconTestPipe',
applicationVariables: {},
packageJsonChecksum: null,
yarnLockChecksum: null,
apiClientChecksum: null,
// Verify database state after first sync
const { objects: objectsAfterSync } = await findManyObjectMetadata({
input: {
filter: { isCustom: { is: true } },
paging: { first: 100 },
},
roles: [
{
universalIdentifier: TEST_ROLE_ID,
label: 'Test Role',
description: 'A test role',
},
],
skills: [
{
universalIdentifier: TEST_SKILL_ID,
name: 'test-skill',
label: 'Test Skill',
description: 'A skill for testing',
icon: 'IconBrain',
content: '# Test Skill\n\nThis is a test skill.',
},
],
objects: [],
fields: [],
logicFunctions: [],
frontComponents: [],
publicAssets: [],
views: [],
navigationMenuItems: [],
pageLayouts: [],
};
const { data: firstSyncData } = await syncApplication({
manifest: initialManifest,
gqlFields:
'id nameSingular namePlural labelSingular labelPlural description icon isCustom',
expectToFail: false,
});
expect(firstSyncData).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny(firstSyncData),
const ticketObject = objectsAfterSync.find(
(obj) => obj.nameSingular === 'ticket',
);
const updatedManifest: Manifest = {
...initialManifest,
skills: [
{
universalIdentifier: TEST_SKILL_ID,
name: 'test-skill',
label: 'Test Skill Updated',
description: 'An updated skill for testing',
icon: 'IconBrain',
content:
'# Test Skill\n\nThis is an updated test skill with more content.',
},
],
};
expect(ticketObject).toBeDefined();
expect(ticketObject).toMatchObject({
nameSingular: 'ticket',
namePlural: 'tickets',
labelSingular: 'Ticket',
labelPlural: 'Tickets',
description: 'A support ticket',
icon: 'IconTicket',
isCustom: true,
});
const { data: secondSyncData } = await syncApplication({
manifest: updatedManifest,
const objects = await findManyObjectMetadataWithIndexes({
expectToFail: false,
});
expect(secondSyncData).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny(secondSyncData),
const fieldsAfterSync = objects.find(
(o) => o.universalIdentifier === TEST_OBJECT.universalIdentifier,
)?.fieldsList;
const descriptionField = fieldsAfterSync?.find(
(f) => f.universalIdentifier === TEST_FIELD_ID,
);
expect(descriptionField).toBeDefined();
expect(descriptionField).toMatchObject({
name: 'description',
label: 'Description',
type: FieldMetadataType.TEXT,
description: 'Ticket description',
icon: 'IconFileDescription',
});
const { data: rolesAfterSync } = await findRoles({
gqlFields: 'id label description universalIdentifier',
expectToFail: false,
});
const testRole = rolesAfterSync.getRoles.find(
(role) => role.universalIdentifier === TEST_ROLE_ID,
);
const { data: skillsAfterSync } = await findSkills({
gqlFields: 'id name label description content icon',
expectToFail: false,
input: undefined,
});
const testSkill = skillsAfterSync.skills.find(
(skill) => skill.name === 'test-skill',
);
expect(testRole).toBeDefined();
expect(testRole).toMatchObject({
label: 'Test Role',
description: 'A test role',
});
expect(testSkill).toBeDefined();
expect(testSkill).toMatchObject({
name: 'test-skill',
label: 'Test Skill',
description: 'A skill for testing',
icon: 'IconBrain',
content: '# Test Skill\n\nThis is a test skill.',
});
}, 60000);
it('should create a TEXT field on the standard Company object', async () => {
const companyFieldId = uuidv4();
const manifest: Manifest = {
application: {
universalIdentifier: TEST_APP_ID,
defaultRoleUniversalIdentifier: TEST_ROLE_ID,
displayName: 'Test Application',
description: 'A test application for workspace migration',
icon: 'IconTestPipe',
applicationVariables: {},
packageJsonChecksum: null,
yarnLockChecksum: null,
apiClientChecksum: null,
},
roles: [
{
universalIdentifier: TEST_ROLE_ID,
label: 'Test Role',
description: 'A test role',
},
],
const manifest = buildManifest({
skills: [],
objects: [],
fields: [
@@ -245,13 +195,7 @@ describe('syncApplication', () => {
STANDARD_OBJECTS.company.universalIdentifier,
},
],
logicFunctions: [],
frontComponents: [],
publicAssets: [],
views: [],
navigationMenuItems: [],
pageLayouts: [],
};
});
const { data: syncData } = await syncApplication({
manifest,
@@ -0,0 +1,40 @@
import { type Manifest } from 'twenty-shared/application';
export const buildBaseManifest = ({
appId,
roleId,
overrides,
}: {
appId: string;
roleId: string;
overrides?: Partial<Manifest>;
}): Manifest => ({
application: {
universalIdentifier: appId,
defaultRoleUniversalIdentifier: roleId,
displayName: 'Test Application',
description: 'Test application',
icon: 'IconTestPipe',
applicationVariables: {},
packageJsonChecksum: null,
yarnLockChecksum: null,
apiClientChecksum: null,
},
roles: [
{
universalIdentifier: roleId,
label: 'Test Role',
description: 'A test role',
},
],
skills: [],
objects: [],
fields: [],
logicFunctions: [],
frontComponents: [],
publicAssets: [],
views: [],
navigationMenuItems: [],
pageLayouts: [],
...overrides,
});
@@ -22,10 +22,15 @@ export const findManyObjectMetadataWithIndexes = async ({
gqlFields: `
id
nameSingular
universalIdentifier
fieldsList {
id
type
name
description
icon
label
universalIdentifier
relation {
type
sourceObjectMetadata {
@@ -5,6 +5,7 @@ const DEFAULT_ROLE_GQL_FIELDS = `
label
description
icon
universalIdentifier
isEditable
canUpdateAllSettings
canAccessAllTools