/ tests / cron / weekly-repricing.test.js
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  });