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', () => {
|
||||
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
|
||||
* (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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user