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
119 changes: 119 additions & 0 deletions services/notification-service/tests/unit/payload/collections.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
return hook({ data } as Parameters<CollectionBeforeValidateHook>[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<typeof validator>[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<typeof audit>[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<CollectionBeforeChangeHook>[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<CollectionBeforeChangeHook>[0])).toBe(data);
});
});
Original file line number Diff line number Diff line change
@@ -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' });
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

interface CreateArgs {
collection: string;
data: Record<string, any>;
}

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' }),
}));
});
});
Loading