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 982d782..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 @@ -6,4 +6,12 @@ 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 } 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..d8646f9 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/GetFriendListService.kt @@ -0,0 +1,30 @@ +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.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, +) { + @Transactional(readOnly = true) + fun execute(memberId: Long, pageable: Pageable): FriendPageResponse { + // 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) + } + + 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..be8df35 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/RespondFriendRequestService.kt @@ -0,0 +1,45 @@ +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 +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, + private val blockRepository: BlockRepository, +) { + @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 -> { + 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() + } + 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..64f77bf --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/SearchFriendService.kt @@ -0,0 +1,58 @@ +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, 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 + } + } +} 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..b03d128 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/friend/application/impl/SendFriendRequestService.kt @@ -0,0 +1,75 @@ +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 +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, +) { + 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 + // 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) + + // 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 -> + 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) + // 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 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) + } + + 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..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 @@ -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,66 @@ 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. 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( + 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, 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, f.id 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, 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/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..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 @@ -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,67 @@ 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. + // + // 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). Callers should use + // the 3-arg overload below instead of calling this one directly. + @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 + + // 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/kotlin/team/cklob/mudda/global/exception/ErrorCode.kt b/src/main/kotlin/team/cklob/mudda/global/exception/ErrorCode.kt index 6c531e2..a347f8f 100644 --- a/src/main/kotlin/team/cklob/mudda/global/exception/ErrorCode.kt +++ b/src/main/kotlin/team/cklob/mudda/global/exception/ErrorCode.kt @@ -21,4 +21,14 @@ enum class ErrorCode(val status: HttpStatus, val code: String, val message: Stri MEDIA_ALREADY_ATTACHED(HttpStatus.CONFLICT, "D003", "Attached media cannot be deleted."), MEDIA_STORAGE_ERROR(HttpStatus.BAD_GATEWAY, "D004", "Media storage request failed."), 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 9c7aaf2..ee05747 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/V5__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 new file mode 100644 index 0000000..904cdcb --- /dev/null +++ b/src/main/resources/db/migration/V5__add_friend_request_indexes_and_pending_pair_constraint.sql @@ -0,0 +1,41 @@ +-- 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); + +-- 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. 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); 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..a13159d --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/GetFriendListServiceTest.kt @@ -0,0 +1,73 @@ +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.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 java.time.LocalDateTime + +class GetFriendListServiceTest { + private val friendRepository = mockk() + private val service = GetFriendListService(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 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, pageable) } returns PageImpl(listOf(friend), pageable, 1) + + 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, pageable) } returns PageImpl(listOf(friend), pageable, 1) + + val response = service.execute(1L, pageable) + + assertEquals(2L, response.content[0].memberId) + } + + @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 `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, 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) + + 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..22727d4 --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/RespondFriendRequestServiceTest.kt @@ -0,0 +1,112 @@ +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.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 +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 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", + 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) + + 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)) + + 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) + } + + @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 new file mode 100644 index 0000000..ab4319e --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/SearchFriendServiceTest.kt @@ -0,0 +1,116 @@ +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", 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", 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", 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", 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", 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", pageable) } returns PageImpl(emptyList(), pageable, 0) + + service.execute(1L, " nick ", 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", 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..95046cd --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/domain/friend/application/impl/SendFriendRequestServiceTest.kt @@ -0,0 +1,196 @@ +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.existsByBlockerIdAndBlockedId(1L, 2L) } returns false + every { blockRepository.existsByBlockerIdAndBlockedId(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) + // 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 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.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) + 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..686328d --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/domain/friend/domain/repository/FriendRepositoryIntegrationTest.kt @@ -0,0 +1,128 @@ +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.dao.DataIntegrityViolationException +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.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 team.cklob.mudda.support.PostgresIntegrationTest +import java.time.LocalDateTime + +// Exercises the real PostgreSQL/PostGIS schema produced by the actual Flyway migrations (including the +// 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( + 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 = 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!!, 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") + 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)) + } + } + + @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") + + 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/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..8d5edd4 --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/domain/member/domain/repository/MemberRepositorySearchIntegrationTest.kt @@ -0,0 +1,95 @@ +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.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.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.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. +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: 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 = LocalDateTime.now()) + val incomplete = member("incomplete", nickname = null) + val target = member("target", nickname = "search-target-3") + + val page = memberRepository.searchSelectableByNickname(viewer.id!!, "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", 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", 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") + + // 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 }) + } + + @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", PageRequest.of(0, 2)) + + assertEquals(2, page.content.size) + assertEquals(3L, page.totalElements) + } +} 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() } + } +}