diff --git a/example_config.json b/example_config.json index 8839f11..eb2358f 100644 --- a/example_config.json +++ b/example_config.json @@ -52,6 +52,11 @@ "passwordColumn": "password" } }, + "communicate": { + "email": { + "emailEngine": "dummy" + } + }, "models": [ { "name": "users", diff --git a/src/interfaces/config.ts b/src/interfaces/config.ts index c7f4031..e685fa3 100644 --- a/src/interfaces/config.ts +++ b/src/interfaces/config.ts @@ -60,6 +60,7 @@ export type JsonSchemaObject = { export type WebhookData = 'query' | 'body' | 'params' | 'resp'; export type AuthEngine = 'api-key' | 'up-auth'; export type SspParamType = 'path' | 'query' | 'body'; +export type EmailEngine = 'dummy'; export interface SwaggerConfig { enabled: boolean; @@ -211,6 +212,14 @@ export interface AuthConfig { apiKey?: string; } +export interface EmailConfig { + emailEngine: EmailEngine; +} + +export interface CommunicateConfig { + email?: EmailConfig; +} + export interface AppConfig { application: ApplicationConfig; swagger: SwaggerConfig; @@ -220,4 +229,5 @@ export interface AppConfig { cache_db?: CacheDbConfig; customAPIs?: CustomAPIConfig; auth?: AuthConfig; + communicate?: CommunicateConfig; } diff --git a/src/plugin/communicate.ts b/src/plugin/communicate.ts new file mode 100644 index 0000000..853f440 --- /dev/null +++ b/src/plugin/communicate.ts @@ -0,0 +1,24 @@ +import {FastifyInstance} from 'fastify'; +import fp from 'fastify-plugin'; + +const sendDummyEmail = async ( + email: string, + htmlBody: string, + body: string, +) => { + console.log('Email:', email); + console.log('HTML Body:', htmlBody); + console.log('Body:', body); +}; + +export default fp(async (fastify: FastifyInstance) => { + const communicate = { + sendEmail: async (email: string, htmlBody: string, body: string) => { + if (fastify.appConfig.communicate?.email?.emailEngine === 'dummy') { + await sendDummyEmail(email, htmlBody, body); + } + }, + }; + + fastify.decorate('communicate', communicate); +}); diff --git a/src/server.ts b/src/server.ts index 51e884b..f2bd6e9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -10,6 +10,7 @@ import Fastify, { import migrateDatabase from '@/migrator'; import authPlugin from '@/plugin/auth'; import cachePlugin from '@/plugin/cache'; +import communicatePlugin from '@/plugin/communicate'; import dbPlugin from '@/plugin/database'; import rateLimitPlugin from '@/plugin/rate-limit'; import responsePlugin from '@/plugin/response'; @@ -121,6 +122,11 @@ export async function startServer( // config-driven cache (Redis or NodeCache) await app.register(cachePlugin); + // config-driven communicate + if (config.communicate) { + await app.register(communicatePlugin); + } + // config-driven rate limit if (config.application.rateLimit) { await app.register(rateLimitPlugin, { diff --git a/src/types/fastify.d.ts b/src/types/fastify.d.ts index 3667ee1..776b8bd 100644 --- a/src/types/fastify.d.ts +++ b/src/types/fastify.d.ts @@ -34,5 +34,12 @@ declare module 'fastify' { payload: unknown, ) => Promise; enforceSSP: (request: import('fastify').FastifyRequest) => void; + communicate: { + sendEmail: ( + email: string, + htmlBody: string, + body: string, + ) => Promise; + }; } } diff --git a/src/validators/config/schema.ts b/src/validators/config/schema.ts index ef1395a..f6a6d8d 100644 --- a/src/validators/config/schema.ts +++ b/src/validators/config/schema.ts @@ -462,6 +462,26 @@ const authSchema = { }, }; +const emailSchema = { + type: 'object', + additionalProperties: false, + required: ['emailEngine'], + properties: { + emailEngine: { + type: 'string', + enum: ['dummy'], + }, + }, +}; + +const communicateSchema = { + type: 'object', + additionalProperties: false, + properties: { + email: emailSchema, + }, +}; + const schema = { type: 'object', required: ['application', 'swagger', 'database', 'models'], @@ -479,6 +499,7 @@ const schema = { cache_db: cacheDbSchema, customAPIs: customAPIsSchema, auth: authSchema, + communicate: communicateSchema, }, }; diff --git a/tests/plugin/communicate.test.ts b/tests/plugin/communicate.test.ts new file mode 100644 index 0000000..0b7f089 --- /dev/null +++ b/tests/plugin/communicate.test.ts @@ -0,0 +1,92 @@ +import Fastify, {FastifyInstance} from 'fastify'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +import communicatePlugin from '@/plugin/communicate'; + +import {AppConfig} from '@/interfaces/config'; + +describe('communicate plugin', () => { + let app: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(async () => { + if (app) { + await app.close(); + } + }); + + it('decorates fastify with communicate', async () => { + app = Fastify(); + app.appConfig = { + communicate: { + email: { + emailEngine: 'dummy', + }, + }, + } as unknown as AppConfig; + + await app.register(communicatePlugin); + await app.ready(); + + expect(app.hasDecorator('communicate')).toBe(true); + expect(app.communicate).toBeDefined(); + expect(typeof app.communicate.sendEmail).toBe('function'); + }); + + it('sends a dummy email when emailEngine is dummy', async () => { + app = Fastify(); + app.appConfig = { + communicate: { + email: { + emailEngine: 'dummy', + }, + }, + } as unknown as AppConfig; + + await app.register(communicatePlugin); + await app.ready(); + + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await app.communicate.sendEmail( + 'test@example.com', + '

Hello

', + 'Hello', + ); + + expect(consoleLogSpy).toHaveBeenCalledWith('Email:', 'test@example.com'); + expect(consoleLogSpy).toHaveBeenCalledWith('HTML Body:', '

Hello

'); + expect(consoleLogSpy).toHaveBeenCalledWith('Body:', 'Hello'); + + consoleLogSpy.mockRestore(); + }); + + it('does not send an email if emailEngine is not dummy', async () => { + app = Fastify(); + app.appConfig = { + communicate: { + email: { + emailEngine: 'other' as unknown as 'dummy', + }, + }, + } as unknown as AppConfig; + + await app.register(communicatePlugin); + await app.ready(); + + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await app.communicate.sendEmail( + 'test@example.com', + '

Hello

', + 'Hello', + ); + + expect(consoleLogSpy).not.toHaveBeenCalled(); + + consoleLogSpy.mockRestore(); + }); +}); diff --git a/tests/server.test.ts b/tests/server.test.ts index 5e90596..b261ccc 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -7,6 +7,7 @@ import Fastify, { import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import migrateDatabase from '@/migrator/index'; +import communicatePlugin from '@/plugin/communicate'; import {startServer} from '@/server'; import {registerRoutes} from '@/routes/index'; @@ -542,4 +543,33 @@ describe('Server', () => { ).toHaveProperty('apiKeyAuth'); }); }); + + describe('Communicate Configuration', () => { + it('should register communicate plugin when communicate is configured', async () => { + const configWithCommunicate: AppConfig = { + ...mockConfig, + communicate: { + email: { + emailEngine: 'dummy', + }, + }, + }; + + const registerMock = mockApp.register; + await startServer(configWithCommunicate, 3000, 'dev'); + + expect(registerMock).toHaveBeenCalledWith(communicatePlugin); + }); + + it('should not register communicate plugin when communicate is not configured', async () => { + const registerMock = mockApp.register; + await startServer(mockConfig, 3000, 'dev'); + + // Assert that none of the registered calls are the communicatePlugin + const communicatePluginCall = registerMock.mock.calls.find( + (call: unknown[]) => call[0] === communicatePlugin, + ); + expect(communicatePluginCall).toBeUndefined(); + }); + }); }); diff --git a/tests/validators/config.test.ts b/tests/validators/config.test.ts index a92cad9..c9230ac 100644 --- a/tests/validators/config.test.ts +++ b/tests/validators/config.test.ts @@ -3466,3 +3466,79 @@ describe('validateValidAuthorizationConfig', () => { expect(validateConfig(config as unknown as AppConfig)).toEqual(config); }); }); + +// check communicate configs validation +describe('validateCommunicateConfig', () => { + it('should pass when communicate config is valid', () => { + const config = { + ...validBaseConfig, + communicate: { + email: { + emailEngine: 'dummy', + }, + }, + }; + + expect(validateConfig(config as unknown as AppConfig)).toEqual(config); + }); + + it('should throw when email config is missing required emailEngine', () => { + const config = { + ...validBaseConfig, + communicate: { + email: {}, + }, + }; + + expect(() => validateConfig(config as unknown as AppConfig)).toThrow( + "must have required property 'emailEngine'", + ); + }); + + it('should throw when email config has invalid emailEngine enum value', () => { + const config = { + ...validBaseConfig, + communicate: { + email: { + emailEngine: 'invalid-engine', + }, + }, + }; + + expect(() => validateConfig(config as unknown as AppConfig)).toThrow( + 'must be equal to one of the allowed values', + ); + }); + + it('should throw when email config has extra properties', () => { + const config = { + ...validBaseConfig, + communicate: { + email: { + emailEngine: 'dummy', + extraProperty: true, + }, + }, + }; + + expect(() => validateConfig(config as unknown as AppConfig)).toThrow( + 'must NOT have additional properties', + ); + }); + + it('should throw when communicate config itself has extra properties', () => { + const config = { + ...validBaseConfig, + communicate: { + email: { + emailEngine: 'dummy', + }, + extraProperty: true, + }, + }; + + expect(() => validateConfig(config as unknown as AppConfig)).toThrow( + 'must NOT have additional properties', + ); + }); +});