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,76 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`createOne FieldMetadataService name/label sync should return an error when name and label are not synced but isLabelSyncedWithName is true 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"fieldMetadata": [
{
"errors": [
{
"code": "NAME_NOT_SYNCED_WITH_LABEL",
"message": "Name is not synced with label",
"userFriendlyMessage": "Field name is not synced with field label",
"value": "Different Label",
},
],
"flatEntityMinimalInformation": {
"name": "testField",
"objectMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "fieldMetadata",
"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",
},
],
},
"message": "Validation failed for 1 fieldMetadata, 2 viewFields",
"summary": {
"fieldMetadata": 1,
"totalErrors": 3,
"viewField": 2,
},
"userFriendlyMessage": "Many validation errors",
},
"message": "Multiple validation errors occurred while creating fields",
"name": "GraphQLError",
}
`;

View File

@@ -0,0 +1,196 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Failing create field metadata tests suite should fail to create NUMERIC field type via API 1`] = `
[
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"fieldMetadata": [
{
"errors": [
{
"code": "INVALID_FIELD_INPUT",
"message": "Field type NUMERIC is not supported for field creation. Use NUMBER instead.",
"userFriendlyMessage": "Field type NUMERIC is not supported. Use Number instead.",
"value": "NUMERIC",
},
],
"flatEntityMinimalInformation": {
"name": "numericField",
"objectMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "fieldMetadata",
"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",
},
],
},
"message": "Validation failed for 1 fieldMetadata, 2 viewFields",
"summary": {
"fieldMetadata": 1,
"totalErrors": 3,
"viewField": 2,
},
"userFriendlyMessage": "Many validation errors",
},
"message": "Multiple validation errors occurred while creating fields",
"name": "GraphQLError",
},
]
`;
exports[`Failing create field metadata tests suite should fail to create POSITION field type via API 1`] = `
[
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"fieldMetadata": [
{
"errors": [
{
"code": "INVALID_FIELD_INPUT",
"message": "Field type POSITION must be named "position", got "positionField"",
"userFriendlyMessage": "Field type POSITION must be named "position"",
"value": "positionField",
},
{
"code": "INVALID_FIELD_INPUT",
"message": "Field type POSITION must be a system field",
"userFriendlyMessage": "Field type POSITION must be a system field",
"value": false,
},
],
"flatEntityMinimalInformation": {
"name": "positionField",
"objectMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "fieldMetadata",
"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",
},
],
},
"message": "Validation failed for 1 fieldMetadata, 2 viewFields",
"summary": {
"fieldMetadata": 1,
"totalErrors": 3,
"viewField": 2,
},
"userFriendlyMessage": "Many validation errors",
},
"message": "Multiple validation errors occurred while creating fields",
"name": "GraphQLError",
},
]
`;
exports[`Failing create field metadata tests suite should fail to create TS_VECTOR field type via API 1`] = `
[
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"fieldMetadata": [
{
"errors": [
{
"code": "INVALID_FIELD_INPUT",
"message": "TS Vector is not supported for field creation",
},
],
"flatEntityMinimalInformation": {
"id": Any<String>,
},
"metadataName": "fieldMetadata",
"type": "create",
},
],
},
"message": "Validation failed for 1 fieldMetadata",
"summary": {
"fieldMetadata": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "Metadata validation failed",
},
"message": "Multiple validation errors occurred while creating field",
"name": "GraphQLError",
},
]
`;

View File

@@ -0,0 +1,100 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Standard field metadata update should be ignored when trying to update isLabelSyncedWithName on standard field 1`] = `
{
"extensions": {
"code": "FORBIDDEN",
"subCode": "FIELD_MUTATION_NOT_ALLOWED",
"userFriendlyMessage": "This field cannot be modified.",
},
"message": "Cannot edit standard field metadata properties: isLabelSyncedWithName",
"name": "ForbiddenError",
}
`;
exports[`Standard field metadata update should be ignored when trying to update name on standard field 1`] = `
{
"extensions": {
"code": "FORBIDDEN",
"subCode": "FIELD_MUTATION_NOT_ALLOWED",
"userFriendlyMessage": "This field cannot be modified.",
},
"message": "Cannot edit standard field metadata properties: name",
"name": "ForbiddenError",
}
`;
exports[`Standard field metadata update should be ignored when trying to update several forbidden properties 1`] = `
{
"extensions": {
"code": "FORBIDDEN",
"subCode": "FIELD_MUTATION_NOT_ALLOWED",
"userFriendlyMessage": "This field cannot be modified.",
},
"message": "Cannot edit standard field metadata properties: isLabelSyncedWithName, name",
"name": "ForbiddenError",
}
`;
exports[`Standard field with standard unique index update should fail on isUnique change should fail when trying to remove unique constraint on standard field with standard index 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"agent": [],
"cronTrigger": [],
"databaseEventTrigger": [],
"fieldMetadata": [
{
"errors": [
{
"code": "INVALID_FIELD_INPUT",
"message": "Cannot delete unique index that have not been created by the workspace custom application",
"userFriendlyMessage": "Cannot delete unique index that have not been created by the workspace custom application",
},
],
"flatEntityMinimalInformation": {},
"type": "update_field",
},
],
"index": [],
"objectMetadata": [],
"pageLayout": [],
"pageLayoutTab": [],
"pageLayoutWidget": [],
"role": [],
"roleTarget": [],
"routeTrigger": [],
"serverlessFunction": [],
"view": [],
"viewField": [],
"viewFilter": [],
"viewGroup": [],
},
"message": "Validation failed for 0 object(s) and 0 field(s)",
"summary": {
"invalidAgent": 0,
"invalidCronTrigger": 0,
"invalidDatabaseEventTrigger": 0,
"invalidFieldMetadata": 0,
"invalidIndex": 0,
"invalidObjectMetadata": 0,
"invalidPageLayout": 0,
"invalidPageLayoutTab": 0,
"invalidPageLayoutWidget": 0,
"invalidRole": 0,
"invalidRoleTarget": 0,
"invalidRouteTrigger": 0,
"invalidServerlessFunction": 0,
"invalidView": 0,
"invalidViewField": 0,
"invalidViewFilter": 0,
"invalidViewGroup": 0,
"totalErrors": 0,
},
"userFriendlyMessage": "Validation failed for 0 object(s) and 0 field(s)",
},
"message": "Multiple validation errors occurred while updating field",
"name": "GraphQLError",
}
`;

View File

@@ -0,0 +1,302 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Standard field metadata update should succeed Atomic update test suite when setting isActive to false 1`] = `
{
"defaultValue": "'NEW'",
"description": "Opportunity stage",
"icon": "IconProgressCheck",
"id": Any<String>,
"isActive": false,
"label": "Stage",
"name": "stage",
"options": [
{
"color": "red",
"id": Any<String>,
"label": "New",
"position": 0,
"value": "NEW",
},
{
"color": "purple",
"id": Any<String>,
"label": "Screening",
"position": 1,
"value": "SCREENING",
},
{
"color": "sky",
"id": Any<String>,
"label": "Meeting",
"position": 2,
"value": "MEETING",
},
{
"color": "turquoise",
"id": Any<String>,
"label": "Proposal",
"position": 3,
"value": "PROPOSAL",
},
{
"color": "yellow",
"id": Any<String>,
"label": "Customer",
"position": 4,
"value": "CUSTOMER",
},
],
}
`;
exports[`Standard field metadata update should succeed Atomic update test suite when updating defaultValue 1`] = `
{
"defaultValue": "'SCREENING'",
"description": "Opportunity stage",
"icon": "IconProgressCheck",
"id": Any<String>,
"isActive": true,
"label": "Stage",
"name": "stage",
"options": [
{
"color": "red",
"id": Any<String>,
"label": "New",
"position": 0,
"value": "NEW",
},
{
"color": "purple",
"id": Any<String>,
"label": "Screening",
"position": 1,
"value": "SCREENING",
},
{
"color": "sky",
"id": Any<String>,
"label": "Meeting",
"position": 2,
"value": "MEETING",
},
{
"color": "turquoise",
"id": Any<String>,
"label": "Proposal",
"position": 3,
"value": "PROPOSAL",
},
{
"color": "yellow",
"id": Any<String>,
"label": "Customer",
"position": 4,
"value": "CUSTOMER",
},
],
}
`;
exports[`Standard field metadata update should succeed Atomic update test suite when updating description 1`] = `
{
"defaultValue": "'NEW'",
"description": "Updated test description for company name field",
"icon": "IconProgressCheck",
"id": Any<String>,
"isActive": true,
"label": "Stage",
"name": "stage",
"options": [
{
"color": "red",
"id": Any<String>,
"label": "New",
"position": 0,
"value": "NEW",
},
{
"color": "purple",
"id": Any<String>,
"label": "Screening",
"position": 1,
"value": "SCREENING",
},
{
"color": "sky",
"id": Any<String>,
"label": "Meeting",
"position": 2,
"value": "MEETING",
},
{
"color": "turquoise",
"id": Any<String>,
"label": "Proposal",
"position": 3,
"value": "PROPOSAL",
},
{
"color": "yellow",
"id": Any<String>,
"label": "Customer",
"position": 4,
"value": "CUSTOMER",
},
],
}
`;
exports[`Standard field metadata update should succeed Atomic update test suite when updating icon 1`] = `
{
"defaultValue": "'NEW'",
"description": "Opportunity stage",
"icon": "IconBuildingFactory",
"id": Any<String>,
"isActive": true,
"label": "Stage",
"name": "stage",
"options": [
{
"color": "red",
"id": Any<String>,
"label": "New",
"position": 0,
"value": "NEW",
},
{
"color": "purple",
"id": Any<String>,
"label": "Screening",
"position": 1,
"value": "SCREENING",
},
{
"color": "sky",
"id": Any<String>,
"label": "Meeting",
"position": 2,
"value": "MEETING",
},
{
"color": "turquoise",
"id": Any<String>,
"label": "Proposal",
"position": 3,
"value": "PROPOSAL",
},
{
"color": "yellow",
"id": Any<String>,
"label": "Customer",
"position": 4,
"value": "CUSTOMER",
},
],
}
`;
exports[`Standard field metadata update should succeed Atomic update test suite when updating label 1`] = `
{
"defaultValue": "'NEW'",
"description": "Opportunity stage",
"icon": "IconProgressCheck",
"id": Any<String>,
"isActive": true,
"label": "Business Name",
"name": "stage",
"options": [
{
"color": "red",
"id": Any<String>,
"label": "New",
"position": 0,
"value": "NEW",
},
{
"color": "purple",
"id": Any<String>,
"label": "Screening",
"position": 1,
"value": "SCREENING",
},
{
"color": "sky",
"id": Any<String>,
"label": "Meeting",
"position": 2,
"value": "MEETING",
},
{
"color": "turquoise",
"id": Any<String>,
"label": "Proposal",
"position": 3,
"value": "PROPOSAL",
},
{
"color": "yellow",
"id": Any<String>,
"label": "Customer",
"position": 4,
"value": "CUSTOMER",
},
],
}
`;
exports[`Standard field metadata update should succeed Atomic update test suite when updating options 1`] = `
{
"defaultValue": "'NEW'",
"description": "Opportunity stage",
"icon": "IconProgressCheck",
"id": Any<String>,
"isActive": true,
"label": "Stage",
"name": "stage",
"options": [
{
"color": "red",
"id": Any<String>,
"label": "New Lead",
"position": 0,
"value": "NEW",
},
{
"color": "purple",
"id": Any<String>,
"label": "Under Review",
"position": 1,
"value": "SCREENING",
},
{
"color": "sky",
"id": Any<String>,
"label": "Meeting",
"position": 2,
"value": "MEETING",
},
{
"color": "turquoise",
"id": Any<String>,
"label": "Proposal Sent",
"position": 3,
"value": "PROPOSAL",
},
{
"color": "yellow",
"id": Any<String>,
"label": "Customer",
"position": 4,
"value": "CUSTOMER",
},
{
"color": "green",
"id": Any<String>,
"label": "Closed",
"position": 5,
"value": "CLOSED",
},
],
}
`;

View File

@@ -0,0 +1,195 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`update-one-field-metadata-view-filters-side-effect-v2 MULTI_SELECT should handle adding new options while maintaining existing view filter 1`] = `
{
"operand": "CONTAINS",
"positionInViewFilterGroup": null,
"subFieldName": null,
"value": [
"OPTION_0",
"OPTION_1",
],
"viewFilterGroupId": null,
}
`;
exports[`update-one-field-metadata-view-filters-side-effect-v2 MULTI_SELECT should handle no changes update of options while maintaining existing view filter values 1`] = `
{
"operand": "CONTAINS",
"positionInViewFilterGroup": null,
"subFieldName": null,
"value": [
"OPTION_0",
"OPTION_1",
"OPTION_2",
"OPTION_3",
"OPTION_4",
"OPTION_5",
"OPTION_6",
"OPTION_7",
"OPTION_8",
"OPTION_9",
],
"viewFilterGroupId": null,
}
`;
exports[`update-one-field-metadata-view-filters-side-effect-v2 MULTI_SELECT should handle partial deletion of selected options in view filter 1`] = `
{
"operand": "CONTAINS",
"positionInViewFilterGroup": null,
"subFieldName": null,
"value": [
"OPTION_4",
"OPTION_5",
"OPTION_6",
"OPTION_7",
"OPTION_8",
"OPTION_9",
],
"viewFilterGroupId": null,
}
`;
exports[`update-one-field-metadata-view-filters-side-effect-v2 MULTI_SELECT should handle reordering of options while maintaining view filter values 1`] = `
{
"operand": "CONTAINS",
"positionInViewFilterGroup": null,
"subFieldName": null,
"value": [
"OPTION_0",
"OPTION_1",
],
"viewFilterGroupId": null,
}
`;
exports[`update-one-field-metadata-view-filters-side-effect-v2 MULTI_SELECT should update related multi selected options view filter 1`] = `
{
"operand": "CONTAINS",
"positionInViewFilterGroup": null,
"subFieldName": null,
"value": [
"OPTION_0_UPDATED",
"OPTION_1",
"OPTION_2_UPDATED",
"OPTION_3",
"OPTION_4_UPDATED",
"OPTION_5",
"OPTION_6_UPDATED",
"OPTION_7",
"OPTION_8_UPDATED",
"OPTION_9",
],
"viewFilterGroupId": null,
}
`;
exports[`update-one-field-metadata-view-filters-side-effect-v2 MULTI_SELECT should update related solo selected option view filter 1`] = `
{
"operand": "CONTAINS",
"positionInViewFilterGroup": null,
"subFieldName": null,
"value": [
"OPTION_5_UPDATED",
],
"viewFilterGroupId": null,
}
`;
exports[`update-one-field-metadata-view-filters-side-effect-v2 SELECT should handle adding new options while maintaining existing view filter 1`] = `
{
"operand": "IS",
"positionInViewFilterGroup": null,
"subFieldName": null,
"value": [
"OPTION_0",
"OPTION_1",
],
"viewFilterGroupId": null,
}
`;
exports[`update-one-field-metadata-view-filters-side-effect-v2 SELECT should handle no changes update of options while maintaining existing view filter values 1`] = `
{
"operand": "IS",
"positionInViewFilterGroup": null,
"subFieldName": null,
"value": [
"OPTION_0",
"OPTION_1",
"OPTION_2",
"OPTION_3",
"OPTION_4",
"OPTION_5",
"OPTION_6",
"OPTION_7",
"OPTION_8",
"OPTION_9",
],
"viewFilterGroupId": null,
}
`;
exports[`update-one-field-metadata-view-filters-side-effect-v2 SELECT should handle partial deletion of selected options in view filter 1`] = `
{
"operand": "IS",
"positionInViewFilterGroup": null,
"subFieldName": null,
"value": [
"OPTION_4",
"OPTION_5",
"OPTION_6",
"OPTION_7",
"OPTION_8",
"OPTION_9",
],
"viewFilterGroupId": null,
}
`;
exports[`update-one-field-metadata-view-filters-side-effect-v2 SELECT should handle reordering of options while maintaining view filter values 1`] = `
{
"operand": "IS",
"positionInViewFilterGroup": null,
"subFieldName": null,
"value": [
"OPTION_0",
"OPTION_1",
],
"viewFilterGroupId": null,
}
`;
exports[`update-one-field-metadata-view-filters-side-effect-v2 SELECT should update related multi selected options view filter 1`] = `
{
"operand": "IS",
"positionInViewFilterGroup": null,
"subFieldName": null,
"value": [
"OPTION_0_UPDATED",
"OPTION_1",
"OPTION_2_UPDATED",
"OPTION_3",
"OPTION_4_UPDATED",
"OPTION_5",
"OPTION_6_UPDATED",
"OPTION_7",
"OPTION_8_UPDATED",
"OPTION_9",
],
"viewFilterGroupId": null,
}
`;
exports[`update-one-field-metadata-view-filters-side-effect-v2 SELECT should update related solo selected option view filter 1`] = `
{
"operand": "IS",
"positionInViewFilterGroup": null,
"subFieldName": null,
"value": [
"OPTION_5_UPDATED",
],
"viewFilterGroupId": null,
}
`;

View File

@@ -0,0 +1,85 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`updateOne failing update should not update a field name if it is not synced correctly with label and labelSync is true 1`] = `
[
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"fieldMetadata": [
{
"errors": [
{
"code": "FIELD_MUTATION_NOT_ALLOWED",
"message": "Name is not synced with label.",
"userFriendlyMessage": "Updated field name is not synced with label",
},
],
"flatEntityMinimalInformation": {
"name": "newName",
"objectMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "fieldMetadata",
"status": "fail",
"type": "update",
},
],
},
"message": "Validation failed for 1 fieldMetadata",
"summary": {
"fieldMetadata": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "Updated field name is not synced with label",
},
"message": "Multiple validation errors occurred while updating field",
"name": "GraphQLError",
},
]
`;
exports[`updateOne failing update should throw if the field name is not available because of other field with the same name 1`] = `
[
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"fieldMetadata": [
{
"errors": [
{
"code": "NOT_AVAILABLE",
"message": "Name "otherTestName" is not available as it is already used by another field",
"userFriendlyMessage": "Name "otherTestName" is not available as it is already used by another field",
"value": "otherTestName",
},
{
"code": "FIELD_MUTATION_NOT_ALLOWED",
"message": "Name is not synced with label.",
"userFriendlyMessage": "Updated field name is not synced with label",
},
],
"flatEntityMinimalInformation": {
"name": "otherTestName",
"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 "otherTestName" is not available as it is already used by another field",
},
"message": "Multiple validation errors occurred while updating field",
"name": "GraphQLError",
},
]
`;

View File

@@ -0,0 +1,316 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`createOne FILES field metadata - failing should fail to create files field with isUnique = true 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"fieldMetadata": [
{
"errors": [
{
"code": "INVALID_FIELD_INPUT",
"message": "Files field is not supported for unique fields",
"userFriendlyMessage": "Files field is not supported for unique fields",
},
],
"flatEntityMinimalInformation": {
"name": "filesFieldUnique",
"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_UNIQUE_47bf8eae9471bbd64cbb794a3b6",
"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",
},
],
},
"message": "Validation failed for 1 fieldMetadata, 2 viewFields, 1 index",
"summary": {
"fieldMetadata": 1,
"index": 1,
"totalErrors": 4,
"viewField": 2,
},
"userFriendlyMessage": "Many validation errors",
},
"message": "Multiple validation errors occurred while creating fields",
"name": "GraphQLError",
}
`;
exports[`createOne FILES field metadata - failing should fail to create files field with maxNumberOfValues = 0 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"fieldMetadata": [
{
"errors": [
{
"code": "INVALID_FIELD_INPUT",
"message": "maxNumberOfValues must be defined in settings and be a number greater than 0 and less than or equal to 60",
"userFriendlyMessage": "maxNumberOfValues must be defined in settings and be a number greater than 0 and less than or equal to 60",
},
],
"flatEntityMinimalInformation": {
"name": "filesFieldInvalid",
"objectMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "fieldMetadata",
"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",
},
],
},
"message": "Validation failed for 1 fieldMetadata, 2 viewFields",
"summary": {
"fieldMetadata": 1,
"totalErrors": 3,
"viewField": 2,
},
"userFriendlyMessage": "Many validation errors",
},
"message": "Multiple validation errors occurred while creating fields",
"name": "GraphQLError",
}
`;
exports[`createOne FILES field metadata - failing should fail to create files field with maxNumberOfValues = 61 (exceeds max) 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"fieldMetadata": [
{
"errors": [
{
"code": "INVALID_FIELD_INPUT",
"message": "maxNumberOfValues must be defined in settings and be a number greater than 0 and less than or equal to 60",
"userFriendlyMessage": "maxNumberOfValues must be defined in settings and be a number greater than 0 and less than or equal to 60",
},
],
"flatEntityMinimalInformation": {
"name": "filesFieldExceeds",
"objectMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "fieldMetadata",
"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",
},
],
},
"message": "Validation failed for 1 fieldMetadata, 2 viewFields",
"summary": {
"fieldMetadata": 1,
"totalErrors": 3,
"viewField": 2,
},
"userFriendlyMessage": "Many validation errors",
},
"message": "Multiple validation errors occurred while creating fields",
"name": "GraphQLError",
}
`;
exports[`createOne FILES field metadata - failing should fail to create files field without settings 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"fieldMetadata": [
{
"errors": [
{
"code": "INVALID_FIELD_INPUT",
"message": "maxNumberOfValues must be defined in settings and be a number greater than 0 and less than or equal to 60",
"userFriendlyMessage": "maxNumberOfValues must be defined in settings and be a number greater than 0 and less than or equal to 60",
},
],
"flatEntityMinimalInformation": {
"name": "filesFieldNoSettings",
"objectMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "fieldMetadata",
"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",
},
],
},
"message": "Validation failed for 1 fieldMetadata, 2 viewFields",
"summary": {
"fieldMetadata": 1,
"totalErrors": 3,
"viewField": 2,
},
"userFriendlyMessage": "Many validation errors",
},
"message": "Multiple validation errors occurred while creating fields",
"name": "GraphQLError",
}
`;

View File

@@ -0,0 +1,112 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`updateOne FILES field metadata - failing should fail to update files field settings with maxNumberOfValues = 0 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"fieldMetadata": [
{
"errors": [
{
"code": "INVALID_FIELD_INPUT",
"message": "maxNumberOfValues must be defined in settings and be a number greater than 0 and less than or equal to 60",
"userFriendlyMessage": "maxNumberOfValues must be defined in settings and be a number greater than 0 and less than or equal to 60",
},
],
"flatEntityMinimalInformation": {
"name": "testFilesForFailure",
"objectMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "fieldMetadata",
"status": "fail",
"type": "update",
},
],
},
"message": "Validation failed for 1 fieldMetadata",
"summary": {
"fieldMetadata": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "maxNumberOfValues must be defined in settings and be a number greater than 0 and less than or equal to 60",
},
"message": "Multiple validation errors occurred while updating field",
"name": "GraphQLError",
}
`;
exports[`updateOne FILES field metadata - failing should fail to update files field settings with maxNumberOfValues = 61 (exceeds max) 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"fieldMetadata": [
{
"errors": [
{
"code": "INVALID_FIELD_INPUT",
"message": "maxNumberOfValues must be defined in settings and be a number greater than 0 and less than or equal to 60",
"userFriendlyMessage": "maxNumberOfValues must be defined in settings and be a number greater than 0 and less than or equal to 60",
},
],
"flatEntityMinimalInformation": {
"name": "testFilesForFailure",
"objectMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "fieldMetadata",
"status": "fail",
"type": "update",
},
],
},
"message": "Validation failed for 1 fieldMetadata",
"summary": {
"fieldMetadata": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "maxNumberOfValues must be defined in settings and be a number greater than 0 and less than or equal to 60",
},
"message": "Multiple validation errors occurred while updating field",
"name": "GraphQLError",
}
`;
exports[`updateOne FILES field metadata - failing should fail to update files field with isUnique = true 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"fieldMetadata": [
{
"errors": [
{
"code": "INVALID_FIELD_INPUT",
"message": "Files field is not supported for unique fields",
"userFriendlyMessage": "Files field is not supported for unique fields",
},
],
"flatEntityMinimalInformation": {
"name": "testFilesForFailure",
"objectMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "fieldMetadata",
"status": "fail",
"type": "update",
},
],
},
"message": "Validation failed for 1 fieldMetadata",
"summary": {
"fieldMetadata": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "Files field is not supported for unique fields",
},
"message": "Multiple validation errors occurred while updating field",
"name": "GraphQLError",
}
`;

View File

@@ -0,0 +1,212 @@
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 { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { FieldMetadataType } from 'twenty-shared/types';
describe('createOne FILES field metadata - successful', () => {
let createdObjectMetadataId: string;
beforeAll(async () => {
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'testFilesFieldObject',
namePlural: 'testFilesFieldObjects',
labelSingular: 'Test Files Field Object',
labelPlural: 'Test Files Field Objects',
icon: 'IconFile',
isLabelSyncedWithName: false,
},
});
createdObjectMetadataId = data.createOneObject.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
});
it('should create files field with maxNumberOfValues = 1', async () => {
const { data, errors } = await createOneFieldMetadata({
expectToFail: false,
input: {
objectMetadataId: createdObjectMetadataId,
name: 'filesFieldOne',
label: 'Files Field One',
type: FieldMetadataType.FILES,
settings: { maxNumberOfValues: 1 },
},
gqlFields: `
id
type
name
label
settings
`,
});
expect(errors).toBeUndefined();
expect(data).not.toBeNull();
expect(data.createOneField).toBeDefined();
expect(data.createOneField.type).toBe(FieldMetadataType.FILES);
expect(data.createOneField.settings).toEqual({ maxNumberOfValues: 1 });
});
it('should create files field with maxNumberOfValues = 5', async () => {
const { data, errors } = await createOneFieldMetadata({
expectToFail: false,
input: {
objectMetadataId: createdObjectMetadataId,
name: 'filesFieldFive',
label: 'Files Field Five',
type: FieldMetadataType.FILES,
settings: { maxNumberOfValues: 5 },
},
gqlFields: `
id
type
name
label
settings
`,
});
expect(errors).toBeUndefined();
expect(data).not.toBeNull();
expect(data.createOneField).toBeDefined();
expect(data.createOneField.type).toBe(FieldMetadataType.FILES);
expect(data.createOneField.settings).toEqual({ maxNumberOfValues: 5 });
});
it('should create files field with maxNumberOfValues = 60 (max allowed)', async () => {
const { data, errors } = await createOneFieldMetadata({
expectToFail: false,
input: {
objectMetadataId: createdObjectMetadataId,
name: 'filesFieldSixty',
label: 'Files Field Sixty',
type: FieldMetadataType.FILES,
settings: { maxNumberOfValues: 60 },
},
gqlFields: `
id
type
name
label
settings
`,
});
expect(errors).toBeUndefined();
expect(data).not.toBeNull();
expect(data.createOneField).toBeDefined();
expect(data.createOneField.type).toBe(FieldMetadataType.FILES);
expect(data.createOneField.settings).toEqual({ maxNumberOfValues: 60 });
});
});
describe('createOne FILES field metadata - failing', () => {
let createdObjectMetadataId: string;
beforeAll(async () => {
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'testFilesFieldFailingObject',
namePlural: 'testFilesFieldFailingObjects',
labelSingular: 'Test Files Field Failing Object',
labelPlural: 'Test Files Field Failing Objects',
icon: 'IconFile',
isLabelSyncedWithName: false,
},
});
createdObjectMetadataId = data.createOneObject.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
});
it('should fail to create files field with maxNumberOfValues = 0', async () => {
const { errors } = await createOneFieldMetadata({
expectToFail: true,
input: {
objectMetadataId: createdObjectMetadataId,
name: 'filesFieldInvalid',
label: 'Files Field Invalid',
type: FieldMetadataType.FILES,
settings: { maxNumberOfValues: 0 },
},
});
expectOneNotInternalServerErrorSnapshot({ errors });
});
it('should fail to create files field with maxNumberOfValues = 61 (exceeds max)', async () => {
const { errors } = await createOneFieldMetadata({
expectToFail: true,
input: {
objectMetadataId: createdObjectMetadataId,
name: 'filesFieldExceeds',
label: 'Files Field Exceeds',
type: FieldMetadataType.FILES,
settings: { maxNumberOfValues: 61 },
},
});
expectOneNotInternalServerErrorSnapshot({ errors });
});
it('should fail to create files field without settings', async () => {
const { errors } = await createOneFieldMetadata({
expectToFail: true,
input: {
objectMetadataId: createdObjectMetadataId,
name: 'filesFieldNoSettings',
label: 'Files Field No Settings',
type: FieldMetadataType.FILES,
},
});
expectOneNotInternalServerErrorSnapshot({ errors });
});
it('should fail to create files field with isUnique = true', async () => {
const { errors } = await createOneFieldMetadata({
expectToFail: true,
input: {
objectMetadataId: createdObjectMetadataId,
name: 'filesFieldUnique',
label: 'Files Field Unique',
type: FieldMetadataType.FILES,
settings: { maxNumberOfValues: 5 },
isUnique: true,
},
});
expectOneNotInternalServerErrorSnapshot({ errors });
});
});

View File

@@ -0,0 +1,244 @@
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';
describe('updateOne FILES field metadata - successful', () => {
let createdObjectMetadataId: string;
let createdFieldMetadataId: string;
beforeAll(async () => {
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'testFilesUpdateObject',
namePlural: 'testFilesUpdateObjects',
labelSingular: 'Test Files Update Object',
labelPlural: 'Test Files Update Objects',
icon: 'IconFile',
isLabelSyncedWithName: false,
},
});
createdObjectMetadataId = data.createOneObject.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
});
beforeEach(async () => {
const { data } = await createOneFieldMetadata({
expectToFail: false,
input: {
objectMetadataId: createdObjectMetadataId,
type: FieldMetadataType.FILES,
name: 'testFiles',
label: 'Test Files',
description: 'Initial description',
icon: 'IconFile',
isLabelSyncedWithName: false,
settings: {
maxNumberOfValues: 5,
},
},
gqlFields: `
id
`,
});
createdFieldMetadataId = data.createOneField.id;
});
afterEach(async () => {
await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: createdFieldMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneFieldMetadata({
expectToFail: false,
input: { idToDelete: createdFieldMetadataId },
});
});
it('should update files field basic metadata (label, description, icon)', async () => {
const updatePayload = {
label: 'Updated Files',
description: 'Updated description',
icon: 'IconFiles',
};
const { data, errors } = await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: createdFieldMetadataId,
updatePayload,
},
gqlFields: `
id
type
name
label
description
icon
settings
`,
});
expect(errors).toBeUndefined();
expect(data.updateOneField).toMatchObject(updatePayload);
});
it('should update files field settings with maxNumberOfValues = 5', async () => {
const updatePayload = {
settings: { maxNumberOfValues: 5 },
};
const { data, errors } = await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: createdFieldMetadataId,
updatePayload,
},
gqlFields: `
id
type
name
label
description
icon
settings
`,
});
expect(errors).toBeUndefined();
expect(data.updateOneField).toMatchObject(updatePayload);
});
});
describe('updateOne FILES field metadata - failing', () => {
let createdObjectMetadataId: string;
let createdFieldMetadataId: string;
beforeAll(async () => {
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'testFilesUpdateFailingObject',
namePlural: 'testFilesUpdateFailingObjects',
labelSingular: 'Test Files Update Failing Object',
labelPlural: 'Test Files Update Failing Objects',
icon: 'IconFile',
isLabelSyncedWithName: false,
},
});
createdObjectMetadataId = data.createOneObject.id;
const { data: fieldData } = await createOneFieldMetadata({
expectToFail: false,
input: {
objectMetadataId: createdObjectMetadataId,
type: FieldMetadataType.FILES,
name: 'testFilesForFailure',
label: 'Test Files For Failure',
description: 'Initial description',
icon: 'IconFile',
isLabelSyncedWithName: false,
settings: {
maxNumberOfValues: 5,
},
},
gqlFields: `
id
`,
});
createdFieldMetadataId = fieldData.createOneField.id;
});
afterAll(async () => {
await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: createdFieldMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneFieldMetadata({
expectToFail: false,
input: { idToDelete: createdFieldMetadataId },
});
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
});
it('should fail to update files field settings with maxNumberOfValues = 0', async () => {
const { errors } = await updateOneFieldMetadata({
expectToFail: true,
input: {
idToUpdate: createdFieldMetadataId,
updatePayload: {
settings: { maxNumberOfValues: 0 },
},
},
});
expectOneNotInternalServerErrorSnapshot({ errors });
});
it('should fail to update files field settings with maxNumberOfValues = 61 (exceeds max)', async () => {
const { errors } = await updateOneFieldMetadata({
expectToFail: true,
input: {
idToUpdate: createdFieldMetadataId,
updatePayload: {
settings: { maxNumberOfValues: 61 },
},
},
});
expectOneNotInternalServerErrorSnapshot({ errors });
});
it('should fail to update files field with isUnique = true', async () => {
const { errors } = await updateOneFieldMetadata({
expectToFail: true,
input: {
idToUpdate: createdFieldMetadataId,
updatePayload: {
isUnique: true,
},
},
});
expectOneNotInternalServerErrorSnapshot({ errors });
});
});

View File

@@ -0,0 +1,308 @@
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { createOneSelectFieldMetadataForIntegrationTests } from 'test/integration/metadata/suites/field-metadata/utils/create-one-select-field-metadata-for-integration-tests.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 { createOneView } from 'test/integration/metadata/suites/view/utils/create-one-view.util';
import { findOneView } from 'test/integration/metadata/suites/view/utils/find-one-view.util';
import { generateRecordName } from 'test/integration/utils/generate-record-name';
import {
FieldMetadataType,
ViewCalendarLayout,
ViewType,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
const VIEW_WITH_CALENDAR_FIELDS = `
id
name
objectMetadataId
type
icon
position
isCompact
calendarLayout
calendarFieldMetadataId
`;
type TestSetup = {
objectMetadataId: string;
calendarFieldMetadataId: string;
nonCalendarFieldMetadataId: string;
viewWithCalendarId: string;
viewWithoutCalendarId: string;
};
describe('calendar-field-deactivation-deletes-views', () => {
let testSetup: TestSetup;
const verifyViewExists = async (viewId: string, shouldExist: boolean) => {
const {
data: { getView },
} = await findOneView({
viewId,
gqlFields: VIEW_WITH_CALENDAR_FIELDS,
expectToFail: false,
});
if (shouldExist) {
expect(isDefined(getView)).toBe(true);
} else {
expect(getView).toBeNull();
}
return getView;
};
const deactivateFieldAndVerify = async (fieldId: string) => {
const { data, errors } = await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: fieldId,
updatePayload: { isActive: false },
},
gqlFields: `
id
isActive
`,
});
expect(errors).toBeUndefined();
expect(data.updateOneField.id).toBe(fieldId);
expect(data.updateOneField.isActive).toBe(false);
};
beforeEach(async () => {
const {
data: {
createOneObject: { id: objectMetadataId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'calendarViewDeletionTestObject',
namePlural: 'calendarViewDeletionTestObjects',
labelSingular: 'Calendar View Deletion Test Object',
labelPlural: 'Calendar View Deletion Test Objects',
icon: 'IconTestTube',
},
});
const {
data: {
createOneField: { id: calendarFieldMetadataId },
},
} = await createOneFieldMetadata({
expectToFail: false,
input: {
name: 'calendarDateField',
type: FieldMetadataType.DATE_TIME,
label: 'Calendar Date Field',
objectMetadataId,
},
gqlFields: 'id',
});
const {
data: {
createOneField: { id: nonCalendarFieldMetadataId },
},
} = await createOneFieldMetadata({
expectToFail: false,
input: {
name: 'normalField',
type: FieldMetadataType.TEXT,
label: 'Normal Field',
objectMetadataId,
},
gqlFields: 'id',
});
const {
data: { createView: viewWithCalendar },
} = await createOneView({
input: {
name: generateRecordName('Calendar View With Date Field'),
objectMetadataId,
type: ViewType.CALENDAR,
calendarFieldMetadataId,
calendarLayout: ViewCalendarLayout.WEEK,
icon: 'IconCalendar',
},
gqlFields: VIEW_WITH_CALENDAR_FIELDS,
expectToFail: false,
});
const {
data: { createView: viewWithoutCalendar },
} = await createOneView({
input: {
name: generateRecordName('Table View Without Calendar'),
objectMetadataId,
type: ViewType.TABLE,
icon: 'IconTable',
},
gqlFields: VIEW_WITH_CALENDAR_FIELDS,
expectToFail: false,
});
testSetup = {
objectMetadataId,
calendarFieldMetadataId,
nonCalendarFieldMetadataId,
viewWithCalendarId: viewWithCalendar.id,
viewWithoutCalendarId: viewWithoutCalendar.id,
};
});
afterEach(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: testSetup.objectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: testSetup.objectMetadataId },
});
});
it('should delete view when field used as calendarFieldMetadataId is deactivated', async () => {
const initialViewWithCalendar = await verifyViewExists(
testSetup.viewWithCalendarId,
true,
);
await verifyViewExists(testSetup.viewWithoutCalendarId, true);
expect(initialViewWithCalendar.calendarFieldMetadataId).toBe(
testSetup.calendarFieldMetadataId,
);
expect(initialViewWithCalendar.calendarLayout).toBe('WEEK');
await deactivateFieldAndVerify(testSetup.calendarFieldMetadataId);
await verifyViewExists(testSetup.viewWithCalendarId, false);
await verifyViewExists(testSetup.viewWithoutCalendarId, true);
});
it('should not delete view when field not used as calendarFieldMetadataId is deactivated', async () => {
await verifyViewExists(testSetup.viewWithCalendarId, true);
await verifyViewExists(testSetup.viewWithoutCalendarId, true);
await deactivateFieldAndVerify(testSetup.nonCalendarFieldMetadataId);
await verifyViewExists(testSetup.viewWithCalendarId, true);
await verifyViewExists(testSetup.viewWithoutCalendarId, true);
});
it('should delete multiple views when they all use the same field as calendarFieldMetadataId', async () => {
const {
data: { createView: secondViewWithCalendar },
} = await createOneView({
input: {
name: generateRecordName('Second Calendar View'),
objectMetadataId: testSetup.objectMetadataId,
type: ViewType.CALENDAR,
calendarFieldMetadataId: testSetup.calendarFieldMetadataId,
calendarLayout: ViewCalendarLayout.MONTH,
icon: 'IconCalendar',
},
gqlFields: VIEW_WITH_CALENDAR_FIELDS,
expectToFail: false,
});
await verifyViewExists(testSetup.viewWithCalendarId, true);
await verifyViewExists(secondViewWithCalendar.id, true);
await verifyViewExists(testSetup.viewWithoutCalendarId, true);
await deactivateFieldAndVerify(testSetup.calendarFieldMetadataId);
await verifyViewExists(testSetup.viewWithCalendarId, false);
await verifyViewExists(secondViewWithCalendar.id, false);
await verifyViewExists(testSetup.viewWithoutCalendarId, true);
});
it('should handle deactivation when views have different calendar layouts on same field', async () => {
const {
data: { createView: viewWithDayLayout },
} = await createOneView({
input: {
name: generateRecordName('Calendar View With Day Layout'),
objectMetadataId: testSetup.objectMetadataId,
type: ViewType.CALENDAR,
calendarFieldMetadataId: testSetup.calendarFieldMetadataId,
calendarLayout: ViewCalendarLayout.DAY,
icon: 'IconCalendar',
},
gqlFields: VIEW_WITH_CALENDAR_FIELDS,
expectToFail: false,
});
const {
data: { createView: viewWithMonthLayout },
} = await createOneView({
input: {
name: generateRecordName('Calendar View With Month Layout'),
objectMetadataId: testSetup.objectMetadataId,
type: ViewType.CALENDAR,
calendarFieldMetadataId: testSetup.calendarFieldMetadataId,
calendarLayout: ViewCalendarLayout.MONTH,
icon: 'IconCalendar',
},
gqlFields: VIEW_WITH_CALENDAR_FIELDS,
expectToFail: false,
});
await verifyViewExists(testSetup.viewWithCalendarId, true);
await verifyViewExists(viewWithDayLayout.id, true);
await verifyViewExists(viewWithMonthLayout.id, true);
await verifyViewExists(testSetup.viewWithoutCalendarId, true);
await deactivateFieldAndVerify(testSetup.calendarFieldMetadataId);
await verifyViewExists(testSetup.viewWithCalendarId, false);
await verifyViewExists(viewWithDayLayout.id, false);
await verifyViewExists(viewWithMonthLayout.id, false);
await verifyViewExists(testSetup.viewWithoutCalendarId, true);
});
it('should delete calendar view but not other view types when calendar field is deactivated', async () => {
const { selectFieldMetadataId } =
await createOneSelectFieldMetadataForIntegrationTests({
input: {
objectMetadataId: testSetup.objectMetadataId,
name: 'selectField',
},
});
const {
data: { createView: kanbanViewWithSameObject },
} = await createOneView({
input: {
name: generateRecordName('Kanban View'),
objectMetadataId: testSetup.objectMetadataId,
mainGroupByFieldMetadataId: selectFieldMetadataId,
type: ViewType.KANBAN,
icon: 'IconLayoutKanban',
},
gqlFields: VIEW_WITH_CALENDAR_FIELDS,
expectToFail: false,
});
await verifyViewExists(testSetup.viewWithCalendarId, true);
await verifyViewExists(testSetup.viewWithoutCalendarId, true);
await verifyViewExists(kanbanViewWithSameObject.id, true);
await deactivateFieldAndVerify(testSetup.calendarFieldMetadataId);
await verifyViewExists(testSetup.viewWithCalendarId, false);
await verifyViewExists(testSetup.viewWithoutCalendarId, true);
await verifyViewExists(kanbanViewWithSameObject.id, true);
});
});

View File

@@ -0,0 +1,183 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`failing create phone field metadata test suite it should fail to create primary phone field with conflicting country code and calling code 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "CONFLICTING_PHONE_CALLING_CODE_AND_COUNTRY_CODE",
"userFriendlyMessage": "Provided country code and calling code are conflicting",
},
"message": "Provided country code and calling code are conflicting",
},
]
`;
exports[`failing create phone field metadata test suite it should fail to create primary phone field with conflicting country code and calling code 2`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "CONFLICTING_PHONE_CALLING_CODE_AND_COUNTRY_CODE",
"userFriendlyMessage": "Provided country code and calling code are conflicting",
},
"message": "Provided country code and calling code are conflicting",
},
]
`;
exports[`failing create phone field metadata test suite it should fail to create primary phone field with conflicting phone number calling code 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "CONFLICTING_PHONE_CALLING_CODE",
"userFriendlyMessage": "Provided and inferred calling code are conflicting",
},
"message": "Provided and inferred calling code are conflicting",
},
]
`;
exports[`failing create phone field metadata test suite it should fail to create primary phone field with conflicting phone number calling code 2`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "CONFLICTING_PHONE_CALLING_CODE",
"userFriendlyMessage": "Provided and inferred calling code are conflicting",
},
"message": "Provided and inferred calling code are conflicting",
},
]
`;
exports[`failing create phone field metadata test suite it should fail to create primary phone field with conflicting phone number country code 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "CONFLICTING_PHONE_COUNTRY_CODE",
"userFriendlyMessage": "Provided and inferred country code are conflicting",
},
"message": "Provided and inferred country code are conflicting",
},
]
`;
exports[`failing create phone field metadata test suite it should fail to create primary phone field with conflicting phone number country code 2`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "CONFLICTING_PHONE_COUNTRY_CODE",
"userFriendlyMessage": "Provided and inferred country code are conflicting",
},
"message": "Provided and inferred country code are conflicting",
},
]
`;
exports[`failing create phone field metadata test suite it should fail to create primary phone field with invalid calling code 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_PHONE_CALLING_CODE",
"userFriendlyMessage": "Invalid calling code +999",
},
"message": "Invalid calling code +999",
},
]
`;
exports[`failing create phone field metadata test suite it should fail to create primary phone field with invalid calling code 2`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_PHONE_CALLING_CODE",
"userFriendlyMessage": "Invalid calling code +999",
},
"message": "Invalid calling code +999",
},
]
`;
exports[`failing create phone field metadata test suite it should fail to create primary phone field with invalid country code 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_PHONE_COUNTRY_CODE",
"userFriendlyMessage": "Invalid country code XX",
},
"message": "Invalid country code XX",
},
]
`;
exports[`failing create phone field metadata test suite it should fail to create primary phone field with invalid country code 2`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_PHONE_COUNTRY_CODE",
"userFriendlyMessage": "Invalid country code XX",
},
"message": "Invalid country code XX",
},
]
`;
exports[`failing create phone field metadata test suite it should fail to create primary phone field with invalid phone number format 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_PHONE_NUMBER",
"userFriendlyMessage": "Provided phone number is invalid not-a-number",
},
"message": "Provided phone number is invalid not-a-number",
},
]
`;
exports[`failing create phone field metadata test suite it should fail to create primary phone field with invalid phone number format 2`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_PHONE_NUMBER",
"userFriendlyMessage": "Provided phone number is invalid not-a-number",
},
"message": "Provided phone number is invalid not-a-number",
},
]
`;
exports[`failing create phone field metadata test suite it should fail to create primary phone field without country or calling code at all 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_PHONE_NUMBER",
"userFriendlyMessage": "Provided phone number is invalid 123456789",
},
"message": "Provided phone number is invalid 123456789",
},
]
`;
exports[`failing create phone field metadata test suite it should fail to create primary phone field without country or calling code at all 2`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_PHONE_NUMBER",
"userFriendlyMessage": "Provided phone number is invalid 123456789",
},
"message": "Provided phone number is invalid 123456789",
},
]
`;

View File

@@ -0,0 +1,216 @@
import { faker } from '@faker-js/faker';
import { type CountryCode } from 'libphonenumber-js';
import { createOneOperation } from 'test/integration/graphql/utils/create-one-operation.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 { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { type EachTestingContext } from 'twenty-shared/testing';
import {
FieldMetadataType,
type AdditionalPhoneMetadata,
type PhonesMetadata,
} from 'twenty-shared/types';
const FIELD_NAME = 'phonenumber';
type TestCaseInputAndExpected = Partial<
Omit<PhonesMetadata, 'additionalPhones'>
> & {
additionalPhones?: Array<Partial<AdditionalPhoneMetadata>> | null;
};
type CreatePhoneFieldMetadataTestCase = {
input: TestCaseInputAndExpected;
expected?: TestCaseInputAndExpected;
};
const FAILING_TEST_INPUTS: { input: Partial<PhonesMetadata>; label: string }[] =
[
{
label: 'phone field without country or calling code at all',
input: {
primaryPhoneNumber: '123456789',
additionalPhones: [],
},
},
{
label: 'phone field with invalid country code',
input: {
primaryPhoneNumber: '123456789',
primaryPhoneCallingCode: '+33',
primaryPhoneCountryCode: 'XX' as CountryCode,
additionalPhones: [],
},
},
{
label: 'phone field with invalid calling code',
input: {
primaryPhoneNumber: '123456789',
primaryPhoneCallingCode: '+999',
primaryPhoneCountryCode: 'FR',
additionalPhones: [],
},
},
{
label: 'phone field with conflicting country code and calling code',
input: {
primaryPhoneNumber: '123456789',
primaryPhoneCallingCode: '+33',
primaryPhoneCountryCode: 'US',
additionalPhones: [],
},
},
{
label: 'phone field with invalid phone number format',
input: {
primaryPhoneNumber: 'not-a-number',
additionalPhones: [],
},
},
{
label: 'phone field with conflicting phone number country code',
input: {
primaryPhoneNumber: '+33123456789',
primaryPhoneCountryCode: 'US',
additionalPhones: [],
},
},
{
label: 'phone field with conflicting phone number calling code',
input: {
primaryPhoneNumber: '+33123456789',
primaryPhoneCallingCode: '+1',
additionalPhones: [],
},
},
];
const primaryFailingTests = FAILING_TEST_INPUTS.map<
EachTestingContext<CreatePhoneFieldMetadataTestCase>
>(({ input, label }) => ({
title: `create primary ${label}`,
context: {
input: {
...input,
additionalPhones: [],
},
},
}));
const additionalPhonesNumberFailingTests = FAILING_TEST_INPUTS.map<
EachTestingContext<CreatePhoneFieldMetadataTestCase>
>(
({
input: {
primaryPhoneCallingCode,
primaryPhoneCountryCode,
primaryPhoneNumber,
},
label,
}) => ({
title: `create primary ${label}`,
context: {
input: {
additionalPhones: [
{
callingCode: primaryPhoneCallingCode,
countryCode: primaryPhoneCountryCode,
number: primaryPhoneNumber,
},
],
},
},
}),
);
const FAILING_TEST_CASES: EachTestingContext<CreatePhoneFieldMetadataTestCase>[] =
[...primaryFailingTests, ...additionalPhonesNumberFailingTests];
describe('failing create phone field metadata test suite', () => {
let createdObjectMetadataId: string;
beforeAll(async () => {
const { data } = await createOneObjectMetadata({
input: {
nameSingular: 'myTestObject',
namePlural: 'myTestObjects',
labelSingular: 'My Test Object',
labelPlural: 'My Test Objects',
icon: 'Icon123',
isLabelSyncedWithName: false,
},
});
createdObjectMetadataId = data.createOneObject.id;
await createOneFieldMetadata({
input: {
name: FIELD_NAME,
label: 'Phone number',
type: FieldMetadataType.PHONES,
objectMetadataId: createdObjectMetadataId,
isLabelSyncedWithName: false,
},
gqlFields: `
id
name
label
isLabelSyncedWithName
`,
});
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: { idToDelete: createdObjectMetadataId },
});
});
test.each(FAILING_TEST_CASES)(
'it should fail to $title',
async ({ context: { input } }) => {
const {
data: { createOneResponse },
errors,
} = await createOneOperation<{
id: string;
[FIELD_NAME]: any;
}>({
objectMetadataSingularName: 'myTestObject',
input: {
id: faker.string.uuid(),
[FIELD_NAME]: input,
},
gqlFields: `
id
${FIELD_NAME} {
primaryPhoneNumber
primaryPhoneCountryCode
primaryPhoneCallingCode
additionalPhones {
number
callingCode
countryCode
}
__typename
}
`,
});
expect(createOneResponse).toBeNull();
expect(errors).toBeDefined();
expect(errors).toMatchSnapshot();
},
);
});

View File

@@ -0,0 +1,142 @@
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 {
type EachTestingContext,
eachTestingContextFilter,
} from 'twenty-shared/testing';
import { FieldMetadataType } from 'twenty-shared/types';
import { type CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
const SUCCESSFUL_TEST_CASES: EachTestingContext<{
input: Omit<CreateFieldInput, 'objectMetadataId' | 'type' | 'workspaceId'>;
}>[] = [
{
title: 'actor field with basic metadata (name, label, description, icon)',
context: {
input: {
name: 'processedBy',
label: 'Processed By',
description: 'Person who processed this',
icon: 'IconUser',
},
},
},
{
title: 'actor field with default value containing source and name',
context: {
input: {
name: 'initiatedBy',
label: 'Initiated By',
defaultValue: {
source: "'MANUAL'",
workspaceMemberId: null,
name: "'System'",
},
},
},
},
{
title: 'actor field with API source default',
context: {
input: {
name: 'importedBy',
label: 'Imported By',
defaultValue: {
source: "'API'",
workspaceMemberId: null,
name: "'API User'",
},
},
},
},
];
describe('Actor field metadata creation tests suite', () => {
let createdObjectMetadataId: string;
let createdFieldMetadataId: string | undefined = undefined;
beforeAll(async () => {
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'testActorObject',
namePlural: 'testActorObjects',
labelSingular: 'Test Actor Object',
labelPlural: 'Test Actor Objects',
icon: 'IconTestPipe',
isLabelSyncedWithName: false,
},
});
createdObjectMetadataId = data.createOneObject.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
});
afterEach(async () => {
if (createdFieldMetadataId) {
await updateOneFieldMetadata({
input: {
updatePayload: { isActive: false },
idToUpdate: createdFieldMetadataId,
},
expectToFail: false,
});
await deleteOneFieldMetadata({
input: { idToDelete: createdFieldMetadataId },
expectToFail: false,
});
createdFieldMetadataId = undefined;
}
});
test.each(eachTestingContextFilter(SUCCESSFUL_TEST_CASES))(
'it should create $title',
async ({ context: { input } }) => {
const inputPayload = {
objectMetadataId: createdObjectMetadataId,
type: FieldMetadataType.ACTOR,
isLabelSyncedWithName: false,
...input,
};
const { data } = await createOneFieldMetadata({
expectToFail: false,
input: inputPayload,
gqlFields: `
id
type
name
label
description
icon
defaultValue
isLabelSyncedWithName
settings
`,
});
createdFieldMetadataId = data.createOneField.id;
const { objectMetadataId: _, ...expectedFields } = inputPayload;
expect(data.createOneField).toMatchObject(expectedFields);
},
);
});

View File

@@ -0,0 +1,217 @@
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 {
type EachTestingContext,
eachTestingContextFilter,
} from 'twenty-shared/testing';
import { FieldMetadataType } from 'twenty-shared/types';
import { type CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
const SUCCESSFUL_TEST_CASES: EachTestingContext<{
input: Omit<CreateFieldInput, 'objectMetadataId' | 'type' | 'workspaceId'>;
}>[] = [
{
title: 'address field with basic metadata (name, label, description, icon)',
context: {
input: {
name: 'location',
label: 'Location',
description: 'Physical location',
icon: 'IconMapPin',
},
},
},
{
title: 'address field with subFields setting (street and city only)',
context: {
input: {
name: 'simpleAddress',
label: 'Simple Address',
settings: {
subFields: ['addressStreet1', 'addressCity'],
},
},
},
},
{
title: 'address field with all subFields',
context: {
input: {
name: 'fullAddress',
label: 'Full Address',
settings: {
subFields: [
'addressStreet1',
'addressStreet2',
'addressCity',
'addressState',
'addressPostcode',
'addressCountry',
'addressLat',
'addressLng',
],
},
},
},
},
{
title: 'address field with default value containing full address',
context: {
input: {
name: 'officeAddress',
label: 'Office Address',
defaultValue: {
addressStreet1: "'123 Main St'",
addressStreet2: "'Suite 100'",
addressCity: "'San Francisco'",
addressState: "'CA'",
addressPostcode: "'94102'",
addressCountry: "'USA'",
addressLat: null,
addressLng: null,
},
},
},
},
{
title: 'address field with partial default value',
context: {
input: {
name: 'shippingAddress',
label: 'Shipping Address',
defaultValue: {
addressStreet1: "'456 Oak Ave'",
addressStreet2: null,
addressCity: "'New York'",
addressState: null,
addressPostcode: null,
addressCountry: null,
addressLat: null,
addressLng: null,
},
},
},
},
{
title: 'address field with empty default value',
context: {
input: {
name: 'billingAddress',
label: 'Billing Address',
defaultValue: null,
},
},
},
{
title: 'address field with settings and default value',
context: {
input: {
name: 'warehouseAddress',
label: 'Warehouse Address',
settings: {
subFields: ['addressStreet1', 'addressCity', 'addressCountry'],
},
defaultValue: {
addressStreet1: null,
addressStreet2: null,
addressCity: null,
addressState: null,
addressPostcode: null,
addressCountry: "'USA'",
addressLat: null,
addressLng: null,
},
},
},
},
];
describe('Address field metadata creation tests suite', () => {
let createdObjectMetadataId: string;
let createdFieldMetadataId: string | undefined = undefined;
beforeAll(async () => {
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'testAddressObject',
namePlural: 'testAddressObjects',
labelSingular: 'Test Address Object',
labelPlural: 'Test Address Objects',
icon: 'IconTestPipe',
isLabelSyncedWithName: false,
},
});
createdObjectMetadataId = data.createOneObject.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
});
afterEach(async () => {
if (createdFieldMetadataId) {
await updateOneFieldMetadata({
input: {
updatePayload: { isActive: false },
idToUpdate: createdFieldMetadataId,
},
expectToFail: false,
});
await deleteOneFieldMetadata({
input: { idToDelete: createdFieldMetadataId },
expectToFail: false,
});
createdFieldMetadataId = undefined;
}
});
test.each(eachTestingContextFilter(SUCCESSFUL_TEST_CASES))(
'it should create $title',
async ({ context: { input } }) => {
const inputPayload = {
objectMetadataId: createdObjectMetadataId,
type: FieldMetadataType.ADDRESS,
isLabelSyncedWithName: false,
...input,
};
const { data } = await createOneFieldMetadata({
expectToFail: false,
input: inputPayload,
gqlFields: `
id
type
name
label
description
icon
defaultValue
isLabelSyncedWithName
settings
`,
});
createdFieldMetadataId = data.createOneField.id;
const { objectMetadataId: _, ...expectedFields } = inputPayload;
expect(data.createOneField).toMatchObject(expectedFields);
},
);
});

View File

@@ -0,0 +1,164 @@
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 {
type EachTestingContext,
eachTestingContextFilter,
} from 'twenty-shared/testing';
import { FieldMetadataType } from 'twenty-shared/types';
import { type CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
const SUCCESSFUL_TEST_CASES: EachTestingContext<{
input: Omit<CreateFieldInput, 'objectMetadataId' | 'type' | 'workspaceId'>;
}>[] = [
{
title:
'currency field with basic metadata (name, label, description, icon)',
context: {
input: {
name: 'amount',
label: 'Amount',
description: 'Transaction amount',
icon: 'IconCurrencyDollar',
},
},
},
{
title: 'currency field with default value containing amount and currency',
context: {
input: {
name: 'price',
label: 'Price',
defaultValue: {
amountMicros: "'1000000'",
currencyCode: "'USD'",
},
},
},
},
{
title: 'currency field with zero default amount',
context: {
input: {
name: 'discount',
label: 'Discount',
defaultValue: {
amountMicros: "'0'",
currencyCode: "'EUR'",
},
},
},
},
{
title: 'currency field with only currency code default',
context: {
input: {
name: 'budget',
label: 'Budget',
defaultValue: {
amountMicros: null,
currencyCode: "'GBP'",
},
},
},
},
{
title: 'currency field with empty default value',
context: {
input: {
name: 'cost',
label: 'Cost',
defaultValue: null,
},
},
},
];
describe('Currency field metadata creation tests suite', () => {
let createdObjectMetadataId: string;
let createdFieldMetadataId: string | undefined = undefined;
beforeAll(async () => {
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'testCurrencyObject',
namePlural: 'testCurrencyObjects',
labelSingular: 'Test Currency Object',
labelPlural: 'Test Currency Objects',
icon: 'IconTestPipe',
isLabelSyncedWithName: false,
},
});
createdObjectMetadataId = data.createOneObject.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
});
afterEach(async () => {
if (createdFieldMetadataId) {
await updateOneFieldMetadata({
input: {
updatePayload: { isActive: false },
idToUpdate: createdFieldMetadataId,
},
expectToFail: false,
});
await deleteOneFieldMetadata({
input: { idToDelete: createdFieldMetadataId },
expectToFail: false,
});
createdFieldMetadataId = undefined;
}
});
test.each(eachTestingContextFilter(SUCCESSFUL_TEST_CASES))(
'it should create $title',
async ({ context: { input } }) => {
const inputPayload = {
objectMetadataId: createdObjectMetadataId,
type: FieldMetadataType.CURRENCY,
isLabelSyncedWithName: false,
...input,
};
const { data } = await createOneFieldMetadata({
expectToFail: false,
input: inputPayload,
gqlFields: `
id
type
name
label
description
icon
defaultValue
isLabelSyncedWithName
settings
`,
});
createdFieldMetadataId = data.createOneField.id;
const { objectMetadataId: _, ...expectedFields } = inputPayload;
expect(data.createOneField).toMatchObject(expectedFields);
},
);
});

View File

@@ -0,0 +1,165 @@
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 {
type EachTestingContext,
eachTestingContextFilter,
} from 'twenty-shared/testing';
import { FieldMetadataType } from 'twenty-shared/types';
import { type CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
const SUCCESSFUL_TEST_CASES: EachTestingContext<{
input: Omit<CreateFieldInput, 'objectMetadataId' | 'type' | 'workspaceId'>;
}>[] = [
{
title: 'emails field with basic metadata (name, label, description, icon)',
context: {
input: {
name: 'contactEmails',
label: 'Contact Emails',
description: 'Contact email addresses',
icon: 'IconMail',
},
},
},
{
title: 'emails field with maxNumberOfValues setting',
context: {
input: {
name: 'limitedEmails',
label: 'Limited Emails',
settings: {
maxNumberOfValues: 3,
},
},
},
},
{
title: 'emails field with default value containing primary email',
context: {
input: {
name: 'defaultEmails',
label: 'Default Emails',
defaultValue: {
primaryEmail: "'contact@example.com'",
additionalEmails: null,
},
},
},
},
{
title: 'emails field with empty default value',
context: {
input: {
name: 'emptyEmails',
label: 'Empty Emails',
defaultValue: null,
},
},
},
{
title: 'emails field with settings and default value',
context: {
input: {
name: 'configuredEmails',
label: 'Configured Emails',
settings: {
maxNumberOfValues: 5,
},
defaultValue: {
primaryEmail: "'support@company.com'",
additionalEmails: null,
},
},
},
},
];
describe('Emails field metadata creation tests suite', () => {
let createdObjectMetadataId: string;
let createdFieldMetadataId: string | undefined = undefined;
beforeAll(async () => {
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'testEmailsObject',
namePlural: 'testEmailsObjects',
labelSingular: 'Test Emails Object',
labelPlural: 'Test Emails Objects',
icon: 'IconTestPipe',
isLabelSyncedWithName: false,
},
});
createdObjectMetadataId = data.createOneObject.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
});
afterEach(async () => {
if (createdFieldMetadataId) {
await updateOneFieldMetadata({
input: {
updatePayload: { isActive: false },
idToUpdate: createdFieldMetadataId,
},
expectToFail: false,
});
await deleteOneFieldMetadata({
input: { idToDelete: createdFieldMetadataId },
expectToFail: false,
});
createdFieldMetadataId = undefined;
}
});
test.each(eachTestingContextFilter(SUCCESSFUL_TEST_CASES))(
'it should create $title',
async ({ context: { input } }) => {
const inputPayload = {
objectMetadataId: createdObjectMetadataId,
type: FieldMetadataType.EMAILS,
isLabelSyncedWithName: false,
...input,
};
const { data } = await createOneFieldMetadata({
expectToFail: false,
input: inputPayload,
gqlFields: `
id
type
name
label
description
icon
defaultValue
isLabelSyncedWithName
settings
`,
});
createdFieldMetadataId = data.createOneField.id;
const { objectMetadataId: _, ...expectedFields } = inputPayload;
expect(data.createOneField).toMatchObject(expectedFields);
},
);
});

View File

@@ -0,0 +1,151 @@
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 {
type EachTestingContext,
eachTestingContextFilter,
} from 'twenty-shared/testing';
import { FieldMetadataType } from 'twenty-shared/types';
import { type CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
const SUCCESSFUL_TEST_CASES: EachTestingContext<{
input: Omit<CreateFieldInput, 'objectMetadataId' | 'type' | 'workspaceId'>;
}>[] = [
{
title:
'full name field with basic metadata (name, label, description, icon)',
context: {
input: {
name: 'contactName',
label: 'Contact Name',
description: 'Full name of contact',
icon: 'IconUser',
},
},
},
{
title: 'full name field with default value containing first and last name',
context: {
input: {
name: 'ownerName',
label: 'Owner Name',
defaultValue: {
firstName: "'John'",
lastName: "'Doe'",
},
},
},
},
{
title: 'full name field with only first name default',
context: {
input: {
name: 'customerName',
label: 'Customer Name',
defaultValue: {
firstName: "'Jane'",
lastName: null,
},
},
},
},
{
title: 'full name field with empty default value',
context: {
input: {
name: 'authorName',
label: 'Author Name',
defaultValue: null,
},
},
},
];
describe('Full name field metadata creation tests suite', () => {
let createdObjectMetadataId: string;
let createdFieldMetadataId: string | undefined = undefined;
beforeAll(async () => {
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'testFullNameObject',
namePlural: 'testFullNameObjects',
labelSingular: 'Test Full Name Object',
labelPlural: 'Test Full Name Objects',
icon: 'IconTestPipe',
isLabelSyncedWithName: false,
},
});
createdObjectMetadataId = data.createOneObject.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
});
afterEach(async () => {
if (createdFieldMetadataId) {
await updateOneFieldMetadata({
input: {
updatePayload: { isActive: false },
idToUpdate: createdFieldMetadataId,
},
expectToFail: false,
});
await deleteOneFieldMetadata({
input: { idToDelete: createdFieldMetadataId },
expectToFail: false,
});
createdFieldMetadataId = undefined;
}
});
test.each(eachTestingContextFilter(SUCCESSFUL_TEST_CASES))(
'it should create $title',
async ({ context: { input } }) => {
const inputPayload = {
objectMetadataId: createdObjectMetadataId,
type: FieldMetadataType.FULL_NAME,
isLabelSyncedWithName: false,
...input,
};
const { data } = await createOneFieldMetadata({
expectToFail: false,
input: inputPayload,
gqlFields: `
id
type
name
label
description
icon
defaultValue
isLabelSyncedWithName
settings
`,
});
createdFieldMetadataId = data.createOneField.id;
const { objectMetadataId: _, ...expectedFields } = inputPayload;
expect(data.createOneField).toMatchObject(expectedFields);
},
);
});

View File

@@ -0,0 +1,167 @@
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 {
type EachTestingContext,
eachTestingContextFilter,
} from 'twenty-shared/testing';
import { FieldMetadataType } from 'twenty-shared/types';
import { type CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
const SUCCESSFUL_TEST_CASES: EachTestingContext<{
input: Omit<CreateFieldInput, 'objectMetadataId' | 'type' | 'workspaceId'>;
}>[] = [
{
title: 'links field with basic metadata (name, label, description, icon)',
context: {
input: {
name: 'socialLinks',
label: 'Social Links',
description: 'Social media links',
icon: 'IconLink',
},
},
},
{
title: 'links field with maxNumberOfValues setting',
context: {
input: {
name: 'limitedLinks',
label: 'Limited Links',
settings: {
maxNumberOfValues: 5,
},
},
},
},
{
title: 'links field with default value containing primary link',
context: {
input: {
name: 'websiteLinks',
label: 'Website Links',
defaultValue: {
primaryLinkLabel: "'Website'",
primaryLinkUrl: "'https://example.com'",
secondaryLinks: null,
},
},
},
},
{
title: 'links field with empty default value',
context: {
input: {
name: 'emptyLinks',
label: 'Empty Links',
defaultValue: null,
},
},
},
{
title: 'links field with settings and default value',
context: {
input: {
name: 'configuredLinks',
label: 'Configured Links',
settings: {
maxNumberOfValues: 3,
},
defaultValue: {
primaryLinkLabel: "'Homepage'",
primaryLinkUrl: "'https://company.com'",
secondaryLinks: null,
},
},
},
},
];
describe('Links field metadata creation tests suite', () => {
let createdObjectMetadataId: string;
let createdFieldMetadataId: string | undefined = undefined;
beforeAll(async () => {
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'testLinksObject',
namePlural: 'testLinksObjects',
labelSingular: 'Test Links Object',
labelPlural: 'Test Links Objects',
icon: 'IconTestPipe',
isLabelSyncedWithName: false,
},
});
createdObjectMetadataId = data.createOneObject.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
});
afterEach(async () => {
if (createdFieldMetadataId) {
await updateOneFieldMetadata({
input: {
updatePayload: { isActive: false },
idToUpdate: createdFieldMetadataId,
},
expectToFail: false,
});
await deleteOneFieldMetadata({
input: { idToDelete: createdFieldMetadataId },
expectToFail: false,
});
createdFieldMetadataId = undefined;
}
});
test.each(eachTestingContextFilter(SUCCESSFUL_TEST_CASES))(
'it should create $title',
async ({ context: { input } }) => {
const inputPayload = {
objectMetadataId: createdObjectMetadataId,
type: FieldMetadataType.LINKS,
isLabelSyncedWithName: false,
...input,
};
const { data } = await createOneFieldMetadata({
expectToFail: false,
input: inputPayload,
gqlFields: `
id
type
name
label
description
icon
defaultValue
isLabelSyncedWithName
settings
`,
});
createdFieldMetadataId = data.createOneField.id;
const { objectMetadataId: _, ...expectedFields } = inputPayload;
expect(data.createOneField).toMatchObject(expectedFields);
},
);
});

View File

@@ -0,0 +1,316 @@
import { faker } from '@faker-js/faker';
import { type CountryCode } from 'libphonenumber-js';
import { createOneOperation } from 'test/integration/graphql/utils/create-one-operation.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 { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { type EachTestingContext } from 'twenty-shared/testing';
import {
FieldMetadataType,
type AdditionalPhoneMetadata,
type PhonesMetadata,
} from 'twenty-shared/types';
const FIELD_NAME = 'phonenumber';
type TestCaseInputAndExpected = Partial<
Omit<PhonesMetadata, 'additionalPhones'>
> & {
additionalPhones?: Array<
Partial<Record<keyof AdditionalPhoneMetadata, string | null>>
> | null;
};
type CreatePhoneFieldMetadataTestCase = {
input: TestCaseInputAndExpected;
expected?: TestCaseInputAndExpected;
};
const SUCCESSFUL_TEST_CASES: EachTestingContext<CreatePhoneFieldMetadataTestCase>[] =
[
{
title: 'create primary phone field',
context: {
input: {
primaryPhoneNumber: '123456789',
primaryPhoneCallingCode: '+33',
primaryPhoneCountryCode: 'FR',
additionalPhones: [],
},
expected: {
primaryPhoneNumber: '123456789',
primaryPhoneCallingCode: '+33',
primaryPhoneCountryCode: 'FR',
additionalPhones: [],
},
},
},
{
title: 'create primary phone field with number and other information',
context: {
input: {
primaryPhoneNumber: '123456789',
primaryPhoneCallingCode: '+33',
primaryPhoneCountryCode: 'FR',
additionalPhones: [],
},
expected: {
primaryPhoneNumber: '123456789',
primaryPhoneCallingCode: '+33',
primaryPhoneCountryCode: 'FR',
additionalPhones: [],
},
},
},
{
title:
'create primary phone field with full international format and other information',
context: {
input: {
primaryPhoneNumber: '+1123456789',
primaryPhoneCountryCode: 'US',
primaryPhoneCallingCode: '+1',
additionalPhones: null,
},
expected: {
primaryPhoneNumber: '123456789',
primaryPhoneCountryCode: 'US',
primaryPhoneCallingCode: '+1',
additionalPhones: [],
},
},
},
{
title:
'create primary phone field with full international and infer other information from it but not the countryCode as its shared',
context: {
input: { primaryPhoneNumber: '+1123456789' },
expected: {
primaryPhoneNumber: '123456789',
primaryPhoneCountryCode: '' as CountryCode,
primaryPhoneCallingCode: '+1',
additionalPhones: [],
},
},
},
{
title:
'create primary phone field with full international and infer other information from it',
context: {
input: { primaryPhoneNumber: '+33123456789' },
expected: {
primaryPhoneNumber: '123456789',
primaryPhoneCountryCode: 'FR',
primaryPhoneCallingCode: '+33',
additionalPhones: [],
},
},
},
{
title: 'create primary phone field with empty payload',
context: {
input: {},
expected: {
primaryPhoneNumber: '',
primaryPhoneCountryCode: '' as CountryCode,
primaryPhoneCallingCode: '',
additionalPhones: [],
},
},
},
{
title: 'create primary phone field with empty strings in payload',
context: {
input: {
primaryPhoneNumber: '',
primaryPhoneCountryCode: '' as CountryCode,
primaryPhoneCallingCode: '',
additionalPhones: null,
},
expected: {
primaryPhoneNumber: '',
primaryPhoneCountryCode: '' as CountryCode,
primaryPhoneCallingCode: '',
additionalPhones: [],
},
},
},
{
title: 'create additional phone field with number and other information',
context: {
input: {
additionalPhones: [
{ callingCode: '+33', countryCode: 'FR', number: '123456789' },
],
},
expected: {
primaryPhoneCallingCode: '',
primaryPhoneCountryCode: '' as CountryCode,
primaryPhoneNumber: '',
additionalPhones: [
{ callingCode: '+33', countryCode: 'FR', number: '123456789' },
],
},
},
},
{
title:
'create additional phone field with full international format and other information',
context: {
input: {
additionalPhones: [
{ callingCode: '+1', countryCode: 'US', number: '+1123456789' },
],
},
expected: {
primaryPhoneCallingCode: '',
primaryPhoneCountryCode: '' as CountryCode,
primaryPhoneNumber: '',
additionalPhones: [
{ callingCode: '+1', countryCode: 'US', number: '123456789' },
],
},
},
},
{
title:
'create additional phone field with full international and infer other information from it but not the countryCode as its shared',
context: {
input: { additionalPhones: [{ number: '+1123456789' }] },
expected: {
primaryPhoneCallingCode: '',
primaryPhoneCountryCode: '' as CountryCode,
primaryPhoneNumber: '',
additionalPhones: [
{
callingCode: '+1',
countryCode: null,
number: '123456789',
},
],
},
},
},
{
title:
'create additional phone field with full international and infer other information from it',
context: {
input: { additionalPhones: [{ number: '+33123456789' }] },
expected: {
primaryPhoneCallingCode: '',
primaryPhoneCountryCode: '' as CountryCode,
primaryPhoneNumber: '',
additionalPhones: [
{ callingCode: '+33', countryCode: 'FR', number: '123456789' },
],
},
},
},
{
title: 'create additional phone field with empty payload',
context: {
input: { additionalPhones: [{}] },
expected: {
primaryPhoneCallingCode: '',
primaryPhoneCountryCode: '' as CountryCode,
primaryPhoneNumber: '',
additionalPhones: [
{ callingCode: null, countryCode: null, number: null },
],
},
},
},
];
describe('successful create phone field metadata test suite', () => {
let createdObjectMetadataId: string;
beforeAll(async () => {
const { data } = await createOneObjectMetadata({
input: {
nameSingular: 'myTestObject',
namePlural: 'myTestObjects',
labelSingular: 'My Test Object',
labelPlural: 'My Test Objects',
icon: 'Icon123',
isLabelSyncedWithName: false,
},
});
createdObjectMetadataId = data.createOneObject.id;
await createOneFieldMetadata({
input: {
name: FIELD_NAME,
label: 'Phone number',
type: FieldMetadataType.PHONES,
objectMetadataId: createdObjectMetadataId,
isLabelSyncedWithName: false,
},
gqlFields: `
id
name
label
isLabelSyncedWithName
`,
});
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: { idToDelete: createdObjectMetadataId },
});
});
test.each(SUCCESSFUL_TEST_CASES)(
'it should succeed $title',
async ({ context: { input, expected } }) => {
const {
data: { createOneResponse },
errors,
} = await createOneOperation<{
id: string;
[FIELD_NAME]: any;
}>({
objectMetadataSingularName: 'myTestObject',
input: {
id: faker.string.uuid(),
[FIELD_NAME]: input,
},
gqlFields: `
id
${FIELD_NAME} {
primaryPhoneNumber
primaryPhoneCountryCode
primaryPhoneCallingCode
additionalPhones {
number
callingCode
countryCode
}
__typename
}
`,
});
expect(errors).toBeUndefined();
const { id: _id, ...rest } = createOneResponse;
expect(rest[FIELD_NAME]).toStrictEqual({
...expected,
__typename: 'Phones',
});
},
);
});

View File

@@ -0,0 +1,151 @@
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 {
type EachTestingContext,
eachTestingContextFilter,
} from 'twenty-shared/testing';
import { FieldMetadataType } from 'twenty-shared/types';
import { type CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
const SUCCESSFUL_TEST_CASES: EachTestingContext<{
input: Omit<CreateFieldInput, 'objectMetadataId' | 'type' | 'workspaceId'>;
}>[] = [
{
title:
'rich text v2 field with basic metadata (name, label, description, icon)',
context: {
input: {
name: 'description',
label: 'Description',
description: 'Rich text description field',
icon: 'IconNotes',
},
},
},
{
title: 'rich text v2 field with default value containing markdown',
context: {
input: {
name: 'notes',
label: 'Notes',
defaultValue: {
blocknote: null,
markdown: "'# Default Title'",
},
},
},
},
{
title: 'rich text v2 field with default value containing blocknote',
context: {
input: {
name: 'content',
label: 'Content',
defaultValue: {
blocknote: '\'{"blocks":[{"type":"paragraph"}]}\'',
markdown: null,
},
},
},
},
{
title: 'rich text v2 field with empty default value',
context: {
input: {
name: 'body',
label: 'Body',
defaultValue: null,
},
},
},
];
describe('Rich text v2 field metadata creation tests suite', () => {
let createdObjectMetadataId: string;
let createdFieldMetadataId: string | undefined = undefined;
beforeAll(async () => {
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'testRichTextObject',
namePlural: 'testRichTextObjects',
labelSingular: 'Test Rich Text V2 Object',
labelPlural: 'Test Rich Text V2 Objects',
icon: 'IconTestPipe',
isLabelSyncedWithName: false,
},
});
createdObjectMetadataId = data.createOneObject.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
});
afterEach(async () => {
if (createdFieldMetadataId) {
await updateOneFieldMetadata({
input: {
updatePayload: { isActive: false },
idToUpdate: createdFieldMetadataId,
},
expectToFail: false,
});
await deleteOneFieldMetadata({
input: { idToDelete: createdFieldMetadataId },
expectToFail: false,
});
createdFieldMetadataId = undefined;
}
});
test.each(eachTestingContextFilter(SUCCESSFUL_TEST_CASES))(
'it should create $title',
async ({ context: { input } }) => {
const inputPayload = {
objectMetadataId: createdObjectMetadataId,
type: FieldMetadataType.RICH_TEXT,
isLabelSyncedWithName: false,
...input,
};
const { data } = await createOneFieldMetadata({
expectToFail: false,
input: inputPayload,
gqlFields: `
id
type
name
label
description
icon
defaultValue
isLabelSyncedWithName
settings
`,
});
createdFieldMetadataId = data.createOneField.id;
const { objectMetadataId: _, ...expectedFields } = inputPayload;
expect(data.createOneField).toMatchObject(expectedFields);
},
);
});

View File

@@ -0,0 +1,156 @@
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 {
type EachTestingContext,
eachTestingContextFilter,
} from 'twenty-shared/testing';
import { FieldMetadataType } from 'twenty-shared/types';
import { type UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
const SUCCESSFUL_TEST_CASES: EachTestingContext<{
input: Partial<UpdateFieldInput>;
}>[] = [
{
title: 'actor field basic metadata (label, description, icon)',
context: {
input: {
label: 'Updated Actor',
description: 'Updated description',
icon: 'IconUsers',
},
},
},
{
title: 'actor field default value with MANUAL source',
context: {
input: {
defaultValue: {
source: "'MANUAL'",
workspaceMemberId: null,
name: "'System'",
},
},
},
},
{
title: 'actor field default value with API source',
context: {
input: {
defaultValue: {
source: "'API'",
workspaceMemberId: null,
name: "'API User'",
},
},
},
},
{
title: 'actor field default value set to null',
context: {
input: {
defaultValue: null,
},
},
},
];
describe('Actor field metadata update tests suite', () => {
let createdObjectMetadataId: string;
let createdFieldMetadataId: string;
beforeAll(async () => {
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'testActorUpdateObject',
namePlural: 'testActorUpdateObjects',
labelSingular: 'Test Actor Update Object',
labelPlural: 'Test Actor Update Objects',
icon: 'IconTestPipe',
isLabelSyncedWithName: false,
},
});
createdObjectMetadataId = data.createOneObject.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
});
beforeEach(async () => {
const {
data: { createOneField },
} = await createOneFieldMetadata({
expectToFail: false,
input: {
objectMetadataId: createdObjectMetadataId,
type: FieldMetadataType.ACTOR,
name: 'testActor',
label: 'Test Actor',
description: 'Initial description',
icon: 'IconUser',
isLabelSyncedWithName: false,
},
gqlFields: `
id
`,
});
createdFieldMetadataId = createOneField.id;
});
afterEach(async () => {
await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: createdFieldMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneFieldMetadata({
expectToFail: false,
input: { idToDelete: createdFieldMetadataId },
});
});
test.each(eachTestingContextFilter(SUCCESSFUL_TEST_CASES))(
'it should update $title',
async ({ context: { input } }) => {
const { data } = await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: createdFieldMetadataId,
updatePayload: input,
},
gqlFields: `
id
type
name
label
description
icon
settings
defaultValue
`,
});
expect(data.updateOneField).toMatchObject(input);
},
);
});

View File

@@ -0,0 +1,215 @@
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 {
type EachTestingContext,
eachTestingContextFilter,
} from 'twenty-shared/testing';
import { FieldMetadataType } from 'twenty-shared/types';
import { type UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
const SUCCESSFUL_TEST_CASES: EachTestingContext<{
input: Partial<UpdateFieldInput>;
}>[] = [
{
title: 'address field basic metadata (label, description, icon)',
context: {
input: {
label: 'Updated Address',
description: 'Updated description',
icon: 'IconHome',
},
},
},
{
title: 'address field settings with limited subFields',
context: {
input: {
settings: {
subFields: ['addressStreet1', 'addressCity', 'addressCountry'],
},
},
},
},
{
title: 'address field settings with all subFields',
context: {
input: {
settings: {
subFields: [
'addressStreet1',
'addressStreet2',
'addressCity',
'addressState',
'addressPostcode',
'addressCountry',
'addressLat',
'addressLng',
],
},
},
},
},
{
title: 'address field default value with full address',
context: {
input: {
defaultValue: {
addressStreet1: "'123 Main St'",
addressStreet2: "'Apt 4'",
addressCity: "'New York'",
addressState: "'NY'",
addressPostcode: "'10001'",
addressCountry: "'USA'",
addressLat: null,
addressLng: null,
},
},
},
},
{
title: 'address field default value with partial address',
context: {
input: {
defaultValue: {
addressStreet1: "'456 Oak Ave'",
addressStreet2: null,
addressCity: "'Boston'",
addressState: null,
addressPostcode: null,
addressCountry: null,
addressLat: null,
addressLng: null,
},
},
},
},
{
title: 'address field default value set to null',
context: {
input: {
defaultValue: null,
},
},
},
{
title: 'address field settings and default value together',
context: {
input: {
settings: {
subFields: ['addressStreet1', 'addressCity', 'addressPostcode'],
},
defaultValue: {
addressStreet1: null,
addressStreet2: null,
addressCity: null,
addressState: null,
addressPostcode: null,
addressCountry: "'USA'",
addressLat: null,
addressLng: null,
},
},
},
},
];
describe('Address field metadata update tests suite', () => {
let createdObjectMetadataId: string;
let createdFieldMetadataId: string;
beforeAll(async () => {
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'testAddressUpdateObject',
namePlural: 'testAddressUpdateObjects',
labelSingular: 'Test Address Update Object',
labelPlural: 'Test Address Update Objects',
icon: 'IconTestPipe',
isLabelSyncedWithName: false,
},
});
createdObjectMetadataId = data.createOneObject.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
});
beforeEach(async () => {
const {
data: { createOneField },
} = await createOneFieldMetadata({
expectToFail: false,
input: {
objectMetadataId: createdObjectMetadataId,
type: FieldMetadataType.ADDRESS,
name: 'testAddress',
label: 'Test Address',
description: 'Initial description',
icon: 'IconMapPin',
isLabelSyncedWithName: false,
},
gqlFields: `
id
`,
});
createdFieldMetadataId = createOneField.id;
});
afterEach(async () => {
await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: createdFieldMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneFieldMetadata({
expectToFail: false,
input: { idToDelete: createdFieldMetadataId },
});
});
test.each(eachTestingContextFilter(SUCCESSFUL_TEST_CASES))(
'it should update $title',
async ({ context: { input } }) => {
const { data } = await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: createdFieldMetadataId,
updatePayload: input,
},
gqlFields: `
id
type
name
label
description
icon
settings
defaultValue
`,
});
expect(data.updateOneField).toMatchObject(input);
},
);
});

View File

@@ -0,0 +1,173 @@
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 {
type EachTestingContext,
eachTestingContextFilter,
} from 'twenty-shared/testing';
import { FieldMetadataType } from 'twenty-shared/types';
import { type UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
const SUCCESSFUL_TEST_CASES: EachTestingContext<{
input: Partial<UpdateFieldInput>;
}>[] = [
{
title: 'currency field basic metadata (label, description, icon)',
context: {
input: {
label: 'Updated Currency',
description: 'Updated description',
icon: 'IconCoin',
},
},
},
{
title: 'currency field default value with amount and currency',
context: {
input: {
defaultValue: {
amountMicros: "'1000000'",
currencyCode: "'USD'",
},
},
},
},
{
title: 'currency field default value with zero amount',
context: {
input: {
defaultValue: {
amountMicros: "'0'",
currencyCode: "'EUR'",
},
},
},
},
{
title: 'currency field default value with only currency code',
context: {
input: {
defaultValue: {
amountMicros: null,
currencyCode: "'GBP'",
},
},
},
},
{
title: 'currency field default value with empty values',
context: {
input: {
defaultValue: null,
},
},
},
{
title: 'currency field default value set to null',
context: {
input: {
defaultValue: null,
},
},
},
];
describe('Currency field metadata update tests suite', () => {
let createdObjectMetadataId: string;
let createdFieldMetadataId: string;
beforeAll(async () => {
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'testCurrencyUpdateObject',
namePlural: 'testCurrencyUpdateObjects',
labelSingular: 'Test Currency Update Object',
labelPlural: 'Test Currency Update Objects',
icon: 'IconTestPipe',
isLabelSyncedWithName: false,
},
});
createdObjectMetadataId = data.createOneObject.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
});
beforeEach(async () => {
const {
data: { createOneField },
} = await createOneFieldMetadata({
expectToFail: false,
input: {
objectMetadataId: createdObjectMetadataId,
type: FieldMetadataType.CURRENCY,
name: 'testCurrency',
label: 'Test Currency',
description: 'Initial description',
icon: 'IconCurrencyDollar',
isLabelSyncedWithName: false,
},
gqlFields: `
id
`,
});
createdFieldMetadataId = createOneField.id;
});
afterEach(async () => {
await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: createdFieldMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneFieldMetadata({
expectToFail: false,
input: { idToDelete: createdFieldMetadataId },
});
});
test.each(eachTestingContextFilter(SUCCESSFUL_TEST_CASES))(
'it should update $title',
async ({ context: { input } }) => {
const { data } = await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: createdFieldMetadataId,
updatePayload: input,
},
gqlFields: `
id
type
name
label
description
icon
settings
defaultValue
`,
});
expect(data.updateOneField).toMatchObject(input);
},
);
});

View File

@@ -0,0 +1,175 @@
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 {
type EachTestingContext,
eachTestingContextFilter,
} from 'twenty-shared/testing';
import { FieldMetadataType } from 'twenty-shared/types';
import { type UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
const SUCCESSFUL_TEST_CASES: EachTestingContext<{
input: Partial<UpdateFieldInput>;
}>[] = [
{
title: 'emails field basic metadata (label, description, icon)',
context: {
input: {
label: 'Updated Emails',
description: 'Updated description',
icon: 'IconMailOpened',
},
},
},
{
title: 'emails field settings with maxNumberOfValues',
context: {
input: {
settings: {
maxNumberOfValues: 3,
},
},
},
},
{
title: 'emails field default value with primary email',
context: {
input: {
defaultValue: {
primaryEmail: "'contact@example.com'",
additionalEmails: null,
},
},
},
},
{
title: 'emails field default value with empty values',
context: {
input: {
defaultValue: null,
},
},
},
{
title: 'emails field default value set to null',
context: {
input: {
defaultValue: null,
},
},
},
{
title: 'emails field settings and default value together',
context: {
input: {
settings: {
maxNumberOfValues: 5,
},
defaultValue: {
primaryEmail: "'support@company.com'",
additionalEmails: null,
},
},
},
},
];
describe('Emails field metadata update tests suite', () => {
let createdObjectMetadataId: string;
let createdFieldMetadataId: string;
beforeAll(async () => {
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'testEmailsUpdateObject',
namePlural: 'testEmailsUpdateObjects',
labelSingular: 'Test Emails Update Object',
labelPlural: 'Test Emails Update Objects',
icon: 'IconTestPipe',
isLabelSyncedWithName: false,
},
});
createdObjectMetadataId = data.createOneObject.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
});
beforeEach(async () => {
const {
data: { createOneField },
} = await createOneFieldMetadata({
expectToFail: false,
input: {
objectMetadataId: createdObjectMetadataId,
type: FieldMetadataType.EMAILS,
name: 'testEmails',
label: 'Test Emails',
description: 'Initial description',
icon: 'IconMail',
isLabelSyncedWithName: false,
},
gqlFields: `
id
`,
});
createdFieldMetadataId = createOneField.id;
});
afterEach(async () => {
await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: createdFieldMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneFieldMetadata({
expectToFail: false,
input: { idToDelete: createdFieldMetadataId },
});
});
test.each(eachTestingContextFilter(SUCCESSFUL_TEST_CASES))(
'it should update $title',
async ({ context: { input } }) => {
const { data } = await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: createdFieldMetadataId,
updatePayload: input,
},
gqlFields: `
id
type
name
label
description
icon
settings
defaultValue
`,
});
expect(data.updateOneField).toMatchObject(input);
},
);
});

View File

@@ -0,0 +1,162 @@
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 {
type EachTestingContext,
eachTestingContextFilter,
} from 'twenty-shared/testing';
import { FieldMetadataType } from 'twenty-shared/types';
import { type UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
const SUCCESSFUL_TEST_CASES: EachTestingContext<{
input: Partial<UpdateFieldInput>;
}>[] = [
{
title: 'full name field basic metadata (label, description, icon)',
context: {
input: {
label: 'Updated Full Name',
description: 'Updated description',
icon: 'IconUserCircle',
},
},
},
{
title: 'full name field default value with first and last name',
context: {
input: {
defaultValue: {
firstName: "'John'",
lastName: "'Doe'",
},
},
},
},
{
title: 'full name field default value with only first name',
context: {
input: {
defaultValue: {
firstName: "'Jane'",
lastName: null,
},
},
},
},
{
title: 'full name field default value with empty values',
context: {
input: {
defaultValue: null,
},
},
},
{
title: 'full name field default value set to null',
context: {
input: {
defaultValue: null,
},
},
},
];
describe('Full name field metadata update tests suite', () => {
let createdObjectMetadataId: string;
let createdFieldMetadataId: string;
beforeAll(async () => {
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'testFullNameUpdateObject',
namePlural: 'testFullNameUpdateObjects',
labelSingular: 'Test Full Name Update Object',
labelPlural: 'Test Full Name Update Objects',
icon: 'IconTestPipe',
isLabelSyncedWithName: false,
},
});
createdObjectMetadataId = data.createOneObject.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
});
beforeEach(async () => {
const {
data: { createOneField },
} = await createOneFieldMetadata({
expectToFail: false,
input: {
objectMetadataId: createdObjectMetadataId,
type: FieldMetadataType.FULL_NAME,
name: 'testFullName',
label: 'Test Full Name',
description: 'Initial description',
icon: 'IconUser',
isLabelSyncedWithName: false,
},
gqlFields: `
id
`,
});
createdFieldMetadataId = createOneField.id;
});
afterEach(async () => {
await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: createdFieldMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneFieldMetadata({
expectToFail: false,
input: { idToDelete: createdFieldMetadataId },
});
});
test.each(eachTestingContextFilter(SUCCESSFUL_TEST_CASES))(
'it should update $title',
async ({ context: { input } }) => {
const { data } = await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: createdFieldMetadataId,
updatePayload: input,
},
gqlFields: `
id
type
name
label
description
icon
settings
defaultValue
`,
});
expect(data.updateOneField).toMatchObject(input);
},
);
});

View File

@@ -0,0 +1,177 @@
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 {
type EachTestingContext,
eachTestingContextFilter,
} from 'twenty-shared/testing';
import { FieldMetadataType } from 'twenty-shared/types';
import { type UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
const SUCCESSFUL_TEST_CASES: EachTestingContext<{
input: Partial<UpdateFieldInput>;
}>[] = [
{
title: 'links field basic metadata (label, description, icon)',
context: {
input: {
label: 'Updated Links',
description: 'Updated description',
icon: 'IconExternalLink',
},
},
},
{
title: 'links field settings with maxNumberOfValues',
context: {
input: {
settings: {
maxNumberOfValues: 5,
},
},
},
},
{
title: 'links field default value with primary link',
context: {
input: {
defaultValue: {
primaryLinkLabel: "'Website'",
primaryLinkUrl: "'https://example.com'",
secondaryLinks: null,
},
},
},
},
{
title: 'links field default value with empty values',
context: {
input: {
defaultValue: null,
},
},
},
{
title: 'links field default value set to null',
context: {
input: {
defaultValue: null,
},
},
},
{
title: 'links field settings and default value together',
context: {
input: {
settings: {
maxNumberOfValues: 3,
},
defaultValue: {
primaryLinkLabel: "'Homepage'",
primaryLinkUrl: "'https://company.com'",
secondaryLinks: null,
},
},
},
},
];
describe('Links field metadata update tests suite', () => {
let createdObjectMetadataId: string;
let createdFieldMetadataId: string;
beforeAll(async () => {
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'testLinksUpdateObject',
namePlural: 'testLinksUpdateObjects',
labelSingular: 'Test Links Update Object',
labelPlural: 'Test Links Update Objects',
icon: 'IconTestPipe',
isLabelSyncedWithName: false,
},
});
createdObjectMetadataId = data.createOneObject.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
});
beforeEach(async () => {
const {
data: { createOneField },
} = await createOneFieldMetadata({
expectToFail: false,
input: {
objectMetadataId: createdObjectMetadataId,
type: FieldMetadataType.LINKS,
name: 'testLinks',
label: 'Test Links',
description: 'Initial description',
icon: 'IconLink',
isLabelSyncedWithName: false,
},
gqlFields: `
id
`,
});
createdFieldMetadataId = createOneField.id;
});
afterEach(async () => {
await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: createdFieldMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneFieldMetadata({
expectToFail: false,
input: { idToDelete: createdFieldMetadataId },
});
});
test.each(eachTestingContextFilter(SUCCESSFUL_TEST_CASES))(
'it should update $title',
async ({ context: { input } }) => {
const { data } = await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: createdFieldMetadataId,
updatePayload: input,
},
gqlFields: `
id
type
name
label
description
icon
settings
defaultValue
`,
});
expect(data.updateOneField).toMatchObject(input);
},
);
});

View File

@@ -0,0 +1,179 @@
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 {
type EachTestingContext,
eachTestingContextFilter,
} from 'twenty-shared/testing';
import { FieldMetadataType } from 'twenty-shared/types';
import { type UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
const SUCCESSFUL_TEST_CASES: EachTestingContext<{
input: Partial<UpdateFieldInput>;
}>[] = [
{
title: 'phones field basic metadata (label, description, icon)',
context: {
input: {
label: 'Updated Phones',
description: 'Updated description',
icon: 'IconPhoneCall',
},
},
},
{
title: 'phones field settings with maxNumberOfValues',
context: {
input: {
settings: {
maxNumberOfValues: 4,
},
},
},
},
{
title: 'phones field default value with primary phone',
context: {
input: {
defaultValue: {
primaryPhoneNumber: "'+33123456789'",
primaryPhoneCountryCode: "'FR'",
primaryPhoneCallingCode: "'+33'",
additionalPhones: null,
},
},
},
},
{
title: 'phones field default value with empty values',
context: {
input: {
defaultValue: null,
},
},
},
{
title: 'phones field default value set to null',
context: {
input: {
defaultValue: null,
},
},
},
{
title: 'phones field settings and default value together',
context: {
input: {
settings: {
maxNumberOfValues: 6,
},
defaultValue: {
primaryPhoneNumber: "'+1555123456'",
primaryPhoneCountryCode: "'US'",
primaryPhoneCallingCode: "'+1'",
additionalPhones: null,
},
},
},
},
];
describe('Phones field metadata update tests suite', () => {
let createdObjectMetadataId: string;
let createdFieldMetadataId: string;
beforeAll(async () => {
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'testPhonesUpdateObject',
namePlural: 'testPhonesUpdateObjects',
labelSingular: 'Test Phones Update Object',
labelPlural: 'Test Phones Update Objects',
icon: 'IconTestPipe',
isLabelSyncedWithName: false,
},
});
createdObjectMetadataId = data.createOneObject.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
});
beforeEach(async () => {
const {
data: { createOneField },
} = await createOneFieldMetadata({
expectToFail: false,
input: {
objectMetadataId: createdObjectMetadataId,
type: FieldMetadataType.PHONES,
name: 'testPhones',
label: 'Test Phones',
description: 'Initial description',
icon: 'IconPhone',
isLabelSyncedWithName: false,
},
gqlFields: `
id
`,
});
createdFieldMetadataId = createOneField.id;
});
afterEach(async () => {
await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: createdFieldMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneFieldMetadata({
expectToFail: false,
input: { idToDelete: createdFieldMetadataId },
});
});
test.each(eachTestingContextFilter(SUCCESSFUL_TEST_CASES))(
'it should update $title',
async ({ context: { input } }) => {
const { data } = await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: createdFieldMetadataId,
updatePayload: input,
},
gqlFields: `
id
type
name
label
description
icon
settings
defaultValue
`,
});
expect(data.updateOneField).toMatchObject(input);
},
);
});

View File

@@ -0,0 +1,165 @@
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 {
type EachTestingContext,
eachTestingContextFilter,
} from 'twenty-shared/testing';
import { FieldMetadataType } from 'twenty-shared/types';
import { type UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
const SUCCESSFUL_TEST_CASES: EachTestingContext<{
input: Partial<UpdateFieldInput>;
}>[] = [
{
title: 'rich text v2 field basic metadata (label, description, icon)',
context: {
input: {
label: 'Updated Rich Text',
description: 'Updated description',
icon: 'IconFileText',
},
},
},
{
title: 'rich text v2 field default value with markdown',
context: {
input: {
defaultValue: {
blocknote: null,
markdown: "'# Default Title'",
},
},
},
},
{
title: 'rich text v2 field default value with blocknote',
context: {
input: {
defaultValue: {
blocknote: '\'{"blocks":[{"type":"paragraph"}]}\'',
markdown: null,
},
},
},
},
{
title: 'rich text v2 field default value with both formats',
context: {
input: {
defaultValue: {
blocknote: '\'{"blocks":[]}\'',
markdown: "'## Heading'",
},
},
},
},
{
title: 'rich text v2 field default value set to null',
context: {
input: {
defaultValue: null,
},
},
},
];
describe('Rich text v2 field metadata update tests suite', () => {
let createdObjectMetadataId: string;
let createdFieldMetadataId: string;
beforeAll(async () => {
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'testRichTextUpdateObject',
namePlural: 'testRichTextUpdateObjects',
labelSingular: 'Test Rich Text V2 Update Object',
labelPlural: 'Test Rich Text V2 Update Objects',
icon: 'IconTestPipe',
isLabelSyncedWithName: false,
},
});
createdObjectMetadataId = data.createOneObject.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
});
beforeEach(async () => {
const {
data: { createOneField },
} = await createOneFieldMetadata({
expectToFail: false,
input: {
objectMetadataId: createdObjectMetadataId,
type: FieldMetadataType.RICH_TEXT,
name: 'testRichText',
label: 'Test Rich Text',
description: 'Initial description',
icon: 'IconNotes',
isLabelSyncedWithName: false,
},
gqlFields: `
id
`,
});
createdFieldMetadataId = createOneField.id;
});
afterEach(async () => {
await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: createdFieldMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneFieldMetadata({
expectToFail: false,
input: { idToDelete: createdFieldMetadataId },
});
});
test.each(eachTestingContextFilter(SUCCESSFUL_TEST_CASES))(
'it should update $title',
async ({ context: { input } }) => {
const { data } = await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: createdFieldMetadataId,
updatePayload: input,
},
gqlFields: `
id
type
name
label
description
icon
settings
defaultValue
`,
});
expect(data.updateOneField).toMatchObject(input);
},
);
});

View File

@@ -0,0 +1,135 @@
import { createManyOperation } from 'test/integration/graphql/utils/create-many-operation.util';
import { search } from 'test/integration/graphql/utils/search.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';
// ISO search surface: a custom object's searchVector indexes the name field only. Creating or
// deleting an additional searchable field must NOT change the search surface through the API.
describe('Field metadata create/delete - search vector ISO surface', () => {
let testObjectMetadataId: string;
let extraFieldMetadataId: string;
const OBJECT_NAME_SINGULAR = 'searchSurfaceObject';
const OBJECT_NAME_PLURAL = 'searchSurfaceObjects';
const EXTRA_FIELD_NAME = 'extraSearchableField';
const RECORD_NAME_VALUE = 'SearchSurfaceNameToken11';
const RECORD_EXTRA_VALUE = 'SearchSurfaceExtraToken22';
beforeAll(async () => {
const {
data: {
createOneObject: { id: objectMetadataId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: OBJECT_NAME_SINGULAR,
namePlural: OBJECT_NAME_PLURAL,
labelSingular: 'Search Surface Object',
labelPlural: 'Search Surface Objects',
icon: 'IconSearch',
isLabelSyncedWithName: false,
},
});
testObjectMetadataId = objectMetadataId;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: testObjectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: testObjectMetadataId },
});
});
it('should not add a newly created searchable field to the search vector or to global search', async () => {
const {
data: {
createOneField: { id: extraFieldId },
},
} = await createOneFieldMetadata({
expectToFail: false,
input: {
name: EXTRA_FIELD_NAME,
label: 'Extra Searchable Field',
type: FieldMetadataType.TEXT,
objectMetadataId: testObjectMetadataId,
isLabelSyncedWithName: false,
},
gqlFields: `id name type`,
});
extraFieldMetadataId = extraFieldId;
await createManyOperation({
objectMetadataSingularName: OBJECT_NAME_SINGULAR,
objectMetadataPluralName: OBJECT_NAME_PLURAL,
gqlFields: `id name ${EXTRA_FIELD_NAME}`,
data: [
{
name: RECORD_NAME_VALUE,
[EXTRA_FIELD_NAME]: RECORD_EXTRA_VALUE,
},
],
expectToFail: false,
});
// The name field is indexed, so the record is reachable through its name value.
const searchByName = await search({
searchInput: RECORD_NAME_VALUE,
includedObjectNameSingulars: [OBJECT_NAME_SINGULAR],
limit: 10,
expectToFail: false,
});
expect(searchByName.data.search.edges.length).toBe(1);
// The extra field is outside the search surface, so its value is not searchable.
const searchByExtraField = await search({
searchInput: RECORD_EXTRA_VALUE,
includedObjectNameSingulars: [OBJECT_NAME_SINGULAR],
limit: 10,
expectToFail: false,
});
expect(searchByExtraField.data.search.edges.length).toBe(0);
});
it('should keep the search surface unchanged after the extra field is deleted', async () => {
// Custom fields must be deactivated before deletion.
await updateOneFieldMetadata({
input: {
idToUpdate: extraFieldMetadataId,
updatePayload: { isActive: false },
},
gqlFields: `id`,
expectToFail: false,
});
await deleteOneFieldMetadata({
input: { idToDelete: extraFieldMetadataId },
expectToFail: false,
});
const searchByName = await search({
searchInput: RECORD_NAME_VALUE,
includedObjectNameSingulars: [OBJECT_NAME_SINGULAR],
limit: 10,
expectToFail: false,
});
expect(searchByName.data.search.edges.length).toBe(1);
});
});

View File

@@ -0,0 +1,113 @@
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 { FieldMetadataType } from 'twenty-shared/types';
describe('createOne FieldMetadataService name/label sync', () => {
let createdObjectMetadataId = '';
beforeEach(async () => {
const {
data: {
createOneObject: { id: objectMetadataId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'myTestObject',
namePlural: 'myTestObjects',
labelSingular: 'My Test Object',
labelPlural: 'My Test Objects',
icon: 'Icon123',
},
});
createdObjectMetadataId = objectMetadataId;
});
afterEach(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: { idToDelete: createdObjectMetadataId },
});
});
it('should create a field when name and label are synced correctly', async () => {
const FIELD_NAME = 'testField';
const createFieldInput = {
name: FIELD_NAME,
label: 'Test Field',
type: FieldMetadataType.TEXT,
objectMetadataId: createdObjectMetadataId,
isLabelSyncedWithName: true,
};
const { data } = await createOneFieldMetadata({
input: createFieldInput,
gqlFields: `
id
name
label
isLabelSyncedWithName
`,
});
expect(data.createOneField.name).toBe(FIELD_NAME);
});
it('should set isLabelSyncWithName to false if not in input', async () => {
const createFieldInput = {
name: 'testField',
label: 'Test Field',
type: FieldMetadataType.TEXT,
objectMetadataId: createdObjectMetadataId,
};
const { data } = await createOneFieldMetadata({
input: createFieldInput,
gqlFields: `
id
name
label
isLabelSyncedWithName
`,
});
expect(data.createOneField.isLabelSyncedWithName).toBe(false);
});
it('should return an error when name and label are not synced but isLabelSyncedWithName is true', async () => {
const createFieldInput = {
name: 'testField',
label: 'Different Label',
type: FieldMetadataType.TEXT,
objectMetadataId: createdObjectMetadataId,
isLabelSyncedWithName: true,
};
const response = await createOneFieldMetadata({
input: createFieldInput,
gqlFields: `
id
name
label
isLabelSyncedWithName
`,
expectToFail: true,
});
expect(response.errors.length).toBe(1);
const [firstError] = response.errors;
expect(firstError).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny(firstError),
);
});
});

View File

@@ -0,0 +1,27 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`successful update default value option value and side effect on records should update existing records when default value option value is changed and not mutate when default option is changed 1`] = `
[
{
"color": "green",
"id": Any<String>,
"label": "Option 1",
"position": 0,
"value": "OPTION_1_MODIFIED",
},
{
"color": "blue",
"id": Any<String>,
"label": "Option 2",
"position": 1,
"value": "OPTION_2",
},
{
"color": "red",
"id": Any<String>,
"label": "Option 3",
"position": 2,
"value": "OPTION_3",
},
]
`;

View File

@@ -0,0 +1,49 @@
import { MUTLI_SELECT_OPERATION_AGNOSTIC_TEST_CASES } from 'test/integration/metadata/suites/field-metadata/enum/common/multi-select-operation-agnostic-test-cases';
import { SELECT_OPERATION_AGNOSTIC_TEST_CASES } from 'test/integration/metadata/suites/field-metadata/enum/common/select-operation-agnostic-test-cases';
import { type FieldMetadataEnumSuccessfulAndFailingTestCases } from 'test/integration/metadata/suites/field-metadata/enum/types/fieldMetadataEnumSuccessfulAndFailingTestCases';
import { type UpdateCreateFieldMetadataSelectTestCase } from 'test/integration/metadata/suites/field-metadata/enum/types/update-create-field-metadata-enum-test-case';
import { type FieldMetadataComplexOption } from 'twenty-shared/types';
import { type EnumFieldMetadataUnionType } from 'src/engine/metadata-modules/field-metadata/utils/is-enum-field-metadata-type.util';
const fieldMetadataTypeAgnosticCreateFailingTestCases: UpdateCreateFieldMetadataSelectTestCase[] =
[
{
title: 'should fail with null options',
context: {
input: {
options: null as unknown as FieldMetadataComplexOption[],
},
},
},
{
title: 'should fail with undefined options',
context: {
input: {
options: undefined as unknown as FieldMetadataComplexOption[],
},
},
},
];
export const CREATE_ENUM_FIELD_METADATA_TEST_CASES: Partial<
Record<
EnumFieldMetadataUnionType,
FieldMetadataEnumSuccessfulAndFailingTestCases
>
> = {
MULTI_SELECT: {
failing: [
...MUTLI_SELECT_OPERATION_AGNOSTIC_TEST_CASES.failing,
...fieldMetadataTypeAgnosticCreateFailingTestCases,
],
successful: [...MUTLI_SELECT_OPERATION_AGNOSTIC_TEST_CASES.successful],
},
SELECT: {
failing: [
...SELECT_OPERATION_AGNOSTIC_TEST_CASES.failing,
...fieldMetadataTypeAgnosticCreateFailingTestCases,
],
successful: [...SELECT_OPERATION_AGNOSTIC_TEST_CASES.successful],
},
};

View File

@@ -0,0 +1,122 @@
import { isDefined } from 'class-validator';
import { BASIC_FAILING_STRING_EDGE_CASE_INPUTS } from 'test/constants/basic-failing-string-edge-case-inputs.constant';
import { SELECT_AND_MULTI_SELECT_OPERATION_AGNOSTIC_SUCCESSFUL_AND_FAILING_TEST_CASES } from 'test/integration/metadata/suites/field-metadata/enum/common/select-and-multi-select-operation-agnostic-tests-cases';
import { type FieldMetadataEnumSuccessfulAndFailingTestCases } from 'test/integration/metadata/suites/field-metadata/enum/types/fieldMetadataEnumSuccessfulAndFailingTestCases';
import { type UpdateCreateFieldMetadataSelectTestCase } from 'test/integration/metadata/suites/field-metadata/enum/types/update-create-field-metadata-enum-test-case';
const fuzzedDefaultValueFailingTestCases: UpdateCreateFieldMetadataSelectTestCase[] =
BASIC_FAILING_STRING_EDGE_CASE_INPUTS.filter((el) => isDefined(el.input)).map(
({ input, label }) => ({
title: `should fail with ${label} defaultValue`,
context: {
input: {
defaultValue: [input],
options: [
{
label: 'Option 1',
value: 'OPTION_1',
color: 'green',
position: 1,
},
],
},
},
}),
);
export const MUTLI_SELECT_OPERATION_AGNOSTIC_TEST_CASES: FieldMetadataEnumSuccessfulAndFailingTestCases =
{
successful: [
...SELECT_AND_MULTI_SELECT_OPERATION_AGNOSTIC_SUCCESSFUL_AND_FAILING_TEST_CASES.successful,
{
title: 'should succeed with valid multi-select default values',
context: {
input: {
defaultValue: ["'OPTION_1'", "'OPTION_2'"],
options: [
{
label: 'Option 1',
value: 'OPTION_1',
color: 'green',
position: 1,
},
{
label: 'Option 2',
value: 'OPTION_2',
color: 'blue',
position: 2,
},
],
},
},
},
],
failing: [
...fuzzedDefaultValueFailingTestCases,
...SELECT_AND_MULTI_SELECT_OPERATION_AGNOSTIC_SUCCESSFUL_AND_FAILING_TEST_CASES.failing,
{
title: 'should fail with non stringified array default value',
context: {
input: {
defaultValue: 'Option_1',
options: [
{
label: 'Option 1',
value: 'OPTION_1',
color: 'green',
position: 1,
},
],
},
},
},
{
title: 'should fail with empty multi-select default values array',
context: {
input: {
defaultValue: [],
options: [
{
label: 'Option 1',
value: 'OPTION_1',
color: 'green',
position: 1,
},
],
},
},
},
{
title: 'should fail with unknown multi-select default values',
context: {
input: {
defaultValue: ["'OPTION_1'", "'UNKNOWN_OPTION'"],
options: [
{
label: 'Option 1',
value: 'OPTION_1',
color: 'green',
position: 1,
},
],
},
},
},
{
title: 'should fail with invalid multi-select default value format',
context: {
input: {
defaultValue: ['invalid', 'format'],
options: [
{
label: 'Option 1',
value: 'OPTION_1',
color: 'green',
position: 1,
},
],
},
},
},
],
};

View File

@@ -0,0 +1,369 @@
import { BASIC_FAILING_STRING_EDGE_CASE_INPUTS } from 'test/constants/basic-failing-string-edge-case-inputs.constant';
import { type FieldMetadataEnumSuccessfulAndFailingTestCases } from 'test/integration/metadata/suites/field-metadata/enum/types/fieldMetadataEnumSuccessfulAndFailingTestCases';
import { type UpdateCreateFieldMetadataSelectTestCase } from 'test/integration/metadata/suites/field-metadata/enum/types/update-create-field-metadata-enum-test-case';
import {
type FieldMetadataComplexOption,
type TagColor,
} from 'twenty-shared/types';
import { v4 } from 'uuid';
const stringFields: (keyof FieldMetadataComplexOption)[] = [
'id',
'label',
'value',
];
const fuzzedOptionsStringFieldFailingTestCases: UpdateCreateFieldMetadataSelectTestCase[] =
stringFields.flatMap((field) => {
return BASIC_FAILING_STRING_EDGE_CASE_INPUTS.map<UpdateCreateFieldMetadataSelectTestCase>(
({ input, label }) => ({
title: `should fail with ${label} ${field}`,
context: {
input: {
options: [
{
label: 'Option 1',
value: 'OPTION_1',
color: 'green',
position: 1,
[field]: input,
},
],
},
},
}),
);
});
export const SELECT_AND_MULTI_SELECT_OPERATION_AGNOSTIC_SUCCESSFUL_AND_FAILING_TEST_CASES: FieldMetadataEnumSuccessfulAndFailingTestCases =
{
failing: [
...fuzzedOptionsStringFieldFailingTestCases,
{
title: 'should fail with invalid option id',
context: {
input: {
options: [
{
label: 'Option 1',
value: 'OPTION_1',
color: 'green',
position: 1,
id: 'not a uuid',
},
],
},
},
},
{
title: 'should fail with empty options',
context: {
input: {
options: [],
},
},
},
{
title: 'should fail with invalid option value format',
context: {
input: {
options: [
{
label: 'Option 1',
value: 'Option 1 and some other things, /',
color: 'green',
position: 1,
},
],
},
},
},
{
title: 'should fail with comma in option label',
context: {
input: {
options: [
{
label: 'Option ,1',
value: 'OPTION_1',
color: 'green',
position: 1,
},
],
},
},
},
{
title: 'should fail with duplicated option values',
context: {
input: {
options: [
{
label: 'Option 1',
value: 'OPTION_1',
color: 'green',
position: 0,
},
{
label: 'Option 1',
value: 'OPTION_1',
color: 'green',
position: 1,
},
],
},
},
},
{
title: 'should fail with duplicated option ids',
context: {
input: {
options: [
{
label: 'Option 1',
value: 'OPTION_1',
color: 'green',
position: 1,
id: 'fd1f11fd-3f05-4a33-bddf-800c3412ce98',
},
{
label: 'Option 2',
value: 'OPTION_2',
color: 'green',
position: 2,
id: 'fd1f11fd-3f05-4a33-bddf-800c3412ce98',
},
],
},
},
},
{
title: 'should fail with duplicated option positions',
context: {
input: {
options: [
{
label: 'Option 1',
value: 'OPTION_1',
color: 'green',
position: 1,
},
{
label: 'Option 2',
value: 'OPTION_2',
color: 'green',
position: 1,
},
],
},
},
},
{
title: 'should fail with duplicated trimmed option values',
context: {
input: {
options: [
{
label: 'Option 1',
value: ' OPTION_1 ',
color: 'green',
position: 1,
},
{
label: 'Option 2',
value: ' OPTION_1 ',
color: 'green',
position: 2,
},
],
},
},
},
{
title: 'should fail with undefined option label',
context: {
input: {
options: [
{
label: undefined as unknown as string,
value: 'OPTION_1',
color: 'green',
position: 1,
},
],
},
},
},
{
title: 'should fail with an invalid default value',
context: {
input: {
defaultValue: 'invalid',
options: [
{
label: 'Option 1',
value: 'OPTION_1',
color: 'green',
position: 1,
},
],
},
},
},
{
title: 'should fail with an unknown default value',
context: {
input: {
defaultValue: "'OPTION_424242'",
options: [
{
label: 'Option 1',
value: 'OPTION_1',
color: 'green',
position: 1,
},
],
},
},
},
{
title: 'should fail with undefined option value',
context: {
input: {
options: [
{
label: 'Option 1',
value: undefined as unknown as string,
color: 'green',
position: 1,
},
],
},
},
},
],
successful: [
{
title: 'should succeed with provided option id',
context: {
input: {
options: [
{
label: 'Option 1',
value: 'OPTION_1',
color: 'green',
position: 1,
id: '26c602c3-cba9-4d83-92d4-4ba7dbae2f31',
},
],
},
},
},
{
title: 'should succeed with various options id',
context: {
input: {
options: Array.from({ length: 42 }, (_value, index) => {
const optionWithoutId: FieldMetadataComplexOption = {
label: `Option ${index}`,
value: `OPTION_${index}`,
color: 'green',
position: index,
};
if (index % 2 === 0) {
return {
...optionWithoutId,
id: v4(),
};
}
return optionWithoutId;
}),
},
},
},
{
title: 'should succeed without option id',
context: {
input: {
options: [
{
label: 'Option 1',
value: 'OPTION_1',
color: 'green',
position: 1,
},
],
},
},
},
{
title: 'should trim option values',
context: {
input: {
options: [
{
label: ' Option 1 ',
value: ' OPTION_1 ',
color: 'green',
position: 1,
},
],
},
expectedOptions: [
{
label: 'Option 1',
value: 'OPTION_1',
color: 'green',
position: 1,
},
],
},
},
{
title: 'should default a missing option color to gray',
context: {
input: {
options: [
{
label: 'Option 1',
value: 'OPTION_1',
position: 1,
},
],
},
expectedOptions: [
{
label: 'Option 1',
value: 'OPTION_1',
color: 'gray',
position: 1,
},
],
},
},
{
title: 'should trim option color',
context: {
input: {
options: [
{
label: 'Option 1',
value: 'OPTION_1',
color: ' blue ' as TagColor,
position: 1,
},
],
},
expectedOptions: [
{
label: 'Option 1',
value: 'OPTION_1',
color: 'blue',
position: 1,
},
],
},
},
],
};

View File

@@ -0,0 +1,70 @@
import { isDefined } from 'class-validator';
import { BASIC_FAILING_STRING_EDGE_CASE_INPUTS } from 'test/constants/basic-failing-string-edge-case-inputs.constant';
import { SELECT_AND_MULTI_SELECT_OPERATION_AGNOSTIC_SUCCESSFUL_AND_FAILING_TEST_CASES } from 'test/integration/metadata/suites/field-metadata/enum/common/select-and-multi-select-operation-agnostic-tests-cases';
import { type FieldMetadataEnumSuccessfulAndFailingTestCases } from 'test/integration/metadata/suites/field-metadata/enum/types/fieldMetadataEnumSuccessfulAndFailingTestCases';
import { type UpdateCreateFieldMetadataSelectTestCase } from 'test/integration/metadata/suites/field-metadata/enum/types/update-create-field-metadata-enum-test-case';
const fuzzedDefaultValueFailingTestCases: UpdateCreateFieldMetadataSelectTestCase[] =
BASIC_FAILING_STRING_EDGE_CASE_INPUTS.filter((el) => isDefined(el.input)).map(
({ input, label }) => ({
title: `should fail with ${label} defaultValue`,
context: {
input: {
defaultValue: input,
options: [
{
label: 'Option 1',
value: 'OPTION_1',
color: 'green',
position: 1,
},
],
},
},
}),
);
export const SELECT_OPERATION_AGNOSTIC_TEST_CASES: FieldMetadataEnumSuccessfulAndFailingTestCases =
{
failing: [
...fuzzedDefaultValueFailingTestCases,
...SELECT_AND_MULTI_SELECT_OPERATION_AGNOSTIC_SUCCESSFUL_AND_FAILING_TEST_CASES.failing,
],
successful: [
...SELECT_AND_MULTI_SELECT_OPERATION_AGNOSTIC_SUCCESSFUL_AND_FAILING_TEST_CASES.successful,
{
title: 'should succeed with valid default value',
context: {
input: {
defaultValue: "'OPTION_1'",
options: [
{
label: 'Option 1',
value: 'OPTION_1',
color: 'green',
position: 1,
id: '26c602c3-cba9-4d83-92d4-4ba7dbae2f31',
},
],
},
},
},
{
title: 'should succeed with null default value',
context: {
input: {
defaultValue: null,
options: [
{
label: 'Option 1',
value: 'OPTION_1',
color: 'green',
position: 1,
id: '26c602c3-cba9-4d83-92d4-4ba7dbae2f31',
},
],
},
},
},
],
};

View File

@@ -0,0 +1,94 @@
import { MUTLI_SELECT_OPERATION_AGNOSTIC_TEST_CASES } from 'test/integration/metadata/suites/field-metadata/enum/common/multi-select-operation-agnostic-test-cases';
import { SELECT_OPERATION_AGNOSTIC_TEST_CASES } from 'test/integration/metadata/suites/field-metadata/enum/common/select-operation-agnostic-test-cases';
import { type FieldMetadataEnumSuccessfulAndFailingTestCases } from 'test/integration/metadata/suites/field-metadata/enum/types/fieldMetadataEnumSuccessfulAndFailingTestCases';
import { type FieldMetadataComplexOption } from 'twenty-shared/types';
import { type EnumFieldMetadataUnionType } from 'src/engine/metadata-modules/field-metadata/utils/is-enum-field-metadata-type.util';
export const UPDATE_ENUM_FIELD_METADATA_TEST_CASES: Partial<
Record<
EnumFieldMetadataUnionType,
FieldMetadataEnumSuccessfulAndFailingTestCases
>
> = {
MULTI_SELECT: {
failing: [
...MUTLI_SELECT_OPERATION_AGNOSTIC_TEST_CASES.failing,
{
title: 'should fail with unknown default value and no options',
context: {
input: {
defaultValue: ["'OPTION_42'"],
options: undefined as unknown as FieldMetadataComplexOption[],
},
},
},
],
successful: [
...MUTLI_SELECT_OPERATION_AGNOSTIC_TEST_CASES.successful,
{
title: 'should succeed with default value and no options',
context: {
input: {
defaultValue: ["'OPTION_2'"],
options: undefined as unknown as FieldMetadataComplexOption[],
},
expectedOptions: [
{
label: 'Option 1',
value: 'OPTION_1',
color: 'green',
position: 1,
},
{
label: 'Option 2',
value: 'OPTION_2',
color: 'green',
position: 2,
},
],
},
},
],
},
SELECT: {
failing: [
...SELECT_OPERATION_AGNOSTIC_TEST_CASES.failing,
{
title: 'should fail with unknown default value and no options',
context: {
input: {
defaultValue: "'OPTION_42'",
options: undefined as unknown as FieldMetadataComplexOption[],
},
},
},
],
successful: [
...SELECT_OPERATION_AGNOSTIC_TEST_CASES.successful,
{
title: 'should succeed with default value and no options',
context: {
input: {
defaultValue: "'OPTION_2'",
options: undefined as unknown as FieldMetadataComplexOption[],
},
expectedOptions: [
{
label: 'Option 1',
value: 'OPTION_1',
color: 'green',
position: 1,
},
{
label: 'Option 2',
value: 'OPTION_2',
color: 'green',
position: 2,
},
],
},
},
],
},
};

View File

@@ -0,0 +1,92 @@
import { CREATE_ENUM_FIELD_METADATA_TEST_CASES } from 'test/integration/metadata/suites/field-metadata/enum/common/create-enum-field-metadata-test-cases';
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { CUSTOM_OBJECT_DISHES } from 'test/integration/metadata/suites/object-metadata/constants/custom-object-dishes.constants';
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 } from 'twenty-shared/testing';
import { isDefined } from 'twenty-shared/utils';
import { fieldMetadataEnumTypes } from 'src/engine/metadata-modules/field-metadata/utils/is-enum-field-metadata-type.util';
describe.each(fieldMetadataEnumTypes)(
'Failing create field metadata %s tests suite v2',
(testedFieldMetadataType) => {
let createdObjectMetadataId: string;
const testCases =
CREATE_ENUM_FIELD_METADATA_TEST_CASES[testedFieldMetadataType];
if (!isDefined(testCases)) {
return;
}
const { failing: failingTestCases } = testCases;
beforeAll(async () => {
const {
labelPlural,
description,
labelSingular,
namePlural,
nameSingular,
} = CUSTOM_OBJECT_DISHES;
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
labelSingular,
labelPlural,
nameSingular,
description,
namePlural,
icon: 'IconBuildingSkyscraper',
isLabelSyncedWithName: false,
},
});
createdObjectMetadataId = data.createOneObject.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
});
test.each(eachTestingContextFilter(failingTestCases))(
'Create $title',
async ({ context: { input } }) => {
const { data, errors } = await createOneFieldMetadata({
expectToFail: true,
input: {
objectMetadataId: createdObjectMetadataId,
type: testedFieldMetadataType,
name: 'testField',
label: 'Test Field',
isLabelSyncedWithName: false,
...input,
},
gqlFields: `
id
options
defaultValue
`,
});
expect(data).toBeNull();
expect(errors).toBeDefined();
expect(errors).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny(errors),
);
expect(errors[0].extensions.code).not.toBe('INTERNAL_SERVER_ERROR');
},
);
},
);

View File

@@ -0,0 +1,126 @@
import { UPDATE_ENUM_FIELD_METADATA_TEST_CASES } from 'test/integration/metadata/suites/field-metadata/enum/common/update-enum-field-metadata-test-cases';
import { type CreateOneFieldFactoryInput } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata-query-factory.util';
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 { CUSTOM_OBJECT_DISHES } from 'test/integration/metadata/suites/object-metadata/constants/custom-object-dishes.constants';
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 } from 'twenty-shared/testing';
import { isDefined } from 'twenty-shared/utils';
import { fieldMetadataEnumTypes } from 'src/engine/metadata-modules/field-metadata/utils/is-enum-field-metadata-type.util';
describe.each(fieldMetadataEnumTypes)(
'Failing update field metadata %s tests suite v2',
(testedFieldMetadataType) => {
let createdObjectMetadataId: string;
let createdFieldMetadataId: string;
const testCases =
UPDATE_ENUM_FIELD_METADATA_TEST_CASES[testedFieldMetadataType];
if (!isDefined(testCases)) {
return;
}
const { failing: failingTestCases } = testCases;
const initialOptions: CreateOneFieldFactoryInput['options'] = [
{
label: 'Option 1',
value: 'OPTION_1',
color: 'green',
position: 1,
},
{
label: 'Option 2',
value: 'OPTION_2',
color: 'green',
position: 2,
},
];
beforeAll(async () => {
const {
labelPlural,
description,
labelSingular,
namePlural,
nameSingular,
} = CUSTOM_OBJECT_DISHES;
const { data } = await createOneObjectMetadata({
input: {
labelSingular,
labelPlural,
nameSingular,
description,
namePlural,
icon: 'IconBuildingSkyscraper',
isLabelSyncedWithName: false,
},
});
createdObjectMetadataId = data.createOneObject.id;
const {
data: { createOneField },
} = await createOneFieldMetadata({
input: {
objectMetadataId: createdObjectMetadataId,
type: testedFieldMetadataType,
name: 'testField',
label: 'Test Field',
isLabelSyncedWithName: false,
options: initialOptions,
},
gqlFields: `
id
type
`,
});
createdFieldMetadataId = createOneField.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: {
idToDelete: createdObjectMetadataId,
},
expectToFail: false,
});
});
test.each(eachTestingContextFilter(failingTestCases))(
'Update $title',
async ({ context: { input } }) => {
const { data, errors } = await updateOneFieldMetadata({
input: {
idToUpdate: createdFieldMetadataId,
updatePayload: input,
},
gqlFields: `
id
options
`,
});
expect(data).toBeNull();
expect(errors).toBeDefined();
expect(errors).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny(errors),
);
expect(errors[0].extensions.code).not.toBe('INTERNAL_SERVER_ERROR');
},
);
},
);

View File

@@ -0,0 +1,130 @@
import { CREATE_ENUM_FIELD_METADATA_TEST_CASES } from 'test/integration/metadata/suites/field-metadata/enum/common/create-enum-field-metadata-test-cases';
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 { CUSTOM_OBJECT_DISHES } from 'test/integration/metadata/suites/object-metadata/constants/custom-object-dishes.constants';
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 } from 'twenty-shared/testing';
import { isDefined } from 'twenty-shared/utils';
import { fieldMetadataEnumTypes } from 'src/engine/metadata-modules/field-metadata/utils/is-enum-field-metadata-type.util';
describe.each(fieldMetadataEnumTypes)(
'Successful create field metadata %s tests suite v2',
(testedFieldMetadataType) => {
let createdObjectMetadataId: string;
let createdFieldMetadataId: string | undefined = undefined;
const testCases =
CREATE_ENUM_FIELD_METADATA_TEST_CASES[testedFieldMetadataType];
if (!isDefined(testCases)) {
return;
}
const { successful: successfulTestCases } = testCases;
beforeAll(async () => {
const {
labelPlural,
description,
labelSingular,
namePlural,
nameSingular,
} = CUSTOM_OBJECT_DISHES;
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
labelPlural,
description,
labelSingular,
namePlural,
nameSingular,
icon: 'IconBuildingSkyscraper',
isLabelSyncedWithName: false,
},
});
createdObjectMetadataId = data.createOneObject.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
});
beforeEach(() => {
createdFieldMetadataId = undefined;
});
afterEach(async () => {
if (!isDefined(createdFieldMetadataId)) {
return;
}
await updateOneFieldMetadata({
input: {
updatePayload: {
isActive: false,
},
idToUpdate: createdFieldMetadataId,
},
expectToFail: false,
});
await deleteOneFieldMetadata({
input: {
idToDelete: createdFieldMetadataId,
},
expectToFail: false,
});
});
test.each(eachTestingContextFilter(successfulTestCases))(
'Create $title',
async ({ context: { input, expectedOptions } }) => {
const { data, errors } = await createOneFieldMetadata<
typeof testedFieldMetadataType
>({
expectToFail: false,
input: {
objectMetadataId: createdObjectMetadataId,
type: testedFieldMetadataType,
name: 'testField',
label: 'Test Field',
isLabelSyncedWithName: false,
...input,
},
gqlFields: `
id
options
defaultValue
type
`,
});
expect(data).not.toBeNull();
expect(data.createOneField).toBeDefined();
expect(data.createOneField.type).toEqual(testedFieldMetadataType);
createdFieldMetadataId = data.createOneField.id;
const createdOptions = data.createOneField.options;
const optionsToCompare = expectedOptions ?? input.options ?? [];
expect(errors).toBeUndefined();
expect(createdOptions?.length).toBe(optionsToCompare.length);
createdOptions?.forEach((option) => expect(option.id).toBeDefined());
expect(createdOptions).toMatchObject(optionsToCompare);
if (isDefined(input.defaultValue)) {
expect(data.createOneField.defaultValue).toEqual(input.defaultValue);
}
},
);
},
);

View File

@@ -0,0 +1,211 @@
import { createOneOperation } from 'test/integration/graphql/utils/create-one-operation.util';
import { findOneOperation } from 'test/integration/graphql/utils/find-one-operation.util';
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 { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { FieldMetadataType } from 'twenty-shared/types';
const TEST_OBJECT_NAME_SINGULAR = 'testMultiSelectRemoval';
const TEST_OBJECT_NAME_PLURAL = 'testMultiSelectRemovals';
// Reproduces the bug where removing a multi-select option fails when
// existing records contain both the removed option and surviving options.
describe('remove multi-select option with side effect on records', () => {
let createdObjectMetadataId: string;
let createdFieldMetadataId: string;
let firstOptionId: string;
let thirdOptionId: string;
beforeAll(async () => {
const { data: objectData } = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: TEST_OBJECT_NAME_SINGULAR,
namePlural: TEST_OBJECT_NAME_PLURAL,
labelSingular: 'Test Multi Select Removal',
labelPlural: 'Test Multi Select Removals',
icon: 'IconTestPipe',
isLabelSyncedWithName: false,
},
});
createdObjectMetadataId = objectData.createOneObject.id;
const { data: fieldData } = await createOneFieldMetadata({
expectToFail: false,
input: {
objectMetadataId: createdObjectMetadataId,
type: FieldMetadataType.MULTI_SELECT,
name: 'tagField',
label: 'Tag Field',
isLabelSyncedWithName: false,
options: [
{
label: 'Option 1',
value: 'OPTION_1',
color: 'green',
position: 0,
},
{
label: 'Option 2',
value: 'OPTION_2',
color: 'blue',
position: 1,
},
{
label: 'Option 3',
value: 'OPTION_3',
color: 'red',
position: 2,
},
],
},
gqlFields: `
id
options
`,
});
createdFieldMetadataId = fieldData.createOneField.id;
const options = fieldData.createOneField.options;
if (!options) {
throw new Error('Options should be defined');
}
const option1 = options.find(
(opt: { value: string }) => opt.value === 'OPTION_1',
);
const option2 = options.find(
(opt: { value: string }) => opt.value === 'OPTION_2',
);
const option3 = options.find(
(opt: { value: string }) => opt.value === 'OPTION_3',
);
if (!option1?.id || !option2?.id || !option3?.id) {
throw new Error('All options and their IDs should be defined');
}
firstOptionId = option1.id;
thirdOptionId = option3.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
});
it('should strip the removed option from existing records that contain both surviving and removed values', async () => {
// Record with OPTION_1 + OPTION_2 (OPTION_2 will be removed)
const { data: mixedRecordData } = await createOneOperation({
expectToFail: false,
objectMetadataSingularName: TEST_OBJECT_NAME_SINGULAR,
input: { tagField: ['OPTION_1', 'OPTION_2'] },
gqlFields: `id tagField`,
});
const mixedRecordId = mixedRecordData.createOneResponse.id;
expect(mixedRecordData.createOneResponse.tagField).toEqual([
'OPTION_1',
'OPTION_2',
]);
// Record with only OPTION_2 (the option that will be removed)
const { data: removedOnlyData } = await createOneOperation({
expectToFail: false,
objectMetadataSingularName: TEST_OBJECT_NAME_SINGULAR,
input: { tagField: ['OPTION_2'] },
gqlFields: `id tagField`,
});
const removedOnlyRecordId = removedOnlyData.createOneResponse.id;
const { data: survivingData } = await createOneOperation({
expectToFail: false,
objectMetadataSingularName: TEST_OBJECT_NAME_SINGULAR,
input: { tagField: ['OPTION_1', 'OPTION_3'] },
gqlFields: `id tagField`,
});
const survivingRecordId = survivingData.createOneResponse.id;
const { data: updateData, errors: updateErrors } =
await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: createdFieldMetadataId,
updatePayload: {
options: [
{
id: firstOptionId,
label: 'Option 1',
value: 'OPTION_1',
color: 'green',
position: 0,
},
{
id: thirdOptionId,
label: 'Option 3',
value: 'OPTION_3',
color: 'red',
position: 1,
},
],
},
},
gqlFields: `id options`,
});
expect(updateErrors).toBeUndefined();
expect(updateData.updateOneField.options).toHaveLength(2);
// Mixed record: OPTION_2 should be stripped, only OPTION_1 remains
const { data: mixedAfter } = await findOneOperation({
expectToFail: false,
objectMetadataSingularName: TEST_OBJECT_NAME_SINGULAR,
gqlFields: `id tagField`,
filter: { id: { eq: mixedRecordId } },
});
expect(mixedAfter.findResponse.tagField).toEqual(['OPTION_1']);
// Removed-only record: all values were removed, field is empty
const { data: removedOnlyAfter } = await findOneOperation({
expectToFail: false,
objectMetadataSingularName: TEST_OBJECT_NAME_SINGULAR,
gqlFields: `id tagField`,
filter: { id: { eq: removedOnlyRecordId } },
});
const removedOnlyTagField = removedOnlyAfter.findResponse.tagField;
expect(
removedOnlyTagField === null || removedOnlyTagField.length === 0,
).toBe(true);
const { data: survivingAfter } = await findOneOperation({
expectToFail: false,
objectMetadataSingularName: TEST_OBJECT_NAME_SINGULAR,
gqlFields: `id tagField`,
filter: { id: { eq: survivingRecordId } },
});
expect(survivingAfter.findResponse.tagField).toEqual([
'OPTION_1',
'OPTION_3',
]);
});
});

View File

@@ -0,0 +1,237 @@
import { createOneOperation } from 'test/integration/graphql/utils/create-one-operation.util';
import { findOneOperation } from 'test/integration/graphql/utils/find-one-operation.util';
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 { 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 { FieldMetadataType } from 'twenty-shared/types';
const TEST_OBJECT_NAME_SINGULAR = 'testSelectObject';
const TEST_OBJECT_NAME_PLURAL = 'testSelectObjects';
describe('successful update default value option value and side effect on records', () => {
let createdObjectMetadataId: string;
let createdFieldMetadataId: string;
let firstOptionId: string;
let secondOptionId: string;
let thirdOptionId: string;
beforeAll(async () => {
const { data: objectData } = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: TEST_OBJECT_NAME_SINGULAR,
namePlural: TEST_OBJECT_NAME_PLURAL,
labelSingular: 'Test Select Object',
labelPlural: 'Test Select Objects',
icon: 'IconTestPipe',
isLabelSyncedWithName: false,
},
});
createdObjectMetadataId = objectData.createOneObject.id;
const { data: fieldData } = await createOneFieldMetadata({
expectToFail: false,
input: {
objectMetadataId: createdObjectMetadataId,
type: FieldMetadataType.SELECT,
name: 'statusField',
label: 'Status Field',
isLabelSyncedWithName: false,
options: [
{
label: 'Option 1',
value: 'OPTION_1',
color: 'green',
position: 0,
},
{
label: 'Option 2',
value: 'OPTION_2',
color: 'blue',
position: 1,
},
{
label: 'Option 3',
value: 'OPTION_3',
color: 'red',
position: 2,
},
],
defaultValue: "'OPTION_1'",
},
gqlFields: `
id
options
defaultValue
`,
});
createdFieldMetadataId = fieldData.createOneField.id;
const options = fieldData.createOneField.options;
if (!options) {
throw new Error('Options should be defined');
}
const option1 = options.find((opt) => opt.value === 'OPTION_1');
const option2 = options.find((opt) => opt.value === 'OPTION_2');
const option3 = options.find((opt) => opt.value === 'OPTION_3');
if (!option1?.id || !option2?.id || !option3?.id) {
throw new Error('All options and their IDs should be defined');
}
firstOptionId = option1.id;
secondOptionId = option2.id;
thirdOptionId = option3.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
});
it('should update existing records when default value option value is changed and not mutate when default option is changed', async () => {
const { data: createFirstRecordData } = await createOneOperation({
expectToFail: false,
objectMetadataSingularName: TEST_OBJECT_NAME_SINGULAR,
input: {},
gqlFields: `
id
statusField
`,
});
const firstRecordId = createFirstRecordData.createOneResponse.id;
expect(createFirstRecordData.createOneResponse.statusField).toBe(
'OPTION_1',
);
const { data: updateOptionValueData } = await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: createdFieldMetadataId,
updatePayload: {
defaultValue: "'OPTION_1_MODIFIED'",
options: [
{
id: firstOptionId,
label: 'Option 1',
value: 'OPTION_1_MODIFIED',
color: 'green',
position: 0,
},
{
id: secondOptionId,
label: 'Option 2',
value: 'OPTION_2',
color: 'blue',
position: 1,
},
{
id: thirdOptionId,
label: 'Option 3',
value: 'OPTION_3',
color: 'red',
position: 2,
},
],
},
},
gqlFields: `
id
options
defaultValue
`,
});
expect(updateOptionValueData.updateOneField.defaultValue).toBe(
"'OPTION_1_MODIFIED'",
);
const { data: queryFirstRecordAfterOptionValueUpdate } =
await findOneOperation({
expectToFail: false,
objectMetadataSingularName: TEST_OBJECT_NAME_SINGULAR,
gqlFields: `
id
statusField
`,
filter: { id: { eq: firstRecordId } },
});
expect(
queryFirstRecordAfterOptionValueUpdate.findResponse.statusField,
).toBe('OPTION_1_MODIFIED');
const { data: updateDefaultOptionData } = await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: createdFieldMetadataId,
updatePayload: {
defaultValue: "'OPTION_2'",
},
},
gqlFields: `
id
options
defaultValue
`,
});
expect(updateDefaultOptionData.updateOneField.defaultValue).toBe(
"'OPTION_2'",
);
expect(updateDefaultOptionData.updateOneField.options).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny(
updateDefaultOptionData.updateOneField.options as any,
),
);
const { data: createSecondRecordData } = await createOneOperation({
expectToFail: false,
objectMetadataSingularName: TEST_OBJECT_NAME_SINGULAR,
input: {},
gqlFields: `
id
statusField
`,
});
expect(createSecondRecordData.createOneResponse.statusField).toBe(
'OPTION_2',
);
const { data: queryFirstRecordAfterDefaultChange } = await findOneOperation(
{
expectToFail: false,
objectMetadataSingularName: TEST_OBJECT_NAME_SINGULAR,
gqlFields: `
id
statusField
`,
filter: { id: { eq: firstRecordId } },
},
);
expect(queryFirstRecordAfterDefaultChange.findResponse.statusField).toBe(
'OPTION_1_MODIFIED',
);
});
});

View File

@@ -0,0 +1,213 @@
import { UPDATE_ENUM_FIELD_METADATA_TEST_CASES } from 'test/integration/metadata/suites/field-metadata/enum/common/update-enum-field-metadata-test-cases';
import { type CreateOneFieldFactoryInput } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata-query-factory.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 { CUSTOM_OBJECT_DISHES } from 'test/integration/metadata/suites/object-metadata/constants/custom-object-dishes.constants';
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 } from 'twenty-shared/testing';
import {
type FieldMetadataComplexOption,
type FieldMetadataDefaultOption,
FieldMetadataType,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { fieldMetadataEnumTypes } from 'src/engine/metadata-modules/field-metadata/utils/is-enum-field-metadata-type.util';
describe.each(fieldMetadataEnumTypes)(
'Successful update field metadata %s tests suite v2',
(testedFieldMetadataType) => {
let createdObjectMetadataId: string;
let createdFieldMetadataId: string;
const testCases =
UPDATE_ENUM_FIELD_METADATA_TEST_CASES[testedFieldMetadataType];
if (!isDefined(testCases)) {
return;
}
const { successful: successfulTestCases } = testCases;
const initialOptions: CreateOneFieldFactoryInput['options'] = [
{
label: 'Option 1',
value: 'OPTION_1',
color: 'green',
position: 1,
},
{
label: 'Option 2',
value: 'OPTION_2',
color: 'green',
position: 2,
},
];
beforeAll(async () => {
const {
labelPlural,
description,
labelSingular,
namePlural,
nameSingular,
} = CUSTOM_OBJECT_DISHES;
const { data } = await createOneObjectMetadata({
input: {
labelPlural,
description,
labelSingular,
namePlural,
nameSingular,
icon: 'IconBuildingSkyscraper',
isLabelSyncedWithName: false,
},
});
createdObjectMetadataId = data.createOneObject.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
});
beforeEach(async () => {
const {
data: { createOneField },
} = await createOneFieldMetadata({
expectToFail: false,
input: {
objectMetadataId: createdObjectMetadataId,
type: testedFieldMetadataType,
name: 'testField',
label: 'Test Field',
isLabelSyncedWithName: false,
options: initialOptions,
},
gqlFields: `
id
type
`,
});
createdFieldMetadataId = createOneField.id;
});
afterEach(async () => {
await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: createdFieldMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneFieldMetadata({
expectToFail: false,
input: { idToDelete: createdFieldMetadataId },
});
});
it('Should update default value to null even if it was set before', async () => {
const isMultiSelect =
testedFieldMetadataType === FieldMetadataType.MULTI_SELECT;
const rawDefaultValue = `'${initialOptions[0].value}'`;
const expectedDefaultValue = isMultiSelect
? [rawDefaultValue]
: rawDefaultValue;
const { data: firstUpdate } = await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: createdFieldMetadataId,
updatePayload: {
defaultValue: expectedDefaultValue,
},
},
gqlFields: `
id
defaultValue
`,
});
expect(firstUpdate.updateOneField.defaultValue).toEqual(
expectedDefaultValue,
);
const updatedOptions = initialOptions.slice(1);
const { data: secondUpdate, errors } = await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: createdFieldMetadataId,
updatePayload: {
defaultValue: null,
options: updatedOptions,
},
},
gqlFields: `
id
options
defaultValue
`,
});
expect(errors).toBeUndefined();
expect(secondUpdate.updateOneField.defaultValue).toBeNull();
expect(secondUpdate.updateOneField.options).toMatchObject(updatedOptions);
});
test.each(eachTestingContextFilter(successfulTestCases))(
'Update $title',
async ({ context: { input, expectedOptions } }) => {
const { ...updatePayload } = input;
const { data, errors } = await updateOneFieldMetadata({
input: {
idToUpdate: createdFieldMetadataId,
updatePayload,
},
gqlFields: `
id
options
defaultValue
`,
});
expect(data.updateOneField).toBeDefined();
const updatedOptions:
| FieldMetadataComplexOption[]
| FieldMetadataDefaultOption[]
| null = data.updateOneField.options;
expect(updatedOptions).toBeDefined();
if (!isDefined(updatedOptions))
throw new Error(
'Should never occur, type invariant post test assertion',
);
expect(errors).toBeUndefined();
updatedOptions.forEach((option) => expect(option.id).toBeDefined());
const optionsToCompare = expectedOptions ?? input.options ?? [];
expect(updatedOptions.length).toBe(optionsToCompare.length);
expect(updatedOptions).toMatchObject(optionsToCompare);
if (isDefined(input.defaultValue)) {
expect(data.updateOneField.defaultValue).toEqual(input.defaultValue);
}
},
);
},
);

View File

@@ -0,0 +1,7 @@
import { type UpdateCreateFieldMetadataSelectTestCase } from 'test/integration/metadata/suites/field-metadata/enum/types/update-create-field-metadata-enum-test-case';
import { type SuccessfulAndFailingTestCases } from 'twenty-shared/testing';
export type FieldMetadataEnumSuccessfulAndFailingTestCases =
SuccessfulAndFailingTestCases<
UpdateCreateFieldMetadataSelectTestCase['context']
>;

View File

@@ -0,0 +1,9 @@
import { type CreateOneFieldFactoryInput } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata-query-factory.util';
import { type EachTestingContext } from 'twenty-shared/testing';
import { type FieldMetadataComplexOption } from 'twenty-shared/types';
export type UpdateCreateFieldMetadataSelectTestCase = EachTestingContext<{
input: Partial<Pick<CreateOneFieldFactoryInput, 'defaultValue'>> &
Required<Pick<CreateOneFieldFactoryInput, 'options'>>;
expectedOptions?: FieldMetadataComplexOption[];
}>;

View File

@@ -0,0 +1,89 @@
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 { FieldMetadataType } from 'twenty-shared/types';
describe('Failing create field metadata tests suite', () => {
let createdObjectMetadataId: string;
beforeAll(async () => {
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'testFailingObject',
namePlural: 'testFailingObjects',
labelSingular: 'Test Failing Object',
labelPlural: 'Test Failing Objects',
icon: 'IconTestPipe',
isLabelSyncedWithName: false,
},
});
createdObjectMetadataId = data.createOneObject.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
});
it.each([
{
type: FieldMetadataType.NUMERIC,
name: 'numericField',
expectedMessage: 'NUMERIC',
},
{
type: FieldMetadataType.POSITION,
name: 'positionField',
expectedMessage: 'POSITION',
},
{
type: FieldMetadataType.TS_VECTOR,
name: 'tsVectorField',
expectedMessage: 'TS Vector',
},
])(
'should fail to create $type field type via API',
async ({ type, name, expectedMessage }) => {
const { data, errors } = await createOneFieldMetadata({
expectToFail: true,
input: {
objectMetadataId: createdObjectMetadataId,
type,
name,
label: name,
isLabelSyncedWithName: false,
},
gqlFields: `
id
type
name
label
`,
});
expect(data).toBeNull();
expect(errors).toBeDefined();
expect(errors).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny(errors),
);
expect(errors[0].extensions.code).toBe('METADATA_VALIDATION_FAILED');
const errorDetails = JSON.stringify(errors[0].extensions.errors);
expect(errorDetails).toContain(expectedMessage);
},
);
});

View File

@@ -0,0 +1,160 @@
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { updateOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata.util';
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test';
import {
eachTestingContextFilter,
type EachTestingContext,
} from 'twenty-shared/testing';
import { type UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
type UpdateOneStandardFieldMetadataTestingContext = EachTestingContext<
Partial<UpdateFieldInput>
>[];
const failingUpdateTestsUseCase: UpdateOneStandardFieldMetadataTestingContext =
[
{
title: 'when trying to update name on standard field',
context: {
name: 'newName',
},
},
{
title: 'when trying to update isLabelSyncedWithName on standard field',
context: {
isLabelSyncedWithName: false,
},
},
{
title: 'when trying to update several forbidden properties',
context: {
name: 'newName',
isLabelSyncedWithName: true,
},
},
];
const allTestsUseCases = [...failingUpdateTestsUseCase];
describe('Standard field metadata update should be ignored', () => {
let companyNameFieldMetadataId: string;
beforeAll(async () => {
const { objects } = await findManyObjectMetadata({
expectToFail: false,
input: {
filter: {},
paging: { first: 100 },
},
gqlFields: `
id
nameSingular
fieldsList {
id
name
label
description
icon
isActive
isLabelSyncedWithName
}
`,
});
const companyObject = objects.find((o) => o.nameSingular === 'company');
jestExpectToBeDefined(companyObject);
const companyNameField = companyObject.fieldsList?.find(
(field) => field.name === 'name',
);
jestExpectToBeDefined(companyNameField);
companyNameFieldMetadataId = companyNameField.id;
});
it.each(eachTestingContextFilter(allTestsUseCases))(
'$title',
async ({ context }) => {
const { errors } = await updateOneFieldMetadata({
input: {
idToUpdate: companyNameFieldMetadataId,
updatePayload: context,
},
expectToFail: true,
gqlFields: `
id
name
label
description
icon
isActive
isLabelSyncedWithName
`,
});
expectOneNotInternalServerErrorSnapshot({
errors,
});
},
);
});
// TODO: Enable this test once isUnique set as editable on standard fields
xdescribe('Standard field with standard unique index update should fail on isUnique change', () => {
let companyDomainNameFieldMetadataId: string;
beforeAll(async () => {
const { objects } = await findManyObjectMetadata({
expectToFail: false,
input: {
filter: {},
paging: { first: 100 },
},
gqlFields: `
id
nameSingular
fieldsList {
id
name
label
isUnique
}
`,
});
const companyObject = objects.find((o) => o.nameSingular === 'company');
jestExpectToBeDefined(companyObject);
const companyDomainNameField = companyObject.fieldsList?.find(
(field) => field.name === 'domainName',
);
jestExpectToBeDefined(companyDomainNameField);
companyDomainNameFieldMetadataId = companyDomainNameField.id;
});
it('should fail when trying to remove unique constraint on standard field with standard index', async () => {
const { errors } = await updateOneFieldMetadata({
input: {
idToUpdate: companyDomainNameFieldMetadataId,
updatePayload: {
isUnique: false,
},
},
gqlFields: `
id
name
isUnique
`,
});
expectOneNotInternalServerErrorSnapshot({
errors,
});
});
});

View File

@@ -0,0 +1,216 @@
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 { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { createOneView } from 'test/integration/metadata/suites/view/utils/create-one-view.util';
import { findOneView } from 'test/integration/metadata/suites/view/utils/find-one-view.util';
import { generateRecordName } from 'test/integration/utils/generate-record-name';
import { FieldMetadataType, ViewType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
const VIEW_FIELDS = `
id
name
objectMetadataId
type
icon
position
`;
type TestSetup = {
objectMetadataId: string;
groupByFieldMetadataId: string;
nonGroupByFieldMetadataId: string;
viewWithGroupId: string;
viewWithoutGroupId: string;
};
describe('field-for-group-by-deactivation-deletes-views', () => {
let testSetup: TestSetup;
const verifyViewExists = async (viewId: string, shouldExist: boolean) => {
const {
data: { getView },
} = await findOneView({
viewId,
gqlFields: VIEW_FIELDS,
expectToFail: false,
});
if (shouldExist) {
expect(isDefined(getView)).toBe(true);
} else {
expect(getView).toBeNull();
}
return getView;
};
const deactivateFieldAndVerify = async (fieldId: string) => {
const { data, errors } = await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: fieldId,
updatePayload: { isActive: false },
},
gqlFields: `
id
isActive
`,
});
expect(errors).toBeUndefined();
expect(data.updateOneField.id).toBe(fieldId);
expect(data.updateOneField.isActive).toBe(false);
};
beforeEach(async () => {
const {
data: {
createOneObject: { id: objectMetadataId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'viewGroupDeletionTestObject',
namePlural: 'viewGroupDeletionTestObjects',
labelSingular: 'View Group Deletion Test Object',
labelPlural: 'View Group Deletion Test Objects',
icon: 'IconTestTube',
},
});
const {
data: {
createOneField: { id: groupByFieldMetadataId },
},
} = await createOneFieldMetadata({
expectToFail: false,
input: {
name: 'groupByField',
type: FieldMetadataType.SELECT,
label: 'Group By Field',
objectMetadataId,
options: [
{ label: 'Option 1', value: 'OPTION_1', color: 'blue', position: 0 },
{ label: 'Option 2', value: 'OPTION_2', color: 'red', position: 1 },
{ label: 'Option 3', value: 'OPTION_3', color: 'green', position: 2 },
],
},
gqlFields: 'id',
});
const {
data: {
createOneField: { id: nonGroupByFieldMetadataId },
},
} = await createOneFieldMetadata({
expectToFail: false,
input: {
name: 'normalField',
type: FieldMetadataType.TEXT,
label: 'Normal Field',
objectMetadataId,
},
gqlFields: 'id',
});
const {
data: { createView: viewWithGroup },
} = await createOneView({
input: {
name: generateRecordName('View With Group'),
objectMetadataId,
type: ViewType.TABLE,
mainGroupByFieldMetadataId: groupByFieldMetadataId,
icon: 'IconTable',
},
gqlFields: VIEW_FIELDS,
expectToFail: false,
});
const {
data: { createView: viewWithoutGroup },
} = await createOneView({
input: {
name: generateRecordName('View Without Group'),
objectMetadataId,
type: ViewType.TABLE,
icon: 'IconTable',
},
gqlFields: VIEW_FIELDS,
expectToFail: false,
});
testSetup = {
objectMetadataId,
groupByFieldMetadataId,
nonGroupByFieldMetadataId,
viewWithGroupId: viewWithGroup.id,
viewWithoutGroupId: viewWithoutGroup.id,
};
});
afterEach(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: testSetup.objectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: testSetup.objectMetadataId },
});
});
it('should delete view when mainGroupByFieldMetadata is deactivated', async () => {
await verifyViewExists(testSetup.viewWithGroupId, true);
await verifyViewExists(testSetup.viewWithoutGroupId, true);
await deactivateFieldAndVerify(testSetup.groupByFieldMetadataId);
await verifyViewExists(testSetup.viewWithGroupId, false);
await verifyViewExists(testSetup.viewWithoutGroupId, true);
});
it('should not delete view when field not used in view group is deactivated', async () => {
await verifyViewExists(testSetup.viewWithGroupId, true);
await verifyViewExists(testSetup.viewWithoutGroupId, true);
await deactivateFieldAndVerify(testSetup.nonGroupByFieldMetadataId);
await verifyViewExists(testSetup.viewWithGroupId, true);
await verifyViewExists(testSetup.viewWithoutGroupId, true);
});
it('should delete multiple views when they all use the same field in view groups', async () => {
const {
data: { createView: secondViewWithGroup },
} = await createOneView({
input: {
name: generateRecordName('Second View With Group'),
objectMetadataId: testSetup.objectMetadataId,
type: ViewType.TABLE,
mainGroupByFieldMetadataId: testSetup.groupByFieldMetadataId,
icon: 'IconTable',
},
gqlFields: VIEW_FIELDS,
expectToFail: false,
});
await verifyViewExists(testSetup.viewWithGroupId, true);
await verifyViewExists(secondViewWithGroup.id, true);
await verifyViewExists(testSetup.viewWithoutGroupId, true);
await deactivateFieldAndVerify(testSetup.groupByFieldMetadataId);
await verifyViewExists(testSetup.viewWithGroupId, false);
await verifyViewExists(secondViewWithGroup.id, false);
await verifyViewExists(testSetup.viewWithoutGroupId, true);
});
});

View File

@@ -0,0 +1,428 @@
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { createOneSelectFieldMetadataForIntegrationTests } from 'test/integration/metadata/suites/field-metadata/utils/create-one-select-field-metadata-for-integration-tests.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 { createOneView } from 'test/integration/metadata/suites/view/utils/create-one-view.util';
import { findOneView } from 'test/integration/metadata/suites/view/utils/find-one-view.util';
import { generateRecordName } from 'test/integration/utils/generate-record-name';
import {
AggregateOperations,
FieldMetadataType,
ViewType,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
const VIEW_WITH_KANBAN_FIELDS = `
id
name
objectMetadataId
type
icon
position
isCompact
kanbanAggregateOperation
kanbanAggregateOperationFieldMetadataId
`;
type TestSetup = {
objectMetadataId: string;
aggregateFieldMetadataId: string;
nonAggregateFieldMetadataId: string;
selectFieldMetadataId: string;
viewWithAggregateId: string;
viewWithoutAggregateId: string;
};
describe('kanban-aggregate-field-deactivation-nullifies-kanban-properties', () => {
let testSetup: TestSetup;
const verifyKanbanPropertiesAreNull = async (viewId: string) => {
const {
data: { getView: view },
} = await findOneView({
viewId,
gqlFields: VIEW_WITH_KANBAN_FIELDS,
expectToFail: false,
});
expect(isDefined(view)).toBe(true);
expect(view.kanbanAggregateOperation).toBeNull();
expect(view.kanbanAggregateOperationFieldMetadataId).toBeNull();
return view;
};
const deactivateFieldAndVerify = async (fieldId: string) => {
const { data, errors } = await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: fieldId,
updatePayload: { isActive: false },
},
gqlFields: `
id
isActive
`,
});
expect(errors).toBeUndefined();
expect(data.updateOneField.id).toBe(fieldId);
expect(data.updateOneField.isActive).toBe(false);
};
beforeEach(async () => {
const {
data: {
createOneObject: { id: objectMetadataId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'viewDeletionTestObject',
namePlural: 'viewDeletionTestObjects',
labelSingular: 'View Deletion Test Object',
labelPlural: 'View Deletion Test Objects',
icon: 'IconTestTube',
},
});
const {
data: {
createOneField: { id: aggregateFieldMetadataId },
},
} = await createOneFieldMetadata({
expectToFail: false,
input: {
name: 'aggregateField',
type: FieldMetadataType.NUMBER,
label: 'Aggregate Field',
objectMetadataId,
},
gqlFields: 'id',
});
const {
data: {
createOneField: { id: nonAggregateFieldMetadataId },
},
} = await createOneFieldMetadata({
expectToFail: false,
input: {
name: 'normalField',
type: FieldMetadataType.TEXT,
label: 'Normal Field',
objectMetadataId,
},
gqlFields: 'id',
});
const { selectFieldMetadataId } =
await createOneSelectFieldMetadataForIntegrationTests({
input: {
name: 'selectField',
objectMetadataId,
options: [
{
label: 'Option 1',
value: 'OPTION_1',
color: 'blue',
position: 0,
},
{ label: 'Option 2', value: 'OPTION_2', color: 'red', position: 1 },
{
label: 'Option 3',
value: 'OPTION_3',
color: 'green',
position: 2,
},
],
},
});
const {
data: { createView: viewWithAggregate },
} = await createOneView({
input: {
name: generateRecordName('Kanban View With Aggregate'),
objectMetadataId,
type: ViewType.KANBAN,
mainGroupByFieldMetadataId: selectFieldMetadataId,
kanbanAggregateOperationFieldMetadataId: aggregateFieldMetadataId,
kanbanAggregateOperation: AggregateOperations.SUM,
icon: 'IconLayoutKanban',
},
gqlFields: VIEW_WITH_KANBAN_FIELDS,
expectToFail: false,
});
const {
data: { createView: viewWithoutAggregate },
} = await createOneView({
input: {
name: generateRecordName('Kanban View Without Aggregate'),
objectMetadataId,
mainGroupByFieldMetadataId: selectFieldMetadataId,
type: ViewType.KANBAN,
icon: 'IconLayoutKanban',
},
gqlFields: VIEW_WITH_KANBAN_FIELDS,
expectToFail: false,
});
testSetup = {
objectMetadataId,
aggregateFieldMetadataId,
nonAggregateFieldMetadataId,
selectFieldMetadataId,
viewWithAggregateId: viewWithAggregate.id,
viewWithoutAggregateId: viewWithoutAggregate.id,
};
});
afterEach(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: testSetup.objectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: testSetup.objectMetadataId },
});
});
it('should nullify kanban properties when field used as kanbanAggregateOperationFieldMetadataId is deactivated', async () => {
const {
data: { getView: initialViewWithAggregate },
} = await findOneView({
viewId: testSetup.viewWithAggregateId,
gqlFields: VIEW_WITH_KANBAN_FIELDS,
expectToFail: false,
});
const {
data: { getView: initialViewWithoutAggregate },
} = await findOneView({
viewId: testSetup.viewWithoutAggregateId,
gqlFields: VIEW_WITH_KANBAN_FIELDS,
expectToFail: false,
});
expect(
initialViewWithAggregate.kanbanAggregateOperationFieldMetadataId,
).toBe(testSetup.aggregateFieldMetadataId);
expect(initialViewWithAggregate.kanbanAggregateOperation).toBe('SUM');
await deactivateFieldAndVerify(testSetup.aggregateFieldMetadataId);
await verifyKanbanPropertiesAreNull(testSetup.viewWithAggregateId);
const {
data: { getView: updatedViewWithoutAggregate },
} = await findOneView({
viewId: testSetup.viewWithoutAggregateId,
gqlFields: VIEW_WITH_KANBAN_FIELDS,
expectToFail: false,
});
expect(updatedViewWithoutAggregate).toEqual(initialViewWithoutAggregate);
});
it('should not modify views when field not used as kanbanAggregateOperationFieldMetadataId is deactivated', async () => {
const {
data: { getView: initialViewWithAggregate },
} = await findOneView({
viewId: testSetup.viewWithAggregateId,
gqlFields: VIEW_WITH_KANBAN_FIELDS,
expectToFail: false,
});
const {
data: { getView: initialViewWithoutAggregate },
} = await findOneView({
viewId: testSetup.viewWithoutAggregateId,
gqlFields: VIEW_WITH_KANBAN_FIELDS,
expectToFail: false,
});
await deactivateFieldAndVerify(testSetup.nonAggregateFieldMetadataId);
const {
data: { getView: updatedViewWithAggregate },
} = await findOneView({
viewId: testSetup.viewWithAggregateId,
gqlFields: VIEW_WITH_KANBAN_FIELDS,
expectToFail: false,
});
const {
data: { getView: updatedViewWithoutAggregate },
} = await findOneView({
viewId: testSetup.viewWithoutAggregateId,
gqlFields: VIEW_WITH_KANBAN_FIELDS,
expectToFail: false,
});
expect(updatedViewWithAggregate).toEqual(initialViewWithAggregate);
expect(updatedViewWithoutAggregate).toEqual(initialViewWithoutAggregate);
});
it('should nullify kanban properties on multiple views when they all use the same field as kanbanAggregateOperationFieldMetadataId', async () => {
const {
data: { createView: secondViewWithAggregate },
} = await createOneView({
input: {
name: generateRecordName('Second Kanban View With Aggregate'),
objectMetadataId: testSetup.objectMetadataId,
type: ViewType.KANBAN,
mainGroupByFieldMetadataId: testSetup.selectFieldMetadataId,
kanbanAggregateOperationFieldMetadataId:
testSetup.aggregateFieldMetadataId,
kanbanAggregateOperation: AggregateOperations.MAX,
icon: 'IconLayoutKanban',
},
gqlFields: VIEW_WITH_KANBAN_FIELDS,
expectToFail: false,
});
const {
data: { getView: initialViewWithAggregate },
} = await findOneView({
viewId: testSetup.viewWithAggregateId,
gqlFields: VIEW_WITH_KANBAN_FIELDS,
expectToFail: false,
});
const {
data: { getView: initialSecondViewWithAggregate },
} = await findOneView({
viewId: secondViewWithAggregate.id,
gqlFields: VIEW_WITH_KANBAN_FIELDS,
expectToFail: false,
});
const {
data: { getView: initialViewWithoutAggregate },
} = await findOneView({
viewId: testSetup.viewWithoutAggregateId,
gqlFields: VIEW_WITH_KANBAN_FIELDS,
expectToFail: false,
});
expect(
initialViewWithAggregate.kanbanAggregateOperationFieldMetadataId,
).toBe(testSetup.aggregateFieldMetadataId);
expect(
initialSecondViewWithAggregate.kanbanAggregateOperationFieldMetadataId,
).toBe(testSetup.aggregateFieldMetadataId);
await deactivateFieldAndVerify(testSetup.aggregateFieldMetadataId);
await verifyKanbanPropertiesAreNull(testSetup.viewWithAggregateId);
await verifyKanbanPropertiesAreNull(secondViewWithAggregate.id);
const {
data: { getView: updatedViewWithoutAggregate },
} = await findOneView({
viewId: testSetup.viewWithoutAggregateId,
gqlFields: VIEW_WITH_KANBAN_FIELDS,
expectToFail: false,
});
expect(updatedViewWithoutAggregate).toEqual(initialViewWithoutAggregate);
});
it('should nullify kanban properties when views have different aggregate operations on same field', async () => {
const {
data: { createView: viewWithMin },
} = await createOneView({
input: {
name: generateRecordName('Kanban View With MIN'),
objectMetadataId: testSetup.objectMetadataId,
type: ViewType.KANBAN,
mainGroupByFieldMetadataId: testSetup.selectFieldMetadataId,
kanbanAggregateOperationFieldMetadataId:
testSetup.aggregateFieldMetadataId,
kanbanAggregateOperation: AggregateOperations.MIN,
icon: 'IconLayoutKanban',
},
gqlFields: VIEW_WITH_KANBAN_FIELDS,
expectToFail: false,
});
const {
data: { createView: viewWithAvg },
} = await createOneView({
input: {
name: generateRecordName('Kanban View With AVG'),
objectMetadataId: testSetup.objectMetadataId,
type: ViewType.KANBAN,
mainGroupByFieldMetadataId: testSetup.selectFieldMetadataId,
kanbanAggregateOperationFieldMetadataId:
testSetup.aggregateFieldMetadataId,
kanbanAggregateOperation: AggregateOperations.AVG,
icon: 'IconLayoutKanban',
},
gqlFields: VIEW_WITH_KANBAN_FIELDS,
expectToFail: false,
});
const {
data: { getView: initialViewWithAggregate },
} = await findOneView({
viewId: testSetup.viewWithAggregateId,
gqlFields: VIEW_WITH_KANBAN_FIELDS,
expectToFail: false,
});
const {
data: { getView: initialViewWithMin },
} = await findOneView({
viewId: viewWithMin.id,
gqlFields: VIEW_WITH_KANBAN_FIELDS,
expectToFail: false,
});
const {
data: { getView: initialViewWithAvg },
} = await findOneView({
viewId: viewWithAvg.id,
gqlFields: VIEW_WITH_KANBAN_FIELDS,
expectToFail: false,
});
const {
data: { getView: initialViewWithoutAggregate },
} = await findOneView({
viewId: testSetup.viewWithoutAggregateId,
gqlFields: VIEW_WITH_KANBAN_FIELDS,
expectToFail: false,
});
expect(initialViewWithAggregate.kanbanAggregateOperation).toBe('SUM');
expect(initialViewWithMin.kanbanAggregateOperation).toBe('MIN');
expect(initialViewWithAvg.kanbanAggregateOperation).toBe('AVG');
await deactivateFieldAndVerify(testSetup.aggregateFieldMetadataId);
await verifyKanbanPropertiesAreNull(testSetup.viewWithAggregateId);
await verifyKanbanPropertiesAreNull(viewWithMin.id);
await verifyKanbanPropertiesAreNull(viewWithAvg.id);
const {
data: { getView: updatedViewWithoutAggregate },
} = await findOneView({
viewId: testSetup.viewWithoutAggregateId,
gqlFields: VIEW_WITH_KANBAN_FIELDS,
expectToFail: false,
});
expect(updatedViewWithoutAggregate).toEqual(initialViewWithoutAggregate);
});
});

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,
});
});
});

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',
});
});
});

View File

@@ -0,0 +1,187 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Field metadata creation with isSearchable on a non-searchable object should reject creating a searchable field on a non-searchable object 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"fieldMetadata": [
{
"errors": [
{
"code": "FIELD_MUTATION_NOT_ALLOWED",
"message": "Object is not searchable",
"userFriendlyMessage": "Enable search on this object before adding searchable fields",
},
],
"flatEntityMinimalInformation": {
"name": "searchableFieldOnNonSearchableObject",
"objectMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "fieldMetadata",
"status": "fail",
"type": "create",
},
],
"searchFieldMetadata": [
{
"errors": [
{
"code": "FIELD_METADATA_NOT_FOUND",
"message": "Field metadata not found",
"userFriendlyMessage": "Field metadata not found",
},
],
"flatEntityMinimalInformation": {
"objectMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "searchFieldMetadata",
"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",
},
],
},
"message": "Validation failed for 1 fieldMetadata, 2 viewFields, 1 searchFieldMetadata",
"summary": {
"fieldMetadata": 1,
"searchFieldMetadata": 1,
"totalErrors": 4,
"viewField": 2,
},
"userFriendlyMessage": "Many validation errors",
},
"message": "Multiple validation errors occurred while creating fields",
"name": "GraphQLError",
}
`;
exports[`Field metadata creation with isSearchable should reject creating a searchable field of a non-searchable type 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"fieldMetadata": [
{
"errors": [
{
"code": "INVALID_FIELD_INPUT",
"message": "Field type NUMBER is not supported for search",
"userFriendlyMessage": "This field type cannot be searched",
},
],
"flatEntityMinimalInformation": {
"name": "searchableNumberField",
"objectMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "fieldMetadata",
"status": "fail",
"type": "create",
},
],
"searchFieldMetadata": [
{
"errors": [
{
"code": "FIELD_METADATA_NOT_FOUND",
"message": "Field metadata not found",
"userFriendlyMessage": "Field metadata not found",
},
],
"flatEntityMinimalInformation": {
"objectMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "searchFieldMetadata",
"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",
},
],
},
"message": "Validation failed for 1 fieldMetadata, 2 viewFields, 1 searchFieldMetadata",
"summary": {
"fieldMetadata": 1,
"searchFieldMetadata": 1,
"totalErrors": 4,
"viewField": 2,
},
"userFriendlyMessage": "Many validation errors",
},
"message": "Multiple validation errors occurred while creating fields",
"name": "GraphQLError",
}
`;

View File

@@ -0,0 +1,112 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Field metadata isSearchable toggling should reject an explicit isSearchable null on the label identifier 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"fieldMetadata": [
{
"errors": [
{
"code": "FIELD_MUTATION_NOT_ALLOWED",
"message": "Label identifier field cannot be removed from search",
"userFriendlyMessage": "Label identifier field cannot be removed from search",
},
],
"flatEntityMinimalInformation": {
"name": "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": "Label identifier field cannot be removed from search",
},
"message": "Multiple validation errors occurred while updating field",
"name": "GraphQLError",
}
`;
exports[`Field metadata isSearchable toggling should reject removing the label identifier from search 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"fieldMetadata": [
{
"errors": [
{
"code": "FIELD_MUTATION_NOT_ALLOWED",
"message": "Label identifier field cannot be removed from search",
"userFriendlyMessage": "Label identifier field cannot be removed from search",
},
],
"flatEntityMinimalInformation": {
"name": "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": "Label identifier field cannot be removed from search",
},
"message": "Multiple validation errors occurred while updating field",
"name": "GraphQLError",
}
`;
exports[`Field metadata isSearchable toggling should reject turning on isSearchable for a non-searchable type 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"fieldMetadata": [
{
"errors": [
{
"code": "INVALID_FIELD_INPUT",
"message": "Field type NUMBER is not supported for search",
"userFriendlyMessage": "This field type cannot be searched",
},
],
"flatEntityMinimalInformation": {
"name": "toggledNumberField",
"objectMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "fieldMetadata",
"status": "fail",
"type": "update",
},
],
},
"message": "Validation failed for 1 fieldMetadata",
"summary": {
"fieldMetadata": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "This field type cannot be searched",
},
"message": "Multiple validation errors occurred while updating field",
"name": "GraphQLError",
}
`;

View File

@@ -0,0 +1,207 @@
import { createManyOperation } from 'test/integration/graphql/utils/create-many-operation.util';
import { search } from 'test/integration/graphql/utils/search.util';
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 { 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 { findOneObjectMetadataWithSearchFieldMetadataList } from 'test/integration/metadata/suites/object-metadata/utils/find-one-object-metadata-with-search-field-metadata-list.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { FieldMetadataType } from 'twenty-shared/types';
describe('Field metadata creation with isSearchable', () => {
const OBJECT_NAME_SINGULAR = 'searchableCreateObject';
const OBJECT_NAME_PLURAL = 'searchableCreateObjects';
const SEARCHABLE_FIELD_NAME = 'searchableOnCreateField';
const RECORD_SEARCHABLE_VALUE = 'SearchableOnCreateToken11';
let testObjectMetadataId: string;
beforeAll(async () => {
const {
data: {
createOneObject: { id: objectMetadataId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: OBJECT_NAME_SINGULAR,
namePlural: OBJECT_NAME_PLURAL,
labelSingular: 'Searchable Create Object',
labelPlural: 'Searchable Create Objects',
icon: 'IconSearch',
isLabelSyncedWithName: false,
},
});
testObjectMetadataId = objectMetadataId;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: testObjectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: testObjectMetadataId },
});
});
it('should create a searchable field whose values reach global search', async () => {
const { searchFieldMetadataList: rowsBeforeCreate } =
await findOneObjectMetadataWithSearchFieldMetadataList({
objectMetadataId: testObjectMetadataId,
});
const maxPositionBeforeCreate = Math.max(
...rowsBeforeCreate.map(
(searchFieldMetadata) => searchFieldMetadata.position,
),
);
const {
data: { createOneField },
} = await createOneFieldMetadata({
expectToFail: false,
input: {
name: SEARCHABLE_FIELD_NAME,
label: 'Searchable On Create Field',
type: FieldMetadataType.TEXT,
objectMetadataId: testObjectMetadataId,
isLabelSyncedWithName: false,
isSearchable: true,
},
gqlFields: `id name isSearchable`,
});
expect(createOneField.isSearchable).toBe(true);
const { searchFieldMetadataList } =
await findOneObjectMetadataWithSearchFieldMetadataList({
objectMetadataId: testObjectMetadataId,
});
const createdRow = searchFieldMetadataList.find(
(searchFieldMetadata) =>
searchFieldMetadata.fieldMetadataId === createOneField.id,
);
expect(createdRow).toBeDefined();
expect(createdRow?.position).toBe(maxPositionBeforeCreate + 1);
const { fields } = await findManyFieldsMetadata({
expectToFail: false,
input: {
filter: { id: { eq: createOneField.id } },
paging: { first: 1 },
},
gqlFields: `id isSearchable`,
});
expect(fields[0].node.isSearchable).toBe(true);
await createManyOperation({
objectMetadataSingularName: OBJECT_NAME_SINGULAR,
objectMetadataPluralName: OBJECT_NAME_PLURAL,
gqlFields: `id name ${SEARCHABLE_FIELD_NAME}`,
data: [
{
name: 'searchable create record',
[SEARCHABLE_FIELD_NAME]: RECORD_SEARCHABLE_VALUE,
},
],
expectToFail: false,
});
const searchBySearchableField = await search({
searchInput: RECORD_SEARCHABLE_VALUE,
includedObjectNameSingulars: [OBJECT_NAME_SINGULAR],
limit: 10,
expectToFail: false,
});
expect(searchBySearchableField.data.search.edges.length).toBe(1);
});
it('should reject creating a searchable field of a non-searchable type', async () => {
const { errors } = await createOneFieldMetadata({
expectToFail: true,
input: {
name: 'searchableNumberField',
label: 'Searchable Number Field',
type: FieldMetadataType.NUMBER,
objectMetadataId: testObjectMetadataId,
isLabelSyncedWithName: false,
isSearchable: true,
},
});
expectOneNotInternalServerErrorSnapshot({ errors });
});
});
describe('Field metadata creation with isSearchable on a non-searchable object', () => {
let nonSearchableObjectMetadataId: string;
beforeAll(async () => {
const {
data: {
createOneObject: { id: objectMetadataId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'nonSearchableCreateObject',
namePlural: 'nonSearchableCreateObjects',
labelSingular: 'Non Searchable Create Object',
labelPlural: 'Non Searchable Create Objects',
icon: 'IconSearchOff',
isLabelSyncedWithName: false,
},
});
nonSearchableObjectMetadataId = objectMetadataId;
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: nonSearchableObjectMetadataId,
updatePayload: { isSearchable: false },
},
});
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: nonSearchableObjectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: nonSearchableObjectMetadataId },
});
});
it('should reject creating a searchable field on a non-searchable object', async () => {
const { errors } = await createOneFieldMetadata({
expectToFail: true,
input: {
name: 'searchableFieldOnNonSearchableObject',
label: 'Searchable Field On Non Searchable Object',
type: FieldMetadataType.TEXT,
objectMetadataId: nonSearchableObjectMetadataId,
isLabelSyncedWithName: false,
isSearchable: true,
},
});
expectOneNotInternalServerErrorSnapshot({ errors });
});
});

View File

@@ -0,0 +1,275 @@
import { createManyOperation } from 'test/integration/graphql/utils/create-many-operation.util';
import { search } from 'test/integration/graphql/utils/search.util';
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 { findOneObjectMetadataWithSearchFieldMetadataList } from 'test/integration/metadata/suites/object-metadata/utils/find-one-object-metadata-with-search-field-metadata-list.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { FieldMetadataType } from 'twenty-shared/types';
import { type UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
describe('Field metadata isSearchable toggling', () => {
const OBJECT_NAME_SINGULAR = 'searchableToggleObject';
const OBJECT_NAME_PLURAL = 'searchableToggleObjects';
const TOGGLED_FIELD_NAME = 'toggledSearchableField';
const RECORD_TOGGLED_VALUE = 'SearchableToggleToken11';
let testObjectMetadataId: string;
let toggledFieldMetadataId: string;
let labelIdentifierFieldMetadataId: string;
let numberFieldMetadataId: string;
const findSearchFieldMetadataList = async (): Promise<
{ id: string; fieldMetadataId: string; position: number }[]
> => {
const objectMetadata =
await findOneObjectMetadataWithSearchFieldMetadataList({
objectMetadataId: testObjectMetadataId,
});
labelIdentifierFieldMetadataId =
objectMetadata.labelIdentifierFieldMetadataId ?? '';
return objectMetadata.searchFieldMetadataList;
};
beforeAll(async () => {
const {
data: {
createOneObject: { id: objectMetadataId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: OBJECT_NAME_SINGULAR,
namePlural: OBJECT_NAME_PLURAL,
labelSingular: 'Searchable Toggle Object',
labelPlural: 'Searchable Toggle Objects',
icon: 'IconSearch',
isLabelSyncedWithName: false,
},
});
testObjectMetadataId = objectMetadataId;
const {
data: {
createOneField: { id: toggledFieldId },
},
} = await createOneFieldMetadata({
expectToFail: false,
input: {
name: TOGGLED_FIELD_NAME,
label: 'Toggled Searchable Field',
type: FieldMetadataType.TEXT,
objectMetadataId: testObjectMetadataId,
isLabelSyncedWithName: false,
},
gqlFields: `id`,
});
toggledFieldMetadataId = toggledFieldId;
const {
data: {
createOneField: { id: numberFieldId },
},
} = await createOneFieldMetadata({
expectToFail: false,
input: {
name: 'toggledNumberField',
label: 'Toggled Number Field',
type: FieldMetadataType.NUMBER,
objectMetadataId: testObjectMetadataId,
isLabelSyncedWithName: false,
},
gqlFields: `id`,
});
numberFieldMetadataId = numberFieldId;
await createManyOperation({
objectMetadataSingularName: OBJECT_NAME_SINGULAR,
objectMetadataPluralName: OBJECT_NAME_PLURAL,
gqlFields: `id name ${TOGGLED_FIELD_NAME}`,
data: [
{
name: 'searchable toggle record',
[TOGGLED_FIELD_NAME]: RECORD_TOGGLED_VALUE,
},
],
expectToFail: false,
});
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: testObjectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: testObjectMetadataId },
});
});
it('should add the field to global search when isSearchable is turned on', async () => {
const searchBeforeToggle = await search({
searchInput: RECORD_TOGGLED_VALUE,
includedObjectNameSingulars: [OBJECT_NAME_SINGULAR],
limit: 10,
expectToFail: false,
});
expect(searchBeforeToggle.data.search.edges.length).toBe(0);
const rowsBeforeToggle = await findSearchFieldMetadataList();
const maxPositionBeforeToggle = Math.max(
...rowsBeforeToggle.map(
(searchFieldMetadata) => searchFieldMetadata.position,
),
);
const {
data: { updateOneField },
} = await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: toggledFieldMetadataId,
updatePayload: { isSearchable: true },
},
gqlFields: `id isSearchable`,
});
expect(updateOneField.isSearchable).toBe(true);
const searchFieldMetadataList = await findSearchFieldMetadataList();
const createdRow = searchFieldMetadataList.find(
(searchFieldMetadata) =>
searchFieldMetadata.fieldMetadataId === toggledFieldMetadataId,
);
expect(createdRow).toBeDefined();
expect(createdRow?.position).toBe(maxPositionBeforeToggle + 1);
const searchAfterToggle = await search({
searchInput: RECORD_TOGGLED_VALUE,
includedObjectNameSingulars: [OBJECT_NAME_SINGULAR],
limit: 10,
expectToFail: false,
});
expect(searchAfterToggle.data.search.edges.length).toBe(1);
});
it('should keep the row untouched when isSearchable stays true', async () => {
const rowsBeforeNoop = await findSearchFieldMetadataList();
const rowBeforeNoop = rowsBeforeNoop.find(
(searchFieldMetadata) =>
searchFieldMetadata.fieldMetadataId === toggledFieldMetadataId,
);
await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: toggledFieldMetadataId,
updatePayload: { isSearchable: true },
},
gqlFields: `id isSearchable`,
});
const rowsAfterNoop = await findSearchFieldMetadataList();
const rowAfterNoop = rowsAfterNoop.find(
(searchFieldMetadata) =>
searchFieldMetadata.fieldMetadataId === toggledFieldMetadataId,
);
expect(rowAfterNoop).toEqual(rowBeforeNoop);
});
it('should remove the field from global search when isSearchable is turned off', async () => {
const {
data: { updateOneField },
} = await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: toggledFieldMetadataId,
updatePayload: { isSearchable: false },
},
gqlFields: `id isSearchable`,
});
expect(updateOneField.isSearchable).toBe(false);
const searchFieldMetadataList = await findSearchFieldMetadataList();
expect(
searchFieldMetadataList.some(
(searchFieldMetadata) =>
searchFieldMetadata.fieldMetadataId === toggledFieldMetadataId,
),
).toBe(false);
const searchAfterToggleOff = await search({
searchInput: RECORD_TOGGLED_VALUE,
includedObjectNameSingulars: [OBJECT_NAME_SINGULAR],
limit: 10,
expectToFail: false,
});
expect(searchAfterToggleOff.data.search.edges.length).toBe(0);
});
it('should reject removing the label identifier from search', async () => {
await findSearchFieldMetadataList();
const { errors } = await updateOneFieldMetadata({
expectToFail: true,
input: {
idToUpdate: labelIdentifierFieldMetadataId,
updatePayload: { isSearchable: false },
},
gqlFields: `id`,
});
expectOneNotInternalServerErrorSnapshot({ errors });
});
it('should reject an explicit isSearchable null on the label identifier', async () => {
await findSearchFieldMetadataList();
const nullIsSearchablePayload = {
isSearchable: null,
} as unknown as Omit<UpdateFieldInput, 'workspaceId' | 'id'>;
const { errors } = await updateOneFieldMetadata({
expectToFail: true,
input: {
idToUpdate: labelIdentifierFieldMetadataId,
updatePayload: nullIsSearchablePayload,
},
gqlFields: `id`,
});
expectOneNotInternalServerErrorSnapshot({ errors });
});
it('should reject turning on isSearchable for a non-searchable type', async () => {
const { errors } = await updateOneFieldMetadata({
expectToFail: true,
input: {
idToUpdate: numberFieldMetadataId,
updatePayload: { isSearchable: true },
},
gqlFields: `id`,
});
expectOneNotInternalServerErrorSnapshot({ errors });
});
});

View File

@@ -0,0 +1,352 @@
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 { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-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 { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test';
import {
eachTestingContextFilter,
type EachTestingContext,
} from 'twenty-shared/testing';
import { isDefined } from 'twenty-shared/utils';
import { type FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
import { type UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
type UpdateOneStandardFieldMetadataTestingContext = EachTestingContext<
Partial<UpdateFieldInput>
>[];
const successfulUpdateTestsUseCase: UpdateOneStandardFieldMetadataTestingContext =
[
{
title: 'when updating description',
context: {
description: 'Updated test description for company name field',
},
},
{
title: 'when updating icon',
context: {
icon: 'IconBuildingFactory',
},
},
{
title: 'when setting isActive to false',
context: {
isActive: false,
},
},
{
title: 'when updating label',
context: {
label: 'Business Name',
},
},
{
title: 'when updating options',
context: {
options: [
{ value: 'NEW', label: 'New Lead', position: 0, color: 'red' },
{
value: 'SCREENING',
label: 'Under Review',
position: 1,
color: 'purple',
},
{ value: 'MEETING', label: 'Meeting', position: 2, color: 'sky' },
{
value: 'PROPOSAL',
label: 'Proposal Sent',
position: 3,
color: 'turquoise',
},
{
value: 'CUSTOMER',
label: 'Customer',
position: 4,
color: 'yellow',
},
{ value: 'CLOSED', label: 'Closed', position: 5, color: 'green' },
],
},
},
{
title: 'when updating defaultValue',
context: {
defaultValue: "'SCREENING'",
},
},
];
describe('Standard field metadata update should succeed', () => {
const opportunityObjectFields: FieldMetadataDTO[] = [];
let originalFieldMetadataToRestore: FieldMetadataDTO[] = [];
let originalStageFieldMetadata: FieldMetadataDTO | undefined;
beforeAll(async () => {
const { objects } = await findManyObjectMetadata({
expectToFail: false,
input: {
filter: {},
paging: { first: 100 },
},
gqlFields: `
id
nameSingular
fieldsList {
id
name
label
description
icon
isActive
type
options
settings
defaultValue
}
`,
});
const opportunityObject = objects.find(
(o) => o.nameSingular === 'opportunity',
);
jestExpectToBeDefined(opportunityObject?.fieldsList);
opportunityObjectFields.push(...opportunityObject.fieldsList);
originalStageFieldMetadata = opportunityObjectFields.find(
(field) => field.name === 'stage',
);
});
afterEach(async () => {
for (const originalFieldMetadata of originalFieldMetadataToRestore) {
await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: originalFieldMetadata.id,
updatePayload: {
label: originalFieldMetadata.label,
description: originalFieldMetadata.description,
icon: originalFieldMetadata.icon,
isActive: originalFieldMetadata.isActive,
options: originalFieldMetadata.options,
defaultValue: originalFieldMetadata.defaultValue,
},
},
});
}
originalFieldMetadataToRestore = [];
});
describe('Atomic update test suite', () => {
it.each(eachTestingContextFilter(successfulUpdateTestsUseCase))(
'$title',
async ({ context }) => {
jestExpectToBeDefined(originalStageFieldMetadata);
originalFieldMetadataToRestore.push(originalStageFieldMetadata);
const updatePayload = context;
const { data } = await updateOneFieldMetadata({
input: {
idToUpdate: (originalStageFieldMetadata as FieldMetadataDTO).id,
updatePayload,
},
expectToFail: false,
gqlFields: `
id
name
label
description
icon
isActive
options
defaultValue
`,
});
expect(data.updateOneField.id).toBe(originalStageFieldMetadata.id);
expect(data.updateOneField).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny({ ...data.updateOneField }),
);
},
);
});
it('Should deactivate and reactivate standard field successfully', async () => {
const deletedAtField = opportunityObjectFields.find(
(field) => field.name === 'deletedAt',
);
jestExpectToBeDefined(deletedAtField);
expect(deletedAtField.isActive).toBe(true);
const { data: firstUpdateData } = await updateOneFieldMetadata({
input: {
idToUpdate: deletedAtField.id,
updatePayload: {
isActive: false,
},
},
expectToFail: false,
gqlFields: `
id
name
label
description
icon
isActive
options
defaultValue
`,
});
expect(firstUpdateData.updateOneField.isActive).toBe(false);
const { data: secondUpdateData } = await updateOneFieldMetadata({
input: {
idToUpdate: deletedAtField.id,
updatePayload: {
isActive: true,
},
},
expectToFail: false,
gqlFields: `
id
name
label
description
icon
isActive
options
defaultValue
`,
});
expect(secondUpdateData.updateOneField.isActive).toBe(true);
});
});
describe('Standard field isUnique update should succeed', () => {
let nameFieldMetadata: FieldMetadataDTO | undefined;
let customObjectId: string;
beforeEach(async () => {
const customObject = {
labelSingular: `Test Unique Standard Field`,
labelPlural: `Test Unique Standard Fields`,
namePlural: `testUniqueStandardField`,
nameSingular: `testUniqueStandardFields`,
description: 'Test unique standard field for isUnique update',
icon: 'IconBox',
isLabelSyncedWithName: false,
};
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: customObject,
gqlFields: `
id
nameSingular
`,
});
customObjectId = data.createOneObject.id;
const { objects } = await findManyObjectMetadata({
expectToFail: false,
input: {
filter: {},
paging: { first: 100 },
},
gqlFields: `
id
nameSingular
fieldsList {
id
name
label
isUnique
isActive
type
}
`,
});
const customObjectWithFields = objects.find((o) => o.id === customObjectId);
jestExpectToBeDefined(customObjectWithFields?.fieldsList);
nameFieldMetadata = customObjectWithFields.fieldsList?.find(
(field) => field.name === 'name',
);
});
afterEach(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: customObjectId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: {
idToDelete: customObjectId,
},
});
});
it('should set isUnique to true on standard field', async () => {
jestExpectToBeDefined(nameFieldMetadata);
const { data } = await updateOneFieldMetadata({
input: {
idToUpdate: nameFieldMetadata.id,
updatePayload: {
isUnique: true,
},
},
expectToFail: false,
gqlFields: `
id
name
label
isUnique
isActive
`,
});
expect(data.updateOneField.id).toBe(nameFieldMetadata.id);
expect(data.updateOneField.name).toBe('name');
expect(data.updateOneField.isUnique).toBe(true);
const objects = await findManyObjectMetadataWithIndexes({
expectToFail: false,
});
const customObject = objects.find((obj) => obj.id === customObjectId);
jestExpectToBeDefined(customObject);
const nameFieldIndex = customObject.indexMetadataList.find((index) =>
index.indexFieldMetadataList.some(
(indexField) =>
isDefined(nameFieldMetadata) &&
indexField.fieldMetadataId === nameFieldMetadata.id,
),
);
jestExpectToBeDefined(nameFieldIndex);
expect(nameFieldIndex.isUnique).toBe(true);
expect(nameFieldIndex.isCustom).toBe(true);
});
});

View File

@@ -0,0 +1,73 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`failing createOne unique field metadata should fail for composite type without unique subfields (FULL_NAME) 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"index": [
{
"errors": [
{
"code": "INDEX_FIELD_INVALID_TYPE_FOR_UNIQUE",
"message": "Unique index cannot be created for field uniqueFullName of type FULL_NAME",
"userFriendlyMessage": "FULL_NAME fields cannot be unique.",
},
],
"flatEntityMinimalInformation": {
"name": "IDX_UNIQUE_98d410e804d758f9bcceb154dde",
"universalIdentifier": Any<String>,
},
"metadataName": "index",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 index",
"summary": {
"index": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "FULL_NAME fields cannot be unique.",
},
"message": "Multiple validation errors occurred while creating fields",
"name": "GraphQLError",
}
`;
exports[`failing createOne unique field metadata should fail when field has custom default value 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"index": [
{
"errors": [
{
"code": "INDEX_FIELD_INVALID_DEFAULT_VALUE",
"message": "Unique index cannot be created for field uniqueFieldWithDefault of type TEXT",
"userFriendlyMessage": "TEXT fields cannot have a default value.",
},
],
"flatEntityMinimalInformation": {
"name": "IDX_UNIQUE_a1b02809c523cdc853d35fbc413",
"universalIdentifier": Any<String>,
},
"metadataName": "index",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 index",
"summary": {
"index": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "TEXT fields cannot have a default value.",
},
"message": "Multiple validation errors occurred while creating fields",
"name": "GraphQLError",
}
`;

View File

@@ -0,0 +1,201 @@
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 { type CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
type SuccessfulTestCases = EachTestingContext<
(args: {
createdObjectMetadataId: string;
}) => Omit<CreateFieldInput, 'workspaceId'>
>[];
type FailingTestCases = EachTestingContext<
(args: {
createdObjectMetadataId: string;
}) => Omit<CreateFieldInput, 'workspaceId'>
>[];
const successfulTestCases: SuccessfulTestCases = [
{
title: 'should create one unique field metadata with TEXT type',
context: ({ createdObjectMetadataId }) => ({
name: 'uniqueTestField',
label: 'Unique Test Field',
type: FieldMetadataType.TEXT,
objectMetadataId: createdObjectMetadataId,
isUnique: true,
}),
},
{
title:
'should create unique field metadata for composite type with unique subfields (EMAILS)',
context: ({ createdObjectMetadataId }) => ({
name: 'uniqueEmails',
label: 'Unique Emails',
type: FieldMetadataType.EMAILS,
objectMetadataId: createdObjectMetadataId,
isUnique: true,
}),
},
];
const failingTestCases: FailingTestCases = [
{
title: 'should fail when field has custom default value',
context: ({ createdObjectMetadataId }) => ({
name: 'uniqueFieldWithDefault',
label: 'Unique Field With Default',
type: FieldMetadataType.TEXT,
objectMetadataId: createdObjectMetadataId,
isUnique: true,
defaultValue: "'?'",
}),
},
{
title:
'should fail for composite type without unique subfields (FULL_NAME)',
context: ({ createdObjectMetadataId }) => ({
name: 'uniqueFullName',
label: 'Unique Full Name',
type: FieldMetadataType.FULL_NAME,
objectMetadataId: createdObjectMetadataId,
isUnique: true,
}),
},
];
describe('successful createOne unique field metadata', () => {
let createdObjectMetadataId = '';
beforeAll(async () => {
const {
data: {
createOneObject: { id: objectMetadataId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'testObjectForUniqueField',
namePlural: 'testObjectsForUniqueField',
labelSingular: 'Test Object For Unique Field',
labelPlural: 'Test Objects For Unique Field',
icon: 'IconTest',
},
});
createdObjectMetadataId = objectMetadataId;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
});
it.each(eachTestingContextFilter(successfulTestCases))(
'$title',
async ({ context }) => {
const contextPayload = context({
createdObjectMetadataId,
});
const { data, errors } = await createOneFieldMetadata({
input: contextPayload,
expectToFail: false,
gqlFields: `
id
name
label
type
isUnique
`,
});
expect(errors).toBeUndefined();
expect(data).not.toBeNull();
expect(data.createOneField).toBeDefined();
expect(data.createOneField.name).toBe(contextPayload.name);
expect(data.createOneField.isUnique).toBe(true);
},
);
});
describe('failing createOne unique field metadata', () => {
let createdObjectMetadataId = '';
beforeAll(async () => {
const {
data: {
createOneObject: { id: objectMetadataId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'testObjectForFailingUniqueField',
namePlural: 'testObjectsForFailingUniqueField',
labelSingular: 'Test Object For Failing Unique Field',
labelPlural: 'Test Objects For Failing Unique Field',
icon: 'IconTest',
},
});
createdObjectMetadataId = objectMetadataId;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
});
it.each(eachTestingContextFilter(failingTestCases))(
'$title',
async ({ context }) => {
const contextPayload = context({
createdObjectMetadataId,
});
const { errors } = await createOneFieldMetadata({
input: contextPayload,
expectToFail: true,
});
expect(errors).toBeDefined();
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,148 @@
import { createManyOperation } from 'test/integration/graphql/utils/create-many-operation.util';
import { search } from 'test/integration/graphql/utils/search.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 { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-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 { type FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
import { computeTableName } from 'src/engine/utils/compute-table-name.util';
// ISO search surface: renaming an indexed field (the name field, the default search field of a
// custom object) must rebuild the searchVector column so global search keeps working,
// since the generated expression embeds the field's column name.
const TEST_SCHEMA_NAME = 'workspace_1wgvd1injqtife6y4rvfbu3h5';
const findSearchVectorGinIndexDefinitions = async (
tableName: string,
): Promise<string[]> => {
const rows: { indexdef: string }[] = await global.testDataSource.query(
`SELECT indexdef FROM pg_indexes
WHERE schemaname = $1 AND tablename = $2
AND indexdef ILIKE '%using gin%'
AND indexdef ILIKE '%searchVector%'`,
[TEST_SCHEMA_NAME, tableName],
);
return rows.map((row) => row.indexdef);
};
describe('Field metadata update - search vector rename rebuild', () => {
let testObjectMetadataId: string;
let nameFieldMetadataId: string;
let createdRecordId: string;
const OBJECT_NAME_SINGULAR = 'searchVectorRenameObject';
const OBJECT_NAME_PLURAL = 'searchVectorRenameObjects';
const OBJECT_TABLE_NAME = computeTableName(OBJECT_NAME_SINGULAR, true);
const RENAMED_NAME_FIELD = 'searchableLabelColumn';
const RECORD_NAME_VALUE = 'RenameRecomputeSearchToken33';
beforeAll(async () => {
const {
data: {
createOneObject: { id: objectMetadataId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: OBJECT_NAME_SINGULAR,
namePlural: OBJECT_NAME_PLURAL,
labelSingular: 'Search Vector Rename Object',
labelPlural: 'Search Vector Rename Objects',
icon: 'IconSearch',
isLabelSyncedWithName: false,
},
});
testObjectMetadataId = objectMetadataId;
const { objects } = await findManyObjectMetadata({
expectToFail: false,
input: {
filter: { id: { eq: testObjectMetadataId } },
paging: { first: 1 },
},
gqlFields: `
id
fieldsList {
id
name
}
`,
});
const nameField = objects[0]?.fieldsList?.find(
(field: FieldMetadataDTO) => field.name === 'name',
);
jestExpectToBeDefined(nameField);
nameFieldMetadataId = nameField.id;
const { data } = await createManyOperation({
objectMetadataSingularName: OBJECT_NAME_SINGULAR,
objectMetadataPluralName: OBJECT_NAME_PLURAL,
gqlFields: `id name`,
data: [{ name: RECORD_NAME_VALUE }],
expectToFail: false,
});
createdRecordId = data.createdRecords[0].id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: testObjectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: testObjectMetadataId },
});
});
it('should rebuild the search vector and keep search working when the indexed name field is renamed', async () => {
const ginIndexesBeforeRename =
await findSearchVectorGinIndexDefinitions(OBJECT_TABLE_NAME);
expect(ginIndexesBeforeRename.length).toBe(1);
await updateOneFieldMetadata({
input: {
idToUpdate: nameFieldMetadataId,
updatePayload: {
name: RENAMED_NAME_FIELD,
label: 'Searchable Label Column',
isLabelSyncedWithName: false,
},
},
gqlFields: `id name`,
expectToFail: false,
});
const searchResult = await search({
searchInput: RECORD_NAME_VALUE,
includedObjectNameSingulars: [OBJECT_NAME_SINGULAR],
limit: 10,
expectToFail: false,
});
expect(searchResult.data.search.edges.length).toBe(1);
expect(searchResult.data.search.edges[0].node.recordId).toBe(
createdRecordId,
);
expect(searchResult.data.search.edges[0].node.objectNameSingular).toBe(
OBJECT_NAME_SINGULAR,
);
const ginIndexesAfterRename =
await findSearchVectorGinIndexDefinitions(OBJECT_TABLE_NAME);
expect(ginIndexesAfterRename).toEqual(ginIndexesBeforeRename);
});
});

View File

@@ -0,0 +1,456 @@
import { faker } from '@faker-js/faker';
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 { createOneViewFilter } from 'test/integration/metadata/suites/view-filter/utils/create-one-view-filter.util';
import { createOneView } from 'test/integration/metadata/suites/view/utils/create-one-view.util';
import { findViewFilterWithRestApi } from 'test/integration/rest/utils/view-rest-api.util';
import {
eachTestingContextFilter,
type EachTestingContext,
} from 'twenty-shared/testing';
import {
type FieldMetadataComplexOption,
type FieldMetadataDefaultOption,
FieldMetadataType,
type EnumFieldMetadataType,
ViewFilterOperand,
ViewType,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type ViewFilterValue } from 'src/engine/metadata-modules/view-filter/types/view-filter-value.type';
type Option = FieldMetadataDefaultOption | FieldMetadataComplexOption;
const generateOption = (index: number): Option => ({
label: `Option ${index}`,
value: `OPTION_${index}`,
color: 'green',
position: index,
});
const generateOptions = (length: number) =>
Array.from({ length }, (_value, index) => generateOption(index));
const fakeOptionUpdate = ({ value, label, ...option }: Option) => ({
...option,
value: `${value}_UPDATED`,
label: `${label} updated`,
});
const ALL_OPTIONS = generateOptions(10);
const isEven = (_value: unknown, index: number) => index % 2 === 0;
type ViewFilterUpdate = {
value: string[];
};
type FieldMetadataOptionsAndType = {
options: Option[];
type: EnumFieldMetadataType;
};
type TestCase = EachTestingContext<{
fieldMetadata?: FieldMetadataOptionsAndType;
createViewFilter?: ViewFilterUpdate;
updateOptions: (
options: FieldMetadataDefaultOption[] | FieldMetadataComplexOption[],
) => FieldMetadataDefaultOption[] | FieldMetadataComplexOption[];
expected?: null;
}>;
const testFieldMetadataType: EnumFieldMetadataType[] = [
FieldMetadataType.SELECT,
FieldMetadataType.MULTI_SELECT,
];
describe('update-one-field-metadata-view-filters-side-effect-v2', () => {
let idToDelete: string;
const createObjectSelectFieldAndView = async ({
options,
type: fieldMetadataType,
}: FieldMetadataOptionsAndType) => {
const singular = faker.lorem.words();
const plural = singular + faker.lorem.word();
const {
data: { createOneObject },
} = await createOneObjectMetadata({
expectToFail: false,
input: getMockCreateObjectInput({
labelSingular: singular,
labelPlural: plural,
nameSingular: singular.split(' ').join(''),
namePlural: plural.split(' ').join(''),
isLabelSyncedWithName: false,
}),
});
idToDelete = createOneObject.id;
const {
data: { createOneField },
} = await createOneFieldMetadata<typeof fieldMetadataType>({
expectToFail: false,
input: {
objectMetadataId: createOneObject.id,
type: fieldMetadataType,
name: 'testName',
label: 'Test name',
isLabelSyncedWithName: true,
options,
},
gqlFields: `
id
options
`,
});
const {
data: { createView: createdView },
} = await createOneView({
input: {
id: faker.string.uuid(),
icon: '123Icon',
name: 'Test View',
objectMetadataId: createOneObject.id,
type: ViewType.TABLE,
},
expectToFail: false,
gqlFields: `
id
`,
});
return { createOneObject, createOneField, createdView };
};
afterEach(async () => {
if (isDefined(idToDelete)) {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: idToDelete,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: idToDelete },
});
}
});
describe.each(testFieldMetadataType)('%s', (fieldType) => {
const operandForFieldType =
fieldType === FieldMetadataType.MULTI_SELECT
? ViewFilterOperand.CONTAINS
: ViewFilterOperand.IS;
const testCases: TestCase[] = [
{
title:
'should delete related view filter if all select field options got deleted',
context: {
updateOptions: () => generateOptions(3),
expected: null,
},
},
{
title: 'should update related multi selected options view filter',
context: {
updateOptions: (options) =>
options.map((option, index) =>
isEven(option, index) ? fakeOptionUpdate(option) : option,
),
},
},
{
title: 'should update related solo selected option view filter',
context: {
createViewFilter: {
value: [ALL_OPTIONS[5].value],
},
updateOptions: (options) => [fakeOptionUpdate(options[5])],
},
},
{
title:
'should handle partial deletion of selected options in view filter',
context: {
updateOptions: (options) => options.slice(4),
},
},
{
title:
'should handle reordering of options while maintaining view filter values',
context: {
createViewFilter: {
value: ALL_OPTIONS.slice(0, 2).map((option) => option.value),
},
updateOptions: (options) => [...options].reverse(),
},
},
{
title:
'should handle no changes update of options while maintaining existing view filter values',
context: {
updateOptions: (options) => options,
},
},
{
title:
'should handle adding new options while maintaining existing view filter',
context: {
fieldMetadata: {
options: ALL_OPTIONS.slice(0, 5),
type: fieldType,
},
createViewFilter: {
value: ALL_OPTIONS.slice(0, 2).map((option) => option.value),
},
updateOptions: (options) => [
...options,
...generateOptions(6).slice(5),
],
},
},
];
test.each(eachTestingContextFilter(testCases))(
'$title',
async ({
context: {
expected,
createViewFilter = {
value: ALL_OPTIONS.map((option) => option.value),
},
fieldMetadata = { options: ALL_OPTIONS, type: fieldType },
updateOptions,
},
}) => {
const { createOneField, createdView } =
await createObjectSelectFieldAndView(fieldMetadata);
const {
data: { createViewFilter: createdViewFilter },
} = await createOneViewFilter({
input: {
viewId: createdView.id,
fieldMetadataId: createOneField.id,
operand: operandForFieldType,
value: createViewFilter.value,
},
expectToFail: false,
gqlFields: `
id
`,
});
const optionsWithIds = createOneField.options;
if (!isDefined(optionsWithIds)) {
throw new Error('optionsWithIds is not defined');
}
const updatedOptions = updateOptions(optionsWithIds);
await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: createOneField.id,
updatePayload: {
options: updatedOptions,
},
},
gqlFields: `
id
options
`,
});
const updatedViewFilter = await findViewFilterWithRestApi(
createdViewFilter.id,
);
if (expected !== undefined) {
expect(updatedViewFilter).toBe(expected);
return;
}
if (!isDefined(updatedViewFilter)) {
throw new Error(
'updatedViewFilter is not defined but should be at this point',
);
}
expect(updatedViewFilter.value).not.toBeNull();
if (updatedViewFilter.value === null) {
throw new Error('Invariant parsedValue should not be null');
}
expect(updatedOptions.map((option) => option.value)).toEqual(
expect.arrayContaining(updatedViewFilter.value as string[]),
);
expect({
value: updatedViewFilter.value,
operand: updatedViewFilter.operand,
viewFilterGroupId: updatedViewFilter.viewFilterGroupId,
positionInViewFilterGroup:
updatedViewFilter.positionInViewFilterGroup,
subFieldName: updatedViewFilter.subFieldName,
}).toMatchSnapshot();
},
);
it('should update a related view filter with a scalar option value', async () => {
const { createOneField, createdView } =
await createObjectSelectFieldAndView({
options: ALL_OPTIONS,
type: fieldType,
});
const {
data: { createViewFilter: createdViewFilter },
} = await createOneViewFilter({
input: {
viewId: createdView.id,
fieldMetadataId: createOneField.id,
operand: operandForFieldType,
value: ALL_OPTIONS[0].value,
},
expectToFail: false,
gqlFields: `
id
`,
});
const optionsWithIds = createOneField.options;
if (!isDefined(optionsWithIds)) {
throw new Error('optionsWithIds is not defined');
}
const updatedOption = fakeOptionUpdate(optionsWithIds[0]);
await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: createOneField.id,
updatePayload: {
options: [updatedOption, ...optionsWithIds.slice(1)],
},
},
gqlFields: `
id
`,
});
const updatedViewFilter = await findViewFilterWithRestApi(
createdViewFilter.id,
);
expect(updatedViewFilter?.value).toEqual([updatedOption.value]);
});
it('should leave a value-less view filter unchanged', async () => {
const { createOneField, createdView } =
await createObjectSelectFieldAndView({
options: ALL_OPTIONS,
type: fieldType,
});
const {
data: { createViewFilter: createdViewFilter },
} = await createOneViewFilter({
input: {
viewId: createdView.id,
fieldMetadataId: createOneField.id,
operand: ViewFilterOperand.IS_NOT_EMPTY,
value: '',
},
expectToFail: false,
gqlFields: `
id
`,
});
const optionsWithIds = createOneField.options;
if (!isDefined(optionsWithIds)) {
throw new Error('optionsWithIds is not defined');
}
await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: createOneField.id,
updatePayload: {
options: [
fakeOptionUpdate(optionsWithIds[0]),
...optionsWithIds.slice(1),
],
},
},
gqlFields: `
id
`,
});
const updatedViewFilter = await findViewFilterWithRestApi(
createdViewFilter.id,
);
expect(updatedViewFilter).toMatchObject({
operand: ViewFilterOperand.IS_NOT_EMPTY,
value: '',
});
});
const failingTestCases: EachTestingContext<{
createViewFilterValue: unknown;
}>[] = [
{
title:
'should throw error if view filter value is not a stringified JSON array',
context: {
createViewFilterValue: JSON.stringify(
'not an array stringified json',
),
},
},
];
test.each(eachTestingContextFilter(failingTestCases))(
'$title',
async ({ context: { createViewFilterValue } }) => {
const { createOneField, createdView } =
await createObjectSelectFieldAndView({
options: ALL_OPTIONS,
type: fieldType,
});
const { errors } = await createOneViewFilter({
input: {
id: '20202020-e3b5-4fa7-85aa-9b1950fc7bf5',
viewId: createdView.id,
fieldMetadataId: createOneField.id,
operand: operandForFieldType,
value: createViewFilterValue as unknown as ViewFilterValue,
},
expectToFail: true,
gqlFields: `
id
`,
});
expect(errors).toBeDefined();
expect(errors![0].extensions.code).toBe('METADATA_VALIDATION_FAILED');
},
);
});
});

View File

@@ -0,0 +1,323 @@
import { faker } from '@faker-js/faker';
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 { findViewGroups } from 'test/integration/metadata/suites/view-group/utils/find-view-groups.util';
import { createOneView } from 'test/integration/metadata/suites/view/utils/create-one-view.util';
import {
type FieldMetadataComplexOption,
type FieldMetadataDefaultOption,
FieldMetadataType,
ViewType,
} from 'twenty-shared/types';
type Option = FieldMetadataDefaultOption | FieldMetadataComplexOption;
const generateOption = (index: number): Option => ({
label: `Option ${index}`,
value: `OPTION_${index}`,
color: 'green',
position: index,
});
const generateOptions = (length: number) =>
Array.from({ length }, (_value, index) => generateOption(index));
const fakeOptionUpdate = ({ value, label, ...option }: Option) => ({
...option,
value: `${value}_UPDATED`,
label: `${label} updated`,
});
describe('update-one-field-metadata-view-groups-side-effect-v2', () => {
let objectMetadataIdToDelete: string;
const createObjectWithSelectFieldAndView = async (
initialOptions: Option[],
) => {
const singular = 'sideEffect';
const plural = 'sideEffects';
const {
data: { createOneObject },
} = await createOneObjectMetadata({
expectToFail: false,
input: getMockCreateObjectInput({
labelSingular: singular,
labelPlural: plural,
nameSingular: singular,
namePlural: plural,
isLabelSyncedWithName: false,
}),
});
objectMetadataIdToDelete = createOneObject.id;
const {
data: { createOneField },
} = await createOneFieldMetadata({
expectToFail: false,
input: {
objectMetadataId: createOneObject.id,
type: FieldMetadataType.SELECT,
name: 'statusField',
label: 'Status Field',
isLabelSyncedWithName: true,
options: initialOptions,
},
gqlFields: 'id options',
});
const {
data: { createView: view },
} = await createOneView({
input: {
id: faker.string.uuid(),
icon: 'IconTable',
name: 'Test View',
objectMetadataId: createOneObject.id,
mainGroupByFieldMetadataId: createOneField.id,
type: ViewType.TABLE,
},
expectToFail: false,
gqlFields: 'id',
});
return {
objectMetadataId: createOneObject.id,
fieldMetadataId: createOneField.id,
fieldOptions: createOneField.options ?? [],
viewId: view.id,
};
};
afterEach(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: objectMetadataIdToDelete,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: objectMetadataIdToDelete },
});
});
describe('SELECT field type', () => {
it('deleted and adds view groups when options are deleted and added', async () => {
const initialOptions = generateOptions(3);
const { fieldMetadataId, viewId } =
await createObjectWithSelectFieldAndView(initialOptions);
const {
data: { getViewGroups: initialViewGroups },
} = await findViewGroups({
viewId,
gqlFields: 'id fieldValue',
expectToFail: false,
});
expect(initialViewGroups.length).toBe(4);
const initialFieldValues = [...initialViewGroups]
.map((vg) => vg.fieldValue)
.sort();
const expectedFieldValues = [
...initialOptions.map((opt) => opt.value),
'',
].sort();
expect(initialFieldValues).toEqual(expectedFieldValues);
await updateOneFieldMetadata({
input: {
idToUpdate: fieldMetadataId,
updatePayload: {
options: [
{
color: 'blue',
label: 'New option label',
position: 42,
value: 'NEW_OPTION_VALUE',
},
],
},
},
gqlFields: 'id options',
expectToFail: false,
});
const {
data: { getViewGroups: updatedViewGroups },
} = await findViewGroups({
viewId,
gqlFields: 'id fieldValue viewId',
expectToFail: false,
});
expect(updatedViewGroups.length).toBe(2);
const updatedFieldValues = updatedViewGroups
.map((vg) => vg.fieldValue)
.sort();
const expectedUpdatedFieldValues = ['', 'NEW_OPTION_VALUE'].sort();
expect(updatedFieldValues).toEqual(expectedUpdatedFieldValues);
});
it('updates specific view groups when some options are updated', async () => {
const initialOptions = generateOptions(3);
const { fieldMetadataId, fieldOptions, viewId } =
await createObjectWithSelectFieldAndView(initialOptions);
const updatedOptions = fieldOptions.map((opt) => fakeOptionUpdate(opt));
await updateOneFieldMetadata({
input: {
idToUpdate: fieldMetadataId,
updatePayload: {
options: updatedOptions,
},
},
gqlFields: 'id options',
expectToFail: false,
});
const {
data: { getViewGroups: updatedViewGroups },
} = await findViewGroups({
viewId,
gqlFields: 'id fieldValue',
expectToFail: false,
});
expect(updatedViewGroups.length).toBe(4);
const updatedFieldValues = updatedViewGroups
.map((vg) => vg.fieldValue)
.sort();
const expectedUpdatedFieldValues = [
...updatedOptions.map((opt) => opt.value),
'',
].sort();
expect(updatedFieldValues).toEqual(expectedUpdatedFieldValues);
});
it('deletes specific view groups when some of all options are removed', async () => {
const initialOptions = generateOptions(5);
const { fieldMetadataId, fieldOptions, viewId } =
await createObjectWithSelectFieldAndView(initialOptions);
const updatedOptions = fieldOptions.slice(2);
await updateOneFieldMetadata({
input: {
idToUpdate: fieldMetadataId,
updatePayload: {
options: updatedOptions,
},
},
gqlFields: 'id options',
expectToFail: false,
});
const {
data: { getViewGroups: updatedViewGroups },
} = await findViewGroups({
viewId,
gqlFields: 'id fieldValue',
expectToFail: false,
});
expect(updatedViewGroups.length).toBe(4);
const remainingFieldValues = updatedViewGroups
.map((vg) => vg.fieldValue)
.sort();
const expectedRemainingFieldValues = [
...updatedOptions.map((opt) => opt.value),
'',
].sort();
expect(remainingFieldValues).toEqual(expectedRemainingFieldValues);
});
it('preserves view groups when no options are changed', async () => {
const initialOptions = generateOptions(3);
const { fieldMetadataId, fieldOptions, viewId } =
await createObjectWithSelectFieldAndView(initialOptions);
await updateOneFieldMetadata({
input: {
idToUpdate: fieldMetadataId,
updatePayload: {
options: fieldOptions,
},
},
gqlFields: 'id options',
expectToFail: false,
});
const {
data: { getViewGroups: updatedViewGroups },
} = await findViewGroups({
viewId,
gqlFields: 'id fieldValue',
expectToFail: false,
});
expect(updatedViewGroups.length).toBe(4);
const actualFieldValues = updatedViewGroups
.map((vg) => vg.fieldValue)
.sort();
const expectedFieldValues = [
...initialOptions.map((opt) => opt.value),
'',
].sort();
expect(actualFieldValues).toEqual(expectedFieldValues);
});
it('adds new view groups when new options are added', async () => {
const initialOptions = generateOptions(3);
const { fieldMetadataId, fieldOptions, viewId } =
await createObjectWithSelectFieldAndView(initialOptions);
const newOptions = generateOptions(6).slice(3);
const updatedOptions = [...fieldOptions, ...newOptions];
await updateOneFieldMetadata({
input: {
idToUpdate: fieldMetadataId,
updatePayload: {
options: updatedOptions,
},
},
gqlFields: 'id options',
expectToFail: false,
});
const {
data: { getViewGroups: updatedViewGroups },
} = await findViewGroups({
viewId,
gqlFields: 'id fieldValue',
expectToFail: false,
});
expect(updatedViewGroups.length).toBe(7);
const actualFieldValues = updatedViewGroups
.map((vg) => vg.fieldValue)
.sort();
const expectedFieldValues = [
...updatedOptions.map((opt) => opt.value),
'',
].sort();
expect(actualFieldValues).toEqual(expectedFieldValues);
});
});
});

View File

@@ -0,0 +1,197 @@
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 {
LISTING_NAME_PLURAL,
LISTING_NAME_SINGULAR,
} from 'test/integration/metadata/suites/object-metadata/constants/test-object-names.constant';
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 { FieldMetadataType } from 'twenty-shared/types';
describe('updateOne', () => {
describe('successful fieldMetadataService name/label sync', () => {
let listingObjectId = '';
let testFieldId = '';
beforeEach(async () => {
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
labelSingular: LISTING_NAME_SINGULAR,
labelPlural: LISTING_NAME_PLURAL,
nameSingular: LISTING_NAME_SINGULAR,
namePlural: LISTING_NAME_PLURAL,
icon: 'IconBuildingSkyscraper',
isLabelSyncedWithName: true,
},
});
listingObjectId = data.createOneObject.id;
const { data: createdFieldMetadata } = await createOneFieldMetadata({
expectToFail: false,
input: {
objectMetadataId: listingObjectId,
type: FieldMetadataType.TEXT,
name: 'testName',
label: 'Test name',
isLabelSyncedWithName: true,
},
});
testFieldId = createdFieldMetadata.createOneField.id;
});
afterEach(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: listingObjectId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: listingObjectId },
});
});
it('should update a field name and label when they are synced correctly', async () => {
const updateFieldInput = {
name: 'newName',
label: 'New name',
isLabelSyncedWithName: true,
};
const { data } = await updateOneFieldMetadata({
input: { idToUpdate: testFieldId, updatePayload: updateFieldInput },
gqlFields: `
id
name
label
isLabelSyncedWithName
`,
});
expect(data.updateOneField.name).toBe('newName');
});
it('should update a field name and label when they are not synced correctly and labelSync is false', async () => {
const updateFieldInput = {
name: 'differentName',
label: 'New name',
isLabelSyncedWithName: false,
};
const { data } = await updateOneFieldMetadata({
input: { idToUpdate: testFieldId, updatePayload: updateFieldInput },
gqlFields: `
id
name
label
isLabelSyncedWithName
`,
});
expect(data.updateOneField.name).toBe('differentName');
});
});
describe('failing update', () => {
let listingObjectId = '';
let testFieldId = '';
beforeAll(async () => {
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
labelSingular: LISTING_NAME_SINGULAR,
labelPlural: LISTING_NAME_PLURAL,
nameSingular: LISTING_NAME_SINGULAR,
namePlural: LISTING_NAME_PLURAL,
icon: 'IconBuildingSkyscraper',
isLabelSyncedWithName: true,
},
});
listingObjectId = data.createOneObject.id;
const { data: createdFieldMetadata } = await createOneFieldMetadata({
expectToFail: false,
input: {
objectMetadataId: listingObjectId,
type: FieldMetadataType.TEXT,
name: 'testName',
label: 'Test name',
isLabelSyncedWithName: true,
},
});
testFieldId = createdFieldMetadata.createOneField.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: listingObjectId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: listingObjectId },
});
});
it('should not update a field name if it is not synced correctly with label and labelSync is true', async () => {
const updateFieldInput = {
name: 'newName',
isLabelSyncedWithName: true,
};
const { errors } = await updateOneFieldMetadata({
input: { idToUpdate: testFieldId, updatePayload: updateFieldInput },
gqlFields: `
id
name
label
isLabelSyncedWithName
`,
expectToFail: true,
});
expect(errors).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny(errors),
);
});
it('should throw if the field name is not available because of other field with the same name', async () => {
await createOneFieldMetadata({
expectToFail: false,
input: {
objectMetadataId: listingObjectId,
type: FieldMetadataType.TEXT,
name: 'otherTestName',
label: 'Test name',
},
});
const { errors } = await updateOneFieldMetadata({
expectToFail: true,
input: {
idToUpdate: testFieldId,
updatePayload: { name: 'otherTestName' },
},
});
expect(errors).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny(errors),
);
});
});
});

View File

@@ -0,0 +1,25 @@
import gql from 'graphql-tag';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { type CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
export type CreateOneFieldFactoryInput = Omit<
CreateFieldInput,
'workspaceId' | 'dataSourceId'
>;
export const createOneFieldMetadataQueryFactory = ({
input,
gqlFields = 'id',
}: PerformMetadataQueryParams<CreateOneFieldFactoryInput>) => ({
query: gql`
mutation CreateOneFieldMetadataItem($input: CreateOneFieldMetadataInput!) {
createOneField(input: $input) {
${gqlFields}
}
}
`,
variables: {
input: { field: input },
},
});

View File

@@ -0,0 +1,43 @@
import {
type CreateOneFieldFactoryInput,
createOneFieldMetadataQueryFactory,
} from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata-query-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { type FieldMetadataType } from 'twenty-shared/types';
import { type FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
export const createOneFieldMetadata = async <T extends FieldMetadataType>({
input,
gqlFields,
expectToFail,
}: PerformMetadataQueryParams<CreateOneFieldFactoryInput>): CommonResponseBody<{
createOneField: FieldMetadataDTO<T>;
}> => {
const graphqlOperation = createOneFieldMetadataQueryFactory({
input,
gqlFields,
});
const response = await makeMetadataAPIRequest(graphqlOperation);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'Field Metadata creation should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
errorMessage: 'Field metadata creation should not have failed',
response,
});
}
return { data: response.body.data, errors: response.body.errors };
};

View File

@@ -0,0 +1,41 @@
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import {
type FieldMetadataComplexOption,
FieldMetadataType,
} from 'twenty-shared/types';
import { type CreateOneFieldMetadataInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
export const createOneSelectFieldMetadataForIntegrationTests = async ({
input,
expectToFail = false,
}: {
input: Partial<CreateOneFieldMetadataInput> & {
objectMetadataId: string;
options?: FieldMetadataComplexOption[];
name?: string;
};
expectToFail?: boolean;
}) => {
const {
data: {
createOneField: { id: selectFieldMetadataId },
},
} = await createOneFieldMetadata({
expectToFail,
input: {
name: 'normalField',
type: FieldMetadataType.SELECT,
label: 'Select Field',
options: [
{ label: 'Option 1', value: 'OPTION_1', color: 'blue', position: 0 },
{ label: 'Option 2', value: 'OPTION_2', color: 'red', position: 1 },
{ label: 'Option 3', value: 'OPTION_3', color: 'green', position: 2 },
],
...input,
},
gqlFields: 'id',
});
return { selectFieldMetadataId };
};

View File

@@ -0,0 +1,22 @@
import gql from 'graphql-tag';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
export type DeleteOneFieldFactoryInput = {
idToDelete: string;
};
export const deleteOneFieldMetadataQueryFactory = ({
input,
gqlFields = 'id',
}: PerformMetadataQueryParams<DeleteOneFieldFactoryInput>) => ({
query: gql`
mutation DeleteOneFieldMetadataItem($idToDelete: UUID!) {
deleteOneField(input: { id: $idToDelete }) {
${gqlFields}
}
}
`,
variables: {
idToDelete: input.idToDelete,
},
});

View File

@@ -0,0 +1,37 @@
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import {
type DeleteOneFieldFactoryInput,
deleteOneFieldMetadataQueryFactory,
} from 'test/integration/metadata/suites/field-metadata/utils/delete-one-field-metadata-query-factory.util';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
export const deleteOneFieldMetadata = async ({
input,
gqlFields,
expectToFail,
}: PerformMetadataQueryParams<DeleteOneFieldFactoryInput>) => {
const graphqlOperation = deleteOneFieldMetadataQueryFactory({
input,
gqlFields,
});
const response = await makeMetadataAPIRequest(graphqlOperation);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'Field Metadata deletion should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
errorMessage: 'Field metadata deletion should not have failed',
response,
});
}
return { data: response.body.data, errors: response.body.errors };
};

View File

@@ -0,0 +1,28 @@
import gql from 'graphql-tag';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
export type FindManyFieldsMetadataFactoryInput = {
filter: object;
paging: object;
};
export const findManyFieldsMetadataQueryFactory = ({
gqlFields = 'id',
input,
}: PerformMetadataQueryParams<FindManyFieldsMetadataFactoryInput>) => ({
query: gql`
query FieldsMetadata($filter: FieldFilter!, $paging: CursorPaging!) {
fields(paging: $paging, filter: $filter) {
edges {
node {
${gqlFields}
}
}
}
}
`,
variables: {
filter: input.filter,
paging: input.paging,
},
});

View File

@@ -0,0 +1,40 @@
import {
type FindManyFieldsMetadataFactoryInput,
findManyFieldsMetadataQueryFactory,
} from 'test/integration/metadata/suites/field-metadata/utils/find-many-fields-metadata-query-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
export const findManyFieldsMetadata = async ({
input,
gqlFields,
expectToFail,
}: PerformMetadataQueryParams<FindManyFieldsMetadataFactoryInput>) => {
const graphqlOperation = findManyFieldsMetadataQueryFactory({
input,
gqlFields,
});
const response = await makeMetadataAPIRequest(graphqlOperation);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'Field Metadata retrieval should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
errorMessage: 'Field metadata retrieval should not have failed',
response,
});
}
return {
errors: response.body.errors,
fields: response.body.data.fields?.edges,
};
};

View File

@@ -0,0 +1,26 @@
import gql from 'graphql-tag';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { type UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
export type UpdateOneFieldFactoryInput = {
idToUpdate: string;
updatePayload: Omit<UpdateFieldInput, 'workspaceId' | 'id'>;
};
export const updateOneFieldMetadataQueryFactory = ({
gqlFields = 'id',
input,
}: PerformMetadataQueryParams<UpdateOneFieldFactoryInput>) => ({
query: gql`
mutation UpdateOneFieldMetadataItem($idToUpdate: UUID!, $updatePayload: UpdateFieldInput!) {
updateOneField(input: {id: $idToUpdate, update: $updatePayload}) {
${gqlFields}
}
}
`,
variables: {
idToUpdate: input.idToUpdate,
updatePayload: input.updatePayload,
},
});

View File

@@ -0,0 +1,42 @@
import {
type UpdateOneFieldFactoryInput,
updateOneFieldMetadataQueryFactory,
} from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata-query-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
export const updateOneFieldMetadata = async ({
input,
gqlFields,
expectToFail,
}: PerformMetadataQueryParams<UpdateOneFieldFactoryInput>): CommonResponseBody<{
updateOneField: FieldMetadataEntity;
}> => {
const graphqlOperation = updateOneFieldMetadataQueryFactory({
input,
gqlFields,
});
const response = await makeMetadataAPIRequest(graphqlOperation);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'Field Metadata update should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
errorMessage: 'Field metadata update should not have failed',
response,
});
}
return { data: response.body.data, errors: response.body.errors };
};