Extend the 2b.0 red-phase coverage: presence, RLS isolation, offline-queue
contract, and Playwright scaffolds for the shopping-session UX. Only the
presence test is blocked on an upstream Realtime bug (documented below); all
other Realtime paths are proven end-to-end.
Tests
- realtime-postgres-changes.test.ts: now uses afterEach socket disconnect
(Vitest singleFork kept Phoenix sockets alive between files, leaking state)
- realtime-isolation.test.ts (new): R-I-01 David (guest, same collective)
receives events; R-I-02 Eva (non-member) receives NONE — RLS is enforced
server-side per subscribed JWT
- realtime-presence.test.ts (new, describe.skip): two-client roster + leave
assertions ready, gated on upstream bug
- sync-queue.test.ts (new, describe.skip): 7 placeholders pinning the
apps/web/src/lib/sync/queue.ts contract (Q-01..Q-04 enqueue / in-order
flush / retry; F-01..F-03 online-event flush + last-write-wins)
- apps/web/tests/e2e/{session,realtime,offline}.test.ts (new, describe.skip):
S-01..S-03, R-E-01, R-E-02, O-01, O-02 pinning the Modo Compra UI contract
- vitest.config.ts: fileParallelism=false, drop singleFork so each file gets
a fresh worker fork — prevents RealtimeClient singleton leakage
Infra
- db-init/00-role-passwords.sh: pre-create `realtime` schema with
AUTHORIZATION supabase_admin. Realtime's per-tenant migrator creates tables
INSIDE the schema but does not create the schema itself; without this the
first tenant connect fails with "schema realtime does not exist"
- docker-compose.dev.yml: adopt the upstream supabase/supabase Realtime env
shape — SEED_SELF_HOST=true + RUN_JANITOR=true + RLIMIT_NOFILE=10000 +
drop the custom `command: eval seeds` (that bypasses the normal supervisor
startup and was observed to cause GenServer crashes under load).
Version pinned to v2.76.5 to match the official docker-compose.
Known upstream bug
- Realtime v2.76.5 and v2.83.0 both crash on presence_diff:
`(UndefinedFunctionError) RealtimeChannel.handle_out/3 is undefined`.
postgres_changes + isolation unaffected (they don't traverse handle_out).
Presence tests stay `describe.skip` until a fixed upstream is released.
Totals: 59 Vitest passed (+ 9 skipped awaiting implementation/upstream),
16 pgTAP, 15 Playwright (+ 7 E2E scaffolded for 2b UI) = 90 green.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
195 lines
7.0 KiB
TypeScript
195 lines
7.0 KiB
TypeScript
/**
|
|
* 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<string, unknown> = {}
|
|
): Promise<{
|
|
channel: RealtimeChannel;
|
|
state: () => Record<string, unknown[]>;
|
|
waitForJoin: (key: string, timeoutMs?: number) => Promise<void>;
|
|
waitForLeave: (key: string, timeoutMs?: number) => Promise<void>;
|
|
unsubscribe: () => Promise<void>;
|
|
}> {
|
|
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<void>((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<string, unknown[]>,
|
|
waitForJoin: (key, timeoutMs = 5_000) =>
|
|
new Promise<void>((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<void>((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();
|
|
}
|
|
});
|
|
});
|