Files
wownerogue/test/latePaymentReconciliation.test.js
T

228 lines
11 KiB
JavaScript

const GameModeManager = require('../src/game/gameModeManager');
const LatePaymentReconciler = require('../src/services/latePaymentReconciler');
describe('late incoming payment reconciliation', () => {
test('bounded scanner sends an expired native receipt only to manual review', async () => {
const row = {
id: 7,
user_id: 3,
status: 'expired',
subaddress: 'late-address',
address_index: 11,
expected_amount: '100',
provider_id: 'native-monero',
provider_invoice_id: 'late-address'
};
const db = { query: jest.fn().mockResolvedValue({ rows: [row] }) };
const receipt = {
evidenceType: 'chain_output', providerId: 'native-monero',
txHash: 'a'.repeat(64), outputId: 'b'.repeat(64),
evidenceId: `${'a'.repeat(64)}:${'b'.repeat(64)}`,
addressIndex: 11, amount: '100', confirmed: true
};
const walletService = {
accountIndex: 0,
addressToUser: new Map(),
addressToSocket: new Map(),
checkPaymentStatus: jest.fn(async address => {
expect(address).toBe('late-address');
expect(walletService.addressToUser.get(address)).toEqual(expect.objectContaining({
amount: 100,
addressIndex: 11
}));
return { confirmed: true, complete: true, amount: 100, receipts: [receipt] };
}),
stopPaymentMonitoring: jest.fn()
};
const gameModeManager = {
paymentProviders: { get: jest.fn(() => null) },
reconcileLatePaymentForReview: jest.fn().mockResolvedValue({ checked: true, needsReview: true })
};
const service = new LatePaymentReconciler({
db, gameModeManager, walletService, lookbackHours: 24, batchSize: 20,
logger: { warn: jest.fn(), error: jest.fn() }
});
const result = await service.runOnce();
expect(result).toEqual({ skipped: false, checked: 1, reviews: 1, errors: 0 });
expect(db.query.mock.calls[0][1]).toEqual([24, 20]);
expect(gameModeManager.reconcileLatePaymentForReview).toHaveBeenCalledWith(7, [receipt]);
expect(walletService.addressToUser.has('late-address')).toBe(false);
expect(walletService.stopPaymentMonitoring).toHaveBeenCalledWith('late-address');
});
test.each([
['missing status', undefined],
['missing receipts', { complete: false }],
['non-array receipts', { receipts: null }],
['malformed receipts', { receipts: {} }],
['nonfinal positive without receipts', { in_mempool: true, confirmed: false, complete: false, receipts: [] }],
['confirmed without receipts', { confirmed: true, complete: true, receipts: [] }],
['complete without confirmation', { confirmed: false, complete: true, receipts: [] }],
['receipts without confirmation', { confirmed: false, complete: false, receipts: [{}] }]
])('does not mark the invoice scanned on %s', async (_case, status) => {
const row = {
id: 9, user_id: 3, status: 'expired', subaddress: 'late-address',
address_index: 11, expected_amount: '100', provider_id: 'native-monero'
};
const walletService = {
accountIndex: 0, addressToUser: new Map(), addressToSocket: new Map(),
checkPaymentStatus: jest.fn().mockResolvedValue(status),
stopPaymentMonitoring: jest.fn()
};
const gameModeManager = {
reconcileLatePaymentForReview: jest.fn(), paymentProviders: { get: jest.fn() }
};
const service = new LatePaymentReconciler({
db: { query: jest.fn().mockResolvedValue({ rows: [row] }) },
gameModeManager, walletService,
logger: { warn: jest.fn(), error: jest.fn() }
});
expect(await service.runOnce()).toEqual({ skipped: false, checked: 0, reviews: 0, errors: 1 });
expect(gameModeManager.reconcileLatePaymentForReview).not.toHaveBeenCalled();
expect(walletService.stopPaymentMonitoring).not.toHaveBeenCalled();
expect(walletService.addressToUser.has(row.subaddress)).toBe(false);
});
test('gateway status alone cannot establish an empty historical receipt scan', async () => {
const provider = { getWalletStatus: jest.fn().mockResolvedValue({
_terminal: true, confirmed: false, complete: false, receipts: []
}) };
const gameModeManager = {
paymentProviders: { get: jest.fn(() => provider) },
reconcileLatePaymentForReview: jest.fn()
};
const service = new LatePaymentReconciler({
db: { query: jest.fn().mockResolvedValue({ rows: [{
id: 13, user_id: 3, subaddress: 'gateway-address',
address_index: 0, expected_amount: '100', provider_id: 'btcpay',
provider_invoice_id: 'gateway-invoice'
}] }) },
gameModeManager,
logger: { warn: jest.fn(), error: jest.fn() }
});
expect(await service.runOnce()).toEqual({ skipped: false, checked: 0, reviews: 0, errors: 1 });
expect(gameModeManager.reconcileLatePaymentForReview).not.toHaveBeenCalled();
});
test('does not log upstream response text from a failed gateway receipt read', async () => {
const privateMarker = 'untrusted-provider-response-canary';
const provider = { getWalletStatus: jest.fn().mockRejectedValue(new Error(privateMarker)) };
const logger = { warn: jest.fn(), error: jest.fn() };
const service = new LatePaymentReconciler({
db: { query: jest.fn().mockResolvedValue({ rows: [{
id: 14, user_id: 3, subaddress: 'gateway-address',
address_index: 0, expected_amount: '100', provider_id: 'btcpay',
provider_invoice_id: 'gateway-invoice'
}] }) },
gameModeManager: {
paymentProviders: { get: jest.fn(() => provider) },
reconcileLatePaymentForReview: jest.fn()
},
logger
});
expect(await service.runOnce()).toEqual({ skipped: false, checked: 0, reviews: 0, errors: 1 });
expect(logger.error).toHaveBeenCalledTimes(1);
expect(logger.error.mock.calls[0][0].includes(privateMarker)).toBe(false);
});
test('does not advance the marker when the wallet read rejects', async () => {
const walletService = {
accountIndex: 0, addressToUser: new Map(), addressToSocket: new Map(),
checkPaymentStatus: jest.fn().mockRejectedValue(new Error('wallet unavailable')),
stopPaymentMonitoring: jest.fn()
};
const gameModeManager = { reconcileLatePaymentForReview: jest.fn() };
const service = new LatePaymentReconciler({
db: { query: jest.fn().mockResolvedValue({ rows: [{
id: 10, user_id: 3, subaddress: 'late-address',
address_index: 11, expected_amount: '100', provider_id: 'native-monero'
}] }) },
gameModeManager, walletService,
logger: { warn: jest.fn(), error: jest.fn() }
});
expect(await service.runOnce()).toEqual({ skipped: false, checked: 0, reviews: 0, errors: 1 });
expect(gameModeManager.reconcileLatePaymentForReview).not.toHaveBeenCalled();
expect(walletService.stopPaymentMonitoring).not.toHaveBeenCalled();
});
test('a successful empty receipt list advances the scan without a review', async () => {
const walletService = {
accountIndex: 0, addressToUser: new Map(), addressToSocket: new Map(),
checkPaymentStatus: jest.fn().mockResolvedValue({
complete: false, confirmed: false, receipts: []
}),
stopPaymentMonitoring: jest.fn()
};
const gameModeManager = {
reconcileLatePaymentForReview: jest.fn().mockResolvedValue({ checked: true, needsReview: false })
};
const service = new LatePaymentReconciler({
db: { query: jest.fn().mockResolvedValue({ rows: [{
id: 11, user_id: 3, subaddress: 'late-address',
address_index: 11, expected_amount: '100', provider_id: 'native-monero'
}] }) },
gameModeManager, walletService,
logger: { warn: jest.fn(), error: jest.fn() }
});
expect(await service.runOnce()).toEqual({ skipped: false, checked: 1, reviews: 0, errors: 0 });
expect(gameModeManager.reconcileLatePaymentForReview).toHaveBeenCalledWith(11, []);
});
test('durable late receipt stays expired and creates review without any entitlement write', async () => {
const calls = [];
const client = {
query: jest.fn(async (sql, params = []) => {
calls.push({ sql, params });
if (/SELECT id, status, expected_amount/i.test(sql)) {
return { rows: [{
id: 8, status: 'expired', expected_amount: '100',
provider_id: 'native-monero', address_index: 12
}] };
}
if (/COALESCE\(SUM\(amount\)/i.test(sql)) {
return { rows: [{ total: '100', primary_tx_hash: 'c'.repeat(64), receipt_count: '1' }] };
}
if (/INSERT INTO payment_late_reviews/i.test(sql)) {
return { rows: [{ status: 'needs_review' }], rowCount: 1 };
}
return { rows: [], rowCount: 1 };
})
};
const manager = Object.create(GameModeManager.prototype);
manager.db = { withTransaction: async callback => callback(client) };
const outputId = 'd'.repeat(64);
const receipt = {
evidenceType: 'chain_output', providerId: 'native-monero',
txHash: 'c'.repeat(64), outputId,
evidenceId: `${'c'.repeat(64)}:${outputId}`,
addressIndex: 12, amount: '100', confirmed: true
};
const result = await manager.reconcileLatePaymentForReview(8, [receipt]);
expect(result).toEqual(expect.objectContaining({
checked: true,
needsReview: true,
coverageComplete: true
}));
expect(calls.some(call => /INSERT INTO payment_receipts/i.test(call.sql))).toBe(true);
expect(calls.some(call => /INSERT INTO payment_late_reviews/i.test(call.sql))).toBe(true);
expect(calls.some(call => /SET status = CASE WHEN status = 'pending' THEN 'expired'/i.test(call.sql))).toBe(true);
expect(calls.some(call => /UPDATE users|payment_entitlement_grants|credit_transactions/i.test(call.sql))).toBe(false);
});
test('the manager refuses missing evidence before starting a transaction', async () => {
const manager = Object.create(GameModeManager.prototype);
manager.db = { withTransaction: jest.fn() };
await expect(manager.reconcileLatePaymentForReview(12)).rejects.toThrow();
expect(manager.db.withTransaction).not.toHaveBeenCalled();
});
});