diff --git a/packages/shared-notifications/package.json b/packages/shared-notifications/package.json index 2c73db8..705f9bc 100644 --- a/packages/shared-notifications/package.json +++ b/packages/shared-notifications/package.json @@ -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" diff --git a/packages/shared-notifications/test/template.service.test.ts b/packages/shared-notifications/test/template.service.test.ts new file mode 100644 index 0000000..89e500a --- /dev/null +++ b/packages/shared-notifications/test/template.service.test.ts @@ -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('