Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.nexters.gamss.conversation.controller.dto.MessageResponse
import com.nexters.gamss.conversation.controller.dto.ReplyGenerationResponse
import com.nexters.gamss.conversation.controller.dto.SaveMessageRequest
import com.nexters.gamss.conversation.controller.dto.SaveMessageResponse
import com.nexters.gamss.conversation.controller.dto.UpdateConversationTitleRequest
import com.nexters.gamss.conversation.controller.dto.toResponseStatus
import com.nexters.gamss.conversation.service.CommentGenerationService
import com.nexters.gamss.conversation.service.ConversationService
Expand All @@ -21,6 +22,7 @@ import org.springframework.format.annotation.DateTimeFormat
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PatchMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
Expand Down Expand Up @@ -113,6 +115,30 @@ class ConversationController(
return ApiResponse.success(ConversationResponse.from(conversation))
}

@Operation(
summary = "채팅방 제목 지정/수정",
description =
"채팅방 제목을 지정하거나 변경합니다. 첫 생성 시 제목은 없으며(null), 이 API로 여러 번 수정할 수 있습니다.\n\n" +
"**실패 응답**\n\n" +
"| error.code | HTTP | 설명 |\n" +
"|---|---|---|\n" +
"| UNAUTHORIZED | 401 | 인증 필요 |\n" +
"| INVALID_INPUT | 400 | title 누락 |\n" +
"| INVALID_CONVERSATION_TITLE | 400 | 제목이 비어 있거나 100자 초과 |\n" +
"| CONVERSATION_NOT_FOUND | 404 | 존재하지 않는 채팅방 |\n" +
"| CONVERSATION_ACCESS_DENIED | 403 | 본인 채팅방이 아님 |\n" +
"| CONVERSATION_ALREADY_DELETED | 409 | 삭제된 채팅방 |",
)
@PatchMapping("/{conversationId}/title")
fun updateTitle(
@Parameter(hidden = true) @AuthenticationPrincipal principal: AuthPrincipal,
@PathVariable conversationId: Long,
@Valid @RequestBody request: UpdateConversationTitleRequest,
): ApiResponse<ConversationResponse> {
val conversation = conversationService.updateTitle(principal.memberId, conversationId, request.title)
return ApiResponse.success(ConversationResponse.from(conversation))
}

@Operation(
summary = "채팅방 메시지 전체 조회",
description =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import java.time.Instant
data class ConversationResponse(
@field:Schema(description = "채팅방 ID", example = "1")
val id: Long,
@field:Schema(description = "채팅방 제목. 아직 지정하지 않았으면 null", example = "비 오는 날의 짜증", nullable = true)
val title: String?,
@field:Schema(description = "채팅방 상태", example = "ACTIVE", allowableValues = ["ACTIVE", "ENDED", "DELETED"])
val status: String,
@field:Schema(description = "생성 일시")
Expand All @@ -18,6 +20,7 @@ data class ConversationResponse(
fun from(conversation: Conversation): ConversationResponse =
ConversationResponse(
id = conversation.id,
title = conversation.title?.value,
status = conversation.status.name,
createdAt = conversation.createdAt,
updatedAt = conversation.updatedAt,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.nexters.gamss.conversation.controller.dto

import io.swagger.v3.oas.annotations.media.Schema

/**
* 제목 유효성(공백·길이)은 [com.nexters.gamss.conversation.domain.ConversationTitle] 값 객체가 단일 검증한다.
* 그래서 여기서는 별도 제약을 두지 않는다 — 공백·초과는 INVALID_CONVERSATION_TITLE, 필드 누락은 INVALID_INPUT.
*/
data class UpdateConversationTitleRequest(
@field:Schema(description = "지정할 채팅방 제목(1~100자)", example = "비 오는 날의 짜증")
val title: String,
Comment thread
theminjunchoi marked this conversation as resolved.
)
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.nexters.gamss.emotion.domain.EmotionType
import com.nexters.gamss.global.exception.BusinessException
import com.nexters.gamss.global.exception.ErrorCode
import jakarta.persistence.Column
import jakarta.persistence.Embedded
import jakarta.persistence.Entity
import jakarta.persistence.EntityListeners
import jakarta.persistence.EnumType
Expand Down Expand Up @@ -32,6 +33,10 @@ class Conversation(
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0L

@Embedded
var title: ConversationTitle? = null
protected set

@Enumerated(EnumType.STRING)
@Column(name = "status", length = 20, nullable = false)
var status: ConversationStatus = ConversationStatus.ACTIVE
Expand Down Expand Up @@ -64,6 +69,12 @@ class Conversation(
status = ConversationStatus.DELETED
}

/** 채팅방 제목을 지정·변경한다. 여러 번 호출할 수 있으며, 삭제된 방은 변경할 수 없다. */
fun rename(title: ConversationTitle) {
ensureNotDeleted()
this.title = title
}

/** 종료된 채팅방에는 사용자 메시지를 추가할 수 없다. */
fun ensureActive() {
ensureNotDeleted()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.nexters.gamss.conversation.domain

import com.nexters.gamss.global.exception.BusinessException
import com.nexters.gamss.global.exception.ErrorCode
import jakarta.persistence.Column
import jakarta.persistence.Embeddable

/**
* 대화방 제목 값 객체. 앞뒤 공백을 정리하고 비어 있지 않음·최대 길이 불변식을 스스로 보장한다.
* (카드 요약과는 무관한 독립 개념이다.)
*/
@Embeddable
class ConversationTitle(
value: String,
) {
@Column(name = "title", length = MAX_LENGTH)
val value: String = value.trim()

init {
validate(this.value)
}

private fun validate(value: String) {
if (value.isEmpty()) {
throw BusinessException(ErrorCode.INVALID_CONVERSATION_TITLE, "채팅방 제목은 비어 있을 수 없습니다.")
}
if (value.length > MAX_LENGTH) {
throw BusinessException(ErrorCode.INVALID_CONVERSATION_TITLE, "채팅방 제목은 ${MAX_LENGTH}자 이하여야 합니다.")
}
}

override fun equals(other: Any?): Boolean {
if (this === other) {
return true
}
if (other !is ConversationTitle) {
return false
}
return value == other.value
}

override fun hashCode(): Int = value.hashCode()

override fun toString(): String = value

companion object {
const val MAX_LENGTH = 100
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.nexters.gamss.conversation.service

import com.nexters.gamss.conversation.domain.Conversation
import com.nexters.gamss.conversation.domain.ConversationTitle
import com.nexters.gamss.conversation.domain.Message
import com.nexters.gamss.conversation.domain.SenderType
import com.nexters.gamss.conversation.repository.ConversationRepository
Expand Down Expand Up @@ -67,6 +68,22 @@ class ConversationService(
return conversation
}

/**
* 채팅방 제목을 지정·변경한다. 여러 번 호출할 수 있다. 삭제된 방은 변경할 수 없다.
* 제목도 상태를 바꾸는 요청이라 행 잠금([getOwnedConversationForUpdate])을 쓴다 — 잠금 없이
* stale 상태로 읽으면 동시 삭제가 flush 로 되살아나거나(모든 컬럼 UPDATE) 삭제된 방의 제목이 바뀔 수 있다.
*/
@Transactional
fun updateTitle(
memberId: Long,
conversationId: Long,
title: String,
): Conversation {
val conversation = getOwnedConversationForUpdate(conversationId, memberId)
conversation.rename(ConversationTitle(title))
return conversation
}

/** 답장 대상 메시지가 실제로 해당 채팅방에 존재하는지 확인한다. */
private fun validateReplyTarget(
repliesToMessageId: Long,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ enum class ErrorCode(
CONVERSATION_ALREADY_DELETED(HttpStatus.CONFLICT, "이미 삭제된 채팅방입니다."),
CONVERSATION_ENDED(HttpStatus.CONFLICT, "종료된 채팅방에는 메시지를 추가할 수 없습니다."),
CONVERSATION_NOT_ENDED(HttpStatus.CONFLICT, "종료된 채팅방에만 카드를 만들 수 있습니다."),
INVALID_CONVERSATION_TITLE(HttpStatus.BAD_REQUEST, "사용할 수 없는 채팅방 제목입니다."),

// 댓글 생성
MESSAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "메시지를 찾을 수 없습니다."),
Expand Down
2 changes: 2 additions & 0 deletions src/main/resources/db/migration/V11__conversation_title.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- 대화방 제목. 첫 생성 시에는 없고(null), 이후 클라이언트가 지정한다(여러 번 수정 가능).
ALTER TABLE conversations ADD COLUMN title VARCHAR(100) NULL;
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfig
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.delete
import org.springframework.test.web.servlet.get
import org.springframework.test.web.servlet.patch
import org.springframework.test.web.servlet.post
import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder
import org.springframework.test.web.servlet.setup.MockMvcBuilders
Expand All @@ -36,6 +37,7 @@ import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import kotlin.test.assertEquals
import kotlin.test.assertNull

@SpringBootTest
@Import(TestcontainersConfig::class, FakeCommentGeneratorConfig::class)
Expand Down Expand Up @@ -702,5 +704,132 @@ class ConversationControllerIntegrationTest {
}
}

@Test
fun `새로 만든 채팅방의 제목은 null이다`() {
val member = memberRepository.save(Member("me@a.com"))
val conversation = conversationRepository.save(Conversation(member.id))

assertNull(conversationRepository.findById(conversation.id).get().title)
}

@Test
fun `제목을 지정하면 응답과 저장소에 반영된다`() {
val member = memberRepository.save(Member("me@a.com"))
val conversation = conversationRepository.save(Conversation(member.id))

mockMvc
.patch("/api/conversations/${conversation.id}/title") {
header(HttpHeaders.AUTHORIZATION, bearerFor(member))
contentType = MediaType.APPLICATION_JSON
content = """{"title":"비 오는 날의 짜증"}"""
}.andExpect {
status { isOk() }
jsonPath("$.data.id") { value(conversation.id) }
jsonPath("$.data.title") { value("비 오는 날의 짜증") }
}

assertEquals(
"비 오는 날의 짜증",
conversationRepository
.findById(conversation.id)
.get()
.title
?.value,
)
}

@Test
fun `제목을 여러 번 수정할 수 있다`() {
val member = memberRepository.save(Member("me@a.com"))
val conversation = conversationRepository.save(Conversation(member.id))

fun patchTitle(title: String) =
mockMvc.patch("/api/conversations/${conversation.id}/title") {
header(HttpHeaders.AUTHORIZATION, bearerFor(member))
contentType = MediaType.APPLICATION_JSON
content = """{"title":"$title"}"""
}

patchTitle("첫 제목").andExpect { status { isOk() } }
patchTitle("바꾼 제목").andExpect {
status { isOk() }
jsonPath("$.data.title") { value("바꾼 제목") }
}

assertEquals(
"바꾼 제목",
conversationRepository
.findById(conversation.id)
.get()
.title
?.value,
)
}

@Test
fun `제목이 100자를 넘으면 INVALID_CONVERSATION_TITLE`() {
val member = memberRepository.save(Member("me@a.com"))
val conversation = conversationRepository.save(Conversation(member.id))

mockMvc
.patch("/api/conversations/${conversation.id}/title") {
header(HttpHeaders.AUTHORIZATION, bearerFor(member))
contentType = MediaType.APPLICATION_JSON
content = """{"title":"${"가".repeat(101)}"}"""
}.andExpect {
status { isBadRequest() }
jsonPath("$.error.code") { value("INVALID_CONVERSATION_TITLE") }
}
}

@Test
fun `공백만 있는 제목이면 INVALID_CONVERSATION_TITLE`() {
val member = memberRepository.save(Member("me@a.com"))
val conversation = conversationRepository.save(Conversation(member.id))

mockMvc
.patch("/api/conversations/${conversation.id}/title") {
header(HttpHeaders.AUTHORIZATION, bearerFor(member))
contentType = MediaType.APPLICATION_JSON
content = """{"title":" "}"""
}.andExpect {
status { isBadRequest() }
jsonPath("$.error.code") { value("INVALID_CONVERSATION_TITLE") }
}
}

@Test
fun `남의 채팅방 제목을 수정하면 403을 반환한다`() {
val me = memberRepository.save(Member("me@a.com"))
val other = memberRepository.save(Member("other@a.com"))
val othersConversation = conversationRepository.save(Conversation(other.id))

mockMvc
.patch("/api/conversations/${othersConversation.id}/title") {
header(HttpHeaders.AUTHORIZATION, bearerFor(me))
contentType = MediaType.APPLICATION_JSON
content = """{"title":"침입"}"""
}.andExpect {
status { isForbidden() }
jsonPath("$.error.code") { value("CONVERSATION_ACCESS_DENIED") }
}
}

@Test
fun `삭제된 채팅방 제목을 수정하면 409를 반환한다`() {
val member = memberRepository.save(Member("me@a.com"))
val conversation = conversationRepository.save(Conversation(member.id).apply { delete() })

mockMvc
.patch("/api/conversations/${conversation.id}/title") {
header(HttpHeaders.AUTHORIZATION, bearerFor(member))
contentType = MediaType.APPLICATION_JSON
content = """{"title":"바꿔보자"}"""
}.andExpect {
status { isConflict() }
jsonPath("$.error.code") { value("CONVERSATION_ALREADY_DELETED") }
}
}

private fun bearerFor(member: Member): String = "Bearer ${jwtIssuer.issueAccessToken(member.id)}"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.nexters.gamss.conversation.domain

import com.nexters.gamss.global.exception.BusinessException
import com.nexters.gamss.global.exception.ErrorCode
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith

class ConversationTitleTest {
@Test
fun `유효한 값으로 제목을 생성한다`() {
assertEquals("비 오는 날의 짜증", ConversationTitle("비 오는 날의 짜증").value)
}

@Test
fun `앞뒤 공백을 제거한다`() {
assertEquals("제목", ConversationTitle(" 제목 ").value)
}

@Test
fun `최대 길이까지 허용한다`() {
val value = "가".repeat(ConversationTitle.MAX_LENGTH)

assertEquals(value, ConversationTitle(value).value)
}

@Test
fun `공백만 있으면 INVALID_CONVERSATION_TITLE`() {
val exception = assertFailsWith<BusinessException> { ConversationTitle(" ") }

assertEquals(ErrorCode.INVALID_CONVERSATION_TITLE, exception.errorCode)
}

@Test
fun `최대 길이를 넘으면 INVALID_CONVERSATION_TITLE`() {
val tooLong = "가".repeat(ConversationTitle.MAX_LENGTH + 1)

val exception = assertFailsWith<BusinessException> { ConversationTitle(tooLong) }

assertEquals(ErrorCode.INVALID_CONVERSATION_TITLE, exception.errorCode)
}
}