Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 54 additions & 1 deletion src/__tests__/chat/chatGateway/chatGateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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',
Expand Down Expand Up @@ -138,4 +191,4 @@ describe('ChatGateway user initialization', () => {
expect(globalChatService.handleNewGlobalReaction).not.toHaveBeenCalled();
},
);
});
});
215 changes: 215 additions & 0 deletions src/__tests__/dailyTasks/formAnInnerConnection.test.ts
Original file line number Diff line number Diff line change
@@ -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>(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();
});
});
});
62 changes: 35 additions & 27 deletions src/chat/chat.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
14 changes: 13 additions & 1 deletion src/dailyTasks/dailyTasks.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -502,6 +503,16 @@ export class DailyTasksService {
emotion?: ChatEmotion;
strongerSoldierStep?: StrongerSoldierStep;
}): Promise<IServiceReturn<DailyTaskProgressResult<DailyTaskDto>>> {
// 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 ||
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -600,4 +612,4 @@ export class DailyTasksService {
async getRewards() {
return { prizePool: prizePool };
}
}
}
Loading
Loading