diff --git a/backend/src/chat/chat.gateway.ts b/backend/src/chat/chat.gateway.ts index c69d61a0..a5015958 100644 --- a/backend/src/chat/chat.gateway.ts +++ b/backend/src/chat/chat.gateway.ts @@ -13,6 +13,7 @@ import { ChatService } from './chat.service'; import { MuteService } from 'src/room/mute/mute.service'; import { CreateMessageDto } from './dto/create-message.dto'; import { MessageEntity } from './entities/message.entity'; +import { v4 } from 'uuid'; @WebSocketGateway({ cors: { @@ -68,6 +69,58 @@ export class ChatGateway { ); } + @SubscribeMessage('invite-pong') + async handleInvitePong( + @MessageBody() data: { userId: number }, + @ConnectedSocket() client: Socket, + ) { + const inviteUser = this.chatService.getUser(client); + const invitedUserWsId = this.chatService.getWsFromUserId(data.userId)?.id; + if (!invitedUserWsId) { + return; + } else { + const blockings = await this.chatService.getUsersBlockedBy(data.userId); + if (blockings.some((user) => user.id === inviteUser.id)) return; + const blocked = await this.chatService.getUsersBlockedBy(inviteUser.id); + if (blocked.some((user) => user.id === data.userId)) return; + this.server + .to(invitedUserWsId) + .emit('invite-pong', { userId: inviteUser.id }); + this.chatService.addInvite(inviteUser.id, data.userId); + } + } + + @SubscribeMessage('invite-cancel-pong') + handleInviteCancelPong(@ConnectedSocket() client: Socket) { + const inviteUser = this.chatService.getUser(client); + this.chatService.removeInvite(inviteUser.id); + } + + @SubscribeMessage('approve-pong') + async handleApprovePong( + @MessageBody() data: { userId: number }, + @ConnectedSocket() client: Socket, + ) { + const approvedUserWsId = this.chatService.getWsFromUserId(data.userId)?.id; + if (!approvedUserWsId) { + return; + } else { + if ( + this.chatService.getInvite(data.userId) !== + this.chatService.getUserId(client) + ) { + this.server + .to(client.id) + .emit('error-pong', 'No pending invite found.'); + return; + } + const emitData = { roomId: v4() }; + this.server.to(client.id).emit('match-pong', emitData); + this.server.to(approvedUserWsId).emit('match-pong', emitData); + this.chatService.removeInvite(data.userId); + } + } + @OnEvent('room.leave', { async: true }) async handleLeave(event: RoomLeftEvent) { this.server.in(event.roomId.toString()).emit('leave', event); diff --git a/backend/src/chat/chat.service.ts b/backend/src/chat/chat.service.ts index 32c9e918..2ccfb8af 100644 --- a/backend/src/chat/chat.service.ts +++ b/backend/src/chat/chat.service.ts @@ -25,11 +25,17 @@ export class ChatService { // Map private clients = new Map(); private users = new Map(); + // key: inviter, value: invitee + private invite = new Map(); getUser(client: Socket) { return this.users.get(client.id); } + getWsFromUserId(userId: number): Socket | undefined { + return this.clients.get(userId); + } + getUserId(client: Socket) { const user = this.users.get(client.id); if (user) { @@ -48,9 +54,22 @@ export class ChatService { if (user) { this.clients.delete(user.id); this.users.delete(client.id); + this.removeInvite(user.id); } } + addInvite(inviterId: number, inviteeId: number) { + this.invite.set(inviterId, inviteeId); + } + + getInvite(inviterId: number) { + return this.invite.get(inviterId); + } + + removeInvite(inviterId: number) { + this.invite.delete(inviterId); + } + addUserToRoom(roomId: number, userId: number) { const client = this.clients.get(userId); if (client) { @@ -58,6 +77,15 @@ export class ChatService { } } + getUsersBlockedBy(userId: number) { + return this.prisma.user + .findUniqueOrThrow({ + where: { id: userId }, + include: { blocking: true }, + }) + .then((user) => user.blocking); + } + @OnEvent('room.created', { async: true }) async handleRoomCreatedEvent(event: RoomCreatedEvent) { await this.addUserToRoom(event.roomId, event.userId); diff --git a/backend/test/chat-gateway.e2e-spec.ts b/backend/test/chat-gateway.e2e-spec.ts index 62ceadc7..0e71084d 100644 --- a/backend/test/chat-gateway.e2e-spec.ts +++ b/backend/test/chat-gateway.e2e-spec.ts @@ -1046,4 +1046,259 @@ describe('ChatGateway and ChatController (e2e)', () => { describe('[joinDM]', () => { // TODO }); + describe('invite pong game', () => { + type UserAndSocket = { + user: any; + ws: Socket; + }; + let userAndSockets: UserAndSocket[]; + + beforeAll(() => { + const users = [user1, user2, mutedUser1, kickedUser1]; + userAndSockets = users.map((user) => ({ + user, + ws: io('ws://localhost:3000/chat', { + extraHeaders: { cookie: 'token=' + user.accessToken }, + }), + })); + }); + afterAll(() => { + userAndSockets.map((userAndSocket) => { + userAndSocket.ws.close(); + }); + }); + afterEach(() => { + userAndSockets.map((us) => { + us.ws.disconnect(); + us.ws.connect(); + }); + }); + describe('invite a user', () => { + describe('success case', () => { + let invite: UserAndSocket; + let invited: UserAndSocket; + let notInvited: UserAndSocket; + + let ctx1: Promise; + const mockCallback = jest.fn(); + + beforeAll(() => { + invite = userAndSockets[0]; + invited = userAndSockets[1]; + notInvited = userAndSockets[2]; + ctx1 = new Promise((resolve) => + invited.ws.on('invite-pong', (data) => resolve(data)), + ); + notInvited.ws.on('invite-pong', mockCallback); + + invite.ws.emit('invite-pong', { + userId: invited.user.id, + }); + ctx1.then((data) => { + expect(data).toEqual({ + userId: invite.user.id, + }); + }); + }); + it('user who is invited should receive invite message', () => ctx1); + it("user who isn't invited should not receive invite message", () => + new Promise((resolve) => + setTimeout(() => { + expect(mockCallback).not.toBeCalled(); + resolve(); + }, 1000), + )); + }); + // TODO: block してるuser から invite されるケース + describe('failure case', () => { + let invitee; + let blocked; + let mockCallback: jest.Mock; + + beforeAll(async () => { + mockCallback = jest.fn(); + invitee = userAndSockets[0]; + blocked = userAndSockets[1]; + await app + .blockUser( + invitee.user.id, + blocked.user.id, + invitee.user.accessToken, + ) + .expect(200); + invitee.ws.on('invite-pong', mockCallback); + blocked.ws.emit('invite-pong', { + userId: invitee.user.id, + }); + }); + afterAll(async () => { + await app + .unblockUser( + invitee.user.id, + blocked.user.id, + invitee.user.accessToken, + ) + .expect(200); + }); + it('user should not receive invite message from blocking user', () => + new Promise((resolve) => + setTimeout(async () => { + expect(mockCallback).not.toHaveBeenCalled(); + resolve(); + }, 500), + )); + }); + describe('invite -> cancel -> invite', () => { + let invitee; + let inviter; + let mockCallback; + + beforeAll(() => { + mockCallback = jest.fn(); + invitee = userAndSockets[0]; + inviter = userAndSockets[1]; + + invitee.ws.on('invite-pong', mockCallback); + inviter.ws.emit('invite-pong', { + userId: invitee.user.id, + }); + inviter.ws.emit('invite-cancel-pong', { + userId: invitee.user.id, + }); + inviter.ws.emit('invite-pong', { + userId: invitee.user.id, + }); + }); + it('user who is invited should receive invite message once per time', () => + new Promise((resolve) => + setTimeout(() => { + expect(mockCallback).toHaveBeenCalledTimes(2); + resolve(); + }, 1000), + )); + }); + }); + describe('approve invite', () => { + describe('success case', () => { + let PromiseToMatchByInviter: Promise; + let PromiseToMatchByInvited: Promise; + let roomId; + const mockCallback1 = jest.fn(); + beforeAll(() => { + const inviter = userAndSockets[0]; + const invitee = userAndSockets[1]; + const notInvited1 = userAndSockets[2]; + + const promiseToInvite = new Promise((resolve) => + invitee.ws.on('invite-pong', (data) => resolve(data)), + ); + PromiseToMatchByInviter = new Promise((resolve) => + inviter.ws.on('match-pong', (data) => resolve(data)), + ); + PromiseToMatchByInvited = new Promise((resolve) => + invitee.ws.on('match-pong', (data) => resolve(data)), + ); + + notInvited1.ws.on('invite-pong', mockCallback1); + notInvited1.ws.on('approve-pong', mockCallback1); + notInvited1.ws.on('match-pong', mockCallback1); + + inviter.ws.emit('invite-pong', { + userId: invitee.user.id, + }); + return promiseToInvite.then((data) => { + invitee.ws.emit('approve-pong', { + userId: data.userId, + }); + }); + }); + it("invite user should receive room's id", () => + PromiseToMatchByInviter.then((data) => { + expect(data).toHaveProperty('roomId'); + roomId = data.roomId; + })); + it("approve user should receive room's id", () => + PromiseToMatchByInvited.then((data) => { + expect(data).toHaveProperty('roomId'); + expect(data.roomId).toEqual(roomId); + })); + it('unrelated user should not receive any messages', () => + new Promise((resolve) => + setTimeout(() => { + expect(mockCallback1).not.toBeCalled(); + resolve(); + }, 1000), + )); + }); + describe('failure case', () => { + const mockCallback1 = jest.fn(); + const mockCallback2 = jest.fn(); + let errorCtx: Promise; + + beforeAll(() => { + const emitter = userAndSockets[0]; + const listener = userAndSockets[1]; + + emitter.ws.on('match-pong', mockCallback1); + errorCtx = new Promise((resolve) => + emitter.ws.on('error-pong', (data) => resolve(data)), + ); + listener.ws.on('match-pong', mockCallback2); + + emitter.ws.emit('approve-pong', { + userId: listener.user.id, + }); + }); + // TODO: 複数のuser から invite されるケース + it('should receive an error when approving without an existing invite', () => + errorCtx); + it('user should not receive approve message from not invite user', () => + new Promise((resolve) => + setTimeout(() => { + expect(mockCallback1).not.toHaveBeenCalled(); + expect(mockCallback2).not.toHaveBeenCalled(); + resolve(); + }, 1000), + )); + }); + describe('invite -> cancel -> approve: dose not match', () => { + const mockToMatchByEmitter = jest.fn(); + const mockToMatchByListener = jest.fn(); + + beforeAll(() => { + const emitter = userAndSockets[0]; + const listener = userAndSockets[1]; + + emitter.ws.on('match-pong', mockToMatchByEmitter); + + const PromiseToInvite = new Promise((resolve) => + listener.ws.on('invite-pong', (data) => resolve(data)), + ); + listener.ws.on('match-pong', mockToMatchByListener); + + emitter.ws.emit('invite-pong', { + userId: listener.user.id, + }); + return PromiseToInvite.then((data) => { + emitter.ws.emit('invite-cancel-pong', { + userId: data.userId, + }); + setTimeout(() => { + listener.ws.emit('approve-pong', { + userId: data.userId, + }); + }, 100); + }); + }); + it('user should not receive match message from canceled invite user', () => + new Promise((resolve) => + setTimeout(() => { + expect(mockToMatchByEmitter).not.toHaveBeenCalled(); + expect(mockToMatchByListener).not.toHaveBeenCalled(); + resolve(); + }, 1000), + )); + }); + }); + }); });