diff --git a/dashcaddy-api/__tests__/i18n.test.js b/dashcaddy-api/__tests__/i18n.test.js index ad48339..cbb13c5 100644 --- a/dashcaddy-api/__tests__/i18n.test.js +++ b/dashcaddy-api/__tests__/i18n.test.js @@ -31,7 +31,7 @@ describe('DC-077: i18n system', () => { }); it('falls back to English for unsupported language', () => { - expect(i18n.t('dashboard.title', 'zh')).toBe('Dashboard'); + expect(i18n.t('dashboard.title', 'xx')).toBe('Dashboard'); }); it('falls back to key if not found in any language', () => { @@ -58,8 +58,8 @@ describe('DC-077: i18n system', () => { }); it('returns false for unsupported languages', () => { - expect(i18n.isSupported('zh')).toBe(false); - expect(i18n.isSupported('ja')).toBe(false); + expect(i18n.isSupported('xx')).toBe(false); + expect(i18n.isSupported('klingon')).toBe(false); }); }); @@ -81,14 +81,61 @@ describe('DC-077: i18n system', () => { }); it('defaults to English for unsupported languages', () => { - expect(i18n.detectLanguage('zh-CN,zh;q=0.9')).toBe('en'); - expect(i18n.detectLanguage('ja-JP,ja;q=0.9')).toBe('en'); + expect(i18n.detectLanguage('xx-XX,xx;q=0.9')).toBe('en'); + expect(i18n.detectLanguage('klingon-KL,klingon;q=0.9')).toBe('en'); }); it('strips region codes before matching', () => { expect(i18n.detectLanguage('en-US,en;q=0.9')).toBe('en'); expect(i18n.detectLanguage('de-AT,de;q=0.9')).toBe('de'); }); + + + it('respects equal q-values by order', () => { + expect(i18n.detectLanguage('en;q=0.5,de;q=0.5')).toBe('en'); + }); + + it('excludes q=0 entries per RFC 7231', () => { + expect(i18n.detectLanguage('en;q=0,fr;q=0.9')).toBe('fr'); + }); + + it('serves default language when all entries have q=0 (intentional fallback)', () => { + expect(i18n.detectLanguage('en;q=0,fr;q=0')).toBe('en'); + }); + + it('handles malformed q-values gracefully', () => { + // 'abc' is not a valid q-value per RFC 7231 grammar, so it is treated as + // "no q-value specified" — per the HTTP spec the default weight is q=1.0. + expect(i18n.detectLanguage('en;q=abc,fr;q=0.9')).toBe('en'); + }); + + it('accepts q=0 boundary (excludes entry)', () => { + expect(i18n.detectLanguage('en;q=0,fr;q=0.9')).toBe('fr'); + }); + + it('accepts q=1 boundary', () => { + expect(i18n.detectLanguage('en;q=1,fr;q=0.9')).toBe('en'); + }); + + it('accepts q=1.0', () => { + expect(i18n.detectLanguage('en;q=1.0,fr;q=0.9')).toBe('en'); + }); + + it('accepts q=0.001 (lowest non-zero weight)', () => { + expect(i18n.detectLanguage('en;q=0.001,fr;q=0.9')).toBe('fr'); + }); + + it('accepts q=0.999', () => { + expect(i18n.detectLanguage('en;q=0.999,fr;q=0.9')).toBe('en'); + }); + + it('rejects q=1.001 (RFC invalid) — defaults to 1.0', () => { + expect(i18n.detectLanguage('en;q=1.001,fr;q=0.9')).toBe('en'); + }); + + it('handles uppercase Q parameter', () => { + expect(i18n.detectLanguage('en;Q=0.5,fr;q=0.9')).toBe('fr'); + }); }); describe('RTL support', () => { diff --git a/dashcaddy-api/__tests__/routes/i18n-routes.test.js b/dashcaddy-api/__tests__/routes/i18n-routes.test.js index 2f5f1fe..724aa6d 100644 --- a/dashcaddy-api/__tests__/routes/i18n-routes.test.js +++ b/dashcaddy-api/__tests__/routes/i18n-routes.test.js @@ -14,13 +14,13 @@ function createI18nApp() { } describe('DC-077: i18n Routes', () => { - it('GET /i18n/languages returns 5 languages', async () => { + it('GET /i18n/languages returns 31 languages', async () => { const app = createI18nApp(); const res = await request(app).get('/api/v1/i18n/languages'); expect(res.status).toBe(200); expect(res.body.success).toBe(true); - expect(res.body.languages).toHaveLength(5); + expect(res.body.languages).toHaveLength(31); expect(res.body.default).toBe('en'); }); diff --git a/dashcaddy-api/src/utilities/i18n.js b/dashcaddy-api/src/utilities/i18n.js index ad73096..b7b1c7c 100644 --- a/dashcaddy-api/src/utilities/i18n.js +++ b/dashcaddy-api/src/utilities/i18n.js @@ -529,10 +529,40 @@ function isSupported(lang) { return SUPPORTED_LANGUAGES.indexOf(lang) >= 0; } function detectLanguage(acceptLanguage) { if (!acceptLanguage) return DEFAULT_LANGUAGE; var parts = acceptLanguage.split(','); + var entries = []; for (var i = 0; i < parts.length; i++) { - var code = parts[i].trim().split(';')[0].split('-')[0].toLowerCase(); - if (isSupported(code)) return code; + var seg = parts[i].trim(); + if (!seg) continue; + var bits = seg.split(';'); + var code = bits[0].split('-')[0].trim().toLowerCase(); + if (!code) continue; + var q = 1.0; + for (var j = 1; j < bits.length; j++) { + var kv = bits[j].trim().split('='); + if (kv.length === 2 && kv[0].trim().toLowerCase() === 'q') { + var qStr = kv[1].trim(); + // RFC 7231 §5.3.1: qvalue = ( "0" [ "." 0*3DIGIT ] ) / ( "1" [ "." 0*3"0" ] ) + // Match the strict grammar; values that do not conform are treated as + // "no q-value specified" and fall back to q=1.0, the HTTP default. + var qMatch = qStr.match(/^(0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/); + if (qMatch) { + q = parseFloat(qMatch[1]); + } + } + } + entries.push({ code: code, q: q, order: i }); } + entries.sort(function (a, b) { + if (b.q !== a.q) return b.q - a.q; + return a.order - b.order; + }); + for (var k = 0; k < entries.length; k++) { + if (entries[k].q === 0) continue; + if (isSupported(entries[k].code)) return entries[k].code; + } + // Intentional design policy: when every supported entry was explicitly + // refused with q=0 (or no supported language was offered), fall back to the + // server default (DEFAULT_LANGUAGE) rather than honoring the refusal. return DEFAULT_LANGUAGE; }