Files
SS/tests/wallet.test.js

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');
});