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:
60
packages/test-utils/tests/language-bootstrap.test.ts
Normal file
60
packages/test-utils/tests/language-bootstrap.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user