From 9ed6edc005b900d8e198bee3d7de55681876b374 Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Tue, 13 May 2025 13:34:27 +0530 Subject: [PATCH] Twenty config integration tests + conversion refactor (#11972) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - In this PR the default value of IS_CONFIG_VARIABLES_IN_DB_ENABLED has been changed to true, - This is my first time writing integration tests, so I’d appreciate a thorough review. :) I’ve tried to follow the existing test patterns closely, but there might be some small mistakes I may have missed. Also let me know if I have missed any important test cases that should be tested UPDATE - ### Config Value Converter Refactoring - Created a centralized type transformers registry with bidirectional validation - Refactored ConfigValueConverterService to support validation in both directions: - Maintained existing DB-to-app conversion behavior - Added validation for app-to-DB conversion - Added integration tests to verify validation works in both directions --------- Co-authored-by: Félix Malfait --- .../twenty-config/config-variables.ts | 2 +- .../config-value-converter.service.spec.ts | 951 +++++++++++------- .../config-value-converter.service.ts | 177 +--- ...g-variable-graphql-api-exception.filter.ts | 1 + .../storage/config-storage.service.ts | 2 +- .../twenty-config/twenty-config.module.ts | 2 +- .../twenty-config/twenty-config.service.ts | 5 +- .../apply-basic-validators.util.spec.ts | 27 +- .../type-transformers.registry.spec.ts | 189 ++++ .../utils/apply-basic-validators.util.ts | 62 +- .../utils/type-transformers.registry.ts | 243 +++++ .../constants/config-test-keys.constants.ts | 16 + .../twenty-config.integration-spec.ts | 487 +++++++++ .../types/perform-twenty-config-query.type.ts | 4 + ...eate-config-variable.query-factory.util.ts | 24 + .../utils/create-config-variable.util.ts | 33 + ...lete-config-variable.query-factory.util.ts | 20 + .../utils/delete-config-variable.util.ts | 28 + .../get-config-variable.query-factory.util.ts | 29 + .../utils/get-config-variable.util.ts | 31 + ...ig-variables-grouped.query-factory.util.ts | 27 + .../get-config-variables-grouped.util.ts | 18 + .../make-admin-panel-api-request.util.ts | 24 + .../make-unauthenticated-api-request.util.ts | 12 + ...date-config-variable.query-factory.util.ts | 24 + .../utils/update-config-variable.util.ts | 29 + 26 files changed, 1891 insertions(+), 576 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/twenty-config/utils/__tests__/type-transformers.registry.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/twenty-config/utils/type-transformers.registry.ts create mode 100644 packages/twenty-server/test/integration/twenty-config/constants/config-test-keys.constants.ts create mode 100644 packages/twenty-server/test/integration/twenty-config/twenty-config.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/twenty-config/types/perform-twenty-config-query.type.ts create mode 100644 packages/twenty-server/test/integration/twenty-config/utils/create-config-variable.query-factory.util.ts create mode 100644 packages/twenty-server/test/integration/twenty-config/utils/create-config-variable.util.ts create mode 100644 packages/twenty-server/test/integration/twenty-config/utils/delete-config-variable.query-factory.util.ts create mode 100644 packages/twenty-server/test/integration/twenty-config/utils/delete-config-variable.util.ts create mode 100644 packages/twenty-server/test/integration/twenty-config/utils/get-config-variable.query-factory.util.ts create mode 100644 packages/twenty-server/test/integration/twenty-config/utils/get-config-variable.util.ts create mode 100644 packages/twenty-server/test/integration/twenty-config/utils/get-config-variables-grouped.query-factory.util.ts create mode 100644 packages/twenty-server/test/integration/twenty-config/utils/get-config-variables-grouped.util.ts create mode 100644 packages/twenty-server/test/integration/twenty-config/utils/make-admin-panel-api-request.util.ts create mode 100644 packages/twenty-server/test/integration/twenty-config/utils/make-unauthenticated-api-request.util.ts create mode 100644 packages/twenty-server/test/integration/twenty-config/utils/update-config-variable.query-factory.util.ts create mode 100644 packages/twenty-server/test/integration/twenty-config/utils/update-config-variable.util.ts diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index 354d25092..6a2437d95 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -762,7 +762,7 @@ export class ConfigVariables { type: ConfigVariableType.BOOLEAN, }) @IsOptional() - IS_CONFIG_VARIABLES_IN_DB_ENABLED = false; + IS_CONFIG_VARIABLES_IN_DB_ENABLED = true; @ConfigVariablesMetadata({ group: ConfigVariablesGroup.TokensDuration, diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/conversion/__tests__/config-value-converter.service.spec.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/conversion/__tests__/config-value-converter.service.spec.ts index a194890e1..b7179bf55 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/conversion/__tests__/config-value-converter.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/conversion/__tests__/config-value-converter.service.spec.ts @@ -6,31 +6,77 @@ import { CONFIG_VARIABLES_INSTANCE_TOKEN } from 'src/engine/core-modules/twenty- import { ConfigValueConverterService } from 'src/engine/core-modules/twenty-config/conversion/config-value-converter.service'; import { ConfigVariableType } from 'src/engine/core-modules/twenty-config/enums/config-variable-type.enum'; import { ConfigVariablesGroup } from 'src/engine/core-modules/twenty-config/enums/config-variables-group.enum'; -import { configTransformers } from 'src/engine/core-modules/twenty-config/utils/config-transformers.util'; +import * as typeTransformersModule from 'src/engine/core-modules/twenty-config/utils/type-transformers.registry'; import { TypedReflect } from 'src/utils/typed-reflect'; jest.mock( - 'src/engine/core-modules/twenty-config/utils/config-transformers.util', + 'src/engine/core-modules/twenty-config/utils/type-transformers.registry', () => { - const originalModule = jest.requireActual( - 'src/engine/core-modules/twenty-config/utils/config-transformers.util', - ); + const createMockTransformer = () => ({ + toApp: jest.fn().mockImplementation((value) => value), + toStorage: jest.fn().mockImplementation((value) => value), + getValidators: jest.fn().mockReturnValue([]), + getTransformers: jest.fn().mockReturnValue([]), + }); + + const mockRegistry = { + boolean: createMockTransformer(), + number: createMockTransformer(), + string: createMockTransformer(), + array: createMockTransformer(), + enum: createMockTransformer(), + }; return { - configTransformers: { - ...originalModule.configTransformers, - _mockedBoolean: jest.fn(), - _mockedNumber: jest.fn(), - _mockedString: jest.fn(), - }, + typeTransformers: mockRegistry, }; }, ); +const typeTransformers = typeTransformersModule.typeTransformers as { + boolean: { + toApp: jest.Mock; + toStorage: jest.Mock; + getValidators: jest.Mock; + getTransformers: jest.Mock; + }; + number: { + toApp: jest.Mock; + toStorage: jest.Mock; + getValidators: jest.Mock; + getTransformers: jest.Mock; + }; + string: { + toApp: jest.Mock; + toStorage: jest.Mock; + getValidators: jest.Mock; + getTransformers: jest.Mock; + }; + array: { + toApp: jest.Mock; + toStorage: jest.Mock; + getValidators: jest.Mock; + getTransformers: jest.Mock; + }; + enum: { + toApp: jest.Mock; + toStorage: jest.Mock; + getValidators: jest.Mock; + getTransformers: jest.Mock; + }; +}; + describe('ConfigValueConverterService', () => { let service: ConfigValueConverterService; beforeEach(async () => { + jest.clearAllMocks(); + + Object.values(typeTransformers).forEach((transformer) => { + transformer.toApp.mockImplementation((value) => value); + transformer.toStorage.mockImplementation((value) => value); + }); + const mockConfigVariables = { NODE_PORT: 3000, }; @@ -51,8 +97,7 @@ describe('ConfigValueConverterService', () => { }); describe('convertDbValueToAppValue', () => { - it('should convert string to boolean based on metadata', () => { - // Mock the metadata + it('should use boolean transformer for boolean type', () => { jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ AUTH_PASSWORD_ENABLED: { type: ConfigVariableType.BOOLEAN, @@ -61,58 +106,21 @@ describe('ConfigValueConverterService', () => { }, }); - expect( - service.convertDbValueToAppValue( - 'true', - 'AUTH_PASSWORD_ENABLED' as keyof ConfigVariables, - ), - ).toBe(true); - expect( - service.convertDbValueToAppValue( - 'True', - 'AUTH_PASSWORD_ENABLED' as keyof ConfigVariables, - ), - ).toBe(true); - expect( - service.convertDbValueToAppValue( - 'yes', - 'AUTH_PASSWORD_ENABLED' as keyof ConfigVariables, - ), - ).toBe(true); - expect( - service.convertDbValueToAppValue( - '1', - 'AUTH_PASSWORD_ENABLED' as keyof ConfigVariables, - ), - ).toBe(true); + typeTransformers.boolean.toApp.mockReturnValueOnce(true); - expect( - service.convertDbValueToAppValue( - 'false', - 'AUTH_PASSWORD_ENABLED' as keyof ConfigVariables, - ), - ).toBe(false); - expect( - service.convertDbValueToAppValue( - 'False', - 'AUTH_PASSWORD_ENABLED' as keyof ConfigVariables, - ), - ).toBe(false); - expect( - service.convertDbValueToAppValue( - 'no', - 'AUTH_PASSWORD_ENABLED' as keyof ConfigVariables, - ), - ).toBe(false); - expect( - service.convertDbValueToAppValue( - '0', - 'AUTH_PASSWORD_ENABLED' as keyof ConfigVariables, - ), - ).toBe(false); + const result = service.convertDbValueToAppValue( + 'true', + 'AUTH_PASSWORD_ENABLED' as keyof ConfigVariables, + ); + + expect(typeTransformers.boolean.toApp).toHaveBeenCalledWith( + 'true', + undefined, + ); + expect(result).toBe(true); }); - it('should convert string to number based on metadata', () => { + it('should use number transformer for number type', () => { jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ NODE_PORT: { type: ConfigVariableType.NUMBER, @@ -121,351 +129,534 @@ describe('ConfigValueConverterService', () => { }, }); - expect( - service.convertDbValueToAppValue( - '42', - 'NODE_PORT' as keyof ConfigVariables, - ), - ).toBe(42); - expect( - service.convertDbValueToAppValue( - '3.14', - 'NODE_PORT' as keyof ConfigVariables, - ), - ).toBe(3.14); + typeTransformers.number.toApp.mockReturnValueOnce(3000); - expect( + const result = service.convertDbValueToAppValue( + '3000', + 'NODE_PORT' as keyof ConfigVariables, + ); + + expect(typeTransformers.number.toApp).toHaveBeenCalledWith( + '3000', + undefined, + ); + expect(result).toBe(3000); + }); + + it('should use string transformer for string type', () => { + jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ + FRONTEND_URL: { + type: ConfigVariableType.STRING, + group: ConfigVariablesGroup.ServerConfig, + description: 'Frontend URL', + }, + }); + + typeTransformers.string.toApp.mockReturnValueOnce( + 'http://localhost:3000', + ); + + const result = service.convertDbValueToAppValue( + 'http://localhost:3000', + 'FRONTEND_URL' as keyof ConfigVariables, + ); + + expect(typeTransformers.string.toApp).toHaveBeenCalledWith( + 'http://localhost:3000', + undefined, + ); + expect(result).toBe('http://localhost:3000'); + }); + + it('should use array transformer for array type', () => { + jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ + LOG_LEVELS: { + type: ConfigVariableType.ARRAY, + group: ConfigVariablesGroup.Logging, + description: 'Levels of logging to be captured', + options: ['log', 'error', 'warn', 'debug', 'verbose'], + }, + }); + + const expectedResult = ['log', 'error', 'warn'] as unknown as LogLevel[]; + + typeTransformers.array.toApp.mockReturnValueOnce(expectedResult); + + const result = service.convertDbValueToAppValue( + 'log,error,warn', + 'LOG_LEVELS' as keyof ConfigVariables, + ); + + expect(typeTransformers.array.toApp).toHaveBeenCalledWith( + 'log,error,warn', + ['log', 'error', 'warn', 'debug', 'verbose'], + ); + expect(result).toEqual(expectedResult); + }); + + it('should use enum transformer for enum type', () => { + jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ + NODE_ENV: { + type: ConfigVariableType.ENUM, + group: ConfigVariablesGroup.ServerConfig, + description: 'Node environment', + options: ['development', 'production', 'test'], + }, + }); + + typeTransformers.enum.toApp.mockReturnValueOnce('development'); + + const result = service.convertDbValueToAppValue( + 'development', + 'NODE_ENV' as keyof ConfigVariables, + ); + + expect(typeTransformers.enum.toApp).toHaveBeenCalledWith('development', [ + 'development', + 'production', + 'test', + ]); + expect(result).toBe('development'); + }); + + it('should infer type from default value when no metadata is available', () => { + jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce(undefined); + + typeTransformers.number.toApp.mockReturnValueOnce(3000); + + const result = service.convertDbValueToAppValue( + '3000', + 'NODE_PORT' as keyof ConfigVariables, + ); + + expect(typeTransformers.number.toApp).toHaveBeenCalledWith( + '3000', + undefined, + ); + expect(result).toBe(3000); + }); + + it('should return undefined for null or undefined input', () => { + const result1 = service.convertDbValueToAppValue( + null, + 'NODE_PORT' as keyof ConfigVariables, + ); + const result2 = service.convertDbValueToAppValue( + undefined, + 'NODE_PORT' as keyof ConfigVariables, + ); + + expect(result1).toBeUndefined(); + expect(result2).toBeUndefined(); + }); + + it('should propagate errors from transformers with context', () => { + jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ + NODE_PORT: { + type: ConfigVariableType.NUMBER, + group: ConfigVariablesGroup.ServerConfig, + description: 'Port for the node server', + }, + }); + + const error = new Error('Test error'); + + typeTransformers.number.toApp.mockImplementationOnce(() => { + throw error; + }); + + expect(() => { service.convertDbValueToAppValue( 'not-a-number', 'NODE_PORT' as keyof ConfigVariables, - ), - ).toBeUndefined(); - }); - - it('should convert string to array based on metadata', () => { - jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ - LOG_LEVELS: { - type: ConfigVariableType.ARRAY, - group: ConfigVariablesGroup.Logging, - description: 'Levels of logging to be captured', - }, - }); - - expect( - service.convertDbValueToAppValue( - 'log,error,warn', - 'LOG_LEVELS' as keyof ConfigVariables, - ), - ).toEqual(['log', 'error', 'warn']); - - jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ - LOG_LEVELS: { - type: ConfigVariableType.ARRAY, - group: ConfigVariablesGroup.Logging, - description: 'Levels of logging to be captured', - }, - }); - expect( - service.convertDbValueToAppValue( - '["log","error","warn"]', - 'LOG_LEVELS' as keyof ConfigVariables, - ), - ).toEqual(['log', 'error', 'warn']); - }); - - it('should handle enum values as strings', () => { - jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce(undefined); - - expect( - service.convertDbValueToAppValue( - 'development', - 'NODE_ENV' as keyof ConfigVariables, - ), - ).toBe('development'); - }); - - it('should handle various input types', () => { - jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ - AUTH_PASSWORD_ENABLED: { - type: ConfigVariableType.BOOLEAN, - group: ConfigVariablesGroup.Other, - description: 'Enable or disable password authentication for users', - }, - }); - expect( - service.convertDbValueToAppValue( - true, - 'AUTH_PASSWORD_ENABLED' as keyof ConfigVariables, - ), - ).toBe(true); - - jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ - NODE_PORT: { - type: ConfigVariableType.NUMBER, - group: ConfigVariablesGroup.ServerConfig, - description: 'Port for the node server', - }, - }); - expect( - service.convertDbValueToAppValue( - 42, - 'NODE_PORT' as keyof ConfigVariables, - ), - ).toBe(42); - - jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ - LOG_LEVELS: { - type: ConfigVariableType.ARRAY, - group: ConfigVariablesGroup.Logging, - description: 'Levels of logging to be captured', - }, - }); - expect( - service.convertDbValueToAppValue( - ['log', 'error'] as LogLevel[], - 'LOG_LEVELS' as keyof ConfigVariables, - ), - ).toEqual(['log', 'error']); - }); - - it('should fall back to default value approach when no metadata', () => { - jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce(undefined); - - expect( - service.convertDbValueToAppValue( - '42', - 'NODE_PORT' as keyof ConfigVariables, - ), - ).toBe(42); - }); - - it('should handle null and undefined values', () => { - expect( - service.convertDbValueToAppValue( - null, - 'NODE_PORT' as keyof ConfigVariables, - ), - ).toBeUndefined(); - - expect( - service.convertDbValueToAppValue( - undefined, - 'NODE_PORT' as keyof ConfigVariables, - ), - ).toBeUndefined(); - }); - - it('should throw error if boolean converter returns non-boolean', () => { - jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ - AUTH_PASSWORD_ENABLED: { - type: ConfigVariableType.BOOLEAN, - group: ConfigVariablesGroup.Other, - description: 'Test boolean', - }, - }); - - const originalBoolean = configTransformers.boolean; - - configTransformers.boolean = jest - .fn() - .mockImplementation(() => 'not-a-boolean'); - - expect(() => { - service.convertDbValueToAppValue( - 'true', - 'AUTH_PASSWORD_ENABLED' as keyof ConfigVariables, ); - }).toThrow(/Expected boolean for key AUTH_PASSWORD_ENABLED/); - - configTransformers.boolean = originalBoolean; - }); - - it('should throw error if number converter returns non-number', () => { - jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ - NODE_PORT: { - type: ConfigVariableType.NUMBER, - group: ConfigVariablesGroup.ServerConfig, - description: 'Test number', - }, - }); - - const originalNumber = configTransformers.number; - - configTransformers.number = jest - .fn() - .mockImplementation(() => 'not-a-number'); - - expect(() => { - service.convertDbValueToAppValue( - '42', - 'NODE_PORT' as keyof ConfigVariables, - ); - }).toThrow(/Expected number for key NODE_PORT/); - - configTransformers.number = originalNumber; - }); - - it('should throw error if string converter returns non-string', () => { - jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ - EMAIL_FROM_ADDRESS: { - type: ConfigVariableType.STRING, - group: ConfigVariablesGroup.EmailSettings, - description: 'Test string', - }, - }); - - const originalString = configTransformers.string; - - configTransformers.string = jest.fn().mockImplementation(() => 42); - - expect(() => { - service.convertDbValueToAppValue( - 'test@example.com', - 'EMAIL_FROM_ADDRESS' as keyof ConfigVariables, - ); - }).toThrow(/Expected string for key EMAIL_FROM_ADDRESS/); - - configTransformers.string = originalString; - }); - - it('should throw error if array conversion produces non-array', () => { - jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ - LOG_LEVELS: { - type: ConfigVariableType.ARRAY, - group: ConfigVariablesGroup.Logging, - description: 'Test array', - }, - }); - - const convertToArraySpy = jest - .spyOn( - service as any, // Cast to any to access private method - 'convertToArray', - ) - .mockReturnValueOnce('not-an-array'); - - expect(() => { - service.convertDbValueToAppValue( - 'log,error,warn', - 'LOG_LEVELS' as keyof ConfigVariables, - ); - }).toThrow(/Expected array for key LOG_LEVELS/); - - convertToArraySpy.mockRestore(); - }); - - it('should handle array with option validation', () => { - jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ - LOG_LEVELS: { - type: ConfigVariableType.ARRAY, - group: ConfigVariablesGroup.Logging, - description: 'Test array with options', - options: ['log', 'error', 'warn', 'debug'], - }, - }); - - expect( - service.convertDbValueToAppValue( - 'log,error,warn', - 'LOG_LEVELS' as keyof ConfigVariables, - ), - ).toEqual(['log', 'error', 'warn']); - - jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ - LOG_LEVELS: { - type: ConfigVariableType.ARRAY, - group: ConfigVariablesGroup.Logging, - description: 'Test array with options', - options: ['log', 'error', 'warn', 'debug'], - }, - }); - - expect( - service.convertDbValueToAppValue( - 'log,invalid,warn', - 'LOG_LEVELS' as keyof ConfigVariables, - ), - ).toEqual(['log', 'warn']); - }); - - it('should properly handle enum with options', () => { - jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ - LOG_LEVEL: { - type: ConfigVariableType.ENUM, - group: ConfigVariablesGroup.Logging, - description: 'Test enum', - options: ['log', 'error', 'warn', 'debug'], - }, - }); - - expect( - service.convertDbValueToAppValue( - 'error', - 'LOG_LEVEL' as keyof ConfigVariables, - ), - ).toBe('error'); - - jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ - LOG_LEVEL: { - type: ConfigVariableType.ENUM, - group: ConfigVariablesGroup.Logging, - description: 'Test enum', - options: ['log', 'error', 'warn', 'debug'], - }, - }); - - expect( - service.convertDbValueToAppValue( - 'invalid', - 'LOG_LEVEL' as keyof ConfigVariables, - ), - ).toBeUndefined(); + }).toThrow(`Failed to convert NODE_PORT to app value: Test error`); }); }); describe('convertAppValueToDbValue', () => { - it('should handle primitive types directly', () => { - expect(service.convertAppValueToDbValue('string-value' as any)).toBe( - 'string-value', + it('should use boolean transformer for boolean type', () => { + jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ + AUTH_PASSWORD_ENABLED: { + type: ConfigVariableType.BOOLEAN, + group: ConfigVariablesGroup.Other, + description: 'Enable or disable password authentication for users', + }, + }); + + typeTransformers.boolean.toStorage.mockReturnValueOnce(true); + + const result = service.convertAppValueToDbValue( + true, + 'AUTH_PASSWORD_ENABLED' as keyof ConfigVariables, ); - expect(service.convertAppValueToDbValue(42 as any)).toBe(42); - expect(service.convertAppValueToDbValue(true as any)).toBe(true); - expect(service.convertAppValueToDbValue(undefined as any)).toBe(null); + + expect(typeTransformers.boolean.toStorage).toHaveBeenCalledWith( + true, + undefined, + ); + expect(result).toBe(true); }); - it('should handle arrays', () => { - const array = ['log', 'error', 'warn'] as LogLevel[]; + it('should use number transformer for number type', () => { + jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ + NODE_PORT: { + type: ConfigVariableType.NUMBER, + group: ConfigVariablesGroup.ServerConfig, + description: 'Port for the node server', + }, + }); - expect(service.convertAppValueToDbValue(array as any)).toEqual(array); + typeTransformers.number.toStorage.mockReturnValueOnce(3000); + + const result = service.convertAppValueToDbValue( + 3000, + 'NODE_PORT' as keyof ConfigVariables, + ); + + expect(typeTransformers.number.toStorage).toHaveBeenCalledWith( + 3000, + undefined, + ); + expect(result).toBe(3000); }); - it('should handle objects', () => { + it('should use string transformer for string type', () => { + jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ + FRONTEND_URL: { + type: ConfigVariableType.STRING, + group: ConfigVariablesGroup.ServerConfig, + description: 'Frontend URL', + }, + }); + + typeTransformers.string.toStorage.mockReturnValueOnce( + 'http://localhost:3000', + ); + + const result = service.convertAppValueToDbValue( + 'http://localhost:3000', + 'FRONTEND_URL' as keyof ConfigVariables, + ); + + expect(typeTransformers.string.toStorage).toHaveBeenCalledWith( + 'http://localhost:3000', + undefined, + ); + expect(result).toBe('http://localhost:3000'); + }); + + it('should use array transformer for array type', () => { + jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ + LOG_LEVELS: { + type: ConfigVariableType.ARRAY, + group: ConfigVariablesGroup.Logging, + description: 'Levels of logging to be captured', + options: ['log', 'error', 'warn', 'debug', 'verbose'], + }, + }); + + const inputArray = ['log', 'error', 'warn'] as LogLevel[]; + const expectedResult = ['log', 'error', 'warn']; + + typeTransformers.array.toStorage.mockReturnValueOnce(expectedResult); + + const result = service.convertAppValueToDbValue( + inputArray, + 'LOG_LEVELS' as keyof ConfigVariables, + ); + + expect(typeTransformers.array.toStorage).toHaveBeenCalledWith( + inputArray, + ['log', 'error', 'warn', 'debug', 'verbose'], + ); + expect(result).toEqual(expectedResult); + }); + + it('should use enum transformer for enum type', () => { + jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ + NODE_ENV: { + type: ConfigVariableType.ENUM, + group: ConfigVariablesGroup.ServerConfig, + description: 'Node environment', + options: ['development', 'production', 'test'], + }, + }); + + typeTransformers.enum.toStorage.mockReturnValueOnce('development'); + + const result = service.convertAppValueToDbValue( + 'development', + 'NODE_ENV' as keyof ConfigVariables, + ); + + expect(typeTransformers.enum.toStorage).toHaveBeenCalledWith( + 'development', + ['development', 'production', 'test'], + ); + expect(result).toBe('development'); + }); + + it('should infer type from default value when no metadata is available', () => { + jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce(undefined); + + typeTransformers.number.toStorage.mockReturnValueOnce(3000); + + const result = service.convertAppValueToDbValue( + 3000, + 'NODE_PORT' as keyof ConfigVariables, + ); + + expect(typeTransformers.number.toStorage).toHaveBeenCalledWith( + 3000, + undefined, + ); + expect(result).toBe(3000); + }); + + it('should return null for null or undefined input', () => { + const result1 = service.convertAppValueToDbValue( + null, + 'NODE_PORT' as keyof ConfigVariables, + ); + const result2 = service.convertAppValueToDbValue( + undefined, + 'NODE_PORT' as keyof ConfigVariables, + ); + + expect(result1).toBeNull(); + expect(result2).toBeNull(); + }); + + it('should handle object serialization when no transformer is found', () => { + jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ + CUSTOM_OBJECT: { + type: 'unknown-type' as ConfigVariableType, + group: ConfigVariablesGroup.Other, + description: 'Custom object', + }, + }); + const obj = { key: 'value' }; - expect(service.convertAppValueToDbValue(obj as any)).toEqual(obj); + const result = service.convertAppValueToDbValue( + obj as any, + 'CUSTOM_OBJECT' as keyof ConfigVariables, + ); + + expect(result).toEqual(obj); }); - it('should convert null to null', () => { - expect(service.convertAppValueToDbValue(null as any)).toBe(null); - }); + it('should propagate errors from transformers with context', () => { + jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ + NODE_PORT: { + type: ConfigVariableType.NUMBER, + group: ConfigVariablesGroup.ServerConfig, + description: 'Port for the node server', + }, + }); - it('should throw error for unsupported types', () => { - const symbol = Symbol('test'); + const error = new Error('Test error'); + + typeTransformers.number.toStorage.mockImplementationOnce(() => { + throw error; + }); expect(() => { - service.convertAppValueToDbValue(symbol as any); - }).toThrow(/Cannot convert value of type symbol/); + service.convertAppValueToDbValue( + 'not-a-number' as any, + 'NODE_PORT' as keyof ConfigVariables, + ); + }).toThrow(`Failed to convert NODE_PORT to DB value: Test error`); }); + }); - it('should handle serialization errors', () => { - // Create an object with circular reference - const circular: any = {}; + describe('convertAppValueToDbValue bidirectional validation', () => { + it('should validate boolean values when converting to storage', () => { + jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ + AUTH_PASSWORD_ENABLED: { + type: ConfigVariableType.BOOLEAN, + group: ConfigVariablesGroup.Other, + description: 'Enable or disable password authentication for users', + }, + }); - circular.self = circular; + typeTransformers.boolean.toStorage.mockImplementationOnce(() => { + throw new Error('Expected boolean, got string'); + }); expect(() => { - service.convertAppValueToDbValue(circular as any); - }).toThrow(/Failed to serialize object value/); + service.convertAppValueToDbValue( + 'not-a-boolean' as any, + 'AUTH_PASSWORD_ENABLED' as keyof ConfigVariables, + ); + }).toThrow( + 'Failed to convert AUTH_PASSWORD_ENABLED to DB value: Expected boolean, got string', + ); + }); + + it('should validate number values when converting to storage', () => { + jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ + NODE_PORT: { + type: ConfigVariableType.NUMBER, + group: ConfigVariablesGroup.ServerConfig, + description: 'Port for the node server', + }, + }); + + typeTransformers.number.toStorage.mockImplementationOnce(() => { + throw new Error('Expected number, got string'); + }); + + expect(() => { + service.convertAppValueToDbValue( + 'invalid-port' as any, + 'NODE_PORT' as keyof ConfigVariables, + ); + }).toThrow( + 'Failed to convert NODE_PORT to DB value: Expected number, got string', + ); + }); + + it('should validate string values when converting to storage', () => { + jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ + FRONTEND_URL: { + type: ConfigVariableType.STRING, + group: ConfigVariablesGroup.ServerConfig, + description: 'Frontend URL', + }, + }); + + typeTransformers.string.toStorage.mockImplementationOnce(() => { + throw new Error('Expected string, got object'); + }); + + expect(() => { + service.convertAppValueToDbValue( + {} as any, + 'FRONTEND_URL' as keyof ConfigVariables, + ); + }).toThrow( + 'Failed to convert FRONTEND_URL to DB value: Expected string, got object', + ); + }); + + it('should validate array values when converting to storage', () => { + jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ + LOG_LEVELS: { + type: ConfigVariableType.ARRAY, + group: ConfigVariablesGroup.Logging, + description: 'Levels of logging to be captured', + options: ['log', 'error', 'warn', 'debug', 'verbose'], + }, + }); + + typeTransformers.array.toStorage.mockImplementationOnce(() => { + throw new Error('Expected array, got string'); + }); + + expect(() => { + service.convertAppValueToDbValue( + 'not-an-array' as any, + 'LOG_LEVELS' as keyof ConfigVariables, + ); + }).toThrow( + 'Failed to convert LOG_LEVELS to DB value: Expected array, got string', + ); + }); + + it('should validate enum values when converting to storage', () => { + jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ + NODE_ENV: { + type: ConfigVariableType.ENUM, + group: ConfigVariablesGroup.ServerConfig, + description: 'Node environment', + options: ['development', 'production', 'test'], + }, + }); + + typeTransformers.enum.toStorage.mockImplementationOnce(() => { + throw new Error("Value 'invalid-env' is not a valid option for enum"); + }); + + expect(() => { + service.convertAppValueToDbValue( + 'invalid-env' as any, + 'NODE_ENV' as keyof ConfigVariables, + ); + }).toThrow( + "Failed to convert NODE_ENV to DB value: Value 'invalid-env' is not a valid option for enum", + ); + }); + + it('should validate enum values against options', () => { + jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ + NODE_ENV: { + type: ConfigVariableType.ENUM, + group: ConfigVariablesGroup.ServerConfig, + description: 'Node environment', + options: ['development', 'production', 'test'], + }, + }); + + typeTransformers.enum.toStorage.mockImplementation((value, options) => { + if (!options?.includes(value)) { + throw new Error(`Value '${value}' is not a valid option for enum`); + } + + return value; + }); + + expect( + service.convertAppValueToDbValue( + 'development' as any, + 'NODE_ENV' as keyof ConfigVariables, + ), + ).toBe('development'); + + expect(() => { + service.convertAppValueToDbValue( + 'staging' as any, + 'NODE_ENV' as keyof ConfigVariables, + ); + }).toThrow( + "Failed to convert NODE_ENV to DB value: Value 'staging' is not a valid option for enum", + ); + }); + + it('should filter array values based on options when converting to storage', () => { + jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ + LOG_LEVELS: { + type: ConfigVariableType.ARRAY, + group: ConfigVariablesGroup.Logging, + description: 'Levels of logging to be captured', + options: ['log', 'error', 'warn', 'debug', 'verbose'], + }, + }); + + typeTransformers.array.toStorage.mockImplementation((value, options) => { + if (!Array.isArray(value)) { + throw new Error(`Expected array, got ${typeof value}`); + } + + if (!options || !Array.isArray(options) || options.length === 0) { + return value; + } + + return value.filter((item) => options.includes(item)); + }); + + const result = service.convertAppValueToDbValue( + ['log', 'invalid-level', 'error'] as any, + 'LOG_LEVELS' as keyof ConfigVariables, + ); + + expect(result).toEqual(['log', 'error']); + + expect(typeTransformers.array.toStorage).toHaveBeenCalledWith( + ['log', 'invalid-level', 'error'], + ['log', 'error', 'warn', 'debug', 'verbose'], + ); }); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/conversion/config-value-converter.service.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/conversion/config-value-converter.service.ts index 975181ee7..d8a74982e 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/conversion/config-value-converter.service.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/conversion/config-value-converter.service.ts @@ -4,8 +4,11 @@ import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-va import { CONFIG_VARIABLES_INSTANCE_TOKEN } from 'src/engine/core-modules/twenty-config/constants/config-variables-instance-tokens.constants'; import { ConfigVariablesMetadataMap } from 'src/engine/core-modules/twenty-config/decorators/config-variables-metadata.decorator'; import { ConfigVariableType } from 'src/engine/core-modules/twenty-config/enums/config-variable-type.enum'; -import { ConfigVariableOptions } from 'src/engine/core-modules/twenty-config/types/config-variable-options.type'; -import { configTransformers } from 'src/engine/core-modules/twenty-config/utils/config-transformers.util'; +import { + ConfigVariableException, + ConfigVariableExceptionCode, +} from 'src/engine/core-modules/twenty-config/twenty-config.exception'; +import { typeTransformers } from 'src/engine/core-modules/twenty-config/utils/type-transformers.registry'; import { TypedReflect } from 'src/utils/typed-reflect'; @Injectable() @@ -30,164 +33,62 @@ export class ConfigValueConverterService { const options = metadata?.options; try { - switch (configType) { - case ConfigVariableType.BOOLEAN: { - const result = configTransformers.boolean(dbValue); + const transformer = typeTransformers[configType]; - if (result !== undefined && typeof result !== 'boolean') { - throw new Error( - `Expected boolean for key ${key}, got ${typeof result}`, - ); - } - - return result as ConfigVariables[T]; - } - - case ConfigVariableType.NUMBER: { - const result = configTransformers.number(dbValue); - - if (result !== undefined && typeof result !== 'number') { - throw new Error( - `Expected number for key ${key}, got ${typeof result}`, - ); - } - - return result as ConfigVariables[T]; - } - - case ConfigVariableType.STRING: { - const result = configTransformers.string(dbValue); - - if (result !== undefined && typeof result !== 'string') { - throw new Error( - `Expected string for key ${key}, got ${typeof result}`, - ); - } - - return result as ConfigVariables[T]; - } - - case ConfigVariableType.ARRAY: { - const result = this.convertToArray(dbValue, options); - - if (result !== undefined && !Array.isArray(result)) { - throw new Error( - `Expected array for key ${key}, got ${typeof result}`, - ); - } - - return result as ConfigVariables[T]; - } - - case ConfigVariableType.ENUM: { - const result = this.convertToEnum(dbValue, options); - - return result as ConfigVariables[T]; - } - - default: - return dbValue as ConfigVariables[T]; + if (!transformer) { + return dbValue as ConfigVariables[T]; } + + return transformer.toApp(dbValue, options) as ConfigVariables[T]; } catch (error) { - throw new Error( + throw new ConfigVariableException( `Failed to convert ${key as string} to app value: ${(error as Error).message}`, + ConfigVariableExceptionCode.VALIDATION_FAILED, ); } } convertAppValueToDbValue( appValue: ConfigVariables[T] | null | undefined, + key: T, ): unknown { - if (appValue === undefined || appValue === null) { + if (appValue === null || appValue === undefined) { return null; } - if ( - typeof appValue === 'string' || - typeof appValue === 'number' || - typeof appValue === 'boolean' - ) { - return appValue; - } + const metadata = this.getConfigVariableMetadata(key); + const configType = metadata?.type || this.inferTypeFromValue(key); + const options = metadata?.options; - if (Array.isArray(appValue)) { - return appValue; - } + try { + const transformer = typeTransformers[configType]; - if (typeof appValue === 'object') { - try { - return JSON.parse(JSON.stringify(appValue)); - } catch (error) { - throw new Error( - `Failed to serialize object value: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - - throw new Error( - `Cannot convert value of type ${typeof appValue} to storage format`, - ); - } - - private convertToArray( - value: unknown, - options?: ConfigVariableOptions, - ): unknown[] { - if (Array.isArray(value)) { - return this.validateArrayAgainstOptions(value, options); - } - - if (typeof value === 'string') { - try { - const parsedArray = JSON.parse(value); - - if (Array.isArray(parsedArray)) { - return this.validateArrayAgainstOptions(parsedArray, options); + if (!transformer) { + if (typeof appValue === 'object') { + try { + return JSON.parse(JSON.stringify(appValue)); + } catch (error) { + throw new ConfigVariableException( + `Failed to serialize object value: ${error instanceof Error ? error.message : String(error)}`, + ConfigVariableExceptionCode.VALIDATION_FAILED, + ); + } } - } catch { - const splitArray = value.split(',').map((item) => item.trim()); - return this.validateArrayAgainstOptions(splitArray, options); - } - } - - return this.validateArrayAgainstOptions([value], options); - } - - private validateArrayAgainstOptions( - array: unknown[], - options?: ConfigVariableOptions, - ): unknown[] { - if (!options || !Array.isArray(options) || options.length === 0) { - return array; - } - - return array.filter((item) => { - const included = options.includes(item as string); - - if (!included) { - this.logger.debug( - `Filtered out array item '${String(item)}' not in allowed options`, - ); + return appValue; } - return included; - }); - } + return transformer.toStorage(appValue as any, options); + } catch (error) { + if (error instanceof ConfigVariableException) { + throw error; + } - private convertToEnum( - value: unknown, - options?: ConfigVariableOptions, - ): unknown | undefined { - if (!options || !Array.isArray(options) || options.length === 0) { - return value; + throw new ConfigVariableException( + `Failed to convert ${key as string} to DB value: ${(error as Error).message}`, + ConfigVariableExceptionCode.VALIDATION_FAILED, + ); } - - if (options.includes(value as string)) { - return value; - } - - return undefined; } private getConfigVariableMetadata(key: T) { diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/filters/config-variable-graphql-api-exception.filter.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/filters/config-variable-graphql-api-exception.filter.ts index 19ff9dbb2..636541d57 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/filters/config-variable-graphql-api-exception.filter.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/filters/config-variable-graphql-api-exception.filter.ts @@ -22,6 +22,7 @@ export class ConfigVariableGraphqlApiExceptionFilter case ConfigVariableExceptionCode.ENVIRONMENT_ONLY_VARIABLE: throw new ForbiddenError(exception.message); case ConfigVariableExceptionCode.DATABASE_CONFIG_DISABLED: + case ConfigVariableExceptionCode.VALIDATION_FAILED: throw new UserInputError(exception.message); case ConfigVariableExceptionCode.INTERNAL_ERROR: default: diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/storage/config-storage.service.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/storage/config-storage.service.ts index 91b0be54e..deda40fa3 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/storage/config-storage.service.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/storage/config-storage.service.ts @@ -64,7 +64,7 @@ export class ConfigStorageService implements ConfigStorageInterface { try { const convertedValue = isDecrypt ? this.configValueConverter.convertDbValueToAppValue(value, key) - : this.configValueConverter.convertAppValueToDbValue(value); + : this.configValueConverter.convertAppValueToDbValue(value, key); const metadata = this.getConfigMetadata(key); const isSensitiveString = diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/twenty-config.module.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/twenty-config.module.ts index d650e8d62..af38e1590 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/twenty-config.module.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/twenty-config.module.ts @@ -16,7 +16,7 @@ import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twent export class TwentyConfigModule extends ConfigurableModuleClass { static forRoot(): DynamicModule { const isConfigVariablesInDbEnabled = - process.env.IS_CONFIG_VARIABLES_IN_DB_ENABLED === 'true'; + process.env.IS_CONFIG_VARIABLES_IN_DB_ENABLED !== 'false'; const imports = [ ConfigModule.forRoot({ diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/twenty-config.service.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/twenty-config.service.ts index 8e80df4d8..2f5074714 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/twenty-config.service.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/twenty-config.service.ts @@ -1,9 +1,8 @@ -import { Inject, Injectable, Logger, Optional } from '@nestjs/common'; +import { Injectable, Logger, Optional } from '@nestjs/common'; import { isString } from 'class-validator'; import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables'; -import { CONFIG_VARIABLES_INSTANCE_TOKEN } from 'src/engine/core-modules/twenty-config/constants/config-variables-instance-tokens.constants'; import { CONFIG_VARIABLES_MASKING_CONFIG } from 'src/engine/core-modules/twenty-config/constants/config-variables-masking-config'; import { ConfigVariablesMetadataOptions } from 'src/engine/core-modules/twenty-config/decorators/config-variables-metadata.decorator'; import { DatabaseConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/database-config.driver'; @@ -26,8 +25,6 @@ export class TwentyConfigService { constructor( private readonly environmentConfigDriver: EnvironmentConfigDriver, @Optional() private readonly databaseConfigDriver: DatabaseConfigDriver, - @Inject(CONFIG_VARIABLES_INSTANCE_TOKEN) - private readonly configVariablesInstance: ConfigVariables, ) { const isConfigVariablesInDbEnabled = this.environmentConfigDriver.get( 'IS_CONFIG_VARIABLES_IN_DB_ENABLED', diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/utils/__tests__/apply-basic-validators.util.spec.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/utils/__tests__/apply-basic-validators.util.spec.ts index 37be43dc7..91223aefe 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/utils/__tests__/apply-basic-validators.util.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/utils/__tests__/apply-basic-validators.util.spec.ts @@ -120,7 +120,7 @@ describe('applyBasicValidators', () => { ); expect(IsString).toHaveBeenCalled(); - expect(Transform).not.toHaveBeenCalled(); // String doesn't need a transform + expect(Transform).not.toHaveBeenCalled(); }); }); @@ -136,7 +136,7 @@ describe('applyBasicValidators', () => { ); expect(IsEnum).toHaveBeenCalledWith(enumOptions); - expect(Transform).not.toHaveBeenCalled(); // Enum doesn't need a transform + expect(Transform).not.toHaveBeenCalled(); }); it('should apply enum validator with enum object options', () => { @@ -146,6 +146,25 @@ describe('applyBasicValidators', () => { Option3 = 'value3', } + jest.mock( + 'src/engine/core-modules/twenty-config/utils/type-transformers.registry', + () => ({ + typeTransformers: { + enum: { + getValidators: jest.fn().mockImplementation((options) => { + if (options && Object.keys(options).length > 0) { + return [IsEnum(options)]; + } + + return []; + }), + getTransformers: jest.fn().mockReturnValue([]), + }, + }, + }), + { virtual: true }, + ); + applyBasicValidators( ConfigVariableType.ENUM, mockTarget, @@ -154,7 +173,7 @@ describe('applyBasicValidators', () => { ); expect(IsEnum).toHaveBeenCalledWith(TestEnum); - expect(Transform).not.toHaveBeenCalled(); // Enum doesn't need a transform + expect(Transform).not.toHaveBeenCalled(); }); it('should not apply enum validator without options', () => { @@ -178,7 +197,7 @@ describe('applyBasicValidators', () => { ); expect(IsArray).toHaveBeenCalled(); - expect(Transform).not.toHaveBeenCalled(); // Array doesn't need a transform + expect(Transform).not.toHaveBeenCalled(); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/utils/__tests__/type-transformers.registry.spec.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/utils/__tests__/type-transformers.registry.spec.ts new file mode 100644 index 000000000..2389ea89b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/utils/__tests__/type-transformers.registry.spec.ts @@ -0,0 +1,189 @@ +import { ConfigVariableType } from 'src/engine/core-modules/twenty-config/enums/config-variable-type.enum'; +import { typeTransformers } from 'src/engine/core-modules/twenty-config/utils/type-transformers.registry'; + +describe('Type Transformers Registry', () => { + describe('Boolean Transformer', () => { + const booleanTransformer = typeTransformers[ConfigVariableType.BOOLEAN]; + + describe('toApp', () => { + it('should convert string "true" to boolean true', () => { + expect(booleanTransformer.toApp('true')).toBe(true); + }); + + it('should convert string "false" to boolean false', () => { + expect(booleanTransformer.toApp('false')).toBe(false); + }); + + it('should return undefined for null or undefined', () => { + expect(booleanTransformer.toApp(null)).toBeUndefined(); + expect(booleanTransformer.toApp(undefined)).toBeUndefined(); + }); + }); + + describe('toStorage', () => { + it('should keep boolean values unchanged', () => { + expect(booleanTransformer.toStorage(true)).toBe(true); + expect(booleanTransformer.toStorage(false)).toBe(false); + }); + + it('should throw error for non-boolean values', () => { + expect(() => { + booleanTransformer.toStorage('true' as any); + }).toThrow(); + }); + }); + }); + + describe('Number Transformer', () => { + const numberTransformer = typeTransformers[ConfigVariableType.NUMBER]; + + describe('toApp', () => { + it('should convert string number to number', () => { + expect(numberTransformer.toApp('123')).toBe(123); + }); + + it('should return undefined for non-parseable strings', () => { + expect(numberTransformer.toApp('not-a-number')).toBeUndefined(); + }); + + it('should return undefined for null or undefined', () => { + expect(numberTransformer.toApp(null)).toBeUndefined(); + expect(numberTransformer.toApp(undefined)).toBeUndefined(); + }); + }); + + describe('toStorage', () => { + it('should keep number values unchanged', () => { + expect(numberTransformer.toStorage(123)).toBe(123); + }); + + it('should throw error for non-number values', () => { + expect(() => { + numberTransformer.toStorage('123' as any); + }).toThrow(); + }); + }); + }); + + describe('String Transformer', () => { + const stringTransformer = typeTransformers[ConfigVariableType.STRING]; + + describe('toApp', () => { + it('should keep string values unchanged', () => { + expect(stringTransformer.toApp('hello')).toBe('hello'); + }); + + it('should convert numbers to strings', () => { + expect(stringTransformer.toApp(123)).toBe('123'); + }); + + it('should return undefined for null or undefined', () => { + expect(stringTransformer.toApp(null)).toBeUndefined(); + expect(stringTransformer.toApp(undefined)).toBeUndefined(); + }); + }); + + describe('toStorage', () => { + it('should keep string values unchanged', () => { + expect(stringTransformer.toStorage('hello')).toBe('hello'); + }); + + it('should throw error for non-string values', () => { + expect(() => { + stringTransformer.toStorage(123 as any); + }).toThrow(); + }); + }); + }); + + describe('Array Transformer', () => { + const arrayTransformer = typeTransformers[ConfigVariableType.ARRAY]; + + describe('toApp', () => { + it('should keep array values unchanged', () => { + const array = [1, 2, 3]; + + expect(arrayTransformer.toApp(array)).toEqual(array); + }); + + it('should convert comma-separated string to array', () => { + expect(arrayTransformer.toApp('a,b,c')).toEqual(['a', 'b', 'c']); + }); + + it('should filter array values based on options', () => { + expect( + arrayTransformer.toApp(['a', 'b', 'c', 'd'], ['a', 'c']), + ).toEqual(['a', 'c']); + }); + + it('should return undefined for null or undefined', () => { + expect(arrayTransformer.toApp(null)).toBeUndefined(); + expect(arrayTransformer.toApp(undefined)).toBeUndefined(); + }); + }); + + describe('toStorage', () => { + it('should keep array values unchanged', () => { + const array = [1, 2, 3]; + + expect(arrayTransformer.toStorage(array)).toEqual(array); + }); + + it('should filter array values based on options', () => { + expect( + arrayTransformer.toStorage(['a', 'b', 'c', 'd'], ['a', 'c']), + ).toEqual(['a', 'c']); + }); + + it('should throw error for non-array values', () => { + expect(() => { + arrayTransformer.toStorage('not-an-array' as any); + }).toThrow(); + }); + }); + }); + + describe('Enum Transformer', () => { + const enumTransformer = typeTransformers[ConfigVariableType.ENUM]; + const options = ['option1', 'option2', 'option3']; + + describe('toApp', () => { + it('should keep valid enum values unchanged', () => { + expect(enumTransformer.toApp('option1', options)).toBe('option1'); + }); + + it('should return undefined for invalid enum values', () => { + expect( + enumTransformer.toApp('invalid-option', options), + ).toBeUndefined(); + }); + + it('should return the value if no options are provided', () => { + expect(enumTransformer.toApp('any-value')).toBe('any-value'); + }); + + it('should return undefined for null or undefined', () => { + expect(enumTransformer.toApp(null)).toBeUndefined(); + expect(enumTransformer.toApp(undefined)).toBeUndefined(); + }); + }); + + describe('toStorage', () => { + it('should keep valid enum values unchanged', () => { + expect(enumTransformer.toStorage('option1', options)).toBe('option1'); + }); + + it('should throw error for invalid enum values', () => { + expect(() => { + enumTransformer.toStorage('invalid-option', options); + }).toThrow(); + }); + + it('should throw error for non-string values', () => { + expect(() => { + enumTransformer.toStorage(123 as any, options); + }).toThrow(); + }); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/utils/apply-basic-validators.util.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/utils/apply-basic-validators.util.ts index 1a35bf172..c06c23273 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/utils/apply-basic-validators.util.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/utils/apply-basic-validators.util.ts @@ -1,19 +1,10 @@ -import { Transform } from 'class-transformer'; -import { - IsArray, - IsBoolean, - IsEnum, - IsNumber, - IsString, -} from 'class-validator'; - import { ConfigVariableType } from 'src/engine/core-modules/twenty-config/enums/config-variable-type.enum'; import { ConfigVariableException, ConfigVariableExceptionCode, } from 'src/engine/core-modules/twenty-config/twenty-config.exception'; import { ConfigVariableOptions } from 'src/engine/core-modules/twenty-config/types/config-variable-options.type'; -import { configTransformers } from 'src/engine/core-modules/twenty-config/utils/config-transformers.util'; +import { typeTransformers } from 'src/engine/core-modules/twenty-config/utils/type-transformers.registry'; export function applyBasicValidators( type: ConfigVariableType, @@ -21,43 +12,20 @@ export function applyBasicValidators( propertyKey: string, options?: ConfigVariableOptions, ): void { - switch (type) { - case ConfigVariableType.BOOLEAN: - Transform(({ value }) => { - const result = configTransformers.boolean(value); + const transformer = typeTransformers[type]; - return result !== undefined ? result : value; - })(target, propertyKey); - IsBoolean()(target, propertyKey); - break; - - case ConfigVariableType.NUMBER: - Transform(({ value }) => { - const result = configTransformers.number(value); - - return result !== undefined ? result : value; - })(target, propertyKey); - IsNumber()(target, propertyKey); - break; - - case ConfigVariableType.STRING: - IsString()(target, propertyKey); - break; - - case ConfigVariableType.ENUM: - if (options) { - IsEnum(options)(target, propertyKey); - } - break; - - case ConfigVariableType.ARRAY: - IsArray()(target, propertyKey); - break; - - default: - throw new ConfigVariableException( - `Unsupported config variable type: ${type}`, - ConfigVariableExceptionCode.UNSUPPORTED_CONFIG_TYPE, - ); + if (!transformer) { + throw new ConfigVariableException( + `Unsupported config variable type: ${type}`, + ConfigVariableExceptionCode.UNSUPPORTED_CONFIG_TYPE, + ); } + + transformer + .getTransformers() + .forEach((decorator) => decorator(target, propertyKey)); + + transformer + .getValidators(options) + .forEach((decorator) => decorator(target, propertyKey)); } diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/utils/type-transformers.registry.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/utils/type-transformers.registry.ts new file mode 100644 index 000000000..0977e243d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/utils/type-transformers.registry.ts @@ -0,0 +1,243 @@ +import { Transform } from 'class-transformer'; +import { + IsArray, + IsBoolean, + IsEnum, + IsNumber, + IsString, +} from 'class-validator'; + +import { ConfigVariableType } from 'src/engine/core-modules/twenty-config/enums/config-variable-type.enum'; +import { + ConfigVariableException, + ConfigVariableExceptionCode, +} from 'src/engine/core-modules/twenty-config/twenty-config.exception'; +import { ConfigVariableOptions } from 'src/engine/core-modules/twenty-config/types/config-variable-options.type'; +import { configTransformers } from 'src/engine/core-modules/twenty-config/utils/config-transformers.util'; + +export interface TypeTransformer { + toApp: (value: unknown, options?: ConfigVariableOptions) => T | undefined; + + toStorage: (value: T, options?: ConfigVariableOptions) => unknown; + + getValidators: (options?: ConfigVariableOptions) => PropertyDecorator[]; + + getTransformers: () => PropertyDecorator[]; +} + +export const typeTransformers: Record< + ConfigVariableType, + TypeTransformer +> = { + [ConfigVariableType.BOOLEAN]: { + toApp: (value: unknown): boolean | undefined => { + if (value === null || value === undefined) return undefined; + + const result = configTransformers.boolean(value); + + if (result !== undefined && typeof result !== 'boolean') { + throw new ConfigVariableException( + `Expected boolean, got ${typeof result}`, + ConfigVariableExceptionCode.VALIDATION_FAILED, + ); + } + + return result; + }, + + toStorage: (value: boolean): boolean => { + if (typeof value !== 'boolean') { + throw new ConfigVariableException( + `Expected boolean, got ${typeof value}`, + ConfigVariableExceptionCode.VALIDATION_FAILED, + ); + } + + return value; + }, + + getValidators: (): PropertyDecorator[] => [IsBoolean()], + + getTransformers: (): PropertyDecorator[] => [ + Transform(({ value }) => { + const result = configTransformers.boolean(value); + + return result !== undefined ? result : value; + }), + ], + }, + + [ConfigVariableType.NUMBER]: { + toApp: (value: unknown): number | undefined => { + if (value === null || value === undefined) return undefined; + + const result = configTransformers.number(value); + + if (result !== undefined && typeof result !== 'number') { + throw new ConfigVariableException( + `Expected number, got ${typeof result}`, + ConfigVariableExceptionCode.VALIDATION_FAILED, + ); + } + + return result; + }, + + toStorage: (value: number): number => { + if (typeof value !== 'number') { + throw new ConfigVariableException( + `Expected number, got ${typeof value}`, + ConfigVariableExceptionCode.VALIDATION_FAILED, + ); + } + + return value; + }, + + getValidators: (): PropertyDecorator[] => [IsNumber()], + + getTransformers: (): PropertyDecorator[] => [ + Transform(({ value }) => { + const result = configTransformers.number(value); + + return result !== undefined ? result : value; + }), + ], + }, + + [ConfigVariableType.STRING]: { + toApp: (value: unknown): string | undefined => { + if (value === null || value === undefined) return undefined; + + const result = configTransformers.string(value); + + if (result !== undefined && typeof result !== 'string') { + throw new ConfigVariableException( + `Expected string, got ${typeof result}`, + ConfigVariableExceptionCode.VALIDATION_FAILED, + ); + } + + return result; + }, + + toStorage: (value: string): string => { + if (typeof value !== 'string') { + throw new ConfigVariableException( + `Expected string, got ${typeof value}`, + ConfigVariableExceptionCode.VALIDATION_FAILED, + ); + } + + return value; + }, + + getValidators: (): PropertyDecorator[] => [IsString()], + + getTransformers: (): PropertyDecorator[] => [], + }, + + [ConfigVariableType.ARRAY]: { + toApp: ( + value: unknown, + options?: ConfigVariableOptions, + ): unknown[] | undefined => { + if (value === null || value === undefined) return undefined; + + let arrayValue: unknown[]; + + if (Array.isArray(value)) { + arrayValue = value; + } else if (typeof value === 'string') { + try { + const parsedArray = JSON.parse(value); + + if (Array.isArray(parsedArray)) { + arrayValue = parsedArray; + } else { + arrayValue = value.split(',').map((item) => item.trim()); + } + } catch { + arrayValue = value.split(',').map((item) => item.trim()); + } + } else { + arrayValue = [value]; + } + + if (!options || !Array.isArray(options) || options.length === 0) { + return arrayValue; + } + + return arrayValue.filter((item) => options.includes(item as string)); + }, + + toStorage: ( + value: unknown[], + options?: ConfigVariableOptions, + ): unknown[] => { + if (!Array.isArray(value)) { + throw new ConfigVariableException( + `Expected array, got ${typeof value}`, + ConfigVariableExceptionCode.VALIDATION_FAILED, + ); + } + + if (!options || !Array.isArray(options) || options.length === 0) { + return value; + } + + return value.filter((item) => options.includes(item as string)); + }, + + getValidators: (): PropertyDecorator[] => [IsArray()], + + getTransformers: (): PropertyDecorator[] => [], + }, + + [ConfigVariableType.ENUM]: { + toApp: ( + value: unknown, + options?: ConfigVariableOptions, + ): string | undefined => { + if (value === null || value === undefined) return undefined; + + if (!options || !Array.isArray(options) || options.length === 0) { + return value as string; + } + + return options.includes(value as string) ? (value as string) : undefined; + }, + + toStorage: (value: string, options?: ConfigVariableOptions): string => { + if (typeof value !== 'string') { + throw new ConfigVariableException( + `Expected string for enum, got ${typeof value}`, + ConfigVariableExceptionCode.VALIDATION_FAILED, + ); + } + + if (!options || !Array.isArray(options) || options.length === 0) { + return value; + } + + if (!options.includes(value)) { + throw new ConfigVariableException( + `Value '${value}' is not a valid option for enum`, + ConfigVariableExceptionCode.VALIDATION_FAILED, + ); + } + + return value; + }, + + getValidators: (options?: ConfigVariableOptions): PropertyDecorator[] => { + if (!options) { + return []; + } + + return [IsEnum(options)]; + }, + + getTransformers: (): PropertyDecorator[] => [], + }, +}; diff --git a/packages/twenty-server/test/integration/twenty-config/constants/config-test-keys.constants.ts b/packages/twenty-server/test/integration/twenty-config/constants/config-test-keys.constants.ts new file mode 100644 index 000000000..e727167ae --- /dev/null +++ b/packages/twenty-server/test/integration/twenty-config/constants/config-test-keys.constants.ts @@ -0,0 +1,16 @@ +import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables'; + +type ConfigKey = keyof ConfigVariables; + +export const TEST_KEY_DEFAULT: ConfigKey = 'IS_ATTACHMENT_PREVIEW_ENABLED'; +export const TEST_KEY_NOTIFICATION: ConfigKey = + 'WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION'; +export const TEST_KEY_SOFT_DELETION: ConfigKey = + 'WORKSPACE_INACTIVE_DAYS_BEFORE_SOFT_DELETION'; +export const TEST_KEY_DELETION: ConfigKey = + 'WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION'; +export const TEST_KEY_METRICS: ConfigKey = + 'HEALTH_METRICS_TIME_WINDOW_IN_MINUTES'; +export const TEST_KEY_ENV_ONLY: ConfigKey = 'PG_DATABASE_URL'; +export const TEST_KEY_NONEXISTENT = 'NONEXISTENT_CONFIG_KEY'; +export const TEST_KEY_STRING_VALUE = 'EMAIL_FROM_NAME'; diff --git a/packages/twenty-server/test/integration/twenty-config/twenty-config.integration-spec.ts b/packages/twenty-server/test/integration/twenty-config/twenty-config.integration-spec.ts new file mode 100644 index 000000000..b7ca99b17 --- /dev/null +++ b/packages/twenty-server/test/integration/twenty-config/twenty-config.integration-spec.ts @@ -0,0 +1,487 @@ +import { + TEST_KEY_DEFAULT, + TEST_KEY_DELETION, + TEST_KEY_ENV_ONLY, + TEST_KEY_METRICS, + TEST_KEY_NONEXISTENT, + TEST_KEY_NOTIFICATION, + TEST_KEY_SOFT_DELETION, + TEST_KEY_STRING_VALUE, +} from 'test/integration/twenty-config/constants/config-test-keys.constants'; + +import { createConfigVariable } from './utils/create-config-variable.util'; +import { deleteConfigVariable } from './utils/delete-config-variable.util'; +import { getConfigVariable } from './utils/get-config-variable.util'; +import { getConfigVariablesGrouped } from './utils/get-config-variables-grouped.util'; +import { makeUnauthenticatedAPIRequest } from './utils/make-unauthenticated-api-request.util'; +import { updateConfigVariable } from './utils/update-config-variable.util'; + +describe('TwentyConfig Integration', () => { + afterAll(async () => { + await deleteConfigVariable({ + input: { key: TEST_KEY_NOTIFICATION }, + }).catch(() => {}); + await deleteConfigVariable({ + input: { key: TEST_KEY_SOFT_DELETION }, + }).catch(() => {}); + await deleteConfigVariable({ + input: { key: TEST_KEY_DELETION }, + }).catch(() => {}); + await deleteConfigVariable({ + input: { key: TEST_KEY_METRICS }, + }).catch(() => {}); + }); + + describe('Basic config operations', () => { + it('should get config variable with DEFAULT source when not overridden', async () => { + const result = await getConfigVariable({ + input: { + key: TEST_KEY_DEFAULT, + }, + }); + + expect(result.data.getDatabaseConfigVariable).toBeDefined(); + expect(result.data.getDatabaseConfigVariable.source).toBe('DEFAULT'); + }); + + it('should show DATABASE source when overridden and DEFAULT after deletion', async () => { + const defaultResult = await getConfigVariable({ + input: { + key: TEST_KEY_NOTIFICATION, + }, + }); + + expect(defaultResult.data.getDatabaseConfigVariable.source).toBe( + 'DEFAULT', + ); + + const overrideValue = 999; + + await createConfigVariable({ + input: { + key: TEST_KEY_NOTIFICATION, + value: overrideValue, + }, + }); + + const overrideResult = await getConfigVariable({ + input: { + key: TEST_KEY_NOTIFICATION, + }, + }); + + expect(overrideResult.data.getDatabaseConfigVariable.source).toBe( + 'DATABASE', + ); + expect(overrideResult.data.getDatabaseConfigVariable.value).toBe( + overrideValue, + ); + + const newOverrideValue = 888; + + await updateConfigVariable({ + input: { + key: TEST_KEY_NOTIFICATION, + value: newOverrideValue, + }, + }); + + const updatedResult = await getConfigVariable({ + input: { + key: TEST_KEY_NOTIFICATION, + }, + }); + + expect(updatedResult.data.getDatabaseConfigVariable.source).toBe( + 'DATABASE', + ); + expect(updatedResult.data.getDatabaseConfigVariable.value).toBe( + newOverrideValue, + ); + + await deleteConfigVariable({ + input: { + key: TEST_KEY_NOTIFICATION, + }, + }); + + const afterDeleteResult = await getConfigVariable({ + input: { + key: TEST_KEY_NOTIFICATION, + }, + }); + + expect(afterDeleteResult.data.getDatabaseConfigVariable.source).toBe( + 'DEFAULT', + ); + }); + }); + + describe('Create operations', () => { + it('should be able to create and retrieve config variables', async () => { + const testKey = TEST_KEY_SOFT_DELETION; + const testValue = 777; + + const createResult = await createConfigVariable({ + input: { + key: testKey, + value: testValue, + }, + }); + + expect(createResult.data.createDatabaseConfigVariable).toBe(true); + + const getResult = await getConfigVariable({ + input: { + key: testKey, + }, + }); + + expect(getResult.data.getDatabaseConfigVariable.value).toBe(testValue); + expect(getResult.data.getDatabaseConfigVariable.source).toBe('DATABASE'); + + await deleteConfigVariable({ + input: { + key: testKey, + }, + }); + }); + + it('should reject creating config variables with invalid types', async () => { + const result = await createConfigVariable({ + input: { + key: TEST_KEY_DEFAULT, + value: 'not-a-boolean', + }, + expectToFail: true, + }); + + expect(result.errors).toBeDefined(); + expect(result.errors[0].message).toContain('Expected boolean'); + }); + }); + + describe('Update operations', () => { + it('should be able to update existing config variables', async () => { + const testKey = TEST_KEY_DELETION; + const initialValue = 555; + const updatedValue = 666; + + await createConfigVariable({ + input: { + key: testKey, + value: initialValue, + }, + }); + + const initialResult = await getConfigVariable({ + input: { + key: testKey, + }, + }); + + expect(initialResult.data.getDatabaseConfigVariable.source).toBe( + 'DATABASE', + ); + expect(initialResult.data.getDatabaseConfigVariable.value).toBe( + initialValue, + ); + + const updateResult = await updateConfigVariable({ + input: { + key: testKey, + value: updatedValue, + }, + }); + + expect(updateResult.data.updateDatabaseConfigVariable).toBe(true); + + const getResult = await getConfigVariable({ + input: { + key: testKey, + }, + }); + + expect(getResult.data.getDatabaseConfigVariable.source).toBe('DATABASE'); + expect(getResult.data.getDatabaseConfigVariable.value).toBe(updatedValue); + + await deleteConfigVariable({ + input: { + key: testKey, + }, + }); + }); + + it('should handle concurrent updates to the same config variable', async () => { + const testKey = TEST_KEY_METRICS; + const initialValue = 5; + const newValue1 = 10; + const newValue2 = 20; + + await createConfigVariable({ + input: { + key: testKey, + value: initialValue, + }, + }); + + const update1 = updateConfigVariable({ + input: { + key: testKey, + value: newValue1, + }, + }); + + const update2 = updateConfigVariable({ + input: { + key: testKey, + value: newValue2, + }, + }); + + await Promise.all([update1, update2]); + + const getResult = await getConfigVariable({ + input: { key: testKey }, + }); + + expect([newValue1, newValue2]).toContain( + getResult.data.getDatabaseConfigVariable.value, + ); + + await deleteConfigVariable({ + input: { key: testKey }, + }); + }); + + it('should reject updating config variables with invalid types', async () => { + await createConfigVariable({ + input: { + key: 'NODE_PORT', + value: 3000, + }, + }); + + const updateResult = await updateConfigVariable({ + input: { + key: 'NODE_PORT', + value: 'not-a-number', + }, + expectToFail: true, + }); + + expect(updateResult.errors).toBeDefined(); + expect(updateResult.errors[0].message).toContain('Expected number'); + + await deleteConfigVariable({ + input: { key: 'NODE_PORT' }, + }); + }); + }); + + describe('Delete operations', () => { + it('should return to DEFAULT source after deleting a variable', async () => { + const testKey = TEST_KEY_DELETION; + const testValue = 333; + + await createConfigVariable({ + input: { + key: testKey, + value: testValue, + }, + }); + + const beforeDelete = await getConfigVariable({ + input: { + key: testKey, + }, + }); + + expect(beforeDelete.data.getDatabaseConfigVariable).toBeDefined(); + expect(beforeDelete.data.getDatabaseConfigVariable.source).toBe( + 'DATABASE', + ); + expect(beforeDelete.data.getDatabaseConfigVariable.value).toBe(testValue); + + const deleteResult = await deleteConfigVariable({ + input: { + key: testKey, + }, + }); + + expect(deleteResult.data.deleteDatabaseConfigVariable).toBe(true); + + const afterDelete = await getConfigVariable({ + input: { + key: testKey, + }, + }); + + expect(afterDelete.data.getDatabaseConfigVariable).toBeDefined(); + expect(afterDelete.data.getDatabaseConfigVariable.source).toBe('DEFAULT'); + }); + }); + + describe('Listing operations', () => { + it('should be able to get all config variables grouped', async () => { + const testKey = TEST_KEY_METRICS; + const testValue = 444; + + await createConfigVariable({ + input: { + key: testKey, + value: testValue, + }, + }); + + const result = await getConfigVariablesGrouped(); + + expect(result.data.getConfigVariablesGrouped).toBeDefined(); + expect(result.data.getConfigVariablesGrouped.groups).toBeInstanceOf( + Array, + ); + + const allVariables = result.data.getConfigVariablesGrouped.groups.flatMap( + (group) => group.variables, + ); + const testVariable = allVariables.find( + (variable) => variable.name === testKey, + ); + + expect(testVariable).toBeDefined(); + expect(testVariable.value).toBe(testValue); + expect(testVariable.source).toBe('DATABASE'); + + await deleteConfigVariable({ + input: { + key: testKey, + }, + }); + }); + }); + + describe('Error handling', () => { + it('should reject modifications to environment-only variables', async () => { + const result = await createConfigVariable({ + input: { + key: TEST_KEY_ENV_ONLY, + value: 'postgres://test:test@localhost:5432/test', + }, + expectToFail: true, + }); + + expect(result.errors).toBeDefined(); + expect(result.errors[0].message).toContain( + `Cannot create environment-only variable: ${TEST_KEY_ENV_ONLY}`, + ); + }); + + it('should reject operations on non-existent config variables', async () => { + const result = await getConfigVariable({ + input: { + key: TEST_KEY_NONEXISTENT, + }, + expectToFail: true, + }); + + expect(result.errors).toBeDefined(); + expect(result.errors[0].message).toContain( + `Config variable "${TEST_KEY_NONEXISTENT}" does not exist in ConfigVariables`, + ); + }); + + it('should reject config operations from non-admin users', async () => { + const graphqlQuery = ` + query GetDatabaseConfigVariable { + getDatabaseConfigVariable(key: "${TEST_KEY_DEFAULT}") { + key + value + source + } + } + `; + + const response = await makeUnauthenticatedAPIRequest(graphqlQuery); + + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toContain( + 'Cannot query field "key" on type "ConfigVariable"', + ); + }); + }); + + describe('Edge cases', () => { + it('should handle large numeric config values', async () => { + const testKey = TEST_KEY_METRICS; + const largeValue = 9999; + + await createConfigVariable({ + input: { + key: testKey, + value: largeValue, + }, + }); + + const getResult = await getConfigVariable({ + input: { + key: testKey, + }, + }); + + expect(getResult.data.getDatabaseConfigVariable.value).toBe(largeValue); + + await deleteConfigVariable({ + input: { + key: testKey, + }, + }); + }); + + it('should handle empty string config values', async () => { + const testKey = TEST_KEY_STRING_VALUE; + const emptyValue = ''; + + await createConfigVariable({ + input: { + key: testKey, + value: emptyValue, + }, + }); + + const getResult = await getConfigVariable({ + input: { key: testKey }, + }); + + expect(getResult.data.getDatabaseConfigVariable.value).toBe(emptyValue); + + await deleteConfigVariable({ + input: { key: testKey }, + }); + }); + + it('should preserve types correctly when retrieving config variables', async () => { + const booleanKey = TEST_KEY_DEFAULT; + const booleanValue = false; + + await createConfigVariable({ + input: { + key: booleanKey, + value: booleanValue, + }, + }); + + const boolResult = await getConfigVariable({ + input: { key: booleanKey }, + }); + + const retrievedValue = boolResult.data.getDatabaseConfigVariable.value; + + expect(typeof retrievedValue).toBe('boolean'); + expect(retrievedValue).toBe(booleanValue); + + await deleteConfigVariable({ + input: { key: booleanKey }, + }); + }); + }); +}); diff --git a/packages/twenty-server/test/integration/twenty-config/types/perform-twenty-config-query.type.ts b/packages/twenty-server/test/integration/twenty-config/types/perform-twenty-config-query.type.ts new file mode 100644 index 000000000..453b174dc --- /dev/null +++ b/packages/twenty-server/test/integration/twenty-config/types/perform-twenty-config-query.type.ts @@ -0,0 +1,4 @@ +export type PerformTwentyConfigQueryParams = { + input: T; + expectToFail?: boolean; +}; diff --git a/packages/twenty-server/test/integration/twenty-config/utils/create-config-variable.query-factory.util.ts b/packages/twenty-server/test/integration/twenty-config/utils/create-config-variable.query-factory.util.ts new file mode 100644 index 000000000..17f07a457 --- /dev/null +++ b/packages/twenty-server/test/integration/twenty-config/utils/create-config-variable.query-factory.util.ts @@ -0,0 +1,24 @@ +import { gql } from 'apollo-server-core'; +import { ConfigVariableValue } from 'twenty-shared/src/types/ConfigVariableValue'; + +export type CreateConfigVariableFactoryInput = { + key: string; + value: ConfigVariableValue; +}; + +export const createConfigVariableQueryFactory = ({ + key, + value, +}: CreateConfigVariableFactoryInput) => { + return { + query: gql` + mutation CreateDatabaseConfigVariable($key: String!, $value: JSON!) { + createDatabaseConfigVariable(key: $key, value: $value) + } + `, + variables: { + key, + value, + }, + }; +}; diff --git a/packages/twenty-server/test/integration/twenty-config/utils/create-config-variable.util.ts b/packages/twenty-server/test/integration/twenty-config/utils/create-config-variable.util.ts new file mode 100644 index 000000000..dfcca4946 --- /dev/null +++ b/packages/twenty-server/test/integration/twenty-config/utils/create-config-variable.util.ts @@ -0,0 +1,33 @@ +import { PerformTwentyConfigQueryParams } from 'test/integration/twenty-config/types/perform-twenty-config-query.type'; + +import { + CreateConfigVariableFactoryInput, + createConfigVariableQueryFactory, +} from './create-config-variable.query-factory.util'; +import { makeAdminPanelAPIRequest } from './make-admin-panel-api-request.util'; + +export const createConfigVariable = async ({ + input, + expectToFail = false, +}: PerformTwentyConfigQueryParams) => { + const graphqlOperation = createConfigVariableQueryFactory({ + key: input.key, + value: input.value, + }); + + const response = await makeAdminPanelAPIRequest(graphqlOperation); + + if (!expectToFail) { + expect(response.body.data).toBeDefined(); + expect(response.body.errors).toBeUndefined(); + expect(response.body.data.createDatabaseConfigVariable).toBeDefined(); + } else { + // For failure cases, we'll check in the individual tests + } + + return { + data: response.body.data, + errors: response.body.errors, + rawResponse: response, + }; +}; diff --git a/packages/twenty-server/test/integration/twenty-config/utils/delete-config-variable.query-factory.util.ts b/packages/twenty-server/test/integration/twenty-config/utils/delete-config-variable.query-factory.util.ts new file mode 100644 index 000000000..b5b2e22c0 --- /dev/null +++ b/packages/twenty-server/test/integration/twenty-config/utils/delete-config-variable.query-factory.util.ts @@ -0,0 +1,20 @@ +import { gql } from 'apollo-server-core'; + +export type DeleteConfigVariableFactoryInput = { + key: string; +}; + +export const deleteConfigVariableQueryFactory = ({ + key, +}: DeleteConfigVariableFactoryInput) => { + return { + query: gql` + mutation DeleteDatabaseConfigVariable($key: String!) { + deleteDatabaseConfigVariable(key: $key) + } + `, + variables: { + key, + }, + }; +}; diff --git a/packages/twenty-server/test/integration/twenty-config/utils/delete-config-variable.util.ts b/packages/twenty-server/test/integration/twenty-config/utils/delete-config-variable.util.ts new file mode 100644 index 000000000..947b28328 --- /dev/null +++ b/packages/twenty-server/test/integration/twenty-config/utils/delete-config-variable.util.ts @@ -0,0 +1,28 @@ +import { PerformTwentyConfigQueryParams } from 'test/integration/twenty-config/types/perform-twenty-config-query.type'; + +import { + DeleteConfigVariableFactoryInput, + deleteConfigVariableQueryFactory, +} from './delete-config-variable.query-factory.util'; +import { makeAdminPanelAPIRequest } from './make-admin-panel-api-request.util'; + +export const deleteConfigVariable = async ({ + input, + expectToFail = false, +}: PerformTwentyConfigQueryParams) => { + const graphqlOperation = deleteConfigVariableQueryFactory({ + key: input.key, + }); + + const response = await makeAdminPanelAPIRequest(graphqlOperation); + + if (!expectToFail) { + expect(response.body.data).toBeDefined(); + expect(response.body.errors).toBeUndefined(); + expect(response.body.data.deleteDatabaseConfigVariable).toBeDefined(); + } else { + expect(response.body.errors).toBeDefined(); + } + + return { data: response.body.data, errors: response.body.errors }; +}; diff --git a/packages/twenty-server/test/integration/twenty-config/utils/get-config-variable.query-factory.util.ts b/packages/twenty-server/test/integration/twenty-config/utils/get-config-variable.query-factory.util.ts new file mode 100644 index 000000000..9594e5e67 --- /dev/null +++ b/packages/twenty-server/test/integration/twenty-config/utils/get-config-variable.query-factory.util.ts @@ -0,0 +1,29 @@ +import { gql } from 'apollo-server-core'; + +export type GetConfigVariableFactoryInput = { + key: string; +}; + +export const getConfigVariableQueryFactory = ({ + key, +}: GetConfigVariableFactoryInput) => { + return { + query: gql` + query GetDatabaseConfigVariable($key: String!) { + getDatabaseConfigVariable(key: $key) { + name + description + value + isSensitive + isEnvOnly + type + options + source + } + } + `, + variables: { + key, + }, + }; +}; diff --git a/packages/twenty-server/test/integration/twenty-config/utils/get-config-variable.util.ts b/packages/twenty-server/test/integration/twenty-config/utils/get-config-variable.util.ts new file mode 100644 index 000000000..36e2623e7 --- /dev/null +++ b/packages/twenty-server/test/integration/twenty-config/utils/get-config-variable.util.ts @@ -0,0 +1,31 @@ +import { PerformTwentyConfigQueryParams } from 'test/integration/twenty-config/types/perform-twenty-config-query.type'; + +import { + GetConfigVariableFactoryInput, + getConfigVariableQueryFactory, +} from './get-config-variable.query-factory.util'; +import { makeAdminPanelAPIRequest } from './make-admin-panel-api-request.util'; + +export const getConfigVariable = async ({ + input, + expectToFail = false, +}: PerformTwentyConfigQueryParams) => { + const graphqlOperation = getConfigVariableQueryFactory({ + key: input.key, + }); + + const response = await makeAdminPanelAPIRequest(graphqlOperation); + + if (!expectToFail) { + expect(response.body.data).toBeDefined(); + expect(response.body.data.getDatabaseConfigVariable).toBeDefined(); + } else { + // For failure cases, we'll check in the individual tests + } + + return { + data: response.body.data, + errors: response.body.errors, + rawResponse: response, + }; +}; diff --git a/packages/twenty-server/test/integration/twenty-config/utils/get-config-variables-grouped.query-factory.util.ts b/packages/twenty-server/test/integration/twenty-config/utils/get-config-variables-grouped.query-factory.util.ts new file mode 100644 index 000000000..01b236be6 --- /dev/null +++ b/packages/twenty-server/test/integration/twenty-config/utils/get-config-variables-grouped.query-factory.util.ts @@ -0,0 +1,27 @@ +import { gql } from 'apollo-server-core'; + +export const getConfigVariablesGroupedQueryFactory = () => { + return { + query: gql` + query GetConfigVariablesGrouped { + getConfigVariablesGrouped { + groups { + name + description + isHiddenOnLoad + variables { + name + description + value + isSensitive + isEnvOnly + type + options + source + } + } + } + } + `, + }; +}; diff --git a/packages/twenty-server/test/integration/twenty-config/utils/get-config-variables-grouped.util.ts b/packages/twenty-server/test/integration/twenty-config/utils/get-config-variables-grouped.util.ts new file mode 100644 index 000000000..fb48c1f58 --- /dev/null +++ b/packages/twenty-server/test/integration/twenty-config/utils/get-config-variables-grouped.util.ts @@ -0,0 +1,18 @@ +import { getConfigVariablesGroupedQueryFactory } from './get-config-variables-grouped.query-factory.util'; +import { makeAdminPanelAPIRequest } from './make-admin-panel-api-request.util'; + +export const getConfigVariablesGrouped = async (expectToFail = false) => { + const graphqlOperation = getConfigVariablesGroupedQueryFactory(); + + const response = await makeAdminPanelAPIRequest(graphqlOperation); + + if (!expectToFail) { + expect(response.body.data).toBeDefined(); + expect(response.body.errors).toBeUndefined(); + expect(response.body.data.getConfigVariablesGrouped).toBeDefined(); + } else { + expect(response.body.errors).toBeDefined(); + } + + return { data: response.body.data, errors: response.body.errors }; +}; diff --git a/packages/twenty-server/test/integration/twenty-config/utils/make-admin-panel-api-request.util.ts b/packages/twenty-server/test/integration/twenty-config/utils/make-admin-panel-api-request.util.ts new file mode 100644 index 000000000..809b28a72 --- /dev/null +++ b/packages/twenty-server/test/integration/twenty-config/utils/make-admin-panel-api-request.util.ts @@ -0,0 +1,24 @@ +import { ASTNode, print } from 'graphql'; +import request from 'supertest'; + +/* global APP_PORT, ADMIN_ACCESS_TOKEN */ + +type GraphqlOperation = { + query: ASTNode; + variables?: Record; +}; + +export const makeAdminPanelAPIRequest = ( + graphqlOperation: GraphqlOperation, +) => { + const client = request(`http://localhost:${APP_PORT}`); + + return client + .post('/graphql') + .set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`) + .send({ + query: print(graphqlOperation.query), + variables: graphqlOperation.variables || {}, + }) + .expect(200); +}; diff --git a/packages/twenty-server/test/integration/twenty-config/utils/make-unauthenticated-api-request.util.ts b/packages/twenty-server/test/integration/twenty-config/utils/make-unauthenticated-api-request.util.ts new file mode 100644 index 000000000..14a89d8f8 --- /dev/null +++ b/packages/twenty-server/test/integration/twenty-config/utils/make-unauthenticated-api-request.util.ts @@ -0,0 +1,12 @@ +import request from 'supertest'; + +export const makeUnauthenticatedAPIRequest = async (query: string) => { + const client = request(`http://localhost:${APP_PORT}`); + + return client + .post('/graphql') + .send({ + query, + }) + .expect(200); +}; diff --git a/packages/twenty-server/test/integration/twenty-config/utils/update-config-variable.query-factory.util.ts b/packages/twenty-server/test/integration/twenty-config/utils/update-config-variable.query-factory.util.ts new file mode 100644 index 000000000..922e0a0c0 --- /dev/null +++ b/packages/twenty-server/test/integration/twenty-config/utils/update-config-variable.query-factory.util.ts @@ -0,0 +1,24 @@ +import { gql } from 'apollo-server-core'; +import { ConfigVariableValue } from 'twenty-shared/src/types/ConfigVariableValue'; + +export type UpdateConfigVariableFactoryInput = { + key: string; + value: ConfigVariableValue; +}; + +export const updateConfigVariableQueryFactory = ({ + key, + value, +}: UpdateConfigVariableFactoryInput) => { + return { + query: gql` + mutation UpdateDatabaseConfigVariable($key: String!, $value: JSON!) { + updateDatabaseConfigVariable(key: $key, value: $value) + } + `, + variables: { + key, + value, + }, + }; +}; diff --git a/packages/twenty-server/test/integration/twenty-config/utils/update-config-variable.util.ts b/packages/twenty-server/test/integration/twenty-config/utils/update-config-variable.util.ts new file mode 100644 index 000000000..68ce7a8b0 --- /dev/null +++ b/packages/twenty-server/test/integration/twenty-config/utils/update-config-variable.util.ts @@ -0,0 +1,29 @@ +import { PerformTwentyConfigQueryParams } from 'test/integration/twenty-config/types/perform-twenty-config-query.type'; + +import { makeAdminPanelAPIRequest } from './make-admin-panel-api-request.util'; +import { + UpdateConfigVariableFactoryInput, + updateConfigVariableQueryFactory, +} from './update-config-variable.query-factory.util'; + +export const updateConfigVariable = async ({ + input, + expectToFail = false, +}: PerformTwentyConfigQueryParams) => { + const graphqlOperation = updateConfigVariableQueryFactory({ + key: input.key, + value: input.value, + }); + + const response = await makeAdminPanelAPIRequest(graphqlOperation); + + if (!expectToFail) { + expect(response.body.data).toBeDefined(); + expect(response.body.errors).toBeUndefined(); + expect(response.body.data.updateDatabaseConfigVariable).toBeDefined(); + } else { + expect(response.body.errors).toBeDefined(); + } + + return { data: response.body.data, errors: response.body.errors }; +};