189 lines
6.0 KiB
JavaScript
189 lines
6.0 KiB
JavaScript
const assert = require('node:assert/strict');
|
|
const test = require('node:test');
|
|
|
|
const { closeDbPool, getDb } = require('../src/config/db');
|
|
const {
|
|
chargeBalance,
|
|
creditBalance,
|
|
createDepositRequest,
|
|
reviewDeposit,
|
|
runReconciliation
|
|
} = require('../src/services/wallet.service');
|
|
|
|
const createdUserIds = [];
|
|
|
|
function unique(prefix) {
|
|
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
}
|
|
|
|
async function createUser(overrides = {}) {
|
|
const db = getDb();
|
|
const username = unique('wallet_user');
|
|
const [id] = await db('users').insert({
|
|
username,
|
|
email: `${username}@example.com`,
|
|
password_hash: 'test_hash',
|
|
balance: '0.0000',
|
|
role: 'user',
|
|
status: 'active',
|
|
...overrides
|
|
});
|
|
|
|
createdUserIds.push(id);
|
|
return id;
|
|
}
|
|
|
|
async function readUser(userId) {
|
|
return getDb()('users').where({ id: userId }).first();
|
|
}
|
|
|
|
async function cleanup() {
|
|
if (createdUserIds.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const db = getDb();
|
|
|
|
await db('password_reset_tokens').whereIn('user_id', createdUserIds).del();
|
|
await db('refresh_tokens').whereIn('user_id', createdUserIds).del();
|
|
await db('idempotency_keys').whereIn('user_id', createdUserIds).del();
|
|
await db('deposits').whereIn('user_id', createdUserIds).orWhereIn('reviewed_by', createdUserIds).del();
|
|
await db('transactions').whereIn('user_id', createdUserIds).del();
|
|
await db('users').whereIn('id', createdUserIds).del();
|
|
}
|
|
|
|
test.after(async () => {
|
|
await cleanup();
|
|
await closeDbPool();
|
|
});
|
|
|
|
test('charge bigger than balance is rejected and leaves balance unchanged', async () => {
|
|
const userId = await createUser();
|
|
|
|
await assert.rejects(
|
|
() => chargeBalance(userId, '1.0000', unique('order_ref'), 'too expensive'),
|
|
(error) => error.code === 'INSUFFICIENT_BALANCE'
|
|
);
|
|
|
|
const user = await readUser(userId);
|
|
const transactions = await getDb()('transactions').where({ user_id: userId });
|
|
|
|
assert.equal(user.balance, '0.0000');
|
|
assert.equal(transactions.length, 0);
|
|
});
|
|
|
|
test('parallel charges on same balance allow only one spend when funds cover one order', async () => {
|
|
const userId = await createUser();
|
|
await creditBalance(userId, '10.0000', unique('seed_ref'), 'seed balance');
|
|
|
|
const results = await Promise.allSettled([
|
|
chargeBalance(userId, '6.0000', unique('order_ref_a'), 'parallel A'),
|
|
chargeBalance(userId, '6.0000', unique('order_ref_b'), 'parallel B')
|
|
]);
|
|
|
|
const fulfilled = results.filter((result) => result.status === 'fulfilled');
|
|
const rejected = results.filter((result) => result.status === 'rejected');
|
|
const user = await readUser(userId);
|
|
const orderTransactions = await getDb()('transactions').where({ user_id: userId, type: 'order' });
|
|
|
|
assert.equal(fulfilled.length, 1);
|
|
assert.equal(rejected.length, 1);
|
|
assert.equal(rejected[0].reason.code, 'INSUFFICIENT_BALANCE');
|
|
assert.equal(user.balance, '4.0000');
|
|
assert.equal(orderTransactions.length, 1);
|
|
});
|
|
|
|
test('parallel approval of one deposit credits balance only once', async () => {
|
|
const adminId = await createUser({ role: 'admin' });
|
|
const userId = await createUser();
|
|
const deposit = await createDepositRequest(userId, {
|
|
amount: '15.0000',
|
|
method: 'paypal',
|
|
tx_ref: unique('tx_ref')
|
|
});
|
|
|
|
const results = await Promise.allSettled([
|
|
reviewDeposit(deposit.id, adminId, 'approve', 'approved once'),
|
|
reviewDeposit(deposit.id, adminId, 'approve', 'approved twice')
|
|
]);
|
|
|
|
const fulfilled = results.filter((result) => result.status === 'fulfilled');
|
|
const rejected = results.filter((result) => result.status === 'rejected');
|
|
const user = await readUser(userId);
|
|
const depositTransactions = await getDb()('transactions').where({
|
|
user_id: userId,
|
|
type: 'deposit',
|
|
reference: `deposit:${deposit.id}`
|
|
});
|
|
|
|
assert.equal(fulfilled.length, 1);
|
|
assert.equal(rejected.length, 1);
|
|
assert.equal(rejected[0].reason.code, 'DEPOSIT_ALREADY_REVIEWED');
|
|
assert.equal(user.balance, '15.0000');
|
|
assert.equal(depositTransactions.length, 1);
|
|
});
|
|
|
|
test('duplicate tx_ref is rejected without touching balance', async () => {
|
|
const userId = await createUser();
|
|
const txRef = unique('duplicate_tx_ref');
|
|
|
|
await createDepositRequest(userId, {
|
|
amount: '5.0000',
|
|
method: 'paypal',
|
|
tx_ref: txRef
|
|
});
|
|
|
|
await assert.rejects(
|
|
() => createDepositRequest(userId, {
|
|
amount: '9.0000',
|
|
method: 'paypal',
|
|
tx_ref: txRef
|
|
}),
|
|
(error) => error.code === 'DUPLICATE_TX_REF'
|
|
);
|
|
|
|
const user = await readUser(userId);
|
|
const deposits = await getDb()('deposits').where({ user_id: userId, tx_ref: txRef });
|
|
|
|
assert.equal(user.balance, '0.0000');
|
|
assert.equal(deposits.length, 1);
|
|
});
|
|
|
|
test('deposit with unavailable payment method is rejected before creating a request', async () => {
|
|
const userId = await createUser();
|
|
const txRef = unique('missing_method_tx_ref');
|
|
|
|
await assert.rejects(
|
|
() => createDepositRequest(userId, {
|
|
amount: '5.0000',
|
|
method: 'missing_method',
|
|
tx_ref: txRef
|
|
}),
|
|
(error) => error.code === 'PAYMENT_METHOD_UNAVAILABLE'
|
|
);
|
|
|
|
const deposits = await getDb()('deposits').where({ user_id: userId, tx_ref: txRef });
|
|
const user = await readUser(userId);
|
|
|
|
assert.equal(deposits.length, 0);
|
|
assert.equal(user.balance, '0.0000');
|
|
});
|
|
|
|
test('transaction sum remains equal to user balance after a mixed sequence', async () => {
|
|
const userId = await createUser();
|
|
|
|
await creditBalance(userId, '20.0000', unique('credit_ref'), 'first credit');
|
|
await chargeBalance(userId, '7.2500', unique('order_ref'), 'order charge');
|
|
await creditBalance(userId, '2.1250', unique('refund_ref'), 'partial refund', 'refund');
|
|
await chargeBalance(userId, '4.8750', unique('order_ref'), 'second order');
|
|
|
|
const mismatches = await runReconciliation();
|
|
const ownMismatch = mismatches.find((row) => Number(row.userId) === Number(userId));
|
|
const [sumRow] = await getDb()('transactions').where({ user_id: userId }).sum({ total: 'amount' });
|
|
const user = await readUser(userId);
|
|
|
|
assert.equal(ownMismatch, undefined);
|
|
assert.equal(sumRow.total, user.balance);
|
|
assert.equal(user.balance, '10.0000');
|
|
});
|