poll-purchases.test.js
1 /** 2 * Poll Purchases Unit Tests 3 * Tests pollPurchases() with mocked CF Worker and database 4 */ 5 6 import { describe, test, mock, beforeEach } from 'node:test'; 7 import assert from 'node:assert/strict'; 8 import { initTestDb } from '../../src/utils/test-db.js'; 9 10 // Mock axios 11 const axiosGetMock = mock.fn(); 12 const axiosDeleteMock = mock.fn(); 13 mock.module('axios', { 14 defaultExport: { 15 get: axiosGetMock, 16 delete: axiosDeleteMock, 17 }, 18 }); 19 20 // Mock confirmation email 21 const sendConfirmationMock = mock.fn(); 22 mock.module('../../src/reports/purchase-confirmation.js', { 23 namedExports: { 24 sendConfirmationEmail: sendConfirmationMock, 25 }, 26 }); 27 28 let testDb; 29 30 class DatabaseMock { 31 constructor() { 32 // Return a proxy that intercepts close() to prevent closing the shared test db 33 const handler = { 34 get(target, prop) { 35 if (prop === 'close') { 36 return () => {}; 37 } 38 if (prop === 'pragma') { 39 return () => {}; 40 } 41 return target[prop]; 42 }, 43 }; 44 return new Proxy(testDb, handler); 45 } 46 } 47 mock.module('better-sqlite3', { 48 defaultExport: DatabaseMock, 49 }); 50 51 // Mock dotenv 52 mock.module('dotenv', { 53 defaultExport: { config: () => {} }, 54 namedExports: { config: () => {} }, 55 }); 56 57 process.env.AUDITANDFIX_WORKER_URL = 'https://test-worker.dev'; 58 process.env.AUDITANDFIX_WORKER_SECRET = 'test-secret'; 59 60 const { pollPurchases } = await import('../../src/cron/poll-purchases.js'); 61 62 describe('pollPurchases', () => { 63 beforeEach(() => { 64 testDb = initTestDb(); 65 66 // poll-purchases.js still uses conversation_id (pre-migration name). 67 // Add the column so source code inserts work against the test DB. 68 try { 69 testDb.exec('ALTER TABLE purchases ADD COLUMN conversation_id INTEGER'); 70 } catch { 71 // Column may already exist 72 } 73 axiosGetMock.mock.resetCalls(); 74 axiosDeleteMock.mock.resetCalls(); 75 sendConfirmationMock.mock.resetCalls(); 76 sendConfirmationMock.mock.mockImplementation(async () => ({ success: true })); 77 axiosDeleteMock.mock.mockImplementation(async () => ({ status: 200 })); 78 }); 79 80 test('returns zero counts when no purchases found', async () => { 81 axiosGetMock.mock.mockImplementation(async () => ({ 82 data: { purchases: [] }, 83 })); 84 85 const result = await pollPurchases(); 86 assert.equal(result.processed, 0); 87 assert.equal(result.successful, 0); 88 assert.equal(result.failed, 0); 89 }); 90 91 test('skips when worker not configured', async () => { 92 const origUrl = process.env.AUDITANDFIX_WORKER_URL; 93 delete process.env.AUDITANDFIX_WORKER_URL; 94 95 const result = await pollPurchases(); 96 assert.equal(result.processed, 0); 97 98 process.env.AUDITANDFIX_WORKER_URL = origUrl; 99 }); 100 101 test('inserts new purchase into database', async () => { 102 axiosGetMock.mock.mockImplementation(async () => ({ 103 data: { 104 purchases: [ 105 { 106 id: 'kv_1', 107 email: 'customer@example.com', 108 landing_page_url: 'https://test-site.com', 109 phone: '+61412345678', 110 paypal_order_id: 'ORDER_NEW_1', 111 paypal_payer_id: 'PAYER_1', 112 paypal_capture_id: 'CAPTURE_1', 113 amount: 29700, 114 currency: 'USD', 115 amount_usd: 29700, 116 country_code: 'US', 117 ip_address: '1.2.3.4', 118 user_agent: 'TestAgent/1.0', 119 }, 120 ], 121 }, 122 })); 123 124 const result = await pollPurchases(); 125 126 assert.equal(result.processed, 1); 127 assert.equal(result.successful, 1); 128 assert.equal(result.failed, 0); 129 130 // Verify DB insert 131 const purchase = testDb 132 .prepare('SELECT * FROM purchases WHERE paypal_order_id = ?') 133 .get('ORDER_NEW_1'); 134 assert.ok(purchase); 135 assert.equal(purchase.email, 'customer@example.com'); 136 assert.equal(purchase.amount, 29700); 137 assert.equal(purchase.status, 'paid'); 138 139 // Verify confirmation email sent 140 assert.equal(sendConfirmationMock.mock.calls.length, 1); 141 142 // Verify CF Worker delete called 143 assert.equal(axiosDeleteMock.mock.calls.length, 1); 144 assert.ok(axiosDeleteMock.mock.calls[0].arguments[0].includes('kv_1')); 145 }); 146 147 test('skips duplicate purchases', async () => { 148 // Pre-insert a purchase 149 testDb 150 .prepare( 151 `INSERT INTO purchases (email, landing_page_url, paypal_order_id, amount, currency, amount_usd, status) 152 VALUES (?, ?, ?, ?, ?, ?, ?)` 153 ) 154 .run( 155 'existing@test.com', 156 'https://existing.com', 157 'ORDER_EXISTING', 158 29700, 159 'USD', 160 29700, 161 'paid' 162 ); 163 164 axiosGetMock.mock.mockImplementation(async () => ({ 165 data: { 166 purchases: [ 167 { 168 id: 'kv_2', 169 email: 'existing@test.com', 170 landing_page_url: 'https://existing.com', 171 paypal_order_id: 'ORDER_EXISTING', 172 amount: 29700, 173 currency: 'USD', 174 amount_usd: 29700, 175 }, 176 ], 177 }, 178 })); 179 180 const result = await pollPurchases(); 181 182 assert.equal(result.successful, 1); // Still counted as successful processing 183 // Confirmation email should NOT be sent for existing purchase 184 assert.equal(sendConfirmationMock.mock.calls.length, 0); 185 }); 186 187 test('handles CF Worker fetch error', async () => { 188 axiosGetMock.mock.mockImplementation(async () => { 189 throw new Error('Connection refused'); 190 }); 191 192 const result = await pollPurchases(); 193 assert.equal(result.processed, 0); 194 assert.equal(result.failed, 0); 195 }); 196 197 test('processes multiple purchases', async () => { 198 axiosGetMock.mock.mockImplementation(async () => ({ 199 data: { 200 purchases: [ 201 { 202 id: 'kv_a', 203 email: 'a@test.com', 204 landing_page_url: 'https://site-a.com', 205 paypal_order_id: 'ORDER_A', 206 amount: 29700, 207 currency: 'USD', 208 amount_usd: 29700, 209 }, 210 { 211 id: 'kv_b', 212 email: 'b@test.com', 213 landing_page_url: 'https://site-b.com', 214 paypal_order_id: 'ORDER_B', 215 amount: 33700, 216 currency: 'AUD', 217 amount_usd: 24100, 218 }, 219 ], 220 }, 221 })); 222 223 const result = await pollPurchases(); 224 225 assert.equal(result.processed, 2); 226 assert.equal(result.successful, 2); 227 228 // Both should be in DB 229 const count = testDb.prepare('SELECT COUNT(*) as c FROM purchases').get().c; 230 assert.equal(count, 2); 231 }); 232 });