Files
iistwin/tests/billing-payments.test.ts
Ильяс Султанов 701af06290 feat(billing): PaymentProvider + Mock, self-service пополнение баланса
Шаг 0.13 плана production-готовности:
- интерфейс PaymentProvider (createPayment/verifyWebhook/getPaymentStatus)
- MockPaymentProvider с настоящим webhook (sha256-подпись, идемпотентность)
- applySucceededPayment: транзакция с guard, credit, авто-снятие billingBlocked
- роуты payments + confirm-test (только mock) + публичный webhook
- Billing.tsx: Пополнить/Подтвердить (тест), фикс setLocation в рендере
- миграция 0080 billing_payments, 13 новых тестов (78/78)
2026-09-07 22:45:30 +03:00

361 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<number, any>(),
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<any> {
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);
});
});