diff --git a/apps/web/src/lib/utils/accept-language.test.ts b/apps/web/src/lib/utils/accept-language.test.ts index 0d54588..66d8186 100644 --- a/apps/web/src/lib/utils/accept-language.test.ts +++ b/apps/web/src/lib/utils/accept-language.test.ts @@ -51,4 +51,28 @@ describe('detectLanguage (Accept-Language style strings)', () => { it('AL-12: unknown language (fr) falls back to en', () => { expect(detectLanguage('fr-FR')).toBe('en'); }); + + // ─── Fase 20.4.1 — Euskera (eu) support ─────────────────────────────── + // Mirrors the existing es-track tests. Coverage matches the plan's + // LANG-U-EU-01..03 identifiers. + + it('LANG-U-EU-01: bare "eu" maps to eu', () => { + expect(detectLanguage('eu')).toBe('eu'); + }); + + it('LANG-U-EU-02: regional variant "eu-ES" maps to eu', () => { + expect(detectLanguage('eu-ES')).toBe('eu'); + }); + + it('LANG-U-EU-03: "eu-FR;q=0.5,en" — eu wins by primary preference', () => { + // q=0.5 is the lower bound we accept (mirrors AL-10 for es). The + // borderline value still beats `en` because the parser ranks by + // q-score and stops at the first known primary. + expect(detectLanguage('eu-FR;q=0.5,en;q=0.4')).toBe('eu'); + }); + + it('LANG-U-EU-04: array of language tags — first eu wins over later en', () => { + // Mirrors AL-11. Validates the navigator.languages path for eu. + expect(detectLanguage(['eu-ES', 'en-GB'])).toBe('eu'); + }); }); diff --git a/apps/web/src/lib/utils/accept-language.ts b/apps/web/src/lib/utils/accept-language.ts index 341e10c..c130bcb 100644 --- a/apps/web/src/lib/utils/accept-language.ts +++ b/apps/web/src/lib/utils/accept-language.ts @@ -1,21 +1,24 @@ /** * Detect the user's preferred app language from an Accept-Language header - * (or navigator.languages array) — Fase 10.8. + * (or navigator.languages array) — Fase 10.8 + Fase 20.3.2 (eu). * - * Supported app languages (in sync with messages/{en,es}.json + Paraglide - * languageTags): `en`, `es`. Anything else falls back to `en`. + * Supported app languages (in sync with messages/{en,es,eu}.json + + * Paraglide languageTags): `en`, `es`, `eu`. Anything else falls back + * to `en`. * - * Rules (per plan §10.8.1): + * Rules (per plan §10.8.1, extended for Fase 20): * * Inputs we accept: - * - bare tag: `'es'`, `'en'`, `'es-ES'`, `'en-US'` - * - RFC 7231 list: `'es;q=0.9,en;q=0.8'` - * - `navigator.languages` array: `['es-ES', 'en-GB']` + * - bare tag: `'es'`, `'en'`, `'es-ES'`, `'en-US'`, `'eu'`, `'eu-ES'` + * - RFC 7231 list: `'es;q=0.9,en;q=0.8'`, `'eu-FR;q=0.5,en;q=0.4'` + * - `navigator.languages` array: `['es-ES', 'en-GB']`, `['eu-ES', 'en']` * - null / undefined / empty → `'en'` - * * If the highest q-scored entry whose primary subtag is `es` has - * q ≥ 0.5, the result is `'es'`. Otherwise (including unknown - * languages, malformed input, or no clear preference) → `'en'`. + * * The first entry (sorted by q-score descending) whose primary subtag + * is an app-known language wins, provided q ≥ 0.5. `en` short-circuits + * immediately regardless of q-score because it's the source language. + * * Otherwise (including unknown languages, malformed input, or no + * clear preference) → `'en'`. */ -export type AppLanguage = 'en' | 'es'; +export type AppLanguage = 'en' | 'es' | 'eu'; interface ParsedTag { tag: string; @@ -76,11 +79,18 @@ export function detectLanguage( // Sort by q-score descending. parsed.sort((a, b) => b.q - a.q); - // First app-known primary wins; if it's es with q ≥ 0.5 → es. + // First app-known primary wins; non-`en` primaries require q ≥ 0.5 to + // beat the implicit `en` fallback. `en` short-circuits because it's the + // source language and can never be more wrong than the user's first + // choice if they listed it at all. Fase 20 adds `eu` on the same gate + // as `es`. for (const p of parsed) { if (p.primary === 'es') { return p.q >= 0.5 ? 'es' : 'en'; } + if (p.primary === 'eu') { + return p.q >= 0.5 ? 'eu' : 'en'; + } if (p.primary === 'en') return 'en'; } diff --git a/packages/test-utils/tests/language-bootstrap.test.ts b/packages/test-utils/tests/language-bootstrap.test.ts index 278cc41..3dd339d 100644 --- a/packages/test-utils/tests/language-bootstrap.test.ts +++ b/packages/test-utils/tests/language-bootstrap.test.ts @@ -57,4 +57,58 @@ describe('Language bootstrap UPDATE', () => { // Borja's seed language is 'es'; RLS must have blocked Ana's update. expect(data?.language).toBe('es'); }); + + // ─── Fase 20.4.2 — Euskera bootstrap ───────────────────────────────── + // The bootstrap call sites build the UPDATE payload from the parser + // output of `detectLanguage(...)`. These exercise the live write of an + // `eu` value against the actual ENUM, and a negative case that the + // gate (the language_code enum from migration 028) rejects an unknown + // locale at write time so a careless client cannot persist a value the + // app has no messages for. + + it('LANG-INT-EU-01: user can update their own language to eu', async () => { + const ana = await createClientAs(ANA_ID); + // Reset to en first via admin so the assertion measures the user + // write, not seed state. + await admin.from('users').update({ language: 'en' }).eq('id', ANA_ID); + + const { error } = await ana + .from('users') + .update({ language: 'eu' }) + .eq('id', ANA_ID); + expect(error).toBeNull(); + + const { data } = await admin + .from('users') + .select('language') + .eq('id', ANA_ID) + .single(); + expect(data?.language).toBe('eu'); + }); + + it('LANG-INT-EU-02: user trying language=\'fr\' is rejected by the enum', async () => { + const ana = await createClientAs(ANA_ID); + // Pre-set Ana to a known good value so a regression that silently + // accepts the bad UPDATE can't hide behind seed state. + await admin.from('users').update({ language: 'es' }).eq('id', ANA_ID); + + const { error } = await ana + .from('users') + // Force any client-side narrow types out of the way — the gate + // lives in Postgres, not TypeScript. + .update({ language: 'fr' as unknown as 'en' }) + .eq('id', ANA_ID); + // Postgres invalid_text_representation (22P02) for an unknown enum + // label; PostgREST surfaces it as a structured error. + expect(error).not.toBeNull(); + expect(error?.code).toBe('22P02'); + + const { data } = await admin + .from('users') + .select('language') + .eq('id', ANA_ID) + .single(); + // Untouched. + expect(data?.language).toBe('es'); + }); });