import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; const mockStorage = vi.hoisted(() => ({ getUserWithOrganization: vi.fn(), })); // In-memory fake payments.service: воспроизводит контракт настоящего сервиса, // включая идемпотентность зачисления (pending→succeeded только один раз). const fake = vi.hoisted(() => { const state = { payments: new Map(), nextId: 1, credits: [] as Array<{ organizationId: number; amount: string }>, }; return { state }; }); vi.mock('../server/db', () => ({ db: { // Цепочка для loadPermissionCache в auth.middleware (requirePermission). select: () => ({ from: () => ({ innerJoin: () => ({ innerJoin: () => Promise.resolve([{ appRoleSlug: 'admin', permissionCode: 'billing.manage' }]), }), }), }), // Заглушка для trackUserActivity (best-effort обновление активности). update: () => ({ set: () => ({ where: () => Promise.resolve(), }), }), }, pool: { query: vi.fn().mockResolvedValue({ rows: [] }) }, withTenant: (_orgId: number, fn: () => unknown) => fn(), openTenantCtx: vi.fn().mockResolvedValue({ run: (fn: () => void) => fn(), release: vi.fn(), }), openSuperAdminCtx: vi.fn().mockResolvedValue(undefined), _tenantCtx: { getStore: vi.fn().mockReturnValue(null) }, })); vi.mock('../server/storage', () => ({ storage: mockStorage })); vi.mock('../server/services/notification.service', () => ({ notificationService: { emit: vi.fn(), on: vi.fn(), sendNotification: vi.fn().mockResolvedValue(undefined), processEvent: vi.fn().mockResolvedValue(undefined), }, EVENT_TYPES: {}, })); vi.mock('../server/services/webhook.service', () => ({ webhookService: { dispatchEvent: vi.fn().mockResolvedValue(undefined), processWebhook: vi.fn().mockResolvedValue(undefined), }, })); vi.mock('../server/utils/webhook', () => ({ sendWebhook: vi.fn().mockResolvedValue(undefined), })); vi.mock('../server/utils/s3', () => ({ isS3Enabled: false, ensureS3Bucket: vi.fn().mockResolvedValue(undefined), streamFromS3: vi.fn().mockResolvedValue(null), deleteFromS3: vi.fn().mockResolvedValue(undefined), })); vi.mock('../server/utils/audit', () => ({ logAudit: vi.fn().mockResolvedValue(undefined), getClientIp: vi.fn().mockReturnValue('127.0.0.1'), })); vi.mock('../server/billing/payments.service', () => ({ createBillingPayment: vi.fn(async (p: any) => { for (const row of fake.state.payments.values()) { if (row.idempotencyKey === p.idempotencyKey) return row; } const row = { id: fake.state.nextId++, status: 'pending', ...p, amount: p.amount.toFixed(2), metadata: null, createdAt: new Date(), updatedAt: new Date(), }; fake.state.payments.set(row.id, row); return row; }), listBillingPayments: vi.fn(async (orgId: number) => [...fake.state.payments.values()].filter((p) => p.organizationId === orgId)), getBillingPaymentById: vi.fn(async (id: number, orgId?: number) => { const p = fake.state.payments.get(id) ?? null; return p && (orgId === undefined || p.organizationId === orgId) ? p : null; }), getBillingPaymentByExternalId: vi.fn(async (externalId: string) => [...fake.state.payments.values()].find((p) => p.externalId === externalId) ?? null), markBillingPaymentCanceled: vi.fn(async (id: number) => { const p = fake.state.payments.get(id) ?? null; if (p && p.status === 'pending') p.status = 'canceled'; return p; }), applySucceededPayment: vi.fn(async (id: number) => { const p = fake.state.payments.get(id) ?? null; if (!p || p.status !== 'pending') return { applied: false, payment: p }; p.status = 'succeeded'; fake.state.credits.push({ organizationId: p.organizationId, amount: p.amount }); return { applied: true, payment: p }; }), })); import express from 'express'; import request from 'supertest'; import jwt from 'jsonwebtoken'; import billingPaymentsRouter from '../server/routes/billing-payments.routes'; import { signMockWebhook } from '../server/billing/mock-provider'; const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET as string; function makeValidToken(userId: number, organizationId: number): string { return jwt.sign( { userId, organizationId, role: 'user' }, ACCESS_SECRET, { issuer: 'workflow-system', audience: 'workflow-users', expiresIn: '1h' }, ); } function buildApp() { const app = express(); app.use(express.json()); app.use(billingPaymentsRouter); return app; } const TEST_USER = { id: 42, email: 'admin@example.com', firstName: 'Admin', lastName: 'User', appRole: 'admin', role: 'user', isActive: true, organizationId: 7, organization: { id: 7, isActive: true, billingBlocked: true }, }; const app = buildApp(); function authHeader(): [string, string] { return ['Authorization', `Bearer ${makeValidToken(TEST_USER.id, TEST_USER.organizationId)}`]; } async function createPayment(amount = 1500): Promise { const res = await request(app) .post('/api/billing/payments') .set(...authHeader()) .send({ amount }); expect(res.status).toBe(201); return res.body.payment; } beforeEach(() => { fake.state.payments.clear(); fake.state.nextId = 1; fake.state.credits.length = 0; mockStorage.getUserWithOrganization.mockResolvedValue(TEST_USER); delete process.env.PAYMENT_PROVIDER; }); afterEach(() => { delete process.env.PAYMENT_PROVIDER; }); describe('POST /api/billing/payments', () => { it('создаёт платёж в статусе pending (201)', async () => { const res = await request(app) .post('/api/billing/payments') .set(...authHeader()) .send({ amount: 1500 }); expect(res.status).toBe(201); expect(res.body.success).toBe(true); expect(res.body.payment).toMatchObject({ organizationId: TEST_USER.organizationId, provider: 'mock', status: 'pending', amount: '1500.00', currency: 'RUB', }); expect(res.body.payment.externalId).toMatch(/^mock_/); }); it('возвращает 401 без токена', async () => { const res = await request(app).post('/api/billing/payments').send({ amount: 100 }); expect(res.status).toBe(401); }); it('возвращает 400 при некорректной сумме', async () => { const res = await request(app) .post('/api/billing/payments') .set(...authHeader()) .send({ amount: -50 }); expect(res.status).toBe(400); }); it('повторный запрос с тем же Idempotency-Key возвращает тот же платёж', async () => { const first = await request(app) .post('/api/billing/payments') .set(...authHeader()) .set('Idempotency-Key', 'topup-abc-1') .send({ amount: 500 }); const second = await request(app) .post('/api/billing/payments') .set(...authHeader()) .set('Idempotency-Key', 'topup-abc-1') .send({ amount: 500 }); expect(first.status).toBe(201); expect(second.status).toBe(201); expect(second.body.payment.id).toBe(first.body.payment.id); expect(fake.state.payments.size).toBe(1); }); }); describe('GET /api/billing/payments', () => { it('возвращает платежи только своей организации', async () => { await createPayment(100); await createPayment(200); const res = await request(app) .get('/api/billing/payments') .set(...authHeader()); expect(res.status).toBe(200); expect(res.body.payments).toHaveLength(2); }); }); describe('POST /api/billing/payments/:id/confirm-test', () => { it('подтверждает платёж и зачисляет средства (снятие блокировки — внутри зачисления)', async () => { const payment = await createPayment(1500); const res = await request(app) .post(`/api/billing/payments/${payment.id}/confirm-test`) .set(...authHeader()); expect(res.status).toBe(200); expect(res.body.applied).toBe(true); expect(res.body.payment.status).toBe('succeeded'); expect(fake.state.credits).toEqual([ { organizationId: TEST_USER.organizationId, amount: '1500.00' }, ]); }); it('возвращает 404 для платежа чужой организации', async () => { const payment = await createPayment(100); const otherToken = makeValidToken(55, 999); mockStorage.getUserWithOrganization.mockResolvedValue({ ...TEST_USER, id: 55, organizationId: 999, organization: { id: 999, isActive: true, billingBlocked: false }, }); const res = await request(app) .post(`/api/billing/payments/${payment.id}/confirm-test`) .set('Authorization', `Bearer ${otherToken}`); expect(res.status).toBe(404); expect(fake.state.credits).toHaveLength(0); }); it('возвращает 404 при не-mock провайдере', async () => { const payment = await createPayment(100); process.env.PAYMENT_PROVIDER = 'yookassa'; const res = await request(app) .post(`/api/billing/payments/${payment.id}/confirm-test`) .set(...authHeader()); expect(res.status).toBe(404); expect(fake.state.credits).toHaveLength(0); }); }); describe('POST /api/billing/webhooks/:provider', () => { it('webhook с валидной подписью зачисляет платёж', async () => { const payment = await createPayment(1500); const res = await request(app) .post('/api/billing/webhooks/mock') .set('x-mock-signature', signMockWebhook(payment.externalId)) .send({ externalId: payment.externalId, status: 'succeeded' }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(fake.state.payments.get(payment.id)?.status).toBe('succeeded'); expect(fake.state.credits).toEqual([ { organizationId: TEST_USER.organizationId, amount: '1500.00' }, ]); }); it('повторный webhook не зачисляет дважды (идемпотентность)', async () => { const payment = await createPayment(1500); const signature = signMockWebhook(payment.externalId); const body = { externalId: payment.externalId, status: 'succeeded' }; const first = await request(app) .post('/api/billing/webhooks/mock') .set('x-mock-signature', signature) .send(body); const second = await request(app) .post('/api/billing/webhooks/mock') .set('x-mock-signature', signature) .send(body); expect(first.status).toBe(200); expect(second.status).toBe(200); expect(fake.state.credits).toHaveLength(1); }); it('webhook с невалидной подписью отклоняется (401)', async () => { const payment = await createPayment(1500); const res = await request(app) .post('/api/billing/webhooks/mock') .set('x-mock-signature', 'deadbeef'.repeat(8)) .send({ externalId: payment.externalId, status: 'succeeded' }); expect(res.status).toBe(401); expect(fake.state.payments.get(payment.id)?.status).toBe('pending'); expect(fake.state.credits).toHaveLength(0); }); it('webhook без подписи отклоняется (401)', async () => { const payment = await createPayment(1500); const res = await request(app) .post('/api/billing/webhooks/mock') .send({ externalId: payment.externalId, status: 'succeeded' }); expect(res.status).toBe(401); expect(fake.state.credits).toHaveLength(0); }); it('webhook для неизвестного провайдера возвращает 404', async () => { const res = await request(app) .post('/api/billing/webhooks/unknown-provider') .send({ externalId: 'mock_whatever', status: 'succeeded' }); expect(res.status).toBe(404); }); });