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>
115 lines
4.0 KiB
TypeScript
115 lines
4.0 KiB
TypeScript
/**
|
|
* LANG-series: language bootstrap (Fase 10.8).
|
|
*
|
|
* The bootstrap path is a two-step in the client:
|
|
* 1. detectLanguage(...) — pure parser (unit-tested in apps/web)
|
|
* 2. update public.users set language = ... where id = $1
|
|
*
|
|
* This integration suite checks (2): the UPDATE wire-format the layout
|
|
* relies on works against the live RLS-enabled table, both as the user
|
|
* themselves (allowed) and as another seed user (rejected). The full
|
|
* end-to-end login dance can't be reproduced here without bringing up
|
|
* a real OIDC flow, so we focus on the contract the bootstrap depends
|
|
* on.
|
|
*/
|
|
import { describe, it, expect, afterAll } from 'vitest';
|
|
import { createClientAs, createAdminClient } from '../src/supabase-clients.js';
|
|
import { sql, closePool } from '../src/db-helpers.js';
|
|
import { ANA_ID, BORJA_ID } from '../src/seed-constants.js';
|
|
|
|
const admin = createAdminClient();
|
|
|
|
afterAll(async () => {
|
|
// Restore Ana's seed language.
|
|
await sql(`UPDATE public.users SET language = 'es' WHERE id = $1`, [ANA_ID]);
|
|
await closePool();
|
|
});
|
|
|
|
describe('Language bootstrap UPDATE', () => {
|
|
it('LANG-01: user can update their own language to es', async () => {
|
|
const ana = await createClientAs(ANA_ID);
|
|
// Reset to en first via admin.
|
|
await admin.from('users').update({ language: 'en' }).eq('id', ANA_ID);
|
|
|
|
const { error } = await ana
|
|
.from('users')
|
|
.update({ language: 'es' })
|
|
.eq('id', ANA_ID);
|
|
expect(error).toBeNull();
|
|
|
|
const { data } = await admin
|
|
.from('users')
|
|
.select('language')
|
|
.eq('id', ANA_ID)
|
|
.single();
|
|
expect(data?.language).toBe('es');
|
|
});
|
|
|
|
it('LANG-02: user cannot update another user\'s language', async () => {
|
|
const ana = await createClientAs(ANA_ID);
|
|
await ana.from('users').update({ language: 'en' }).eq('id', BORJA_ID);
|
|
|
|
const { data } = await admin
|
|
.from('users')
|
|
.select('language')
|
|
.eq('id', BORJA_ID)
|
|
.single();
|
|
// 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');
|
|
});
|
|
});
|