refund-processor.test.js
1 import { test, describe, mock } from 'node:test'; 2 import assert from 'node:assert/strict'; 3 4 // ─── Mocks (must be before import) ─────────────────────────────────────────── 5 6 process.env.LOGS_DIR = '/tmp/test-logs'; 7 process.env.NODE_ENV = 'test'; 8 9 mock.module('../src/utils/load-env.js', { 10 defaultExport: {}, 11 }); 12 13 mock.module('../src/utils/logger.js', { 14 defaultExport: class { 15 info() {} 16 warn() {} 17 error() {} 18 success() {} 19 debug() {} 20 }, 21 }); 22 23 mock.module('../src/payment/paypal.js', { 24 namedExports: { 25 refundPayment: async () => ({ refund_id: 'REFUND_TEST', status: 'COMPLETED' }), 26 }, 27 }); 28 29 mock.module('resend', { 30 namedExports: { 31 Resend: class { 32 constructor() { 33 this.emails = { send: async () => ({ id: 'email-ok' }) }; 34 } 35 }, 36 }, 37 }); 38 39 // ─── db.js mock ─────────────────────────────────────────────────────────────── 40 41 let mockPurchaseRow = null; 42 const runCalls = []; 43 44 mock.module('../src/utils/db.js', { 45 namedExports: { 46 getOne: async (sql, params) => { 47 if (sql.includes('purchases')) return mockPurchaseRow; 48 return null; 49 }, 50 run: async (sql, params) => { 51 runCalls.push({ sql, params }); 52 return { changes: 1 }; 53 }, 54 getAll: async () => [], 55 query: async () => ({ rows: [], rowCount: 0 }), 56 withTransaction: async (fn) => fn({ query: async () => ({ rows: [], rowCount: 0 }) }), 57 }, 58 }); 59 60 // ─── Import module under test ───────────────────────────────────────────────── 61 62 const { isRefundRequest, findEligiblePurchase } = 63 await import('../src/payment/refund-processor.js'); 64 65 // ─── isRefundRequest ────────────────────────────────────────────────────────── 66 67 describe('isRefundRequest', () => { 68 test('detects "refund" keyword', () => { 69 assert.equal(isRefundRequest('I want a refund please'), true); 70 }); 71 72 test('detects "money back" keyword', () => { 73 assert.equal(isRefundRequest('I want my money back'), true); 74 }); 75 76 test('detects "chargeback" keyword', () => { 77 assert.equal(isRefundRequest('I will do a chargeback'), true); 78 }); 79 80 test('detects "cancel my order"', () => { 81 assert.equal(isRefundRequest('Please cancel my order'), true); 82 }); 83 84 test('is case-insensitive', () => { 85 assert.equal(isRefundRequest('PLEASE REFUND ME'), true); 86 }); 87 88 test('returns false for normal reply', () => { 89 assert.equal(isRefundRequest('Thanks for the report, very helpful'), false); 90 }); 91 92 test('returns false for empty body', () => { 93 assert.equal(isRefundRequest(''), false); 94 }); 95 96 test('returns false for null', () => { 97 assert.equal(isRefundRequest(null), false); 98 }); 99 }); 100 101 // ─── findEligiblePurchase ───────────────────────────────────────────────────── 102 103 describe('findEligiblePurchase', () => { 104 test('returns no_purchase when no record found', async () => { 105 mockPurchaseRow = null; 106 const result = await findEligiblePurchase('nobody@example.com'); 107 assert.equal(result.eligible, false); 108 assert.equal(result.reason, 'no_purchase'); 109 }); 110 111 test('returns eligible for fresh purchase within 7 days', async () => { 112 const yesterday = new Date(); 113 yesterday.setDate(yesterday.getDate() - 1); 114 mockPurchaseRow = { 115 id: 1, 116 email: 'customer@example.com', 117 paypal_capture_id: 'CAP123', 118 amount: 29700, 119 currency: 'USD', 120 status: 'paid', 121 created_at: yesterday.toISOString(), 122 }; 123 const result = await findEligiblePurchase('customer@example.com'); 124 assert.equal(result.eligible, true); 125 assert.equal(result.reason, null); 126 }); 127 128 test('returns outside_window for purchase older than 7 days', async () => { 129 const eightDaysAgo = new Date(); 130 eightDaysAgo.setDate(eightDaysAgo.getDate() - 8); 131 mockPurchaseRow = { 132 id: 2, 133 email: 'old@example.com', 134 paypal_capture_id: 'CAP456', 135 amount: 29700, 136 currency: 'USD', 137 status: 'paid', 138 created_at: eightDaysAgo.toISOString(), 139 }; 140 const result = await findEligiblePurchase('old@example.com'); 141 assert.equal(result.eligible, false); 142 assert.equal(result.reason, 'outside_window'); 143 }); 144 145 test('returns already_refunded for refunded purchase', async () => { 146 mockPurchaseRow = { 147 id: 3, 148 email: 'refunded@example.com', 149 paypal_capture_id: 'CAP789', 150 amount: 29700, 151 currency: 'USD', 152 status: 'refunded', 153 created_at: new Date().toISOString(), 154 }; 155 const result = await findEligiblePurchase('refunded@example.com'); 156 assert.equal(result.eligible, false); 157 assert.equal(result.reason, 'already_refunded'); 158 }); 159 160 test('returns no_capture_id when paypal_capture_id is null', async () => { 161 mockPurchaseRow = { 162 id: 4, 163 email: 'nocap@example.com', 164 paypal_capture_id: null, 165 amount: 29700, 166 currency: 'USD', 167 status: 'paid', 168 created_at: new Date().toISOString(), 169 }; 170 const result = await findEligiblePurchase('nocap@example.com'); 171 assert.equal(result.eligible, false); 172 assert.equal(result.reason, 'no_capture_id'); 173 }); 174 175 test('is case-insensitive on email lookup', async () => { 176 const yesterday = new Date(); 177 yesterday.setDate(yesterday.getDate() - 1); 178 mockPurchaseRow = { 179 id: 5, 180 email: 'Customer@Example.COM', 181 paypal_capture_id: 'CAP999', 182 amount: 29700, 183 currency: 'USD', 184 status: 'paid', 185 created_at: yesterday.toISOString(), 186 }; 187 const result = await findEligiblePurchase('customer@example.com'); 188 assert.equal(result.eligible, true); 189 }); 190 191 test('picks most recent purchase when customer has multiple (mock returns most recent)', async () => { 192 // The mock always returns one row; this tests that the result is correctly 193 // classified. In production the SQL ORDER BY created_at DESC LIMIT 1 handles ordering. 194 const twoDaysAgo = new Date(); 195 twoDaysAgo.setDate(twoDaysAgo.getDate() - 2); 196 mockPurchaseRow = { 197 id: 6, 198 email: 'multi@example.com', 199 paypal_capture_id: 'CAP_NEW', 200 amount: 29700, 201 currency: 'USD', 202 status: 'paid', 203 created_at: twoDaysAgo.toISOString(), 204 }; 205 const result = await findEligiblePurchase('multi@example.com'); 206 assert.equal(result.eligible, true); 207 assert.equal(result.purchase.paypal_capture_id, 'CAP_NEW'); 208 }); 209 });