Шаг 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)
361 lines
12 KiB
TypeScript
361 lines
12 KiB
TypeScript
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);
|
||
});
|
||
});
|