mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-11-14 11:03:53 +01:00
228 lines
11 KiB
JavaScript
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();
|
|
});
|
|
});
|