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:
2026-05-19 03:54:31 +02:00
parent 39ec7996c5
commit e9c29dcf38
3 changed files with 100 additions and 12 deletions

View File

@@ -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');
});
}); });

View File

@@ -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';
} }

View File

@@ -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');
});
}); });