From 7edfe4bc7a125d146748c29bb2d8d5050579c558 Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Thu, 23 Oct 2025 17:57:41 +0200 Subject: [PATCH] `[Followup]` `Fix defaultValue with enum options update in migration v2` integration test + polish (#15300) # Introduction Followup of https://github.com/twentyhq/twenty/pull/15286 - Polish replacing sorting by conditional filter and push - Integration test --- .../update-field-action-handler.service.ts | 10 +- ...effect-on-records.integration-spec.ts.snap | 27 ++ ...side-effect-on-records.integration-spec.ts | 237 ++++++++++++++++++ ...-if-error-but-not-expected-to-fail.util.ts | 9 +- 4 files changed, 278 insertions(+), 5 deletions(-) create mode 100644 packages/twenty-server/test/integration/metadata/suites/field-metadata/enum/__snapshots__/successful-update-default-value-option-value-and-side-effect-on-records.integration-spec.ts.snap create mode 100644 packages/twenty-server/test/integration/metadata/suites/field-metadata/enum/successful-update-default-value-option-value-and-side-effect-on-records.integration-spec.ts diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/field/services/update-field-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/field/services/update-field-action-handler.service.ts index e317795d23..bd00eddc99 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/field/services/update-field-action-handler.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/field/services/update-field-action-handler.service.ts @@ -156,10 +156,12 @@ export class UpdateFieldActionHandlerService extends WorkspaceMigrationRunnerAct let wasDefaultValueHandledByEnumUpdate = false; - const sortedUpdatesWithDefaultValuesUpdateLast = [...updates].sort( - (a, b) => - +(a.property === 'defaultValue') - +(b.property === 'defaultValue'), - ); + const sortedUpdatesWithDefaultValuesUpdateLast = hasDefaultValueUpdate + ? [ + ...updates.filter((update) => update.property !== 'defaultValue'), + defaultValueUpdate, + ] + : updates; for (const update of sortedUpdatesWithDefaultValuesUpdateLast) { if (isPropertyUpdate(update, 'name')) { diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/enum/__snapshots__/successful-update-default-value-option-value-and-side-effect-on-records.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/field-metadata/enum/__snapshots__/successful-update-default-value-option-value-and-side-effect-on-records.integration-spec.ts.snap new file mode 100644 index 0000000000..d9ae47efa2 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/enum/__snapshots__/successful-update-default-value-option-value-and-side-effect-on-records.integration-spec.ts.snap @@ -0,0 +1,27 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`successful update default value option value and side effect on records should update existing records when default value option value is changed and not mutate when default option is changed 1`] = ` +[ + { + "color": "green", + "id": Any, + "label": "Option 1", + "position": 0, + "value": "OPTION_1_MODIFIED", + }, + { + "color": "blue", + "id": Any, + "label": "Option 2", + "position": 1, + "value": "OPTION_2", + }, + { + "color": "red", + "id": Any, + "label": "Option 3", + "position": 2, + "value": "OPTION_3", + }, +] +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/enum/successful-update-default-value-option-value-and-side-effect-on-records.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/enum/successful-update-default-value-option-value-and-side-effect-on-records.integration-spec.ts new file mode 100644 index 0000000000..2a6bcbdd09 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/enum/successful-update-default-value-option-value-and-side-effect-on-records.integration-spec.ts @@ -0,0 +1,237 @@ +import { createOneOperation } from 'test/integration/graphql/utils/create-one-operation.util'; +import { findOneOperation } from 'test/integration/graphql/utils/find-one-operation.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 { 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 { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util'; +import { extractRecordIdsAndDatesAsExpectAny } from 'test/utils/extract-record-ids-and-dates-as-expect-any'; +import { FieldMetadataType } from 'twenty-shared/types'; + +const TEST_OBJECT_NAME_SINGULAR = 'testSelectObject'; +const TEST_OBJECT_NAME_PLURAL = 'testSelectObjects'; + +describe('successful update default value option value and side effect on records', () => { + let createdObjectMetadataId: string; + let createdFieldMetadataId: string; + let firstOptionId: string; + let secondOptionId: string; + let thirdOptionId: string; + + beforeAll(async () => { + const { data: objectData } = await createOneObjectMetadata({ + expectToFail: false, + input: { + nameSingular: TEST_OBJECT_NAME_SINGULAR, + namePlural: TEST_OBJECT_NAME_PLURAL, + labelSingular: 'Test Select Object', + labelPlural: 'Test Select Objects', + icon: 'IconTestPipe', + isLabelSyncedWithName: false, + }, + }); + + createdObjectMetadataId = objectData.createOneObject.id; + + const { data: fieldData } = await createOneFieldMetadata({ + expectToFail: false, + input: { + objectMetadataId: createdObjectMetadataId, + type: FieldMetadataType.SELECT, + name: 'statusField', + label: 'Status Field', + isLabelSyncedWithName: false, + options: [ + { + label: 'Option 1', + value: 'OPTION_1', + color: 'green', + position: 0, + }, + { + label: 'Option 2', + value: 'OPTION_2', + color: 'blue', + position: 1, + }, + { + label: 'Option 3', + value: 'OPTION_3', + color: 'red', + position: 2, + }, + ], + defaultValue: "'OPTION_1'", + }, + gqlFields: ` + id + options + defaultValue + `, + }); + + createdFieldMetadataId = fieldData.createOneField.id; + const options = fieldData.createOneField.options; + + if (!options) { + throw new Error('Options should be defined'); + } + + const option1 = options.find((opt) => opt.value === 'OPTION_1'); + const option2 = options.find((opt) => opt.value === 'OPTION_2'); + const option3 = options.find((opt) => opt.value === 'OPTION_3'); + + if (!option1?.id || !option2?.id || !option3?.id) { + throw new Error('All options and their IDs should be defined'); + } + + firstOptionId = option1.id; + secondOptionId = option2.id; + thirdOptionId = option3.id; + }); + + afterAll(async () => { + await updateOneObjectMetadata({ + expectToFail: false, + input: { + idToUpdate: createdObjectMetadataId, + updatePayload: { + isActive: false, + }, + }, + }); + await deleteOneObjectMetadata({ + expectToFail: false, + input: { idToDelete: createdObjectMetadataId }, + }); + }); + + it('should update existing records when default value option value is changed and not mutate when default option is changed', async () => { + const { data: createFirstRecordData } = await createOneOperation({ + expectToFail: false, + objectMetadataSingularName: TEST_OBJECT_NAME_SINGULAR, + input: {}, + gqlFields: ` + id + statusField + `, + }); + + const firstRecordId = createFirstRecordData.createOneResponse.id; + + expect(createFirstRecordData.createOneResponse.statusField).toBe( + 'OPTION_1', + ); + + const { data: updateOptionValueData } = await updateOneFieldMetadata({ + expectToFail: false, + input: { + idToUpdate: createdFieldMetadataId, + updatePayload: { + defaultValue: "'OPTION_1_MODIFIED'", + options: [ + { + id: firstOptionId, + label: 'Option 1', + value: 'OPTION_1_MODIFIED', + color: 'green', + position: 0, + }, + { + id: secondOptionId, + label: 'Option 2', + value: 'OPTION_2', + color: 'blue', + position: 1, + }, + { + id: thirdOptionId, + label: 'Option 3', + value: 'OPTION_3', + color: 'red', + position: 2, + }, + ], + }, + }, + gqlFields: ` + id + options + defaultValue + `, + }); + + expect(updateOptionValueData.updateOneField.defaultValue).toBe( + "'OPTION_1_MODIFIED'", + ); + + const { data: queryFirstRecordAfterOptionValueUpdate } = + await findOneOperation({ + expectToFail: false, + objectMetadataSingularName: TEST_OBJECT_NAME_SINGULAR, + gqlFields: ` + id + statusField + `, + filter: { id: { eq: firstRecordId } }, + }); + + expect( + queryFirstRecordAfterOptionValueUpdate.findResponse.statusField, + ).toBe('OPTION_1_MODIFIED'); + + const { data: updateDefaultOptionData } = await updateOneFieldMetadata({ + expectToFail: false, + input: { + idToUpdate: createdFieldMetadataId, + updatePayload: { + defaultValue: "'OPTION_2'", + }, + }, + gqlFields: ` + id + options + defaultValue + `, + }); + + expect(updateDefaultOptionData.updateOneField.defaultValue).toBe( + "'OPTION_2'", + ); + expect(updateDefaultOptionData.updateOneField.options).toMatchSnapshot( + extractRecordIdsAndDatesAsExpectAny( + updateDefaultOptionData.updateOneField.options as any, + ), + ); + + const { data: createSecondRecordData } = await createOneOperation({ + expectToFail: false, + objectMetadataSingularName: TEST_OBJECT_NAME_SINGULAR, + input: {}, + gqlFields: ` + id + statusField + `, + }); + + expect(createSecondRecordData.createOneResponse.statusField).toBe( + 'OPTION_2', + ); + + const { data: queryFirstRecordAfterDefaultChange } = await findOneOperation( + { + expectToFail: false, + objectMetadataSingularName: TEST_OBJECT_NAME_SINGULAR, + gqlFields: ` + id + statusField + `, + filter: { id: { eq: firstRecordId } }, + }, + ); + + expect(queryFirstRecordAfterDefaultChange.findResponse.statusField).toBe( + 'OPTION_1_MODIFIED', + ); + }); +}); diff --git a/packages/twenty-server/test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util.ts b/packages/twenty-server/test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util.ts index 637e2aa44f..a8f7845753 100644 --- a/packages/twenty-server/test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util.ts +++ b/packages/twenty-server/test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util.ts @@ -1,5 +1,5 @@ -import { isDefined } from 'class-validator'; import { type Response } from 'supertest'; +import { isDefined } from 'twenty-shared/utils'; type WarnIfErrorButNotExpectedToFailInput = { response: Response; @@ -11,6 +11,13 @@ export const warnIfErrorButNotExpectedToFail = ({ errorMessage, }: WarnIfErrorButNotExpectedToFailInput) => { if (isDefined(response.body.errors) && response.body.errors.length > 0) { + if ( + isDefined(process.env.LOG_LEVELS) && + process.env.LOG_LEVELS.includes('debug') + ) { + // eslint-disable-next-line no-console + console.log(JSON.stringify(response.body.errors, null, 2)); + } expect(response.body.errors).toEqual(errorMessage); } expect(response.body.data).toBeDefined();