Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -14,36 +15,31 @@
public interface MatchingRepository extends JpaRepository<Matching, Long> {
@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<Participant> findMatchingCandidate(
List<Participant> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -48,12 +49,12 @@ public MatchingStatusResponse createMatching(Long userId, Long festivalId) {
);

isMatchingDateValid(LocalDateTime.now(), festival.getMatchingStartAt());

pointService.usePoint(participant);

Optional<Participant> targetParticipantOptional = findBestCandidateByPriority(festivalId, participant);
Optional<Participant> 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());
}

Expand All @@ -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);
}
Expand All @@ -102,12 +106,14 @@ public Optional<Participant> findBestCandidateByPriority(long festivalId, Partic
Gender myGender = participant.getUser().getGender();

for (TypeResult priorityType : priorities) {
Optional<Participant> candidate = matchingRepository.findMatchingCandidate(
Optional<Participant> candidate = matchingRepository.findMatchingCandidates(
participant.getParticipantId(),
priorityType,
myGender,
festivalId
);
festivalId,
PageRequest.of(0, 1)
).stream().findFirst();

if (candidate.isPresent()) {
return candidate;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<Participant> result = matchingService.findBestCandidateByPriority(
festival.getFestivalId(), applicant
);
var result = matchingService.findBestCandidateByPriority(festival.getFestivalId(), applicant);

assertThat(result).isPresent();
assertThat(result.get().getTypeResult()).isEqualTo(TypeResult.PHOTO);
Expand All @@ -148,19 +147,20 @@ 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()
.user(secondPriorityUser)
.typeResult(TypeResult.INFLUENCER)
.festival(festival)
.build();
when(matchingRepository.findMatchingCandidate(1L, TypeResult.INFLUENCER, Gender.MAN, 1L))
.thenReturn(Optional.of(secondPriorityCandidate));

Optional<Participant> 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);
Expand All @@ -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<Participant> result = matchingService.findBestCandidateByPriority(festival.getFestivalId(), applicant);
var result = matchingService.findBestCandidateByPriority(festival.getFestivalId(), applicant);

assertThat(result).isPresent();
assertThat(result.get().getTypeResult()).isEqualTo(TypeResult.NEWBIE);
Expand All @@ -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<Participant> result = matchingService.findBestCandidateByPriority(festival.getFestivalId(), applicant);

Expand All @@ -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<Participant> result = matchingService.findBestCandidateByPriority(
festival.getFestivalId(), applicant
Expand All @@ -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();

Expand All @@ -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");
}


}