diff --git a/backend/src/chat/chat.gateway.ts b/backend/src/chat/chat.gateway.ts index a793eaa1..ab8479e7 100644 --- a/backend/src/chat/chat.gateway.ts +++ b/backend/src/chat/chat.gateway.ts @@ -8,6 +8,7 @@ import { WebSocketServer, } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; +import { RoomDeletedEvent } from 'src/common/events/room-deleted.event'; import { RoomEnteredEvent } from 'src/common/events/room-entered.event'; import { RoomMuteEvent } from 'src/common/events/room-mute.event'; import { RoomUnmuteEvent } from 'src/common/events/room-unmute.event'; @@ -166,16 +167,28 @@ export class ChatGateway { this.server.emit('online-status', event); } + @OnEvent('room.delete', { async: true }) + async handleDelete(event: RoomDeletedEvent) { + if (event.accessLevel === 'PUBLIC' || event.accessLevel === 'PROTECTED') { + this.server.emit('delete-room', { roomId: event.roomId }); + } else { + this.server + .in(event.roomId.toString()) + .emit('delete-room', { roomId: event.roomId }); + } + this.chatService.deleteSocketRoom(event); + } + @OnEvent('room.enter', { async: true }) async handleEnter(event: RoomEnteredEvent) { - await this.chatService.addUserToRoom(event.roomId, event.userId); + this.chatService.addUserToRoom(event.roomId, event.userId); this.server.in(event.roomId.toString()).emit('enter-room', event); } @OnEvent('room.leave', { async: true }) async handleLeave(event: RoomLeftEvent) { this.server.in(event.roomId.toString()).emit('leave', event); - await this.chatService.removeUserFromRoom(event); + this.chatService.removeUserFromRoom(event.roomId, event.userId); } @OnEvent('room.update.role', { async: true }) diff --git a/backend/src/chat/chat.service.ts b/backend/src/chat/chat.service.ts index 8d7f7023..809b0092 100644 --- a/backend/src/chat/chat.service.ts +++ b/backend/src/chat/chat.service.ts @@ -6,7 +6,7 @@ import { Socket } from 'socket.io'; import { AuthService } from 'src/auth/auth.service'; import { BlockEvent } from 'src/common/events/block.event'; import { RoomCreatedEvent } from 'src/common/events/room-created.event'; -import { RoomLeftEvent } from 'src/common/events/room-left.event'; +import { RoomDeletedEvent } from 'src/common/events/room-deleted.event'; import { UnblockEvent } from 'src/common/events/unblock.event'; import { PrismaService } from 'src/prisma/prisma.service'; import { UserService } from 'src/user/user.service'; @@ -84,6 +84,13 @@ export class ChatService { } } + removeUserFromRoom(roomId: number, userId: number) { + const client = this.clients.get(userId); + if (client) { + client.leave(roomId.toString()); + } + } + getUsersBlockedBy(userId: number) { return this.prisma.user .findUniqueOrThrow({ @@ -96,12 +103,10 @@ export class ChatService { @OnEvent('room.created', { async: true }) async handleRoomCreatedEvent(event: RoomCreatedEvent) { await this.addUserToRoom(event.roomId, event.userId); - } - - async removeUserFromRoom(event: RoomLeftEvent) { - const client = this.clients.get(event.userId); - if (client) { - client.leave(event.roomId.toString()); + if (event.userIds) { + event.userIds.forEach((userId) => + this.addUserToRoom(event.roomId, userId), + ); } } @@ -131,10 +136,10 @@ export class ChatService { }); } - deleteRoom(roomId: number) { - roomId; - // TODO: delete room - // this.server.socketsLeave(roomId.toString()); + deleteSocketRoom(event: RoomDeletedEvent) { + event.userIds.forEach((userId) => + this.removeUserFromRoom(event.roomId, userId), + ); } sendToRoom(roomId: number, event: string, data: any) { diff --git a/backend/src/common/events/room-created.event.ts b/backend/src/common/events/room-created.event.ts index 143c7736..8a7b2c79 100644 --- a/backend/src/common/events/room-created.event.ts +++ b/backend/src/common/events/room-created.event.ts @@ -1,4 +1,5 @@ export class RoomCreatedEvent { roomId: number; userId: number; + userIds?: number[]; } diff --git a/backend/src/common/events/room-deleted.event.ts b/backend/src/common/events/room-deleted.event.ts new file mode 100644 index 00000000..621ecb68 --- /dev/null +++ b/backend/src/common/events/room-deleted.event.ts @@ -0,0 +1,5 @@ +export class RoomDeletedEvent { + roomId: number; + userIds: number[]; + accessLevel: 'PUBLIC' | 'PRIVATE' | 'PROTECTED' | 'DIRECT'; +} diff --git a/backend/src/room/room.service.ts b/backend/src/room/room.service.ts index f31b5e5d..8a764757 100644 --- a/backend/src/room/room.service.ts +++ b/backend/src/room/room.service.ts @@ -6,6 +6,7 @@ import { import { EventEmitter2 } from '@nestjs/event-emitter'; import { Role, User } from '@prisma/client'; import { RoomCreatedEvent } from 'src/common/events/room-created.event'; +import { RoomDeletedEvent } from 'src/common/events/room-deleted.event'; import { RoomEnteredEvent } from 'src/common/events/room-entered.event'; import { RoomLeftEvent } from 'src/common/events/room-left.event'; import { RoomUpdateRoleEvent } from 'src/common/events/room-update-role.event'; @@ -65,6 +66,7 @@ export class RoomService { const event: RoomCreatedEvent = { roomId: room.id, userId: user.id, + userIds, }; this.eventEmitter.emit('room.created', event); return room; @@ -197,10 +199,19 @@ export class RoomService { }); } - removeRoom(roomId: number): Promise { - return this.prisma.room.delete({ + async removeRoom(roomId: number): Promise { + const users = await this.findAllUserOnRoom(roomId); + const deletedRoom = await this.prisma.room.delete({ where: { id: roomId }, }); + const memberIds = users.map((member) => member.userId); + const event: RoomDeletedEvent = { + roomId: roomId, + userIds: memberIds, + accessLevel: deletedRoom.accessLevel, + }; + this.eventEmitter.emit('room.delete', event); + return deletedRoom; } // UserOnRoom CRUD @@ -316,7 +327,9 @@ export class RoomService { }, }, }); - // TODO: If owner leaves the room, the room should be deleted or a new owner should be assigned + if (deletedUserOnRoom.role === Role.OWNER) { + await this.removeRoom(roomId); + } const event: RoomLeftEvent = { roomId: roomId, userId: userId, diff --git a/backend/test/chat-gateway.e2e-spec.ts b/backend/test/chat-gateway.e2e-spec.ts index 69ba2e79..ed02da8d 100644 --- a/backend/test/chat-gateway.e2e-spec.ts +++ b/backend/test/chat-gateway.e2e-spec.ts @@ -6,6 +6,7 @@ import { AppModule } from 'src/app.module'; import { MessageEntity } from 'src/chat/entities/message.entity'; import { constants } from './constants'; import { TestApp, UserEntityWithAccessToken } from './utils/app'; +import { CreateRoomDto } from 'src/room/dto/create-room.dto'; async function createNestApp(): Promise { const moduleFixture: TestingModule = await Test.createTestingModule({ @@ -1148,6 +1149,161 @@ describe('ChatGateway and ChatController (e2e)', () => { }); }); }); + + describe('Notification that deleted a room', () => { + const setupRoom = async (createRoomDto: CreateRoomDto) => { + const dto = { ...createRoomDto, userIds: [user2.id] }; + const res = await app.createRoom(dto, user1.accessToken).expect(201); + const room = res.body; + expect(room.id).toBeDefined(); + return room; + }; + + describe('Notification that deleted a public room', () => { + let _publicRoom; + let ctx11: Promise; + beforeAll(async () => { + _publicRoom = await setupRoom(constants.room.publicRoom); + + const expectedEvent = { + roomId: _publicRoom.id, + }; + const promises = [ws1, ws2, ws3, ws4, ws5, ws6].map( + (ws) => + new Promise((resolve) => { + ws.on('delete-room', (data) => { + expect(data).toEqual(expectedEvent); + ws.off('delete-room'); + resolve(); + }); + }), + ); + ctx11 = Promise.all(promises); + }); + afterAll(async () => { + await app.deleteRoom(_publicRoom.id, user1.accessToken); + }); + it('is sent when user1 deletes the public room', async () => { + await app.deleteRoom(_publicRoom.id, user1.accessToken).expect(204); + }); + it('should be received by all users', async () => { + await ctx11; + }); + }); + + describe('Notification that deleted a protected room', () => { + let _protectedRoom; + let ctx12: Promise; + beforeAll(async () => { + _protectedRoom = await setupRoom(constants.room.protectedRoom); + + const expectedEvent = { + roomId: _protectedRoom.id, + }; + const promises = [ws1, ws2, ws3, ws4, ws5, ws6].map( + (ws) => + new Promise((resolve) => { + ws.on('delete-room', (data) => { + expect(data).toEqual(expectedEvent); + ws.off('delete-room'); + resolve(); + }); + }), + ); + ctx12 = Promise.all(promises); + }); + afterAll(async () => { + await app.deleteRoom(_protectedRoom.id, user1.accessToken); + }); + it('is sent when user1 deletes the protected room', async () => { + await app + .deleteRoom(_protectedRoom.id, user1.accessToken) + .expect(204); + }); + it('should be received by all users', async () => { + await ctx12; + }); + }); + + describe('Notification that deleted a private room', () => { + let _privateRoom; + let ctx13: Promise; + beforeAll(async () => { + _privateRoom = await setupRoom(constants.room.privateRoom); + + const expectedEvent = { + roomId: _privateRoom.id, + }; + const promises = [ws1, ws2].map( + (ws) => + new Promise((resolve) => { + ws.on('delete-room', (data) => { + expect(data).toEqual(expectedEvent); + ws.off('delete-room'); + resolve(); + }); + }), + ); + ctx13 = Promise.all(promises); + }); + afterAll(async () => { + await app.deleteRoom(_privateRoom.id, user1.accessToken); + }); + it('is sent when user1 deletes the private room', async () => { + await app.deleteRoom(_privateRoom.id, user1.accessToken).expect(204); + }); + it('should be received by room members', async () => { + await ctx13; + }); + it('should not be received by non-members', (done) => { + const mockDeleteRoomEventListener = jest.fn(); + ws3.on('delete-room', mockDeleteRoomEventListener); + setTimeout(() => { + expect(mockDeleteRoomEventListener).not.toBeCalled(); + ws3.off('delete-room'); + done(); + }, waitTime); + }); + }); + }); + + describe('Notification that owner has left the room (delete-room)', () => { + let room; + let ctx14: Promise; + const setupRoom = async (createRoomDto: CreateRoomDto) => { + const dto = { ...createRoomDto, userIds: [user2.id] }; + const res = await app.createRoom(dto, user1.accessToken).expect(201); + const room = res.body; + expect(room.id).toBeDefined(); + return room; + }; + beforeAll(async () => { + room = await setupRoom(constants.room.publicRoom); + const expectedEvent = { + roomId: room.id, + }; + const promises = [ws1, ws2, ws3, ws4, ws5, ws6].map( + (ws) => + new Promise((resolve) => { + ws.on('delete-room', (data) => { + expect(data).toEqual(expectedEvent); + ws.off('delete-room'); + resolve(); + }); + }), + ); + ctx14 = Promise.all(promises); + }); + afterAll(async () => { + await app.deleteRoom(room.id, user1.accessToken); + }); + it('is sent when owner(user1) leaves the public room', async () => { + await app.leaveRoom(room.id, user1.accessToken).expect(204); + }); + it('should be received by all users', async () => { + await ctx14; + }); + }); }); /* diff --git a/backend/test/room.e2e-spec.ts b/backend/test/room.e2e-spec.ts index 2cd14e1e..6cf26652 100644 --- a/backend/test/room.e2e-spec.ts +++ b/backend/test/room.e2e-spec.ts @@ -966,7 +966,6 @@ describe('RoomController (e2e)', () => { describe('owner', () => { beforeAll(setupRooms); afterAll(teardownRooms); - // TODO: What if owner leaves the room? test('should leave public room (204 No Content)', async () => { await app.leaveRoom(_publicRoom.id, owner.accessToken).expect(204); }); @@ -976,6 +975,17 @@ describe('RoomController (e2e)', () => { test('should leave protected room (204 No Content)', async () => { await app.leaveRoom(_protectedRoom.id, owner.accessToken).expect(204); }); + test('should not get public room after owner leaves (404 Not Found)', async () => { + return app.getRoom(_publicRoom.id, owner.accessToken).expect(404); + }); + test('should not get private room after owner leaves (404 Not Found)', async () => { + await app.getRoom(_privateRoom.id, member.accessToken).expect(404); + await app.getRoom(_privateRoom.id, admin.accessToken).expect(404); + return app.getRoom(_privateRoom.id, owner.accessToken).expect(404); + }); + test('should not get protected room after owner leaves (404 Not Found)', async () => { + return app.getRoom(_protectedRoom.id, owner.accessToken).expect(404); + }); }); describe('admin', () => { beforeAll(setupRooms);