FieldMetadata ENUM CREATE UPDATE server validation and integration tests (#12121)

# 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
This commit is contained in:
Paul Rastoin
2025-05-22 17:58:59 +02:00
committed by GitHub
parent 7cc0a7ae72
commit 45c89a46d6
21 changed files with 1959 additions and 115 deletions

View File

@ -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 {}

View File

@ -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<T extends UpdateFieldInput | CreateFieldInput> =
{
fieldMetadataType: FieldMetadataType;
fieldMetadataInput: T;
objectMetadata: ObjectMetadataEntity;
existingFieldMetadata?: FieldMetadataEntity;
};
@Injectable()
export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntity> {
@ -88,8 +98,7 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
private readonly workspaceMigrationFactory: WorkspaceMigrationFactory,
private readonly workspaceMigrationService: WorkspaceMigrationService,
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
private readonly dataSourceService: DataSourceService,
private readonly typeORMService: TypeORMService,
private readonly fieldMetadataEnumValidationService: FieldMetadataEnumValidationService,
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly fieldMetadataValidationService: FieldMetadataValidationService,
@ -104,7 +113,7 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
): Promise<FieldMetadataEntity> {
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<FieldMetadataEntit
},
});
if (!existingFieldMetadata) {
if (!isDefined(existingFieldMetadata)) {
throw new FieldMetadataException(
'Field does not exist',
FieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND,
@ -152,14 +161,14 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
order: {},
});
if (!objectMetadata) {
if (!isDefined(objectMetadata)) {
throw new FieldMetadataException(
'Object metadata does not exist',
FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
);
}
if (!objectMetadata.labelIdentifierFieldMetadataId) {
if (!isDefined(objectMetadata.labelIdentifierFieldMetadataId)) {
throw new FieldMetadataException(
'Label identifier field metadata id does not exist',
FieldMetadataExceptionCode.LABEL_IDENTIFIER_FIELD_METADATA_ID_NOT_FOUND,
@ -190,17 +199,6 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
});
}
if (fieldMetadataInput.options) {
for (const option of fieldMetadataInput.options) {
if (!option.id) {
throw new FieldMetadataException(
'Option id is required',
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
}
}
const updatableFieldInput =
existingFieldMetadata.isCustom === false
? this.buildUpdatableStandardFieldInput(
@ -209,19 +207,26 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
)
: fieldMetadataInput;
const optionsForUpdate = isDefined(fieldMetadataInput.options)
? this.prepareCustomFieldMetadataOptions(fieldMetadataInput.options)
: undefined;
const defaultValueForUpdate =
updatableFieldInput.defaultValue !== undefined
? updatableFieldInput.defaultValue
: existingFieldMetadata.defaultValue;
const fieldMetadataForUpdate = {
...updatableFieldInput,
defaultValue:
updatableFieldInput.defaultValue !== undefined
? updatableFieldInput.defaultValue
: existingFieldMetadata.defaultValue,
defaultValue: defaultValueForUpdate,
...optionsForUpdate,
};
await this.validateFieldMetadata<UpdateFieldInput>(
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<FieldMetadataEntit
where: { id },
});
if (!updatedFieldMetadata) {
if (!isDefined(updatedFieldMetadata)) {
throw new FieldMetadataException(
'Field does not exist',
FieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND,
@ -605,11 +610,12 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
private async validateFieldMetadata<
T extends UpdateFieldInput | CreateFieldInput,
>(
fieldMetadataType: FieldMetadataType,
fieldMetadataInput: T,
objectMetadata: ObjectMetadataEntity,
): Promise<T> {
>({
fieldMetadataInput,
fieldMetadataType,
objectMetadata,
existingFieldMetadata,
}: ValidateFieldMetadataArgs<T>): Promise<T> {
if (fieldMetadataInput.name) {
try {
validateMetadataNameOrThrow(fieldMetadataInput.name);
@ -650,23 +656,13 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
}
}
if (fieldMetadataInput.options) {
for (const option of fieldMetadataInput.options) {
if (exceedsDatabaseIdentifierMaximumLength(option.value)) {
throw new FieldMetadataException(
`Option value "${option.value}" exceeds 63 characters`,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
}
if (isDefined(fieldMetadataInput.defaultValue)) {
await this.fieldMetadataValidationService.validateDefaultValueOrThrow({
fieldType: fieldMetadataType,
options: fieldMetadataInput.options,
defaultValue: fieldMetadataInput.defaultValue ?? null,
});
}
}
await this.fieldMetadataEnumValidationService.validateEnumFieldMetadataInput(
{
fieldMetadataInput,
fieldMetadataType,
existingFieldMetadata,
},
);
if (fieldMetadataInput.settings) {
await this.fieldMetadataValidationService.validateSettingsOrThrow({
@ -716,7 +712,28 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
return translatedMessage;
}
private prepareCustomFieldMetadataOptions(
options: FieldMetadataDefaultOption[] | FieldMetadataComplexOption[],
): undefined | Pick<FieldMetadataEntity, 'options'> {
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<FieldMetadataEntit
fieldMetadataInput.isNullable,
fieldMetadataInput.isRemoteCreation,
),
defaultValue:
fieldMetadataInput.defaultValue ??
generateDefaultValue(fieldMetadataInput.type),
options: fieldMetadataInput.options
? fieldMetadataInput.options.map((option) => ({
...option,
id: uuidV4(),
}))
: undefined,
defaultValue,
...options,
isActive: true,
isCustom: true,
};
@ -766,18 +776,6 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
assertMutationNotOnRemoteObject(objectMetadata);
}
if (isEnumFieldMetadataType(fieldMetadataInput.type)) {
if (
!fieldMetadataInput.options &&
fieldMetadataInput.type !== FieldMetadataType.RATING
) {
throw new FieldMetadataException(
'Options are required for enum fields',
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
}
if (fieldMetadataInput.type === FieldMetadataType.RATING) {
fieldMetadataInput.options = generateRatingOptions();
}
@ -785,11 +783,11 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
const fieldMetadataForCreate =
this.prepareCustomFieldMetadata(fieldMetadataInput);
await this.validateFieldMetadata<CreateFieldInput>(
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<FieldMetadataEntit
for (const objectMetadataId of objectMetadataIds) {
const objectMetadata = objectMetadataMap[objectMetadataId];
if (!objectMetadata) {
if (!isDefined(objectMetadata)) {
throw new FieldMetadataException(
'Object metadata does not exist',
FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
@ -887,7 +885,7 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
fieldMetadataInput.isRemoteCreation ?? false,
);
if (migrationAction) {
if (isDefined(migrationAction)) {
migrationActions.push(migrationAction);
}
}

View File

@ -0,0 +1,217 @@
import { Injectable } from '@nestjs/common';
import { isNonEmptyString } from '@sniptt/guards';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { z } from 'zod';
import { FieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-options.interface';
import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
import { UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
import { FieldMetadataValidationService } from 'src/engine/metadata-modules/field-metadata/field-metadata-validation.service';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import {
FieldMetadataException,
FieldMetadataExceptionCode,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { isEnumFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-enum-field-metadata-type.util';
import {
beneathDatabaseIdentifierMinimumLength,
exceedsDatabaseIdentifierMaximumLength,
} from 'src/engine/metadata-modules/utils/validate-database-identifier-length.utils';
import { isSnakeCaseString } from 'src/utils/is-snake-case-string';
type Validator<T> = { 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<T>(
elementToValidate: T,
{ message, validator }: Validator<T>,
) {
const shouldThrow = validator(elementToValidate);
if (shouldThrow) {
throw new FieldMetadataException(
message,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
}
private validateMetadataOptionId(sanitizedId?: string) {
const validators: Validator<string>[] = [
{
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<string>[] = [
{
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<string>[] = [
{
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<FieldMetadataOptions[number]['id']>();
const seenOptionValues = new Set<FieldMetadataOptions[number]['value']>();
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,
});
}
}
}

View File

@ -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) {

View File

@ -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: {

View File

@ -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' },
],
},
{

View File

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

View File

@ -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<string, any>;
keys: string[];
expected: Record<string, any>;
}>;
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);
});
});

View File

@ -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);

View File

@ -0,0 +1,30 @@
import { isDefined } from 'twenty-shared/utils';
type OnlyStringPropertiesKey<T> = Extract<keyof T, string>;
type StringPropertyKeys<T> = {
[K in OnlyStringPropertiesKey<T>]: T[K] extends string | undefined
? K
: never;
}[OnlyStringPropertiesKey<T>];
const sanitizeString = (str: string | null) =>
isDefined(str) ? str.trim().replace(/\s+/g, ' ') : str;
export const trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties = <T>(
obj: T,
keys: StringPropertyKeys<T>[],
) => {
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);
};

View File

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

View File

@ -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",
},
]
`;

View File

@ -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'",
},
]
`;

View File

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

View File

@ -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<CreateOneFieldFactoryInput> &
Required<Pick<CreateOneFieldFactoryInput, 'options'>>;
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<UpdateCreateFieldMetadataSelectTestCase>(
({ 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,
};

View File

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

View File

@ -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",
},
]
`);
});
});
});

View File

@ -12,7 +12,7 @@ export const findManyFieldsMetadataQueryFactory = ({
}: PerformMetadataQueryParams<FindManyFieldsMetadataFactoryInput>) => ({
query: gql`
query FieldsMetadata($filter: FieldFilter!, $paging: CursorPaging!) {
fields(filter: $filter, paging: $paging) {
fields(paging: $paging, filter: $filter) {
edges {
node {
${gqlFields}

View File

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

View File

@ -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<FindManyObjectMetadataFactoryInput>) => {
}: PerformMetadataQueryParams<FindManyObjectMetadataFactoryInput>): 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,
),
};
};

View File

@ -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<CreateOneObjectFactoryInput>;
}) => {
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,
},
});
};