refund-processor-unit.test.js
1 /** 2 * Refund Processor Unit Tests 3 * 4 * Comprehensive tests for refund-processor.js covering: 5 * - isRefundRequest() pure function — all keywords, case insensitivity, edge cases 6 * - findEligiblePurchase() — all branches: no_purchase, already_refunded, 7 * no_capture_id, outside_window, eligible 8 * - processRefundRequest() — email send failure (non-fatal), amount formatting 9 * - sendRefundConfirmation() — error path (lines 133-135) 10 * 11 * Uses node:test + node:assert/strict with mocked dependencies. 12 */ 13 14 import { describe, test, mock, beforeEach } from 'node:test'; 15 import assert from 'node:assert/strict'; 16 17 // ---- Environment ---- 18 process.env.LOGS_DIR = '/tmp/test-logs'; 19 process.env.NODE_ENV = 'test'; 20 21 // ---- Mocks (before import) ---- 22 23 mock.module('../../src/utils/load-env.js', { 24 defaultExport: {}, 25 }); 26 27 mock.module('../../src/utils/logger.js', { 28 defaultExport: class { 29 info() {} 30 warn() {} 31 error() {} 32 success() {} 33 debug() {} 34 }, 35 }); 36 37 // PayPal refund mock 38 const refundPaymentMock = mock.fn(async () => ({ 39 refund_id: 'REFUND_UNIT_TEST', 40 status: 'COMPLETED', 41 })); 42 43 mock.module('../../src/payment/paypal.js', { 44 namedExports: { 45 refundPayment: refundPaymentMock, 46 }, 47 }); 48 49 // Resend mock — track calls and allow failure injection 50 let emailsSendImpl = async () => ({ id: 'email-ok' }); 51 const emailsSendMock = mock.fn(async (...args) => emailsSendImpl(...args)); 52 53 mock.module('resend', { 54 namedExports: { 55 Resend: class { 56 constructor() { 57 this.emails = { send: emailsSendMock }; 58 } 59 }, 60 }, 61 }); 62 63 // ---- db.js mock ---- 64 65 let mockPurchaseResult = null; 66 let updateCalls = []; 67 68 mock.module('../../src/utils/db.js', { 69 namedExports: { 70 getOne: async (sql, params) => { 71 if (sql.includes('purchases')) return mockPurchaseResult; 72 return null; 73 }, 74 run: async (sql, params) => { 75 updateCalls.push({ sql, params }); 76 return { changes: 1 }; 77 }, 78 getAll: async () => [], 79 query: async () => ({ rows: [], rowCount: 0 }), 80 withTransaction: async (fn) => fn({ query: async () => ({ rows: [], rowCount: 0 }) }), 81 }, 82 }); 83 84 // ---- Import module under test ---- 85 86 const { isRefundRequest, findEligiblePurchase, processRefundRequest } = 87 await import('../../src/payment/refund-processor.js'); 88 89 // ---- Tests ---- 90 91 describe('isRefundRequest()', () => { 92 test('returns false for null/undefined/empty body', () => { 93 assert.equal(isRefundRequest(null), false); 94 assert.equal(isRefundRequest(undefined), false); 95 assert.equal(isRefundRequest(''), false); 96 }); 97 98 test('returns false for normal non-refund messages', () => { 99 assert.equal(isRefundRequest('Thanks for the report, looks great!'), false); 100 assert.equal(isRefundRequest('Can you explain section 3?'), false); 101 assert.equal(isRefundRequest('We want to proceed with the recommendations'), false); 102 }); 103 104 // Test each keyword individually 105 const keywords = [ 106 'refund', 107 'money back', 108 'give me my money', 109 'want my money', 110 'cancel my order', 111 'cancel my purchase', 112 'charge back', 113 'chargeback', 114 ]; 115 116 for (const keyword of keywords) { 117 test(`detects keyword: "${keyword}"`, () => { 118 assert.equal(isRefundRequest(`I would like a ${keyword} please`), true); 119 }); 120 121 test(`detects keyword case-insensitively: "${keyword.toUpperCase()}"`, () => { 122 assert.equal(isRefundRequest(`I would like a ${keyword.toUpperCase()} please`), true); 123 }); 124 } 125 126 test('detects keyword in mixed-case body', () => { 127 assert.equal(isRefundRequest('I Want My MONEY Back immediately'), true); 128 }); 129 130 test('detects keyword embedded in longer text', () => { 131 assert.equal( 132 isRefundRequest( 133 'Hi, I purchased the report yesterday but it was not what I expected. Please process a refund to my account. Thanks.' 134 ), 135 true 136 ); 137 }); 138 }); 139 140 describe('findEligiblePurchase()', () => { 141 test('returns no_purchase when no purchase found for email', async () => { 142 mockPurchaseResult = null; 143 const result = await findEligiblePurchase('unknown@example.com'); 144 145 assert.equal(result.purchase, null); 146 assert.equal(result.eligible, false); 147 assert.equal(result.reason, 'no_purchase'); 148 }); 149 150 test('returns already_refunded when purchase status is refunded', async () => { 151 mockPurchaseResult = { 152 id: 1, 153 email: 'buyer@example.com', 154 paypal_capture_id: 'CAP_123', 155 amount: 29700, 156 currency: 'USD', 157 status: 'refunded', 158 created_at: new Date().toISOString(), 159 refunded_at: new Date().toISOString(), 160 }; 161 162 const result = await findEligiblePurchase('buyer@example.com'); 163 164 assert.equal(result.eligible, false); 165 assert.equal(result.reason, 'already_refunded'); 166 assert.ok(result.purchase, 'Should still return the purchase object'); 167 assert.equal(result.purchase.id, 1); 168 }); 169 170 test('returns no_capture_id when paypal_capture_id is null', async () => { 171 mockPurchaseResult = { 172 id: 2, 173 email: 'buyer@example.com', 174 paypal_capture_id: null, 175 amount: 29700, 176 currency: 'USD', 177 status: 'paid', 178 created_at: new Date().toISOString(), 179 }; 180 181 const result = await findEligiblePurchase('buyer@example.com'); 182 183 assert.equal(result.eligible, false); 184 assert.equal(result.reason, 'no_capture_id'); 185 assert.ok(result.purchase); 186 }); 187 188 test('returns no_capture_id when paypal_capture_id is empty string', async () => { 189 mockPurchaseResult = { 190 id: 3, 191 email: 'buyer@example.com', 192 paypal_capture_id: '', 193 amount: 29700, 194 currency: 'USD', 195 status: 'paid', 196 created_at: new Date().toISOString(), 197 }; 198 199 const result = await findEligiblePurchase('buyer@example.com'); 200 201 assert.equal(result.eligible, false); 202 assert.equal(result.reason, 'no_capture_id'); 203 }); 204 205 test('returns outside_window when purchase is older than 7 days', async () => { 206 const eightDaysAgo = new Date(); 207 eightDaysAgo.setDate(eightDaysAgo.getDate() - 8); 208 209 mockPurchaseResult = { 210 id: 4, 211 email: 'buyer@example.com', 212 paypal_capture_id: 'CAP_OLD', 213 amount: 29700, 214 currency: 'USD', 215 status: 'paid', 216 created_at: eightDaysAgo.toISOString(), 217 }; 218 219 const result = await findEligiblePurchase('buyer@example.com'); 220 221 assert.equal(result.eligible, false); 222 assert.equal(result.reason, 'outside_window'); 223 assert.ok(result.purchase); 224 }); 225 226 test('returns eligible for purchase within 7-day window', async () => { 227 const twoDaysAgo = new Date(); 228 twoDaysAgo.setDate(twoDaysAgo.getDate() - 2); 229 230 mockPurchaseResult = { 231 id: 5, 232 email: 'buyer@example.com', 233 paypal_capture_id: 'CAP_RECENT', 234 amount: 29700, 235 currency: 'USD', 236 status: 'paid', 237 created_at: twoDaysAgo.toISOString(), 238 }; 239 240 const result = await findEligiblePurchase('buyer@example.com'); 241 242 assert.equal(result.eligible, true); 243 assert.equal(result.reason, null); 244 assert.ok(result.purchase); 245 }); 246 247 test('returns eligible for purchase made today (0 days ago)', async () => { 248 mockPurchaseResult = { 249 id: 6, 250 email: 'buyer@example.com', 251 paypal_capture_id: 'CAP_TODAY', 252 amount: 15900, 253 currency: 'GBP', 254 status: 'paid', 255 created_at: new Date().toISOString(), 256 }; 257 258 const result = await findEligiblePurchase('buyer@example.com'); 259 260 assert.equal(result.eligible, true); 261 assert.equal(result.reason, null); 262 }); 263 264 test('returns outside_window for purchase exactly on 7-day boundary (just past)', async () => { 265 const justPast7Days = new Date(); 266 justPast7Days.setDate(justPast7Days.getDate() - 7); 267 justPast7Days.setHours(justPast7Days.getHours() - 1); 268 269 mockPurchaseResult = { 270 id: 7, 271 email: 'buyer@example.com', 272 paypal_capture_id: 'CAP_BOUNDARY', 273 amount: 29700, 274 currency: 'USD', 275 status: 'paid', 276 created_at: justPast7Days.toISOString(), 277 }; 278 279 const result = await findEligiblePurchase('buyer@example.com'); 280 281 assert.equal(result.eligible, false); 282 assert.equal(result.reason, 'outside_window'); 283 }); 284 285 test('normalizes email to lowercase and trims whitespace', async () => { 286 // The function normalizes email before querying. Verify the eligible 287 // check succeeds when the mock row has a normalized email. 288 mockPurchaseResult = { 289 id: 8, 290 email: 'buyer@example.com', 291 paypal_capture_id: 'CAP_NORM', 292 amount: 29700, 293 currency: 'USD', 294 status: 'paid', 295 created_at: new Date().toISOString(), 296 }; 297 // Passing mixed-case with spaces — should still be eligible 298 const result = await findEligiblePurchase(' BUYER@Example.COM '); 299 assert.equal(result.eligible, true); 300 }); 301 }); 302 303 describe('processRefundRequest() — integration paths', () => { 304 function resetMocks() { 305 mockPurchaseResult = null; 306 updateCalls = []; 307 refundPaymentMock.mock.resetCalls(); 308 emailsSendMock.mock.resetCalls(); 309 emailsSendImpl = async () => ({ id: 'email-ok' }); 310 refundPaymentMock.mock.mockImplementation(async () => ({ 311 refund_id: 'REFUND_UNIT_TEST', 312 status: 'COMPLETED', 313 })); 314 } 315 316 test('returns not_a_refund_request for empty body', async () => { 317 resetMocks(); 318 const result = await processRefundRequest('test@example.com', ''); 319 assert.equal(result.processed, false); 320 assert.equal(result.reason, 'not_a_refund_request'); 321 }); 322 323 test('handles already_refunded purchase gracefully', async () => { 324 resetMocks(); 325 mockPurchaseResult = { 326 id: 10, 327 email: 'prev@example.com', 328 paypal_capture_id: 'CAP_PREV', 329 amount: 29700, 330 currency: 'USD', 331 status: 'refunded', 332 created_at: new Date().toISOString(), 333 refunded_at: new Date().toISOString(), 334 }; 335 336 const result = await processRefundRequest('prev@example.com', 'I want a refund'); 337 338 assert.equal(result.processed, false); 339 assert.equal(result.reason, 'already_refunded'); 340 assert.equal(refundPaymentMock.mock.calls.length, 0, 'Should not call PayPal for already refunded'); 341 }); 342 343 test('handles no_capture_id purchase gracefully', async () => { 344 resetMocks(); 345 mockPurchaseResult = { 346 id: 11, 347 email: 'nocap@example.com', 348 paypal_capture_id: null, 349 amount: 29700, 350 currency: 'USD', 351 status: 'paid', 352 created_at: new Date().toISOString(), 353 }; 354 355 const result = await processRefundRequest('nocap@example.com', 'I want a refund'); 356 357 assert.equal(result.processed, false); 358 assert.equal(result.reason, 'no_capture_id'); 359 assert.equal(refundPaymentMock.mock.calls.length, 0); 360 }); 361 362 test('handles outside_window purchase gracefully', async () => { 363 resetMocks(); 364 const thirtyDaysAgo = new Date(); 365 thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); 366 367 mockPurchaseResult = { 368 id: 12, 369 email: 'late@example.com', 370 paypal_capture_id: 'CAP_LATE', 371 amount: 29700, 372 currency: 'USD', 373 status: 'paid', 374 created_at: thirtyDaysAgo.toISOString(), 375 }; 376 377 const result = await processRefundRequest('late@example.com', 'I want a refund'); 378 379 assert.equal(result.processed, false); 380 assert.equal(result.reason, 'outside_window'); 381 assert.equal(refundPaymentMock.mock.calls.length, 0); 382 }); 383 384 test('continues successfully even when confirmation email send throws', async () => { 385 resetMocks(); 386 process.env.RESEND_API_KEY = 'test-key-for-email-failure'; 387 388 // Make email send throw — this should be non-fatal 389 emailsSendImpl = async () => { 390 throw new Error('Resend API rate limited'); 391 }; 392 393 mockPurchaseResult = { 394 id: 13, 395 email: 'emailfail@example.com', 396 paypal_capture_id: 'CAP_EMAILFAIL', 397 amount: 15900, 398 currency: 'GBP', 399 status: 'paid', 400 created_at: new Date().toISOString(), 401 }; 402 403 const result = await processRefundRequest( 404 'emailfail@example.com', 405 'I want a refund for my purchase' 406 ); 407 408 // Refund itself should still succeed 409 assert.equal(result.processed, true); 410 assert.equal(result.reason, 'refund_issued'); 411 assert.equal(result.purchaseId, 13); 412 413 // PayPal refund was called 414 assert.equal(refundPaymentMock.mock.calls.length, 1); 415 416 // Email send was attempted 417 assert.equal(emailsSendMock.mock.calls.length, 1); 418 419 delete process.env.RESEND_API_KEY; 420 }); 421 422 test('formats refund amount correctly in confirmation email (cents to dollars)', async () => { 423 resetMocks(); 424 process.env.RESEND_API_KEY = 'test-key-for-formatting'; 425 426 let capturedEmailHtml = null; 427 emailsSendImpl = async (opts) => { 428 capturedEmailHtml = opts.html; 429 return { id: 'email-format-test' }; 430 }; 431 432 mockPurchaseResult = { 433 id: 14, 434 email: 'format@example.com', 435 paypal_capture_id: 'CAP_FORMAT', 436 amount: 29700, // $297.00 in cents 437 currency: 'USD', 438 status: 'paid', 439 created_at: new Date().toISOString(), 440 }; 441 442 await processRefundRequest('format@example.com', 'Please give me a refund'); 443 444 assert.ok(capturedEmailHtml, 'Should have captured email HTML'); 445 assert.ok( 446 capturedEmailHtml.includes('USD 297.00'), 447 `Expected "USD 297.00" in email, got: ${capturedEmailHtml.substring(0, 200)}` 448 ); 449 assert.ok( 450 capturedEmailHtml.includes('REFUND_UNIT_TEST'), 451 'Should include refund ID in email' 452 ); 453 454 delete process.env.RESEND_API_KEY; 455 }); 456 457 test('uses correct sender email from env', async () => { 458 resetMocks(); 459 process.env.RESEND_API_KEY = 'test-key'; 460 process.env.SENDER_EMAIL = 'custom@auditandfix.com'; 461 462 let capturedFrom = null; 463 emailsSendImpl = async (opts) => { 464 capturedFrom = opts.from; 465 return { id: 'email-sender-test' }; 466 }; 467 468 mockPurchaseResult = { 469 id: 15, 470 email: 'sender@example.com', 471 paypal_capture_id: 'CAP_SENDER', 472 amount: 29700, 473 currency: 'USD', 474 status: 'paid', 475 created_at: new Date().toISOString(), 476 }; 477 478 await processRefundRequest('sender@example.com', 'refund please'); 479 480 assert.ok(capturedFrom.includes('custom@auditandfix.com'), `From was: ${capturedFrom}`); 481 482 delete process.env.RESEND_API_KEY; 483 delete process.env.SENDER_EMAIL; 484 }); 485 486 test('detects all refund keyword variants in processRefundRequest', async () => { 487 resetMocks(); 488 delete process.env.RESEND_API_KEY; 489 490 mockPurchaseResult = { 491 id: 16, 492 email: 'kw@example.com', 493 paypal_capture_id: 'CAP_KW', 494 amount: 9700, 495 currency: 'USD', 496 status: 'paid', 497 created_at: new Date().toISOString(), 498 }; 499 500 // Test with "chargeback" keyword 501 const result = await processRefundRequest('kw@example.com', 'I will file a chargeback'); 502 503 assert.equal(result.processed, true); 504 assert.equal(result.reason, 'refund_issued'); 505 }); 506 507 test('passes correct reason to PayPal refundPayment', async () => { 508 resetMocks(); 509 delete process.env.RESEND_API_KEY; 510 511 mockPurchaseResult = { 512 id: 17, 513 email: 'reason@example.com', 514 paypal_capture_id: 'CAP_REASON', 515 amount: 29700, 516 currency: 'USD', 517 status: 'paid', 518 created_at: new Date().toISOString(), 519 }; 520 521 await processRefundRequest('reason@example.com', 'cancel my order please'); 522 523 assert.equal(refundPaymentMock.mock.calls.length, 1); 524 assert.equal(refundPaymentMock.mock.calls[0].arguments[0], 'CAP_REASON'); 525 assert.equal(refundPaymentMock.mock.calls[0].arguments[1], 'Customer requested refund'); 526 }); 527 528 test('updates purchase record with refund status and reason', async () => { 529 resetMocks(); 530 delete process.env.RESEND_API_KEY; 531 532 mockPurchaseResult = { 533 id: 18, 534 email: 'update@example.com', 535 paypal_capture_id: 'CAP_UPDATE', 536 amount: 29700, 537 currency: 'USD', 538 status: 'paid', 539 created_at: new Date().toISOString(), 540 }; 541 542 await processRefundRequest('update@example.com', 'money back please'); 543 544 const updateCall = updateCalls.find(c => c.sql.includes('UPDATE purchases')); 545 assert.ok(updateCall, 'Should update purchases table'); 546 assert.ok(updateCall.sql.includes("status = 'refunded'"), 'Should set status to refunded'); 547 assert.equal(updateCall.params[0], 18, 'Should update the correct purchase ID'); 548 }); 549 });