refund-processor-supplement.test.js
1 /** 2 * Supplement tests for refund-processor.js processRefundRequest() 3 * 4 * Tests the full refund flow: isRefundRequest check → findEligiblePurchase → 5 * refundPayment (PayPal) → UPDATE purchases → sendRefundConfirmation (Resend). 6 * 7 * Mocks: db.js, paypal.js, resend, logger, load-env. 8 */ 9 10 import { test, describe, mock } from 'node:test'; 11 import assert from 'node:assert/strict'; 12 13 // ─── Mocks (must be before import) ─────────────────────────────────────────── 14 15 process.env.LOGS_DIR = '/tmp/test-logs'; 16 17 mock.module('../../src/utils/load-env.js', { 18 defaultExport: {}, 19 }); 20 21 mock.module('../../src/utils/logger.js', { 22 defaultExport: class { 23 info() {} 24 warn() {} 25 error() {} 26 success() {} 27 debug() {} 28 }, 29 }); 30 31 // PayPal refund mock 32 const refundPaymentMock = mock.fn(async () => ({ 33 refund_id: 'REFUND_ABC123', 34 status: 'COMPLETED', 35 })); 36 37 mock.module('../../src/payment/paypal.js', { 38 namedExports: { 39 refundPayment: refundPaymentMock, 40 }, 41 }); 42 43 // Resend mock 44 const emailsSendMock = mock.fn(async () => ({ id: 'email-123' })); 45 46 mock.module('resend', { 47 namedExports: { 48 Resend: class { 49 constructor() { 50 this.emails = { send: emailsSendMock }; 51 } 52 }, 53 }, 54 }); 55 56 // ─── db.js mock ─────────────────────────────────────────────────────────────── 57 58 let mockPurchase = null; 59 let runCalls = []; 60 61 mock.module('../../src/utils/db.js', { 62 namedExports: { 63 getOne: async (sql, params) => { 64 if (sql.includes('purchases')) return mockPurchase; 65 return null; 66 }, 67 run: async (sql, params) => { 68 runCalls.push({ sql, params }); 69 return { changes: 1 }; 70 }, 71 getAll: async () => [], 72 query: async () => ({ rows: [], rowCount: 0 }), 73 withTransaction: async (fn) => fn({ query: async () => ({ rows: [], rowCount: 0 }) }), 74 }, 75 }); 76 77 // ─── Import module under test ───────────────────────────────────────────────── 78 79 const { processRefundRequest } = await import('../../src/payment/refund-processor.js'); 80 81 // ─── Tests ─────────────────────────────────────────────────────────────────── 82 83 describe('processRefundRequest', () => { 84 function resetMocks() { 85 mockPurchase = null; 86 runCalls = []; 87 refundPaymentMock.mock.resetCalls(); 88 emailsSendMock.mock.resetCalls(); 89 } 90 91 test('returns not_a_refund_request when body has no refund keywords', async () => { 92 resetMocks(); 93 const result = await processRefundRequest('test@example.com', 'Thanks for the report!'); 94 assert.deepStrictEqual(result, { processed: false, reason: 'not_a_refund_request' }); 95 assert.strictEqual(refundPaymentMock.mock.calls.length, 0); 96 }); 97 98 test('returns not_eligible when no purchase found for email', async () => { 99 resetMocks(); 100 mockPurchase = null; // No purchase record 101 102 const result = await processRefundRequest('unknown@example.com', 'I want a refund'); 103 assert.strictEqual(result.processed, false); 104 assert.ok(result.reason !== 'not_a_refund_request'); 105 assert.strictEqual(refundPaymentMock.mock.calls.length, 0); 106 }); 107 108 test('processes refund successfully when eligible purchase exists', async () => { 109 resetMocks(); 110 process.env.RESEND_API_KEY = 'test-resend-key'; 111 112 // Eligible purchase: paid, within 7-day window 113 mockPurchase = { 114 id: 42, 115 email: 'buyer@example.com', 116 paypal_capture_id: 'CAPTURE_XYZ', 117 amount: 29700, 118 currency: 'AUD', 119 status: 'paid', 120 created_at: new Date().toISOString(), // just now — within window 121 }; 122 123 const result = await processRefundRequest('buyer@example.com', 'I want a refund please'); 124 125 assert.strictEqual(result.processed, true); 126 assert.strictEqual(result.reason, 'refund_issued'); 127 assert.strictEqual(result.purchaseId, 42); 128 assert.strictEqual(result.refundId, 'REFUND_ABC123'); 129 130 // Verify PayPal refund was called 131 assert.strictEqual(refundPaymentMock.mock.calls.length, 1); 132 assert.strictEqual(refundPaymentMock.mock.calls[0].arguments[0], 'CAPTURE_XYZ'); 133 134 // Verify purchases.status was updated 135 const updateCall = runCalls.find(c => c.sql.includes('UPDATE purchases')); 136 assert.ok(updateCall, 'Should have updated the purchase record'); 137 138 // Verify confirmation email was sent 139 assert.strictEqual(emailsSendMock.mock.calls.length, 1); 140 141 delete process.env.RESEND_API_KEY; 142 }); 143 144 test('skips confirmation email when RESEND_API_KEY not set', async () => { 145 resetMocks(); 146 delete process.env.RESEND_API_KEY; 147 148 mockPurchase = { 149 id: 43, 150 email: 'nokey@example.com', 151 paypal_capture_id: 'CAPTURE_NOKEY', 152 amount: 15900, 153 currency: 'USD', 154 status: 'paid', 155 created_at: new Date().toISOString(), 156 }; 157 158 const result = await processRefundRequest('nokey@example.com', 'refund please'); 159 160 assert.strictEqual(result.processed, true); 161 assert.strictEqual( 162 emailsSendMock.mock.calls.length, 163 0, 164 'Should not send email without API key' 165 ); 166 }); 167 168 test('returns error when refundPayment throws', async () => { 169 resetMocks(); 170 refundPaymentMock.mock.mockImplementationOnce(async () => { 171 throw new Error('PayPal API timeout'); 172 }); 173 174 mockPurchase = { 175 id: 44, 176 email: 'error@example.com', 177 paypal_capture_id: 'CAPTURE_ERR', 178 amount: 9700, 179 currency: 'USD', 180 status: 'paid', 181 created_at: new Date().toISOString(), 182 }; 183 184 const result = await processRefundRequest('error@example.com', 'I want my money back'); 185 186 assert.strictEqual(result.processed, false); 187 assert.ok(result.reason.includes('PayPal API timeout'), `Reason was: ${result.reason}`); 188 }); 189 });