geo-detection.test.js
1 /** 2 * Geo Detection & Currency Switching E2E Tests 3 * 4 * Tests that the sales page auto-detects country and shows the correct currency, 5 * and that the country/currency dropdown correctly updates the price on change. 6 * 7 * Strategy: hit the origin server directly (bypassing Cloudflare CDN cache) so we 8 * can inject CF-IPCountry and Accept-Language headers that Cloudflare would normally 9 * set/strip before passing to origin PHP. 10 * 11 * Run: 12 * node --test tests/e2e/geo-detection.test.js 13 * 14 * Requirements: 15 * - E2E_BRAND_URL in .env (defaults to https://${BRAND_DOMAIN}) 16 * - ORIGIN_IP in .env (optional, defaults to 194.164.29.209) 17 * - Chromium available (for browser-based dropdown test) 18 */ 19 20 import { test, describe } from 'node:test'; 21 import assert from 'node:assert/strict'; 22 import { execSync } from 'node:child_process'; 23 import { existsSync } from 'node:fs'; 24 import { chromium } from 'playwright'; 25 import dotenv from 'dotenv'; 26 dotenv.config(); 27 28 const BASE_URL = process.env.E2E_BRAND_URL || `https://${process.env.BRAND_DOMAIN || 'auditandfix.com'}`; 29 const ORIGIN_IP = process.env.ORIGIN_IP || '194.164.29.209'; 30 31 // ── Chromium detection (same logic as checkout test) ──────────────────────── 32 33 function detectChromiumPath() { 34 if (process.env.CHROMIUM_PATH && existsSync(process.env.CHROMIUM_PATH)) { 35 return process.env.CHROMIUM_PATH; 36 } 37 try { 38 const p = execSync('which chromium 2>/dev/null || which chromium-browser 2>/dev/null', { 39 encoding: 'utf-8', 40 }).trim(); 41 if (p && existsSync(p)) return p; 42 } catch {} 43 try { 44 const nixPaths = execSync('ls /nix/store/*/bin/chromium 2>/dev/null | head -1', { 45 encoding: 'utf-8', 46 shell: true, 47 }).trim(); 48 if (nixPaths && existsSync(nixPaths)) return nixPaths; 49 } catch {} 50 return null; 51 } 52 53 // ── HTTP helper — fetch page HTML via origin IP ────────────────────────────── 54 55 /** 56 * Fetch the page from the origin server directly, injecting custom headers. 57 * Using --connect-to bypasses Cloudflare so we can set CF-IPCountry ourselves. 58 */ 59 async function fetchPage(extraHeaders = {}) { 60 const url = new URL(BASE_URL); 61 const protocol = url.protocol === 'https:' ? 'https' : 'http'; 62 const port = url.protocol === 'https:' ? 443 : 80; 63 64 const headerArgs = Object.entries(extraHeaders) 65 .map(([k, v]) => `-H "${k}: ${v}"`) 66 .join(' '); 67 68 const cmd = [ 69 'curl -s', 70 protocol === 'https' ? '-k' : '', 71 `--connect-to "${url.hostname}:${port}:${ORIGIN_IP}:${port}"`, 72 `-H "Host: ${url.hostname}"`, 73 headerArgs, 74 `"${protocol}://${url.hostname}/"`, 75 ] 76 .filter(Boolean) 77 .join(' '); 78 79 const html = execSync(cmd, { encoding: 'utf-8', timeout: 15000 }); 80 return html; 81 } 82 83 function extractVar(html, varName) { 84 const match = html.match(new RegExp(`window\\.${varName}\\s*=\\s*(.+?);`)); 85 if (!match) return null; 86 try { 87 return JSON.parse(match[1]); 88 } catch { 89 return match[1]; 90 } 91 } 92 93 // ── Tests ──────────────────────────────────────────────────────────────────── 94 95 describe('Geo detection — CF-IPCountry header', () => { 96 test('detects Germany (DE) and shows EUR pricing', async () => { 97 const html = await fetchPage({ 'CF-IPCountry': 'DE' }); 98 const country = extractVar(html, 'DETECTED_COUNTRY'); 99 const price = extractVar(html, 'INITIAL_PRICE'); 100 101 assert.equal(country, 'DE', `Expected DE, got ${country}`); 102 assert.equal(price?.currency, 'EUR', `Expected EUR for DE, got ${price?.currency}`); 103 }); 104 105 test('detects Japan (JP) and shows JPY pricing', async () => { 106 const html = await fetchPage({ 'CF-IPCountry': 'JP' }); 107 const country = extractVar(html, 'DETECTED_COUNTRY'); 108 const price = extractVar(html, 'INITIAL_PRICE'); 109 110 assert.equal(country, 'JP', `Expected JP, got ${country}`); 111 assert.equal(price?.currency, 'JPY', `Expected JPY for JP, got ${price?.currency}`); 112 }); 113 114 test('detects Australia (AU) and shows AUD pricing', async () => { 115 const html = await fetchPage({ 'CF-IPCountry': 'AU' }); 116 const country = extractVar(html, 'DETECTED_COUNTRY'); 117 const price = extractVar(html, 'INITIAL_PRICE'); 118 119 assert.equal(country, 'AU', `Expected AU, got ${country}`); 120 assert.equal(price?.currency, 'AUD', `Expected AUD for AU, got ${price?.currency}`); 121 }); 122 123 test('GB is normalised to UK and shows GBP pricing', async () => { 124 const html = await fetchPage({ 'CF-IPCountry': 'GB' }); 125 const country = extractVar(html, 'DETECTED_COUNTRY'); 126 const price = extractVar(html, 'INITIAL_PRICE'); 127 128 // GB is normalised to UK internally 129 assert.equal(country, 'UK', `Expected UK (normalised from GB), got ${country}`); 130 assert.equal(price?.currency, 'GBP', `Expected GBP for GB/UK, got ${price?.currency}`); 131 }); 132 133 test('unknown country XX falls back to ip-api.com lookup (not US hardcode)', async () => { 134 const html = await fetchPage({ 'CF-IPCountry': 'XX' }); 135 const country = extractVar(html, 'DETECTED_COUNTRY'); 136 const price = extractVar(html, 'INITIAL_PRICE'); 137 138 // XX = Cloudflare "unknown" — falls back to ip-api.com real IP lookup, not a hardcoded US. 139 // From AU test environment this returns AU; from US it'd return US. 140 // Just verify we got a valid 2-letter country code and a price. 141 assert.match(country, /^[A-Z]{2}$/, `Expected valid 2-letter country code, got ${country}`); 142 assert.ok(price?.currency, `Expected a currency, got ${price?.currency}`); 143 }); 144 }); 145 146 describe('Geo detection — Accept-Language fallback', () => { 147 test('en-CA Accept-Language → CA → CAD (when no CF-IPCountry)', async () => { 148 // Omit CF-IPCountry; ip-api.com will detect our real IP (AU). 149 // Accept-Language only fires if ip-api.com fails or returns nothing. 150 // This test verifies the fallback path using a spoofed localhost-like scenario. 151 // Skip if we can't reach origin or ip-api.com overrides it. 152 const html = await fetchPage({ 'Accept-Language': 'en-CA,en;q=0.9' }); 153 const country = extractVar(html, 'DETECTED_COUNTRY'); 154 // Accept-Language is only a fallback — if ip-api.com worked it'll return our real IP's country. 155 // Just assert we got a valid 2-letter code back. 156 assert.match(country, /^[A-Z]{2}$/, `Expected 2-letter country code, got ${country}`); 157 }); 158 }); 159 160 describe('Pricing data — GB/UK alias in PRICING_DATA', () => { 161 test('PRICING_DATA contains a GBP entry (GB key from CF Worker / pricing.json)', async () => { 162 const html = await fetchPage({ 'CF-IPCountry': 'US' }); 163 const pricingData = extractVar(html, 'PRICING_DATA'); 164 165 assert.ok(pricingData, 'PRICING_DATA should be present'); 166 // The pricing data uses GB as the key (not UK). 167 // getPriceForCountry() internally converts GB→UK for lookup, but raw data uses GB. 168 const gbpEntry = pricingData['GB'] || pricingData['UK']; 169 assert.ok(gbpEntry, 'A GBP entry should exist in PRICING_DATA (under GB or UK key)'); 170 assert.equal(gbpEntry.currency, 'GBP', 'GBP entry should have GBP currency'); 171 }); 172 }); 173 174 describe('Currency dropdown — browser switching', () => { 175 test('selecting Germany in dropdown updates price to EUR', async () => { 176 const chromiumPath = detectChromiumPath(); 177 const browser = await chromium.launch({ 178 executablePath: chromiumPath || undefined, 179 headless: true, 180 }); 181 182 try { 183 const context = await browser.newContext(); 184 const page = await context.newPage(); 185 186 await page.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 30000 }); 187 188 // Wait for currency dropdown to be populated by JS 189 await page.waitForFunction(() => document.getElementById('currency')?.options.length > 1, { 190 timeout: 10000, 191 }); 192 193 // Find the Germany option (value="DE") and select it 194 const deOption = await page.$('#currency option[value="DE"]'); 195 assert.ok(deOption, 'Germany option should exist in dropdown'); 196 197 await page.selectOption('#currency', 'DE'); 198 199 // Price display should update to show EUR symbol 200 await page.waitForFunction( 201 () => { 202 const el = document.getElementById('display-price'); 203 return el && el.textContent.includes('€'); 204 }, 205 { timeout: 5000 } 206 ); 207 208 const priceText = await page.$eval('#display-price', el => el.textContent); 209 assert.ok( 210 priceText.includes('€'), 211 `Expected € in price after selecting DE, got: ${priceText}` 212 ); 213 } finally { 214 await browser.close(); 215 } 216 }); 217 218 test('dropdown options include country name and currency code', async () => { 219 const chromiumPath = detectChromiumPath(); 220 const browser = await chromium.launch({ 221 executablePath: chromiumPath || undefined, 222 headless: true, 223 }); 224 225 try { 226 const context = await browser.newContext(); 227 const page = await context.newPage(); 228 229 await page.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 30000 }); 230 231 await page.waitForFunction(() => document.getElementById('currency')?.options.length > 5, { 232 timeout: 10000, 233 }); 234 235 const optionTexts = await page.$$eval('#currency option', opts => 236 opts.map(o => o.textContent) 237 ); 238 239 // Every option should contain " — " separating country name from currency 240 const malformed = optionTexts.filter(t => !t.includes(' — ')); 241 assert.equal( 242 malformed.length, 243 0, 244 `All options should have "Country — Currency" format. Bad options: ${malformed.join(', ')}` 245 ); 246 247 // Should have a reasonable number of countries 248 assert.ok(optionTexts.length >= 20, `Expected ≥20 countries, got ${optionTexts.length}`); 249 } finally { 250 await browser.close(); 251 } 252 }); 253 });