diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/create-field.input.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/create-field.input.ts index 47a8ef6f6..1734be2c8 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/create-field.input.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/create-field.input.ts @@ -22,6 +22,7 @@ export class CreateFieldInput extends OmitType( @IsOptional() isRemoteCreation?: boolean; + // TODO @prastoin implement validation for this with validate nested and dedicated class instance @IsOptional() @Field(() => GraphQLJSON, { nullable: true }) relationCreationPayload?: { diff --git a/packages/twenty-server/src/engine/metadata-modules/utils/__tests__/compute-metadata-name-from-label.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/utils/__tests__/compute-metadata-name-from-label.util.spec.ts new file mode 100644 index 000000000..d89e751e6 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/utils/__tests__/compute-metadata-name-from-label.util.spec.ts @@ -0,0 +1,112 @@ +import { EachTestingContext } from 'twenty-shared/testing'; + +import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/compute-metadata-name-from-label.util'; +import { + InvalidMetadataException, + InvalidMetadataExceptionCode, +} from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception'; + +type ComputeMetadataNameFromLabelTestCase = EachTestingContext<{ + input: string; + expected?: string; + expectToThrow?: { + error: InvalidMetadataException; + }; +}>; + +describe('computeMetadataNameFromLabel', () => { + const successfulTestCases: ComputeMetadataNameFromLabelTestCase[] = [ + { + title: 'should convert a simple label to camelCase', + context: { + input: 'Simple Label', + expected: 'simpleLabel', + }, + }, + { + title: 'should handle special characters and convert to camelCase', + context: { + input: 'Special & Characters!', + expected: 'specialCharacters', + }, + }, + { + title: 'should prefix numeric labels with n', + context: { + input: '123 Test', + expected: 'n123Test', + }, + }, + { + title: 'should handle multiple spaces and convert to camelCase', + context: { + input: 'Multiple Spaces Here', + expected: 'multipleSpacesHere', + }, + }, + { + title: 'should handle accented characters', + context: { + input: 'Café Crème', + expected: 'cafeCreme', + }, + }, + { + title: 'should handle empty label', + context: { + input: '', + expected: '', + }, + }, + { + title: 'should handle mixed case input', + context: { + input: 'MiXeD cAsE', + expected: 'mixedCase', + }, + }, + ]; + + const failingTestCases: ComputeMetadataNameFromLabelTestCase[] = [ + { + title: 'should throw when label is undefined', + context: { + input: undefined as unknown as string, + expectToThrow: { + error: new InvalidMetadataException( + 'Label is required', + InvalidMetadataExceptionCode.LABEL_REQUIRED, + ), + }, + }, + }, + { + title: 'should throw when label contains only special characters', + context: { + input: '!@#$%^&*()', + expectToThrow: { + error: new InvalidMetadataException( + 'Invalid label: "!@#$%^&*()"', + InvalidMetadataExceptionCode.INVALID_LABEL, + ), + }, + }, + }, + ]; + + describe('successful cases', () => { + it.each(successfulTestCases)('$title', ({ context }) => { + const result = computeMetadataNameFromLabel(context.input); + + expect(result).toBe(context.expected); + }); + }); + + describe('failing cases', () => { + it.each(failingTestCases)('$title', ({ context }) => { + expect(() => computeMetadataNameFromLabel(context.input)).toThrow( + context.expectToThrow?.error, + ); + }); + }); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/object-metadata/__snapshots__/failing-field-metadata-relation-creation.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/object-metadata/__snapshots__/failing-field-metadata-relation-creation.integration-spec.ts.snap new file mode 100644 index 000000000..c51a112e1 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/object-metadata/__snapshots__/failing-field-metadata-relation-creation.integration-spec.ts.snap @@ -0,0 +1,57 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Field metadata relation creation should fail relation when targetFieldLabel conflicts with an existing field on target object metadata id 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Name "collisionfieldlabel" is not available", + }, +] +`; + +exports[`Field metadata relation creation should fail relation when targetFieldLabel contains only whitespace 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Invalid label: " "", + }, +] +`; + +exports[`Field metadata relation creation should fail relation when targetFieldLabel exceeds maximum length 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "String "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" exceeds 63 characters limit", + }, +] +`; + +exports[`Field metadata relation creation should fail relation when targetFieldLabel is empty 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Input is too short: """, + }, +] +`; + +exports[`Field metadata relation creation should fail relation when targetObjectMetadataId is unknown 1`] = ` +[ + { + "extensions": { + "code": "INTERNAL_SERVER_ERROR", + "exceptionEventId": "mocked-exception-id", + }, + "message": "Cannot read properties of undefined (reading 'fieldsById')", + }, +] +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/object-metadata/failing-field-metadata-relation-creation.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/object-metadata/failing-field-metadata-relation-creation.integration-spec.ts new file mode 100644 index 000000000..aaea040ca --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/object-metadata/failing-field-metadata-relation-creation.integration-spec.ts @@ -0,0 +1,149 @@ +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 { getMockCreateObjectInput } from 'test/integration/metadata/suites/object-metadata/utils/generate-mock-create-object-metadata-input'; +import { 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 { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input'; + +type GlobalTestContext = { + objectMetadataIds: { + targetObjectId: string; + sourceObjectId: string; + }; + collisionFieldLabel: string; +}; +const globalTestContext: GlobalTestContext = { + objectMetadataIds: { + targetObjectId: '', + sourceObjectId: '', + }, + collisionFieldLabel: 'collisionfieldlabel', +}; + +type TestedRelationCreationPayload = Partial< + NonNullable +>; + +type CreateOneObjectMetadataItemTestingContext = EachTestingContext< + | TestedRelationCreationPayload + | ((context: GlobalTestContext) => TestedRelationCreationPayload) +>[]; +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: 'when targetFieldLabel is empty', + context: { targetFieldLabel: '' }, + }, + { + title: 'when targetFieldLabel exceeds maximum length', + context: { targetFieldLabel: 'A'.repeat(64) }, + }, + { + // Not handled gracefully should be refactored + title: 'when targetObjectMetadataId is unknown', + context: { targetObjectMetadataId: faker.string.uuid() }, + }, + { + title: 'when targetFieldLabel contains only whitespace', + context: { targetFieldLabel: ' ' }, + }, + { + title: + 'when targetFieldLabel conflicts with an existing field on target object metadata id', + context: ({ collisionFieldLabel, objectMetadataIds }) => ({ + targetObjectMetadataId: objectMetadataIds.targetObjectId, + targetFieldLabel: collisionFieldLabel, + }), + }, + ]; + + beforeAll(async () => { + const { + data: { + createOneObject: { id: sourceObjectId }, + }, + } = await createOneObjectMetadata({ + input: getMockCreateObjectInput({ + namePlural: 'collisionRelations', + nameSingular: 'collisionRelation', + }), + }); + + const { + data: { + createOneObject: { id: targetObjectId }, + }, + } = await createOneObjectMetadata({ + input: getMockCreateObjectInput({ + namePlural: 'collisionRelationTargets', + nameSingular: 'collisionRelationTarget', + }), + }); + + globalTestContext.objectMetadataIds = { + sourceObjectId, + targetObjectId, + }; + + const { data } = await createOneFieldMetadata({ + input: { + objectMetadataId: targetObjectId, + name: globalTestContext.collisionFieldLabel, + label: 'LabelThatCouldBeAnything', + isLabelSyncedWithName: false, + type: FieldMetadataType.TEXT, + }, + }); + + expect(data).toBeDefined(); + }); + + afterAll(async () => { + for (const objectMetadataId of Object.values( + globalTestContext.objectMetadataIds, + )) { + await deleteOneObjectMetadata({ + input: { + idToDelete: objectMetadataId, + }, + }); + } + }); + + it.each(failingLabelsCreationTestsUseCase)( + 'relation $title', + async ({ context }) => { + const computedContext = + typeof context === 'function' ? context(globalTestContext) : context; + + const { errors } = await createOneFieldMetadata({ + expectToFail: true, + input: { + objectMetadataId: globalTestContext.objectMetadataIds.sourceObjectId, + name: 'fieldname', + label: 'Relation field', + isLabelSyncedWithName: false, + type: FieldMetadataType.RELATION, + relationCreationPayload: { + targetFieldLabel: 'defaultTargetFieldLabel', + type: RelationType.ONE_TO_MANY, + targetObjectMetadataId: + globalTestContext.objectMetadataIds.targetObjectId, + targetFieldIcon: 'IconBuildingSkyscraper', + ...computedContext, + }, + }, + }); + + expect(errors).toBeDefined(); + expect(errors).toMatchSnapshot(); + }, + ); +});