From fce1d42b71d3050a24e9b5fefd431de38ca80b78 Mon Sep 17 00:00:00 2001 From: Romain Lussier Date: Wed, 9 Sep 2026 21:17:44 +0200 Subject: [PATCH] test(notification-service): cover payload cms validation and seeding --- .../tests/unit/payload/collections.test.ts | 119 ++++++++++++++++++ .../tests/unit/payload/payload.config.test.ts | 44 +++++++ .../tests/unit/payload/seed-templates.test.ts | 112 +++++++++++++++++ 3 files changed, 275 insertions(+) create mode 100644 services/notification-service/tests/unit/payload/collections.test.ts create mode 100644 services/notification-service/tests/unit/payload/payload.config.test.ts create mode 100644 services/notification-service/tests/unit/payload/seed-templates.test.ts diff --git a/services/notification-service/tests/unit/payload/collections.test.ts b/services/notification-service/tests/unit/payload/collections.test.ts new file mode 100644 index 0000000..f855ad2 --- /dev/null +++ b/services/notification-service/tests/unit/payload/collections.test.ts @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { CollectionBeforeChangeHook, CollectionBeforeValidateHook, Field, TextField } from 'payload'; +import { NotificationChannels } from '../../../src/payload/collections/NotificationChannels.js'; +import { NotificationTemplates } from '../../../src/payload/collections/NotificationTemplates.js'; +import { TemplateVariables } from '../../../src/payload/collections/TemplateVariables.js'; + +// Payload supplies request/context arguments at runtime; these hooks only use data. +function validate(hook: CollectionBeforeValidateHook, data?: Record) { + return hook({ data } as Parameters[0]); +} + +function fieldAt(fields: Field[], ...names: string[]): Field { + const [name, ...rest] = names; + const field = fields.find((candidate) => 'name' in candidate && candidate.name === name); + if (!field) throw new Error(`Missing field: ${name}`); + if (!rest.length) return field; + if (!('fields' in field)) throw new Error(`Field ${name} has no children`); + return fieldAt(field.fields, ...rest); +} + +function validateField(field: Field, value: string | null | undefined, data = {}) { + const textField = field as TextField; + if (textField.hasMany) throw new Error('Expected a single-value field'); + const validator = textField.validate!; + return validator(value, { data } as Parameters[1]); +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe('notification channel validation', () => { + const hook = NotificationChannels.hooks!.beforeValidate![0]; + + it.each([undefined, {}, { defaultFromEmail: '' }])( + 'rejects enabled email channels without a sender: %j', + (configuration) => { + expect(() => validate(hook, { channel: 'email', isEnabled: true, configuration })) + .toThrow('Email channel requires a default from email address'); + }, + ); + + it.each([ + undefined, + {}, + { channel: 'email', isEnabled: false }, + { channel: 'sms', isEnabled: true }, + { channel: 'email', isEnabled: true, configuration: { defaultFromEmail: 'studio@example.com' } }, + ])('preserves valid or partial channel data: %j', async (data) => { + expect(await validate(hook, data)).toBe(data); + }); + + it.each([ + [undefined, true, 'Webhook endpoint is required when webhooks are enabled'], + ['', true, 'Webhook endpoint is required when webhooks are enabled'], + ['ftp://example.com', true, 'Webhook endpoint must be a valid URL'], + ['https://', true, 'Webhook endpoint must be a valid URL'], + ['https://example.com/events', true, true], + ['http://localhost/events', true, true], + [undefined, false, true], + [undefined, undefined, true], + ])('validates webhook endpoint %s with enabled=%s', (value, enabled, expected) => { + const field = fieldAt(NotificationChannels.fields, 'webhooks', 'endpoint'); + expect(validateField(field, value, { webhooks: { enabled } })).toBe(expected); + }); +}); + +describe('notification template hooks', () => { + const hook = NotificationTemplates.hooks!.beforeValidate![0]; + + it.each([{}, { subject: '' }, { subject: null }])('rejects an email template without a subject: %j', (templates) => { + expect(() => validate(hook, { channel: 'email', templates })) + .toThrow('Email templates must have a subject line'); + }); + + it.each([ + undefined, + {}, + { channel: 'email' }, + { channel: 'email', templates: { subject: 'Your photos are ready' } }, + { channel: 'sms', templates: { text: 'Your photos are ready' } }, + ])('preserves valid or partial template data: %j', async (data) => { + expect(await validate(hook, data)).toBe(data); + }); + + it.each(['create', 'update'] as const)('records the %s operation in the audit log', (operation) => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-09-09T16:00:00Z')); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + const audit = NotificationTemplates.hooks!.afterChange![0]; + audit({ doc: { name: 'Gallery invitation' }, operation } as Parameters[0]); + expect(log).toHaveBeenCalledWith(`Template Gallery invitation was ${operation}d at 2026-09-09T16:00:00.000Z`); + }); +}); + +describe('template variable validation', () => { + it.each(['clientName', '_client', 'photo_count2'])('accepts the identifier %s', (value) => { + const field = fieldAt(TemplateVariables.fields, 'name'); + expect(validateField(field, value)).toBe(true); + }); + + it.each([undefined, null, '', '2photos', 'client name', 'client-name', 'client.name'])('rejects invalid identifier %s', (value) => { + const field = fieldAt(TemplateVariables.fields, 'name'); + expect(validateField(field, value)).toBe('Variable name must start with a letter or underscore and contain only letters, numbers, and underscores'); + }); + + it('trims the variable name without losing the other fields', async () => { + const hook = TemplateVariables.hooks!.beforeChange![0]; + const data = { name: ' clientName ', description: 'Client display name' }; + const result = await hook({ data } as unknown as Parameters[0]); + expect(result).toEqual({ name: 'clientName', description: 'Client display name' }); + }); + + it.each([undefined, {}, { name: '' }])('handles absent variable names: %j', async (data) => { + const hook = TemplateVariables.hooks!.beforeChange![0]; + expect(await hook({ data } as Parameters[0])).toBe(data); + }); +}); diff --git a/services/notification-service/tests/unit/payload/payload.config.test.ts b/services/notification-service/tests/unit/payload/payload.config.test.ts new file mode 100644 index 0000000..e7065df --- /dev/null +++ b/services/notification-service/tests/unit/payload/payload.config.test.ts @@ -0,0 +1,44 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Config } from 'payload'; + +const mocks = vi.hoisted(() => ({ + buildConfig: vi.fn(async (config: Config) => config), + mongooseAdapter: vi.fn(() => ({ name: 'test-adapter' })), +})); + +vi.mock('payload', () => ({ buildConfig: mocks.buildConfig })); +vi.mock('@payloadcms/db-mongodb', () => ({ mongooseAdapter: mocks.mongooseAdapter })); + +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); +}); + +afterEach(() => vi.unstubAllEnvs()); + +describe('Payload Local API configuration', () => { + it('uses the configured secret and MongoDB URI and registers all notification collections', async () => { + vi.stubEnv('PAYLOAD_SECRET', 'test-only-secret'); + vi.stubEnv('MONGODB_URI', 'mongodb://localhost/test-notification-cms'); + const { default: pendingConfig } = await import('../../../src/payload/payload.config.js'); + const config = await pendingConfig; + + expect(config.secret).toBe('test-only-secret'); + expect(mocks.mongooseAdapter).toHaveBeenCalledWith({ url: 'mongodb://localhost/test-notification-cms' }); + expect(config.db).toEqual({ name: 'test-adapter' }); + expect(config.collections?.map(({ slug }) => slug)).toEqual([ + 'notification-templates', 'template-variables', 'notification-channels', + ]); + expect(mocks.buildConfig).toHaveBeenCalledOnce(); + }); + + it('uses the local development defaults when environment variables are absent', async () => { + vi.stubEnv('PAYLOAD_SECRET', undefined); + vi.stubEnv('MONGODB_URI', undefined); + const { default: pendingConfig } = await import('../../../src/payload/payload.config.js'); + const config = await pendingConfig; + + expect(config.secret).toBe('your-secret-here'); + expect(mocks.mongooseAdapter).toHaveBeenCalledWith({ url: 'mongodb://localhost/notification-templates' }); + }); +}); diff --git a/services/notification-service/tests/unit/payload/seed-templates.test.ts b/services/notification-service/tests/unit/payload/seed-templates.test.ts new file mode 100644 index 0000000..f87ce6a --- /dev/null +++ b/services/notification-service/tests/unit/payload/seed-templates.test.ts @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +interface CreateArgs { + collection: string; + data: Record; +} + +const cms = vi.hoisted(() => ({ + find: vi.fn(), + create: vi.fn(), + getPayload: vi.fn(), +})); + +vi.mock('payload', () => ({ getPayload: cms.getPayload })); +vi.mock('../../../src/payload/payload.config.js', () => ({ default: { secret: 'seed-test' } })); + +// This is a standalone script with top-level await. Resetting modules lets each +// test exercise its real entry point, with only the external CMS boundary mocked. +const runSeed = () => import('../../../src/payload/seed-templates.js'); + +beforeEach(() => { + vi.resetModules(); + vi.resetAllMocks(); + cms.getPayload.mockResolvedValue(cms); + cms.find.mockResolvedValue({ docs: [] }); + cms.create.mockResolvedValue({ id: 'created' }); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => vi.restoreAllMocks()); + +describe('notification template seed script', () => { + it('initializes Payload and creates usable email templates and their variable definitions', async () => { + await runSeed(); + + expect(cms.getPayload).toHaveBeenCalledWith({ config: { secret: 'seed-test' } }); + expect(cms.find).toHaveBeenCalledWith({ collection: 'notification-templates', limit: 1 }); + const writes = cms.create.mock.calls.map(([args]) => args as CreateArgs); + const templates = writes.filter(({ collection }) => collection === 'notification-templates'); + const variables = writes.filter(({ collection }) => collection === 'template-variables'); + expect(templates.map(({ data }) => data.type)).toEqual(['magic-link', 'photos-ready', 'shoot-update']); + for (const { data } of templates) { + expect(data).toMatchObject({ channel: 'email', language: 'en', isActive: true }); + expect(data.templates.subject).toContain('{{eventName}}'); + expect(data.templates.text).toContain('{{clientName}}'); + expect(data.templates.html).toContain('{{clientName}}'); + expect(data.variables).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'clientName', required: true }), + ])); + } + expect(variables.map(({ data }) => data.name)).toEqual([ + 'clientName', 'eventName', 'photographerName', 'photographerEmail', + 'magicLinkUrl', 'expirationDate', 'eventDate', 'eventLocation', 'totalPhotoCount', 'galleryUrl', + ]); + expect(console.error).not.toHaveBeenCalled(); + }); + + it('does not overwrite templates or create variables when templates already exist', async () => { + cms.find.mockResolvedValue({ docs: [{ id: 'existing' }] }); + await runSeed(); + expect(cms.create).not.toHaveBeenCalled(); + }); + + it('propagates initialization failure without attempting any writes', async () => { + const failure = new Error('CMS unavailable'); + cms.getPayload.mockRejectedValue(failure); + await expect(runSeed()).rejects.toBe(failure); + expect(cms.find).not.toHaveBeenCalled(); + expect(cms.create).not.toHaveBeenCalled(); + }); + + it('reports and propagates a failed lookup without writing templates', async () => { + const failure = new Error('Database lookup failed'); + cms.find.mockRejectedValue(failure); + await expect(runSeed()).rejects.toBe(failure); + expect(cms.create).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith('❌ Failed to seed templates:', failure); + }); + + it('stops and propagates a failed template write before seeding variables', async () => { + const failure = new Error('Template validation failed'); + cms.create.mockRejectedValueOnce(failure); + await expect(runSeed()).rejects.toBe(failure); + expect(cms.create).toHaveBeenCalledTimes(1); + expect(console.error).toHaveBeenCalledWith('❌ Failed to seed templates:', failure); + }); + + it.each([new Error('duplicate key'), 'duplicate key'])('skips duplicate variables and continues seeding: %s', async (failure) => { + cms.create.mockImplementation(async ({ collection, data }: CreateArgs) => { + if (collection === 'template-variables' && data.name === 'clientName') throw failure; + return { id: data.name }; + }); + await runSeed(); + expect(cms.create).toHaveBeenLastCalledWith(expect.objectContaining({ + collection: 'template-variables', data: expect.objectContaining({ name: 'galleryUrl' }), + })); + expect(console.error).not.toHaveBeenCalled(); + }); + + it.each([new Error('Write denied'), 'Write denied'])('reports other variable failures and continues: %s', async (failure) => { + cms.create.mockImplementation(async ({ collection, data }: CreateArgs) => { + if (collection === 'template-variables' && data.name === 'clientName') throw failure; + return { id: data.name }; + }); + await runSeed(); + expect(console.error).toHaveBeenCalledWith('Failed to create variable clientName:', failure); + expect(cms.create).toHaveBeenLastCalledWith(expect.objectContaining({ + collection: 'template-variables', data: expect.objectContaining({ name: 'galleryUrl' }), + })); + }); +});