fix: add retry to getPayment, normalize return, env validation, webhook/builder tests
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPayment, getPayment } from '../yookassa.js'
|
||||
import { createPayment, getPayment, buildReceipt, validateWebhook } from '../yookassa.js'
|
||||
|
||||
describe('yookassa createPayment', () => {
|
||||
beforeEach(() => {
|
||||
@@ -157,8 +157,111 @@ describe('yookassa getPayment', () => {
|
||||
const result = await getPayment('payment-id')
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
expect(fetch.mock.calls[0][0]).toBe('https://api.yookassa.ru/v3/payments/payment-id')
|
||||
expect(result.id).toBe('payment-id')
|
||||
expect(result.paymentId).toBe('payment-id')
|
||||
expect(result.status).toBe('succeeded')
|
||||
expect(result.paid).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('yookassa buildReceipt', () => {
|
||||
it('builds receipt with order items', () => {
|
||||
const result = buildReceipt({
|
||||
orderItems: [
|
||||
{ titleSnapshot: 'Test Product', qty: 2, priceCentsSnapshot: 100000 },
|
||||
],
|
||||
deliveryFeeCents: 0,
|
||||
userEmail: 'user@test.ru',
|
||||
})
|
||||
|
||||
expect(result.customer.email).toBe('user@test.ru')
|
||||
expect(result.items).toHaveLength(1)
|
||||
expect(result.items[0].description).toBe('Test Product')
|
||||
expect(result.items[0].quantity).toBe(2)
|
||||
expect(result.items[0].amount.value).toBe('1000.00')
|
||||
expect(result.items[0].vat_code).toBe(1)
|
||||
expect(result.items[0].measure).toBe('piece')
|
||||
expect(result.items[0].payment_subject).toBe('commodity')
|
||||
expect(result.items[0].payment_mode).toBe('full_prepayment')
|
||||
expect(result.tax_system_code).toBe(1)
|
||||
})
|
||||
|
||||
it('adds delivery item when deliveryFeeCents > 0', () => {
|
||||
const result = buildReceipt({
|
||||
orderItems: [
|
||||
{ titleSnapshot: 'Item A', qty: 1, priceCentsSnapshot: 50000 },
|
||||
],
|
||||
deliveryFeeCents: 35000,
|
||||
userEmail: 'user@test.ru',
|
||||
})
|
||||
|
||||
expect(result.items).toHaveLength(2)
|
||||
expect(result.items[1].description).toBe('Доставка')
|
||||
expect(result.items[1].amount.value).toBe('350.00')
|
||||
expect(result.items[1].payment_subject).toBe('service')
|
||||
})
|
||||
|
||||
it('passes through taxSystemCode', () => {
|
||||
const result = buildReceipt({
|
||||
orderItems: [
|
||||
{ titleSnapshot: 'Item', qty: 1, priceCentsSnapshot: 1000 },
|
||||
],
|
||||
deliveryFeeCents: 0,
|
||||
userEmail: 'user@test.ru',
|
||||
taxSystemCode: 3,
|
||||
})
|
||||
|
||||
expect(result.tax_system_code).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('yookassa validateWebhook', () => {
|
||||
beforeEach(() => {
|
||||
process.env.YOOKASSA_SECRET_KEY = 'test_secret'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.YOOKASSA_SECRET_KEY
|
||||
})
|
||||
|
||||
it('returns event and paymentObject for valid notification', () => {
|
||||
const body = {
|
||||
type: 'notification',
|
||||
event: 'payment.succeeded',
|
||||
object: { id: 'yk-id', status: 'succeeded', paid: true },
|
||||
}
|
||||
const result = validateWebhook('127.0.0.1', body)
|
||||
expect(result.event).toBe('payment.succeeded')
|
||||
expect(result.paymentObject.id).toBe('yk-id')
|
||||
})
|
||||
|
||||
it('throws if type is not notification', () => {
|
||||
expect(() => validateWebhook('127.0.0.1', { type: 'other', event: 'x', object: {} })).toThrow(
|
||||
'Expected notification type',
|
||||
)
|
||||
})
|
||||
|
||||
it('throws if missing event', () => {
|
||||
expect(() => validateWebhook('127.0.0.1', { type: 'notification', object: {} })).toThrow(
|
||||
'Missing event or object',
|
||||
)
|
||||
})
|
||||
|
||||
it('throws if missing object', () => {
|
||||
expect(() => validateWebhook('127.0.0.1', { type: 'notification', event: 'x' })).toThrow(
|
||||
'Missing event or object',
|
||||
)
|
||||
})
|
||||
|
||||
it('throws for invalid body type', () => {
|
||||
expect(() => validateWebhook('127.0.0.1', 'not an object')).toThrow('Invalid webhook body')
|
||||
})
|
||||
|
||||
it('throws for null body', () => {
|
||||
expect(() => validateWebhook('127.0.0.1', null)).toThrow('Invalid webhook body')
|
||||
})
|
||||
|
||||
it('skips IP validation in test mode (test_ key)', () => {
|
||||
const body = { type: 'notification', event: 'payment.succeeded', object: {} }
|
||||
expect(() => validateWebhook('1.2.3.4', body)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,9 @@ const YOOKASSA_API_URL = 'https://api.yookassa.ru/v3'
|
||||
function getAuthHeader() {
|
||||
const shopId = process.env.YOOKASSA_SHOP_ID
|
||||
const secretKey = process.env.YOOKASSA_SECRET_KEY
|
||||
if (!shopId || !secretKey) {
|
||||
throw new Error('YOOKASSA_SHOP_ID and YOOKASSA_SECRET_KEY are required')
|
||||
}
|
||||
const token = Buffer.from(`${shopId}:${secretKey}`).toString('base64')
|
||||
return `Basic ${token}`
|
||||
}
|
||||
@@ -31,7 +34,7 @@ async function fetchWithRetry(url, opts, maxRetries = 3) {
|
||||
)
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.startsWith('YooKassa API error')) throw err
|
||||
lastError = err
|
||||
lastError = new Error(`YooKassa API error: network failure — ${err instanceof Error ? err.message : String(err)}`)
|
||||
if (attempt === maxRetries) throw lastError
|
||||
}
|
||||
}
|
||||
@@ -86,14 +89,18 @@ export async function createPayment({
|
||||
}
|
||||
|
||||
export async function getPayment(paymentId) {
|
||||
const res = await fetch(`${YOOKASSA_API_URL}/payments/${paymentId}`, {
|
||||
const res = await fetchWithRetry(`${YOOKASSA_API_URL}/payments/${paymentId}`, {
|
||||
headers: { Authorization: getAuthHeader() },
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(`YooKassa getPayment error: ${res.status} — ${body.description || 'unknown'}`)
|
||||
const data = await res.json()
|
||||
return {
|
||||
paymentId: data.id,
|
||||
status: data.status,
|
||||
confirmationUrl: data.confirmation?.confirmation_url || null,
|
||||
expiresAt: data.expires_at || null,
|
||||
paid: data.paid,
|
||||
test: data.test,
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
const YOOKASSA_IP_RANGES_V4 = ['185.71.76.0/27', '185.71.77.0/27', '77.75.153.0/25', '77.75.154.128/25']
|
||||
@@ -116,10 +123,12 @@ function isYookassaIp(ip) {
|
||||
return YOOKASSA_IP_RANGES_V4.some((cidr) => cidrMatch(v4, cidr))
|
||||
}
|
||||
|
||||
const TEST_MODE = process.env.YOOKASSA_SECRET_KEY?.startsWith('test_') ?? false
|
||||
function isTestMode() {
|
||||
return (process.env.YOOKASSA_SECRET_KEY?.startsWith('test_')) ?? false
|
||||
}
|
||||
|
||||
export function validateWebhook(ip, body) {
|
||||
if (!TEST_MODE && !isYookassaIp(ip)) {
|
||||
if (!isTestMode() && !isYookassaIp(ip)) {
|
||||
throw new Error('Invalid webhook source IP')
|
||||
}
|
||||
if (!body || typeof body !== 'object') {
|
||||
|
||||
Reference in New Issue
Block a user