Files
insforge--insforge/backend/tests/unit/razorpay-config.service.test.ts
wehub-resource-sync 3a28426bf4
Lint and Format Check / lint-and-format (push) Failing after 0s
Check Migrations / Check for duplicate migration numbers (push) Failing after 1s
CI Pre-merge Check / CI Pre-merge Check (push) Failing after 2m17s
chore: import upstream snapshot with attribution
2026-07-13 12:23:40 +08:00

252 lines
8.1 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
const {
mockPool,
mockClient,
mockProvider,
mockGetSecretByKey,
mockEncrypt,
mockWithPaymentSessionAdvisoryLock,
} = vi.hoisted(() => ({
mockPool: {
query: vi.fn(),
connect: vi.fn(),
},
mockClient: {
query: vi.fn(),
release: vi.fn(),
},
mockProvider: {
retrieveAccount: vi.fn(),
},
mockGetSecretByKey: vi.fn(),
mockEncrypt: vi.fn(),
mockWithPaymentSessionAdvisoryLock: vi.fn(),
}));
vi.mock('../../src/infra/database/database.manager', () => ({
DatabaseManager: {
getInstance: () => ({
getPool: () => mockPool,
}),
},
}));
vi.mock('../../src/services/payments/payments-advisory-lock', () => ({
withPaymentSessionAdvisoryLock: mockWithPaymentSessionAdvisoryLock,
}));
vi.mock('../../src/providers/payments/razorpay.provider', () => ({
RazorpayProvider: vi.fn(function () {
return mockProvider;
}),
validateRazorpayKey: (environment: 'test' | 'live', value: string) => {
const prefix = environment === 'test' ? 'rzp_test_' : 'rzp_live_';
if (!value.startsWith(prefix)) {
throw new Error(`Razorpay key ID must start with ${prefix}`);
}
},
maskRazorpayKey: (key: string) => `masked:${key.slice(-4)}`,
}));
vi.mock('../../src/services/secrets/secret.service', () => ({
SecretService: {
getInstance: () => ({
getSecretByKey: mockGetSecretByKey,
}),
},
}));
vi.mock('../../src/infra/security/encryption.manager', () => ({
EncryptionManager: {
encrypt: mockEncrypt,
},
}));
vi.mock('../../src/utils/logger', () => ({
default: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
import { RazorpayConfigService } from '../../src/services/payments/razorpay/config.service';
function expectRazorpayScopedDelete(tableName: string, params: unknown[]) {
expect(mockClient.query).toHaveBeenCalledWith(
expect.stringMatching(
new RegExp(`DELETE FROM payments\\.${tableName}[\\s\\S]*provider\\s*=\\s*\\$2`, 'i')
),
params
);
}
function expectRazorpayRuntimeDelete(tableName: string, params: unknown[]) {
expect(mockClient.query).toHaveBeenCalledWith(
expect.stringMatching(new RegExp(`DELETE FROM payments\\.${tableName}`, 'i')),
params
);
}
function expectRazorpayTransactionDelete() {
expect(mockClient.query).toHaveBeenCalledWith(
expect.stringMatching(/DELETE FROM payments\.transactions[\s\S]*provider\s*=\s*\$1/i),
['razorpay', 'test']
);
}
describe('RazorpayConfigService', () => {
beforeEach(() => {
vi.clearAllMocks();
delete process.env.API_BASE_URL;
mockPool.query.mockResolvedValue({ rows: [], rowCount: 0 });
mockPool.connect.mockResolvedValue(mockClient);
mockClient.query.mockResolvedValue({ rows: [], rowCount: 0 });
mockProvider.retrieveAccount.mockResolvedValue({
id: 'rzp_test_new',
merchantName: 'New Merchant',
livemode: false,
});
mockEncrypt.mockImplementation((value: string) => `encrypted:${value}`);
mockWithPaymentSessionAdvisoryLock.mockImplementation(
async (_pool: unknown, _lockName: string, task: () => Promise<unknown>) => task()
);
});
it('clears stale Razorpay data and syncs after an account key change', async () => {
mockGetSecretByKey.mockImplementation(async (key: string) => {
if (key === 'RAZORPAY_TEST_KEY_ID') {
return 'rzp_test_old';
}
if (key === 'RAZORPAY_TEST_KEY_SECRET') {
return 'old_secret';
}
return null;
});
mockPool.query.mockResolvedValueOnce({ rows: [{ accountId: 'rzp_test_old' }], rowCount: 1 });
const syncAfterKeyChange = vi.fn().mockResolvedValue(undefined);
await RazorpayConfigService.getInstance().setRazorpayKeys(
'test',
'rzp_test_new',
'new_secret',
undefined,
syncAfterKeyChange
);
expect(mockWithPaymentSessionAdvisoryLock).toHaveBeenCalledWith(
mockPool,
'payments_razorpay_environment_test',
expect.any(Function)
);
expectRazorpayRuntimeDelete('razorpay_subscriptions', ['test']);
expectRazorpayTransactionDelete();
expectRazorpayRuntimeDelete('razorpay_orders', ['test']);
expectRazorpayScopedDelete('customers', ['test', 'razorpay']);
expectRazorpayScopedDelete('customer_mappings', ['test', 'razorpay']);
expectRazorpayScopedDelete('webhook_events', ['test', 'razorpay']);
expectRazorpayRuntimeDelete('razorpay_plans', ['test']);
expectRazorpayRuntimeDelete('razorpay_items', ['test']);
expect(mockClient.query).toHaveBeenCalledWith(
expect.stringMatching(/INSERT INTO payments\.provider_connections/i),
['test', 'rzp_test_new', 'New Merchant', false, true]
);
expect(syncAfterKeyChange).toHaveBeenCalledWith('test', mockProvider);
});
it('does not clear or sync when the same Razorpay keys are saved again', async () => {
mockGetSecretByKey.mockImplementation(async (key: string) => {
if (key === 'RAZORPAY_TEST_KEY_ID') {
return 'rzp_test_same';
}
if (key === 'RAZORPAY_TEST_KEY_SECRET') {
return 'same_secret';
}
return null;
});
mockPool.query.mockResolvedValueOnce({ rows: [{ accountId: 'rzp_test_same' }], rowCount: 1 });
mockProvider.retrieveAccount.mockResolvedValue({
id: 'rzp_test_same',
merchantName: 'Same Merchant',
livemode: false,
});
const syncAfterKeyChange = vi.fn().mockResolvedValue(undefined);
await RazorpayConfigService.getInstance().setRazorpayKeys(
'test',
'rzp_test_same',
'same_secret',
undefined,
syncAfterKeyChange
);
const executedSql = mockClient.query.mock.calls.map(([sql]) => String(sql)).join('\n');
expect(executedSql).not.toMatch(/DELETE FROM payments\.razorpay_/i);
expect(executedSql).not.toMatch(/DELETE FROM payments\.customers/i);
expect(executedSql).not.toMatch(/DELETE FROM payments\.customer_mappings/i);
expect(executedSql).not.toMatch(/DELETE FROM payments\.webhook_events/i);
expect(mockClient.query).toHaveBeenCalledWith(
expect.stringMatching(/INSERT INTO payments\.provider_connections/i),
['test', 'rzp_test_same', 'Same Merchant', false, false]
);
expect(syncAfterKeyChange).not.toHaveBeenCalled();
});
it('serializes Razorpay key removal with the environment advisory lock', async () => {
mockClient.query.mockResolvedValue({ rows: [], rowCount: 0 });
await RazorpayConfigService.getInstance().removeRazorpayKeys('test');
expect(mockWithPaymentSessionAdvisoryLock).toHaveBeenCalledWith(
mockPool,
'payments_razorpay_environment_test',
expect.any(Function)
);
});
it('normalizes trailing slashes when preparing the Razorpay webhook URL', async () => {
process.env.API_BASE_URL = 'https://api.example.test/';
mockGetSecretByKey.mockImplementation(async (key: string) => {
if (key === 'RAZORPAY_TEST_KEY_ID') {
return 'rzp_test_key';
}
if (key === 'RAZORPAY_TEST_KEY_SECRET') {
return 'secret';
}
if (key === 'RAZORPAY_TEST_WEBHOOK_SECRET') {
return 'whsec_123';
}
return null;
});
mockPool.query.mockResolvedValueOnce({ rows: [], rowCount: 0 }).mockResolvedValueOnce({
rows: [
{
environment: 'test',
status: 'connected',
accountId: 'acc_123',
merchantName: 'Merchant',
accountLivemode: false,
webhookEndpointId: 'manual',
webhookEndpointUrl: 'https://api.example.test/api/webhooks/razorpay/test',
webhookConfiguredAt: new Date('2026-06-10T00:00:00.000Z'),
lastSyncedAt: null,
lastSyncStatus: null,
lastSyncError: null,
lastSyncCounts: null,
},
],
rowCount: 1,
});
const setup = await RazorpayConfigService.getInstance().getWebhookSetup('test');
expect(setup.webhookUrl).toBe('https://api.example.test/api/webhooks/razorpay/test');
expect(mockPool.query).toHaveBeenCalledWith(
expect.stringMatching(/INSERT INTO payments\.provider_connections/i),
['test', 'https://api.example.test/api/webhooks/razorpay/test']
);
});
});