import Fastify from 'fastify' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { NOTIFICATION_EVENTS } from '../../../../shared/constants/notification-events.js' const { mockPrisma } = vi.hoisted(() => ({ mockPrisma: { payment: { findFirst: vi.fn(), update: vi.fn() }, order: { findFirst: vi.fn(), updateMany: vi.fn() }, }, })) vi.mock('../../lib/prisma.js', () => ({ prisma: mockPrisma, })) vi.mock('../../lib/yookassa.js', () => ({ validateWebhook: vi.fn(), })) import { validateWebhook } from '../../lib/yookassa.js' import { registerYookassaWebhookRoute } from '../webhook-yookassa.js' function buildApp(eventBusMock) { const app = Fastify({ logger: false }) app.decorate('eventBus', eventBusMock || { emit: () => {} }) return app } describe('POST /api/webhooks/yookassa', () => { let app let eventBus beforeEach(async () => { eventBus = { emit: vi.fn() } validateWebhook.mockImplementation((_ip, body) => { if (!body || typeof body !== 'object') throw new Error('Invalid webhook body') if (body.type !== 'notification') throw new Error('Expected notification type in webhook body') if (!body.event || !body.object) throw new Error('Missing event or object in webhook body') return { event: body.event, paymentObject: body.object } }) app = buildApp(eventBus) await registerYookassaWebhookRoute(app) await app.ready() }) afterEach(async () => { await app.close() vi.clearAllMocks() }) it('returns 400 for invalid body', async () => { const res = await app.inject({ method: 'POST', url: '/api/webhooks/yookassa', payload: { not: 'valid' }, }) expect(res.statusCode).toBe(400) }) it('returns 404 when payment not found', async () => { mockPrisma.payment.findFirst.mockResolvedValue(null) const res = await app.inject({ method: 'POST', url: '/api/webhooks/yookassa', payload: { type: 'notification', event: 'payment.succeeded', object: { id: 'unknown-id', status: 'succeeded', paid: true }, }, }) expect(res.statusCode).toBe(404) }) it('updates payment and order on payment.succeeded', async () => { mockPrisma.payment.findFirst.mockResolvedValue({ id: 'payment-1', yookassaPaymentId: 'yk-id', status: 'pending', orderId: 'order-1', }) mockPrisma.payment.update.mockResolvedValue({}) mockPrisma.order.findFirst.mockResolvedValue({ id: 'order-1', status: 'PENDING_PAYMENT', userId: 'user-1', }) mockPrisma.order.updateMany.mockResolvedValue({ count: 1 }) const res = await app.inject({ method: 'POST', url: '/api/webhooks/yookassa', payload: { type: 'notification', event: 'payment.succeeded', object: { id: 'yk-id', status: 'succeeded', paid: true }, }, }) expect(res.statusCode).toBe(200) const updateData = mockPrisma.payment.update.mock.calls[0][0].data expect(updateData.status).toBe('succeeded') const orderUpdateData = mockPrisma.order.updateMany.mock.calls[0][0].data expect(orderUpdateData.status).toBe('PAID') expect(eventBus.emit).toHaveBeenCalledWith(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, { orderId: 'order-1', userId: 'user-1', paymentStatus: 'paid', }) }) it('updates payment on payment.canceled without changing order', async () => { mockPrisma.payment.findFirst.mockResolvedValue({ id: 'payment-1', yookassaPaymentId: 'yk-id', status: 'pending', orderId: 'order-1', }) mockPrisma.payment.update.mockResolvedValue({}) const res = await app.inject({ method: 'POST', url: '/api/webhooks/yookassa', payload: { type: 'notification', event: 'payment.canceled', object: { id: 'yk-id', status: 'canceled', paid: false }, }, }) expect(res.statusCode).toBe(200) expect(mockPrisma.order.findFirst).not.toHaveBeenCalled() }) })