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 });