diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..3a175800 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + push: + branches: [ main ] # push 되었을 때, 실행 + +jobs: + cd: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # jdk 21 환경 구성 + - name: set up jdk 21 + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '21' + + # Gradle wrapper 파일 실행 권한주기 + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + # Gradle jib를 통한 이미지 배포 + - name: update image using jib + run: ./gradlew --info jib diff --git a/auth/build.gradle.kts b/auth/build.gradle.kts index c255935c..93c5f609 100644 --- a/auth/build.gradle.kts +++ b/auth/build.gradle.kts @@ -70,8 +70,10 @@ tasks.asciidoctor { dependsOn(tasks.test) } +val hostname = "kpring.duckdns.org" + openapi3 { - setServer("http://localhost/auth") + setServer("http://$hostname/auth") title = "Auth API" description = "API document" version = "0.1.0" @@ -82,11 +84,17 @@ openapi3 { jib { from { image = "eclipse-temurin:21-jre" + platforms { + platform { + architecture = "arm64" + os = "linux" + } + } } to { - image = "localhost:5000/auth-application" + image = "youdong98/kpring-auth-application" setAllowInsecureRegistries(true) - tags = setOf("latest") + tags = setOf("latest", version.toString()) } container { jvmFlags = listOf("-Xms512m", "-Xmx512m") diff --git a/build.gradle.kts b/build.gradle.kts index c700c98d..1fabda5d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,6 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.springframework.boot.gradle.tasks.bundling.BootJar +import java.io.IOException plugins { id("org.springframework.boot") version "3.2.4" @@ -17,7 +18,7 @@ repositories { allprojects { group = "com.sideproject" - version = "0.0.1-SNAPSHOT" + version = "git rev-parse --short=8 HEAD".runCommand(workingDir = rootDir) repositories { mavenCentral() @@ -53,3 +54,25 @@ subprojects { debug.set(true) } } + +/** + * cli 실행 결과를 반환한기 위한 함수 + */ +fun String.runCommand( + workingDir: File = File("."), + timeoutAmount: Long = 60, + timeoutUnit: TimeUnit = TimeUnit.SECONDS, +): String = + ProcessBuilder(split("\\s(?=(?:[^'\"`]*(['\"`])[^'\"`]*\\1)*[^'\"`]*$)".toRegex())) + .directory(workingDir) + .redirectOutput(ProcessBuilder.Redirect.PIPE) + .redirectError(ProcessBuilder.Redirect.PIPE) + .start() + .apply { waitFor(timeoutAmount, timeoutUnit) } + .run { + val error = errorStream.bufferedReader().readText().trim() + if (error.isNotEmpty()) { + throw IOException(error) + } + inputStream.bufferedReader().readText().trim() + } diff --git a/chat/build.gradle.kts b/chat/build.gradle.kts index 019abc56..6f1757a4 100644 --- a/chat/build.gradle.kts +++ b/chat/build.gradle.kts @@ -41,6 +41,9 @@ dependencies { implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.jetbrains.kotlin:kotlin-reflect") + // non-blocking redis + implementation("org.springframework.boot:spring-boot-starter-data-redis") + // test testImplementation(project(":test")) testImplementation("org.springframework.boot:spring-boot-starter-test") diff --git a/chat/compose.yml b/chat/compose.yml index b7c7023d..66c48662 100644 --- a/chat/compose.yml +++ b/chat/compose.yml @@ -3,8 +3,17 @@ services: image: mongo:latest container_name: mongo ports: - - "27017:27017" + - "27018:27017" environment: - MONGO_INITDB_ROOT_USERNAME: root - MONGO_INITDB_ROOT_PASSWORD: 58155815 - MONGO_INITDB_DATABASE: mongodb \ No newline at end of file + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: testpassword1234 + MONGO_INITDB_DATABASE: mongodb + + redis: + image: redis:alpine + container_name: redis_link + ports: + - "6379:6379" + environment: + - REDIS_PASSWORD = "testpassword1234" + command: [ "redis-server","--requirepass","${REDIS_PASSWORD}" ] \ No newline at end of file diff --git a/chat/src/main/kotlin/kpring/chat/ChatApplication.kt b/chat/src/main/kotlin/kpring/chat/ChatApplication.kt index 18906f4a..7e6d9407 100644 --- a/chat/src/main/kotlin/kpring/chat/ChatApplication.kt +++ b/chat/src/main/kotlin/kpring/chat/ChatApplication.kt @@ -2,7 +2,9 @@ package kpring.chat import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication +import org.springframework.data.mongodb.config.EnableMongoAuditing +@EnableMongoAuditing @SpringBootApplication class ChatApplication diff --git a/chat/src/main/kotlin/kpring/chat/chat/api/v1/ChatController.kt b/chat/src/main/kotlin/kpring/chat/chat/api/v1/ChatController.kt index bb2caffc..8bbdd64f 100644 --- a/chat/src/main/kotlin/kpring/chat/chat/api/v1/ChatController.kt +++ b/chat/src/main/kotlin/kpring/chat/chat/api/v1/ChatController.kt @@ -6,6 +6,7 @@ import kpring.chat.global.exception.GlobalException import kpring.core.auth.client.AuthClient import kpring.core.chat.chat.dto.request.ChatType import kpring.core.chat.chat.dto.request.CreateChatRequest +import kpring.core.chat.chat.dto.request.UpdateChatRequest import kpring.core.global.dto.response.ApiResponse import kpring.core.server.client.ServerClient import kpring.core.server.dto.request.GetServerCondition @@ -18,8 +19,8 @@ import org.springframework.web.bind.annotation.* @RequestMapping("/api/v1") class ChatController( private val chatService: ChatService, - val authClient: AuthClient, - val serverClient: ServerClient, + private val authClient: AuthClient, + private val serverClient: ServerClient, ) { @PostMapping("/chat") fun createChat( @@ -39,7 +40,7 @@ class ChatController( @GetMapping("/chat") fun getChats( - @RequestParam("type") type: String, + @RequestParam("type") type: ChatType, @RequestParam("id") id: String, @RequestParam("page") page: Int, @RequestHeader("Authorization") token: String, @@ -47,16 +48,44 @@ class ChatController( val userId = authClient.getTokenInfo(token).data!!.userId val result = when (type) { - ChatType.Room.toString() -> chatService.getRoomChats(id, userId, page) - ChatType.Server.toString() -> + ChatType.Room -> chatService.getRoomChats(id, userId, page) + ChatType.Server -> chatService.getServerChats( id, userId, page, serverClient.getServerList(token, GetServerCondition()).body!!.data!!, ) - else -> throw GlobalException(ErrorCode.INVALID_CHAT_TYPE) } return ResponseEntity.ok().body(ApiResponse(data = result, status = 200)) } + + @PatchMapping("/chat") + fun updateChat( + @Validated @RequestBody request: UpdateChatRequest, + @RequestHeader("Authorization") token: String, + ): ResponseEntity<*> { + val userId = authClient.getTokenInfo(token).data!!.userId + val result = + when (request.type) { + ChatType.Room -> chatService.updateRoomChat(request, userId) + ChatType.Server -> chatService.updateServerChat(request, userId) + } + return ResponseEntity.ok().body(ApiResponse(status = 200)) + } + + @DeleteMapping("/chat/{chatId}") + fun deleteChat( + @RequestParam("type") type: ChatType, + @PathVariable("chatId") chatId: String, + @RequestHeader("Authorization") token: String, + ): ResponseEntity<*> { + val userId = authClient.getTokenInfo(token).data!!.userId + val result = + when (type) { + ChatType.Room -> chatService.deleteRoomChat(chatId, userId) + ChatType.Server -> chatService.deleteServerChat(chatId, userId) + } + return ResponseEntity.ok().body(ApiResponse(status = 200)) + } } diff --git a/chat/src/main/kotlin/kpring/chat/chat/model/RoomChat.kt b/chat/src/main/kotlin/kpring/chat/chat/model/Chat.kt similarity index 67% rename from chat/src/main/kotlin/kpring/chat/chat/model/RoomChat.kt rename to chat/src/main/kotlin/kpring/chat/chat/model/Chat.kt index c11c2290..0d295218 100644 --- a/chat/src/main/kotlin/kpring/chat/chat/model/RoomChat.kt +++ b/chat/src/main/kotlin/kpring/chat/chat/model/Chat.kt @@ -7,15 +7,19 @@ import org.springframework.data.mongodb.core.mapping.Document @NoArg @Document(collection = "chats") -class RoomChat( +class Chat( + @Id + val id: String? = null, val userId: String, - val roomId: String, - val content: String, + // roomId or serverId + val contextId: String, + var content: String, ) : BaseTime() { - @Id - var id: String? = null - fun isEdited(): Boolean { return !createdAt.equals(updatedAt) } + + fun updateContent(content: String) { + this.content = content + } } diff --git a/chat/src/main/kotlin/kpring/chat/chat/model/ServerChat.kt b/chat/src/main/kotlin/kpring/chat/chat/model/ServerChat.kt deleted file mode 100644 index 5eef055b..00000000 --- a/chat/src/main/kotlin/kpring/chat/chat/model/ServerChat.kt +++ /dev/null @@ -1,21 +0,0 @@ -package kpring.chat.chat.model - -import kpring.chat.NoArg -import kpring.chat.global.model.BaseTime -import org.springframework.data.annotation.Id -import org.springframework.data.mongodb.core.mapping.Document - -@NoArg -@Document(collection = "server_chats") -class ServerChat( - val userId: String, - val serverId: String, - val content: String, -) : BaseTime() { - @Id - var id: String? = null - - fun isEdited(): Boolean { - return !createdAt.equals(updatedAt) - } -} diff --git a/chat/src/main/kotlin/kpring/chat/chat/repository/RoomChatRepository.kt b/chat/src/main/kotlin/kpring/chat/chat/repository/RoomChatRepository.kt index f8506d1e..53dc1d2f 100644 --- a/chat/src/main/kotlin/kpring/chat/chat/repository/RoomChatRepository.kt +++ b/chat/src/main/kotlin/kpring/chat/chat/repository/RoomChatRepository.kt @@ -1,15 +1,15 @@ package kpring.chat.chat.repository -import kpring.chat.chat.model.RoomChat +import kpring.chat.chat.model.Chat import org.springframework.data.domain.Pageable import org.springframework.data.mongodb.repository.MongoRepository import org.springframework.data.querydsl.QuerydslPredicateExecutor import org.springframework.stereotype.Repository @Repository -interface RoomChatRepository : MongoRepository, QuerydslPredicateExecutor { - fun findAllByRoomId( - roomId: String, +interface RoomChatRepository : MongoRepository, QuerydslPredicateExecutor { + fun findAllByContextId( + contextId: String, pageable: Pageable, - ): List + ): List } diff --git a/chat/src/main/kotlin/kpring/chat/chat/repository/ServerChatRepository.kt b/chat/src/main/kotlin/kpring/chat/chat/repository/ServerChatRepository.kt index 7bc1ad42..212d8c32 100644 --- a/chat/src/main/kotlin/kpring/chat/chat/repository/ServerChatRepository.kt +++ b/chat/src/main/kotlin/kpring/chat/chat/repository/ServerChatRepository.kt @@ -1,15 +1,15 @@ package kpring.chat.chat.repository -import kpring.chat.chat.model.ServerChat +import kpring.chat.chat.model.Chat import org.springframework.data.domain.Pageable import org.springframework.data.mongodb.repository.MongoRepository import org.springframework.data.querydsl.QuerydslPredicateExecutor import org.springframework.stereotype.Repository @Repository -interface ServerChatRepository : MongoRepository, QuerydslPredicateExecutor { - fun findAllByServerId( +interface ServerChatRepository : MongoRepository, QuerydslPredicateExecutor { + fun findAllByContextId( serverId: String, pageable: Pageable, - ): List + ): List } diff --git a/chat/src/main/kotlin/kpring/chat/chat/service/ChatService.kt b/chat/src/main/kotlin/kpring/chat/chat/service/ChatService.kt index 81412cae..518ff7bf 100644 --- a/chat/src/main/kotlin/kpring/chat/chat/service/ChatService.kt +++ b/chat/src/main/kotlin/kpring/chat/chat/service/ChatService.kt @@ -1,13 +1,13 @@ package kpring.chat.chat.service -import kpring.chat.chat.model.RoomChat -import kpring.chat.chat.model.ServerChat +import kpring.chat.chat.model.Chat import kpring.chat.chat.repository.RoomChatRepository import kpring.chat.chat.repository.ServerChatRepository import kpring.chat.chatroom.repository.ChatRoomRepository import kpring.chat.global.exception.ErrorCode import kpring.chat.global.exception.GlobalException import kpring.core.chat.chat.dto.request.CreateChatRequest +import kpring.core.chat.chat.dto.request.UpdateChatRequest import kpring.core.chat.chat.dto.response.ChatResponse import kpring.core.server.dto.ServerSimpleInfo import org.springframework.beans.factory.annotation.Value @@ -28,9 +28,9 @@ class ChatService( ): Boolean { val chat = roomChatRepository.save( - RoomChat( + Chat( userId = userId, - roomId = request.id, + contextId = request.id, content = request.content, ), ) @@ -43,9 +43,9 @@ class ChatService( ): Boolean { val chat = serverChatRepository.save( - ServerChat( + Chat( userId = userId, - serverId = request.id, + contextId = request.id, content = request.content, ), ) @@ -60,9 +60,9 @@ class ChatService( verifyChatRoomAccess(chatRoomId, userId) val pageable: Pageable = PageRequest.of(page, pageSize) - val roomChats: List = roomChatRepository.findAllByRoomId(chatRoomId, pageable) + val roomChats: List = roomChatRepository.findAllByContextId(chatRoomId, pageable) - return convertRoomChatsToResponses(roomChats) + return convertChatsToResponses(roomChats) } fun getServerChats( @@ -74,9 +74,60 @@ class ChatService( verifyServerAccess(servers, serverId) val pageable: Pageable = PageRequest.of(page, pageSize) - val chats: List = serverChatRepository.findAllByServerId(serverId, pageable) + val chats: List = serverChatRepository.findAllByContextId(serverId, pageable) - return convertServerChatsToResponses(chats) + return convertChatsToResponses(chats) + } + + fun updateRoomChat( + request: UpdateChatRequest, + userId: String, + ): Boolean { + val chat = roomChatRepository.findById(request.id).orElseThrow { GlobalException(ErrorCode.CHAT_NOT_FOUND) } + verifyIfAuthor(userId, chat) + chat.updateContent(request.content) + roomChatRepository.save(chat) + return true + } + + fun updateServerChat( + request: UpdateChatRequest, + userId: String, + ): Boolean { + val chat = serverChatRepository.findById(request.id).orElseThrow { GlobalException(ErrorCode.CHAT_NOT_FOUND) } + verifyIfAuthor(userId, chat) + chat.updateContent(request.content) + serverChatRepository.save(chat) + return true + } + + fun deleteRoomChat( + chatId: String, + userId: String, + ): Boolean { + val chat = roomChatRepository.findById(chatId).orElseThrow { GlobalException(ErrorCode.CHAT_NOT_FOUND) } + verifyIfAuthor(userId, chat) + roomChatRepository.delete(chat) + return true + } + + fun deleteServerChat( + chatId: String, + userId: String, + ): Boolean { + val chat = serverChatRepository.findById(chatId).orElseThrow { GlobalException(ErrorCode.CHAT_NOT_FOUND) } + verifyIfAuthor(userId, chat) + serverChatRepository.delete(chat) + return true + } + + private fun verifyIfAuthor( + userId: String, + chat: Chat, + ) { + if (userId != chat.userId) { + throw GlobalException(ErrorCode.FORBIDDEN_CHAT) + } } private fun verifyServerAccess( @@ -100,15 +151,7 @@ class ChatService( } } - private fun convertRoomChatsToResponses(roomChats: List): List { - val chatResponse = - roomChats.map { chat -> - ChatResponse(chat.id!!, chat.isEdited(), chat.createdAt.toString(), chat.content) - } - return chatResponse - } - - private fun convertServerChatsToResponses(chats: List): List { + private fun convertChatsToResponses(chats: List): List { val chatResponse = chats.map { chat -> ChatResponse(chat.id!!, chat.isEdited(), chat.createdAt.toString(), chat.content) diff --git a/chat/src/main/kotlin/kpring/chat/chatroom/api/v1/ChatRoomController.kt b/chat/src/main/kotlin/kpring/chat/chatroom/api/v1/ChatRoomController.kt index 85829ec3..fc061c8f 100644 --- a/chat/src/main/kotlin/kpring/chat/chatroom/api/v1/ChatRoomController.kt +++ b/chat/src/main/kotlin/kpring/chat/chatroom/api/v1/ChatRoomController.kt @@ -3,6 +3,7 @@ package kpring.chat.chatroom.api.v1 import kpring.chat.chatroom.service.ChatRoomService import kpring.core.auth.client.AuthClient import kpring.core.chat.chatroom.dto.request.CreateChatRoomRequest +import kpring.core.global.dto.response.ApiResponse import org.springframework.http.ResponseEntity import org.springframework.validation.annotation.Validated import org.springframework.web.bind.annotation.* @@ -34,4 +35,24 @@ class ChatRoomController( val result = chatRoomService.exitChatRoom(chatRoomId, userId) return ResponseEntity.ok().body(result) } + + @GetMapping("/chatroom/{chatRoomId}/invite") + fun getChatRoomInvitation( + @PathVariable("chatRoomId") chatRoomId: String, + @RequestHeader("Authorization") token: String, + ): ResponseEntity<*> { + val userId = authClient.getTokenInfo(token).data!!.userId + val result = chatRoomService.getChatRoomInvitation(chatRoomId, userId) + return ResponseEntity.ok().body(ApiResponse(data = result)) + } + + @PatchMapping("/chatroom/{code}/join") + fun joinChatRoom( + @PathVariable("code") code: String, + @RequestHeader("Authorization") token: String, + ): ResponseEntity<*> { + val userId = authClient.getTokenInfo(token).data!!.userId + val result = chatRoomService.joinChatRoom(code, userId) + return ResponseEntity.ok().body(ApiResponse(status = 200)) + } } diff --git a/chat/src/main/kotlin/kpring/chat/chatroom/dto/InvitationInfo.kt b/chat/src/main/kotlin/kpring/chat/chatroom/dto/InvitationInfo.kt new file mode 100644 index 00000000..3e2230cf --- /dev/null +++ b/chat/src/main/kotlin/kpring/chat/chatroom/dto/InvitationInfo.kt @@ -0,0 +1,7 @@ +package kpring.chat.chatroom.dto + +data class InvitationInfo( + val userId: String, + val chatRoomId: String, + val code: String, +) diff --git a/chat/src/main/kotlin/kpring/chat/chatroom/model/ChatRoom.kt b/chat/src/main/kotlin/kpring/chat/chatroom/model/ChatRoom.kt index c309123f..d2ee241d 100644 --- a/chat/src/main/kotlin/kpring/chat/chatroom/model/ChatRoom.kt +++ b/chat/src/main/kotlin/kpring/chat/chatroom/model/ChatRoom.kt @@ -1,17 +1,17 @@ package kpring.chat.chatroom.model +import kpring.chat.NoArg import kpring.chat.global.model.BaseTime import org.springframework.data.annotation.Id import org.springframework.data.mongodb.core.mapping.Document +@NoArg @Document(collection = "chatrooms") -class ChatRoom : BaseTime() { - @Id - var id: String? = null - - var members: MutableList = mutableListOf() - - fun getUsers(): List { +class ChatRoom( + @Id val id: String? = null, + val members: MutableSet = mutableSetOf(), +) : BaseTime() { + fun getUsers(): Set { return members } @@ -19,6 +19,10 @@ class ChatRoom : BaseTime() { members.addAll(list) } + fun addUser(userId: String) { + members.add(userId) + } + fun removeUser(userId: String) { members.remove(userId) } diff --git a/chat/src/main/kotlin/kpring/chat/chatroom/repository/InvitationRepository.kt b/chat/src/main/kotlin/kpring/chat/chatroom/repository/InvitationRepository.kt new file mode 100644 index 00000000..5e1fd019 --- /dev/null +++ b/chat/src/main/kotlin/kpring/chat/chatroom/repository/InvitationRepository.kt @@ -0,0 +1,28 @@ +package kpring.chat.chatroom.repository + +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.data.redis.core.ValueOperations +import org.springframework.stereotype.Component +import java.time.Duration + +@Component +class InvitationRepository( + private val redisTemplate: RedisTemplate, +) { + fun getValue(key: String): String? { + return redisTemplate.opsForValue().get(key) + } + + fun setValueAndExpiration( + key: String, + value: String, + expiration: Duration, + ) { + val ops: ValueOperations = redisTemplate.opsForValue() + ops.set(key, value, expiration) + } + + fun getExpiration(key: String): Long { + return redisTemplate.getExpire(key) + } +} diff --git a/chat/src/main/kotlin/kpring/chat/chatroom/service/ChatRoomService.kt b/chat/src/main/kotlin/kpring/chat/chatroom/service/ChatRoomService.kt index f277d55f..b301ec2f 100644 --- a/chat/src/main/kotlin/kpring/chat/chatroom/service/ChatRoomService.kt +++ b/chat/src/main/kotlin/kpring/chat/chatroom/service/ChatRoomService.kt @@ -1,21 +1,24 @@ package kpring.chat.chatroom.service +import kpring.chat.chatroom.dto.InvitationInfo import kpring.chat.chatroom.model.ChatRoom import kpring.chat.chatroom.repository.ChatRoomRepository import kpring.chat.global.exception.ErrorCode import kpring.chat.global.exception.GlobalException +import kpring.core.chat.chat.dto.response.InvitationResponse import kpring.core.chat.chatroom.dto.request.CreateChatRoomRequest import org.springframework.stereotype.Service @Service class ChatRoomService( private val chatRoomRepository: ChatRoomRepository, + private val invitationService: InvitationService, ) { fun createChatRoom( request: CreateChatRoomRequest, userId: String, ) { - val chatRoom = ChatRoom() + val chatRoom = ChatRoom(members = mutableSetOf(userId)) chatRoom.addUsers(request.users) chatRoomRepository.save(chatRoom) } @@ -30,7 +33,38 @@ class ChatRoomService( chatRoomRepository.save(chatRoom) } - fun verifyChatRoomAccess( + fun getChatRoomInvitation( + chatRoomId: String, + userId: String, + ): InvitationResponse { + verifyChatRoomAccess(chatRoomId, userId) + var code = invitationService.getInvitation(userId, chatRoomId) + if (code == null) { + code = invitationService.setInvitation(userId, chatRoomId) + } + val encodedCode = invitationService.generateKeyAndCode(userId, chatRoomId, code) + return InvitationResponse(encodedCode) + } + + fun joinChatRoom( + code: String, + userId: String, + ): Boolean { + val invitationInfo = invitationService.getInvitationInfoFromCode(code) + verifyInvitationExistence(invitationInfo) + val chatRoom = getChatRoom(invitationInfo.chatRoomId) + chatRoom.addUser(userId) + chatRoomRepository.save(chatRoom) + return true + } + + private fun verifyInvitationExistence(invitationInfo: InvitationInfo) { + if (invitationInfo.code != invitationService.getInvitation(invitationInfo.userId, invitationInfo.chatRoomId)) { + throw GlobalException(ErrorCode.EXPIRED_INVITATION) + } + } + + private fun verifyChatRoomAccess( chatRoomId: String, userId: String, ) { @@ -39,7 +73,7 @@ class ChatRoomService( } } - fun getChatRoom(chatRoomId: String): ChatRoom { + private fun getChatRoom(chatRoomId: String): ChatRoom { val chatRoom: ChatRoom = chatRoomRepository.findById(chatRoomId).orElseThrow { GlobalException(ErrorCode.CHATROOM_NOT_FOUND) } return chatRoom diff --git a/chat/src/main/kotlin/kpring/chat/chatroom/service/InvitationService.kt b/chat/src/main/kotlin/kpring/chat/chatroom/service/InvitationService.kt new file mode 100644 index 00000000..fef49d77 --- /dev/null +++ b/chat/src/main/kotlin/kpring/chat/chatroom/service/InvitationService.kt @@ -0,0 +1,74 @@ +package kpring.chat.chatroom.service + +import kpring.chat.chatroom.dto.InvitationInfo +import kpring.chat.chatroom.repository.InvitationRepository +import kpring.chat.global.config.ChatRoomProperty +import org.springframework.stereotype.Service +import java.nio.charset.StandardCharsets +import java.util.* + +@Service +class InvitationService( + private val invitationRepository: InvitationRepository, + private val chatRoomProperty: ChatRoomProperty, +) { + fun getInvitation( + userId: String, + chatRoomId: String, + ): String? { + val key = generateKey(userId, chatRoomId) + return invitationRepository.getValue(key) + } + + fun setInvitation( + userId: String, + chatRoomId: String, + ): String { + val key = generateKey(userId, chatRoomId) + val value = generateValue() + invitationRepository.setValueAndExpiration(key, value, chatRoomProperty.getExpiration()) + return value + } + + fun generateKeyAndCode( + userId: String, + chatRoomId: String, + code: String, + ): String { + val key = generateKey(userId, chatRoomId) + return encodeCode(key, code) + } + + private fun decodeCode(encodedString: String): String { + val decodedBytes = Base64.getUrlDecoder().decode(encodedString) + val decodedString = String(decodedBytes, StandardCharsets.UTF_8) + return decodedString + } + + fun getInvitationInfoFromCode(code: String): InvitationInfo { + val decodedCode = decodeCode(code) // decode encoded code + val keyAndValue: List = decodedCode.split(",") // a code is consisted of key,value + val userIdAndChatRoomId: List = keyAndValue[0].split(":") // a key is consisted of userId:chatRoomId + return InvitationInfo(userId = userIdAndChatRoomId[0], chatRoomId = userIdAndChatRoomId[1], code = keyAndValue[1]) + } + + private fun encodeCode( + key: String, + value: String, + ): String { + val combinedString = "$key,$value" + val encodedString = Base64.getUrlEncoder().withoutPadding().encodeToString(combinedString.toByteArray(StandardCharsets.UTF_8)) + return encodedString + } + + private fun generateKey( + userId: String, + chatRoomId: String, + ): String { + return "$userId:$chatRoomId" + } + + private fun generateValue(): String { + return UUID.randomUUID().toString() + } +} diff --git a/chat/src/main/kotlin/kpring/chat/global/config/ChatRoomProperty.kt b/chat/src/main/kotlin/kpring/chat/global/config/ChatRoomProperty.kt new file mode 100644 index 00000000..4207c97c --- /dev/null +++ b/chat/src/main/kotlin/kpring/chat/global/config/ChatRoomProperty.kt @@ -0,0 +1,19 @@ +package kpring.chat.global.config + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.context.annotation.Configuration +import java.time.Duration + +@Configuration +@ConfigurationProperties(prefix = "chatroom") +class ChatRoomProperty { + private lateinit var expiration: Duration + + fun getExpiration(): Duration { + return expiration + } + + fun setExpiration(expiration: Duration) { + this.expiration = expiration + } +} diff --git a/chat/src/main/kotlin/kpring/chat/global/config/RedisConfig.kt b/chat/src/main/kotlin/kpring/chat/global/config/RedisConfig.kt new file mode 100644 index 00000000..07a6e51e --- /dev/null +++ b/chat/src/main/kotlin/kpring/chat/global/config/RedisConfig.kt @@ -0,0 +1,16 @@ +package kpring.chat.global.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.data.redis.connection.RedisConnectionFactory +import org.springframework.data.redis.core.RedisTemplate + +@Configuration +class RedisConfig { + @Bean + fun redisTemplate(connectionFactory: RedisConnectionFactory): RedisTemplate<*, *> { + val template = RedisTemplate() + template.connectionFactory = connectionFactory + return template + } +} diff --git a/chat/src/main/kotlin/kpring/chat/global/exception/ErrorCode.kt b/chat/src/main/kotlin/kpring/chat/global/exception/ErrorCode.kt index 09798d7a..258e88a8 100644 --- a/chat/src/main/kotlin/kpring/chat/global/exception/ErrorCode.kt +++ b/chat/src/main/kotlin/kpring/chat/global/exception/ErrorCode.kt @@ -9,7 +9,15 @@ enum class ErrorCode(val httpStatus: Int, val message: String) { // 403 FORBIDDEN_CHATROOM(HttpStatus.FORBIDDEN.value(), "접근이 제한된 채팅방 입니다"), FORBIDDEN_SERVER(HttpStatus.FORBIDDEN.value(), "접근이 제한된 서버 입니다"), + FORBIDDEN_CHAT(HttpStatus.FORBIDDEN.value(), "접근이 제한된 채팅 입니다"), // 404 CHATROOM_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "해당 id로 chatroom을 찾을 수 없습니다"), + CHAT_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "해당 id로 채팅을 찾을 수 없습니다"), + + // 410 + EXPIRED_INVITATION(HttpStatus.GONE.value(), "만료된 Invitation입니다."), + + // 500 + INVITATION_LINK_SAVE_FAILURE(HttpStatus.INTERNAL_SERVER_ERROR.value(), "Invitation Code가 저장되지 않았습니다"), } diff --git a/chat/src/main/resources/application.yml b/chat/src/main/resources/application.yml index 0590f9e8..c5bb0569 100644 --- a/chat/src/main/resources/application.yml +++ b/chat/src/main/resources/application.yml @@ -1,6 +1,8 @@ spring: application: name: chat + main: + allow-bean-definition-overriding: true data: mongodb: host: localhost @@ -10,13 +12,24 @@ spring: database: mongodb authentication-database: admin authSource: admin + repositories: + enabled: true + + redis: + host: localhost + port: 6379 + password: testpassword1234 server: port: 8081 auth: - url: "http://localhost:30000/" + url: "http://localhost:30001/" url: - server: "http://localhost:8080/" + server: "http://localhost:8080/" + page: size: 100 +chatroom: + expiration: 1d + baseurl: "http://localhost:8081/" diff --git a/chat/src/test/kotlin/kpring/chat/chat/ChatServiceTest.kt b/chat/src/test/kotlin/kpring/chat/chat/ChatServiceTest.kt index 4ae96ba3..b28b82c5 100644 --- a/chat/src/test/kotlin/kpring/chat/chat/ChatServiceTest.kt +++ b/chat/src/test/kotlin/kpring/chat/chat/ChatServiceTest.kt @@ -3,10 +3,8 @@ package kpring.chat.chat import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import kpring.chat.chat.model.RoomChat +import io.mockk.* +import kpring.chat.chat.model.Chat import kpring.chat.chat.repository.RoomChatRepository import kpring.chat.chat.repository.ServerChatRepository import kpring.chat.chat.service.ChatService @@ -18,8 +16,10 @@ import kpring.chat.global.exception.ErrorCode import kpring.chat.global.exception.GlobalException import kpring.core.chat.chat.dto.request.ChatType import kpring.core.chat.chat.dto.request.CreateChatRequest +import kpring.core.chat.chat.dto.request.UpdateChatRequest import kpring.core.server.dto.ServerSimpleInfo import org.springframework.beans.factory.annotation.Value +import java.util.* class ChatServiceTest( @Value("\${page.size}") val pageSize: Int = 100, @@ -33,7 +33,8 @@ class ChatServiceTest( // Given val request = CreateChatRequest(id = ChatRoomTest.TEST_ROOM_ID, content = ChatTest.CONTENT, type = ChatType.Room) val userId = CommonTest.TEST_USER_ID - val roomChat = RoomChat(userId, request.id, request.content) + val chatId = ChatTest.TEST_CHAT_ID + val roomChat = Chat(chatId, userId, request.id, request.content) every { roomChatRepository.save(any()) } returns roomChat // When @@ -85,4 +86,159 @@ class ChatServiceTest( val errorCode = errorCodeField.get(exception) as ErrorCode errorCode shouldBe ErrorCode.FORBIDDEN_SERVER } + + test("updateRoomChat 은 권한이 없는 사용자에게 에러 발생") { + // Given + val roomId = "test_room_id" + val chatId = "test_chat_id" + val contentUpdate = "content update" + val request = UpdateChatRequest(id = chatId, type = ChatType.Room, content = contentUpdate) + val anotherUserId = CommonTest.TEST_ANOTHER_USER_ID + val userId = CommonTest.TEST_USER_ID + val chat = + Chat( + chatId, + userId, + roomId, + "content", + ) + + every { roomChatRepository.findById(request.id) } returns Optional.of(chat) + + // When & Then + val exception = + shouldThrow { + chatService.updateRoomChat(request, anotherUserId) + } + val errorCodeField = GlobalException::class.java.getDeclaredField("errorCode") + errorCodeField.isAccessible = true + val errorCode = errorCodeField.get(exception) as ErrorCode + errorCode shouldBe ErrorCode.FORBIDDEN_CHAT + } + + test("updateServerChat 은 권한이 없는 사용자에게 에러 발생") { + // Given + val serverId = "test_server_id" + val chatId = "test_chat_id" + val contentUpdate = "content update" + val request = UpdateChatRequest(id = chatId, type = ChatType.Server, content = contentUpdate) + val anotherUserId = CommonTest.TEST_ANOTHER_USER_ID + val userId = CommonTest.TEST_USER_ID + val chat = + Chat( + chatId, + userId, + serverId, + "content", + ) + + every { serverChatRepository.findById(request.id) } returns Optional.of(chat) + + // When & Then + val exception = + shouldThrow { + chatService.updateServerChat(request, anotherUserId) + } + val errorCodeField = GlobalException::class.java.getDeclaredField("errorCode") + errorCodeField.isAccessible = true + val errorCode = errorCodeField.get(exception) as ErrorCode + errorCode shouldBe ErrorCode.FORBIDDEN_CHAT + } + + test("updateRoomChat 은 권한이 있는 사용자의 요청에 따라 Chat 수정") { + // Given + val roomId = "test_room_id" + val chatId = "test_chat_id" + val contentUpdate = "content update" + val request = UpdateChatRequest(id = chatId, type = ChatType.Room, content = contentUpdate) + val userId = CommonTest.TEST_USER_ID + val chat = + Chat( + chatId, + userId, + roomId, + "content", + ) + + every { roomChatRepository.findById(request.id) } returns Optional.of(chat) + + // When + val result = chatService.updateRoomChat(request, userId) + + // Then + result shouldBe true + verify { roomChatRepository.save(any()) } + } + + test("updateServerChat 은 권한이 있는 사용자의 요청에 따라 Chat 수정") { + // Given + val serverId = "test_server_id" + val chatId = "test_chat_id" + val contentUpdate = "content update" + val request = UpdateChatRequest(id = chatId, type = ChatType.Server, content = contentUpdate) + val userId = CommonTest.TEST_USER_ID + val chat = + Chat( + chatId, + userId, + serverId, + "content", + ) + val updated = + + every { serverChatRepository.findById(request.id) } returns Optional.of(chat) + + // When + val result = chatService.updateRoomChat(request, userId) + + // Then + result shouldBe true + verify { roomChatRepository.save(any()) } + } + + test("deleteServerChat 은 권한이 있는 사용자의 요청에 따라 Chat 삭제") { + // Given + val serverId = "test_server_id" + val chatId = "test_chat_id" + val userId = CommonTest.TEST_USER_ID + val chat = + Chat( + id = chatId, + userId = userId, + contextId = serverId, + content = "content", + ) + + every { serverChatRepository.findById(chatId) } returns Optional.of(chat) + every { serverChatRepository.delete(chat) } just Runs + + // When + val result = chatService.deleteServerChat(chatId, userId) + + // Then + result shouldBe true + } + + test("deleteRoomChat 은 권한이 있는 사용자의 요청에 따라 Chat 삭제") { + // Given + val roomId = "test_room_id" + val chatId = "test_chat_id" + val userId = CommonTest.TEST_USER_ID + val chat = + Chat( + id = chatId, + userId = userId, + contextId = roomId, + content = "content", + ) + + every { roomChatRepository.findById(chatId) } returns Optional.of(chat) + every { roomChatRepository.delete(chat) } just Runs + + // When + val result = chatService.deleteRoomChat(chatId, userId) + + // Then + result shouldBe true + } }) diff --git a/chat/src/test/kotlin/kpring/chat/chat/api/v1/ChatControllerTest.kt b/chat/src/test/kotlin/kpring/chat/chat/api/v1/ChatControllerTest.kt index 77a71e77..a665c1ca 100644 --- a/chat/src/test/kotlin/kpring/chat/chat/api/v1/ChatControllerTest.kt +++ b/chat/src/test/kotlin/kpring/chat/chat/api/v1/ChatControllerTest.kt @@ -7,12 +7,15 @@ import io.mockk.every import io.mockk.junit5.MockKExtension import kpring.chat.chat.service.ChatService import kpring.chat.global.ChatRoomTest +import kpring.chat.global.ChatTest import kpring.chat.global.CommonTest +import kpring.chat.global.config.TestMongoConfig import kpring.core.auth.client.AuthClient import kpring.core.auth.dto.response.TokenInfo import kpring.core.auth.enums.TokenType import kpring.core.chat.chat.dto.request.ChatType import kpring.core.chat.chat.dto.request.CreateChatRequest +import kpring.core.chat.chat.dto.request.UpdateChatRequest import kpring.core.chat.chat.dto.response.ChatResponse import kpring.core.global.dto.response.ApiResponse import kpring.core.server.client.ServerClient @@ -22,6 +25,7 @@ import kpring.test.restdoc.json.JsonDataType import kpring.test.web.URLBuilder import org.junit.jupiter.api.extension.ExtendWith import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.context.annotation.Import import org.springframework.http.ResponseEntity import org.springframework.restdocs.ManualRestDocumentation import org.springframework.restdocs.RestDocumentationExtension @@ -37,6 +41,7 @@ import java.time.LocalDateTime @ExtendWith(RestDocumentationExtension::class) @ExtendWith(SpringExtension::class) @ExtendWith(MockKExtension::class) +@Import(TestMongoConfig::class) class ChatControllerTest( private val om: ObjectMapper, webContext: WebApplicationContext, @@ -91,6 +96,7 @@ class ChatControllerTest( .bodyValue(request) .exchange() + // Then val docs = result .expectStatus() @@ -98,7 +104,6 @@ class ChatControllerTest( .expectBody() .json(om.writeValueAsString(ApiResponse(data = null, status = 201))) - // Then docs.restDoc( identifier = "create_chat_201", description = "채팅 생성 api", @@ -168,6 +173,7 @@ class ChatControllerTest( .header("Authorization", "Bearer mock_token") .exchange() + // Then val docs = result .expectStatus() @@ -175,7 +181,6 @@ class ChatControllerTest( .expectBody() .json(om.writeValueAsString(ApiResponse(data = data))) - // Then docs.restDoc( identifier = "get_server_chats_200", description = "서버 채팅 조회 api", @@ -248,6 +253,7 @@ class ChatControllerTest( .header("Authorization", "Bearer mock_token") .exchange() + // Then val docs = result .expectStatus() @@ -255,7 +261,6 @@ class ChatControllerTest( .expectBody() .json(om.writeValueAsString(ApiResponse(data = data))) - // Then docs.restDoc( identifier = "get_room_chats_200", description = "채팅방 채팅 조회 api", @@ -280,4 +285,247 @@ class ChatControllerTest( } } } + + describe("PATCH /api/v1/chat : updateChat api test") { + + val url = "/api/v1/chat" + it("updateRoomChat api test") { + + // Given + val roomId = "test_room_id" + val content = "edit test" + val request = UpdateChatRequest(id = roomId, type = ChatType.Room, content = content) + val userId = CommonTest.TEST_USER_ID + + every { authClient.getTokenInfo(any()) } returns + ApiResponse( + data = + TokenInfo( + type = TokenType.ACCESS, userId = userId, + ), + ) + + every { + chatService.updateRoomChat( + request, + CommonTest.TEST_USER_ID, + ) + } returns true + + // When + val result = + webTestClient.patch().uri(url) + .bodyValue(request) + .header("Authorization", "Bearer mock_token") + .exchange() + + // Then + val docs = + result + .expectStatus() + .isOk + .expectBody() + .json(om.writeValueAsString(ApiResponse(status = 200))) + + docs.restDoc( + identifier = "update_chats_200", + description = "채팅방 채팅 업데이트 api", + ) { + response { + body { + "status" type JsonDataType.Integers mean "상태 코드" + } + } + } + } + it("updateServerChat api test") { + + // Given + val serverId = "test_server_id" + val content = "edit test" + val request = UpdateChatRequest(id = serverId, type = ChatType.Server, content = content) + val userId = CommonTest.TEST_USER_ID + + val serverList = + listOf( + ServerSimpleInfo( + id = serverId, + name = "test_server_name", + bookmarked = true, + ), + ) + + every { authClient.getTokenInfo(any()) } returns + ApiResponse( + data = + TokenInfo( + type = TokenType.ACCESS, userId = userId, + ), + ) + + every { serverClient.getServerList(any(), any()) } returns + ResponseEntity.ok().body(ApiResponse(data = serverList)) + + every { + chatService.updateServerChat( + request, + userId, + ) + } returns + true + + // When + val result = + webTestClient.patch().uri(url) + .bodyValue(request) + .header("Authorization", "Bearer mock_token") + .exchange() + + // Then + val docs = + result + .expectStatus() + .isOk + .expectBody() + .json(om.writeValueAsString(ApiResponse(status = 200))) + + docs.restDoc( + identifier = "update_chats_200", + description = "서버 채팅 업데이트 api", + ) { + response { + body { + "status" type JsonDataType.Integers mean "상태 코드" + } + } + } + } + } + + describe("DELETE /api/v1/chat : deleteChat api test") { + + val url = "/api/v1/chat/{chatId}" + // Given + val userId = CommonTest.TEST_USER_ID + + it("deleteRoomChat api test") { + + // Given + val chatId = ChatTest.TEST_CHAT_ID + + every { authClient.getTokenInfo(any()) } returns + ApiResponse( + data = + TokenInfo( + type = TokenType.ACCESS, userId = userId, + ), + ) + + every { + chatService.deleteRoomChat( + chatId, + userId, + ) + } returns true + + // When + val result = + webTestClient.delete().uri( + URLBuilder(url) + .query("type", "Room") + .build(), + chatId, + ) + .header("Authorization", "Bearer mock_token") + .exchange() + + // Then + val docs = + result + .expectStatus() + .isOk + .expectBody() + .json(om.writeValueAsString(ApiResponse(status = 200))) + + docs.restDoc( + identifier = "delete_room_chat_200", + description = "채팅방 채팅 삭제 api", + ) { + request { + query { + "type" mean "Server / Room" + } + path { + "chatId" mean "채팅 ID" + } + } + + response { + body { + "status" type JsonDataType.Integers mean "상태 코드" + } + } + } + } + + it("deleteServerChat api test") { + + // Given + val chatId = ChatTest.TEST_CHAT_ID + + every { authClient.getTokenInfo(any()) } returns + ApiResponse( + data = + TokenInfo( + type = TokenType.ACCESS, userId = userId, + ), + ) + + every { + chatService.deleteServerChat( + chatId, + userId, + ) + } returns true + + // When + val result = + webTestClient.delete().uri( + URLBuilder(url) + .query("type", "Server") + .build(), + chatId, + ) + .header("Authorization", "Bearer mock_token") + .exchange() + + // Then + val docs = + result + .expectStatus() + .isOk + .expectBody() + .json(om.writeValueAsString(ApiResponse(status = 200))) + + docs.restDoc( + identifier = "delete_server_chat_200", + description = "서버 채팅 삭제 api", + ) { + request { + query { + "type" mean "Server / Room" + } + path { + "chatId" mean "채팅 ID" + } + } + + response { + body { + "status" type JsonDataType.Integers mean "상태 코드" + } + } + } + } + } }) diff --git a/chat/src/test/kotlin/kpring/chat/chatroom/ChatRoomServiceTest.kt b/chat/src/test/kotlin/kpring/chat/chatroom/ChatRoomServiceTest.kt index 0a744c34..4bc2f3cc 100644 --- a/chat/src/test/kotlin/kpring/chat/chatroom/ChatRoomServiceTest.kt +++ b/chat/src/test/kotlin/kpring/chat/chatroom/ChatRoomServiceTest.kt @@ -8,6 +8,7 @@ import io.mockk.verify import kpring.chat.chatroom.model.ChatRoom import kpring.chat.chatroom.repository.ChatRoomRepository import kpring.chat.chatroom.service.ChatRoomService +import kpring.chat.chatroom.service.InvitationService import kpring.chat.global.ChatRoomTest import kpring.chat.global.CommonTest import kpring.core.chat.chatroom.dto.request.CreateChatRoomRequest @@ -16,7 +17,8 @@ import java.util.* class ChatRoomServiceTest : FunSpec({ val chatRoomRepository = mockk() - val chatRoomService = ChatRoomService(chatRoomRepository) + val invitationService = mockk() + val chatRoomService = ChatRoomService(chatRoomRepository, invitationService) test("createChatRoom 는 새 ChatRoom을 저장해야 한다") { // Given @@ -34,9 +36,10 @@ class ChatRoomServiceTest : FunSpec({ test("exitChatRoom 은 요청한 사람이 members의 일원이라면 삭제해야 한다") { // Given val chatRoom = - ChatRoom().apply { + ChatRoom( id = - ChatRoomTest.TEST_ROOM_ID + ChatRoomTest.TEST_ROOM_ID, + ).apply { addUsers(ChatRoomTest.TEST_MEMBERS) } every { chatRoomRepository.findById(chatRoom.id!!) } returns Optional.of(chatRoom) diff --git a/chat/src/test/kotlin/kpring/chat/chatroom/api/v1/ChatRoomControllerTest.kt b/chat/src/test/kotlin/kpring/chat/chatroom/api/v1/ChatRoomControllerTest.kt new file mode 100644 index 00000000..09dd27d0 --- /dev/null +++ b/chat/src/test/kotlin/kpring/chat/chatroom/api/v1/ChatRoomControllerTest.kt @@ -0,0 +1,156 @@ +package kpring.chat.chat.api.v1 + +import com.fasterxml.jackson.databind.ObjectMapper +import com.ninjasquad.springmockk.MockkBean +import io.kotest.core.spec.style.DescribeSpec +import io.mockk.every +import io.mockk.junit5.MockKExtension +import kpring.chat.chatroom.api.v1.ChatRoomController +import kpring.chat.chatroom.service.ChatRoomService +import kpring.chat.global.ChatRoomTest +import kpring.chat.global.CommonTest +import kpring.chat.global.config.TestMongoConfig +import kpring.core.auth.client.AuthClient +import kpring.core.auth.dto.response.TokenInfo +import kpring.core.auth.enums.TokenType +import kpring.core.chat.chat.dto.response.InvitationResponse +import kpring.core.global.dto.response.ApiResponse +import kpring.test.restdoc.dsl.restDoc +import kpring.test.restdoc.json.JsonDataType +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.context.annotation.Import +import org.springframework.restdocs.ManualRestDocumentation +import org.springframework.restdocs.RestDocumentationExtension +import org.springframework.restdocs.operation.preprocess.Preprocessors +import org.springframework.restdocs.webtestclient.WebTestClientRestDocumentation +import org.springframework.test.context.junit.jupiter.SpringExtension +import org.springframework.test.web.reactive.server.WebTestClient +import org.springframework.test.web.servlet.client.MockMvcWebTestClient +import org.springframework.web.context.WebApplicationContext + +@WebMvcTest(controllers = [ChatRoomController::class]) +@ExtendWith(RestDocumentationExtension::class) +@ExtendWith(SpringExtension::class) +@ExtendWith(MockKExtension::class) +@Import(TestMongoConfig::class) +class ChatRoomControllerTest( + private val om: ObjectMapper, + webContext: WebApplicationContext, + @MockkBean val chatRoomService: ChatRoomService, + @MockkBean val authClient: AuthClient, +) : DescribeSpec({ + + val restDocument = ManualRestDocumentation() + val webTestClient: WebTestClient = + MockMvcWebTestClient.bindToApplicationContext(webContext).configureClient().baseUrl("http://localhost:8081").filter( + WebTestClientRestDocumentation.documentationConfiguration(restDocument).operationPreprocessors() + .withRequestDefaults(Preprocessors.prettyPrint()).withResponseDefaults(Preprocessors.prettyPrint()), + ).build() + + beforeSpec { restDocument.beforeTest(this.javaClass, "chat controller") } + + afterSpec { restDocument.afterTest() } + + describe("GET /api/v1/chatroom/{chatRoomId}/invite : getChatRoomInvitation api test") { + + val url = "/api/v1/chatroom/{chatRoomId}/invite" + it("getChatRoomInvitation api test") { + + // Given + val chatRoomId = ChatRoomTest.TEST_ROOM_ID + val userId = CommonTest.TEST_USER_ID + val key = "62e9df6b-13cb-4673-a6fe-8566451b7f15" + val data = InvitationResponse(key) + + every { authClient.getTokenInfo(any()) } returns + ApiResponse( + data = + TokenInfo( + type = TokenType.ACCESS, userId = CommonTest.TEST_USER_ID, + ), + ) + + every { + chatRoomService.getChatRoomInvitation( + chatRoomId, + userId, + ) + } returns data + + // When + val result = webTestClient.get().uri(url, chatRoomId).header("Authorization", "Bearer mock_token").exchange() + + // Then + val docs = result.expectStatus().isOk.expectBody().json(om.writeValueAsString(ApiResponse(data = data))) + + docs.restDoc( + identifier = "getChatRoomInvitation_200", + description = "채팅방 참여코드를 위한 key값을 반환하는 api", + ) { + request { + path { + "chatRoomId" mean "채팅방 참여코드를 발급할 채팅방 Id" + } + } + + response { + body { + "data.code" type JsonDataType.Strings mean "참여 코드" + } + } + } + } + } + + describe("GET /api/v1//chatroom/{code}/join : joinChatRoom api test") { + + val url = "/api/v1/chatroom/{code}/join" + it("joinChatRoom api test") { + + // Given + val chatRoomId = ChatRoomTest.TEST_ROOM_ID + val userId = CommonTest.TEST_USER_ID + val code = "666fcd76027b2432e4b49a0f" + val data = true + + every { authClient.getTokenInfo(any()) } returns + ApiResponse( + data = + TokenInfo( + type = TokenType.ACCESS, userId = CommonTest.TEST_USER_ID, + ), + ) + + every { + chatRoomService.joinChatRoom( + code, + userId, + ) + } returns data + + // When + val result = webTestClient.patch().uri(url, code).header("Authorization", "Bearer mock_token").exchange() + + // Then + val docs = result.expectStatus().isOk.expectBody().json(om.writeValueAsString(ApiResponse(status = 200))) + + docs.restDoc( + identifier = "joinChatRoom_200", + description = "코드로 채팅방에 참여하는 api", + ) { + request { + path { + "code" mean "채팅방 참여코드" + } + } + + response { + body { + "status" type JsonDataType.Integers mean "상태 코드" + } + } + } + } + } + }) diff --git a/chat/src/test/kotlin/kpring/chat/example/SampleTest.kt b/chat/src/test/kotlin/kpring/chat/example/SampleTest.kt index 2fa7d1f7..f9b70bfc 100644 --- a/chat/src/test/kotlin/kpring/chat/example/SampleTest.kt +++ b/chat/src/test/kotlin/kpring/chat/example/SampleTest.kt @@ -3,8 +3,8 @@ package kpring.chat.example import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe -import kpring.chat.chat.model.QRoomChat -import kpring.chat.chat.model.RoomChat +import kpring.chat.chat.model.Chat +import kpring.chat.chat.model.QChat import kpring.chat.chat.repository.RoomChatRepository import kpring.test.testcontainer.SpringTestContext import org.springframework.boot.test.context.SpringBootTest @@ -25,10 +25,10 @@ class SampleTest( it("query dsl 적용 테스트") { // given - val chat = QRoomChat.roomChat + val chat = QChat.chat repeat(5) { idx -> roomChatRepository.save( - RoomChat("testUserId", "testRoomId", "testContent$idx"), + Chat(userId = "testUserId", contextId = "testRoomId", content = "testContent$idx"), ) } @@ -49,11 +49,11 @@ class SampleTest( it("query dsl 적용 테스트 : 다중 조건") { // given - val chat = QRoomChat.roomChat + val chat = QChat.chat roomChatRepository.deleteAll() repeat(5) { idx -> roomChatRepository.save( - RoomChat("testUserId", "testRoomId", "testContent$idx"), + Chat(userId = "testUserId", contextId = "testRoomId", content = "testContent$idx"), ) } diff --git a/chat/src/test/kotlin/kpring/chat/global/config/TestMongoConfig.kt b/chat/src/test/kotlin/kpring/chat/global/config/TestMongoConfig.kt new file mode 100644 index 00000000..3bc3120e --- /dev/null +++ b/chat/src/test/kotlin/kpring/chat/global/config/TestMongoConfig.kt @@ -0,0 +1,15 @@ +package kpring.chat.global.config + +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Bean +import org.springframework.data.mongodb.config.EnableMongoAuditing +import org.springframework.data.mongodb.core.mapping.MongoMappingContext + +@TestConfiguration +@EnableMongoAuditing +class TestMongoConfig { + @Bean + fun mongoMappingContext(): MongoMappingContext { + return MongoMappingContext() + } +} diff --git a/core/src/main/kotlin/kpring/core/chat/chat/dto/request/UpdateChatRequest.kt b/core/src/main/kotlin/kpring/core/chat/chat/dto/request/UpdateChatRequest.kt new file mode 100644 index 00000000..65ded789 --- /dev/null +++ b/core/src/main/kotlin/kpring/core/chat/chat/dto/request/UpdateChatRequest.kt @@ -0,0 +1,11 @@ +package kpring.core.chat.chat.dto.request + +import jakarta.validation.constraints.NotNull + +data class UpdateChatRequest( + @field:NotNull + val id: String, + @field:NotNull + val type: ChatType, + val content: String, +) diff --git a/core/src/main/kotlin/kpring/core/chat/chat/dto/response/InvitationResponse.kt b/core/src/main/kotlin/kpring/core/chat/chat/dto/response/InvitationResponse.kt new file mode 100644 index 00000000..70f2823a --- /dev/null +++ b/core/src/main/kotlin/kpring/core/chat/chat/dto/response/InvitationResponse.kt @@ -0,0 +1,5 @@ +package kpring.core.chat.chat.dto.response + +data class InvitationResponse( + val code: String, +) diff --git a/core/src/main/kotlin/kpring/core/chat/chatroom/dto/request/CreateChatRoomRequest.kt b/core/src/main/kotlin/kpring/core/chat/chatroom/dto/request/CreateChatRoomRequest.kt index 0f583563..9582b872 100644 --- a/core/src/main/kotlin/kpring/core/chat/chatroom/dto/request/CreateChatRoomRequest.kt +++ b/core/src/main/kotlin/kpring/core/chat/chatroom/dto/request/CreateChatRoomRequest.kt @@ -4,5 +4,5 @@ import jakarta.validation.constraints.NotNull data class CreateChatRoomRequest( @field:NotNull - var users: List, + val users: List, ) diff --git a/core/src/main/kotlin/kpring/core/server/dto/CategoryInfo.kt b/core/src/main/kotlin/kpring/core/server/dto/CategoryInfo.kt new file mode 100644 index 00000000..f1268634 --- /dev/null +++ b/core/src/main/kotlin/kpring/core/server/dto/CategoryInfo.kt @@ -0,0 +1,6 @@ +package kpring.core.server.dto + +data class CategoryInfo( + val id: String, + val name: String, +) diff --git a/core/src/main/kotlin/kpring/core/server/dto/ServerThemeInfo.kt b/core/src/main/kotlin/kpring/core/server/dto/ServerThemeInfo.kt new file mode 100644 index 00000000..5e8809d2 --- /dev/null +++ b/core/src/main/kotlin/kpring/core/server/dto/ServerThemeInfo.kt @@ -0,0 +1,6 @@ +package kpring.core.server.dto + +data class ServerThemeInfo( + val id: String, + val name: String, +) diff --git a/core/src/main/kotlin/kpring/core/server/dto/request/CreateServerRequest.kt b/core/src/main/kotlin/kpring/core/server/dto/request/CreateServerRequest.kt index b35d8fe6..262acc8e 100644 --- a/core/src/main/kotlin/kpring/core/server/dto/request/CreateServerRequest.kt +++ b/core/src/main/kotlin/kpring/core/server/dto/request/CreateServerRequest.kt @@ -2,4 +2,7 @@ package kpring.core.server.dto.request data class CreateServerRequest( val serverName: String, + val userId: String, + val theme: String? = null, + val categories: List? = null, ) diff --git a/core/src/main/kotlin/kpring/core/server/dto/response/CreateServerResponse.kt b/core/src/main/kotlin/kpring/core/server/dto/response/CreateServerResponse.kt index 888e930c..2edcf252 100644 --- a/core/src/main/kotlin/kpring/core/server/dto/response/CreateServerResponse.kt +++ b/core/src/main/kotlin/kpring/core/server/dto/response/CreateServerResponse.kt @@ -1,6 +1,11 @@ package kpring.core.server.dto.response +import kpring.core.server.dto.CategoryInfo +import kpring.core.server.dto.ServerThemeInfo + data class CreateServerResponse( val serverId: String, val serverName: String, + val theme: ServerThemeInfo, + val categories: List, ) diff --git a/front/.gitignore b/front/.gitignore index 4d29575d..c5e54f8a 100644 --- a/front/.gitignore +++ b/front/.gitignore @@ -21,3 +21,4 @@ npm-debug.log* yarn-debug.log* yarn-error.log* +Chart.lock diff --git a/front/src/components/Auth/LoginBox.tsx b/front/src/components/Auth/LoginBox.tsx index 645309a2..29c7b62b 100644 --- a/front/src/components/Auth/LoginBox.tsx +++ b/front/src/components/Auth/LoginBox.tsx @@ -63,8 +63,7 @@ function LoginBox() { " border="1px solid #e4d4e7" padding="20px" - onSubmit={clickSubmitHandler} - > + onSubmit={clickSubmitHandler}>

디코타운에 어서오세요!

@@ -100,8 +99,7 @@ function LoginBox() { type="submit" variant="contained" startIcon={} - sx={{ width: "90%" }} - > + sx={{ width: "90%" }}> 로그인 @@ -110,8 +108,7 @@ function LoginBox() { color="secondary" startIcon={} sx={{ mt: "20px", width: "90%", mb: "20px" }} - onClick={() => navigation("/join")} - > + onClick={() => navigation("/join")}> 회원가입 diff --git a/front/src/components/Chat/ChatRoomSideBar.tsx b/front/src/components/Chat/ChatRoomSideBar.tsx index 20a558b5..e247a3df 100644 --- a/front/src/components/Chat/ChatRoomSideBar.tsx +++ b/front/src/components/Chat/ChatRoomSideBar.tsx @@ -9,7 +9,6 @@ const ChatRoomSideBar = () => { setIsChatRoomShow(false); },[setIsChatRoomShow]) - const DrawerHeader = styled("div")(({ theme }) => ({ display: "flex", height: "8rem", @@ -23,8 +22,7 @@ const ChatRoomSideBar = () => {
다이렉트 메세지
diff --git a/front/src/components/Home/FavoriteStar.tsx b/front/src/components/Home/FavoriteStar.tsx index fb0565e5..13fc515d 100644 --- a/front/src/components/Home/FavoriteStar.tsx +++ b/front/src/components/Home/FavoriteStar.tsx @@ -1,7 +1,7 @@ import StarBorderRoundedIcon from "@mui/icons-material/StarBorderRounded"; import StarRoundedIcon from "@mui/icons-material/StarRounded"; import { useIsFavorite } from "../../hooks/FavoriteServer"; -import useFavoriteStore from "../../stores/store"; +import useFavoriteStore from "../../store/useFavoriteStore"; const FavoriteStar = ({ id }: { id: string }) => { const isFavorite = useIsFavorite(id); diff --git a/front/src/components/Home/ServerCard.tsx b/front/src/components/Home/ServerCard.tsx index 377fa844..45665012 100644 --- a/front/src/components/Home/ServerCard.tsx +++ b/front/src/components/Home/ServerCard.tsx @@ -1,24 +1,16 @@ import { Card, CardContent, CardMedia, Typography } from "@mui/material"; import FavoriteStar from "./FavoriteStar"; +import { ServerCardProps } from "../../types/server"; -const ServerCard = ({ - servers, -}: { - servers: { - serverId: string; - serverName: string; - image: string; - members: string[]; - }; -}) => { +const ServerCard: React.FC = ({ server }) => { return (
- + - {servers.serverName} - {servers.members.length}명 - + {server.serverName} + {server.members.length}명 +
diff --git a/front/src/components/Home/ServerCardList.tsx b/front/src/components/Home/ServerCardList.tsx index 6feff9d8..e4f258a9 100644 --- a/front/src/components/Home/ServerCardList.tsx +++ b/front/src/components/Home/ServerCardList.tsx @@ -1,23 +1,16 @@ import ServerCard from "./ServerCard"; import { useFilterFavorites } from "../../hooks/FavoriteServer"; +import { ServerCardListProps, ServerType } from "../../types/server"; -const ServerCardList = ({ - servers, -}: { - servers: { - serverId: string; - serverName: string; - image: string; - members: string[]; - }[]; -}) => { +const ServerCardList: React.FC = ({ servers }) => { const favoriteItems = useFilterFavorites(); + return ( <> {servers - .filter((server) => favoriteItems.includes(server.serverId)) - .map((server) => ( - + .filter((server: ServerType) => favoriteItems.includes(server.serverId)) + .map((server: ServerType) => ( + ))} ); diff --git a/front/src/components/Layout/DropDown.tsx b/front/src/components/Layout/DropDown.tsx index d442a0b9..374c26e8 100644 --- a/front/src/components/Layout/DropDown.tsx +++ b/front/src/components/Layout/DropDown.tsx @@ -1,4 +1,4 @@ -import { Box } from '@mui/material' +import { Box, List, ListItem } from '@mui/material' import React, { useMemo } from 'react' interface DropDownProps{ @@ -9,18 +9,24 @@ const DropDown: React.FC = ({dropDownItems}) => { const dropDownList = useMemo(()=>{ return dropDownItems.map((item, index)=>{ - return
  • {item}
  • + return {item} }) },[dropDownItems]) return ( - -
      - { - dropDownList - } -
    + + {dropDownList} ) } diff --git a/front/src/components/Layout/FriendsRightSideBar.tsx b/front/src/components/Layout/FriendsRightSideBar.tsx index 389de741..28b6c43b 100644 --- a/front/src/components/Layout/FriendsRightSideBar.tsx +++ b/front/src/components/Layout/FriendsRightSideBar.tsx @@ -16,7 +16,7 @@ import { messageMemberList } from "../../utils/fakeData"; const FriendsRightSideBar: React.FC = ({ close }) => { const DrawerHeader = styled("div")(({ theme }) => ({ display: "flex", - height: "8rem", + height: "128", alignItems: "center", padding: theme.spacing(0, 1), ...theme.mixins.toolbar, @@ -29,19 +29,29 @@ const FriendsRightSideBar: React.FC = ({ close }) => { return ( <> - - - - -
    친구 목록
    - + + + + + 친구 목록 + { openDropDown? : - + } { openDropDown && @@ -59,7 +69,7 @@ const FriendsRightSideBar: React.FC = ({ close }) => { }, } }} - sx={{paddingBottom: "1rem"}} + sx={{paddingBottom: "16px"}} /> diff --git a/front/src/components/Layout/Header.tsx b/front/src/components/Layout/Header.tsx index a645002a..7d104c98 100644 --- a/front/src/components/Layout/Header.tsx +++ b/front/src/components/Layout/Header.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import MuiAppBar, { AppBarProps as MuiAppBarProps } from "@mui/material/AppBar"; +import MuiAppBar from "@mui/material/AppBar"; import { Box, Drawer, Toolbar, Typography, styled } from "@mui/material"; import SupervisedUserCircleIcon from "@mui/icons-material/SupervisedUserCircle"; import ChatBubbleIcon from "@mui/icons-material/ChatBubble"; @@ -7,17 +7,13 @@ import FriendsRightSideBar from "./FriendsRightSideBar"; import MessageRightSideBar from "./MessageRightSideBar"; import ChatRoomSideBar from "../Chat/ChatRoomSideBar"; import useChatRoomStore from "../../store/useChatRoomStore"; - -interface AppBarProps extends MuiAppBarProps { - open?: boolean; -} +import { AppBarProps } from "../../types/layout"; const Header = () => { const DRAWER_WIDTH = 240; // 오른쪽 사이드바 넓이 const [open, setOpen] = useState(false); // 사이드바 열고 닫힌 상태 const [openDrawer, setOpenDrawer] = useState(null); // 메세지 또는 친구 사이드바 상태 - const IsChatRoomShow = useChatRoomStore(state=>state.isChatRoomShow); - + const IsChatRoomShow = useChatRoomStore((state) => state.isChatRoomShow); // 오른쪽 사이드바 오픈 핸들러 const handleDrawerOpen = (sidebar: string) => { @@ -35,6 +31,7 @@ const Header = () => { const AppBar = styled(MuiAppBar, { shouldForwardProp: (prop) => prop !== "open", })(({ theme, open }) => ({ + backgroundColor: "#2A2F4F", transition: theme.transitions.create(["margin", "width"], { easing: theme.transitions.easing.sharp, duration: theme.transitions.duration.leavingScreen, @@ -88,27 +85,25 @@ const Header = () => { anchor="right" open={openDrawer === "message"}> - - - - + flexShrink: 0, + "& .MuiDrawer-paper": { + width: DRAWER_WIDTH, + backgroundColor: "#2A2F4F", + color: "white", + }, + }} + variant="persistent" + anchor="right" + open={IsChatRoomShow}> + + ); }; -export default Header; +export default Header; \ No newline at end of file diff --git a/front/src/components/Layout/Layout.tsx b/front/src/components/Layout/Layout.tsx index 8107da35..74217641 100644 --- a/front/src/components/Layout/Layout.tsx +++ b/front/src/components/Layout/Layout.tsx @@ -3,12 +3,12 @@ import { Outlet } from "react-router-dom"; import theme from "../../theme/themeConfig"; import Header from "./Header"; import LeftSideBar from "./LeftSideBar"; + const Layout: React.FC = () => { return (
    -
    diff --git a/front/src/components/Layout/LeftSideBar.tsx b/front/src/components/Layout/LeftSideBar.tsx index 71084821..048e7922 100644 --- a/front/src/components/Layout/LeftSideBar.tsx +++ b/front/src/components/Layout/LeftSideBar.tsx @@ -13,26 +13,30 @@ import AddIcon from "@mui/icons-material/Add"; import { useState } from "react"; import { serverData } from "../../utils/fakeData"; import ServerInfoSidebar from "./ServerInfoSidebar"; +import CreateServerForm from "../Server/CreateServerForm"; +import useModal from "../../hooks/Modal"; +import ModalComponent from "../Modal/ModalComponent"; const LeftSideBar = () => { const DRAWER_WIDTH = 88; // 왼쪽 서버 사이드바 넓이 - const [open, setOpen] = useState(false); // 서버 인포 사이드바 열 + const [openServerInfo, setOpenServerInfo] = useState(false); // 서버 인포 사이드바 열기 + const { isOpen, openModal } = useModal(); const [serverId, setServerId] = useState(""); // 왼쪽 멤버 사이드바 오픈 핸들러 const handleDrawerOpen = (id: string) => { - setOpen(true); + setOpenServerInfo((openServerInfo) => !openServerInfo); setServerId(id); }; // 왼쪽 멤버 사이드바 닫기 핸들러 const handleDrawerClose = () => { - setOpen(false); + setOpenServerInfo((openServerInfo) => !openServerInfo); }; return ( - {/* { }, }}> - - - - + + + @@ -71,13 +80,16 @@ const LeftSideBar = () => { })} - + - */} + + + + ); }; diff --git a/front/src/components/Layout/MemberList.tsx b/front/src/components/Layout/MemberList.tsx index b1518bf4..9b96ae7a 100644 --- a/front/src/components/Layout/MemberList.tsx +++ b/front/src/components/Layout/MemberList.tsx @@ -1,33 +1,39 @@ import { List } from "@mui/material"; import MemberListItem from "./MemberListItem"; -import MemberProfile from "../Profile/MemberProfile"; import React from "react"; import { Member } from "../../types/layout"; +import ModalComponent from "../Modal/ModalComponent"; +import Profile from "../Profile/Profile"; +import useModal from "../../hooks/Modal"; -interface MemberListProps{ - memberList: Member[] +interface MemberListProps { + memberList: Member[]; } // TODO : 오른쪽 사이드바 멤버 리스트 -const MemberList : React.FC = ({memberList}) => { - const [openProfile, setOpenProfile] = React.useState(false); - const handleProfileOpen = () => setOpenProfile(true); - const handleProfileClose = () => setOpenProfile(false); +const MemberList: React.FC = ({ memberList }) => { + const { isOpen, openModal, closeModal } = useModal(); - return ( + return ( <> - - {memberList.map(member=>( - - ))} - - + + {memberList.map((member,index) => ( + + ))} + + + {/* TODO: API연결되면 props로 유저정보 내려주기 */} + + + ); +}; - ) -} - -export default MemberList \ No newline at end of file +export default MemberList; diff --git a/front/src/components/Layout/MemberListItem.tsx b/front/src/components/Layout/MemberListItem.tsx index a1d74da7..e8797893 100644 --- a/front/src/components/Layout/MemberListItem.tsx +++ b/front/src/components/Layout/MemberListItem.tsx @@ -22,7 +22,8 @@ const MemberListItem : React.FC= ({member, handleProfileOpe return (
    - diff --git a/front/src/components/Layout/MessageRightSideBar.tsx b/front/src/components/Layout/MessageRightSideBar.tsx index 5cdd8ea1..78253038 100644 --- a/front/src/components/Layout/MessageRightSideBar.tsx +++ b/front/src/components/Layout/MessageRightSideBar.tsx @@ -17,7 +17,7 @@ import { messageMemberList } from "../../utils/fakeData"; const MessageRightSideBar: React.FC = ({ close }) => { const DrawerHeader = styled("div")(({ theme }) => ({ display: "flex", - height: "8rem", + height: "128px", alignItems: "center", padding: theme.spacing(0, 1), ...theme.mixins.toolbar, @@ -30,19 +30,29 @@ const MessageRightSideBar: React.FC = ({ close }) => { return ( <> - - - - -
    메세지 목록
    - + + + + + 메세지 목록 + { openDropDown? : - + } { openDropDown && @@ -60,7 +70,7 @@ const MessageRightSideBar: React.FC = ({ close }) => { }, } }} - sx={{paddingBottom: "1rem"}} + sx={{paddingBottom: "16px"}} /> diff --git a/front/src/components/Layout/ServerInfoSidebar.tsx b/front/src/components/Layout/ServerInfoSidebar.tsx index 6ad9c7d6..6320cfca 100644 --- a/front/src/components/Layout/ServerInfoSidebar.tsx +++ b/front/src/components/Layout/ServerInfoSidebar.tsx @@ -10,38 +10,60 @@ import { ListItemText, Button, styled, + Modal, + Box, + Typography, } from "@mui/material"; +import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew"; import { serverData } from "../../utils/fakeData"; -import MemberProfile from "../Profile/MemberProfile"; -import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos"; import { useNavigate } from "react-router-dom"; import FavoriteStar from "../Home/FavoriteStar"; +import ModalComponent from "../Modal/ModalComponent"; +import useModal from "../../hooks/Modal"; +import Profile from "../Profile/Profile"; -const ServerInfoSidebar: React.FC = ({ - close, - open, - serverID, -}) => { +const ServerInfoSidebar: React.FC = ({ close, serverID }) => { const DrawerHeader = styled("div")(({ theme }) => ({ display: "flex", alignItems: "center", padding: theme.spacing(0, 1), ...theme.mixins.toolbar, justifyContent: "flex-start", + flexDirection: "column", + width: "240px", })); - - const [openProfile, setOpenProfile] = React.useState(false); - const handleOpen = () => setOpenProfile(true); - const handleClose = () => setOpenProfile(false); const navigate = useNavigate(); + const { isOpen, openModal, closeModal } = useModal(); return ( <> - -
    서버 멤버
    - - + + + 서버이름 + + + +
    @@ -50,7 +72,7 @@ const ServerInfoSidebar: React.FC = ({ .map((member) => { return ( - + = ({ ); })} - + + + ); }; diff --git a/front/src/components/Map/ServerMap.tsx b/front/src/components/Map/ServerMap.tsx index bc1940b8..f1e1fac2 100644 --- a/front/src/components/Map/ServerMap.tsx +++ b/front/src/components/Map/ServerMap.tsx @@ -1,7 +1,10 @@ import { forwardRef, useEffect, useLayoutEffect, useRef } from "react"; import { EventBus } from "./EventBus"; -import { ServerMapProps, ServerMapTypes } from "../../types/server"; +import { ServerMapProps, ServerMapTypes } from "../../types/map"; import EnterServer from "./main"; +import { MainMap } from "./ServerMap/MainMap"; +import VideoCallBoxList from "../VideoCall/VideoCallBoxList"; +import VideoCallToolBar from "../VideoCall/VideoCallToolBar"; // 서버를 생성하고 관리하는 컴포넌트 // forwardRef를 사용해 부모 컴포넌트로부터 ref를 전달 받음 @@ -87,9 +90,31 @@ export const ServerMap = forwardRef( }; }, [currentActiveScene, ref]); + useEffect(() => { + const inputField = document.getElementById( + "chat-input" + ) as HTMLInputElement; + console.log(inputField); + const handleKeyEnter = (event: KeyboardEvent) => { + if (event.key === "Enter") { + const target = event.target as HTMLInputElement; + + const mainScene = mapRef.current?.scene.getScene( + "MainMap" + ) as MainMap; + mainScene?.setBalloonText(target.value); + } + }; + inputField?.addEventListener("keydown", handleKeyEnter); + return () => { + inputField?.removeEventListener("keydown", handleKeyEnter); + }; + }, []); + return (
    +
    확대
    @@ -99,7 +124,13 @@ export const ServerMap = forwardRef(
    드래그
    + + +
    +
    +
    +
    ); } diff --git a/front/src/components/Map/ServerMap/Debug.ts b/front/src/components/Map/ServerMap/Debug.ts index 86a4ca65..e4f96783 100644 --- a/front/src/components/Map/ServerMap/Debug.ts +++ b/front/src/components/Map/ServerMap/Debug.ts @@ -1,7 +1,7 @@ // 이 파일은 캐릭터와 물체의 충돌을 확인하기 위한 디버그 파일입니다. import { Scene } from "phaser"; -import { Layers } from "../../../types/server"; +import { Layers } from "../../../types/map"; export const debugCollision = (scene: Scene, layers: Layers) => { const collidableLayers = [ diff --git a/front/src/components/Map/ServerMap/Layers.ts b/front/src/components/Map/ServerMap/Layers.ts index ca480885..3846c430 100644 --- a/front/src/components/Map/ServerMap/Layers.ts +++ b/front/src/components/Map/ServerMap/Layers.ts @@ -1,5 +1,5 @@ import { Scene } from "phaser"; -import { Layers } from "../../../types/server"; +import { Layers } from "../../../types/map"; export const createLayers = (map: Phaser.Tilemaps.Tilemap, scene: Scene) => { // Tiled에서 그린 잔디, 집, 나무 등과 같은 타일 요소들을 화면에 뿌려준다. diff --git a/front/src/components/Map/ServerMap/MainMap.ts b/front/src/components/Map/ServerMap/MainMap.ts index 2e9e4e2e..eabfcf6e 100644 --- a/front/src/components/Map/ServerMap/MainMap.ts +++ b/front/src/components/Map/ServerMap/MainMap.ts @@ -10,6 +10,7 @@ export class MainMap extends Scene { private isDragging: boolean = false; private dragStartX: number = 0; private dragStartY: number = 0; + private speechBalloon!: Phaser.GameObjects.Text; constructor() { super("MainMap"); @@ -49,6 +50,16 @@ export class MainMap extends Scene { } else { console.log("레이어가 생성되지 않았습니다."); } + // 말풍선 + this.speechBalloon = this.add + .text(this.character.x, this.character.y - 20, "", { + fontSize: 10, + color: "#000", + backgroundColor: "#fff", + padding: { x: 10, y: 10 }, + resolution: 2, + }) + .setOrigin(0.5); } catch (error) { console.error(error); } @@ -87,5 +98,11 @@ export class MainMap extends Scene { } else { this.character.anims.stop(); } + this.speechBalloon.setPosition(this.character.x, this.character.y - 50); + } + setBalloonText(text: string) { + if (this.speechBalloon) { + this.speechBalloon.setText(text); + } } } diff --git a/front/src/components/Modal/ModalComponent.tsx b/front/src/components/Modal/ModalComponent.tsx new file mode 100644 index 00000000..a0ddfa0f --- /dev/null +++ b/front/src/components/Modal/ModalComponent.tsx @@ -0,0 +1,21 @@ +import { Box, Modal } from "@mui/material"; +import { ReactNode } from "react"; +import { modalStyle } from "../../style/modal"; + +interface ModalComponentProps { + children: ReactNode; + isOpen: boolean; +} + +const ModalComponent = ({ children, isOpen }: ModalComponentProps) => { + return ( + + {children} + + ); +}; + +export default ModalComponent; diff --git a/front/src/components/Profile/MemberProfile.tsx b/front/src/components/Profile/MemberProfile.tsx deleted file mode 100644 index a9cc85ae..00000000 --- a/front/src/components/Profile/MemberProfile.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Avatar, Box, Button, Modal, Typography } from "@mui/material"; -import React from "react"; -import { - MemberProfileModalProps, - memberProfileModalStyle, -} from "../../types/layout"; - -const MemberProfile: React.FC = ({ - openModal, - closeModal, -}) => { - return ( -
    - - - - User Name - - - -
    - ); -}; - -export default MemberProfile; diff --git a/front/src/components/Profile/Profile.tsx b/front/src/components/Profile/Profile.tsx new file mode 100644 index 00000000..dc61338b --- /dev/null +++ b/front/src/components/Profile/Profile.tsx @@ -0,0 +1,17 @@ +import { Avatar, Button, Typography } from "@mui/material"; + +interface ProfileProps { + closeModal: () => void; +} + +const Profile = ({ closeModal }: ProfileProps) => { + return ( + <> + + User Name + + + ); +}; + +export default Profile; diff --git a/front/src/components/Server/CreateServerForm.tsx b/front/src/components/Server/CreateServerForm.tsx new file mode 100644 index 00000000..f2c9b6dc --- /dev/null +++ b/front/src/components/Server/CreateServerForm.tsx @@ -0,0 +1,80 @@ +import { Input } from "@mui/material"; + +import React, { useState } from "react"; +import useModal from "../../hooks/Modal"; + +import axios from "axios"; + +const CreateServerForm = () => { + const { closeModal, openModal } = useModal(); + const [jwtToken, setJwtToken] = useState(""); + + console.log(jwtToken); + + const onSubmitHandler = async (e: React.FormEvent) => { + e.preventDefault(); + const SERVER_URL = "http://localhost/server/api/v1/server"; + + try { + const res = await axios.post( + SERVER_URL, + { + serverName: "TEST", + }, + { + headers: { + Authorization: `Bearer ${jwtToken}`, + }, + } + ); + console.log(res); + } catch (error) { + console.error(error); + } + }; + + const tempLogin = async () => { + const SERVER_URL = "http://localhost/user/api/v1/login"; + + try { + const res = await axios.post(SERVER_URL, { + email: "test@email.com", + password: "tesT@1234", + }); + setJwtToken(res.data.data.accessToken); + } catch (error) { + console.error(error); + } + }; + + return ( +
    +

    새로운 서버 생성

    +
    + + + + {/* + + + + + + + + */} + +
    + +
    + ); +}; + +export default CreateServerForm; diff --git a/front/src/components/VideoCall/VideoCallBoxList.tsx b/front/src/components/VideoCall/VideoCallBoxList.tsx new file mode 100644 index 00000000..0d9eeb69 --- /dev/null +++ b/front/src/components/VideoCall/VideoCallBoxList.tsx @@ -0,0 +1,87 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import VideoCallBoxListItem from './VideoCallBoxListItem' +import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos'; +import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'; +import { Member } from '../../types/layout'; +import { Box } from '@mui/material'; + +const VideoCallBoxList = () => { + const [curVideoCallBoxPage, setCurVideoCallBoxPage] = useState(0); + const [slicedMemberList, setSlicedMemberList] = useState([]); // 페이징 처리 된 멤버 리스트 + const serverMemberList = [ { + id: 13, + userName: "test", + profilePath: "https://helpx.adobe.com/content/dam/help/en/photoshop/using/quick-actions/remove-background-before-qa1.png" + },]; + + // 마지막 페이지 수 + const lastPage = useMemo(()=>{ + const memberCnt = serverMemberList.length; + let lastPage = 0; + if(memberCnt%4 === 0){ + lastPage = Math.floor(memberCnt/4) - 1 + }else{ + lastPage = Math.floor(memberCnt/4) + } + return lastPage + },[]) + + // TODO : 화면 공유 박스 이전 페이지 이동 핸들링 함수 + const handleBoxPagePrev = useCallback(()=>{ + let curPage = curVideoCallBoxPage; + if(curPage!==0){ + setCurVideoCallBoxPage(curPage - 1) + } + },[curVideoCallBoxPage]) + + // // TODO : 화면 공유 박스 다음 페이지 이동 핸들링 함수 + const handleBoxPageNext = useCallback(()=>{ + if(curVideoCallBoxPage!==lastPage){ + let curPage = curVideoCallBoxPage; + setCurVideoCallBoxPage(curPage + 1) + } + },[curVideoCallBoxPage,lastPage]) + + + // TODO : 화면공유 멤버 리스트 슬라이싱 함수 + const sliceMemberList = useCallback(()=>{ + const newMemberList = serverMemberList.slice(curVideoCallBoxPage*4, (curVideoCallBoxPage*4)+4); + setSlicedMemberList(newMemberList) + },[curVideoCallBoxPage]) + + useEffect(() => { + sliceMemberList(); + }, [sliceMemberList]); + + return ( + + + + { + slicedMemberList.map((member,index)=>( + + )) + } + + + + ) +} + +export default VideoCallBoxList \ No newline at end of file diff --git a/front/src/components/VideoCall/VideoCallBoxListItem.tsx b/front/src/components/VideoCall/VideoCallBoxListItem.tsx new file mode 100644 index 00000000..b61cc182 --- /dev/null +++ b/front/src/components/VideoCall/VideoCallBoxListItem.tsx @@ -0,0 +1,45 @@ +import React, { useEffect, useRef } from 'react' +import { Member } from '../../types/layout' +import { Box } from '@mui/material'; + +interface MemberListItemProps{ + member: Member; +} + +const VideoCallBoxListItem : React.FC= ({member}) => { + + return ( + + + {/* */} + + {member.userName} + + + + ) +} + +export default VideoCallBoxListItem \ No newline at end of file diff --git a/front/src/components/VideoCall/VideoCallToolBar.tsx b/front/src/components/VideoCall/VideoCallToolBar.tsx new file mode 100644 index 00000000..d6033deb --- /dev/null +++ b/front/src/components/VideoCall/VideoCallToolBar.tsx @@ -0,0 +1,59 @@ +import React from 'react' +import MicIcon from '@mui/icons-material/Mic'; +import PhotoCameraFrontIcon from '@mui/icons-material/PhotoCameraFront'; +import MonitorIcon from '@mui/icons-material/Monitor'; +import ArrowDropUpIcon from '@mui/icons-material/ArrowDropUp'; +import { Box } from '@mui/material'; + +// TODO : 비디오 툴바 +const VideoCallToolBar = () => { + const iconStyle = { + color: 'green', + padding: '2px', + boxSizing: 'content-box', + borderRadius: '3px', + '&:hover': { + backgroundColor: '#C7C8CC', + }, + }; + return ( + + + + + + + + + + + + + + + ) +} + +export default VideoCallToolBar \ No newline at end of file diff --git a/front/src/hooks/FavoriteServer.ts b/front/src/hooks/FavoriteServer.ts index ec32232d..5e5518cf 100644 --- a/front/src/hooks/FavoriteServer.ts +++ b/front/src/hooks/FavoriteServer.ts @@ -1,4 +1,4 @@ -import useFavoriteStore from "../stores/store"; +import useFavoriteStore from "../store/useFavoriteStore"; export const useIsFavorite = (id: string) => { return useFavoriteStore((state) => state.favorites[id] ?? false); diff --git a/front/src/hooks/Modal.ts b/front/src/hooks/Modal.ts new file mode 100644 index 00000000..58e57dcc --- /dev/null +++ b/front/src/hooks/Modal.ts @@ -0,0 +1,11 @@ +import { useState } from "react"; + +const useModal = () => { + const [isOpen, setIsOpen] = useState(false); + const openModal = () => setIsOpen(true); + const closeModal = () => setIsOpen(false); + + return { isOpen, openModal, closeModal }; +}; + +export default useModal; diff --git a/front/src/stores/store.ts b/front/src/store/useFavoriteStore.ts similarity index 100% rename from front/src/stores/store.ts rename to front/src/store/useFavoriteStore.ts diff --git a/front/src/style/modal.ts b/front/src/style/modal.ts new file mode 100644 index 00000000..72b3330b --- /dev/null +++ b/front/src/style/modal.ts @@ -0,0 +1,12 @@ +// 멤버 프로필 모달 스타일 +export const modalStyle = { + position: "absolute" as "absolute", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + width: 400, + bgcolor: "backround.paper", + border: "2px solid #000", + boxShadow: 24, + p: 4, +}; diff --git a/front/src/types/layout.ts b/front/src/types/layout.ts index 80d52eae..038b1a61 100644 --- a/front/src/types/layout.ts +++ b/front/src/types/layout.ts @@ -1,3 +1,10 @@ +// Header + +import { AppBarProps as MuiAppBarProps } from "@mui/material/AppBar"; +export interface AppBarProps extends MuiAppBarProps { + open?: boolean; +} + // 오른쪽 사이드바 export interface SideBarProps { close: () => void; @@ -10,30 +17,9 @@ export interface ServerInforProps { serverID: string; } -// 멤버 프로필 모달 스타일 -export const memberProfileModalStyle = { - position: "absolute" as "absolute", - top: "50%", - left: "50%", - transform: "translate(-50%, -50%)", - width: 400, - bgcolor: "backround.paper", - border: "2px solid #000", - boxShadow: 24, - p: 4, -}; - -// 멤버 프로필 모달 -export interface MemberProfileModalProps { - openModal: boolean; - closeModal: () => void; -} - -// FIXME :멤버 조회 타입 임의로 지정(이후 API 명세서 수정에 따라 변경 필요) -export interface Member{ - +// FIXME :멤버 조회 타입 임의로 지정(이후 API 명세서 수정에 따라 변경 필요) +export interface Member { id: number; profilePath: string; userName: string; - -} \ No newline at end of file +} diff --git a/front/src/types/map.ts b/front/src/types/map.ts new file mode 100644 index 00000000..e198f489 --- /dev/null +++ b/front/src/types/map.ts @@ -0,0 +1,29 @@ +export interface ServerMapTypes { + server: Phaser.Game | null; + scene: Phaser.Scene | null; +} + +// 현재 활성화된 씬을 부모 컴포넌트로 전달하는 콜백 함수 +export interface ServerMapProps { + currentActiveScene?: (scene_instance: Phaser.Scene) => void; +} + +// Tiled Map 레이어 타입 +export interface Layers { + mapLayer?: Phaser.Tilemaps.TilemapLayer | null; + groundLayer?: Phaser.Tilemaps.TilemapLayer | null; + chickHouseLayer?: Phaser.Tilemaps.TilemapLayer | null; + bridgeLayer?: Phaser.Tilemaps.TilemapLayer | null; + dirtLayer?: Phaser.Tilemaps.TilemapLayer | null; + basicPlantsLayer?: Phaser.Tilemaps.TilemapLayer | null; + hillsLayer?: Phaser.Tilemaps.TilemapLayer | null; + woodenHouseLayer?: Phaser.Tilemaps.TilemapLayer | null; + basicGrassLayer?: Phaser.Tilemaps.TilemapLayer | null; + cowLayer?: Phaser.Tilemaps.TilemapLayer | null; + fenceLayer?: Phaser.Tilemaps.TilemapLayer | null; + eggsLayer?: Phaser.Tilemaps.TilemapLayer | null; + chickenLayer?: Phaser.Tilemaps.TilemapLayer | null; + furnitureLayer?: Phaser.Tilemaps.TilemapLayer | null; + wallsLayer?: Phaser.Tilemaps.TilemapLayer | null; + hillsCollidesLayer?: Phaser.Tilemaps.TilemapLayer | null; +} diff --git a/front/src/types/server.ts b/front/src/types/server.ts index e198f489..a6e07bdd 100644 --- a/front/src/types/server.ts +++ b/front/src/types/server.ts @@ -1,29 +1,14 @@ -export interface ServerMapTypes { - server: Phaser.Game | null; - scene: Phaser.Scene | null; +export interface ServerType { + serverId: string; + serverName: string; + image: string; + members: string[]; } -// 현재 활성화된 씬을 부모 컴포넌트로 전달하는 콜백 함수 -export interface ServerMapProps { - currentActiveScene?: (scene_instance: Phaser.Scene) => void; +export interface ServerCardProps { + server: ServerType; } -// Tiled Map 레이어 타입 -export interface Layers { - mapLayer?: Phaser.Tilemaps.TilemapLayer | null; - groundLayer?: Phaser.Tilemaps.TilemapLayer | null; - chickHouseLayer?: Phaser.Tilemaps.TilemapLayer | null; - bridgeLayer?: Phaser.Tilemaps.TilemapLayer | null; - dirtLayer?: Phaser.Tilemaps.TilemapLayer | null; - basicPlantsLayer?: Phaser.Tilemaps.TilemapLayer | null; - hillsLayer?: Phaser.Tilemaps.TilemapLayer | null; - woodenHouseLayer?: Phaser.Tilemaps.TilemapLayer | null; - basicGrassLayer?: Phaser.Tilemaps.TilemapLayer | null; - cowLayer?: Phaser.Tilemaps.TilemapLayer | null; - fenceLayer?: Phaser.Tilemaps.TilemapLayer | null; - eggsLayer?: Phaser.Tilemaps.TilemapLayer | null; - chickenLayer?: Phaser.Tilemaps.TilemapLayer | null; - furnitureLayer?: Phaser.Tilemaps.TilemapLayer | null; - wallsLayer?: Phaser.Tilemaps.TilemapLayer | null; - hillsCollidesLayer?: Phaser.Tilemaps.TilemapLayer | null; +export interface ServerCardListProps { + servers: ServerType[]; } diff --git a/front/tailwind.config.js b/front/tailwind.config.js index 4a85e8bb..26abaafd 100644 --- a/front/tailwind.config.js +++ b/front/tailwind.config.js @@ -2,17 +2,25 @@ module.exports = { content: ["./src/**/*.{html,js,jsx,ts,tsx}"], theme: { - extend: {}, - colors: { - dark:{ - DEFAULT : "#2A2F4F", + extend: { + // 기존 색상에서 확장 위해서 extend에 colors 넣는 방식으로 변경 + colors: { + dark:{ + DEFAULT : "#2A2F4F", + }, + pink:{ + DEFAULT : "#FDE2F3", + }, + darkPink:{ + DEFAULT : "#E5BEEC", + }, + gray:{ + DEFAULT : "hsla(0, 0%, 100%, .9)" + }, + darkGray:{ + DEFAULT : "rgb(39 38 46/var(--tw-text-opacity))" + }, }, - pink:{ - DEFAULT : "#FDE2F3", - }, - darkPink:{ - DEFAULT : "#E5BEEC", - } }, }, plugins: [], diff --git a/infra/charts/auth/values.yaml b/infra/charts/auth/values.yaml index 6f4adb54..86222375 100644 --- a/infra/charts/auth/values.yaml +++ b/infra/charts/auth/values.yaml @@ -1,7 +1,7 @@ # auth service service: name: auth-application - image: localhost:5000/auth-application:latest + image: youdong98/kpring-auth-application:latest port: 8080 nodePort: 30001 jwt: diff --git a/infra/charts/ingress-nginx-4.10.1.tgz b/infra/charts/ingress-nginx-4.10.1.tgz new file mode 100644 index 00000000..278d0a54 Binary files /dev/null and b/infra/charts/ingress-nginx-4.10.1.tgz differ diff --git a/infra/charts/server/values.yaml b/infra/charts/server/values.yaml index 5a260e38..52b3f6fe 100644 --- a/infra/charts/server/values.yaml +++ b/infra/charts/server/values.yaml @@ -1,7 +1,7 @@ # server service service: name: server-application - image: localhost:5000/server-application:latest + image: youdong98/kpring-server-application:latest port: 8080 nodePort: 30003 diff --git a/infra/charts/swagger/.helmignore b/infra/charts/swagger/.helmignore deleted file mode 100644 index 0e8a0eb3..00000000 --- a/infra/charts/swagger/.helmignore +++ /dev/null @@ -1,23 +0,0 @@ -# Patterns to ignore when building packages. -# This supports shell glob matching, relative path matching, and -# negation (prefixed with !). Only one pattern per line. -.DS_Store -# Common VCS dirs -.git/ -.gitignore -.bzr/ -.bzrignore -.hg/ -.hgignore -.svn/ -# Common backup files -*.swp -*.bak -*.tmp -*.orig -*~ -# Various IDEs -.project -.idea/ -*.tmproj -.vscode/ diff --git a/infra/charts/swagger/Chart.yaml b/infra/charts/swagger/Chart.yaml deleted file mode 100644 index 0ac2a497..00000000 --- a/infra/charts/swagger/Chart.yaml +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: v2 -name: swagger -description: swagger ui service - -type: application - -version: 0.1.0 - -appVersion: "1.16.0" diff --git a/infra/charts/swagger/templates/_helpers.tpl b/infra/charts/swagger/templates/_helpers.tpl deleted file mode 100644 index 55c38e84..00000000 --- a/infra/charts/swagger/templates/_helpers.tpl +++ /dev/null @@ -1,62 +0,0 @@ -{{/* -Expand the name of the chart. -*/}} -{{- define "swagger.name" -}} -{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} -{{- end }} - -{{/* -Create a default fully qualified app name. -We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). -If release name contains chart name it will be used as a full name. -*/}} -{{- define "swagger.fullname" -}} -{{- if .Values.fullnameOverride }} -{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} -{{- else }} -{{- $name := default .Chart.Name .Values.nameOverride }} -{{- if contains $name .Release.Name }} -{{- .Release.Name | trunc 63 | trimSuffix "-" }} -{{- else }} -{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} -{{- end }} -{{- end }} -{{- end }} - -{{/* -Create chart name and version as used by the chart label. -*/}} -{{- define "swagger.chart" -}} -{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} -{{- end }} - -{{/* -Common labels -*/}} -{{- define "swagger.labels" -}} -helm.sh/chart: {{ include "swagger.chart" . }} -{{ include "swagger.selectorLabels" . }} -{{- if .Chart.AppVersion }} -app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} -{{- end }} -app.kubernetes.io/managed-by: {{ .Release.Service }} -{{- end }} - -{{/* -Selector labels -*/}} -{{- define "swagger.selectorLabels" -}} -app.kubernetes.io/name: {{ include "swagger.name" . }} -app.kubernetes.io/instance: {{ .Release.Name }} -{{- end }} - -{{/* -Create the name of the service account to use -*/}} -{{- define "swagger.serviceAccountName" -}} -{{- if .Values.serviceAccount.create }} -{{- default (include "swagger.fullname" .) .Values.serviceAccount.name }} -{{- else }} -{{- default "default" .Values.serviceAccount.name }} -{{- end }} -{{- end }} diff --git a/infra/charts/swagger/templates/deployment.yaml b/infra/charts/swagger/templates/deployment.yaml deleted file mode 100644 index 9563e632..00000000 --- a/infra/charts/swagger/templates/deployment.yaml +++ /dev/null @@ -1,28 +0,0 @@ -{{- if eq .Values.global.profile "local" }} -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ .Values.service.name }} - namespace: {{ .Release.namespace }} - labels: - app: {{ .Values.service.name }} -spec: - replicas: 1 - selector: - matchLabels: - app: {{ .Values.service.name }} - template: - metadata: - labels: - app: {{ .Values.service.name }} - spec: - containers: - - name: {{ .Values.service.name }} - image: {{ .Values.service.image }} - ports: - - containerPort: {{ .Values.service.port }} - env: - - name: URLS - value: | - {{ .Values.swagger.urls }} -{{ end }} \ No newline at end of file diff --git a/infra/charts/swagger/templates/service.yaml b/infra/charts/swagger/templates/service.yaml deleted file mode 100644 index d06e7c01..00000000 --- a/infra/charts/swagger/templates/service.yaml +++ /dev/null @@ -1,16 +0,0 @@ -{{- if eq .Values.global.profile "local" }} -apiVersion: v1 -kind: Service -metadata: - name: {{ .Values.service.name }} - namespace: {{ .Release.namespace }} -spec: - type: NodePort - ports: - - port: {{ .Values.service.port }} - targetPort: {{ .Values.service.port }} - nodePort: {{ .Values.service.nodePort }} - protocol: TCP - selector: - app: {{ .Values.service.name }} -{{- end }} \ No newline at end of file diff --git a/infra/charts/swagger/templates/tests/test-connection.yaml b/infra/charts/swagger/templates/tests/test-connection.yaml deleted file mode 100644 index 5023d861..00000000 --- a/infra/charts/swagger/templates/tests/test-connection.yaml +++ /dev/null @@ -1,13 +0,0 @@ -apiVersion: v1 -kind: Pod -metadata: - name: "{{ .Values.service.name }}-test-connection" - annotations: - "helm.sh/hook": test -spec: - containers: - - name: wget - image: busybox - command: ['wget'] - args: ['{{ .Values.service.name }}:{{ .Values.service.port }}'] - restartPolicy: Never diff --git a/infra/charts/swagger/values.yaml b/infra/charts/swagger/values.yaml deleted file mode 100644 index 35e377cb..00000000 --- a/infra/charts/swagger/values.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# swagger ui service -service: - name: swagger-ui - image: swaggerapi/swagger-ui - port: 8080 - nodePort: 30000 - -swagger: - urls: "[{name: 'auth', url: 'http://localhost/auth/static/openapi3.yaml'},{name: 'user', url: 'http://localhost/user/static/openapi3.yaml'}, {name: 'server', url: 'http://localhost/server/static/openapi3.yaml'}]" diff --git a/infra/charts/user/values.yaml b/infra/charts/user/values.yaml index 76d01e7b..c393a713 100644 --- a/infra/charts/user/values.yaml +++ b/infra/charts/user/values.yaml @@ -1,7 +1,7 @@ # user service service: name: user-application - image: localhost:5000/user-application:latest + image: youdong98/kpring-user-application:latest port: 8080 nodePort: 30002 diff --git a/infra/compose.yml b/infra/compose.yml index a25712f4..623d1962 100644 --- a/infra/compose.yml +++ b/infra/compose.yml @@ -4,3 +4,10 @@ services: image: registry:latest ports: - "5000:5000" + + swagger: + image: swaggerapi/swagger-ui + ports: + - "8080:8080" + environment: + URLS: "[{name: 'auth', url: 'http://kpring.duckdns.org/auth/static/openapi3.yaml'},{name: 'user', url: 'http://kpring.duckdns.org/user/static/openapi3.yaml'}, {name: 'server', url: 'http://kpring.duckdns.org/server/static/openapi3.yaml'}]" diff --git a/server/build.gradle.kts b/server/build.gradle.kts index b0989482..c2fd02cc 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -67,8 +67,10 @@ kapt { annotationProcessor("org.springframework.data.mongodb.repository.support.MongoAnnotationProcessor") } +val hostname = "kpring.duckdns.org" + openapi3 { - setServer("http://localhost/server") + setServer("http://$hostname/server") title = "Server API" description = "API document" version = "0.1.0" @@ -79,11 +81,17 @@ openapi3 { jib { from { image = "eclipse-temurin:21-jre" + platforms { + platform { + architecture = "arm64" + os = "linux" + } + } } to { - image = "localhost:5000/server-application" + image = "youdong98/kpring-server-application" setAllowInsecureRegistries(true) - tags = setOf("latest") + tags = setOf("latest", version.toString()) } container { jvmFlags = listOf("-Xms512m", "-Xmx512m") diff --git a/server/src/main/kotlin/kpring/server/adapter/input/rest/CategoryController.kt b/server/src/main/kotlin/kpring/server/adapter/input/rest/CategoryController.kt new file mode 100644 index 00000000..4aa9232e --- /dev/null +++ b/server/src/main/kotlin/kpring/server/adapter/input/rest/CategoryController.kt @@ -0,0 +1,21 @@ +package kpring.server.adapter.input.rest + +import kpring.core.global.dto.response.ApiResponse +import kpring.server.application.port.input.GetCategoryUseCase +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v1/category") +class CategoryController( + val getCategoryUseCase: GetCategoryUseCase, +) { + @GetMapping("") + fun getCategories(): ResponseEntity> { + val response = getCategoryUseCase.getCategories() + return ResponseEntity.ok() + .body(ApiResponse(data = response)) + } +} diff --git a/server/src/main/kotlin/kpring/server/adapter/input/rest/RestApiServerController.kt b/server/src/main/kotlin/kpring/server/adapter/input/rest/RestApiServerController.kt index 6174ea6d..20a0e77a 100644 --- a/server/src/main/kotlin/kpring/server/adapter/input/rest/RestApiServerController.kt +++ b/server/src/main/kotlin/kpring/server/adapter/input/rest/RestApiServerController.kt @@ -27,9 +27,19 @@ class RestApiServerController( fun createServer( @RequestHeader("Authorization") token: String, @RequestBody request: CreateServerRequest, - ): ResponseEntity> { + ): ResponseEntity> { + // get and validate user token val userInfo = authClient.getTokenInfo(token).data!! - val data = createServerUseCase.createServer(request, userInfo.userId) + + // validate userId + if (userInfo.userId != request.userId) { + return ResponseEntity.badRequest() + .body(ApiResponse(message = "유저 정보가 일치하지 않습니다")) + } + + // logic + val data = createServerUseCase.createServer(request) + return ResponseEntity.ok() .body(ApiResponse(data = data)) } diff --git a/server/src/main/kotlin/kpring/server/adapter/output/mongo/GetServerPortMongoImpl.kt b/server/src/main/kotlin/kpring/server/adapter/output/mongo/GetServerPortMongoImpl.kt index 3a9d7911..a4b646b7 100644 --- a/server/src/main/kotlin/kpring/server/adapter/output/mongo/GetServerPortMongoImpl.kt +++ b/server/src/main/kotlin/kpring/server/adapter/output/mongo/GetServerPortMongoImpl.kt @@ -3,6 +3,7 @@ package kpring.server.adapter.output.mongo import kpring.core.global.exception.CommonErrorCode import kpring.core.global.exception.ServiceException import kpring.server.adapter.output.mongo.entity.QServerEntity +import kpring.server.adapter.output.mongo.entity.ServerEntity import kpring.server.adapter.output.mongo.repository.ServerRepository import kpring.server.application.port.output.GetServerPort import kpring.server.domain.Server @@ -17,12 +18,7 @@ class GetServerPortMongoImpl( serverRepository.findById(id) .orElseThrow { throw ServiceException(CommonErrorCode.NOT_FOUND) } - return Server( - id = serverEntity.id, - name = serverEntity.name, - users = serverEntity.users.toMutableSet(), - invitedUserIds = serverEntity.invitedUserIds.toMutableSet(), - ) + return serverEntity.toDomain() } override fun getServerWith(userId: String): List { @@ -32,13 +28,6 @@ class GetServerPortMongoImpl( server.users.any().eq(userId), ) - return servers.map { entity -> - Server( - id = entity.id, - name = entity.name, - users = entity.users.toMutableSet(), - invitedUserIds = entity.invitedUserIds.toMutableSet(), - ) - } + return servers.map(ServerEntity::toDomain) } } diff --git a/server/src/main/kotlin/kpring/server/adapter/output/mongo/GetServerProfileMongoImpl.kt b/server/src/main/kotlin/kpring/server/adapter/output/mongo/GetServerProfileMongoImpl.kt index 68839b28..cf89c776 100644 --- a/server/src/main/kotlin/kpring/server/adapter/output/mongo/GetServerProfileMongoImpl.kt +++ b/server/src/main/kotlin/kpring/server/adapter/output/mongo/GetServerProfileMongoImpl.kt @@ -36,6 +36,7 @@ class GetServerProfileMongoImpl( val server = serverEntity.toDomain() val serverProfile = ServerProfile( + id = serverProfileEntity.id, server = server, userId = serverProfileEntity.userId, name = serverProfileEntity.name, @@ -60,7 +61,7 @@ class GetServerProfileMongoImpl( // get server val targetServerIds = serverProfileEntities - .map { it.serverId } + .map { it.serverId!! } val qServer = QServerEntity.serverEntity @@ -76,6 +77,7 @@ class GetServerProfileMongoImpl( val serverEntity = serverMap[it.serverId]!! val server = serverEntity.toDomain() ServerProfile( + id = serverEntity.id, server = server, userId = it.userId, name = it.name, @@ -97,6 +99,7 @@ class GetServerProfileMongoImpl( return serverProfileEntities.map { ServerProfile( + id = it.id, server = server, userId = it.userId, name = it.name, diff --git a/server/src/main/kotlin/kpring/server/adapter/output/mongo/SaveServerPortMongoImpl.kt b/server/src/main/kotlin/kpring/server/adapter/output/mongo/SaveServerPortMongoImpl.kt index ab6c86be..1b16d6d9 100644 --- a/server/src/main/kotlin/kpring/server/adapter/output/mongo/SaveServerPortMongoImpl.kt +++ b/server/src/main/kotlin/kpring/server/adapter/output/mongo/SaveServerPortMongoImpl.kt @@ -1,6 +1,5 @@ package kpring.server.adapter.output.mongo -import kpring.core.server.dto.request.CreateServerRequest import kpring.server.adapter.output.mongo.entity.ServerEntity import kpring.server.adapter.output.mongo.entity.ServerProfileEntity import kpring.server.adapter.output.mongo.repository.ServerProfileRepository @@ -20,18 +19,15 @@ class SaveServerPortMongoImpl( @Value("\${resource.default.profileImagePath}") private lateinit var defaultImagePath: String - override fun create( - req: CreateServerRequest, - userId: String, - ): Server { + override fun create(server: Server): Server { // create server - val serverEntity = - serverRepository.save(ServerEntity(name = req.serverName)) + val serverEntity = serverRepository.save(ServerEntity(server)) // create owner server profile serverProfileRepository.save( ServerProfileEntity( - userId = userId, + id = null, + userId = server.users.first(), // todo change name = "USER_${UUID.randomUUID()}", // todo change @@ -43,10 +39,6 @@ class SaveServerPortMongoImpl( ) // mapping - return Server( - id = serverEntity.id, - name = serverEntity.name, - users = mutableSetOf(), - ) + return serverEntity.toDomain() } } diff --git a/server/src/main/kotlin/kpring/server/adapter/output/mongo/entity/ServerEntity.kt b/server/src/main/kotlin/kpring/server/adapter/output/mongo/entity/ServerEntity.kt index 6c38322c..dc3adf62 100644 --- a/server/src/main/kotlin/kpring/server/adapter/output/mongo/entity/ServerEntity.kt +++ b/server/src/main/kotlin/kpring/server/adapter/output/mongo/entity/ServerEntity.kt @@ -1,24 +1,46 @@ package kpring.server.adapter.output.mongo.entity +import kpring.server.domain.Category import kpring.server.domain.Server +import kpring.server.domain.Theme import org.springframework.data.annotation.Id import org.springframework.data.mongodb.core.mapping.Document +/** + * @property id MongoDB의 ID를 나타냅니다. + * @property name 서버의 이름을 나타냅니다. + * @property users 서버에 속한 사용자의 아이디를 나타냅니다. 더 상세한 서버 유저에 대한 정보는 [ServerProfileEntity] 에서 찾을 수 있습니다. + * @property invitedUserIds 서버에 구성원은 아니지만 서버에 초대된 사용자의 아이디를 나타냅니다. 초대된 사용자는 서버에 가입할 수 있는 권한을 얻게 됩니다. + * @property theme 서버의 테마 정보를 나타냅니다. 테마 정보를 입력하지 않는다면 디폴트 값이 설정되며 디폴트 값은 [Theme.default] 입니다. + * @property categories 서버의 카테고리 정보를 나타냅니다. 카테고리 정보를 입력하지 않는다면 카테고리가 없는 서버를 생성합니다. + */ @Document(collection = "server") class ServerEntity( + @Id + val id: String?, var name: String, - var users: MutableList = mutableListOf(), - var invitedUserIds: MutableList = mutableListOf(), + var users: MutableSet, + var invitedUserIds: MutableSet, + val theme: Theme, + val categories: Set, ) { - @Id - lateinit var id: String + constructor(server: Server) : this( + id = server.id, + name = server.name, + users = server.users, + invitedUserIds = server.invitedUserIds, + theme = server.theme, + categories = server.categories, + ) fun toDomain(): Server { return Server( id = id, name = name, - users = users.toMutableSet(), + users = users, invitedUserIds = invitedUserIds.toMutableSet(), + theme = theme, + categories = categories, ) } } diff --git a/server/src/main/kotlin/kpring/server/adapter/output/mongo/entity/ServerProfileEntity.kt b/server/src/main/kotlin/kpring/server/adapter/output/mongo/entity/ServerProfileEntity.kt index 4cae2378..edcf1b02 100644 --- a/server/src/main/kotlin/kpring/server/adapter/output/mongo/entity/ServerProfileEntity.kt +++ b/server/src/main/kotlin/kpring/server/adapter/output/mongo/entity/ServerProfileEntity.kt @@ -1,5 +1,6 @@ package kpring.server.adapter.output.mongo.entity +import kpring.server.domain.ServerProfile import kpring.server.domain.ServerRole import org.springframework.data.annotation.Id import org.springframework.data.mongodb.core.mapping.Document @@ -7,10 +8,21 @@ import org.springframework.data.mongodb.core.mapping.Document @Document("server_profile") class ServerProfileEntity( @Id + val id: String?, val userId: String, val name: String, val imagePath: String, - val serverId: String, + val serverId: String?, val role: ServerRole, val bookmarked: Boolean, -) +) { + constructor(profile: ServerProfile) : this( + id = profile.id, + userId = profile.userId, + name = profile.name, + imagePath = profile.imagePath, + serverId = profile.server.id, + role = profile.role, + bookmarked = profile.bookmarked, + ) +} diff --git a/server/src/main/kotlin/kpring/server/application/port/input/CreateServerUseCase.kt b/server/src/main/kotlin/kpring/server/application/port/input/CreateServerUseCase.kt index 92dcb330..d3c5a1ac 100644 --- a/server/src/main/kotlin/kpring/server/application/port/input/CreateServerUseCase.kt +++ b/server/src/main/kotlin/kpring/server/application/port/input/CreateServerUseCase.kt @@ -4,8 +4,5 @@ import kpring.core.server.dto.request.CreateServerRequest import kpring.core.server.dto.response.CreateServerResponse interface CreateServerUseCase { - fun createServer( - req: CreateServerRequest, - userId: String, - ): CreateServerResponse + fun createServer(req: CreateServerRequest): CreateServerResponse } diff --git a/server/src/main/kotlin/kpring/server/application/port/input/GetCategoryUseCase.kt b/server/src/main/kotlin/kpring/server/application/port/input/GetCategoryUseCase.kt new file mode 100644 index 00000000..f2dd3a61 --- /dev/null +++ b/server/src/main/kotlin/kpring/server/application/port/input/GetCategoryUseCase.kt @@ -0,0 +1,7 @@ +package kpring.server.application.port.input + +import kpring.core.server.dto.CategoryInfo + +interface GetCategoryUseCase { + fun getCategories(): List +} diff --git a/server/src/main/kotlin/kpring/server/application/port/output/SaveServerPort.kt b/server/src/main/kotlin/kpring/server/application/port/output/SaveServerPort.kt index c62e6e14..f0cb75b6 100644 --- a/server/src/main/kotlin/kpring/server/application/port/output/SaveServerPort.kt +++ b/server/src/main/kotlin/kpring/server/application/port/output/SaveServerPort.kt @@ -1,11 +1,7 @@ package kpring.server.application.port.output -import kpring.core.server.dto.request.CreateServerRequest import kpring.server.domain.Server interface SaveServerPort { - fun create( - req: CreateServerRequest, - userId: String, - ): Server + fun create(server: Server): Server } diff --git a/server/src/main/kotlin/kpring/server/application/service/CategoryService.kt b/server/src/main/kotlin/kpring/server/application/service/CategoryService.kt new file mode 100644 index 00000000..9018fc67 --- /dev/null +++ b/server/src/main/kotlin/kpring/server/application/service/CategoryService.kt @@ -0,0 +1,18 @@ +package kpring.server.application.service + +import kpring.core.server.dto.CategoryInfo +import kpring.server.application.port.input.GetCategoryUseCase +import kpring.server.domain.Category +import org.springframework.stereotype.Service + +@Service +class CategoryService : GetCategoryUseCase { + override fun getCategories(): List { + return Category.entries.map { category -> + CategoryInfo( + id = category.name, + name = category.toString(), + ) + } + } +} diff --git a/server/src/main/kotlin/kpring/server/application/service/ServerService.kt b/server/src/main/kotlin/kpring/server/application/service/ServerService.kt index 5977c9fe..dc0189f0 100644 --- a/server/src/main/kotlin/kpring/server/application/service/ServerService.kt +++ b/server/src/main/kotlin/kpring/server/application/service/ServerService.kt @@ -18,7 +18,10 @@ import kpring.server.application.port.output.GetServerPort import kpring.server.application.port.output.GetServerProfilePort import kpring.server.application.port.output.SaveServerPort import kpring.server.application.port.output.UpdateServerPort +import kpring.server.domain.Category +import kpring.server.domain.Server import kpring.server.domain.ServerAuthority +import kpring.server.util.toInfo import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -30,22 +33,29 @@ class ServerService( val updateServerPort: UpdateServerPort, val deleteServerPort: DeleteServerPort, ) : CreateServerUseCase, GetServerInfoUseCase, AddUserAtServerUseCase, DeleteServerUseCase { - override fun createServer( - req: CreateServerRequest, - userId: String, - ): CreateServerResponse { - val server = createServerPort.create(req, userId) + override fun createServer(req: CreateServerRequest): CreateServerResponse { + val server = + createServerPort.create( + Server( + name = req.serverName, + users = mutableSetOf(req.userId), + theme = req.theme, + categories = req.categories, + ), + ) return CreateServerResponse( - serverId = server.id, + serverId = server.id!!, serverName = server.name, + theme = server.theme.toInfo(), + categories = server.categories.map(Category::toInfo), ) } override fun getServerInfo(serverId: String): ServerInfo { val server = getServer.get(serverId) - val serverProfiles = getServerProfilePort.getAll(server.id) + val serverProfiles = getServerProfilePort.getAll(server.id!!) return ServerInfo( - id = server.id, + id = server.id!!, name = server.name, users = serverProfiles.map { profile -> @@ -65,7 +75,7 @@ class ServerService( return getServerProfilePort.getProfiles(condition, userId) .map { profile -> ServerSimpleInfo( - id = profile.server.id, + id = profile.server.id!!, name = profile.server.name, bookmarked = profile.bookmarked, ) @@ -86,7 +96,7 @@ class ServerService( // register invitation val server = serverProfile.server server.registerInvitation(userId) - updateServerPort.inviteUser(server.id, userId) + updateServerPort.inviteUser(server.id!!, userId) } @Transactional diff --git a/server/src/main/kotlin/kpring/server/domain/Category.kt b/server/src/main/kotlin/kpring/server/domain/Category.kt new file mode 100644 index 00000000..be9ccc16 --- /dev/null +++ b/server/src/main/kotlin/kpring/server/domain/Category.kt @@ -0,0 +1,17 @@ +package kpring.server.domain + +/** + * 2024-06-11 기준으로 카테고리와 관련된 기능이 없기 때문에 하드 코딩된 제한적인 카테고리를 사용합니다. + * 카테고리의 기능이 확장된다면 해당 클래스는 deprecated 처리되며 해당 정보는 DB에서 관리될 예정입니다. + */ +enum class Category( + private val toString: String, +) { + SERVER_CATEGORY1("학습"), + SERVER_CATEGORY2("운동"), + ; + + override fun toString(): String { + return toString + } +} diff --git a/server/src/main/kotlin/kpring/server/domain/Server.kt b/server/src/main/kotlin/kpring/server/domain/Server.kt index 0b61384b..8ef863b9 100644 --- a/server/src/main/kotlin/kpring/server/domain/Server.kt +++ b/server/src/main/kotlin/kpring/server/domain/Server.kt @@ -4,11 +4,60 @@ import kpring.core.global.exception.ServiceException import kpring.server.error.ServerErrorCode class Server( - val id: String, + val id: String?, val name: String, val users: MutableSet = mutableSetOf(), val invitedUserIds: MutableSet = mutableSetOf(), + val theme: Theme, + val categories: Set, ) { + constructor( + name: String, + users: MutableSet = mutableSetOf(), + invitedUserIds: MutableSet = mutableSetOf(), + theme: String? = null, + categories: List? = null, + ) : this(null, name, users, invitedUserIds, initTheme(theme), initCategories(categories)) + + companion object { + // -----------start : 초기화 로직 ------------ + + /** + * 서버의 테마 정보를 초기화합니다. + * @param theme 서버의 테마 id를 나타냅니다. 테마 정보를 입력하지 않는다면 디폴트 값이 설정되며 디폴트 값은 [Theme.default] 입니다. + * @throws ServiceException 요청한 테마 id가 잘못된 경우 + */ + private fun initTheme(theme: String?): Theme { + if (theme == null) { + return Theme.default() + } + try { + return Theme.valueOf(theme) + } catch (e: IllegalArgumentException) { + throw ServiceException(ServerErrorCode.INVALID_THEME_ID) + } + } + + /** + * 서버의 카테고리 정보를 초기화합니다. + * @param categories 서버의 카테고리 id를 나타냅니다. 카테고리 정보를 입력하지 않는다면 카테고리가 없는 서버를 생성합니다. + * @throws ServiceException 요청한 카테고리 id가 잘못된 경우 + */ + private fun initCategories(categories: List?): Set { + if (categories == null) { + return setOf() + } + return categories.map { + try { + Category.valueOf(it) + } catch (e: IllegalArgumentException) { + throw ServiceException(ServerErrorCode.INVALID_CATEGORY_ID) + } + }.toSet() + } + // -----------end : 초기화 로직 -------------- + } + private fun isInvited(userId: String): Boolean { return invitedUserIds.contains(userId) } @@ -28,7 +77,7 @@ class Server( } else { throw ServiceException(ServerErrorCode.USER_NOT_INVITED) } - return ServerProfile(userId, name, imagePath, this) + return ServerProfile(null, userId, name, imagePath, this) } /** diff --git a/server/src/main/kotlin/kpring/server/domain/ServerProfile.kt b/server/src/main/kotlin/kpring/server/domain/ServerProfile.kt index f53673f5..e2ff599d 100644 --- a/server/src/main/kotlin/kpring/server/domain/ServerProfile.kt +++ b/server/src/main/kotlin/kpring/server/domain/ServerProfile.kt @@ -1,6 +1,7 @@ package kpring.server.domain class ServerProfile( + val id: String?, val userId: String, val name: String, val imagePath: String, diff --git a/server/src/main/kotlin/kpring/server/domain/Theme.kt b/server/src/main/kotlin/kpring/server/domain/Theme.kt new file mode 100644 index 00000000..55998fd1 --- /dev/null +++ b/server/src/main/kotlin/kpring/server/domain/Theme.kt @@ -0,0 +1,17 @@ +package kpring.server.domain + +enum class Theme( + val title: String, +) { + SERVER_THEME_001("숲"), + SERVER_THEME_002("오피스"), + ; + + companion object { + fun default() = SERVER_THEME_001 + } + + fun id(): String = this.name + + fun displayName(): String = this.title +} diff --git a/server/src/main/kotlin/kpring/server/error/ServerErrorCode.kt b/server/src/main/kotlin/kpring/server/error/ServerErrorCode.kt index b3c0b6ce..4a7c148b 100644 --- a/server/src/main/kotlin/kpring/server/error/ServerErrorCode.kt +++ b/server/src/main/kotlin/kpring/server/error/ServerErrorCode.kt @@ -10,6 +10,8 @@ enum class ServerErrorCode( ) : ErrorCode { USER_NOT_INVITED("SERVER_001", "유저가 초대되지 않았습니다.", HttpStatus.FORBIDDEN), ALREADY_REGISTERED_USER("SERVER_002", "이미 등록된 유저입니다.", HttpStatus.BAD_REQUEST), + INVALID_THEME_ID("SERVER_003", "유효하지 않은 테마 아이디입니다.", HttpStatus.BAD_REQUEST), + INVALID_CATEGORY_ID("SERVER_004", "유효하지 않은 카테고리 아이디입니다.", HttpStatus.BAD_REQUEST), ; override fun id(): String = id diff --git a/server/src/main/kotlin/kpring/server/util/DtoMapper.kt b/server/src/main/kotlin/kpring/server/util/DtoMapper.kt new file mode 100644 index 00000000..ef37b0ea --- /dev/null +++ b/server/src/main/kotlin/kpring/server/util/DtoMapper.kt @@ -0,0 +1,20 @@ +package kpring.server.util + +import kpring.core.server.dto.CategoryInfo +import kpring.core.server.dto.ServerThemeInfo +import kpring.server.domain.Category +import kpring.server.domain.Theme + +fun Category.toInfo(): CategoryInfo { + return CategoryInfo( + id = this.name, + name = this.toString(), + ) +} + +fun Theme.toInfo(): ServerThemeInfo { + return ServerThemeInfo( + id = this.id(), + name = this.displayName(), + ) +} diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index 72e7bbe6..11b41545 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -7,13 +7,13 @@ spring: mongodb: host: ${MONGO_HOST:localhost} port: ${MONGO_PORT:27017} - username: ${MONGO_USER:root} + username: ${MONGO_USERNAME:root} password: ${MONGO_PASSWORD:test1234@} database: ${MONGO_DATABASE:mongodb} authentication-database: admin auth: - url: ${AUTH_SERVICE_URL:http://localhost:8080} + url: ${AUTH_SERVICE_URL:http://localhost/auth} resource: default: diff --git a/server/src/test/kotlin/kpring/server/adapter/input/rest/CategoryControllerTest.kt b/server/src/test/kotlin/kpring/server/adapter/input/rest/CategoryControllerTest.kt new file mode 100644 index 00000000..9f394f82 --- /dev/null +++ b/server/src/test/kotlin/kpring/server/adapter/input/rest/CategoryControllerTest.kt @@ -0,0 +1,78 @@ +package kpring.server.adapter.input.rest + +import com.fasterxml.jackson.databind.ObjectMapper +import com.ninjasquad.springmockk.MockkBean +import io.mockk.every +import io.mockk.junit5.MockKExtension +import kpring.core.auth.client.AuthClient +import kpring.core.global.dto.response.ApiResponse +import kpring.core.server.dto.CategoryInfo +import kpring.server.application.service.CategoryService +import kpring.server.application.service.ServerService +import kpring.server.config.CoreConfiguration +import kpring.test.restdoc.dsl.restDoc +import kpring.test.web.MvcWebTestClientDescribeSpec +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.context.annotation.Import +import org.springframework.web.context.WebApplicationContext + +@Import(CoreConfiguration::class) +@WebMvcTest( + controllers = [ + RestApiServerController::class, + CategoryController::class, + ], +) +@ExtendWith( + value = [ + MockKExtension::class, + ], +) +class CategoryControllerTest( + private val om: ObjectMapper, + webContext: WebApplicationContext, + @MockkBean val serverService: ServerService, + @MockkBean val authClient: AuthClient, + @MockkBean val categoryService: CategoryService, +) : MvcWebTestClientDescribeSpec( + "Rest api server category controller test", + webContext, + { client -> + describe("GET /api/v1/category") { + it("200 : 성공 케이스") { + // given + val data = + listOf( + CategoryInfo( + id = "SERVER_CATEGORY_1", + name = "category1", + ), + CategoryInfo( + id = "SERVER_CATEGORY_2", + name = "category2", + ), + ) + every { categoryService.getCategories() } returns data + // when + val result = + client.get() + .uri("/api/v1/category") + .exchange() + + // then + val docs = + result + .expectStatus().isOk + .expectBody().json(om.writeValueAsString(ApiResponse(data = data))) + + // docs + docs.restDoc( + identifier = "get-categories", + description = "서버 카테고리 목록 조회", + ) { + } + } + } + }, + ) diff --git a/server/src/test/kotlin/kpring/server/adapter/input/rest/RestApiServerControllerTest.kt b/server/src/test/kotlin/kpring/server/adapter/input/rest/RestApiServerControllerTest.kt index 741cca7d..9d16b16d 100644 --- a/server/src/test/kotlin/kpring/server/adapter/input/rest/RestApiServerControllerTest.kt +++ b/server/src/test/kotlin/kpring/server/adapter/input/rest/RestApiServerControllerTest.kt @@ -2,7 +2,6 @@ package kpring.server.adapter.input.rest import com.fasterxml.jackson.databind.ObjectMapper import com.ninjasquad.springmockk.MockkBean -import io.kotest.core.spec.style.DescribeSpec import io.mockk.clearMocks import io.mockk.every import io.mockk.junit5.MockKExtension @@ -19,22 +18,28 @@ import kpring.core.server.dto.request.AddUserAtServerRequest import kpring.core.server.dto.request.CreateServerRequest import kpring.core.server.dto.request.GetServerCondition import kpring.core.server.dto.response.CreateServerResponse +import kpring.server.application.service.CategoryService import kpring.server.application.service.ServerService import kpring.server.config.CoreConfiguration +import kpring.server.domain.Category +import kpring.server.domain.Theme +import kpring.server.util.toInfo import kpring.test.restdoc.dsl.restDoc import kpring.test.restdoc.json.JsonDataType.* +import kpring.test.web.MvcWebTestClientDescribeSpec import kpring.test.web.URLBuilder import org.junit.jupiter.api.extension.ExtendWith import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.context.annotation.Import -import org.springframework.restdocs.ManualRestDocumentation -import org.springframework.restdocs.operation.preprocess.Preprocessors -import org.springframework.restdocs.webtestclient.WebTestClientRestDocumentation -import org.springframework.test.web.servlet.client.MockMvcWebTestClient import org.springframework.web.context.WebApplicationContext @Import(CoreConfiguration::class) -@WebMvcTest(controllers = [RestApiServerController::class]) +@WebMvcTest( + controllers = [ + RestApiServerController::class, + CategoryController::class, + ], +) @ExtendWith( value = [ MockKExtension::class, @@ -45,338 +50,397 @@ class RestApiServerControllerTest( webContext: WebApplicationContext, @MockkBean val serverService: ServerService, @MockkBean val authClient: AuthClient, -) : DescribeSpec({ - - val restDocument = ManualRestDocumentation() - val webTestClient = - MockMvcWebTestClient.bindToApplicationContext(webContext) - .configureClient() - .filter( - WebTestClientRestDocumentation.documentationConfiguration(restDocument) - .operationPreprocessors() - .withRequestDefaults(Preprocessors.prettyPrint()) - .withResponseDefaults(Preprocessors.prettyPrint()), - ) - .build() - - beforeSpec { restDocument.beforeTest(this.javaClass, "user controller") } - - afterSpec { restDocument.afterTest() } - - afterTest { clearMocks(authClient) } - - describe("POST /api/v1/server : createServer api test") { - val url = "/api/v1/server" - it("요청 성공시") { - // given - val request = CreateServerRequest(serverName = "test server") - val data = CreateServerResponse(serverId = "1", serverName = request.serverName) - - every { authClient.getTokenInfo(any()) } returns - ApiResponse( - data = - TokenInfo( - type = TokenType.ACCESS, - userId = "test_user_id", - ), - ) - every { serverService.createServer(eq(request), any()) } returns data - - // when - val result = - webTestClient.post() - .uri(url) - .header("Authorization", "Bearer mock_token") - .bodyValue(request) - .exchange() - - // then - val docs = - result - .expectStatus().isOk - .expectBody() - .json(om.writeValueAsString(ApiResponse(data = data))) - - // docs - docs.restDoc( - identifier = "create_server_200", - description = "서버 생성 api", - ) { - request { - header { "Authorization" mean "jwt access token" } - body { - "serverName" type Strings mean "생성할 서버의 이름" + @MockkBean val categoryService: CategoryService, +) : MvcWebTestClientDescribeSpec( + testMethodName = "RestApiServerControllerTest", + webContext = webContext, + body = { client -> + + afterTest { clearMocks(authClient) } + + describe("POST /api/v1/server : createServer api test") { + val url = "/api/v1/server" + it("요청 성공시") { + // given + val userId = "test_user_id" + + val request = CreateServerRequest(serverName = "test server", userId = userId) + val data = + CreateServerResponse( + serverId = "1", + serverName = request.serverName, + theme = Theme.default().toInfo(), + categories = listOf(Category.SERVER_CATEGORY1, Category.SERVER_CATEGORY2).map(Category::toInfo), + ) + + every { authClient.getTokenInfo(any()) } returns + ApiResponse( + data = + TokenInfo( + type = TokenType.ACCESS, + userId = userId, + ), + ) + every { serverService.createServer(eq(request)) } returns data + + // when + val result = + client.post() + .uri(url) + .header("Authorization", "Bearer mock_token") + .bodyValue(request) + .exchange() + + // then + val docs = + result + .expectStatus().isOk + .expectBody() + .json(om.writeValueAsString(ApiResponse(data = data))) + + // docs + docs.restDoc( + identifier = "create_server_200", + description = "서버 생성 api", + ) { + request { + header { "Authorization" mean "jwt access token" } + body { + "serverName" type Strings mean "생성할 서버의 이름" + "userId" type Strings mean "서버를 생성하는 유저의 id" + "theme" type Strings mean "생성할 서버의 테마" optional true + "categories" type Arrays mean "생성할 서버의 카테고리 목록" optional true + } } - } - response { - body { - "data.serverId" type Strings mean "서버 id" - "data.serverName" type Strings mean "생성된 서버 이름" + response { + body { + "data.serverId" type Strings mean "서버 id" + "data.serverName" type Strings mean "생성된 서버 이름" + + "data.theme.id" type Strings mean "테마 id" + "data.theme.name" type Strings mean "테마 이름" + + "data.categories[].id" type Strings mean "카테고리 id" + "data.categories[].name" type Strings mean "카테고리 이름" + } } } } - } - } - - describe("GET /api/v1/server/{serverId}: 서버 조회 api test") { - - val url = "/api/v1/server/{serverId}" - it("요청 성공시") { - // given - val serverId = "test_server_id" - val data = ServerInfo(id = serverId, name = "test_server", users = emptyList()) - every { serverService.getServerInfo(serverId) } returns data - - // when - val result = - webTestClient.get() - .uri(url, serverId) - .exchange() - - // then - val docs = - result - .expectStatus().isOk - .expectBody() - .json(om.writeValueAsString(ApiResponse(data = data))) - - // docs - docs.restDoc( - identifier = "get_server_info_200", - description = "서버 단건 조회 api", - ) { - request { - path { "serverId" mean "서버 id" } - } - response { - body { - "data.id" type Strings mean "서버 id" - "data.name" type Strings mean "생성된 서버 이름" - "data.users" type Arrays mean "서버에 가입된 유저 목록" + it("요청 실패시 : 요청한 유저와 서버 권한을 가진 유저가 일치하지 않는 경우") { + // given + val serverOwnerId = "server owner id" + + val request = CreateServerRequest(serverName = "test server", userId = serverOwnerId) + + every { authClient.getTokenInfo(any()) } returns + ApiResponse( + data = + TokenInfo( + type = TokenType.ACCESS, + userId = "request user id", + ), + ) + + // when + val result = + client.post() + .uri(url) + .header("Authorization", "Bearer mock_token") + .bodyValue(request) + .exchange() + + // then + val docs = + result + .expectStatus().isBadRequest + .expectBody() + .json(om.writeValueAsString(ApiResponse(message = "유저 정보가 일치하지 않습니다"))) + + // docs + docs.restDoc( + identifier = "create_server_fail-400", + description = "서버 생성 api", + ) { + request { + header { "Authorization" mean "jwt access token" } + body { + "serverName" type Strings mean "생성할 서버의 이름" + "userId" type Strings mean "서버를 생성하는 유저의 id" + "theme" type Strings mean "생성할 서버의 테마" optional true + "categories" type Arrays mean "생성할 서버의 카테고리 목록" optional true + } + } + + response { + body { + "message" type Strings mean "실패 메시지" + } } } } } - it("요청 실패 : 존재하지 않은 서버") { - // given - val serverId = "not_exist_server_id" - val errorCode = CommonErrorCode.NOT_FOUND - every { serverService.getServerInfo(serverId) } throws ServiceException(errorCode) - - // when - val result = - webTestClient.get() - .uri(url, serverId) - .exchange() - - // then - val docs = - result - .expectStatus().isNotFound - .expectBody() - .json(om.writeValueAsString(ApiResponse(message = errorCode.message()))) - - // docs - docs.restDoc( - identifier = "get_server_info_with_invalid_id", - description = "서버 단건 조회 api", - ) { - request { - path { "serverId" mean "서버 id" } + describe("GET /api/v1/server/{serverId}: 서버 조회 api test") { + + val url = "/api/v1/server/{serverId}" + it("요청 성공시") { + // given + val serverId = "test_server_id" + val data = ServerInfo(id = serverId, name = "test_server", users = emptyList()) + every { serverService.getServerInfo(serverId) } returns data + + // when + val result = + client.get() + .uri(url, serverId) + .exchange() + + // then + val docs = + result + .expectStatus().isOk + .expectBody() + .json(om.writeValueAsString(ApiResponse(data = data))) + + // docs + docs.restDoc( + identifier = "get_server_info_200", + description = "서버 단건 조회 api", + ) { + request { + path { "serverId" mean "서버 id" } + } + + response { + body { + "data.id" type Strings mean "서버 id" + "data.name" type Strings mean "생성된 서버 이름" + "data.users" type Arrays mean "서버에 가입된 유저 목록" + } + } } + } + + it("요청 실패 : 존재하지 않은 서버") { + // given + val serverId = "not_exist_server_id" + val errorCode = CommonErrorCode.NOT_FOUND + every { serverService.getServerInfo(serverId) } throws ServiceException(errorCode) + + // when + val result = + client.get() + .uri(url, serverId) + .exchange() + + // then + val docs = + result + .expectStatus().isNotFound + .expectBody() + .json(om.writeValueAsString(ApiResponse(message = errorCode.message()))) + + // docs + docs.restDoc( + identifier = "get_server_info_with_invalid_id", + description = "서버 단건 조회 api", + ) { + request { + path { "serverId" mean "서버 id" } + } - response { - body { - "message" type Strings mean "실패 관련 메시지" + response { + body { + "message" type Strings mean "실패 관련 메시지" + } } } } } - } - - describe("PUT /api/v1/server/{serverId}/user : 서버 가입 api test") { - val url = "/api/v1/server/{serverId}/user" - it("요청 성공시") { - // given - val request = - AddUserAtServerRequest(userId = "userId", userName = "test", profileImage = "test") - justRun { serverService.addInvitedUser("test_server_id", request) } - - // when - val result = - webTestClient.put() - .uri(url, "test_server_id") - .header("Authorization", "Bearer mock_token") - .bodyValue(request) - .exchange() - - // then - val docs = - result - .expectStatus().isOk - .expectBody() - - // docs - docs.restDoc( - identifier = "add_invited_user_200", - description = "서버 가입 api", - ) { - request { - header { "Authorization" mean "jwt access token" } - path { "serverId" mean "서버 id" } - body { - "userId" type Strings mean "가입할 유저 id" - "userName" type Strings mean "가입할 유저 이름" - "profileImage" type Strings mean "가입할 유저 프로필 이미지" + + describe("PUT /api/v1/server/{serverId}/user : 서버 가입 api test") { + val url = "/api/v1/server/{serverId}/user" + it("요청 성공시") { + // given + val request = + AddUserAtServerRequest(userId = "userId", userName = "test", profileImage = "test") + justRun { serverService.addInvitedUser("test_server_id", request) } + + // when + val result = + client.put() + .uri(url, "test_server_id") + .header("Authorization", "Bearer mock_token") + .bodyValue(request) + .exchange() + + // then + val docs = + result + .expectStatus().isOk + .expectBody() + + // docs + docs.restDoc( + identifier = "add_invited_user_200", + description = "서버 가입 api", + ) { + request { + header { "Authorization" mean "jwt access token" } + path { "serverId" mean "서버 id" } + body { + "userId" type Strings mean "가입할 유저 id" + "userName" type Strings mean "가입할 유저 이름" + "profileImage" type Strings mean "가입할 유저 프로필 이미지" + } } } } } - } - - describe("PUT /api/v1/server/{serverId}/invitation/{userId} : 서버 초대 api test") { - val url = "/api/v1/server/{serverId}/invitation/{userId}" - it("요청 성공시") { - // given - every { authClient.getTokenInfo(any()) } returns - ApiResponse( - data = - TokenInfo( - type = TokenType.ACCESS, - userId = "test_user_id", - ), - ) - justRun { serverService.inviteUser(eq("test_server_id"), any(), any()) } - - // when - val result = - webTestClient.put() - .uri(url, "test_server_id", "userId") - .header("Authorization", "Bearer mock_token") - .exchange() - - // then - val docs = - result - .expectStatus().isOk - .expectBody() - - // docs - docs.restDoc( - identifier = "invite_user_200", - description = "서버 초대 api", - ) { - request { - header { "Authorization" mean "jwt access token" } - path { - "serverId" mean "서버 id" - "userId" mean "초대할 유저 id" + + describe("PUT /api/v1/server/{serverId}/invitation/{userId} : 서버 초대 api test") { + val url = "/api/v1/server/{serverId}/invitation/{userId}" + it("요청 성공시") { + // given + every { authClient.getTokenInfo(any()) } returns + ApiResponse( + data = + TokenInfo( + type = TokenType.ACCESS, + userId = "test_user_id", + ), + ) + justRun { serverService.inviteUser(eq("test_server_id"), any(), any()) } + + // when + val result = + client.put() + .uri(url, "test_server_id", "userId") + .header("Authorization", "Bearer mock_token") + .exchange() + + // then + val docs = + result + .expectStatus().isOk + .expectBody() + + // docs + docs.restDoc( + identifier = "invite_user_200", + description = "서버 초대 api", + ) { + request { + header { "Authorization" mean "jwt access token" } + path { + "serverId" mean "서버 id" + "userId" mean "초대할 유저 id" + } } } } } - } - - describe("GET /api/v1/server: 서버 목록 조회 api test") { - - val url = "/api/v1/server" - it("요청 성공시") { - // given - val userId = "test user id" - val data = - listOf( - ServerSimpleInfo(id = "server1", name = "test_server", bookmarked = false), - ServerSimpleInfo(id = "server2", name = "test_server", bookmarked = true), - ) - val condition = GetServerCondition(serverIds = listOf("server1", "server2")) - - every { authClient.getTokenInfo(any()) } returns - ApiResponse( - data = TokenInfo(TokenType.ACCESS, userId), - ) - every { serverService.getServerList(any(), eq(userId)) } returns data - - // when - val result = - webTestClient.get() - .uri( - URLBuilder(url) - .query("serverIds", condition.serverIds!!) - .build(), + + describe("GET /api/v1/server: 서버 목록 조회 api test") { + + val url = "/api/v1/server" + it("요청 성공시") { + // given + val userId = "test user id" + val data = + listOf( + ServerSimpleInfo(id = "server1", name = "test_server", bookmarked = false), + ServerSimpleInfo(id = "server2", name = "test_server", bookmarked = true), ) - .header("Authorization", "Bearer test_token") - .exchange() - - // then - val docs = - result - .expectStatus().isOk - .expectBody() - .json(om.writeValueAsString(ApiResponse(data = data))) - - // docs - docs.restDoc( - identifier = "get_server_list_info_200", - description = "서버 목록 조회 api", - ) { - request { - query { "serverIds" mean "조회시 해당 서버 목록만을 조회합니다. 값이 없다면 조건은 적용되지 않습니다." } - header { "Authorization" mean "jwt access token" } - } + val condition = GetServerCondition(serverIds = listOf("server1", "server2")) + + every { authClient.getTokenInfo(any()) } returns + ApiResponse( + data = TokenInfo(TokenType.ACCESS, userId), + ) + every { serverService.getServerList(any(), eq(userId)) } returns data + + // when + val result = + client.get() + .uri( + URLBuilder(url) + .query("serverIds", condition.serverIds!!) + .build(), + ) + .header("Authorization", "Bearer test_token") + .exchange() + + // then + val docs = + result + .expectStatus().isOk + .expectBody() + .json(om.writeValueAsString(ApiResponse(data = data))) + + // docs + docs.restDoc( + identifier = "get_server_list_info_200", + description = "서버 목록 조회 api", + ) { + request { + query { "serverIds" mean "조회시 해당 서버 목록만을 조회합니다. 값이 없다면 조건은 적용되지 않습니다." } + header { "Authorization" mean "jwt access token" } + } - response { - body { - "data[].id" type Strings mean "서버 id" - "data[].name" type Strings mean "서버 이름" - "data[].bookmarked" type Booleans mean "북마크 여부" + response { + body { + "data[].id" type Strings mean "서버 id" + "data[].name" type Strings mean "서버 이름" + "data[].bookmarked" type Booleans mean "북마크 여부" + } } } } } - } - - describe("DELETE /api/v1/server/{serverId} : 서버 삭제") { - val url = "/api/v1/server/{serverId}" - it("요청 성공시") { - // given - val serverId = "test_server_id" - val token = "Bearer mock_token" - every { authClient.getTokenInfo(token) } returns - ApiResponse( - data = - TokenInfo( - type = TokenType.ACCESS, - userId = "test_user_id", - ), - ) - justRun { serverService.deleteServer(eq(serverId), any()) } - - // when - val result = - webTestClient.delete() - .uri(url, serverId) - .header("Authorization", token) - .exchange() - - // then - val docs = - result - .expectStatus().isOk - .expectBody() - - // docs - docs.restDoc( - identifier = "delete_server_200", - description = "서버 삭제 api", - ) { - request { - header { "Authorization" mean "jwt 사용자 토큰" } - path { "serverId" mean "서버 id" } + + describe("DELETE /api/v1/server/{serverId} : 서버 삭제") { + val url = "/api/v1/server/{serverId}" + it("요청 성공시") { + // given + val serverId = "test_server_id" + val token = "Bearer mock_token" + every { authClient.getTokenInfo(token) } returns + ApiResponse( + data = + TokenInfo( + type = TokenType.ACCESS, + userId = "test_user_id", + ), + ) + justRun { serverService.deleteServer(eq(serverId), any()) } + + // when + val result = + client.delete() + .uri(url, serverId) + .header("Authorization", token) + .exchange() + + // then + val docs = + result + .expectStatus().isOk + .expectBody() + + // docs + docs.restDoc( + identifier = "delete_server_200", + description = "서버 삭제 api", + ) { + request { + header { "Authorization" mean "jwt 사용자 토큰" } + path { "serverId" mean "서버 id" } + } } } } - } - }) + }, + ) diff --git a/server/src/test/kotlin/kpring/server/application/port/input/AddUserAtServerUseCaseTest.kt b/server/src/test/kotlin/kpring/server/application/port/input/AddUserAtServerUseCaseTest.kt index 8766d4d7..ebe0f6e8 100644 --- a/server/src/test/kotlin/kpring/server/application/port/input/AddUserAtServerUseCaseTest.kt +++ b/server/src/test/kotlin/kpring/server/application/port/input/AddUserAtServerUseCaseTest.kt @@ -28,9 +28,11 @@ class AddUserAtServerUseCaseTest( // given val invitorId = "invitorId" val userId = "userId" - val server = Server(id = "serverId", name = "serverName") + val serverId = "serverId" + val server = Server(name = "serverName") val serverProfile = ServerProfile( + id = null, userId = invitorId, name = "invitor", imagePath = "imagePath", @@ -38,13 +40,13 @@ class AddUserAtServerUseCaseTest( server = server, ) - every { getServerPort.get(server.id) } returns server - every { getServerProfilePort.get(server.id, invitorId) } returns serverProfile + every { getServerPort.get(serverId) } returns server + every { getServerProfilePort.get(serverId, invitorId) } returns serverProfile // when val ex = shouldThrow { - service.inviteUser(server.id, invitorId, userId) + service.inviteUser(serverId, invitorId, userId) } // then diff --git a/server/src/test/kotlin/kpring/server/application/port/input/DeleteServerUseCaseTest.kt b/server/src/test/kotlin/kpring/server/application/port/input/DeleteServerUseCaseTest.kt index dd6f1722..0550431c 100644 --- a/server/src/test/kotlin/kpring/server/application/port/input/DeleteServerUseCaseTest.kt +++ b/server/src/test/kotlin/kpring/server/application/port/input/DeleteServerUseCaseTest.kt @@ -29,11 +29,11 @@ class DeleteServerUseCaseTest( val userId = "userId" val server = Server( - id = serverId, name = "serverName", ) val serverProfile = ServerProfile( + id = null, userId = userId, name = "name", imagePath = "/imagePath", diff --git a/server/src/test/kotlin/kpring/server/application/port/output/GetServerPortTest.kt b/server/src/test/kotlin/kpring/server/application/port/output/GetServerPortTest.kt index 0fa27f89..6fedc724 100644 --- a/server/src/test/kotlin/kpring/server/application/port/output/GetServerPortTest.kt +++ b/server/src/test/kotlin/kpring/server/application/port/output/GetServerPortTest.kt @@ -9,6 +9,7 @@ import kpring.core.global.exception.CommonErrorCode import kpring.core.global.exception.ServiceException import kpring.server.adapter.output.mongo.entity.ServerEntity import kpring.server.adapter.output.mongo.repository.ServerRepository +import kpring.server.domain.Server import kpring.test.testcontainer.SpringTestContext import org.springframework.boot.test.context.SpringBootTest import org.springframework.test.context.ContextConfiguration @@ -35,14 +36,12 @@ class GetServerPortTest( it("저장된 서버의 정보를 조회할 수 있다.") { // given - val userIds = mutableListOf("id") - val serverEntity = - serverRepository.save( - ServerEntity(name = "test", users = userIds), - ) + val userIds = mutableSetOf("id") + val domain = Server(name = "test", users = userIds) + val serverEntity = serverRepository.save(ServerEntity(domain)) // when - val server = getServerPort.get(serverEntity.id) + val server = getServerPort.get(serverEntity.id!!) // then server.name shouldBe "test" @@ -64,10 +63,11 @@ class GetServerPortTest( it("유저가 속한 서버 목록을 조회할 수 있다.") { // given val userId = "test-user" - val userIds = mutableListOf(userId) + val userIds = mutableSetOf(userId) + val server = Server(name = "server", users = userIds) repeat(2) { - serverRepository.save(ServerEntity(name = "test$it", users = userIds)) + serverRepository.save(ServerEntity(server)) } // when diff --git a/server/src/test/kotlin/kpring/server/application/port/output/GetServerProfilePortTest.kt b/server/src/test/kotlin/kpring/server/application/port/output/GetServerProfilePortTest.kt index fea97eff..8c7dedfb 100644 --- a/server/src/test/kotlin/kpring/server/application/port/output/GetServerProfilePortTest.kt +++ b/server/src/test/kotlin/kpring/server/application/port/output/GetServerProfilePortTest.kt @@ -7,7 +7,10 @@ import kpring.server.adapter.output.mongo.entity.ServerEntity import kpring.server.adapter.output.mongo.entity.ServerProfileEntity import kpring.server.adapter.output.mongo.repository.ServerProfileRepository import kpring.server.adapter.output.mongo.repository.ServerRepository +import kpring.server.domain.Server +import kpring.server.domain.ServerProfile import kpring.server.domain.ServerRole +import kpring.server.domain.Theme import kpring.test.testcontainer.SpringTestContext import org.springframework.boot.test.context.SpringBootTest import org.springframework.test.context.ContextConfiguration @@ -24,34 +27,29 @@ class GetServerProfilePortTest( it("제한된 서버 프로필을 조회하는 조건을 사용한다면 모든 프로필을 조회하지 않고 조건에 해당하는 서버 프로필만을 조회한다.") { // given - val userIds = mutableListOf("testUserId") - - val serverEntity1 = - serverRepository.save( - ServerEntity(name = "test", users = userIds), - ) - - val serverEntity2 = - serverRepository.save( - ServerEntity(name = "test", users = userIds), + val userIds = mutableSetOf("testUserId") + val server1 = Server(id = "testId", name = "test", users = userIds, theme = Theme.default(), categories = emptySet()) + val server2 = Server(name = "test", users = userIds) + + val serverEntity1 = serverRepository.save(ServerEntity(server1)) + val serverEntity2 = serverRepository.save(ServerEntity(server2)) + + val server1Profile = + ServerProfile( + id = null, + userId = "testUserId", + name = "test", + imagePath = "test", + role = ServerRole.MEMBER, + server = server1, ) - val serverProfileEntity = - serverProfileRepository.save( - ServerProfileEntity( - serverId = serverEntity1.id, - userId = "testUserId", - name = "test", - imagePath = "test", - role = ServerRole.MEMBER, - bookmarked = false, - ), - ) + val serverProfileEntity = serverProfileRepository.save(ServerProfileEntity(server1Profile)) - val condition = GetServerCondition(serverIds = listOf(serverEntity1.id)) + val condition = GetServerCondition(serverIds = listOf(serverEntity1.id!!)) // when - val serverProfiles = getServerProfilePort.getProfiles(condition, userIds[0]) + val serverProfiles = getServerProfilePort.getProfiles(condition, "testUserId") // then serverProfiles shouldHaveSize 1 diff --git a/server/src/test/kotlin/kpring/server/application/port/output/SaveServerPortTest.kt b/server/src/test/kotlin/kpring/server/application/port/output/SaveServerPortTest.kt index 828d5cc7..91b1c7ea 100644 --- a/server/src/test/kotlin/kpring/server/application/port/output/SaveServerPortTest.kt +++ b/server/src/test/kotlin/kpring/server/application/port/output/SaveServerPortTest.kt @@ -2,7 +2,7 @@ package kpring.server.application.port.output import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe -import kpring.core.server.dto.request.CreateServerRequest +import kpring.server.domain.Server import kpring.server.domain.ServerRole import kpring.test.testcontainer.SpringTestContext import org.springframework.boot.test.context.SpringBootTest @@ -18,11 +18,11 @@ class SaveServerPortTest( it("서버를 저장하면 생성한 유저는 서버의 소유자가 된다.") { // given val userId = "userId" - val req = CreateServerRequest(serverName = "serverName") + val domain = Server(name = "test", users = mutableSetOf(userId)) // when - val server = saveServerPort.create(req, userId) - val profile = getServerProfilePort.get(server.id, userId) + val server = saveServerPort.create(domain) + val profile = getServerProfilePort.get(server.id!!, userId) // then profile.role shouldBe ServerRole.OWNER diff --git a/server/src/test/kotlin/kpring/server/application/port/output/UpdateServerPortTest.kt b/server/src/test/kotlin/kpring/server/application/port/output/UpdateServerPortTest.kt index a82dbb2a..4a9c21c5 100644 --- a/server/src/test/kotlin/kpring/server/application/port/output/UpdateServerPortTest.kt +++ b/server/src/test/kotlin/kpring/server/application/port/output/UpdateServerPortTest.kt @@ -3,7 +3,7 @@ package kpring.server.application.port.output import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.collections.shouldContain import io.kotest.matchers.collections.shouldHaveSize -import kpring.core.server.dto.request.CreateServerRequest +import kpring.server.domain.Server import kpring.test.testcontainer.SpringTestContext import org.springframework.boot.test.context.SpringBootTest import org.springframework.test.context.ContextConfiguration @@ -19,33 +19,35 @@ class UpdateServerPortTest( it("유저를 초대가 작동한다.") { // given val userId = "userId" - val server = createServerPort.create(CreateServerRequest("serverName"), userId) + val domain = Server(name = "serverName", users = mutableSetOf(userId)) + val server = createServerPort.create(domain) // when repeat(5) { - updateServerPort.inviteUser(server.id, "test$it") + updateServerPort.inviteUser(server.id!!, "test$it") } // then - val result = getServerPort.get(server.id) + val result = getServerPort.get(server.id!!) result.invitedUserIds shouldHaveSize 5 } it("가입 유저를 추가할 수 있다.") { // given val ownerId = "ownerId" - val server = createServerPort.create(CreateServerRequest("serverName"), ownerId) + val domain = Server(name = "serverName", users = mutableSetOf(ownerId)) + val server = createServerPort.create(domain) val userId = "userId" server.registerInvitation(userId) - updateServerPort.inviteUser(server.id, userId) + updateServerPort.inviteUser(server.id!!, userId) val profile = server.addUser(userId, "name", "/path") // when updateServerPort.addUser(profile) // then - val result = getServerPort.get(server.id) + val result = getServerPort.get(server.id!!) result.users shouldContain userId result.invitedUserIds shouldHaveSize 0 } diff --git a/server/src/test/kotlin/kpring/server/application/service/CategoryServiceTest.kt b/server/src/test/kotlin/kpring/server/application/service/CategoryServiceTest.kt new file mode 100644 index 00000000..fcf1981d --- /dev/null +++ b/server/src/test/kotlin/kpring/server/application/service/CategoryServiceTest.kt @@ -0,0 +1,25 @@ +package kpring.server.application.service + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import kpring.core.server.dto.CategoryInfo +import kpring.server.domain.Category + +class CategoryServiceTest : FunSpec({ + + test("카테고리 목록 조회시 하드 코딩된 카테고리 정보를 조회한다.") { + // given + val categoryService = CategoryService() + + // when + val categories = categoryService.getCategories() + + // then + categories shouldBe + listOf( + CategoryInfo(id = Category.SERVER_CATEGORY1.name, name = Category.SERVER_CATEGORY1.toString()), + CategoryInfo(id = Category.SERVER_CATEGORY2.name, name = Category.SERVER_CATEGORY2.toString()), + ) + println(categories) + } +}) diff --git a/server/src/test/kotlin/kpring/server/domain/ServerProfileTest.kt b/server/src/test/kotlin/kpring/server/domain/ServerProfileTest.kt index 7e4ea526..e4609eac 100644 --- a/server/src/test/kotlin/kpring/server/domain/ServerProfileTest.kt +++ b/server/src/test/kotlin/kpring/server/domain/ServerProfileTest.kt @@ -11,13 +11,13 @@ class ServerProfileTest : DescribeSpec({ val userId = "invitedUserId" val server = Server( - id = "serverId", name = "serverName", invitedUserIds = mutableSetOf("invitedUserId"), users = mutableSetOf(userId), ) val serverProfile = ServerProfile( + id = null, server = server, name = "name", imagePath = "/imagePath", diff --git a/server/src/test/kotlin/kpring/server/domain/ServerTest.kt b/server/src/test/kotlin/kpring/server/domain/ServerTest.kt index 43ce25ef..09533178 100644 --- a/server/src/test/kotlin/kpring/server/domain/ServerTest.kt +++ b/server/src/test/kotlin/kpring/server/domain/ServerTest.kt @@ -17,7 +17,6 @@ class ServerTest : DescribeSpec({ // given val server = Server( - "serverId", "serverName", mutableSetOf(), invitedUserIds = mutableSetOf("invitedUserId"), @@ -35,7 +34,6 @@ class ServerTest : DescribeSpec({ // given val server = Server( - "serverId", "serverName", mutableSetOf(), invitedUserIds = mutableSetOf("invitedUserId"), @@ -51,4 +49,20 @@ class ServerTest : DescribeSpec({ // then result shouldBe ServerErrorCode.ALREADY_REGISTERED_USER } + + it("테마를 지정하지 않은 서버는 기본 테마를 가진다.") { + // given & when + val server = Server("serverName") + + // then + server.theme shouldBe Theme.default() + } + + it("카테고리를 지정하지 않은 서버는 빈 카테고리 목록을 가진다.") { + // given & when + val server = Server("serverName") + + // then + server.categories shouldBe emptySet() + } }) diff --git a/test/src/main/kotlin/kpring/test/restdoc/dsl/RestDocBodyBuilder.kt b/test/src/main/kotlin/kpring/test/restdoc/dsl/RestDocBodyBuilder.kt index f2654ab1..70a46de7 100644 --- a/test/src/main/kotlin/kpring/test/restdoc/dsl/RestDocBodyBuilder.kt +++ b/test/src/main/kotlin/kpring/test/restdoc/dsl/RestDocBodyBuilder.kt @@ -7,9 +7,15 @@ import org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath class RestDocBodyBuilder { val bodyFields = mutableListOf() - infix fun FieldDescriptor.mean(description: String) { - bodyFields.add(this.description(description)) + infix fun FieldDescriptor.mean(description: String): FieldDescriptor { + val descriptor = this.description(description) + bodyFields.add(descriptor) + return descriptor } infix fun String.type(type: JsonDataType): FieldDescriptor = fieldWithPath(this).type(type.value) + + infix fun FieldDescriptor.optional(isOptional: Boolean): FieldDescriptor { + return if (isOptional) this.optional() else this + } } diff --git a/test/src/main/kotlin/kpring/test/web/MvcWebTestClientDescribeSpec.kt b/test/src/main/kotlin/kpring/test/web/MvcWebTestClientDescribeSpec.kt new file mode 100644 index 00000000..d7ec9f4f --- /dev/null +++ b/test/src/main/kotlin/kpring/test/web/MvcWebTestClientDescribeSpec.kt @@ -0,0 +1,34 @@ +package kpring.test.web + +import io.kotest.core.spec.style.DescribeSpec +import org.springframework.restdocs.ManualRestDocumentation +import org.springframework.restdocs.operation.preprocess.Preprocessors +import org.springframework.restdocs.webtestclient.WebTestClientRestDocumentation +import org.springframework.test.web.reactive.server.WebTestClient +import org.springframework.test.web.servlet.client.MockMvcWebTestClient +import org.springframework.web.context.WebApplicationContext + +abstract class MvcWebTestClientDescribeSpec( + testMethodName: String, + webContext: WebApplicationContext, + body: DescribeSpec.(webTestClient: WebTestClient) -> Unit = {}, +) : DescribeSpec({ + + val restDocument = ManualRestDocumentation() + + val webTestClient: WebTestClient = + MockMvcWebTestClient.bindToApplicationContext(webContext) + .configureClient() + .filter( + WebTestClientRestDocumentation.documentationConfiguration(restDocument) + .operationPreprocessors() + .withRequestDefaults(Preprocessors.prettyPrint()) + .withResponseDefaults(Preprocessors.prettyPrint()), + ) + .build() + + beforeSpec { restDocument.beforeTest(this.javaClass, testMethodName) } + afterSpec { restDocument.afterTest() } + + body(webTestClient) + }) diff --git a/user/build.gradle.kts b/user/build.gradle.kts index db4ef108..3c49ed7d 100644 --- a/user/build.gradle.kts +++ b/user/build.gradle.kts @@ -51,8 +51,10 @@ dependencies { implementation("org.springframework.restdocs:spring-restdocs-asciidoctor") } +val hostname = "kpring.duckdns.org" + openapi3 { - setServer("http://localhost/user") + setServer("http://$hostname/user") title = "User API" description = "API document" version = "0.1.0" @@ -63,11 +65,17 @@ openapi3 { jib { from { image = "eclipse-temurin:21-jre" + platforms { + platform { + architecture = "arm64" + os = "linux" + } + } } to { - image = "localhost:5000/user-application" + image = "youdong98/kpring-user-application" setAllowInsecureRegistries(true) - tags = setOf("latest") + tags = setOf("latest", version.toString()) } container { jvmFlags = listOf("-Xms512m", "-Xmx512m") diff --git a/user/src/main/java/kpring/user/dto/request/UpdateUserProfileRequest.java b/user/src/main/java/kpring/user/dto/request/UpdateUserProfileRequest.java index 5ec35926..e8a5a692 100644 --- a/user/src/main/java/kpring/user/dto/request/UpdateUserProfileRequest.java +++ b/user/src/main/java/kpring/user/dto/request/UpdateUserProfileRequest.java @@ -1,9 +1,23 @@ package kpring.user.dto.request; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Pattern; import lombok.Builder; @Builder public record UpdateUserProfileRequest( - String email + + @Email(message = "invalid email") + String email, + + @Pattern(regexp = "^[a-zA-Z0-9가-힣]{1,32}$", + message = "닉네임은 영문 대소문자, 숫자, 한글로 구성되어야 하며, 1자 이상 32자 이하여야 합니다.") + String username, + String password, + @Pattern(regexp = "^(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9])[a-zA-Z0-9!@#$]{8,15}$", + message = "비밀번호는 최소 8자에서 15자 사이, 대문자와 소문자, 숫자가 포함되어야 하며, " + + "특수문자 (!, @, #, $)도 사용할 수 있습니다.") + String newPassword ) { -} + +} \ No newline at end of file diff --git a/user/src/main/java/kpring/user/dto/response/UpdateUserProfileResponse.java b/user/src/main/java/kpring/user/dto/response/UpdateUserProfileResponse.java index 3da68ef1..5e5779d6 100644 --- a/user/src/main/java/kpring/user/dto/response/UpdateUserProfileResponse.java +++ b/user/src/main/java/kpring/user/dto/response/UpdateUserProfileResponse.java @@ -4,6 +4,7 @@ @Builder public record UpdateUserProfileResponse( - String email + String email, + String username ) { -} +} \ No newline at end of file diff --git a/user/src/main/java/kpring/user/service/FriendService.java b/user/src/main/java/kpring/user/service/FriendService.java deleted file mode 100644 index 2edc4128..00000000 --- a/user/src/main/java/kpring/user/service/FriendService.java +++ /dev/null @@ -1,40 +0,0 @@ -package kpring.user.service; - -import kpring.user.dto.request.AddFriendRequest; -import kpring.user.dto.result.AddFriendResponse; -import kpring.user.dto.response.DeleteFriendResponse; -import kpring.user.dto.response.GetFriendsResponse; -import kpring.user.repository.UserRepository; -import org.springframework.stereotype.Service; - -@Service -public class FriendService { - - private final UserRepository userRepository; - - public FriendService(UserRepository userRepository) { - this.userRepository = userRepository; - } - - public GetFriendsResponse getFriends(Long userId) { - return null; - } - - public AddFriendResponse addFriend(AddFriendRequest friendsRequestDto, Long userId) { - var user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("User not found")); - - user.getFollowers().forEach(follower -> { - follower.getFollowees().add(user); - }); - - user.getFollowees().forEach(follower -> { - follower.getFollowers().add(user); - }); - return new AddFriendResponse(friendsRequestDto.getFriendId()); - } - - public DeleteFriendResponse deleteFriend(Long userId, Long friendId) { - return null; - } -} diff --git a/user/src/main/kotlin/kpring/user/controller/FriendController.kt b/user/src/main/kotlin/kpring/user/controller/FriendController.kt index ae6ff72d..094672a9 100644 --- a/user/src/main/kotlin/kpring/user/controller/FriendController.kt +++ b/user/src/main/kotlin/kpring/user/controller/FriendController.kt @@ -1,24 +1,32 @@ package kpring.user.controller +import kpring.core.auth.client.AuthClient import kpring.core.global.dto.response.ApiResponse -import kpring.user.dto.request.AddFriendRequest +import kpring.user.dto.response.AddFriendResponse import kpring.user.dto.response.DeleteFriendResponse +import kpring.user.dto.response.GetFriendRequestsResponse import kpring.user.dto.response.GetFriendsResponse -import kpring.user.dto.result.AddFriendResponse +import kpring.user.global.AuthValidator import kpring.user.service.FriendService import org.springframework.http.ResponseEntity -import org.springframework.validation.annotation.Validated import org.springframework.web.bind.annotation.* @RestController @RequestMapping("/api/v1") -class FriendController(val friendService: FriendService) { - @PostMapping("/user/{userId}/friend/{friendId}") - fun addFriend( +class FriendController( + private val friendService: FriendService, + private val authValidator: AuthValidator, + private val authClient: AuthClient, +) { + @GetMapping("/user/{userId}/requests") + fun getFriendRequests( + @RequestHeader("Authorization") token: String, @PathVariable userId: Long, - @Validated @RequestBody request: AddFriendRequest, - ): ResponseEntity> { - val response = friendService.addFriend(request, userId) + ): ResponseEntity> { + val validationResult = authClient.getTokenInfo(token) + val validatedUserId = authValidator.checkIfAccessTokenAndGetUserId(validationResult) + authValidator.checkIfUserIsSelf(userId.toString(), validatedUserId) + val response = friendService.getFriendRequests(userId) return ResponseEntity.ok(ApiResponse(data = response)) } @@ -27,16 +35,34 @@ class FriendController(val friendService: FriendService) { @RequestHeader("Authorization") token: String, @PathVariable userId: Long, ): ResponseEntity> { + val validationResult = authClient.getTokenInfo(token) + authValidator.checkIfAccessTokenAndGetUserId(validationResult) val response = friendService.getFriends(userId) return ResponseEntity.ok(ApiResponse(data = response)) } + @PostMapping("/user/{userId}/friend/{friendId}") + fun addFriend( + @RequestHeader("Authorization") token: String, + @PathVariable userId: Long, + @PathVariable friendId: Long, + ): ResponseEntity> { + val validationResult = authClient.getTokenInfo(token) + val validatedUserId = authValidator.checkIfAccessTokenAndGetUserId(validationResult) + authValidator.checkIfUserIsSelf(userId.toString(), validatedUserId) + val response = friendService.addFriend(userId, friendId) + return ResponseEntity.ok(ApiResponse(data = response)) + } + @DeleteMapping("/user/{userId}/friend/{friendId}") fun deleteFriend( @RequestHeader("Authorization") token: String, @PathVariable userId: Long, @PathVariable friendId: Long, ): ResponseEntity> { + val validationResult = authClient.getTokenInfo(token) + val validatedUserId = authValidator.checkIfAccessTokenAndGetUserId(validationResult) + authValidator.checkIfUserIsSelf(userId.toString(), validatedUserId) val response = friendService.deleteFriend(userId, friendId) return ResponseEntity.ok(ApiResponse(data = response)) } diff --git a/user/src/main/kotlin/kpring/user/controller/UserController.kt b/user/src/main/kotlin/kpring/user/controller/UserController.kt index 4c79c421..0591cf18 100644 --- a/user/src/main/kotlin/kpring/user/controller/UserController.kt +++ b/user/src/main/kotlin/kpring/user/controller/UserController.kt @@ -1,32 +1,33 @@ package kpring.user.controller import kpring.core.auth.client.AuthClient -import kpring.core.auth.enums.TokenType import kpring.core.global.dto.response.ApiResponse -import kpring.core.global.exception.ServiceException import kpring.user.dto.request.CreateUserRequest import kpring.user.dto.request.UpdateUserProfileRequest import kpring.user.dto.response.CreateUserResponse import kpring.user.dto.response.GetUserProfileResponse import kpring.user.dto.response.UpdateUserProfileResponse -import kpring.user.exception.UserErrorCode +import kpring.user.global.AuthValidator import kpring.user.service.UserService import org.springframework.http.ResponseEntity import org.springframework.validation.annotation.Validated import org.springframework.web.bind.annotation.* +import org.springframework.web.multipart.MultipartFile @RestController @RequestMapping("/api/v1") class UserController( - val userService: UserService, - val authClient: AuthClient, + private val userService: UserService, + private val authValidator: AuthValidator, + private val authClient: AuthClient, ) { @GetMapping("/user/{userId}") fun getUserProfile( @RequestHeader("Authorization") token: String, @PathVariable userId: Long, ): ResponseEntity> { - checkIfAccessTokenAndGetUserId(token) + val validationResult = authClient.getTokenInfo(token) + authValidator.checkIfAccessTokenAndGetUserId(validationResult) val response = userService.getProfile(userId) return ResponseEntity.ok(ApiResponse(data = response)) } @@ -43,12 +44,14 @@ class UserController( fun updateUserProfile( @RequestHeader("Authorization") token: String, @PathVariable userId: Long, - @RequestBody request: UpdateUserProfileRequest, + @Validated @RequestPart(value = "json") request: UpdateUserProfileRequest, + @RequestPart(value = "file") multipartFile: MultipartFile, ): ResponseEntity> { - val validatedUserId = checkIfAccessTokenAndGetUserId(token) - checkIfUserIsSelf(userId.toString(), validatedUserId) + val validationResult = authClient.getTokenInfo(token) + val validatedUserId = authValidator.checkIfAccessTokenAndGetUserId(validationResult) + authValidator.checkIfUserIsSelf(userId.toString(), validatedUserId) - val response = userService.updateProfile(userId, request) + val response = userService.updateProfile(userId, request, multipartFile) return ResponseEntity.ok(ApiResponse(data = response)) } @@ -57,8 +60,9 @@ class UserController( @RequestHeader("Authorization") token: String, @PathVariable userId: Long, ): ResponseEntity> { - val validatedUserId = checkIfAccessTokenAndGetUserId(token) - checkIfUserIsSelf(userId.toString(), validatedUserId) + val validationResult = authClient.getTokenInfo(token) + val validatedUserId = authValidator.checkIfAccessTokenAndGetUserId(validationResult) + authValidator.checkIfUserIsSelf(userId.toString(), validatedUserId) val isExit = userService.exitUser(userId) @@ -68,22 +72,4 @@ class UserController( ResponseEntity.badRequest().build() } } - - private fun checkIfAccessTokenAndGetUserId(token: String): String { - val validationResult = authClient.getTokenInfo(token) - if (validationResult.data!!.type != TokenType.ACCESS) { - throw ServiceException(UserErrorCode.BAD_REQUEST) - } - - return validationResult.data!!.userId - } - - private fun checkIfUserIsSelf( - userId: String, - validatedUserId: String, - ) { - if (userId != validatedUserId) { - throw ServiceException(UserErrorCode.NOT_ALLOWED) - } - } } diff --git a/user/src/main/kotlin/kpring/user/dto/result/AddFriendResponse.kt b/user/src/main/kotlin/kpring/user/dto/response/AddFriendResponse.kt similarity index 62% rename from user/src/main/kotlin/kpring/user/dto/result/AddFriendResponse.kt rename to user/src/main/kotlin/kpring/user/dto/response/AddFriendResponse.kt index b479cbb5..73508dcf 100644 --- a/user/src/main/kotlin/kpring/user/dto/result/AddFriendResponse.kt +++ b/user/src/main/kotlin/kpring/user/dto/response/AddFriendResponse.kt @@ -1,4 +1,4 @@ -package kpring.user.dto.result +package kpring.user.dto.response data class AddFriendResponse( val friendId: Long, diff --git a/user/src/main/kotlin/kpring/user/dto/response/GetFriendRequestResponse.kt b/user/src/main/kotlin/kpring/user/dto/response/GetFriendRequestResponse.kt new file mode 100644 index 00000000..2ca82ac2 --- /dev/null +++ b/user/src/main/kotlin/kpring/user/dto/response/GetFriendRequestResponse.kt @@ -0,0 +1,6 @@ +package kpring.user.dto.response + +data class GetFriendRequestResponse( + val friendId: Long, + val username: String, +) diff --git a/user/src/main/kotlin/kpring/user/dto/response/GetFriendRequestsResponse.kt b/user/src/main/kotlin/kpring/user/dto/response/GetFriendRequestsResponse.kt new file mode 100644 index 00000000..879ef532 --- /dev/null +++ b/user/src/main/kotlin/kpring/user/dto/response/GetFriendRequestsResponse.kt @@ -0,0 +1,6 @@ +package kpring.user.dto.response + +data class GetFriendRequestsResponse( + val userId: Long, + var friendRequests: List, +) diff --git a/user/src/main/kotlin/kpring/user/entity/Friend.kt b/user/src/main/kotlin/kpring/user/entity/Friend.kt new file mode 100644 index 00000000..da9aeab5 --- /dev/null +++ b/user/src/main/kotlin/kpring/user/entity/Friend.kt @@ -0,0 +1,20 @@ +package kpring.user.entity + +import jakarta.persistence.* + +@Entity +@Table(name = "tb_friend") +class Friend( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private var id: Long? = null, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private var user: User, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "friend_id") + var friend: User, + @Enumerated(EnumType.STRING) + @Column(nullable = false) + var requestStatus: FriendRequestStatus, +) diff --git a/user/src/main/kotlin/kpring/user/entity/FriendRequestStatus.kt b/user/src/main/kotlin/kpring/user/entity/FriendRequestStatus.kt new file mode 100644 index 00000000..a744b5d3 --- /dev/null +++ b/user/src/main/kotlin/kpring/user/entity/FriendRequestStatus.kt @@ -0,0 +1,7 @@ +package kpring.user.entity + +enum class FriendRequestStatus { + REQUESTED, // 친구신청을 보낸 상태 + RECEIVED, // 친구신청을 받은 상태 + ACCEPTED, // 친구신청을 수락한(친구가 된) 상태 +} diff --git a/user/src/main/kotlin/kpring/user/entity/User.kt b/user/src/main/kotlin/kpring/user/entity/User.kt index b3c5d763..02f71211 100644 --- a/user/src/main/kotlin/kpring/user/entity/User.kt +++ b/user/src/main/kotlin/kpring/user/entity/User.kt @@ -1,6 +1,7 @@ package kpring.user.entity import jakarta.persistence.* +import kpring.user.dto.request.UpdateUserProfileRequest @Entity @Table(name = "tb_user") @@ -14,24 +15,42 @@ class User( var email: String, @Column(nullable = false) var password: String, - @ManyToMany(fetch = FetchType.LAZY) - @JoinTable( - name = "user_followers", - joinColumns = [JoinColumn(name = "user_id")], - inverseJoinColumns = [JoinColumn(name = "follower_id")], - ) - val followers: MutableSet = mutableSetOf(), - @ManyToMany(mappedBy = "followers", fetch = FetchType.LAZY) - val followees: MutableSet = mutableSetOf(), + var file: String?, + @OneToMany(fetch = FetchType.LAZY, mappedBy = "user", cascade = [CascadeType.ALL]) + val friends: MutableSet = mutableSetOf(), // Other fields and methods... ) { - fun addFollower(follower: User) { - followers.add(follower) - follower.followees.add(this) + fun requestFriend(user: User) { + val friendRelation = + Friend( + user = this, + friend = user, + requestStatus = FriendRequestStatus.REQUESTED, + ) + friends.add(friendRelation) } - fun removeFollower(follower: User) { - followers.remove(follower) - follower.followees.remove(this) + fun receiveFriendRequest(user: User) { + val friendRelation = + Friend( + user = this, + friend = user, + requestStatus = FriendRequestStatus.RECEIVED, + ) + friends.add(friendRelation) + } + + fun updateInfo( + request: UpdateUserProfileRequest, + newPassword: String?, + file: String?, + ) { + request.email?.let { this.email = it } + request.username?.let { this.username = it } + newPassword?.let { this.password = it } + + if (this.file != null || file != null) { + this.file = file + } } } diff --git a/user/src/main/kotlin/kpring/user/exception/UserErrorCode.kt b/user/src/main/kotlin/kpring/user/exception/UserErrorCode.kt index d1270bc4..e0dea1b5 100644 --- a/user/src/main/kotlin/kpring/user/exception/UserErrorCode.kt +++ b/user/src/main/kotlin/kpring/user/exception/UserErrorCode.kt @@ -8,13 +8,17 @@ enum class UserErrorCode( val id: String, val message: String, ) : ErrorCode { - BAD_REQUEST(HttpStatus.BAD_REQUEST, "4000", "Invalid email"), - ALREADY_EXISTS_EMAIL(HttpStatus.BAD_REQUEST, "4001", "Email already exists"), - NOT_MATCH_PASSWORD(HttpStatus.BAD_REQUEST, "4002", "Password does not match"), - NOT_ALLOWED(HttpStatus.FORBIDDEN, "4003", "Not allowed"), // 권한이 없는 경우 + BAD_REQUEST(HttpStatus.BAD_REQUEST, "4000", "이메일 형식이 올바르지 않습니다."), + ALREADY_EXISTS_EMAIL(HttpStatus.BAD_REQUEST, "4001", "이미 존재하는 이메일입니다."), + NOT_ALLOWED(HttpStatus.FORBIDDEN, "4003", "권한이 없는 사용자입니다."), // 권한이 없는 경우 - INCORRECT_PASSWORD(HttpStatus.UNAUTHORIZED, "4010", "Incorrect password"), - USER_NOT_FOUND(HttpStatus.NOT_FOUND, "4011", "User not found"), + INCORRECT_PASSWORD(HttpStatus.UNAUTHORIZED, "4010", "비밀번호가 올바르지 않습니다."), + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "4011", "사용자를 찾을 수 없습니다."), + + EXTENSION_NOT_SUPPORTED(HttpStatus.BAD_REQUEST, "4020", "지원되지 않는 미디어 유형입니다."), + + ALREADY_FRIEND(HttpStatus.BAD_REQUEST, "4030", "이미 친구입니다."), + NOT_SELF_FOLLOW(HttpStatus.BAD_REQUEST, "4031", "자기자신에게 친구요청을 보낼 수 없습니다"), ; override fun message(): String = this.message diff --git a/user/src/main/kotlin/kpring/user/global/AuthValidator.kt b/user/src/main/kotlin/kpring/user/global/AuthValidator.kt new file mode 100644 index 00000000..51cc4c6c --- /dev/null +++ b/user/src/main/kotlin/kpring/user/global/AuthValidator.kt @@ -0,0 +1,27 @@ +package kpring.user.global + +import kpring.core.auth.dto.response.TokenInfo +import kpring.core.auth.enums.TokenType +import kpring.core.global.dto.response.ApiResponse +import kpring.core.global.exception.ServiceException +import kpring.user.exception.UserErrorCode +import org.springframework.stereotype.Component + +@Component +class AuthValidator() { + fun checkIfAccessTokenAndGetUserId(validationResult: ApiResponse): String { + if (validationResult.data!!.type != TokenType.ACCESS) { + throw ServiceException(UserErrorCode.BAD_REQUEST) + } + return validationResult.data!!.userId + } + + fun checkIfUserIsSelf( + userId: String, + validatedUserId: String, + ) { + if (userId != validatedUserId) { + throw ServiceException(UserErrorCode.NOT_ALLOWED) + } + } +} diff --git a/user/src/main/kotlin/kpring/user/global/SupportedMediaType.kt b/user/src/main/kotlin/kpring/user/global/SupportedMediaType.kt new file mode 100644 index 00000000..4bdf154a --- /dev/null +++ b/user/src/main/kotlin/kpring/user/global/SupportedMediaType.kt @@ -0,0 +1,6 @@ +package kpring.user.global + +enum class SupportedMediaType(val mediaType: String) { + IMAGE_PNG("image/png"), + IMAGE_JPEG("image/jpeg"), +} diff --git a/user/src/main/kotlin/kpring/user/repository/FriendRepository.kt b/user/src/main/kotlin/kpring/user/repository/FriendRepository.kt new file mode 100644 index 00000000..ee5bc406 --- /dev/null +++ b/user/src/main/kotlin/kpring/user/repository/FriendRepository.kt @@ -0,0 +1,17 @@ +package kpring.user.repository + +import kpring.user.entity.Friend +import kpring.user.entity.FriendRequestStatus +import org.springframework.data.jpa.repository.JpaRepository + +interface FriendRepository : JpaRepository { + fun existsByUserIdAndFriendId( + userId: Long, + friendId: Long, + ): Boolean + + fun findAllByUserIdAndRequestStatus( + userId: Long, + requestStatus: FriendRequestStatus, + ): List +} diff --git a/user/src/main/kotlin/kpring/user/service/FriendService.kt b/user/src/main/kotlin/kpring/user/service/FriendService.kt new file mode 100644 index 00000000..aa903558 --- /dev/null +++ b/user/src/main/kotlin/kpring/user/service/FriendService.kt @@ -0,0 +1,22 @@ +package kpring.user.service + +import kpring.user.dto.response.AddFriendResponse +import kpring.user.dto.response.DeleteFriendResponse +import kpring.user.dto.response.GetFriendRequestsResponse +import kpring.user.dto.response.GetFriendsResponse + +interface FriendService { + fun getFriendRequests(userId: Long): GetFriendRequestsResponse + + fun getFriends(userId: Long): GetFriendsResponse + + fun addFriend( + userId: Long, + friendId: Long, + ): AddFriendResponse + + fun deleteFriend( + userId: Long, + friendId: Long, + ): DeleteFriendResponse +} diff --git a/user/src/main/kotlin/kpring/user/service/FriendServiceImpl.kt b/user/src/main/kotlin/kpring/user/service/FriendServiceImpl.kt new file mode 100644 index 00000000..d1d7e16a --- /dev/null +++ b/user/src/main/kotlin/kpring/user/service/FriendServiceImpl.kt @@ -0,0 +1,76 @@ +package kpring.user.service + +import kpring.core.global.exception.ServiceException +import kpring.user.dto.response.* +import kpring.user.entity.Friend +import kpring.user.entity.FriendRequestStatus +import kpring.user.entity.User +import kpring.user.exception.UserErrorCode +import kpring.user.repository.FriendRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.stream.Collectors + +@Service +@Transactional +class FriendServiceImpl( + private val userServiceImpl: UserServiceImpl, + private val friendRepository: FriendRepository, +) : FriendService { + override fun getFriendRequests(userId: Long): GetFriendRequestsResponse { + val friendRelations: List = + friendRepository.findAllByUserIdAndRequestStatus(userId, FriendRequestStatus.RECEIVED) + val friendRequests: MutableList = mutableListOf() + + friendRelations.stream().map { friendRelation -> + val friend = friendRelation.friend + GetFriendRequestResponse(friend.id!!, friend.username) + }.collect(Collectors.toList()) + + return GetFriendRequestsResponse(userId, friendRequests) + } + + override fun getFriends(userId: Long): GetFriendsResponse { + TODO("Not yet implemented") + } + + override fun addFriend( + userId: Long, + friendId: Long, + ): AddFriendResponse { + val user = userServiceImpl.getUser(userId) + val friend = userServiceImpl.getUser(friendId) + + checkSelfFriend(user, friend) + checkFriendRelationExists(userId, friendId) + user.requestFriend(friend) + friend.receiveFriendRequest(user) + + return AddFriendResponse(friend.id!!) + } + + override fun deleteFriend( + userId: Long, + friendId: Long, + ): DeleteFriendResponse { + TODO("Not yet implemented") + } + + fun checkSelfFriend( + user: User, + friend: User, + ) { + if (user == friend) { + throw ServiceException(UserErrorCode.NOT_SELF_FOLLOW) + } + } + + fun checkFriendRelationExists( + userId: Long, + friendId: Long, + ) { + if (friendRepository.existsByUserIdAndFriendId(userId, friendId)) { + throw ServiceException(UserErrorCode.ALREADY_FRIEND) + } + } +} diff --git a/user/src/main/kotlin/kpring/user/service/UploadProfileImageService.kt b/user/src/main/kotlin/kpring/user/service/UploadProfileImageService.kt new file mode 100644 index 00000000..bdf44a5c --- /dev/null +++ b/user/src/main/kotlin/kpring/user/service/UploadProfileImageService.kt @@ -0,0 +1,47 @@ +package kpring.user.service + +import kpring.core.global.exception.ServiceException +import kpring.user.exception.UserErrorCode +import kpring.user.global.SupportedMediaType +import org.springframework.stereotype.Service +import org.springframework.web.multipart.MultipartFile +import java.nio.file.Files +import java.nio.file.Path +import java.text.SimpleDateFormat +import java.util.* + +@Service +class UploadProfileImageService { + public fun saveUploadedFile( + multipartFile: MultipartFile, + userId: Long, + savedPath: Path, + ): String { + if (Files.notExists(savedPath)) { + Files.createDirectories(savedPath) + } + val extension = multipartFile.originalFilename!!.substringAfterLast('.') + isFileExtensionSupported(multipartFile) + + val uniqueFileName = generateUniqueFileName(userId, extension) + val filePath = savedPath.resolve(uniqueFileName) + multipartFile.transferTo(filePath.toFile()) + + return uniqueFileName + } + + private fun isFileExtensionSupported(multipartFile: MultipartFile) { + val supportedExtensions = SupportedMediaType.entries.map { it.mediaType } + if (multipartFile.contentType !in supportedExtensions) { + throw ServiceException(UserErrorCode.EXTENSION_NOT_SUPPORTED) + } + } + + private fun generateUniqueFileName( + userId: Long, + extension: String, + ): String { + val timeStamp = SimpleDateFormat("yyMMddHHmmss").format(Date()) + return "$timeStamp$userId.$extension" + } +} diff --git a/user/src/main/kotlin/kpring/user/service/UserService.kt b/user/src/main/kotlin/kpring/user/service/UserService.kt index 53804828..7586102e 100644 --- a/user/src/main/kotlin/kpring/user/service/UserService.kt +++ b/user/src/main/kotlin/kpring/user/service/UserService.kt @@ -5,6 +5,7 @@ import kpring.user.dto.request.UpdateUserProfileRequest import kpring.user.dto.response.CreateUserResponse import kpring.user.dto.response.GetUserProfileResponse import kpring.user.dto.response.UpdateUserProfileResponse +import org.springframework.web.multipart.MultipartFile interface UserService { fun getProfile(userId: Long): GetUserProfileResponse @@ -12,6 +13,7 @@ interface UserService { fun updateProfile( userId: Long, request: UpdateUserProfileRequest, + multipartFile: MultipartFile, ): UpdateUserProfileResponse fun exitUser(userId: Long): Boolean diff --git a/user/src/main/kotlin/kpring/user/service/UserServiceImpl.kt b/user/src/main/kotlin/kpring/user/service/UserServiceImpl.kt index 9ab4893a..4f467467 100644 --- a/user/src/main/kotlin/kpring/user/service/UserServiceImpl.kt +++ b/user/src/main/kotlin/kpring/user/service/UserServiceImpl.kt @@ -9,9 +9,13 @@ import kpring.user.dto.response.UpdateUserProfileResponse import kpring.user.entity.User import kpring.user.exception.UserErrorCode import kpring.user.repository.UserRepository +import org.springframework.beans.factory.annotation.Value import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import org.springframework.web.multipart.MultipartFile +import java.nio.file.Files +import java.nio.file.Paths @Service @Transactional @@ -19,20 +23,42 @@ class UserServiceImpl( private val userRepository: UserRepository, private val passwordEncoder: PasswordEncoder, private val userValidationService: UserValidationService, + private val uploadProfileImageService: UploadProfileImageService, ) : UserService { - override fun getProfile(userId: Long): GetUserProfileResponse { - val user = - userRepository.findById(userId) - .orElseThrow { throw ServiceException(UserErrorCode.USER_NOT_FOUND) } + @Value("\${file.path.profile-dir}") + private lateinit var profileImgDirPath: String + override fun getProfile(userId: Long): GetUserProfileResponse { + val user = getUser(userId) return GetUserProfileResponse(user.id, user.email, user.username) } override fun updateProfile( userId: Long, request: UpdateUserProfileRequest, + multipartFile: MultipartFile, ): UpdateUserProfileResponse { - TODO("Not yet implemented") + var newPassword: String? = null + var uniqueFileName: String? = null + val dir = System.getProperty("user.dir") + val profileImgDir = Paths.get(dir) + val user = getUser(userId) + + request.email?.let { handleDuplicateEmail(it) } + request.newPassword?.let { + userValidationService.validateUserPassword(request.password, user.password) + newPassword = passwordEncoder.encode(it) + } + + if (!multipartFile.isEmpty) { + uniqueFileName = + uploadProfileImageService.saveUploadedFile(multipartFile, userId, profileImgDir) + } + val previousFile = user.file + user.updateInfo(request, newPassword, uniqueFileName) + previousFile?.let { profileImgDir.resolve(it) }?.let { Files.deleteIfExists(it) } + + return UpdateUserProfileResponse(user.email, user.username) } override fun exitUser(userId: Long): Boolean { @@ -50,6 +76,7 @@ class UserServiceImpl( email = request.email, password = password, username = request.username, + file = null, ), ) @@ -61,4 +88,9 @@ class UserServiceImpl( throw ServiceException(UserErrorCode.ALREADY_EXISTS_EMAIL) } } + + fun getUser(userId: Long): User { + return userRepository.findById(userId) + .orElseThrow { throw ServiceException(UserErrorCode.USER_NOT_FOUND) } + } } diff --git a/user/src/main/resources/application.yaml b/user/src/main/resources/application.yaml index 68b06624..6b03bfcd 100644 --- a/user/src/main/resources/application.yaml +++ b/user/src/main/resources/application.yaml @@ -14,3 +14,7 @@ spring: auth: url: ${AUTH_SERVICE_URL:http://localhost:8080/api/v1} + +file: + path: + profile-dir: / \ No newline at end of file diff --git a/user/src/test/kotlin/kpring/user/controller/FriendControllerTest.kt b/user/src/test/kotlin/kpring/user/controller/FriendControllerTest.kt new file mode 100644 index 00000000..0367a6bf --- /dev/null +++ b/user/src/test/kotlin/kpring/user/controller/FriendControllerTest.kt @@ -0,0 +1,347 @@ +package kpring.user.controller + +import com.fasterxml.jackson.databind.ObjectMapper +import com.ninjasquad.springmockk.MockkBean +import io.kotest.core.spec.style.DescribeSpec +import io.mockk.every +import io.mockk.junit5.MockKExtension +import kpring.core.auth.client.AuthClient +import kpring.core.auth.dto.response.TokenInfo +import kpring.core.auth.enums.TokenType +import kpring.core.global.dto.response.ApiResponse +import kpring.core.global.exception.ServiceException +import kpring.test.restdoc.dsl.restDoc +import kpring.test.restdoc.json.JsonDataType +import kpring.test.restdoc.json.JsonDataType.Strings +import kpring.user.dto.response.AddFriendResponse +import kpring.user.dto.response.FailMessageResponse +import kpring.user.dto.response.GetFriendRequestResponse +import kpring.user.dto.response.GetFriendRequestsResponse +import kpring.user.exception.UserErrorCode +import kpring.user.global.AuthValidator +import kpring.user.global.CommonTest +import kpring.user.service.FriendServiceImpl +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.restdocs.ManualRestDocumentation +import org.springframework.restdocs.operation.preprocess.Preprocessors +import org.springframework.restdocs.webtestclient.WebTestClientRestDocumentation +import org.springframework.test.web.servlet.client.MockMvcWebTestClient +import org.springframework.web.context.WebApplicationContext + +@WebMvcTest(controllers = [FriendController::class]) +@ExtendWith(value = [MockKExtension::class]) +internal class FriendControllerTest( + private val objectMapper: ObjectMapper, + webContext: WebApplicationContext, + @MockkBean val friendService: FriendServiceImpl, + @MockkBean val authValidator: AuthValidator, + @MockkBean val authClient: AuthClient, +) : DescribeSpec({ + + val restDocument = ManualRestDocumentation() + val webTestClient = + MockMvcWebTestClient.bindToApplicationContext(webContext) + .configureClient() + .filter( + WebTestClientRestDocumentation.documentationConfiguration(restDocument) + .operationPreprocessors() + .withRequestDefaults(Preprocessors.prettyPrint()) + .withResponseDefaults(Preprocessors.prettyPrint()), + ) + .build() + + beforeSpec { restDocument.beforeTest(this.javaClass, "friend controller") } + + afterSpec { restDocument.afterTest() } + + describe("친구신청 API") { + it("친구신청 성공") { + // given + val data = AddFriendResponse(friendId = CommonTest.TEST_FRIEND_ID) + val response = ApiResponse(data = data) + + every { authClient.getTokenInfo(any()) }.returns( + ApiResponse(data = TokenInfo(TokenType.ACCESS, CommonTest.TEST_USER_ID.toString())), + ) + every { + authValidator.checkIfAccessTokenAndGetUserId(any()) + } returns CommonTest.TEST_USER_ID.toString() + every { authValidator.checkIfUserIsSelf(any(), any()) } returns Unit + every { + friendService.addFriend(CommonTest.TEST_USER_ID, CommonTest.TEST_FRIEND_ID) + } returns data + + // when + val result = + webTestClient.post() + .uri( + "/api/v1/user/{userId}/friend/{friendId}", + CommonTest.TEST_USER_ID, + CommonTest.TEST_FRIEND_ID, + ) + .header("Authorization", CommonTest.TEST_TOKEN) + .exchange() + + // then + val docsRoot = + result + .expectStatus().isOk + .expectBody().json(objectMapper.writeValueAsString(response)) + + // docs + docsRoot + .restDoc( + identifier = "addFriend200", + description = "친구신청 API", + ) { + request { + path { + "userId" mean "사용자 아이디" + "friendId" mean "친구신청을 받은 사용자의 아이디" + } + header { "Authorization" mean "Bearer token" } + } + response { + body { + "data.friendId" type Strings mean "친구신청을 받은 사용자의 아이디" + } + } + } + } + + it("친구신청 실패 : 권한이 없는 토큰") { + // given + val response = + FailMessageResponse.builder().message(UserErrorCode.NOT_ALLOWED.message()).build() + every { authClient.getTokenInfo(any()) } throws ServiceException(UserErrorCode.NOT_ALLOWED) + + // when + val result = + webTestClient.post() + .uri( + "/api/v1/user/{userId}/friend/{friendId}", + CommonTest.TEST_USER_ID, + CommonTest.TEST_FRIEND_ID, + ) + .header("Authorization", CommonTest.TEST_TOKEN) + .exchange() + + // then + val docsRoot = + result + .expectStatus().isForbidden + .expectBody().json(objectMapper.writeValueAsString(response)) + + // docs + docsRoot + .restDoc( + identifier = "addFriend403", + description = "친구신청 API", + ) { + request { + path { + "userId" mean "사용자 아이디" + "friendId" mean "친구신청을 받은 사용자의 아이디" + } + header { "Authorization" mean "Bearer token" } + } + response { + body { + body { "message" type Strings mean "에러 메시지" } + } + } + } + } + + it("친구신청 실패 : 서버 내부 오류") { + // given + every { authClient.getTokenInfo(any()) } throws RuntimeException("서버 내부 오류") + val response = FailMessageResponse.serverError + + // when + val result = + webTestClient.post() + .uri( + "/api/v1/user/{userId}/friend/{friendId}", + CommonTest.TEST_USER_ID, + CommonTest.TEST_FRIEND_ID, + ) + .header("Authorization", CommonTest.TEST_TOKEN) + .exchange() + + // then + val docsRoot = + result + .expectStatus().isEqualTo(500) + .expectBody().json(objectMapper.writeValueAsString(response)) + + // docs + docsRoot + .restDoc( + identifier = "addFriend500", + description = "친구신청 API", + ) { + request { + path { + "userId" mean "사용자 아이디" + "friendId" mean "친구신청을 받은 사용자의 아이디" + } + header { "Authorization" mean "Bearer token" } + } + response { + body { + "message" type Strings mean "에러 메시지" + } + } + } + } + } + + describe("친구신청 조회 API") { + it("친구신청 조회 성공") { + // given + val friendRequest = + GetFriendRequestResponse( + friendId = CommonTest.TEST_FRIEND_ID, + username = CommonTest.TEST_FRIEND_USERNAME, + ) + val data = + GetFriendRequestsResponse( + userId = CommonTest.TEST_USER_ID, + friendRequests = mutableListOf(friendRequest), + ) + val response = ApiResponse(data = data) + + every { authClient.getTokenInfo(any()) }.returns( + ApiResponse(data = TokenInfo(TokenType.ACCESS, CommonTest.TEST_USER_ID.toString())), + ) + every { + authValidator.checkIfAccessTokenAndGetUserId(any()) + } returns CommonTest.TEST_USER_ID.toString() + every { authValidator.checkIfUserIsSelf(any(), any()) } returns Unit + every { + friendService.getFriendRequests(CommonTest.TEST_USER_ID) + } returns data + + // when + val result = + webTestClient.get() + .uri( + "/api/v1/user/{userId}/requests", + CommonTest.TEST_USER_ID, + ) + .header("Authorization", CommonTest.TEST_TOKEN) + .exchange() + + // then + val docsRoot = + result + .expectStatus().isOk + .expectBody().json(objectMapper.writeValueAsString(response)) + + // docs + docsRoot + .restDoc( + identifier = "getFriendRequests200", + description = "친구신청 조회 API", + ) { + request { + path { + "userId" mean "사용자 아이디" + } + header { "Authorization" mean "Bearer token" } + } + response { + body { + "data.userId" type Strings mean "사용자 아이디" + "data.friendRequests" type JsonDataType.Arrays mean "친구신청한 사용자 리스트" + "data.friendRequests[].friendId" type Strings mean "친구신청한 사용자 아이디" + "data.friendRequests[].username" type Strings mean "친구신청한 사용자 닉네임" + } + } + } + } + it("친구신청 조회 실패 : 권한이 없는 토큰") { + // given + val response = + FailMessageResponse.builder().message(UserErrorCode.NOT_ALLOWED.message()).build() + every { authClient.getTokenInfo(any()) } throws ServiceException(UserErrorCode.NOT_ALLOWED) + + // when + val result = + webTestClient.get() + .uri( + "/api/v1/user/{userId}/requests", + CommonTest.TEST_USER_ID, + ) + .header("Authorization", CommonTest.TEST_TOKEN) + .exchange() + + // then + val docsRoot = + result + .expectStatus().isForbidden + .expectBody().json(objectMapper.writeValueAsString(response)) + + // docs + docsRoot + .restDoc( + identifier = "getFriendRequests403", + description = "친구신청 조회 API", + ) { + request { + path { + "userId" mean "사용자 아이디" + } + header { "Authorization" mean "Bearer token" } + } + response { + body { + "message" type Strings mean "에러 메시지" + } + } + } + } + it("친구신청 조회 실패 : 서버 내부 오류") { + // given + val response = FailMessageResponse.serverError + every { authClient.getTokenInfo(any()) } throws RuntimeException("서버 내부 오류") + + // when + val result = + webTestClient.get() + .uri( + "/api/v1/user/{userId}/requests", + CommonTest.TEST_USER_ID, + ) + .header("Authorization", CommonTest.TEST_TOKEN) + .exchange() + + // then + val docsRoot = + result + .expectStatus().isEqualTo(500) + .expectBody().json(objectMapper.writeValueAsString(response)) + + // docs + docsRoot + .restDoc( + identifier = "getFriendRequests500", + description = "친구신청 조회 API", + ) { + request { + path { + "userId" mean "사용자 아이디" + } + header { "Authorization" mean "Bearer token" } + } + response { + body { + "message" type Strings mean "에러 메시지" + } + } + } + } + } + }) diff --git a/user/src/test/kotlin/kpring/user/controller/UserControllerTest.kt b/user/src/test/kotlin/kpring/user/controller/UserControllerTest.kt index d7e5fc8e..9b8f4fd5 100644 --- a/user/src/test/kotlin/kpring/user/controller/UserControllerTest.kt +++ b/user/src/test/kotlin/kpring/user/controller/UserControllerTest.kt @@ -6,10 +6,8 @@ import io.kotest.core.spec.style.DescribeSpec import io.mockk.clearMocks import io.mockk.every import io.mockk.junit5.MockKExtension -import io.mockk.verify import kpring.core.auth.client.AuthClient import kpring.core.auth.dto.response.TokenInfo -import kpring.core.auth.dto.response.TokenValidationResponse import kpring.core.auth.enums.TokenType import kpring.core.global.dto.response.ApiResponse import kpring.core.global.exception.ServiceException @@ -22,16 +20,25 @@ import kpring.user.dto.response.FailMessageResponse import kpring.user.dto.response.GetUserProfileResponse import kpring.user.dto.response.UpdateUserProfileResponse import kpring.user.exception.UserErrorCode +import kpring.user.global.AuthValidator +import kpring.user.global.CommonTest import kpring.user.service.UserService import org.junit.jupiter.api.extension.ExtendWith import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.core.io.ByteArrayResource +import org.springframework.core.io.ClassPathResource import org.springframework.http.HttpStatus import org.springframework.http.MediaType +import org.springframework.http.client.MultipartBodyBuilder +import org.springframework.mock.web.MockMultipartFile import org.springframework.restdocs.ManualRestDocumentation import org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint +import org.springframework.restdocs.payload.PayloadDocumentation.* +import org.springframework.restdocs.request.RequestDocumentation.* import org.springframework.restdocs.webtestclient.WebTestClientRestDocumentation.documentationConfiguration import org.springframework.test.web.servlet.client.MockMvcWebTestClient import org.springframework.web.context.WebApplicationContext +import org.springframework.web.reactive.function.BodyInserters @WebMvcTest(controllers = [UserController::class]) @ExtendWith(value = [MockKExtension::class]) @@ -40,6 +47,7 @@ class UserControllerTest( webContext: WebApplicationContext, @MockkBean val authClient: AuthClient, @MockkBean val userService: UserService, + @MockkBean val authValidator: AuthValidator, ) : DescribeSpec( { @@ -266,21 +274,53 @@ class UserControllerTest( it("회원정보 수정 성공") { // given val userId = 1L - val request = UpdateUserProfileRequest.builder().email("test@test.com").build() - val data = UpdateUserProfileResponse.builder().email("test@test.com").build() + val request = + UpdateUserProfileRequest.builder() + .email(TEST_EMAIL) + .username(TEST_USERNAME) + .password(TEST_PASSWORD) + .newPassword(TEST_NEW_PASSWORD) + .build() + + val fileResource = ClassPathResource(TEST_PROFILE_IMG) + val file = + MockMultipartFile( + "image", + fileResource.filename, + MediaType.IMAGE_JPEG_VALUE, + fileResource.inputStream, + ) + val data = + UpdateUserProfileResponse.builder() + .email(TEST_EMAIL) + .username(TEST_USERNAME) + .build() + + val requestJson = objectMapper.writeValueAsString(request) + val response = ApiResponse(data = data) - every { userService.updateProfile(userId, request) } returns data every { authClient.getTokenInfo(any()) }.returns( - ApiResponse(data = TokenInfo(TokenType.ACCESS, userId.toString())), + ApiResponse(data = TokenInfo(TokenType.ACCESS, CommonTest.TEST_USER_ID.toString())), ) + every { authValidator.checkIfAccessTokenAndGetUserId(any()) } returns userId.toString() + every { authValidator.checkIfUserIsSelf(any(), any()) } returns Unit + every { userService.updateProfile(userId, any(), any()) } returns data + + val bodyBuilder = MultipartBodyBuilder() + bodyBuilder.part("json", requestJson, MediaType.APPLICATION_JSON) + fileResource.filename?.let { + bodyBuilder.part("file", ByteArrayResource(file.bytes), MediaType.IMAGE_JPEG).filename( + it, + ) + } // when val result = webTestClient.patch() .uri("/api/v1/user/{userId}", userId) .header("Authorization", "Bearer token") - .contentType(MediaType.APPLICATION_JSON) - .bodyValue(request) + .contentType(MediaType.MULTIPART_FORM_DATA) + .body(BodyInserters.fromMultipartData(bodyBuilder.build())) .exchange() // then @@ -297,15 +337,28 @@ class UserControllerTest( ) { request { header { - "Content-Type" mean "application/json" + "Authorization" mean "Bearer token" + "Content-Type" mean "multipart/form-data" } - body { + path { + "userId" mean "사용자 아이디" + } + part { + "json" mean "회원정보 수정 요청 JSON" + "file" mean "프로필 이미지 파일" + } + part("json") { "email" type Strings mean "이메일" + "username" type Strings mean "닉네임" + "password" type Strings mean "기존 비밀번호" + "newPassword" type Strings mean "새 비밀번호" } } response { body { "data.email" type Strings mean "이메일" + "data.username" type Strings mean "닉네임" + "data.email" type Strings mean "이메일" } } } @@ -314,18 +367,45 @@ class UserControllerTest( it("회원정보 수정 실패 : 권한이 없는 토큰") { // given val userId = 1L - val request = UpdateUserProfileRequest.builder().email("test@test.com").build() + val request = + UpdateUserProfileRequest.builder() + .email(TEST_EMAIL) + .username(TEST_USERNAME) + .password(TEST_PASSWORD) + .newPassword(TEST_NEW_PASSWORD) + .build() + + val fileResource = ClassPathResource(TEST_PROFILE_IMG) + val file = + MockMultipartFile( + "image", + fileResource.filename, + MediaType.IMAGE_JPEG_VALUE, + fileResource.inputStream, + ) + + val requestJson = objectMapper.writeValueAsString(request) + val response = FailMessageResponse.builder().message(UserErrorCode.NOT_ALLOWED.message()).build() every { authClient.getTokenInfo(any()) } throws ServiceException(UserErrorCode.NOT_ALLOWED) + val bodyBuilder = + MultipartBodyBuilder() + bodyBuilder.part("json", requestJson, MediaType.APPLICATION_JSON) + fileResource.filename?.let { + bodyBuilder.part("file", ByteArrayResource(file.bytes), MediaType.IMAGE_JPEG).filename( + it, + ) + } + // when val result = webTestClient.patch() .uri("/api/v1/user/{userId}", userId) .header("Authorization", "Bearer token") - .contentType(MediaType.APPLICATION_JSON) - .bodyValue(request) + .contentType(MediaType.MULTIPART_FORM_DATA) + .body(BodyInserters.fromMultipartData(bodyBuilder.build())) .exchange() // then @@ -342,10 +422,21 @@ class UserControllerTest( ) { request { header { - "Content-Type" mean "application/json" + "Content-Type" mean "multipart/form-data" + "Authorization" mean "Bearer token" } - body { + path { + "userId" mean "사용자 아이디" + } + part { + "json" mean "회원정보 수정 요청 JSON" + "file" mean "프로필 이미지 파일" + } + part("json") { "email" type Strings mean "이메일" + "username" type Strings mean "닉네임" + "password" type Strings mean "기존 비밀번호" + "newPassword" type Strings mean "새 비밀번호" } } response { @@ -359,18 +450,43 @@ class UserControllerTest( it("회원정보 수정 실패 : 서버 내부 오류") { // given val userId = 1L - val token = "Bearer token" - val request = UpdateUserProfileRequest.builder().email("test@test.com").build() + val request = + UpdateUserProfileRequest.builder() + .email(TEST_EMAIL) + .username(TEST_USERNAME) + .password(TEST_PASSWORD) + .newPassword(TEST_NEW_PASSWORD) + .build() + + val fileResource = ClassPathResource(TEST_PROFILE_IMG) + val file = + MockMultipartFile( + "image", + fileResource.filename, + MediaType.IMAGE_JPEG_VALUE, + fileResource.inputStream, + ) + val requestJson = objectMapper.writeValueAsString(request) + val response = FailMessageResponse.serverError - every { authClient.getTokenInfo(token) } throws RuntimeException("서버 내부 오류") + every { authClient.getTokenInfo(any()) } throws RuntimeException("서버 내부 오류") + + val bodyBuilder = + MultipartBodyBuilder() + bodyBuilder.part("json", requestJson, MediaType.APPLICATION_JSON) + fileResource.filename?.let { + bodyBuilder.part("file", ByteArrayResource(file.bytes), MediaType.IMAGE_JPEG).filename( + it, + ) + } // when val result = webTestClient.patch() .uri("/api/v1/user/{userId}", userId) .header("Authorization", "Bearer token") - .contentType(MediaType.APPLICATION_JSON) - .bodyValue(request) + .contentType(MediaType.MULTIPART_FORM_DATA) + .body(BodyInserters.fromMultipartData(bodyBuilder.build())) .exchange() // then @@ -387,10 +503,20 @@ class UserControllerTest( ) { request { header { - "Content-Type" mean "application/json" + "Content-Type" mean "multipart/form-data" } - body { + path { + "userId" mean "사용자 아이디" + } + part { + "json" mean "회원정보 수정 요청 JSON" + "file" mean "프로필 이미지 파일" + } + part("json") { "email" type Strings mean "이메일" + "username" type Strings mean "닉네임" + "password" type Strings mean "기존 비밀번호" + "newPassword" type Strings mean "새 비밀번호" } } response { @@ -414,9 +540,10 @@ class UserControllerTest( .username(TEST_USERNAME) .build() val response = ApiResponse(data = data) - every { authClient.getTokenInfo(token) }.returns( - ApiResponse(data = TokenInfo(TokenType.ACCESS, userId.toString())), + every { authClient.getTokenInfo(any()) }.returns( + ApiResponse(data = TokenInfo(TokenType.ACCESS, CommonTest.TEST_USER_ID.toString())), ) + every { authValidator.checkIfAccessTokenAndGetUserId(any()) } returns userId.toString() every { userService.getProfile(userId) } returns data // when @@ -462,7 +589,7 @@ class UserControllerTest( val token = "Bearer test" val response = FailMessageResponse.builder().message(UserErrorCode.NOT_ALLOWED.message()).build() - every { authClient.getTokenInfo(token) } throws ServiceException(UserErrorCode.NOT_ALLOWED) + every { authClient.getTokenInfo(any()) } throws ServiceException(UserErrorCode.NOT_ALLOWED) // when val result = @@ -532,9 +659,11 @@ class UserControllerTest( it("탈퇴 성공") { // given val userId = 1L - val validationResponse = TokenValidationResponse(true, TokenType.ACCESS, userId.toString()) - every { authClient.getTokenInfo(any()) } returns - ApiResponse(data = TokenInfo(TokenType.ACCESS, userId.toString())) + every { authClient.getTokenInfo(any()) }.returns( + ApiResponse(data = TokenInfo(TokenType.ACCESS, CommonTest.TEST_USER_ID.toString())), + ) + every { authValidator.checkIfAccessTokenAndGetUserId(any()) } returns userId.toString() + every { authValidator.checkIfUserIsSelf(any(), any()) } returns Unit every { userService.exitUser(userId) } returns true // when @@ -545,7 +674,6 @@ class UserControllerTest( .exchange() // then - verify(exactly = 1) { authClient.getTokenInfo(any()) } val docsRoot = result .expectStatus().isOk @@ -569,7 +697,6 @@ class UserControllerTest( it("탈퇴 실패 : 권한이 없는 토큰") { // given val userId = 1L - val validationResponse = TokenValidationResponse(false, null, null) every { authClient.getTokenInfo(any()) } throws ServiceException(UserErrorCode.NOT_ALLOWED) // when @@ -580,7 +707,6 @@ class UserControllerTest( .exchange() // then - verify(exactly = 1) { authClient.getTokenInfo(any()) } val docsRoot = result .expectStatus().isForbidden @@ -602,8 +728,7 @@ class UserControllerTest( it("탈퇴 실패 : 서버 내부 오류") { // given val userId = 1L - val token = "Bearer token" - every { authClient.getTokenInfo(token) } throws RuntimeException("서버 내부 오류") + every { authClient.getTokenInfo(any()) } throws RuntimeException("서버 내부 오류") // when val result = @@ -613,7 +738,6 @@ class UserControllerTest( .exchange() // then - verify(exactly = 1) { authClient.getTokenInfo(any()) } val docsRoot = result .expectStatus().isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR) @@ -640,6 +764,8 @@ class UserControllerTest( companion object { private const val TEST_EMAIL = "test@email.com" private const val TEST_PASSWORD = "tesT@1234" + private const val TEST_NEW_PASSWORD = "tesT@1234!" private const val TEST_USERNAME = "testuser" + private const val TEST_PROFILE_IMG = "/images/profileImg" } } diff --git a/user/src/test/kotlin/kpring/user/global/CommonTest.kt b/user/src/test/kotlin/kpring/user/global/CommonTest.kt new file mode 100644 index 00000000..9c550963 --- /dev/null +++ b/user/src/test/kotlin/kpring/user/global/CommonTest.kt @@ -0,0 +1,15 @@ +package kpring.user.global + +interface CommonTest { + companion object { + const val TEST_USER_ID = 1L + const val TEST_EMAIL = "test@email.com" + const val TEST_PASSWORD = "Password123!" + const val TEST_ENCODED_PASSWORD = "EncodedPassword123!" + const val TEST_USERNAME = "test" + const val TEST_TOKEN = "Bearer test" + + const val TEST_FRIEND_ID = 2L + const val TEST_FRIEND_USERNAME = "friend" + } +} diff --git a/user/src/test/kotlin/kpring/user/service/FriendServiceImplTest.kt b/user/src/test/kotlin/kpring/user/service/FriendServiceImplTest.kt new file mode 100644 index 00000000..e5a24eaa --- /dev/null +++ b/user/src/test/kotlin/kpring/user/service/FriendServiceImplTest.kt @@ -0,0 +1,119 @@ +package kpring.user.service + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.mockk.* +import kpring.core.global.exception.ServiceException +import kpring.user.entity.Friend +import kpring.user.entity.FriendRequestStatus +import kpring.user.entity.User +import kpring.user.exception.UserErrorCode +import kpring.user.global.CommonTest +import kpring.user.repository.FriendRepository +import kpring.user.repository.UserRepository +import org.springframework.security.crypto.password.PasswordEncoder + +internal class FriendServiceImplTest : FunSpec({ + val userRepository: UserRepository = mockk() + val friendRepository: FriendRepository = mockk() + val passwordEncoder: PasswordEncoder = mockk() + val userValidationService: UserValidationService = mockk() + val uploadProfileImageService: UploadProfileImageService = mockk() + val userService = + UserServiceImpl( + userRepository, + passwordEncoder, + userValidationService, + uploadProfileImageService, + ) + val friendService = + FriendServiceImpl( + userService, + friendRepository, + ) + + test("친구신청_성공") { + val user = mockk(relaxed = true) + val friend = mockk(relaxed = true) + + every { userService.getUser(CommonTest.TEST_USER_ID) } returns user + every { userService.getUser(CommonTest.TEST_FRIEND_ID) } returns friend + + every { + friendRepository.existsByUserIdAndFriendId(CommonTest.TEST_USER_ID, CommonTest.TEST_FRIEND_ID) + } returns false + every { user.requestFriend(friend) } just Runs + every { friend.receiveFriendRequest(user) } just Runs + + val response = friendService.addFriend(CommonTest.TEST_USER_ID, CommonTest.TEST_FRIEND_ID) + response.friendId shouldBe friend.id + + verify { friendService.checkSelfFriend(user, friend) } + verify { + friendService.checkFriendRelationExists(CommonTest.TEST_USER_ID, CommonTest.TEST_FRIEND_ID) + } + } + + test("친구신청_실패_자기자신을 친구로 추가하는 케이스") { + val user = mockk(relaxed = true) + + every { userService.getUser(CommonTest.TEST_USER_ID) } returns user + every { + friendRepository.existsByUserIdAndFriendId(CommonTest.TEST_USER_ID, CommonTest.TEST_USER_ID) + } returns false + every { + friendService.checkSelfFriend(user, user) + } throws ServiceException(UserErrorCode.NOT_SELF_FOLLOW) + + val exception = + shouldThrow { + friendService.addFriend(CommonTest.TEST_USER_ID, CommonTest.TEST_USER_ID) + } + exception.errorCode.message() shouldBe "자기자신에게 친구요청을 보낼 수 없습니다" + + verify(exactly = 0) { user.requestFriend(any()) } + verify(exactly = 0) { user.receiveFriendRequest(any()) } + } + + test("친구신청_실패_이미 친구인 케이스") { + val user = mockk(relaxed = true) + val friend = mockk(relaxed = true) + + every { userService.getUser(CommonTest.TEST_USER_ID) } returns user + every { userService.getUser(CommonTest.TEST_FRIEND_ID) } returns friend + + every { + friendRepository.existsByUserIdAndFriendId(CommonTest.TEST_USER_ID, CommonTest.TEST_FRIEND_ID) + } returns true + every { + friendService.checkFriendRelationExists(CommonTest.TEST_USER_ID, CommonTest.TEST_FRIEND_ID) + } throws ServiceException(UserErrorCode.ALREADY_FRIEND) + + val exception = + shouldThrow { + friendService.addFriend(CommonTest.TEST_USER_ID, CommonTest.TEST_FRIEND_ID) + } + exception.errorCode.message() shouldBe "이미 친구입니다." + + verify { friendService.checkSelfFriend(user, friend) } + verify { + friendService.checkFriendRelationExists(CommonTest.TEST_USER_ID, CommonTest.TEST_FRIEND_ID) + } + } + + test("친구신청조회_성공") { + val friend = mockk(relaxed = true) + val friendList = listOf(mockk(relaxed = true)) + + every { + friendRepository.findAllByUserIdAndRequestStatus(CommonTest.TEST_USER_ID, FriendRequestStatus.RECEIVED) + } returns friendList + + val response = friendService.getFriendRequests(CommonTest.TEST_USER_ID) + for (request in response.friendRequests) { + request.friendId shouldBe friend.id + request.username shouldBe friend.username + } + } +}) diff --git a/user/src/test/kotlin/kpring/user/service/UserServiceImplTest.kt b/user/src/test/kotlin/kpring/user/service/UserServiceImplTest.kt index 96658129..ec4c34ca 100644 --- a/user/src/test/kotlin/kpring/user/service/UserServiceImplTest.kt +++ b/user/src/test/kotlin/kpring/user/service/UserServiceImplTest.kt @@ -16,13 +16,14 @@ class UserServiceImplTest : FunSpec({ val userRepository: UserRepository = mockk() val passwordEncoder: PasswordEncoder = mockk() val userValidationService: UserValidationService = mockk() + val uploadProfileImageService: UploadProfileImageService = mockk() val userService = UserServiceImpl( userRepository, passwordEncoder, userValidationService, + uploadProfileImageService, ) - val friendService = FriendService(userRepository) lateinit var createUserRequest: CreateUserRequest beforeTest { @@ -43,6 +44,7 @@ class UserServiceImplTest : FunSpec({ createUserRequest.username, createUserRequest.email, createUserRequest.password, + null, ) every { userService.handleDuplicateEmail(createUserRequest.email) } just Runs @@ -69,49 +71,10 @@ class UserServiceImplTest : FunSpec({ shouldThrow { userService.handleDuplicateEmail(createUserRequest.email) } - exception.errorCode.message() shouldBe "Email already exists" + exception.errorCode.message() shouldBe "이미 존재하는 이메일입니다." verify { userRepository.save(any()) wasNot Called } } - -// test("친구추가_성공") { -// val user = User(id = 1L, username = "user1", followers = mutableSetOf(), followees = mutableSetOf()) -// val friend = User(id = 2L, username = "user2", followers = mutableSetOf(), followees = mutableSetOf()) -// -// `when`(userRepository.findById(user.id!!)).thenReturn(Optional.of(user)) -// `when`(userRepository.findById(friend.id!!)).thenReturn(Optional.of(friend)) -// -// val result: AddFriendResponse = friendService.addFriend(AddFriendRequest(friend.id!!), user.id) -// -// assertEquals(friend.id!!, result.friendId) -// -// // Verify that the followers and followees relationships are updated -// user.followers.forEach { follower -> -// assertEquals(1, follower.followees.size) -// assertEquals(user, follower.followees.first()) -// } -// user.followees.forEach { followee -> -// assertEquals(1, followee.followers.size) -// assertEquals(user, followee.followers.first()) -// } -// -// verify(userRepository).findById(user.id!!) -// } -// -// test("친구추가실패_유저조회불가능케이스") { -// val userId = 2L -// val friendId = 1L -// -// `when`(userRepository.findById(userId)).thenReturn(Optional.empty()) -// -// val exception = assertThrows(IllegalArgumentException::class.java) { -// friendService.addFriend(AddFriendRequest(friendId), userId) -// } -// -// assertEquals("User not found", exception.message) -// -// verify(userRepository).findById(userId) -// } }) { companion object { private const val TEST_EMAIL = "test@email.com" diff --git a/user/src/test/resources/images/profileImg/testImg.jpg b/user/src/test/resources/images/profileImg/testImg.jpg new file mode 100644 index 00000000..0d4e6dee Binary files /dev/null and b/user/src/test/resources/images/profileImg/testImg.jpg differ