weekly-repricing-supplement.test.js
1 /** 2 * Supplement tests for src/cron/weekly-repricing.js 3 * 4 * The existing weekly-repricing.test.js covers: 5 * - applyPPPAdjustment (5 tests) 6 * - roundToNearest7 (4 tests) 7 * - applyCulturalRounding (all countries, ~30 tests) 8 * - updateCountryPricing with old signature (broken — tries to connect to PG) 9 * 10 * This supplement covers: 11 * - updateCountryPricing via pg-mock (proper async DB path) 12 * - fetchExchangeRates: missing API key error path 13 * - fetchExchangeRates: successful response / stale data / API error paths 14 * - applyCulturalRounding: CN lucky-8 boundary cases not covered by existing tests 15 * - applyCulturalRounding: SG (Singapore) falls through to default roundToNearest7 16 * 17 * Uses mock.module for db.js to stay fast (no real PG needed). 18 */ 19 20 import { test, describe, mock, before, after, beforeEach } from 'node:test'; 21 import assert from 'node:assert/strict'; 22 import Database from 'better-sqlite3'; 23 import { createPgMock } from '../helpers/pg-mock.js'; 24 25 // ─── In-memory DB ───────────────────────────────────────────────────────────── 26 27 const db = new Database(':memory:'); 28 29 db.exec(` 30 CREATE TABLE IF NOT EXISTS countries ( 31 country_code TEXT PRIMARY KEY, 32 country_name TEXT NOT NULL DEFAULT 'Test', 33 google_domain TEXT NOT NULL DEFAULT 'google.com', 34 language_code TEXT NOT NULL DEFAULT 'en', 35 timezone TEXT NOT NULL DEFAULT 'UTC', 36 currency_code TEXT NOT NULL DEFAULT 'USD', 37 currency_symbol TEXT NOT NULL DEFAULT '$', 38 date_format TEXT NOT NULL DEFAULT 'MM/DD/YYYY', 39 price_usd INTEGER NOT NULL DEFAULT 29700, 40 pricing_tier TEXT NOT NULL DEFAULT 'Standard', 41 ppp_gdp_per_capita INTEGER DEFAULT 80412, 42 is_active INTEGER DEFAULT 1, 43 price_overridden INTEGER DEFAULT 0, 44 price_usd_base INTEGER DEFAULT 30000, 45 price_usd_ppp INTEGER DEFAULT 29700, 46 price_local INTEGER DEFAULT 29700, 47 price_local_formatted TEXT DEFAULT '297', 48 exchange_rate REAL DEFAULT 1.0, 49 price_last_updated TEXT, 50 pricing_variant TEXT DEFAULT 'control', 51 variant_multiplier REAL DEFAULT 1.0, 52 created_at TEXT DEFAULT (datetime('now')), 53 updated_at TEXT DEFAULT (datetime('now')), 54 twilio_phone_number TEXT 55 ) 56 `); 57 58 // ─── Mock db.js BEFORE importing module under test ──────────────────────────── 59 60 mock.module('../../src/utils/db.js', { 61 namedExports: createPgMock(db), 62 }); 63 64 mock.module('../../src/utils/logger.js', { 65 defaultExport: class { 66 info() {} 67 warn() {} 68 error() {} 69 success() {} 70 debug() {} 71 }, 72 }); 73 74 mock.module('../../src/utils/load-env.js', { 75 namedExports: {}, 76 }); 77 78 // Set FIXER_API_KEY before import so the module-level constant is populated. 79 // Tests that want the "missing key" error path mock it differently below. 80 process.env.FIXER_API_KEY = 'test-fixer-key-for-testing'; 81 82 const { 83 fetchExchangeRates, 84 updateCountryPricing, 85 applyCulturalRounding, 86 applyPPPAdjustment, 87 } = await import('../../src/cron/weekly-repricing.js'); 88 89 // ─── Helpers ────────────────────────────────────────────────────────────────── 90 91 function clearCountries() { 92 db.prepare('DELETE FROM countries').run(); 93 } 94 95 function insertCountry(row) { 96 const defaults = { 97 country_code: 'AU', 98 country_name: 'Australia', 99 currency_code: 'AUD', 100 currency_symbol: 'A$', 101 ppp_gdp_per_capita: 64674, 102 is_active: 1, 103 price_overridden: 0, 104 variant_multiplier: 1.0, 105 pricing_tier: 'Standard', 106 price_usd: 29700, 107 price_usd_base: 30000, 108 price_usd_ppp: 24100, 109 price_local: 36700, 110 price_local_formatted: '367', 111 exchange_rate: 1.52, 112 }; 113 const r = { ...defaults, ...row }; 114 db.prepare(` 115 INSERT OR REPLACE INTO countries 116 (country_code, country_name, currency_code, currency_symbol, 117 ppp_gdp_per_capita, is_active, price_overridden, variant_multiplier, 118 pricing_tier, price_usd, price_usd_base, price_usd_ppp, 119 price_local, price_local_formatted, exchange_rate) 120 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) 121 `).run( 122 r.country_code, r.country_name, r.currency_code, r.currency_symbol, 123 r.ppp_gdp_per_capita, r.is_active, r.price_overridden, r.variant_multiplier, 124 r.pricing_tier, r.price_usd, r.price_usd_base, r.price_usd_ppp, 125 r.price_local, r.price_local_formatted, r.exchange_rate 126 ); 127 } 128 129 // ─── updateCountryPricing ───────────────────────────────────────────────────── 130 131 describe('updateCountryPricing — via pg-mock', () => { 132 beforeEach(() => clearCountries()); 133 134 test('updates price_local for an active non-overridden country', async () => { 135 insertCountry({ country_code: 'AU', currency_code: 'AUD', ppp_gdp_per_capita: 64674 }); 136 137 await updateCountryPricing({ AUD: 1.52 }); 138 139 const au = db.prepare('SELECT price_local, exchange_rate FROM countries WHERE country_code = ?').get('AU'); 140 assert.ok(au.price_local > 0, 'price_local should be set'); 141 assert.ok(Math.abs(au.exchange_rate - 1.52) < 0.001, 'exchange_rate should be updated'); 142 }); 143 144 test('updates price_usd_base to base USD price in cents', async () => { 145 insertCountry({ country_code: 'US', currency_code: 'USD', ppp_gdp_per_capita: 80412 }); 146 147 await updateCountryPricing({ USD: 1.0 }); 148 149 const us = db.prepare('SELECT price_usd_base FROM countries WHERE country_code = ?').get('US'); 150 // BASE_USD_PRICE = 300.0 → 300 * 100 = 30000 cents 151 assert.equal(us.price_usd_base, 30000); 152 }); 153 154 test('sets price_local_formatted to the rounded price string', async () => { 155 insertCountry({ country_code: 'AU', currency_code: 'AUD', ppp_gdp_per_capita: 64674 }); 156 157 await updateCountryPricing({ AUD: 1.52 }); 158 159 const au = db.prepare('SELECT price_local_formatted FROM countries WHERE country_code = ?').get('AU'); 160 assert.ok(typeof au.price_local_formatted === 'string', 'price_local_formatted should be a string'); 161 assert.ok(au.price_local_formatted.length > 0, 'price_local_formatted should be non-empty'); 162 }); 163 164 test('skips countries with price_overridden = 1', async () => { 165 insertCountry({ country_code: 'XX', currency_code: 'XXX', price_overridden: 1, price_local: 99900 }); 166 167 const before_local = db.prepare('SELECT price_local FROM countries WHERE country_code = ?').get('XX').price_local; 168 await updateCountryPricing({ XXX: 5.0 }); 169 const after_local = db.prepare('SELECT price_local FROM countries WHERE country_code = ?').get('XX').price_local; 170 171 assert.equal(before_local, after_local, 'overridden country price should not change'); 172 }); 173 174 test('skips countries when exchange rate is missing', async () => { 175 insertCountry({ country_code: 'ZA', currency_code: 'ZAR', price_local: 55000, price_overridden: 0 }); 176 177 const before_local = db.prepare('SELECT price_local FROM countries WHERE country_code = ?').get('ZA').price_local; 178 // Pass rates without ZAR → country is skipped 179 await updateCountryPricing({ AUD: 1.52 }); 180 const after_local = db.prepare('SELECT price_local FROM countries WHERE country_code = ?').get('ZA').price_local; 181 182 // price_local should remain unchanged (skip branch, no UPDATE fired) 183 assert.equal(before_local, after_local); 184 }); 185 186 test('processes multiple countries in one call', async () => { 187 insertCountry({ country_code: 'AU', currency_code: 'AUD', ppp_gdp_per_capita: 64674 }); 188 insertCountry({ country_code: 'GB', currency_code: 'GBP', ppp_gdp_per_capita: 53000 }); 189 190 await updateCountryPricing({ AUD: 1.52, GBP: 0.79 }); 191 192 const au = db.prepare('SELECT exchange_rate FROM countries WHERE country_code = ?').get('AU'); 193 const gb = db.prepare('SELECT exchange_rate FROM countries WHERE country_code = ?').get('GB'); 194 assert.ok(Math.abs(au.exchange_rate - 1.52) < 0.001); 195 assert.ok(Math.abs(gb.exchange_rate - 0.79) < 0.001); 196 }); 197 198 test('applies variant_multiplier to final price', async () => { 199 // Insert same country twice with different multipliers in separate calls 200 insertCountry({ 201 country_code: 'NZ', 202 currency_code: 'NZD', 203 ppp_gdp_per_capita: 50000, 204 variant_multiplier: 1.0, 205 }); 206 207 await updateCountryPricing({ NZD: 1.6 }); 208 const nzBase = db.prepare('SELECT price_local FROM countries WHERE country_code = ?').get('NZ').price_local; 209 210 insertCountry({ 211 country_code: 'NZ', 212 currency_code: 'NZD', 213 ppp_gdp_per_capita: 50000, 214 variant_multiplier: 1.2, // 20% higher 215 }); 216 217 await updateCountryPricing({ NZD: 1.6 }); 218 const nzVariant = db.prepare('SELECT price_local FROM countries WHERE country_code = ?').get('NZ').price_local; 219 220 assert.ok(nzVariant > nzBase, 'variant multiplier 1.2 should produce higher price than 1.0'); 221 }); 222 223 test('handles USD country correctly (rate = 1.0)', async () => { 224 insertCountry({ country_code: 'US', currency_code: 'USD', ppp_gdp_per_capita: 80412 }); 225 226 // USD is handled specially: rate = 1.0 (not from the rates object) 227 await updateCountryPricing({ AUD: 1.52 }); // no USD key in rates 228 229 const us = db.prepare('SELECT price_local, exchange_rate FROM countries WHERE country_code = ?').get('US'); 230 // USD rate = 1.0 → PPP * 1.0 = localPrice, then cultural rounding 231 assert.ok(us.price_local > 0); 232 assert.ok(Math.abs(us.exchange_rate - 1.0) < 0.001); 233 }); 234 235 test('skips inactive countries (is_active = 0)', async () => { 236 insertCountry({ country_code: 'IE', currency_code: 'EUR', is_active: 0, price_local: 22200 }); 237 238 const before_local = db.prepare('SELECT price_local FROM countries WHERE country_code = ?').get('IE').price_local; 239 await updateCountryPricing({ EUR: 0.92 }); 240 const after_local = db.prepare('SELECT price_local FROM countries WHERE country_code = ?').get('IE').price_local; 241 242 // Inactive → not returned by the WHERE is_active = true query → unchanged 243 assert.equal(before_local, after_local); 244 }); 245 246 test('handles empty countries table without throwing', async () => { 247 // Table is already cleared by beforeEach 248 await assert.doesNotReject(async () => updateCountryPricing({ AUD: 1.5 })); 249 }); 250 }); 251 252 // ─── fetchExchangeRates ─────────────────────────────────────────────────────── 253 254 describe('fetchExchangeRates', () => { 255 // FIXER_API_KEY was set before the import above, so the module-level constant 256 // is populated. Tests mock global.fetch to simulate API responses. 257 258 test('returns rates object on successful response', async () => { 259 const today = new Date().toISOString().slice(0, 10); 260 const origFetch = global.fetch; 261 global.fetch = async () => ({ 262 ok: true, 263 json: async () => ({ 264 success: true, 265 date: today, 266 rates: { USD: 1.09, AUD: 1.66, GBP: 0.86, CAD: 1.47 }, 267 }), 268 }); 269 270 try { 271 const rates = await fetchExchangeRates(); 272 assert.ok(typeof rates === 'object' && rates !== null); 273 assert.ok(Object.keys(rates).length > 0); 274 } finally { 275 global.fetch = origFetch; 276 } 277 }); 278 279 test('converts EUR-based rates to USD-based rates', async () => { 280 process.env.FIXER_API_KEY = 'test-key'; 281 282 const today = new Date().toISOString().slice(0, 10); 283 const origFetch = global.fetch; 284 global.fetch = async () => ({ 285 ok: true, 286 json: async () => ({ 287 success: true, 288 date: today, 289 rates: { 290 USD: 1.09, // EUR to USD 291 AUD: 1.66, // EUR to AUD 292 GBP: 0.86, // EUR to GBP 293 }, 294 }), 295 }); 296 297 try { 298 const rates = await fetchExchangeRates(); 299 // AUD rate in USD = 1.66 / 1.09 ≈ 1.523 300 assert.ok('AUD' in rates, 'AUD should be in rates'); 301 assert.ok(Math.abs(rates.AUD - 1.66 / 1.09) < 0.001, 'AUD rate should be EUR-to-AUD / EUR-to-USD'); 302 assert.ok('GBP' in rates, 'GBP should be in rates'); 303 } finally { 304 global.fetch = origFetch; 305 } 306 }); 307 308 test('throws when Fixer API returns success: false', async () => { 309 process.env.FIXER_API_KEY = 'test-key'; 310 311 const origFetch = global.fetch; 312 global.fetch = async () => ({ 313 ok: true, 314 json: async () => ({ 315 success: false, 316 error: { info: 'Invalid API Key.' }, 317 }), 318 }); 319 320 try { 321 await assert.rejects( 322 () => fetchExchangeRates(), 323 /Invalid API Key/ 324 ); 325 } finally { 326 global.fetch = origFetch; 327 } 328 }); 329 330 test('throws when Fixer API returns HTTP error', async () => { 331 process.env.FIXER_API_KEY = 'test-key'; 332 333 const origFetch = global.fetch; 334 global.fetch = async () => ({ 335 ok: false, 336 status: 429, 337 }); 338 339 try { 340 await assert.rejects( 341 () => fetchExchangeRates(), 342 /Fixer\.io API error: HTTP 429/ 343 ); 344 } finally { 345 global.fetch = origFetch; 346 } 347 }); 348 349 test('throws when Fixer returns stale data (> 2 days old)', async () => { 350 process.env.FIXER_API_KEY = 'test-key'; 351 352 // Date 5 days ago 353 const staleDate = new Date(Date.now() - 5 * 24 * 3600 * 1000).toISOString().slice(0, 10); 354 355 const origFetch = global.fetch; 356 global.fetch = async () => ({ 357 ok: true, 358 json: async () => ({ 359 success: true, 360 date: staleDate, 361 rates: { USD: 1.09, AUD: 1.66 }, 362 }), 363 }); 364 365 try { 366 await assert.rejects( 367 () => fetchExchangeRates(), 368 /stale data/ 369 ); 370 } finally { 371 global.fetch = origFetch; 372 } 373 }); 374 375 test('does not throw for fresh data (same day)', async () => { 376 process.env.FIXER_API_KEY = 'test-key'; 377 378 const today = new Date().toISOString().slice(0, 10); 379 380 const origFetch = global.fetch; 381 global.fetch = async () => ({ 382 ok: true, 383 json: async () => ({ 384 success: true, 385 date: today, 386 rates: { USD: 1.09, AUD: 1.66, GBP: 0.86 }, 387 }), 388 }); 389 390 try { 391 await assert.doesNotReject(() => fetchExchangeRates()); 392 } finally { 393 global.fetch = origFetch; 394 } 395 }); 396 397 test('returns only currencies present in the Fixer response', async () => { 398 process.env.FIXER_API_KEY = 'test-key'; 399 400 const today = new Date().toISOString().slice(0, 10); 401 const origFetch = global.fetch; 402 global.fetch = async () => ({ 403 ok: true, 404 json: async () => ({ 405 success: true, 406 date: today, 407 rates: { USD: 1.09, AUD: 1.66 }, // Only 2 currencies 408 }), 409 }); 410 411 try { 412 const rates = await fetchExchangeRates(); 413 // Should have AUD but not GBP (absent from mock response) 414 assert.ok('AUD' in rates); 415 assert.ok(!('GBP' in rates)); 416 } finally { 417 global.fetch = origFetch; 418 } 419 }); 420 }); 421 422 // ─── applyCulturalRounding — uncovered edge cases ───────────────────────────── 423 // The existing test file covers most countries. This supplement covers gaps. 424 425 describe('applyCulturalRounding — supplement', () => { 426 describe('CN (China) — 88 lucky proximity', () => { 427 test('returns base100 + 88 when price is within 20 of option88', () => { 428 // price=190 → base100=100, option88=188 (diff=2) < 20 → returns 188 429 const result = applyCulturalRounding(190, 'CN'); 430 assert.equal(result, 188); 431 }); 432 433 test('returns base100 + 188 when price is within 20 of nextOption88', () => { 434 // price=205 → base100=100, option88=188(diff=17<20)? No 17<20 → returns 188 435 // price=375 → base100=300, option88=388(diff=13<20) → returns 388 436 const result = applyCulturalRounding(375, 'CN'); 437 assert.equal(result, 388); 438 }); 439 440 test('falls back to base10+8 when not near 88 or 188', () => { 441 // price=140 → base100=100, option88=188(diff=48≥20), nextOption88=288(diff=148≥20) 442 // → falls to base10+8: Math.floor(140/10)*10+8 = 148 443 const result = applyCulturalRounding(140, 'CN'); 444 assert.equal(result, 148); 445 }); 446 }); 447 448 describe('SG (Singapore) — falls through to default roundToNearest7', () => { 449 test('SG uses default rounding (ends in 7)', () => { 450 const result = applyCulturalRounding(250, 'SG'); 451 assert.equal(result % 10, 7, `Expected last digit 7, got ${result}`); 452 }); 453 }); 454 455 describe('NZD (New Zealand) — charm pricing', () => { 456 test('NZ price ends in 7 or 9', () => { 457 const result = applyCulturalRounding(289, 'NZ'); 458 const lastDigit = result % 10; 459 assert.ok(lastDigit === 7 || lastDigit === 9, `Expected 7 or 9, got ${lastDigit}`); 460 }); 461 }); 462 463 describe('IE (Ireland) — charm pricing', () => { 464 test('IE price ends in 7 or 9', () => { 465 const result = applyCulturalRounding(195, 'IE'); 466 const lastDigit = result % 10; 467 assert.ok(lastDigit === 7 || lastDigit === 9); 468 }); 469 }); 470 471 describe('IN (India) — auspicious 1 endings', () => { 472 test('IN price >= 1000 ends in 01 (base100 + 1)', () => { 473 // 2500 → base100=2500, returns 2501 474 const result = applyCulturalRounding(2500, 'IN'); 475 assert.equal(result % 100, 1); 476 }); 477 }); 478 479 describe('MX (Mexico) — round numbers edge cases', () => { 480 test('MX: 500 rounds to nearest 100', () => { 481 const result = applyCulturalRounding(550, 'MX'); 482 assert.equal(result % 100, 0); 483 }); 484 }); 485 486 describe('FR, IT, ES — charm pricing (included in Western markets)', () => { 487 test('FR: price ends in 7 or 9', () => { 488 const result = applyCulturalRounding(300, 'FR'); 489 const lastDigit = result % 10; 490 assert.ok(lastDigit === 7 || lastDigit === 9); 491 }); 492 493 test('IT: price ends in 7 or 9', () => { 494 const result = applyCulturalRounding(200, 'IT'); 495 const lastDigit = result % 10; 496 assert.ok(lastDigit === 7 || lastDigit === 9); 497 }); 498 499 test('ES: price ends in 7 or 9', () => { 500 const result = applyCulturalRounding(180, 'ES'); 501 const lastDigit = result % 10; 502 assert.ok(lastDigit === 7 || lastDigit === 9); 503 }); 504 }); 505 506 describe('applyPPPAdjustment — additional coverage', () => { 507 test('returns proportional result', () => { 508 // 300 * (40000/80412) ≈ 149.22 509 const result = applyPPPAdjustment(300, 40000, 80412); 510 assert.ok(Math.abs(result - 149.22) < 1); 511 }); 512 513 test('handles non-standard base price', () => { 514 const result = applyPPPAdjustment(150, 80412, 80412); 515 assert.equal(result, 150); 516 }); 517 }); 518 });