- B1: Next.js 15 + Payload CMS 3.0 + Postgres 16, ESLint, Prettier, Husky, Vitest - B2: 9 collections, 6 globals, 12 Page Builder blocks, access control, slugify/revalidate hooks - B3: ezy.com.ua payments, Binotel HMAC webhook, leads API, Telegram bot, Resend email, rate limiting - B4: Tariffs collection with ezy API sync (cron + manual), dynamic pricing source-of-truth - B5: 13 test files covering unit libs and all API routes - B6: Dockerfile multi-stage, docker-compose.prod.yml, nginx.conf SSL, GitHub Actions CI/CD, health endpoint - B7: docs/admin-guide-ua.md (marketer guide), docs/deploy.md (VPS instructions), README quickstart Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
127 lines
4.1 KiB
TypeScript
127 lines
4.1 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { NextRequest } from 'next/server'
|
|
import { createHmac } from 'crypto'
|
|
|
|
const SECRET = 'test-hmac-secret' // matches vitest.setup.ts
|
|
|
|
const mockPayloadFind = vi.fn()
|
|
const mockPayloadUpdate = vi.fn()
|
|
const mockGetPayload = vi.fn(() => ({
|
|
find: mockPayloadFind,
|
|
update: mockPayloadUpdate,
|
|
}))
|
|
|
|
vi.mock('payload', () => ({ getPayload: mockGetPayload }))
|
|
vi.mock('@payload-config', () => ({ default: {} }))
|
|
|
|
function makeSignature(body: string): string {
|
|
return createHmac('sha256', SECRET).update(body).digest('hex')
|
|
}
|
|
|
|
function makeRequest(body: string, signature: string): NextRequest {
|
|
return new NextRequest('http://localhost/api/binotel/webhook', {
|
|
method: 'POST',
|
|
body,
|
|
headers: {
|
|
'content-type': 'text/plain',
|
|
'x-binotel-signature': signature,
|
|
},
|
|
})
|
|
}
|
|
|
|
let POST: (req: NextRequest) => Promise<Response>
|
|
|
|
beforeEach(async () => {
|
|
vi.resetModules()
|
|
mockPayloadFind.mockReset()
|
|
mockPayloadUpdate.mockReset()
|
|
vi.stubEnv('BINOTEL_HMAC_SECRET', SECRET)
|
|
const mod = await import('@/app/api/binotel/webhook/route')
|
|
POST = mod.POST
|
|
})
|
|
|
|
describe('POST /api/binotel/webhook', () => {
|
|
it('returns 401 for invalid HMAC signature', async () => {
|
|
const req = makeRequest('{"externalNumber":"0501234567"}', 'bad-signature')
|
|
const res = await POST(req)
|
|
expect(res.status).toBe(401)
|
|
})
|
|
|
|
it('returns 400 for valid signature but invalid JSON body', async () => {
|
|
const body = 'not json'
|
|
const req = makeRequest(body, makeSignature(body))
|
|
const res = await POST(req)
|
|
expect(res.status).toBe(400)
|
|
})
|
|
|
|
it('returns ok:true when externalNumber is missing from payload', async () => {
|
|
const body = JSON.stringify({ event: 'call' })
|
|
const req = makeRequest(body, makeSignature(body))
|
|
const res = await POST(req)
|
|
const json = await res.json()
|
|
expect(res.status).toBe(200)
|
|
expect(json.ok).toBe(true)
|
|
expect(mockPayloadFind).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns ok:true when externalNumber is not a string', async () => {
|
|
const body = JSON.stringify({ externalNumber: 12345 })
|
|
const req = makeRequest(body, makeSignature(body))
|
|
const res = await POST(req)
|
|
expect((await res.json()).ok).toBe(true)
|
|
})
|
|
|
|
it('returns ok:true when phone cannot be normalized', async () => {
|
|
const body = JSON.stringify({ externalNumber: 'INVALID' })
|
|
const req = makeRequest(body, makeSignature(body))
|
|
const res = await POST(req)
|
|
expect((await res.json()).ok).toBe(true)
|
|
expect(mockPayloadFind).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('updates lead lastCallAt when matching lead is found', async () => {
|
|
mockPayloadFind.mockResolvedValueOnce({ docs: [{ id: 'lead-42' }] })
|
|
mockPayloadUpdate.mockResolvedValueOnce({})
|
|
|
|
const body = JSON.stringify({ externalNumber: '0501234567' })
|
|
const req = makeRequest(body, makeSignature(body))
|
|
const res = await POST(req)
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(mockPayloadFind).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
collection: 'leads',
|
|
where: { phone: { equals: '+380501234567' } },
|
|
})
|
|
)
|
|
expect(mockPayloadUpdate).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
collection: 'leads',
|
|
id: 'lead-42',
|
|
data: expect.objectContaining({ lastCallAt: expect.any(String) }),
|
|
})
|
|
)
|
|
})
|
|
|
|
it('returns ok:true when no matching lead is found (no update)', async () => {
|
|
mockPayloadFind.mockResolvedValueOnce({ docs: [] })
|
|
|
|
const body = JSON.stringify({ externalNumber: '0501234567' })
|
|
const req = makeRequest(body, makeSignature(body))
|
|
const res = await POST(req)
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(mockPayloadUpdate).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns ok:true even when the DB call throws', async () => {
|
|
mockPayloadFind.mockRejectedValueOnce(new Error('DB down'))
|
|
|
|
const body = JSON.stringify({ externalNumber: '0501234567' })
|
|
const req = makeRequest(body, makeSignature(body))
|
|
const res = await POST(req)
|
|
|
|
expect(res.status).toBe(200)
|
|
expect((await res.json()).ok).toBe(true)
|
|
})
|
|
})
|