### Primary Changes: Dynamic Driver Configuration Refactors FileStorageService and EmailSenderService to support dynamic driver configuration changes at runtime without requiring application restarts. **Key Architectural Change**: Instead of conditionally registering drivers at build time based on configuration, we now **register all possible drivers eagerly** and select the appropriate one at runtime. ### What Changed: - **Before**: Modules conditionally registered only the configured driver (e.g., only S3Driver if STORAGE_TYPE=S3) - **After**: All drivers (LocalDriver, S3Driver, SmtpDriver, LoggerDriver) are registered at startup - **Runtime Selection**: Services dynamically choose and instantiate the correct driver based on current configuration ### Secondary Fix: Integration Test Log Cleanup Addresses ConfigStorageService error logs appearing in integration test output by using injected LoggerService for consistent log handling.
490 lines
13 KiB
TypeScript
490 lines
13 KiB
TypeScript
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: TEST_KEY_DEFAULT,
|
|
value: true,
|
|
},
|
|
});
|
|
|
|
const updateResult = await updateConfigVariable({
|
|
input: {
|
|
key: TEST_KEY_DEFAULT,
|
|
value: 'not-a-boolean',
|
|
},
|
|
expectToFail: true,
|
|
});
|
|
|
|
expect(updateResult.errors).toBeDefined();
|
|
expect(updateResult.errors[0].message).toContain('Expected boolean');
|
|
|
|
await deleteConfigVariable({
|
|
input: { key: TEST_KEY_DEFAULT },
|
|
});
|
|
});
|
|
});
|
|
|
|
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(
|
|
// @ts-expect-error legacy noImplicitAny
|
|
(group) => group.variables,
|
|
);
|
|
const testVariable = allVariables.find(
|
|
// @ts-expect-error legacy noImplicitAny
|
|
(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 },
|
|
});
|
|
});
|
|
});
|
|
});
|