user status report system
This commit is contained in:
16
src/ws/dto/user-status.dto.ts
Normal file
16
src/ws/dto/user-status.dto.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { IsEnum, IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
export enum UserState {
|
||||||
|
IDLE = 'idle',
|
||||||
|
RESTING = 'resting',
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserStatusDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(100)
|
||||||
|
activeApp: string;
|
||||||
|
|
||||||
|
@IsEnum(UserState)
|
||||||
|
state: UserState;
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ import { WsNotificationService } from './ws-notification.service';
|
|||||||
import { SendInteractionDto } from '../dto/send-interaction.dto';
|
import { SendInteractionDto } from '../dto/send-interaction.dto';
|
||||||
import { WsException } from '@nestjs/websockets';
|
import { WsException } from '@nestjs/websockets';
|
||||||
|
|
||||||
|
import { UserStatusDto, UserState } from '../dto/user-status.dto';
|
||||||
|
|
||||||
interface MockSocket extends Partial<AuthenticatedSocket> {
|
interface MockSocket extends Partial<AuthenticatedSocket> {
|
||||||
id: string;
|
id: string;
|
||||||
data: {
|
data: {
|
||||||
@@ -475,6 +477,156 @@ describe('StateGateway', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('handleClientReportUserStatus', () => {
|
||||||
|
it('should emit user status to connected friends', async () => {
|
||||||
|
const mockClient: MockSocket = {
|
||||||
|
id: 'client1',
|
||||||
|
data: {
|
||||||
|
user: { keycloakSub: 'test-sub' },
|
||||||
|
userId: 'user-1',
|
||||||
|
activeDollId: 'doll-1', // User must have active doll
|
||||||
|
friends: new Set(['friend-1']),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock getFriendsSockets to return the friend's socket
|
||||||
|
(mockUserSocketService.getFriendsSockets as jest.Mock).mockResolvedValue([
|
||||||
|
{ userId: 'friend-1', socketId: 'friend-socket-id' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const data: UserStatusDto = {
|
||||||
|
activeApp: 'VS Code',
|
||||||
|
state: UserState.IDLE,
|
||||||
|
};
|
||||||
|
|
||||||
|
await gateway.handleClientReportUserStatus(
|
||||||
|
mockClient as unknown as AuthenticatedSocket,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify that message was emitted via WsNotificationService
|
||||||
|
expect(mockWsNotificationService.emitToSocket).toHaveBeenCalledWith(
|
||||||
|
'friend-socket-id',
|
||||||
|
'friend-user-status',
|
||||||
|
{
|
||||||
|
userId: 'user-1',
|
||||||
|
status: data,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT emit if user has no active doll', async () => {
|
||||||
|
const mockClient: MockSocket = {
|
||||||
|
id: 'client1',
|
||||||
|
data: {
|
||||||
|
user: { keycloakSub: 'test-sub' },
|
||||||
|
userId: 'user-1',
|
||||||
|
activeDollId: null, // No doll
|
||||||
|
friends: new Set(['friend-1']),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const data: UserStatusDto = {
|
||||||
|
activeApp: 'VS Code',
|
||||||
|
state: UserState.IDLE,
|
||||||
|
};
|
||||||
|
|
||||||
|
await gateway.handleClientReportUserStatus(
|
||||||
|
mockClient as unknown as AuthenticatedSocket,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockWsNotificationService.emitToSocket).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return early when userId is missing (not initialized)', async () => {
|
||||||
|
const mockClient: MockSocket = {
|
||||||
|
id: 'client1',
|
||||||
|
data: {
|
||||||
|
user: { keycloakSub: 'test-sub' },
|
||||||
|
// userId is missing
|
||||||
|
friends: new Set(['friend-1']),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const data: UserStatusDto = {
|
||||||
|
activeApp: 'VS Code',
|
||||||
|
state: UserState.IDLE,
|
||||||
|
};
|
||||||
|
|
||||||
|
await gateway.handleClientReportUserStatus(
|
||||||
|
mockClient as unknown as AuthenticatedSocket,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify that no message was emitted
|
||||||
|
expect(mockWsNotificationService.emitToSocket).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw exception when client is not authenticated', async () => {
|
||||||
|
const mockClient: MockSocket = {
|
||||||
|
id: 'client1',
|
||||||
|
data: {},
|
||||||
|
};
|
||||||
|
const data: UserStatusDto = {
|
||||||
|
activeApp: 'VS Code',
|
||||||
|
state: UserState.IDLE,
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
gateway.handleClientReportUserStatus(
|
||||||
|
mockClient as unknown as AuthenticatedSocket,
|
||||||
|
data,
|
||||||
|
),
|
||||||
|
).rejects.toThrow('Unauthorized');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throttle broadcasts to prevent spam', async () => {
|
||||||
|
const mockClient: MockSocket = {
|
||||||
|
id: 'client1',
|
||||||
|
data: {
|
||||||
|
user: { keycloakSub: 'test-sub' },
|
||||||
|
userId: 'user-1',
|
||||||
|
activeDollId: 'doll-1',
|
||||||
|
friends: new Set(['friend-1']),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock getFriendsSockets to return the friend's socket
|
||||||
|
(mockUserSocketService.getFriendsSockets as jest.Mock).mockResolvedValue([
|
||||||
|
{ userId: 'friend-1', socketId: 'friend-socket-id' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const data: UserStatusDto = {
|
||||||
|
activeApp: 'VS Code',
|
||||||
|
state: UserState.IDLE,
|
||||||
|
};
|
||||||
|
|
||||||
|
// First call should succeed
|
||||||
|
await gateway.handleClientReportUserStatus(
|
||||||
|
mockClient as unknown as AuthenticatedSocket,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Second call immediately after should be throttled
|
||||||
|
await gateway.handleClientReportUserStatus(
|
||||||
|
mockClient as unknown as AuthenticatedSocket,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify that message was emitted only once (throttled)
|
||||||
|
expect(mockWsNotificationService.emitToSocket).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockWsNotificationService.emitToSocket).toHaveBeenCalledWith(
|
||||||
|
'friend-socket-id',
|
||||||
|
'friend-user-status',
|
||||||
|
{
|
||||||
|
userId: 'user-1',
|
||||||
|
status: data,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('handleSendInteraction', () => {
|
describe('handleSendInteraction', () => {
|
||||||
it('should send interaction to friend if online', async () => {
|
it('should send interaction to friend if online', async () => {
|
||||||
const mockClient: MockSocket = {
|
const mockClient: MockSocket = {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type { AuthenticatedSocket } from '../../types/socket';
|
|||||||
import { AuthService } from '../../auth/auth.service';
|
import { AuthService } from '../../auth/auth.service';
|
||||||
import { JwtVerificationService } from '../../auth/services/jwt-verification.service';
|
import { JwtVerificationService } from '../../auth/services/jwt-verification.service';
|
||||||
import { CursorPositionDto } from '../dto/cursor-position.dto';
|
import { CursorPositionDto } from '../dto/cursor-position.dto';
|
||||||
|
import { UserStatusDto } from '../dto/user-status.dto';
|
||||||
import { SendInteractionDto } from '../dto/send-interaction.dto';
|
import { SendInteractionDto } from '../dto/send-interaction.dto';
|
||||||
import { InteractionPayloadDto } from '../dto/interaction-payload.dto';
|
import { InteractionPayloadDto } from '../dto/interaction-payload.dto';
|
||||||
import { PrismaService } from '../../database/prisma.service';
|
import { PrismaService } from '../../database/prisma.service';
|
||||||
@@ -339,6 +340,67 @@ export class StateGateway
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage(WS_EVENT.CLIENT_REPORT_USER_STATUS)
|
||||||
|
async handleClientReportUserStatus(
|
||||||
|
client: AuthenticatedSocket,
|
||||||
|
data: UserStatusDto,
|
||||||
|
) {
|
||||||
|
const user = client.data.user;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new WsException('Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUserId = client.data.userId;
|
||||||
|
|
||||||
|
if (!currentUserId) {
|
||||||
|
// User has not initialized yet
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do not broadcast user status if user has no active doll
|
||||||
|
if (!client.data.activeDollId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const lastBroadcast = this.lastBroadcastMap.get(currentUserId) || 0;
|
||||||
|
if (now - lastBroadcast < 500) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.lastBroadcastMap.set(currentUserId, now);
|
||||||
|
|
||||||
|
// Broadcast to online friends
|
||||||
|
const friends = client.data.friends;
|
||||||
|
if (friends) {
|
||||||
|
const friendIds = Array.from(friends);
|
||||||
|
try {
|
||||||
|
const friendSockets =
|
||||||
|
await this.userSocketService.getFriendsSockets(friendIds);
|
||||||
|
|
||||||
|
for (const { socketId } of friendSockets) {
|
||||||
|
const payload = {
|
||||||
|
userId: currentUserId,
|
||||||
|
status: data,
|
||||||
|
};
|
||||||
|
this.wsNotificationService.emitToSocket(
|
||||||
|
socketId,
|
||||||
|
WS_EVENT.FRIEND_USER_STATUS,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.logger.debug(
|
||||||
|
`Broadcasted user status to ${friendSockets.length} friends for user ${currentUserId}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to broadcast user status for user ${currentUserId}: ${(error as Error).message}`,
|
||||||
|
(error as Error).stack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@SubscribeMessage(WS_EVENT.CLIENT_SEND_INTERACTION)
|
@SubscribeMessage(WS_EVENT.CLIENT_SEND_INTERACTION)
|
||||||
async handleSendInteraction(
|
async handleSendInteraction(
|
||||||
client: AuthenticatedSocket,
|
client: AuthenticatedSocket,
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export const WS_EVENT = {
|
|||||||
FRIEND_REQUEST_DENIED: 'friend-request-denied',
|
FRIEND_REQUEST_DENIED: 'friend-request-denied',
|
||||||
UNFRIENDED: 'unfriended',
|
UNFRIENDED: 'unfriended',
|
||||||
FRIEND_CURSOR_POSITION: 'friend-cursor-position',
|
FRIEND_CURSOR_POSITION: 'friend-cursor-position',
|
||||||
|
CLIENT_REPORT_USER_STATUS: 'client-report-user-status',
|
||||||
|
FRIEND_USER_STATUS: 'friend-user-status',
|
||||||
FRIEND_DISCONNECTED: 'friend-disconnected',
|
FRIEND_DISCONNECTED: 'friend-disconnected',
|
||||||
FRIEND_DOLL_CREATED: 'friend-doll-created',
|
FRIEND_DOLL_CREATED: 'friend-doll-created',
|
||||||
FRIEND_DOLL_UPDATED: 'friend-doll-updated',
|
FRIEND_DOLL_UPDATED: 'friend-doll-updated',
|
||||||
|
|||||||
Reference in New Issue
Block a user