feat(fase-10): auto language detection on first login (10.8)

New $lib/utils/accept-language.ts parses Accept-Language strings
(server-style "es;q=0.9,en;q=0.8") and navigator.languages arrays,
returns 'en' or 'es'. Rule (plan §10.8.1): the highest-q-scored 'es'
entry wins if q >= 0.5; otherwise fall back to 'en'. Unknown
languages (e.g. fr) → 'en'. Pure function, 12 unit tests covering
bare tags, regional variants, q-score ordering, malformed input,
navigator.languages arrays, and the borderline q=0.5 case.

The root +layout.svelte calls maybeBootstrapLanguage() after each
SIGNED_IN: it only fires for genuinely new users (public.users row
created within the last 60s) whose language is still the default
('en'); it reads navigator.languages, runs the parser, and
UPDATEs public.users.language if the result is 'es'. setLanguageTag()
flips the live Paraglide runtime so the user lands on the next
page already in Spanish — no reload needed.

Established users keep their explicit choice (plan §10.8.5 / Riesgo
5). Server-side Accept-Language sniffing was rejected: the OIDC
dance lives outside SvelteKit (Keycloak ↔ GoTrue), so a hooks.server.ts
would never see those requests. navigator.languages is the
client-side equivalent and survives Paraglide's compile-time
tree-shaking.

LANG-01/02 (vitest integration): UPDATE public.users RLS contract
the bootstrap relies on — own row OK, other user's row rejected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 02:30:09 +02:00
parent 2924b94329
commit 378fcbe5f4
4 changed files with 251 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
/**
* 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');
});
});