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