diff --git a/src/__tests__/chat/chatGateway/chatGateway.test.ts b/src/__tests__/chat/chatGateway/chatGateway.test.ts index fa8bee84..e1d150c2 100644 --- a/src/__tests__/chat/chatGateway/chatGateway.test.ts +++ b/src/__tests__/chat/chatGateway/chatGateway.test.ts @@ -93,6 +93,12 @@ describe('ChatGateway user initialization', () => { 1, 'player-id', ServerTaskName.FORM_AN_INNER_CONNECTION, + true, + { + clanId: 'clan-id', + responseType: ChatResponseType.YES, + emotion: ChatEmotion.JOY, + }, ); expect(emitterService.EmitNewDailyTaskEvent).toHaveBeenNthCalledWith( 2, @@ -107,6 +113,53 @@ describe('ChatGateway user initialization', () => { ); }); + it('does not emit daily task events for global messages', async () => { + const client = createClient(); + client.user = { playerId: 'player-id', clanId: 'clan-id' } as any; + globalChatService.handleNewGlobalMessage.mockResolvedValue([ + { _id: 'msg-1' }, + null, + ]); + + await gateway.handleGlobalMessage({ content: 'Hello global!' }, client); + + expect(emitterService.EmitNewDailyTaskEvent).not.toHaveBeenCalled(); + }); + + it('emits daily task events with undefined responseType and emotion for plain chat messages', async () => { + const client = createClient(); + client.user = { playerId: 'player-id', clanId: 'clan-id' } as any; + clanChatService.handleNewClanMessage.mockResolvedValue([ + { clan_id: 'clan-id' }, + null, + ]); + + await gateway.handleClanMessage({ content: 'Just plain chat' }, client); + + expect(emitterService.EmitNewDailyTaskEvent).toHaveBeenNthCalledWith( + 1, + 'player-id', + ServerTaskName.FORM_AN_INNER_CONNECTION, + true, + { + clanId: 'clan-id', + responseType: undefined, + emotion: undefined, + }, + ); + expect(emitterService.EmitNewDailyTaskEvent).toHaveBeenNthCalledWith( + 2, + 'player-id', + ServerTaskName.PLAY_WITH_EMOTIONS, + true, + { + clanId: 'clan-id', + responseType: undefined, + emotion: undefined, + }, + ); + }); + it.each([ [ 'clanMessage', @@ -138,4 +191,4 @@ describe('ChatGateway user initialization', () => { expect(globalChatService.handleNewGlobalReaction).not.toHaveBeenCalled(); }, ); -}); +}); \ No newline at end of file diff --git a/src/__tests__/dailyTasks/formAnInnerConnection.test.ts b/src/__tests__/dailyTasks/formAnInnerConnection.test.ts new file mode 100644 index 00000000..d3fa36f5 --- /dev/null +++ b/src/__tests__/dailyTasks/formAnInnerConnection.test.ts @@ -0,0 +1,215 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getModelToken, getConnectionToken } from '@nestjs/mongoose'; +import { DailyTasksService } from '../../dailyTasks/dailyTasks.service'; +import { DailyTask } from '../../dailyTasks/dailyTasks.schema'; +import DailyTaskNotifier from '../../dailyTasks/dailyTask.notifier'; +import { DailyTaskQueue } from '../../dailyTasks/dailyTask.queue'; +import { TaskGeneratorService } from '../../dailyTasks/taskGenerator.service'; +import { DailyTaskProgressService } from '../../dailyTasks/dailyTaskProgress.service'; +import { ServerTaskName } from '../../dailyTasks/enum/serverTaskName.enum'; +import { ChatEmotion } from '../../chat/enum/chatEmotion.enum'; +import { ChatResponseType } from '../../chat/enum/chatResponseType.enum'; + +describe('FORM_AN_INNER_CONNECTION Daily Task', () => { + let service: DailyTasksService; + let mockModel: any; + + beforeEach(async () => { + mockModel = { + findOne: jest.fn(), + findOneAndUpdate: jest.fn(), + updateOne: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DailyTasksService, + { + provide: getModelToken(DailyTask.name), + useValue: mockModel, + }, + { + provide: getConnectionToken(), + useValue: { + startSession: jest.fn().mockResolvedValue({ + startTransaction: jest.fn(), + commitTransaction: jest.fn(), + abortTransaction: jest.fn(), + endSession: jest.fn(), + inTransaction: jest.fn().mockReturnValue(true), + }), + }, + }, + { + provide: DailyTaskNotifier, + useValue: { taskReceived: jest.fn(), taskCompleted: jest.fn() }, + }, + { + provide: DailyTaskQueue, + useValue: { addDailyTask: jest.fn() }, + }, + { + provide: TaskGeneratorService, + useValue: { + createTaskRandomValues: jest.fn().mockReturnValue({ amount: 5 }), + }, + }, + { + provide: DailyTaskProgressService, + useValue: { handleProgress: jest.fn().mockResolvedValue([true, null]) }, + }, + ], + }).compile(); + + service = module.get(DailyTasksService); + }); + + describe('Validation & Guards', () => { + it('returns [null, null] when clanId is missing', async () => { + const [res, err] = await service.handleDailyTaskEvent({ + playerId: 'player-1', + serverTaskName: ServerTaskName.FORM_AN_INNER_CONNECTION, + responseType: ChatResponseType.YES, + emotion: ChatEmotion.JOY, + }); + + expect(res).toBeNull(); + expect(err).toBeNull(); + }); + + it('returns [null, null] for unsupported response types', async () => { + const [res, err] = await service.handleDailyTaskEvent({ + playerId: 'player-1', + serverTaskName: ServerTaskName.FORM_AN_INNER_CONNECTION, + clanId: 'clan-1', + responseType: 'INVALID_RESPONSE_TYPE' as any, + emotion: ChatEmotion.JOY, + }); + + expect(res).toBeNull(); + expect(err).toBeNull(); + }); + + it('returns [null, null] for invalid emotion values', async () => { + const [res, err] = await service.handleDailyTaskEvent({ + playerId: 'player-1', + serverTaskName: ServerTaskName.FORM_AN_INNER_CONNECTION, + clanId: 'clan-1', + responseType: ChatResponseType.YES, + emotion: 9999 as any, + }); + + expect(res).toBeNull(); + expect(err).toBeNull(); + }); + }); + + describe('Task Progress & Clan Ownership', () => { + it('successfully progresses task when clanId, responseType, and emotion are valid', async () => { + const mockTask = { + _id: 'task-1', + clan_id: 'clan-1', + player_id: 'player-1', + amountLeft: 2, + type: ServerTaskName.FORM_AN_INNER_CONNECTION, + }; + + jest.spyOn(service['basicService'], 'readOne').mockResolvedValue([mockTask as any, null]); + jest.spyOn(service['basicService'], 'updateOne').mockResolvedValue([true, null]); + + const [res, err] = await service.handleDailyTaskEvent({ + playerId: 'player-1', + serverTaskName: ServerTaskName.FORM_AN_INNER_CONNECTION, + clanId: 'clan-1', + responseType: ChatResponseType.YES, + emotion: ChatEmotion.JOY, + needsClanReward: true, + }); + + expect(err).toBeNull(); + expect(res).toBeDefined(); + expect(res?.status).toBe('advanced'); + expect(res?.needsClanReward).toBe(true); + }); + + it('successfully completes task when amountLeft reaches 0', async () => { + const mockTask = { + _id: 'task-1', + clan_id: 'clan-1', + player_id: 'player-1', + amountLeft: 1, + type: ServerTaskName.FORM_AN_INNER_CONNECTION, + }; + + jest.spyOn(service['basicService'], 'readOne').mockResolvedValue([mockTask as any, null]); + jest.spyOn(service, 'deleteTask').mockResolvedValue([true as any, null]); + + const [res, err] = await service.handleDailyTaskEvent({ + playerId: 'player-1', + serverTaskName: ServerTaskName.FORM_AN_INNER_CONNECTION, + clanId: 'clan-1', + responseType: ChatResponseType.YES, + emotion: ChatEmotion.JOY, + needsClanReward: true, + }); + + expect(err).toBeNull(); + expect(res).toBeDefined(); + expect(res?.status).toBe('completed'); + expect(res?.currentAmountLeft).toBe(0); + expect(service.deleteTask).toHaveBeenCalledWith( + 'task-1', + 'clan-1', + 'player-1', + expect.anything(), + ); + }); + + it('returns error when task is not found or owned by another clan', async () => { + jest.spyOn(service['basicService'], 'readOne').mockResolvedValue([ + null, + [{ reason: 'NOT_FOUND', message: 'Task not found' }] as any, + ]); + + const [res, err] = await service.handleDailyTaskEvent({ + playerId: 'player-1', + serverTaskName: ServerTaskName.FORM_AN_INNER_CONNECTION, + clanId: 'wrong-clan', + responseType: ChatResponseType.YES, + emotion: ChatEmotion.JOY, + }); + + expect(res).toBeNull(); + expect(err).toBeDefined(); + }); + }); + + describe('Failed Persistence', () => { + it('returns database error when update fails', async () => { + const mockTask = { + _id: 'task-1', + clan_id: 'clan-1', + player_id: 'player-1', + amountLeft: 2, + type: ServerTaskName.FORM_AN_INNER_CONNECTION, + }; + + jest.spyOn(service['basicService'], 'readOne').mockResolvedValue([mockTask as any, null]); + jest.spyOn(service['basicService'], 'updateOne').mockResolvedValue([ + null, + [{ reason: 'DATABASE_ERROR', message: 'Write failed' }] as any, + ]); + + const [res, err] = await service.handleDailyTaskEvent({ + playerId: 'player-1', + serverTaskName: ServerTaskName.FORM_AN_INNER_CONNECTION, + clanId: 'clan-1', + responseType: ChatResponseType.YES, + emotion: ChatEmotion.JOY, + }); + + expect(res).toBeNull(); + expect(err).toBeDefined(); + }); + }); +}); \ No newline at end of file diff --git a/src/chat/chat.gateway.ts b/src/chat/chat.gateway.ts index 56336ee0..c5cfc5f2 100644 --- a/src/chat/chat.gateway.ts +++ b/src/chat/chat.gateway.ts @@ -88,33 +88,41 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { } @SubscribeMessage('clanMessage') - @WsLog() - async handleClanMessage( - @MessageBody() message: WsMessageBodyDto, - @ConnectedSocket() client: WebSocketUser, - ) { - this.assertUserInitialized(client); - - const [createdMessage, error] = - await this.clanChatService.handleNewClanMessage(client, message); - - if (error) return [null, error]; - - this.emitterService.EmitNewDailyTaskEvent( - client.user.playerId, - ServerTaskName.FORM_AN_INNER_CONNECTION, - ); - this.emitterService.EmitNewDailyTaskEvent( - client.user.playerId, - ServerTaskName.PLAY_WITH_EMOTIONS, - true, - { - clanId: createdMessage.clan_id, - responseType: message.responseType, - emotion: message.emotion, - }, - ); - } +@WsLog() +async handleClanMessage( + @MessageBody() message: WsMessageBodyDto, + @ConnectedSocket() client: WebSocketUser, +) { + this.assertUserInitialized(client); + + const [createdMessage, error] = + await this.clanChatService.handleNewClanMessage(client, message); + + if (error) return [null, error]; + + // Update FORM_AN_INNER_CONNECTION to include payload context: + this.emitterService.EmitNewDailyTaskEvent( + client.user.playerId, + ServerTaskName.FORM_AN_INNER_CONNECTION, + true, + { + clanId: createdMessage.clan_id, + responseType: message.responseType, + emotion: message.emotion, + }, + ); + + this.emitterService.EmitNewDailyTaskEvent( + client.user.playerId, + ServerTaskName.PLAY_WITH_EMOTIONS, + true, + { + clanId: createdMessage.clan_id, + responseType: message.responseType, + emotion: message.emotion, + }, + ); +} @SubscribeMessage('clanMessageReaction') @WsLog() diff --git a/src/dailyTasks/dailyTasks.service.ts b/src/dailyTasks/dailyTasks.service.ts index 6603faa6..fc88d51c 100644 --- a/src/dailyTasks/dailyTasks.service.ts +++ b/src/dailyTasks/dailyTasks.service.ts @@ -45,6 +45,7 @@ const isChatResponseType = ( const isStrongerSoldierStep = (step: unknown): step is StrongerSoldierStep => typeof step === 'string' && Object.values(StrongerSoldierStep).includes(step as StrongerSoldierStep); + @Injectable() export class DailyTasksService { public constructor( @@ -502,6 +503,16 @@ export class DailyTasksService { emotion?: ChatEmotion; strongerSoldierStep?: StrongerSoldierStep; }): Promise>> { + // Validates payload for FORM_AN_INNER_CONNECTION task to ensure clanId, responseType, and emotion are provided and valid + if ( + payload.serverTaskName === ServerTaskName.FORM_AN_INNER_CONNECTION && + (!payload.clanId || + !isChatResponseType(payload.responseType) || + !isChatEmotion(payload.emotion)) + ) { + return [null, null]; + } + if ( payload.serverTaskName === ServerTaskName.PLAY_WITH_EMOTIONS && (!payload.clanId || @@ -539,6 +550,7 @@ export class DailyTasksService { session, ); } else { + // Handles standard tasks including FORM_AN_INNER_CONNECTION via updateTask [progressResult, updateErrors] = await this.updateTask( payload.playerId, payload.serverTaskName, @@ -600,4 +612,4 @@ export class DailyTasksService { async getRewards() { return { prizePool: prizePool }; } -} +} \ No newline at end of file diff --git a/src/dailyTasks/taskGenerator.service.ts b/src/dailyTasks/taskGenerator.service.ts index 741d59e3..6002dbce 100644 --- a/src/dailyTasks/taskGenerator.service.ts +++ b/src/dailyTasks/taskGenerator.service.ts @@ -48,11 +48,11 @@ export const ACTIVE_SERVER_TASK_DEFINITIONS: readonly ServerTaskDefinition[] = [ }), }, { - type: ServerTaskName.FORM_AN_INNER_CONNECTION, - createAmount: createRandomAmount, - createTitle: (amount) => ({ - fi: `Lähetä ${amount} viesti klaanichattiin`, - }), + type: ServerTaskName.FORM_AN_INNER_CONNECTION, + createAmount: () => 1, + createTitle: () => ({ + fi: 'Avaa klaanin chat ja lähetä viesti. Huomaa, miltä tuntuu aloittaa vuorovaikutus. Opit, että viestintä pelissä rakentaa suhteita ja merkityksiä, ei vain siirrä tietoa.', + }), }, { type: ServerTaskName.PLAY_WITH_EMOTIONS,