diff --git a/apps/web/src/app/session/[id]/page.test.tsx b/apps/web/src/app/session/[id]/page.test.tsx index d8d0a2a5..362718b6 100644 --- a/apps/web/src/app/session/[id]/page.test.tsx +++ b/apps/web/src/app/session/[id]/page.test.tsx @@ -26,8 +26,25 @@ const mockUseWebRTC = { toggleMic: mockToggleMic, }; +// Capture the options each WebRTC hook is called with so we can assert the +// participantId is a valid UUID (the token/signal routes require z.uuid()). +const { mockUseWebRTCFn, mockUseWebRTCSFUFn } = vi.hoisted(() => ({ + mockUseWebRTCFn: vi.fn(), + mockUseWebRTCSFUFn: vi.fn(), +})); + vi.mock('@/hooks/useWebRTC', () => ({ - useWebRTC: () => mockUseWebRTC, + useWebRTC: (opts: { participantId: string }) => { + mockUseWebRTCFn(opts); + return mockUseWebRTC; + }, +})); + +vi.mock('@/hooks/useWebRTCSFU', () => ({ + useWebRTCSFU: (opts: { participantId: string }) => { + mockUseWebRTCSFUFn(opts); + return mockUseWebRTC; + }, })); vi.mock('@/hooks/useSessionPresence', () => ({ @@ -504,4 +521,50 @@ describe('SessionViewerPage', () => { expect(screen.queryByTestId('session-settings-panel')).not.toBeInTheDocument(); }); }); + + describe('participant identity', () => { + const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const baseSession = { + id: 'session-123', + join_code: 'ABC123', + status: 'active', + settings: { quality: 'medium', allowControl: false, maxParticipants: 5 }, + created_at: '2024-01-01T00:00:00Z', + session_participants: [], + }; + + it('passes a UUID participantId to the SFU viewer (token route requires uuid)', async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ data: { ...baseSession, mode: 'sfu' } }), + } as Response); + + await act(async () => { + renderWithSuspense(); + }); + await waitFor(() => { + expect(mockUseWebRTCSFUFn).toHaveBeenCalled(); + }); + + const opts = mockUseWebRTCSFUFn.mock.calls[0]?.[0] as { participantId: string }; + expect(opts.participantId).toMatch(UUID_RE); + }); + + it('passes a UUID participantId to the P2P viewer', async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ data: { ...baseSession, mode: 'p2p' } }), + } as Response); + + await act(async () => { + renderWithSuspense(); + }); + await waitFor(() => { + expect(mockUseWebRTCFn).toHaveBeenCalled(); + }); + + const opts = mockUseWebRTCFn.mock.calls[0]?.[0] as { participantId: string }; + expect(opts.participantId).toMatch(UUID_RE); + }); + }); }); diff --git a/apps/web/src/app/session/[id]/page.tsx b/apps/web/src/app/session/[id]/page.tsx index 0ce09d96..1435366e 100644 --- a/apps/web/src/app/session/[id]/page.tsx +++ b/apps/web/src/app/session/[id]/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, use, useId, useRef, useCallback, useMemo } from 'react'; +import { useState, useEffect, use, useRef, useCallback, useMemo } from 'react'; import Link from 'next/link'; import { Users, @@ -155,7 +155,10 @@ interface SessionViewerWrapperProps { } function P2PSessionViewer({ sessionId, session }: SessionViewerWrapperProps) { - const participantId = useId(); + // A real UUID — /api/livekit/token and the signal routes validate + // participantId with z.string().uuid(). useId() returns React opaque ids + // (":r0:"), which the SFU token route rejects ("Invalid participant ID"). + const [participantId] = useState(() => crypto.randomUUID()); const [remoteCursors, setRemoteCursors] = useState>(new Map()); const handleCursorUpdate = useCallback((cursor: CursorPositionMessage) => { @@ -189,7 +192,10 @@ function P2PSessionViewer({ sessionId, session }: SessionViewerWrapperProps) { } function SFUSessionViewer({ sessionId, session }: SessionViewerWrapperProps) { - const participantId = useId(); + // A real UUID — /api/livekit/token and the signal routes validate + // participantId with z.string().uuid(). useId() returns React opaque ids + // (":r0:"), which the SFU token route rejects ("Invalid participant ID"). + const [participantId] = useState(() => crypto.randomUUID()); const [remoteCursors, setRemoteCursors] = useState>(new Map()); const handleCursorUpdate = useCallback((cursor: CursorPositionMessage) => {