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