diff --git a/packages/twenty-front/src/modules/object-record/object-sort-dropdown/utils/__tests__/turnSortsIntoOrderBy.test.ts b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/utils/__tests__/turnSortsIntoOrderBy.test.ts index 33c01fbbf..2e59c8a96 100644 --- a/packages/twenty-front/src/modules/object-record/object-sort-dropdown/utils/__tests__/turnSortsIntoOrderBy.test.ts +++ b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/utils/__tests__/turnSortsIntoOrderBy.test.ts @@ -3,8 +3,8 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { RecordGqlOperationOrderBy } from '@/object-record/graphql/types/RecordGqlOperationOrderBy'; import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy'; import { RecordSort } from '@/object-record/record-sort/types/RecordSort'; +import { EachTestingContext } from 'twenty-shared'; import { FieldMetadataType } from '~/generated-metadata/graphql'; -import { EachTestingContext } from '~/types/EachTestingContext'; const objectMetadataItemWithPositionField: ObjectMetadataItem = { id: 'object1', diff --git a/packages/twenty-front/src/modules/settings/data-model/validation-schemas/__tests__/settingsDataModelObjectAboutFormSchema.test.ts b/packages/twenty-front/src/modules/settings/data-model/validation-schemas/__tests__/settingsDataModelObjectAboutFormSchema.test.ts index 08572a7a2..e24c54244 100644 --- a/packages/twenty-front/src/modules/settings/data-model/validation-schemas/__tests__/settingsDataModelObjectAboutFormSchema.test.ts +++ b/packages/twenty-front/src/modules/settings/data-model/validation-schemas/__tests__/settingsDataModelObjectAboutFormSchema.test.ts @@ -2,7 +2,7 @@ import { SettingsDataModelObjectAboutFormValues, settingsDataModelObjectAboutFormSchema, } from '@/settings/data-model/validation-schemas/settingsDataModelObjectAboutFormSchema'; -import { EachTestingContext } from '~/types/EachTestingContext'; +import { EachTestingContext } from 'twenty-shared'; describe('settingsDataModelObjectAboutFormSchema', () => { const validInput: SettingsDataModelObjectAboutFormValues = { diff --git a/packages/twenty-front/src/utils/array/__tests__/buildRecordFromKeysWithSameValue.test.ts b/packages/twenty-front/src/utils/array/__tests__/buildRecordFromKeysWithSameValue.test.ts index 9a3ac8c9c..70127f2a3 100644 --- a/packages/twenty-front/src/utils/array/__tests__/buildRecordFromKeysWithSameValue.test.ts +++ b/packages/twenty-front/src/utils/array/__tests__/buildRecordFromKeysWithSameValue.test.ts @@ -1,4 +1,4 @@ -import { EachTestingContext } from '~/types/EachTestingContext'; +import { EachTestingContext } from 'twenty-shared'; import { buildRecordFromKeysWithSameValue } from '~/utils/array/buildRecordFromKeysWithSameValue'; type BuildRecordFromKeysWithSameValueTestContext = EachTestingContext<{ diff --git a/packages/twenty-server/jest-integration.config.ts b/packages/twenty-server/jest-integration.config.ts index f532f9ab1..deab56040 100644 --- a/packages/twenty-server/jest-integration.config.ts +++ b/packages/twenty-server/jest-integration.config.ts @@ -5,6 +5,9 @@ const isBillingEnabled = process.env.IS_BILLING_ENABLED === 'true'; const tsConfig = require('./tsconfig.json'); const jestConfig: JestConfigWithTsJest = { + // For more information please have a look to official docs https://jestjs.io/docs/configuration/#prettierpath-string + // Prettier v3 should be supported in jest v30 https://github.com/jestjs/jest/releases/tag/v30.0.0-alpha.1 + prettierPath: null, silent: false, verbose: true, moduleFileExtensions: ['js', 'json', 'ts'], diff --git a/packages/twenty-server/jest.config.ts b/packages/twenty-server/jest.config.ts index aef2c489e..7f2226b33 100644 --- a/packages/twenty-server/jest.config.ts +++ b/packages/twenty-server/jest.config.ts @@ -1,6 +1,6 @@ const jestConfig = { // For more information please have a look to official docs https://jestjs.io/docs/configuration/#prettierpath-string - // Prettier v3 will should be supported in jest v30 https://github.com/jestjs/jest/releases/tag/v30.0.0-alpha.1 + // Prettier v3 should be supported in jest v30 https://github.com/jestjs/jest/releases/tag/v30.0.0-alpha.1 prettierPath: null, // to enable logs, comment out the following line silent: true, @@ -39,6 +39,7 @@ const jestConfig = { }, moduleNameMapper: { '^src/(.*)': '/src/$1', + '^test/(.*)': '/test/$1', }, moduleFileExtensions: ['js', 'json', 'ts'], modulePathIgnorePatterns: ['/dist'], diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.service.ts index 7078cbb7f..637447c3f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.service.ts @@ -41,12 +41,9 @@ import { RelationMetadataEntity, RelationMetadataType, } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; -import { InvalidStringException } from 'src/engine/metadata-modules/utils/exceptions/invalid-string.exception'; -import { NameNotAvailableException } from 'src/engine/metadata-modules/utils/exceptions/name-not-available.exception'; -import { NameTooLongException } from 'src/engine/metadata-modules/utils/exceptions/name-too-long.exception'; import { exceedsDatabaseIdentifierMaximumLength } from 'src/engine/metadata-modules/utils/validate-database-identifier-length.utils'; import { validateFieldNameAvailabilityOrThrow } from 'src/engine/metadata-modules/utils/validate-field-name-availability.utils'; -import { validateMetadataNameValidityOrThrow as validateFieldNameValidityOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-validity.utils'; +import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name.utils'; import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service'; import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util'; import { @@ -62,6 +59,7 @@ import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service'; import { ViewService } from 'src/modules/view/services/view.service'; import { ViewFieldWorkspaceEntity } from 'src/modules/view/standard-objects/view-field.workspace-entity'; +import { InvalidMetadataNameException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata-name.exception'; import { FieldMetadataValidationService } from './field-metadata-validation.service'; import { FieldMetadataEntity } from './field-metadata.entity'; @@ -540,30 +538,32 @@ export class FieldMetadataService extends TypeOrmQueryService { - validateObjectMetadataInputOrThrow(input.update); + validateObjectMetadataInputNamesOrThrow(input.update); const existingObjectMetadata = await this.objectMetadataRepository.findOne({ where: { id: input.id, workspaceId: workspaceId }, @@ -242,10 +255,14 @@ export class ObjectMetadataService extends TypeOrmQueryService +>; +const validateObjectMetadataTestCases: ValidateObjectNameTestingContext[] = [ + { + title: 'when nameSingular has invalid characters', + context: { nameSingular: 'μ' }, + }, + { + title: 'when namePlural has invalid characters', + context: { namePlural: 'μ' }, + }, + { + title: 'when nameSingular is a reserved keyword', + context: { nameSingular: 'user' }, + }, + { + title: 'when namePlural is a reserved keyword', + context: { namePlural: 'users' }, + }, + { + title: 'when nameSingular is not camelCased', + context: { nameSingular: 'Not_Camel_Case' }, + }, + { + title: 'when namePlural is not camelCased', + context: { namePlural: 'Not_Camel_Case' }, + }, + { + title: 'when namePlural is an empty string', + context: { namePlural: '' }, + }, + { + title: 'when nameSingular is an empty string', + context: { nameSingular: '' }, + }, + { + title: 'when name exceeds maximum length', + context: { nameSingular: 'a'.repeat(64) }, + }, +]; -const reservedKeyword = 'user'; - -describe('validateObjectName', () => { - it('should not throw if names are valid', () => { +describe('validateObjectMetadataInputOrThrow should fail', () => { + it.each(validateObjectMetadataTestCases)('$title', ({ context }) => { expect(() => - validateObjectMetadataInputOrThrow(validObjectInput), - ).not.toThrow(); - }); - - it('should throw is nameSingular has invalid characters', () => { - const invalidObjectInput = { - ...validObjectInput, - nameSingular: 'μ', - }; - - expect(() => - validateObjectMetadataInputOrThrow(invalidObjectInput), - ).toThrow(); - }); - - it('should throw is namePlural has invalid characters', () => { - const invalidObjectInput = { - ...validObjectInput, - namePlural: 'μ', - }; - - expect(() => - validateObjectMetadataInputOrThrow(invalidObjectInput), - ).toThrow(); - }); - - it('should throw if nameSingular is a reserved keyword', async () => { - const invalidObjectInput = { - ...validObjectInput, - nameSingular: reservedKeyword, - }; - - expect(() => - validateObjectMetadataInputOrThrow(invalidObjectInput), - ).toThrow(); - }); - - it('should throw if namePlural is a reserved keyword', async () => { - const invalidObjectInput = { - ...validObjectInput, - namePlural: reservedKeyword, - }; - - expect(() => - validateObjectMetadataInputOrThrow(invalidObjectInput), - ).toThrow(); - }); - - it('should throw if nameSingular is not camelCased', async () => { - const invalidObjectInput = { - ...validObjectInput, - nameSingular: 'notACamelCase1a', - }; - - expect(() => - validateObjectMetadataInputOrThrow(invalidObjectInput), - ).toThrow(); - }); - - it('should throw if namePlural is a not camelCased', async () => { - const invalidObjectInput = { - ...validObjectInput, - namePlural: 'notACamelCase1b', - }; - - expect(() => - validateObjectMetadataInputOrThrow(invalidObjectInput), - ).toThrow(); + validateObjectMetadataInputNamesOrThrow( + getMockCreateObjectInput(context), + ), + ).toThrowErrorMatchingSnapshot(); }); }); diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/validate-object-metadata-input.util.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/validate-object-metadata-input.util.ts index 00e7246ea..2ed051c78 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/validate-object-metadata-input.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/validate-object-metadata-input.util.ts @@ -1,4 +1,5 @@ import { slugify } from 'transliteration'; +import { isDefined } from 'twenty-shared'; import { CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input'; import { UpdateObjectPayload } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input'; @@ -6,138 +7,72 @@ import { ObjectMetadataException, ObjectMetadataExceptionCode, } from 'src/engine/metadata-modules/object-metadata/object-metadata.exception'; -import { InvalidStringException } from 'src/engine/metadata-modules/utils/exceptions/invalid-string.exception'; -import { exceedsDatabaseIdentifierMaximumLength } from 'src/engine/metadata-modules/utils/validate-database-identifier-length.utils'; -import { validateMetadataNameValidityOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-validity.utils'; +import { InvalidMetadataNameException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata-name.exception'; +import { validateMetadataNameIsNotTooLongOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-not-too-long.utils'; +import { validateMetadataNameIsNotTooShortOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-not-too-short.utils'; +import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name.utils'; import { camelCase } from 'src/utils/camel-case'; -const coreObjectNames = [ - 'approvedAccessDomain', - 'approvedAccessDomains', - 'appToken', - 'appTokens', - 'billingCustomer', - 'billingCustomers', - 'billingEntitlement', - 'billingEntitlements', - 'billingMeter', - 'billingMeters', - 'billingProduct', - 'billingProducts', - 'billingSubscription', - 'billingSubscriptions', - 'billingSubscriptionItem', - 'billingSubscriptionItems', - 'featureFlag', - 'featureFlags', - 'keyValuePair', - 'keyValuePairs', - 'postgresCredential', - 'postgresCredentials', - 'twoFactorMethod', - 'twoFactorMethods', - 'user', - 'users', - 'userWorkspace', - 'userWorkspaces', - 'workspace', - 'workspaces', - - 'role', - 'roles', - 'userWorkspaceRole', - 'userWorkspaceRoles', -]; - -const reservedKeywords = [ - ...coreObjectNames, - 'event', - 'events', - 'field', - 'fields', - 'link', - 'links', - 'currency', - 'currencies', - 'fullName', - 'fullNames', - 'address', - 'addresses', - 'type', - 'types', - 'object', - 'objects', - 'index', - 'relation', - 'relations', -]; - -export const validateObjectMetadataInputOrThrow = < +export const validateObjectMetadataInputNamesOrThrow = < T extends UpdateObjectPayload | CreateObjectInput, ->( - objectMetadataInput: T, -): void => { - validateNameCamelCasedOrThrow(objectMetadataInput.nameSingular); - validateNameCamelCasedOrThrow(objectMetadataInput.namePlural); - - validateNameCharactersOrThrow(objectMetadataInput.nameSingular); - validateNameCharactersOrThrow(objectMetadataInput.namePlural); - - validateNameIsNotReservedKeywordOrThrow(objectMetadataInput.nameSingular); - validateNameIsNotReservedKeywordOrThrow(objectMetadataInput.namePlural); - - validateNameIsNotTooLongThrow(objectMetadataInput.nameSingular); - validateNameIsNotTooLongThrow(objectMetadataInput.namePlural); -}; - -const validateNameIsNotReservedKeywordOrThrow = (name?: string) => { - if (name) { - if (reservedKeywords.includes(name)) { - throw new ObjectMetadataException( - `The name "${name}" is not available`, - ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT, - ); +>({ + namePlural, + nameSingular, +}: T): void => + [namePlural, nameSingular].forEach((name) => { + if (!isDefined(name)) { + return; } - } -}; + validateObjectMetadataInputNameOrThrow(name); + }); -const validateNameCamelCasedOrThrow = (name?: string) => { - if (name && name !== camelCase(name)) { - throw new ObjectMetadataException( - `Name should be in camelCase: ${name}`, - ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT, - ); - } -}; - -const validateNameIsNotTooLongThrow = (name?: string) => { - if (name && exceedsDatabaseIdentifierMaximumLength(name)) { - throw new ObjectMetadataException( - `Name exceeds 63 characters: ${name}`, - ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT, - ); - } -}; - -const validateNameCharactersOrThrow = (name?: string) => { +export const validateObjectMetadataInputNameOrThrow = (name: string): void => { try { - if (name) { - validateMetadataNameValidityOrThrow(name); - } + validateMetadataNameOrThrow(name); } catch (error) { - if (error instanceof InvalidStringException) { + if (error instanceof InvalidMetadataNameException) { throw new ObjectMetadataException( - `Characters used in name "${name}" are not supported`, + error.message, ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT, ); - } else { - throw error; } + + throw error; + } +}; + +export const validateObjectMetadataInputLabelsOrThrow = < + T extends CreateObjectInput, +>({ + labelPlural, + labelSingular, +}: T): void => + [labelPlural, labelSingular].forEach((label) => + validateObjectMetadataInputLabelOrThrow(label), + ); + +const validateObjectMetadataInputLabelOrThrow = (name: string): void => { + const validators = [ + validateMetadataNameIsNotTooShortOrThrow, + validateMetadataNameIsNotTooLongOrThrow, + ]; + + try { + validators.forEach((validator) => validator(name.trim())); + } catch (error) { + if (error instanceof InvalidMetadataNameException) { + throw new ObjectMetadataException( + error.message, + ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT, + ); + } + + throw error; } }; export const computeMetadataNameFromLabel = (label: string): string => { - if (!label) { + if (!isDefined(label)) { throw new ObjectMetadataException( 'Label is required', ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT, @@ -180,13 +115,17 @@ export const validateNameAndLabelAreSyncOrThrow = ( } }; -export const validateNameSingularAndNamePluralAreDifferentOrThrow = ( - nameSingular: string, - namePlural: string, -) => { - if (nameSingular === namePlural) { +type ValidateLowerCasedAndTrimmedStringAreDifferentOrThrowArgs = { + inputs: [string, string]; + message: string; +}; +export const validateLowerCasedAndTrimmedStringsAreDifferentOrThrow = ({ + message, + inputs: [firstString, secondString], +}: ValidateLowerCasedAndTrimmedStringAreDifferentOrThrowArgs) => { + if (firstString.trim().toLowerCase() === secondString.trim().toLowerCase()) { throw new ObjectMetadataException( - 'The singular and plural name cannot be the same for an object', + message, ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT, ); } diff --git a/packages/twenty-server/src/engine/metadata-modules/relation-metadata/relation-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/relation-metadata/relation-metadata.service.ts index 6d5510169..71d7c636e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/relation-metadata/relation-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/relation-metadata/relation-metadata.service.ts @@ -19,9 +19,8 @@ import { RelationMetadataException, RelationMetadataExceptionCode, } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.exception'; -import { InvalidStringException } from 'src/engine/metadata-modules/utils/exceptions/invalid-string.exception'; import { validateFieldNameAvailabilityOrThrow } from 'src/engine/metadata-modules/utils/validate-field-name-availability.utils'; -import { validateMetadataNameValidityOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-validity.utils'; +import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name.utils'; import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service'; import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util'; import { @@ -34,6 +33,7 @@ import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service'; import { BASE_OBJECT_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; +import { InvalidMetadataNameException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata-name.exception'; import { RelationMetadataEntity, @@ -67,17 +67,15 @@ export class RelationMetadataService extends TypeOrmQueryService; + +const validateFieldNameAvailabilityTestCases: ValidateFieldNameAvailabilityTestContext[] = + [ + { + title: 'does not throw if name is not reserved', + context: { + input: 'testName', + shouldNotThrow: true, + }, + }, + { + title: 'throws error with LINKS suffixes', + context: { + input: `${FIELD_LINKS_MOCK_NAME}PrimaryLinkLabel`, + }, + }, + { + title: 'throws error with CURRENCY suffixes', + context: { + input: `${FIELD_CURRENCY_MOCK_NAME}AmountMicros`, + }, + }, + { + title: 'throws error with FULL_NAME suffixes', + context: { + input: `${FIELD_FULL_NAME_MOCK_NAME}FirstName`, + }, + }, + { + title: 'throws error with ACTOR suffixes', + context: { + input: `${FIELD_ACTOR_MOCK_NAME}Name`, + }, + }, + { + title: 'throws error with ADDRESS suffixes', + context: { + input: `${FIELD_ADDRESS_MOCK_NAME}AddressStreet1`, + }, + }, + ]; + describe('validateFieldNameAvailabilityOrThrow', () => { const objectMetadata = objectMetadataItemMock; - it('does not throw if name is not reserved', () => { - const name = 'testName'; - - expect(() => - validateFieldNameAvailabilityOrThrow(name, objectMetadata), - ).not.toThrow(); - }); - - describe('error cases', () => { - it('throws error with LINKS suffixes', () => { - const name = `${FIELD_LINKS_MOCK_NAME}PrimaryLinkLabel`; - - expect(() => - validateFieldNameAvailabilityOrThrow(name, objectMetadata), - ).toThrow(NameNotAvailableException); - }); - it('throws error with CURRENCY suffixes', () => { - const name = `${FIELD_CURRENCY_MOCK_NAME}AmountMicros`; - - expect(() => - validateFieldNameAvailabilityOrThrow(name, objectMetadata), - ).toThrow(NameNotAvailableException); - }); - it('throws error with FULL_NAME suffixes', () => { - const name = `${FIELD_FULL_NAME_MOCK_NAME}FirstName`; - - expect(() => - validateFieldNameAvailabilityOrThrow(name, objectMetadata), - ).toThrow(NameNotAvailableException); - }); - it('throws error with ACTOR suffixes', () => { - const name = `${FIELD_ACTOR_MOCK_NAME}Name`; - - expect(() => - validateFieldNameAvailabilityOrThrow(name, objectMetadata), - ).toThrow(NameNotAvailableException); - }); - it('throws error with ADDRESS suffixes', () => { - const name = `${FIELD_ADDRESS_MOCK_NAME}AddressStreet1`; - - expect(() => - validateFieldNameAvailabilityOrThrow(name, objectMetadata), - ).toThrow(NameNotAvailableException); - }); - }); + it.each(validateFieldNameAvailabilityTestCases)( + '$title', + ({ context: { input, shouldNotThrow } }) => { + if (shouldNotThrow) { + expect(() => + validateFieldNameAvailabilityOrThrow(input, objectMetadata), + ).not.toThrow(); + } else { + expect(() => + validateFieldNameAvailabilityOrThrow(input, objectMetadata), + ).toThrowErrorMatchingSnapshot(); + } + }, + ); }); diff --git a/packages/twenty-server/src/engine/metadata-modules/utils/__tests__/validate-metadata-name-validity.spec.ts b/packages/twenty-server/src/engine/metadata-modules/utils/__tests__/validate-metadata-name-validity.spec.ts deleted file mode 100644 index 6cd9bd2d6..000000000 --- a/packages/twenty-server/src/engine/metadata-modules/utils/__tests__/validate-metadata-name-validity.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { InvalidStringException } from 'src/engine/metadata-modules/utils/exceptions/invalid-string.exception'; -import { NameTooLongException } from 'src/engine/metadata-modules/utils/exceptions/name-too-long.exception'; -import { validateMetadataNameValidityOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-validity.utils'; - -describe('validateMetadataNameValidityOrThrow', () => { - it('does not throw if string is valid', () => { - const input = 'testName'; - - expect(validateMetadataNameValidityOrThrow(input)).not.toThrow; - }); - it('throws error if string has spaces', () => { - const input = 'name with spaces'; - - expect(() => validateMetadataNameValidityOrThrow(input)).toThrow( - InvalidStringException, - ); - }); - it('throws error if string starts with capital letter', () => { - const input = 'StringStartingWithCapitalLetter'; - - expect(() => validateMetadataNameValidityOrThrow(input)).toThrow( - InvalidStringException, - ); - }); - - it('throws error if string has non latin characters', () => { - const input = 'בְרִבְרִ'; - - expect(() => validateMetadataNameValidityOrThrow(input)).toThrow( - InvalidStringException, - ); - }); - - it('throws error if starts with digits', () => { - const input = '123string'; - - expect(() => validateMetadataNameValidityOrThrow(input)).toThrow( - InvalidStringException, - ); - }); - it('does not throw if string is less than 63 characters', () => { - const inputWith63Characters = - 'thisIsAstringWithSixtyThreeCharacters11111111111111111111111111'; - - expect(validateMetadataNameValidityOrThrow(inputWith63Characters)).not - .toThrow; - }); - it('throws error if string is above 63 characters', () => { - const inputWith64Characters = - 'thisIsAstringWithSixtyFourCharacters1111111111111111111111111111'; - - expect(() => - validateMetadataNameValidityOrThrow(inputWith64Characters), - ).toThrow(NameTooLongException); - }); -}); diff --git a/packages/twenty-server/src/engine/metadata-modules/utils/__tests__/validate-metadata-name.spec.ts b/packages/twenty-server/src/engine/metadata-modules/utils/__tests__/validate-metadata-name.spec.ts new file mode 100644 index 000000000..51f36a78c --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/utils/__tests__/validate-metadata-name.spec.ts @@ -0,0 +1,88 @@ +import { EachTestingContext } from 'twenty-shared'; + +import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name.utils'; + +type ValidateMetadataNameTestContext = EachTestingContext<{ + input: string; + shouldNotThrow?: true; +}>; + +const validateMetadataNameTestCases: ValidateMetadataNameTestContext[] = [ + { + title: 'validates when string is valid', + context: { + input: 'testName', + shouldNotThrow: true, + }, + }, + { + title: 'throw error when string is not in camel case', + context: { + input: 'TestName', + }, + }, + { + title: 'throws error when string has spaces', + context: { + input: 'name with spaces', + }, + }, + { + title: 'throws error when string is a reserved word', + context: { + input: 'role', + }, + }, + { + title: 'throws error when string starts with capital letter', + context: { + input: 'StringStartingWithCapitalLetter', + }, + }, + { + title: 'throws error when string has non latin characters', + context: { + input: 'בְרִבְרִ', + }, + }, + { + title: 'throws error when starts with digits', + context: { + input: '123string', + }, + }, + { + title: 'validates when string is less than 63 characters', + context: { + input: 'a'.repeat(63), + shouldNotThrow: true, + }, + }, + { + title: 'throws error when string is above 63 characters', + context: { + input: 'a'.repeat(64), + }, + }, + { + title: 'throws error when string is empty', + context: { + input: '', + }, + }, +]; + +describe('validateMetadataNameOrThrow', () => { + it.each(validateMetadataNameTestCases)( + '$title', + ({ context: { input, shouldNotThrow } }) => { + if (shouldNotThrow) { + expect(() => validateMetadataNameOrThrow(input)).not.toThrow(); + } else { + expect(() => + validateMetadataNameOrThrow(input), + ).toThrowErrorMatchingSnapshot(); + } + }, + ); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/utils/constants/identifier-min-char-length.constants.ts b/packages/twenty-server/src/engine/metadata-modules/utils/constants/identifier-min-char-length.constants.ts new file mode 100644 index 000000000..ba267790e --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/utils/constants/identifier-min-char-length.constants.ts @@ -0,0 +1 @@ +export const IDENTIFIER_MIN_CHAR_LENGTH = 1; diff --git a/packages/twenty-server/src/engine/metadata-modules/utils/exceptions/invalid-metadata-name.exception.ts b/packages/twenty-server/src/engine/metadata-modules/utils/exceptions/invalid-metadata-name.exception.ts new file mode 100644 index 000000000..dd985672d --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/utils/exceptions/invalid-metadata-name.exception.ts @@ -0,0 +1,5 @@ +export class InvalidMetadataNameException extends Error { + constructor(message: string) { + super(message); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/utils/exceptions/invalid-string.exception.ts b/packages/twenty-server/src/engine/metadata-modules/utils/exceptions/invalid-string.exception.ts deleted file mode 100644 index 0404de70b..000000000 --- a/packages/twenty-server/src/engine/metadata-modules/utils/exceptions/invalid-string.exception.ts +++ /dev/null @@ -1,7 +0,0 @@ -export class InvalidStringException extends Error { - constructor(string: string) { - const message = `String "${string}" is not valid`; - - super(message); - } -} diff --git a/packages/twenty-server/src/engine/metadata-modules/utils/exceptions/name-not-available.exception.ts b/packages/twenty-server/src/engine/metadata-modules/utils/exceptions/name-not-available.exception.ts deleted file mode 100644 index 308fc6ddd..000000000 --- a/packages/twenty-server/src/engine/metadata-modules/utils/exceptions/name-not-available.exception.ts +++ /dev/null @@ -1,7 +0,0 @@ -export class NameNotAvailableException extends Error { - constructor(name: string) { - const message = `Name "${name}" is not available`; - - super(message); - } -} diff --git a/packages/twenty-server/src/engine/metadata-modules/utils/exceptions/name-too-long.exception.ts b/packages/twenty-server/src/engine/metadata-modules/utils/exceptions/name-too-long.exception.ts deleted file mode 100644 index 0c9cf45f6..000000000 --- a/packages/twenty-server/src/engine/metadata-modules/utils/exceptions/name-too-long.exception.ts +++ /dev/null @@ -1,7 +0,0 @@ -export class NameTooLongException extends Error { - constructor(string: string) { - const message = `String "${string}" exceeds 63 characters limit`; - - super(message); - } -} diff --git a/packages/twenty-server/src/engine/metadata-modules/utils/validate-database-identifier-length.utils.ts b/packages/twenty-server/src/engine/metadata-modules/utils/validate-database-identifier-length.utils.ts index b798049e5..2776fb920 100644 --- a/packages/twenty-server/src/engine/metadata-modules/utils/validate-database-identifier-length.utils.ts +++ b/packages/twenty-server/src/engine/metadata-modules/utils/validate-database-identifier-length.utils.ts @@ -1,5 +1,8 @@ import { IDENTIFIER_MAX_CHAR_LENGTH } from 'src/engine/metadata-modules/utils/constants/identifier-max-char-length.constants'; +import { IDENTIFIER_MIN_CHAR_LENGTH } from 'src/engine/metadata-modules/utils/constants/identifier-min-char-length.constants'; -export const exceedsDatabaseIdentifierMaximumLength = (string: string) => { - return string.length > IDENTIFIER_MAX_CHAR_LENGTH; -}; +export const exceedsDatabaseIdentifierMaximumLength = (string: string) => + string.length > IDENTIFIER_MAX_CHAR_LENGTH; + +export const beneathDatabaseIdentifierMinimumLength = (string: string) => + string.length < IDENTIFIER_MIN_CHAR_LENGTH; diff --git a/packages/twenty-server/src/engine/metadata-modules/utils/validate-field-name-availability.utils.ts b/packages/twenty-server/src/engine/metadata-modules/utils/validate-field-name-availability.utils.ts index 974971fbc..adbd0948f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/utils/validate-field-name-availability.utils.ts +++ b/packages/twenty-server/src/engine/metadata-modules/utils/validate-field-name-availability.utils.ts @@ -2,7 +2,7 @@ import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-meta import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util'; import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; -import { NameNotAvailableException } from 'src/engine/metadata-modules/utils/exceptions/name-not-available.exception'; +import { InvalidMetadataNameException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata-name.exception'; const getReservedCompositeFieldNames = ( objectMetadata: ObjectMetadataEntity, @@ -33,10 +33,10 @@ export const validateFieldNameAvailabilityOrThrow = ( getReservedCompositeFieldNames(objectMetadata); if (objectMetadata.fields.some((field) => field.name === name)) { - throw new NameNotAvailableException(name); + throw new InvalidMetadataNameException(`Name "${name}" is not available`); } if (reservedCompositeFieldsNames.includes(name)) { - throw new NameNotAvailableException(name); + throw new InvalidMetadataNameException(`Name "${name}" is not available`); } }; diff --git a/packages/twenty-server/src/engine/metadata-modules/utils/validate-metadata-name-is-camel-case.utils.ts b/packages/twenty-server/src/engine/metadata-modules/utils/validate-metadata-name-is-camel-case.utils.ts new file mode 100644 index 000000000..ef89e0d76 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/utils/validate-metadata-name-is-camel-case.utils.ts @@ -0,0 +1,11 @@ +import camelCase from 'lodash.camelcase'; + +import { InvalidMetadataNameException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata-name.exception'; + +export const validateMetadataNameIsCamelCaseOrThrow = (name: string) => { + if (name !== camelCase(name)) { + throw new InvalidMetadataNameException( + `Name should be in camelCase: ${name}`, + ); + } +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/utils/validate-metadata-name-is-not-reserved-keyword.ts b/packages/twenty-server/src/engine/metadata-modules/utils/validate-metadata-name-is-not-reserved-keyword.ts new file mode 100644 index 000000000..c8073ae7e --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/utils/validate-metadata-name-is-not-reserved-keyword.ts @@ -0,0 +1,71 @@ +import { InvalidMetadataNameException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata-name.exception'; + +const coreObjectNames = [ + 'approvedAccessDomain', + 'approvedAccessDomains', + 'appToken', + 'appTokens', + 'billingCustomer', + 'billingCustomers', + 'billingEntitlement', + 'billingEntitlements', + 'billingMeter', + 'billingMeters', + 'billingProduct', + 'billingProducts', + 'billingSubscription', + 'billingSubscriptions', + 'billingSubscriptionItem', + 'billingSubscriptionItems', + 'featureFlag', + 'featureFlags', + 'keyValuePair', + 'keyValuePairs', + 'postgresCredential', + 'postgresCredentials', + 'twoFactorMethod', + 'twoFactorMethods', + 'user', + 'users', + 'userWorkspace', + 'userWorkspaces', + 'workspace', + 'workspaces', + 'role', + 'roles', + 'userWorkspaceRole', + 'userWorkspaceRoles', +]; + +const reservedKeywords = [ + ...coreObjectNames, + 'event', + 'events', + 'field', + 'fields', + 'link', + 'links', + 'currency', + 'currencies', + 'fullName', + 'fullNames', + 'address', + 'addresses', + 'type', + 'types', + 'object', + 'objects', + 'index', + 'relation', + 'relations', +]; + +export const validateMetadataNameIsNotReservedKeywordOrThrow = ( + name: string, +) => { + if (reservedKeywords.includes(name)) { + throw new InvalidMetadataNameException( + `The name "${name}" is not available`, + ); + } +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/utils/validate-metadata-name-is-not-too-long.utils.ts b/packages/twenty-server/src/engine/metadata-modules/utils/validate-metadata-name-is-not-too-long.utils.ts new file mode 100644 index 000000000..ae4cd293d --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/utils/validate-metadata-name-is-not-too-long.utils.ts @@ -0,0 +1,10 @@ +import { InvalidMetadataNameException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata-name.exception'; +import { exceedsDatabaseIdentifierMaximumLength } from 'src/engine/metadata-modules/utils/validate-database-identifier-length.utils'; + +export const validateMetadataNameIsNotTooLongOrThrow = (name: string) => { + if (exceedsDatabaseIdentifierMaximumLength(name)) { + throw new InvalidMetadataNameException( + `String "${name}" exceeds 63 characters limit`, + ); + } +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/utils/validate-metadata-name-is-not-too-short.utils.ts b/packages/twenty-server/src/engine/metadata-modules/utils/validate-metadata-name-is-not-too-short.utils.ts new file mode 100644 index 000000000..52077b577 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/utils/validate-metadata-name-is-not-too-short.utils.ts @@ -0,0 +1,8 @@ +import { InvalidMetadataNameException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata-name.exception'; +import { beneathDatabaseIdentifierMinimumLength } from 'src/engine/metadata-modules/utils/validate-database-identifier-length.utils'; + +export const validateMetadataNameIsNotTooShortOrThrow = (name: string) => { + if (beneathDatabaseIdentifierMinimumLength(name)) { + throw new InvalidMetadataNameException(`Input is too short: "${name}"`); + } +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/utils/validate-metadata-name-start-with-lowercase-letter-and-contain-digits-nor-letters.utils.ts b/packages/twenty-server/src/engine/metadata-modules/utils/validate-metadata-name-start-with-lowercase-letter-and-contain-digits-nor-letters.utils.ts new file mode 100644 index 000000000..fd80f678e --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/utils/validate-metadata-name-start-with-lowercase-letter-and-contain-digits-nor-letters.utils.ts @@ -0,0 +1,17 @@ +import { InvalidMetadataNameException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata-name.exception'; + +const STARTS_WITH_LOWER_CASE_AND_CONTAINS_ONLY_CAPS_AND_LOWER_LETTERS_AND_NUMBER_STRING_REGEX = + /^[a-z][a-zA-Z0-9]*$/; + +export const validateMetadataNameStartWithLowercaseLetterAndContainDigitsNorLettersOrThrow = + (name: string) => { + if ( + !name.match( + STARTS_WITH_LOWER_CASE_AND_CONTAINS_ONLY_CAPS_AND_LOWER_LETTERS_AND_NUMBER_STRING_REGEX, + ) + ) { + throw new InvalidMetadataNameException( + `String "${name}" is not valid: must start with lowercase letter and contain only alphanumeric letters`, + ); + } + }; diff --git a/packages/twenty-server/src/engine/metadata-modules/utils/validate-metadata-name-validity.utils.ts b/packages/twenty-server/src/engine/metadata-modules/utils/validate-metadata-name-validity.utils.ts deleted file mode 100644 index 42cb2fc70..000000000 --- a/packages/twenty-server/src/engine/metadata-modules/utils/validate-metadata-name-validity.utils.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { InvalidStringException } from 'src/engine/metadata-modules/utils/exceptions/invalid-string.exception'; -import { NameTooLongException } from 'src/engine/metadata-modules/utils/exceptions/name-too-long.exception'; -import { exceedsDatabaseIdentifierMaximumLength } from 'src/engine/metadata-modules/utils/validate-database-identifier-length.utils'; - -const VALID_STRING_PATTERN = /^[a-z][a-zA-Z0-9]*$/; - -export const validateMetadataNameValidityOrThrow = (name: string) => { - if (!name.match(VALID_STRING_PATTERN)) { - throw new InvalidStringException(name); - } - if (exceedsDatabaseIdentifierMaximumLength(name)) { - throw new NameTooLongException(name); - } -}; diff --git a/packages/twenty-server/src/engine/metadata-modules/utils/validate-metadata-name.utils.ts b/packages/twenty-server/src/engine/metadata-modules/utils/validate-metadata-name.utils.ts new file mode 100644 index 000000000..663bb0291 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/utils/validate-metadata-name.utils.ts @@ -0,0 +1,17 @@ +import { validateMetadataNameIsCamelCaseOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-camel-case.utils'; +import { validateMetadataNameIsNotReservedKeywordOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-not-reserved-keyword'; +import { validateMetadataNameIsNotTooLongOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-not-too-long.utils'; +import { validateMetadataNameIsNotTooShortOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-not-too-short.utils'; +import { validateMetadataNameStartWithLowercaseLetterAndContainDigitsNorLettersOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-start-with-lowercase-letter-and-contain-digits-nor-letters.utils'; + +export const validateMetadataNameOrThrow = (name: string): void => { + const validators = [ + validateMetadataNameIsNotTooLongOrThrow, + validateMetadataNameIsNotTooShortOrThrow, + validateMetadataNameIsCamelCaseOrThrow, + validateMetadataNameStartWithLowercaseLetterAndContainDigitsNorLettersOrThrow, + validateMetadataNameIsNotReservedKeywordOrThrow, + ]; + + validators.forEach((validator) => validator(name)); +}; diff --git a/packages/twenty-server/test/integration/metadata/suites/object-metadata/__snapshots__/failing-create-one-object-metadata.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/object-metadata/__snapshots__/failing-create-one-object-metadata.integration-spec.ts.snap new file mode 100644 index 000000000..5cc944696 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/object-metadata/__snapshots__/failing-create-one-object-metadata.integration-spec.ts.snap @@ -0,0 +1,43 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Object metadata creation should fail when labelPlural contains only whitespace 1`] = `"Input is too short: """`; + +exports[`Object metadata creation should fail when labelPlural exceeds maximum length 1`] = `"String "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" exceeds 63 characters limit"`; + +exports[`Object metadata creation should fail when labelPlural is empty 1`] = `"Input is too short: """`; + +exports[`Object metadata creation should fail when labelSingular contains only whitespace 1`] = `"Input is too short: """`; + +exports[`Object metadata creation should fail when labelSingular exceeds maximum length 1`] = `"String "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" exceeds 63 characters limit"`; + +exports[`Object metadata creation should fail when labelSingular is empty 1`] = `"Input is too short: """`; + +exports[`Object metadata creation should fail when labels are identical 1`] = `"The singular and plural labels cannot be the same for an object"`; + +exports[`Object metadata creation should fail when labels with whitespaces result to be identical 1`] = `"The singular and plural labels cannot be the same for an object"`; + +exports[`Object metadata creation should fail when name exceeds maximum length 1`] = `"String "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" exceeds 63 characters limit"`; + +exports[`Object metadata creation should fail when namePlural has invalid characters 1`] = `"String "μ" is not valid: must start with lowercase letter and contain only alphanumeric letters"`; + +exports[`Object metadata creation should fail when namePlural is a reserved keyword 1`] = `"The name "users" is not available"`; + +exports[`Object metadata creation should fail when namePlural is an empty string 1`] = `"Input is too short: """`; + +exports[`Object metadata creation should fail when namePlural is not camelCased 1`] = `"Name should be in camelCase: Not_Camel_Case"`; + +exports[`Object metadata creation should fail when nameSingular contains only one char and whitespaces 1`] = `"Name should be in camelCase: a a "`; + +exports[`Object metadata creation should fail when nameSingular contains only whitespaces 1`] = `"Name should be in camelCase: "`; + +exports[`Object metadata creation should fail when nameSingular has invalid characters 1`] = `"String "μ" is not valid: must start with lowercase letter and contain only alphanumeric letters"`; + +exports[`Object metadata creation should fail when nameSingular is a reserved keyword 1`] = `"The name "user" is not available"`; + +exports[`Object metadata creation should fail when nameSingular is an empty string 1`] = `"Input is too short: """`; + +exports[`Object metadata creation should fail when nameSingular is not camelCased 1`] = `"Name should be in camelCase: Not_Camel_Case"`; + +exports[`Object metadata creation should fail when names are identical 1`] = `"The singular and plural names cannot be the same for an object"`; + +exports[`Object metadata creation should fail when names with whitespaces result to be identical 1`] = `"Name should be in camelCase: fooBar "`; diff --git a/packages/twenty-server/test/integration/metadata/suites/object-metadata/failing-create-one-object-metadata.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/object-metadata/failing-create-one-object-metadata.integration-spec.ts new file mode 100644 index 000000000..31e4b7ba8 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/object-metadata/failing-create-one-object-metadata.integration-spec.ts @@ -0,0 +1,137 @@ +import { getMockCreateObjectInput } from 'test/integration/utils/object-metadata/generate-mock-create-object-metadata-input'; +import { performFailingObjectMetadataCreation } from 'test/integration/utils/object-metadata/perform-failing-object-metadata-creation'; +import { EachTestingContext } from 'twenty-shared'; + +import { CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input'; +import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; + +type CreateObjectInputPayload = Omit< + CreateObjectInput, + 'workspaceId' | 'dataSourceId' +>; + +type CreateOneObjectMetadataItemTestingContext = EachTestingContext< + Partial +>[]; +const failingNamesCreationTestsUseCase: CreateOneObjectMetadataItemTestingContext = + [ + { + title: 'when nameSingular has invalid characters', + context: { nameSingular: 'μ' }, + }, + { + title: 'when namePlural has invalid characters', + context: { namePlural: 'μ' }, + }, + { + title: 'when nameSingular is a reserved keyword', + context: { nameSingular: 'user' }, + }, + { + title: 'when namePlural is a reserved keyword', + context: { namePlural: 'users' }, + }, + { + title: 'when nameSingular is not camelCased', + context: { nameSingular: 'Not_Camel_Case' }, + }, + { + title: 'when namePlural is not camelCased', + context: { namePlural: 'Not_Camel_Case' }, + }, + { + title: 'when namePlural is an empty string', + context: { namePlural: '' }, + }, + { + title: 'when nameSingular is an empty string', + context: { nameSingular: '' }, + }, + { + title: 'when nameSingular contains only whitespaces', + context: { nameSingular: ' ' }, + }, + { + title: 'when nameSingular contains only one char and whitespaces', + context: { nameSingular: ' a a ' }, + }, + { + title: 'when name exceeds maximum length', + context: { nameSingular: 'a'.repeat(64) }, + }, + { + title: 'when names are identical', + context: { + nameSingular: 'fooBar', + namePlural: 'fooBar', + }, + }, + { + title: 'when names with whitespaces result to be identical', + context: { + nameSingular: ' fooBar ', + namePlural: 'fooBar', + }, + }, + ]; + +const failingLabelsCreationTestsUseCase: CreateOneObjectMetadataItemTestingContext = + [ + { + title: 'when labelSingular is empty', + context: { labelSingular: '' }, + }, + { + title: 'when labelPlural is empty', + context: { labelPlural: '' }, + }, + { + title: 'when labelSingular exceeds maximum length', + context: { labelSingular: 'A'.repeat(64) }, + }, + { + title: 'when labelPlural exceeds maximum length', + context: { labelPlural: 'A'.repeat(64) }, + }, + { + title: 'when labelSingular contains only whitespace', + context: { labelSingular: ' ' }, + }, + { + title: 'when labelPlural contains only whitespace', + context: { labelPlural: ' ' }, + }, + { + title: 'when labels are identical', + context: { + labelPlural: 'fooBar', + labelSingular: 'fooBar', + }, + }, + { + title: 'when labels with whitespaces result to be identical', + context: { + labelPlural: ' fooBar ', + labelSingular: 'fooBar', + }, + }, + ]; + +const allTestsUseCases = [ + ...failingNamesCreationTestsUseCase, + ...failingLabelsCreationTestsUseCase, +]; + +describe('Object metadata creation should fail', () => { + it.each(allTestsUseCases)('$title', async ({ context }) => { + const errors = await performFailingObjectMetadataCreation( + getMockCreateObjectInput(context), + ); + + expect(errors.length).toBe(1); + const firstError = errors[0]; + + expect(firstError.extensions.code).toBe(ErrorCode.BAD_USER_INPUT); + expect(firstError.message).toMatchSnapshot(); + }); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/object-metadata/successful-create-one-object-metadata.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/object-metadata/successful-create-one-object-metadata.integration-spec.ts new file mode 100644 index 000000000..078016a30 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/object-metadata/successful-create-one-object-metadata.integration-spec.ts @@ -0,0 +1,38 @@ +import { deleteOneObjectMetadataItem } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; +import { getMockCreateObjectInput } from 'test/integration/utils/object-metadata/generate-mock-create-object-metadata-input'; +import { performObjectMetadataCreation } from 'test/integration/utils/object-metadata/perform-object-metadata-creation'; +import { EachTestingContext } from 'twenty-shared'; + +import { CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input'; + +type CreateObjectInputPayload = Omit< + CreateObjectInput, + 'workspaceId' | 'dataSourceId' +>; + +type CreateOneObjectMetadataItemTestingContext = EachTestingContext< + Partial +>[]; +const successfulObjectMetadataItemCreateOneUseCase: CreateOneObjectMetadataItemTestingContext = + [ + { + title: 'with basic input', + context: {}, + }, + // TODO populate + ]; + +const allTestsUseCases = [...successfulObjectMetadataItemCreateOneUseCase]; + +describe('Object metadata creation should succeed', () => { + it.each(allTestsUseCases)('$title', async ({ context }) => { + const response = await performObjectMetadataCreation( + getMockCreateObjectInput(context), + ); + + expect(response.body.data.createOneObject.id).toBeDefined(); + await deleteOneObjectMetadataItem( + response.body.data.createOneObject.id, + ).catch(); + }); +}); diff --git a/packages/twenty-server/test/integration/utils/object-metadata/generate-mock-create-object-metadata-input.ts b/packages/twenty-server/test/integration/utils/object-metadata/generate-mock-create-object-metadata-input.ts new file mode 100644 index 000000000..50fc02817 --- /dev/null +++ b/packages/twenty-server/test/integration/utils/object-metadata/generate-mock-create-object-metadata-input.ts @@ -0,0 +1,15 @@ +import { CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input'; + +// TODO would tend to use faker +export const getMockCreateObjectInput = ( + overrides?: Partial>, +) => ({ + namePlural: 'listings', + nameSingular: 'listing', + labelPlural: 'Listings', + labelSingular: 'Listing', + description: 'Listing object', + icon: 'IconListNumbers', + isLabelSyncedWithName: false, + ...overrides, +}); diff --git a/packages/twenty-server/test/integration/utils/object-metadata/perform-failing-object-metadata-creation.ts b/packages/twenty-server/test/integration/utils/object-metadata/perform-failing-object-metadata-creation.ts new file mode 100644 index 000000000..24d66352e --- /dev/null +++ b/packages/twenty-server/test/integration/utils/object-metadata/perform-failing-object-metadata-creation.ts @@ -0,0 +1,28 @@ +import { deleteOneObjectMetadataItem } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; +import { performObjectMetadataCreation } from 'test/integration/utils/object-metadata/perform-object-metadata-creation'; +import { isDefined } from 'twenty-shared'; + +import { CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input'; + +export const performFailingObjectMetadataCreation = async ( + objectInput: Omit, +) => { + const response = await performObjectMetadataCreation(objectInput); + + if (isDefined(response.body.data)) { + try { + const createdId = response.body.data.createOneObject.id; + + await deleteOneObjectMetadataItem(createdId); + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + } + expect(false).toEqual( + 'Object Metadata Item should have failed but did not', + ); + } + expect(response.body.errors.length).toBeGreaterThan(0); + + return response.body.errors; +}; diff --git a/packages/twenty-server/test/integration/utils/object-metadata/perform-object-metadata-creation.ts b/packages/twenty-server/test/integration/utils/object-metadata/perform-object-metadata-creation.ts new file mode 100644 index 000000000..d62d12a1d --- /dev/null +++ b/packages/twenty-server/test/integration/utils/object-metadata/perform-object-metadata-creation.ts @@ -0,0 +1,18 @@ +import { createOneObjectMetadataFactory } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata-factory.util'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; + +import { CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input'; + +export const performObjectMetadataCreation = async ( + args: Omit, +) => { + const graphqlOperation = createOneObjectMetadataFactory({ + input: { object: args }, + gqlFields: ` + id + nameSingular + `, + }); + + return await makeMetadataAPIRequest(graphqlOperation); +}; diff --git a/packages/twenty-shared/src/index.ts b/packages/twenty-shared/src/index.ts index 6404c1b7d..8122da452 100644 --- a/packages/twenty-shared/src/index.ts +++ b/packages/twenty-shared/src/index.ts @@ -1,5 +1,6 @@ export * from './constants'; export * from './i18n'; +export * from './testing'; export * from './types'; export * from './utils'; export * from './workspace'; diff --git a/packages/twenty-shared/src/testing/index.ts b/packages/twenty-shared/src/testing/index.ts new file mode 100644 index 000000000..b20959e67 --- /dev/null +++ b/packages/twenty-shared/src/testing/index.ts @@ -0,0 +1 @@ +export * from './types/EachTestingContext.type'; diff --git a/packages/twenty-front/src/types/EachTestingContext.ts b/packages/twenty-shared/src/testing/types/EachTestingContext.type.ts similarity index 100% rename from packages/twenty-front/src/types/EachTestingContext.ts rename to packages/twenty-shared/src/testing/types/EachTestingContext.type.ts diff --git a/packages/twenty-shared/src/types/index.ts b/packages/twenty-shared/src/types/index.ts index f29f71571..aaa4c8053 100644 --- a/packages/twenty-shared/src/types/index.ts +++ b/packages/twenty-shared/src/types/index.ts @@ -1,4 +1,3 @@ export * from './ConnectedAccountProvider'; export * from './FieldMetadataType'; export * from './IsExactly'; - diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index f7747df2b..602bc60d3 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -4,4 +4,3 @@ export * from './image'; export * from './strings'; export * from './url'; export * from './validation'; - diff --git a/packages/twenty-shared/src/utils/validation/__tests__/isValideLocale.test.ts b/packages/twenty-shared/src/utils/validation/__tests__/isValideLocale.test.ts index 92d48184a..e74e5e906 100644 --- a/packages/twenty-shared/src/utils/validation/__tests__/isValideLocale.test.ts +++ b/packages/twenty-shared/src/utils/validation/__tests__/isValideLocale.test.ts @@ -1,5 +1,5 @@ +import { APP_LOCALES } from 'src/i18n/constants/AppLocales'; import { isValidLocale } from '../isValidLocale'; -import { APP_LOCALES } from 'src/constants/Locales'; describe('isValidLocale', () => { it('should return true for valid locales', () => {