diff --git a/backend/package-lock.json b/backend/package-lock.json index f531eeea5..44ae1d2c4 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -37,6 +37,7 @@ "jest": "29.5.0", "prettier": "^2.3.2", "prisma": "^4.12.0", + "socket.io-client": "^4.6.1", "source-map-support": "^0.5.20", "supertest": "^6.1.3", "ts-jest": "29.0.5", @@ -3681,6 +3682,19 @@ "node": ">=10.0.0" } }, + "node_modules/engine.io-client": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.4.0.tgz", + "integrity": "sha512-GyKPDyoEha+XZ7iEqam49vz6auPnNJ9ZBfy89f+rMMas8AuiMWOZ9PVzu8xb9ZC6rafUqiGHSCfu22ih66E+1g==", + "dev": true, + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.0.3", + "ws": "~8.11.0", + "xmlhttprequest-ssl": "~2.0.0" + } + }, "node_modules/engine.io-parser": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.0.6.tgz", @@ -7898,6 +7912,21 @@ "ws": "~8.11.0" } }, + "node_modules/socket.io-client": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.6.1.tgz", + "integrity": "sha512-5UswCV6hpaRsNg5kkEHVcbBIXEYoVbMQaHJBXJCyEQ+CiFPV1NIOY0XOFWG4XR4GZcB8Kn6AsRs/9cy9TbqVMQ==", + "dev": true, + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.4.0", + "socket.io-parser": "~4.2.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/socket.io-parser": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.3.tgz", @@ -9040,6 +9069,15 @@ } } }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", + "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index 67b5632b0..dde0bb03a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -50,6 +50,7 @@ "jest": "29.5.0", "prettier": "^2.3.2", "prisma": "^4.12.0", + "socket.io-client": "^4.6.1", "source-map-support": "^0.5.20", "supertest": "^6.1.3", "ts-jest": "29.0.5", diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index 3f253b8b5..d6cbe9c71 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -25,12 +25,8 @@ async function main() { hashedPassword: 'piyopiyo', }, }); - const room = await prisma.chatRoom.upsert({ - where: { - roomName: 'hogeRoom', - }, - update: {}, - create: { + const room = await prisma.chatRoom.create({ + data: { roomName: 'hogeRoom', }, }); diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index d69ed6a0b..7b2ca6f3f 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -7,9 +7,16 @@ import { EventsGateway } from './events/events.gateway'; import { PostMessageModule } from './post-message/post-message.module'; import { PrismaModule } from './prisma/prisma.module'; import { UserModule } from './user/user.module'; +import { ChatModule } from './chat/chat.module'; @Module({ - imports: [PrismaModule, PostMessageModule, ConfigModule, UserModule], + imports: [ + PrismaModule, + PostMessageModule, + ConfigModule, + UserModule, + ChatModule, + ], controllers: [AppController], providers: [AppService, EventsGateway], }) diff --git a/backend/src/chat/chat.controller.spec.ts b/backend/src/chat/chat.controller.spec.ts new file mode 100644 index 000000000..3f9e47fe3 --- /dev/null +++ b/backend/src/chat/chat.controller.spec.ts @@ -0,0 +1,19 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { ChatController } from './chat.controller'; + +describe('ChatController', () => { + let controller: ChatController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ChatController], + }).compile(); + + controller = module.get(ChatController); + }); + + test('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/backend/src/chat/chat.controller.ts b/backend/src/chat/chat.controller.ts new file mode 100644 index 000000000..d71b71a9a --- /dev/null +++ b/backend/src/chat/chat.controller.ts @@ -0,0 +1,4 @@ +import { Controller } from '@nestjs/common'; + +@Controller('chat') +export class ChatController {} diff --git a/backend/src/chat/chat.gateway.spec.ts b/backend/src/chat/chat.gateway.spec.ts new file mode 100644 index 000000000..96743462b --- /dev/null +++ b/backend/src/chat/chat.gateway.spec.ts @@ -0,0 +1,234 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { io, Socket } from 'socket.io-client'; +import { ChatRoom, User } from '@prisma/client'; +import { IoAdapter } from '@nestjs/platform-socket.io'; +import { INestApplication } from '@nestjs/common'; + +import { TestModule } from '../test/test.module'; +import { PrismaService } from '../prisma/prisma.service'; + +import { CreateChannelDto, JoinChannelDto } from './dto/Channel.dto'; +import { ChatGateway } from './chat.gateway'; +import { MessageDto } from './dto/message.dto'; + +type testUser = { + user: User; + socket: Socket; +}; + +const modelNames = ['chatRoom', 'user']; +const USERNUM = 10; + +const createTestUsers = async (prismaService: PrismaService) => { + const testUsers: testUser[] = []; + for (let i = 0; i < USERNUM; i++) { + const sock: Socket = io('http://localhost:8001'); + + const user: User = await prismaService.user.upsert({ + where: { + email: `chatTestUser${i}@test.com`, + }, + update: {}, + create: { + email: `chatTestUser${i}@test.com`, + username: `chatTestUser${i}`, + hashedPassword: `chatTestUser${i}`, + }, + }); + + testUsers.push({ user, socket: sock }); + } + return testUsers; +}; + +const cleanupDatabase = async ( + modelNames: string[], + prisma: PrismaService, +): Promise => { + console.log(modelNames); + // prisma.user prisma.chatroom 的なのになる + for (const name of modelNames) { + await (prisma as any)[name].deleteMany({}); + } +}; + +const emitAndWaitForEvent = async ( + eventName: string, + socket: Socket, + dto: T, +) => { + return new Promise((resolve) => { + socket.on(eventName, async () => resolve(null)); + socket.emit(eventName, dto); + }); +}; + +describe('ChatGateway', () => { + let gateway: ChatGateway; + let prismaService: PrismaService; + let testUsers: testUser[]; + let room: ChatRoom | null; + let app: INestApplication; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [TestModule], + providers: [ChatGateway, PrismaService], + }).compile(); + + gateway = module.get(ChatGateway); + prismaService = module.get(PrismaService); + + app = module.createNestApplication(); + app.useWebSocketAdapter(new IoAdapter(app)); + await app.listen(8001); + + testUsers = []; + testUsers = await createTestUsers(prismaService); + + testUsers.map((testUser) => { + testUser.socket.on('connect', () => { + console.log(`connected ${testUser.user.username}`); + }); + }); + }); + afterAll(async () => { + testUsers.map((testUser) => { + testUser.socket.off('connect', () => { + console.log(`dissconnected ${testUser.user.username}`); + }); + }); + + testUsers.map((testUser) => { + testUser.socket.disconnect(); + }); + + await app.close(); + await cleanupDatabase(modelNames, prismaService); + }); + + test('should be defined', () => { + expect(gateway).toBeDefined(); + }); + + describe('create Channel', () => { + test('users[0]が部屋を作成', async () => { + const user: testUser = testUsers[0]; + const createChannelDto: CreateChannelDto = { + roomName: 'testroom', + userId: user.user.id, + }; + await emitAndWaitForEvent( + 'createChannel', + user.socket, + createChannelDto, + ); + + room = await prismaService.chatRoom.findFirst({ + where: { + roomName: 'testroom', + }, + }); + + expect(room?.roomName).toEqual(createChannelDto.roomName); + }); + }); + + describe('join Channel', () => { + let roomId: string; + beforeEach(() => { + if (!room) { + throw Error('room is not created'); + } + roomId = room.id; + }); + + test('users[1]~users[9]が部屋に参加', async () => { + const promises: Promise[] = []; + + testUsers.slice(1).map((testUser) => { + const joinChannel: JoinChannelDto = { + userId: testUser.user.id, + chatRoomId: roomId, + }; + const joinPromise = emitAndWaitForEvent( + 'joinChannel', + testUser.socket, + joinChannel, + ); + promises.push(joinPromise); + }); + + await Promise.all(promises).then(async () => { + const MemberList = await prismaService.roomMember.findMany({ + where: { + chatRoomId: roomId, + }, + }); + expect(MemberList.length).toEqual(USERNUM); + }); + }); + }); + + describe('sendMessage', () => { + let roomId: string; + beforeEach(() => { + if (!room) { + throw Error('room is not created'); + } + roomId = room.id; + }); + + test('users[0]が送信したメッセージがDBに保存されるか', async () => { + const user: testUser = testUsers[0]; + const messageDto: MessageDto = { + content: 'test message', + userId: user.user.id, + chatRoomId: roomId, + }; + + await emitAndWaitForEvent( + 'sendMessage', + user.socket, + messageDto, + ); + const dbMsg = await prismaService.message.findFirst({ + where: { + chatRoomId: roomId, + }, + }); + + expect(dbMsg?.content).toEqual(messageDto.content); + }); + + test('users[0]が送信したメッセージが全員に送信されるか', async () => { + const user = testUsers[0]; + const messageDto: MessageDto = { + content: 'test message', + userId: user.user.id, + chatRoomId: roomId, + }; + const promises: Promise[] = []; + + let receivedCount = 0; + + testUsers.map((user) => { + const joinPromise = new Promise((resolve) => { + user.socket.on('sendMessage', async () => { + receivedCount++; + console.log(receivedCount); + resolve(null); + }); + }); + + promises.push(joinPromise); + }); + + user.socket.emit('sendMessage', messageDto); + + Promise.all(promises).then(() => { + expect(receivedCount).toEqual(USERNUM); + }); + }); + }); +}); diff --git a/backend/src/chat/chat.gateway.ts b/backend/src/chat/chat.gateway.ts new file mode 100644 index 000000000..c04eff1aa --- /dev/null +++ b/backend/src/chat/chat.gateway.ts @@ -0,0 +1,78 @@ +import { + SubscribeMessage, + WebSocketGateway, + WebSocketServer, +} from '@nestjs/websockets'; +import { Socket, Server } from 'socket.io'; + +import { PrismaService } from '../prisma/prisma.service'; + +import { MessageDto } from './dto/message.dto'; +import { CreateChannelDto, JoinChannelDto } from './dto/Channel.dto'; +@WebSocketGateway({ + cors: { + origin: '*', + }, +}) +export class ChatGateway { + @WebSocketServer() + server: Server; + constructor(private prisma: PrismaService) {} + handleConnection(client: Socket) { + // TODO jwtができたら接続時にdbに保存されてる所属しているチャンネルに全てにclient.joinする + console.log('chat Connection'); + console.log(client.id); + } + + handleDisconnect(client: Socket) { + console.log('chat Disconnection'); + console.log(client.id); + } + @SubscribeMessage('createChannel') + async createChannel(client: Socket, createChannelDto: CreateChannelDto) { + const createdRoom = await this.prisma.chatRoom.create({ + data: { + roomName: createChannelDto.roomName, + }, + }); + await this.prisma.roomMember.create({ + data: { + userId: createChannelDto.userId, + chatRoomId: createdRoom.id, + }, + }); + client.join(createdRoom.id.toString()); + this.server.emit('createChannel', createdRoom); + } + + @SubscribeMessage('joinChannel') + async joinChannel(client: Socket, joinChannelDto: JoinChannelDto) { + const addedUser = await this.prisma.roomMember.create({ + data: { + userId: joinChannelDto.userId, + chatRoomId: joinChannelDto.chatRoomId, + }, + }); + client.join(addedUser.chatRoomId.toString()); + this.server + .to(addedUser.chatRoomId.toString()) + .emit('joinChannel', addedUser); + } + + @SubscribeMessage('sendMessage') + async sendMessage(client: Socket, messageDto: MessageDto) { + const msg = await this.prisma.message.create({ + data: { + content: messageDto.content, + userId: messageDto.userId, + chatRoomId: messageDto.chatRoomId, + }, + }); + const roomMsgs = await this.prisma.message.findMany({ + where: { + chatRoomId: messageDto.chatRoomId, + }, + }); + this.server.to(msg.chatRoomId.toString()).emit('sendMessage', roomMsgs); + } +} diff --git a/backend/src/chat/chat.module.ts b/backend/src/chat/chat.module.ts new file mode 100644 index 000000000..59398df5c --- /dev/null +++ b/backend/src/chat/chat.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; + +import { PrismaModule } from '../prisma/prisma.module'; + +import { ChatService } from './chat.service'; +import { ChatGateway } from './chat.gateway'; +import { ChatController } from './chat.controller'; + +@Module({ + imports: [PrismaModule], + providers: [ChatService, ChatGateway], + controllers: [ChatController], +}) +export class ChatModule {} diff --git a/backend/src/chat/chat.service.spec.ts b/backend/src/chat/chat.service.spec.ts new file mode 100644 index 000000000..60e28ba74 --- /dev/null +++ b/backend/src/chat/chat.service.spec.ts @@ -0,0 +1,19 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { ChatService } from './chat.service'; + +describe('ChatService', () => { + let service: ChatService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ChatService], + }).compile(); + + service = module.get(ChatService); + }); + + test('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/backend/src/chat/chat.service.ts b/backend/src/chat/chat.service.ts new file mode 100644 index 000000000..408edccb1 --- /dev/null +++ b/backend/src/chat/chat.service.ts @@ -0,0 +1,4 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class ChatService {} diff --git a/backend/src/chat/dto/Channel.dto.ts b/backend/src/chat/dto/Channel.dto.ts new file mode 100644 index 000000000..54c089588 --- /dev/null +++ b/backend/src/chat/dto/Channel.dto.ts @@ -0,0 +1,8 @@ +export class JoinChannelDto { + chatRoomId: string; + userId: string; +} +export class CreateChannelDto { + roomName: string; + userId: string; +} diff --git a/backend/src/chat/dto/message.dto.ts b/backend/src/chat/dto/message.dto.ts new file mode 100644 index 000000000..3e4b884b2 --- /dev/null +++ b/backend/src/chat/dto/message.dto.ts @@ -0,0 +1,5 @@ +export class MessageDto { + content: string; + userId: string; + chatRoomId: string; +}