snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
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 3s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,80 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Field metadata relation update should fail relation when name is not in camel case 1`] = `
[
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"fieldMetadata": [
{
"errors": [
{
"code": "INVALID_FIELD_INPUT",
"message": "Name is not valid: it must start with lowercase letter and contain only alphanumeric letters",
"userFriendlyMessage": "Name is not valid: it must start with lowercase letter and contain only alphanumeric letters",
"value": "New Name",
},
],
"flatEntityMinimalInformation": {
"name": "New Name",
"objectMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "fieldMetadata",
"status": "fail",
"type": "update",
},
],
},
"message": "Validation failed for 1 fieldMetadata",
"summary": {
"fieldMetadata": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "Name is not valid: it must start with lowercase letter and contain only alphanumeric letters",
},
"message": "Multiple validation errors occurred while updating field",
"name": "GraphQLError",
},
]
`;
exports[`Field metadata relation update should fail relation when updating ONE_TO_MANY relation with onDelete action 1`] = `
[
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"fieldMetadata": [
{
"errors": [
{
"code": "INVALID_FIELD_INPUT",
"message": "On delete action is only supported for many to one relations",
"userFriendlyMessage": "On delete action is only supported for many to one relations",
},
],
"flatEntityMinimalInformation": {
"name": "staff",
"objectMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "fieldMetadata",
"status": "fail",
"type": "update",
},
],
},
"message": "Validation failed for 1 fieldMetadata",
"summary": {
"fieldMetadata": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "On delete action is only supported for many to one relations",
},
"message": "Multiple validation errors occurred while updating field",
"name": "GraphQLError",
},
]
`;

View File

@@ -0,0 +1,302 @@
import { findManyFieldsMetadata } from 'test/integration/metadata/suites/field-metadata/utils/find-many-fields-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { createRelationBetweenObjects } from 'test/integration/metadata/suites/object-metadata/utils/create-relation-between-objects.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
describe('createOne FieldMetadataService relation fields', () => {
let createdObjectMetadataPersonId: string;
let createdObjectMetadataOpportunityId: string;
beforeEach(async () => {
const {
data: {
createOneObject: { id: objectMetadataPersonId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'personForRelation',
namePlural: 'peopleForRelation',
labelSingular: 'Person For Relation',
labelPlural: 'People For Relation',
icon: 'IconPerson',
},
});
createdObjectMetadataPersonId = objectMetadataPersonId;
const {
data: {
createOneObject: { id: objectMetadataOpportunityId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'opportunityForRelation',
namePlural: 'opportunitiesForRelation',
labelSingular: 'Opportunity For Relation',
labelPlural: 'Opportunities For Relation',
icon: 'IconOpportunity',
},
});
createdObjectMetadataOpportunityId = objectMetadataOpportunityId;
});
afterEach(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataPersonId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataPersonId },
});
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataOpportunityId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataOpportunityId },
});
});
it('MANY TO ONE field relation creation', async () => {
const createdField = await createRelationBetweenObjects({
objectMetadataId: createdObjectMetadataOpportunityId,
targetObjectMetadataId: createdObjectMetadataPersonId,
relationType: RelationType.MANY_TO_ONE,
type: FieldMetadataType.RELATION,
name: 'person',
});
expect(createdField).toMatchObject({
name: 'person',
relation: {
type: RelationType.MANY_TO_ONE,
},
settings: {
joinColumnName: 'personId',
},
});
expect(createdField.id).toBeDefined();
expect(createdField.relation?.targetFieldMetadata.id).toBeDefined();
if (!isDefined(createdField.relation?.targetFieldMetadata?.id)) {
throw new Error('targetFieldMetadata.id is not defined');
}
const opportunityFieldOnPerson = await findFieldMetadata({
fieldMetadataId: createdField.relation.targetFieldMetadata.id,
});
expect(opportunityFieldOnPerson).toMatchObject({
object: {
nameSingular: 'personForRelation',
},
relation: {
type: RelationType.ONE_TO_MANY,
},
});
expect(
opportunityFieldOnPerson.relation.targetFieldMetadata.id,
).toBeDefined();
expect(
opportunityFieldOnPerson.relation.targetObjectMetadata.id,
).toBeDefined();
expect(opportunityFieldOnPerson.settings?.joinColumnName).toBeUndefined();
});
it('ONE TO MANY field relation creation', async () => {
const createdField = await createRelationBetweenObjects({
objectMetadataId: createdObjectMetadataOpportunityId,
targetObjectMetadataId: createdObjectMetadataPersonId,
relationType: RelationType.ONE_TO_MANY,
type: FieldMetadataType.RELATION,
name: 'person',
});
expect(createdField).toMatchObject({
name: 'person',
relation: {
type: RelationType.ONE_TO_MANY,
},
});
expect(createdField.id).toBeDefined();
expect(createdField.relation?.targetFieldMetadata.id).toBeDefined();
expect(createdField.settings?.joinColumnName).toBeUndefined();
if (!isDefined(createdField.relation?.targetFieldMetadata?.id)) {
throw new Error('targetFieldMetadata.id is not defined');
}
const opportunityFieldOnPerson = await findFieldMetadata({
fieldMetadataId: createdField.relation.targetFieldMetadata.id,
});
expect(opportunityFieldOnPerson).toMatchObject({
object: {
nameSingular: 'personForRelation',
},
relation: {
type: RelationType.MANY_TO_ONE,
},
settings: {
joinColumnName: 'opportunityId',
},
});
expect(
opportunityFieldOnPerson.relation.targetFieldMetadata.id,
).toBeDefined();
expect(
opportunityFieldOnPerson.relation.targetObjectMetadata.id,
).toBeDefined();
});
it('MANY TO ONE self-relation field creation', async () => {
const createdField = await createRelationBetweenObjects({
objectMetadataId: createdObjectMetadataPersonId,
targetObjectMetadataId: createdObjectMetadataPersonId,
relationType: RelationType.MANY_TO_ONE,
type: FieldMetadataType.RELATION,
name: 'manager',
});
expect(createdField).toMatchObject({
name: 'manager',
relation: {
type: RelationType.MANY_TO_ONE,
targetObjectMetadata: {
id: createdObjectMetadataPersonId,
},
},
settings: {
joinColumnName: 'managerId',
},
});
jestExpectToBeDefined(createdField.relation?.targetFieldMetadata.id);
const reportsFieldOnPerson = await findFieldMetadata({
fieldMetadataId: createdField.relation.targetFieldMetadata.id,
});
expect(reportsFieldOnPerson).toMatchObject({
object: {
nameSingular: 'personForRelation',
id: createdObjectMetadataPersonId,
},
relation: {
type: RelationType.ONE_TO_MANY,
targetObjectMetadata: {
id: createdObjectMetadataPersonId,
},
},
});
expect(reportsFieldOnPerson.relation.targetFieldMetadata.id).toBeDefined();
expect(reportsFieldOnPerson.settings?.joinColumnName).toBeUndefined();
});
it('ONE TO MANY self-relation field creation', async () => {
const createdField = await createRelationBetweenObjects({
objectMetadataId: createdObjectMetadataPersonId,
targetObjectMetadataId: createdObjectMetadataPersonId,
relationType: RelationType.ONE_TO_MANY,
type: FieldMetadataType.RELATION,
name: 'reports',
targetFieldLabel: 'reportTarget',
});
expect(createdField).toMatchObject({
name: 'reports',
relation: {
type: RelationType.ONE_TO_MANY,
targetObjectMetadata: {
id: createdObjectMetadataPersonId,
},
},
});
expect(createdField.relation?.targetFieldMetadata.id).toBeDefined();
expect(createdField.settings?.joinColumnName).toBeUndefined();
if (!isDefined(createdField.relation?.targetFieldMetadata?.id)) {
throw new Error('targetFieldMetadata.id is not defined');
}
const managerFieldOnPerson = await findFieldMetadata({
fieldMetadataId: createdField.relation.targetFieldMetadata.id,
});
expect(managerFieldOnPerson).toMatchObject({
object: {
nameSingular: 'personForRelation',
id: createdObjectMetadataPersonId,
},
relation: {
type: RelationType.MANY_TO_ONE,
targetObjectMetadata: {
id: createdObjectMetadataPersonId,
},
},
settings: {
joinColumnName: 'reporttargetId',
},
});
expect(managerFieldOnPerson.relation.targetFieldMetadata.id).toBeDefined();
});
});
const findFieldMetadata = async ({
fieldMetadataId,
}: {
fieldMetadataId: string;
}) => {
const { fields } = await findManyFieldsMetadata({
input: {
filter: {
id: { eq: fieldMetadataId },
},
paging: { first: 10 },
},
expectToFail: false,
gqlFields: `
id
name
object {
id
nameSingular
}
relation {
type
targetFieldMetadata {
id
}
targetObjectMetadata {
id
}
}
settings
`,
});
expect(fields.length).toBe(1);
const field = fields[0]?.node;
return field;
};

View File

@@ -0,0 +1,500 @@
import { faker } from '@faker-js/faker';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { getMockCreateObjectInput } from 'test/integration/metadata/suites/object-metadata/utils/generate-mock-create-object-metadata-input';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { extractRecordIdsAndDatesAsExpectAny } from 'test/utils/extract-record-ids-and-dates-as-expect-any';
import {
eachTestingContextFilter,
type EachTestingContext,
} from 'twenty-shared/testing';
import { FieldMetadataType } from 'twenty-shared/types';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { type CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
type GlobalTestContext = {
objectMetadataIds: {
targetObjectId: string;
sourceObjectId: string;
};
collisionFieldName: string;
collisionFieldNameWithId: string;
collisionFieldLabel: string;
collisionFieldLabelWithId: string;
};
const globalTestContext: GlobalTestContext = {
objectMetadataIds: {
targetObjectId: '',
sourceObjectId: '',
},
collisionFieldLabel: 'Field Name',
collisionFieldName: 'fieldName',
collisionFieldNameWithId: 'fieldNameBisId',
collisionFieldLabelWithId: 'Field Name Bis Id',
};
type TestedRelationCreationPayload = Partial<
NonNullable<CreateFieldInput['relationCreationPayload']>
>;
type TestedContext = {
input: {
name?: string;
relationCreationPayload?: TestedRelationCreationPayload;
};
};
type CreateOneObjectMetadataItemTestingContext = EachTestingContext<
TestedContext | ((context: GlobalTestContext) => TestedContext)
>[];
describe('Field metadata relation creation should fail', () => {
const failingLabelsCreationTestsUseCase: CreateOneObjectMetadataItemTestingContext =
[
// TODO @prastoin add coverage other fields such as the Type, icon etc etc ( using edge cases fuzzing etc )
{
title: '(relationCreationPayload) when targetFieldLabel is empty',
context: {
input: { relationCreationPayload: { targetFieldLabel: '' } },
},
},
{
title:
'(relationCreationPayload) when targetFieldLabel exceeds maximum length',
context: {
input: {
relationCreationPayload: { targetFieldLabel: 'A'.repeat(64) },
},
},
},
{
// Not handled gracefully should be refactored
title:
'(relationCreationPayload) when targetObjectMetadataId is unknown',
context: {
input: {
relationCreationPayload: {
targetObjectMetadataId: faker.string.uuid(),
},
},
},
},
{
title:
'(relationCreationPayload) when targetFieldLabel contains only whitespace',
context: {
input: {
relationCreationPayload: { targetFieldLabel: ' ' },
},
},
},
{
title:
'(relationCreationPayload) when targetFieldLabel conflicts with an existing field on target object metadata id',
context: ({ collisionFieldLabel, objectMetadataIds }) => ({
input: {
relationCreationPayload: {
targetObjectMetadataId: objectMetadataIds.targetObjectId,
targetFieldLabel: collisionFieldLabel,
},
},
}),
},
{
title:
'(relationCreationPayload) when targetFieldLabel conflicts with an existing {name}Id on target object metadata id',
context: ({ collisionFieldLabelWithId, objectMetadataIds }) => ({
input: {
relationCreationPayload: {
targetObjectMetadataId: objectMetadataIds.targetObjectId,
targetFieldLabel: collisionFieldLabelWithId,
},
},
}),
},
{
title: '(relationCreationPayload) when type is not provided',
context: {
input: {
relationCreationPayload: { type: undefined },
},
},
},
{
title: '(relationCreationPayload) when type is a wrong value',
context: {
input: {
relationCreationPayload: { type: 'wrong' as RelationType },
},
},
},
{
title: 'when {name}Id is already used',
context: ({ collisionFieldNameWithId }) => ({
input: {
name: collisionFieldNameWithId,
relationCreationPayload: { targetFieldIcon: '' },
},
}),
},
{
title:
'when target and source are the same object and name are the same',
context: {
input: {
name: 'relationName',
relationCreationPayload: {
targetObjectMetadataId:
globalTestContext.objectMetadataIds.sourceObjectId,
targetFieldLabel: 'Relation Name',
},
},
},
},
{
title:
'(relationCreationPayload) when targetObjectMetadataId is not a valid uuid format',
context: {
input: {
relationCreationPayload: {
targetObjectMetadataId: 'not-a-valid-uuid',
},
},
},
},
{
title: 'when source field name conflicts with existing field name',
context: ({ collisionFieldName }) => ({
input: {
name: collisionFieldName,
relationCreationPayload: {
targetFieldLabel: 'Unique Target Label',
},
},
}),
},
{
title:
'when self-relation source field join column would conflict with target field name',
context: {
input: {
name: 'parent',
relationCreationPayload: {
targetObjectMetadataId:
globalTestContext.objectMetadataIds.sourceObjectId,
targetFieldLabel: 'Parent Id',
},
},
},
},
{
title:
'when self-relation target join column would conflict with source field name',
context: {
input: {
name: 'childrenId',
relationCreationPayload: {
targetObjectMetadataId:
globalTestContext.objectMetadataIds.sourceObjectId,
targetFieldLabel: 'Children',
},
},
},
},
];
beforeAll(async () => {
const {
data: {
createOneObject: { id: sourceObjectId },
},
} = await createOneObjectMetadata({
input: getMockCreateObjectInput({
namePlural: 'sourceObjects',
nameSingular: 'sourceObject',
}),
});
const {
data: {
createOneObject: { id: targetObjectId },
},
} = await createOneObjectMetadata({
input: getMockCreateObjectInput({
namePlural: 'targetObjects',
nameSingular: 'targetObject',
}),
});
globalTestContext.objectMetadataIds = {
sourceObjectId,
targetObjectId,
};
const { data: collisionFieldWithLabelTargetData } =
await createOneFieldMetadata({
input: {
objectMetadataId: targetObjectId,
name: globalTestContext.collisionFieldName,
label: 'LabelThatCouldBeAnything',
isLabelSyncedWithName: false,
type: FieldMetadataType.TEXT,
},
});
const { data: collisionFieldWithIdTargetData } =
await createOneFieldMetadata({
input: {
objectMetadataId: targetObjectId,
name: globalTestContext.collisionFieldNameWithId,
label: 'LabelThatCouldBeAnything',
isLabelSyncedWithName: false,
type: FieldMetadataType.TEXT,
},
});
const { data: collisionFieldWithLabelSourceData } =
await createOneFieldMetadata({
input: {
objectMetadataId: sourceObjectId,
name: globalTestContext.collisionFieldName,
label: 'LabelThatCouldBeAnything',
isLabelSyncedWithName: false,
type: FieldMetadataType.TEXT,
},
});
const { data: collisionFieldWithIdSourceData } =
await createOneFieldMetadata({
input: {
objectMetadataId: sourceObjectId,
name: globalTestContext.collisionFieldNameWithId,
label: 'LabelThatCouldBeAnything',
isLabelSyncedWithName: false,
type: FieldMetadataType.TEXT,
},
});
expect(collisionFieldWithLabelTargetData).toBeDefined();
expect(collisionFieldWithIdTargetData).toBeDefined();
expect(collisionFieldWithLabelSourceData).toBeDefined();
expect(collisionFieldWithIdSourceData).toBeDefined();
});
afterAll(async () => {
for (const objectMetadataId of Object.values(
globalTestContext.objectMetadataIds,
)) {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: objectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: {
idToDelete: objectMetadataId,
},
});
}
});
it.each(eachTestingContextFilter(failingLabelsCreationTestsUseCase))(
'relation ONE_TO_MANY $title',
async ({ context }) => {
const computedRelationCreationPayload =
typeof context === 'function'
? context(globalTestContext).input.relationCreationPayload
: context.input.relationCreationPayload;
const computedName =
typeof context === 'function'
? context(globalTestContext).input.name
: context.input.name;
const { errors } = await createOneFieldMetadata({
expectToFail: true,
input: {
objectMetadataId: globalTestContext.objectMetadataIds.sourceObjectId,
name: computedName ?? 'fieldname',
label: 'Relation field',
isLabelSyncedWithName: false,
type: FieldMetadataType.RELATION,
relationCreationPayload: {
targetFieldLabel: 'defaultTargetFieldLabel',
type: RelationType.ONE_TO_MANY,
targetObjectMetadataId:
globalTestContext.objectMetadataIds.targetObjectId,
targetFieldIcon: 'IconBuildingSkyscraper',
...computedRelationCreationPayload,
},
},
});
expect(errors).toBeDefined();
expect(errors).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny(errors),
);
},
);
it.each(eachTestingContextFilter(failingLabelsCreationTestsUseCase))(
'relation MANY_TO_ONE $title',
async ({ context }) => {
const computedRelationCreationPayload =
typeof context === 'function'
? context(globalTestContext).input.relationCreationPayload
: context.input.relationCreationPayload;
const computedName =
typeof context === 'function'
? context(globalTestContext).input.name
: context.input.name;
const { errors } = await createOneFieldMetadata({
expectToFail: true,
input: {
objectMetadataId: globalTestContext.objectMetadataIds.sourceObjectId,
name: computedName ?? 'fieldname',
label: 'Relation field',
isLabelSyncedWithName: false,
type: FieldMetadataType.RELATION,
relationCreationPayload: {
targetFieldLabel: 'defaultTargetFieldLabel',
type: RelationType.MANY_TO_ONE,
targetObjectMetadataId:
globalTestContext.objectMetadataIds.targetObjectId,
targetFieldIcon: 'IconBuildingSkyscraper',
...computedRelationCreationPayload,
},
},
});
expectOneNotInternalServerErrorSnapshot({
errors,
});
},
);
it('should fail when creating MANY_TO_ONE self-relation with same field name and label', async () => {
const { errors } = await createOneFieldMetadata({
expectToFail: true,
input: {
objectMetadataId: globalTestContext.objectMetadataIds.sourceObjectId,
name: 'manager',
label: 'Manager',
isLabelSyncedWithName: false,
type: FieldMetadataType.RELATION,
relationCreationPayload: {
targetFieldLabel: 'Manager',
type: RelationType.MANY_TO_ONE,
targetObjectMetadataId:
globalTestContext.objectMetadataIds.sourceObjectId,
targetFieldIcon: 'IconUser',
},
},
});
expectOneNotInternalServerErrorSnapshot({
errors,
});
});
it('should fail when creating ONE_TO_MANY self-relation with same field name and label', async () => {
const { errors } = await createOneFieldMetadata({
expectToFail: true,
input: {
objectMetadataId: globalTestContext.objectMetadataIds.sourceObjectId,
name: 'manager',
label: 'Manager',
isLabelSyncedWithName: false,
type: FieldMetadataType.RELATION,
relationCreationPayload: {
targetFieldLabel: 'Manager',
type: RelationType.ONE_TO_MANY,
targetObjectMetadataId:
globalTestContext.objectMetadataIds.sourceObjectId,
targetFieldIcon: 'IconUser',
},
},
});
expectOneNotInternalServerErrorSnapshot({
errors,
});
});
it('should fail when creating a relation field with name that conflicts with existing field name', async () => {
const { errors } = await createOneFieldMetadata({
expectToFail: true,
input: {
objectMetadataId: globalTestContext.objectMetadataIds.sourceObjectId,
name: globalTestContext.collisionFieldName,
label: 'Duplicate Field',
isLabelSyncedWithName: false,
type: FieldMetadataType.RELATION,
relationCreationPayload: {
targetFieldLabel: 'Related Object',
type: RelationType.MANY_TO_ONE,
targetObjectMetadataId:
globalTestContext.objectMetadataIds.targetObjectId,
targetFieldIcon: 'IconLink',
},
},
});
expectOneNotInternalServerErrorSnapshot({
errors,
});
});
it('should fail when creating a relation field with name that conflicts with existing join column name', async () => {
const relationFieldName = 'company';
await createOneFieldMetadata({
expectToFail: false,
input: {
objectMetadataId: globalTestContext.objectMetadataIds.sourceObjectId,
name: relationFieldName,
label: 'Company',
isLabelSyncedWithName: false,
type: FieldMetadataType.RELATION,
relationCreationPayload: {
targetFieldLabel: 'company',
type: RelationType.MANY_TO_ONE,
targetObjectMetadataId:
globalTestContext.objectMetadataIds.targetObjectId,
targetFieldIcon: 'IconBuilding',
},
},
});
const { errors } = await createOneFieldMetadata({
expectToFail: true,
input: {
objectMetadataId: globalTestContext.objectMetadataIds.sourceObjectId,
name: `${relationFieldName}Id`,
label: 'Company Id Field',
isLabelSyncedWithName: false,
type: FieldMetadataType.RELATION,
relationCreationPayload: {
targetFieldLabel: 'Related',
type: RelationType.MANY_TO_ONE,
targetObjectMetadataId:
globalTestContext.objectMetadataIds.targetObjectId,
targetFieldIcon: 'IconLink',
},
},
});
expectOneNotInternalServerErrorSnapshot({
errors,
});
});
});

View File

@@ -0,0 +1,166 @@
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { updateOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { getMockCreateObjectInput } from 'test/integration/metadata/suites/object-metadata/utils/generate-mock-create-object-metadata-input';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { extractRecordIdsAndDatesAsExpectAny } from 'test/utils/extract-record-ids-and-dates-as-expect-any';
import { type EachTestingContext } from 'twenty-shared/testing';
import { FieldMetadataType, RelationOnDeleteAction } from 'twenty-shared/types';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
type UpdateOneFieldMetadataTestingContext = EachTestingContext<
| {
fieldKey: 'employerFieldMetadataId';
updatePayload: { name: string };
}
| {
fieldKey: 'employeesOneToManyFieldMetadataId';
updatePayload: {
settings: {
relationType: RelationType;
onDelete: RelationOnDeleteAction;
};
};
}
>;
const globalTestContext = {
employeeObjectId: '',
enterpriseObjectId: '',
employerFieldMetadataId: '',
employeesOneToManyFieldMetadataId: '',
};
describe('Field metadata relation update should fail', () => {
const failingRelationUpdateTestsUseCase: UpdateOneFieldMetadataTestingContext[] =
[
{
title: 'when name is not in camel case',
context: {
fieldKey: 'employerFieldMetadataId',
updatePayload: { name: 'New Name' },
},
},
{
title: 'when updating ONE_TO_MANY relation with onDelete action',
context: {
fieldKey: 'employeesOneToManyFieldMetadataId',
updatePayload: {
settings: {
relationType: RelationType.ONE_TO_MANY,
onDelete: RelationOnDeleteAction.SET_NULL,
},
},
},
},
];
beforeAll(async () => {
const {
data: {
createOneObject: { id: employeeObjectId },
},
} = await createOneObjectMetadata({
input: getMockCreateObjectInput({
namePlural: 'employees',
nameSingular: 'employee',
}),
});
const {
data: {
createOneObject: { id: enterpriseObjectId },
},
} = await createOneObjectMetadata({
input: getMockCreateObjectInput({
namePlural: 'enterprises',
nameSingular: 'enterprise',
}),
});
const { data } = await createOneFieldMetadata({
input: {
objectMetadataId: employeeObjectId,
name: 'employer',
label: 'Employer',
isLabelSyncedWithName: false,
type: FieldMetadataType.RELATION,
relationCreationPayload: {
targetFieldLabel: 'employees',
type: RelationType.MANY_TO_ONE,
targetObjectMetadataId: enterpriseObjectId,
targetFieldIcon: 'IconBuildingSkyscraper',
},
},
});
globalTestContext.employeeObjectId = employeeObjectId;
globalTestContext.enterpriseObjectId = enterpriseObjectId;
globalTestContext.employerFieldMetadataId = data.createOneField.id;
expect(data).toBeDefined();
const { data: oneToManyData } = await createOneFieldMetadata({
input: {
objectMetadataId: enterpriseObjectId,
name: 'staff',
label: 'Staff',
isLabelSyncedWithName: false,
type: FieldMetadataType.RELATION,
relationCreationPayload: {
targetFieldLabel: 'company',
type: RelationType.ONE_TO_MANY,
targetObjectMetadataId: employeeObjectId,
targetFieldIcon: 'IconUsers',
},
},
});
globalTestContext.employeesOneToManyFieldMetadataId =
oneToManyData.createOneField.id;
expect(oneToManyData).toBeDefined();
});
afterAll(async () => {
for (const objectMetadataId of [
globalTestContext.employeeObjectId,
globalTestContext.enterpriseObjectId,
]) {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: objectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: {
idToDelete: objectMetadataId,
},
});
}
});
it.each(failingRelationUpdateTestsUseCase)(
'relation $title',
async ({ context }) => {
const { errors } = await updateOneFieldMetadata({
expectToFail: true,
input: {
idToUpdate: globalTestContext[context.fieldKey],
updatePayload: context.updatePayload,
},
});
expect(errors).toBeDefined();
expect(errors).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny(errors),
);
},
);
});

View File

@@ -0,0 +1,363 @@
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { findManyFieldsMetadata } from 'test/integration/metadata/suites/field-metadata/utils/find-many-fields-metadata.util';
import { updateOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { createRelationBetweenObjects } from 'test/integration/metadata/suites/object-metadata/utils/create-relation-between-objects.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { getMockCreateObjectInput } from 'test/integration/metadata/suites/object-metadata/utils/generate-mock-create-object-metadata-input';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test';
import { FieldMetadataType } from 'twenty-shared/types';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
const globalTestContext = {
employeeObjectId: '',
enterpriseObjectId: '',
employerFieldMetadataId: '',
};
describe('Field metadata relation update should succeed', () => {
beforeAll(async () => {
const {
data: {
createOneObject: { id: employeeObjectId },
},
} = await createOneObjectMetadata({
input: getMockCreateObjectInput({
namePlural: 'employees',
nameSingular: 'employee',
}),
});
const {
data: {
createOneObject: { id: enterpriseObjectId },
},
} = await createOneObjectMetadata({
input: getMockCreateObjectInput({
namePlural: 'enterprises',
nameSingular: 'enterprise',
}),
});
const { data } = await createOneFieldMetadata({
input: {
objectMetadataId: employeeObjectId,
name: 'employer',
label: 'Employer',
isLabelSyncedWithName: false,
type: FieldMetadataType.RELATION,
relationCreationPayload: {
targetFieldLabel: 'employees',
type: RelationType.MANY_TO_ONE,
targetObjectMetadataId: enterpriseObjectId,
targetFieldIcon: 'IconBuildingSkyscraper',
},
},
});
globalTestContext.employeeObjectId = employeeObjectId;
globalTestContext.enterpriseObjectId = enterpriseObjectId;
globalTestContext.employerFieldMetadataId = data.createOneField.id;
expect(data).toBeDefined();
});
afterAll(async () => {
for (const objectMetadataId of [
globalTestContext.employeeObjectId,
globalTestContext.enterpriseObjectId,
]) {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: objectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: {
idToDelete: objectMetadataId,
},
});
}
});
it('when isActive is updated to false', async () => {
const { data, errors } = await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: globalTestContext.employerFieldMetadataId,
updatePayload: {
isActive: false,
},
},
gqlFields: `
id
isActive
`,
});
expect(errors).toBeUndefined();
expect(data).toBeDefined();
expect(data.updateOneField.isActive).toBe(false);
});
it('should successfully update the name of a relation field', async () => {
const { fields } = await findManyFieldsMetadata({
input: {
filter: {
id: { eq: globalTestContext.employerFieldMetadataId },
},
paging: { first: 1 },
},
gqlFields: `
id
name
`,
});
const field = fields[0]?.node;
expect(field?.name).toBe('employer');
const { data, errors } = await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: globalTestContext.employerFieldMetadataId,
updatePayload: {
name: 'leadEmployer',
},
},
gqlFields: `
id
name
`,
});
expect(errors).toBeUndefined();
expect(data).toBeDefined();
expect(data.updateOneField.name).toBe('leadEmployer');
});
// Regression test for https://github.com/twentyhq/twenty/issues/21751: a non-nullable relation has no literal defaultValue and must still be updatable.
it('should successfully update a non-nullable relation field', async () => {
const {
data: {
createOneField: { id: nonNullableRelationFieldId, isNullable },
},
} = await createOneFieldMetadata({
input: {
objectMetadataId: globalTestContext.employeeObjectId,
name: 'mandatoryEmployer',
label: 'Mandatory employer',
isLabelSyncedWithName: false,
isNullable: false,
type: FieldMetadataType.RELATION,
relationCreationPayload: {
targetFieldLabel: 'mandatoryEmployees',
type: RelationType.MANY_TO_ONE,
targetObjectMetadataId: globalTestContext.enterpriseObjectId,
targetFieldIcon: 'IconBuildingSkyscraper',
},
},
gqlFields: `
id
isNullable
`,
});
expect(isNullable).toBe(false);
const { data, errors } = await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: nonNullableRelationFieldId,
updatePayload: {
description: 'Updated description for a required relation',
},
},
gqlFields: `
id
description
`,
});
expect(errors).toBeUndefined();
expect(data).toBeDefined();
expect(data.updateOneField.description).toBe(
'Updated description for a required relation',
);
});
});
describe('Field metadata self-relation update should succeed', () => {
let personObjectId: string;
beforeEach(async () => {
const {
data: {
createOneObject: { id: objectId },
},
} = await createOneObjectMetadata({
input: getMockCreateObjectInput({
namePlural: 'peopleForUpdate',
nameSingular: 'personForUpdate',
}),
});
personObjectId = objectId;
});
afterEach(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: personObjectId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: {
idToDelete: personObjectId,
},
});
});
it('should successfully update MANY_TO_ONE self-relation field label', async () => {
const createdField = await createRelationBetweenObjects({
objectMetadataId: personObjectId,
targetObjectMetadataId: personObjectId,
relationType: RelationType.MANY_TO_ONE,
type: FieldMetadataType.RELATION,
name: 'supervisor',
});
jestExpectToBeDefined(createdField.id);
const { data } = await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: createdField.id,
updatePayload: {
label: 'Direct Supervisor',
description: 'The direct supervisor of this person',
},
},
gqlFields: `
id
label
description
`,
});
expect(data.updateOneField).toMatchObject({
id: createdField.id,
label: 'Direct Supervisor',
description: 'The direct supervisor of this person',
});
});
it('should successfully update ONE_TO_MANY self-relation field label', async () => {
const createdField = await createRelationBetweenObjects({
objectMetadataId: personObjectId,
targetObjectMetadataId: personObjectId,
relationType: RelationType.ONE_TO_MANY,
type: FieldMetadataType.RELATION,
name: 'directReports',
targetFieldLabel: 'supervisor',
});
jestExpectToBeDefined(createdField.id);
const { data } = await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: createdField.id,
updatePayload: {
label: 'Direct Reports',
description: 'All direct reports under this person',
icon: 'IconUsers',
},
},
gqlFields: `
id
label
description
icon
`,
});
expect(data.updateOneField).toMatchObject({
id: createdField.id,
label: 'Direct Reports',
description: 'All direct reports under this person',
icon: 'IconUsers',
});
});
it('should successfully update both sides of self-relation independently', async () => {
const createdField = await createRelationBetweenObjects({
objectMetadataId: personObjectId,
targetObjectMetadataId: personObjectId,
relationType: RelationType.MANY_TO_ONE,
type: FieldMetadataType.RELATION,
name: 'mentor',
});
jestExpectToBeDefined(createdField.id);
jestExpectToBeDefined(createdField.relation?.targetFieldMetadata.id);
const mentorFieldId = createdField.id;
const menteesFieldId = createdField.relation.targetFieldMetadata.id;
const { data: mentorUpdateData } = await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: mentorFieldId,
updatePayload: {
label: 'Mentor',
description: 'The mentor of this person',
},
},
gqlFields: `
id
label
description
`,
});
expect(mentorUpdateData.updateOneField).toMatchObject({
id: mentorFieldId,
label: 'Mentor',
description: 'The mentor of this person',
});
const { data: menteesUpdateData } = await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: menteesFieldId,
updatePayload: {
label: 'Mentees',
description: 'All mentees under this person',
},
},
gqlFields: `
id
label
description
`,
});
expect(menteesUpdateData.updateOneField).toMatchObject({
id: menteesFieldId,
label: 'Mentees',
description: 'All mentees under this person',
});
});
});