FRE-4520: Add unit tests for notification template system
- 25 tests covering template resolution, localization fallback, variable substitution, caching, custom template registration, and edge cases - Update package.json to use vitest for test execution - All 25 tests passing Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -5,7 +5,8 @@
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "jest",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -17,7 +18,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.0",
|
||||
"typescript": "^5.0.0"
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^4.1.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
|
||||
413
packages/shared-notifications/test/template.service.test.ts
Normal file
413
packages/shared-notifications/test/template.service.test.ts
Normal file
@@ -0,0 +1,413 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { TemplateService } from '../src/services/template.service';
|
||||
import type { TemplateDefinition } from '../src/types/template.types';
|
||||
|
||||
describe('TemplateService', () => {
|
||||
let templateService: TemplateService;
|
||||
|
||||
beforeEach(() => {
|
||||
templateService = TemplateService.getInstance();
|
||||
templateService.clearCache();
|
||||
});
|
||||
|
||||
describe('resolveTemplate', () => {
|
||||
it('resolves an English email template by ID', () => {
|
||||
const resolved = templateService.resolveTemplate({
|
||||
templateId: 'welcome_email',
|
||||
locale: 'en',
|
||||
variables: { name: 'John', profile_url: 'https://shieldai.app/profile' },
|
||||
});
|
||||
|
||||
expect(resolved).not.toBeNull();
|
||||
expect(resolved!.id).toBe('welcome_email');
|
||||
expect(resolved!.channel).toBe('email');
|
||||
expect(resolved!.locale).toBe('en');
|
||||
expect(resolved!.subject).toContain('John');
|
||||
expect(resolved!.body).toContain('John');
|
||||
expect(resolved!.body).toContain('https://shieldai.app/profile');
|
||||
});
|
||||
|
||||
it('resolves a Spanish email template when locale is es', () => {
|
||||
const resolved = templateService.resolveTemplate({
|
||||
templateId: 'welcome_email',
|
||||
locale: 'es',
|
||||
variables: { name: 'Juan', profile_url: 'https://shieldai.app/profile' },
|
||||
});
|
||||
|
||||
expect(resolved).not.toBeNull();
|
||||
expect(resolved!.locale).toBe('es');
|
||||
expect(resolved!.subject).toContain('Juan');
|
||||
expect(resolved!.body).toContain('Bienvenido');
|
||||
});
|
||||
|
||||
it('falls back to English when locale is not found', () => {
|
||||
const resolved = templateService.resolveTemplate({
|
||||
templateId: 'welcome_email',
|
||||
locale: 'fr',
|
||||
variables: { name: 'Pierre', profile_url: 'https://shieldai.app/profile' },
|
||||
});
|
||||
|
||||
expect(resolved).not.toBeNull();
|
||||
expect(resolved!.locale).toBe('en');
|
||||
});
|
||||
|
||||
it('falls back to language code match', () => {
|
||||
const resolved = templateService.resolveTemplate({
|
||||
templateId: 'welcome_email',
|
||||
locale: 'es-MX',
|
||||
variables: { name: 'Maria', profile_url: 'https://shieldai.app/profile' },
|
||||
});
|
||||
|
||||
expect(resolved).not.toBeNull();
|
||||
expect(resolved!.locale).toBe('es');
|
||||
});
|
||||
|
||||
it('returns null for unknown template ID', () => {
|
||||
const resolved = templateService.resolveTemplate({
|
||||
templateId: 'nonexistent_template',
|
||||
locale: 'en',
|
||||
});
|
||||
|
||||
expect(resolved).toBeNull();
|
||||
});
|
||||
|
||||
it('substitutes all variables in the template', () => {
|
||||
const resolved = templateService.resolveTemplate({
|
||||
templateId: 'alert_notification',
|
||||
locale: 'en',
|
||||
variables: {
|
||||
alert_type: 'Dark Web Exposure',
|
||||
alert_details: 'Email found in breach',
|
||||
alert_time: '2026-05-01',
|
||||
alert_url: 'https://shieldai.app/alerts/123',
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved).not.toBeNull();
|
||||
expect(resolved!.subject).toBe('ShieldAI Alert: Dark Web Exposure');
|
||||
expect(resolved!.body).toContain('Dark Web Exposure');
|
||||
expect(resolved!.body).toContain('Email found in breach');
|
||||
expect(resolved!.body).toContain('2026-05-01');
|
||||
expect(resolved!.body).toContain('https://shieldai.app/alerts/123');
|
||||
});
|
||||
|
||||
it('uses default values for optional variables', () => {
|
||||
const resolved = templateService.resolveTemplate({
|
||||
templateId: 'alert_notification',
|
||||
locale: 'en',
|
||||
variables: {
|
||||
alert_type: 'Test Alert',
|
||||
alert_details: 'Test details',
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved).not.toBeNull();
|
||||
expect(resolved!.body).toContain('Just now');
|
||||
});
|
||||
|
||||
it('keeps unsubstituted variables as-is', () => {
|
||||
const resolved = templateService.resolveTemplate({
|
||||
templateId: 'password_reset',
|
||||
locale: 'en',
|
||||
variables: {
|
||||
name: 'John',
|
||||
reset_url: 'https://shieldai.app/reset/abc',
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved).not.toBeNull();
|
||||
expect(resolved!.body).toContain('24');
|
||||
});
|
||||
|
||||
it('resolves SMS templates', () => {
|
||||
const resolved = templateService.resolveTemplate({
|
||||
templateId: 'verification_sms',
|
||||
locale: 'en',
|
||||
variables: { code: '123456' },
|
||||
});
|
||||
|
||||
expect(resolved).not.toBeNull();
|
||||
expect(resolved!.channel).toBe('sms');
|
||||
expect(resolved!.body).toContain('123456');
|
||||
expect(resolved!.body).toContain('10');
|
||||
});
|
||||
|
||||
it('resolves push notification templates', () => {
|
||||
const resolved = templateService.resolveTemplate({
|
||||
templateId: 'alert_push',
|
||||
locale: 'en',
|
||||
variables: {
|
||||
alert_type: 'Voice Clone',
|
||||
alert_details: 'Suspicious call detected',
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved).not.toBeNull();
|
||||
expect(resolved!.channel).toBe('push');
|
||||
expect(resolved!.subject).toContain('Voice Clone');
|
||||
expect(resolved!.body).toBe('Suspicious call detected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerTemplate', () => {
|
||||
it('registers a custom template', () => {
|
||||
const customTemplate: TemplateDefinition = {
|
||||
id: 'custom_welcome',
|
||||
name: 'Custom Welcome',
|
||||
channel: 'email',
|
||||
locale: 'en',
|
||||
category: 'onboarding',
|
||||
subject: 'Welcome, {{name}}!',
|
||||
body: 'Custom welcome message for {{name}}.',
|
||||
variables: [
|
||||
{ name: 'name', type: 'string', required: true },
|
||||
],
|
||||
};
|
||||
|
||||
templateService.registerTemplate(customTemplate);
|
||||
|
||||
const resolved = templateService.resolveTemplate({
|
||||
templateId: 'custom_welcome',
|
||||
locale: 'en',
|
||||
variables: { name: 'Alice' },
|
||||
});
|
||||
|
||||
expect(resolved).not.toBeNull();
|
||||
expect(resolved!.subject).toBe('Welcome, Alice!');
|
||||
expect(resolved!.body).toBe('Custom welcome message for Alice.');
|
||||
});
|
||||
|
||||
it('registers multiple templates at once', () => {
|
||||
const templates: TemplateDefinition[] = [
|
||||
{
|
||||
id: 'bulk_template',
|
||||
name: 'Bulk Template',
|
||||
channel: 'email',
|
||||
locale: 'en',
|
||||
category: 'test',
|
||||
subject: 'Bulk test',
|
||||
body: 'Bulk body {{var1}} {{var2}}',
|
||||
variables: [
|
||||
{ name: 'var1', type: 'string', required: true },
|
||||
{ name: 'var2', type: 'string', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'bulk_template',
|
||||
name: 'Bulk Template ES',
|
||||
channel: 'email',
|
||||
locale: 'es',
|
||||
category: 'test',
|
||||
subject: 'Prueba masiva',
|
||||
body: 'Cuerpo masivo {{var1}} {{var2}}',
|
||||
variables: [
|
||||
{ name: 'var1', type: 'string', required: true },
|
||||
{ name: 'var2', type: 'string', required: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
templateService.registerTemplates(templates);
|
||||
|
||||
const enResolved = templateService.resolveTemplate({
|
||||
templateId: 'bulk_template',
|
||||
locale: 'en',
|
||||
variables: { var1: 'A', var2: 'B' },
|
||||
});
|
||||
|
||||
expect(enResolved!.locale).toBe('en');
|
||||
expect(enResolved!.body).toBe('Bulk body A B');
|
||||
|
||||
const esResolved = templateService.resolveTemplate({
|
||||
templateId: 'bulk_template',
|
||||
locale: 'es',
|
||||
variables: { var1: 'A', var2: 'B' },
|
||||
});
|
||||
|
||||
expect(esResolved!.locale).toBe('es');
|
||||
expect(esResolved!.body).toBe('Cuerpo masivo A B');
|
||||
});
|
||||
|
||||
it('invalidates cache after registration', () => {
|
||||
const template: TemplateDefinition = {
|
||||
id: 'cache_test',
|
||||
name: 'Cache Test',
|
||||
channel: 'email',
|
||||
locale: 'en',
|
||||
category: 'test',
|
||||
subject: 'Original',
|
||||
body: 'Original body',
|
||||
variables: [],
|
||||
};
|
||||
|
||||
templateService.registerTemplate(template);
|
||||
|
||||
templateService.resolveTemplate({
|
||||
templateId: 'cache_test',
|
||||
locale: 'en',
|
||||
});
|
||||
|
||||
const statsBefore = templateService.getCacheStats();
|
||||
expect(statsBefore.size).toBeGreaterThan(0);
|
||||
|
||||
templateService.registerTemplate({
|
||||
...template,
|
||||
subject: 'Updated',
|
||||
body: 'Updated body',
|
||||
});
|
||||
|
||||
const resolved = templateService.resolveTemplate({
|
||||
templateId: 'cache_test',
|
||||
locale: 'en',
|
||||
});
|
||||
|
||||
expect(resolved!.subject).toBe('Updated');
|
||||
});
|
||||
});
|
||||
|
||||
describe('caching', () => {
|
||||
it('caches resolved templates', () => {
|
||||
templateService.resolveTemplate({
|
||||
templateId: 'welcome_email',
|
||||
locale: 'en',
|
||||
variables: { name: 'Test', profile_url: 'http://test.com' },
|
||||
});
|
||||
|
||||
const stats = templateService.getCacheStats();
|
||||
expect(stats.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('clears cache on clearCache call', () => {
|
||||
templateService.resolveTemplate({
|
||||
templateId: 'welcome_email',
|
||||
locale: 'en',
|
||||
variables: { name: 'Test', profile_url: 'http://test.com' },
|
||||
});
|
||||
|
||||
templateService.clearCache();
|
||||
|
||||
const stats = templateService.getCacheStats();
|
||||
expect(stats.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvailableLocales', () => {
|
||||
it('returns available locales for a template', () => {
|
||||
const locales = templateService.getAvailableLocales('welcome_email');
|
||||
expect(locales).toContain('en');
|
||||
expect(locales).toContain('es');
|
||||
});
|
||||
|
||||
it('returns empty array for unknown template', () => {
|
||||
const locales = templateService.getAvailableLocales('nonexistent');
|
||||
expect(locales).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTemplateIds', () => {
|
||||
it('returns all registered template IDs', () => {
|
||||
const ids = templateService.getTemplateIds();
|
||||
expect(ids).toContain('welcome_email');
|
||||
expect(ids).toContain('alert_notification');
|
||||
expect(ids).toContain('password_reset');
|
||||
expect(ids).toContain('scan_complete');
|
||||
expect(ids).toContain('alert_sms');
|
||||
expect(ids).toContain('verification_sms');
|
||||
expect(ids).toContain('alert_push');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTemplateInfo', () => {
|
||||
it('returns template metadata', () => {
|
||||
const info = templateService.getTemplateInfo('welcome_email');
|
||||
expect(info).not.toBeNull();
|
||||
expect(info!.id).toBe('welcome_email');
|
||||
expect(info!.channel).toBe('email');
|
||||
expect(info!.category).toBe('onboarding');
|
||||
expect(info!.locales).toContain('en');
|
||||
expect(info!.locales).toContain('es');
|
||||
expect(info!.variables).toContain('name');
|
||||
expect(info!.variables).toContain('profile_url');
|
||||
});
|
||||
|
||||
it('returns null for unknown template', () => {
|
||||
const info = templateService.getTemplateInfo('nonexistent');
|
||||
expect(info).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('locale normalization', () => {
|
||||
it('normalizes locale with underscore separator', () => {
|
||||
const resolved = templateService.resolveTemplate({
|
||||
templateId: 'welcome_email',
|
||||
locale: 'es_ES',
|
||||
variables: { name: 'Test', profile_url: 'http://test.com' },
|
||||
});
|
||||
|
||||
expect(resolved).not.toBeNull();
|
||||
expect(resolved!.locale).toBe('es');
|
||||
});
|
||||
|
||||
it('normalizes mixed case locale', () => {
|
||||
const resolved = templateService.resolveTemplate({
|
||||
templateId: 'welcome_email',
|
||||
locale: 'EN-US',
|
||||
variables: { name: 'Test', profile_url: 'http://test.com' },
|
||||
});
|
||||
|
||||
expect(resolved).not.toBeNull();
|
||||
expect(resolved!.locale).toBe('en');
|
||||
});
|
||||
});
|
||||
|
||||
describe('variable substitution edge cases', () => {
|
||||
it('handles numeric variable values', () => {
|
||||
const resolved = templateService.resolveTemplate({
|
||||
templateId: 'password_reset',
|
||||
locale: 'en',
|
||||
variables: {
|
||||
name: 'John',
|
||||
reset_url: 'https://shieldai.app/reset',
|
||||
expiry_hours: 48,
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved).not.toBeNull();
|
||||
expect(resolved!.body).toContain('48');
|
||||
});
|
||||
|
||||
it('handles boolean variable values', () => {
|
||||
templateService.registerTemplate({
|
||||
id: 'bool_test',
|
||||
name: 'Bool Test',
|
||||
channel: 'email',
|
||||
locale: 'en',
|
||||
category: 'test',
|
||||
subject: 'Test',
|
||||
body: 'Value is {{flag}}',
|
||||
variables: [
|
||||
{ name: 'flag', type: 'boolean', required: true },
|
||||
],
|
||||
});
|
||||
|
||||
const resolved = templateService.resolveTemplate({
|
||||
templateId: 'bool_test',
|
||||
locale: 'en',
|
||||
variables: { flag: true },
|
||||
});
|
||||
|
||||
expect(resolved!.body).toBe('Value is true');
|
||||
});
|
||||
|
||||
it('preserves HTML in htmlBody', () => {
|
||||
const resolved = templateService.resolveTemplate({
|
||||
templateId: 'welcome_email',
|
||||
locale: 'en',
|
||||
variables: { name: 'John', profile_url: 'https://shieldai.app/profile' },
|
||||
});
|
||||
|
||||
expect(resolved!.htmlBody).toContain('<h1>');
|
||||
expect(resolved!.htmlBody).toContain('<a href="https://shieldai.app/profile">');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user