diff --git a/.env.example b/.env.example deleted file mode 100644 index 18efef2..0000000 --- a/.env.example +++ /dev/null @@ -1,32 +0,0 @@ -# πŸ”‘ ν™˜κ²½λ³€μˆ˜ μ„€μ • 예제 -# 이 νŒŒμΌμ„ .env둜 λ³΅μ‚¬ν•˜κ³  μ‹€μ œ 값을 μž…λ ₯ν•˜μ„Έμš” - -# πŸ€– OpenRouter AI API μ„€μ • -OPENROUTER_API_KEY=sk-or-your-openrouter-api-key-here -OPENROUTER_MODEL=openai/gpt-3.5-turbo - -# 🌦️ 기상청 API μ„€μ • (곡곡데이터포털 λ°œκΈ‰) -WEATHER_API_KEY=your-weather-api-key-here - -# πŸ”΄ Redis μ„œλ²„ μ„€μ • (캐싱 및 μ„Έμ…˜ μ €μž₯μ†Œ) -# 개발용: 둜컬 Redis μ„œλ²„ (docker run -d -p 6379:6379 redis:alpine) -REDIS_HOST=localhost -REDIS_PORT=6379 -REDIS_PASSWORD= - -# πŸ—„οΈ λ°μ΄ν„°λ² μ΄μŠ€ μ„€μ • (운영용) -# 개발 μ‹œμ—λŠ” H2 μ‚¬μš©ν•˜λ―€λ‘œ ν•„μš”μ—†μŒ -# DB_URL=jdbc:postgresql://localhost:5432/korea_travel_guide -# DB_USERNAME=your-db-username -# DB_PASSWORD=your-db-password - -# πŸ”§ 개발 λͺ¨λ“œ μ„€μ • -SPRING_PROFILES_ACTIVE=dev - -# πŸ” OAuth 2.0 Client Credentials -GOOGLE_CLIENT_ID=your-google-client-id -GOOGLE_CLIENT_SECRET=your-google-client-secret -NAVER_CLIENT_ID=your-naver-client-id -NAVER_CLIENT_SECRET=your-naver-client-secret -KAKAO_CLIENT_ID=your-kakao-client-id -KAKAO_CLIENT_SECRET=your-kakao-client-secret \ No newline at end of file diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/dto/ChatMessageResponse.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/dto/ChatMessageResponse.kt index 97f6468..cd261fa 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/dto/ChatMessageResponse.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/dto/ChatMessageResponse.kt @@ -1,14 +1,15 @@ package com.back.koreaTravelGuide.domain.userChat.chatmessage.dto import com.back.koreaTravelGuide.domain.userChat.chatmessage.entity.ChatMessage -import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime data class ChatMessageResponse( val id: Long?, val roomId: Long, val senderId: Long, val content: String, - val createdAt: Instant, + val createdAt: ZonedDateTime = ZonedDateTime.now(ZoneId.of("Asia/Seoul")), ) { companion object { fun from(message: ChatMessage): ChatMessageResponse { diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/entity/ChatMessage.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/entity/ChatMessage.kt index 991554a..cfad985 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/entity/ChatMessage.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/entity/ChatMessage.kt @@ -7,7 +7,8 @@ import jakarta.persistence.GenerationType import jakarta.persistence.Id import jakarta.persistence.Index import jakarta.persistence.Table -import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime @Entity @Table( @@ -24,5 +25,5 @@ data class ChatMessage( @Column(nullable = false, columnDefinition = "text") val content: String, @Column(name = "created_at", nullable = false) - val createdAt: Instant = Instant.now(), + val createdAt: ZonedDateTime = ZonedDateTime.now(ZoneId.of("Asia/Seoul")), ) diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/controller/ChatRoomController.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/controller/ChatRoomController.kt index 6ff7328..553b123 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/controller/ChatRoomController.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/controller/ChatRoomController.kt @@ -27,7 +27,7 @@ class ChatRoomController( @RequestBody req: ChatRoomStartRequest, ): ResponseEntity> { val authenticatedId = requesterId ?: throw AccessDeniedException("인증이 ν•„μš”ν•©λ‹ˆλ‹€.") - val room = roomService.checkOneToOneRoom(req.guideId, req.userId, authenticatedId) + val room = roomService.createOneToOneRoom(req.guideId, req.userId, authenticatedId) return ResponseEntity.ok(ApiResponse(msg = "μ±„νŒ…λ°© μ‹œμž‘", data = ChatRoomResponse.from(room))) } diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/dto/ChatRoomResponse.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/dto/ChatRoomResponse.kt index af1c46b..f128142 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/dto/ChatRoomResponse.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/dto/ChatRoomResponse.kt @@ -1,14 +1,15 @@ package com.back.koreaTravelGuide.domain.userChat.chatroom.dto import com.back.koreaTravelGuide.domain.userChat.chatroom.entity.ChatRoom -import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime data class ChatRoomResponse( val id: Long?, val title: String, val guideId: Long, val userId: Long, - val updatedAt: Instant, + val updatedAt: ZonedDateTime = ZonedDateTime.now(ZoneId.of("Asia/Seoul")), val lastMessageId: Long?, ) { companion object { diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/entity/ChatRoom.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/entity/ChatRoom.kt index 09fa508..93e5009 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/entity/ChatRoom.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/entity/ChatRoom.kt @@ -7,7 +7,8 @@ import jakarta.persistence.GenerationType import jakarta.persistence.Id import jakarta.persistence.Index import jakarta.persistence.Table -import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime @Entity @Table( @@ -24,7 +25,7 @@ data class ChatRoom( @Column(name = "user_id", nullable = false) val userId: Long, @Column(name = "updated_at", nullable = false) - val updatedAt: Instant = Instant.now(), + val updatedAt: ZonedDateTime = ZonedDateTime.now(ZoneId.of("Asia/Seoul")), @Column(name = "last_message_id") val lastMessageId: Long? = null, ) diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/service/ChatRoomService.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/service/ChatRoomService.kt index e9dcf1d..82ae3f0 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/service/ChatRoomService.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/service/ChatRoomService.kt @@ -6,7 +6,8 @@ import com.back.koreaTravelGuide.domain.userChat.chatroom.repository.ChatRoomRep import org.springframework.security.access.AccessDeniedException import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime import java.util.NoSuchElementException @Service @@ -15,7 +16,7 @@ class ChatRoomService( private val messageRepository: ChatMessageRepository, ) { @Transactional - fun checkOneToOneRoom( + fun createOneToOneRoom( guideId: Long, userId: Long, requesterId: Long, @@ -27,7 +28,12 @@ class ChatRoomService( // 2) μ—†μœΌλ©΄ 생성 (λ™μ‹œμš”μ²­μ€ DB μœ λ‹ˆν¬ 인덱슀둜 κ°€λ“œ) val title = "Guide-$guideId Β· User-$userId" return roomRepository.save( - ChatRoom(title = title, guideId = guideId, userId = userId, updatedAt = Instant.now()), + ChatRoom( + title = title, + guideId = guideId, + userId = userId, + updatedAt = ZonedDateTime.now(ZoneId.of("Asia/Seoul")), + ), ) } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ae847e2..ecd6acd 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -130,7 +130,7 @@ springdoc: # Weather API μ„€μ • weather: api: - key: ${WEATHER__API__KEY} + key: ${WEATHER_API_KEY} base-url: https://apihub.kma.go.kr/api/typ02/openApi/MidFcstInfoService # Tour API μ„€μ • diff --git a/src/test/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/controller/ChatMessageControllerTest.kt b/src/test/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/controller/ChatMessageControllerTest.kt new file mode 100644 index 0000000..6b97461 --- /dev/null +++ b/src/test/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/controller/ChatMessageControllerTest.kt @@ -0,0 +1,159 @@ +package com.back.koreaTravelGuide.domain.userChat.chatmessage.controller + +import com.back.koreaTravelGuide.common.security.JwtTokenProvider +import com.back.koreaTravelGuide.domain.user.entity.User +import com.back.koreaTravelGuide.domain.user.enums.UserRole +import com.back.koreaTravelGuide.domain.user.repository.UserRepository +import com.back.koreaTravelGuide.domain.userChat.chatmessage.dto.ChatMessageSendRequest +import com.back.koreaTravelGuide.domain.userChat.chatmessage.entity.ChatMessage +import com.back.koreaTravelGuide.domain.userChat.chatmessage.repository.ChatMessageRepository +import com.back.koreaTravelGuide.domain.userChat.chatroom.entity.ChatRoom +import com.back.koreaTravelGuide.domain.userChat.chatroom.repository.ChatRoomRepository +import com.fasterxml.jackson.databind.ObjectMapper +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional + +@ActiveProfiles("test") +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class ChatMessageControllerTest { + @Autowired + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var chatMessageRepository: ChatMessageRepository + + @Autowired + private lateinit var chatRoomRepository: ChatRoomRepository + + @Autowired + private lateinit var userRepository: UserRepository + + @Autowired + private lateinit var jwtTokenProvider: JwtTokenProvider + + @Autowired + private lateinit var objectMapper: ObjectMapper + + private lateinit var guide: User + private lateinit var guest: User + private lateinit var guideToken: String + private lateinit var guestToken: String + private lateinit var room: ChatRoom + private lateinit var firstMessage: ChatMessage + + @BeforeEach + fun setUp() { + chatMessageRepository.deleteAll() + chatRoomRepository.deleteAll() + userRepository.deleteAll() + guide = + userRepository.save( + User( + email = "guide2@test.com", + nickname = "guideTwo", + role = UserRole.GUIDE, + oauthProvider = "test", + oauthId = "guide456", + ), + ) + guest = + userRepository.save( + User( + email = "guest2@test.com", + nickname = "guestTwo", + role = UserRole.USER, + oauthProvider = "test", + oauthId = "guest456", + ), + ) + guideToken = jwtTokenProvider.createAccessToken(guide.id!!, guide.role) + guestToken = jwtTokenProvider.createAccessToken(guest.id!!, guest.role) + room = + chatRoomRepository.save( + ChatRoom( + title = "Guide-${guide.id} Β· User-${guest.id}", + guideId = guide.id!!, + userId = guest.id!!, + ), + ) + firstMessage = + chatMessageRepository.save( + ChatMessage( + roomId = room.id!!, + senderId = guide.id!!, + content = "첫 λ©”μ„Έμ§€", + ), + ) + } + + @Test + @DisplayName("listMessages returns latest batch when after is missing") + fun listMessagesReturnsLatest() { + mockMvc.perform( + get("/api/userchat/rooms/${room.id}/messages") + .header("Authorization", "Bearer $guestToken"), + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data[0].id").value(firstMessage.id!!.toInt())) + .andExpect(jsonPath("$.data[0].content").value(firstMessage.content)) + } + + @Test + @DisplayName("listMessages filters newer messages when after provided") + fun listMessagesFiltersNewer() { + val newer = + chatMessageRepository.save( + ChatMessage( + roomId = room.id!!, + senderId = guest.id!!, + content = "두 번째", + ), + ) + + mockMvc.perform( + get("/api/userchat/rooms/${room.id}/messages") + .param("after", firstMessage.id!!.toString()) + .header("Authorization", "Bearer $guestToken"), + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].id").value(newer.id!!.toInt())) + .andExpect(jsonPath("$.data[0].content").value(newer.content)) + } + + @Test + @DisplayName("sendMessage stores message and updates room summary") + fun sendMessageStoresMessage() { + val before = chatMessageRepository.count() + val body = objectMapper.writeValueAsString(ChatMessageSendRequest("μƒˆ λ©”μ„Έμ§€")) + + mockMvc.perform( + post("/api/userchat/rooms/${room.id}/messages") + .header("Authorization", "Bearer $guestToken") + .contentType(MediaType.APPLICATION_JSON) + .content(body), + ) + .andExpect(status().isCreated) + .andExpect(jsonPath("$.data.content").value("μƒˆ λ©”μ„Έμ§€")) + + val messages = chatMessageRepository.findAll() + assertEquals(before + 1, messages.size.toLong()) + val latest = messages.maxByOrNull { it.id ?: 0L }!! + assertEquals(latest.id, chatRoomRepository.findById(room.id!!).get().lastMessageId) + } +} diff --git a/src/test/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/controller/ChatRoomControllerTest.kt b/src/test/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/controller/ChatRoomControllerTest.kt new file mode 100644 index 0000000..ec43791 --- /dev/null +++ b/src/test/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/controller/ChatRoomControllerTest.kt @@ -0,0 +1,128 @@ +package com.back.koreaTravelGuide.domain.userChat.chatroom.controller + +import com.back.koreaTravelGuide.common.security.JwtTokenProvider +import com.back.koreaTravelGuide.domain.user.entity.User +import com.back.koreaTravelGuide.domain.user.enums.UserRole +import com.back.koreaTravelGuide.domain.user.repository.UserRepository +import com.back.koreaTravelGuide.domain.userChat.chatroom.dto.ChatRoomStartRequest +import com.back.koreaTravelGuide.domain.userChat.chatroom.entity.ChatRoom +import com.back.koreaTravelGuide.domain.userChat.chatroom.repository.ChatRoomRepository +import com.fasterxml.jackson.databind.ObjectMapper +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional + +@ActiveProfiles("test") +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class ChatRoomControllerTest { + @Autowired + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var chatRoomRepository: ChatRoomRepository + + @Autowired + private lateinit var userRepository: UserRepository + + @Autowired + private lateinit var jwtTokenProvider: JwtTokenProvider + + @Autowired + private lateinit var objectMapper: ObjectMapper + + private lateinit var guide: User + private lateinit var guest: User + private lateinit var guideToken: String + private lateinit var guestToken: String + private lateinit var existingRoom: ChatRoom + + @BeforeEach + fun setUp() { + chatRoomRepository.deleteAll() + userRepository.deleteAll() + guide = + userRepository.save( + User( + email = "guide@test.com", + nickname = "guideUser", + role = UserRole.GUIDE, + oauthProvider = "test", + oauthId = "guide123", + ), + ) + guest = + userRepository.save( + User( + email = "guest@test.com", + nickname = "guestUser", + role = UserRole.USER, + oauthProvider = "test", + oauthId = "guest123", + ), + ) + guideToken = jwtTokenProvider.createAccessToken(guide.id!!, guide.role) + guestToken = jwtTokenProvider.createAccessToken(guest.id!!, guest.role) + existingRoom = + chatRoomRepository.save( + ChatRoom( + title = "Guide-${guide.id} Β· User-${guest.id}", + guideId = guide.id!!, + userId = guest.id!!, + ), + ) + } + + @Test + @DisplayName("startChat returns existing room for participant") + fun startChatReturnsExistingRoom() { + val body = objectMapper.writeValueAsString(ChatRoomStartRequest(guide.id!!, guest.id!!)) + mockMvc.perform( + post("/api/userchat/rooms/start") + .header("Authorization", "Bearer $guestToken") + .contentType(MediaType.APPLICATION_JSON) + .content(body), + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.id").value(existingRoom.id!!.toInt())) + .andExpect(jsonPath("$.data.guideId").value(guide.id!!.toInt())) + .andExpect(jsonPath("$.data.userId").value(guest.id!!.toInt())) + } + + @Test + @DisplayName("get returns room for participant") + fun getReturnsRoomForParticipant() { + mockMvc.perform( + get("/api/userchat/rooms/${existingRoom.id}") + .header("Authorization", "Bearer $guideToken"), + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.id").value(existingRoom.id!!.toInt())) + .andExpect(jsonPath("$.data.title").value(existingRoom.title)) + } + + @Test + @DisplayName("delete removes room when requester is owner") + fun deleteRemovesRoomWhenOwner() { + mockMvc.perform( + delete("/api/userchat/rooms/${existingRoom.id}") + .header("Authorization", "Bearer $guestToken"), + ) + .andExpect(status().isOk) + assertFalse(chatRoomRepository.existsById(existingRoom.id!!)) + } +}