From 62a7cb8171a8eacb679a770b8859f3e282a06cad Mon Sep 17 00:00:00 2001 From: EemeliJ Date: Thu, 24 Sep 2026 14:52:43 +0300 Subject: [PATCH] feat(chat): require predefined responses and emotions for messages --- .../baseChatService/handleNewMessage.test.ts | 6 ++- .../chat/chatGateway/chatGateway.test.ts | 38 +++++++++++++++- .../chatService/createChatMessage.test.ts | 20 ++++++++- .../handleNewClanMessage.test.ts | 15 ++++--- .../handleNewGlobalMessage.test.ts | 21 ++++++--- src/__tests__/chat/wsExceptionFilter.test.ts | 44 +++++++++++++++++++ src/__tests__/chat/wsMessageBody.dto.test.ts | 28 ++++++++++++ src/chat/chat.gateway.ts | 18 +++++--- .../decorator/wsExceptionFilter.decorator.ts | 11 ++++- src/chat/dto/createMessage.dto.ts | 9 ++-- src/chat/dto/wsMessageBody.dto.ts | 26 +++-------- src/chat/service/clanChat.service.ts | 5 ++- src/chat/service/globalChat.service.ts | 7 ++- 13 files changed, 193 insertions(+), 55 deletions(-) create mode 100644 src/__tests__/chat/wsExceptionFilter.test.ts create mode 100644 src/__tests__/chat/wsMessageBody.dto.test.ts diff --git a/src/__tests__/chat/baseChatService/handleNewMessage.test.ts b/src/__tests__/chat/baseChatService/handleNewMessage.test.ts index 42dada43f..9292375e1 100644 --- a/src/__tests__/chat/baseChatService/handleNewMessage.test.ts +++ b/src/__tests__/chat/baseChatService/handleNewMessage.test.ts @@ -6,6 +6,8 @@ import { ChatType } from '../../../chat/enum/chatMessageType.enum'; import { MessageEventType } from '../../../chat/enum/messageEventType.enum'; import ChatModule from '../modules/chat.module'; import { ObjectId } from 'mongodb'; +import { ChatEmotion } from '../../../chat/enum/chatEmotion.enum'; +import { ChatResponseType } from '../../../chat/enum/chatResponseType.enum'; class TestBaseChatService extends BaseChatService { public broadcast = jest.fn(); @@ -63,7 +65,9 @@ describe('BaseChatService.handleNewMessage() test suite', () => { const message = new CreateChatMessageDto({ type: ChatType.GLOBAL, sender_id: new ObjectId().toString(), - content: 'Hello there!', + content: ChatResponseType.ONLINE, + responseType: ChatResponseType.ONLINE, + emotion: ChatEmotion.JOY, }); (chatService.createChatMessage as jest.Mock).mockResolvedValue([ message, diff --git a/src/__tests__/chat/chatGateway/chatGateway.test.ts b/src/__tests__/chat/chatGateway/chatGateway.test.ts index fa8bee845..c0701d6b5 100644 --- a/src/__tests__/chat/chatGateway/chatGateway.test.ts +++ b/src/__tests__/chat/chatGateway/chatGateway.test.ts @@ -76,13 +76,16 @@ describe('ChatGateway user initialization', () => { const client = createClient(); client.user = { playerId: 'player-id', clanId: 'clan-id' } as any; clanChatService.handleNewClanMessage.mockResolvedValue([ - { clan_id: 'clan-id' }, + { + clan_id: 'clan-id', + responseType: ChatResponseType.YES, + emotion: ChatEmotion.JOY, + }, null, ]); await gateway.handleClanMessage( { - content: 'Hello!', responseType: ChatResponseType.YES, emotion: ChatEmotion.JOY, }, @@ -107,6 +110,37 @@ describe('ChatGateway user initialization', () => { ); }); + it.each([ + [ + 'clan messages', + (client: WebSocketUser) => + gateway.handleClanMessage( + { responseType: ChatResponseType.YES, emotion: ChatEmotion.JOY }, + client, + ), + clanChatService.handleNewClanMessage, + ], + [ + 'global messages', + (client: WebSocketUser) => + gateway.handleGlobalMessage( + { responseType: ChatResponseType.YES, emotion: ChatEmotion.JOY }, + client, + ), + globalChatService.handleNewGlobalMessage, + ], + ])( + 'does not emit a second error when %s are rejected by their service', + async (_, handler, serviceMethod) => { + const client = createClient(); + client.user = { playerId: 'player-id', clanId: 'clan-id' } as any; + serviceMethod.mockResolvedValue(undefined); + + await expect(handler(client)).resolves.toBeUndefined(); + expect(emitterService.EmitNewDailyTaskEvent).not.toHaveBeenCalled(); + }, + ); + it.each([ [ 'clanMessage', diff --git a/src/__tests__/chat/chatService/createChatMessage.test.ts b/src/__tests__/chat/chatService/createChatMessage.test.ts index 43a04056e..a7602d380 100644 --- a/src/__tests__/chat/chatService/createChatMessage.test.ts +++ b/src/__tests__/chat/chatService/createChatMessage.test.ts @@ -2,6 +2,10 @@ import { ChatService } from '../../../chat/service/chat.service'; import ChatModule from '../modules/chat.module'; import ChatBuilderFactory from '../data/chatBuilderFactory'; import { ObjectId } from 'mongodb'; +import { CreateChatMessageDto } from '../../../chat/dto/createMessage.dto'; +import { ChatType } from '../../../chat/enum/chatMessageType.enum'; +import { ChatResponseType } from '../../../chat/enum/chatResponseType.enum'; +import { ChatEmotion } from '../../../chat/enum/chatEmotion.enum'; describe('ChatService.createChatMessage() test suite', () => { let chatService: ChatService; @@ -15,7 +19,13 @@ describe('ChatService.createChatMessage() test suite', () => { it('Should save chat message to DB if input is valid', async () => { const senderId = new ObjectId(); - const chatToCreate = chatMessageBuilder.setSenderId(senderId).build(); + const chatToCreate = new CreateChatMessageDto({ + type: ChatType.GLOBAL, + sender_id: senderId, + content: ChatResponseType.ONLINE, + responseType: ChatResponseType.ONLINE, + emotion: ChatEmotion.JOY, + }); await chatService.createChatMessage(chatToCreate); const dbResp = await chatModel.find({ content: chatToCreate.content }); @@ -27,7 +37,13 @@ describe('ChatService.createChatMessage() test suite', () => { it('Should return saved chat message data if input is valid', async () => { const senderId = new ObjectId(); - const chatToCreate = chatMessageBuilder.setSenderId(senderId).build(); + const chatToCreate = new CreateChatMessageDto({ + type: ChatType.GLOBAL, + sender_id: senderId, + content: ChatResponseType.YES, + responseType: ChatResponseType.YES, + emotion: ChatEmotion.BLANK, + }); const [result, errors] = await chatService.createChatMessage(chatToCreate); expect(errors).toBeNull(); diff --git a/src/__tests__/chat/clanChatService/handleNewClanMessage.test.ts b/src/__tests__/chat/clanChatService/handleNewClanMessage.test.ts index 7ee9a2f5d..1d83d765e 100644 --- a/src/__tests__/chat/clanChatService/handleNewClanMessage.test.ts +++ b/src/__tests__/chat/clanChatService/handleNewClanMessage.test.ts @@ -29,9 +29,10 @@ describe('ClanChatService.handleNewClanMessage() test suite', () => { it('should call handleNewMessage with correct parameters and broadcast to clan room', async () => { const client = createClient('clanA', 'player123'); + const clientProvidedContent = + 'Client-provided free text must not be stored'; const message: WsMessageBodyDto = { - content: 'Hello clan!', - feeling: 'happy', + content: clientProvidedContent, responseType: ChatResponseType.YES, emotion: ChatEmotion.JOY, } as any; @@ -45,8 +46,8 @@ describe('ClanChatService.handleNewClanMessage() test suite', () => { expect(chatMessage.type).toBe(ChatType.CLAN); expect(chatMessage.clan_id).toBe('clanA'); expect(chatMessage.sender_id).toBe('player123'); - expect(chatMessage.content).toBe('Hello clan!'); - expect(chatMessage.feeling).toBe('happy'); + expect(chatMessage.content).toBe(ChatResponseType.YES); + expect(chatMessage.content).not.toBe(clientProvidedContent); expect(chatMessage.responseType).toBe(ChatResponseType.YES); expect(chatMessage.emotion).toBe(ChatEmotion.JOY); expect(calledClient).toBe(client); @@ -57,9 +58,9 @@ describe('ClanChatService.handleNewClanMessage() test suite', () => { it('should not throw if clan room does not exist', async () => { const client = createClient('clanB', 'playerX'); const message: WsMessageBodyDto = { - content: 'No one here', - feeling: 'sad', - } as any; + responseType: ChatResponseType.NEED_COMPANY, + emotion: ChatEmotion.SORROW, + }; await expect( clanChatService.handleNewClanMessage(client, message), diff --git a/src/__tests__/chat/globalChatService/handleNewGlobalMessage.test.ts b/src/__tests__/chat/globalChatService/handleNewGlobalMessage.test.ts index f93a58c3b..524922e84 100644 --- a/src/__tests__/chat/globalChatService/handleNewGlobalMessage.test.ts +++ b/src/__tests__/chat/globalChatService/handleNewGlobalMessage.test.ts @@ -3,6 +3,8 @@ import { WebSocketUser } from '../../../chat/types/WsUser.type'; import ChatModule from '../modules/chat.module'; import { WsMessageBodyDto } from '../../../chat/dto/wsMessageBody.dto'; import { ChatType } from '../../../chat/enum/chatMessageType.enum'; +import { ChatEmotion } from '../../../chat/enum/chatEmotion.enum'; +import { ChatResponseType } from '../../../chat/enum/chatResponseType.enum'; describe('GlobalChatService.handleNewGlobalMessage() test suite', () => { let globalChatService: GlobalChatService; @@ -26,9 +28,12 @@ describe('GlobalChatService.handleNewGlobalMessage() test suite', () => { it('should call handleNewMessage with correct parameters and broadcast to all connected users', async () => { const client = createClient('player123'); + const clientProvidedContent = + 'Client-provided free text must not be stored'; const message: WsMessageBodyDto = { - content: 'Hello world!', - feeling: 'excited', + content: clientProvidedContent, + responseType: ChatResponseType.ONLINE, + emotion: ChatEmotion.JOY, } as any; globalChatService.handleJoinChat(client); @@ -39,8 +44,10 @@ describe('GlobalChatService.handleNewGlobalMessage() test suite', () => { mockHandleNewMessage.mock.calls[0]; expect(chatMessage.type).toBe(ChatType.GLOBAL); expect(chatMessage.sender_id).toBe('player123'); - expect(chatMessage.content).toBe('Hello world!'); - expect(chatMessage.feeling).toBe('excited'); + expect(chatMessage.content).toBe(ChatResponseType.ONLINE); + expect(chatMessage.content).not.toBe(clientProvidedContent); + expect(chatMessage.responseType).toBe(ChatResponseType.ONLINE); + expect(chatMessage.emotion).toBe(ChatEmotion.JOY); expect(calledClient).toBe(client); expect(chatType).toBe(ChatType.GLOBAL); expect(recipients.has(client)).toBe(true); @@ -49,9 +56,9 @@ describe('GlobalChatService.handleNewGlobalMessage() test suite', () => { it('should not throw if there are no connected users', async () => { const client = createClient('playerX'); const message: WsMessageBodyDto = { - content: 'Anyone here?', - feeling: 'lonely', - } as any; + responseType: ChatResponseType.LONELY, + emotion: ChatEmotion.SORROW, + }; await expect( globalChatService.handleNewGlobalMessage(message, client), diff --git a/src/__tests__/chat/wsExceptionFilter.test.ts b/src/__tests__/chat/wsExceptionFilter.test.ts new file mode 100644 index 000000000..894d80e89 --- /dev/null +++ b/src/__tests__/chat/wsExceptionFilter.test.ts @@ -0,0 +1,44 @@ +import { BadRequestException } from '@nestjs/common'; +import { WsException } from '@nestjs/websockets'; +import { GlobalWsExceptionFilter } from '../../chat/decorator/wsExceptionFilter.decorator'; + +describe('GlobalWsExceptionFilter', () => { + const client = { send: jest.fn() }; + const host = { + switchToWs: () => ({ getClient: () => client }), + } as any; + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, 'error').mockImplementation(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns the validation response for a bad WebSocket message', () => { + const validationError = { + statusCode: 400, + error: 'Bad Request', + errors: [{ field: 'emotion', message: 'emotion must be an enum value' }], + }; + + new GlobalWsExceptionFilter().catch( + new BadRequestException(validationError), + host, + ); + + expect(client.send).toHaveBeenCalledWith( + JSON.stringify({ event: 'error', data: validationError }), + ); + }); + + it('preserves explicit WebSocket errors', () => { + new GlobalWsExceptionFilter().catch(new WsException('Invalid event'), host); + + expect(client.send).toHaveBeenCalledWith( + JSON.stringify({ event: 'error', data: 'Invalid event' }), + ); + }); +}); diff --git a/src/__tests__/chat/wsMessageBody.dto.test.ts b/src/__tests__/chat/wsMessageBody.dto.test.ts new file mode 100644 index 000000000..9694e4fe8 --- /dev/null +++ b/src/__tests__/chat/wsMessageBody.dto.test.ts @@ -0,0 +1,28 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { WsMessageBodyDto } from '../../chat/dto/wsMessageBody.dto'; +import { ChatEmotion } from '../../chat/enum/chatEmotion.enum'; +import { ChatResponseType } from '../../chat/enum/chatResponseType.enum'; + +describe('WsMessageBodyDto', () => { + const validateMessage = (message: object) => + validate(plainToInstance(WsMessageBodyDto, message)); + + it('accepts a predefined response with an allowed emotion', async () => { + await expect( + validateMessage({ + responseType: ChatResponseType.NEED_COMPANY, + emotion: ChatEmotion.BLANK, + }), + ).resolves.toHaveLength(0); + }); + + it.each([ + ['a missing response type', { emotion: ChatEmotion.JOY }], + ['an invalid response type', { responseType: 'FreeText', emotion: 1 }], + ['a missing emotion', { responseType: ChatResponseType.YES }], + ['an invalid emotion', { responseType: ChatResponseType.YES, emotion: 99 }], + ])('rejects %s', async (_, message) => { + await expect(validateMessage(message)).resolves.not.toHaveLength(0); + }); +}); diff --git a/src/chat/chat.gateway.ts b/src/chat/chat.gateway.ts index 56336ee00..603e5c86a 100644 --- a/src/chat/chat.gateway.ts +++ b/src/chat/chat.gateway.ts @@ -95,8 +95,13 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { ) { this.assertUserInitialized(client); - const [createdMessage, error] = - await this.clanChatService.handleNewClanMessage(client, message); + const result = await this.clanChatService.handleNewClanMessage( + client, + message, + ); + if (!result) return; + + const [createdMessage, error] = result; if (error) return [null, error]; @@ -110,8 +115,8 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { true, { clanId: createdMessage.clan_id, - responseType: message.responseType, - emotion: message.emotion, + responseType: createdMessage.responseType, + emotion: createdMessage.emotion, }, ); } @@ -146,10 +151,13 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { ) { this.assertUserInitialized(client); - const [_, error] = await this.globalChatService.handleNewGlobalMessage( + const result = await this.globalChatService.handleNewGlobalMessage( message, client, ); + if (!result) return; + + const [_, error] = result; if (error) return [null, error]; } diff --git a/src/chat/decorator/wsExceptionFilter.decorator.ts b/src/chat/decorator/wsExceptionFilter.decorator.ts index 6bc320473..fd01f5da6 100644 --- a/src/chat/decorator/wsExceptionFilter.decorator.ts +++ b/src/chat/decorator/wsExceptionFilter.decorator.ts @@ -1,4 +1,9 @@ -import { ArgumentsHost, Catch, WsExceptionFilter } from '@nestjs/common'; +import { + ArgumentsHost, + BadRequestException, + Catch, + WsExceptionFilter, +} from '@nestjs/common'; import { WsException } from '@nestjs/websockets'; @Catch() @@ -9,7 +14,9 @@ export class GlobalWsExceptionFilter implements WsExceptionFilter { const error = exception instanceof WsException ? exception.getError() - : 'Internal server error'; + : exception instanceof BadRequestException + ? exception.getResponse() + : 'Internal server error'; client.send?.(JSON.stringify({ event: 'error', data: error })); } diff --git a/src/chat/dto/createMessage.dto.ts b/src/chat/dto/createMessage.dto.ts index a9080c7a6..502b08081 100644 --- a/src/chat/dto/createMessage.dto.ts +++ b/src/chat/dto/createMessage.dto.ts @@ -1,5 +1,6 @@ import { IsEnum, + IsDefined, IsMongoId, IsNotEmpty, IsOptional, @@ -76,11 +77,11 @@ export class CreateChatMessageDto { @IsOptional() feeling?: Feeling; + @IsDefined() @IsEnum(ChatResponseType) - @IsOptional() - responseType?: ChatResponseType; + responseType: ChatResponseType; + @IsDefined() @IsEnum(ChatEmotion) - @IsOptional() - emotion?: ChatEmotion; + emotion: ChatEmotion; } diff --git a/src/chat/dto/wsMessageBody.dto.ts b/src/chat/dto/wsMessageBody.dto.ts index 5bf4fa35d..98d8f5503 100644 --- a/src/chat/dto/wsMessageBody.dto.ts +++ b/src/chat/dto/wsMessageBody.dto.ts @@ -1,5 +1,4 @@ -import { IsEnum, IsOptional, IsString } from 'class-validator'; -import { Feeling } from '../enum/feeling.enum'; +import { IsDefined, IsEnum } from 'class-validator'; import { ChatEmotion } from '../enum/chatEmotion.enum'; import { ChatResponseType } from '../enum/chatResponseType.enum'; @@ -7,32 +6,17 @@ import { ChatResponseType } from '../enum/chatResponseType.enum'; * DTO representing message body of incoming websocket chat messages. */ export class WsMessageBodyDto { - /** - * The message content. - * @example "Hello there!" - */ - @IsString() - content: string; - - /** - * Feeling of the message. - * @example "Happy" - */ - @IsOptional() - @IsEnum(Feeling) - feeling?: Feeling; - /** * Predefined response selected by the player. */ - @IsOptional() + @IsDefined() @IsEnum(ChatResponseType) - responseType?: ChatResponseType; + responseType: ChatResponseType; /** * Emotion selected for a predefined response. */ - @IsOptional() + @IsDefined() @IsEnum(ChatEmotion) - emotion?: ChatEmotion; + emotion: ChatEmotion; } diff --git a/src/chat/service/clanChat.service.ts b/src/chat/service/clanChat.service.ts index 656048b1f..8db6455a4 100644 --- a/src/chat/service/clanChat.service.ts +++ b/src/chat/service/clanChat.service.ts @@ -80,8 +80,9 @@ export class ClanChatService extends BaseChatService { type: ChatType.CLAN, clan_id: client.user.clanId, sender_id: client.user.playerId, - content: message.content, - feeling: message.feeling, + // Content is retained as a stable, non-localized fallback for legacy + // consumers. The client must never supply the stored message text. + content: message.responseType, responseType: message.responseType, emotion: message.emotion, }); diff --git a/src/chat/service/globalChat.service.ts b/src/chat/service/globalChat.service.ts index a93168db7..2d98103fe 100644 --- a/src/chat/service/globalChat.service.ts +++ b/src/chat/service/globalChat.service.ts @@ -56,8 +56,11 @@ export class GlobalChatService extends BaseChatService { const chatMessage = new CreateChatMessageDto({ type: ChatType.GLOBAL, sender_id: client.user.playerId, - content: message.content, - feeling: message.feeling, + // Content is retained as a stable, non-localized fallback for legacy + // consumers. The client must never supply the stored message text. + content: message.responseType, + responseType: message.responseType, + emotion: message.emotion, }); return this.handleNewMessage(