feat(fase-20): accept-language + bootstrap recognise 'eu'
Extends the Fase 10.8 Accept-Language parser to surface `'eu'` as a third
known primary subtag, gated on the same q ≥ 0.5 threshold the existing
`'es'` path uses. `'en'` continues to short-circuit because it's the
source language. `AppLanguage` widens to `'en' | 'es' | 'eu'`.
Tests:
* Unit (apps/web): LANG-U-EU-01 bare 'eu', LANG-U-EU-02 'eu-ES',
LANG-U-EU-03 'eu-FR;q=0.5,en;q=0.4' (borderline q wins), LANG-U-EU-04
navigator.languages array — 4 new specs, all green.
* Integration (packages/test-utils): LANG-INT-EU-01 user updates own
language=eu, LANG-INT-EU-02 user attempts language='fr' and gets the
Postgres 22P02 from the ENUM (cast through `as unknown as` so the
narrow TS type doesn't block the test).
The integration negative case lands on the language_code ENUM gate from
migration 028, not RLS — it would still fail even from a service-role
client.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -51,4 +51,28 @@ describe('detectLanguage (Accept-Language style strings)', () => {
|
|||||||
it('AL-12: unknown language (fr) falls back to en', () => {
|
it('AL-12: unknown language (fr) falls back to en', () => {
|
||||||
expect(detectLanguage('fr-FR')).toBe('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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* Detect the user's preferred app language from an Accept-Language header
|
* 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
|
* Supported app languages (in sync with messages/{en,es,eu}.json +
|
||||||
* languageTags): `en`, `es`. Anything else falls back to `en`.
|
* 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:
|
* * Inputs we accept:
|
||||||
* - bare tag: `'es'`, `'en'`, `'es-ES'`, `'en-US'`
|
* - bare tag: `'es'`, `'en'`, `'es-ES'`, `'en-US'`, `'eu'`, `'eu-ES'`
|
||||||
* - RFC 7231 list: `'es;q=0.9,en;q=0.8'`
|
* - 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']`
|
* - `navigator.languages` array: `['es-ES', 'en-GB']`, `['eu-ES', 'en']`
|
||||||
* - null / undefined / empty → `'en'`
|
* - null / undefined / empty → `'en'`
|
||||||
* * If the highest q-scored entry whose primary subtag is `es` has
|
* * The first entry (sorted by q-score descending) whose primary subtag
|
||||||
* q ≥ 0.5, the result is `'es'`. Otherwise (including unknown
|
* is an app-known language wins, provided q ≥ 0.5. `en` short-circuits
|
||||||
* languages, malformed input, or no clear preference) → `'en'`.
|
* 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 {
|
interface ParsedTag {
|
||||||
tag: string;
|
tag: string;
|
||||||
@@ -76,11 +79,18 @@ export function detectLanguage(
|
|||||||
// Sort by q-score descending.
|
// Sort by q-score descending.
|
||||||
parsed.sort((a, b) => b.q - a.q);
|
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) {
|
for (const p of parsed) {
|
||||||
if (p.primary === 'es') {
|
if (p.primary === 'es') {
|
||||||
return p.q >= 0.5 ? 'es' : 'en';
|
return p.q >= 0.5 ? 'es' : 'en';
|
||||||
}
|
}
|
||||||
|
if (p.primary === 'eu') {
|
||||||
|
return p.q >= 0.5 ? 'eu' : 'en';
|
||||||
|
}
|
||||||
if (p.primary === 'en') return 'en';
|
if (p.primary === 'en') return 'en';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,4 +57,58 @@ describe('Language bootstrap UPDATE', () => {
|
|||||||
// Borja's seed language is 'es'; RLS must have blocked Ana's update.
|
// Borja's seed language is 'es'; RLS must have blocked Ana's update.
|
||||||
expect(data?.language).toBe('es');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user