/ tests / utils / country-pricing.test.js
country-pricing.test.js
  1  /**
  2   * Tests for src/utils/country-pricing.js
  3   *
  4   * Tests all exports: getCountry, getPrice, getAllCountries, getCountriesByTier,
  5   * getPricingSummary, overridePrice, getAcceptLanguage, getGoogleDomain, requiresGDPR.
  6   *
  7   * Uses pg-mock pattern since country-pricing.js imports from db.js.
  8   */
  9  
 10  import { test, describe, mock } from 'node:test';
 11  import assert from 'node:assert/strict';
 12  import Database from 'better-sqlite3';
 13  import { createPgMock } from '../helpers/pg-mock.js';
 14  
 15  const db = new Database(':memory:');
 16  db.exec(`
 17    CREATE TABLE IF NOT EXISTS countries (
 18      country_code TEXT PRIMARY KEY,
 19      country_name TEXT NOT NULL,
 20      google_domain TEXT NOT NULL,
 21      language_code TEXT NOT NULL,
 22      timezone TEXT NOT NULL,
 23      currency_code TEXT NOT NULL,
 24      currency_symbol TEXT NOT NULL,
 25      date_format TEXT NOT NULL DEFAULT 'DD/MM/YYYY',
 26      price_usd INTEGER NOT NULL,
 27      pricing_tier TEXT NOT NULL DEFAULT 'Standard',
 28      ppp_gdp_per_capita INTEGER,
 29      market_notes TEXT,
 30      is_price_sensitive INTEGER DEFAULT 0,
 31      is_premium_market INTEGER DEFAULT 0,
 32      requires_gdpr_check INTEGER DEFAULT 0,
 33      phone_format TEXT,
 34      accept_language TEXT,
 35      company_types TEXT,
 36      company_keywords TEXT,
 37      common_cities TEXT,
 38      key_page_names TEXT,
 39      is_active INTEGER DEFAULT 1,
 40      created_at TEXT DEFAULT CURRENT_TIMESTAMP,
 41      updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
 42      price_overridden INTEGER DEFAULT 0,
 43      override_reason TEXT,
 44      override_date TEXT,
 45      price_usd_base INTEGER DEFAULT 30000,
 46      price_usd_ppp INTEGER,
 47      price_local INTEGER,
 48      price_local_formatted TEXT,
 49      exchange_rate REAL,
 50      price_last_updated TEXT,
 51      pricing_variant TEXT DEFAULT 'control',
 52      twilio_phone_number TEXT
 53    )
 54  `);
 55  
 56  // Insert test countries
 57  db.prepare(
 58    `
 59    INSERT INTO countries (
 60      country_code, country_name, google_domain, language_code, timezone,
 61      currency_code, currency_symbol, price_usd, pricing_tier,
 62      requires_gdpr_check, is_price_sensitive, accept_language,
 63      common_cities, company_types, company_keywords, key_page_names,
 64      price_usd_ppp, price_local, price_local_formatted, exchange_rate,
 65      is_active
 66    ) VALUES (
 67      'AU', 'Australia', 'google.com.au', 'en', 'Australia/Sydney',
 68      'AUD', '$', 29700, 'Premium',
 69      0, 0, 'en-AU',
 70      '["Sydney","Melbourne"]', '["Pty Ltd","Ltd"]', '["pty","ltd"]', '["contact","about"]',
 71      29700, 33700, '337', 1.134,
 72      1
 73    )
 74  `
 75  ).run();
 76  
 77  db.prepare(
 78    `
 79    INSERT INTO countries (
 80      country_code, country_name, google_domain, language_code, timezone,
 81      currency_code, currency_symbol, price_usd, pricing_tier,
 82      requires_gdpr_check, is_price_sensitive, accept_language,
 83      price_usd_ppp, price_local, price_local_formatted, exchange_rate,
 84      is_active
 85    ) VALUES (
 86      'US', 'United States', 'google.com', 'en', 'America/New_York',
 87      'USD', '$', 29700, 'Premium+',
 88      0, 0, 'en-US',
 89      29700, 29700, '297', 1.0,
 90      1
 91    )
 92  `
 93  ).run();
 94  
 95  db.prepare(
 96    `
 97    INSERT INTO countries (
 98      country_code, country_name, google_domain, language_code, timezone,
 99      currency_code, currency_symbol, price_usd, pricing_tier,
100      requires_gdpr_check, is_price_sensitive, accept_language,
101      price_usd_ppp, price_local, price_local_formatted, exchange_rate,
102      is_active
103    ) VALUES (
104      'DE', 'Germany', 'google.de', 'de', 'Europe/Berlin',
105      'EUR', '€', 19700, 'Standard',
106      1, 0, 'de-DE',
107      19700, 18700, '187', 0.92,
108      1
109    )
110  `
111  ).run();
112  
113  // Inactive country — should not appear in queries
114  db.prepare(
115    `
116    INSERT INTO countries (
117      country_code, country_name, google_domain, language_code, timezone,
118      currency_code, currency_symbol, price_usd, pricing_tier, is_active,
119      price_usd_ppp, price_local, price_local_formatted, exchange_rate
120    ) VALUES (
121      'XX', 'Inactive Country', 'google.xx', 'en', 'UTC',
122      'XXX', 'X', 10000, 'Developing', 0,
123      10000, 10000, '100', 1.0
124    )
125  `
126  ).run();
127  
128  mock.module('../../src/utils/db.js', { namedExports: createPgMock(db) });
129  
130  const {
131    getCountry,
132    getPrice,
133    getAllCountries,
134    getCountriesByTier,
135    getPricingSummary,
136    overridePrice,
137    getAcceptLanguage,
138    getGoogleDomain,
139    requiresGDPR,
140  } = await import('../../src/utils/country-pricing.js');
141  
142  // ─── getCountry ───────────────────────────────────────────────────────────────
143  
144  describe('getCountry', () => {
145    test('returns country object for known country', async () => {
146      const country = await getCountry('AU');
147      assert.ok(country !== null);
148      assert.equal(country.country_code, 'AU');
149      assert.equal(country.country_name, 'Australia');
150    });
151  
152    test('parses JSON array fields', async () => {
153      const country = await getCountry('AU');
154      assert.ok(Array.isArray(country.commonCities));
155      assert.ok(country.commonCities.includes('Sydney'));
156      assert.ok(Array.isArray(country.companyTypes));
157      assert.ok(Array.isArray(country.companyKeywords));
158      assert.ok(Array.isArray(country.keyPageNames));
159    });
160  
161    test('converts price_usd from cents to dollars', async () => {
162      const country = await getCountry('AU');
163      assert.equal(country.priceUsd, 297); // 29700 / 100
164    });
165  
166    test('returns null for unknown country', async () => {
167      const result = await getCountry('ZZ');
168      assert.equal(result, null);
169    });
170  
171    test('returns null for inactive country', async () => {
172      const result = await getCountry('XX');
173      assert.equal(result, null);
174    });
175  
176    test('is case-insensitive (converts to uppercase)', async () => {
177      const result = await getCountry('au');
178      assert.ok(result !== null);
179      assert.equal(result.country_code, 'AU');
180    });
181  });
182  
183  // ─── getPrice ─────────────────────────────────────────────────────────────────
184  
185  describe('getPrice', () => {
186    test('returns pricing object for known country', async () => {
187      const price = await getPrice('AU');
188      assert.ok(price !== null);
189      assert.equal(price.countryCode, 'AU');
190      assert.equal(price.currency, 'AUD');
191      assert.equal(price.currencySymbol, '$');
192      assert.equal(price.formattedPrice, '$337');
193    });
194  
195    test('converts prices from cents to dollars', async () => {
196      const price = await getPrice('AU');
197      assert.equal(price.priceUsd, 297); // 29700 / 100
198      assert.equal(price.priceLocal, 337); // 33700 / 100
199    });
200  
201    test('includes tier and variant info', async () => {
202      const price = await getPrice('AU');
203      assert.ok('tier' in price);
204      assert.ok('variant' in price);
205      assert.ok('exchangeRate' in price);
206    });
207  
208    test('includes isPriceSensitive boolean', async () => {
209      const price = await getPrice('AU');
210      assert.equal(typeof price.isPriceSensitive, 'boolean');
211      assert.equal(price.isPriceSensitive, false);
212    });
213  
214    test('returns null for unknown country', async () => {
215      const result = await getPrice('ZZ');
216      assert.equal(result, null);
217    });
218  
219    test('is case-insensitive', async () => {
220      const result = await getPrice('us');
221      assert.ok(result !== null);
222      assert.equal(result.countryCode, 'US');
223    });
224  });
225  
226  // ─── getAllCountries ──────────────────────────────────────────────────────────
227  
228  describe('getAllCountries', () => {
229    test('returns only active countries', async () => {
230      const countries = await getAllCountries();
231      const codes = countries.map(c => c.country_code);
232      assert.ok(codes.includes('AU'));
233      assert.ok(codes.includes('US'));
234      assert.ok(!codes.includes('XX'), 'should exclude inactive country');
235    });
236  
237    test('returns at least 3 active countries (AU, US, DE)', async () => {
238      const countries = await getAllCountries();
239      assert.ok(countries.length >= 3);
240    });
241  
242    test('parses JSON fields on each country', async () => {
243      const countries = await getAllCountries();
244      const au = countries.find(c => c.country_code === 'AU');
245      assert.ok(au);
246      assert.ok(Array.isArray(au.commonCities));
247    });
248  
249    test('respects limit option', async () => {
250      const countries = await getAllCountries({ limit: 1 });
251      assert.equal(countries.length, 1);
252    });
253  });
254  
255  // ─── getCountriesByTier ───────────────────────────────────────────────────────
256  
257  describe('getCountriesByTier', () => {
258    test('returns countries matching the tier', async () => {
259      const countries = await getCountriesByTier('Premium');
260      assert.ok(countries.length >= 1);
261      assert.ok(countries.every(c => c.pricing_tier === 'Premium'));
262    });
263  
264    test('returns empty array for unknown tier', async () => {
265      const result = await getCountriesByTier('UnknownTier');
266      assert.deepEqual(result, []);
267    });
268  
269    test('converts priceUsd to dollars', async () => {
270      const countries = await getCountriesByTier('Premium+');
271      assert.ok(countries.length >= 1);
272      assert.equal(countries[0].priceUsd, 297);
273    });
274  });
275  
276  // ─── getPricingSummary ────────────────────────────────────────────────────────
277  
278  describe('getPricingSummary', () => {
279    test('returns summary object with expected fields', async () => {
280      const summary = await getPricingSummary();
281      assert.ok(summary !== null);
282      assert.ok('totalCountries' in summary);
283      assert.ok('minPrice' in summary);
284      assert.ok('maxPrice' in summary);
285      assert.ok('avgPrice' in summary);
286      assert.ok('tierCount' in summary);
287      assert.ok('gdprCountries' in summary);
288      assert.ok('priceSensitiveCountries' in summary);
289      assert.ok('tierBreakdown' in summary);
290    });
291  
292    test('totalCountries counts only active countries', async () => {
293      const summary = await getPricingSummary();
294      assert.equal(summary.totalCountries, 3); // AU, US, DE (XX is inactive)
295    });
296  
297    test('gdprCountries counts countries with requires_gdpr_check=1', async () => {
298      const summary = await getPricingSummary();
299      assert.equal(summary.gdprCountries, 1); // Only DE
300    });
301  
302    test('tierBreakdown is an array with tier/count/avgPrice fields', async () => {
303      const summary = await getPricingSummary();
304      assert.ok(Array.isArray(summary.tierBreakdown));
305      assert.ok(summary.tierBreakdown.length > 0);
306      const tier = summary.tierBreakdown[0];
307      assert.ok('tier' in tier);
308      assert.ok('count' in tier);
309      assert.ok('avgPrice' in tier);
310    });
311  });
312  
313  // ─── overridePrice ────────────────────────────────────────────────────────────
314  
315  describe('overridePrice', () => {
316    test('updates price and returns true for existing country', async () => {
317      const result = await overridePrice('AU', 349, 'Test override');
318      assert.equal(result, true);
319  
320      // Verify the update took effect
321      const updated = db
322        .prepare(
323          'SELECT price_usd, price_overridden, override_reason FROM countries WHERE country_code = ?'
324        )
325        .get('AU');
326      assert.equal(updated.price_usd, 34900); // $349 → 34900 cents
327      assert.equal(updated.price_overridden, 1);
328      assert.equal(updated.override_reason, 'Test override');
329  
330      // Restore original
331      db.prepare(
332        'UPDATE countries SET price_usd = 29700, price_overridden = 0, override_reason = NULL WHERE country_code = ?'
333      ).run('AU');
334    });
335  
336    test('returns false for non-existent country', async () => {
337      const result = await overridePrice('ZZ', 100, 'Should fail');
338      assert.equal(result, false);
339    });
340  });
341  
342  // ─── getAcceptLanguage ────────────────────────────────────────────────────────
343  
344  describe('getAcceptLanguage', () => {
345    test('returns accept_language for known country', async () => {
346      const result = await getAcceptLanguage('AU');
347      assert.equal(result, 'en-AU');
348    });
349  
350    test('returns null for unknown country', async () => {
351      const result = await getAcceptLanguage('ZZ');
352      assert.equal(result, null);
353    });
354  });
355  
356  // ─── getGoogleDomain ──────────────────────────────────────────────────────────
357  
358  describe('getGoogleDomain', () => {
359    test('returns google_domain for known country', async () => {
360      const result = await getGoogleDomain('AU');
361      assert.equal(result, 'google.com.au');
362    });
363  
364    test('returns null for unknown country', async () => {
365      const result = await getGoogleDomain('ZZ');
366      assert.equal(result, null);
367    });
368  });
369  
370  // ─── requiresGDPR ─────────────────────────────────────────────────────────────
371  
372  describe('requiresGDPR', () => {
373    test('returns false for non-GDPR country', async () => {
374      const result = await requiresGDPR('AU');
375      assert.equal(result, false);
376    });
377  
378    test('returns true for GDPR country', async () => {
379      const result = await requiresGDPR('DE');
380      assert.equal(result, true);
381    });
382  
383    test('returns false for unknown country', async () => {
384      const result = await requiresGDPR('ZZ');
385      assert.equal(result, false);
386    });
387  });