cms.c2sgmbh/tests/int/email.int.spec.ts
Martin Porwoll 47d4825f77 revert: downgrade to Next.js 15.5.9 for Payload compatibility
Payload CMS 3.68.4 doesn't officially support Next.js 16 yet.
Reverting to 15.5.9 restores full compatibility.

Changes:
- Revert next: 16.0.10 → 15.5.9
- Revert eslint-config-next: 16.0.10 → 15.5.9
- Revert proxy.ts → middleware.ts (Next.js 15 convention)
- Restore eslint config in next.config.mjs
- Remove turbopack config (not needed for Next.js 15)

Test fixes (TypeScript errors):
- Fix MockPayloadRequest interface (remove PayloadRequest extension)
- Add Where type imports to access control tests
- Fix headers type casting in rate-limiter tests
- Fix localization type guard in i18n tests
- Add type property to post creation in search tests
- Fix nodemailer mock typing in email tests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 10:07:39 +00:00

231 lines
6.3 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import type { Payload } from 'payload'
import type { Tenant } from '@/payload-types'
const mockSendMail = vi.fn(async () => ({ messageId: 'mocked-id' }))
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const mockCreateTransport = vi.fn((_options?: unknown) => ({ sendMail: mockSendMail }))
vi.mock('nodemailer', () => ({
__esModule: true,
default: {
createTransport: (options: unknown) => mockCreateTransport(options),
},
}))
import {
sendTenantEmail,
invalidateTenantEmailCache,
invalidateGlobalEmailCache,
} from '@/lib/email/tenant-email-service'
describe('tenant email service', () => {
let payload: Payload
let mockFindByID: ReturnType<typeof vi.fn>
let mockCreate: ReturnType<typeof vi.fn>
beforeEach(() => {
mockSendMail.mockClear()
mockCreateTransport.mockClear()
mockFindByID = vi.fn()
mockCreate = vi.fn().mockResolvedValue({ id: 1 })
payload = {
findByID: mockFindByID,
create: mockCreate,
update: vi.fn().mockResolvedValue({}),
} as unknown as Payload
process.env.SMTP_HOST = 'smtp.global.test'
process.env.SMTP_PORT = '587'
process.env.SMTP_SECURE = 'false'
process.env.SMTP_USER = 'global-user'
process.env.SMTP_PASS = 'global-pass'
process.env.SMTP_FROM_ADDRESS = 'noreply@example.com'
invalidateGlobalEmailCache()
})
afterEach(() => {
// Restore NODE_ENV after each test
delete process.env.EMAIL_DELIVERY_DISABLED
})
describe('with EMAIL_DELIVERY_DISABLED=false (production mode)', () => {
beforeEach(() => {
// Override test environment to simulate production
vi.stubEnv('NODE_ENV', 'production')
vi.stubEnv('EMAIL_DELIVERY_DISABLED', 'false')
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('falls back to global SMTP configuration when tenant has no custom SMTP', async () => {
const tenant = {
id: 1,
slug: 'tenant-a',
name: 'Tenant A',
email: {
useCustomSmtp: false,
},
} as Tenant
mockFindByID.mockResolvedValue(tenant)
const result = await sendTenantEmail(payload, tenant.id, {
to: 'user@example.com',
subject: 'Test',
text: 'Hello from test',
})
expect(result.success).toBe(true)
expect(mockCreateTransport).toHaveBeenCalledTimes(1)
expect(mockCreateTransport).toHaveBeenCalledWith({
host: 'smtp.global.test',
port: 587,
secure: false,
auth: {
user: 'global-user',
pass: 'global-pass',
},
})
expect(mockSendMail).toHaveBeenCalledWith(
expect.objectContaining({
from: '"Tenant A" <noreply@example.com>',
to: 'user@example.com',
}),
)
})
it('uses cached tenant-specific transporter and re-creates it after invalidation', async () => {
const tenant = {
id: 42,
slug: 'tenant-b',
name: 'Tenant B',
email: {
useCustomSmtp: true,
fromAddress: 'info@tenant-b.de',
fromName: 'Tenant B',
smtp: {
host: 'smtp.tenant-b.de',
port: 465,
secure: true,
user: 'tenant-user',
pass: 'tenant-pass',
},
},
} as Tenant
mockFindByID.mockResolvedValue(tenant)
await sendTenantEmail(payload, tenant.id, {
to: 'recipient@example.com',
subject: 'Hi',
text: 'First email',
})
expect(mockCreateTransport).toHaveBeenCalledTimes(1)
expect(mockCreateTransport).toHaveBeenCalledWith({
host: 'smtp.tenant-b.de',
port: 465,
secure: true,
auth: {
user: 'tenant-user',
pass: 'tenant-pass',
},
})
mockCreateTransport.mockClear()
await sendTenantEmail(payload, tenant.id, {
to: 'recipient@example.com',
subject: 'Hi again',
text: 'Second email',
})
expect(mockCreateTransport).not.toHaveBeenCalled()
invalidateTenantEmailCache(tenant.id)
await sendTenantEmail(payload, tenant.id, {
to: 'recipient@example.com',
subject: 'After invalidation',
text: 'Third email',
})
expect(mockCreateTransport).toHaveBeenCalledTimes(1)
})
})
describe('with EMAIL_DELIVERY_DISABLED=true (test mode)', () => {
it('skips SMTP delivery and returns synthetic message ID', async () => {
// NODE_ENV=test is default in vitest, which disables email delivery
const tenant = {
id: 1,
slug: 'tenant-a',
name: 'Tenant A',
email: {
useCustomSmtp: false,
},
} as Tenant
mockFindByID.mockResolvedValue(tenant)
const result = await sendTenantEmail(payload, tenant.id, {
to: 'user@example.com',
subject: 'Test',
text: 'Hello from test',
})
expect(result.success).toBe(true)
expect(result.messageId).toMatch(/^test-message-\d+$/)
// SMTP should NOT be called in test mode
expect(mockCreateTransport).not.toHaveBeenCalled()
expect(mockSendMail).not.toHaveBeenCalled()
})
it('creates email log even when delivery is disabled', async () => {
const tenant = {
id: 1,
slug: 'tenant-a',
name: 'Tenant A',
email: {
useCustomSmtp: false,
},
} as Tenant
mockFindByID.mockResolvedValue(tenant)
await sendTenantEmail(payload, tenant.id, {
to: 'user@example.com',
subject: 'Test',
text: 'Hello from test',
})
// Email log should still be created
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
collection: 'email-logs',
data: expect.objectContaining({
to: 'user@example.com',
subject: 'Test',
status: 'pending',
}),
}),
)
})
})
describe('cache invalidation', () => {
it('invalidates tenant-specific cache', () => {
// This is a simple function that clears cache - just verify it doesn't throw
expect(() => invalidateTenantEmailCache(42)).not.toThrow()
})
it('invalidates global cache', () => {
expect(() => invalidateGlobalEmailCache()).not.toThrow()
})
})
})