weekly-repricing.test.js
1 /** 2 * Tests for src/cron/weekly-repricing.js 3 * 4 * Tests the pure math functions: 5 * - applyPPPAdjustment 6 * - roundToNearest7 7 * - applyCulturalRounding 8 * - updateCountryPricing (DB function with minimal fixture) 9 * 10 * fetchExchangeRates() requires live Fixer.io API — not tested here. 11 */ 12 13 import { test, describe, before, after } from 'node:test'; 14 import assert from 'node:assert/strict'; 15 import { existsSync, unlinkSync } from 'fs'; 16 import { join } from 'path'; 17 import { tmpdir } from 'os'; 18 import Database from 'better-sqlite3'; 19 20 import { 21 applyPPPAdjustment, 22 roundToNearest7, 23 applyCulturalRounding, 24 updateCountryPricing, 25 } from '../../src/cron/weekly-repricing.js'; 26 27 // ─── applyPPPAdjustment ─────────────────────────────────────────────────────── 28 29 describe('applyPPPAdjustment', () => { 30 test('returns base price when PPP equals US PPP', () => { 31 const usPPP = 80412; 32 const result = applyPPPAdjustment(300, usPPP, usPPP); 33 assert.equal(result, 300); 34 }); 35 36 test('scales down for lower-PPP countries', () => { 37 // Australia PPP: 64,674 < US PPP: 80,412 → lower price 38 const result = applyPPPAdjustment(300, 64674, 80412); 39 assert.ok(result < 300); 40 assert.ok(Math.abs(result - 241) < 1); // approximately $241 41 }); 42 43 test('scales up for higher-PPP countries', () => { 44 // Higher PPP country 45 const result = applyPPPAdjustment(300, 100000, 80412); 46 assert.ok(result > 300); 47 }); 48 49 test('uses US_PPP default when not provided', () => { 50 const withDefault = applyPPPAdjustment(300, 80412); 51 assert.equal(withDefault, 300); // same PPP = no change 52 }); 53 54 test('handles zero PPP gracefully (returns 0)', () => { 55 const result = applyPPPAdjustment(300, 0, 80412); 56 assert.equal(result, 0); 57 }); 58 }); 59 60 // ─── roundToNearest7 ───────────────────────────────────────────────────────── 61 62 describe('roundToNearest7', () => { 63 test('rounds down to nearest 7 in current decade when close', () => { 64 // 363 → closest 7 is 367 (diff=4) vs 377 (diff=14) → 367 65 const result = roundToNearest7(363); 66 assert.equal(result, 367); 67 }); 68 69 test('rounds up to next 7 when current decade 7 is too far', () => { 70 // 374 → option7=367 (diff=7), nextOption7=377 (diff=3) → 377 71 const result = roundToNearest7(374); 72 assert.equal(result, 377); 73 }); 74 75 test('returns exact 7-ending when already at 7', () => { 76 const result = roundToNearest7(297); 77 assert.equal(result, 297); 78 }); 79 80 test('handles prices in the hundreds', () => { 81 const result = roundToNearest7(245); 82 assert.ok(result.toString().endsWith('7')); 83 }); 84 }); 85 86 // ─── applyCulturalRounding ──────────────────────────────────────────────────── 87 88 describe('applyCulturalRounding', () => { 89 describe('Western charm pricing (AU, US, NZ, UK, FR, IT, ES, IE, CA)', () => { 90 test('AU: returns price ending in 7 or 9', () => { 91 const result = applyCulturalRounding(366.87, 'AU'); 92 const lastDigit = result % 10; 93 assert.ok(lastDigit === 7 || lastDigit === 9, `Expected 7 or 9, got ${lastDigit}`); 94 }); 95 96 test('US: returns price ending in 7 or 9', () => { 97 const result = applyCulturalRounding(290, 'US'); 98 const lastDigit = result % 10; 99 assert.ok(lastDigit === 7 || lastDigit === 9, `Expected 7 or 9, got ${lastDigit}`); 100 }); 101 102 test('CA: charm pricing', () => { 103 const result = applyCulturalRounding(320, 'CA'); 104 const lastDigit = result % 10; 105 assert.ok(lastDigit === 7 || lastDigit === 9); 106 }); 107 108 test('AU: returns option9 when price is closest to 9-ending', () => { 109 // price=169: base10=160, option7=167(diff=2), option9=169(diff=0), nextOption7=177(diff=8) 110 // diffTo7(2) > diffTo9(0) AND diffTo9(0) < diffToNext7(8) → returns option9=169 111 const result = applyCulturalRounding(169, 'AU'); 112 assert.equal(result, 169); 113 }); 114 115 test('AU: returns nextOption7 when price is equidistant from option9 and nextOption7', () => { 116 // price=173: base10=160, option7=167(diff=6), option9=169(diff=4), nextOption7=177(diff=4) 117 // diffTo7(6) > diffTo9(4), diffTo9(4) < diffToNext7(4) is FALSE → returns nextOption7=177 118 const result = applyCulturalRounding(173, 'AU'); 119 assert.equal(result, 177); 120 }); 121 }); 122 123 describe('German/Nordic round numbers (DE, NO, SE, DK, CH, AT)', () => { 124 test('DE: rounds to nearest 50 for medium amounts', () => { 125 const result = applyCulturalRounding(174, 'DE'); 126 assert.equal(result % 50, 0, `Expected multiple of 50, got ${result}`); 127 }); 128 129 test('NO: rounds to nearest 50', () => { 130 const result = applyCulturalRounding(230, 'NO'); 131 assert.equal(result % 50, 0); 132 }); 133 134 test('DE: rounds to nearest 100 for large amounts (>= 1000)', () => { 135 const result = applyCulturalRounding(1250, 'DE'); 136 assert.equal(result % 100, 0, `Expected multiple of 100, got ${result}`); 137 }); 138 139 test('AT: rounds to nearest 10 for small amounts (< 100)', () => { 140 const result = applyCulturalRounding(73, 'AT'); 141 assert.equal(result % 10, 0, `Expected multiple of 10, got ${result}`); 142 }); 143 144 test('SE: small prices round to nearest 10', () => { 145 const result = applyCulturalRounding(85, 'SE'); 146 assert.equal(result % 10, 0); 147 }); 148 }); 149 150 describe('China (CN): lucky 8', () => { 151 test('CN: price ends in 8 for medium amounts (< 1000)', () => { 152 const result = applyCulturalRounding(150, 'CN'); 153 assert.equal(result % 10, 8, `Expected last digit 8, got ${result}`); 154 }); 155 156 test('CN: price ends in 8 for large amounts (>= 1000)', () => { 157 // For price >= 1000, uses base10 + 8 branch 158 const result = applyCulturalRounding(5000, 'CN'); 159 assert.equal(result % 10, 8, `Expected last digit 8, got ${result}`); 160 }); 161 }); 162 163 describe('Japan (JP): round numbers', () => { 164 test('JP: rounds to nearest 100 for prices < 10000', () => { 165 const result = applyCulturalRounding(1550, 'JP'); 166 assert.equal(result % 100, 0); 167 }); 168 169 test('JP: rounds to nearest 1000 for prices >= 10000', () => { 170 const result = applyCulturalRounding(15500, 'JP'); 171 assert.equal(result % 1000, 0); 172 }); 173 }); 174 175 describe('India (IN): auspicious 1 endings', () => { 176 test('IN: price ends in 1 for amounts < 1000', () => { 177 const result = applyCulturalRounding(250, 'IN'); 178 assert.equal(result % 10, 1, `Expected last digit 1, got ${result}`); 179 }); 180 181 test('IN: for amounts >= 1000, ends in 1', () => { 182 const result = applyCulturalRounding(1500, 'IN'); 183 assert.equal(result % 100, 1, `Expected last two digits 01, got ${result}`); 184 }); 185 }); 186 187 describe('Mexico (MX): round numbers', () => { 188 test('MX: rounds to nearest 100 for < 1000', () => { 189 const result = applyCulturalRounding(450, 'MX'); 190 assert.equal(result % 100, 0); 191 }); 192 193 test('MX: rounds to nearest 500 for >= 1000', () => { 194 const result = applyCulturalRounding(1300, 'MX'); 195 assert.equal(result % 500, 0); 196 }); 197 }); 198 199 describe('Indonesia (ID): round thousands', () => { 200 test('ID: rounds to nearest 1000', () => { 201 const result = applyCulturalRounding(4500, 'ID'); 202 assert.equal(result % 1000, 0); 203 }); 204 }); 205 206 describe('Korea (KR): avoid 4 and 7, prefer round thousands', () => { 207 test('KR: rounds to nearest 1000', () => { 208 const result = applyCulturalRounding(250000, 'KR'); 209 assert.equal(result % 1000, 0); 210 }); 211 212 test('KR: adjusts to next 1000 when rounded value contains 4 or 7', () => { 213 // 47000 rounds to 47000 which contains '4' and '7' → should adjust to 48000 214 const result = applyCulturalRounding(47000, 'KR'); 215 const asStr = result.toString(); 216 // Should not contain 4 or 7 (if adjustment worked), or be 48000 217 assert.ok(!asStr.includes('4') || !asStr.includes('7') || result === 48000); 218 }); 219 220 test('KR: returns rounded thousand when no 4 or 7', () => { 221 // 50000 rounds to 50000 (no 4 or 7) → returns 50000 222 const result = applyCulturalRounding(50000, 'KR'); 223 assert.equal(result, 50000); 224 }); 225 }); 226 227 describe('Poland (PL): lucky 7, avoid 13', () => { 228 test('PL: price ends in 7', () => { 229 const result = applyCulturalRounding(250, 'PL'); 230 assert.equal(result % 10, 7, `Expected last digit 7, got ${result}`); 231 }); 232 233 test('PL: falls back to base10+9 when both option7 and nextOption7 contain "13"', () => { 234 // price=1310 → base10=1310, option7=1317 (contains '13'), nextOption7=1327 (contains '13') 235 // → falls back to base10+9 = 1319 236 const result = applyCulturalRounding(1310, 'PL'); 237 assert.equal(result, 1319, `Expected 1319, got ${result}`); 238 }); 239 }); 240 241 describe('Default fallback', () => { 242 test('unknown country uses roundToNearest7', () => { 243 const result = applyCulturalRounding(363, 'ZZ'); 244 assert.equal(result.toString().slice(-1), '7', `Expected last digit 7, got ${result}`); 245 }); 246 }); 247 }); 248 249 // ─── updateCountryPricing ──────────────────────────────────────────────────── 250 251 describe('updateCountryPricing', () => { 252 const TEST_DB = join(tmpdir(), `test-weekly-repricing-${Date.now()}.db`); 253 let db; 254 255 before(() => { 256 db = new Database(TEST_DB); 257 db.pragma('journal_mode = WAL'); 258 db.exec(` 259 CREATE TABLE IF NOT EXISTS countries ( 260 country_code TEXT PRIMARY KEY, 261 country_name TEXT NOT NULL DEFAULT 'Test', 262 google_domain TEXT NOT NULL DEFAULT 'google.com', 263 language_code TEXT NOT NULL DEFAULT 'en', 264 timezone TEXT NOT NULL DEFAULT 'UTC', 265 currency_code TEXT NOT NULL DEFAULT 'USD', 266 currency_symbol TEXT NOT NULL DEFAULT '$', 267 date_format TEXT NOT NULL DEFAULT 'MM/DD/YYYY', 268 price_usd INTEGER NOT NULL DEFAULT 29700, 269 pricing_tier TEXT NOT NULL DEFAULT 'Standard', 270 ppp_gdp_per_capita INTEGER DEFAULT 80412, 271 is_active INTEGER DEFAULT 1, 272 price_overridden INTEGER DEFAULT 0, 273 price_usd_base INTEGER DEFAULT 30000, 274 price_usd_ppp INTEGER DEFAULT 29700, 275 price_local INTEGER DEFAULT 29700, 276 price_local_formatted TEXT DEFAULT '297', 277 exchange_rate REAL DEFAULT 1.0, 278 price_last_updated TEXT, 279 pricing_variant TEXT DEFAULT 'control', 280 variant_multiplier REAL DEFAULT 1.0, 281 created_at TEXT DEFAULT CURRENT_TIMESTAMP, 282 updated_at TEXT DEFAULT CURRENT_TIMESTAMP, 283 twilio_phone_number TEXT 284 ) 285 `); 286 287 // Insert test countries 288 db.prepare( 289 `INSERT INTO countries (country_code, country_name, currency_code, currency_symbol, ppp_gdp_per_capita, is_active, price_overridden) VALUES ('AU', 'Australia', 'AUD', '$', 64674, 1, 0)` 290 ).run(); 291 db.prepare( 292 `INSERT INTO countries (country_code, country_name, currency_code, currency_symbol, ppp_gdp_per_capita, is_active, price_overridden) VALUES ('US', 'United States', 'USD', '$', 80412, 1, 0)` 293 ).run(); 294 // Country with price override — should be skipped 295 db.prepare( 296 `INSERT INTO countries (country_code, country_name, currency_code, currency_symbol, ppp_gdp_per_capita, is_active, price_overridden) VALUES ('XX', 'Skip Country', 'XXX', 'X', 50000, 1, 1)` 297 ).run(); 298 }); 299 300 after(() => { 301 if (db) { 302 try { 303 db.close(); 304 } catch { 305 /* ignore */ 306 } 307 } 308 if (existsSync(TEST_DB)) { 309 try { 310 unlinkSync(TEST_DB); 311 } catch { 312 /* ignore */ 313 } 314 } 315 }); 316 317 test('updates active non-overridden countries with exchange rates', () => { 318 const rates = { AUD: 1.52, USD: 1.0 }; 319 assert.doesNotThrow(() => updateCountryPricing(db, rates)); 320 321 const au = db.prepare('SELECT * FROM countries WHERE country_code = ?').get('AU'); 322 assert.ok(au.exchange_rate !== null); 323 assert.ok(au.price_local !== null); 324 }); 325 326 test('skips countries with price_overridden=1', () => { 327 const rates = { XXX: 1.0 }; 328 const xx_before = db 329 .prepare('SELECT price_local FROM countries WHERE country_code = ?') 330 .get('XX'); 331 updateCountryPricing(db, rates); 332 const xx_after = db 333 .prepare('SELECT price_local FROM countries WHERE country_code = ?') 334 .get('XX'); 335 assert.equal(xx_before.price_local, xx_after.price_local); 336 }); 337 338 test('handles missing exchange rate gracefully', () => { 339 // US has USD exchange rate; pass empty rates object 340 assert.doesNotThrow(() => updateCountryPricing(db, {})); 341 }); 342 });