/ __quarantined_tests__ / e2e / geo-detection.test.js
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_AUDITANDFIX_URL in .env (defaults to https://auditandfix.com)
 16   *   - AUDITANDFIX_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_AUDITANDFIX_URL || 'https://auditandfix.com';
 29  const ORIGIN_IP = process.env.AUDITANDFIX_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  });