feat: implement dynamic driver configuration + fix integration test log pollution (#12104)

### 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.
This commit is contained in:
nitin
2025-05-28 14:19:20 +05:30
committed by GitHub
parent d133055609
commit 1c64b7b072
31 changed files with 1432 additions and 540 deletions

View File

@ -0,0 +1,229 @@
import { Test, TestingModule } from '@nestjs/testing';
import { EmailDriverFactory } from 'src/engine/core-modules/email/email-driver.factory';
import { EmailDriver } from 'src/engine/core-modules/email/enums/email-driver.enum';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
describe('EmailDriverFactory', () => {
let factory: EmailDriverFactory;
let twentyConfigService: TwentyConfigService;
const mockTwentyConfigService = {
get: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
EmailDriverFactory,
{
provide: TwentyConfigService,
useValue: mockTwentyConfigService,
},
],
}).compile();
factory = module.get<EmailDriverFactory>(EmailDriverFactory);
twentyConfigService = module.get<TwentyConfigService>(TwentyConfigService);
jest.clearAllMocks();
});
describe('buildConfigKey', () => {
it('should return "logger" for logger driver', () => {
jest
.spyOn(twentyConfigService, 'get')
.mockReturnValue(EmailDriver.Logger);
const result = factory['buildConfigKey']();
expect(result).toBe('logger');
expect(twentyConfigService.get).toHaveBeenCalledWith('EMAIL_DRIVER');
});
it('should return smtp config key for smtp driver', () => {
jest.spyOn(twentyConfigService, 'get').mockReturnValue(EmailDriver.Smtp);
jest
.spyOn(factory as any, 'getConfigGroupHash')
.mockReturnValue('smtp-hash-123');
const result = factory['buildConfigKey']();
expect(result).toBe('smtp|smtp-hash-123');
expect(twentyConfigService.get).toHaveBeenCalledWith('EMAIL_DRIVER');
});
it('should throw error for unsupported driver', () => {
jest.spyOn(twentyConfigService, 'get').mockReturnValue('invalid-driver');
expect(() => factory['buildConfigKey']()).toThrow(
'Unsupported email driver: invalid-driver',
);
});
});
describe('createDriver', () => {
it('should create logger driver', () => {
jest
.spyOn(twentyConfigService, 'get')
.mockReturnValue(EmailDriver.Logger);
const driver = factory['createDriver']();
expect(driver).toBeDefined();
expect(driver.constructor.name).toBe('LoggerDriver');
});
it('should create smtp driver with basic configuration', () => {
jest
.spyOn(twentyConfigService, 'get')
.mockImplementation((key: string) => {
switch (key) {
case 'EMAIL_DRIVER':
return EmailDriver.Smtp;
case 'EMAIL_SMTP_HOST':
return 'smtp.example.com';
case 'EMAIL_SMTP_PORT':
return 587;
case 'EMAIL_SMTP_USER':
return undefined;
case 'EMAIL_SMTP_PASSWORD':
return undefined;
case 'EMAIL_SMTP_NO_TLS':
return false;
default:
return undefined;
}
});
const driver = factory['createDriver']();
expect(driver).toBeDefined();
expect(driver.constructor.name).toBe('SmtpDriver');
});
it('should throw error when smtp host is missing', () => {
jest
.spyOn(twentyConfigService, 'get')
.mockImplementation((key: string) => {
switch (key) {
case 'EMAIL_DRIVER':
return EmailDriver.Smtp;
case 'EMAIL_SMTP_HOST':
return undefined;
case 'EMAIL_SMTP_PORT':
return 587;
default:
return undefined;
}
});
expect(() => factory['createDriver']()).toThrow(
'SMTP driver requires host and port to be defined',
);
});
it('should throw error for invalid driver', () => {
jest.spyOn(twentyConfigService, 'get').mockReturnValue('invalid-driver');
expect(() => factory['createDriver']()).toThrow(
'Invalid email driver: invalid-driver',
);
});
});
describe('getCurrentDriver', () => {
it('should return current driver for logger', () => {
jest
.spyOn(twentyConfigService, 'get')
.mockReturnValue(EmailDriver.Logger);
const driver = factory.getCurrentDriver();
expect(driver).toBeDefined();
expect(driver.constructor.name).toBe('LoggerDriver');
});
it('should reuse driver when config key unchanged', () => {
jest
.spyOn(twentyConfigService, 'get')
.mockReturnValue(EmailDriver.Logger);
const driver1 = factory.getCurrentDriver();
const driver2 = factory.getCurrentDriver();
expect(driver1).toBe(driver2);
});
it('should create new driver when config key changes', () => {
// First call with logger
jest
.spyOn(twentyConfigService, 'get')
.mockReturnValue(EmailDriver.Logger);
const driver1 = factory.getCurrentDriver();
// Second call with smtp
jest
.spyOn(twentyConfigService, 'get')
.mockImplementation((key: string) => {
switch (key) {
case 'EMAIL_DRIVER':
return EmailDriver.Smtp;
case 'EMAIL_SMTP_HOST':
return 'smtp.example.com';
case 'EMAIL_SMTP_PORT':
return 587;
default:
return undefined;
}
});
jest
.spyOn(factory as any, 'getConfigGroupHash')
.mockReturnValue('smtp-hash-123');
const driver2 = factory.getCurrentDriver();
expect(driver1).not.toBe(driver2);
expect(driver1.constructor.name).toBe('LoggerDriver');
expect(driver2.constructor.name).toBe('SmtpDriver');
});
it('should throw error for unsupported email driver', () => {
jest.spyOn(twentyConfigService, 'get').mockReturnValue('invalid-driver');
expect(() => factory.getCurrentDriver()).toThrow(
'Failed to build config key for EmailDriverFactory. Original error: Unsupported email driver: invalid-driver',
);
});
it('should throw error when driver creation fails after valid config', () => {
jest
.spyOn(twentyConfigService, 'get')
.mockImplementation((key: string) => {
switch (key) {
case 'EMAIL_DRIVER':
return EmailDriver.Smtp;
case 'EMAIL_SMTP_HOST':
return 'smtp.example.com';
case 'EMAIL_SMTP_PORT':
return 587;
default:
return undefined;
}
});
jest
.spyOn(factory as any, 'getConfigGroupHash')
.mockReturnValue('smtp-hash-123');
jest.spyOn(factory as any, 'createDriver').mockImplementation(() => {
throw new Error('Driver creation failed');
});
expect(() => factory.getCurrentDriver()).toThrow(
'Failed to create driver for EmailDriverFactory with config key: smtp|smtp-hash-123. Original error: Driver creation failed',
);
});
});
});

View File

@ -0,0 +1,73 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SendMailOptions } from 'nodemailer';
import { EmailDriverFactory } from 'src/engine/core-modules/email/email-driver.factory';
import { EmailSenderService } from 'src/engine/core-modules/email/email-sender.service';
describe('EmailSenderService', () => {
let service: EmailSenderService;
let emailDriverFactory: EmailDriverFactory;
const mockEmailDriverFactory = {
getCurrentDriver: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
EmailSenderService,
{
provide: EmailDriverFactory,
useValue: mockEmailDriverFactory,
},
],
}).compile();
service = module.get<EmailSenderService>(EmailSenderService);
emailDriverFactory = module.get<EmailDriverFactory>(EmailDriverFactory);
jest.clearAllMocks();
});
describe('send', () => {
it('should delegate to the current driver', async () => {
const mockDriver = {
send: jest.fn().mockResolvedValue(undefined),
};
mockEmailDriverFactory.getCurrentDriver.mockReturnValue(mockDriver);
const sendMailOptions: SendMailOptions = {
to: 'test@example.com',
subject: 'Test',
text: 'Test message',
};
await service.send(sendMailOptions);
expect(emailDriverFactory.getCurrentDriver).toHaveBeenCalled();
expect(mockDriver.send).toHaveBeenCalledWith(sendMailOptions);
});
it('should handle driver errors', async () => {
const mockDriver = {
send: jest.fn().mockRejectedValue(new Error('Driver error')),
};
mockEmailDriverFactory.getCurrentDriver.mockReturnValue(mockDriver);
const sendMailOptions: SendMailOptions = {
to: 'test@example.com',
subject: 'Test',
text: 'Test message',
};
await expect(service.send(sendMailOptions)).rejects.toThrow(
'Driver error',
);
expect(emailDriverFactory.getCurrentDriver).toHaveBeenCalled();
expect(mockDriver.send).toHaveBeenCalledWith(sendMailOptions);
});
});
});

View File

@ -1,5 +1,5 @@
import { SendMailOptions } from 'nodemailer';
export interface EmailDriver {
export interface EmailDriverInterface {
send(sendMailOptions: SendMailOptions): Promise<void>;
}

View File

@ -2,9 +2,9 @@ import { Logger } from '@nestjs/common';
import { SendMailOptions } from 'nodemailer';
import { EmailDriver } from 'src/engine/core-modules/email/drivers/interfaces/email-driver.interface';
import { EmailDriverInterface } from 'src/engine/core-modules/email/drivers/interfaces/email-driver.interface';
export class LoggerDriver implements EmailDriver {
export class LoggerDriver implements EmailDriverInterface {
private readonly logger = new Logger(LoggerDriver.name);
async send(sendMailOptions: SendMailOptions): Promise<void> {

View File

@ -1,11 +1,11 @@
import { Logger } from '@nestjs/common';
import { createTransport, Transporter, SendMailOptions } from 'nodemailer';
import { createTransport, SendMailOptions, Transporter } from 'nodemailer';
import SMTPConnection from 'nodemailer/lib/smtp-connection';
import { EmailDriver } from 'src/engine/core-modules/email/drivers/interfaces/email-driver.interface';
import { EmailDriverInterface } from 'src/engine/core-modules/email/drivers/interfaces/email-driver.interface';
export class SmtpDriver implements EmailDriver {
export class SmtpDriver implements EmailDriverInterface {
private readonly logger = new Logger(SmtpDriver.name);
private transport: Transporter;

View File

@ -0,0 +1,79 @@
import { Injectable } from '@nestjs/common';
import { EmailDriverInterface } from 'src/engine/core-modules/email/drivers/interfaces/email-driver.interface';
import { LoggerDriver } from 'src/engine/core-modules/email/drivers/logger.driver';
import { SmtpDriver } from 'src/engine/core-modules/email/drivers/smtp.driver';
import { EmailDriver } from 'src/engine/core-modules/email/enums/email-driver.enum';
import { DriverFactoryBase } from 'src/engine/core-modules/twenty-config/dynamic-factory.base';
import { ConfigVariablesGroup } from 'src/engine/core-modules/twenty-config/enums/config-variables-group.enum';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Injectable()
export class EmailDriverFactory extends DriverFactoryBase<EmailDriverInterface> {
constructor(twentyConfigService: TwentyConfigService) {
super(twentyConfigService);
}
protected buildConfigKey(): string {
const driver = this.twentyConfigService.get('EMAIL_DRIVER');
if (driver === EmailDriver.Logger) {
return 'logger';
}
if (driver === EmailDriver.Smtp) {
const emailConfigHash = this.getConfigGroupHash(
ConfigVariablesGroup.EmailSettings,
);
return `smtp|${emailConfigHash}`;
}
throw new Error(`Unsupported email driver: ${driver}`);
}
protected createDriver(): EmailDriverInterface {
const driver = this.twentyConfigService.get('EMAIL_DRIVER');
switch (driver) {
case EmailDriver.Logger:
return new LoggerDriver();
case EmailDriver.Smtp: {
const host = this.twentyConfigService.get('EMAIL_SMTP_HOST');
const port = this.twentyConfigService.get('EMAIL_SMTP_PORT');
const user = this.twentyConfigService.get('EMAIL_SMTP_USER');
const pass = this.twentyConfigService.get('EMAIL_SMTP_PASSWORD');
const noTLS = this.twentyConfigService.get('EMAIL_SMTP_NO_TLS');
if (!host || !port) {
throw new Error('SMTP driver requires host and port to be defined');
}
const options: {
host: string;
port: number;
auth?: { user: string; pass: string };
secure?: boolean;
ignoreTLS?: boolean;
requireTLS?: boolean;
} = { host, port };
if (user && pass) {
options.auth = { user, pass };
}
if (noTLS) {
options.secure = false;
options.ignoreTLS = true;
}
return new SmtpDriver(options);
}
default:
throw new Error(`Invalid email driver: ${driver}`);
}
}
}

View File

@ -1,16 +1,18 @@
import { Inject, Injectable } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { SendMailOptions } from 'nodemailer';
import { EmailDriver } from 'src/engine/core-modules/email/drivers/interfaces/email-driver.interface';
import { EmailDriverInterface } from 'src/engine/core-modules/email/drivers/interfaces/email-driver.interface';
import { EMAIL_DRIVER } from 'src/engine/core-modules/email/email.constants';
import { EmailDriverFactory } from 'src/engine/core-modules/email/email-driver.factory';
@Injectable()
export class EmailSenderService implements EmailDriver {
constructor(@Inject(EMAIL_DRIVER) private driver: EmailDriver) {}
export class EmailSenderService implements EmailDriverInterface {
constructor(private readonly emailDriverFactory: EmailDriverFactory) {}
async send(sendMailOptions: SendMailOptions): Promise<void> {
await this.driver.send(sendMailOptions);
const driver = this.emailDriverFactory.getCurrentDriver();
await driver.send(sendMailOptions);
}
}

View File

@ -1 +0,0 @@
export const EMAIL_DRIVER = Symbol('EMAIL_DRIVER');

View File

@ -1,52 +0,0 @@
import {
EmailDriver,
EmailModuleOptions,
} from 'src/engine/core-modules/email/interfaces/email.interface';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
export const emailModuleFactory = (
twentyConfigService: TwentyConfigService,
): EmailModuleOptions => {
const driver = twentyConfigService.get('EMAIL_DRIVER');
switch (driver) {
case EmailDriver.Logger:
return {
type: EmailDriver.Logger,
};
case EmailDriver.Smtp: {
const options: EmailModuleOptions = {
type: EmailDriver.Smtp,
};
const host = twentyConfigService.get('EMAIL_SMTP_HOST');
const port = twentyConfigService.get('EMAIL_SMTP_PORT');
const user = twentyConfigService.get('EMAIL_SMTP_USER');
const pass = twentyConfigService.get('EMAIL_SMTP_PASSWORD');
const noTLS = twentyConfigService.get('EMAIL_SMTP_NO_TLS');
if (!host || !port) {
throw new Error(
`${driver} email driver requires host and port to be defined, check your .env file`,
);
}
options.host = host;
options.port = port;
if (user && pass) options.auth = { user, pass };
if (noTLS) {
options.secure = false;
options.ignoreTLS = true;
}
return options;
}
default:
throw new Error(`Invalid email driver (${driver}), check your .env file`);
}
};

View File

@ -1,35 +1,17 @@
import { DynamicModule, Global } from '@nestjs/common';
import {
EmailDriver,
EmailModuleAsyncOptions,
} from 'src/engine/core-modules/email/interfaces/email.interface';
import { LoggerDriver } from 'src/engine/core-modules/email/drivers/logger.driver';
import { SmtpDriver } from 'src/engine/core-modules/email/drivers/smtp.driver';
import { EmailDriverFactory } from 'src/engine/core-modules/email/email-driver.factory';
import { EmailSenderService } from 'src/engine/core-modules/email/email-sender.service';
import { EMAIL_DRIVER } from 'src/engine/core-modules/email/email.constants';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
@Global()
export class EmailModule {
static forRoot(options: EmailModuleAsyncOptions): DynamicModule {
const provider = {
provide: EMAIL_DRIVER,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
useFactory: (...args: any[]) => {
const config = options.useFactory(...args);
return config.type === EmailDriver.Smtp
? new SmtpDriver(config)
: new LoggerDriver();
},
inject: options.inject || [],
};
static forRoot(): DynamicModule {
return {
module: EmailModule,
providers: [EmailSenderService, EmailService, provider],
imports: [TwentyConfigModule],
providers: [EmailDriverFactory, EmailSenderService, EmailService],
exports: [EmailSenderService, EmailService],
};
}

View File

@ -0,0 +1,4 @@
export enum EmailDriver {
Logger = 'logger',
Smtp = 'smtp',
}

View File

@ -1,22 +0,0 @@
import { FactoryProvider, ModuleMetadata } from '@nestjs/common';
import SMTPConnection from 'nodemailer/lib/smtp-connection';
export enum EmailDriver {
Logger = 'logger',
Smtp = 'smtp',
}
export type EmailModuleOptions =
| (SMTPConnection.Options & {
type: EmailDriver.Smtp;
})
| {
type: EmailDriver.Logger;
};
export type EmailModuleAsyncOptions = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
useFactory: (...args: any[]) => EmailModuleOptions;
} & Pick<ModuleMetadata, 'imports'> &
Pick<FactoryProvider, 'inject'>;