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>
61 lines
2.0 KiB
TypeScript
61 lines
2.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');
|
|
});
|
|
});
|