/ tests / payments / process-purchases.test.js
process-purchases.test.js
  1  /**
  2   * Process Purchases Unit Tests
  3   *
  4   * Tests processPendingPurchases() with mocked report generation and delivery,
  5   * and a real in-memory SQLite database via pg-mock.
  6   *
  7   * Key behaviors tested:
  8   * - Status transitions: paid → processing → delivered
  9   * - Retry logic: increments retry_count on failure, resets status to 'paid'
 10   * - Max retries: after 3 failures, status becomes 'failed' + human_review_queue entry
 11   * - Mixed batch: multiple purchases with mixed outcomes
 12   */
 13  
 14  import { describe, test, mock, before, after, beforeEach } from 'node:test';
 15  import assert from 'node:assert/strict';
 16  import Database from 'better-sqlite3';
 17  import { createPgMock } from '../helpers/pg-mock.js';
 18  
 19  // Mock report modules before importing process-purchases.js
 20  const generateReportMock = mock.fn();
 21  const deliverReportMock = mock.fn();
 22  
 23  mock.module('../../src/reports/report-orchestrator.js', {
 24    namedExports: {
 25      generateAuditReportForPurchase: generateReportMock,
 26    },
 27  });
 28  
 29  mock.module('../../src/reports/report-delivery.js', {
 30    namedExports: {
 31      deliverReport: deliverReportMock,
 32    },
 33  });
 34  
 35  // Mock dotenv
 36  mock.module('dotenv', {
 37    defaultExport: { config: () => {} },
 38    namedExports: { config: () => {} },
 39  });
 40  
 41  // Create in-memory SQLite and mock db.js BEFORE importing process-purchases
 42  const testDb = new Database(':memory:');
 43  
 44  testDb.exec(`
 45    CREATE TABLE IF NOT EXISTS purchases (
 46      id INTEGER PRIMARY KEY AUTOINCREMENT,
 47      email TEXT,
 48      landing_page_url TEXT,
 49      paypal_order_id TEXT UNIQUE,
 50      amount INTEGER,
 51      currency TEXT,
 52      amount_usd INTEGER,
 53      status TEXT DEFAULT 'paid',
 54      retry_count INTEGER DEFAULT 0,
 55      error_message TEXT,
 56      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
 57      updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
 58      delivered_at DATETIME,
 59      site_id INTEGER,
 60      report_path TEXT,
 61      report_score REAL,
 62      report_grade TEXT
 63    );
 64    CREATE TABLE IF NOT EXISTS human_review_queue (
 65      id INTEGER PRIMARY KEY AUTOINCREMENT,
 66      file TEXT NOT NULL,
 67      type TEXT NOT NULL,
 68      priority TEXT NOT NULL DEFAULT 'medium',
 69      reason TEXT,
 70      status TEXT NOT NULL DEFAULT 'pending',
 71      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
 72      resolved_at DATETIME
 73    );
 74  `);
 75  
 76  mock.module('../../src/utils/db.js', { namedExports: createPgMock(testDb) });
 77  
 78  const { processPendingPurchases } = await import('../../src/cron/process-purchases.js');
 79  
 80  /**
 81   * Insert a purchase into the test DB
 82   */
 83  function insertPurchase(overrides = {}) {
 84    const defaults = {
 85      email: 'test@example.com',
 86      landing_page_url: 'https://test-biz.com',
 87      paypal_order_id: `ORDER_${Date.now()}_${Math.random().toString(36).slice(2)}`,
 88      amount: 29700,
 89      currency: 'USD',
 90      amount_usd: 29700,
 91      status: 'paid',
 92      retry_count: 0,
 93    };
 94    const data = { ...defaults, ...overrides };
 95  
 96    const result = testDb
 97      .prepare(
 98        `INSERT INTO purchases (email, landing_page_url, paypal_order_id, amount, currency, amount_usd, status, retry_count)
 99         VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
100      )
101      .run(
102        data.email,
103        data.landing_page_url,
104        data.paypal_order_id,
105        data.amount,
106        data.currency,
107        data.amount_usd,
108        data.status,
109        data.retry_count
110      );
111  
112    return result.lastInsertRowid;
113  }
114  
115  /**
116   * Age a purchase to be older than 6 hours so it passes any age gate
117   */
118  function ageRecord(id) {
119    testDb
120      .prepare("UPDATE purchases SET created_at = datetime('now', '-7 hours') WHERE id = ?")
121      .run(id);
122  }
123  
124  describe('processPendingPurchases', () => {
125    beforeEach(() => {
126      // Clear purchases between tests
127      testDb.prepare('DELETE FROM purchases').run();
128      testDb.prepare('DELETE FROM human_review_queue').run();
129  
130      generateReportMock.mock.resetCalls();
131      deliverReportMock.mock.resetCalls();
132  
133      // Default: both succeed
134      generateReportMock.mock.mockImplementation(async () => ({ success: true }));
135      deliverReportMock.mock.mockImplementation(async () => ({
136        success: true,
137        emailId: 'email_123',
138      }));
139    });
140  
141    test('returns zero counts with empty queue', async () => {
142      const result = await processPendingPurchases();
143  
144      assert.deepEqual(result, { processed: 0, delivered: 0, failed: 0 });
145      assert.equal(generateReportMock.mock.calls.length, 0);
146    });
147  
148    test('processes fresh purchases immediately (no age gate)', async () => {
149      insertPurchase({ email: 'fresh@test.com', paypal_order_id: 'FRESH_ORDER' });
150  
151      const result = await processPendingPurchases();
152  
153      assert.equal(result.processed, 1, 'Fresh purchase should be processed immediately');
154      assert.equal(result.delivered, 1);
155      assert.equal(generateReportMock.mock.calls.length, 1);
156    });
157  
158    test('processes purchase older than 6 hours', async () => {
159      const id = insertPurchase({ email: 'old@test.com', paypal_order_id: 'OLD_ORDER' });
160      ageRecord(id);
161  
162      const result = await processPendingPurchases();
163  
164      assert.equal(result.processed, 1);
165      assert.equal(result.delivered, 1);
166      assert.equal(result.failed, 0);
167  
168      const purchase = testDb.prepare('SELECT * FROM purchases WHERE id = ?').get(id);
169      assert.equal(purchase.status, 'delivered');
170      assert.ok(purchase.delivered_at, 'delivered_at should be set');
171    });
172  
173    test('sets status to processing before generating report', async () => {
174      const id = insertPurchase({ paypal_order_id: 'ORDER_STATUS_CHECK' });
175      ageRecord(id);
176  
177      let statusDuringGeneration;
178      generateReportMock.mock.mockImplementation(async () => {
179        statusDuringGeneration = testDb
180          .prepare('SELECT status FROM purchases WHERE id = ?')
181          .get(id).status;
182        return { success: true };
183      });
184  
185      await processPendingPurchases();
186  
187      assert.equal(
188        statusDuringGeneration,
189        'processing',
190        'Status should be processing during report generation'
191      );
192    });
193  
194    test('increments retry_count and resets to paid on first failure', async () => {
195      const id = insertPurchase({ paypal_order_id: 'ORDER_FAIL_1', retry_count: 0 });
196      ageRecord(id);
197  
198      generateReportMock.mock.mockImplementation(async () => {
199        throw new Error('Browser timeout');
200      });
201  
202      const result = await processPendingPurchases();
203  
204      assert.equal(result.processed, 1);
205      assert.equal(result.failed, 1);
206      assert.equal(result.delivered, 0);
207  
208      const purchase = testDb.prepare('SELECT * FROM purchases WHERE id = ?').get(id);
209      assert.equal(purchase.status, 'paid', 'Should reset to paid for retry');
210      assert.equal(purchase.retry_count, 1);
211      assert.equal(purchase.error_message, 'Browser timeout');
212    });
213  
214    test('escalates to failed status after MAX_RETRIES (3) failures', async () => {
215      const id = insertPurchase({ paypal_order_id: 'ORDER_MAX_RETRY', retry_count: 2 });
216      ageRecord(id);
217  
218      generateReportMock.mock.mockImplementation(async () => {
219        throw new Error('Max retries exceeded');
220      });
221  
222      await processPendingPurchases();
223  
224      const purchase = testDb.prepare('SELECT * FROM purchases WHERE id = ?').get(id);
225      assert.equal(purchase.status, 'failed');
226      assert.equal(purchase.retry_count, 3);
227      assert.equal(purchase.error_message, 'Max retries exceeded');
228    });
229  
230    test('adds failed purchase to human_review_queue', async () => {
231      const id = insertPurchase({
232        email: 'maxretry@test.com',
233        paypal_order_id: 'ORDER_HUMAN_REVIEW',
234        retry_count: 2,
235      });
236      ageRecord(id);
237  
238      generateReportMock.mock.mockImplementation(async () => {
239        throw new Error('Report generation crashed');
240      });
241  
242      await processPendingPurchases();
243  
244      const queueEntry = testDb
245        .prepare('SELECT * FROM human_review_queue WHERE file = ?')
246        .get(`purchase_${id}`);
247  
248      assert.ok(queueEntry, 'Should have human review queue entry');
249      assert.equal(queueEntry.type, 'purchase_failure');
250      assert.equal(queueEntry.priority, 'high');
251      assert.ok(queueEntry.reason.includes('maxretry@test.com'));
252      assert.ok(queueEntry.reason.includes('Report generation crashed'));
253    });
254  
255    test('delivery failure also counts as failed', async () => {
256      const id = insertPurchase({ paypal_order_id: 'ORDER_DELIVERY_FAIL', retry_count: 0 });
257      ageRecord(id);
258  
259      generateReportMock.mock.mockImplementation(async () => ({ success: true }));
260      deliverReportMock.mock.mockImplementation(async () => {
261        throw new Error('Email delivery failed: invalid address');
262      });
263  
264      const result = await processPendingPurchases();
265  
266      assert.equal(result.failed, 1);
267  
268      const purchase = testDb.prepare('SELECT * FROM purchases WHERE id = ?').get(id);
269      assert.equal(purchase.status, 'paid', 'Should reset to paid for retry');
270      assert.equal(purchase.retry_count, 1);
271    });
272  
273    test('processes mixed batch: one success + one failure', async () => {
274      const successId = insertPurchase({
275        email: 'success@test.com',
276        paypal_order_id: 'ORDER_SUCCESS',
277      });
278      const failId = insertPurchase({ email: 'fail@test.com', paypal_order_id: 'ORDER_FAIL' });
279      ageRecord(successId);
280      ageRecord(failId);
281  
282      let callCount = 0;
283      generateReportMock.mock.mockImplementation(async () => {
284        callCount++;
285        if (callCount === 2) throw new Error('Second purchase failed');
286        return { success: true };
287      });
288  
289      const result = await processPendingPurchases();
290  
291      assert.equal(result.processed, 2);
292      assert.equal(result.delivered, 1);
293      assert.equal(result.failed, 1);
294    });
295  
296    test('processes multiple aged purchases in order (oldest first)', async () => {
297      const ids = [];
298      for (let i = 0; i < 3; i++) {
299        const id = insertPurchase({ paypal_order_id: `ORDER_MULTI_${i}` });
300        ageRecord(id);
301        ids.push(id);
302      }
303  
304      const processedIds = [];
305      generateReportMock.mock.mockImplementation(async purchaseId => {
306        processedIds.push(purchaseId);
307        return { success: true };
308      });
309  
310      const result = await processPendingPurchases();
311  
312      assert.equal(result.processed, 3);
313      assert.equal(result.delivered, 3);
314      assert.equal(processedIds.length, 3);
315    });
316  
317    test('skips non-paid statuses (processing, delivered, failed, refunded)', async () => {
318      const statuses = ['processing', 'delivered', 'failed', 'refunded'];
319      for (const status of statuses) {
320        const id = insertPurchase({ paypal_order_id: `ORDER_SKIP_${status}`, status });
321        ageRecord(id);
322      }
323  
324      const result = await processPendingPurchases();
325      assert.equal(result.processed, 0, 'Should only pick up paid purchases');
326    });
327  });