import { CursorPositionDto } from '../dto/cursor-position.dto'; import { Test, TestingModule } from '@nestjs/testing'; import { StateGateway } from './state.gateway'; import { AuthenticatedSocket } from '../../types/socket'; import { JwtVerificationService } from '../../auth/services/jwt-verification.service'; import { UsersService } from '../../users/users.service'; import { PrismaService } from '../../database/prisma.service'; import { UserSocketService } from './user-socket.service'; import { WsNotificationService } from './ws-notification.service'; import { SendInteractionDto } from '../dto/send-interaction.dto'; import { WsException } from '@nestjs/websockets'; import { UserStatusDto, UserState } from '../dto/user-status.dto'; type MockSocket = { id: string; data: { user?: { userId: string; email: string; roles?: string[]; }; userId?: string; activeDollId?: string | null; friends?: Set; }; handshake?: any; disconnect?: jest.Mock; emit?: jest.Mock; }; describe('StateGateway', () => { let gateway: StateGateway; let mockLoggerLog: jest.SpyInstance; let mockLoggerDebug: jest.SpyInstance; let mockLoggerWarn: jest.SpyInstance; let mockLoggerError: jest.SpyInstance; let mockServer: { sockets: { sockets: { size: number; get: jest.Mock } }; to: jest.Mock; }; let mockUsersService: Partial; let mockJwtVerificationService: Partial; let mockPrismaService: Partial; let mockUserSocketService: Partial; let mockRedisClient: { publish: jest.Mock }; let mockRedisSubscriber: { subscribe: jest.Mock; on: jest.Mock }; let mockWsNotificationService: { setIo: jest.Mock; emitToUser: jest.Mock; emitToFriends: jest.Mock; emitToSocket: jest.Mock; updateActiveDollCache: jest.Mock; publishActiveDollUpdate: jest.Mock; }; beforeEach(async () => { mockServer = { sockets: { sockets: { size: 5, get: jest.fn(), }, }, to: jest.fn().mockReturnValue({ emit: jest.fn(), }), }; mockUsersService = { findOne: jest.fn().mockResolvedValue({ id: 'user-id', }), }; mockJwtVerificationService = { extractToken: jest.fn((handshake) => handshake.auth?.token), verifyToken: jest.fn().mockReturnValue({ sub: 'test-sub', email: 'test@example.com', }), }; mockPrismaService = { user: { findUnique: jest.fn().mockResolvedValue({ activeDollId: 'doll-123' }), } as any, friendship: { findMany: jest.fn().mockResolvedValue([]), } as any, }; mockUserSocketService = { setSocket: jest.fn().mockResolvedValue(undefined), removeSocket: jest.fn().mockResolvedValue(undefined), getSocket: jest.fn().mockResolvedValue(null), isUserOnline: jest.fn().mockResolvedValue(false), getFriendsSockets: jest.fn().mockResolvedValue([]), }; mockRedisClient = { publish: jest.fn().mockResolvedValue(1), }; mockRedisSubscriber = { subscribe: jest.fn().mockResolvedValue(undefined), on: jest.fn(), }; mockWsNotificationService = { setIo: jest.fn(), emitToUser: jest.fn(), emitToFriends: jest.fn(), emitToSocket: jest.fn(), updateActiveDollCache: jest.fn(), publishActiveDollUpdate: jest.fn(), }; const module: TestingModule = await Test.createTestingModule({ providers: [ StateGateway, { provide: UsersService, useValue: mockUsersService }, { provide: JwtVerificationService, useValue: mockJwtVerificationService, }, { provide: PrismaService, useValue: mockPrismaService }, { provide: UserSocketService, useValue: mockUserSocketService }, { provide: WsNotificationService, useValue: mockWsNotificationService }, { provide: 'REDIS_CLIENT', useValue: mockRedisClient }, { provide: 'REDIS_SUBSCRIBER_CLIENT', useValue: mockRedisSubscriber }, ], }).compile(); gateway = module.get(StateGateway); gateway.io = mockServer as any; mockLoggerLog = jest.spyOn(gateway['logger'], 'log').mockImplementation(); mockLoggerDebug = jest .spyOn(gateway['logger'], 'debug') .mockImplementation(); mockLoggerWarn = jest.spyOn(gateway['logger'], 'warn').mockImplementation(); mockLoggerError = jest .spyOn(gateway['logger'], 'error') .mockImplementation(); }); afterEach(() => { jest.restoreAllMocks(); }); it('should be defined', () => { expect(gateway).toBeDefined(); }); describe('afterInit', () => { it('should log initialization message', () => { gateway.afterInit(); expect(mockLoggerLog).toHaveBeenCalledWith('Initialized'); }); it('should subscribe to redis channel', () => { expect(mockRedisSubscriber.subscribe).toHaveBeenCalledWith( 'active-doll-update', 'friend-cache-update', expect.any(Function), ); }); }); describe('handleConnection', () => { it('should verify token and set basic user data (but NOT sync DB)', () => { const mockClient: MockSocket = { id: 'client1', data: {}, handshake: { auth: { token: 'mock-token' }, headers: {}, }, disconnect: jest.fn(), }; gateway.handleConnection(mockClient as unknown as AuthenticatedSocket); expect(mockJwtVerificationService.extractToken).toHaveBeenCalledWith( mockClient.handshake, ); expect(mockJwtVerificationService.verifyToken).toHaveBeenCalledWith( 'mock-token', ); // Should NOT call these anymore in handleConnection expect(mockUsersService.findOne).not.toHaveBeenCalled(); expect(mockUserSocketService.setSocket).not.toHaveBeenCalled(); // Should set data on client expect(mockClient.data.user).toEqual( expect.objectContaining({ userId: 'test-sub', }), ); expect(mockClient.data.activeDollId).toBeNull(); expect(mockLoggerLog).toHaveBeenCalledWith( expect.stringContaining('WebSocket authenticated (Pending Init)'), ); }); it('should disconnect client when no token provided', () => { const mockClient: MockSocket = { id: 'client1', data: {}, handshake: { auth: {}, headers: {}, }, disconnect: jest.fn(), }; (mockJwtVerificationService.extractToken as jest.Mock).mockReturnValue( undefined, ); gateway.handleConnection(mockClient as unknown as AuthenticatedSocket); expect(mockLoggerWarn).toHaveBeenCalledWith( 'WebSocket connection attempt without token', ); expect(mockClient.disconnect).toHaveBeenCalled(); }); }); describe('handleClientInitialize', () => { it('should sync user, fetch state, and emit initialized event', async () => { const mockClient: MockSocket = { id: 'client1', data: { user: { userId: 'test-sub', email: 'test@example.com' }, friends: new Set(), }, emit: jest.fn(), disconnect: jest.fn(), }; // Mock Prisma responses (mockPrismaService.user!.findUnique as jest.Mock).mockResolvedValue({ activeDollId: 'doll-123', }); (mockPrismaService.friendship!.findMany as jest.Mock).mockResolvedValue([ { friendId: 'friend-1' }, { friendId: 'friend-2' }, ]); await gateway.handleClientInitialize( mockClient as unknown as AuthenticatedSocket, ); // 1. Load User expect(mockUsersService.findOne).toHaveBeenCalledWith('test-sub'); // 2. Set Socket expect(mockUserSocketService.setSocket).toHaveBeenCalledWith( 'user-id', 'client1', ); // 3. Fetch State (DB) expect(mockPrismaService.user!.findUnique).toHaveBeenCalledWith({ where: { id: 'user-id' }, select: { activeDollId: true }, }); expect(mockPrismaService.friendship!.findMany).toHaveBeenCalledWith({ where: { userId: 'user-id' }, select: { friendId: true }, }); // 4. Update Client Data expect(mockClient.data.userId).toBe('user-id'); expect(mockClient.data.activeDollId).toBe('doll-123'); expect(mockClient.data.friends).toContain('friend-1'); expect(mockClient.data.friends).toContain('friend-2'); // 5. Emit Initialized expect(mockClient.emit).toHaveBeenCalledWith('initialized', { userId: 'user-id', activeDollId: 'doll-123', }); }); it('should disconnect if no user data present on socket', async () => { const mockClient: MockSocket = { id: 'client1', data: {}, // Missing user data handshake: {}, emit: jest.fn(), disconnect: jest.fn(), }; await gateway.handleClientInitialize( mockClient as unknown as AuthenticatedSocket, ); expect(mockLoggerError).toHaveBeenCalledWith( expect.stringContaining('Unauthorized: No user data found'), ); expect(mockClient.disconnect).toHaveBeenCalled(); }); }); describe('handleDisconnect', () => { it('should log client disconnection', async () => { const mockClient: MockSocket = { id: 'client1', data: { user: { userId: 'test-sub', email: 'test@example.com' } }, }; await gateway.handleDisconnect( mockClient as unknown as AuthenticatedSocket, ); expect(mockLoggerLog).toHaveBeenCalledWith( `Client id: ${mockClient.id} disconnected (user: test-sub)`, ); }); it('should handle disconnection when no user data', async () => { const mockClient: MockSocket = { id: 'client1', data: {}, }; await gateway.handleDisconnect( mockClient as unknown as AuthenticatedSocket, ); expect(mockLoggerLog).toHaveBeenCalledWith( `Client id: ${mockClient.id} disconnected (user: unknown)`, ); }); it('should remove socket if it matches', async () => { const mockClient: MockSocket = { id: 'client1', data: { user: { userId: 'test-sub', email: 'test@example.com' }, userId: 'user-id', friends: new Set(['friend-1']), }, }; (mockUserSocketService.getSocket as jest.Mock).mockResolvedValue( 'client1', ); (mockUserSocketService.getFriendsSockets as jest.Mock).mockResolvedValue([ { userId: 'friend-1', socketId: 'friend-socket-id' }, ]); await gateway.handleDisconnect( mockClient as unknown as AuthenticatedSocket, ); expect(mockUserSocketService.getSocket).toHaveBeenCalledWith('user-id'); expect(mockUserSocketService.removeSocket).toHaveBeenCalledWith( 'user-id', ); expect(mockWsNotificationService.emitToSocket).toHaveBeenCalledWith( 'friend-socket-id', expect.any(String), expect.any(Object), ); }); }); describe('handleCursorReportPosition', () => { it('should emit cursor position to connected friends', async () => { const mockClient: MockSocket = { id: 'client1', data: { user: { userId: 'test-sub', email: 'test@example.com' }, 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: CursorPositionDto = { x: 100, y: 200 }; // Force time to pass for throttle check if needed, or rely on first call passing // The implementation uses lastBroadcastMap, initialized to empty, so first call should pass if now > 0 await gateway.handleCursorReportPosition( mockClient as unknown as AuthenticatedSocket, data, ); // Verify that message was emitted via WsNotificationService expect(mockWsNotificationService.emitToSocket).toHaveBeenCalledWith( 'friend-socket-id', 'friend-cursor-position', { userId: 'user-1', position: data, }, ); }); it('should NOT emit if user has no active doll', async () => { const mockClient: MockSocket = { id: 'client1', data: { user: { userId: 'test-sub', email: 'test@example.com' }, userId: 'user-1', activeDollId: null, // No doll friends: new Set(['friend-1']), }, }; const data: CursorPositionDto = { x: 100, y: 200 }; await gateway.handleCursorReportPosition( mockClient as unknown as AuthenticatedSocket, data, ); expect(mockServer.to).not.toHaveBeenCalled(); }); it('should return early when userId is missing (not initialized)', async () => { const mockClient: MockSocket = { id: 'client1', data: { user: { userId: 'test-sub', email: 'test@example.com' }, // userId is missing friends: new Set(['friend-1']), }, }; const data: CursorPositionDto = { x: 100, y: 200 }; await gateway.handleCursorReportPosition( mockClient as unknown as AuthenticatedSocket, data, ); // Verify that no message was emitted expect(mockServer.to).not.toHaveBeenCalled(); // No explicit warning log expected in new implementation for just return }); it('should throw exception when client is not authenticated', async () => { const mockClient: MockSocket = { id: 'client1', data: {}, }; const data: CursorPositionDto = { x: 100, y: 200 }; await expect( gateway.handleCursorReportPosition( mockClient as unknown as AuthenticatedSocket, data, ), ).rejects.toThrow('Unauthorized'); }); }); describe('handleClientReportUserStatus', () => { it('should emit user status to connected friends', async () => { const mockClient: MockSocket = { id: 'client1', data: { user: { userId: 'test-sub', email: 'test@example.com' }, 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 = { presenceStatus: { title: null, subtitle: 'VS Code', graphicsB64: null, }, 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: { userId: 'test-sub', email: 'test@example.com' }, userId: 'user-1', activeDollId: null, // No doll friends: new Set(['friend-1']), }, }; const data: UserStatusDto = { presenceStatus: { title: null, subtitle: 'VS Code', graphicsB64: null, }, 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: { userId: 'test-sub', email: 'test@example.com' }, // userId is missing friends: new Set(['friend-1']), }, }; const data: UserStatusDto = { presenceStatus: { title: null, subtitle: 'VS Code', graphicsB64: null, }, 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 = { presenceStatus: { title: null, subtitle: 'VS Code', graphicsB64: null, }, 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: { userId: 'test-sub', email: 'test@example.com' }, 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 = { presenceStatus: { title: null, subtitle: 'VS Code', graphicsB64: null, }, 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', () => { it('should send interaction to friend if online', async () => { const mockClient: MockSocket = { id: 'client1', data: { user: { userId: 'test-sub', email: 'test@example.com' }, userId: 'user-1', friends: new Set(['friend-1']), }, emit: jest.fn(), }; const data: SendInteractionDto = { recipientUserId: 'friend-1', content: 'hello', type: 'text', }; (mockUserSocketService.isUserOnline as jest.Mock).mockResolvedValue(true); await gateway.handleSendInteraction( mockClient as unknown as AuthenticatedSocket, data, ); expect(mockWsNotificationService.emitToUser).toHaveBeenCalledWith( 'friend-1', 'interaction-received', expect.objectContaining({ senderUserId: 'user-1', content: 'hello', type: 'text', }), ); }); it('should fail if recipient is not a friend', async () => { const mockClient: MockSocket = { id: 'client1', data: { user: { userId: 'test-sub', email: 'test@example.com' }, userId: 'user-1', friends: new Set(['friend-1']), }, emit: jest.fn(), }; const data: SendInteractionDto = { recipientUserId: 'stranger-1', content: 'hello', type: 'text', }; await gateway.handleSendInteraction( mockClient as unknown as AuthenticatedSocket, data, ); expect(mockClient.emit).toHaveBeenCalledWith( 'interaction-delivery-failed', expect.objectContaining({ reason: 'Recipient is not a friend', }), ); expect(mockWsNotificationService.emitToUser).not.toHaveBeenCalled(); }); it('should fail if recipient is offline', async () => { const mockClient: MockSocket = { id: 'client1', data: { user: { userId: 'test-sub', email: 'test@example.com' }, userId: 'user-1', friends: new Set(['friend-1']), }, emit: jest.fn(), }; const data: SendInteractionDto = { recipientUserId: 'friend-1', content: 'hello', type: 'text', }; (mockUserSocketService.isUserOnline as jest.Mock).mockResolvedValue( false, ); await gateway.handleSendInteraction( mockClient as unknown as AuthenticatedSocket, data, ); expect(mockClient.emit).toHaveBeenCalledWith( 'interaction-delivery-failed', expect.objectContaining({ reason: 'Recipient is offline', }), ); expect(mockWsNotificationService.emitToUser).not.toHaveBeenCalled(); }); it('should throw Unauthorized if user not initialized', async () => { const mockClient: MockSocket = { id: 'client1', data: { // Missing user/userId }, }; const data: SendInteractionDto = { recipientUserId: 'friend-1', content: 'hello', type: 'text', }; await expect( gateway.handleSendInteraction( mockClient as unknown as AuthenticatedSocket, data, ), ).rejects.toThrow(WsException); }); }); });