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/friend/domain/repository/FriendRepository.kt b/src/main/kotlin/team/cklob/mudda/domain/friend/domain/repository/FriendRepository.kt index 54d59d4..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 @@ -8,4 +8,14 @@ interface FriendRepository : JpaRepository { fun findByRequesterIdOrReceiverId(requesterId: Long, receiverId: Long): List 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, + ): List } 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..1adbc94 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/member/application/ProfileAccessPolicy.kt @@ -0,0 +1,15 @@ +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 { + if (isSelf) return true + return when (visibility) { + ProfileVisibility.PUBLIC -> true + ProfileVisibility.FRIEND -> friendStatus == FriendStatus.FRIEND + ProfileVisibility.PRIVATE -> 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..662eb28 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/member/application/impl/GetMemberProfileService.kt @@ -0,0 +1,43 @@ +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) + } + + // 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 relations = friendRepository.findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(viewerId, memberId, memberId, viewerId) + if (relations.any { it.status == FriendRequestStatus.ACCEPTED }) return FriendStatus.FRIEND + + 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/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..f873faa --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/member/application/impl/GetMyMemberService.kt @@ -0,0 +1,23 @@ +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.AuthException +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 { + // 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 new file mode 100644 index 0000000..50fdd69 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/member/application/impl/UpdateMyMemberService.kt @@ -0,0 +1,57 @@ +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.AuthException +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 { 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) + + request.name?.let { + if (it.isBlank()) throw BusinessException(ErrorCode.INVALID_INPUT) + member.name = it.trim() + } + 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) + } + member.nickname = nickname + } + request.gender?.let { member.gender = it } + request.birthYear?.let { member.birthYear = it } + 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) { + // 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/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/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..c4c7149 --- /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/members") +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..352cd97 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/member/presentation/request/UpdateMyMemberRequest.kt @@ -0,0 +1,38 @@ +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 + +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, + + // 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, + + @field:Size(max = 100) + val bio: String? = null, + + 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/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..51125c2 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/member/presentation/response/MemberProfileResponse.kt @@ -0,0 +1,30 @@ +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 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), + 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, + ) + } +} 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..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,9 @@ 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."), CAPSULE_NOT_FOUND(HttpStatus.NOT_FOUND, "T001", "Time capsule not found."), } 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/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" 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..93a9c82 --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/domain/member/application/impl/GetMemberProfileServiceTest.kt @@ -0,0 +1,167 @@ +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 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`() { + 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)) + mockFriendRelation(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)) + mockFriendRelation(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)) + mockFriendRelation(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)) + mockFriendRelation(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)) + 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())) + + 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..be0d1b3 --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/domain/member/application/impl/GetMyMemberServiceTest.kt @@ -0,0 +1,58 @@ +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.AuthException +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(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.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 new file mode 100644 index 0000000..e46c7b3 --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/domain/member/application/impl/UpdateMyMemberServiceTest.kt @@ -0,0 +1,200 @@ +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.AuthException +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 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`() { + 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 `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) + 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.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`() { + 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) + 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) } + } + + @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) } + } +} 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..22a1de0 --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/domain/member/presentation/controller/MemberControllerTest.kt @@ -0,0 +1,198 @@ +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/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/members/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")) + .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/members/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`() { + val token = accessTokenFor(1L) + + mockMvc.perform( + patch("/api/v1/members/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/members/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 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) + + mockMvc.perform( + patch("/api/v1/members/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/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, nickname = "other-nick", gender = Gender.FEMALE, birthYear = 1999, + profileImageUrl = null, bio = null, friendStatus = FriendStatus.NONE, createdAt = now, + ) + + 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()) + } + + @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/members/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/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")) + } +} 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?) }