/ tests / payments / pricing-export.test.js
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  });