diff --git a/src/main/java/org/festimate/team/domain/matching/repository/MatchingRepository.java b/src/main/java/org/festimate/team/domain/matching/repository/MatchingRepository.java index c77b024..fac9ded 100644 --- a/src/main/java/org/festimate/team/domain/matching/repository/MatchingRepository.java +++ b/src/main/java/org/festimate/team/domain/matching/repository/MatchingRepository.java @@ -4,6 +4,7 @@ import org.festimate.team.domain.participant.entity.Participant; import org.festimate.team.domain.participant.entity.TypeResult; import org.festimate.team.domain.user.entity.Gender; +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; @@ -14,36 +15,31 @@ public interface MatchingRepository extends JpaRepository { @Query(""" SELECT p FROM Participant p + JOIN FETCH p.user + LEFT JOIN Matching m1 + ON (m1.applicantParticipant = p OR m1.targetParticipant = p) WHERE p.festival.festivalId = :festivalId AND p.typeResult = :typeResult AND p.user.gender != :gender AND p.participantId != :participantId AND p.participantId NOT IN ( - SELECT m.targetParticipant.participantId FROM Matching m + SELECT m.targetParticipant.participantId + FROM Matching m WHERE m.applicantParticipant.participantId = :participantId AND m.status = 'COMPLETED' ) - AND (SIZE(p.matchingsAsTarget) + SIZE(p.matchingsAsApplicant)) = ( - SELECT MIN(SIZE(p2.matchingsAsTarget) + SIZE(p2.matchingsAsApplicant)) - FROM Participant p2 - WHERE p2.festival.festivalId = :festivalId - AND p2.typeResult = :typeResult - AND p2.user.gender != :gender - AND p2.participantId != :participantId - AND p2.participantId NOT IN ( - SELECT m2.targetParticipant.participantId FROM Matching m2 - WHERE m2.applicantParticipant.participantId = :participantId - AND m2.status = 'COMPLETED' - ) - ) + GROUP BY p + ORDER BY COUNT(m1) ASC """) - Optional findMatchingCandidate( + List findMatchingCandidates( @Param("participantId") Long participantId, @Param("typeResult") TypeResult typeResult, @Param("gender") Gender gender, - @Param("festivalId") Long festivalId + @Param("festivalId") Long festivalId, + Pageable pageable ); + @Query(""" SELECT m FROM Matching m WHERE m.festival.festivalId = :festivalId diff --git a/src/main/java/org/festimate/team/domain/matching/service/impl/MatchingServiceImpl.java b/src/main/java/org/festimate/team/domain/matching/service/impl/MatchingServiceImpl.java index 9e75235..df4bd55 100644 --- a/src/main/java/org/festimate/team/domain/matching/service/impl/MatchingServiceImpl.java +++ b/src/main/java/org/festimate/team/domain/matching/service/impl/MatchingServiceImpl.java @@ -20,6 +20,7 @@ import org.festimate.team.domain.user.service.UserService; import org.festimate.team.global.exception.FestimateException; import org.festimate.team.global.response.ResponseError; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -48,12 +49,12 @@ public MatchingStatusResponse createMatching(Long userId, Long festivalId) { ); isMatchingDateValid(LocalDateTime.now(), festival.getMatchingStartAt()); - pointService.usePoint(participant); - Optional targetParticipantOptional = findBestCandidateByPriority(festivalId, participant); + Optional targetOptional = findBestCandidateByPriority(festivalId, participant); + Participant target = targetOptional.orElse(null); - Matching matching = saveMatching(festival, targetParticipantOptional, participant); + Matching matching = saveMatching(festival, Optional.ofNullable(target), participant); return MatchingStatusResponse.of(matching.getStatus(), matching.getMatchingId()); } @@ -76,6 +77,9 @@ public MatchingDetailInfo getMatchingDetail(Long userId, Long festivalId, Long m participantService.getParticipantOrThrow(userService.getUserByIdOrThrow(userId), festival); Matching matching = matchingRepository.findByMatchingId(matchingId) .orElseThrow(() -> new FestimateException(ResponseError.TARGET_NOT_FOUND)); + if (matching.getTargetParticipant() == null || matching.getTargetParticipant().getUser() == null) { + throw new FestimateException(ResponseError.TARGET_NOT_FOUND); + } if (!matching.getFestival().getFestivalId().equals(festivalId)) { throw new FestimateException(ResponseError.FORBIDDEN_RESOURCE); } @@ -102,12 +106,14 @@ public Optional findBestCandidateByPriority(long festivalId, Partic Gender myGender = participant.getUser().getGender(); for (TypeResult priorityType : priorities) { - Optional candidate = matchingRepository.findMatchingCandidate( + Optional candidate = matchingRepository.findMatchingCandidates( participant.getParticipantId(), priorityType, myGender, - festivalId - ); + festivalId, + PageRequest.of(0, 1) + ).stream().findFirst(); + if (candidate.isPresent()) { return candidate; } diff --git a/src/test/java/org/festimate/team/domain/festival/entity/FestivalTest.java b/src/test/java/org/festimate/team/domain/festival/entity/FestivalTest.java index edc1358..bab44a5 100644 --- a/src/test/java/org/festimate/team/domain/festival/entity/FestivalTest.java +++ b/src/test/java/org/festimate/team/domain/festival/entity/FestivalTest.java @@ -1,7 +1,5 @@ package org.festimate.team.domain.festival.entity; -import org.festimate.team.domain.festival.entity.Festival; -import org.festimate.team.domain.festival.entity.FestivalStatus; import org.festimate.team.domain.user.entity.Gender; import org.festimate.team.domain.user.entity.User; import org.junit.jupiter.api.DisplayName; @@ -13,7 +11,7 @@ import static org.festimate.team.common.mock.MockFactory.mockFestival; import static org.festimate.team.common.mock.MockFactory.mockUser; -public class FestivalTest { +class FestivalTest { private final User mockHost = mockUser("호스트", Gender.MAN, 1L); diff --git a/src/test/java/org/festimate/team/domain/festival/service/impl/FestivalServiceImplTest.java b/src/test/java/org/festimate/team/domain/festival/service/impl/FestivalServiceImplTest.java index f4a2f4a..9923984 100644 --- a/src/test/java/org/festimate/team/domain/festival/service/impl/FestivalServiceImplTest.java +++ b/src/test/java/org/festimate/team/domain/festival/service/impl/FestivalServiceImplTest.java @@ -18,7 +18,7 @@ import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; import static org.mockito.Mockito.when; -public class FestivalServiceImplTest { +class FestivalServiceImplTest { @Mock private FestivalRepository festivalRepository; diff --git a/src/test/java/org/festimate/team/domain/matching/service/impl/MatchingServiceImplTest.java b/src/test/java/org/festimate/team/domain/matching/service/impl/MatchingServiceImplTest.java index b2b4d9a..e0b8a8b 100644 --- a/src/test/java/org/festimate/team/domain/matching/service/impl/MatchingServiceImplTest.java +++ b/src/test/java/org/festimate/team/domain/matching/service/impl/MatchingServiceImplTest.java @@ -21,10 +21,12 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockitoAnnotations; +import org.springframework.data.domain.PageRequest; import org.springframework.test.util.ReflectionTestUtils; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.Collections; import java.util.List; import java.util.Optional; @@ -121,13 +123,10 @@ void findBestCandidateByPriority_success() { .festival(festival) .build(); - when(matchingRepository.findMatchingCandidate( - 1L, TypeResult.PHOTO, Gender.MAN, 1L - )).thenReturn(Optional.of(target)); + when(matchingRepository.findMatchingCandidates(1L, TypeResult.PHOTO, Gender.MAN, 1L, PageRequest.of(0, 1))) + .thenReturn(List.of(target)); - Optional result = matchingService.findBestCandidateByPriority( - festival.getFestivalId(), applicant - ); + var result = matchingService.findBestCandidateByPriority(festival.getFestivalId(), applicant); assertThat(result).isPresent(); assertThat(result.get().getTypeResult()).isEqualTo(TypeResult.PHOTO); @@ -148,8 +147,8 @@ void findBestCandidate_prioritySecond_success() { ReflectionTestUtils.setField(applicant, "participantId", 1L); // 1순위 PHOTO에 대상 없음 - when(matchingRepository.findMatchingCandidate(1L, TypeResult.PHOTO, Gender.MAN, 1L)) - .thenReturn(Optional.empty()); + when(matchingRepository.findMatchingCandidates(1L, TypeResult.PHOTO, Gender.MAN, 1L, PageRequest.of(0, 1))) + .thenReturn(Collections.emptyList()); // 2순위 INFLUENCER에 대상 있음 Participant secondPriorityCandidate = Participant.builder() @@ -157,10 +156,11 @@ void findBestCandidate_prioritySecond_success() { .typeResult(TypeResult.INFLUENCER) .festival(festival) .build(); - when(matchingRepository.findMatchingCandidate(1L, TypeResult.INFLUENCER, Gender.MAN, 1L)) - .thenReturn(Optional.of(secondPriorityCandidate)); - Optional result = matchingService.findBestCandidateByPriority(festival.getFestivalId(), applicant); + when(matchingRepository.findMatchingCandidates(1L, TypeResult.INFLUENCER, Gender.MAN, 1L, PageRequest.of(0, 1))) + .thenReturn(List.of(secondPriorityCandidate)); + + var result = matchingService.findBestCandidateByPriority(festival.getFestivalId(), applicant); assertThat(result).isPresent(); assertThat(result.get().getTypeResult()).isEqualTo(TypeResult.INFLUENCER); @@ -174,21 +174,21 @@ void findBestCandidate_priorityThird_success() { User thirdPriorityUser = mockUser("3순위타겟", Gender.WOMAN, 2L); Festival festival = mockFestival(applicantUser, 1L, LocalDate.now().minusDays(1), LocalDate.now().plusDays(1)); + // 신청자 Participant applicant = mockParticipant(applicantUser, festival, TypeResult.INFLUENCER, 1L); - - // 1순위 PHOTO, 2순위 INFLUENCER 대상 없음 - when(matchingRepository.findMatchingCandidate(applicant.getParticipantId(), TypeResult.PHOTO, Gender.MAN, festival.getFestivalId())) - .thenReturn(Optional.empty()); - when(matchingRepository.findMatchingCandidate(applicant.getParticipantId(), TypeResult.INFLUENCER, Gender.MAN, festival.getFestivalId())) - .thenReturn(Optional.empty()); - // 3순위 NEWBIE에 대상 있음 Participant thirdPriorityCandidate = mockParticipant(thirdPriorityUser, festival, TypeResult.NEWBIE, 2L); - when(matchingRepository.findMatchingCandidate(applicant.getParticipantId(), TypeResult.NEWBIE, Gender.MAN, festival.getFestivalId())) - .thenReturn(Optional.of(thirdPriorityCandidate)); + // 1순위 PHOTO, 2순위 INFLUENCER 대상 없음 + when(matchingRepository.findMatchingCandidates(1L, TypeResult.PHOTO, Gender.MAN, 1L, PageRequest.of(0, 1))) + .thenReturn(Collections.emptyList()); + when(matchingRepository.findMatchingCandidates(1L, TypeResult.INFLUENCER, Gender.MAN, 1L, PageRequest.of(0, 1))) + .thenReturn(Collections.emptyList()); + // 3순위 대상 있음 + when(matchingRepository.findMatchingCandidates(1L, TypeResult.NEWBIE, Gender.MAN, 1L, PageRequest.of(0, 1))) + .thenReturn(List.of(thirdPriorityCandidate)); - Optional result = matchingService.findBestCandidateByPriority(festival.getFestivalId(), applicant); + var result = matchingService.findBestCandidateByPriority(festival.getFestivalId(), applicant); assertThat(result).isPresent(); assertThat(result.get().getTypeResult()).isEqualTo(TypeResult.NEWBIE); @@ -208,12 +208,12 @@ void findBestCandidate_alreadyMatchedCandidate_empty() { ReflectionTestUtils.setField(applicant, "participantId", 1L); // 대상이 존재하나 이미 매칭됨 (Repository에서 필터링 됨) - when(matchingRepository.findMatchingCandidate(1L, TypeResult.PHOTO, Gender.MAN, 1L)) - .thenReturn(Optional.empty()); - when(matchingRepository.findMatchingCandidate(1L, TypeResult.INFLUENCER, Gender.MAN, 1L)) - .thenReturn(Optional.empty()); - when(matchingRepository.findMatchingCandidate(1L, TypeResult.NEWBIE, Gender.MAN, 1L)) - .thenReturn(Optional.empty()); + when(matchingRepository.findMatchingCandidates(1L, TypeResult.PHOTO, Gender.MAN, 1L, PageRequest.of(0, 1))) + .thenReturn(Collections.emptyList()); + when(matchingRepository.findMatchingCandidates(1L, TypeResult.INFLUENCER, Gender.MAN, 1L, PageRequest.of(0, 1))) + .thenReturn(Collections.emptyList()); + when(matchingRepository.findMatchingCandidates(1L, TypeResult.NEWBIE, Gender.MAN, 1L, PageRequest.of(0, 1))) + .thenReturn(Collections.emptyList()); Optional result = matchingService.findBestCandidateByPriority(festival.getFestivalId(), applicant); @@ -232,9 +232,9 @@ void findBestCandidateByPriority_empty() { .festival(festival) .build(); - when(matchingRepository.findMatchingCandidate( - applicant.getParticipantId(), TypeResult.PHOTO, Gender.MAN, festival.getFestivalId() - )).thenReturn(Optional.empty()); + when(matchingRepository.findMatchingCandidates( + applicant.getParticipantId(), TypeResult.PHOTO, Gender.MAN, festival.getFestivalId(), PageRequest.of(0, 1) + )).thenReturn(Collections.emptyList()); Optional result = matchingService.findBestCandidateByPriority( festival.getFestivalId(), applicant @@ -255,8 +255,10 @@ void getMatchingDetail_invalidFestival_throwsException() { Matching mismatchedMatching = Matching.builder() .festival(otherFestival) .applicantParticipant(participant) - .targetParticipant(null) - .status(MatchingStatus.PENDING) + .targetParticipant(Participant.builder() + .user(mockUser("상대방", Gender.WOMAN, 3L)) + .build()) + .status(MatchingStatus.COMPLETED) .matchDate(LocalDateTime.now()) .build(); @@ -270,4 +272,88 @@ void getMatchingDetail_invalidFestival_throwsException() { .isInstanceOf(FestimateException.class) .hasMessage(ResponseError.FORBIDDEN_RESOURCE.getMessage()); } + + @Test + @DisplayName("매칭 상세 조회 - 보류 중인 매칭을 조회할 경우 예외 발생") + void getMatchingDetail_pendingMatching_throwsException() { + // given + User user = mockUser("사용자", Gender.MAN, 1L); + Festival requestedFestival = mockFestival(user, 1L, LocalDate.now().minusDays(1), LocalDate.now().plusDays(1)); + Festival otherFestival = mockFestival(user, 2L, LocalDate.now().minusDays(1), LocalDate.now().plusDays(1)); + + Participant participant = mockParticipant(user, requestedFestival, TypeResult.INFLUENCER, 1L); + Matching mismatchedMatching = Matching.builder() + .festival(otherFestival) + .applicantParticipant(participant) + .targetParticipant(null) + .status(MatchingStatus.COMPLETED) + .matchDate(LocalDateTime.now()) + .build(); + + when(userService.getUserByIdOrThrow(user.getUserId())).thenReturn(user); + when(festivalService.getFestivalByIdOrThrow(requestedFestival.getFestivalId())).thenReturn(requestedFestival); + when(participantService.getParticipantOrThrow(user, requestedFestival)).thenReturn(participant); + when(matchingRepository.findByMatchingId(1L)).thenReturn(Optional.of(mismatchedMatching)); + + // when & then + assertThatThrownBy(() -> matchingService.getMatchingDetail(user.getUserId(), requestedFestival.getFestivalId(), 1L)) + .isInstanceOf(FestimateException.class) + .hasMessage(ResponseError.TARGET_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("매칭 상세 조회 - 없는 매칭 ID를 조회할 경우 예외 발생") + void getMatchingDetail_nonExistentMatchingId_throwsException() { + // given + User user = mockUser("사용자", Gender.MAN, 1L); + Festival festival = mockFestival(user, 1L, LocalDate.now().minusDays(1), LocalDate.now().plusDays(1)); + Participant participant = mockParticipant(user, festival, TypeResult.INFLUENCER, 1L); + Matching mismatchedMatching = Matching.builder() + .festival(festival) + .applicantParticipant(participant) + .targetParticipant(Participant.builder() + .user(mockUser("상대방", Gender.WOMAN, 3L)) + .build()) + .status(MatchingStatus.COMPLETED) + .matchDate(LocalDateTime.now()) + .build(); + + when(userService.getUserByIdOrThrow(user.getUserId())).thenReturn(user); + when(festivalService.getFestivalByIdOrThrow(festival.getFestivalId())).thenReturn(festival); + when(participantService.getParticipantOrThrow(user, festival)).thenReturn(participant); + when(matchingRepository.findByMatchingId(1L)) + .thenReturn(Optional.ofNullable(mismatchedMatching)); + + // when & then + assertThatThrownBy(() -> matchingService.getMatchingDetail(user.getUserId(), festival.getFestivalId(), 2L)) + .isInstanceOf(FestimateException.class) + .hasMessage(ResponseError.TARGET_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("우선순위 기반 매칭 - 후보가 2명 이상일 때 첫 번째만 선택") + void findBestCandidate_multipleCandidates_returnsFirstOnly() { + // given + User applicantUser = mockUser("신청자", Gender.MAN, 1L); + Festival festival = mockFestival(applicantUser, 1L, LocalDate.now().minusDays(1), LocalDate.now().plusDays(1)); + Participant applicant = mockParticipant(applicantUser, festival, TypeResult.INFLUENCER, 1L); + + User candidate1 = mockUser("후보1", Gender.WOMAN, 2L); + User candidate2 = mockUser("후보2", Gender.WOMAN, 3L); + Participant p1 = mockParticipant(candidate1, festival, TypeResult.PHOTO, 2L); + Participant p2 = mockParticipant(candidate2, festival, TypeResult.PHOTO, 3L); + + // 후보 2명 반환 + when(matchingRepository.findMatchingCandidates(1L, TypeResult.PHOTO, Gender.MAN, 1L, PageRequest.of(0, 1))) + .thenReturn(List.of(p1, p2)); + + // when + var result = matchingService.findBestCandidateByPriority(festival.getFestivalId(), applicant); + + // then + assertThat(result).isPresent(); + assertThat(result.get().getUser().getNickname()).isEqualTo("후보1"); + } + + }