Files
wownerogue/test/payoutRetry.test.js
T
jwinterm ce83ec4b6c fix: retry payout stat-counting and atomic tx_hash write
Two bugs in payoutRetryService.retryPayout:

- The "already on-chain" path guarded the user-stats update with
  NOT EXISTS (... WHERE id = this AND status = 'completed'), but it had just
  marked that same row completed in the same transaction, so the guard was
  always true-negative and stats were never counted. Replaced with a
  conditional UPDATE ... WHERE status <> 'completed' RETURNING, so the row's
  transition drives an exactly-once stat count.
- A successful retry wrote tx_hash in a standalone query and then marked the
  payout completed in a separate transaction; a crash between the two left a
  tx_hash on a non-completed row. Now tx_hash + completion + stats happen in
  one transaction.

Adds payoutRetry.test.js (count-once, no-double-count, atomic completion).
Full suite: 16 suites / 134 tests.
2026-05-31 10:00:51 -04:00

88 lines
3.8 KiB
JavaScript

/**
* Payout retry service tests (Phase 1.4).
*
* Guards two previously-broken behaviours:
* - the "already on-chain" path counts user stats exactly once (the old NOT EXISTS guard
* checked the same row it had just marked completed, so it never counted), and
* - a successful retry stores tx_hash + marks completed + counts stats in ONE transaction
* (no non-atomic tx_hash write that could strand a tx_hash on a non-completed row).
*/
const PayoutRetryService = require('../src/payments/payoutRetryService');
function makeHarness() {
const client = { query: jest.fn().mockResolvedValue({ rows: [] }) };
const db = {
query: jest.fn().mockResolvedValue({ rows: [] }),
withTransaction: jest.fn().mockImplementation(async (cb) => cb(client))
};
return { client, db };
}
function svcWith(db, wallet) {
return new PayoutRetryService({
db, walletService: wallet, debugManager: { CONSOLE_LOGGING: false }, maxRetries: 3
});
}
const findCall = (mockFn, re) => mockFn.mock.calls.find(c => re.test(c[0]));
describe('PayoutRetryService.retryPayout', () => {
test('already-on-chain: marks completed and counts stats exactly once', async () => {
const { client, db } = makeHarness();
client.query
.mockResolvedValueOnce({ rows: [{ id: 5 }] }) // payouts transition (did transition)
.mockResolvedValue({ rows: [] }); // users stat update
const wallet = {
checkTransactionStatus: jest.fn().mockResolvedValue({ exists: true, confirmations: 3 }),
processPayout: jest.fn()
};
await svcWith(db, wallet).retryPayout({ id: 5, user_id: 1, amount: '100', tx_hash: 'tx5', status: 'failed', retry_count: 0 });
// Transition guarded by `status <> 'completed'` (so it counts once).
expect(findCall(client.query, /UPDATE payouts[\s\S]*status <> 'completed'/i)).toBeDefined();
// Stats were counted.
expect(findCall(client.query, /UPDATE users[\s\S]*total_amount_won/i)).toBeDefined();
// Did NOT re-send funds (already on chain).
expect(wallet.processPayout).not.toHaveBeenCalled();
});
test('already-on-chain but already completed: does NOT double-count stats', async () => {
const { client, db } = makeHarness();
client.query
.mockResolvedValueOnce({ rows: [] }) // payouts transition affected 0 rows (already completed)
.mockResolvedValue({ rows: [] });
const wallet = {
checkTransactionStatus: jest.fn().mockResolvedValue({ exists: true, confirmations: 9 }),
processPayout: jest.fn()
};
await svcWith(db, wallet).retryPayout({ id: 5, user_id: 1, amount: '100', tx_hash: 'tx5', status: 'failed', retry_count: 0 });
expect(findCall(client.query, /UPDATE users[\s\S]*total_amount_won/i)).toBeUndefined();
});
test('successful retry: stores tx_hash, completes, counts stats in one transaction', async () => {
const { client, db } = makeHarness();
client.query
.mockResolvedValueOnce({ rows: [{ id: 7 }] }) // completion transition
.mockResolvedValue({ rows: [] });
const wallet = {
checkTransactionStatus: jest.fn(),
processPayout: jest.fn().mockResolvedValue({ success: true, txHash: 'txNEW', fee: 10 })
};
await svcWith(db, wallet).retryPayout({ id: 7, user_id: 1, amount: '100', payout_address: 'wow1addr', status: 'failed', retry_count: 0 });
// tx_hash + completion happen in the SAME statement (atomic).
const completion = findCall(client.query, /UPDATE payouts[\s\S]*tx_hash[\s\S]*status = 'completed'/i);
expect(completion).toBeDefined();
expect(completion[1]).toContain('txNEW');
// No standalone pre-transaction tx_hash write on db.query.
expect(findCall(db.query, /UPDATE payouts SET tx_hash = \$1, fee = \$2 WHERE id/i)).toBeUndefined();
// Stats counted once.
expect(findCall(client.query, /UPDATE users[\s\S]*total_amount_won/i)).toBeDefined();
});
});