From cef88abf00de506436696a18c8b6d19a10e45cd4 Mon Sep 17 00:00:00 2001 From: hej090224 Date: Wed, 5 Aug 2026 17:19:21 +0900 Subject: [PATCH 1/4] feat: #21 :: implement friend domain api --- .../domain/repository/BlockRepository.kt | 12 + .../application/impl/DeleteFriendService.kt | 20 ++ .../application/impl/GetFriendListService.kt | 38 +++ .../impl/GetFriendRequestListService.kt | 32 ++ .../impl/RespondFriendRequestService.kt | 33 ++ .../application/impl/SearchFriendService.kt | 62 ++++ .../impl/SendFriendRequestService.kt | 60 ++++ .../domain/repository/FriendRepository.kt | 45 +++ .../friend/domain/type/FriendRequestAction.kt | 6 + .../controller/FriendController.kt | 127 ++++++++ .../request/RespondFriendRequestRequest.kt | 10 + .../request/SendFriendRequestRequest.kt | 15 + .../response/FriendPageResponse.kt | 40 +++ .../response/FriendRequestResponse.kt | 44 +++ .../presentation/response/FriendResponse.kt | 29 ++ .../response/FriendSearchResponse.kt | 38 +++ .../response/SendFriendRequestResponse.kt | 18 ++ .../domain/repository/MemberRepository.kt | 49 +++ .../cklob/mudda/global/exception/ErrorCode.kt | 10 + .../exception/GlobalExceptionHandler.kt | 6 + src/main/resources/application.yaml | 4 + ...st_indexes_and_pending_pair_constraint.sql | 16 + .../impl/DeleteFriendServiceTest.kt | 62 ++++ .../impl/GetFriendListServiceTest.kt | 95 ++++++ .../impl/GetFriendRequestListServiceTest.kt | 69 +++++ .../impl/RespondFriendRequestServiceTest.kt | 86 ++++++ .../impl/SearchFriendServiceTest.kt | 121 ++++++++ .../impl/SendFriendRequestServiceTest.kt | 181 +++++++++++ .../FriendRepositoryIntegrationTest.kt | 110 +++++++ .../controller/FriendControllerTest.kt | 288 ++++++++++++++++++ .../MemberRepositorySearchIntegrationTest.kt | 122 ++++++++ 31 files changed, 1848 insertions(+) create mode 100644 src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/DeleteFriendService.kt create mode 100644 src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/GetFriendListService.kt create mode 100644 src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/GetFriendRequestListService.kt create mode 100644 src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/RespondFriendRequestService.kt create mode 100644 src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/SearchFriendService.kt create mode 100644 src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/SendFriendRequestService.kt create mode 100644 src/main/kotlin/team/cklob/mudda/domain/friend/domain/type/FriendRequestAction.kt create mode 100644 src/main/kotlin/team/cklob/mudda/domain/friend/presentation/controller/FriendController.kt create mode 100644 src/main/kotlin/team/cklob/mudda/domain/friend/presentation/request/RespondFriendRequestRequest.kt create mode 100644 src/main/kotlin/team/cklob/mudda/domain/friend/presentation/request/SendFriendRequestRequest.kt create mode 100644 src/main/kotlin/team/cklob/mudda/domain/friend/presentation/response/FriendPageResponse.kt create mode 100644 src/main/kotlin/team/cklob/mudda/domain/friend/presentation/response/FriendRequestResponse.kt create mode 100644 src/main/kotlin/team/cklob/mudda/domain/friend/presentation/response/FriendResponse.kt create mode 100644 src/main/kotlin/team/cklob/mudda/domain/friend/presentation/response/FriendSearchResponse.kt create mode 100644 src/main/kotlin/team/cklob/mudda/domain/friend/presentation/response/SendFriendRequestResponse.kt create mode 100644 src/main/resources/db/migration/V4__add_friend_request_indexes_and_pending_pair_constraint.sql create mode 100644 src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/DeleteFriendServiceTest.kt create mode 100644 src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/GetFriendListServiceTest.kt create mode 100644 src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/GetFriendRequestListServiceTest.kt create mode 100644 src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/RespondFriendRequestServiceTest.kt create mode 100644 src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/SearchFriendServiceTest.kt create mode 100644 src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/SendFriendRequestServiceTest.kt create mode 100644 src/test/kotlin/team/cklob/mudda/domain/friend/domain/repository/FriendRepositoryIntegrationTest.kt create mode 100644 src/test/kotlin/team/cklob/mudda/domain/friend/presentation/controller/FriendControllerTest.kt create mode 100644 src/test/kotlin/team/cklob/mudda/domain/member/domain/repository/MemberRepositorySearchIntegrationTest.kt diff --git a/src/main/kotlin/team/cklob/mudda/domain/block/domain/repository/BlockRepository.kt b/src/main/kotlin/team/cklob/mudda/domain/block/domain/repository/BlockRepository.kt index 982d782..c5a4b88 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/block/domain/repository/BlockRepository.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/block/domain/repository/BlockRepository.kt @@ -6,4 +6,16 @@ import team.cklob.mudda.domain.block.domain.entity.Block interface BlockRepository : JpaRepository { fun existsByBlockerIdAndBlockedId(blockerId: Long, blockedId: Long): Boolean fun findByBlockerId(blockerId: Long): List + + // Bidirectional existence check: true if either member has blocked the other. + fun existsByBlockerIdAndBlockedIdOrBlockerIdAndBlockedId( + blockerId1: Long, + blockedId1: Long, + blockerId2: Long, + blockedId2: Long, + ): Boolean + + // All block rows where the given member is on either side, used to build a single member's full + // bidirectional block set in one query (e.g. filtering the friend list) instead of per-row lookups. + fun findByBlockerIdOrBlockedId(blockerId: Long, blockedId: Long): List } diff --git a/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/DeleteFriendService.kt b/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/DeleteFriendService.kt new file mode 100644 index 0000000..9b95f96 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/DeleteFriendService.kt @@ -0,0 +1,20 @@ +package team.cklob.mudda.domain.friend.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.global.exception.BusinessException +import team.cklob.mudda.global.exception.ErrorCode + +@Service +class DeleteFriendService( + private val friendRepository: FriendRepository, +) { + @Transactional + fun execute(memberId: Long, targetMemberId: Long) { + val relations = friendRepository.findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(memberId, targetMemberId, targetMemberId, memberId) + val friend = relations.firstOrNull { it.status == FriendRequestStatus.ACCEPTED } ?: throw BusinessException(ErrorCode.FRIEND_NOT_FOUND) + friendRepository.delete(friend) + } +} diff --git a/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/GetFriendListService.kt b/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/GetFriendListService.kt new file mode 100644 index 0000000..d583bac --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/GetFriendListService.kt @@ -0,0 +1,38 @@ +package team.cklob.mudda.domain.friend.application.impl + +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import team.cklob.mudda.domain.block.domain.repository.BlockRepository +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.presentation.response.FriendPageResponse +import team.cklob.mudda.domain.friend.presentation.response.FriendResponse +import team.cklob.mudda.domain.member.domain.entity.Member + +@Service +class GetFriendListService( + private val friendRepository: FriendRepository, + private val blockRepository: BlockRepository, +) { + @Transactional(readOnly = true) + fun execute(memberId: Long, pageable: Pageable): FriendPageResponse { + val page = friendRepository.findFriendships(memberId, FriendRequestStatus.ACCEPTED, pageable) + val blockedMemberIds = blockRepository.findByBlockerIdOrBlockedId(memberId, memberId) + .mapNotNull { if (it.blocker.id == memberId) it.blocked.id else it.blocker.id } + .toSet() + + // Block rows are filtered out of the already-paginated content, so a page can legitimately return + // fewer than `size` items when a blocked member is among ACCEPTED friends -- acceptable for now since + // blocking is expected to be rare and the Block domain's own API is out of this PR's scope. + val content = page.content.mapNotNull { friend -> + val other = counterpart(friend, memberId) + if (other.id in blockedMemberIds) null else FriendResponse.of(other, requireNotNull(friend.acceptedAt)) + } + + return FriendPageResponse.of(page, content) + } + + private fun counterpart(friend: Friend, memberId: Long): Member = if (friend.requester.id == memberId) friend.receiver else friend.requester +} diff --git a/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/GetFriendRequestListService.kt b/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/GetFriendRequestListService.kt new file mode 100644 index 0000000..5aafb44 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/GetFriendRequestListService.kt @@ -0,0 +1,32 @@ +package team.cklob.mudda.domain.friend.application.impl + +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +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.FriendRequestType +import team.cklob.mudda.domain.friend.presentation.response.FriendPageResponse +import team.cklob.mudda.domain.friend.presentation.response.FriendRequestResponse + +@Service +class GetFriendRequestListService( + private val friendRepository: FriendRepository, +) { + @Transactional(readOnly = true) + fun execute(memberId: Long, type: FriendRequestType, status: FriendRequestStatus, pageable: Pageable): FriendPageResponse { + val page: Page = when (type) { + FriendRequestType.RECEIVED -> friendRepository.findReceivedRequests(memberId, status, pageable) + FriendRequestType.SENT -> friendRepository.findSentRequests(memberId, status, pageable) + } + + val content = page.content.map { friend -> + val counterpart = if (type == FriendRequestType.RECEIVED) friend.requester else friend.receiver + FriendRequestResponse.of(friend, type, counterpart) + } + + return FriendPageResponse.of(page, content) + } +} diff --git a/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/RespondFriendRequestService.kt b/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/RespondFriendRequestService.kt new file mode 100644 index 0000000..706c481 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/RespondFriendRequestService.kt @@ -0,0 +1,33 @@ +package team.cklob.mudda.domain.friend.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.FriendRequestAction +import team.cklob.mudda.domain.friend.domain.type.FriendRequestStatus +import team.cklob.mudda.domain.friend.presentation.request.RespondFriendRequestRequest +import team.cklob.mudda.global.exception.BusinessException +import team.cklob.mudda.global.exception.ErrorCode +import java.time.LocalDateTime + +@Service +class RespondFriendRequestService( + private val friendRepository: FriendRepository, +) { + @Transactional + fun execute(memberId: Long, requestId: Long, request: RespondFriendRequestRequest) { + val friend = friendRepository.findById(requestId).orElseThrow { BusinessException(ErrorCode.FRIEND_REQUEST_NOT_FOUND) } + if (friend.receiver.id != memberId) throw BusinessException(ErrorCode.FRIEND_REQUEST_NOT_RECEIVER) + if (friend.status != FriendRequestStatus.PENDING) throw BusinessException(ErrorCode.FRIEND_REQUEST_ALREADY_PROCESSED) + + when (request.action) { + FriendRequestAction.ACCEPT -> { + friend.status = FriendRequestStatus.ACCEPTED + friend.acceptedAt = LocalDateTime.now() + } + FriendRequestAction.REJECT -> { + friend.status = FriendRequestStatus.REJECTED + } + } + } +} diff --git a/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/SearchFriendService.kt b/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/SearchFriendService.kt new file mode 100644 index 0000000..d32ee85 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/SearchFriendService.kt @@ -0,0 +1,62 @@ +package team.cklob.mudda.domain.friend.application.impl + +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +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.FriendRequestType +import team.cklob.mudda.domain.friend.domain.type.FriendStatus +import team.cklob.mudda.domain.friend.presentation.response.FriendPageResponse +import team.cklob.mudda.domain.friend.presentation.response.FriendSearchResponse +import team.cklob.mudda.domain.member.domain.repository.MemberRepository +import team.cklob.mudda.global.exception.BusinessException +import team.cklob.mudda.global.exception.ErrorCode + +@Service +class SearchFriendService( + private val memberRepository: MemberRepository, + private val friendRepository: FriendRepository, +) { + @Transactional(readOnly = true) + fun execute(memberId: Long, keyword: String, pageable: Pageable): FriendPageResponse { + val trimmed = keyword.trim() + if (trimmed.isBlank()) throw BusinessException(ErrorCode.INVALID_SEARCH_KEYWORD) + + val page = memberRepository.searchSelectableByNickname(memberId, trimmed, escapeLike(trimmed), pageable) + val candidateIds = page.content.mapNotNull { it.id } + val relationsByOtherId = if (candidateIds.isEmpty()) emptyMap() else groupRelationsByOtherId(memberId, friendRepository.findAllBetween(memberId, candidateIds)) + + val content = page.content.map { candidate -> + val relation = relationsByOtherId[candidate.id] + val (status, direction) = resolveRelation(memberId, relation) + FriendSearchResponse.of(candidate, status, relation?.id, direction) + } + + return FriendPageResponse.of(page, content) + } + + // 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, mirroring GetMemberProfileService. + private fun groupRelationsByOtherId(memberId: Long, relations: List): Map = + relations.groupBy { if (it.requester.id == memberId) it.receiver.id else it.requester.id } + .mapNotNull { (otherId, rels) -> + val chosen = rels.firstOrNull { it.status == FriendRequestStatus.ACCEPTED } ?: rels.firstOrNull { it.status == FriendRequestStatus.PENDING } ?: rels.first() + otherId?.let { it to chosen } + }.toMap() + + private fun resolveRelation(memberId: Long, relation: Friend?): Pair { + if (relation == null) return FriendStatus.NONE to null + val sentByMe = relation.requester.id == memberId + return when (relation.status) { + FriendRequestStatus.ACCEPTED -> FriendStatus.FRIEND to (if (sentByMe) FriendRequestType.SENT else FriendRequestType.RECEIVED) + FriendRequestStatus.PENDING -> (if (sentByMe) FriendStatus.REQUESTED else FriendStatus.RECEIVED) to (if (sentByMe) FriendRequestType.SENT else FriendRequestType.RECEIVED) + FriendRequestStatus.REJECTED -> FriendStatus.NONE to null + } + } + + // Escapes LIKE wildcards (%, _) and the escape character itself ('!') so a keyword containing them is + // matched literally instead of as a wildcard pattern. Paired with `ESCAPE '!'` in MemberRepository. + private fun escapeLike(raw: String): String = raw.replace("!", "!!").replace("%", "!%").replace("_", "!_") +} diff --git a/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/SendFriendRequestService.kt b/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/SendFriendRequestService.kt new file mode 100644 index 0000000..1364003 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/SendFriendRequestService.kt @@ -0,0 +1,60 @@ +package team.cklob.mudda.domain.friend.application.impl + +import org.springframework.dao.DataIntegrityViolationException +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import team.cklob.mudda.domain.block.domain.repository.BlockRepository +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.presentation.request.SendFriendRequestRequest +import team.cklob.mudda.domain.friend.presentation.response.SendFriendRequestResponse +import team.cklob.mudda.domain.member.domain.repository.MemberRepository +import team.cklob.mudda.global.exception.AuthException +import team.cklob.mudda.global.exception.BusinessException +import team.cklob.mudda.global.exception.ErrorCode + +@Service +class SendFriendRequestService( + private val friendRepository: FriendRepository, + private val memberRepository: MemberRepository, + private val blockRepository: BlockRepository, +) { + @Transactional + fun execute(memberId: Long, request: SendFriendRequestRequest): SendFriendRequestResponse { + // @field:NotNull on SendFriendRequestRequest.receiverId already rejects a null/missing value with a + // 400 before this service runs; requireNotNull here just documents that invariant for callers. + val receiverId = requireNotNull(request.receiverId) + if (memberId == receiverId) throw BusinessException(ErrorCode.CANNOT_REQUEST_SELF) + + val requester = memberRepository.findById(memberId).orElseThrow { AuthException(ErrorCode.UNAUTHORIZED) } + if (requester.withdrawnAt != null) throw BusinessException(ErrorCode.WITHDRAWN_MEMBER) + + val receiver = memberRepository.findById(receiverId).orElseThrow { BusinessException(ErrorCode.MEMBER_NOT_FOUND) } + if (receiver.withdrawnAt != null || receiver.nickname == null) throw BusinessException(ErrorCode.MEMBER_NOT_FOUND) + + if (blockRepository.existsByBlockerIdAndBlockedIdOrBlockerIdAndBlockedId(memberId, receiverId, receiverId, memberId)) { + throw BusinessException(ErrorCode.BLOCKED_MEMBER) + } + + val existingRelations = friendRepository.findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(memberId, receiverId, receiverId, memberId) + existingRelations.forEach { relation -> + when { + relation.status == FriendRequestStatus.ACCEPTED -> throw BusinessException(ErrorCode.ALREADY_FRIENDS) + relation.status == FriendRequestStatus.PENDING && relation.requester.id == memberId -> throw BusinessException(ErrorCode.FRIEND_REQUEST_ALREADY_EXISTS) + relation.status == FriendRequestStatus.PENDING -> throw BusinessException(ErrorCode.REVERSE_FRIEND_REQUEST_EXISTS) + // REJECTED rows don't block a new request; a fresh row is created below. + } + } + + val saved = try { + friendRepository.saveAndFlush(Friend(requester = requester, receiver = receiver, status = FriendRequestStatus.PENDING)) + } catch (e: DataIntegrityViolationException) { + // Safety net for a concurrent reverse-direction PENDING insert that raced past the check above -- + // see uq_friend_pending_pair in V4__add_friend_request_indexes_and_pending_pair_constraint.sql. + throw BusinessException(ErrorCode.REVERSE_FRIEND_REQUEST_EXISTS) + } + + return SendFriendRequestResponse.from(saved) + } +} 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 08feefb..1bf8996 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 @@ -1,7 +1,12 @@ package team.cklob.mudda.domain.friend.domain.repository +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param import team.cklob.mudda.domain.friend.domain.entity.Friend +import team.cklob.mudda.domain.friend.domain.type.FriendRequestStatus import java.util.Optional interface FriendRepository : JpaRepository { @@ -18,4 +23,44 @@ interface FriendRepository : JpaRepository { requesterId2: Long, receiverId2: Long, ): List + + // Fetches every relationship row (any status) between the viewer and a batch of other member ids in a + // single query, so a search-result page or similar batch lookup doesn't issue one query per candidate. + @Query( + """ + SELECT f FROM Friend f + WHERE (f.requester.id = :memberId AND f.receiver.id IN :otherIds) + OR (f.receiver.id = :memberId AND f.requester.id IN :otherIds) + """, + ) + fun findAllBetween(@Param("memberId") memberId: Long, @Param("otherIds") otherIds: Collection): List + + // requester/receiver are eagerly fetched so the response mapping (counterpart nickname/profileImageUrl) + // doesn't trigger an N+1 lazy load per row. + @Query( + """ + SELECT f FROM Friend f JOIN FETCH f.requester JOIN FETCH f.receiver + WHERE f.status = :status AND (f.requester.id = :memberId OR f.receiver.id = :memberId) + ORDER BY f.acceptedAt DESC + """, + ) + fun findFriendships(@Param("memberId") memberId: Long, @Param("status") status: FriendRequestStatus, pageable: Pageable): Page + + @Query( + """ + SELECT f FROM Friend f JOIN FETCH f.requester JOIN FETCH f.receiver + WHERE f.receiver.id = :receiverId AND f.status = :status + ORDER BY f.createdAt DESC + """, + ) + fun findReceivedRequests(@Param("receiverId") receiverId: Long, @Param("status") status: FriendRequestStatus, pageable: Pageable): Page + + @Query( + """ + SELECT f FROM Friend f JOIN FETCH f.requester JOIN FETCH f.receiver + WHERE f.requester.id = :requesterId AND f.status = :status + ORDER BY f.createdAt DESC + """, + ) + fun findSentRequests(@Param("requesterId") requesterId: Long, @Param("status") status: FriendRequestStatus, pageable: Pageable): Page } diff --git a/src/main/kotlin/team/cklob/mudda/domain/friend/domain/type/FriendRequestAction.kt b/src/main/kotlin/team/cklob/mudda/domain/friend/domain/type/FriendRequestAction.kt new file mode 100644 index 0000000..aed9b0b --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/friend/domain/type/FriendRequestAction.kt @@ -0,0 +1,6 @@ +package team.cklob.mudda.domain.friend.domain.type + +enum class FriendRequestAction { + ACCEPT, + REJECT, +} diff --git a/src/main/kotlin/team/cklob/mudda/domain/friend/presentation/controller/FriendController.kt b/src/main/kotlin/team/cklob/mudda/domain/friend/presentation/controller/FriendController.kt new file mode 100644 index 0000000..e98ec88 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/friend/presentation/controller/FriendController.kt @@ -0,0 +1,127 @@ +package team.cklob.mudda.domain.friend.presentation.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.responses.ApiResponse as SwaggerApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses as SwaggerApiResponses +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.data.domain.Pageable +import org.springframework.data.web.PageableDefault +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import team.cklob.mudda.domain.friend.application.impl.DeleteFriendService +import team.cklob.mudda.domain.friend.application.impl.GetFriendListService +import team.cklob.mudda.domain.friend.application.impl.GetFriendRequestListService +import team.cklob.mudda.domain.friend.application.impl.RespondFriendRequestService +import team.cklob.mudda.domain.friend.application.impl.SearchFriendService +import team.cklob.mudda.domain.friend.application.impl.SendFriendRequestService +import team.cklob.mudda.domain.friend.domain.type.FriendRequestStatus +import team.cklob.mudda.domain.friend.domain.type.FriendRequestType +import team.cklob.mudda.domain.friend.presentation.request.RespondFriendRequestRequest +import team.cklob.mudda.domain.friend.presentation.request.SendFriendRequestRequest +import team.cklob.mudda.domain.friend.presentation.response.FriendPageResponse +import team.cklob.mudda.domain.friend.presentation.response.FriendRequestResponse +import team.cklob.mudda.domain.friend.presentation.response.FriendResponse +import team.cklob.mudda.domain.friend.presentation.response.FriendSearchResponse +import team.cklob.mudda.domain.friend.presentation.response.SendFriendRequestResponse +import team.cklob.mudda.global.response.ApiResponse +import team.cklob.mudda.global.security.LoginUser + +@Tag(name = "Friend", description = "친구 목록/검색/요청/삭제 API") +@SecurityRequirement(name = "bearerAuth") +@RestController +@RequestMapping("/api/v1/friends") +class FriendController( + private val getFriendListService: GetFriendListService, + private val searchFriendService: SearchFriendService, + private val sendFriendRequestService: SendFriendRequestService, + private val getFriendRequestListService: GetFriendRequestListService, + private val respondFriendRequestService: RespondFriendRequestService, + private val deleteFriendService: DeleteFriendService, +) { + @Operation(summary = "친구 목록 조회", description = "로그인 사용자의 ACCEPTED 상태 친구 목록을 최근 친구가 된 순으로 조회합니다.") + @GetMapping + fun getFriends( + @LoginUser memberId: Long, + @PageableDefault(size = 20) pageable: Pageable, + ): ResponseEntity>> = + ResponseEntity.ok(ApiResponse.success(getFriendListService.execute(memberId, pageable))) + + @Operation(summary = "사용자 검색", description = "닉네임으로 다른 회원을 검색하고, 로그인 사용자와의 친구 관계 상태를 함께 반환합니다.") + @GetMapping("/search") + fun search( + @LoginUser memberId: Long, + @Parameter(description = "검색 닉네임 키워드", example = "nick") @RequestParam keyword: String, + @PageableDefault(size = 20) pageable: Pageable, + ): ResponseEntity>> = + ResponseEntity.ok(ApiResponse.success(searchFriendService.execute(memberId, keyword, pageable))) + + @Operation(summary = "친구 요청 전송", description = "다른 회원에게 친구 요청을 보냅니다.") + @SwaggerApiResponses( + SwaggerApiResponse(responseCode = "201", description = "요청 생성 성공"), + SwaggerApiResponse(responseCode = "400", description = "자기 자신에게 요청(CANNOT_REQUEST_SELF) 등 잘못된 입력"), + SwaggerApiResponse(responseCode = "403", description = "차단 관계로 요청 불가(BLOCKED_MEMBER)"), + SwaggerApiResponse(responseCode = "404", description = "대상 회원 없음(MEMBER_NOT_FOUND)"), + SwaggerApiResponse(responseCode = "409", description = "이미 친구이거나(ALREADY_FRIENDS) 중복/역방향 대기 요청 존재"), + ) + @PostMapping("/requests") + @ResponseStatus(HttpStatus.CREATED) + fun sendRequest( + @LoginUser memberId: Long, + @Valid @RequestBody request: SendFriendRequestRequest, + ): ApiResponse = ApiResponse.success(sendFriendRequestService.execute(memberId, request)) + + @Operation(summary = "친구 요청 목록 조회", description = "받은(RECEIVED) 또는 보낸(SENT) 친구 요청 목록을 조회합니다. 기본적으로 PENDING 상태만 반환합니다.") + @GetMapping("/requests") + fun getRequests( + @LoginUser memberId: Long, + @Parameter(description = "요청 방향", example = "RECEIVED") @RequestParam type: FriendRequestType, + @Parameter(description = "요청 상태 필터", example = "PENDING") @RequestParam(defaultValue = "PENDING") status: FriendRequestStatus, + @PageableDefault(size = 20) pageable: Pageable, + ): ResponseEntity>> = + ResponseEntity.ok(ApiResponse.success(getFriendRequestListService.execute(memberId, type, status, pageable))) + + @Operation(summary = "친구 요청 수락/거절", description = "요청 수신자만 자신이 받은 PENDING 요청을 ACCEPT 또는 REJECT 할 수 있습니다.") + @SwaggerApiResponses( + SwaggerApiResponse(responseCode = "204", description = "처리 성공"), + SwaggerApiResponse(responseCode = "400", description = "action 누락 또는 지원하지 않는 값"), + SwaggerApiResponse(responseCode = "403", description = "요청 수신자가 아님(FRIEND_REQUEST_NOT_RECEIVER)"), + SwaggerApiResponse(responseCode = "404", description = "요청 없음(FRIEND_REQUEST_NOT_FOUND)"), + SwaggerApiResponse(responseCode = "409", description = "이미 처리된 요청(FRIEND_REQUEST_ALREADY_PROCESSED)"), + ) + @PatchMapping("/requests/{requestId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun respondToRequest( + @LoginUser memberId: Long, + @PathVariable requestId: Long, + @Valid @RequestBody request: RespondFriendRequestRequest, + ) { + respondFriendRequestService.execute(memberId, requestId, request) + } + + @Operation(summary = "친구 삭제", description = "ACCEPTED 상태인 친구 관계를 삭제합니다. 두 당사자 중 누구든 호출할 수 있습니다.") + @SwaggerApiResponses( + SwaggerApiResponse(responseCode = "204", description = "삭제 성공"), + SwaggerApiResponse(responseCode = "404", description = "ACCEPTED 상태의 친구 관계 없음(FRIEND_NOT_FOUND)"), + ) + @DeleteMapping("/{memberId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun deleteFriend( + @LoginUser loginMemberId: Long, + @Parameter(description = "삭제할 친구의 회원 ID") @PathVariable("memberId") targetMemberId: Long, + ) { + deleteFriendService.execute(loginMemberId, targetMemberId) + } +} diff --git a/src/main/kotlin/team/cklob/mudda/domain/friend/presentation/request/RespondFriendRequestRequest.kt b/src/main/kotlin/team/cklob/mudda/domain/friend/presentation/request/RespondFriendRequestRequest.kt new file mode 100644 index 0000000..28c4892 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/friend/presentation/request/RespondFriendRequestRequest.kt @@ -0,0 +1,10 @@ +package team.cklob.mudda.domain.friend.presentation.request + +import io.swagger.v3.oas.annotations.media.Schema +import team.cklob.mudda.domain.friend.domain.type.FriendRequestAction + +@Schema(description = "친구 요청 수락/거절 요청") +data class RespondFriendRequestRequest( + @Schema(description = "수행할 행위. ACCEPT 또는 REJECT", example = "ACCEPT") + val action: FriendRequestAction, +) diff --git a/src/main/kotlin/team/cklob/mudda/domain/friend/presentation/request/SendFriendRequestRequest.kt b/src/main/kotlin/team/cklob/mudda/domain/friend/presentation/request/SendFriendRequestRequest.kt new file mode 100644 index 0000000..7ef2df9 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/friend/presentation/request/SendFriendRequestRequest.kt @@ -0,0 +1,15 @@ +package team.cklob.mudda.domain.friend.presentation.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotNull + +// receiverId is nullable + @NotNull (rather than a bare non-null Long) so a missing/explicit-null value +// fails bean validation with a 400 before reaching the service, instead of relying on Jackson's +// implicit Kotlin non-null parameter enforcement for a primitive-backed type, which does not reliably +// trigger for a missing JSON key. +@Schema(description = "친구 요청 전송 요청") +data class SendFriendRequestRequest( + @field:NotNull + @Schema(description = "친구 요청을 받을 회원의 ID", example = "2") + val receiverId: Long?, +) diff --git a/src/main/kotlin/team/cklob/mudda/domain/friend/presentation/response/FriendPageResponse.kt b/src/main/kotlin/team/cklob/mudda/domain/friend/presentation/response/FriendPageResponse.kt new file mode 100644 index 0000000..4f3b07e --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/friend/presentation/response/FriendPageResponse.kt @@ -0,0 +1,40 @@ +package team.cklob.mudda.domain.friend.presentation.response + +import io.swagger.v3.oas.annotations.media.Schema +import org.springframework.data.domain.Page + +// Minimal page wrapper shared by the Friend domain's three list endpoints (friend list, search, +// request list). Spring's own PageImpl serializes a lot of pageable/sort internals that clients don't +// need, and the project has no existing common page response to reuse -- this is a right-sized +// substitute rather than a project-wide abstraction. +@Schema(description = "페이지 응답") +data class FriendPageResponse( + @Schema(description = "현재 페이지의 데이터 목록") + val content: List, + + @Schema(description = "현재 페이지 번호(0-base)", example = "0") + val page: Int, + + @Schema(description = "페이지 크기", example = "20") + val size: Int, + + @Schema(description = "전체 요소 수", example = "3") + val totalElements: Long, + + @Schema(description = "전체 페이지 수", example = "1") + val totalPages: Int, + + @Schema(description = "다음 페이지 존재 여부", example = "false") + val hasNext: Boolean, +) { + companion object { + fun of(page: Page, content: List) = FriendPageResponse( + content = content, + page = page.number, + size = page.size, + totalElements = page.totalElements, + totalPages = page.totalPages, + hasNext = page.hasNext(), + ) + } +} diff --git a/src/main/kotlin/team/cklob/mudda/domain/friend/presentation/response/FriendRequestResponse.kt b/src/main/kotlin/team/cklob/mudda/domain/friend/presentation/response/FriendRequestResponse.kt new file mode 100644 index 0000000..1969735 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/friend/presentation/response/FriendRequestResponse.kt @@ -0,0 +1,44 @@ +package team.cklob.mudda.domain.friend.presentation.response + +import io.swagger.v3.oas.annotations.media.Schema +import team.cklob.mudda.domain.friend.domain.entity.Friend +import team.cklob.mudda.domain.friend.domain.type.FriendRequestStatus +import team.cklob.mudda.domain.friend.domain.type.FriendRequestType +import team.cklob.mudda.domain.member.domain.entity.Member +import java.time.LocalDateTime + +@Schema(description = "친구 요청 목록 항목") +data class FriendRequestResponse( + @Schema(description = "친구 요청(Friend row)의 ID", example = "10") + val requestId: Long, + + @Schema(description = "요청 방향. RECEIVED(내가 받음) 또는 SENT(내가 보냄)", example = "RECEIVED") + val direction: FriendRequestType, + + @Schema(description = "상대방의 회원 ID", example = "2") + val memberId: Long, + + @Schema(description = "상대방의 닉네임", example = "nickname") + val nickname: String?, + + @Schema(description = "상대방의 프로필 이미지 URL", example = "https://cdn.mudda.team/profile/2.png") + val profileImageUrl: String?, + + @Schema(description = "요청 생성 시각") + val createdAt: LocalDateTime, + + @Schema(description = "요청 상태", example = "PENDING") + val status: FriendRequestStatus, +) { + companion object { + fun of(friend: Friend, direction: FriendRequestType, counterpart: Member) = FriendRequestResponse( + requestId = requireNotNull(friend.id), + direction = direction, + memberId = requireNotNull(counterpart.id), + nickname = counterpart.nickname, + profileImageUrl = counterpart.profileImageUrl, + createdAt = friend.createdAt, + status = friend.status, + ) + } +} diff --git a/src/main/kotlin/team/cklob/mudda/domain/friend/presentation/response/FriendResponse.kt b/src/main/kotlin/team/cklob/mudda/domain/friend/presentation/response/FriendResponse.kt new file mode 100644 index 0000000..7d812d4 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/friend/presentation/response/FriendResponse.kt @@ -0,0 +1,29 @@ +package team.cklob.mudda.domain.friend.presentation.response + +import io.swagger.v3.oas.annotations.media.Schema +import team.cklob.mudda.domain.member.domain.entity.Member +import java.time.LocalDateTime + +@Schema(description = "친구 목록 항목") +data class FriendResponse( + @Schema(description = "친구의 회원 ID", example = "2") + val memberId: Long, + + @Schema(description = "친구의 닉네임", example = "nickname") + val nickname: String?, + + @Schema(description = "친구의 프로필 이미지 URL", example = "https://cdn.mudda.team/profile/2.png") + val profileImageUrl: String?, + + @Schema(description = "친구가 된 시각") + val acceptedAt: LocalDateTime, +) { + companion object { + fun of(counterpart: Member, acceptedAt: LocalDateTime) = FriendResponse( + memberId = requireNotNull(counterpart.id), + nickname = counterpart.nickname, + profileImageUrl = counterpart.profileImageUrl, + acceptedAt = acceptedAt, + ) + } +} diff --git a/src/main/kotlin/team/cklob/mudda/domain/friend/presentation/response/FriendSearchResponse.kt b/src/main/kotlin/team/cklob/mudda/domain/friend/presentation/response/FriendSearchResponse.kt new file mode 100644 index 0000000..f59a1fb --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/friend/presentation/response/FriendSearchResponse.kt @@ -0,0 +1,38 @@ +package team.cklob.mudda.domain.friend.presentation.response + +import io.swagger.v3.oas.annotations.media.Schema +import team.cklob.mudda.domain.friend.domain.type.FriendRequestType +import team.cklob.mudda.domain.friend.domain.type.FriendStatus +import team.cklob.mudda.domain.member.domain.entity.Member + +@Schema(description = "사용자 검색 결과 항목") +data class FriendSearchResponse( + @Schema(description = "검색된 회원의 ID", example = "2") + val memberId: Long, + + @Schema(description = "검색된 회원의 닉네임", example = "nickname") + val nickname: String?, + + @Schema(description = "검색된 회원의 프로필 이미지 URL", example = "https://cdn.mudda.team/profile/2.png") + val profileImageUrl: String?, + + @Schema(description = "로그인 사용자와의 관계 상태. NONE / FRIEND / REQUESTED(내가 보냄) / RECEIVED(내가 받음)", example = "NONE") + val relationStatus: FriendStatus, + + @Schema(description = "진행 중이거나 성사된 친구 관계 row의 ID. 관계가 없으면 null", example = "null") + val requestId: Long?, + + @Schema(description = "관계를 먼저 시작한 방향. 관계가 없으면 null", example = "null") + val requestDirection: FriendRequestType?, +) { + companion object { + fun of(candidate: Member, relationStatus: FriendStatus, requestId: Long?, requestDirection: FriendRequestType?) = FriendSearchResponse( + memberId = requireNotNull(candidate.id), + nickname = candidate.nickname, + profileImageUrl = candidate.profileImageUrl, + relationStatus = relationStatus, + requestId = requestId, + requestDirection = requestDirection, + ) + } +} diff --git a/src/main/kotlin/team/cklob/mudda/domain/friend/presentation/response/SendFriendRequestResponse.kt b/src/main/kotlin/team/cklob/mudda/domain/friend/presentation/response/SendFriendRequestResponse.kt new file mode 100644 index 0000000..1655d05 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/friend/presentation/response/SendFriendRequestResponse.kt @@ -0,0 +1,18 @@ +package team.cklob.mudda.domain.friend.presentation.response + +import io.swagger.v3.oas.annotations.media.Schema +import team.cklob.mudda.domain.friend.domain.entity.Friend +import team.cklob.mudda.domain.friend.domain.type.FriendRequestStatus + +@Schema(description = "친구 요청 전송 결과") +data class SendFriendRequestResponse( + @Schema(description = "생성된 친구 요청(Friend row)의 ID", example = "10") + val requestId: Long, + + @Schema(description = "생성된 요청의 상태", example = "PENDING") + val status: FriendRequestStatus, +) { + companion object { + fun from(friend: Friend) = SendFriendRequestResponse(requestId = requireNotNull(friend.id), status = friend.status) + } +} diff --git a/src/main/kotlin/team/cklob/mudda/domain/member/domain/repository/MemberRepository.kt b/src/main/kotlin/team/cklob/mudda/domain/member/domain/repository/MemberRepository.kt index e5fadce..e97dac8 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/member/domain/repository/MemberRepository.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/member/domain/repository/MemberRepository.kt @@ -1,6 +1,10 @@ package team.cklob.mudda.domain.member.domain.repository +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param import team.cklob.mudda.domain.member.domain.entity.Member import team.cklob.mudda.domain.member.domain.type.OAuthProvider import java.util.Optional @@ -11,4 +15,49 @@ interface MemberRepository : JpaRepository { fun findByEmail(email: String): Optional fun findByNickname(nickname: String): Optional fun findByOauthProviderAndProviderId(oauthProvider: OAuthProvider, providerId: String): Optional + + // Friend search: excludes the viewer, withdrawn/not-yet-signed-up members, and anyone blocked in + // either direction (all filtered in SQL so pagination stays accurate). Ranks an exact nickname match + // first, a prefix match second, and any other `contains` match last. + // `keyword` is the trimmed raw keyword (used for the exact-match rank check); `escapedKeyword` is the + // same keyword with LIKE wildcards (%, _, !) escaped with '!' (used inside LIKE). + @Query( + value = """ + SELECT m FROM Member m + WHERE m.id <> :viewerId + AND m.nickname IS NOT NULL + AND m.withdrawnAt IS NULL + AND LOWER(m.nickname) LIKE LOWER(CONCAT('%', :escapedKeyword, '%')) ESCAPE '!' + AND NOT EXISTS ( + SELECT 1 FROM Block b + WHERE (b.blocker.id = :viewerId AND b.blocked.id = m.id) + OR (b.blocker.id = m.id AND b.blocked.id = :viewerId) + ) + ORDER BY + CASE + WHEN LOWER(m.nickname) = LOWER(:keyword) THEN 0 + WHEN LOWER(m.nickname) LIKE LOWER(CONCAT(:escapedKeyword, '%')) ESCAPE '!' THEN 1 + ELSE 2 + END, + m.nickname ASC + """, + countQuery = """ + SELECT COUNT(m) FROM Member m + WHERE m.id <> :viewerId + AND m.nickname IS NOT NULL + AND m.withdrawnAt IS NULL + AND LOWER(m.nickname) LIKE LOWER(CONCAT('%', :escapedKeyword, '%')) ESCAPE '!' + AND NOT EXISTS ( + SELECT 1 FROM Block b + WHERE (b.blocker.id = :viewerId AND b.blocked.id = m.id) + OR (b.blocker.id = m.id AND b.blocked.id = :viewerId) + ) + """, + ) + fun searchSelectableByNickname( + @Param("viewerId") viewerId: Long, + @Param("keyword") keyword: String, + @Param("escapedKeyword") escapedKeyword: String, + pageable: Pageable, + ): Page } 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 73e4409..3d89a6f 100644 --- a/src/main/kotlin/team/cklob/mudda/global/exception/ErrorCode.kt +++ b/src/main/kotlin/team/cklob/mudda/global/exception/ErrorCode.kt @@ -17,4 +17,14 @@ enum class ErrorCode(val status: HttpStatus, val code: String, val message: Stri 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."), + CANNOT_REQUEST_SELF(HttpStatus.BAD_REQUEST, "F001", "Cannot send a friend request to yourself."), + FRIEND_REQUEST_ALREADY_EXISTS(HttpStatus.CONFLICT, "F002", "A pending friend request already exists."), + ALREADY_FRIENDS(HttpStatus.CONFLICT, "F003", "You are already friends with this member."), + REVERSE_FRIEND_REQUEST_EXISTS(HttpStatus.CONFLICT, "F004", "This member has already sent you a friend request."), + FRIEND_REQUEST_NOT_FOUND(HttpStatus.NOT_FOUND, "F005", "Friend request not found."), + FRIEND_REQUEST_NOT_RECEIVER(HttpStatus.FORBIDDEN, "F006", "Only the request recipient can respond to it."), + FRIEND_REQUEST_ALREADY_PROCESSED(HttpStatus.CONFLICT, "F007", "This friend request has already been processed."), + FRIEND_NOT_FOUND(HttpStatus.NOT_FOUND, "F008", "Friend relationship not found."), + BLOCKED_MEMBER(HttpStatus.FORBIDDEN, "F009", "This action is not allowed due to a block relationship."), + INVALID_SEARCH_KEYWORD(HttpStatus.BAD_REQUEST, "F010", "Search keyword must not be blank."), } 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 be3ae60..bf7ec6d 100644 --- a/src/main/kotlin/team/cklob/mudda/global/exception/GlobalExceptionHandler.kt +++ b/src/main/kotlin/team/cklob/mudda/global/exception/GlobalExceptionHandler.kt @@ -4,6 +4,7 @@ import org.springframework.http.ResponseEntity import org.springframework.http.MediaType import org.springframework.http.converter.HttpMessageNotReadableException import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.MissingServletRequestParameterException import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.RestControllerAdvice import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException @@ -30,6 +31,11 @@ class GlobalExceptionHandler { @ExceptionHandler(MethodArgumentTypeMismatchException::class) fun handleTypeMismatch(e: MethodArgumentTypeMismatchException) = response(ErrorCode.INVALID_INPUT) + // A required @RequestParam that is missing (e.g. friend search's `keyword`, the request list's + // `type`) would otherwise fall through to the catch-all 500 handler below. + @ExceptionHandler(MissingServletRequestParameterException::class) + fun handleMissingParameter(e: MissingServletRequestParameterException) = response(ErrorCode.INVALID_INPUT) + @ExceptionHandler(Exception::class) fun handleException(e: Exception): ResponseEntity> { logger.error("Unexpected exception type: {}", e.javaClass.name) diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 798def9..cfc22b5 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -9,6 +9,10 @@ spring: redis: host: ${REDIS_HOST:localhost} port: ${REDIS_PORT:6379} + web: + pageable: + default-page-size: 20 + max-page-size: 50 docker: compose: lifecycle-management: start-and-stop diff --git a/src/main/resources/db/migration/V4__add_friend_request_indexes_and_pending_pair_constraint.sql b/src/main/resources/db/migration/V4__add_friend_request_indexes_and_pending_pair_constraint.sql new file mode 100644 index 0000000..bb2d1f4 --- /dev/null +++ b/src/main/resources/db/migration/V4__add_friend_request_indexes_and_pending_pair_constraint.sql @@ -0,0 +1,16 @@ +-- tbl_friend already has idx_friend_receiver (receiver_id) from V2. Add the composite indexes the +-- new Friend APIs actually query by (received/sent PENDING lists, ACCEPTED friend list lookups). +CREATE INDEX idx_friend_requester_status ON tbl_friend (requester_id, status); +CREATE INDEX idx_friend_receiver_status ON tbl_friend (receiver_id, status); + +-- uq_friend_requester_receiver (requester_id, receiver_id) only blocks a duplicate row in the exact +-- same direction. Two members can still race a PENDING request in opposite directions at nearly the +-- same time (A -> B and B -> A) and end up with two live PENDING rows for the same pair, since each +-- row targets a different unique-constraint key. This partial unique index normalizes the pair with +-- LEAST/GREATEST so at most one PENDING row can exist between any two members regardless of +-- direction, without altering existing columns, the existing constraint, or already-deployed +-- migrations. The application layer still pre-checks for a reverse PENDING request before insert; +-- this index is the safety net for the race the application check alone cannot close. +CREATE UNIQUE INDEX uq_friend_pending_pair + ON tbl_friend (LEAST(requester_id, receiver_id), GREATEST(requester_id, receiver_id)) + WHERE status = 'PENDING'; diff --git a/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/DeleteFriendServiceTest.kt b/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/DeleteFriendServiceTest.kt new file mode 100644 index 0000000..0c3ecce --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/DeleteFriendServiceTest.kt @@ -0,0 +1,62 @@ +package team.cklob.mudda.domain.friend.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.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.member.domain.entity.Member +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 + +class DeleteFriendServiceTest { + private val friendRepository = mockk() + private val service = DeleteFriendService(friendRepository) + + private fun member(id: Long) = Member( + name = "name-$id", nickname = "nickname-$id", email = "user$id@example.com", + oauthProvider = OAuthProvider.GOOGLE, providerId = "google-sub-$id", profileVisibility = ProfileVisibility.PUBLIC, id = id, + ) + + @Test fun `deletes the friendship when the caller was the requester`() { + val friend = Friend(requester = member(1L), receiver = member(2L), status = FriendRequestStatus.ACCEPTED, id = 10L) + every { friendRepository.findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(1L, 2L, 2L, 1L) } returns listOf(friend) + every { friendRepository.delete(friend) } returns Unit + + service.execute(1L, 2L) + + verify(exactly = 1) { friendRepository.delete(friend) } + } + + @Test fun `deletes the friendship when the caller was the receiver`() { + val friend = Friend(requester = member(2L), receiver = member(1L), status = FriendRequestStatus.ACCEPTED, id = 10L) + every { friendRepository.findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(1L, 2L, 2L, 1L) } returns listOf(friend) + every { friendRepository.delete(friend) } returns Unit + + service.execute(1L, 2L) + + verify(exactly = 1) { friendRepository.delete(friend) } + } + + @Test fun `rejects when there is no relationship at all`() { + every { friendRepository.findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(1L, 2L, 2L, 1L) } returns emptyList() + + val exception = assertThrows(BusinessException::class.java) { service.execute(1L, 2L) } + assertEquals(ErrorCode.FRIEND_NOT_FOUND, exception.errorCode) + } + + @Test fun `rejects deleting a pending (not yet accepted) relationship`() { + val friend = Friend(requester = member(1L), receiver = member(2L), status = FriendRequestStatus.PENDING, id = 10L) + every { friendRepository.findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(1L, 2L, 2L, 1L) } returns listOf(friend) + + val exception = assertThrows(BusinessException::class.java) { service.execute(1L, 2L) } + assertEquals(ErrorCode.FRIEND_NOT_FOUND, exception.errorCode) + verify(exactly = 0) { friendRepository.delete(any()) } + } +} diff --git a/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/GetFriendListServiceTest.kt b/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/GetFriendListServiceTest.kt new file mode 100644 index 0000000..31076bc --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/GetFriendListServiceTest.kt @@ -0,0 +1,95 @@ +package team.cklob.mudda.domain.friend.application.impl + +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +import team.cklob.mudda.domain.block.domain.entity.Block +import team.cklob.mudda.domain.block.domain.repository.BlockRepository +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.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 java.time.LocalDateTime + +class GetFriendListServiceTest { + private val friendRepository = mockk() + private val blockRepository = mockk() + private val service = GetFriendListService(friendRepository, blockRepository) + private val pageable = PageRequest.of(0, 20) + + private fun member(id: Long) = Member( + name = "name-$id", nickname = "nickname-$id", email = "user$id@example.com", + oauthProvider = OAuthProvider.GOOGLE, providerId = "google-sub-$id", profileVisibility = ProfileVisibility.PUBLIC, id = id, + ) + + private fun mockNoBlocks() { + every { blockRepository.findByBlockerIdOrBlockedId(1L, 1L) } returns emptyList() + } + + @Test fun `returns the counterpart when the caller was the requester`() { + val acceptedAt = LocalDateTime.now() + val friend = Friend(requester = member(1L), receiver = member(2L), status = FriendRequestStatus.ACCEPTED, acceptedAt = acceptedAt, id = 10L) + every { friendRepository.findFriendships(1L, FriendRequestStatus.ACCEPTED, pageable) } returns PageImpl(listOf(friend), pageable, 1) + mockNoBlocks() + + val response = service.execute(1L, pageable) + + assertEquals(1, response.content.size) + assertEquals(2L, response.content[0].memberId) + assertEquals(acceptedAt, response.content[0].acceptedAt) + } + + @Test fun `returns the counterpart when the caller was the receiver`() { + val acceptedAt = LocalDateTime.now() + val friend = Friend(requester = member(2L), receiver = member(1L), status = FriendRequestStatus.ACCEPTED, acceptedAt = acceptedAt, id = 10L) + every { friendRepository.findFriendships(1L, FriendRequestStatus.ACCEPTED, pageable) } returns PageImpl(listOf(friend), pageable, 1) + mockNoBlocks() + + val response = service.execute(1L, pageable) + + assertEquals(2L, response.content[0].memberId) + } + + @Test fun `only queries ACCEPTED relationships`() { + mockNoBlocks() + every { friendRepository.findFriendships(1L, FriendRequestStatus.ACCEPTED, pageable) } returns PageImpl(emptyList(), pageable, 0) + + val response = service.execute(1L, pageable) + + assertTrue(response.content.isEmpty()) + } + + @Test fun `filters out a friend that is in a block relationship with the caller`() { + val acceptedAt = LocalDateTime.now() + val kept = Friend(requester = member(1L), receiver = member(2L), status = FriendRequestStatus.ACCEPTED, acceptedAt = acceptedAt, id = 10L) + val blocked = Friend(requester = member(1L), receiver = member(3L), status = FriendRequestStatus.ACCEPTED, acceptedAt = acceptedAt, id = 11L) + every { friendRepository.findFriendships(1L, FriendRequestStatus.ACCEPTED, pageable) } returns PageImpl(listOf(kept, blocked), pageable, 2) + every { blockRepository.findByBlockerIdOrBlockedId(1L, 1L) } returns listOf(Block(blocker = member(1L), blocked = member(3L), id = 100L)) + + val response = service.execute(1L, pageable) + + assertEquals(1, response.content.size) + assertEquals(2L, response.content[0].memberId) + } + + @Test fun `maps page metadata and sorting order from the repository result`() { + val older = Friend(requester = member(1L), receiver = member(2L), status = FriendRequestStatus.ACCEPTED, acceptedAt = LocalDateTime.now().minusDays(1), id = 10L) + val newer = Friend(requester = member(1L), receiver = member(3L), status = FriendRequestStatus.ACCEPTED, acceptedAt = LocalDateTime.now(), id = 11L) + // The repository query itself orders by acceptedAt DESC; the service must preserve that order, not re-sort. + every { friendRepository.findFriendships(1L, FriendRequestStatus.ACCEPTED, pageable) } returns PageImpl(listOf(newer, older), pageable, 2) + mockNoBlocks() + + val response = service.execute(1L, pageable) + + assertEquals(listOf(3L, 2L), response.content.map { it.memberId }) + assertEquals(2L, response.totalElements) + assertEquals(1, response.totalPages) + } +} diff --git a/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/GetFriendRequestListServiceTest.kt b/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/GetFriendRequestListServiceTest.kt new file mode 100644 index 0000000..7592690 --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/GetFriendRequestListServiceTest.kt @@ -0,0 +1,69 @@ +package team.cklob.mudda.domain.friend.application.impl + +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +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.FriendRequestType +import team.cklob.mudda.domain.member.domain.entity.Member +import team.cklob.mudda.domain.member.domain.type.OAuthProvider +import team.cklob.mudda.domain.member.domain.type.ProfileVisibility + +class GetFriendRequestListServiceTest { + private val friendRepository = mockk() + private val service = GetFriendRequestListService(friendRepository) + private val pageable = PageRequest.of(0, 20) + + private fun member(id: Long) = Member( + name = "name-$id", nickname = "nickname-$id", email = "user$id@example.com", + oauthProvider = OAuthProvider.GOOGLE, providerId = "google-sub-$id", profileVisibility = ProfileVisibility.PUBLIC, id = id, + ) + + @Test fun `returns the requester as the counterpart for RECEIVED requests`() { + val friend = Friend(requester = member(2L), receiver = member(1L), status = FriendRequestStatus.PENDING, id = 10L) + every { friendRepository.findReceivedRequests(1L, FriendRequestStatus.PENDING, pageable) } returns PageImpl(listOf(friend), pageable, 1) + + val response = service.execute(1L, FriendRequestType.RECEIVED, FriendRequestStatus.PENDING, pageable) + + assertEquals(1, response.content.size) + assertEquals(2L, response.content[0].memberId) + assertEquals(FriendRequestType.RECEIVED, response.content[0].direction) + assertEquals(10L, response.content[0].requestId) + } + + @Test fun `returns the receiver as the counterpart for SENT requests`() { + val friend = Friend(requester = member(1L), receiver = member(3L), status = FriendRequestStatus.PENDING, id = 11L) + every { friendRepository.findSentRequests(1L, FriendRequestStatus.PENDING, pageable) } returns PageImpl(listOf(friend), pageable, 1) + + val response = service.execute(1L, FriendRequestType.SENT, FriendRequestStatus.PENDING, pageable) + + assertEquals(3L, response.content[0].memberId) + assertEquals(FriendRequestType.SENT, response.content[0].direction) + } + + @Test fun `applies the requested status filter`() { + every { friendRepository.findReceivedRequests(1L, FriendRequestStatus.ACCEPTED, pageable) } returns PageImpl(emptyList(), pageable, 0) + + val response = service.execute(1L, FriendRequestType.RECEIVED, FriendRequestStatus.ACCEPTED, pageable) + + assertEquals(0, response.content.size) + } + + @Test fun `maps page metadata from the repository result`() { + // Page size 2 keeps offset(0) + pageSize(2) <= total(5), otherwise PageImpl silently recomputes + // total down to offset + content.size() -- see https://github.com/spring-projects/spring-data-commons. + val smallPage = PageRequest.of(0, 2) + val friends = listOf(Friend(requester = member(2L), receiver = member(1L), status = FriendRequestStatus.PENDING, id = 10L), Friend(requester = member(3L), receiver = member(1L), status = FriendRequestStatus.PENDING, id = 11L)) + every { friendRepository.findReceivedRequests(1L, FriendRequestStatus.PENDING, smallPage) } returns PageImpl(friends, smallPage, 5) + + val response = service.execute(1L, FriendRequestType.RECEIVED, FriendRequestStatus.PENDING, smallPage) + + assertEquals(5L, response.totalElements) + assertEquals(3, response.totalPages) + } +} diff --git a/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/RespondFriendRequestServiceTest.kt b/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/RespondFriendRequestServiceTest.kt new file mode 100644 index 0000000..b1f8271 --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/RespondFriendRequestServiceTest.kt @@ -0,0 +1,86 @@ +package team.cklob.mudda.domain.friend.application.impl + +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +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.FriendRequestAction +import team.cklob.mudda.domain.friend.domain.type.FriendRequestStatus +import team.cklob.mudda.domain.friend.presentation.request.RespondFriendRequestRequest +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.BusinessException +import team.cklob.mudda.global.exception.ErrorCode +import java.util.Optional + +class RespondFriendRequestServiceTest { + private val friendRepository = mockk() + private val service = RespondFriendRequestService(friendRepository) + + private fun member(id: Long) = Member( + name = "name-$id", nickname = "nickname-$id", email = "user$id@example.com", + oauthProvider = OAuthProvider.GOOGLE, providerId = "google-sub-$id", profileVisibility = ProfileVisibility.PUBLIC, id = id, + ) + + private fun pendingRequest(requesterId: Long = 1L, receiverId: Long = 2L) = + Friend(requester = member(requesterId), receiver = member(receiverId), status = FriendRequestStatus.PENDING, id = 10L) + + @Test fun `accepts a pending request addressed to the caller`() { + val friend = pendingRequest() + every { friendRepository.findById(10L) } returns Optional.of(friend) + + service.execute(2L, 10L, RespondFriendRequestRequest(FriendRequestAction.ACCEPT)) + + assertEquals(FriendRequestStatus.ACCEPTED, friend.status) + assertNotNull(friend.acceptedAt) + } + + @Test fun `rejects a pending request addressed to the caller`() { + val friend = pendingRequest() + every { friendRepository.findById(10L) } returns Optional.of(friend) + + service.execute(2L, 10L, RespondFriendRequestRequest(FriendRequestAction.REJECT)) + + assertEquals(FriendRequestStatus.REJECTED, friend.status) + assertNull(friend.acceptedAt) + } + + @Test fun `rejects when the caller is not the receiver`() { + val friend = pendingRequest() + every { friendRepository.findById(10L) } returns Optional.of(friend) + + val exception = assertThrows(BusinessException::class.java) { service.execute(1L, 10L, RespondFriendRequestRequest(FriendRequestAction.ACCEPT)) } + assertEquals(ErrorCode.FRIEND_REQUEST_NOT_RECEIVER, exception.errorCode) + assertEquals(FriendRequestStatus.PENDING, friend.status) + } + + @Test fun `rejects when the request does not exist`() { + every { friendRepository.findById(99L) } returns Optional.empty() + + val exception = assertThrows(BusinessException::class.java) { service.execute(2L, 99L, RespondFriendRequestRequest(FriendRequestAction.ACCEPT)) } + assertEquals(ErrorCode.FRIEND_REQUEST_NOT_FOUND, exception.errorCode) + } + + @Test fun `rejects responding to an already-accepted request`() { + val friend = pendingRequest().apply { status = FriendRequestStatus.ACCEPTED } + every { friendRepository.findById(10L) } returns Optional.of(friend) + + val exception = assertThrows(BusinessException::class.java) { service.execute(2L, 10L, RespondFriendRequestRequest(FriendRequestAction.ACCEPT)) } + assertEquals(ErrorCode.FRIEND_REQUEST_ALREADY_PROCESSED, exception.errorCode) + } + + @Test fun `rejects responding to an already-rejected request`() { + val friend = pendingRequest().apply { status = FriendRequestStatus.REJECTED } + every { friendRepository.findById(10L) } returns Optional.of(friend) + + val exception = assertThrows(BusinessException::class.java) { service.execute(2L, 10L, RespondFriendRequestRequest(FriendRequestAction.REJECT)) } + assertEquals(ErrorCode.FRIEND_REQUEST_ALREADY_PROCESSED, exception.errorCode) + } +} diff --git a/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/SearchFriendServiceTest.kt b/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/SearchFriendServiceTest.kt new file mode 100644 index 0000000..379162d --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/SearchFriendServiceTest.kt @@ -0,0 +1,121 @@ +package team.cklob.mudda.domain.friend.application.impl + +import io.mockk.every +import io.mockk.mockk +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.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +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.FriendRequestType +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.OAuthProvider +import team.cklob.mudda.domain.member.domain.type.ProfileVisibility +import team.cklob.mudda.global.exception.BusinessException +import team.cklob.mudda.global.exception.ErrorCode + +class SearchFriendServiceTest { + private val memberRepository = mockk() + private val friendRepository = mockk() + private val service = SearchFriendService(memberRepository, friendRepository) + private val pageable = PageRequest.of(0, 20) + + private fun member(id: Long) = Member( + name = "name-$id", nickname = "nickname-$id", email = "user$id@example.com", + oauthProvider = OAuthProvider.GOOGLE, providerId = "google-sub-$id", profileVisibility = ProfileVisibility.PUBLIC, id = id, + ) + + @Test fun `returns NONE when there is no relationship with a candidate`() { + val candidate = member(2L) + every { memberRepository.searchSelectableByNickname(1L, "nick", "nick", pageable) } returns PageImpl(listOf(candidate), pageable, 1) + every { friendRepository.findAllBetween(1L, listOf(2L)) } returns emptyList() + + val response = service.execute(1L, "nick", pageable) + + assertEquals(FriendStatus.NONE, response.content[0].relationStatus) + assertNull(response.content[0].requestId) + assertNull(response.content[0].requestDirection) + } + + @Test fun `marks a candidate the caller already sent a request to`() { + val candidate = member(2L) + every { memberRepository.searchSelectableByNickname(1L, "nick", "nick", pageable) } returns PageImpl(listOf(candidate), pageable, 1) + every { friendRepository.findAllBetween(1L, listOf(2L)) } returns + listOf(Friend(requester = member(1L), receiver = candidate, status = FriendRequestStatus.PENDING, id = 10L)) + + val response = service.execute(1L, "nick", pageable) + + assertEquals(FriendStatus.REQUESTED, response.content[0].relationStatus) + assertEquals(10L, response.content[0].requestId) + assertEquals(FriendRequestType.SENT, response.content[0].requestDirection) + } + + @Test fun `marks a candidate who sent the caller a request`() { + val candidate = member(2L) + every { memberRepository.searchSelectableByNickname(1L, "nick", "nick", pageable) } returns PageImpl(listOf(candidate), pageable, 1) + every { friendRepository.findAllBetween(1L, listOf(2L)) } returns + listOf(Friend(requester = candidate, receiver = member(1L), status = FriendRequestStatus.PENDING, id = 10L)) + + val response = service.execute(1L, "nick", pageable) + + assertEquals(FriendStatus.RECEIVED, response.content[0].relationStatus) + assertEquals(FriendRequestType.RECEIVED, response.content[0].requestDirection) + } + + @Test fun `marks an already-accepted friend`() { + val candidate = member(2L) + every { memberRepository.searchSelectableByNickname(1L, "nick", "nick", pageable) } returns PageImpl(listOf(candidate), pageable, 1) + every { friendRepository.findAllBetween(1L, listOf(2L)) } returns + listOf(Friend(requester = member(1L), receiver = candidate, status = FriendRequestStatus.ACCEPTED, id = 10L)) + + val response = service.execute(1L, "nick", pageable) + + assertEquals(FriendStatus.FRIEND, response.content[0].relationStatus) + } + + @Test fun `treats a rejected relationship as NONE`() { + val candidate = member(2L) + every { memberRepository.searchSelectableByNickname(1L, "nick", "nick", pageable) } returns PageImpl(listOf(candidate), pageable, 1) + every { friendRepository.findAllBetween(1L, listOf(2L)) } returns + listOf(Friend(requester = member(1L), receiver = candidate, status = FriendRequestStatus.REJECTED, id = 10L)) + + val response = service.execute(1L, "nick", pageable) + + assertEquals(FriendStatus.NONE, response.content[0].relationStatus) + } + + @Test fun `rejects a blank keyword`() { + val exception = assertThrows(BusinessException::class.java) { service.execute(1L, " ", pageable) } + assertEquals(ErrorCode.INVALID_SEARCH_KEYWORD, exception.errorCode) + } + + @Test fun `trims the keyword before searching`() { + every { memberRepository.searchSelectableByNickname(1L, "nick", "nick", pageable) } returns PageImpl(emptyList(), pageable, 0) + + service.execute(1L, " nick ", pageable) + + io.mockk.verify { memberRepository.searchSelectableByNickname(1L, "nick", "nick", pageable) } + } + + @Test fun `escapes LIKE wildcard characters before searching`() { + every { memberRepository.searchSelectableByNickname(1L, "50%_off", "50!%!_off", pageable) } returns PageImpl(emptyList(), pageable, 0) + + service.execute(1L, "50%_off", pageable) + + io.mockk.verify { memberRepository.searchSelectableByNickname(1L, "50%_off", "50!%!_off", pageable) } + } + + @Test fun `paginates results and reports page metadata`() { + every { memberRepository.searchSelectableByNickname(1L, "nick", "nick", pageable) } returns PageImpl(emptyList(), pageable, 42) + + val response = service.execute(1L, "nick", pageable) + + assertEquals(42L, response.totalElements) + } +} diff --git a/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/SendFriendRequestServiceTest.kt b/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/SendFriendRequestServiceTest.kt new file mode 100644 index 0000000..fed94de --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/SendFriendRequestServiceTest.kt @@ -0,0 +1,181 @@ +package team.cklob.mudda.domain.friend.application.impl + +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import org.springframework.dao.DataIntegrityViolationException +import team.cklob.mudda.domain.block.domain.repository.BlockRepository +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.presentation.request.SendFriendRequestRequest +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.BusinessException +import team.cklob.mudda.global.exception.ErrorCode +import java.time.LocalDateTime +import java.util.Optional + +class SendFriendRequestServiceTest { + private val friendRepository = mockk() + private val memberRepository = mockk() + private val blockRepository = mockk() + private val service = SendFriendRequestService(friendRepository, memberRepository, blockRepository) + + private fun member(id: Long, 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", + profileVisibility = ProfileVisibility.PUBLIC, withdrawnAt = withdrawnAt, id = id, + ) + + private fun mockNoBlock() { + every { blockRepository.existsByBlockerIdAndBlockedIdOrBlockerIdAndBlockedId(1L, 2L, 2L, 1L) } returns false + } + + private fun mockNoExistingRelation() { + every { friendRepository.findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(1L, 2L, 2L, 1L) } returns emptyList() + } + + @Test fun `creates a pending request when there is no prior relationship`() { + val requester = member(1L) + val receiver = member(2L) + every { memberRepository.findById(1L) } returns Optional.of(requester) + every { memberRepository.findById(2L) } returns Optional.of(receiver) + mockNoBlock() + mockNoExistingRelation() + val savedSlot = slot() + every { friendRepository.saveAndFlush(capture(savedSlot)) } answers { Friend(requester = requester, receiver = receiver, status = FriendRequestStatus.PENDING, id = 10L) } + + val response = service.execute(1L, SendFriendRequestRequest(receiverId = 2L)) + + assertEquals(10L, response.requestId) + assertEquals(FriendRequestStatus.PENDING, response.status) + assertEquals(1L, savedSlot.captured.requester.id) + assertEquals(2L, savedSlot.captured.receiver.id) + } + + @Test fun `rejects a request to yourself`() { + val exception = assertThrows(BusinessException::class.java) { service.execute(1L, SendFriendRequestRequest(receiverId = 1L)) } + assertEquals(ErrorCode.CANNOT_REQUEST_SELF, exception.errorCode) + } + + @Test fun `rejects when the requester id does not exist`() { + every { memberRepository.findById(1L) } returns Optional.empty() + + val exception = assertThrows(AuthException::class.java) { service.execute(1L, SendFriendRequestRequest(receiverId = 2L)) } + assertEquals(ErrorCode.UNAUTHORIZED, exception.errorCode) + } + + @Test fun `rejects when the receiver does not exist`() { + every { memberRepository.findById(1L) } returns Optional.of(member(1L)) + every { memberRepository.findById(2L) } returns Optional.empty() + + val exception = assertThrows(BusinessException::class.java) { service.execute(1L, SendFriendRequestRequest(receiverId = 2L)) } + assertEquals(ErrorCode.MEMBER_NOT_FOUND, exception.errorCode) + } + + @Test fun `rejects when the receiver has withdrawn`() { + every { memberRepository.findById(1L) } returns Optional.of(member(1L)) + every { memberRepository.findById(2L) } returns Optional.of(member(2L, withdrawnAt = LocalDateTime.now())) + + val exception = assertThrows(BusinessException::class.java) { service.execute(1L, SendFriendRequestRequest(receiverId = 2L)) } + assertEquals(ErrorCode.MEMBER_NOT_FOUND, exception.errorCode) + } + + @Test fun `rejects when already friends`() { + val requester = member(1L) + val receiver = member(2L) + every { memberRepository.findById(1L) } returns Optional.of(requester) + every { memberRepository.findById(2L) } returns Optional.of(receiver) + mockNoBlock() + every { friendRepository.findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(1L, 2L, 2L, 1L) } returns + listOf(Friend(requester = requester, receiver = receiver, status = FriendRequestStatus.ACCEPTED, id = 5L)) + + val exception = assertThrows(BusinessException::class.java) { service.execute(1L, SendFriendRequestRequest(receiverId = 2L)) } + assertEquals(ErrorCode.ALREADY_FRIENDS, exception.errorCode) + } + + @Test fun `rejects a duplicate same-direction pending request`() { + val requester = member(1L) + val receiver = member(2L) + every { memberRepository.findById(1L) } returns Optional.of(requester) + every { memberRepository.findById(2L) } returns Optional.of(receiver) + mockNoBlock() + every { friendRepository.findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(1L, 2L, 2L, 1L) } returns + listOf(Friend(requester = requester, receiver = receiver, status = FriendRequestStatus.PENDING, id = 5L)) + + val exception = assertThrows(BusinessException::class.java) { service.execute(1L, SendFriendRequestRequest(receiverId = 2L)) } + assertEquals(ErrorCode.FRIEND_REQUEST_ALREADY_EXISTS, exception.errorCode) + } + + @Test fun `rejects when the other member already sent a pending request in reverse`() { + val requester = member(1L) + val receiver = member(2L) + every { memberRepository.findById(1L) } returns Optional.of(requester) + every { memberRepository.findById(2L) } returns Optional.of(receiver) + mockNoBlock() + every { friendRepository.findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(1L, 2L, 2L, 1L) } returns + listOf(Friend(requester = receiver, receiver = requester, status = FriendRequestStatus.PENDING, id = 5L)) + + val exception = assertThrows(BusinessException::class.java) { service.execute(1L, SendFriendRequestRequest(receiverId = 2L)) } + assertEquals(ErrorCode.REVERSE_FRIEND_REQUEST_EXISTS, exception.errorCode) + } + + @Test fun `allows a new request after a prior rejection`() { + val requester = member(1L) + val receiver = member(2L) + every { memberRepository.findById(1L) } returns Optional.of(requester) + every { memberRepository.findById(2L) } returns Optional.of(receiver) + mockNoBlock() + every { friendRepository.findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(1L, 2L, 2L, 1L) } returns + listOf(Friend(requester = requester, receiver = receiver, status = FriendRequestStatus.REJECTED, id = 5L)) + every { friendRepository.saveAndFlush(any()) } returns Friend(requester = requester, receiver = receiver, status = FriendRequestStatus.PENDING, id = 11L) + + val response = service.execute(1L, SendFriendRequestRequest(receiverId = 2L)) + + assertEquals(11L, response.requestId) + } + + @Test fun `rejects when a block relationship exists`() { + every { memberRepository.findById(1L) } returns Optional.of(member(1L)) + every { memberRepository.findById(2L) } returns Optional.of(member(2L)) + every { blockRepository.existsByBlockerIdAndBlockedIdOrBlockerIdAndBlockedId(1L, 2L, 2L, 1L) } returns true + + val exception = assertThrows(BusinessException::class.java) { service.execute(1L, SendFriendRequestRequest(receiverId = 2L)) } + assertEquals(ErrorCode.BLOCKED_MEMBER, exception.errorCode) + } + + @Test fun `translates a concurrent reverse-direction insert race into a conflict`() { + val requester = member(1L) + val receiver = member(2L) + every { memberRepository.findById(1L) } returns Optional.of(requester) + every { memberRepository.findById(2L) } returns Optional.of(receiver) + mockNoBlock() + mockNoExistingRelation() + every { friendRepository.saveAndFlush(any()) } throws DataIntegrityViolationException("uq_friend_pending_pair") + + val exception = assertThrows(BusinessException::class.java) { service.execute(1L, SendFriendRequestRequest(receiverId = 2L)) } + assertEquals(ErrorCode.REVERSE_FRIEND_REQUEST_EXISTS, exception.errorCode) + } + + @Test fun `saves the request through the repository`() { + val requester = member(1L) + val receiver = member(2L) + every { memberRepository.findById(1L) } returns Optional.of(requester) + every { memberRepository.findById(2L) } returns Optional.of(receiver) + mockNoBlock() + mockNoExistingRelation() + every { friendRepository.saveAndFlush(any()) } returns Friend(requester = requester, receiver = receiver, status = FriendRequestStatus.PENDING, id = 10L) + + service.execute(1L, SendFriendRequestRequest(receiverId = 2L)) + + verify(exactly = 1) { friendRepository.saveAndFlush(any()) } + } +} diff --git a/src/test/kotlin/team/cklob/mudda/domain/friend/domain/repository/FriendRepositoryIntegrationTest.kt b/src/test/kotlin/team/cklob/mudda/domain/friend/domain/repository/FriendRepositoryIntegrationTest.kt new file mode 100644 index 0000000..b269336 --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/domain/friend/domain/repository/FriendRepositoryIntegrationTest.kt @@ -0,0 +1,110 @@ +package team.cklob.mudda.domain.friend.domain.repository + +import jakarta.persistence.EntityManager +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.testcontainers.service.connection.ServiceConnection +import org.springframework.dao.DataIntegrityViolationException +import org.springframework.data.domain.PageRequest +import org.springframework.transaction.annotation.Transactional +import org.testcontainers.containers.PostgreSQLContainer +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers +import org.testcontainers.utility.DockerImageName +import team.cklob.mudda.domain.friend.domain.entity.Friend +import team.cklob.mudda.domain.friend.domain.type.FriendRequestStatus +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 + +class PostgisContainer(imageName: DockerImageName) : PostgreSQLContainer(imageName) + +// Exercises the real PostgreSQL/PostGIS schema produced by the actual Flyway migrations (including the +// new V4 pending-pair unique index), which a MockK-based unit test cannot verify. +@SpringBootTest( + properties = [ + "spring.cloud.aws.region.static=ap-northeast-2", + "spring.cloud.aws.credentials.access-key=test", + "spring.cloud.aws.credentials.secret-key=test", + "jwt.secret=local-test-secret-must-be-at-least-32-bytes", + ], +) +@Testcontainers +@Transactional +class FriendRepositoryIntegrationTest { + @Autowired private lateinit var friendRepository: FriendRepository + @Autowired private lateinit var memberRepository: MemberRepository + @Autowired private lateinit var entityManager: EntityManager + + private fun member(tag: String) = memberRepository.saveAndFlush( + Member( + name = "name-$tag", nickname = "nickname-$tag", email = "user-$tag@example.com", + oauthProvider = OAuthProvider.GOOGLE, providerId = "google-sub-$tag", profileVisibility = ProfileVisibility.PUBLIC, + ), + ) + + @Test fun `finds a relationship regardless of which side is the requester`() { + val a = member("a") + val b = member("b") + friendRepository.saveAndFlush(Friend(requester = a, receiver = b, status = FriendRequestStatus.PENDING)) + + val fromAsFirst = friendRepository.findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(a.id!!, b.id!!, b.id!!, a.id!!) + val fromBAsFirst = friendRepository.findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(b.id!!, a.id!!, a.id!!, b.id!!) + + assertEquals(1, fromAsFirst.size) + assertEquals(1, fromBAsFirst.size) + } + + @Test fun `findFriendships returns ACCEPTED relationships from either direction`() { + val a = member("a") + val b = member("b") + val c = member("c") + friendRepository.saveAndFlush(Friend(requester = a, receiver = b, status = FriendRequestStatus.ACCEPTED, acceptedAt = java.time.LocalDateTime.now())) + friendRepository.saveAndFlush(Friend(requester = c, receiver = a, status = FriendRequestStatus.ACCEPTED, acceptedAt = java.time.LocalDateTime.now())) + friendRepository.saveAndFlush(Friend(requester = a, receiver = member("d"), status = FriendRequestStatus.PENDING)) + entityManager.flush() + entityManager.clear() + + val page = friendRepository.findFriendships(a.id!!, FriendRequestStatus.ACCEPTED, PageRequest.of(0, 20)) + + assertEquals(2, page.totalElements) + assertTrue(page.content.all { it.status == FriendRequestStatus.ACCEPTED }) + } + + @Test fun `same-direction duplicate PENDING request is rejected by the unique constraint`() { + val a = member("a") + val b = member("b") + friendRepository.saveAndFlush(Friend(requester = a, receiver = b, status = FriendRequestStatus.PENDING)) + + assertThrows(DataIntegrityViolationException::class.java) { + friendRepository.saveAndFlush(Friend(requester = a, receiver = b, status = FriendRequestStatus.PENDING)) + } + } + + @Test fun `reverse-direction concurrent PENDING request is rejected by uq_friend_pending_pair`() { + val a = member("a") + val b = member("b") + friendRepository.saveAndFlush(Friend(requester = a, receiver = b, status = FriendRequestStatus.PENDING)) + + assertThrows(DataIntegrityViolationException::class.java) { + friendRepository.saveAndFlush(Friend(requester = b, receiver = a, status = FriendRequestStatus.PENDING)) + } + } + + companion object { + private val postgisImage = DockerImageName + .parse("postgis/postgis:16-3.5-alpine") + .asCompatibleSubstituteFor("postgres") + + @Container + @ServiceConnection + @JvmStatic + val postgres = PostgisContainer(postgisImage) + .withInitScript("db/init/001_enable_postgis.sql") + } +} diff --git a/src/test/kotlin/team/cklob/mudda/domain/friend/presentation/controller/FriendControllerTest.kt b/src/test/kotlin/team/cklob/mudda/domain/friend/presentation/controller/FriendControllerTest.kt new file mode 100644 index 0000000..2468a8a --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/domain/friend/presentation/controller/FriendControllerTest.kt @@ -0,0 +1,288 @@ +package team.cklob.mudda.domain.friend.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.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import team.cklob.mudda.domain.friend.application.impl.DeleteFriendService +import team.cklob.mudda.domain.friend.application.impl.GetFriendListService +import team.cklob.mudda.domain.friend.application.impl.GetFriendRequestListService +import team.cklob.mudda.domain.friend.application.impl.RespondFriendRequestService +import team.cklob.mudda.domain.friend.application.impl.SearchFriendService +import team.cklob.mudda.domain.friend.application.impl.SendFriendRequestService +import team.cklob.mudda.domain.friend.domain.type.FriendRequestAction +import team.cklob.mudda.domain.friend.domain.type.FriendRequestStatus +import team.cklob.mudda.domain.friend.domain.type.FriendRequestType +import team.cklob.mudda.domain.friend.domain.type.FriendStatus +import team.cklob.mudda.domain.friend.presentation.request.RespondFriendRequestRequest +import team.cklob.mudda.domain.friend.presentation.request.SendFriendRequestRequest +import team.cklob.mudda.domain.friend.presentation.response.FriendPageResponse +import team.cklob.mudda.domain.friend.presentation.response.FriendRequestResponse +import team.cklob.mudda.domain.friend.presentation.response.FriendResponse +import team.cklob.mudda.domain.friend.presentation.response.FriendSearchResponse +import team.cklob.mudda.domain.friend.presentation.response.SendFriendRequestResponse +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 = [FriendController::class], properties = [ + "jwt.secret=local-test-secret-must-be-at-least-32-bytes", +]) +@Import(SecurityConfig::class, JwtTokenProvider::class) +class FriendControllerTest(@Autowired private val mockMvc: MockMvc, @Autowired private val jwtTokenProvider: JwtTokenProvider) { + @MockkBean lateinit var jpaMappingContext: JpaMetamodelMappingContext + @MockkBean lateinit var accessTokenBlacklist: AccessTokenBlacklist + @MockkBean lateinit var getFriendListService: GetFriendListService + @MockkBean lateinit var searchFriendService: SearchFriendService + @MockkBean lateinit var sendFriendRequestService: SendFriendRequestService + @MockkBean lateinit var getFriendRequestListService: GetFriendRequestListService + @MockkBean lateinit var respondFriendRequestService: RespondFriendRequestService + @MockkBean lateinit var deleteFriendService: DeleteFriendService + + 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) + } + + // -------- GET /api/v1/friends -------- + + @Test fun `getFriends requires authentication`() { + mockMvc.perform(get("/api/v1/friends")).andExpect(status().isUnauthorized) + } + + @Test fun `getFriends returns the authenticated member's friend page`() { + val token = accessTokenFor(1L) + val page = FriendPageResponse( + content = listOf(FriendResponse(memberId = 2L, nickname = "nick", profileImageUrl = null, acceptedAt = now)), + page = 0, size = 20, totalElements = 1, totalPages = 1, hasNext = false, + ) + every { getFriendListService.execute(1L, any()) } returns page + + mockMvc.perform(get("/api/v1/friends").header("Authorization", "Bearer $token")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.content[0].memberId").value(2)) + .andExpect(jsonPath("$.data.content[0].nickname").value("nick")) + } + + // -------- GET /api/v1/friends/search -------- + + @Test fun `search requires authentication`() { + mockMvc.perform(get("/api/v1/friends/search").param("keyword", "nick")).andExpect(status().isUnauthorized) + } + + @Test fun `search returns 400 when the keyword parameter is missing`() { + val token = accessTokenFor(1L) + + mockMvc.perform(get("/api/v1/friends/search").header("Authorization", "Bearer $token")) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.error.code").value("C001")) + } + + @Test fun `search returns candidates with relation status`() { + val token = accessTokenFor(1L) + val page = FriendPageResponse( + content = listOf(FriendSearchResponse(memberId = 2L, nickname = "nick", profileImageUrl = null, relationStatus = FriendStatus.NONE, requestId = null, requestDirection = null)), + page = 0, size = 20, totalElements = 1, totalPages = 1, hasNext = false, + ) + every { searchFriendService.execute(1L, "nick", any()) } returns page + + mockMvc.perform(get("/api/v1/friends/search").param("keyword", "nick").header("Authorization", "Bearer $token")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.content[0].relationStatus").value("NONE")) + } + + @Test fun `search returns 400 when the service rejects a blank keyword`() { + val token = accessTokenFor(1L) + every { searchFriendService.execute(1L, " ", any()) } throws BusinessException(ErrorCode.INVALID_SEARCH_KEYWORD) + + mockMvc.perform(get("/api/v1/friends/search").param("keyword", " ").header("Authorization", "Bearer $token")) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.error.code").value("F010")) + } + + // -------- POST /api/v1/friends/requests -------- + + @Test fun `sendRequest requires authentication`() { + mockMvc.perform( + post("/api/v1/friends/requests").contentType(MediaType.APPLICATION_JSON).content("""{"receiverId":2}"""), + ).andExpect(status().isUnauthorized) + } + + @Test fun `sendRequest creates a request and returns 201`() { + val token = accessTokenFor(1L) + every { sendFriendRequestService.execute(1L, SendFriendRequestRequest(receiverId = 2L)) } returns SendFriendRequestResponse(requestId = 10L, status = FriendRequestStatus.PENDING) + + mockMvc.perform( + post("/api/v1/friends/requests").header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON).content("""{"receiverId":2}"""), + ) + .andExpect(status().isCreated) + .andExpect(jsonPath("$.data.requestId").value(10)) + .andExpect(jsonPath("$.data.status").value("PENDING")) + } + + @Test fun `sendRequest returns 400 for a malformed body`() { + val token = accessTokenFor(1L) + + mockMvc.perform( + post("/api/v1/friends/requests").header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON).content("""{}"""), + ).andExpect(status().isBadRequest) + } + + @Test fun `sendRequest returns 409 when a business rule rejects the request`() { + val token = accessTokenFor(1L) + every { sendFriendRequestService.execute(1L, SendFriendRequestRequest(receiverId = 2L)) } throws BusinessException(ErrorCode.ALREADY_FRIENDS) + + mockMvc.perform( + post("/api/v1/friends/requests").header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON).content("""{"receiverId":2}"""), + ) + .andExpect(status().isConflict) + .andExpect(jsonPath("$.error.code").value("F003")) + } + + // -------- GET /api/v1/friends/requests -------- + + @Test fun `getRequests requires authentication`() { + mockMvc.perform(get("/api/v1/friends/requests").param("type", "RECEIVED")).andExpect(status().isUnauthorized) + } + + @Test fun `getRequests returns 400 when type is missing`() { + val token = accessTokenFor(1L) + + mockMvc.perform(get("/api/v1/friends/requests").header("Authorization", "Bearer $token")) + .andExpect(status().isBadRequest) + } + + @Test fun `getRequests returns 400 for an invalid type value`() { + val token = accessTokenFor(1L) + + mockMvc.perform(get("/api/v1/friends/requests").param("type", "NOT_A_TYPE").header("Authorization", "Bearer $token")) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.error.code").value("C001")) + } + + @Test fun `getRequests defaults status to PENDING and returns the requested type`() { + val token = accessTokenFor(1L) + val page = FriendPageResponse( + content = listOf(FriendRequestResponse(requestId = 10L, direction = FriendRequestType.RECEIVED, memberId = 2L, nickname = "nick", profileImageUrl = null, createdAt = now, status = FriendRequestStatus.PENDING)), + page = 0, size = 20, totalElements = 1, totalPages = 1, hasNext = false, + ) + every { getFriendRequestListService.execute(1L, FriendRequestType.RECEIVED, FriendRequestStatus.PENDING, any()) } returns page + + mockMvc.perform(get("/api/v1/friends/requests").param("type", "RECEIVED").header("Authorization", "Bearer $token")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.content[0].direction").value("RECEIVED")) + .andExpect(jsonPath("$.data.content[0].requestId").value(10)) + } + + // -------- PATCH /api/v1/friends/requests/{requestId} -------- + + @Test fun `respondToRequest requires authentication`() { + mockMvc.perform( + patch("/api/v1/friends/requests/10").contentType(MediaType.APPLICATION_JSON).content("""{"action":"ACCEPT"}"""), + ).andExpect(status().isUnauthorized) + } + + @Test fun `respondToRequest accepts and returns 204`() { + val token = accessTokenFor(2L) + every { respondFriendRequestService.execute(2L, 10L, RespondFriendRequestRequest(FriendRequestAction.ACCEPT)) } returns Unit + + mockMvc.perform( + patch("/api/v1/friends/requests/10").header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON).content("""{"action":"ACCEPT"}"""), + ).andExpect(status().isNoContent) + } + + @Test fun `respondToRequest returns 400 for a missing action`() { + val token = accessTokenFor(2L) + + mockMvc.perform( + patch("/api/v1/friends/requests/10").header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON).content("""{}"""), + ).andExpect(status().isBadRequest) + } + + @Test fun `respondToRequest returns 400 for an unsupported action value`() { + val token = accessTokenFor(2L) + + mockMvc.perform( + patch("/api/v1/friends/requests/10").header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON).content("""{"action":"MAYBE"}"""), + ).andExpect(status().isBadRequest) + } + + @Test fun `respondToRequest returns 403 when the caller is not the receiver`() { + val token = accessTokenFor(1L) + every { respondFriendRequestService.execute(1L, 10L, RespondFriendRequestRequest(FriendRequestAction.ACCEPT)) } throws + BusinessException(ErrorCode.FRIEND_REQUEST_NOT_RECEIVER) + + mockMvc.perform( + patch("/api/v1/friends/requests/10").header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON).content("""{"action":"ACCEPT"}"""), + ) + .andExpect(status().isForbidden) + .andExpect(jsonPath("$.error.code").value("F006")) + } + + @Test fun `respondToRequest returns 404 when the request does not exist`() { + val token = accessTokenFor(2L) + every { respondFriendRequestService.execute(2L, 99L, RespondFriendRequestRequest(FriendRequestAction.ACCEPT)) } throws + BusinessException(ErrorCode.FRIEND_REQUEST_NOT_FOUND) + + mockMvc.perform( + patch("/api/v1/friends/requests/99").header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON).content("""{"action":"ACCEPT"}"""), + ).andExpect(status().isNotFound) + } + + // -------- DELETE /api/v1/friends/{memberId} -------- + + @Test fun `deleteFriend requires authentication`() { + mockMvc.perform(delete("/api/v1/friends/2")).andExpect(status().isUnauthorized) + } + + @Test fun `deleteFriend passes the authenticated caller and target id and returns 204`() { + val token = accessTokenFor(1L) + every { deleteFriendService.execute(1L, 2L) } returns Unit + + mockMvc.perform(delete("/api/v1/friends/2").header("Authorization", "Bearer $token")) + .andExpect(status().isNoContent) + } + + @Test fun `deleteFriend returns 404 when there is no accepted friendship`() { + val token = accessTokenFor(1L) + every { deleteFriendService.execute(1L, 2L) } throws BusinessException(ErrorCode.FRIEND_NOT_FOUND) + + mockMvc.perform(delete("/api/v1/friends/2").header("Authorization", "Bearer $token")) + .andExpect(status().isNotFound) + .andExpect(jsonPath("$.error.code").value("F008")) + } + + @Test fun `deleteFriend returns 400 for a non-numeric memberId instead of a 500`() { + val token = accessTokenFor(1L) + + mockMvc.perform(delete("/api/v1/friends/abc").header("Authorization", "Bearer $token")) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.error.code").value("C001")) + } +} diff --git a/src/test/kotlin/team/cklob/mudda/domain/member/domain/repository/MemberRepositorySearchIntegrationTest.kt b/src/test/kotlin/team/cklob/mudda/domain/member/domain/repository/MemberRepositorySearchIntegrationTest.kt new file mode 100644 index 0000000..6a4bd69 --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/domain/member/domain/repository/MemberRepositorySearchIntegrationTest.kt @@ -0,0 +1,122 @@ +package team.cklob.mudda.domain.member.domain.repository + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.testcontainers.service.connection.ServiceConnection +import org.springframework.data.domain.PageRequest +import org.springframework.transaction.annotation.Transactional +import org.testcontainers.containers.PostgreSQLContainer +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers +import org.testcontainers.utility.DockerImageName +import team.cklob.mudda.domain.block.domain.entity.Block +import team.cklob.mudda.domain.block.domain.repository.BlockRepository +import team.cklob.mudda.domain.member.domain.entity.Member +import team.cklob.mudda.domain.member.domain.type.OAuthProvider +import team.cklob.mudda.domain.member.domain.type.ProfileVisibility + +class PostgisContainer(imageName: DockerImageName) : PostgreSQLContainer(imageName) + +// Exercises MemberRepository#searchSelectableByNickname against a real PostgreSQL instance -- the LIKE +// ESCAPE clause, the CASE-based ranking, and the NOT EXISTS block-exclusion subquery are all things a +// MockK-based unit test cannot verify actually compile to valid, correct SQL. +@SpringBootTest( + properties = [ + "spring.cloud.aws.region.static=ap-northeast-2", + "spring.cloud.aws.credentials.access-key=test", + "spring.cloud.aws.credentials.secret-key=test", + "jwt.secret=local-test-secret-must-be-at-least-32-bytes", + ], +) +@Testcontainers +@Transactional +class MemberRepositorySearchIntegrationTest { + @Autowired private lateinit var memberRepository: MemberRepository + @Autowired private lateinit var blockRepository: BlockRepository + + private fun member(tag: String, nickname: String? = "nick-$tag", withdrawnAt: java.time.LocalDateTime? = null) = memberRepository.saveAndFlush( + Member( + name = "name-$tag", nickname = nickname, email = "user-$tag@example.com", + oauthProvider = OAuthProvider.GOOGLE, providerId = "google-sub-$tag", + profileVisibility = ProfileVisibility.PUBLIC, withdrawnAt = withdrawnAt, + ), + ) + + @Test fun `excludes the viewer, withdrawn members and members without a nickname`() { + val viewer = member("viewer", nickname = "search-target") + val withdrawn = member("withdrawn", nickname = "search-target-2", withdrawnAt = java.time.LocalDateTime.now()) + val incomplete = member("incomplete", nickname = null) + val target = member("target", nickname = "search-target-3") + + val page = memberRepository.searchSelectableByNickname(viewer.id!!, "search-target", "search-target", PageRequest.of(0, 20)) + + val ids = page.content.mapNotNull { it.id } + assertFalse(viewer.id in ids) + assertFalse(withdrawn.id in ids) + assertFalse(incomplete.id in ids) + assertTrue(target.id in ids) + } + + @Test fun `excludes a member blocked in either direction`() { + val viewer = member("viewer2", nickname = "block-search") + val blockedByViewer = member("blocked-by-viewer", nickname = "block-search-2") + val blockedViewer = member("blocked-viewer", nickname = "block-search-3") + val stranger = member("stranger", nickname = "block-search-4") + blockRepository.saveAndFlush(Block(blocker = viewer, blocked = blockedByViewer)) + blockRepository.saveAndFlush(Block(blocker = blockedViewer, blocked = viewer)) + + val page = memberRepository.searchSelectableByNickname(viewer.id!!, "block-search", "block-search", PageRequest.of(0, 20)) + + val ids = page.content.mapNotNull { it.id } + assertFalse(blockedByViewer.id in ids) + assertFalse(blockedViewer.id in ids) + assertTrue(stranger.id in ids) + } + + @Test fun `ranks an exact match first and a prefix match before a contains-only match`() { + val viewer = member("viewer3") + val containsOnly = member("contains", nickname = "aa-ranktest-zz") + val prefix = member("prefix", nickname = "ranktest-suffix") + val exact = member("exact", nickname = "ranktest") + + val page = memberRepository.searchSelectableByNickname(viewer.id!!, "ranktest", "ranktest", PageRequest.of(0, 20)) + + assertEquals(listOf(exact.id, prefix.id, containsOnly.id), page.content.mapNotNull { it.id }) + } + + @Test fun `escapes LIKE wildcard characters in the keyword`() { + val viewer = member("viewer4") + val literalMatch = member("literal", nickname = "50%_off") + member("decoy", nickname = "50xyoff") + + val page = memberRepository.searchSelectableByNickname(viewer.id!!, "50%_off", "50!%!_off", PageRequest.of(0, 20)) + + assertEquals(listOf(literalMatch.id), page.content.mapNotNull { it.id }) + } + + @Test fun `respects the page size`() { + val viewer = member("viewer5") + repeat(3) { member("page-$it", nickname = "page-target-$it") } + + val page = memberRepository.searchSelectableByNickname(viewer.id!!, "page-target", "page-target", PageRequest.of(0, 2)) + + assertEquals(2, page.content.size) + assertEquals(3L, page.totalElements) + } + + companion object { + private val postgisImage = DockerImageName + .parse("postgis/postgis:16-3.5-alpine") + .asCompatibleSubstituteFor("postgres") + + @Container + @ServiceConnection + @JvmStatic + val postgres = PostgisContainer(postgisImage) + .withInitScript("db/init/001_enable_postgis.sql") + } +} From 98a501534db7b363aadc9767be7e2f6e334002be Mon Sep 17 00:00:00 2001 From: hej090224 Date: Wed, 5 Aug 2026 21:18:18 +0900 Subject: [PATCH 2/4] fix: #21 :: address PR review feedback on friend domain - fix reject-then-resend being permanently blocked by making uq_friend_requester_receiver a partial index that excludes REJECTED rows - log constraint violations instead of silently folding them into one error code - report block-by-target as member-not-found instead of leaking it via BLOCKED_MEMBER - re-verify block state when accepting a request, not just when sending one - move friend-list block filtering into the repository query so pagination metadata stays accurate instead of drifting from a post-fetch filter - add a tie-breaker to every ORDER BY so paginated results are stable - drop idx_friend_receiver, now redundant with idx_friend_receiver_status - add ck_friend_accepted_at so the ACCEPTED -> accepted_at invariant is enforced by the database, not just by convention - encapsulate LIKE-wildcard escaping in MemberRepository instead of the service - enable -Xjvm-default=all so the above Kotlin interface default method is actually dispatched as a JVM default method by the Spring Data proxy --- build.gradle.kts | 8 +++- .../domain/repository/BlockRepository.kt | 4 -- .../application/impl/GetFriendListService.kt | 22 +++------- .../impl/RespondFriendRequestService.kt | 12 ++++++ .../application/impl/SearchFriendService.kt | 6 +-- .../impl/SendFriendRequestService.kt | 23 ++++++++-- .../domain/repository/FriendRepository.kt | 38 ++++++++++++---- .../domain/repository/MemberRepository.kt | 20 ++++++++- ...st_indexes_and_pending_pair_constraint.sql | 43 +++++++++++++++---- 9 files changed, 129 insertions(+), 47 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 1fc236a..25285ad 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -66,7 +66,13 @@ dependencies { kotlin { compilerOptions { - freeCompilerArgs.addAll("-Xjsr305=strict") + // Without this, a Kotlin interface method with a body (e.g. MemberRepository's convenience + // overload of searchSelectableByNickname) compiles to a synthetic DefaultImpls dispatch instead of + // a real JVM `default` method. Spring Data's repository proxy only recognizes true JVM default + // methods via Method.isDefault() -- otherwise it treats the method as yet another abstract query + // method and tries (and fails) to derive a query from its name. This flag makes Kotlin emit real + // default methods so Spring Data dispatches to the actual Kotlin-written body instead. + freeCompilerArgs.addAll("-Xjsr305=strict", "-Xjvm-default=all") } } diff --git a/src/main/kotlin/team/cklob/mudda/domain/block/domain/repository/BlockRepository.kt b/src/main/kotlin/team/cklob/mudda/domain/block/domain/repository/BlockRepository.kt index c5a4b88..1a70dce 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/block/domain/repository/BlockRepository.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/block/domain/repository/BlockRepository.kt @@ -14,8 +14,4 @@ interface BlockRepository : JpaRepository { blockerId2: Long, blockedId2: Long, ): Boolean - - // All block rows where the given member is on either side, used to build a single member's full - // bidirectional block set in one query (e.g. filtering the friend list) instead of per-row lookups. - fun findByBlockerIdOrBlockedId(blockerId: Long, blockedId: Long): List } diff --git a/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/GetFriendListService.kt b/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/GetFriendListService.kt index d583bac..d8646f9 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/GetFriendListService.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/GetFriendListService.kt @@ -3,10 +3,8 @@ package team.cklob.mudda.domain.friend.application.impl import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import team.cklob.mudda.domain.block.domain.repository.BlockRepository 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.presentation.response.FriendPageResponse import team.cklob.mudda.domain.friend.presentation.response.FriendResponse import team.cklob.mudda.domain.member.domain.entity.Member @@ -14,22 +12,16 @@ import team.cklob.mudda.domain.member.domain.entity.Member @Service class GetFriendListService( private val friendRepository: FriendRepository, - private val blockRepository: BlockRepository, ) { @Transactional(readOnly = true) fun execute(memberId: Long, pageable: Pageable): FriendPageResponse { - val page = friendRepository.findFriendships(memberId, FriendRequestStatus.ACCEPTED, pageable) - val blockedMemberIds = blockRepository.findByBlockerIdOrBlockedId(memberId, memberId) - .mapNotNull { if (it.blocker.id == memberId) it.blocked.id else it.blocker.id } - .toSet() - - // Block rows are filtered out of the already-paginated content, so a page can legitimately return - // fewer than `size` items when a blocked member is among ACCEPTED friends -- acceptable for now since - // blocking is expected to be rare and the Block domain's own API is out of this PR's scope. - val content = page.content.mapNotNull { friend -> - val other = counterpart(friend, memberId) - if (other.id in blockedMemberIds) null else FriendResponse.of(other, requireNotNull(friend.acceptedAt)) - } + // Blocked counterparts are already excluded by FriendRepository#findFriendships itself (NOT EXISTS + // in SQL), so the page's totalElements/totalPages/hasNext are accurate as-is -- no post-fetch + // filtering needed here. + val page = friendRepository.findFriendships(memberId, pageable) + // accepted_at is backed by ck_friend_accepted_at (see V4 migration): the DB itself guarantees an + // ACCEPTED row always has a non-null accepted_at, so this can never actually throw. + val content = page.content.map { FriendResponse.of(counterpart(it, memberId), requireNotNull(it.acceptedAt)) } return FriendPageResponse.of(page, content) } diff --git a/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/RespondFriendRequestService.kt b/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/RespondFriendRequestService.kt index 706c481..be8df35 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/RespondFriendRequestService.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/RespondFriendRequestService.kt @@ -2,6 +2,7 @@ package team.cklob.mudda.domain.friend.application.impl import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import team.cklob.mudda.domain.block.domain.repository.BlockRepository import team.cklob.mudda.domain.friend.domain.repository.FriendRepository import team.cklob.mudda.domain.friend.domain.type.FriendRequestAction import team.cklob.mudda.domain.friend.domain.type.FriendRequestStatus @@ -13,6 +14,7 @@ import java.time.LocalDateTime @Service class RespondFriendRequestService( private val friendRepository: FriendRepository, + private val blockRepository: BlockRepository, ) { @Transactional fun execute(memberId: Long, requestId: Long, request: RespondFriendRequestRequest) { @@ -22,6 +24,16 @@ class RespondFriendRequestService( when (request.action) { FriendRequestAction.ACCEPT -> { + val requesterId = requireNotNull(friend.requester.id) + // SendFriendRequestService only checks for a block at the moment the request is sent. A block + // created afterwards, while the request is still PENDING, must not be bypassed by simply + // accepting it -- re-verify here too. (Direction doesn't need to be distinguished the way + // SendFriendRequestService does: whichever side is blocked, the member calling this endpoint is + // the receiver, so a BLOCKED_MEMBER response never tells them something about the other party + // they couldn't already infer from being unable to accept.) + if (blockRepository.existsByBlockerIdAndBlockedIdOrBlockerIdAndBlockedId(memberId, requesterId, requesterId, memberId)) { + throw BusinessException(ErrorCode.BLOCKED_MEMBER) + } friend.status = FriendRequestStatus.ACCEPTED friend.acceptedAt = LocalDateTime.now() } diff --git a/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/SearchFriendService.kt b/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/SearchFriendService.kt index d32ee85..64f77bf 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/SearchFriendService.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/SearchFriendService.kt @@ -24,7 +24,7 @@ class SearchFriendService( val trimmed = keyword.trim() if (trimmed.isBlank()) throw BusinessException(ErrorCode.INVALID_SEARCH_KEYWORD) - val page = memberRepository.searchSelectableByNickname(memberId, trimmed, escapeLike(trimmed), pageable) + val page = memberRepository.searchSelectableByNickname(memberId, trimmed, pageable) val candidateIds = page.content.mapNotNull { it.id } val relationsByOtherId = if (candidateIds.isEmpty()) emptyMap() else groupRelationsByOtherId(memberId, friendRepository.findAllBetween(memberId, candidateIds)) @@ -55,8 +55,4 @@ class SearchFriendService( FriendRequestStatus.REJECTED -> FriendStatus.NONE to null } } - - // Escapes LIKE wildcards (%, _) and the escape character itself ('!') so a keyword containing them is - // matched literally instead of as a wildcard pattern. Paired with `ESCAPE '!'` in MemberRepository. - private fun escapeLike(raw: String): String = raw.replace("!", "!!").replace("%", "!%").replace("_", "!_") } diff --git a/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/SendFriendRequestService.kt b/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/SendFriendRequestService.kt index 1364003..b03d128 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/SendFriendRequestService.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/SendFriendRequestService.kt @@ -1,5 +1,6 @@ package team.cklob.mudda.domain.friend.application.impl +import org.slf4j.LoggerFactory import org.springframework.dao.DataIntegrityViolationException import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -20,6 +21,8 @@ class SendFriendRequestService( private val memberRepository: MemberRepository, private val blockRepository: BlockRepository, ) { + private val logger = LoggerFactory.getLogger(javaClass) + @Transactional fun execute(memberId: Long, request: SendFriendRequestRequest): SendFriendRequestResponse { // @field:NotNull on SendFriendRequestRequest.receiverId already rejects a null/missing value with a @@ -33,9 +36,16 @@ class SendFriendRequestService( val receiver = memberRepository.findById(receiverId).orElseThrow { BusinessException(ErrorCode.MEMBER_NOT_FOUND) } if (receiver.withdrawnAt != null || receiver.nickname == null) throw BusinessException(ErrorCode.MEMBER_NOT_FOUND) - if (blockRepository.existsByBlockerIdAndBlockedIdOrBlockerIdAndBlockedId(memberId, receiverId, receiverId, memberId)) { + // Direction matters here: if I blocked them, telling them BLOCKED_MEMBER doesn't leak anything they + // don't already know. If they blocked me, BLOCKED_MEMBER would leak the fact that a block exists + // (unlike the search API, which silently excludes blocked members via a NOT EXISTS filter) -- so + // that direction is reported as MEMBER_NOT_FOUND instead, indistinguishable from a nonexistent id. + if (blockRepository.existsByBlockerIdAndBlockedId(memberId, receiverId)) { throw BusinessException(ErrorCode.BLOCKED_MEMBER) } + if (blockRepository.existsByBlockerIdAndBlockedId(receiverId, memberId)) { + throw BusinessException(ErrorCode.MEMBER_NOT_FOUND) + } val existingRelations = friendRepository.findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(memberId, receiverId, receiverId, memberId) existingRelations.forEach { relation -> @@ -43,15 +53,20 @@ class SendFriendRequestService( relation.status == FriendRequestStatus.ACCEPTED -> throw BusinessException(ErrorCode.ALREADY_FRIENDS) relation.status == FriendRequestStatus.PENDING && relation.requester.id == memberId -> throw BusinessException(ErrorCode.FRIEND_REQUEST_ALREADY_EXISTS) relation.status == FriendRequestStatus.PENDING -> throw BusinessException(ErrorCode.REVERSE_FRIEND_REQUEST_EXISTS) - // REJECTED rows don't block a new request; a fresh row is created below. + // A REJECTED row doesn't block a new request -- uq_friend_requester_receiver (see V4) is a + // partial index that excludes REJECTED rows, so a fresh row for the same direction can be + // inserted below even while the old REJECTED row is kept around as history. } } val saved = try { friendRepository.saveAndFlush(Friend(requester = requester, receiver = receiver, status = FriendRequestStatus.PENDING)) } catch (e: DataIntegrityViolationException) { - // Safety net for a concurrent reverse-direction PENDING insert that raced past the check above -- - // see uq_friend_pending_pair in V4__add_friend_request_indexes_and_pending_pair_constraint.sql. + // Safety net for a concurrent insert that raced past the checks above -- most likely the reverse- + // direction pending race guarded by uq_friend_pending_pair, but could in principle be any + // constraint on this table (e.g. a member row deleted mid-request). Logged with the original + // exception since folding every violation into one error code would otherwise hide the real cause. + logger.warn("friend request insert violated a constraint: requester={}, receiver={}", memberId, receiverId, e) throw BusinessException(ErrorCode.REVERSE_FRIEND_REQUEST_EXISTS) } 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 1bf8996..797f334 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 @@ -36,21 +36,43 @@ interface FriendRepository : JpaRepository { fun findAllBetween(@Param("memberId") memberId: Long, @Param("otherIds") otherIds: Collection): List // requester/receiver are eagerly fetched so the response mapping (counterpart nickname/profileImageUrl) - // doesn't trigger an N+1 lazy load per row. + // doesn't trigger an N+1 lazy load per row. status is hardcoded to ACCEPTED (the only caller, + // GetFriendListService, always wants that; ORDER BY acceptedAt is meaningless for any other status + // since the column is only ever populated for ACCEPTED rows anyway). Blocked counterparts are excluded + // in SQL -- the same NOT EXISTS shape as MemberRepository#searchSelectableByNickname -- so pagination + // metadata (totalElements/totalPages/hasNext) stays accurate instead of drifting from a post-fetch + // filter. f.id DESC breaks ties for rows that share the same acceptedAt second, which is common when + // requests are accepted in a batch, so a stable page boundary doesn't skip or repeat a row. @Query( - """ - SELECT f FROM Friend f JOIN FETCH f.requester JOIN FETCH f.receiver - WHERE f.status = :status AND (f.requester.id = :memberId OR f.receiver.id = :memberId) - ORDER BY f.acceptedAt DESC + value = """ + SELECT f FROM Friend f JOIN FETCH f.requester JOIN FETCH f.receiver + WHERE f.status = team.cklob.mudda.domain.friend.domain.type.FriendRequestStatus.ACCEPTED + AND (f.requester.id = :memberId OR f.receiver.id = :memberId) + AND NOT EXISTS ( + SELECT 1 FROM Block b + WHERE (b.blocker.id = :memberId AND b.blocked.id = CASE WHEN f.requester.id = :memberId THEN f.receiver.id ELSE f.requester.id END) + OR (b.blocked.id = :memberId AND b.blocker.id = CASE WHEN f.requester.id = :memberId THEN f.receiver.id ELSE f.requester.id END) + ) + ORDER BY f.acceptedAt DESC, f.id DESC + """, + countQuery = """ + SELECT COUNT(f) FROM Friend f + WHERE f.status = team.cklob.mudda.domain.friend.domain.type.FriendRequestStatus.ACCEPTED + AND (f.requester.id = :memberId OR f.receiver.id = :memberId) + AND NOT EXISTS ( + SELECT 1 FROM Block b + WHERE (b.blocker.id = :memberId AND b.blocked.id = CASE WHEN f.requester.id = :memberId THEN f.receiver.id ELSE f.requester.id END) + OR (b.blocked.id = :memberId AND b.blocker.id = CASE WHEN f.requester.id = :memberId THEN f.receiver.id ELSE f.requester.id END) + ) """, ) - fun findFriendships(@Param("memberId") memberId: Long, @Param("status") status: FriendRequestStatus, pageable: Pageable): Page + fun findFriendships(@Param("memberId") memberId: Long, pageable: Pageable): Page @Query( """ SELECT f FROM Friend f JOIN FETCH f.requester JOIN FETCH f.receiver WHERE f.receiver.id = :receiverId AND f.status = :status - ORDER BY f.createdAt DESC + ORDER BY f.createdAt DESC, f.id DESC """, ) fun findReceivedRequests(@Param("receiverId") receiverId: Long, @Param("status") status: FriendRequestStatus, pageable: Pageable): Page @@ -59,7 +81,7 @@ interface FriendRepository : JpaRepository { """ SELECT f FROM Friend f JOIN FETCH f.requester JOIN FETCH f.receiver WHERE f.requester.id = :requesterId AND f.status = :status - ORDER BY f.createdAt DESC + ORDER BY f.createdAt DESC, f.id DESC """, ) fun findSentRequests(@Param("requesterId") requesterId: Long, @Param("status") status: FriendRequestStatus, pageable: Pageable): Page diff --git a/src/main/kotlin/team/cklob/mudda/domain/member/domain/repository/MemberRepository.kt b/src/main/kotlin/team/cklob/mudda/domain/member/domain/repository/MemberRepository.kt index e97dac8..b3ed21e 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/member/domain/repository/MemberRepository.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/member/domain/repository/MemberRepository.kt @@ -19,8 +19,16 @@ interface MemberRepository : JpaRepository { // Friend search: excludes the viewer, withdrawn/not-yet-signed-up members, and anyone blocked in // either direction (all filtered in SQL so pagination stays accurate). Ranks an exact nickname match // first, a prefix match second, and any other `contains` match last. + // + // This does a leading-wildcard LIKE ('%keyword%'), so it cannot use a B-tree (or even a LOWER() + // functional) index and always scans tbl_member in full -- fine at today's member counts, but if + // nickname search traffic or table size grows enough for this to show up in query latency, switch to + // a trigram index instead: `CREATE EXTENSION pg_trgm; + // CREATE INDEX idx_member_nickname_trgm ON tbl_member USING gin (LOWER(nickname) gin_trgm_ops);` + // // `keyword` is the trimmed raw keyword (used for the exact-match rank check); `escapedKeyword` is the - // same keyword with LIKE wildcards (%, _, !) escaped with '!' (used inside LIKE). + // same keyword with LIKE wildcards (%, _, !) escaped with '!' (used inside LIKE). Callers should use + // the 3-arg overload below instead of calling this one directly. @Query( value = """ SELECT m FROM Member m @@ -60,4 +68,14 @@ interface MemberRepository : JpaRepository { @Param("escapedKeyword") escapedKeyword: String, pageable: Pageable, ): Page + + // Escapes LIKE wildcards (%, _) and the escape character itself ('!') so a keyword containing them is + // matched literally instead of as a wildcard pattern, then delegates to the 4-arg query above (paired + // with its `ESCAPE '!'` clauses). Keeping this in the repository -- rather than in the calling service + // -- means the escaping rule and the ESCAPE clause it must match live in the same file, and a future + // caller can't forget to escape before calling. + fun searchSelectableByNickname(viewerId: Long, keyword: String, pageable: Pageable): Page { + val escapedKeyword = keyword.replace("!", "!!").replace("%", "!%").replace("_", "!_") + return searchSelectableByNickname(viewerId, keyword, escapedKeyword, pageable) + } } diff --git a/src/main/resources/db/migration/V4__add_friend_request_indexes_and_pending_pair_constraint.sql b/src/main/resources/db/migration/V4__add_friend_request_indexes_and_pending_pair_constraint.sql index bb2d1f4..904cdcb 100644 --- a/src/main/resources/db/migration/V4__add_friend_request_indexes_and_pending_pair_constraint.sql +++ b/src/main/resources/db/migration/V4__add_friend_request_indexes_and_pending_pair_constraint.sql @@ -1,16 +1,41 @@ --- tbl_friend already has idx_friend_receiver (receiver_id) from V2. Add the composite indexes the --- new Friend APIs actually query by (received/sent PENDING lists, ACCEPTED friend list lookups). +-- Add the composite indexes the new Friend APIs actually query by (received/sent PENDING lists, +-- ACCEPTED friend list lookups). CREATE INDEX idx_friend_requester_status ON tbl_friend (requester_id, status); CREATE INDEX idx_friend_receiver_status ON tbl_friend (receiver_id, status); --- uq_friend_requester_receiver (requester_id, receiver_id) only blocks a duplicate row in the exact --- same direction. Two members can still race a PENDING request in opposite directions at nearly the --- same time (A -> B and B -> A) and end up with two live PENDING rows for the same pair, since each --- row targets a different unique-constraint key. This partial unique index normalizes the pair with +-- idx_friend_receiver (receiver_id) from V2 is now a strict prefix of idx_friend_receiver_status +-- (receiver_id, status) above, so it is fully redundant -- it only costs an extra index to maintain on +-- every write. Safe to drop since this migration hasn't been deployed anywhere yet. +DROP INDEX idx_friend_receiver; + +-- uq_friend_requester_receiver (requester_id, receiver_id) from V2 is an unconditional unique +-- constraint: at most one row can ever exist for a given (requester_id, receiver_id) pair, regardless +-- of status. Since RespondFriendRequestService rejects a request by flipping its status to REJECTED +-- in place (the row is never deleted), that REJECTED row permanently occupies the pair's only slot -- +-- the same requester can never send that receiver another request again, because inserting a fresh +-- PENDING row for the same (requester_id, receiver_id) pair always violates this constraint. +-- Replacing it with a partial unique index that excludes REJECTED rows fixes this: at most one +-- PENDING/ACCEPTED row can still exist per direction (the original intent), but any number of REJECTED +-- rows can accumulate as history, and a fresh request after a rejection is a plain new row. +ALTER TABLE tbl_friend DROP CONSTRAINT uq_friend_requester_receiver; +CREATE UNIQUE INDEX uq_friend_requester_receiver + ON tbl_friend (requester_id, receiver_id) + WHERE status <> 'REJECTED'; + +-- Two members can still race a PENDING request in opposite directions at nearly the same time +-- (A -> B and B -> A) and end up with two live PENDING rows for the same pair, since each row targets +-- a different unique-constraint key above. This partial unique index normalizes the pair with -- LEAST/GREATEST so at most one PENDING row can exist between any two members regardless of --- direction, without altering existing columns, the existing constraint, or already-deployed --- migrations. The application layer still pre-checks for a reverse PENDING request before insert; --- this index is the safety net for the race the application check alone cannot close. +-- direction. The application layer still pre-checks for a reverse PENDING request before insert; this +-- index is the safety net for the race the application check alone cannot close. CREATE UNIQUE INDEX uq_friend_pending_pair ON tbl_friend (LEAST(requester_id, receiver_id), GREATEST(requester_id, receiver_id)) WHERE status = 'PENDING'; + +-- RespondFriendRequestService always sets accepted_at when it transitions a row to ACCEPTED, and every +-- read path (e.g. FriendResponse.of) trusts that with requireNotNull(). That invariant was previously +-- only a code convention -- a batch job or manual data fix touching this table could silently break it +-- and turn the friend list endpoint into a 500 for the affected member. Enforcing it as a CHECK +-- constraint makes it impossible to violate regardless of which code path writes to this table. +ALTER TABLE tbl_friend + ADD CONSTRAINT ck_friend_accepted_at CHECK (status <> 'ACCEPTED' OR accepted_at IS NOT NULL); From 0af5b2bea827b4c8bb381009b59924d1ec79fec7 Mon Sep 17 00:00:00 2001 From: hej090224 Date: Wed, 5 Aug 2026 21:18:27 +0900 Subject: [PATCH 3/4] test: #21 :: update tests for review fixes and extract shared IT base - update unit tests for the direction-aware block checks, the simplified findFriendships signature, and the MemberRepository escaping change - add integration coverage for: reject-then-resend, the accepted_at CHECK constraint, and the block filter now applied in findFriendships itself - extract PostgresIntegrationTest as a shared base for FriendRepositoryIntegrationTest and MemberRepositorySearchIntegrationTest so both reuse one Testcontainers Postgres instance and Spring context instead of starting their own --- .../impl/GetFriendListServiceTest.kt | 42 +++------- .../impl/RespondFriendRequestServiceTest.kt | 28 ++++++- .../impl/SearchFriendServiceTest.kt | 27 +++--- .../impl/SendFriendRequestServiceTest.kt | 21 ++++- .../FriendRepositoryIntegrationTest.kt | 84 +++++++++++-------- .../MemberRepositorySearchIntegrationTest.kt | 51 +++-------- .../mudda/support/PostgresIntegrationTest.kt | 43 ++++++++++ 7 files changed, 172 insertions(+), 124 deletions(-) create mode 100644 src/test/kotlin/team/cklob/mudda/support/PostgresIntegrationTest.kt diff --git a/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/GetFriendListServiceTest.kt b/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/GetFriendListServiceTest.kt index 31076bc..a13159d 100644 --- a/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/GetFriendListServiceTest.kt +++ b/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/GetFriendListServiceTest.kt @@ -7,21 +7,17 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.springframework.data.domain.PageImpl import org.springframework.data.domain.PageRequest -import team.cklob.mudda.domain.block.domain.entity.Block -import team.cklob.mudda.domain.block.domain.repository.BlockRepository 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.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 java.time.LocalDateTime class GetFriendListServiceTest { private val friendRepository = mockk() - private val blockRepository = mockk() - private val service = GetFriendListService(friendRepository, blockRepository) + private val service = GetFriendListService(friendRepository) private val pageable = PageRequest.of(0, 20) private fun member(id: Long) = Member( @@ -29,15 +25,10 @@ class GetFriendListServiceTest { oauthProvider = OAuthProvider.GOOGLE, providerId = "google-sub-$id", profileVisibility = ProfileVisibility.PUBLIC, id = id, ) - private fun mockNoBlocks() { - every { blockRepository.findByBlockerIdOrBlockedId(1L, 1L) } returns emptyList() - } - @Test fun `returns the counterpart when the caller was the requester`() { val acceptedAt = LocalDateTime.now() val friend = Friend(requester = member(1L), receiver = member(2L), status = FriendRequestStatus.ACCEPTED, acceptedAt = acceptedAt, id = 10L) - every { friendRepository.findFriendships(1L, FriendRequestStatus.ACCEPTED, pageable) } returns PageImpl(listOf(friend), pageable, 1) - mockNoBlocks() + every { friendRepository.findFriendships(1L, pageable) } returns PageImpl(listOf(friend), pageable, 1) val response = service.execute(1L, pageable) @@ -49,42 +40,29 @@ class GetFriendListServiceTest { @Test fun `returns the counterpart when the caller was the receiver`() { val acceptedAt = LocalDateTime.now() val friend = Friend(requester = member(2L), receiver = member(1L), status = FriendRequestStatus.ACCEPTED, acceptedAt = acceptedAt, id = 10L) - every { friendRepository.findFriendships(1L, FriendRequestStatus.ACCEPTED, pageable) } returns PageImpl(listOf(friend), pageable, 1) - mockNoBlocks() + every { friendRepository.findFriendships(1L, pageable) } returns PageImpl(listOf(friend), pageable, 1) val response = service.execute(1L, pageable) assertEquals(2L, response.content[0].memberId) } - @Test fun `only queries ACCEPTED relationships`() { - mockNoBlocks() - every { friendRepository.findFriendships(1L, FriendRequestStatus.ACCEPTED, pageable) } returns PageImpl(emptyList(), pageable, 0) + @Test fun `returns an empty page when the repository finds nothing`() { + every { friendRepository.findFriendships(1L, pageable) } returns PageImpl(emptyList(), pageable, 0) val response = service.execute(1L, pageable) assertTrue(response.content.isEmpty()) } - @Test fun `filters out a friend that is in a block relationship with the caller`() { - val acceptedAt = LocalDateTime.now() - val kept = Friend(requester = member(1L), receiver = member(2L), status = FriendRequestStatus.ACCEPTED, acceptedAt = acceptedAt, id = 10L) - val blocked = Friend(requester = member(1L), receiver = member(3L), status = FriendRequestStatus.ACCEPTED, acceptedAt = acceptedAt, id = 11L) - every { friendRepository.findFriendships(1L, FriendRequestStatus.ACCEPTED, pageable) } returns PageImpl(listOf(kept, blocked), pageable, 2) - every { blockRepository.findByBlockerIdOrBlockedId(1L, 1L) } returns listOf(Block(blocker = member(1L), blocked = member(3L), id = 100L)) - - val response = service.execute(1L, pageable) - - assertEquals(1, response.content.size) - assertEquals(2L, response.content[0].memberId) - } - @Test fun `maps page metadata and sorting order from the repository result`() { val older = Friend(requester = member(1L), receiver = member(2L), status = FriendRequestStatus.ACCEPTED, acceptedAt = LocalDateTime.now().minusDays(1), id = 10L) val newer = Friend(requester = member(1L), receiver = member(3L), status = FriendRequestStatus.ACCEPTED, acceptedAt = LocalDateTime.now(), id = 11L) - // The repository query itself orders by acceptedAt DESC; the service must preserve that order, not re-sort. - every { friendRepository.findFriendships(1L, FriendRequestStatus.ACCEPTED, pageable) } returns PageImpl(listOf(newer, older), pageable, 2) - mockNoBlocks() + // The repository query itself orders by acceptedAt DESC, id DESC; the service must preserve that + // order, not re-sort. Block filtering has moved into the repository query too (see + // FriendRepositoryIntegrationTest's "findFriendships excludes a friend blocked in either direction"), + // so this service no longer depends on BlockRepository at all. + every { friendRepository.findFriendships(1L, pageable) } returns PageImpl(listOf(newer, older), pageable, 2) val response = service.execute(1L, pageable) diff --git a/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/RespondFriendRequestServiceTest.kt b/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/RespondFriendRequestServiceTest.kt index b1f8271..22727d4 100644 --- a/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/RespondFriendRequestServiceTest.kt +++ b/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/RespondFriendRequestServiceTest.kt @@ -7,6 +7,7 @@ import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Test +import team.cklob.mudda.domain.block.domain.repository.BlockRepository 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.FriendRequestAction @@ -22,7 +23,8 @@ import java.util.Optional class RespondFriendRequestServiceTest { private val friendRepository = mockk() - private val service = RespondFriendRequestService(friendRepository) + private val blockRepository = mockk() + private val service = RespondFriendRequestService(friendRepository, blockRepository) private fun member(id: Long) = Member( name = "name-$id", nickname = "nickname-$id", email = "user$id@example.com", @@ -32,9 +34,14 @@ class RespondFriendRequestServiceTest { private fun pendingRequest(requesterId: Long = 1L, receiverId: Long = 2L) = Friend(requester = member(requesterId), receiver = member(receiverId), status = FriendRequestStatus.PENDING, id = 10L) + private fun mockNoBlock(receiverId: Long = 2L, requesterId: Long = 1L) { + every { blockRepository.existsByBlockerIdAndBlockedIdOrBlockerIdAndBlockedId(receiverId, requesterId, requesterId, receiverId) } returns false + } + @Test fun `accepts a pending request addressed to the caller`() { val friend = pendingRequest() every { friendRepository.findById(10L) } returns Optional.of(friend) + mockNoBlock() service.execute(2L, 10L, RespondFriendRequestRequest(FriendRequestAction.ACCEPT)) @@ -83,4 +90,23 @@ class RespondFriendRequestServiceTest { val exception = assertThrows(BusinessException::class.java) { service.execute(2L, 10L, RespondFriendRequestRequest(FriendRequestAction.REJECT)) } assertEquals(ErrorCode.FRIEND_REQUEST_ALREADY_PROCESSED, exception.errorCode) } + + @Test fun `rejects accepting when a block exists between the two members`() { + val friend = pendingRequest() + every { friendRepository.findById(10L) } returns Optional.of(friend) + every { blockRepository.existsByBlockerIdAndBlockedIdOrBlockerIdAndBlockedId(2L, 1L, 1L, 2L) } returns true + + val exception = assertThrows(BusinessException::class.java) { service.execute(2L, 10L, RespondFriendRequestRequest(FriendRequestAction.ACCEPT)) } + assertEquals(ErrorCode.BLOCKED_MEMBER, exception.errorCode) + assertEquals(FriendRequestStatus.PENDING, friend.status) + } + + @Test fun `does not check for a block when rejecting`() { + val friend = pendingRequest() + every { friendRepository.findById(10L) } returns Optional.of(friend) + + service.execute(2L, 10L, RespondFriendRequestRequest(FriendRequestAction.REJECT)) + + io.mockk.verify(exactly = 0) { blockRepository.existsByBlockerIdAndBlockedIdOrBlockerIdAndBlockedId(any(), any(), any(), any()) } + } } diff --git a/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/SearchFriendServiceTest.kt b/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/SearchFriendServiceTest.kt index 379162d..ab4319e 100644 --- a/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/SearchFriendServiceTest.kt +++ b/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/SearchFriendServiceTest.kt @@ -33,7 +33,7 @@ class SearchFriendServiceTest { @Test fun `returns NONE when there is no relationship with a candidate`() { val candidate = member(2L) - every { memberRepository.searchSelectableByNickname(1L, "nick", "nick", pageable) } returns PageImpl(listOf(candidate), pageable, 1) + every { memberRepository.searchSelectableByNickname(1L, "nick", pageable) } returns PageImpl(listOf(candidate), pageable, 1) every { friendRepository.findAllBetween(1L, listOf(2L)) } returns emptyList() val response = service.execute(1L, "nick", pageable) @@ -45,7 +45,7 @@ class SearchFriendServiceTest { @Test fun `marks a candidate the caller already sent a request to`() { val candidate = member(2L) - every { memberRepository.searchSelectableByNickname(1L, "nick", "nick", pageable) } returns PageImpl(listOf(candidate), pageable, 1) + every { memberRepository.searchSelectableByNickname(1L, "nick", pageable) } returns PageImpl(listOf(candidate), pageable, 1) every { friendRepository.findAllBetween(1L, listOf(2L)) } returns listOf(Friend(requester = member(1L), receiver = candidate, status = FriendRequestStatus.PENDING, id = 10L)) @@ -58,7 +58,7 @@ class SearchFriendServiceTest { @Test fun `marks a candidate who sent the caller a request`() { val candidate = member(2L) - every { memberRepository.searchSelectableByNickname(1L, "nick", "nick", pageable) } returns PageImpl(listOf(candidate), pageable, 1) + every { memberRepository.searchSelectableByNickname(1L, "nick", pageable) } returns PageImpl(listOf(candidate), pageable, 1) every { friendRepository.findAllBetween(1L, listOf(2L)) } returns listOf(Friend(requester = candidate, receiver = member(1L), status = FriendRequestStatus.PENDING, id = 10L)) @@ -70,7 +70,7 @@ class SearchFriendServiceTest { @Test fun `marks an already-accepted friend`() { val candidate = member(2L) - every { memberRepository.searchSelectableByNickname(1L, "nick", "nick", pageable) } returns PageImpl(listOf(candidate), pageable, 1) + every { memberRepository.searchSelectableByNickname(1L, "nick", pageable) } returns PageImpl(listOf(candidate), pageable, 1) every { friendRepository.findAllBetween(1L, listOf(2L)) } returns listOf(Friend(requester = member(1L), receiver = candidate, status = FriendRequestStatus.ACCEPTED, id = 10L)) @@ -81,7 +81,7 @@ class SearchFriendServiceTest { @Test fun `treats a rejected relationship as NONE`() { val candidate = member(2L) - every { memberRepository.searchSelectableByNickname(1L, "nick", "nick", pageable) } returns PageImpl(listOf(candidate), pageable, 1) + every { memberRepository.searchSelectableByNickname(1L, "nick", pageable) } returns PageImpl(listOf(candidate), pageable, 1) every { friendRepository.findAllBetween(1L, listOf(2L)) } returns listOf(Friend(requester = member(1L), receiver = candidate, status = FriendRequestStatus.REJECTED, id = 10L)) @@ -96,23 +96,18 @@ class SearchFriendServiceTest { } @Test fun `trims the keyword before searching`() { - every { memberRepository.searchSelectableByNickname(1L, "nick", "nick", pageable) } returns PageImpl(emptyList(), pageable, 0) + every { memberRepository.searchSelectableByNickname(1L, "nick", pageable) } returns PageImpl(emptyList(), pageable, 0) service.execute(1L, " nick ", pageable) - io.mockk.verify { memberRepository.searchSelectableByNickname(1L, "nick", "nick", pageable) } - } - - @Test fun `escapes LIKE wildcard characters before searching`() { - every { memberRepository.searchSelectableByNickname(1L, "50%_off", "50!%!_off", pageable) } returns PageImpl(emptyList(), pageable, 0) - - service.execute(1L, "50%_off", pageable) - - io.mockk.verify { memberRepository.searchSelectableByNickname(1L, "50%_off", "50!%!_off", pageable) } + io.mockk.verify { memberRepository.searchSelectableByNickname(1L, "nick", pageable) } + // LIKE-wildcard escaping is no longer this service's concern -- it's encapsulated in + // MemberRepository#searchSelectableByNickname's 3-arg default method now, and verified for real + // against Postgres by MemberRepositorySearchIntegrationTest's "escapes LIKE wildcard characters". } @Test fun `paginates results and reports page metadata`() { - every { memberRepository.searchSelectableByNickname(1L, "nick", "nick", pageable) } returns PageImpl(emptyList(), pageable, 42) + every { memberRepository.searchSelectableByNickname(1L, "nick", pageable) } returns PageImpl(emptyList(), pageable, 42) val response = service.execute(1L, "nick", pageable) diff --git a/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/SendFriendRequestServiceTest.kt b/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/SendFriendRequestServiceTest.kt index fed94de..95046cd 100644 --- a/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/SendFriendRequestServiceTest.kt +++ b/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/SendFriendRequestServiceTest.kt @@ -36,7 +36,8 @@ class SendFriendRequestServiceTest { ) private fun mockNoBlock() { - every { blockRepository.existsByBlockerIdAndBlockedIdOrBlockerIdAndBlockedId(1L, 2L, 2L, 1L) } returns false + every { blockRepository.existsByBlockerIdAndBlockedId(1L, 2L) } returns false + every { blockRepository.existsByBlockerIdAndBlockedId(2L, 1L) } returns false } private fun mockNoExistingRelation() { @@ -141,17 +142,31 @@ class SendFriendRequestServiceTest { val response = service.execute(1L, SendFriendRequestRequest(receiverId = 2L)) assertEquals(11L, response.requestId) + // This only verifies the app-level check doesn't block a REJECTED relation; it can't prove the DB + // itself allows the insert (saveAndFlush is mocked). That's covered separately -- and for real -- by + // FriendRepositoryIntegrationTest's "a rejected request can be sent again in the same direction", + // which exercises the actual uq_friend_requester_receiver partial index from the V4 migration. } - @Test fun `rejects when a block relationship exists`() { + @Test fun `rejects when the caller has blocked the target`() { every { memberRepository.findById(1L) } returns Optional.of(member(1L)) every { memberRepository.findById(2L) } returns Optional.of(member(2L)) - every { blockRepository.existsByBlockerIdAndBlockedIdOrBlockerIdAndBlockedId(1L, 2L, 2L, 1L) } returns true + every { blockRepository.existsByBlockerIdAndBlockedId(1L, 2L) } returns true val exception = assertThrows(BusinessException::class.java) { service.execute(1L, SendFriendRequestRequest(receiverId = 2L)) } assertEquals(ErrorCode.BLOCKED_MEMBER, exception.errorCode) } + @Test fun `hides the block as member-not-found when the target has blocked the caller`() { + every { memberRepository.findById(1L) } returns Optional.of(member(1L)) + every { memberRepository.findById(2L) } returns Optional.of(member(2L)) + every { blockRepository.existsByBlockerIdAndBlockedId(1L, 2L) } returns false + every { blockRepository.existsByBlockerIdAndBlockedId(2L, 1L) } returns true + + val exception = assertThrows(BusinessException::class.java) { service.execute(1L, SendFriendRequestRequest(receiverId = 2L)) } + assertEquals(ErrorCode.MEMBER_NOT_FOUND, exception.errorCode) + } + @Test fun `translates a concurrent reverse-direction insert race into a conflict`() { val requester = member(1L) val receiver = member(2L) diff --git a/src/test/kotlin/team/cklob/mudda/domain/friend/domain/repository/FriendRepositoryIntegrationTest.kt b/src/test/kotlin/team/cklob/mudda/domain/friend/domain/repository/FriendRepositoryIntegrationTest.kt index b269336..686328d 100644 --- a/src/test/kotlin/team/cklob/mudda/domain/friend/domain/repository/FriendRepositoryIntegrationTest.kt +++ b/src/test/kotlin/team/cklob/mudda/domain/friend/domain/repository/FriendRepositoryIntegrationTest.kt @@ -6,39 +6,26 @@ import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.boot.testcontainers.service.connection.ServiceConnection import org.springframework.dao.DataIntegrityViolationException import org.springframework.data.domain.PageRequest -import org.springframework.transaction.annotation.Transactional -import org.testcontainers.containers.PostgreSQLContainer -import org.testcontainers.junit.jupiter.Container -import org.testcontainers.junit.jupiter.Testcontainers -import org.testcontainers.utility.DockerImageName +import team.cklob.mudda.domain.block.domain.entity.Block +import team.cklob.mudda.domain.block.domain.repository.BlockRepository import team.cklob.mudda.domain.friend.domain.entity.Friend import team.cklob.mudda.domain.friend.domain.type.FriendRequestStatus 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 - -class PostgisContainer(imageName: DockerImageName) : PostgreSQLContainer(imageName) +import team.cklob.mudda.support.PostgresIntegrationTest +import java.time.LocalDateTime // Exercises the real PostgreSQL/PostGIS schema produced by the actual Flyway migrations (including the -// new V4 pending-pair unique index), which a MockK-based unit test cannot verify. -@SpringBootTest( - properties = [ - "spring.cloud.aws.region.static=ap-northeast-2", - "spring.cloud.aws.credentials.access-key=test", - "spring.cloud.aws.credentials.secret-key=test", - "jwt.secret=local-test-secret-must-be-at-least-32-bytes", - ], -) -@Testcontainers -@Transactional -class FriendRepositoryIntegrationTest { +// V4 pending-pair/rejected-reuse unique indexes and the accepted_at CHECK constraint), which a MockK-based +// unit test cannot verify. +class FriendRepositoryIntegrationTest : PostgresIntegrationTest() { @Autowired private lateinit var friendRepository: FriendRepository @Autowired private lateinit var memberRepository: MemberRepository + @Autowired private lateinit var blockRepository: BlockRepository @Autowired private lateinit var entityManager: EntityManager private fun member(tag: String) = memberRepository.saveAndFlush( @@ -64,18 +51,39 @@ class FriendRepositoryIntegrationTest { val a = member("a") val b = member("b") val c = member("c") - friendRepository.saveAndFlush(Friend(requester = a, receiver = b, status = FriendRequestStatus.ACCEPTED, acceptedAt = java.time.LocalDateTime.now())) - friendRepository.saveAndFlush(Friend(requester = c, receiver = a, status = FriendRequestStatus.ACCEPTED, acceptedAt = java.time.LocalDateTime.now())) + friendRepository.saveAndFlush(Friend(requester = a, receiver = b, status = FriendRequestStatus.ACCEPTED, acceptedAt = LocalDateTime.now())) + friendRepository.saveAndFlush(Friend(requester = c, receiver = a, status = FriendRequestStatus.ACCEPTED, acceptedAt = LocalDateTime.now())) friendRepository.saveAndFlush(Friend(requester = a, receiver = member("d"), status = FriendRequestStatus.PENDING)) entityManager.flush() entityManager.clear() - val page = friendRepository.findFriendships(a.id!!, FriendRequestStatus.ACCEPTED, PageRequest.of(0, 20)) + val page = friendRepository.findFriendships(a.id!!, PageRequest.of(0, 20)) assertEquals(2, page.totalElements) assertTrue(page.content.all { it.status == FriendRequestStatus.ACCEPTED }) } + @Test fun `findFriendships excludes a friend blocked in either direction`() { + val a = member("a") + val kept = member("kept") + val blockedByA = member("blocked-by-a") + val blockedA = member("blocked-a") + friendRepository.saveAndFlush(Friend(requester = a, receiver = kept, status = FriendRequestStatus.ACCEPTED, acceptedAt = LocalDateTime.now())) + friendRepository.saveAndFlush(Friend(requester = a, receiver = blockedByA, status = FriendRequestStatus.ACCEPTED, acceptedAt = LocalDateTime.now())) + friendRepository.saveAndFlush(Friend(requester = blockedA, receiver = a, status = FriendRequestStatus.ACCEPTED, acceptedAt = LocalDateTime.now())) + blockRepository.saveAndFlush(Block(blocker = a, blocked = blockedByA)) + blockRepository.saveAndFlush(Block(blocker = blockedA, blocked = a)) + entityManager.flush() + entityManager.clear() + + val page = friendRepository.findFriendships(a.id!!, PageRequest.of(0, 20)) + + val counterpartIds = page.content.map { if (it.requester.id == a.id) it.receiver.id else it.requester.id } + assertEquals(listOf(kept.id), counterpartIds) + // The block filter runs in SQL, not as a post-fetch step, so totalElements reflects it too. + assertEquals(1L, page.totalElements) + } + @Test fun `same-direction duplicate PENDING request is rejected by the unique constraint`() { val a = member("a") val b = member("b") @@ -96,15 +104,25 @@ class FriendRepositoryIntegrationTest { } } - companion object { - private val postgisImage = DockerImageName - .parse("postgis/postgis:16-3.5-alpine") - .asCompatibleSubstituteFor("postgres") + @Test fun `a rejected request can be sent again in the same direction`() { + val a = member("a") + val b = member("b") + friendRepository.saveAndFlush(Friend(requester = a, receiver = b, status = FriendRequestStatus.REJECTED)) + + // Before V4's uq_friend_requester_receiver partial index (excluding REJECTED), this insert violated + // the unconditional unique constraint from V2 and made re-requesting permanently impossible. + val resent = friendRepository.saveAndFlush(Friend(requester = a, receiver = b, status = FriendRequestStatus.PENDING)) + + assertEquals(FriendRequestStatus.PENDING, resent.status) + assertEquals(2, friendRepository.findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(a.id!!, b.id!!, b.id!!, a.id!!).size) + } + + @Test fun `an ACCEPTED row without accepted_at is rejected by ck_friend_accepted_at`() { + val a = member("a") + val b = member("b") - @Container - @ServiceConnection - @JvmStatic - val postgres = PostgisContainer(postgisImage) - .withInitScript("db/init/001_enable_postgis.sql") + assertThrows(DataIntegrityViolationException::class.java) { + friendRepository.saveAndFlush(Friend(requester = a, receiver = b, status = FriendRequestStatus.ACCEPTED, acceptedAt = null)) + } } } diff --git a/src/test/kotlin/team/cklob/mudda/domain/member/domain/repository/MemberRepositorySearchIntegrationTest.kt b/src/test/kotlin/team/cklob/mudda/domain/member/domain/repository/MemberRepositorySearchIntegrationTest.kt index 6a4bd69..8d5edd4 100644 --- a/src/test/kotlin/team/cklob/mudda/domain/member/domain/repository/MemberRepositorySearchIntegrationTest.kt +++ b/src/test/kotlin/team/cklob/mudda/domain/member/domain/repository/MemberRepositorySearchIntegrationTest.kt @@ -5,40 +5,23 @@ import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.boot.testcontainers.service.connection.ServiceConnection import org.springframework.data.domain.PageRequest -import org.springframework.transaction.annotation.Transactional -import org.testcontainers.containers.PostgreSQLContainer -import org.testcontainers.junit.jupiter.Container -import org.testcontainers.junit.jupiter.Testcontainers -import org.testcontainers.utility.DockerImageName import team.cklob.mudda.domain.block.domain.entity.Block import team.cklob.mudda.domain.block.domain.repository.BlockRepository import team.cklob.mudda.domain.member.domain.entity.Member import team.cklob.mudda.domain.member.domain.type.OAuthProvider import team.cklob.mudda.domain.member.domain.type.ProfileVisibility - -class PostgisContainer(imageName: DockerImageName) : PostgreSQLContainer(imageName) +import team.cklob.mudda.support.PostgresIntegrationTest +import java.time.LocalDateTime // Exercises MemberRepository#searchSelectableByNickname against a real PostgreSQL instance -- the LIKE // ESCAPE clause, the CASE-based ranking, and the NOT EXISTS block-exclusion subquery are all things a // MockK-based unit test cannot verify actually compile to valid, correct SQL. -@SpringBootTest( - properties = [ - "spring.cloud.aws.region.static=ap-northeast-2", - "spring.cloud.aws.credentials.access-key=test", - "spring.cloud.aws.credentials.secret-key=test", - "jwt.secret=local-test-secret-must-be-at-least-32-bytes", - ], -) -@Testcontainers -@Transactional -class MemberRepositorySearchIntegrationTest { +class MemberRepositorySearchIntegrationTest : PostgresIntegrationTest() { @Autowired private lateinit var memberRepository: MemberRepository @Autowired private lateinit var blockRepository: BlockRepository - private fun member(tag: String, nickname: String? = "nick-$tag", withdrawnAt: java.time.LocalDateTime? = null) = memberRepository.saveAndFlush( + private fun member(tag: String, nickname: String? = "nick-$tag", withdrawnAt: LocalDateTime? = null) = memberRepository.saveAndFlush( Member( name = "name-$tag", nickname = nickname, email = "user-$tag@example.com", oauthProvider = OAuthProvider.GOOGLE, providerId = "google-sub-$tag", @@ -48,11 +31,11 @@ class MemberRepositorySearchIntegrationTest { @Test fun `excludes the viewer, withdrawn members and members without a nickname`() { val viewer = member("viewer", nickname = "search-target") - val withdrawn = member("withdrawn", nickname = "search-target-2", withdrawnAt = java.time.LocalDateTime.now()) + val withdrawn = member("withdrawn", nickname = "search-target-2", withdrawnAt = LocalDateTime.now()) val incomplete = member("incomplete", nickname = null) val target = member("target", nickname = "search-target-3") - val page = memberRepository.searchSelectableByNickname(viewer.id!!, "search-target", "search-target", PageRequest.of(0, 20)) + val page = memberRepository.searchSelectableByNickname(viewer.id!!, "search-target", PageRequest.of(0, 20)) val ids = page.content.mapNotNull { it.id } assertFalse(viewer.id in ids) @@ -69,7 +52,7 @@ class MemberRepositorySearchIntegrationTest { blockRepository.saveAndFlush(Block(blocker = viewer, blocked = blockedByViewer)) blockRepository.saveAndFlush(Block(blocker = blockedViewer, blocked = viewer)) - val page = memberRepository.searchSelectableByNickname(viewer.id!!, "block-search", "block-search", PageRequest.of(0, 20)) + val page = memberRepository.searchSelectableByNickname(viewer.id!!, "block-search", PageRequest.of(0, 20)) val ids = page.content.mapNotNull { it.id } assertFalse(blockedByViewer.id in ids) @@ -83,7 +66,7 @@ class MemberRepositorySearchIntegrationTest { val prefix = member("prefix", nickname = "ranktest-suffix") val exact = member("exact", nickname = "ranktest") - val page = memberRepository.searchSelectableByNickname(viewer.id!!, "ranktest", "ranktest", PageRequest.of(0, 20)) + val page = memberRepository.searchSelectableByNickname(viewer.id!!, "ranktest", PageRequest.of(0, 20)) assertEquals(listOf(exact.id, prefix.id, containsOnly.id), page.content.mapNotNull { it.id }) } @@ -93,7 +76,9 @@ class MemberRepositorySearchIntegrationTest { val literalMatch = member("literal", nickname = "50%_off") member("decoy", nickname = "50xyoff") - val page = memberRepository.searchSelectableByNickname(viewer.id!!, "50%_off", "50!%!_off", PageRequest.of(0, 20)) + // Calling the 3-arg overload here (rather than pre-computing the escaped keyword) exercises the + // real production call path, including MemberRepository's own escaping logic. + val page = memberRepository.searchSelectableByNickname(viewer.id!!, "50%_off", PageRequest.of(0, 20)) assertEquals(listOf(literalMatch.id), page.content.mapNotNull { it.id }) } @@ -102,21 +87,9 @@ class MemberRepositorySearchIntegrationTest { val viewer = member("viewer5") repeat(3) { member("page-$it", nickname = "page-target-$it") } - val page = memberRepository.searchSelectableByNickname(viewer.id!!, "page-target", "page-target", PageRequest.of(0, 2)) + val page = memberRepository.searchSelectableByNickname(viewer.id!!, "page-target", PageRequest.of(0, 2)) assertEquals(2, page.content.size) assertEquals(3L, page.totalElements) } - - companion object { - private val postgisImage = DockerImageName - .parse("postgis/postgis:16-3.5-alpine") - .asCompatibleSubstituteFor("postgres") - - @Container - @ServiceConnection - @JvmStatic - val postgres = PostgisContainer(postgisImage) - .withInitScript("db/init/001_enable_postgis.sql") - } } diff --git a/src/test/kotlin/team/cklob/mudda/support/PostgresIntegrationTest.kt b/src/test/kotlin/team/cklob/mudda/support/PostgresIntegrationTest.kt new file mode 100644 index 0000000..3659386 --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/support/PostgresIntegrationTest.kt @@ -0,0 +1,43 @@ +package team.cklob.mudda.support + +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.testcontainers.service.connection.ServiceConnection +import org.springframework.transaction.annotation.Transactional +import org.testcontainers.containers.PostgreSQLContainer +import org.testcontainers.utility.DockerImageName + +class PostgisContainer(imageName: DockerImageName) : PostgreSQLContainer(imageName) + +// Shared base for repository integration tests that need a real PostgreSQL/PostGIS instance (real Flyway +// migrations, JPQL, unique/CHECK constraints -- things a MockK-based unit test cannot verify). Subclasses +// share this single container and the same @SpringBootTest bootstrap configuration, so Spring's context +// cache and the Testcontainers container are both reused across every subclass instead of each test class +// paying for its own container + full migration run. +// +// The container is deliberately NOT annotated with @Container/@Testcontainers. That JUnit-managed +// lifecycle is scoped per test class -- when two unrelated top-level classes both inherit the same +// @JvmStatic container field from this base, the extension stops it after the first class's tests finish, +// leaving the second class unable to connect. Starting it once here (on first access to the companion +// object, which the JVM guarantees happens at most once) and never stopping it is the standard +// Testcontainers "singleton container" pattern for sharing a container across multiple test classes; the +// Ryuk resource reaper Testcontainers registers internally still guarantees cleanup on JVM exit. +@SpringBootTest( + properties = [ + "spring.cloud.aws.region.static=ap-northeast-2", + "spring.cloud.aws.credentials.access-key=test", + "spring.cloud.aws.credentials.secret-key=test", + "jwt.secret=local-test-secret-must-be-at-least-32-bytes", + ], +) +@Transactional +abstract class PostgresIntegrationTest { + companion object { + @ServiceConnection + @JvmStatic + val postgres: PostgisContainer = PostgisContainer( + DockerImageName.parse("postgis/postgis:16-3.5-alpine").asCompatibleSubstituteFor("postgres"), + ) + .withInitScript("db/init/001_enable_postgis.sql") + .also { it.start() } + } +} From a7a69a54f42dc5f0480ccb44581e3ee188c6c1b3 Mon Sep 17 00:00:00 2001 From: hej090224 Date: Wed, 5 Aug 2026 21:27:43 +0900 Subject: [PATCH 4/4] fix: #21 :: renumber friend migration to V5 after V4 collision with #20 develop merged PR #20's V4__support_pending_media_uploads.sql after this branch's V4__add_friend_request_indexes_and_pending_pair_constraint.sql was already written. Renumbering to V5 to avoid two migrations claiming version 4, which Flyway rejects (this is what broke CI on the merge commit). --- ...5__add_friend_request_indexes_and_pending_pair_constraint.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/main/resources/db/migration/{V4__add_friend_request_indexes_and_pending_pair_constraint.sql => V5__add_friend_request_indexes_and_pending_pair_constraint.sql} (100%) diff --git a/src/main/resources/db/migration/V4__add_friend_request_indexes_and_pending_pair_constraint.sql b/src/main/resources/db/migration/V5__add_friend_request_indexes_and_pending_pair_constraint.sql similarity index 100% rename from src/main/resources/db/migration/V4__add_friend_request_indexes_and_pending_pair_constraint.sql rename to src/main/resources/db/migration/V5__add_friend_request_indexes_and_pending_pair_constraint.sql