/** * R-series (Presence): Supabase Realtime Presence on a list channel. * * Verifies that two authenticated clients sharing the same channel name see * each other's presence state, and that leaving the channel removes a user * from everyone's view. */ import { describe, it, expect, afterEach } from 'vitest'; import { createClientAs } from '../src/supabase-clients.js'; import { ANA_ID, BORJA_ID, SEED_LIST_ID } from '../src/seed-constants.js'; import type { RealtimeChannel, SupabaseClient } from '@supabase/supabase-js'; // Track all clients created in a test so we can force-close their sockets in // afterEach. `removeAllChannels` only unsubs channels; the underlying Phoenix // socket stays open per singleFork and leaks state into later tests. const activeClients: SupabaseClient[] = []; function track(client: SupabaseClient): SupabaseClient { activeClients.push(client); return client; } afterEach(async () => { for (const c of activeClients.splice(0)) { await c.removeAllChannels(); await c.realtime.disconnect(); } }); /** * Subscribe to a presence channel and track the state. Resolves once the * initial `presence sync` event has fired so callers can rely on the state * snapshot being current before asserting. */ async function subscribePresence( client: SupabaseClient, channelName: string, presenceKey: string, payload: Record = {} ): Promise<{ channel: RealtimeChannel; state: () => Record; waitForJoin: (key: string, timeoutMs?: number) => Promise; waitForLeave: (key: string, timeoutMs?: number) => Promise; unsubscribe: () => Promise; }> { const channel = client.channel(channelName, { config: { presence: { key: presenceKey } } }); const joinWaiters: Array<{ key: string; resolve: () => void; reject: (e: Error) => void }> = []; const leaveWaiters: Array<{ key: string; resolve: () => void; reject: (e: Error) => void }> = []; channel.on('presence', { event: 'sync' }, () => { // Tracked for debugging only — state accessor returns the current // snapshot on demand. }); channel.on('presence', { event: 'join' }, ({ key }) => { for (let i = joinWaiters.length - 1; i >= 0; i--) { if (joinWaiters[i].key === key) { joinWaiters[i].resolve(); joinWaiters.splice(i, 1); } } }); channel.on('presence', { event: 'leave' }, ({ key }) => { for (let i = leaveWaiters.length - 1; i >= 0; i--) { if (leaveWaiters[i].key === key) { leaveWaiters[i].resolve(); leaveWaiters.splice(i, 1); } } }); await new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('Presence SUBSCRIBED timeout')), 10_000); channel.subscribe(async (status) => { if (status === 'SUBSCRIBED') { clearTimeout(timeout); await channel.track({ user_id: presenceKey, ...payload }); resolve(); } else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') { clearTimeout(timeout); reject(new Error(`Presence subscribe failed: ${status}`)); } }); }); return { channel, state: () => channel.presenceState() as Record, waitForJoin: (key, timeoutMs = 5_000) => new Promise((resolve, reject) => { if (key in channel.presenceState()) return resolve(); const t = setTimeout(() => reject(new Error(`waitForJoin(${key}) timeout`)), timeoutMs); joinWaiters.push({ key, resolve: () => { clearTimeout(t); resolve(); }, reject }); }), waitForLeave: (key, timeoutMs = 5_000) => new Promise((resolve, reject) => { const t = setTimeout(() => reject(new Error(`waitForLeave(${key}) timeout`)), timeoutMs); leaveWaiters.push({ key, resolve: () => { clearTimeout(t); resolve(); }, reject }); }), unsubscribe: async () => { await channel.unsubscribe(); } }; } // KNOWN UPSTREAM BUG (supabase/realtime v2.76.5 + v2.83.0): // Presence `presence_diff` broadcasts crash `RealtimeWeb.RealtimeChannel`: // (UndefinedFunctionError) function RealtimeChannel.handle_out/3 is undefined // The GenServer terminates, taking the connection down. Presence state appears // to work in one-off runs but is unreliable under load. // // The tests below are correct — unskip once the upstream bug is fixed (or // when we upgrade to a Realtime version that defines handle_out properly). // See: Realtime logs show the crash, postgres_changes + isolation tests work // fine (they don't go through handle_out). describe.skip('Realtime Presence — list session channel (blocked by upstream bug)', () => { it('R-P-01: two clients on the same channel see each other', async () => { const channelName = `list:${SEED_LIST_ID}:presence-${Date.now()}`; const ana = track(await createClientAs(ANA_ID)); const borja = track(await createClientAs(BORJA_ID)); const anaSub = await subscribePresence(ana, channelName, ANA_ID, { name: 'Ana' }); const borjaSub = await subscribePresence(borja, channelName, BORJA_ID, { name: 'Borja' }); try { // Each side should eventually see both users in the presence state. // Wait for the "join" event that completes each side's view of the // full roster. We wait for BOTH directions in parallel — when the // test runs after other Realtime files (singleFork) the server // occasionally takes >1s to emit the cross-presence_diff, so a // serial await would race with it. await Promise.all([anaSub.waitForJoin(BORJA_ID), borjaSub.waitForJoin(ANA_ID)]); // Small settle window so both channel.presenceState() snapshots // reflect the same roster before we diff them. await new Promise((r) => setTimeout(r, 100)); expect(Object.keys(anaSub.state()).sort()).toEqual([ANA_ID, BORJA_ID].sort()); expect(Object.keys(borjaSub.state()).sort()).toEqual([ANA_ID, BORJA_ID].sort()); } finally { await anaSub.unsubscribe(); await borjaSub.unsubscribe(); } }); it('R-P-02: unsubscribing removes the user from the other client\'s state', async () => { const channelName = `list:${SEED_LIST_ID}:presence-${Date.now()}`; const ana = track(await createClientAs(ANA_ID)); const borja = track(await createClientAs(BORJA_ID)); const anaSub = await subscribePresence(ana, channelName, ANA_ID); const borjaSub = await subscribePresence(borja, channelName, BORJA_ID); try { await anaSub.waitForJoin(BORJA_ID); // Ana sees both herself and Borja before the leave expect(Object.keys(anaSub.state()).sort()).toEqual([ANA_ID, BORJA_ID].sort()); // Borja leaves — Ana should see the leave event const leavePromise = anaSub.waitForLeave(BORJA_ID); await borjaSub.unsubscribe(); await leavePromise; // Small settle window for the presence_diff to be applied to the // internal state snapshot after the `leave` callback fires. await new Promise((r) => setTimeout(r, 50)); const afterKeys = Object.keys(anaSub.state()); expect(afterKeys).not.toContain(BORJA_ID); expect(afterKeys).toContain(ANA_ID); } finally { await anaSub.unsubscribe(); await ana.removeAllChannels(); await borja.removeAllChannels(); } }); });