diff --git a/apps/web/__tests__/orphaned-session.e2e.test.ts b/apps/web/__tests__/orphaned-session.e2e.test.ts index c2cb5d44..2d0422ae 100644 --- a/apps/web/__tests__/orphaned-session.e2e.test.ts +++ b/apps/web/__tests__/orphaned-session.e2e.test.ts @@ -98,7 +98,7 @@ describe('E2E: Orphaned Session Cleanup on Navigation', () => { // === USER NAVIGATION PHASE === // User navigates to /arcade (arcade lobby) - // The useArcadeRedirect hook calls getArcadeSession to check for active session + // Client checks for active session const activeSession = await getArcadeSession(testGuestId) // === ASSERTION PHASE === diff --git a/apps/web/src/hooks/__tests__/useRoomData.test.tsx b/apps/web/src/hooks/__tests__/useRoomData.test.tsx new file mode 100644 index 00000000..ecbeb7cb --- /dev/null +++ b/apps/web/src/hooks/__tests__/useRoomData.test.tsx @@ -0,0 +1,436 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, test, vi } from 'vitest' +import { + useCreateRoom, + useGetRoomByCode, + useJoinRoom, + useLeaveRoom, + useRoomData, +} from '../useRoomData' + +// Mock the useViewerId hook +vi.mock('../useViewerId', () => ({ + useViewerId: () => ({ data: 'test-user-id' }), +})) + +// Mock socket.io-client +vi.mock('socket.io-client', () => ({ + io: vi.fn(() => ({ + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + disconnect: vi.fn(), + connected: false, + })), +})) + +describe('useRoomData hooks', () => { + let queryClient: QueryClient + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + vi.clearAllMocks() + global.fetch = vi.fn() + }) + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ) + + describe('useRoomData', () => { + test('returns null roomData when not in a room', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + }) + + const { result } = renderHook(() => useRoomData(), { wrapper }) + + await waitFor(() => { + expect(result.current.roomData).toBeNull() + expect(result.current.isInRoom).toBe(false) + }) + }) + + test('returns room data when user is in a room', async () => { + const mockRoomData = { + room: { + id: 'room-123', + name: 'Test Room', + code: 'ABC123', + gameName: 'matching', + }, + members: [], + memberPlayers: {}, + } + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockRoomData, + }) + + const { result } = renderHook(() => useRoomData(), { wrapper }) + + await waitFor(() => { + expect(result.current.roomData).toEqual({ + id: 'room-123', + name: 'Test Room', + code: 'ABC123', + gameName: 'matching', + members: [], + memberPlayers: {}, + }) + expect(result.current.isInRoom).toBe(true) + }) + }) + + test('provides getRoomShareUrl function', () => { + const { result } = renderHook(() => useRoomData(), { wrapper }) + + const url = result.current.getRoomShareUrl('ABC123') + expect(url).toContain('/join/ABC123') + }) + }) + + describe('useCreateRoom', () => { + test('creates a room successfully', async () => { + const mockCreatedRoom = { + room: { + id: 'new-room-123', + name: 'New Room', + code: 'XYZ789', + gameName: 'matching', + }, + members: [], + memberPlayers: {}, + } + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockCreatedRoom, + }) + + const { result } = renderHook(() => useCreateRoom(), { wrapper }) + + let createdRoom: any + result.current.mutate( + { + name: 'New Room', + gameName: 'matching', + creatorName: 'Player 1', + }, + { + onSuccess: (data) => { + createdRoom = data + }, + } + ) + + await waitFor(() => { + expect(createdRoom).toEqual({ + id: 'new-room-123', + name: 'New Room', + code: 'XYZ789', + gameName: 'matching', + members: [], + memberPlayers: {}, + }) + }) + + // Verify fetch was called correctly + expect(global.fetch).toHaveBeenCalledWith( + '/api/arcade/rooms', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'New Room', + gameName: 'matching', + creatorName: 'Player 1', + gameConfig: { difficulty: 6 }, + }), + }) + ) + }) + + test('handles create room error', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + json: async () => ({ error: 'Invalid game name' }), + }) + + const { result } = renderHook(() => useCreateRoom(), { wrapper }) + + let error: any + result.current.mutate( + { + name: 'Bad Room', + gameName: 'invalid-game' as any, + creatorName: 'Player 1', + }, + { + onError: (err) => { + error = err + }, + } + ) + + await waitFor(() => { + expect(error).toBeDefined() + expect(error.message).toContain('Invalid game name') + }) + }) + + test('updates cache after creating room', async () => { + const mockCreatedRoom = { + room: { + id: 'new-room-123', + name: 'New Room', + code: 'XYZ789', + gameName: 'matching', + }, + members: [], + memberPlayers: {}, + } + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockCreatedRoom, + }) + + const { result } = renderHook(() => useCreateRoom(), { wrapper }) + + result.current.mutate({ + name: 'New Room', + gameName: 'matching', + creatorName: 'Player 1', + }) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + // Verify cache was updated + const cachedData = queryClient.getQueryData(['rooms', 'current']) + expect(cachedData).toEqual({ + id: 'new-room-123', + name: 'New Room', + code: 'XYZ789', + gameName: 'matching', + members: [], + memberPlayers: {}, + }) + }) + }) + + describe('useJoinRoom', () => { + test('joins a room successfully', async () => { + const mockJoinResult = { + member: { + id: 'member-1', + userId: 'test-user-id', + displayName: 'Player 1', + isOnline: true, + isCreator: false, + }, + room: { + id: 'room-123', + name: 'Test Room', + code: 'ABC123', + gameName: 'matching', + members: [], + memberPlayers: {}, + }, + members: [], + memberPlayers: {}, + activePlayers: [], + } + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockJoinResult, + }) + + const { result } = renderHook(() => useJoinRoom(), { wrapper }) + + let joinedRoom: any + result.current.mutate( + { + roomId: 'room-123', + displayName: 'Player 1', + }, + { + onSuccess: (data) => { + joinedRoom = data + }, + } + ) + + await waitFor(() => { + expect(joinedRoom).toEqual(mockJoinResult) + }) + + // Verify fetch was called correctly + expect(global.fetch).toHaveBeenCalledWith( + '/api/arcade/rooms/room-123/join', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ displayName: 'Player 1' }), + }) + ) + }) + + test('handles join room error', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + json: async () => ({ error: 'Room is locked' }), + }) + + const { result } = renderHook(() => useJoinRoom(), { wrapper }) + + let error: any + result.current.mutate( + { + roomId: 'locked-room', + displayName: 'Player 1', + }, + { + onError: (err) => { + error = err + }, + } + ) + + await waitFor(() => { + expect(error).toBeDefined() + expect(error.message).toContain('Room is locked') + }) + }) + }) + + describe('useLeaveRoom', () => { + test('leaves a room successfully', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + }) + + const { result } = renderHook(() => useLeaveRoom(), { wrapper }) + + let success = false + result.current.mutate('room-123', { + onSuccess: () => { + success = true + }, + }) + + await waitFor(() => { + expect(success).toBe(true) + }) + + // Verify fetch was called correctly + expect(global.fetch).toHaveBeenCalledWith( + '/api/arcade/rooms/room-123/leave', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }) + ) + + // Verify cache was cleared + const cachedData = queryClient.getQueryData(['rooms', 'current']) + expect(cachedData).toBeNull() + }) + + test('handles leave room error', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + json: async () => ({ error: 'Not in room' }), + }) + + const { result } = renderHook(() => useLeaveRoom(), { wrapper }) + + let error: any + result.current.mutate('room-123', { + onError: (err) => { + error = err + }, + }) + + await waitFor(() => { + expect(error).toBeDefined() + expect(error.message).toContain('Not in room') + }) + }) + }) + + describe('useGetRoomByCode', () => { + test('fetches room by code successfully', async () => { + const mockRoom = { + room: { + id: 'room-123', + name: 'Test Room', + code: 'ABC123', + gameName: 'matching', + }, + members: [], + memberPlayers: {}, + } + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockRoom, + }) + + const { result } = renderHook(() => useGetRoomByCode(), { wrapper }) + + let fetchedRoom: any + result.current.mutate('ABC123', { + onSuccess: (data) => { + fetchedRoom = data + }, + }) + + await waitFor(() => { + expect(fetchedRoom).toEqual({ + id: 'room-123', + name: 'Test Room', + code: 'ABC123', + gameName: 'matching', + members: [], + memberPlayers: {}, + }) + }) + + // Verify fetch was called correctly + expect(global.fetch).toHaveBeenCalledWith('/api/arcade/rooms/code/ABC123') + }) + + test('handles room not found error', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + }) + + const { result } = renderHook(() => useGetRoomByCode(), { wrapper }) + + let error: any + result.current.mutate('INVALID', { + onError: (err) => { + error = err + }, + }) + + await waitFor(() => { + expect(error).toBeDefined() + expect(error.message).toBe('Room not found') + }) + }) + }) +}) diff --git a/apps/web/src/lib/arcade/__tests__/orphaned-session-cleanup.test.ts b/apps/web/src/lib/arcade/__tests__/orphaned-session-cleanup.test.ts index 5362e46e..bdd786c0 100644 --- a/apps/web/src/lib/arcade/__tests__/orphaned-session-cleanup.test.ts +++ b/apps/web/src/lib/arcade/__tests__/orphaned-session-cleanup.test.ts @@ -167,8 +167,8 @@ describe('Orphaned Session Cleanup', () => { * - Room gets TTL deleted * - Session persists with null/invalid roomId * - User visits /arcade - * - useArcadeRedirect finds the orphaned session - * - User gets redirected to /arcade/matching + * - Client checks for active session + * - Without cleanup, user would be directed to /arcade/matching * - But there's no valid game to play * * Fix: getArcadeSession should auto-delete orphaned sessions @@ -187,7 +187,7 @@ describe('Orphaned Session Cleanup', () => { // 2. Room gets TTL deleted await deleteRoom(testRoomId) - // 3. User's client checks for active session (like useArcadeRedirect does) + // 3. User's client checks for active session const activeSession = await getArcadeSession(testGuestId) // 4. Should return undefined, preventing redirect