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

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