Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s
411 lines
14 KiB
TypeScript
411 lines
14 KiB
TypeScript
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 { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.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 { findManyObjectMetadataWithIndexes } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata-with-indexes.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 FieldManifest,
|
|
getSystemRelationFieldUniversalIdentifier,
|
|
type Manifest,
|
|
} from 'twenty-shared/application';
|
|
import {
|
|
DEFAULT_RELATIONS_OBJECTS_STANDARD_IDS,
|
|
STANDARD_OBJECTS,
|
|
} from 'twenty-shared/metadata';
|
|
import { FieldMetadataType } from 'twenty-shared/types';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
|
|
const TEST_APP_ID = '0c17e907-f32a-4526-98e4-9addd4302b1a';
|
|
const TEST_ROLE_ID = uuidv4();
|
|
const TEST_FIELD_ID = uuidv4();
|
|
const TEST_SKILL_ID = uuidv4();
|
|
|
|
const TEST_OBJECT = buildDefaultObjectManifest({
|
|
applicationUniversalIdentifier: TEST_APP_ID,
|
|
nameSingular: 'ticket',
|
|
namePlural: 'tickets',
|
|
labelSingular: 'Ticket',
|
|
labelPlural: 'Tickets',
|
|
description: 'A support ticket',
|
|
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', () => {
|
|
beforeEach(async () => {
|
|
await setupApplicationForSync({
|
|
applicationUniversalIdentifier: TEST_APP_ID,
|
|
name: 'Test Application',
|
|
description: 'A test application',
|
|
sourcePath: 'test-sync',
|
|
});
|
|
}, 60000);
|
|
|
|
afterEach(async () => {
|
|
await cleanupApplicationAndAppRegistration({
|
|
applicationUniversalIdentifier: TEST_APP_ID,
|
|
});
|
|
});
|
|
|
|
it('should return workspace migration actions on initial sync then on second sync with field rename and new role', async () => {
|
|
const { data: firstSyncData } = await syncApplication({
|
|
manifest: buildManifest(),
|
|
expectToFail: false,
|
|
});
|
|
|
|
expect(firstSyncData).toMatchSnapshot(
|
|
extractRecordIdsAndDatesAsExpectAny(firstSyncData),
|
|
);
|
|
|
|
const { objects: objectsAfterSync } = await findManyObjectMetadata({
|
|
input: {
|
|
filter: {},
|
|
paging: { first: 100 },
|
|
},
|
|
gqlFields:
|
|
'id nameSingular namePlural labelSingular labelPlural description icon',
|
|
expectToFail: false,
|
|
});
|
|
|
|
const ticketObject = objectsAfterSync.find(
|
|
(obj) => obj.nameSingular === 'ticket',
|
|
);
|
|
|
|
expect(ticketObject).toBeDefined();
|
|
expect(ticketObject).toMatchObject({
|
|
nameSingular: 'ticket',
|
|
namePlural: 'tickets',
|
|
labelSingular: 'Ticket',
|
|
labelPlural: 'Tickets',
|
|
description: 'A support ticket',
|
|
icon: 'IconTicket',
|
|
});
|
|
|
|
const objects = await findManyObjectMetadataWithIndexes({
|
|
expectToFail: false,
|
|
});
|
|
|
|
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);
|
|
|
|
// The snapshot masks every identifier as Any<String>, so it cannot catch a
|
|
// regression in the deterministic universal identifiers of the default
|
|
// relations. This test recomputes them and asserts the emitted migration
|
|
// actions carry them exactly, on both sides of each relation.
|
|
it('should provision the four engine-owned default relations on initial sync', async () => {
|
|
const NAME_PLURAL_BY_STANDARD_OBJECT_NAME_SINGULAR = {
|
|
timelineActivity: 'timelineActivities',
|
|
attachment: 'attachments',
|
|
noteTarget: 'noteTargets',
|
|
taskTarget: 'taskTargets',
|
|
} as const;
|
|
|
|
const { data: syncData } = await syncApplication({
|
|
manifest: buildManifest(),
|
|
expectToFail: false,
|
|
});
|
|
|
|
type FlatFieldEntity = {
|
|
universalIdentifier: string;
|
|
name?: string;
|
|
type?: string;
|
|
isSystem?: boolean;
|
|
isSystemSideEffect?: boolean;
|
|
morphId?: string | null;
|
|
objectMetadataUniversalIdentifier?: string;
|
|
relationTargetObjectMetadataUniversalIdentifier?: string | null;
|
|
relationTargetFieldMetadataUniversalIdentifier?: string | null;
|
|
universalSettings?: { joinColumnName?: string } | null;
|
|
universalFlatIndexFieldMetadatas?: {
|
|
fieldMetadataUniversalIdentifier: string;
|
|
}[];
|
|
};
|
|
type FlatEntityAction = {
|
|
type: string;
|
|
metadataName: string;
|
|
flatEntity: FlatFieldEntity;
|
|
// A relation field create action carries its reverse field inline.
|
|
relatedUniversalFlatFieldMetadata?: FlatFieldEntity;
|
|
};
|
|
|
|
const actions = (
|
|
syncData as {
|
|
syncApplication: { actions: FlatEntityAction[] };
|
|
}
|
|
).syncApplication.actions;
|
|
|
|
const fieldCreateActionByUniversalIdentifier = Object.fromEntries(
|
|
actions
|
|
.filter(
|
|
(action) =>
|
|
action.metadataName === 'fieldMetadata' && action.type === 'create',
|
|
)
|
|
.map((action) => [action.flatEntity.universalIdentifier, action]),
|
|
);
|
|
const indexCreateActions = actions.filter(
|
|
(action) => action.metadataName === 'index' && action.type === 'create',
|
|
);
|
|
|
|
for (const standardObjectNameSingular of DEFAULT_RELATIONS_OBJECTS_STANDARD_IDS) {
|
|
const standardObjectUniversalIdentifier =
|
|
STANDARD_OBJECTS[standardObjectNameSingular].universalIdentifier;
|
|
const standardObjectNamePlural =
|
|
NAME_PLURAL_BY_STANDARD_OBJECT_NAME_SINGULAR[
|
|
standardObjectNameSingular
|
|
];
|
|
|
|
const forwardUniversalIdentifier =
|
|
getSystemRelationFieldUniversalIdentifier({
|
|
applicationUniversalIdentifier: TEST_APP_ID,
|
|
objectUniversalIdentifier: TEST_OBJECT.universalIdentifier,
|
|
relationTargetObjectUniversalIdentifier:
|
|
standardObjectUniversalIdentifier,
|
|
});
|
|
const reverseUniversalIdentifier =
|
|
getSystemRelationFieldUniversalIdentifier({
|
|
applicationUniversalIdentifier: TEST_APP_ID,
|
|
objectUniversalIdentifier: standardObjectUniversalIdentifier,
|
|
relationTargetObjectUniversalIdentifier:
|
|
TEST_OBJECT.universalIdentifier,
|
|
});
|
|
|
|
// Forward RELATION field on the ticket, named after the standard
|
|
// object's namePlural, under the name-free universal identifier.
|
|
const forwardCreateAction =
|
|
fieldCreateActionByUniversalIdentifier[forwardUniversalIdentifier];
|
|
|
|
expect(forwardCreateAction?.flatEntity).toMatchObject({
|
|
name: standardObjectNamePlural,
|
|
type: FieldMetadataType.RELATION,
|
|
// Engine-owned (isSystemSideEffect) but not isSystem: the engine
|
|
// rename/delete cascades must pass the validator system-field gates.
|
|
isSystem: false,
|
|
isSystemSideEffect: true,
|
|
objectMetadataUniversalIdentifier: TEST_OBJECT.universalIdentifier,
|
|
relationTargetObjectMetadataUniversalIdentifier:
|
|
standardObjectUniversalIdentifier,
|
|
relationTargetFieldMetadataUniversalIdentifier:
|
|
reverseUniversalIdentifier,
|
|
});
|
|
|
|
// Reverse MORPH_RELATION field on the standard object, carried inline on
|
|
// the forward create action, under the name-free deterministic universal
|
|
// identifier, with the engine-pinned targetMorphId and the join column.
|
|
expect(
|
|
forwardCreateAction?.relatedUniversalFlatFieldMetadata,
|
|
).toMatchObject({
|
|
name: 'targetTicket',
|
|
type: FieldMetadataType.MORPH_RELATION,
|
|
isSystem: false,
|
|
isSystemSideEffect: true,
|
|
universalIdentifier: reverseUniversalIdentifier,
|
|
morphId:
|
|
STANDARD_OBJECTS[standardObjectNameSingular].morphIds.targetMorphId
|
|
.morphId,
|
|
objectMetadataUniversalIdentifier: standardObjectUniversalIdentifier,
|
|
relationTargetObjectMetadataUniversalIdentifier:
|
|
TEST_OBJECT.universalIdentifier,
|
|
relationTargetFieldMetadataUniversalIdentifier:
|
|
forwardUniversalIdentifier,
|
|
universalSettings: expect.objectContaining({
|
|
joinColumnName: 'targetTicketId',
|
|
}),
|
|
});
|
|
|
|
// Engine-owned join-column index backing the reverse field.
|
|
const reverseJoinColumnIndexAction = indexCreateActions.find((action) =>
|
|
action.flatEntity.universalFlatIndexFieldMetadatas?.some(
|
|
(indexFieldMetadata) =>
|
|
indexFieldMetadata.fieldMetadataUniversalIdentifier ===
|
|
reverseUniversalIdentifier,
|
|
),
|
|
);
|
|
|
|
expect(reverseJoinColumnIndexAction).toBeDefined();
|
|
expect(reverseJoinColumnIndexAction?.flatEntity).toMatchObject({
|
|
isSystemSideEffect: true,
|
|
objectMetadataUniversalIdentifier: standardObjectUniversalIdentifier,
|
|
});
|
|
}
|
|
}, 60000);
|
|
|
|
it('should delete old field and create equivalent one when field universalIdentifier changes', async () => {
|
|
const originalFieldId = '8abbef24-f8c3-41d5-826b-73a02825e712';
|
|
const updatedFieldId = 'a4262080-673d-430a-883b-c5d04610abf1';
|
|
|
|
const testObject = buildDefaultObjectManifest({
|
|
applicationUniversalIdentifier: TEST_APP_ID,
|
|
universalIdentifier: '8fc4bfec-01d8-4404-bd1a-dc90fe8e7699',
|
|
nameSingular: 'ticket',
|
|
namePlural: 'tickets',
|
|
labelSingular: 'Ticket',
|
|
labelPlural: 'Tickets',
|
|
description: 'A support ticket',
|
|
icon: 'IconTicket',
|
|
});
|
|
|
|
const baseField: FieldManifest = {
|
|
universalIdentifier: originalFieldId,
|
|
type: FieldMetadataType.TEXT,
|
|
name: 'description',
|
|
label: 'Description',
|
|
description: 'Ticket description',
|
|
icon: 'IconFileDescription',
|
|
objectUniversalIdentifier: testObject.universalIdentifier,
|
|
};
|
|
|
|
const { data: firstSyncData } = await syncApplication({
|
|
manifest: buildBaseManifest({
|
|
appId: TEST_APP_ID,
|
|
roleId: TEST_ROLE_ID,
|
|
overrides: {
|
|
objects: [testObject],
|
|
fields: [baseField],
|
|
},
|
|
}),
|
|
expectToFail: false,
|
|
});
|
|
|
|
expect(firstSyncData).toMatchSnapshot(
|
|
extractRecordIdsAndDatesAsExpectAny(firstSyncData),
|
|
);
|
|
|
|
const { data: secondSyncData } = await syncApplication({
|
|
manifest: buildBaseManifest({
|
|
appId: TEST_APP_ID,
|
|
roleId: TEST_ROLE_ID,
|
|
overrides: {
|
|
objects: [testObject],
|
|
fields: [
|
|
{
|
|
...baseField,
|
|
universalIdentifier: updatedFieldId,
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
expectToFail: false,
|
|
});
|
|
|
|
expect(secondSyncData).toMatchSnapshot(
|
|
extractRecordIdsAndDatesAsExpectAny(secondSyncData),
|
|
);
|
|
}, 60000);
|
|
|
|
it('should create a TEXT field on the standard Company object', async () => {
|
|
const companyFieldId = uuidv4();
|
|
|
|
const manifest = buildManifest({
|
|
skills: [],
|
|
objects: [],
|
|
fields: [
|
|
{
|
|
universalIdentifier: companyFieldId,
|
|
type: FieldMetadataType.TEXT,
|
|
name: 'industry',
|
|
label: 'Industry',
|
|
description: 'The industry of the company',
|
|
icon: 'IconBuildingFactory2',
|
|
objectUniversalIdentifier:
|
|
STANDARD_OBJECTS.company.universalIdentifier,
|
|
},
|
|
],
|
|
});
|
|
|
|
const { data: syncData } = await syncApplication({
|
|
manifest,
|
|
expectToFail: false,
|
|
});
|
|
|
|
expect(syncData).toMatchSnapshot(
|
|
extractRecordIdsAndDatesAsExpectAny(syncData),
|
|
);
|
|
}, 60000);
|
|
});
|