304 lines
9.8 KiB
TypeScript
304 lines
9.8 KiB
TypeScript
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> = {}
|
|
): 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);
|
|
});
|
|
});
|