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,301 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`updateOne FieldMetadataService morph relation fields v2 - Add one target Should fail adding one object metadata target to pre-existing morph relation where object is already a target 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"fieldMetadata": [
{
"errors": [
{
"code": "NOT_AVAILABLE",
"message": "Name "fieldNamePersonForMorphRelationSecond" is not available as it is already used by another field",
"userFriendlyMessage": "Name "fieldNamePersonForMorphRelationSecond" is not available as it is already used by another field",
"value": "fieldNamePersonForMorphRelationSecond",
},
{
"code": "NOT_AVAILABLE",
"message": "Name "fieldNamePersonForMorphRelationSecondId" is not available as it is already used by another join column name",
"userFriendlyMessage": "Name "fieldNamePersonForMorphRelationSecondId" is not available as it is already used by join column name",
"value": "fieldNamePersonForMorphRelationSecondId",
},
],
"flatEntityMinimalInformation": {
"name": "fieldNamePersonForMorphRelationSecond",
"objectMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "fieldMetadata",
"status": "fail",
"type": "create",
},
{
"errors": [
{
"code": "FIELD_METADATA_NOT_FOUND",
"message": "Relation field target metadata not found in both existing and about to be created field metadatas",
"userFriendlyMessage": "Relation field target metadata not found",
},
],
"flatEntityMinimalInformation": {
"name": "toto",
"objectMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "fieldMetadata",
"status": "fail",
"type": "create",
},
],
"index": [
{
"errors": [
{
"code": "INDEX_ALREADY_EXISTS",
"message": "Index with same name already exists",
"userFriendlyMessage": "Index with same name already exists",
},
{
"code": "INDEX_FIELD_NOT_FOUND",
"message": "Could not find index field related field metadata",
"userFriendlyMessage": "Field referenced in index does not exist",
},
],
"flatEntityMinimalInformation": {
"name": "IDX_4ff93dd3375abd83d29ed3d01b4",
"universalIdentifier": Any<String>,
},
"metadataName": "index",
"status": "fail",
"type": "create",
},
],
"viewField": [
{
"errors": [
{
"code": "INVALID_VIEW_DATA",
"message": "Field metadata not found",
"userFriendlyMessage": "Field metadata not found",
},
],
"flatEntityMinimalInformation": {
"fieldMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
"viewUniversalIdentifier": Any<String>,
},
"metadataName": "viewField",
"status": "fail",
"type": "create",
},
{
"errors": [
{
"code": "INVALID_VIEW_DATA",
"message": "Field metadata not found",
"userFriendlyMessage": "Field metadata not found",
},
],
"flatEntityMinimalInformation": {
"fieldMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
"viewUniversalIdentifier": Any<String>,
},
"metadataName": "viewField",
"status": "fail",
"type": "create",
},
{
"errors": [
{
"code": "INVALID_VIEW_DATA",
"message": "Field metadata not found",
"userFriendlyMessage": "Field metadata not found",
},
],
"flatEntityMinimalInformation": {
"fieldMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
"viewUniversalIdentifier": Any<String>,
},
"metadataName": "viewField",
"status": "fail",
"type": "create",
},
{
"errors": [
{
"code": "INVALID_VIEW_DATA",
"message": "Field metadata not found",
"userFriendlyMessage": "Field metadata not found",
},
],
"flatEntityMinimalInformation": {
"fieldMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
"viewUniversalIdentifier": Any<String>,
},
"metadataName": "viewField",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 2 fieldMetadata, 4 viewFields, 1 index",
"summary": {
"fieldMetadata": 2,
"index": 1,
"totalErrors": 7,
"viewField": 4,
},
"userFriendlyMessage": "Many validation errors",
},
"message": "Multiple validation errors occurred while updating field",
"name": "GraphQLError",
}
`;
exports[`updateOne FieldMetadataService morph relation fields v2 - Add one target Should fail adding one object metadata target to pre-existing morph relation where object is already source object 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"fieldMetadata": [
{
"errors": [
{
"code": "INVALID_FIELD_INPUT",
"message": "Source object cannot be the target object",
"userFriendlyMessage": "Source object cannot be the target object",
},
],
"flatEntityMinimalInformation": {
"name": "fieldNameCompanyForMorphRelationSecond",
"objectMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "fieldMetadata",
"status": "fail",
"type": "create",
},
{
"errors": [
{
"code": "FIELD_METADATA_NOT_FOUND",
"message": "Relation field target metadata not found in both existing and about to be created field metadatas",
"userFriendlyMessage": "Relation field target metadata not found",
},
],
"flatEntityMinimalInformation": {
"name": "toto",
"objectMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "fieldMetadata",
"status": "fail",
"type": "create",
},
],
"index": [
{
"errors": [
{
"code": "INDEX_FIELD_NOT_FOUND",
"message": "Could not find index field related field metadata",
"userFriendlyMessage": "Field referenced in index does not exist",
},
],
"flatEntityMinimalInformation": {
"name": "IDX_59563a0476bb3b8760e33ae6d5b",
"universalIdentifier": Any<String>,
},
"metadataName": "index",
"status": "fail",
"type": "create",
},
],
"viewField": [
{
"errors": [
{
"code": "INVALID_VIEW_DATA",
"message": "Field metadata not found",
"userFriendlyMessage": "Field metadata not found",
},
],
"flatEntityMinimalInformation": {
"fieldMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
"viewUniversalIdentifier": Any<String>,
},
"metadataName": "viewField",
"status": "fail",
"type": "create",
},
{
"errors": [
{
"code": "INVALID_VIEW_DATA",
"message": "Field metadata not found",
"userFriendlyMessage": "Field metadata not found",
},
],
"flatEntityMinimalInformation": {
"fieldMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
"viewUniversalIdentifier": Any<String>,
},
"metadataName": "viewField",
"status": "fail",
"type": "create",
},
{
"errors": [
{
"code": "INVALID_VIEW_DATA",
"message": "Field metadata not found",
"userFriendlyMessage": "Field metadata not found",
},
],
"flatEntityMinimalInformation": {
"fieldMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
"viewUniversalIdentifier": Any<String>,
},
"metadataName": "viewField",
"status": "fail",
"type": "create",
},
{
"errors": [
{
"code": "INVALID_VIEW_DATA",
"message": "Field metadata not found",
"userFriendlyMessage": "Field metadata not found",
},
],
"flatEntityMinimalInformation": {
"fieldMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
"viewUniversalIdentifier": Any<String>,
},
"metadataName": "viewField",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 2 fieldMetadata, 4 viewFields, 1 index",
"summary": {
"fieldMetadata": 2,
"index": 1,
"totalErrors": 7,
"viewField": 4,
},
"userFriendlyMessage": "Many validation errors",
},
"message": "Multiple validation errors occurred while updating field",
"name": "GraphQLError",
}
`;

View File

@@ -0,0 +1,75 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`updateOne FieldMetadataService morph relation fields v2 It should update all morph related flat field metadata and their related field allowing its deletion 1`] = `
{
"description": [
"Description for all",
],
"isActive": [
true,
],
"label": [
"field label",
],
"name": [
"fieldNameOpportunityForMorphRelationSecond",
"fieldNamePersonForMorphRelationSecond",
],
}
`;
exports[`updateOne FieldMetadataService morph relation fields v2 It should update all morph related flat field metadata and their related field allowing its deletion 2`] = `
{
"description": [
"Description for all",
],
"isActive": [
true,
],
"label": [
"tata",
"toto",
],
"name": [
"tata",
"toto",
],
}
`;
exports[`updateOne FieldMetadataService morph relation fields v2 It should update all morph related flat field metadata and their related field allowing its deletion 3`] = `
{
"description": [
"new description",
],
"isActive": [
false,
],
"label": [
"new label",
],
"name": [
"fieldNameOpportunityForMorphRelationSecond",
"fieldNamePersonForMorphRelationSecond",
],
}
`;
exports[`updateOne FieldMetadataService morph relation fields v2 It should update all morph related flat field metadata and their related field allowing its deletion 4`] = `
{
"description": [
"Description for all",
],
"isActive": [
false,
],
"label": [
"tata",
"toto",
],
"name": [
"tata",
"toto",
],
}
`;

View File

@@ -0,0 +1,266 @@
import { deleteOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/delete-one-field-metadata.util';
import { updateOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata.util';
import { createMorphRelationBetweenObjects } from 'test/integration/metadata/suites/object-metadata/utils/create-morph-relation-between-objects.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 { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { 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';
describe('deleteOne FieldMetadataService morph relation fields', () => {
let createdObjectMetadataPersonId = '';
let createdObjectMetadataOpportunityId = '';
let createdObjectMetadataCompanyId = '';
beforeEach(async () => {
const {
data: {
createOneObject: { id: objectMetadataPersonId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'personForMorphRelation',
namePlural: 'peopleForMorphRelation',
labelSingular: 'Person For Morph Relation',
labelPlural: 'People For Morph Relation',
icon: 'IconPerson',
},
});
createdObjectMetadataPersonId = objectMetadataPersonId;
const {
data: {
createOneObject: { id: objectMetadataCompanyId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'companyForMorphRelation',
namePlural: 'companiesForMorphRelation',
labelSingular: 'Company For Morph Relation',
labelPlural: 'Companies For Morph Relation',
icon: 'IconCompany',
},
});
createdObjectMetadataCompanyId = objectMetadataCompanyId;
const {
data: {
createOneObject: { id: objectMetadataOpportunityId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'opportunityForMorphRelation',
namePlural: 'opportunitiesForMorphRelation',
labelSingular: 'Opportunity For Morph Relation',
labelPlural: 'Opportunities For Morph Relation',
icon: 'IconOpportunity',
},
});
createdObjectMetadataOpportunityId = objectMetadataOpportunityId;
});
afterEach(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataPersonId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: { idToDelete: createdObjectMetadataPersonId },
});
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataOpportunityId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: { idToDelete: createdObjectMetadataOpportunityId },
});
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataCompanyId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: { idToDelete: createdObjectMetadataCompanyId },
});
});
type EachTestingContextArray = EachTestingContext<
(args: {
objectMetadataId: string;
firstTargetObjectMetadataId: string;
secondTargetObjectMetadataId: string;
}) => {
relationType: RelationType;
objectMetadataId: string;
firstTargetObjectMetadataId: string;
secondTargetObjectMetadataId: string;
type: FieldMetadataType;
}
>[];
const eachTestingContextArray: EachTestingContextArray = [
{
title: 'should delete a MORPH_RELATION field type MANY_TO_ONE',
context: ({
objectMetadataId,
firstTargetObjectMetadataId,
secondTargetObjectMetadataId,
}) => ({
relationType: RelationType.MANY_TO_ONE,
objectMetadataId,
firstTargetObjectMetadataId,
secondTargetObjectMetadataId,
type: FieldMetadataType.MORPH_RELATION,
}),
},
{
title: 'should delete a MORPH_RELATION field type ONE_TO_MANY',
context: ({
objectMetadataId,
firstTargetObjectMetadataId,
secondTargetObjectMetadataId,
}) => ({
relationType: RelationType.ONE_TO_MANY,
objectMetadataId,
firstTargetObjectMetadataId,
secondTargetObjectMetadataId,
type: FieldMetadataType.MORPH_RELATION,
}),
},
];
const eachTestingContextForRelationWithMorphRelationTargetArray: EachTestingContextArray =
[
{
title:
'should delete a ONE_TO_MANY RELATION that has a MORPH_RELATION field as target',
context: ({
objectMetadataId,
firstTargetObjectMetadataId,
secondTargetObjectMetadataId,
}) => ({
relationType: RelationType.MANY_TO_ONE,
objectMetadataId,
firstTargetObjectMetadataId,
secondTargetObjectMetadataId,
type: FieldMetadataType.MORPH_RELATION,
}),
},
{
title:
'should delete a MANY_TO_ONE RELATION that has a MORPH_RELATION field as target',
context: ({
objectMetadataId,
firstTargetObjectMetadataId,
secondTargetObjectMetadataId,
}) => ({
relationType: RelationType.ONE_TO_MANY,
objectMetadataId,
firstTargetObjectMetadataId,
secondTargetObjectMetadataId,
type: FieldMetadataType.MORPH_RELATION,
}),
},
];
it.each(eachTestingContextArray)('$title', async ({ context }) => {
const contextPayload = context({
objectMetadataId: createdObjectMetadataOpportunityId,
firstTargetObjectMetadataId: createdObjectMetadataPersonId,
secondTargetObjectMetadataId: createdObjectMetadataCompanyId,
});
const createdField = await createMorphRelationBetweenObjects({
objectMetadataId: contextPayload.objectMetadataId,
firstTargetObjectMetadataId: contextPayload.firstTargetObjectMetadataId,
secondTargetObjectMetadataId: contextPayload.secondTargetObjectMetadataId,
type: contextPayload.type,
relationType: contextPayload.relationType,
});
const deactivatedField = await updateOneFieldMetadata({
input: {
idToUpdate: createdField.id,
updatePayload: { isActive: false },
},
gqlFields: `
id
isActive
`,
});
expect(deactivatedField.data.updateOneField.id).toBe(createdField.id);
const { errors } = await deleteOneFieldMetadata({
input: { idToDelete: createdField.id },
expectToFail: false,
});
expect(errors).toBeUndefined();
});
it.each(eachTestingContextForRelationWithMorphRelationTargetArray)(
'$title',
async ({ context }) => {
const contextPayload = context({
objectMetadataId: createdObjectMetadataOpportunityId,
firstTargetObjectMetadataId: createdObjectMetadataPersonId,
secondTargetObjectMetadataId: createdObjectMetadataCompanyId,
});
const createdField = await createMorphRelationBetweenObjects({
objectMetadataId: contextPayload.objectMetadataId,
firstTargetObjectMetadataId: contextPayload.firstTargetObjectMetadataId,
secondTargetObjectMetadataId:
contextPayload.secondTargetObjectMetadataId,
type: contextPayload.type,
relationType: contextPayload.relationType,
});
const targetRelationField =
createdField.morphRelations[0].targetFieldMetadata;
const deactivatedTargetRelationField = await updateOneFieldMetadata({
input: {
idToUpdate: targetRelationField.id,
updatePayload: { isActive: false },
},
gqlFields: `
id
isActive
`,
});
expect(deactivatedTargetRelationField.data.updateOneField.id).toBe(
targetRelationField.id,
);
const { errors } = await deleteOneFieldMetadata({
input: { idToDelete: targetRelationField.id },
expectToFail: false,
});
expect(errors).toBeUndefined();
},
);
});

View File

@@ -0,0 +1,238 @@
import { isDefined } from 'class-validator';
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 { deleteOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/delete-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 { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { FieldMetadataType } from 'twenty-shared/types';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
describe('updateOne FieldMetadataService morph relation fields v2 - Add one target', () => {
let createdObjectMetadataPersonId: string;
let createdObjectMetadataOpportunityId: string;
let createdObjectMetadataCompanyId: string;
let createdObjectMetadataNewTargetId: string;
let createdFieldMetadataId: string;
beforeAll(async () => {
const {
data: {
createOneObject: { id: objectMetadataPersonId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'personForMorphRelationSecond',
namePlural: 'peopleForMorphRelationSecond',
labelSingular: 'Person For Morph Relation',
labelPlural: 'People For Morph Relation',
icon: 'IconPerson',
},
});
createdObjectMetadataPersonId = objectMetadataPersonId;
const {
data: {
createOneObject: { id: objectMetadataCompanyId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'companyForMorphRelationSecond',
namePlural: 'companiesForMorphRelationSecond',
labelSingular: 'Company For Morph Relation',
labelPlural: 'Companies For Morph Relation',
icon: 'IconCompany',
},
});
createdObjectMetadataCompanyId = objectMetadataCompanyId;
const {
data: {
createOneObject: { id: objectMetadataOpportunityId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'opportunityForMorphRelationSecond',
namePlural: 'opportunitiesForMorphRelationSecond',
labelSingular: 'Opportunity For Morph Relation',
labelPlural: 'Opportunities For Morph Relation',
icon: 'IconOpportunity',
},
});
createdObjectMetadataOpportunityId = objectMetadataOpportunityId;
const {
data: {
createOneObject: { id: objectMetadataNewTargetId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'newTargetForMorphRelation',
namePlural: 'newTargetsForMorphRelation',
labelSingular: 'New Target For Morph Relation',
labelPlural: 'New Targets For Morph Relation',
icon: 'IconNewTarget',
},
});
createdObjectMetadataNewTargetId = objectMetadataNewTargetId;
});
afterAll(async () => {
const createdObjectMetadataIds = [
createdObjectMetadataPersonId,
createdObjectMetadataOpportunityId,
createdObjectMetadataCompanyId,
createdObjectMetadataNewTargetId,
];
for (const objectMetadataId of createdObjectMetadataIds) {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: objectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: objectMetadataId },
});
}
});
beforeEach(async () => {
const {
data: { createOneField: rawCreateOneField },
} = await createOneFieldMetadata({
input: {
label: 'field label',
name: 'fieldName',
objectMetadataId: createdObjectMetadataCompanyId,
description: 'Description for all',
type: FieldMetadataType.MORPH_RELATION,
morphRelationsCreationPayload: [
{
targetFieldIcon: 'Icon123',
targetFieldLabel: 'toto',
targetObjectMetadataId: createdObjectMetadataOpportunityId,
type: RelationType.MANY_TO_ONE,
},
{
targetFieldIcon: 'Icon123',
targetFieldLabel: 'tata',
targetObjectMetadataId: createdObjectMetadataPersonId,
type: RelationType.MANY_TO_ONE,
},
],
},
expectToFail: false,
});
createdFieldMetadataId = rawCreateOneField.id;
});
afterEach(async () => {
if (!isDefined(createdFieldMetadataId)) {
return;
}
await updateOneFieldMetadata({
input: {
idToUpdate: createdFieldMetadataId,
updatePayload: {
isActive: false,
},
},
expectToFail: false,
});
await deleteOneFieldMetadata({
input: {
idToDelete: createdFieldMetadataId,
},
expectToFail: false,
});
createdFieldMetadataId = '';
});
it('Should fail adding one object metadata target to pre-existing morph relation where object is already a target', async () => {
const input = {
idToUpdate: createdFieldMetadataId,
updatePayload: {
morphRelationsUpdatePayload: [
{
targetObjectMetadataId: createdObjectMetadataPersonId,
},
],
},
};
const { errors } = await updateOneFieldMetadata({
expectToFail: true,
input,
gqlFields: `
id
name
morphRelations {
type
targetFieldMetadata {
id
name
}
}
`,
});
expectOneNotInternalServerErrorSnapshot({ errors });
});
it('Should fail adding one object metadata target to pre-existing morph relation where object is already source object', async () => {
const input = {
idToUpdate: createdFieldMetadataId,
updatePayload: {
morphRelationsUpdatePayload: [
{
targetObjectMetadataId: createdObjectMetadataCompanyId,
},
],
},
};
const { errors } = await updateOneFieldMetadata({
expectToFail: true,
input,
gqlFields: `
id
name
morphRelations {
type
targetFieldMetadata {
id
name
}
targetObjectMetadata {
id
nameSingular
}
sourceObjectMetadata {
id
nameSingular
}
}
`,
});
expectOneNotInternalServerErrorSnapshot({ errors });
});
});

View File

@@ -0,0 +1,548 @@
import { faker } from '@faker-js/faker';
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 { 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 FailingTestCases = EachTestingContext<
(args: {
createdObjectMetadataPersonId: string;
createdObjectMetadataOpportunityId: string;
createdObjectMetadataCompanyId: string;
}) => Omit<
CreateFieldInput,
'morphRelationsCreationPayload' | 'workspaceId'
> &
Required<Pick<CreateFieldInput, 'morphRelationsCreationPayload'>>
>[];
const morphCreationPayloadMalformedFailingTestCases: FailingTestCases = [
{
title: 'with empty morphRelationCreationPayload',
context: ({ createdObjectMetadataCompanyId }) => ({
label: 'field label',
name: 'fieldName',
objectMetadataId: createdObjectMetadataCompanyId,
type: FieldMetadataType.MORPH_RELATION,
morphRelationsCreationPayload: [],
}),
},
{
title: 'with undefined morphRelationCreationPayload',
context: ({ createdObjectMetadataCompanyId }) => ({
label: 'field label',
name: 'fieldName',
objectMetadataId: createdObjectMetadataCompanyId,
type: FieldMetadataType.MORPH_RELATION,
morphRelationsCreationPayload: undefined as any,
}),
},
{
title: 'with null morphRelationCreationPayload',
context: ({ createdObjectMetadataCompanyId }) => ({
label: 'field label',
name: 'fieldName',
objectMetadataId: createdObjectMetadataCompanyId,
type: FieldMetadataType.MORPH_RELATION,
morphRelationsCreationPayload: null as any,
}),
},
{
title: 'with not an array morphRelationCreationPayload',
context: ({ createdObjectMetadataCompanyId }) => ({
label: 'field label',
name: 'fieldName',
objectMetadataId: createdObjectMetadataCompanyId,
type: FieldMetadataType.MORPH_RELATION,
morphRelationsCreationPayload: 'not-an-array' as any,
}),
},
{
title: 'with malformed morphRelationCreationPayload occurrence',
context: ({
createdObjectMetadataCompanyId,
createdObjectMetadataOpportunityId,
}) => ({
label: 'field label',
name: 'fieldName',
objectMetadataId: createdObjectMetadataCompanyId,
type: FieldMetadataType.MORPH_RELATION,
morphRelationsCreationPayload: [
{
targetFieldIcon: 'Icon123',
targetFieldLabel: 'toto',
targetObjectMetadataId: createdObjectMetadataOpportunityId,
type: RelationType.ONE_TO_MANY,
},
{
targetFieldIcon: 'Icon123',
},
{
targetFieldIcon: 'Icon123',
targetFieldLabel: 'titi',
targetObjectMetadataId: createdObjectMetadataOpportunityId,
type: RelationType.ONE_TO_MANY,
},
],
}),
},
];
const morphCreationPayloadFailingIntegrityValidationFailingTestCases: FailingTestCases =
[
{
title: 'with many relation types',
context: ({
createdObjectMetadataCompanyId,
createdObjectMetadataOpportunityId,
createdObjectMetadataPersonId,
}) => ({
label: 'field label',
name: 'fieldName',
objectMetadataId: createdObjectMetadataCompanyId,
type: FieldMetadataType.MORPH_RELATION,
morphRelationsCreationPayload: [
{
targetFieldIcon: 'Icon123',
targetFieldLabel: 'toto',
targetObjectMetadataId: createdObjectMetadataOpportunityId,
type: RelationType.MANY_TO_ONE,
},
{
targetFieldIcon: 'Icon123',
targetFieldLabel: 'tata',
targetObjectMetadataId: createdObjectMetadataPersonId,
type: RelationType.ONE_TO_MANY,
},
],
}),
},
{
title: 'on source object itself',
context: ({
createdObjectMetadataCompanyId,
createdObjectMetadataOpportunityId,
createdObjectMetadataPersonId,
}) => ({
label: 'field label',
name: 'fieldName',
objectMetadataId: createdObjectMetadataCompanyId,
type: FieldMetadataType.MORPH_RELATION,
morphRelationsCreationPayload: [
{
targetFieldIcon: 'Icon123',
targetFieldLabel: 'toto',
targetObjectMetadataId: createdObjectMetadataOpportunityId,
type: RelationType.ONE_TO_MANY,
},
{
targetFieldIcon: 'Icon123',
targetFieldLabel: 'tata',
targetObjectMetadataId: createdObjectMetadataPersonId,
type: RelationType.ONE_TO_MANY,
},
{
targetFieldIcon: 'Icon123',
targetFieldLabel: 'tata',
targetObjectMetadataId: createdObjectMetadataCompanyId,
type: RelationType.ONE_TO_MANY,
},
],
}),
},
{
title: 'that has several references to same object',
context: ({
createdObjectMetadataCompanyId,
createdObjectMetadataOpportunityId,
createdObjectMetadataPersonId,
}) => ({
label: 'field label',
name: 'fieldName',
objectMetadataId: createdObjectMetadataCompanyId,
type: FieldMetadataType.MORPH_RELATION,
morphRelationsCreationPayload: [
{
targetFieldIcon: 'Icon123',
targetFieldLabel: 'toto',
targetObjectMetadataId: createdObjectMetadataOpportunityId,
type: RelationType.ONE_TO_MANY,
},
{
targetFieldIcon: 'Icon123',
targetFieldLabel: 'tata',
targetObjectMetadataId: createdObjectMetadataPersonId,
type: RelationType.ONE_TO_MANY,
},
{
targetFieldIcon: 'Icon123',
targetFieldLabel: 'titi',
targetObjectMetadataId: createdObjectMetadataOpportunityId,
type: RelationType.ONE_TO_MANY,
},
],
}),
},
];
const relationCreationPayloadEdgeCasesFailingTestsCases: FailingTestCases = [
{
title: 'when targetFieldLabel is empty',
context: ({
createdObjectMetadataCompanyId,
createdObjectMetadataOpportunityId,
}) => ({
label: 'field label',
name: 'fieldName',
objectMetadataId: createdObjectMetadataCompanyId,
type: FieldMetadataType.MORPH_RELATION,
morphRelationsCreationPayload: [
{
targetFieldIcon: 'Icon123',
targetFieldLabel: '',
targetObjectMetadataId: createdObjectMetadataOpportunityId,
type: RelationType.ONE_TO_MANY,
},
],
}),
},
{
title: 'when targetFieldLabel exceeds maximum length',
context: ({
createdObjectMetadataCompanyId,
createdObjectMetadataOpportunityId,
}) => ({
label: 'field label',
name: 'fieldName',
objectMetadataId: createdObjectMetadataCompanyId,
type: FieldMetadataType.MORPH_RELATION,
morphRelationsCreationPayload: [
{
targetFieldIcon: 'Icon123',
targetFieldLabel: 'A'.repeat(64),
targetObjectMetadataId: createdObjectMetadataOpportunityId,
type: RelationType.ONE_TO_MANY,
},
],
}),
},
{
title: 'when targetObjectMetadataId is unknown',
context: ({ createdObjectMetadataCompanyId }) => ({
label: 'field label',
name: 'fieldName',
objectMetadataId: createdObjectMetadataCompanyId,
type: FieldMetadataType.MORPH_RELATION,
morphRelationsCreationPayload: [
{
targetFieldIcon: 'Icon123',
targetFieldLabel: 'test',
targetObjectMetadataId: faker.string.uuid(),
type: RelationType.ONE_TO_MANY,
},
],
}),
},
{
title: 'when targetFieldLabel contains only whitespace',
context: ({
createdObjectMetadataCompanyId,
createdObjectMetadataOpportunityId,
}) => ({
label: 'field label',
name: 'fieldName',
objectMetadataId: createdObjectMetadataCompanyId,
type: FieldMetadataType.MORPH_RELATION,
morphRelationsCreationPayload: [
{
targetFieldIcon: 'Icon123',
targetFieldLabel: ' ',
targetObjectMetadataId: createdObjectMetadataOpportunityId,
type: RelationType.ONE_TO_MANY,
},
],
}),
},
{
title:
'when targetFieldLabel has a first degree conflicts with an existing field on target object metadata id',
context: ({
createdObjectMetadataCompanyId,
createdObjectMetadataOpportunityId,
}) => ({
label: 'field label',
name: 'fieldName',
objectMetadataId: createdObjectMetadataCompanyId,
type: FieldMetadataType.MORPH_RELATION,
morphRelationsCreationPayload: [
{
targetFieldIcon: 'Icon123',
targetFieldLabel: 'name',
targetObjectMetadataId: createdObjectMetadataOpportunityId,
type: RelationType.ONE_TO_MANY,
},
],
}),
},
{
title: 'when type is not provided',
context: ({
createdObjectMetadataCompanyId,
createdObjectMetadataOpportunityId,
}) => ({
label: 'field label',
name: 'fieldName',
objectMetadataId: createdObjectMetadataCompanyId,
type: FieldMetadataType.MORPH_RELATION,
morphRelationsCreationPayload: [
{
targetFieldIcon: 'Icon123',
targetFieldLabel: 'test',
targetObjectMetadataId: createdObjectMetadataOpportunityId,
type: undefined as unknown as RelationType,
},
],
}),
},
{
title: 'when type is a wrong value',
context: ({
createdObjectMetadataCompanyId,
createdObjectMetadataOpportunityId,
}) => ({
label: 'field label',
name: 'fieldName',
objectMetadataId: createdObjectMetadataCompanyId,
type: FieldMetadataType.MORPH_RELATION,
morphRelationsCreationPayload: [
{
targetFieldIcon: 'Icon123',
targetFieldLabel: 'test',
targetObjectMetadataId: createdObjectMetadataOpportunityId,
type: 'wrong' as RelationType,
},
],
}),
},
];
const failingTestCases: FailingTestCases = [
...morphCreationPayloadMalformedFailingTestCases,
...morphCreationPayloadFailingIntegrityValidationFailingTestCases,
...relationCreationPayloadEdgeCasesFailingTestsCases,
];
describe('failing createOne FieldMetadataService morph relation fields v2', () => {
let createdObjectMetadataPersonId: string;
let createdObjectMetadataOpportunityId: string;
let createdObjectMetadataCompanyId: string;
beforeAll(async () => {
const {
data: {
createOneObject: { id: objectMetadataPersonId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'personForMorphRelationSecond',
namePlural: 'peopleForMorphRelationSecond',
labelSingular: 'Person For Morph Relation',
labelPlural: 'People For Morph Relation',
icon: 'IconPerson',
},
});
createdObjectMetadataPersonId = objectMetadataPersonId;
const {
data: {
createOneObject: { id: objectMetadataCompanyId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'companyForMorphRelationSecond',
namePlural: 'companiesForMorphRelationSecond',
labelSingular: 'Company For Morph Relation',
labelPlural: 'Companies For Morph Relation',
icon: 'IconCompany',
},
});
createdObjectMetadataCompanyId = objectMetadataCompanyId;
const {
data: {
createOneObject: { id: objectMetadataOpportunityId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'opportunityForMorphRelationSecond',
namePlural: 'opportunitiesForMorphRelationSecond',
labelSingular: 'Opportunity For Morph Relation',
labelPlural: 'Opportunities For Morph Relation',
icon: 'IconOpportunity',
},
});
createdObjectMetadataOpportunityId = objectMetadataOpportunityId;
});
afterAll(async () => {
const createdObjectMetadataIds = [
createdObjectMetadataPersonId,
createdObjectMetadataOpportunityId,
createdObjectMetadataCompanyId,
];
for (const objectMetadataId of createdObjectMetadataIds) {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: objectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: objectMetadataId },
});
}
});
it.each(eachTestingContextFilter(failingTestCases))(
'it should fail to create a MORPH_RELATION field $title',
async ({ context }) => {
const contextPayload = context({
createdObjectMetadataOpportunityId,
createdObjectMetadataPersonId,
createdObjectMetadataCompanyId,
});
const { errors } = await createOneFieldMetadata({
input: contextPayload,
expectToFail: true,
});
expect(errors.length).toBe(1);
const [firstError] = errors;
expect(firstError).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny(firstError),
);
expect(firstError.extensions.code).not.toBe('INTERNAL_SERVER_ERROR');
},
);
describe('Morph relation collision tests suite', () => {
beforeAll(async () => {
const morphRelationCreateFieldInput: Omit<
CreateFieldInput,
'workspaceId'
> = {
label: 'field label',
name: 'fieldName',
objectMetadataId: createdObjectMetadataCompanyId,
type: FieldMetadataType.MORPH_RELATION,
morphRelationsCreationPayload: [
{
targetFieldIcon: 'Icon123',
targetFieldLabel: 'toto',
targetObjectMetadataId: createdObjectMetadataOpportunityId,
type: RelationType.MANY_TO_ONE,
},
{
targetFieldIcon: 'Icon123',
targetFieldLabel: 'tata',
targetObjectMetadataId: createdObjectMetadataPersonId,
type: RelationType.MANY_TO_ONE,
},
],
};
await createOneFieldMetadata({
input: morphRelationCreateFieldInput,
expectToFail: false,
gqlFields: `
id
name
`,
});
});
it('it should fail to create a field with name than result in existing morph relation field join column', async () => {
const { errors } = await createOneFieldMetadata({
input: {
label: 'colliding field label',
name: `fieldNamePersonForMorphRelationSecondId`,
objectMetadataId: createdObjectMetadataCompanyId,
type: FieldMetadataType.TEXT,
},
expectToFail: true,
});
expect(errors.length).toBe(1);
const [firstError] = errors;
expect(firstError).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny(firstError),
);
expect(firstError.extensions.code).not.toBe('INTERNAL_SERVER_ERROR');
});
it('it should fail to create a already existing morph relation with same field', async () => {
const morphRelationCreateFieldInput: Omit<
CreateFieldInput,
'workspaceId'
> = {
label: 'field label',
name: 'differentFromExisting',
objectMetadataId: createdObjectMetadataCompanyId,
type: FieldMetadataType.MORPH_RELATION,
morphRelationsCreationPayload: [
{
targetFieldIcon: 'Icon123',
targetFieldLabel: 'toto',
targetObjectMetadataId: createdObjectMetadataOpportunityId,
type: RelationType.ONE_TO_MANY,
},
{
targetFieldIcon: 'Icon123',
targetFieldLabel: 'tata',
targetObjectMetadataId: createdObjectMetadataPersonId,
type: RelationType.ONE_TO_MANY,
},
],
};
const { errors } = await createOneFieldMetadata({
input: morphRelationCreateFieldInput,
expectToFail: true,
gqlFields: `
id
name
`,
});
expect(errors.length).toBe(1);
const [firstError] = errors;
expect(firstError).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny(firstError),
);
expect(firstError.extensions.code).not.toBe('INTERNAL_SERVER_ERROR');
});
});
});

View File

@@ -0,0 +1,161 @@
import gql from 'graphql-tag';
import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util';
import { findManyOperationFactory } from 'test/integration/graphql/utils/find-many-operation-factory.util';
import { makeGraphqlAPIRequestWithApiKey } from 'test/integration/graphql/utils/make-graphql-api-request-with-api-key.util';
import { deleteOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/delete-one-field-metadata.util';
import { createMorphRelationBetweenObjects } from 'test/integration/metadata/suites/object-metadata/utils/create-morph-relation-between-objects.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 { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { capitalize } from 'twenty-shared/utils';
import { FieldMetadataType } from 'twenty-shared/types';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
describe('empty morph to-many relation read', () => {
let opportunityObjectId = '';
let personObjectId = '';
let companyObjectId = '';
let morphFieldId = '';
beforeAll(async () => {
({
data: {
createOneObject: { id: opportunityObjectId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'oppForEmptyMorph',
namePlural: 'oppsForEmptyMorph',
labelSingular: 'Opp For Empty Morph',
labelPlural: 'Opps For Empty Morph',
icon: 'IconOpportunity',
},
}));
({
data: {
createOneObject: { id: personObjectId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'personForEmptyMorph',
namePlural: 'peopleForEmptyMorph',
labelSingular: 'Person For Empty Morph',
labelPlural: 'People For Empty Morph',
icon: 'IconUser',
},
}));
({
data: {
createOneObject: { id: companyObjectId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'companyForEmptyMorph',
namePlural: 'companiesForEmptyMorph',
labelSingular: 'Company For Empty Morph',
labelPlural: 'Companies For Empty Morph',
icon: 'IconBuildingSkyscraper',
},
}));
const createdField = await createMorphRelationBetweenObjects({
objectMetadataId: opportunityObjectId,
firstTargetObjectMetadataId: personObjectId,
secondTargetObjectMetadataId: companyObjectId,
type: FieldMetadataType.MORPH_RELATION,
relationType: RelationType.ONE_TO_MANY,
name: 'owner',
});
morphFieldId = createdField.id;
});
afterAll(async () => {
await deleteOneFieldMetadata({ input: { idToDelete: morphFieldId } }).catch(
() => {},
);
for (const idToDelete of [
opportunityObjectId,
personObjectId,
companyObjectId,
]) {
await updateOneObjectMetadata({
expectToFail: false,
input: { idToUpdate: idToDelete, updatePayload: { isActive: false } },
});
await deleteOneObjectMetadata({ input: { idToDelete } });
}
});
it('returns an empty connection (not null) for each morph to-many target', async () => {
await makeGraphqlAPIRequestWithApiKey(
createOneOperationFactory({
objectMetadataSingularName: 'oppForEmptyMorph',
gqlFields: 'id',
data: { name: 'No owners' },
}),
);
const { body: schemaBody } = await makeGraphqlAPIRequestWithApiKey({
query: gql`
query {
__type(name: "${capitalize('oppForEmptyMorph')}") {
fields {
name
type {
name
ofType {
name
}
}
}
}
}
`,
});
const morphConnectionFieldNames: string[] = schemaBody.data.__type.fields
.filter(
(field: {
name: string;
type: { name?: string; ofType?: { name?: string } };
}) => {
const typeName = field.type?.name ?? field.type?.ofType?.name;
return (
field.name.startsWith('owner') && typeName?.endsWith('Connection')
);
},
)
.map((field: { name: string }) => field.name);
expect(morphConnectionFieldNames.length).toBeGreaterThan(0);
const gqlFields = [
'id',
...morphConnectionFieldNames.map(
(fieldName) => `${fieldName} { edges { node { id } } }`,
),
].join('\n');
const { body } = await makeGraphqlAPIRequestWithApiKey(
findManyOperationFactory({
objectMetadataSingularName: 'oppForEmptyMorph',
objectMetadataPluralName: 'oppsForEmptyMorph',
gqlFields,
}),
);
expect(body.errors).toBeUndefined();
const node = body.data.oppsForEmptyMorph.edges[0].node;
for (const fieldName of morphConnectionFieldNames) {
expect(node[fieldName]).toEqual({ edges: [] });
}
});
});

View File

@@ -0,0 +1,232 @@
import { findManyOperationFactory } from 'test/integration/graphql/utils/find-many-operation-factory.util';
import { makeGraphqlAPIRequestWithApiKey } from 'test/integration/graphql/utils/make-graphql-api-request-with-api-key.util';
import { deleteOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/delete-one-field-metadata.util';
import { createMorphRelationBetweenObjects } from 'test/integration/metadata/suites/object-metadata/utils/create-morph-relation-between-objects.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 { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { 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';
// todo @guillim : make ths test work to cover morph CORE api
xdescribe('findMany morph relation records', () => {
let createdObjectMetadataPersonId = '';
let createdObjectMetadataOpportunityId = '';
let createdObjectMetadataCompanyId = '';
beforeEach(async () => {
const {
data: {
createOneObject: { id: objectMetadataPersonId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'personForMorphRelation',
namePlural: 'peopleForMorphRelation',
labelSingular: 'Person For Morph Relation',
labelPlural: 'People For Morph Relation',
icon: 'IconPerson',
},
});
createdObjectMetadataPersonId = objectMetadataPersonId;
const {
data: {
createOneObject: { id: objectMetadataCompanyId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'companyForMorphRelation',
namePlural: 'companiesForMorphRelation',
labelSingular: 'Company For Morph Relation',
labelPlural: 'Companies For Morph Relation',
icon: 'IconCompany',
},
});
createdObjectMetadataCompanyId = objectMetadataCompanyId;
const {
data: {
createOneObject: { id: objectMetadataOpportunityId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'opportunityForMorphRelation',
namePlural: 'opportunitiesForMorphRelation',
labelSingular: 'Opportunity For Morph Relation',
labelPlural: 'Opportunities For Morph Relation',
icon: 'IconOpportunity',
},
});
createdObjectMetadataOpportunityId = objectMetadataOpportunityId;
});
afterEach(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataPersonId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: { idToDelete: createdObjectMetadataPersonId },
});
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataOpportunityId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: { idToDelete: createdObjectMetadataOpportunityId },
});
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataCompanyId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: { idToDelete: createdObjectMetadataCompanyId },
});
});
type EachTestingContextArray = EachTestingContext<
(args: {
objectMetadataId: string;
firstTargetObjectMetadataId: string;
secondTargetObjectMetadataId: string;
}) => {
relationType: RelationType;
objectMetadataId: string;
firstTargetObjectMetadataId: string;
secondTargetObjectMetadataId: string;
type: FieldMetadataType;
}
>[];
const eachTestingContextArray: EachTestingContextArray = [
{
title:
'should find many records with a MORPH_RELATION field type MANY_TO_ONE',
context: ({
objectMetadataId,
firstTargetObjectMetadataId,
secondTargetObjectMetadataId,
}) => ({
relationType: RelationType.MANY_TO_ONE,
objectMetadataId,
firstTargetObjectMetadataId,
secondTargetObjectMetadataId,
type: FieldMetadataType.MORPH_RELATION,
}),
},
// {
// title:
// 'should find many records with a MORPH_RELATION field type ONE_TO_MANY',
// context: ({
// objectMetadataId,
// firstTargetObjectMetadataId,
// secondTargetObjectMetadataId,
// }) => ({
// relationType: RelationType.ONE_TO_MANY,
// objectMetadataId,
// firstTargetObjectMetadataId,
// secondTargetObjectMetadataId,
// type: FieldMetadataType.MORPH_RELATION,
// }),
// },
];
it.each(eachTestingContextArray)('$title', async ({ context }) => {
const contextPayload = context({
objectMetadataId: createdObjectMetadataOpportunityId,
firstTargetObjectMetadataId: createdObjectMetadataPersonId,
secondTargetObjectMetadataId: createdObjectMetadataCompanyId,
});
const createdField = await createMorphRelationBetweenObjects({
objectMetadataId: contextPayload.objectMetadataId,
firstTargetObjectMetadataId: contextPayload.firstTargetObjectMetadataId,
secondTargetObjectMetadataId: contextPayload.secondTargetObjectMetadataId,
type: contextPayload.type,
relationType: contextPayload.relationType,
});
// todo : fill with records
expect(createdField.id).toBeDefined();
expect(createdField.name).toBe('owner');
const MANY_TO_ONE_GQL_FIELDS = `
id
name
ownerPersonForMorphRelationId
ownerPersonForMorphRelation {
id
name
}
ownerCompanyForMorphRelationId
ownerCompanyForMorphRelation {
id
name
}
`;
// const ONE_TO_MANY_GQL_FIELDS = `
// id
// name
// ownerPersonId
// ownerPerson {
// edges {
// node {
// id
// name
// }
// }
// }
// ownerCompanyId
// ownerCompany {
// edges {
// node {
// id
// name
// }
// }
// }
// `;
const graphqlOperation = findManyOperationFactory({
objectMetadataSingularName: 'opportunityForMorphRelation',
objectMetadataPluralName: 'opportunitiesForMorphRelation',
gqlFields: MANY_TO_ONE_GQL_FIELDS,
});
const response = await makeGraphqlAPIRequestWithApiKey(graphqlOperation);
expect(response.body.errors).toBeUndefined();
expect(response.body.data).toBeDefined();
expect(
response.body.data.opportunitiesForMorphRelation.edges,
).toBeDefined();
// const isManyToOne =
// contextPayload.relationType === RelationType.MANY_TO_ONE;
await deleteOneFieldMetadata({
input: { idToDelete: createdField.id },
}).catch();
});
});

View File

@@ -0,0 +1,237 @@
import { randomUUID } from 'crypto';
import { createManyOperationFactory } from 'test/integration/graphql/utils/create-many-operation-factory.util';
import { findManyOperationFactory } from 'test/integration/graphql/utils/find-many-operation-factory.util';
import { makeGraphqlAPIRequestWithApiKey } from 'test/integration/graphql/utils/make-graphql-api-request-with-api-key.util';
import { createMorphRelationBetweenObjects } from 'test/integration/metadata/suites/object-metadata/utils/create-morph-relation-between-objects.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 { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { FieldMetadataType } from 'twenty-shared/types';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
type MorphParentConnection = {
edges: {
node: { id: string; ownerMorphCursorPerson?: { name: string } | null };
}[];
pageInfo: { hasNextPage: boolean; endCursor: string };
};
// A MANY_TO_ONE morph relation materializes one relation field per target
// (owner -> ownerMorphCursorPerson / ownerMorphCursorCompany), so ordering by
// one leg must treat records attached to the other leg - and unattached ones -
// as the NULL block of the scan.
describe('morph relation cursor pagination', () => {
let parentObjectMetadataId = '';
let personObjectMetadataId = '';
let companyObjectMetadataId = '';
const personTargetIds = [randomUUID(), randomUUID(), randomUUID()];
const companyTargetIds = [randomUUID(), randomUUID()];
const personAttachedParentIds = [randomUUID(), randomUUID(), randomUUID()];
const companyAttachedParentIds = [randomUUID(), randomUUID()];
const unattachedParentIds = [randomUUID(), randomUUID()];
const parentTotalCount =
personAttachedParentIds.length +
companyAttachedParentIds.length +
unattachedParentIds.length;
beforeAll(async () => {
const {
data: {
createOneObject: { id: createdParentId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'morphCursorParent',
namePlural: 'morphCursorParents',
labelSingular: 'Morph Cursor Parent',
labelPlural: 'Morph Cursor Parents',
icon: 'IconBox',
},
});
parentObjectMetadataId = createdParentId;
const {
data: {
createOneObject: { id: createdPersonId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'morphCursorPerson',
namePlural: 'morphCursorPeople',
labelSingular: 'Morph Cursor Person',
labelPlural: 'Morph Cursor People',
icon: 'IconPerson',
},
});
personObjectMetadataId = createdPersonId;
const {
data: {
createOneObject: { id: createdCompanyId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'morphCursorCompany',
namePlural: 'morphCursorCompanies',
labelSingular: 'Morph Cursor Company',
labelPlural: 'Morph Cursor Companies',
icon: 'IconCompany',
},
});
companyObjectMetadataId = createdCompanyId;
await createMorphRelationBetweenObjects({
objectMetadataId: parentObjectMetadataId,
firstTargetObjectMetadataId: personObjectMetadataId,
secondTargetObjectMetadataId: companyObjectMetadataId,
type: FieldMetadataType.MORPH_RELATION,
relationType: RelationType.MANY_TO_ONE,
name: 'owner',
label: 'Owner',
});
await makeGraphqlAPIRequestWithApiKey(
createManyOperationFactory({
objectMetadataSingularName: 'morphCursorPerson',
objectMetadataPluralName: 'morphCursorPeople',
gqlFields: 'id',
data: personTargetIds.map((id, index) => ({
id,
name: `Person target ${index + 1}`,
})),
}),
).expect(200);
await makeGraphqlAPIRequestWithApiKey(
createManyOperationFactory({
objectMetadataSingularName: 'morphCursorCompany',
objectMetadataPluralName: 'morphCursorCompanies',
gqlFields: 'id',
data: companyTargetIds.map((id, index) => ({
id,
name: `Company target ${index + 1}`,
})),
}),
).expect(200);
await makeGraphqlAPIRequestWithApiKey(
createManyOperationFactory({
objectMetadataSingularName: 'morphCursorParent',
objectMetadataPluralName: 'morphCursorParents',
gqlFields: 'id',
data: [
...personAttachedParentIds.map((id, index) => ({
id,
name: `Person-attached parent ${index + 1}`,
ownerMorphCursorPersonId: personTargetIds[index],
})),
...companyAttachedParentIds.map((id, index) => ({
id,
name: `Company-attached parent ${index + 1}`,
ownerMorphCursorCompanyId: companyTargetIds[index],
})),
...unattachedParentIds.map((id, index) => ({
id,
name: `Unattached parent ${index + 1}`,
})),
],
}),
).expect(200);
});
afterAll(async () => {
for (const objectMetadataId of [
parentObjectMetadataId,
personObjectMetadataId,
companyObjectMetadataId,
]) {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: objectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
input: { idToDelete: objectMetadataId },
});
}
});
const collectAllPages = async (gqlFields: string) => {
const collectedIds: string[] = [];
const collectedOwnerNames: (string | null)[] = [];
let after: string | undefined = undefined;
for (let iteration = 0; iteration < 10; iteration++) {
const response: {
body: {
errors?: unknown;
data: { morphCursorParents: MorphParentConnection };
};
} = await makeGraphqlAPIRequestWithApiKey(
findManyOperationFactory({
objectMetadataSingularName: 'morphCursorParent',
objectMetadataPluralName: 'morphCursorParents',
gqlFields,
orderBy: { ownerMorphCursorPerson: { name: 'AscNullsLast' } },
first: 2,
after,
}),
).expect(200);
expect(response.body.errors).toBeUndefined();
const connection = response.body.data.morphCursorParents;
for (const edge of connection.edges) {
collectedIds.push(edge.node.id);
collectedOwnerNames.push(
edge.node.ownerMorphCursorPerson?.name ?? null,
);
}
if (!connection.pageInfo.hasNextPage) {
break;
}
after = connection.pageInfo.endCursor;
}
expect(collectedIds).toHaveLength(parentTotalCount);
expect(new Set(collectedIds).size).toBe(parentTotalCount);
expect(collectedIds.slice(0, personAttachedParentIds.length)).toEqual(
personAttachedParentIds,
);
return { collectedIds, collectedOwnerNames };
};
it('should paginate exhaustively when ordering by one morph leg', async () => {
const { collectedIds, collectedOwnerNames } = await collectAllPages(`
id
ownerMorphCursorPerson {
name
}
`);
expect(new Set(collectedIds.slice(personAttachedParentIds.length))).toEqual(
new Set([...companyAttachedParentIds, ...unattachedParentIds]),
);
expect(collectedOwnerNames.slice(personAttachedParentIds.length)).toEqual(
Array(parentTotalCount - personAttachedParentIds.length).fill(null),
);
});
// Cursors read the morph leg's orderBy values from the ordering join itself,
// so pagination must not depend on the selection set (issue #24333)
it('should paginate exhaustively when the ordered morph leg is not selected', async () => {
await collectAllPages('id');
});
});

View File

@@ -0,0 +1,296 @@
import { isDefined } from 'class-validator';
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { deleteOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/delete-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 { findManyObjectMetadataWithIndexes } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata-with-indexes.util';
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';
import { type FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
import { type RelationDTO } from 'src/engine/metadata-modules/field-metadata/dtos/relation.dto';
describe('updateOne FieldMetadataService morph relation fields v2 - Add one target', () => {
let createdObjectMetadataPersonId: string;
let createdObjectMetadataOpportunityId: string;
let createdObjectMetadataCompanyId: string;
let createdObjectMetadataNewTargetId: string;
let createdFieldMetadataId: string;
beforeAll(async () => {
const {
data: {
createOneObject: { id: objectMetadataPersonId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'personForMorphRelationSecond',
namePlural: 'peopleForMorphRelationSecond',
labelSingular: 'Person For Morph Relation',
labelPlural: 'People For Morph Relation',
icon: 'IconPerson',
},
});
createdObjectMetadataPersonId = objectMetadataPersonId;
const {
data: {
createOneObject: { id: objectMetadataCompanyId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'companyForMorphRelationSecond',
namePlural: 'companiesForMorphRelationSecond',
labelSingular: 'Company For Morph Relation',
labelPlural: 'Companies For Morph Relation',
icon: 'IconCompany',
},
});
createdObjectMetadataCompanyId = objectMetadataCompanyId;
const {
data: {
createOneObject: { id: objectMetadataOpportunityId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'opportunityForMorphRelationSecond',
namePlural: 'opportunitiesForMorphRelationSecond',
labelSingular: 'Opportunity For Morph Relation',
labelPlural: 'Opportunities For Morph Relation',
icon: 'IconOpportunity',
},
});
createdObjectMetadataOpportunityId = objectMetadataOpportunityId;
const {
data: {
createOneObject: { id: objectMetadataNewTargetId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'newTargetForMorphRelation',
namePlural: 'newTargetsForMorphRelation',
labelSingular: 'New Target For Morph Relation',
labelPlural: 'New Targets For Morph Relation',
icon: 'IconNewTarget',
},
});
createdObjectMetadataNewTargetId = objectMetadataNewTargetId;
});
afterAll(async () => {
const createdObjectMetadataIds = [
createdObjectMetadataPersonId,
createdObjectMetadataOpportunityId,
createdObjectMetadataCompanyId,
createdObjectMetadataNewTargetId,
];
for (const objectMetadataId of createdObjectMetadataIds) {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: objectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: objectMetadataId },
});
}
});
beforeEach(async () => {
const {
data: { createOneField: rawCreateOneField },
} = await createOneFieldMetadata({
input: {
label: 'field label',
name: 'fieldName',
objectMetadataId: createdObjectMetadataCompanyId,
description: 'Description for all',
type: FieldMetadataType.MORPH_RELATION,
morphRelationsCreationPayload: [
{
targetFieldIcon: 'Icon123',
targetFieldLabel: 'toto',
targetObjectMetadataId: createdObjectMetadataOpportunityId,
type: RelationType.MANY_TO_ONE,
},
{
targetFieldIcon: 'Icon123',
targetFieldLabel: 'tata',
targetObjectMetadataId: createdObjectMetadataPersonId,
type: RelationType.MANY_TO_ONE,
},
],
},
expectToFail: false,
});
createdFieldMetadataId = rawCreateOneField.id;
});
afterEach(async () => {
if (!isDefined(createdFieldMetadataId)) {
return;
}
await updateOneFieldMetadata({
input: {
idToUpdate: createdFieldMetadataId,
updatePayload: {
isActive: false,
},
},
expectToFail: false,
});
await deleteOneFieldMetadata({
input: {
idToDelete: createdFieldMetadataId,
},
expectToFail: false,
});
createdFieldMetadataId = '';
});
it('When adding one object metadata target to pre-existing morph relation, the relation on the target object metadata should be updated', async () => {
const input = {
idToUpdate: createdFieldMetadataId,
updatePayload: {
morphRelationsUpdatePayload: [
{
targetObjectMetadataId: createdObjectMetadataNewTargetId,
},
],
},
};
const result = await updateOneFieldMetadata({
expectToFail: false,
input,
gqlFields: `
id
name
morphRelations {
type
targetFieldMetadata {
id
name
}
targetObjectMetadata {
id
nameSingular
}
sourceFieldMetadata {
id
name
}
}
`,
});
const updateOneField = result.data
?.updateOneField as unknown as FieldMetadataDTO & {
morphRelations: RelationDTO[];
};
expect(updateOneField?.morphRelations.length).toBe(3);
const objectMetadatasWithIndexes = await findManyObjectMetadataWithIndexes({
expectToFail: false,
});
const newLyCreatedMorphRelation = updateOneField?.morphRelations.find(
(morphRelation) =>
morphRelation.targetObjectMetadata.id ===
createdObjectMetadataNewTargetId,
);
jestExpectToBeDefined(newLyCreatedMorphRelation);
const sourceObjectMetadataIndexFieldMetadataList =
objectMetadatasWithIndexes.find(
(objectMetadataWithIndexes) =>
objectMetadataWithIndexes.id === createdObjectMetadataCompanyId,
)?.indexMetadataList;
const isIndexCreated = sourceObjectMetadataIndexFieldMetadataList?.some(
(indexList) =>
indexList.indexFieldMetadataList.some(
(indexField) =>
indexField.fieldMetadataId ===
newLyCreatedMorphRelation.sourceFieldMetadata.id,
),
);
expect(isIndexCreated).toBe(true);
});
// todo and the name ?
it('Should add a new morph relation target and update the label of the field at the same time and expect the added created morph and the old morphs to follow the updated label', async () => {
const input = {
idToUpdate: createdFieldMetadataId,
updatePayload: {
label: 'new label for morph Fields',
morphRelationsUpdatePayload: [
{
targetObjectMetadataId: createdObjectMetadataNewTargetId,
},
],
},
};
const result = await updateOneFieldMetadata({
expectToFail: false,
input,
gqlFields: `
id
name
label
morphRelations {
type
targetFieldMetadata {
id
name
}
sourceFieldMetadata {
id
name
label
}
}
`,
});
const updateOneField = result.data
?.updateOneField as unknown as FieldMetadataDTO & {
morphRelations: RelationDTO[];
};
const morphRelationsLabels = updateOneField.morphRelations.map(
(morphRelation) => morphRelation.sourceFieldMetadata.label,
);
expect(morphRelationsLabels[0]).toBe('new label for morph Fields');
const morphRelationsLabelsSet = new Set(morphRelationsLabels);
expect(morphRelationsLabelsSet.size).toBe(1);
});
});

View File

@@ -0,0 +1,266 @@
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { deleteOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/delete-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 { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import {
eachTestingContextFilter,
type EachTestingContext,
} from 'twenty-shared/testing';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
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';
import { type FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
import { type RelationDTO } from 'src/engine/metadata-modules/field-metadata/dtos/relation.dto';
type EachTestingContextArray = EachTestingContext<
(args: {
createdObjectMetadataPersonId: string;
createdObjectMetadataOpportunityId: string;
createdObjectMetadataCompanyId: string;
}) => Omit<
CreateFieldInput,
'morphRelationsCreationPayload' | 'workspaceId'
> &
Required<Pick<CreateFieldInput, 'morphRelationsCreationPayload'>>
>[];
const successfulTestCases: EachTestingContextArray = [
{
title: 'should create a MORPH_RELATION field type MANY_TO_ONE',
context: ({
createdObjectMetadataCompanyId,
createdObjectMetadataOpportunityId,
createdObjectMetadataPersonId,
}) => ({
label: 'field label',
name: 'fieldName',
objectMetadataId: createdObjectMetadataCompanyId,
type: FieldMetadataType.MORPH_RELATION,
morphRelationsCreationPayload: [
{
targetFieldIcon: 'Icon123',
targetFieldLabel: 'toto',
targetObjectMetadataId: createdObjectMetadataOpportunityId,
type: RelationType.MANY_TO_ONE,
},
{
targetFieldIcon: 'Icon123',
targetFieldLabel: 'tata',
targetObjectMetadataId: createdObjectMetadataPersonId,
type: RelationType.MANY_TO_ONE,
},
],
}),
},
{
title: 'should create a MORPH_RELATION field type ONE_TO_MANY',
context: ({
createdObjectMetadataCompanyId,
createdObjectMetadataOpportunityId,
createdObjectMetadataPersonId,
}) => ({
label: 'field label',
name: 'fieldName',
objectMetadataId: createdObjectMetadataCompanyId,
type: FieldMetadataType.MORPH_RELATION,
morphRelationsCreationPayload: [
{
targetFieldIcon: 'Icon123',
targetFieldLabel: 'toto',
targetObjectMetadataId: createdObjectMetadataOpportunityId,
type: RelationType.ONE_TO_MANY,
},
{
targetFieldIcon: 'Icon123',
targetFieldLabel: 'tata',
targetObjectMetadataId: createdObjectMetadataPersonId,
type: RelationType.ONE_TO_MANY,
},
],
}),
},
];
describe('successful createOne FieldMetadataService morph relation fields v2', () => {
let createdObjectMetadataPersonId: string;
let createdObjectMetadataOpportunityId: string;
let createdObjectMetadataCompanyId: string;
let createdFieldMetadataId: string | undefined = undefined;
beforeAll(async () => {
const {
data: {
createOneObject: { id: objectMetadataPersonId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'personForMorphRelationSecond',
namePlural: 'peopleForMorphRelationSecond',
labelSingular: 'Person For Morph Relation',
labelPlural: 'People For Morph Relation',
icon: 'IconPerson',
},
});
createdObjectMetadataPersonId = objectMetadataPersonId;
const {
data: {
createOneObject: { id: objectMetadataCompanyId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'companyForMorphRelationSecond',
namePlural: 'companiesForMorphRelationSecond',
labelSingular: 'Company For Morph Relation',
labelPlural: 'Companies For Morph Relation',
icon: 'IconCompany',
},
});
createdObjectMetadataCompanyId = objectMetadataCompanyId;
const {
data: {
createOneObject: { id: objectMetadataOpportunityId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'opportunityForMorphRelationSecond',
namePlural: 'opportunitiesForMorphRelationSecond',
labelSingular: 'Opportunity For Morph Relation',
labelPlural: 'Opportunities For Morph Relation',
icon: 'IconOpportunity',
},
});
createdObjectMetadataOpportunityId = objectMetadataOpportunityId;
});
afterAll(async () => {
const createdObjectMetadataIds = [
createdObjectMetadataPersonId,
createdObjectMetadataOpportunityId,
createdObjectMetadataCompanyId,
];
for (const objectMetadataId of createdObjectMetadataIds) {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: objectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: objectMetadataId },
});
}
});
afterEach(async () => {
if (!isDefined(createdFieldMetadataId)) {
return;
}
await updateOneFieldMetadata({
input: {
idToUpdate: createdFieldMetadataId,
updatePayload: {
isActive: false,
},
},
expectToFail: false,
});
await deleteOneFieldMetadata({
input: {
idToDelete: createdFieldMetadataId,
},
expectToFail: false,
});
createdFieldMetadataId = undefined;
});
it.each(eachTestingContextFilter(successfulTestCases))(
'$title',
async ({ context }) => {
const contextPayload = context({
createdObjectMetadataOpportunityId,
createdObjectMetadataPersonId,
createdObjectMetadataCompanyId,
});
const {
data: { createOneField: rawCreateOneField },
} = await createOneFieldMetadata({
input: contextPayload,
expectToFail: false,
gqlFields: `
id
name
label
isLabelSyncedWithName
settings
object {
id
nameSingular
}
morphRelations {
type
targetFieldMetadata {
id
}
targetObjectMetadata {
id
}
sourceFieldMetadata {
id
}
sourceObjectMetadata {
id
}
}
`,
});
const createOneField = rawCreateOneField as FieldMetadataDTO & {
morphRelations: RelationDTO[];
};
createdFieldMetadataId = createOneField.id;
const expectedMorphRelations =
contextPayload.morphRelationsCreationPayload.map(
({ targetObjectMetadataId, type }) => ({
sourceFieldMetadata: {
id: expect.any(String),
},
sourceObjectMetadata: {
id: contextPayload.objectMetadataId,
},
targetFieldMetadata: {
id: expect.any(String),
},
targetObjectMetadata: {
id: targetObjectMetadataId,
},
type,
}),
);
expect(createOneField.morphRelations).toMatchObject(
expectedMorphRelations,
);
},
);
});

View File

@@ -0,0 +1,367 @@
import { type DeepPartial } from 'ai';
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { deleteOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/delete-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 { 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 { 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';
import { type FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
import { type RelationDTO } from 'src/engine/metadata-modules/field-metadata/dtos/relation.dto';
type AggregatedFieldMetadataDto = {
description: (string | null)[];
name: string[];
label: string[];
isActive: boolean[];
};
const aggregateFieldMetadata = (
fieldMetadataDtos: { node: FieldMetadataDTO }[],
) => {
const initialAcc: AggregatedFieldMetadataDto = {
description: [],
name: [],
label: [],
isActive: [],
};
return fieldMetadataDtos.reduce((acc, { node: fieldMetadataDto }) => {
return {
...acc,
description: [
...new Set([...acc.description, fieldMetadataDto.description]),
].sort(),
isActive: [
...new Set([...acc.isActive, fieldMetadataDto.isActive]),
].sort(),
label: [...new Set([...acc.label, fieldMetadataDto.label])].sort(),
name: [...new Set([...acc.name, fieldMetadataDto.name])].sort(),
};
}, initialAcc);
};
describe('updateOne FieldMetadataService morph relation fields v2', () => {
let createdObjectMetadataPersonId: string;
let createdObjectMetadataOpportunityId: string;
let createdObjectMetadataCompanyId: string;
let createdFieldMetadataId: string;
beforeAll(async () => {
const {
data: {
createOneObject: { id: objectMetadataPersonId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'personForMorphRelationSecond',
namePlural: 'peopleForMorphRelationSecond',
labelSingular: 'Person For Morph Relation',
labelPlural: 'People For Morph Relation',
icon: 'IconPerson',
},
});
createdObjectMetadataPersonId = objectMetadataPersonId;
const {
data: {
createOneObject: { id: objectMetadataCompanyId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'companyForMorphRelationSecond',
namePlural: 'companiesForMorphRelationSecond',
labelSingular: 'Company For Morph Relation',
labelPlural: 'Companies For Morph Relation',
icon: 'IconCompany',
},
});
createdObjectMetadataCompanyId = objectMetadataCompanyId;
const {
data: {
createOneObject: { id: objectMetadataOpportunityId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'opportunityForMorphRelationSecond',
namePlural: 'opportunitiesForMorphRelationSecond',
labelSingular: 'Opportunity For Morph Relation',
labelPlural: 'Opportunities For Morph Relation',
icon: 'IconOpportunity',
},
});
createdObjectMetadataOpportunityId = objectMetadataOpportunityId;
});
afterAll(async () => {
const createdObjectMetadataIds = [
createdObjectMetadataPersonId,
createdObjectMetadataOpportunityId,
createdObjectMetadataCompanyId,
];
for (const objectMetadataId of createdObjectMetadataIds) {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: objectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: objectMetadataId },
});
}
});
beforeEach(async () => {
const {
data: { createOneField: rawCreateOneField },
} = await createOneFieldMetadata({
input: {
label: 'field label',
name: 'fieldName',
objectMetadataId: createdObjectMetadataCompanyId,
description: 'Description for all',
type: FieldMetadataType.MORPH_RELATION,
morphRelationsCreationPayload: [
{
targetFieldIcon: 'Icon123',
targetFieldLabel: 'toto',
targetObjectMetadataId: createdObjectMetadataOpportunityId,
type: RelationType.MANY_TO_ONE,
},
{
targetFieldIcon: 'Icon123',
targetFieldLabel: 'tata',
targetObjectMetadataId: createdObjectMetadataPersonId,
type: RelationType.MANY_TO_ONE,
},
],
},
expectToFail: false,
});
createdFieldMetadataId = rawCreateOneField.id;
});
it('It should update all morph related flat field metadata and their related field allowing its deletion', async () => {
const { fields: findResult } = await findManyFieldsMetadata({
input: {
filter: { id: { eq: createdFieldMetadataId } },
paging: { first: 1 },
},
expectToFail: false,
gqlFields: `
id
type
name
label
isLabelSyncedWithName
settings
object {
id
nameSingular
}
morphRelations {
type
targetFieldMetadata {
id
type
}
sourceFieldMetadata {
id
type
}
}
`,
});
expect(findResult.length).toBe(1);
const createdMorphFromResult = findResult[0].node as FieldMetadataDTO & {
morphRelations: RelationDTO[];
};
jestExpectToBeDefined(createdMorphFromResult);
createdMorphFromResult.morphRelations.map((relationDto) =>
expect(relationDto).toMatchObject<DeepPartial<RelationDTO>>({
sourceFieldMetadata: {
type: FieldMetadataType.MORPH_RELATION,
},
targetFieldMetadata: {
type: FieldMetadataType.RELATION,
},
}),
);
const allMorphFieldIds = createdMorphFromResult.morphRelations.map(
({ sourceFieldMetadata }) => sourceFieldMetadata.id,
);
const allRelationFieldIds = createdMorphFromResult.morphRelations.map(
({ targetFieldMetadata }) => targetFieldMetadata.id,
);
{
const morphRelationFieldsBeforeUpdate = (await findManyFieldsMetadata({
input: {
filter: { id: { in: allMorphFieldIds } },
paging: { first: allMorphFieldIds.length },
},
gqlFields: `
id
name
description
label
isActive
`,
expectToFail: false,
})) as { fields: { node: FieldMetadataDTO }[] };
expect(morphRelationFieldsBeforeUpdate.fields.length).toBe(
allMorphFieldIds.length,
);
const aggregatedMorphFieldMetadataDtos = aggregateFieldMetadata(
morphRelationFieldsBeforeUpdate.fields,
);
expect(aggregatedMorphFieldMetadataDtos).toMatchSnapshot();
expect(
aggregatedMorphFieldMetadataDtos,
).toMatchObject<AggregatedFieldMetadataDto>({
description: ['Description for all'],
isActive: [true],
label: ['field label'],
name: [expect.any(String), expect.any(String)],
});
const relationFieldsBeforeUpdate = (await findManyFieldsMetadata({
input: {
filter: { id: { in: allRelationFieldIds } },
paging: { first: allRelationFieldIds.length },
},
gqlFields: `
id
name
description
label
isActive
`,
expectToFail: false,
})) as { fields: { node: FieldMetadataDTO }[] };
expect(relationFieldsBeforeUpdate.fields.length).toBe(
allRelationFieldIds.length,
);
const aggregatedRelationFieldMetadataDtos = aggregateFieldMetadata(
relationFieldsBeforeUpdate.fields,
);
expect(aggregatedRelationFieldMetadataDtos).toMatchSnapshot();
}
const input = {
idToUpdate: createdFieldMetadataId,
updatePayload: {
isActive: false,
label: 'new label',
description: 'new description',
},
};
await updateOneFieldMetadata({
expectToFail: false,
input,
gqlFields: `
id
name
description
label
isActive
`,
});
{
const morphRelationFieldsAfterUpdate = (await findManyFieldsMetadata({
input: {
filter: { id: { in: allMorphFieldIds } },
paging: { first: allMorphFieldIds.length },
},
gqlFields: `
id
name
description
label
isActive
`,
expectToFail: false,
})) as { fields: { node: FieldMetadataDTO }[] };
expect(morphRelationFieldsAfterUpdate.fields.length).toBe(
allMorphFieldIds.length,
);
const aggregatedMorphFieldMetadataDtos = aggregateFieldMetadata(
morphRelationFieldsAfterUpdate.fields,
);
expect(aggregatedMorphFieldMetadataDtos).toMatchSnapshot();
expect(
aggregatedMorphFieldMetadataDtos,
).toMatchObject<AggregatedFieldMetadataDto>({
description: ['new description'],
isActive: [false],
label: ['new label'],
name: [expect.any(String), expect.any(String)],
});
const allRelationFieldIds = createdMorphFromResult.morphRelations.map(
({ targetFieldMetadata }) => targetFieldMetadata.id,
);
const relationFieldsBeforeUpdate = (await findManyFieldsMetadata({
input: {
filter: { id: { in: allRelationFieldIds } },
paging: { first: allRelationFieldIds.length },
},
gqlFields: `
id
name
description
label
isActive
`,
expectToFail: false,
})) as { fields: { node: FieldMetadataDTO }[] };
expect(relationFieldsBeforeUpdate.fields.length).toBe(
allRelationFieldIds.length,
);
const aggregatedRelationFieldMetadataDtos = aggregateFieldMetadata(
relationFieldsBeforeUpdate.fields,
);
expect(aggregatedRelationFieldMetadataDtos).toMatchSnapshot();
}
await deleteOneFieldMetadata({
input: {
idToDelete: createdFieldMetadataId,
},
expectToFail: false,
});
});
});