Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions example_config.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@
"passwordColumn": "password"
}
},
"communicate": {
"email": {
"emailEngine": "dummy"
}
},
"models": [
{
"name": "users",
Expand Down
10 changes: 10 additions & 0 deletions src/interfaces/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -220,4 +229,5 @@ export interface AppConfig {
cache_db?: CacheDbConfig;
customAPIs?: CustomAPIConfig;
auth?: AuthConfig;
communicate?: CommunicateConfig;
}
24 changes: 24 additions & 0 deletions src/plugin/communicate.ts
Original file line number Diff line number Diff line change
@@ -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);
});
6 changes: 6 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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, {
Expand Down
7 changes: 7 additions & 0 deletions src/types/fastify.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,12 @@ declare module 'fastify' {
payload: unknown,
) => Promise<void>;
enforceSSP: (request: import('fastify').FastifyRequest) => void;
communicate: {
sendEmail: (
email: string,
htmlBody: string,
body: string,
) => Promise<void>;
};
}
}
21 changes: 21 additions & 0 deletions src/validators/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -479,6 +499,7 @@ const schema = {
cache_db: cacheDbSchema,
customAPIs: customAPIsSchema,
auth: authSchema,
communicate: communicateSchema,
},
};

Expand Down
92 changes: 92 additions & 0 deletions tests/plugin/communicate.test.ts
Original file line number Diff line number Diff line change
@@ -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',
'<h1>Hello</h1>',
'Hello',
);

expect(consoleLogSpy).toHaveBeenCalledWith('Email:', 'test@example.com');
expect(consoleLogSpy).toHaveBeenCalledWith('HTML Body:', '<h1>Hello</h1>');
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',
'<h1>Hello</h1>',
'Hello',
);

expect(consoleLogSpy).not.toHaveBeenCalled();

consoleLogSpy.mockRestore();
});
});
30 changes: 30 additions & 0 deletions tests/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
});
});
});
76 changes: 76 additions & 0 deletions tests/validators/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
});
});
Loading