From 45c89a46d649c5b2941e6afc51081e4001ef74f8 Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Thu, 22 May 2025 17:58:59 +0200 Subject: [PATCH] FieldMetadata ENUM `CREATE` `UPDATE` server validation and integration tests (#12121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Introduction Big diff a lot of tests and snapshots ( real diff < 500+ ) close https://github.com/twentyhq/twenty/issues/12117 close https://github.com/twentyhq/twenty/issues/12133 ## What has been done here Implemented a strong integration coverage on both fieldmetadata`SELECT` `UPDATE` and `CREATE`. Implemented server side validation for the options `value` `label` `id` and collision issue with also `position` We could improve: - Position validation - DefaultValue validation ## Update ```ts PASS test/integration/metadata/suites/field-metadata/update-one-field-metadata-select.integration-spec.ts (41.054 s) Field metadata select update tests group ✓ Update should succeed with provided option id (2565 ms) ✓ Update should succeed with valid default value (1469 ms) ✓ Update should succeed with various options id (1257 ms) ✓ Update should succeed without option id (1286 ms) ✓ Update should trim option values (1366 ms) ✓ Update should succeed with default value and no options (1122 ms) ✓ Update should fail with unknown default value and no options (1075 ms) ✓ Update should fail with only white spaces id (1195 ms) ✓ Update should fail with empty string id (1058 ms) ✓ Update should fail with null id (1066 ms) ✓ Update should fail with not a string id (1098 ms) ✓ Update should fail with too long id (1373 ms) ✓ Update should fail with only white spaces label (1034 ms) ✓ Update should fail with empty string label (1057 ms) ✓ Update should fail with null label (1100 ms) ✓ Update should fail with not a string label (1144 ms) ✓ Update should fail with too long label (1273 ms) ✓ Update should fail with only white spaces value (1385 ms) ✓ Update should fail with empty string value (1035 ms) ✓ Update should fail with null value (1068 ms) ✓ Update should fail with not a string value (1021 ms) ✓ Update should fail with too long value (1134 ms) ✓ Update should fail with invalid option id (1137 ms) ✓ Update should fail with empty options (1238 ms) ✓ Update should fail with invalid option value format (1104 ms) ✓ Update should fail with comma in option label (1004 ms) ✓ Update should fail with duplicated option values (1015 ms) ✓ Update should fail with duplicated option ids (1079 ms) ✓ Update should fail with duplicated option positions (1266 ms) ✓ Update should fail with duplicated trimmed option values (1220 ms) ✓ Update should fail with undefined option label (1029 ms) ✓ Update should fail with an invalid default value (1142 ms) ✓ Update should fail with an unknown default value (1081 ms) ✓ Update should fail with undefined option value (1086 ms) Test Suites: 1 passed, 1 total Tests: 34 passed, 34 total Snapshots: 28 passed, 28 total Time: 41.079 s ``` ## Create ```ts PASS test/integration/metadata/suites/field-metadata/create-one-field-metadata-select.integration-spec.ts (38.292 s) Field metadata select creation tests group ✓ Create should succeed with provided option id (2096 ms) ✓ Create should succeed with valid default value (1316 ms) ✓ Create should succeed with various options id (1113 ms) ✓ Create should succeed without option id (1378 ms) ✓ Create should trim option values (1296 ms) ✓ Create should fail with only white spaces id (1000 ms) ✓ Create should fail with empty string id (1325 ms) ✓ Create should fail with null id (1060 ms) ✓ Create should fail with not a string id (1142 ms) ✓ Create should fail with too long id (1321 ms) ✓ Create should fail with only white spaces label (999 ms) ✓ Create should fail with empty string label (1163 ms) ✓ Create should fail with null label (1198 ms) ✓ Create should fail with not a string label (1678 ms) ✓ Create should fail with too long label (1527 ms) ✓ Create should fail with only white spaces value (1200 ms) ✓ Create should fail with empty string value (1102 ms) ✓ Create should fail with null value (1037 ms) ✓ Create should fail with not a string value (1462 ms) ✓ Create should fail with too long value (896 ms) ✓ Create should fail with invalid option id (997 ms) ✓ Create should fail with empty options (1058 ms) ✓ Create should fail with invalid option value format (1190 ms) ✓ Create should fail with comma in option label (1142 ms) ✓ Create should fail with duplicated option values (872 ms) ✓ Create should fail with duplicated option ids (860 ms) ✓ Create should fail with duplicated option positions (1002 ms) ✓ Create should fail with duplicated trimmed option values (1336 ms) ✓ Create should fail with undefined option label (754 ms) ✓ Create should fail with an invalid default value (696 ms) ✓ Create should fail with an unknown default value (678 ms) ✓ Create should fail with undefined option value (699 ms) ✓ Create should fail with null options (720 ms) ✓ Create should fail with undefined options (686 ms) Test Suites: 1 passed, 1 total Tests: 34 passed, 34 total Snapshots: 29 passed, 29 total Time: 38.314 s ``` ## Conclusion As always any suggestions are welcomed ! Please let me know ## Discussion about validation governance ### Front Front side will be dealing with zod validations schema that he will handle and maintain by himself ### Back validation instances - Validation hold through DTO declarations ( run by yoga through the resolvers ) - Server programmatic validation and exceptions handling ( run through the services ) For this refactor/fix we decided to stick to the current implementation only touching the `Server programmatic validation and exceptions handling` we will handle validation centralization when we will onboard the `nestjs-query` deprecation/integration refactor. ### Vision In the best of the world we could think of an intermediary model that will handle and take responsibility of the validation decorators that would be run programmatically through the service, Yoga would still consume it ? then we would need to have enough grain in the service to know the input has already validated ## Notes Introduced zod back side in order to handle very atomic and primitive validation --- .../field-metadata/field-metadata.module.ts | 6 + .../field-metadata/field-metadata.service.ts | 156 ++++---- .../field-metadata-enum-validation.service.ts | 217 ++++++++++ .../is-valid-graphql-enum-name.validator.ts | 7 +- .../seeder/data-seeds/pets-data-seeds.ts | 5 +- .../metadata-seeds/pets-metadata-seeds.ts | 24 +- .../__test__/is-snake-case-string.spec.ts | 45 +++ ...aces-from-object-string-properties.spec.ts | 107 +++++ .../src/utils/is-snake-case-string.ts | 3 + ...itespaces-from-object-string-properties.ts | 30 ++ .../search-resolver.integration-spec.ts | 3 +- ...d-metadata-select.integration-spec.ts.snap | 321 +++++++++++++++ ...d-metadata-select.integration-spec.ts.snap | 310 +++++++++++++++ ...-field-metadata-select.integration-spec.ts | 124 ++++++ ...e-one-field-metadata-select-tests-cases.ts | 374 ++++++++++++++++++ ...-field-metadata-select.integration-spec.ts | 198 ++++++++++ ...ate-one-field-metadata.integration-spec.ts | 35 +- ...many-fields-metadata-query-factory.util.ts | 2 +- .../utils/find-many-fields-metadata.util.ts | 6 +- .../utils/find-many-object-metadata.util.ts | 16 +- .../force-create-one-object-metadata.util.ts | 85 ++++ 21 files changed, 1959 insertions(+), 115 deletions(-) create mode 100644 packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-enum-validation.service.ts create mode 100644 packages/twenty-server/src/utils/__test__/is-snake-case-string.spec.ts create mode 100644 packages/twenty-server/src/utils/__test__/trim-and-remove-duplicated-whitespaces-from-object-string-properties.spec.ts create mode 100644 packages/twenty-server/src/utils/is-snake-case-string.ts create mode 100644 packages/twenty-server/src/utils/trim-and-remove-duplicated-whitespaces-from-object-string-properties.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/create-one-field-metadata-select.integration-spec.ts.snap create mode 100644 packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/update-one-field-metadata-select.integration-spec.ts.snap create mode 100644 packages/twenty-server/test/integration/metadata/suites/field-metadata/create-one-field-metadata-select.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/field-metadata/update-create-one-field-metadata-select-tests-cases.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-select.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/object-metadata/utils/force-create-one-object-metadata.util.ts diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts index 31ec5d65c..9b4032c0e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts @@ -17,6 +17,7 @@ import { FieldMetadataResolver } from 'src/engine/metadata-modules/field-metadat import { BeforeUpdateOneField } from 'src/engine/metadata-modules/field-metadata/hooks/before-update-one-field.hook'; import { FieldMetadataGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/field-metadata/interceptors/field-metadata-graphql-api-exception.interceptor'; import { FieldMetadataRelationService } from 'src/engine/metadata-modules/field-metadata/relation/field-metadata-relation.service'; +import { FieldMetadataEnumValidationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-enum-validation.service'; import { FieldMetadataRelatedRecordsService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service'; import { IsFieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/validators/is-field-metadata-default-value.validator'; import { IsFieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata/validators/is-field-metadata-options.validator'; @@ -60,6 +61,7 @@ import { UpdateFieldInput } from './dtos/update-field.input'; FieldMetadataService, FieldMetadataRelatedRecordsService, FieldMetadataValidationService, + FieldMetadataEnumValidationService, ], resolvers: [ { @@ -95,6 +97,8 @@ import { UpdateFieldInput } from './dtos/update-field.input'; FieldMetadataService, FieldMetadataRelationService, FieldMetadataRelatedRecordsService, + FieldMetadataValidationService, + FieldMetadataEnumValidationService, FieldMetadataResolver, BeforeUpdateOneField, ], @@ -102,6 +106,8 @@ import { UpdateFieldInput } from './dtos/update-field.input'; FieldMetadataService, FieldMetadataRelationService, FieldMetadataRelatedRecordsService, + FieldMetadataEnumValidationService, + FieldMetadataValidationService, ], }) export class FieldMetadataModule {} 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 bf0bfd33e..da27cab17 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 @@ -12,15 +12,17 @@ import { v4 as uuidV4, v4 } from 'uuid'; import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; -import { TypeORMService } from 'src/database/typeorm/typeorm.service'; import { settings } from 'src/engine/constants/settings'; import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId'; -import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types'; import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input'; import { DeleteOneFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/delete-field.input'; import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto'; import { FieldStandardOverridesDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-standard-overrides.dto'; +import { + FieldMetadataComplexOption, + FieldMetadataDefaultOption, +} from 'src/engine/metadata-modules/field-metadata/dtos/options.input'; import { RelationDefinitionDTO, RelationDefinitionType, @@ -30,6 +32,7 @@ import { FieldMetadataException, FieldMetadataExceptionCode, } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; +import { FieldMetadataEnumValidationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-enum-validation.service'; import { FieldMetadataRelatedRecordsService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service'; import { assertDoesNotNullifyDefaultValueForNonNullableField } from 'src/engine/metadata-modules/field-metadata/utils/assert-does-not-nullify-default-value-for-non-nullable-field.util'; import { checkCanDeactivateFieldOrThrow } from 'src/engine/metadata-modules/field-metadata/utils/check-can-deactivate-field-or-throw'; @@ -47,7 +50,6 @@ import { RelationMetadataType, } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; import { InvalidMetadataException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.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 { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name.utils'; import { validateNameAndLabelAreSyncOrThrow } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util'; @@ -66,13 +68,21 @@ import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global. import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util'; import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service'; import { ViewService } from 'src/modules/view/services/view.service'; +import { trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties } from 'src/utils/trim-and-remove-duplicated-whitespaces-from-object-string-properties'; import { FieldMetadataValidationService } from './field-metadata-validation.service'; import { FieldMetadataEntity } from './field-metadata.entity'; import { generateDefaultValue } from './utils/generate-default-value'; import { generateRatingOptions } from './utils/generate-rating-optionts.util'; -import { isEnumFieldMetadataType } from './utils/is-enum-field-metadata-type.util'; + +type ValidateFieldMetadataArgs = + { + fieldMetadataType: FieldMetadataType; + fieldMetadataInput: T; + objectMetadata: ObjectMetadataEntity; + existingFieldMetadata?: FieldMetadataEntity; + }; @Injectable() export class FieldMetadataService extends TypeOrmQueryService { @@ -88,8 +98,7 @@ export class FieldMetadataService extends TypeOrmQueryService { const [createdFieldMetadata] = await this.createMany([fieldMetadataInput]); - if (!createdFieldMetadata) { + if (!isDefined(createdFieldMetadata)) { throw new FieldMetadataException( 'Failed to create field metadata', FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR, @@ -136,7 +145,7 @@ export class FieldMetadataService extends TypeOrmQueryService( - existingFieldMetadata.type, - fieldMetadataForUpdate, + await this.validateFieldMetadata({ + fieldMetadataType: existingFieldMetadata.type, + existingFieldMetadata, + fieldMetadataInput: fieldMetadataForUpdate, objectMetadata, - ); + }); const isLabelSyncedWithName = fieldMetadataForUpdate.isLabelSyncedWithName ?? @@ -241,7 +246,7 @@ export class FieldMetadataService extends TypeOrmQueryService( - fieldMetadataType: FieldMetadataType, - fieldMetadataInput: T, - objectMetadata: ObjectMetadataEntity, - ): Promise { + >({ + fieldMetadataInput, + fieldMetadataType, + objectMetadata, + existingFieldMetadata, + }: ValidateFieldMetadataArgs): Promise { if (fieldMetadataInput.name) { try { validateMetadataNameOrThrow(fieldMetadataInput.name); @@ -650,23 +656,13 @@ export class FieldMetadataService extends TypeOrmQueryService { + return { + options: options.map((option) => ({ + id: uuidV4(), + ...trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties( + option, + ['label', 'value', 'id'], + ), + })), + }; + } + private prepareCustomFieldMetadata(fieldMetadataInput: CreateFieldInput) { + const options = fieldMetadataInput.options + ? this.prepareCustomFieldMetadataOptions(fieldMetadataInput.options) + : undefined; + const defaultValue = + fieldMetadataInput.defaultValue ?? + generateDefaultValue(fieldMetadataInput.type); + return { id: v4(), createdAt: new Date(), @@ -727,15 +744,8 @@ export class FieldMetadataService extends TypeOrmQueryService ({ - ...option, - id: uuidV4(), - })) - : undefined, + defaultValue, + ...options, isActive: true, isCustom: true, }; @@ -766,18 +776,6 @@ export class FieldMetadataService extends TypeOrmQueryService( - fieldMetadataForCreate.type, - fieldMetadataForCreate, + await this.validateFieldMetadata({ + fieldMetadataType: fieldMetadataForCreate.type, + fieldMetadataInput: fieldMetadataForCreate, objectMetadata, - ); + }); if (fieldMetadataForCreate.isLabelSyncedWithName === true) { validateNameAndLabelAreSyncOrThrow( @@ -862,7 +860,7 @@ export class FieldMetadataService extends TypeOrmQueryService = { validator: (str: T) => boolean; message: string }; + +type FieldMetadataUpdateCreateInput = CreateFieldInput | UpdateFieldInput; + +type ValidateEnumFieldMetadataArgs = { + existingFieldMetadata?: FieldMetadataEntity; + fieldMetadataInput: FieldMetadataUpdateCreateInput; + fieldMetadataType: FieldMetadataType; +}; + +@Injectable() +export class FieldMetadataEnumValidationService { + constructor( + private readonly fieldMetadataValidationService: FieldMetadataValidationService, + ) {} + + private validatorRunner( + elementToValidate: T, + { message, validator }: Validator, + ) { + const shouldThrow = validator(elementToValidate); + + if (shouldThrow) { + throw new FieldMetadataException( + message, + FieldMetadataExceptionCode.INVALID_FIELD_INPUT, + ); + } + } + + private validateMetadataOptionId(sanitizedId?: string) { + const validators: Validator[] = [ + { + validator: (id) => !isDefined(id), + message: 'Option id is required', + }, + { + validator: (id) => !z.string().uuid().safeParse(id).success, + message: 'Option id is invalid', + }, + ]; + + validators.forEach((validator) => + this.validatorRunner(sanitizedId, validator), + ); + } + + private validateMetadataOptionLabel(sanitizedLabel: string) { + const validators: Validator[] = [ + { + validator: (label) => !isDefined(label), + message: 'Option label is required', + }, + { + validator: exceedsDatabaseIdentifierMaximumLength, + message: `Option label "${sanitizedLabel}" exceeds 63 characters`, + }, + { + validator: beneathDatabaseIdentifierMinimumLength, + message: `Option label "${sanitizedLabel}" is beneath 1 character`, + }, + { + validator: (label) => label.includes(','), + message: 'Label must not contain a comma', + }, + { + validator: (label) => !isNonEmptyString(label) || label === ' ', + message: 'Label must not be empty', + }, + ]; + + validators.forEach((validator) => + this.validatorRunner(sanitizedLabel, validator), + ); + } + + private validateMetadataOptionValue(sanitizedValue: string) { + const validators: Validator[] = [ + { + validator: (value) => !isDefined(value), + message: 'Option value is required', + }, + { + validator: exceedsDatabaseIdentifierMaximumLength, + message: `Option value "${sanitizedValue}" exceeds 63 characters`, + }, + { + validator: beneathDatabaseIdentifierMinimumLength, + message: `Option value "${sanitizedValue}" is beneath 1 character`, + }, + { + validator: (value) => !isSnakeCaseString(value), + message: `Value must be in UPPER_CASE and follow snake_case "${sanitizedValue}"`, + }, + ]; + + validators.forEach((validator) => + this.validatorRunner(sanitizedValue, validator), + ); + } + + private validateDuplicates(options: FieldMetadataOptions) { + const seenOptionIds = new Set(); + const seenOptionValues = new Set(); + const seenOptionPositions = new Set< + FieldMetadataOptions[number]['position'] + >(); + + for (const option of options) { + if (seenOptionIds.has(option.id)) { + throw new FieldMetadataException( + `Duplicated option id "${option.id}"`, + FieldMetadataExceptionCode.INVALID_FIELD_INPUT, + ); + } + + if (seenOptionValues.has(option.value)) { + throw new FieldMetadataException( + `Duplicated option value "${option.value}"`, + FieldMetadataExceptionCode.INVALID_FIELD_INPUT, + ); + } + + if (seenOptionPositions.has(option.position)) { + throw new FieldMetadataException( + `Duplicated option position "${option.position}"`, + FieldMetadataExceptionCode.INVALID_FIELD_INPUT, + ); + } + + seenOptionIds.add(option.id); + seenOptionValues.add(option.value); + seenOptionPositions.add(option.position); + } + } + + private validateFieldMetadataInputOptions( + fieldMetadataInput: FieldMetadataUpdateCreateInput, + ) { + const { options } = fieldMetadataInput; + + if (!isDefined(options) || options.length === 0) { + throw new FieldMetadataException( + 'Options are required for enum fields', + FieldMetadataExceptionCode.INVALID_FIELD_INPUT, + ); + } + + for (const option of options) { + this.validateMetadataOptionId(option.id); + this.validateMetadataOptionValue(option.value); + this.validateMetadataOptionLabel(option.label); + } + + this.validateDuplicates(options); + } + + async validateEnumFieldMetadataInput({ + fieldMetadataInput, + fieldMetadataType, + existingFieldMetadata, + }: ValidateEnumFieldMetadataArgs) { + if (!isEnumFieldMetadataType(fieldMetadataType)) { + return; + } + + const isUpdate = isDefined(existingFieldMetadata); + const shouldSkipFieldMetadataInputOptionsValidation = + isUpdate && fieldMetadataInput.options === undefined; + + if (!shouldSkipFieldMetadataInputOptionsValidation) { + this.validateFieldMetadataInputOptions(fieldMetadataInput); + } + + if (isDefined(fieldMetadataInput.defaultValue)) { + const options = + fieldMetadataInput.options ?? existingFieldMetadata?.options; + + if (!isDefined(options)) { + throw new FieldMetadataException( + 'Should never occur, could not retrieve any options to validate default value', + FieldMetadataExceptionCode.INVALID_FIELD_INPUT, + ); + } + + await this.fieldMetadataValidationService.validateDefaultValueOrThrow({ + fieldType: fieldMetadataType, + options, + defaultValue: fieldMetadataInput.defaultValue, + }); + } + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/validators/is-valid-graphql-enum-name.validator.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/validators/is-valid-graphql-enum-name.validator.ts index 3c9c8cbf2..f943f6541 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/validators/is-valid-graphql-enum-name.validator.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/validators/is-valid-graphql-enum-name.validator.ts @@ -1,7 +1,7 @@ import { - registerDecorator, - ValidationOptions, ValidationArguments, + ValidationOptions, + registerDecorator, } from 'class-validator'; const graphQLEnumNameRegex = /^[_A-Za-z][_0-9A-Za-z]*$/; @@ -14,8 +14,7 @@ export function IsValidGraphQLEnumName(validationOptions?: ValidationOptions) { propertyName: propertyName, options: validationOptions, validator: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - validate(value: any) { + validate(value: unknown) { return typeof value === 'string' && graphQLEnumNameRegex.test(value); }, defaultMessage(args: ValidationArguments) { diff --git a/packages/twenty-server/src/engine/seeder/data-seeds/pets-data-seeds.ts b/packages/twenty-server/src/engine/seeder/data-seeds/pets-data-seeds.ts index a17f5c6a4..fe25f9e17 100644 --- a/packages/twenty-server/src/engine/seeder/data-seeds/pets-data-seeds.ts +++ b/packages/twenty-server/src/engine/seeder/data-seeds/pets-data-seeds.ts @@ -1,9 +1,8 @@ - export const PETS_DATA_SEEDS = [ { name: 'Toby', - species: 'dog', - traits: ['curious', 'friendly'], + species: 'DOG', + traits: ['CURIOUS', 'FRIENDLY'], comments: 'Needs to have people around.', age: 3, location: { diff --git a/packages/twenty-server/src/engine/seeder/metadata-seeds/pets-metadata-seeds.ts b/packages/twenty-server/src/engine/seeder/metadata-seeds/pets-metadata-seeds.ts index 7368a5681..e98cfe9f8 100644 --- a/packages/twenty-server/src/engine/seeder/metadata-seeds/pets-metadata-seeds.ts +++ b/packages/twenty-server/src/engine/seeder/metadata-seeds/pets-metadata-seeds.ts @@ -13,12 +13,12 @@ export const PETS_METADATA_SEEDS: ObjectMetadataSeed = { label: 'Species', name: 'species', options: [ - { label: 'Dog', value: 'dog', position: 0, color: 'blue' }, - { label: 'Cat', value: 'cat', position: 1, color: 'red' }, - { label: 'Bird', value: 'bird', position: 2, color: 'green' }, - { label: 'Fish', value: 'fish', position: 3, color: 'yellow' }, - { label: 'Rabbit', value: 'rabbit', position: 4, color: 'purple' }, - { label: 'Hamster', value: 'hamster', position: 5, color: 'orange' }, + { label: 'Dog', value: 'DOG', position: 0, color: 'blue' }, + { label: 'Cat', value: 'CAT', position: 1, color: 'red' }, + { label: 'Bird', value: 'BIRD', position: 2, color: 'green' }, + { label: 'Fish', value: 'FISH', position: 3, color: 'yellow' }, + { label: 'Rabbit', value: 'RABBIT', position: 4, color: 'purple' }, + { label: 'Hamster', value: 'HAMSTER', position: 5, color: 'orange' }, ], }, { @@ -26,17 +26,17 @@ export const PETS_METADATA_SEEDS: ObjectMetadataSeed = { label: 'Traits', name: 'traits', options: [ - { label: 'Playful', value: 'playful', position: 0, color: 'blue' }, - { label: 'Friendly', value: 'friendly', position: 1, color: 'red' }, + { label: 'Playful', value: 'PLAYFUL', position: 0, color: 'blue' }, + { label: 'Friendly', value: 'FRIENDLY', position: 1, color: 'red' }, { label: 'Protective', - value: 'protective', + value: 'PROTECTIVE', position: 2, color: 'green', }, - { label: 'Shy', value: 'shy', position: 3, color: 'yellow' }, - { label: 'Brave', value: 'brave', position: 4, color: 'purple' }, - { label: 'Curious', value: 'curious', position: 5, color: 'orange' }, + { label: 'Shy', value: 'SHY', position: 3, color: 'yellow' }, + { label: 'Brave', value: 'BRAVE', position: 4, color: 'purple' }, + { label: 'Curious', value: 'CURIOUS', position: 5, color: 'orange' }, ], }, { diff --git a/packages/twenty-server/src/utils/__test__/is-snake-case-string.spec.ts b/packages/twenty-server/src/utils/__test__/is-snake-case-string.spec.ts new file mode 100644 index 000000000..c2eb11589 --- /dev/null +++ b/packages/twenty-server/src/utils/__test__/is-snake-case-string.spec.ts @@ -0,0 +1,45 @@ +import { EachTestingContext } from 'twenty-shared/testing'; + +import { isSnakeCaseString } from 'src/utils/is-snake-case-string'; + +type IsSnakeCaseStringTestCase = EachTestingContext<{ + input: string; + expected: boolean; +}>; + +const testCases: IsSnakeCaseStringTestCase[] = [ + { title: 'single word', context: { input: 'FOO', expected: true } }, + { title: 'two words', context: { input: 'FOO_BAR', expected: true } }, + { + title: 'words with numbers', + context: { input: 'FOO1_BAR2', expected: true }, + }, + { title: 'lowercase', context: { input: 'foo_bar', expected: false } }, + { + title: 'double underscore', + context: { input: 'FOO__BAR', expected: false }, + }, + { + title: 'dash instead of underscore', + context: { input: 'FOO-BAR', expected: false }, + }, + { + title: 'leading underscore', + context: { input: '_FOO_BAR', expected: false }, + }, + { + title: 'trailing underscore', + context: { input: 'FOO_BAR_', expected: false }, + }, + { title: 'empty string', context: { input: '', expected: false } }, + { + title: 'space instead of underscore', + context: { input: 'FOO BAR', expected: false }, + }, +]; + +describe('is-snake-case-string', () => { + test.each(testCases)('$title', ({ context: { input, expected } }) => { + expect(isSnakeCaseString(input)).toBe(expected); + }); +}); diff --git a/packages/twenty-server/src/utils/__test__/trim-and-remove-duplicated-whitespaces-from-object-string-properties.spec.ts b/packages/twenty-server/src/utils/__test__/trim-and-remove-duplicated-whitespaces-from-object-string-properties.spec.ts new file mode 100644 index 000000000..9e5a9957e --- /dev/null +++ b/packages/twenty-server/src/utils/__test__/trim-and-remove-duplicated-whitespaces-from-object-string-properties.spec.ts @@ -0,0 +1,107 @@ +import { EachTestingContext } from 'twenty-shared/testing'; + +import { trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties } from 'src/utils/trim-and-remove-duplicated-whitespaces-from-object-string-properties'; + +type SanitizeObjectStringPropertiesTestCase = EachTestingContext<{ + input: Record; + keys: string[]; + expected: Record; +}>; + +describe('trim-and-remove-duplicated-whitespaces-from-object-string-properties', () => { + const testCases: SanitizeObjectStringPropertiesTestCase[] = [ + { + title: 'should sanitize single string property', + context: { + input: { name: ' John Doe ' }, + keys: ['name'], + expected: { name: 'John Doe' }, + }, + }, + { + title: 'should sanitize multiple string properties', + context: { + input: { + firstName: ' John ', + lastName: ' Doe ', + email: ' john.doe@example.com ', + }, + keys: ['firstName', 'lastName', 'email'], + expected: { + firstName: 'John', + lastName: 'Doe', + email: 'john.doe@example.com', + }, + }, + }, + { + title: 'should preserve undefined properties', + context: { + input: { name: ' John Doe ' }, + keys: ['name', 'age'], + expected: { name: 'John Doe' }, + }, + }, + { + title: 'should handle null properties', + context: { + input: { name: ' John Doe ', description: null }, + keys: ['name', 'description'], + expected: { name: 'John Doe', description: null }, + }, + }, + { + title: 'should not modify non-string properties', + context: { + input: { name: ' John Doe ', age: 30, active: true }, + // In real life passing age would raise an TypeScript error + keys: ['name', 'age', 'active'], + expected: { name: 'John Doe', age: 30, active: true }, + }, + }, + { + title: 'should handle empty string', + context: { + input: { name: ' ' }, + keys: ['name'], + expected: { name: '' }, + }, + }, + { + title: 'should handle object with no properties to sanitize', + context: { + input: { age: 30, active: true }, + keys: ['name'], + expected: { age: 30, active: true }, + }, + }, + { + title: 'should handle nested whitespace', + context: { + input: { description: ' This is a test ' }, + keys: ['description'], + expected: { description: 'This is a test' }, + }, + }, + { + title: 'should trim only provided keys fields', + context: { + input: { + name: ' John Doe ', + description: ' this is a test ', + }, + keys: ['description'], + expected: { name: ' John Doe ', description: 'this is a test' }, + }, + }, + ]; + + test.each(testCases)('$title', ({ context: { input, keys, expected } }) => { + const result = trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties( + input, + keys, + ); + + expect(result).toEqual(expected); + }); +}); diff --git a/packages/twenty-server/src/utils/is-snake-case-string.ts b/packages/twenty-server/src/utils/is-snake-case-string.ts new file mode 100644 index 000000000..c3e0c0ee5 --- /dev/null +++ b/packages/twenty-server/src/utils/is-snake-case-string.ts @@ -0,0 +1,3 @@ +const SNAKE_CASE_REGEX = /^(?!.*__)[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$/; + +export const isSnakeCaseString = (str: string) => SNAKE_CASE_REGEX.test(str); diff --git a/packages/twenty-server/src/utils/trim-and-remove-duplicated-whitespaces-from-object-string-properties.ts b/packages/twenty-server/src/utils/trim-and-remove-duplicated-whitespaces-from-object-string-properties.ts new file mode 100644 index 000000000..9abed176a --- /dev/null +++ b/packages/twenty-server/src/utils/trim-and-remove-duplicated-whitespaces-from-object-string-properties.ts @@ -0,0 +1,30 @@ +import { isDefined } from 'twenty-shared/utils'; + +type OnlyStringPropertiesKey = Extract; + +type StringPropertyKeys = { + [K in OnlyStringPropertiesKey]: T[K] extends string | undefined + ? K + : never; +}[OnlyStringPropertiesKey]; + +const sanitizeString = (str: string | null) => + isDefined(str) ? str.trim().replace(/\s+/g, ' ') : str; + +export const trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties = ( + obj: T, + keys: StringPropertyKeys[], +) => { + return keys.reduce((acc, key) => { + const occurrence = acc[key]; + + if (occurrence === undefined || typeof occurrence !== 'string') { + return acc; + } + + return { + ...acc, + [key]: sanitizeString(acc[key] as string | null), + }; + }, obj); +}; diff --git a/packages/twenty-server/test/integration/graphql/suites/search/search-resolver.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/search/search-resolver.integration-spec.ts index f44514f6e..ff47b901e 100644 --- a/packages/twenty-server/test/integration/graphql/suites/search/search-resolver.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/search/search-resolver.integration-spec.ts @@ -55,8 +55,7 @@ describe('SearchResolver', () => { }, }); - const listingObjectMetadata = objectsMetadata.find( - // @ts-expect-error legacy noImplicitAny + const listingObjectMetadata = objectsMetadata.objects.find( (object) => object.nameSingular === LISTING_NAME_SINGULAR, ); diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/create-one-field-metadata-select.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/create-one-field-metadata-select.integration-spec.ts.snap new file mode 100644 index 000000000..2fd7dd334 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/create-one-field-metadata-select.integration-spec.ts.snap @@ -0,0 +1,321 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Field metadata select creation tests group Create should fail with an invalid default value 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Default value for existing options is invalid: invalid", + }, +] +`; + +exports[`Field metadata select creation tests group Create should fail with an unknown default value 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Default value for existing options is invalid: 'OPTION_424242'", + }, +] +`; + +exports[`Field metadata select creation tests group Create should fail with comma in option label 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Label must not contain a comma", + }, +] +`; + +exports[`Field metadata select creation tests group Create should fail with duplicated option ids 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Duplicated option id "fd1f11fd-3f05-4a33-bddf-800c3412ce98"", + }, +] +`; + +exports[`Field metadata select creation tests group Create should fail with duplicated option positions 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Duplicated option position "1"", + }, +] +`; + +exports[`Field metadata select creation tests group Create should fail with duplicated option values 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Duplicated option value "OPTION_1"", + }, +] +`; + +exports[`Field metadata select creation tests group Create should fail with duplicated trimmed option values 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Duplicated option value "OPTION_1"", + }, +] +`; + +exports[`Field metadata select creation tests group Create should fail with empty options 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Options are required for enum fields", + }, +] +`; + +exports[`Field metadata select creation tests group Create should fail with empty string id 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Option id is invalid", + }, +] +`; + +exports[`Field metadata select creation tests group Create should fail with empty string label 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Option label "" is beneath 1 character", + }, +] +`; + +exports[`Field metadata select creation tests group Create should fail with empty string value 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Option value "" is beneath 1 character", + }, +] +`; + +exports[`Field metadata select creation tests group Create should fail with invalid option id 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Option id is invalid", + }, +] +`; + +exports[`Field metadata select creation tests group Create should fail with invalid option value format 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Value must be in UPPER_CASE and follow snake_case "Option 1 and some other things, /"", + }, +] +`; + +exports[`Field metadata select creation tests group Create should fail with not a string id 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Option id is invalid", + }, +] +`; + +exports[`Field metadata select creation tests group Create should fail with not a string label 1`] = ` +[ + { + "extensions": { + "code": "INTERNAL_SERVER_ERROR", + "exceptionEventId": "mocked-exception-id", + }, + "message": "label.includes is not a function", + }, +] +`; + +exports[`Field metadata select creation tests group Create should fail with not a string value 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Value must be in UPPER_CASE and follow snake_case "22222"", + }, +] +`; + +exports[`Field metadata select creation tests group Create should fail with null id 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Option id is required", + }, +] +`; + +exports[`Field metadata select creation tests group Create should fail with null label 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Option label is required", + }, +] +`; + +exports[`Field metadata select creation tests group Create should fail with null options 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Options are required for enum fields", + }, +] +`; + +exports[`Field metadata select creation tests group Create should fail with null value 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Option value is required", + }, +] +`; + +exports[`Field metadata select creation tests group Create should fail with only white spaces id 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Option id is invalid", + }, +] +`; + +exports[`Field metadata select creation tests group Create should fail with only white spaces label 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Option label "" is beneath 1 character", + }, +] +`; + +exports[`Field metadata select creation tests group Create should fail with only white spaces value 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Option value "" is beneath 1 character", + }, +] +`; + +exports[`Field metadata select creation tests group Create should fail with too long id 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Option id is invalid", + }, +] +`; + +exports[`Field metadata select creation tests group Create should fail with too long label 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Option label "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" exceeds 63 characters", + }, +] +`; + +exports[`Field metadata select creation tests group Create should fail with too long value 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Option value "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" exceeds 63 characters", + }, +] +`; + +exports[`Field metadata select creation tests group Create should fail with undefined option label 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Option label is required", + }, +] +`; + +exports[`Field metadata select creation tests group Create should fail with undefined option value 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Option value is required", + }, +] +`; + +exports[`Field metadata select creation tests group Create should fail with undefined options 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Options are required for enum fields", + }, +] +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/update-one-field-metadata-select.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/update-one-field-metadata-select.integration-spec.ts.snap new file mode 100644 index 000000000..758c0d2b1 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/update-one-field-metadata-select.integration-spec.ts.snap @@ -0,0 +1,310 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Field metadata select update tests group Update should fail with an invalid default value 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Default value for existing options is invalid: invalid", + }, +] +`; + +exports[`Field metadata select update tests group Update should fail with an unknown default value 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Default value for existing options is invalid: 'OPTION_424242'", + }, +] +`; + +exports[`Field metadata select update tests group Update should fail with comma in option label 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Label must not contain a comma", + }, +] +`; + +exports[`Field metadata select update tests group Update should fail with duplicated option ids 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Duplicated option id "fd1f11fd-3f05-4a33-bddf-800c3412ce98"", + }, +] +`; + +exports[`Field metadata select update tests group Update should fail with duplicated option positions 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Duplicated option position "1"", + }, +] +`; + +exports[`Field metadata select update tests group Update should fail with duplicated option values 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Duplicated option value "OPTION_1"", + }, +] +`; + +exports[`Field metadata select update tests group Update should fail with duplicated trimmed option values 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Duplicated option value "OPTION_1"", + }, +] +`; + +exports[`Field metadata select update tests group Update should fail with empty options 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Options are required for enum fields", + }, +] +`; + +exports[`Field metadata select update tests group Update should fail with empty string id 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Option id is invalid", + }, +] +`; + +exports[`Field metadata select update tests group Update should fail with empty string label 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Option label "" is beneath 1 character", + }, +] +`; + +exports[`Field metadata select update tests group Update should fail with empty string value 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Option value "" is beneath 1 character", + }, +] +`; + +exports[`Field metadata select update tests group Update should fail with invalid option id 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Option id is invalid", + }, +] +`; + +exports[`Field metadata select update tests group Update should fail with invalid option value format 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Value must be in UPPER_CASE and follow snake_case "Option 1 and some other things, /"", + }, +] +`; + +exports[`Field metadata select update tests group Update should fail with not a string id 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Option id is invalid", + }, +] +`; + +exports[`Field metadata select update tests group Update should fail with not a string label 1`] = ` +[ + { + "extensions": { + "code": "INTERNAL_SERVER_ERROR", + "exceptionEventId": "mocked-exception-id", + }, + "message": "label.includes is not a function", + }, +] +`; + +exports[`Field metadata select update tests group Update should fail with not a string value 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Value must be in UPPER_CASE and follow snake_case "22222"", + }, +] +`; + +exports[`Field metadata select update tests group Update should fail with null id 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Option id is required", + }, +] +`; + +exports[`Field metadata select update tests group Update should fail with null label 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Option label is required", + }, +] +`; + +exports[`Field metadata select update tests group Update should fail with null value 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Option value is required", + }, +] +`; + +exports[`Field metadata select update tests group Update should fail with only white spaces id 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Option id is invalid", + }, +] +`; + +exports[`Field metadata select update tests group Update should fail with only white spaces label 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Option label "" is beneath 1 character", + }, +] +`; + +exports[`Field metadata select update tests group Update should fail with only white spaces value 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Option value "" is beneath 1 character", + }, +] +`; + +exports[`Field metadata select update tests group Update should fail with too long id 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Option id is invalid", + }, +] +`; + +exports[`Field metadata select update tests group Update should fail with too long label 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Option label "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" exceeds 63 characters", + }, +] +`; + +exports[`Field metadata select update tests group Update should fail with too long value 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Option value "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" exceeds 63 characters", + }, +] +`; + +exports[`Field metadata select update tests group Update should fail with undefined option label 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Option label is required", + }, +] +`; + +exports[`Field metadata select update tests group Update should fail with undefined option value 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Option value is required", + }, +] +`; + +exports[`Field metadata select update tests group Update should fail with unknown default value and no options 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Default value for existing options is invalid: 'OPTION_42'", + }, +] +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/create-one-field-metadata-select.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/create-one-field-metadata-select.integration-spec.ts new file mode 100644 index 000000000..f42837bde --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/create-one-field-metadata-select.integration-spec.ts @@ -0,0 +1,124 @@ +import { + UPDATE_CREATE_ONE_FIELD_METADATA_SELECT_TEST_CASES, + UpdateCreateFieldMetadataSelectTestCase, +} from 'test/integration/metadata/suites/field-metadata/update-create-one-field-metadata-select-tests-cases'; +import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util'; +import { + LISTING_NAME_PLURAL, + LISTING_NAME_SINGULAR, +} from 'test/integration/metadata/suites/object-metadata/constants/test-object-names.constant'; +import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util'; + +import { FieldMetadataComplexOption } from 'src/engine/metadata-modules/field-metadata/dtos/options.input'; + +const { failingTestCases, successfulTestCases } = + UPDATE_CREATE_ONE_FIELD_METADATA_SELECT_TEST_CASES; + +describe('Field metadata select creation tests group', () => { + let createdObjectMetadataId: string; + + beforeEach(async () => { + const { data } = await createOneObjectMetadata({ + input: { + labelSingular: LISTING_NAME_SINGULAR, + labelPlural: LISTING_NAME_PLURAL, + nameSingular: LISTING_NAME_SINGULAR, + namePlural: LISTING_NAME_PLURAL, + icon: 'IconBuildingSkyscraper', + isLabelSyncedWithName: false, + }, + }); + + createdObjectMetadataId = data.createOneObject.id; + }); + + afterEach(async () => { + await deleteOneObjectMetadata({ + input: { idToDelete: createdObjectMetadataId }, + }); + }); + + test.each(successfulTestCases)( + 'Create $title', + async ({ context: { input, expectedOptions } }) => { + const { data, errors } = await createOneFieldMetadata({ + input: { + objectMetadataId: createdObjectMetadataId, + type: FieldMetadataType.SELECT, + name: 'testField', + label: 'Test Field', + isLabelSyncedWithName: false, + ...input, + }, + gqlFields: ` + id + options + defaultValue + `, + }); + + expect(data).not.toBeNull(); + expect(data.createOneField).toBeDefined(); + const createdOptions: FieldMetadataComplexOption[] = + 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); + } + }, + ); + + const createSpecificFailingTestCases: 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[], + }, + }, + }, + ]; + + test.each([...failingTestCases, ...createSpecificFailingTestCases])( + 'Create $title', + async ({ context: { input } }) => { + const { data, errors } = await createOneFieldMetadata({ + input: { + objectMetadataId: createdObjectMetadataId, + type: FieldMetadataType.SELECT, + name: 'testField', + label: 'Test Field', + isLabelSyncedWithName: false, + ...input, + }, + gqlFields: ` + id + options + `, + }); + + expect(data).toBeNull(); + expect(errors).toBeDefined(); + expect(errors).toMatchSnapshot(); + }, + ); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-create-one-field-metadata-select-tests-cases.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-create-one-field-metadata-select-tests-cases.ts new file mode 100644 index 000000000..35d23fc15 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-create-one-field-metadata-select-tests-cases.ts @@ -0,0 +1,374 @@ +import { CreateOneFieldFactoryInput } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata-query-factory.util'; +import { EachTestingContext } from 'twenty-shared/testing'; +import { v4 } from 'uuid'; + +import { FieldMetadataComplexOption } from 'src/engine/metadata-modules/field-metadata/dtos/options.input'; + +export type UpdateCreateFieldMetadataSelectTestCase = EachTestingContext<{ + input: Partial & + Required>; + expectedOptions?: FieldMetadataComplexOption[]; +}>; + +const successfulTestCases: UpdateCreateFieldMetadataSelectTestCase[] = [ + { + 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 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 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 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', + }, + ], + }, + }, + }, +]; + +const basicFailingStringEdgeCaseInputs: { + label: string; + input: string | undefined | number | null; +}[] = [ + { input: ' ', label: 'only white spaces' }, + { input: '', label: 'empty string' }, + { input: null, label: 'null' }, + { input: 22222, label: 'not a string' }, + { input: 'a'.repeat(64), label: 'too long' }, +]; + +const stringFields: (keyof FieldMetadataComplexOption)[] = [ + 'id', + 'label', + 'value', +]; +const autoGeneratedStringFailingTestsCases: UpdateCreateFieldMetadataSelectTestCase[] = + stringFields.flatMap((field) => { + return basicFailingStringEdgeCaseInputs.map( + ({ input, label }) => ({ + title: `should fail with ${label} ${field}`, + context: { + input: { + options: [ + { + label: 'Option 1', + value: 'OPTION_1', + color: 'green', + position: 1, + [field]: input, + }, + ], + }, + }, + }), + ); + }); +const failingTestCases: UpdateCreateFieldMetadataSelectTestCase[] = [ + ...autoGeneratedStringFailingTestsCases, + { + 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, + }, + ], + }, + }, + }, +]; + +export const UPDATE_CREATE_ONE_FIELD_METADATA_SELECT_TEST_CASES = { + successfulTestCases, + failingTestCases, +}; diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-select.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-select.integration-spec.ts new file mode 100644 index 000000000..24d01e545 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-select.integration-spec.ts @@ -0,0 +1,198 @@ +import { + UPDATE_CREATE_ONE_FIELD_METADATA_SELECT_TEST_CASES, + UpdateCreateFieldMetadataSelectTestCase, +} from 'test/integration/metadata/suites/field-metadata/update-create-one-field-metadata-select-tests-cases'; +import { 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 { + LISTING_NAME_PLURAL, + LISTING_NAME_SINGULAR, +} from 'test/integration/metadata/suites/object-metadata/constants/test-object-names.constant'; +import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util'; + +import { FieldMetadataComplexOption } from 'src/engine/metadata-modules/field-metadata/dtos/options.input'; + +const { failingTestCases, successfulTestCases } = + UPDATE_CREATE_ONE_FIELD_METADATA_SELECT_TEST_CASES; + +describe('Field metadata select update tests group', () => { + let createdObjectMetadataId: string; + let createdFieldMetadata: string; + const initialOptions: CreateOneFieldFactoryInput['options'] = [ + { + label: 'Option 1', + value: 'OPTION_1', + color: 'green', + position: 1, + }, + { + label: 'Option 2', + value: 'OPTION_2', + color: 'green', + position: 2, + }, + ]; + + beforeEach(async () => { + const { data } = await createOneObjectMetadata({ + input: { + labelSingular: LISTING_NAME_SINGULAR, + labelPlural: LISTING_NAME_PLURAL, + nameSingular: LISTING_NAME_SINGULAR, + namePlural: LISTING_NAME_PLURAL, + icon: 'IconBuildingSkyscraper', + isLabelSyncedWithName: false, + }, + }); + + createdObjectMetadataId = data.createOneObject.id; + + const { + data: { createOneField }, + } = await createOneFieldMetadata({ + input: { + objectMetadataId: createdObjectMetadataId, + type: FieldMetadataType.SELECT, + name: 'testField', + label: 'Test Field', + isLabelSyncedWithName: false, + options: initialOptions, + }, + gqlFields: ` + id + `, + }); + + createdFieldMetadata = createOneField.id; + }); + + afterEach(async () => { + await deleteOneObjectMetadata({ + input: { idToDelete: createdObjectMetadataId }, + }); + }); + + it('Should update default value to null even if it was set before', async () => { + const expectedDefaultValue = `'${initialOptions[0].value}'`; + const { data: firstUdpate } = await updateOneFieldMetadata({ + input: { + idToUpdate: createdFieldMetadata, + updatePayload: { + defaultValue: expectedDefaultValue, + }, + }, + gqlFields: ` + id + defaultValue + `, + }); + + expect(firstUdpate.updateOneField.defaultValue).toEqual( + expectedDefaultValue, + ); + + const updatedOptions = initialOptions.slice(1); + const { data: secondUpdate, errors } = await updateOneFieldMetadata({ + input: { + idToUpdate: createdFieldMetadata, + updatePayload: { + defaultValue: null, + options: updatedOptions, + }, + }, + gqlFields: ` + id + options + defaultValue + `, + }); + + expect(errors).toBeUndefined(); + expect(secondUpdate.updateOneField.defaultValue).toBeNull(); + expect(secondUpdate.updateOneField.options).toMatchObject(updatedOptions); + }); + + const updateSpecificSuccessfulTestCases: UpdateCreateFieldMetadataSelectTestCase[] = + [ + { + title: 'should succeed with default value and no options', + context: { + input: { + defaultValue: "'OPTION_2'", + options: undefined as unknown as FieldMetadataComplexOption[], + }, + expectedOptions: initialOptions, + }, + }, + ]; + + test.each([...successfulTestCases, ...updateSpecificSuccessfulTestCases])( + 'Update $title', + async ({ context: { input, expectedOptions } }) => { + const { data, errors } = await updateOneFieldMetadata({ + input: { + idToUpdate: createdFieldMetadata, + updatePayload: input, + }, + gqlFields: ` + id + options + defaultValue + `, + }); + + expect(data.updateOneField).toBeDefined(); + const updatedOptions: FieldMetadataComplexOption[] = + data.updateOneField.options; + + 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); + } + }, + ); + + const updateSpecificFailingTestCases: UpdateCreateFieldMetadataSelectTestCase[] = + [ + { + title: 'should fail with unknown default value and no options', + context: { + input: { + defaultValue: "'OPTION_42'", + options: undefined as unknown as FieldMetadataComplexOption[], + }, + }, + }, + ]; + + test.each([...updateSpecificFailingTestCases, ...failingTestCases])( + 'Update $title', + async ({ context: { input } }) => { + const { data, errors } = await updateOneFieldMetadata({ + input: { + idToUpdate: createdFieldMetadata, + updatePayload: input, + }, + gqlFields: ` + id + options + `, + }); + + expect(data).toBeNull(); + expect(errors).toBeDefined(); + expect(errors).toMatchSnapshot(); + }, + ); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata.integration-spec.ts index ac073a149..81a270fdb 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata.integration-spec.ts @@ -118,7 +118,9 @@ describe('updateOne', () => { }); describe('FieldMetadataService Enum Default Value Validation', () => { - it('should throw an error if the default value is not in the options', async () => { + let createdObjectMetadataId: string; + + beforeEach(async () => { const { data: listingObjectMetadata } = await createOneObjectMetadata({ input: { labelSingular: LISTING_NAME_SINGULAR, @@ -130,9 +132,19 @@ describe('updateOne', () => { }, }); + createdObjectMetadataId = listingObjectMetadata.createOneObject.id; + }); + + afterEach(async () => { + await deleteOneObjectMetadata({ + input: { idToDelete: createdObjectMetadataId }, + }); + }); + + it('should throw an error if the default value is not in the options', async () => { const { data: createdFieldMetadata } = await createOneFieldMetadata({ input: { - objectMetadataId: listingObjectMetadata.createOneObject.id, + objectMetadataId: createdObjectMetadataId, type: FieldMetadataType.SELECT, name: 'testName', label: 'Test name', @@ -140,7 +152,7 @@ describe('updateOne', () => { options: [ { label: 'Option 1', - value: 'option1', + value: 'OPTION_1', color: 'green', position: 1, }, @@ -152,7 +164,7 @@ describe('updateOne', () => { input: { idToUpdate: createdFieldMetadata.createOneField.id, updatePayload: { - defaultValue: 'option2', + defaultValue: 'OPTION_2', }, }, gqlFields: ` @@ -164,11 +176,16 @@ describe('updateOne', () => { expectToFail: true, }); - expect(errors[0].message).toBe('Invalid default value "option2"'); - - await deleteOneObjectMetadata({ - input: { idToDelete: listingObjectMetadata.createOneObject.id }, - }); + expect(errors).toMatchInlineSnapshot(` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + }, + "message": "Default value for existing options is invalid: OPTION_2", + }, +] +`); }); }); }); diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/utils/find-many-fields-metadata-query-factory.util.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/utils/find-many-fields-metadata-query-factory.util.ts index e2df4c921..7a291b7d1 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/utils/find-many-fields-metadata-query-factory.util.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/utils/find-many-fields-metadata-query-factory.util.ts @@ -12,7 +12,7 @@ export const findManyFieldsMetadataQueryFactory = ({ }: PerformMetadataQueryParams) => ({ query: gql` query FieldsMetadata($filter: FieldFilter!, $paging: CursorPaging!) { - fields(filter: $filter, paging: $paging) { + fields(paging: $paging, filter: $filter) { edges { node { ${gqlFields} diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/utils/find-many-fields-metadata.util.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/utils/find-many-fields-metadata.util.ts index c371bc61b..25639efd2 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/utils/find-many-fields-metadata.util.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/utils/find-many-fields-metadata.util.ts @@ -25,6 +25,8 @@ export const findManyFieldsMetadata = async ({ }); } - // @ts-expect-error legacy noImplicitAny - return response.body.data.fields.edges.map((edge) => edge.node); + return { + errors: response.body.errors, + fields: response.body.data.fields?.edges, + }; }; diff --git a/packages/twenty-server/test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util.ts b/packages/twenty-server/test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util.ts index 2c7f18ba9..a0827035d 100644 --- a/packages/twenty-server/test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util.ts +++ b/packages/twenty-server/test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util.ts @@ -6,11 +6,17 @@ import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/m import { PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; +import { BaseGraphQLError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto'; + export const findManyObjectMetadata = async ({ input, gqlFields, expectToFail = false, -}: PerformMetadataQueryParams) => { +}: PerformMetadataQueryParams): Promise<{ + errors: BaseGraphQLError[]; + objects: ObjectMetadataDTO[]; +}> => { const graphqlOperation = findManyObjectMetadataQueryFactory({ input, gqlFields, @@ -25,6 +31,10 @@ export const findManyObjectMetadata = async ({ }); } - // @ts-expect-error legacy noImplicitAny - return response.body.data.objects.edges.map((edge) => edge.node); + return { + errors: response.body.errors, + objects: response.body.data.objects?.edges.map( + ({ node }: { node: unknown }) => node, + ), + }; }; diff --git a/packages/twenty-server/test/integration/metadata/suites/object-metadata/utils/force-create-one-object-metadata.util.ts b/packages/twenty-server/test/integration/metadata/suites/object-metadata/utils/force-create-one-object-metadata.util.ts new file mode 100644 index 000000000..f6142180b --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/object-metadata/utils/force-create-one-object-metadata.util.ts @@ -0,0 +1,85 @@ +import { + LISTING_NAME_PLURAL, + LISTING_NAME_SINGULAR, +} from 'test/integration/metadata/suites/object-metadata/constants/test-object-names.constant'; +import { CreateOneObjectFactoryInput } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata-query-factory.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 { isDefined } from 'twenty-shared/utils'; + +export const forceCreateOneObjectMetadata = async ({ + input: { + labelSingular = LISTING_NAME_SINGULAR, + labelPlural = LISTING_NAME_PLURAL, + nameSingular = LISTING_NAME_SINGULAR, + namePlural = LISTING_NAME_PLURAL, + isLabelSyncedWithName = true, + icon = 'IconBuildingSkyscraper', + ...rest + }, +}: { + input: Partial; +}) => { + const result = await createOneObjectMetadata({ + input: { + labelSingular, + labelPlural, + nameSingular, + namePlural, + icon, + isLabelSyncedWithName, + ...rest, + }, + }); + + if (!isDefined(result.errors)) { + return result; + } + const { objects, errors } = await findManyObjectMetadata({ + input: { + filter: {}, + paging: { + first: 10000, + }, + }, + gqlFields: ` + nameSingular, + id + `, + }); + + if (isDefined(errors) || !isDefined(objects)) { + throw new Error( + 'Force create object metadata find many failed, should never occur', + ); + } + + const match = objects.find((object) => object.nameSingular === nameSingular); + + if (!isDefined(match)) { + throw new Error( + `Could not find an object with nameSingular ${nameSingular}, high chances this is a race condition`, + ); + } + + const { errors: deleteErrors } = await deleteOneObjectMetadata({ + input: { idToDelete: match.id }, + }); + + if (isDefined(deleteErrors)) { + throw new Error(JSON.stringify(deleteErrors)); + } + + return await createOneObjectMetadata({ + input: { + labelSingular, + labelPlural, + nameSingular, + namePlural, + icon, + isLabelSyncedWithName, + ...rest, + }, + }); +};