/ tests / website / i18n-keys.test.js
i18n-keys.test.js
  1  /**
  2   * i18n Translation Key Tests
  3   *
  4   * Verifies that:
  5   * 1. en.json (master) contains all required keys with non-empty values
  6   * 2. Every other lang file has valid JSON and no raw key strings as values
  7   * 3. Keys that MUST be translated in each lang (not just English fallback) are present
  8   * 4. No lang file introduces keys absent from en.json (orphan keys)
  9   */
 10  
 11  import { test, describe } from 'node:test';
 12  import assert from 'node:assert/strict';
 13  import { readFileSync, readdirSync } from 'fs';
 14  import { join, dirname } from 'path';
 15  import { fileURLToPath } from 'url';
 16  
 17  const __dirname = dirname(fileURLToPath(import.meta.url));
 18  const LANG_DIR = join(__dirname, '../../../mmo-platform/auditandfix.com/lang');
 19  // Derived from the actual lang/ directory — automatically picks up future translations
 20  const SUPPORTED_LANGS = readdirSync(LANG_DIR)
 21    .filter(f => f.endsWith('.json'))
 22    .map(f => f.replace('.json', ''))
 23    .sort();
 24  
 25  function loadLang(lang) {
 26    return JSON.parse(readFileSync(join(LANG_DIR, `${lang}.json`), 'utf-8'));
 27  }
 28  
 29  const en = loadLang('en');
 30  
 31  // Keys that must exist and be non-empty in en.json (all keys are required in master)
 32  const ALL_KEYS = Object.keys(en);
 33  
 34  // Keys where per-language translation is important enough to warn if missing
 35  // (locale-specific: phone placeholders, currency labels, price-sensitive copy)
 36  const LOCALE_SENSITIVE_KEYS = [
 37    'order.phone_placeholder',
 38    'order.currency_hint',
 39    'order.price_meta',
 40  ];
 41  
 42  describe('en.json — master translation file', () => {
 43    test('parses as valid JSON', () => {
 44      assert.ok(typeof en === 'object' && en !== null);
 45    });
 46  
 47    test('contains order.country_label', () => {
 48      assert.ok('order.country_label' in en, 'order.country_label must exist in en.json');
 49      assert.ok(en['order.country_label'].length > 0, 'order.country_label must not be empty');
 50    });
 51  
 52    test('order.country_label is not a raw key string', () => {
 53      assert.notEqual(en['order.country_label'], 'order.country_label');
 54    });
 55  
 56    test('all order.* keys have non-empty string values', () => {
 57      const orderKeys = ALL_KEYS.filter(k => k.startsWith('order.'));
 58      for (const key of orderKeys) {
 59        assert.ok(typeof en[key] === 'string', `en.json key "${key}" must be a string`);
 60        assert.ok(en[key].trim().length > 0, `en.json key "${key}" must not be empty`);
 61      }
 62    });
 63  
 64    test('no duplicate keys (JSON parse would silently keep last)', () => {
 65      // JSON.parse silently deduplicates — count occurrences in raw text instead
 66      const raw = readFileSync(join(LANG_DIR, 'en.json'), 'utf-8');
 67      const keyMatches = [...raw.matchAll(/"([^"]+)":/g)].map(m => m[1]);
 68      const counts = {};
 69      for (const k of keyMatches) counts[k] = (counts[k] || 0) + 1;
 70      const dupes = Object.entries(counts)
 71        .filter(([, n]) => n > 1)
 72        .map(([k]) => k);
 73      assert.equal(dupes.length, 0, `Duplicate keys in en.json: ${dupes.join(', ')}`);
 74    });
 75  });
 76  
 77  describe('All lang files — structural validity', () => {
 78    for (const lang of SUPPORTED_LANGS) {
 79      test(`${lang}.json parses as valid JSON`, () => {
 80        assert.doesNotThrow(() => loadLang(lang), `${lang}.json must be valid JSON`);
 81      });
 82  
 83      test(`${lang}.json has no orphan keys (keys absent from en.json)`, () => {
 84        if (lang === 'en') return;
 85        const translations = loadLang(lang);
 86        const orphans = Object.keys(translations).filter(k => !(k in en));
 87        assert.equal(
 88          orphans.length,
 89          0,
 90          `${lang}.json has keys not in en.json (orphans): ${orphans.join(', ')}`
 91        );
 92      });
 93  
 94      test(`${lang}.json values are non-empty strings where present`, () => {
 95        const translations = loadLang(lang);
 96        // Only check keys that are non-empty strings in en.json (skip _meta object,
 97        // and intentionally-empty keys like props.title / deal.expired)
 98        const requiredNonEmpty = Object.keys(en).filter(
 99          k => typeof en[k] === 'string' && en[k].trim().length > 0
100        );
101        const bad = Object.entries(translations)
102          .filter(([k]) => requiredNonEmpty.includes(k))
103          .filter(([, v]) => typeof v !== 'string' || v.trim().length === 0);
104        assert.equal(
105          bad.length,
106          0,
107          `${lang}.json has empty/non-string values for required keys: ${bad.map(([k]) => k).join(', ')}`
108        );
109      });
110  
111      test(`${lang}.json values are not raw key strings`, () => {
112        const translations = loadLang(lang);
113        const raw = Object.entries(translations).filter(([k, v]) => k === v);
114        assert.equal(
115          raw.length,
116          0,
117          `${lang}.json has values equal to their own key (unfilled): ${raw.map(([k]) => k).join(', ')}`
118        );
119      });
120    }
121  });
122  
123  describe('i18n fallback — order.country_label resolves for all langs', () => {
124    // Simulates the PHP t() function: lang-specific value, falling back to en
125    function t(lang, key) {
126      const translations = lang === 'en' ? en : { ...en, ...loadLang(lang) };
127      return translations[key] ?? key;
128    }
129  
130    for (const lang of SUPPORTED_LANGS) {
131      test(`${lang}: order.country_label resolves to a real string (not the key)`, () => {
132        const value = t(lang, 'order.country_label');
133        assert.notEqual(
134          value,
135          'order.country_label',
136          `${lang} returned raw key for order.country_label`
137        );
138        assert.ok(value.length > 0, `${lang} returned empty string for order.country_label`);
139      });
140    }
141  });
142  
143  describe('i18n file coverage', () => {
144    test('en.json is present (master file required)', () => {
145      assert.ok(SUPPORTED_LANGS.includes('en'), 'en.json must exist in lang/');
146    });
147  
148    test('at least 2 language files exist', () => {
149      assert.ok(
150        SUPPORTED_LANGS.length >= 2,
151        `Expected ≥2 lang files, found ${SUPPORTED_LANGS.length}`
152      );
153    });
154  });