From abadbbd4c4d6fe56851bbd6b6fb5559002bb83fb Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 20 May 2026 18:11:14 +0500 Subject: [PATCH] fix: add retry to getPayment, normalize return, env validation, webhook/builder tests --- server/src/lib/__tests__/yookassa.test.js | 107 +++++++++++++++++++++- server/src/lib/yookassa.js | 25 +++-- 2 files changed, 122 insertions(+), 10 deletions(-) diff --git a/server/src/lib/__tests__/yookassa.test.js b/server/src/lib/__tests__/yookassa.test.js index ccedd24..26823bd 100644 --- a/server/src/lib/__tests__/yookassa.test.js +++ b/server/src/lib/__tests__/yookassa.test.js @@ -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() + }) +}) diff --git a/server/src/lib/yookassa.js b/server/src/lib/yookassa.js index c5e2b32..32185ab 100644 --- a/server/src/lib/yookassa.js +++ b/server/src/lib/yookassa.js @@ -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') {