Files
collective-lists/packages/test-utils/tests/language-bootstrap.test.ts
Oier Bravo Urtasun e9c29dcf38 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>
2026-05-19 03:54:31 +02:00

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