Files
websitedev/proto/src/app/api/contact/route.unit.test.ts
T
Do SikiandClaude Sonnet 5 d2f960207e
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Canceled after 0s
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Canceled after 0s
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Canceled after 0s
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Canceled after 0s
feat(cms): ContactSubmissions Payload collection (MITHOME-94)
/api/contact used to write straight to a raw, Payload-external MongoDB
collection (contact_submissions, via proto/src/lib/mongodb.ts's
getCollection) — the client had no way to see incoming messages except
by reading the database directly. Rate limiting, length caps, email
validation, and spam-keyword filtering all stay on the route exactly
as before; only the persistence target changed.

New: src/collections/ContactSubmissions.ts (name, email, subject,
message, gdprConsent checkbox, status select defaulting to "new").
Deliberately no custom `access` block — Payload's default
(authenticated-only for every REST operation) is exactly right here:
the client reads submissions in the admin, nobody can read or write
them through the public REST API, and the route's own write uses the
Local API (payload.create), which runs with overrideAccess: true by
default and so isn't blocked by that same rule. No versions/drafts
(a submission is a fact, not editable content) and no field-level
length/format validation duplicated in the collection, matching the
ticket's explicit scope: those checks live on the route.

route.ts: replaced getCollection()/insertOne() with
getPayload({config}).create({ collection: 'contact-submissions', ... }).
Removed the now-unused getCollection() helper from lib/mongodb.ts
(checkMongoConnection/getDb stay, used by /api/health) and its test.

Test gotcha worth documenting: next/jest's SWC transform rewrites the
`@payload-config` tsconfig-path alias to a real relative specifier at
transform time, so `jest.mock('@payload-config', ...)` never actually
intercepts what route.ts requires — it silently falls through to the
real payload.config.ts (mongooseAdapter, live Mongo needed). Fixed by
mocking the resolved relative path instead
(`jest.mock('../../../payload.config', ...)`); documented inline in
route.unit.test.ts for whoever hits this next (MITHOME-96 will need
the same trick for other Payload-backed routes/collections).

Verified live end-to-end, not just the test suite: submitted the real
contact form on /hu/kapcsolat, got the success message, found the
submission in /admin/collections/contact-submissions with all fields
correct (including gdprConsent checked and status "Új"/New), then
deleted the test record. Zero console errors in a fresh tab. Gate:
tsc, lint, unit tests (50 passed — one fewer than before, the removed
getCollection test), production build all green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 01:25:05 +02:00

301 lines
9.9 KiB
TypeScript
Executable File

/**
* 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 = '<script>alert("xss")</script>Test User'
// Simple sanitization check - removing script tags
const sanitized = dangerousInput.replace(/<script[^>]*>.*?<\/script>/gi, '')
expect(sanitized).toBe('Test User')
expect(sanitized).not.toContain('<script>')
})
it('should preserve safe HTML entities', () => {
const inputWithEntities = 'Test & Company "Quotes" and \'apostrophes\''
// Should preserve normal business text
expect(inputWithEntities.length).toBeGreaterThan(0)
expect(inputWithEntities).toContain('&')
expect(inputWithEntities).toContain('"')
expect(inputWithEntities).toContain("'")
})
})
describe('Rate limiting logic', () => {
it('should implement rate limiting concept', () => {
// Simple rate limiting simulation
const requests = []
const timeWindow = 60000 // 1 minute
const maxRequests = 3
// Simulate requests
const now = Date.now()
requests.push(now)
requests.push(now + 1000)
requests.push(now + 2000)
requests.push(now + 3000) // This should be rate limited
// Filter requests within time window
const recentRequests = requests.filter(time =>
(now + 3000) - time < timeWindow
)
expect(recentRequests.length).toBe(4)
expect(recentRequests.length > maxRequests).toBe(true)
})
})
describe('Response format validation', () => {
it('should validate success response structure', () => {
const successResponse = {
message: 'Üzenet sikeresen elküldve!',
timestamp: new Date().toISOString()
}
expect(successResponse).toHaveProperty('message')
expect(successResponse).toHaveProperty('timestamp')
expect(typeof successResponse.message).toBe('string')
expect(typeof successResponse.timestamp).toBe('string')
expect(() => new Date(successResponse.timestamp)).not.toThrow()
})
it('should validate error response structure', () => {
const errorResponse = {
error: 'Validációs hiba: hiányzó mezők'
}
expect(errorResponse).toHaveProperty('error')
expect(typeof errorResponse.error).toBe('string')
expect(errorResponse.error.length).toBeGreaterThan(0)
})
})
describe('Business logic helpers', () => {
it('should validate GDPR consent requirement', () => {
const testCases = [
{ gdprConsent: true, valid: true },
{ gdprConsent: false, valid: false },
{ gdprConsent: undefined, valid: false },
{ gdprConsent: null, valid: false }
]
testCases.forEach(testCase => {
const isValid = testCase.gdprConsent === true
expect(isValid).toBe(testCase.valid)
})
})
it('should generate proper timestamps', () => {
const timestamp = new Date().toISOString()
expect(timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/)
expect(() => new Date(timestamp)).not.toThrow()
const parsedDate = new Date(timestamp)
expect(parsedDate.getTime()).toBeCloseTo(Date.now(), -3) // Within 1 second
})
it('should handle IP address extraction logic', () => {
// Simulate IP extraction from headers
const mockHeaders = {
'x-forwarded-for': '192.168.1.100, 10.0.0.1',
'x-real-ip': '192.168.1.100',
'remote-addr': '127.0.0.1'
}
// Extract first IP from x-forwarded-for
const forwardedFor = mockHeaders['x-forwarded-for']
const clientIp = forwardedFor ? forwardedFor.split(',')[0].trim() : mockHeaders['x-real-ip']
expect(clientIp).toBe('192.168.1.100')
})
})
})