/**
* Unit tests for Contact API route
* These tests focus on testing the business logic without complex mocking
*/
import { POST } from './route'
import { getPayload } from 'payload'
const mockCreate = jest.fn()
// Mock the logger to avoid complex setup
jest.mock('@/lib/logger', () => ({
createComponentLogger: () => ({
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
})
}))
// WHY mock the resolved relative path instead of the '@payload-config' alias:
// next/jest's SWC transform rewrites the tsconfig path alias to a real
// relative specifier at transform time (Jest itself doesn't understand
// tsconfig `paths`), so a `jest.mock('@payload-config', ...)` never actually
// intercepts what route.ts ends up requiring — it silently falls through to
// the real proto/src/payload.config.ts (mongooseAdapter, Users/LegalPages/etc
// imports), which needs a live MongoDB and PAYLOAD_SECRET, exactly what these
// tests avoid. The route only ever passes this value through to the (also
// mocked) getPayload(), so its actual shape doesn't matter here.
jest.mock('../../../payload.config', () => ({ __esModule: true, default: {} }))
jest.mock('payload', () => ({
getPayload: jest.fn(async () => ({ create: mockCreate })),
}))
describe('/api/contact Unit Tests', () => {
beforeEach(() => {
mockCreate.mockReset()
;(getPayload as jest.Mock).mockClear()
})
describe('Payload persistence', () => {
const validData = {
name: 'Test User',
email: 'test@example.com',
subject: 'Test Subject',
message: 'This is a test message with enough content',
gdprConsent: true,
}
it('persists a valid submission before reporting success', async () => {
mockCreate.mockResolvedValue({ id: 'submission-123', createdAt: '2026-09-12T00:00:00.000Z' })
const request = {
headers: new Headers({ 'x-forwarded-for': 'persistence-success' }),
json: async () => validData,
} as any
const response = await POST(request)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.submissionId).toBe('submission-123')
expect(mockCreate).toHaveBeenCalledWith({
collection: 'contact-submissions',
data: expect.objectContaining({
...validData,
status: 'new',
}),
})
})
it('returns a server error when persistence fails', async () => {
mockCreate.mockRejectedValue(new Error('MongoDB unavailable'))
const request = {
headers: new Headers({ 'x-forwarded-for': 'persistence-failure' }),
json: async () => validData,
} as any
const response = await POST(request)
expect(response.status).toBe(500)
expect(mockCreate).toHaveBeenCalledTimes(1)
})
it('rejects an over-long message before persisting', async () => {
const longMessage = { ...validData, message: 'x'.repeat(5001) }
const request = {
headers: new Headers({ 'x-forwarded-for': 'too-long' }),
json: async () => longMessage,
} as any
const response = await POST(request)
expect(response.status).toBe(400)
expect(mockCreate).not.toHaveBeenCalled()
})
})
// TC-001: Email Format Validation Test (ZEE-48)
describe('Input validation logic', () => {
it('should validate required fields', () => {
const validData = {
name: 'Test User',
email: 'test@example.com',
subject: 'Test Subject',
message: 'This is a test message with enough content',
gdprConsent: true
}
// Test individual field validation logic
expect(validData.name.length).toBeGreaterThan(1)
expect(validData.email).toMatch(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)
expect(validData.subject.length).toBeGreaterThan(2)
expect(validData.message.length).toBeGreaterThan(9)
expect(validData.gdprConsent).toBe(true)
})
it('TC-001: should detect invalid email formats', () => {
const invalidEmails = [
'invalid-email',
'test@',
'@example.com',
'test.example.com',
''
]
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
invalidEmails.forEach(email => {
expect(email).not.toMatch(emailRegex)
})
})
it('should validate field lengths', () => {
const testCases = [
{ field: 'name', value: 'T', minLength: 2, valid: false },
{ field: 'name', value: 'Test User', minLength: 2, valid: true },
{ field: 'subject', value: 'Te', minLength: 3, valid: false },
{ field: 'subject', value: 'Test Subject', minLength: 3, valid: true },
{ field: 'message', value: 'Short', minLength: 10, valid: false },
{ field: 'message', value: 'This is a longer message', minLength: 10, valid: true }
]
testCases.forEach(testCase => {
const isValid = testCase.value.length >= testCase.minLength
expect(isValid).toBe(testCase.valid)
})
})
})
describe('Spam detection logic', () => {
it('should detect spam keywords', () => {
const spamKeywords = [
'free money', 'click here', 'buy now', 'urgent business',
'viagra', 'cheap', 'limited time', 'act now'
]
const spamText = 'FREE MONEY CLICK HERE NOW BUY VIAGRA CHEAP URGENT BUSINESS PROPOSAL'
let spamCount = 0
spamKeywords.forEach(keyword => {
if (spamText.toLowerCase().includes(keyword.toLowerCase())) {
spamCount++
}
})
// Should detect multiple spam keywords
expect(spamCount).toBeGreaterThan(3)
})
it('should allow legitimate business content', () => {
const legitimateText = 'Hello, I would like to buy your web hosting service. Can you provide more information about your business offerings?'
const spamKeywords = [
'free money', 'click here now', 'urgent business proposal',
'viagra', 'cheap pills', 'limited time offer'
]
let spamCount = 0
spamKeywords.forEach(keyword => {
if (legitimateText.toLowerCase().includes(keyword.toLowerCase())) {
spamCount++
}
})
// Should not trigger spam detection
expect(spamCount).toBeLessThan(2)
})
})
describe('Input sanitization logic', () => {
it('should handle potentially dangerous characters', () => {
const dangerousInput = 'Test User'
// Simple sanitization check - removing script tags
const sanitized = dangerousInput.replace(/