import { describe, it, expect, beforeEach, vi } from 'vitest'; import { connectedUsers } from './state'; import { handleWebSocketMessage } from './handler'; import { ConnectedUser } from './types'; import { WebSocket } from 'ws'; vi.mock('../services/server-access.service', () => ({ authorizeWebSocketJoin: vi.fn(async () => ({ allowed: true as const })) })); /** * Minimal mock WebSocket that records sent messages. */ function createMockWs(): WebSocket & { sentMessages: string[] } { const sent: string[] = []; const ws = { readyState: WebSocket.OPEN, send: (data: string) => { sent.push(data); }, close: () => {}, sentMessages: sent } as unknown as WebSocket & { sentMessages: string[] }; return ws; } function createConnectedUser( connectionId: string, oderId: string, overrides: Partial = {} ): ConnectedUser { const ws = createMockWs(); const user: ConnectedUser = { oderId, ws, serverIds: new Set(), displayName: 'Test User', lastPong: Date.now(), ...overrides }; connectedUsers.set(connectionId, user); return user; } function getRequiredConnectedUser(connectionId: string): ConnectedUser { const connectedUser = connectedUsers.get(connectionId); if (!connectedUser) throw new Error(`Expected connected user for ${connectionId}`); return connectedUser; } function getSentMessagesStore(user: ConnectedUser): { sentMessages: string[] } { return user.ws as unknown as { sentMessages: string[] }; } describe('server websocket handler - status_update', () => { beforeEach(() => { connectedUsers.clear(); }); it('treats signaling keepalive messages as connection liveness', async () => { createConnectedUser('conn-1', 'user-1', { lastPong: 1 }); await handleWebSocketMessage('conn-1', { type: 'keepalive' }); expect(connectedUsers.get('conn-1')?.lastPong).toBeGreaterThan(1); }); it('updates user status on valid status_update message', async () => { const user = createConnectedUser('conn-1', 'user-1'); user.serverIds.add('server-1'); await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'away' }); expect(connectedUsers.get('conn-1')?.status).toBe('away'); }); it('broadcasts status_update to other users in the same server', async () => { const user1 = createConnectedUser('conn-1', 'user-1'); const user2 = createConnectedUser('conn-2', 'user-2'); user1.serverIds.add('server-1'); user2.serverIds.add('server-1'); await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'busy' }); const messages = getSentMessagesStore(user2).sentMessages.map((messageText: string) => JSON.parse(messageText)); const statusMsg = messages.find((message: { type: string }) => message.type === 'status_update'); expect(statusMsg).toBeDefined(); expect(statusMsg?.oderId).toBe('user-1'); expect(statusMsg?.status).toBe('busy'); }); it('does not broadcast to users in different servers', async () => { createConnectedUser('conn-1', 'user-1'); const user2 = createConnectedUser('conn-2', 'user-2'); getRequiredConnectedUser('conn-1').serverIds.add('server-1'); user2.serverIds.add('server-2'); await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'away' }); expect(getSentMessagesStore(user2).sentMessages.length).toBe(0); }); it('ignores invalid status values', async () => { createConnectedUser('conn-1', 'user-1'); await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'invalid_status' }); expect(connectedUsers.get('conn-1')?.status).toBeUndefined(); }); it('ignores missing status field', async () => { createConnectedUser('conn-1', 'user-1'); await handleWebSocketMessage('conn-1', { type: 'status_update' }); expect(connectedUsers.get('conn-1')?.status).toBeUndefined(); }); it('accepts all valid status values', async () => { for (const status of [ 'online', 'away', 'busy', 'offline' ]) { connectedUsers.clear(); createConnectedUser('conn-1', 'user-1'); await handleWebSocketMessage('conn-1', { type: 'status_update', status }); expect(connectedUsers.get('conn-1')?.status).toBe(status); } }); it('includes status in server_users response after status change', async () => { const user1 = createConnectedUser('conn-1', 'user-1'); const user2 = createConnectedUser('conn-2', 'user-2'); user1.serverIds.add('server-1'); user2.serverIds.add('server-1'); // Set user-1 to away await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'away' }); // Clear sent messages getSentMessagesStore(user2).sentMessages.length = 0; // Identify first (required for handler) await handleWebSocketMessage('conn-1', { type: 'identify', oderId: 'user-1', displayName: 'User 1' }); // user-2 joins server -> should receive server_users with user-1's status getSentMessagesStore(user2).sentMessages.length = 0; await handleWebSocketMessage('conn-2', { type: 'join_server', serverId: 'server-1' }); const messages = getSentMessagesStore(user2).sentMessages.map((messageText: string) => JSON.parse(messageText)); const serverUsersMsg = messages.find((message: { type: string }) => message.type === 'server_users'); expect(serverUsersMsg).toBeDefined(); const user1InList = serverUsersMsg?.users?.find((userEntry: { oderId: string }) => userEntry.oderId === 'user-1'); expect(user1InList?.status).toBe('away'); }); }); describe('server websocket handler - user_joined includes status', () => { beforeEach(() => { connectedUsers.clear(); }); it('includes status in user_joined broadcast', async () => { const user1 = createConnectedUser('conn-1', 'user-1'); const user2 = createConnectedUser('conn-2', 'user-2'); user1.serverIds.add('server-1'); user2.serverIds.add('server-1'); // Set user-1's status to busy before joining getRequiredConnectedUser('conn-1').status = 'busy'; // Identify user-1 await handleWebSocketMessage('conn-1', { type: 'identify', oderId: 'user-1', displayName: 'User 1' }); getSentMessagesStore(user2).sentMessages.length = 0; // user-1 joins server-1 await handleWebSocketMessage('conn-1', { type: 'join_server', serverId: 'server-1' }); const messages = getSentMessagesStore(user2).sentMessages.map((messageText: string) => JSON.parse(messageText)); const joinMsg = messages.find((message: { type: string }) => message.type === 'user_joined'); // user_joined may or may not appear depending on whether it's a new identity membership // Since both are already in the server, it may not broadcast. Either way, verify no crash. if (joinMsg) { expect(joinMsg.status).toBe('busy'); } }); }); describe('server websocket handler - profile metadata in presence messages', () => { beforeEach(() => { connectedUsers.clear(); }); it('broadcasts updated profile metadata when an identified user changes it', async () => { const alice = createConnectedUser('conn-1', 'user-1', { displayName: 'Alice', viewedServerId: 'server-1' }); const bob = createConnectedUser('conn-2', 'user-2', { viewedServerId: 'server-1' }); alice.serverIds.add('server-1'); bob.serverIds.add('server-1'); getSentMessagesStore(bob).sentMessages.length = 0; await handleWebSocketMessage('conn-1', { type: 'identify', oderId: 'user-1', displayName: 'Alice Updated', description: 'Updated bio', profileUpdatedAt: 789 }); const messages = getSentMessagesStore(bob).sentMessages.map((messageText: string) => JSON.parse(messageText)); const joinMsg = messages.find((message: { type: string }) => message.type === 'user_joined'); expect(joinMsg?.displayName).toBe('Alice Updated'); expect(joinMsg?.description).toBe('Updated bio'); expect(joinMsg?.profileUpdatedAt).toBe(789); expect(joinMsg?.serverId).toBe('server-1'); }); it('includes description and profileUpdatedAt in server_users responses', async () => { const alice = createConnectedUser('conn-1', 'user-1'); const bob = createConnectedUser('conn-2', 'user-2'); alice.serverIds.add('server-1'); bob.serverIds.add('server-1'); await handleWebSocketMessage('conn-1', { type: 'identify', oderId: 'user-1', displayName: 'Alice', description: 'Alice bio', profileUpdatedAt: 123 }); getSentMessagesStore(bob).sentMessages.length = 0; await handleWebSocketMessage('conn-2', { type: 'view_server', serverId: 'server-1' }); const messages = getSentMessagesStore(bob).sentMessages.map((messageText: string) => JSON.parse(messageText)); const serverUsersMsg = messages.find((message: { type: string }) => message.type === 'server_users'); const aliceInList = serverUsersMsg?.users?.find((userEntry: { oderId: string }) => userEntry.oderId === 'user-1'); expect(aliceInList?.description).toBe('Alice bio'); expect(aliceInList?.profileUpdatedAt).toBe(123); }); it('includes description and profileUpdatedAt in user_joined broadcasts', async () => { const bob = createConnectedUser('conn-2', 'user-2'); bob.serverIds.add('server-1'); bob.viewedServerId = 'server-1'; createConnectedUser('conn-1', 'user-1', { displayName: 'Alice', description: 'Alice bio', profileUpdatedAt: 456, viewedServerId: 'server-1' }); await handleWebSocketMessage('conn-1', { type: 'join_server', serverId: 'server-1' }); const messages = getSentMessagesStore(bob).sentMessages.map((messageText: string) => JSON.parse(messageText)); const joinMsg = messages.find((message: { type: string }) => message.type === 'user_joined'); expect(joinMsg?.description).toBe('Alice bio'); expect(joinMsg?.profileUpdatedAt).toBe(456); }); });