From 87e7c899009bcab6ed4e07a4ddcf3de741f028f0 Mon Sep 17 00:00:00 2001 From: hej090224 Date: Fri, 31 Jul 2026 20:58:42 +0900 Subject: [PATCH 01/15] refactor: #17 :: use ProfileVisibility enum instead of a string for member profile visibility --- .../mudda/domain/auth/application/impl/LoginAuthService.kt | 4 ++-- .../team/cklob/mudda/domain/member/domain/entity/Member.kt | 4 +++- .../mudda/domain/member/domain/type/ProfileVisibility.kt | 7 +++++++ .../domain/auth/application/impl/LoginAuthServiceTest.kt | 3 ++- .../domain/auth/application/impl/SignupAuthServiceTest.kt | 3 ++- .../auth/application/impl/WithdrawAuthServiceTest.kt | 3 ++- 6 files changed, 18 insertions(+), 6 deletions(-) create mode 100644 src/main/kotlin/team/cklob/mudda/domain/member/domain/type/ProfileVisibility.kt diff --git a/src/main/kotlin/team/cklob/mudda/domain/auth/application/impl/LoginAuthService.kt b/src/main/kotlin/team/cklob/mudda/domain/auth/application/impl/LoginAuthService.kt index 9925780..7167460 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/auth/application/impl/LoginAuthService.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/auth/application/impl/LoginAuthService.kt @@ -10,6 +10,7 @@ import team.cklob.mudda.domain.auth.presentation.response.LoginAuthResponse import team.cklob.mudda.domain.member.domain.entity.Member import team.cklob.mudda.domain.member.domain.repository.MemberRepository import team.cklob.mudda.domain.member.domain.type.OAuthProvider +import team.cklob.mudda.domain.member.domain.type.ProfileVisibility import team.cklob.mudda.global.exception.AuthException import team.cklob.mudda.global.exception.ErrorCode import team.cklob.mudda.global.security.JwtTokenProvider @@ -60,11 +61,10 @@ class LoginAuthService( email = userInfo.email, oauthProvider = userInfo.provider, providerId = userInfo.providerId, - profileVisibility = DEFAULT_PROFILE_VISIBILITY, + profileVisibility = ProfileVisibility.PUBLIC, ) private companion object { - const val DEFAULT_PROFILE_VISIBILITY = "PUBLIC" const val WITHDRAWAL_GRACE_PERIOD_DAYS = 30L } } diff --git a/src/main/kotlin/team/cklob/mudda/domain/member/domain/entity/Member.kt b/src/main/kotlin/team/cklob/mudda/domain/member/domain/entity/Member.kt index 2649052..18fa8f9 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/member/domain/entity/Member.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/member/domain/entity/Member.kt @@ -10,6 +10,7 @@ import jakarta.persistence.Id import jakarta.persistence.Table import team.cklob.mudda.domain.member.domain.type.Gender import team.cklob.mudda.domain.member.domain.type.OAuthProvider +import team.cklob.mudda.domain.member.domain.type.ProfileVisibility import team.cklob.mudda.global.common.entity.BaseTimeEntity import java.time.LocalDateTime @@ -47,8 +48,9 @@ class Member( @Column(length = 100) var bio: String? = null, + @Enumerated(EnumType.STRING) @Column(name = "profile_visibility", nullable = false, length = 20) - var profileVisibility: String, + var profileVisibility: ProfileVisibility, @Column(name = "withdrawn_at") var withdrawnAt: LocalDateTime? = null, diff --git a/src/main/kotlin/team/cklob/mudda/domain/member/domain/type/ProfileVisibility.kt b/src/main/kotlin/team/cklob/mudda/domain/member/domain/type/ProfileVisibility.kt new file mode 100644 index 0000000..cdd0006 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/member/domain/type/ProfileVisibility.kt @@ -0,0 +1,7 @@ +package team.cklob.mudda.domain.member.domain.type + +enum class ProfileVisibility { + PUBLIC, + FRIEND, + PRIVATE, +} diff --git a/src/test/kotlin/team/cklob/mudda/domain/auth/application/impl/LoginAuthServiceTest.kt b/src/test/kotlin/team/cklob/mudda/domain/auth/application/impl/LoginAuthServiceTest.kt index 573c1dc..c0211e7 100644 --- a/src/test/kotlin/team/cklob/mudda/domain/auth/application/impl/LoginAuthServiceTest.kt +++ b/src/test/kotlin/team/cklob/mudda/domain/auth/application/impl/LoginAuthServiceTest.kt @@ -15,6 +15,7 @@ import team.cklob.mudda.domain.auth.presentation.request.LoginAuthRequest import team.cklob.mudda.domain.member.domain.entity.Member import team.cklob.mudda.domain.member.domain.repository.MemberRepository import team.cklob.mudda.domain.member.domain.type.OAuthProvider +import team.cklob.mudda.domain.member.domain.type.ProfileVisibility import team.cklob.mudda.global.exception.AuthException import team.cklob.mudda.global.security.JwtTokenProvider import java.time.LocalDateTime @@ -32,7 +33,7 @@ class LoginAuthServiceTest { private fun member(id: Long, withdrawnAt: LocalDateTime? = null, providerId: String = "google-sub-1") = Member( email = "user@example.com", oauthProvider = OAuthProvider.GOOGLE, providerId = providerId, - profileVisibility = "PUBLIC", withdrawnAt = withdrawnAt, id = id, + profileVisibility = ProfileVisibility.PUBLIC, withdrawnAt = withdrawnAt, id = id, ) @Test fun `issues tokens for an existing fully signed up member`() { diff --git a/src/test/kotlin/team/cklob/mudda/domain/auth/application/impl/SignupAuthServiceTest.kt b/src/test/kotlin/team/cklob/mudda/domain/auth/application/impl/SignupAuthServiceTest.kt index 936cfeb..af016b9 100644 --- a/src/test/kotlin/team/cklob/mudda/domain/auth/application/impl/SignupAuthServiceTest.kt +++ b/src/test/kotlin/team/cklob/mudda/domain/auth/application/impl/SignupAuthServiceTest.kt @@ -10,6 +10,7 @@ import team.cklob.mudda.domain.member.domain.entity.Member import team.cklob.mudda.domain.member.domain.repository.MemberRepository import team.cklob.mudda.domain.member.domain.type.Gender import team.cklob.mudda.domain.member.domain.type.OAuthProvider +import team.cklob.mudda.domain.member.domain.type.ProfileVisibility import team.cklob.mudda.global.exception.BusinessException import team.cklob.mudda.global.exception.ErrorCode import java.util.Optional @@ -21,7 +22,7 @@ class SignupAuthServiceTest { private val request = SignupAuthRequest(name = "name", nickname = "nickname", gender = Gender.MALE, birthYear = 2000) private fun incompleteMember(): Member = - Member(email = "user@example.com", oauthProvider = OAuthProvider.GOOGLE, providerId = "google-sub-1", profileVisibility = "PUBLIC", id = 1L) + Member(email = "user@example.com", oauthProvider = OAuthProvider.GOOGLE, providerId = "google-sub-1", profileVisibility = ProfileVisibility.PUBLIC, id = 1L) @Test fun `completes signup for an incomplete member`() { val member = incompleteMember() diff --git a/src/test/kotlin/team/cklob/mudda/domain/auth/application/impl/WithdrawAuthServiceTest.kt b/src/test/kotlin/team/cklob/mudda/domain/auth/application/impl/WithdrawAuthServiceTest.kt index cff636a..2e0cc29 100644 --- a/src/test/kotlin/team/cklob/mudda/domain/auth/application/impl/WithdrawAuthServiceTest.kt +++ b/src/test/kotlin/team/cklob/mudda/domain/auth/application/impl/WithdrawAuthServiceTest.kt @@ -11,6 +11,7 @@ import team.cklob.mudda.domain.auth.application.RefreshTokenStore import team.cklob.mudda.domain.member.domain.entity.Member import team.cklob.mudda.domain.member.domain.repository.MemberRepository import team.cklob.mudda.domain.member.domain.type.OAuthProvider +import team.cklob.mudda.domain.member.domain.type.ProfileVisibility import team.cklob.mudda.global.security.AccessTokenBlacklist import team.cklob.mudda.global.security.JwtTokenProvider import java.time.Duration @@ -26,7 +27,7 @@ class WithdrawAuthServiceTest { @Test fun `soft deletes and anonymizes the member, then revokes tokens`() { val member = Member( name = "name", nickname = "nickname", email = "user@example.com", - oauthProvider = OAuthProvider.GOOGLE, providerId = "google-sub-1", profileVisibility = "PUBLIC", id = 1L, + oauthProvider = OAuthProvider.GOOGLE, providerId = "google-sub-1", profileVisibility = ProfileVisibility.PUBLIC, id = 1L, ) every { memberRepository.findById(1L) } returns Optional.of(member) every { jwtTokenProvider.getJti("access-token") } returns "jti-1" From 2986d414e89805f6f6ad570a8153f7db98070b5f Mon Sep 17 00:00:00 2001 From: hej090224 Date: Fri, 31 Jul 2026 20:58:46 +0900 Subject: [PATCH 02/15] feat: #17 :: add member not-found and profile access error codes --- src/main/kotlin/team/cklob/mudda/global/exception/ErrorCode.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/kotlin/team/cklob/mudda/global/exception/ErrorCode.kt b/src/main/kotlin/team/cklob/mudda/global/exception/ErrorCode.kt index 302707c..01e9444 100644 --- a/src/main/kotlin/team/cklob/mudda/global/exception/ErrorCode.kt +++ b/src/main/kotlin/team/cklob/mudda/global/exception/ErrorCode.kt @@ -13,5 +13,7 @@ enum class ErrorCode(val status: HttpStatus, val code: String, val message: Stri WITHDRAWN_MEMBER(HttpStatus.FORBIDDEN, "A007", "This account has been withdrawn."), ALREADY_SIGNED_UP(HttpStatus.CONFLICT, "A008", "This member has already completed signup."), NICKNAME_ALREADY_EXISTS(HttpStatus.CONFLICT, "M001", "Nickname already exists."), + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "M002", "Member not found."), + PROFILE_ACCESS_DENIED(HttpStatus.FORBIDDEN, "M003", "You do not have access to this profile."), CAPSULE_NOT_FOUND(HttpStatus.NOT_FOUND, "T001", "Time capsule not found."), } From cbeb1914e79acd7a9dc3cb147ec91a8d89e5145b Mon Sep 17 00:00:00 2001 From: hej090224 Date: Fri, 31 Jul 2026 20:58:49 +0900 Subject: [PATCH 03/15] feat: #17 :: add bidirectional friend relation lookup to friend repository --- .../domain/friend/domain/repository/FriendRepository.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/kotlin/team/cklob/mudda/domain/friend/domain/repository/FriendRepository.kt b/src/main/kotlin/team/cklob/mudda/domain/friend/domain/repository/FriendRepository.kt index 54d59d4..96e7df2 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/friend/domain/repository/FriendRepository.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/friend/domain/repository/FriendRepository.kt @@ -8,4 +8,11 @@ interface FriendRepository : JpaRepository { fun findByRequesterIdOrReceiverId(requesterId: Long, receiverId: Long): List fun findByRequesterIdAndReceiverId(requesterId: Long, receiverId: Long): Optional fun existsByRequesterIdAndReceiverId(requesterId: Long, receiverId: Long): Boolean + + fun findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId( + requesterId1: Long, + receiverId1: Long, + requesterId2: Long, + receiverId2: Long, + ): Optional } From ee90d26d0effe44d9266eb71ee8121d6cbf132c5 Mon Sep 17 00:00:00 2001 From: hej090224 Date: Fri, 31 Jul 2026 20:58:55 +0900 Subject: [PATCH 04/15] feat: #17 :: implement member info query and update apis --- .../member/application/ProfileAccessPolicy.kt | 13 +++++ .../impl/GetMemberProfileService.kt | 45 +++++++++++++++++ .../application/impl/GetMyMemberService.kt | 20 ++++++++ .../application/impl/UpdateMyMemberService.kt | 48 +++++++++++++++++++ .../controller/MemberController.kt | 42 ++++++++++++++++ .../request/UpdateMyMemberRequest.kt | 33 +++++++++++++ .../response/MemberProfileResponse.kt | 32 +++++++++++++ .../presentation/response/MyMemberResponse.kt | 34 +++++++++++++ 8 files changed, 267 insertions(+) create mode 100644 src/main/kotlin/team/cklob/mudda/domain/member/application/ProfileAccessPolicy.kt create mode 100644 src/main/kotlin/team/cklob/mudda/domain/member/application/impl/GetMemberProfileService.kt create mode 100644 src/main/kotlin/team/cklob/mudda/domain/member/application/impl/GetMyMemberService.kt create mode 100644 src/main/kotlin/team/cklob/mudda/domain/member/application/impl/UpdateMyMemberService.kt create mode 100644 src/main/kotlin/team/cklob/mudda/domain/member/presentation/controller/MemberController.kt create mode 100644 src/main/kotlin/team/cklob/mudda/domain/member/presentation/request/UpdateMyMemberRequest.kt create mode 100644 src/main/kotlin/team/cklob/mudda/domain/member/presentation/response/MemberProfileResponse.kt create mode 100644 src/main/kotlin/team/cklob/mudda/domain/member/presentation/response/MyMemberResponse.kt diff --git a/src/main/kotlin/team/cklob/mudda/domain/member/application/ProfileAccessPolicy.kt b/src/main/kotlin/team/cklob/mudda/domain/member/application/ProfileAccessPolicy.kt new file mode 100644 index 0000000..8726d38 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/member/application/ProfileAccessPolicy.kt @@ -0,0 +1,13 @@ +package team.cklob.mudda.domain.member.application + +import team.cklob.mudda.domain.friend.domain.type.FriendStatus +import team.cklob.mudda.domain.member.domain.type.ProfileVisibility + +object ProfileAccessPolicy { + fun canView(visibility: ProfileVisibility, isSelf: Boolean, friendStatus: FriendStatus): Boolean = when { + isSelf -> true + visibility == ProfileVisibility.PUBLIC -> true + visibility == ProfileVisibility.FRIEND -> friendStatus == FriendStatus.FRIEND + else -> false + } +} diff --git a/src/main/kotlin/team/cklob/mudda/domain/member/application/impl/GetMemberProfileService.kt b/src/main/kotlin/team/cklob/mudda/domain/member/application/impl/GetMemberProfileService.kt new file mode 100644 index 0000000..6ae77d4 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/member/application/impl/GetMemberProfileService.kt @@ -0,0 +1,45 @@ +package team.cklob.mudda.domain.member.application.impl + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import team.cklob.mudda.domain.friend.domain.repository.FriendRepository +import team.cklob.mudda.domain.friend.domain.type.FriendRequestStatus +import team.cklob.mudda.domain.friend.domain.type.FriendStatus +import team.cklob.mudda.domain.member.application.ProfileAccessPolicy +import team.cklob.mudda.domain.member.domain.repository.MemberRepository +import team.cklob.mudda.domain.member.presentation.response.MemberProfileResponse +import team.cklob.mudda.global.exception.BusinessException +import team.cklob.mudda.global.exception.ErrorCode + +@Service +class GetMemberProfileService( + private val memberRepository: MemberRepository, + private val friendRepository: FriendRepository, +) { + @Transactional(readOnly = true) + fun execute(viewerId: Long, memberId: Long): MemberProfileResponse { + val member = memberRepository.findById(memberId).orElseThrow { BusinessException(ErrorCode.MEMBER_NOT_FOUND) } + if (member.withdrawnAt != null || member.nickname == null) throw BusinessException(ErrorCode.MEMBER_NOT_FOUND) + + val isSelf = viewerId == memberId + val friendStatus = if (isSelf) FriendStatus.NONE else resolveFriendStatus(viewerId, memberId) + + if (!ProfileAccessPolicy.canView(member.profileVisibility, isSelf, friendStatus)) { + throw BusinessException(ErrorCode.PROFILE_ACCESS_DENIED) + } + + return MemberProfileResponse.of(member, friendStatus) + } + + private fun resolveFriendStatus(viewerId: Long, memberId: Long): FriendStatus { + val friend = friendRepository + .findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(viewerId, memberId, memberId, viewerId) + .orElse(null) ?: return FriendStatus.NONE + + return when (friend.status) { + FriendRequestStatus.ACCEPTED -> FriendStatus.FRIEND + FriendRequestStatus.REJECTED -> FriendStatus.NONE + FriendRequestStatus.PENDING -> if (friend.requester.id == viewerId) FriendStatus.REQUESTED else FriendStatus.RECEIVED + } + } +} diff --git a/src/main/kotlin/team/cklob/mudda/domain/member/application/impl/GetMyMemberService.kt b/src/main/kotlin/team/cklob/mudda/domain/member/application/impl/GetMyMemberService.kt new file mode 100644 index 0000000..65ae985 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/member/application/impl/GetMyMemberService.kt @@ -0,0 +1,20 @@ +package team.cklob.mudda.domain.member.application.impl + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import team.cklob.mudda.domain.member.domain.repository.MemberRepository +import team.cklob.mudda.domain.member.presentation.response.MyMemberResponse +import team.cklob.mudda.global.exception.BusinessException +import team.cklob.mudda.global.exception.ErrorCode + +@Service +class GetMyMemberService( + private val memberRepository: MemberRepository, +) { + @Transactional(readOnly = true) + fun execute(memberId: Long): MyMemberResponse { + val member = memberRepository.findById(memberId).orElseThrow { BusinessException(ErrorCode.MEMBER_NOT_FOUND) } + if (member.withdrawnAt != null) throw BusinessException(ErrorCode.MEMBER_NOT_FOUND) + return MyMemberResponse.from(member) + } +} diff --git a/src/main/kotlin/team/cklob/mudda/domain/member/application/impl/UpdateMyMemberService.kt b/src/main/kotlin/team/cklob/mudda/domain/member/application/impl/UpdateMyMemberService.kt new file mode 100644 index 0000000..9a8a6ad --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/member/application/impl/UpdateMyMemberService.kt @@ -0,0 +1,48 @@ +package team.cklob.mudda.domain.member.application.impl + +import org.springframework.dao.DataIntegrityViolationException +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import team.cklob.mudda.domain.member.domain.repository.MemberRepository +import team.cklob.mudda.domain.member.presentation.request.UpdateMyMemberRequest +import team.cklob.mudda.domain.member.presentation.response.MyMemberResponse +import team.cklob.mudda.global.exception.BusinessException +import team.cklob.mudda.global.exception.ErrorCode + +@Service +class UpdateMyMemberService( + private val memberRepository: MemberRepository, +) { + @Transactional + fun execute(memberId: Long, request: UpdateMyMemberRequest): MyMemberResponse { + if (request.isEmpty()) throw BusinessException(ErrorCode.INVALID_INPUT) + + val member = memberRepository.findById(memberId).orElseThrow { BusinessException(ErrorCode.MEMBER_NOT_FOUND) } + if (member.withdrawnAt != null) throw BusinessException(ErrorCode.MEMBER_NOT_FOUND) + + request.name?.let { + if (it.isBlank()) throw BusinessException(ErrorCode.INVALID_INPUT) + member.name = it + } + request.nickname?.let { nickname -> + if (nickname.isBlank()) throw BusinessException(ErrorCode.INVALID_INPUT) + if (nickname != member.nickname && memberRepository.existsByNickname(nickname)) { + throw BusinessException(ErrorCode.NICKNAME_ALREADY_EXISTS) + } + member.nickname = nickname + } + request.gender?.let { member.gender = it } + request.birthYear?.let { member.birthYear = it } + request.profileImageUrl?.let { member.profileImageUrl = it.ifEmpty { null } } + request.bio?.let { member.bio = it.ifEmpty { null } } + request.profileVisibility?.let { member.profileVisibility = it } + + val saved = try { + memberRepository.saveAndFlush(member) + } catch (e: DataIntegrityViolationException) { + throw BusinessException(ErrorCode.NICKNAME_ALREADY_EXISTS) + } + + return MyMemberResponse.from(saved) + } +} diff --git a/src/main/kotlin/team/cklob/mudda/domain/member/presentation/controller/MemberController.kt b/src/main/kotlin/team/cklob/mudda/domain/member/presentation/controller/MemberController.kt new file mode 100644 index 0000000..61d05eb --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/member/presentation/controller/MemberController.kt @@ -0,0 +1,42 @@ +package team.cklob.mudda.domain.member.presentation.controller + +import jakarta.validation.Valid +import org.springframework.http.ResponseEntity +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.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import team.cklob.mudda.domain.member.application.impl.GetMemberProfileService +import team.cklob.mudda.domain.member.application.impl.GetMyMemberService +import team.cklob.mudda.domain.member.application.impl.UpdateMyMemberService +import team.cklob.mudda.domain.member.presentation.request.UpdateMyMemberRequest +import team.cklob.mudda.domain.member.presentation.response.MemberProfileResponse +import team.cklob.mudda.domain.member.presentation.response.MyMemberResponse +import team.cklob.mudda.global.response.ApiResponse +import team.cklob.mudda.global.security.LoginUser + +@RestController +@RequestMapping("/api/v1/member") +class MemberController( + private val getMyMemberService: GetMyMemberService, + private val updateMyMemberService: UpdateMyMemberService, + private val getMemberProfileService: GetMemberProfileService, +) { + @GetMapping("/me") + fun getMe(@LoginUser memberId: Long): ResponseEntity> = + ResponseEntity.ok(ApiResponse.success(getMyMemberService.execute(memberId))) + + @PatchMapping("/me") + fun updateMe( + @LoginUser memberId: Long, + @Valid @RequestBody request: UpdateMyMemberRequest, + ): ResponseEntity> = ResponseEntity.ok(ApiResponse.success(updateMyMemberService.execute(memberId, request))) + + @GetMapping("/{memberId}") + fun getProfile( + @LoginUser viewerId: Long, + @PathVariable memberId: Long, + ): ResponseEntity> = ResponseEntity.ok(ApiResponse.success(getMemberProfileService.execute(viewerId, memberId))) +} diff --git a/src/main/kotlin/team/cklob/mudda/domain/member/presentation/request/UpdateMyMemberRequest.kt b/src/main/kotlin/team/cklob/mudda/domain/member/presentation/request/UpdateMyMemberRequest.kt new file mode 100644 index 0000000..35075a7 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/member/presentation/request/UpdateMyMemberRequest.kt @@ -0,0 +1,33 @@ +package team.cklob.mudda.domain.member.presentation.request + +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.Size +import team.cklob.mudda.domain.member.domain.type.Gender +import team.cklob.mudda.domain.member.domain.type.ProfileVisibility + +data class UpdateMyMemberRequest( + @field:Size(max = 30) + val name: String? = null, + + @field:Size(max = 30) + val nickname: String? = null, + + val gender: Gender? = null, + + @field:Min(1900) + @field:Max(2100) + val birthYear: Int? = null, + + @field:Size(max = 255) + val profileImageUrl: String? = null, + + @field:Size(max = 100) + val bio: String? = null, + + val profileVisibility: ProfileVisibility? = null, +) { + fun isEmpty(): Boolean = + name == null && nickname == null && gender == null && birthYear == null && + profileImageUrl == null && bio == null && profileVisibility == null +} diff --git a/src/main/kotlin/team/cklob/mudda/domain/member/presentation/response/MemberProfileResponse.kt b/src/main/kotlin/team/cklob/mudda/domain/member/presentation/response/MemberProfileResponse.kt new file mode 100644 index 0000000..0f46783 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/member/presentation/response/MemberProfileResponse.kt @@ -0,0 +1,32 @@ +package team.cklob.mudda.domain.member.presentation.response + +import team.cklob.mudda.domain.friend.domain.type.FriendStatus +import team.cklob.mudda.domain.member.domain.entity.Member +import team.cklob.mudda.domain.member.domain.type.Gender +import java.time.LocalDateTime + +data class MemberProfileResponse( + val memberId: Long, + val name: String?, + val nickname: String?, + val gender: Gender?, + val birthYear: Int?, + val profileImageUrl: String?, + val bio: String?, + val friendStatus: FriendStatus, + val createdAt: LocalDateTime, +) { + companion object { + fun of(member: Member, friendStatus: FriendStatus) = MemberProfileResponse( + memberId = requireNotNull(member.id), + name = member.name, + nickname = member.nickname, + gender = member.gender, + birthYear = member.birthYear, + profileImageUrl = member.profileImageUrl, + bio = member.bio, + friendStatus = friendStatus, + createdAt = member.createdAt, + ) + } +} diff --git a/src/main/kotlin/team/cklob/mudda/domain/member/presentation/response/MyMemberResponse.kt b/src/main/kotlin/team/cklob/mudda/domain/member/presentation/response/MyMemberResponse.kt new file mode 100644 index 0000000..5cb1129 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/member/presentation/response/MyMemberResponse.kt @@ -0,0 +1,34 @@ +package team.cklob.mudda.domain.member.presentation.response + +import team.cklob.mudda.domain.member.domain.entity.Member +import team.cklob.mudda.domain.member.domain.type.Gender +import team.cklob.mudda.domain.member.domain.type.ProfileVisibility +import java.time.LocalDateTime + +data class MyMemberResponse( + val memberId: Long, + val name: String?, + val nickname: String?, + val gender: Gender?, + val birthYear: Int?, + val profileImageUrl: String?, + val bio: String?, + val profileVisibility: ProfileVisibility, + val createdAt: LocalDateTime, + val updatedAt: LocalDateTime, +) { + companion object { + fun from(member: Member) = MyMemberResponse( + memberId = requireNotNull(member.id), + name = member.name, + nickname = member.nickname, + gender = member.gender, + birthYear = member.birthYear, + profileImageUrl = member.profileImageUrl, + bio = member.bio, + profileVisibility = member.profileVisibility, + createdAt = member.createdAt, + updatedAt = member.updatedAt, + ) + } +} From f0931f3cbf803362557e3035d7866613d1c0be87 Mon Sep 17 00:00:00 2001 From: hej090224 Date: Fri, 31 Jul 2026 20:58:58 +0900 Subject: [PATCH 05/15] test: #17 :: add tests for member profile apis --- .../impl/GetMemberProfileServiceTest.kt | 152 ++++++++++++++++++ .../impl/GetMyMemberServiceTest.kt | 57 +++++++ .../impl/UpdateMyMemberServiceTest.kt | 140 ++++++++++++++++ .../controller/MemberControllerTest.kt | 145 +++++++++++++++++ 4 files changed, 494 insertions(+) create mode 100644 src/test/kotlin/team/cklob/mudda/domain/member/application/impl/GetMemberProfileServiceTest.kt create mode 100644 src/test/kotlin/team/cklob/mudda/domain/member/application/impl/GetMyMemberServiceTest.kt create mode 100644 src/test/kotlin/team/cklob/mudda/domain/member/application/impl/UpdateMyMemberServiceTest.kt create mode 100644 src/test/kotlin/team/cklob/mudda/domain/member/presentation/controller/MemberControllerTest.kt diff --git a/src/test/kotlin/team/cklob/mudda/domain/member/application/impl/GetMemberProfileServiceTest.kt b/src/test/kotlin/team/cklob/mudda/domain/member/application/impl/GetMemberProfileServiceTest.kt new file mode 100644 index 0000000..9145261 --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/domain/member/application/impl/GetMemberProfileServiceTest.kt @@ -0,0 +1,152 @@ +package team.cklob.mudda.domain.member.application.impl + +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import team.cklob.mudda.domain.friend.domain.entity.Friend +import team.cklob.mudda.domain.friend.domain.repository.FriendRepository +import team.cklob.mudda.domain.friend.domain.type.FriendRequestStatus +import team.cklob.mudda.domain.friend.domain.type.FriendStatus +import team.cklob.mudda.domain.member.domain.entity.Member +import team.cklob.mudda.domain.member.domain.repository.MemberRepository +import team.cklob.mudda.domain.member.domain.type.Gender +import team.cklob.mudda.domain.member.domain.type.OAuthProvider +import team.cklob.mudda.domain.member.domain.type.ProfileVisibility +import team.cklob.mudda.global.exception.BusinessException +import team.cklob.mudda.global.exception.ErrorCode +import java.time.LocalDateTime +import java.util.Optional + +class GetMemberProfileServiceTest { + private val memberRepository = mockk() + private val friendRepository = mockk() + private val service = GetMemberProfileService(memberRepository, friendRepository) + + private fun member(id: Long, visibility: ProfileVisibility, withdrawnAt: LocalDateTime? = null, nickname: String? = "nickname-$id") = Member( + name = "name-$id", nickname = nickname, email = "user$id@example.com", + oauthProvider = OAuthProvider.GOOGLE, providerId = "google-sub-$id", + gender = Gender.MALE, birthYear = 2000, + profileVisibility = visibility, withdrawnAt = withdrawnAt, id = id, + ) + + private fun mockNoFriendRelation() { + every { friendRepository.findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(1L, 2L, 2L, 1L) } returns Optional.empty() + } + + @Test fun `returns a PUBLIC profile for another member`() { + every { memberRepository.findById(2L) } returns Optional.of(member(2L, ProfileVisibility.PUBLIC)) + mockNoFriendRelation() + + val response = service.execute(1L, 2L) + + assertEquals(2L, response.memberId) + assertEquals(FriendStatus.NONE, response.friendStatus) + } + + @Test fun `allows viewing the caller's own PRIVATE profile`() { + every { memberRepository.findById(1L) } returns Optional.of(member(1L, ProfileVisibility.PRIVATE)) + + val response = service.execute(1L, 1L) + + assertEquals(1L, response.memberId) + assertEquals(FriendStatus.NONE, response.friendStatus) + } + + @Test fun `denies another member's PRIVATE profile`() { + every { memberRepository.findById(2L) } returns Optional.of(member(2L, ProfileVisibility.PRIVATE)) + mockNoFriendRelation() + + val exception = assertThrows(BusinessException::class.java) { service.execute(1L, 2L) } + assertEquals(ErrorCode.PROFILE_ACCESS_DENIED, exception.errorCode) + } + + @Test fun `allows a friend to view a FRIEND-visibility profile`() { + val friend = Friend(requester = member(1L, ProfileVisibility.PUBLIC), receiver = member(2L, ProfileVisibility.FRIEND), status = FriendRequestStatus.ACCEPTED, id = 10L) + every { memberRepository.findById(2L) } returns Optional.of(member(2L, ProfileVisibility.FRIEND)) + every { friendRepository.findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(1L, 2L, 2L, 1L) } returns Optional.of(friend) + + val response = service.execute(1L, 2L) + + assertEquals(FriendStatus.FRIEND, response.friendStatus) + } + + @Test fun `denies a non-friend viewing a FRIEND-visibility profile`() { + every { memberRepository.findById(2L) } returns Optional.of(member(2L, ProfileVisibility.FRIEND)) + mockNoFriendRelation() + + val exception = assertThrows(BusinessException::class.java) { service.execute(1L, 2L) } + assertEquals(ErrorCode.PROFILE_ACCESS_DENIED, exception.errorCode) + } + + @Test fun `returns REQUESTED when the viewer sent the pending friend request`() { + val friend = Friend(requester = member(1L, ProfileVisibility.PUBLIC), receiver = member(2L, ProfileVisibility.PUBLIC), status = FriendRequestStatus.PENDING, id = 10L) + every { memberRepository.findById(2L) } returns Optional.of(member(2L, ProfileVisibility.PUBLIC)) + every { friendRepository.findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(1L, 2L, 2L, 1L) } returns Optional.of(friend) + + val response = service.execute(1L, 2L) + + assertEquals(FriendStatus.REQUESTED, response.friendStatus) + } + + @Test fun `returns RECEIVED when the viewer received the pending friend request`() { + val friend = Friend(requester = member(2L, ProfileVisibility.PUBLIC), receiver = member(1L, ProfileVisibility.PUBLIC), status = FriendRequestStatus.PENDING, id = 10L) + every { memberRepository.findById(2L) } returns Optional.of(member(2L, ProfileVisibility.PUBLIC)) + every { friendRepository.findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(1L, 2L, 2L, 1L) } returns Optional.of(friend) + + val response = service.execute(1L, 2L) + + assertEquals(FriendStatus.RECEIVED, response.friendStatus) + } + + @Test fun `returns FRIEND for an accepted relationship`() { + val friend = Friend(requester = member(1L, ProfileVisibility.PUBLIC), receiver = member(2L, ProfileVisibility.PUBLIC), status = FriendRequestStatus.ACCEPTED, id = 10L) + every { memberRepository.findById(2L) } returns Optional.of(member(2L, ProfileVisibility.PUBLIC)) + every { friendRepository.findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(1L, 2L, 2L, 1L) } returns Optional.of(friend) + + val response = service.execute(1L, 2L) + + assertEquals(FriendStatus.FRIEND, response.friendStatus) + } + + @Test fun `returns NONE when no friend relationship exists`() { + every { memberRepository.findById(2L) } returns Optional.of(member(2L, ProfileVisibility.PUBLIC)) + mockNoFriendRelation() + + val response = service.execute(1L, 2L) + + assertEquals(FriendStatus.NONE, response.friendStatus) + } + + @Test fun `returns NONE when the prior request was rejected`() { + val friend = Friend(requester = member(1L, ProfileVisibility.PUBLIC), receiver = member(2L, ProfileVisibility.PUBLIC), status = FriendRequestStatus.REJECTED, id = 10L) + every { memberRepository.findById(2L) } returns Optional.of(member(2L, ProfileVisibility.PUBLIC)) + every { friendRepository.findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(1L, 2L, 2L, 1L) } returns Optional.of(friend) + + val response = service.execute(1L, 2L) + + assertEquals(FriendStatus.NONE, response.friendStatus) + } + + @Test fun `rejects a withdrawn member's profile`() { + every { memberRepository.findById(2L) } returns Optional.of(member(2L, ProfileVisibility.PUBLIC, withdrawnAt = LocalDateTime.now())) + + val exception = assertThrows(BusinessException::class.java) { service.execute(1L, 2L) } + assertEquals(ErrorCode.MEMBER_NOT_FOUND, exception.errorCode) + } + + @Test fun `rejects a member that has not completed signup`() { + every { memberRepository.findById(2L) } returns Optional.of(member(2L, ProfileVisibility.PUBLIC, nickname = null)) + + val exception = assertThrows(BusinessException::class.java) { service.execute(1L, 2L) } + assertEquals(ErrorCode.MEMBER_NOT_FOUND, exception.errorCode) + } + + @Test fun `rejects a memberId that does not exist`() { + every { memberRepository.findById(2L) } returns Optional.empty() + + val exception = assertThrows(BusinessException::class.java) { service.execute(1L, 2L) } + assertEquals(ErrorCode.MEMBER_NOT_FOUND, exception.errorCode) + } +} diff --git a/src/test/kotlin/team/cklob/mudda/domain/member/application/impl/GetMyMemberServiceTest.kt b/src/test/kotlin/team/cklob/mudda/domain/member/application/impl/GetMyMemberServiceTest.kt new file mode 100644 index 0000000..32fedf4 --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/domain/member/application/impl/GetMyMemberServiceTest.kt @@ -0,0 +1,57 @@ +package team.cklob.mudda.domain.member.application.impl + +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import team.cklob.mudda.domain.member.domain.entity.Member +import team.cklob.mudda.domain.member.domain.repository.MemberRepository +import team.cklob.mudda.domain.member.domain.type.Gender +import team.cklob.mudda.domain.member.domain.type.OAuthProvider +import team.cklob.mudda.domain.member.domain.type.ProfileVisibility +import team.cklob.mudda.global.exception.BusinessException +import team.cklob.mudda.global.exception.ErrorCode +import java.time.LocalDateTime +import java.util.Optional + +class GetMyMemberServiceTest { + private val memberRepository = mockk() + private val service = GetMyMemberService(memberRepository) + + private fun member(withdrawnAt: LocalDateTime? = null) = Member( + name = "name", nickname = "nickname", email = "user@example.com", + oauthProvider = OAuthProvider.GOOGLE, providerId = "google-sub-1", + gender = Gender.MALE, birthYear = 2000, bio = "hello", + profileVisibility = ProfileVisibility.PUBLIC, withdrawnAt = withdrawnAt, id = 1L, + ) + + @Test fun `returns the current member's data`() { + val member = member() + every { memberRepository.findById(1L) } returns Optional.of(member) + + val response = service.execute(1L) + + assertEquals(1L, response.memberId) + assertEquals("name", response.name) + assertEquals("nickname", response.nickname) + assertEquals(Gender.MALE, response.gender) + assertEquals(2000, response.birthYear) + assertEquals("hello", response.bio) + assertEquals(ProfileVisibility.PUBLIC, response.profileVisibility) + } + + @Test fun `rejects a member id that does not exist`() { + every { memberRepository.findById(1L) } returns Optional.empty() + + val exception = assertThrows(BusinessException::class.java) { service.execute(1L) } + assertEquals(ErrorCode.MEMBER_NOT_FOUND, exception.errorCode) + } + + @Test fun `rejects a withdrawn member`() { + every { memberRepository.findById(1L) } returns Optional.of(member(withdrawnAt = LocalDateTime.now())) + + val exception = assertThrows(BusinessException::class.java) { service.execute(1L) } + assertEquals(ErrorCode.MEMBER_NOT_FOUND, exception.errorCode) + } +} diff --git a/src/test/kotlin/team/cklob/mudda/domain/member/application/impl/UpdateMyMemberServiceTest.kt b/src/test/kotlin/team/cklob/mudda/domain/member/application/impl/UpdateMyMemberServiceTest.kt new file mode 100644 index 0000000..0e8cef2 --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/domain/member/application/impl/UpdateMyMemberServiceTest.kt @@ -0,0 +1,140 @@ +package team.cklob.mudda.domain.member.application.impl + +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import org.springframework.dao.DataIntegrityViolationException +import team.cklob.mudda.domain.member.domain.entity.Member +import team.cklob.mudda.domain.member.domain.repository.MemberRepository +import team.cklob.mudda.domain.member.domain.type.Gender +import team.cklob.mudda.domain.member.domain.type.OAuthProvider +import team.cklob.mudda.domain.member.domain.type.ProfileVisibility +import team.cklob.mudda.domain.member.presentation.request.UpdateMyMemberRequest +import team.cklob.mudda.global.exception.BusinessException +import team.cklob.mudda.global.exception.ErrorCode +import java.time.LocalDateTime +import java.util.Optional + +class UpdateMyMemberServiceTest { + private val memberRepository = mockk() + private val service = UpdateMyMemberService(memberRepository) + + private fun member(withdrawnAt: LocalDateTime? = null) = Member( + name = "name", nickname = "nickname", email = "user@example.com", + oauthProvider = OAuthProvider.GOOGLE, providerId = "google-sub-1", + gender = Gender.MALE, birthYear = 2000, profileImageUrl = "https://img.example.com/old.png", bio = "old bio", + profileVisibility = ProfileVisibility.PUBLIC, withdrawnAt = withdrawnAt, id = 1L, + ) + + private fun emptyRequest() = UpdateMyMemberRequest() + + @Test fun `updates only the fields present in the request`() { + val member = member() + every { memberRepository.findById(1L) } returns Optional.of(member) + every { memberRepository.saveAndFlush(member) } returns member + val request = emptyRequest().copy(bio = "new bio") + + val response = service.execute(1L, request) + + assertEquals("new bio", response.bio) + assertEquals("name", response.name) + assertEquals("nickname", response.nickname) + assertEquals(Gender.MALE, response.gender) + assertEquals(2000, response.birthYear) + assertEquals("https://img.example.com/old.png", response.profileImageUrl) + } + + @Test fun `allows keeping the member's own current nickname`() { + val member = member() + every { memberRepository.findById(1L) } returns Optional.of(member) + every { memberRepository.saveAndFlush(member) } returns member + val request = emptyRequest().copy(nickname = "nickname") + + val response = service.execute(1L, request) + + assertEquals("nickname", response.nickname) + } + + @Test fun `rejects a nickname already used by another member`() { + val member = member() + every { memberRepository.findById(1L) } returns Optional.of(member) + every { memberRepository.existsByNickname("taken") } returns true + val request = emptyRequest().copy(nickname = "taken") + + val exception = assertThrows(BusinessException::class.java) { service.execute(1L, request) } + assertEquals(ErrorCode.NICKNAME_ALREADY_EXISTS, exception.errorCode) + } + + @Test fun `rejects a request with every field empty`() { + every { memberRepository.findById(1L) } returns Optional.of(member()) + + val exception = assertThrows(BusinessException::class.java) { service.execute(1L, emptyRequest()) } + assertEquals(ErrorCode.INVALID_INPUT, exception.errorCode) + } + + @Test fun `rejects a blank name`() { + every { memberRepository.findById(1L) } returns Optional.of(member()) + val request = emptyRequest().copy(name = " ") + + val exception = assertThrows(BusinessException::class.java) { service.execute(1L, request) } + assertEquals(ErrorCode.INVALID_INPUT, exception.errorCode) + } + + @Test fun `rejects a blank nickname`() { + every { memberRepository.findById(1L) } returns Optional.of(member()) + val request = emptyRequest().copy(nickname = " ") + + val exception = assertThrows(BusinessException::class.java) { service.execute(1L, request) } + assertEquals(ErrorCode.INVALID_INPUT, exception.errorCode) + } + + @Test fun `normalizes an empty bio and profileImageUrl to null`() { + val member = member() + every { memberRepository.findById(1L) } returns Optional.of(member) + every { memberRepository.saveAndFlush(member) } returns member + val request = emptyRequest().copy(bio = "", profileImageUrl = "") + + val response = service.execute(1L, request) + + assertNull(response.bio) + assertNull(response.profileImageUrl) + } + + @Test fun `does not touch auth-owned fields`() { + val member = member() + every { memberRepository.findById(1L) } returns Optional.of(member) + every { memberRepository.saveAndFlush(member) } returns member + val request = emptyRequest().copy(name = "new name") + + service.execute(1L, request) + + assertEquals("user@example.com", member.email) + assertEquals(OAuthProvider.GOOGLE, member.oauthProvider) + assertEquals("google-sub-1", member.providerId) + assertNull(member.withdrawnAt) + } + + @Test fun `rejects updates for a withdrawn member`() { + every { memberRepository.findById(1L) } returns Optional.of(member(withdrawnAt = LocalDateTime.now())) + val request = emptyRequest().copy(bio = "new bio") + + val exception = assertThrows(BusinessException::class.java) { service.execute(1L, request) } + assertEquals(ErrorCode.MEMBER_NOT_FOUND, exception.errorCode) + } + + @Test fun `converts a database-level nickname race into a conflict`() { + val member = member() + every { memberRepository.findById(1L) } returns Optional.of(member) + every { memberRepository.existsByNickname("taken") } returns false + every { memberRepository.saveAndFlush(member) } throws DataIntegrityViolationException("duplicate key") + val request = emptyRequest().copy(nickname = "taken") + + val exception = assertThrows(BusinessException::class.java) { service.execute(1L, request) } + assertEquals(ErrorCode.NICKNAME_ALREADY_EXISTS, exception.errorCode) + verify { memberRepository.saveAndFlush(member) } + } +} diff --git a/src/test/kotlin/team/cklob/mudda/domain/member/presentation/controller/MemberControllerTest.kt b/src/test/kotlin/team/cklob/mudda/domain/member/presentation/controller/MemberControllerTest.kt new file mode 100644 index 0000000..98a5dc6 --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/domain/member/presentation/controller/MemberControllerTest.kt @@ -0,0 +1,145 @@ +package team.cklob.mudda.domain.member.presentation.controller + +import com.ninjasquad.springmockk.MockkBean +import io.mockk.every +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.context.annotation.Import +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import team.cklob.mudda.domain.friend.domain.type.FriendStatus +import team.cklob.mudda.domain.member.application.impl.GetMemberProfileService +import team.cklob.mudda.domain.member.application.impl.GetMyMemberService +import team.cklob.mudda.domain.member.application.impl.UpdateMyMemberService +import team.cklob.mudda.domain.member.domain.type.Gender +import team.cklob.mudda.domain.member.domain.type.ProfileVisibility +import team.cklob.mudda.domain.member.presentation.request.UpdateMyMemberRequest +import team.cklob.mudda.domain.member.presentation.response.MemberProfileResponse +import team.cklob.mudda.domain.member.presentation.response.MyMemberResponse +import team.cklob.mudda.global.config.SecurityConfig +import team.cklob.mudda.global.exception.BusinessException +import team.cklob.mudda.global.exception.ErrorCode +import team.cklob.mudda.global.security.AccessTokenBlacklist +import team.cklob.mudda.global.security.JwtTokenProvider +import java.time.LocalDateTime + +@WebMvcTest(controllers = [MemberController::class], properties = [ + "jwt.secret=local-test-secret-must-be-at-least-32-bytes", +]) +@Import(SecurityConfig::class, JwtTokenProvider::class) +class MemberControllerTest(@Autowired private val mockMvc: MockMvc, @Autowired private val jwtTokenProvider: JwtTokenProvider) { + @MockkBean lateinit var jpaMappingContext: JpaMetamodelMappingContext + @MockkBean lateinit var accessTokenBlacklist: AccessTokenBlacklist + @MockkBean lateinit var getMyMemberService: GetMyMemberService + @MockkBean lateinit var updateMyMemberService: UpdateMyMemberService + @MockkBean lateinit var getMemberProfileService: GetMemberProfileService + + private val now: LocalDateTime = LocalDateTime.now() + + private fun accessTokenFor(memberId: Long): String { + every { accessTokenBlacklist.isBlacklisted(any()) } returns false + every { accessTokenBlacklist.isRevoked(any(), any()) } returns false + return jwtTokenProvider.createAccessToken(memberId) + } + + private fun myMemberResponse() = MyMemberResponse( + memberId = 1L, name = "name", nickname = "nickname", gender = Gender.MALE, birthYear = 2000, + profileImageUrl = null, bio = null, profileVisibility = ProfileVisibility.PUBLIC, createdAt = now, updatedAt = now, + ) + + @Test fun `getMe requires authentication`() { + mockMvc.perform(get("/api/v1/member/me")).andExpect(status().isUnauthorized) + } + + @Test fun `getMe returns the authenticated member's data wrapped in the common envelope`() { + val token = accessTokenFor(1L) + every { getMyMemberService.execute(1L) } returns myMemberResponse() + + mockMvc.perform(get("/api/v1/member/me").header("Authorization", "Bearer $token")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.memberId").value(1)) + .andExpect(jsonPath("$.data.nickname").value("nickname")) + } + + @Test fun `updateMe rejects an out-of-range birthYear before reaching the service`() { + val token = accessTokenFor(1L) + + mockMvc.perform( + patch("/api/v1/member/me") + .header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"birthYear":1800}"""), + ).andExpect(status().isBadRequest) + } + + @Test fun `updateMe returns the saved member's data`() { + val token = accessTokenFor(1L) + every { updateMyMemberService.execute(1L, UpdateMyMemberRequest(bio = "new bio")) } returns myMemberResponse().copy(bio = "new bio") + + mockMvc.perform( + patch("/api/v1/member/me") + .header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"bio":"new bio"}"""), + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.bio").value("new bio")) + } + + @Test fun `updateMe returns 409 when the nickname is already taken`() { + val token = accessTokenFor(1L) + every { updateMyMemberService.execute(1L, UpdateMyMemberRequest(nickname = "taken")) } throws BusinessException(ErrorCode.NICKNAME_ALREADY_EXISTS) + + mockMvc.perform( + patch("/api/v1/member/me") + .header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"nickname":"taken"}"""), + ) + .andExpect(status().isConflict) + .andExpect(jsonPath("$.error.code").value("M001")) + } + + @Test fun `getProfile requires authentication`() { + mockMvc.perform(get("/api/v1/member/2")).andExpect(status().isUnauthorized) + } + + @Test fun `getProfile passes the authenticated viewer id and the path member id to the service`() { + val token = accessTokenFor(1L) + every { getMemberProfileService.execute(1L, 2L) } returns MemberProfileResponse( + memberId = 2L, name = "other", nickname = "other-nick", gender = Gender.FEMALE, birthYear = 1999, + profileImageUrl = null, bio = null, friendStatus = FriendStatus.NONE, createdAt = now, + ) + + mockMvc.perform(get("/api/v1/member/2").header("Authorization", "Bearer $token")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.memberId").value(2)) + .andExpect(jsonPath("$.data.friendStatus").value("NONE")) + } + + @Test fun `getProfile returns 404 when the member does not exist`() { + val token = accessTokenFor(1L) + every { getMemberProfileService.execute(1L, 99L) } throws BusinessException(ErrorCode.MEMBER_NOT_FOUND) + + mockMvc.perform(get("/api/v1/member/99").header("Authorization", "Bearer $token")) + .andExpect(status().isNotFound) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.error.code").value("M002")) + } + + @Test fun `getProfile returns 403 when the profile is not accessible`() { + val token = accessTokenFor(1L) + every { getMemberProfileService.execute(1L, 2L) } throws BusinessException(ErrorCode.PROFILE_ACCESS_DENIED) + + mockMvc.perform(get("/api/v1/member/2").header("Authorization", "Bearer $token")) + .andExpect(status().isForbidden) + .andExpect(jsonPath("$.error.code").value("M003")) + } +} From 325809247e5cfb2e86b233e25518187e61fa86de Mon Sep 17 00:00:00 2001 From: hej090224 Date: Fri, 31 Jul 2026 22:27:17 +0900 Subject: [PATCH 06/15] fix: #17 :: return a list from the bidirectional friend lookup to avoid a 500 on duplicate rows --- .../domain/repository/FriendRepository.kt | 5 +++- .../impl/GetMemberProfileService.kt | 14 +++++----- .../impl/GetMemberProfileServiceTest.kt | 27 ++++++++++++++----- 3 files changed, 31 insertions(+), 15 deletions(-) diff --git a/src/main/kotlin/team/cklob/mudda/domain/friend/domain/repository/FriendRepository.kt b/src/main/kotlin/team/cklob/mudda/domain/friend/domain/repository/FriendRepository.kt index 96e7df2..08feefb 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/friend/domain/repository/FriendRepository.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/friend/domain/repository/FriendRepository.kt @@ -9,10 +9,13 @@ interface FriendRepository : JpaRepository { fun findByRequesterIdAndReceiverId(requesterId: Long, receiverId: Long): Optional fun existsByRequesterIdAndReceiverId(requesterId: Long, receiverId: Long): Boolean + // uq_friend_requester_receiver only blocks a duplicate row in the same direction, so a requester/receiver + // pair can still have two rows (e.g. both sides sent a request before either was accepted). Returning a + // List keeps that a normal case instead of an Optional throwing IncorrectResultSizeDataAccessException. fun findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId( requesterId1: Long, receiverId1: Long, requesterId2: Long, receiverId2: Long, - ): Optional + ): List } diff --git a/src/main/kotlin/team/cklob/mudda/domain/member/application/impl/GetMemberProfileService.kt b/src/main/kotlin/team/cklob/mudda/domain/member/application/impl/GetMemberProfileService.kt index 6ae77d4..662eb28 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/member/application/impl/GetMemberProfileService.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/member/application/impl/GetMemberProfileService.kt @@ -31,15 +31,13 @@ class GetMemberProfileService( return MemberProfileResponse.of(member, friendStatus) } + // A requester/receiver pair can have relationship rows in both directions (see FriendRepository), so an + // ACCEPTED row always wins over a stray PENDING row for the same pair. private fun resolveFriendStatus(viewerId: Long, memberId: Long): FriendStatus { - val friend = friendRepository - .findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(viewerId, memberId, memberId, viewerId) - .orElse(null) ?: return FriendStatus.NONE + val relations = friendRepository.findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(viewerId, memberId, memberId, viewerId) + if (relations.any { it.status == FriendRequestStatus.ACCEPTED }) return FriendStatus.FRIEND - return when (friend.status) { - FriendRequestStatus.ACCEPTED -> FriendStatus.FRIEND - FriendRequestStatus.REJECTED -> FriendStatus.NONE - FriendRequestStatus.PENDING -> if (friend.requester.id == viewerId) FriendStatus.REQUESTED else FriendStatus.RECEIVED - } + val pending = relations.firstOrNull { it.status == FriendRequestStatus.PENDING } ?: return FriendStatus.NONE + return if (pending.requester.id == viewerId) FriendStatus.REQUESTED else FriendStatus.RECEIVED } } diff --git a/src/test/kotlin/team/cklob/mudda/domain/member/application/impl/GetMemberProfileServiceTest.kt b/src/test/kotlin/team/cklob/mudda/domain/member/application/impl/GetMemberProfileServiceTest.kt index 9145261..93a9c82 100644 --- a/src/test/kotlin/team/cklob/mudda/domain/member/application/impl/GetMemberProfileServiceTest.kt +++ b/src/test/kotlin/team/cklob/mudda/domain/member/application/impl/GetMemberProfileServiceTest.kt @@ -32,7 +32,11 @@ class GetMemberProfileServiceTest { ) private fun mockNoFriendRelation() { - every { friendRepository.findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(1L, 2L, 2L, 1L) } returns Optional.empty() + every { friendRepository.findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(1L, 2L, 2L, 1L) } returns emptyList() + } + + private fun mockFriendRelation(vararg friends: Friend) { + every { friendRepository.findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(1L, 2L, 2L, 1L) } returns friends.toList() } @Test fun `returns a PUBLIC profile for another member`() { @@ -65,7 +69,7 @@ class GetMemberProfileServiceTest { @Test fun `allows a friend to view a FRIEND-visibility profile`() { val friend = Friend(requester = member(1L, ProfileVisibility.PUBLIC), receiver = member(2L, ProfileVisibility.FRIEND), status = FriendRequestStatus.ACCEPTED, id = 10L) every { memberRepository.findById(2L) } returns Optional.of(member(2L, ProfileVisibility.FRIEND)) - every { friendRepository.findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(1L, 2L, 2L, 1L) } returns Optional.of(friend) + mockFriendRelation(friend) val response = service.execute(1L, 2L) @@ -83,7 +87,7 @@ class GetMemberProfileServiceTest { @Test fun `returns REQUESTED when the viewer sent the pending friend request`() { val friend = Friend(requester = member(1L, ProfileVisibility.PUBLIC), receiver = member(2L, ProfileVisibility.PUBLIC), status = FriendRequestStatus.PENDING, id = 10L) every { memberRepository.findById(2L) } returns Optional.of(member(2L, ProfileVisibility.PUBLIC)) - every { friendRepository.findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(1L, 2L, 2L, 1L) } returns Optional.of(friend) + mockFriendRelation(friend) val response = service.execute(1L, 2L) @@ -93,7 +97,7 @@ class GetMemberProfileServiceTest { @Test fun `returns RECEIVED when the viewer received the pending friend request`() { val friend = Friend(requester = member(2L, ProfileVisibility.PUBLIC), receiver = member(1L, ProfileVisibility.PUBLIC), status = FriendRequestStatus.PENDING, id = 10L) every { memberRepository.findById(2L) } returns Optional.of(member(2L, ProfileVisibility.PUBLIC)) - every { friendRepository.findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(1L, 2L, 2L, 1L) } returns Optional.of(friend) + mockFriendRelation(friend) val response = service.execute(1L, 2L) @@ -103,7 +107,7 @@ class GetMemberProfileServiceTest { @Test fun `returns FRIEND for an accepted relationship`() { val friend = Friend(requester = member(1L, ProfileVisibility.PUBLIC), receiver = member(2L, ProfileVisibility.PUBLIC), status = FriendRequestStatus.ACCEPTED, id = 10L) every { memberRepository.findById(2L) } returns Optional.of(member(2L, ProfileVisibility.PUBLIC)) - every { friendRepository.findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(1L, 2L, 2L, 1L) } returns Optional.of(friend) + mockFriendRelation(friend) val response = service.execute(1L, 2L) @@ -122,13 +126,24 @@ class GetMemberProfileServiceTest { @Test fun `returns NONE when the prior request was rejected`() { val friend = Friend(requester = member(1L, ProfileVisibility.PUBLIC), receiver = member(2L, ProfileVisibility.PUBLIC), status = FriendRequestStatus.REJECTED, id = 10L) every { memberRepository.findById(2L) } returns Optional.of(member(2L, ProfileVisibility.PUBLIC)) - every { friendRepository.findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(1L, 2L, 2L, 1L) } returns Optional.of(friend) + mockFriendRelation(friend) val response = service.execute(1L, 2L) assertEquals(FriendStatus.NONE, response.friendStatus) } + @Test fun `prefers ACCEPTED when both directions have a relationship row for the same pair`() { + val stalePending = Friend(requester = member(2L, ProfileVisibility.PUBLIC), receiver = member(1L, ProfileVisibility.PUBLIC), status = FriendRequestStatus.PENDING, id = 10L) + val accepted = Friend(requester = member(1L, ProfileVisibility.PUBLIC), receiver = member(2L, ProfileVisibility.PUBLIC), status = FriendRequestStatus.ACCEPTED, id = 11L) + every { memberRepository.findById(2L) } returns Optional.of(member(2L, ProfileVisibility.PUBLIC)) + mockFriendRelation(stalePending, accepted) + + val response = service.execute(1L, 2L) + + assertEquals(FriendStatus.FRIEND, response.friendStatus) + } + @Test fun `rejects a withdrawn member's profile`() { every { memberRepository.findById(2L) } returns Optional.of(member(2L, ProfileVisibility.PUBLIC, withdrawnAt = LocalDateTime.now())) From 466387d2150fba5d63bafb8024f37882dda1639e Mon Sep 17 00:00:00 2001 From: hej090224 Date: Fri, 31 Jul 2026 22:27:21 +0900 Subject: [PATCH 07/15] fix: #17 :: block PATCH /member/me for members that have not completed signup --- .../application/impl/UpdateMyMemberService.kt | 3 +++ .../team/cklob/mudda/global/exception/ErrorCode.kt | 1 + .../application/impl/UpdateMyMemberServiceTest.kt | 13 +++++++++++++ 3 files changed, 17 insertions(+) diff --git a/src/main/kotlin/team/cklob/mudda/domain/member/application/impl/UpdateMyMemberService.kt b/src/main/kotlin/team/cklob/mudda/domain/member/application/impl/UpdateMyMemberService.kt index 9a8a6ad..4e82fb8 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/member/application/impl/UpdateMyMemberService.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/member/application/impl/UpdateMyMemberService.kt @@ -19,6 +19,9 @@ class UpdateMyMemberService( val member = memberRepository.findById(memberId).orElseThrow { BusinessException(ErrorCode.MEMBER_NOT_FOUND) } if (member.withdrawnAt != null) throw BusinessException(ErrorCode.MEMBER_NOT_FOUND) + // Without this gate, an OAuth-logged-in member who never called /auth/signup could set only a + // nickname here and skip the name/gender/birthYear requirements SignupAuthService enforces. + if (member.nickname == null) throw BusinessException(ErrorCode.SIGNUP_REQUIRED) request.name?.let { if (it.isBlank()) throw BusinessException(ErrorCode.INVALID_INPUT) diff --git a/src/main/kotlin/team/cklob/mudda/global/exception/ErrorCode.kt b/src/main/kotlin/team/cklob/mudda/global/exception/ErrorCode.kt index 01e9444..73e4409 100644 --- a/src/main/kotlin/team/cklob/mudda/global/exception/ErrorCode.kt +++ b/src/main/kotlin/team/cklob/mudda/global/exception/ErrorCode.kt @@ -12,6 +12,7 @@ enum class ErrorCode(val status: HttpStatus, val code: String, val message: Stri OAUTH_PROVIDER_NOT_SUPPORTED(HttpStatus.BAD_REQUEST, "A006", "This OAuth provider is not supported yet."), WITHDRAWN_MEMBER(HttpStatus.FORBIDDEN, "A007", "This account has been withdrawn."), ALREADY_SIGNED_UP(HttpStatus.CONFLICT, "A008", "This member has already completed signup."), + SIGNUP_REQUIRED(HttpStatus.FORBIDDEN, "A009", "Signup must be completed before this action."), NICKNAME_ALREADY_EXISTS(HttpStatus.CONFLICT, "M001", "Nickname already exists."), MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "M002", "Member not found."), PROFILE_ACCESS_DENIED(HttpStatus.FORBIDDEN, "M003", "You do not have access to this profile."), diff --git a/src/test/kotlin/team/cklob/mudda/domain/member/application/impl/UpdateMyMemberServiceTest.kt b/src/test/kotlin/team/cklob/mudda/domain/member/application/impl/UpdateMyMemberServiceTest.kt index 0e8cef2..e1c714e 100644 --- a/src/test/kotlin/team/cklob/mudda/domain/member/application/impl/UpdateMyMemberServiceTest.kt +++ b/src/test/kotlin/team/cklob/mudda/domain/member/application/impl/UpdateMyMemberServiceTest.kt @@ -30,6 +30,11 @@ class UpdateMyMemberServiceTest { profileVisibility = ProfileVisibility.PUBLIC, withdrawnAt = withdrawnAt, id = 1L, ) + private fun incompleteMember() = Member( + email = "user@example.com", oauthProvider = OAuthProvider.GOOGLE, providerId = "google-sub-1", + profileVisibility = ProfileVisibility.PUBLIC, id = 1L, + ) + private fun emptyRequest() = UpdateMyMemberRequest() @Test fun `updates only the fields present in the request`() { @@ -126,6 +131,14 @@ class UpdateMyMemberServiceTest { assertEquals(ErrorCode.MEMBER_NOT_FOUND, exception.errorCode) } + @Test fun `rejects updates for a member that has not completed signup`() { + every { memberRepository.findById(1L) } returns Optional.of(incompleteMember()) + val request = emptyRequest().copy(nickname = "new-nickname") + + val exception = assertThrows(BusinessException::class.java) { service.execute(1L, request) } + assertEquals(ErrorCode.SIGNUP_REQUIRED, exception.errorCode) + } + @Test fun `converts a database-level nickname race into a conflict`() { val member = member() every { memberRepository.findById(1L) } returns Optional.of(member) From e876093b8e80a99e8a4765467c07594873053f61 Mon Sep 17 00:00:00 2001 From: hej090224 Date: Fri, 31 Jul 2026 22:27:26 +0900 Subject: [PATCH 08/15] test: #17 :: assert auth fields stay out of member responses and PATCH /me requires auth --- .../presentation/controller/MemberControllerTest.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/test/kotlin/team/cklob/mudda/domain/member/presentation/controller/MemberControllerTest.kt b/src/test/kotlin/team/cklob/mudda/domain/member/presentation/controller/MemberControllerTest.kt index 98a5dc6..70651e2 100644 --- a/src/test/kotlin/team/cklob/mudda/domain/member/presentation/controller/MemberControllerTest.kt +++ b/src/test/kotlin/team/cklob/mudda/domain/member/presentation/controller/MemberControllerTest.kt @@ -66,6 +66,15 @@ class MemberControllerTest(@Autowired private val mockMvc: MockMvc, @Autowired p .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.data.memberId").value(1)) .andExpect(jsonPath("$.data.nickname").value("nickname")) + .andExpect(jsonPath("$.data.email").doesNotExist()) + .andExpect(jsonPath("$.data.oauthProvider").doesNotExist()) + .andExpect(jsonPath("$.data.providerId").doesNotExist()) + } + + @Test fun `updateMe requires authentication`() { + mockMvc.perform( + patch("/api/v1/member/me").contentType(MediaType.APPLICATION_JSON).content("""{"bio":"new bio"}"""), + ).andExpect(status().isUnauthorized) } @Test fun `updateMe rejects an out-of-range birthYear before reaching the service`() { @@ -122,6 +131,9 @@ class MemberControllerTest(@Autowired private val mockMvc: MockMvc, @Autowired p .andExpect(status().isOk) .andExpect(jsonPath("$.data.memberId").value(2)) .andExpect(jsonPath("$.data.friendStatus").value("NONE")) + .andExpect(jsonPath("$.data.email").doesNotExist()) + .andExpect(jsonPath("$.data.oauthProvider").doesNotExist()) + .andExpect(jsonPath("$.data.providerId").doesNotExist()) } @Test fun `getProfile returns 404 when the member does not exist`() { From 43fc3b399df54aac362f65c42c92a70b6ab0e5f3 Mon Sep 17 00:00:00 2001 From: hej090224 Date: Fri, 31 Jul 2026 22:43:54 +0900 Subject: [PATCH 09/15] refactor: #17 :: make ProfileAccessPolicy's visibility check an exhaustive when --- .../domain/member/application/ProfileAccessPolicy.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/team/cklob/mudda/domain/member/application/ProfileAccessPolicy.kt b/src/main/kotlin/team/cklob/mudda/domain/member/application/ProfileAccessPolicy.kt index 8726d38..1adbc94 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/member/application/ProfileAccessPolicy.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/member/application/ProfileAccessPolicy.kt @@ -4,10 +4,12 @@ import team.cklob.mudda.domain.friend.domain.type.FriendStatus import team.cklob.mudda.domain.member.domain.type.ProfileVisibility object ProfileAccessPolicy { - fun canView(visibility: ProfileVisibility, isSelf: Boolean, friendStatus: FriendStatus): Boolean = when { - isSelf -> true - visibility == ProfileVisibility.PUBLIC -> true - visibility == ProfileVisibility.FRIEND -> friendStatus == FriendStatus.FRIEND - else -> false + fun canView(visibility: ProfileVisibility, isSelf: Boolean, friendStatus: FriendStatus): Boolean { + if (isSelf) return true + return when (visibility) { + ProfileVisibility.PUBLIC -> true + ProfileVisibility.FRIEND -> friendStatus == FriendStatus.FRIEND + ProfileVisibility.PRIVATE -> false + } } } From 6fd5c4b7925d46fddf741b5cb3579ba14ee25649 Mon Sep 17 00:00:00 2001 From: hej090224 Date: Fri, 31 Jul 2026 22:43:56 +0900 Subject: [PATCH 10/15] fix: #17 :: trim and blank-normalize member update fields, narrow the nickname conflict catch --- .../application/impl/UpdateMyMemberService.kt | 17 ++++++--- .../request/UpdateMyMemberRequest.kt | 1 + .../impl/UpdateMyMemberServiceTest.kt | 38 +++++++++++++++++++ 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/team/cklob/mudda/domain/member/application/impl/UpdateMyMemberService.kt b/src/main/kotlin/team/cklob/mudda/domain/member/application/impl/UpdateMyMemberService.kt index 4e82fb8..d8f7570 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/member/application/impl/UpdateMyMemberService.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/member/application/impl/UpdateMyMemberService.kt @@ -25,10 +25,11 @@ class UpdateMyMemberService( request.name?.let { if (it.isBlank()) throw BusinessException(ErrorCode.INVALID_INPUT) - member.name = it + member.name = it.trim() } - request.nickname?.let { nickname -> - if (nickname.isBlank()) throw BusinessException(ErrorCode.INVALID_INPUT) + request.nickname?.let { raw -> + if (raw.isBlank()) throw BusinessException(ErrorCode.INVALID_INPUT) + val nickname = raw.trim() if (nickname != member.nickname && memberRepository.existsByNickname(nickname)) { throw BusinessException(ErrorCode.NICKNAME_ALREADY_EXISTS) } @@ -36,14 +37,18 @@ class UpdateMyMemberService( } request.gender?.let { member.gender = it } request.birthYear?.let { member.birthYear = it } - request.profileImageUrl?.let { member.profileImageUrl = it.ifEmpty { null } } - request.bio?.let { member.bio = it.ifEmpty { null } } + request.profileImageUrl?.let { member.profileImageUrl = it.trim().ifBlank { null } } + request.bio?.let { member.bio = it.trim().ifBlank { null } } request.profileVisibility?.let { member.profileVisibility = it } val saved = try { memberRepository.saveAndFlush(member) } catch (e: DataIntegrityViolationException) { - throw BusinessException(ErrorCode.NICKNAME_ALREADY_EXISTS) + // The only unique constraint reachable in this transaction today is uq_member_nickname, but only + // translate to a 409 when a nickname change was actually requested so an unrelated future + // constraint doesn't get misreported as a nickname conflict. + if (request.nickname != null) throw BusinessException(ErrorCode.NICKNAME_ALREADY_EXISTS) + throw e } return MyMemberResponse.from(saved) diff --git a/src/main/kotlin/team/cklob/mudda/domain/member/presentation/request/UpdateMyMemberRequest.kt b/src/main/kotlin/team/cklob/mudda/domain/member/presentation/request/UpdateMyMemberRequest.kt index 35075a7..bc26fbb 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/member/presentation/request/UpdateMyMemberRequest.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/member/presentation/request/UpdateMyMemberRequest.kt @@ -27,6 +27,7 @@ data class UpdateMyMemberRequest( val profileVisibility: ProfileVisibility? = null, ) { + // Add new fields to this comparison too, or an all-null request for the new field would silently pass. fun isEmpty(): Boolean = name == null && nickname == null && gender == null && birthYear == null && profileImageUrl == null && bio == null && profileVisibility == null diff --git a/src/test/kotlin/team/cklob/mudda/domain/member/application/impl/UpdateMyMemberServiceTest.kt b/src/test/kotlin/team/cklob/mudda/domain/member/application/impl/UpdateMyMemberServiceTest.kt index e1c714e..417e9ea 100644 --- a/src/test/kotlin/team/cklob/mudda/domain/member/application/impl/UpdateMyMemberServiceTest.kt +++ b/src/test/kotlin/team/cklob/mudda/domain/member/application/impl/UpdateMyMemberServiceTest.kt @@ -109,6 +109,35 @@ class UpdateMyMemberServiceTest { assertNull(response.profileImageUrl) } + @Test fun `normalizes a whitespace-only bio and profileImageUrl to null`() { + val member = member() + every { memberRepository.findById(1L) } returns Optional.of(member) + every { memberRepository.saveAndFlush(member) } returns member + val request = emptyRequest().copy(bio = " ", profileImageUrl = " ") + + val response = service.execute(1L, request) + + assertNull(response.bio) + assertNull(response.profileImageUrl) + } + + @Test fun `trims surrounding whitespace from name, nickname, bio and profileImageUrl`() { + val member = member() + every { memberRepository.findById(1L) } returns Optional.of(member) + every { memberRepository.existsByNickname("new-nickname") } returns false + every { memberRepository.saveAndFlush(member) } returns member + val request = emptyRequest().copy( + name = " new name ", nickname = " new-nickname ", bio = " new bio ", profileImageUrl = " https://img.example.com/new.png ", + ) + + val response = service.execute(1L, request) + + assertEquals("new name", response.name) + assertEquals("new-nickname", response.nickname) + assertEquals("new bio", response.bio) + assertEquals("https://img.example.com/new.png", response.profileImageUrl) + } + @Test fun `does not touch auth-owned fields`() { val member = member() every { memberRepository.findById(1L) } returns Optional.of(member) @@ -150,4 +179,13 @@ class UpdateMyMemberServiceTest { assertEquals(ErrorCode.NICKNAME_ALREADY_EXISTS, exception.errorCode) verify { memberRepository.saveAndFlush(member) } } + + @Test fun `rethrows a database-level violation unrelated to the nickname`() { + val member = member() + every { memberRepository.findById(1L) } returns Optional.of(member) + every { memberRepository.saveAndFlush(member) } throws DataIntegrityViolationException("some other constraint") + val request = emptyRequest().copy(bio = "new bio") + + assertThrows(DataIntegrityViolationException::class.java) { service.execute(1L, request) } + } } From c0993166eebcad5989c8c22b63de05233c653383 Mon Sep 17 00:00:00 2001 From: hej090224 Date: Fri, 31 Jul 2026 22:52:58 +0900 Subject: [PATCH 11/15] fix: #17 :: align /member/me error codes with the auth domain's invalid-subject handling --- .../member/application/impl/GetMyMemberService.kt | 7 +++++-- .../member/application/impl/UpdateMyMemberService.kt | 5 +++-- .../member/application/impl/GetMyMemberServiceTest.kt | 7 ++++--- .../application/impl/UpdateMyMemberServiceTest.kt | 11 ++++++++++- 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/team/cklob/mudda/domain/member/application/impl/GetMyMemberService.kt b/src/main/kotlin/team/cklob/mudda/domain/member/application/impl/GetMyMemberService.kt index 65ae985..f873faa 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/member/application/impl/GetMyMemberService.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/member/application/impl/GetMyMemberService.kt @@ -4,6 +4,7 @@ import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import team.cklob.mudda.domain.member.domain.repository.MemberRepository import team.cklob.mudda.domain.member.presentation.response.MyMemberResponse +import team.cklob.mudda.global.exception.AuthException import team.cklob.mudda.global.exception.BusinessException import team.cklob.mudda.global.exception.ErrorCode @@ -13,8 +14,10 @@ class GetMyMemberService( ) { @Transactional(readOnly = true) fun execute(memberId: Long): MyMemberResponse { - val member = memberRepository.findById(memberId).orElseThrow { BusinessException(ErrorCode.MEMBER_NOT_FOUND) } - if (member.withdrawnAt != null) throw BusinessException(ErrorCode.MEMBER_NOT_FOUND) + // Matches the Auth domain's handling of an invalid token subject (see SignupAuthService, + // WithdrawAuthService) so a client's "401 -> re-login" rule doesn't need a /member/me exception. + val member = memberRepository.findById(memberId).orElseThrow { AuthException(ErrorCode.UNAUTHORIZED) } + if (member.withdrawnAt != null) throw BusinessException(ErrorCode.WITHDRAWN_MEMBER) return MyMemberResponse.from(member) } } diff --git a/src/main/kotlin/team/cklob/mudda/domain/member/application/impl/UpdateMyMemberService.kt b/src/main/kotlin/team/cklob/mudda/domain/member/application/impl/UpdateMyMemberService.kt index d8f7570..50fdd69 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/member/application/impl/UpdateMyMemberService.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/member/application/impl/UpdateMyMemberService.kt @@ -6,6 +6,7 @@ import org.springframework.transaction.annotation.Transactional import team.cklob.mudda.domain.member.domain.repository.MemberRepository import team.cklob.mudda.domain.member.presentation.request.UpdateMyMemberRequest import team.cklob.mudda.domain.member.presentation.response.MyMemberResponse +import team.cklob.mudda.global.exception.AuthException import team.cklob.mudda.global.exception.BusinessException import team.cklob.mudda.global.exception.ErrorCode @@ -17,8 +18,8 @@ class UpdateMyMemberService( fun execute(memberId: Long, request: UpdateMyMemberRequest): MyMemberResponse { if (request.isEmpty()) throw BusinessException(ErrorCode.INVALID_INPUT) - val member = memberRepository.findById(memberId).orElseThrow { BusinessException(ErrorCode.MEMBER_NOT_FOUND) } - if (member.withdrawnAt != null) throw BusinessException(ErrorCode.MEMBER_NOT_FOUND) + val member = memberRepository.findById(memberId).orElseThrow { AuthException(ErrorCode.UNAUTHORIZED) } + if (member.withdrawnAt != null) throw BusinessException(ErrorCode.WITHDRAWN_MEMBER) // Without this gate, an OAuth-logged-in member who never called /auth/signup could set only a // nickname here and skip the name/gender/birthYear requirements SignupAuthService enforces. if (member.nickname == null) throw BusinessException(ErrorCode.SIGNUP_REQUIRED) diff --git a/src/test/kotlin/team/cklob/mudda/domain/member/application/impl/GetMyMemberServiceTest.kt b/src/test/kotlin/team/cklob/mudda/domain/member/application/impl/GetMyMemberServiceTest.kt index 32fedf4..be0d1b3 100644 --- a/src/test/kotlin/team/cklob/mudda/domain/member/application/impl/GetMyMemberServiceTest.kt +++ b/src/test/kotlin/team/cklob/mudda/domain/member/application/impl/GetMyMemberServiceTest.kt @@ -10,6 +10,7 @@ import team.cklob.mudda.domain.member.domain.repository.MemberRepository import team.cklob.mudda.domain.member.domain.type.Gender import team.cklob.mudda.domain.member.domain.type.OAuthProvider import team.cklob.mudda.domain.member.domain.type.ProfileVisibility +import team.cklob.mudda.global.exception.AuthException import team.cklob.mudda.global.exception.BusinessException import team.cklob.mudda.global.exception.ErrorCode import java.time.LocalDateTime @@ -44,14 +45,14 @@ class GetMyMemberServiceTest { @Test fun `rejects a member id that does not exist`() { every { memberRepository.findById(1L) } returns Optional.empty() - val exception = assertThrows(BusinessException::class.java) { service.execute(1L) } - assertEquals(ErrorCode.MEMBER_NOT_FOUND, exception.errorCode) + val exception = assertThrows(AuthException::class.java) { service.execute(1L) } + assertEquals(ErrorCode.UNAUTHORIZED, exception.errorCode) } @Test fun `rejects a withdrawn member`() { every { memberRepository.findById(1L) } returns Optional.of(member(withdrawnAt = LocalDateTime.now())) val exception = assertThrows(BusinessException::class.java) { service.execute(1L) } - assertEquals(ErrorCode.MEMBER_NOT_FOUND, exception.errorCode) + assertEquals(ErrorCode.WITHDRAWN_MEMBER, exception.errorCode) } } diff --git a/src/test/kotlin/team/cklob/mudda/domain/member/application/impl/UpdateMyMemberServiceTest.kt b/src/test/kotlin/team/cklob/mudda/domain/member/application/impl/UpdateMyMemberServiceTest.kt index 417e9ea..e46c7b3 100644 --- a/src/test/kotlin/team/cklob/mudda/domain/member/application/impl/UpdateMyMemberServiceTest.kt +++ b/src/test/kotlin/team/cklob/mudda/domain/member/application/impl/UpdateMyMemberServiceTest.kt @@ -14,6 +14,7 @@ import team.cklob.mudda.domain.member.domain.type.Gender import team.cklob.mudda.domain.member.domain.type.OAuthProvider import team.cklob.mudda.domain.member.domain.type.ProfileVisibility import team.cklob.mudda.domain.member.presentation.request.UpdateMyMemberRequest +import team.cklob.mudda.global.exception.AuthException import team.cklob.mudda.global.exception.BusinessException import team.cklob.mudda.global.exception.ErrorCode import java.time.LocalDateTime @@ -157,7 +158,15 @@ class UpdateMyMemberServiceTest { val request = emptyRequest().copy(bio = "new bio") val exception = assertThrows(BusinessException::class.java) { service.execute(1L, request) } - assertEquals(ErrorCode.MEMBER_NOT_FOUND, exception.errorCode) + assertEquals(ErrorCode.WITHDRAWN_MEMBER, exception.errorCode) + } + + @Test fun `rejects updates for a member id that does not exist`() { + every { memberRepository.findById(1L) } returns Optional.empty() + val request = emptyRequest().copy(bio = "new bio") + + val exception = assertThrows(AuthException::class.java) { service.execute(1L, request) } + assertEquals(ErrorCode.UNAUTHORIZED, exception.errorCode) } @Test fun `rejects updates for a member that has not completed signup`() { From c129022ae78f89c79273ce14ee982e4bf4cc070d Mon Sep 17 00:00:00 2001 From: hej090224 Date: Fri, 31 Jul 2026 22:52:59 +0900 Subject: [PATCH 12/15] fix: #17 :: drop real name from the member profile response --- .../response/MemberProfileResponse.kt | 2 -- .../controller/MemberControllerTest.kt | 31 ++++++++++++------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/team/cklob/mudda/domain/member/presentation/response/MemberProfileResponse.kt b/src/main/kotlin/team/cklob/mudda/domain/member/presentation/response/MemberProfileResponse.kt index 0f46783..51125c2 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/member/presentation/response/MemberProfileResponse.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/member/presentation/response/MemberProfileResponse.kt @@ -7,7 +7,6 @@ import java.time.LocalDateTime data class MemberProfileResponse( val memberId: Long, - val name: String?, val nickname: String?, val gender: Gender?, val birthYear: Int?, @@ -19,7 +18,6 @@ data class MemberProfileResponse( companion object { fun of(member: Member, friendStatus: FriendStatus) = MemberProfileResponse( memberId = requireNotNull(member.id), - name = member.name, nickname = member.nickname, gender = member.gender, birthYear = member.birthYear, diff --git a/src/test/kotlin/team/cklob/mudda/domain/member/presentation/controller/MemberControllerTest.kt b/src/test/kotlin/team/cklob/mudda/domain/member/presentation/controller/MemberControllerTest.kt index 70651e2..c0f44e4 100644 --- a/src/test/kotlin/team/cklob/mudda/domain/member/presentation/controller/MemberControllerTest.kt +++ b/src/test/kotlin/team/cklob/mudda/domain/member/presentation/controller/MemberControllerTest.kt @@ -54,14 +54,14 @@ class MemberControllerTest(@Autowired private val mockMvc: MockMvc, @Autowired p ) @Test fun `getMe requires authentication`() { - mockMvc.perform(get("/api/v1/member/me")).andExpect(status().isUnauthorized) + mockMvc.perform(get("/api/v1/members/me")).andExpect(status().isUnauthorized) } @Test fun `getMe returns the authenticated member's data wrapped in the common envelope`() { val token = accessTokenFor(1L) every { getMyMemberService.execute(1L) } returns myMemberResponse() - mockMvc.perform(get("/api/v1/member/me").header("Authorization", "Bearer $token")) + mockMvc.perform(get("/api/v1/members/me").header("Authorization", "Bearer $token")) .andExpect(status().isOk) .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.data.memberId").value(1)) @@ -73,7 +73,7 @@ class MemberControllerTest(@Autowired private val mockMvc: MockMvc, @Autowired p @Test fun `updateMe requires authentication`() { mockMvc.perform( - patch("/api/v1/member/me").contentType(MediaType.APPLICATION_JSON).content("""{"bio":"new bio"}"""), + patch("/api/v1/members/me").contentType(MediaType.APPLICATION_JSON).content("""{"bio":"new bio"}"""), ).andExpect(status().isUnauthorized) } @@ -81,7 +81,7 @@ class MemberControllerTest(@Autowired private val mockMvc: MockMvc, @Autowired p val token = accessTokenFor(1L) mockMvc.perform( - patch("/api/v1/member/me") + patch("/api/v1/members/me") .header("Authorization", "Bearer $token") .contentType(MediaType.APPLICATION_JSON) .content("""{"birthYear":1800}"""), @@ -93,7 +93,7 @@ class MemberControllerTest(@Autowired private val mockMvc: MockMvc, @Autowired p every { updateMyMemberService.execute(1L, UpdateMyMemberRequest(bio = "new bio")) } returns myMemberResponse().copy(bio = "new bio") mockMvc.perform( - patch("/api/v1/member/me") + patch("/api/v1/members/me") .header("Authorization", "Bearer $token") .contentType(MediaType.APPLICATION_JSON) .content("""{"bio":"new bio"}"""), @@ -107,7 +107,7 @@ class MemberControllerTest(@Autowired private val mockMvc: MockMvc, @Autowired p every { updateMyMemberService.execute(1L, UpdateMyMemberRequest(nickname = "taken")) } throws BusinessException(ErrorCode.NICKNAME_ALREADY_EXISTS) mockMvc.perform( - patch("/api/v1/member/me") + patch("/api/v1/members/me") .header("Authorization", "Bearer $token") .contentType(MediaType.APPLICATION_JSON) .content("""{"nickname":"taken"}"""), @@ -117,20 +117,21 @@ class MemberControllerTest(@Autowired private val mockMvc: MockMvc, @Autowired p } @Test fun `getProfile requires authentication`() { - mockMvc.perform(get("/api/v1/member/2")).andExpect(status().isUnauthorized) + mockMvc.perform(get("/api/v1/members/2")).andExpect(status().isUnauthorized) } @Test fun `getProfile passes the authenticated viewer id and the path member id to the service`() { val token = accessTokenFor(1L) every { getMemberProfileService.execute(1L, 2L) } returns MemberProfileResponse( - memberId = 2L, name = "other", nickname = "other-nick", gender = Gender.FEMALE, birthYear = 1999, + memberId = 2L, nickname = "other-nick", gender = Gender.FEMALE, birthYear = 1999, profileImageUrl = null, bio = null, friendStatus = FriendStatus.NONE, createdAt = now, ) - mockMvc.perform(get("/api/v1/member/2").header("Authorization", "Bearer $token")) + mockMvc.perform(get("/api/v1/members/2").header("Authorization", "Bearer $token")) .andExpect(status().isOk) .andExpect(jsonPath("$.data.memberId").value(2)) .andExpect(jsonPath("$.data.friendStatus").value("NONE")) + .andExpect(jsonPath("$.data.name").doesNotExist()) .andExpect(jsonPath("$.data.email").doesNotExist()) .andExpect(jsonPath("$.data.oauthProvider").doesNotExist()) .andExpect(jsonPath("$.data.providerId").doesNotExist()) @@ -140,7 +141,7 @@ class MemberControllerTest(@Autowired private val mockMvc: MockMvc, @Autowired p val token = accessTokenFor(1L) every { getMemberProfileService.execute(1L, 99L) } throws BusinessException(ErrorCode.MEMBER_NOT_FOUND) - mockMvc.perform(get("/api/v1/member/99").header("Authorization", "Bearer $token")) + mockMvc.perform(get("/api/v1/members/99").header("Authorization", "Bearer $token")) .andExpect(status().isNotFound) .andExpect(jsonPath("$.success").value(false)) .andExpect(jsonPath("$.error.code").value("M002")) @@ -150,8 +151,16 @@ class MemberControllerTest(@Autowired private val mockMvc: MockMvc, @Autowired p val token = accessTokenFor(1L) every { getMemberProfileService.execute(1L, 2L) } throws BusinessException(ErrorCode.PROFILE_ACCESS_DENIED) - mockMvc.perform(get("/api/v1/member/2").header("Authorization", "Bearer $token")) + mockMvc.perform(get("/api/v1/members/2").header("Authorization", "Bearer $token")) .andExpect(status().isForbidden) .andExpect(jsonPath("$.error.code").value("M003")) } + + @Test fun `getProfile returns 400 for a non-numeric memberId instead of a 500`() { + val token = accessTokenFor(1L) + + mockMvc.perform(get("/api/v1/members/abc").header("Authorization", "Bearer $token")) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.error.code").value("C001")) + } } From f0b65e2ddc115c9867ccaeb64a414b1a1c54ba69 Mon Sep 17 00:00:00 2001 From: hej090224 Date: Fri, 31 Jul 2026 22:53:09 +0900 Subject: [PATCH 13/15] fix: #17 :: rename member API base path to /api/v1/members --- .../domain/member/presentation/controller/MemberController.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/team/cklob/mudda/domain/member/presentation/controller/MemberController.kt b/src/main/kotlin/team/cklob/mudda/domain/member/presentation/controller/MemberController.kt index 61d05eb..c4c7149 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/member/presentation/controller/MemberController.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/member/presentation/controller/MemberController.kt @@ -18,7 +18,7 @@ import team.cklob.mudda.global.response.ApiResponse import team.cklob.mudda.global.security.LoginUser @RestController -@RequestMapping("/api/v1/member") +@RequestMapping("/api/v1/members") class MemberController( private val getMyMemberService: GetMyMemberService, private val updateMyMemberService: UpdateMyMemberService, From 5e632c37cbb13ed68dfde46529ca1a523aec6996 Mon Sep 17 00:00:00 2001 From: hej090224 Date: Fri, 31 Jul 2026 22:53:10 +0900 Subject: [PATCH 14/15] fix: #17 :: return 400 instead of 500 for a path variable that fails type conversion --- .../cklob/mudda/global/exception/GlobalExceptionHandler.kt | 6 ++++++ .../mudda/global/exception/GlobalExceptionHandlerTest.kt | 3 +++ 2 files changed, 9 insertions(+) diff --git a/src/main/kotlin/team/cklob/mudda/global/exception/GlobalExceptionHandler.kt b/src/main/kotlin/team/cklob/mudda/global/exception/GlobalExceptionHandler.kt index 22bc5e3..be3ae60 100644 --- a/src/main/kotlin/team/cklob/mudda/global/exception/GlobalExceptionHandler.kt +++ b/src/main/kotlin/team/cklob/mudda/global/exception/GlobalExceptionHandler.kt @@ -6,6 +6,7 @@ import org.springframework.http.converter.HttpMessageNotReadableException import org.springframework.web.bind.MethodArgumentNotValidException import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException import team.cklob.mudda.global.response.ApiResponse import org.slf4j.LoggerFactory @@ -24,6 +25,11 @@ class GlobalExceptionHandler { @ExceptionHandler(HttpMessageNotReadableException::class) fun handleNotReadable(e: HttpMessageNotReadableException) = response(ErrorCode.INVALID_INPUT) + // A path/query variable that fails to convert to its declared type (e.g. a non-numeric member id) + // would otherwise fall through to the catch-all 500 handler below. + @ExceptionHandler(MethodArgumentTypeMismatchException::class) + fun handleTypeMismatch(e: MethodArgumentTypeMismatchException) = response(ErrorCode.INVALID_INPUT) + @ExceptionHandler(Exception::class) fun handleException(e: Exception): ResponseEntity> { logger.error("Unexpected exception type: {}", e.javaClass.name) diff --git a/src/test/kotlin/team/cklob/mudda/global/exception/GlobalExceptionHandlerTest.kt b/src/test/kotlin/team/cklob/mudda/global/exception/GlobalExceptionHandlerTest.kt index e25276c..a8795a6 100644 --- a/src/test/kotlin/team/cklob/mudda/global/exception/GlobalExceptionHandlerTest.kt +++ b/src/test/kotlin/team/cklob/mudda/global/exception/GlobalExceptionHandlerTest.kt @@ -5,6 +5,7 @@ import jakarta.validation.constraints.NotBlank import org.junit.jupiter.api.Test import org.springframework.http.MediaType import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RestController @@ -23,6 +24,7 @@ class GlobalExceptionHandlerTest { mockMvc.perform(post("/valid").contentType(MediaType.APPLICATION_JSON).content("{}")) .andExpect(status().isBadRequest).andExpect(jsonPath("$.error.code").value("C001")) mockMvc.perform(get("/unexpected")).andExpect(status().isInternalServerError).andExpect(jsonPath("$.error.message").value("Internal server error.")) + mockMvc.perform(get("/typed/not-a-number")).andExpect(status().isBadRequest).andExpect(jsonPath("$.error.code").value("C001")) } @RestController @@ -30,6 +32,7 @@ class GlobalExceptionHandlerTest { @GetMapping("/business") fun business(): Nothing = throw CapsuleException() @PostMapping("/valid") fun valid(@Valid @RequestBody body: Body) = body @GetMapping("/unexpected") fun unexpected(): Nothing = error("boom") + @GetMapping("/typed/{id}") fun typed(@PathVariable id: Long) = id } private data class Body(@field:NotBlank val value: String?) } From cb99237de6332b7b4f248f632e3140f343024c26 Mon Sep 17 00:00:00 2001 From: hej090224 Date: Sat, 1 Aug 2026 13:08:23 +0900 Subject: [PATCH 15/15] fix: #17 :: reject non-http(s) profileImageUrl values at the request boundary --- .../request/UpdateMyMemberRequest.kt | 4 +++ .../controller/MemberControllerTest.kt | 32 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/main/kotlin/team/cklob/mudda/domain/member/presentation/request/UpdateMyMemberRequest.kt b/src/main/kotlin/team/cklob/mudda/domain/member/presentation/request/UpdateMyMemberRequest.kt index bc26fbb..352cd97 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/member/presentation/request/UpdateMyMemberRequest.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/member/presentation/request/UpdateMyMemberRequest.kt @@ -2,6 +2,7 @@ package team.cklob.mudda.domain.member.presentation.request import jakarta.validation.constraints.Max import jakarta.validation.constraints.Min +import jakarta.validation.constraints.Pattern import jakarta.validation.constraints.Size import team.cklob.mudda.domain.member.domain.type.Gender import team.cklob.mudda.domain.member.domain.type.ProfileVisibility @@ -19,6 +20,9 @@ data class UpdateMyMemberRequest( @field:Max(2100) val birthYear: Int? = null, + // Blank is allowed through here so the service layer's empty-string-to-null clearing still works; + // only an actually non-blank, non-http(s) value (e.g. javascript:, data:, file:) is rejected. + @field:Pattern(regexp = "^\\s*$|^https?://\\S+$", message = "profileImageUrl must be blank or an http(s) URL") @field:Size(max = 255) val profileImageUrl: String? = null, diff --git a/src/test/kotlin/team/cklob/mudda/domain/member/presentation/controller/MemberControllerTest.kt b/src/test/kotlin/team/cklob/mudda/domain/member/presentation/controller/MemberControllerTest.kt index c0f44e4..22a1de0 100644 --- a/src/test/kotlin/team/cklob/mudda/domain/member/presentation/controller/MemberControllerTest.kt +++ b/src/test/kotlin/team/cklob/mudda/domain/member/presentation/controller/MemberControllerTest.kt @@ -102,6 +102,38 @@ class MemberControllerTest(@Autowired private val mockMvc: MockMvc, @Autowired p .andExpect(jsonPath("$.data.bio").value("new bio")) } + @Test fun `updateMe rejects a non-http(s) profileImageUrl before reaching the service`() { + val token = accessTokenFor(1L) + + mockMvc.perform( + patch("/api/v1/members/me") + .header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"profileImageUrl":"javascript:alert(1)"}"""), + ).andExpect(status().isBadRequest) + } + + @Test fun `updateMe accepts an http(s) profileImageUrl and an empty string to clear it`() { + val token = accessTokenFor(1L) + every { updateMyMemberService.execute(1L, UpdateMyMemberRequest(profileImageUrl = "http://cdn.local/img.png")) } returns + myMemberResponse().copy(profileImageUrl = "http://cdn.local/img.png") + every { updateMyMemberService.execute(1L, UpdateMyMemberRequest(profileImageUrl = "")) } returns myMemberResponse().copy(profileImageUrl = null) + + mockMvc.perform( + patch("/api/v1/members/me") + .header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"profileImageUrl":"http://cdn.local/img.png"}"""), + ).andExpect(status().isOk) + + mockMvc.perform( + patch("/api/v1/members/me") + .header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"profileImageUrl":""}"""), + ).andExpect(status().isOk) + } + @Test fun `updateMe returns 409 when the nickname is already taken`() { val token = accessTokenFor(1L) every { updateMyMemberService.execute(1L, UpdateMyMemberRequest(nickname = "taken")) } throws BusinessException(ErrorCode.NICKNAME_ALREADY_EXISTS)