/ tests / payments / refund-processor-unit.test.js
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  });