pricing-export.test.js
1 /** 2 * Pricing Export Unit Tests 3 * Tests exportPricing() with mocked CF Worker and filesystem 4 */ 5 6 import { describe, test, mock, beforeEach } from 'node:test'; 7 import assert from 'node:assert/strict'; 8 import Database from 'better-sqlite3'; 9 import { createPgMock } from '../helpers/pg-mock.js'; 10 11 // Mock axios 12 const axiosPostMock = mock.fn(); 13 mock.module('axios', { 14 defaultExport: { post: axiosPostMock }, 15 }); 16 17 // Mock fs - must include createWriteStream for Logger 18 const realFs = await import('fs'); 19 const writeFileSyncMock = mock.fn(); 20 const mkdirSyncMock = mock.fn(); 21 mock.module('fs', { 22 namedExports: { 23 writeFileSync: writeFileSyncMock, 24 mkdirSync: mkdirSyncMock, 25 readFileSync: realFs.readFileSync, 26 existsSync: realFs.existsSync, 27 createWriteStream: realFs.createWriteStream, 28 statSync: realFs.statSync, 29 unlinkSync: realFs.unlinkSync, 30 readdirSync: realFs.readdirSync, 31 }, 32 }); 33 34 // Mock country-pricing — must export both getAllCountries and getPrice 35 const getAllCountriesMock = mock.fn(); 36 const getPriceMock = mock.fn(); 37 mock.module('../../src/utils/country-pricing.js', { 38 namedExports: { 39 getAllCountries: getAllCountriesMock, 40 getPrice: getPriceMock, 41 }, 42 }); 43 44 // Mock dotenv 45 mock.module('dotenv', { 46 defaultExport: { config: () => {} }, 47 namedExports: { config: () => {} }, 48 }); 49 50 // Set up in-memory SQLite with sites table for getOne('SELECT COUNT(*) ...') 51 const testDb = new Database(':memory:'); 52 testDb.exec(` 53 CREATE TABLE IF NOT EXISTS sites ( 54 id INTEGER PRIMARY KEY AUTOINCREMENT, 55 status TEXT DEFAULT 'found', 56 rescored_at DATETIME 57 ); 58 `); 59 // Seed some sites so COUNT returns a non-zero value 60 for (let i = 0; i < 12345; i += 1000) { 61 testDb.prepare("INSERT INTO sites (status) VALUES ('scored')").run(); 62 } 63 64 mock.module('../../src/utils/db.js', { namedExports: createPgMock(testDb) }); 65 66 process.env.API_WORKER_URL = 'https://test-worker.dev'; 67 process.env.API_WORKER_SECRET = 'test-secret'; 68 69 const { exportPricing } = await import('../../src/api/pricing-export.js'); 70 71 // ── Helpers ─────────────────────────────────────────────────────────────────── 72 73 /** Minimal country row as returned by getAllCountries() */ 74 function makeCountry(overrides = {}) { 75 return { 76 country_code: 'US', 77 country_name: 'United States', 78 price_usd: 29700, 79 is_active: 1, 80 ...overrides, 81 }; 82 } 83 84 /** Minimal getPrice() return value */ 85 function makePrice(overrides = {}) { 86 return { 87 priceLocalCents: 29700, 88 currency: 'USD', 89 currencySymbol: '$', 90 formattedPrice: '$297', 91 ...overrides, 92 }; 93 } 94 95 // ── Tests ───────────────────────────────────────────────────────────────────── 96 97 describe('exportPricing', () => { 98 beforeEach(() => { 99 axiosPostMock.mock.resetCalls(); 100 writeFileSyncMock.mock.resetCalls(); 101 mkdirSyncMock.mock.resetCalls(); 102 getAllCountriesMock.mock.resetCalls(); 103 getPriceMock.mock.resetCalls(); 104 // Default implementations — individual tests override as needed 105 getAllCountriesMock.mock.mockImplementation(() => []); 106 getPriceMock.mock.mockImplementation(() => null); 107 axiosPostMock.mock.mockImplementation(async () => ({ status: 200 })); 108 }); 109 110 test('exports pricing to CF Worker and local file', async () => { 111 getAllCountriesMock.mock.mockImplementation(() => [ 112 makeCountry({ country_code: 'US', country_name: 'United States', price_usd: 29700 }), 113 makeCountry({ country_code: 'AU', country_name: 'Australia', price_usd: 33700 }), 114 ]); 115 getPriceMock.mock.mockImplementation(code => { 116 if (code === 'US') 117 return makePrice({ 118 priceLocalCents: 29700, 119 currency: 'USD', 120 currencySymbol: '$', 121 formattedPrice: '$297', 122 }); 123 if (code === 'AU') 124 return makePrice({ 125 priceLocalCents: 33700, 126 currency: 'AUD', 127 currencySymbol: 'A$', 128 formattedPrice: 'A$337', 129 }); 130 return null; 131 }); 132 axiosPostMock.mock.mockImplementation(async () => ({ status: 200 })); 133 134 const result = await exportPricing(); 135 136 assert.equal(result.success, true); 137 assert.equal(result.countries, 2); 138 139 // Check CF Worker POST 140 assert.equal(axiosPostMock.mock.calls.length, 1); 141 const postCall = axiosPostMock.mock.calls[0]; 142 assert.ok(postCall.arguments[0].includes('/pricing')); 143 const pricingData = postCall.arguments[1]; 144 assert.equal(pricingData.US.price, 29700); 145 assert.equal(pricingData.US.currency, 'USD'); 146 assert.equal(pricingData.AU.price, 33700); 147 assert.equal(pricingData.AU.formatted, 'A$337'); 148 149 // Check auth header 150 assert.equal(postCall.arguments[2].headers['X-Auth-Secret'], 'test-secret'); 151 152 // Check local backup 153 assert.equal(writeFileSyncMock.mock.calls.length, 1); 154 const writtenContent = JSON.parse(writeFileSyncMock.mock.calls[0].arguments[1]); 155 assert.equal(writtenContent.US.currency, 'USD'); 156 }); 157 158 test('includes country_name in each pricing entry', async () => { 159 getAllCountriesMock.mock.mockImplementation(() => [ 160 makeCountry({ country_code: 'DE', country_name: 'Germany' }), 161 ]); 162 getPriceMock.mock.mockImplementation(() => 163 makePrice({ 164 priceLocalCents: 21000, 165 currency: 'EUR', 166 currencySymbol: '€', 167 formattedPrice: '€210', 168 }) 169 ); 170 axiosPostMock.mock.mockImplementation(async () => ({ status: 200 })); 171 172 await exportPricing(); 173 174 const pricingData = axiosPostMock.mock.calls[0].arguments[1]; 175 assert.equal(pricingData.DE.country_name, 'Germany'); 176 }); 177 178 test('adds GB as alias for UK with same GBP pricing', async () => { 179 getAllCountriesMock.mock.mockImplementation(() => [ 180 makeCountry({ country_code: 'UK', country_name: 'United Kingdom', price_usd: 15900 }), 181 ]); 182 getPriceMock.mock.mockImplementation(() => 183 makePrice({ 184 priceLocalCents: 15900, 185 currency: 'GBP', 186 currencySymbol: '£', 187 formattedPrice: '£159', 188 }) 189 ); 190 axiosPostMock.mock.mockImplementation(async () => ({ status: 200 })); 191 192 await exportPricing(); 193 194 const pricingData = axiosPostMock.mock.calls[0].arguments[1]; 195 196 assert.ok(pricingData['UK'], 'UK entry should exist'); 197 assert.equal(pricingData['UK'].currency, 'GBP'); 198 199 assert.ok(pricingData['GB'], 'GB alias should be added automatically'); 200 assert.equal(pricingData['GB'].currency, 'GBP'); 201 assert.equal(pricingData['GB'].country_name, 'United Kingdom'); 202 203 // GB and UK should be the same object 204 assert.deepEqual(pricingData['GB'], pricingData['UK']); 205 }); 206 207 test('does not overwrite existing GB entry if present', async () => { 208 getAllCountriesMock.mock.mockImplementation(() => [ 209 makeCountry({ country_code: 'UK', country_name: 'United Kingdom', price_usd: 15900 }), 210 makeCountry({ country_code: 'GB', country_name: 'Great Britain', price_usd: 99900 }), 211 ]); 212 getPriceMock.mock.mockImplementation(code => { 213 if (code === 'UK') 214 return makePrice({ 215 priceLocalCents: 15900, 216 currency: 'GBP', 217 currencySymbol: '£', 218 formattedPrice: '£159', 219 }); 220 if (code === 'GB') 221 return makePrice({ 222 priceLocalCents: 99900, 223 currency: 'GBP', 224 currencySymbol: '£', 225 formattedPrice: '£999', 226 }); 227 return null; 228 }); 229 axiosPostMock.mock.mockImplementation(async () => ({ status: 200 })); 230 231 await exportPricing(); 232 233 const pricingData = axiosPostMock.mock.calls[0].arguments[1]; 234 // GB row from DB should be preserved as-is, alias not applied 235 assert.equal(pricingData['GB'].price, 99900); 236 }); 237 238 test('falls back to USD price when no local price set', async () => { 239 getAllCountriesMock.mock.mockImplementation(() => [ 240 makeCountry({ country_code: 'US', country_name: 'United States', price_usd: 29700 }), 241 ]); 242 // priceLocalCents === 0 → should fall back to price_usd with USD symbol 243 getPriceMock.mock.mockImplementation(() => 244 makePrice({ priceLocalCents: 0, currency: 'XYZ', currencySymbol: 'X', formattedPrice: 'X0' }) 245 ); 246 axiosPostMock.mock.mockImplementation(async () => ({ status: 200 })); 247 248 await exportPricing(); 249 250 const pricingData = axiosPostMock.mock.calls[0].arguments[1]; 251 assert.equal(pricingData.US.currency, 'USD'); 252 assert.equal(pricingData.US.symbol, '$'); 253 assert.equal(pricingData.US.price, 29700); // uses price_usd fallback 254 }); 255 256 test('handles missing worker config gracefully', async () => { 257 const origUrl = process.env.API_WORKER_URL; 258 delete process.env.API_WORKER_URL; 259 260 getAllCountriesMock.mock.mockImplementation(() => [makeCountry()]); 261 getPriceMock.mock.mockImplementation(() => makePrice()); 262 263 const result = await exportPricing(); 264 265 assert.equal(result.success, false); 266 assert.equal(result.reason, 'not_configured'); 267 // Local backup should still be written 268 assert.equal(writeFileSyncMock.mock.calls.length, 1); 269 // No CF Worker POST 270 assert.equal(axiosPostMock.mock.calls.length, 0); 271 272 process.env.API_WORKER_URL = origUrl; 273 }); 274 275 test('handles CF Worker POST failure gracefully', async () => { 276 getAllCountriesMock.mock.mockImplementation(() => [makeCountry()]); 277 getPriceMock.mock.mockImplementation(() => makePrice()); 278 axiosPostMock.mock.mockImplementation(async () => { 279 throw new Error('Network error'); 280 }); 281 282 const result = await exportPricing(); 283 284 assert.equal(result.success, false); 285 assert.ok(result.error.includes('Network error')); 286 }); 287 288 test('passes activeOnly option to getAllCountries', async () => { 289 getAllCountriesMock.mock.mockImplementation(() => []); 290 axiosPostMock.mock.mockImplementation(async () => ({ status: 200 })); 291 292 await exportPricing(); 293 294 assert.equal(getAllCountriesMock.mock.calls.length, 1); 295 const opts = getAllCountriesMock.mock.calls[0].arguments[0]; 296 assert.equal(opts.activeOnly, true); 297 }); 298 299 test('skips countries where getPrice returns null', async () => { 300 getAllCountriesMock.mock.mockImplementation(() => [ 301 makeCountry({ country_code: 'US', country_name: 'United States' }), 302 makeCountry({ country_code: 'XX', country_name: 'Unknown' }), 303 ]); 304 getPriceMock.mock.mockImplementation(code => (code === 'US' ? makePrice() : null)); 305 axiosPostMock.mock.mockImplementation(async () => ({ status: 200 })); 306 307 await exportPricing(); 308 309 const pricingData = axiosPostMock.mock.calls[0].arguments[1]; 310 assert.ok(pricingData['US'], 'US should be present'); 311 assert.equal(pricingData['XX'], undefined, 'XX with null price should be skipped'); 312 }); 313 });