diff --git a/docs/meeting-lifecycle-fix.md b/docs/meeting-lifecycle-fix.md new file mode 100644 index 0000000..3bfa429 --- /dev/null +++ b/docs/meeting-lifecycle-fix.md @@ -0,0 +1,117 @@ +# 장소 투표 ~ 모임 확정 및 종료 로직 수정 + +> ### 브랜치: fix/#151 +> 장소 투표 ~ 모임 확정/종료 과정의 동시성 문제 해결 및 예외 상황 처리 개선 + +--- + +## 1. 요약 + +### 개요 +`동시 요청, 모임 나가기, 배치 실패` 상황에서 장소 투표 → 모임 확정 → 모임 종료로 이어지는 상태 전환이 틀어지는 상황 발생 + +```mermaid +flowchart LR + V["VOTING
장소 투표 중"] -->|"마지막 장소 투표
마지막 미투표자 나가기
투표 마감 배치 (자정)"| C["CONFIRMED
모임 확정"] + C -->|"모임 종료 배치 (자정, 서버 시작 시)"| D["COMPLETED
모임 종료"] +``` + +### 문제 +| 문제 | 대표 상황 | 원인 | 해결 | +|---|-----------------------------------------------------------------------|----------------------------------------------------|-------------------------------------------------------| +| **① 동시 요청 충돌** | 마지막 두 명 동시 투표 시 확정 누락, 확정 중 모임 참여/나가기 시 확정 취소, 배치와 겹치면 이중 확정 및 변경 덮어씀 | 잠금 없이 읽은 모임 데이터로 판단 및 저장함(Write Skew, Lost Update) | 모임 단위 비관적 락으로 같은 모임 요청 순차 처리, 배치는 상태 재확인 및 조건부 UPDATE | +| **② 모임 나가기 처리 누락** | 나간 참여자의 표가 확정에 반영됨, 확정 시점 어긋남, 마지막 미투표자가 나가도 자정까지 미확정됨 | 참여자 기준이 쿼리마다 다르고, 확정 판단이 장소 투표 로직에만 있음 | '현재 참여 중인 투표 가능 참여자' 기준 통일 + 모임 나가기 시 모임 확정 가능 여부 재확인 | +| **③ 배치 실패 전파** | 모임 하나 실패 시 전체 롤백, 투표 마감 배치 실패 시 모임 종료 배치 미실행됨 | 여러 모임을 한 트랜잭션에서 처리함 | 투표 마감/모임 종료 처리 분리, 모임별 트랜잭션 분리 | + +### 수정 후 플로우 +```text + [장소 투표 API] [모임 나가기 API] [모임 참여 API] + │ │ │ + ▼ ▼ ▼ + findByIdForUpdate findByIdForUpdate findByInviteCodeForUpdate ← Meeting 행 락 (같은 모임 요청은 순서대로) + │ │ │ + 투표 저장 나가기 처리 참여 처리 (투표 중이면 NEW_RESTRICTED) + │ │ + └──────────┬─────────────┘ + ▼ + confirmMeetingIfAllVoted(meeting) (모임 확정 가능 여부 판단) + - VOTING 상태인가? & 장소 추천 완료되었는가? + - 투표 가능 참여자 중 미투표자 = 0 ? + │ 예 + ▼ + confirmMeeting(meeting) (모임 확정) + 1) 일정 미정인 경우: 일정 투표 1위 항목으로 일정 확정 (나간 참여자 표 제외) + 2) 장소 투표 1위 항목으로 장소 확정 (나간 참여자 표 제외) + 3) CONFIRMED + 참여자 확정 + 확정 알림 + +[스케줄러: 매일 자정 실행] + ① 장소 투표 마감 (전체 트랜잭션 없음) + └ 마감 대상 모임마다 별도의 트랜잭션으로 처리 (실패한 모임만 롤백) + : 모임 행 잠금 → VOTING 상태 재확인 → 모임 확정 처리 + ② 모임 종료 (조건부 UPDATE 한 번) + └ UPDATE meetings SET status=COMPLETED, updated_at=? WHERE status=CONFIRMED AND scheduled_date < 오늘 +``` + +--- + +## 2. 커밋 내역 + +### `f379b6a`: 장소 투표 동시성 문제 해결 +| 항목 | 내용 | +|---|------------------------------------------------------------------------------------------------------------------------------------------------| +| 문제 | - 마지막 두 명이 동시에 투표 → 둘 다 '남은 사람 있음'으로 판단해 확정 누락
- 같은 사람 연속 요청 → 투표 중복 저장 | +| 원인 | 남은 인원 및 기투표 확인과 저장 사이에 잠금 없음 | +| 해결 | `모임 행 비관적 락(SELECT ... FOR UPDATE)` 으로 같은 모임의 투표 요청은 순차적으로 처리 | +| 검토 대안 | - 낙관적 락, SERIALIZABLE: 동시에 투표하면 한쪽 투표를 실패시키는 방식이라, 정상 투표가 에러를 받지 않도록 재시도 로직이 따로 필요함
- 커밋 후 재판정: 투표 저장 후 확정 판단 전에 서버가 중단되면 마감 배치까지 확정이 지연됨 | + +### `84c7325`: 투표한 참여자가 나갈 때 모임 확정 시점이 어긋나는 문제 해결 +| 항목 | 내용 | +|---|---------------------------------------------------------------| +| 문제 | 투표한 참여자가 나갈 경우 남은 사람이 투표하기 전에 모임 확정됨, 전원 투표해도 모임 확정 안됨 | +| 원인 | `투표한 사람 수(나간 사람 포함) == 투표 가능 인원(나간 사람 제외)`으로 판단 (서로 다른 집합 비교) | +| 해결 | `투표 가능 참여자 중 투표 기록 없는 사람 = 0`으로 판단 (한 집합만 세는 `NOT EXISTS` 쿼리) | + +### `2f02d9f`: 모임 확정 중 모임 참여/나가기 시 확정 취소 문제 해결 +| 항목 | 내용 | +|---|----------------------------------------------------------------------------------------------------------------------------------------------------------| +| 문제 | 마지막 투표로 확정되는 순간 모임 참여/나가기 → 확정된 모임이 VOTING 상태로 되돌아감 | +| 원인 | 잠금 없이 읽은 이전 모임 데이터를 저장 → JPA 변경 감지가 모든 컬럼을 옛 값으로 덮어씀 (Lost Update) | +| 해결 | 모임 참여/나가기에도 `같은 모임 행 비관적 락` 조회 적용 (조회 방식만 교체) | +| 검토 대안 | - @DynamicUpdate: 바뀐 컬럼만 저장하지만, 두 요청이 같은 컬럼(상태)을 고치는 경합은 못 막음
- 원자적 UPDATE 쿼리: 모임장 승계 등 관련 로직 추가 변경 필요
- 낙관적 락: 충돌한 참여/나가기 요청이 실패해 재시도 처리가 필요함 | + +### `cef14e2`: 일정 미정 모임의 일정 확정 시점 변경 (일정 투표 현황 조회 API → 모임 확정) +| 항목 | 내용 | +|---|-------------------------------------------------------------------------------| +| 문제 | - 일정 투표 현황 조회(GET) 시 일정 확정 → 아무도 조회하지 않으면 일정 없이 확정됨
- 조회 API에서 쓰기 작업이 발생함 | +| 원인 | 조회 API가 상태 변경(일정 확정)을 진행함 | +| 해결 | 모임 확정 시점에 일정과 장소를 한 번에 확정 (일정 투표 현황 조회 API는 일정 투표 내역 집계만 담당) | + +### `0c367d7`: 장소 투표 집계에서 나간 참여자 표 제외 +| 항목 | 내용 | +|---|------------------------------------------------------------------------------------------------------------------| +| 문제 | 장소 투표 현황(투표 인원, 득표수)과 확정 장소에 나간 참여자의 표가 반영됨 | +| 원인 | 집계 쿼리가 참여자가 나갔는지 보지 않음 | +| 해결 | 집계 쿼리에서 참여자 테이블과 조인해 현재 참여 중인 사람의 표만 집계 | + +### `c85dfb5`: 마지막 미투표자가 나가면 즉시 모임 확정 처리 +| 항목 | 내용 | +|---|--------------------------------------------------------------------------------------------------------------------------------------------------------| +| 문제 | 마지막 미투표자가 나가 남은 전원이 투표 완료 상태가 돼도 자정 마감 배치까지 모임 확정 안됨 | +| 원인 | 확정 판단이 장소 투표 로직에만 존재함 | +| 해결 | - 모임 확정 처리를 한 곳으로 모으고 모임 나가기 직후 같은 트랜잭션, 같은 잠금 안에서 판단
- 판단 조건을 '장소 추천이 끝난 VOTING'으로 통일 (투표, 모임 나가기, 배치 공통) | +| 검토 대안 | - 나가기 커밋 후 이벤트로 판단: 트랜잭션이 둘로 나뉘어 구조가 복잡해짐
- 마감 배치가 처리: 최대 하루 확정 지연이 그대로 남음 | + +### `91e1297`: 장소 투표 마감 배치 실패 격리 및 이중 확정 방지 +| 항목 | 내용 | +|---|--------------------------------------------------------------------| +| 문제 | - 모임 하나 실패 시 전체가 롤백됨
- 배치와 사용자의 마지막 투표가 겹치면 이중 확정 및 덮어쓰기 문제 발생 | +| 원인 | 모든 모임을 한 트랜잭션에서 잠금 없이 처리함 | +| 해결 | `모임별 트랜잭션 분리(REQUIRES_NEW)` + 처리 직전 모임 행 잠금 후 VOTING 상태인지 재확인 | + +### `3a8e672`: 모임 종료 배치 실패 격리 및 덮어쓰기 문제 해결 +| 항목 | 내용 | +|---|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| 문제 | - 모임 종료 중 모임을 나갈 경우 인원, 모임장 변경이 덮어써짐
- 투표 마감 배치 실패 시 모임 종료 배치 미실행 | +| 원인 | - 엔티티를 읽어 상태 변경 후 통째로 저장
- 장소 투표 마감/모임 종료 처리를 한 try에서 실행함 | +| 해결 | - 모임 종료: 조건부 UPDATE 한 번 (`status=CONFIRMED AND scheduled_date<오늘` 인 행의 상태만 변경 → 덮어쓰기 없음, 여러 번 실행해도 결과 동일), 실패 시 재시도(총 3번)
- 스케줄러 단계 분리: 투표 마감/모임 종료를 각각 따로 예외 처리
- 서버 시작 시 모임 종료 배치 실행 (자정에 서버가 꺼져 있었어도 밀린 모임 종료 처리) | +| 검토 대안 | - 대상 모임을 잠금으로 한 번에 조회: 여러 모임을 동시에 잠가 다른 요청과 잠금 순서가 엇갈리면 데드락 발생 가능
- 모임별 트랜잭션: 상태 값 하나만 바꾸는 작업에 비해 무거움 | diff --git a/src/main/java/com/moive/MoiveBE/domain/meeting/repository/MeetingRepository.java b/src/main/java/com/moive/MoiveBE/domain/meeting/repository/MeetingRepository.java index 114f288..d301f32 100644 --- a/src/main/java/com/moive/MoiveBE/domain/meeting/repository/MeetingRepository.java +++ b/src/main/java/com/moive/MoiveBE/domain/meeting/repository/MeetingRepository.java @@ -2,9 +2,15 @@ import com.moive.MoiveBE.domain.meeting.entity.Meeting; import com.moive.MoiveBE.domain.meeting.entity.MeetingStatus; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -17,4 +23,28 @@ public interface MeetingRepository extends JpaRepository { List findAllByStatus(MeetingStatus status); List findAllByStatusAndScheduledDateBefore(MeetingStatus status, LocalDate date); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select m from Meeting m where m.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select m from Meeting m where m.inviteCode = :inviteCode") + Optional findByInviteCodeForUpdate(@Param("inviteCode") String inviteCode); + + // 모임 일정이 지난 모임의 상태 일괄 변경 (CONFIRMED -> COMPLETED) + @Modifying + @Query(""" + update Meeting m + set m.status = :toStatus, + m.updatedAt = :updatedAt + where m.status = :fromStatus + and m.scheduledDate < :date + """) + int updateStatusByStatusAndScheduledDateBefore( + @Param("fromStatus") MeetingStatus fromStatus, + @Param("toStatus") MeetingStatus toStatus, + @Param("date") LocalDate date, + @Param("updatedAt") LocalDateTime updatedAt + ); } \ No newline at end of file diff --git a/src/main/java/com/moive/MoiveBE/domain/meeting/repository/ParticipantRepository.java b/src/main/java/com/moive/MoiveBE/domain/meeting/repository/ParticipantRepository.java index ded7124..1c377d1 100644 --- a/src/main/java/com/moive/MoiveBE/domain/meeting/repository/ParticipantRepository.java +++ b/src/main/java/com/moive/MoiveBE/domain/meeting/repository/ParticipantRepository.java @@ -3,6 +3,8 @@ import com.moive.MoiveBE.domain.meeting.entity.Participant; import com.moive.MoiveBE.domain.meeting.entity.ParticipantState; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -22,4 +24,25 @@ public interface ParticipantRepository extends JpaRepository List findAllByMeetingIdAndLeftAtIsNull(Long meetingId); long countByMeetingIdAndLeftAtIsNullAndStateNot(Long meetingId, ParticipantState state); + + // 장소 투표를 아직 하지 않은 투표 가능 참여자 수 카운트 + // - 투표 가능 참여자: 모임을 나가지 않았고, 신규 참여(NEW_RESTRICTED)가 아닌 참여자 + // - 투표 여부: 장소 투표 내역 존재 여부로 판단 + @Query(""" + select count(p) + from Participant p + where p.meetingId = :meetingId + and p.leftAt is null + and p.state <> :excludedState + and not exists ( + select 1 + from PlaceVote pv + where pv.meetingId = p.meetingId + and pv.participantId = p.id + ) + """) + long countParticipantsYetToVote( + @Param("meetingId") Long meetingId, + @Param("excludedState") ParticipantState excludedState + ); } diff --git a/src/main/java/com/moive/MoiveBE/domain/meeting/service/MeetingLeaveService.java b/src/main/java/com/moive/MoiveBE/domain/meeting/service/MeetingLeaveService.java index 77fb5b3..eac0763 100644 --- a/src/main/java/com/moive/MoiveBE/domain/meeting/service/MeetingLeaveService.java +++ b/src/main/java/com/moive/MoiveBE/domain/meeting/service/MeetingLeaveService.java @@ -5,6 +5,7 @@ import com.moive.MoiveBE.domain.meeting.entity.Participant; import com.moive.MoiveBE.domain.meeting.repository.MeetingRepository; import com.moive.MoiveBE.domain.meeting.repository.ParticipantRepository; +import com.moive.MoiveBE.domain.vote.service.VoteService; import com.moive.MoiveBE.global.exception.CustomErrorCode; import com.moive.MoiveBE.global.exception.CustomException; import lombok.RequiredArgsConstructor; @@ -21,11 +22,13 @@ public class MeetingLeaveService { private final MeetingRepository meetingRepository; private final ParticipantRepository participantRepository; + private final MeetingService meetingService; + private final VoteService voteService; public void leaveMeeting(Long meetingId) { Long currentUserId = getCurrentUserId(); - Meeting meeting = meetingRepository.findById(meetingId) + Meeting meeting = meetingRepository.findByIdForUpdate(meetingId) .orElseThrow(() -> new CustomException(CustomErrorCode.MEETING_NOT_FOUND)); if (meeting.getStatus() == MeetingStatus.COMPLETED) { @@ -60,6 +63,14 @@ public void leaveMeeting(Long meetingId) { myParticipant.leave(); meeting.decrementParticipantCnt(); if (myParticipant.isConditionCompleted()) meeting.decrementSubmittedCnt(); + + // 조건 입력 단계에서 아직 조건을 제출하지 않은 참여자가 나가며 남은 참여자가 모두 제출을 마친 상태가 되는 경우 + // => 모임 상태 전환 & 추천 트리거 + meetingService.startVotingIfAllSubmitted(meeting); + + // 장소 투표 단계에서 아직 투표하지 않은 참여자가 나가며 남은 참여자가 모두 투표를 마친 상태가 되는 경우 + // => 모임 확정 + voteService.confirmMeetingIfAllVoted(meeting); } private Long getCurrentUserId() { diff --git a/src/main/java/com/moive/MoiveBE/domain/meeting/service/MeetingService.java b/src/main/java/com/moive/MoiveBE/domain/meeting/service/MeetingService.java index ae4092d..009fbb4 100644 --- a/src/main/java/com/moive/MoiveBE/domain/meeting/service/MeetingService.java +++ b/src/main/java/com/moive/MoiveBE/domain/meeting/service/MeetingService.java @@ -21,6 +21,7 @@ import java.security.SecureRandom; import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.LocalTime; import java.util.List; import java.util.Optional; @@ -130,7 +131,7 @@ public CreateMeetingResponse createMeeting(CreateMeetingRequest request) { public JoinMeetingResponse joinMeeting(String inviteCode) { Long userId = getCurrentUserId(); - Meeting meeting = meetingRepository.findByInviteCode(inviteCode) + Meeting meeting = meetingRepository.findByInviteCodeForUpdate(inviteCode) .orElseThrow(() -> new CustomException(CustomErrorCode.INVALID_INVITE_CODE)); if (meeting.getStatus() == MeetingStatus.COMPLETED) { @@ -174,7 +175,7 @@ public JoinMeetingResponse joinMeeting(String inviteCode) { public SubmitPreferenceResponse submitPreference(Long meetingId, SubmitPreferenceRequest request) { Long userId = getCurrentUserId(); - Meeting meeting = meetingRepository.findById(meetingId) + Meeting meeting = meetingRepository.findByIdForUpdate(meetingId) .orElseThrow(() -> new CustomException(CustomErrorCode.MEETING_NOT_FOUND)); if (meeting.getStatus() != MeetingStatus.CONDITION_INPUT) { @@ -261,15 +262,7 @@ public SubmitPreferenceResponse submitPreference(Long meetingId, SubmitPreferenc } // 전원 완료 시 모임 상태 전환 및 추천 트리거 - boolean triggered = false; - if (isFirstSubmit && meeting.getSubmittedCnt() >= meeting.getParticipantCnt()) { - meeting.transitionToVoting(); - triggered = true; - eventPublisher.publishEvent( - new AreaRecommendationRequestedEvent(meetingId) - ); - - } + boolean triggered = isFirstSubmit && startVotingIfAllSubmitted(meeting); return SubmitPreferenceResponse.of( meetingId, @@ -280,19 +273,31 @@ public SubmitPreferenceResponse submitPreference(Long meetingId, SubmitPreferenc ); } + public boolean startVotingIfAllSubmitted(Meeting meeting) { + if (meeting.getStatus() != MeetingStatus.CONDITION_INPUT + || meeting.getSubmittedCnt() < meeting.getParticipantCnt()) { + return false; + } + + meeting.transitionToVoting(); + eventPublisher.publishEvent( + new AreaRecommendationRequestedEvent(meeting.getId()) + ); + return true; + } + /** * 모임 일정이 지난 확정된 모임을 종료 처리 (CONFIRMED -> COMPLETED) * - 날짜 단위로 판단 (scheduledDate < 오늘) - * - 매일 MeetingLifecycleScheduler에서 호출됨 + * - 매일 MeetingLifecycleScheduler에서 호출됨 (서버 시작 시에도 1회 호출) */ - public void completeElapsedMeetings() { - List elapsedMeetings = meetingRepository - .findAllByStatusAndScheduledDateBefore(MeetingStatus.CONFIRMED, LocalDate.now()); + public int completeElapsedMeetings() { + LocalDate today = LocalDate.now(); + int completedCnt = meetingRepository.updateStatusByStatusAndScheduledDateBefore( + MeetingStatus.CONFIRMED, MeetingStatus.COMPLETED, today, LocalDateTime.now()); - for (Meeting meeting : elapsedMeetings) { - meeting.complete(); - log.info("[모임 종료 처리] 모임 일정 경과로 자동 종료 (meetingId={})", meeting.getId()); - } + log.info("[모임 종료 배치] 완료 - 종료 {}건 (기준: 모임 일정이 {} 이전인 확정 모임)", completedCnt, today); + return completedCnt; } private Long getCurrentUserId() { diff --git a/src/main/java/com/moive/MoiveBE/domain/vote/repository/PlaceVoteRepository.java b/src/main/java/com/moive/MoiveBE/domain/vote/repository/PlaceVoteRepository.java index 237b32b..643e3ea 100644 --- a/src/main/java/com/moive/MoiveBE/domain/vote/repository/PlaceVoteRepository.java +++ b/src/main/java/com/moive/MoiveBE/domain/vote/repository/PlaceVoteRepository.java @@ -12,18 +12,23 @@ public interface PlaceVoteRepository extends JpaRepository { boolean existsByMeetingIdAndParticipantId(Long meetingId, Long participantId); + // 장소 투표 참여 인원 수 + // - 모임을 나간 참여자의 투표 제외 @Query(""" select count(distinct pv.participantId) from PlaceVote pv + join Participant p on pv.participantId = p.id where pv.meetingId = :meetingId + and p.leftAt is null """) long countDistinctVoters(@Param("meetingId") Long meetingId); // 장소별 득표 집계 + // - 모임을 나간 참여자의 투표는 제외 // - 서로 다른 recommended_place_id라도 google_place_id(실제 장소)가 같으면 하나로 병합해서 집계 - // - recommendedPlaceId : 병합된 그룹을 대표하는 id (같은 google_place_id 중 가장 작은 recommended_place_id) - // - voterCnt : 해당 장소(googlePlaceId 기준)에 투표한 서로 다른 참여자 수 - // - votedByMe : 조회 유저가 해당 장소에 투표했으면 1 + // - recommendedPlaceId: 병합된 그룹을 대표하는 ID (같은 google_place_id 중 가장 작은 recommended_place_id) + // - voterCnt: 해당 장소(googlePlaceId 기준)에 투표한 서로 다른 참여자 수 + // - votedByMe: 조회 유저가 해당 장소에 투표했으면 1 @Query(""" select new com.moive.MoiveBE.domain.vote.dto.PlaceVoteSummary( min(rp.id), @@ -32,7 +37,9 @@ select count(distinct pv.participantId) ) from PlaceVote pv join RecommendedPlace rp on pv.recommendedPlaceId = rp.id + join Participant p on pv.participantId = p.id where pv.meetingId = :meetingId + and p.leftAt is null group by rp.googlePlaceId """) List aggregateByPlace( diff --git a/src/main/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteCloseService.java b/src/main/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteCloseService.java new file mode 100644 index 0000000..2535904 --- /dev/null +++ b/src/main/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteCloseService.java @@ -0,0 +1,98 @@ +package com.moive.MoiveBE.domain.vote.service; + +import com.moive.MoiveBE.domain.meeting.entity.Meeting; +import com.moive.MoiveBE.domain.meeting.entity.MeetingStatus; +import com.moive.MoiveBE.domain.meeting.repository.MeetingRepository; +import com.moive.MoiveBE.domain.recommendation.entity.RecommendationRun; +import com.moive.MoiveBE.domain.recommendation.entity.RecommendationStatus; +import com.moive.MoiveBE.domain.recommendation.repository.RecommendationRunRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 장소 투표 마감 배치 + * - 장소 투표 마감 기한이 지났는데도 전원이 투표를 마치지 못한 모임 확정 + * - 매일 MeetingLifecycleScheduler에서 호출됨 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class PlaceVoteCloseService { + + private final MeetingRepository meetingRepository; + private final RecommendationRunRepository recommendationRunRepository; + private final VoteService voteService; + + @Value("${place-vote.deadline-days}") + private int placeVoteDeadlineDays; + + // 트랜잭션 처리 x: 모임별 트랜잭션(VoteService.finalizeExpiredPlaceVote)으로 실패를 격리하기 위함 + public void finalizeExpiredPlaceVotes() { + long startMillis = System.currentTimeMillis(); + LocalDateTime deadline = LocalDateTime.now().minusDays(placeVoteDeadlineDays); + + List targetMeetingIds = findExpiredVotingMeetingIds(deadline); + log.info("[장소 투표 마감 배치] 시작 - 대상 {}건 (마감 기준 시각={}, 대상 meetingId={})", + targetMeetingIds.size(), deadline, targetMeetingIds); + + int confirmedCnt = 0; + int skippedCnt = 0; + List failedMeetingIds = new ArrayList<>(); + + for (Long meetingId : targetMeetingIds) { + try { + if (voteService.finalizeExpiredPlaceVote(meetingId)) { + confirmedCnt++; + } else { + skippedCnt++; + } + } catch (Exception e) { + failedMeetingIds.add(meetingId); + log.error("[장소 투표 마감 배치] 모임 확정 실패 - 다음 실행에서 재시도 예정 (meetingId={})", meetingId, e); + } + } + + long elapsedMillis = System.currentTimeMillis() - startMillis; + if (failedMeetingIds.isEmpty()) { + log.info("[장소 투표 마감 배치] 완료 - 대상 {}건, 확정 {}건, 건너뜀 {}건, 실패 0건, 소요 {}ms", + targetMeetingIds.size(), confirmedCnt, skippedCnt, elapsedMillis); + } else { + log.warn("[장소 투표 마감 배치] 완료(실패 있음) - 대상 {}건, 확정 {}건, 건너뜀 {}건, 실패 {}건 (실패 meetingId={}), 소요 {}ms", + targetMeetingIds.size(), confirmedCnt, skippedCnt, failedMeetingIds.size(), failedMeetingIds, elapsedMillis); + } + } + + // 마감 기한이 지난 장소 투표 진행 중(VOTING) 모임 ID 목록 + private List findExpiredVotingMeetingIds(LocalDateTime deadline) { + List votingMeetingIds = meetingRepository.findAllByStatus(MeetingStatus.VOTING).stream() + .map(Meeting::getId) + .toList(); + if (votingMeetingIds.isEmpty()) { + return List.of(); + } + + Map placeVoteStartedAtByMeetingId = recommendationRunRepository + .findAllByMeetingIdInAndStatus(votingMeetingIds, RecommendationStatus.COMPLETED) + .stream() + .collect(Collectors.toMap( + RecommendationRun::getMeetingId, + RecommendationRun::getUpdatedAt, + (earlier, later) -> earlier.isAfter(later) ? earlier : later + )); + + return votingMeetingIds.stream() + .filter(meetingId -> { + LocalDateTime placeVoteStartedAt = placeVoteStartedAtByMeetingId.get(meetingId); + return placeVoteStartedAt != null && !placeVoteStartedAt.isAfter(deadline); + }) + .toList(); + } +} diff --git a/src/main/java/com/moive/MoiveBE/domain/vote/service/VoteService.java b/src/main/java/com/moive/MoiveBE/domain/vote/service/VoteService.java index c061669..7cc7d65 100644 --- a/src/main/java/com/moive/MoiveBE/domain/vote/service/VoteService.java +++ b/src/main/java/com/moive/MoiveBE/domain/vote/service/VoteService.java @@ -28,12 +28,11 @@ import com.moive.MoiveBE.global.exception.CustomException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; import java.util.Comparator; import java.util.HashSet; import java.util.List; @@ -66,14 +65,9 @@ public class VoteService { private final AreaDistanceService areaDistanceService; private final NotificationService notificationService; - @Value("${place-vote.deadline-days}") - private int placeVoteDeadlineDays; - /** * 일정 투표 현황 조회 - * - 모임 생성 시 일정을 확정하지 않은 경우, 일정 투표 기반으로 모임 일정 확정 */ - @Transactional public DateVoteResultResponse getMeetingScheduleVoteResult(Long userId, Long meetingId) { // 모임 조회 Meeting meeting = meetingRepository.findById(meetingId) @@ -113,9 +107,6 @@ public DateVoteResultResponse getMeetingScheduleVoteResult(Long userId, Long mee List topDates = dateVoteRepository .aggregateTopDates(meetingId, participant.getId(), validParticipantIds, PageRequest.of(0, TOP_N)); - // - 집계 결과 1위 일정을 모임 일정으로 확정 - confirmScheduleWithTopDate(meeting, topDates); - List candidates = topDates.stream() .map(this::toCandidate) .toList(); @@ -162,7 +153,7 @@ private DateVoteResultResponse.Candidate toCandidate(DateVoteSummary aggregate) @Transactional public void createPlaceVote(Long userId, Long meetingId, PlaceVoteRequest request) { // 모임 조회 - Meeting meeting = meetingRepository.findById(meetingId) + Meeting meeting = meetingRepository.findByIdForUpdate(meetingId) .orElseThrow(() -> new CustomException(MEETING_NOT_FOUND)); // 모임 진행 상황 검증 @@ -214,22 +205,47 @@ public void createPlaceVote(Long userId, Long meetingId, PlaceVoteRequest reques placeVoteRepository.saveAll(placeVotes); participant.completeVote(); - // 마지막 투표자인 경우 => 득표 집계 결과 1위 장소를 모임 장소로 확정 - // NEW_RESTRICTED(투표 불가 신규 참여자) 제외한 투표 가능 인원과 비교 - long voterCnt = placeVoteRepository.countDistinctVoters(meetingId); - long eligibleCnt = participantRepository.countByMeetingIdAndLeftAtIsNullAndStateNot( - meetingId, ParticipantState.NEW_RESTRICTED); - if (voterCnt == eligibleCnt) { - confirmMeetingPlace(meeting); + // 마지막 투표자인 경우 => 모임 일정 및 장소 확정 + confirmMeetingIfAllVoted(meeting); + } + + /** + * 투표 가능 참여자가 모두 장소 투표를 마친 경우 => 모임 확정 (일정, 장소) + * - 장소 투표(마지막 투표자), 모임 나가기(마지막 미투표자가 나간 경우)에서 공통으로 사용 + * - 장소 투표 단계(VOTING + 장소 추천 완료)에서만 판단 + * - 투표 가능 참여자(모임 나가지 않음 + 신규 참여(NEW_RESTRICTED) 제외) 중 미투표자가 없으면 확정 + * (투표 가능 참여자가 모두 나가 신규 참여자만 남은 경우도 확정 → 유효한 표가 없으면 장소는 null) + */ + @Transactional + public void confirmMeetingIfAllVoted(Meeting meeting) { + if (meeting.getStatus() != MeetingStatus.VOTING) { + return; + } + + boolean isRecommendationCompleted = recommendationRunRepository + .findTopByMeetingIdAndStatusOrderByCreatedAtDesc(meeting.getId(), RecommendationStatus.COMPLETED) + .isPresent(); + if (!isRecommendationCompleted) { + return; + } + + if (participantRepository.countParticipantsYetToVote(meeting.getId(), ParticipantState.NEW_RESTRICTED) == 0) { + confirmMeeting(meeting); } } - // 모임 장소 확정 - private void confirmMeetingPlace(Meeting meeting) { + // 모임 확정: 일정(미확정인 경우) 및 장소 확정 -> MeetingStatus.CONFIRMED로 전환 + private void confirmMeeting(Meeting meeting) { + // 일정 확정 + confirmScheduleIfNotConfirmed(meeting); + + // 장소 확정 List ranked = rankCandidates(meeting.getId(), NO_VIEWER_PARTICIPANT_ID); Long confirmedPlaceId = ranked.isEmpty() ? null : ranked.get(0).recommendedPlaceId(); meeting.confirmPlace(confirmedPlaceId); - log.info("[장소 투표] 마지막 투표자 완료 => 장소 확정 (meetingId={}, confirmedPlaceId={})", meeting.getConfirmedPlaceId(), confirmedPlaceId); + + log.info("[모임 확정] meetingId={}, scheduledDate={}, scheduledTime={}, confirmedPlaceId={}", + meeting.getId(), meeting.getScheduledDate(), meeting.getScheduledTime(), confirmedPlaceId); // NOTI-004: 장소 확정 알림 (전체 참여자) + 참여자 상태 CONFIRMED 업데이트 // 아무도 투표하지 않아 confirmedPlaceId가 없는 경우, 장소 확정 알림은 생략 @@ -245,42 +261,24 @@ private void confirmMeetingPlace(Meeting meeting) { } /** - * 장소 투표 마감 기한이 지났는데도 전원이 투표를 마치지 못한 모임 처리 - * - Case A) 1명 이상 투표했다면 그때까지의 집계 결과로 장소 확정 o + 모임 상태 CONFIRMED로 전환 - * - Case B) 아무도 투표하지 않았다면 장소 확정 x + 모임 상태 CONFIRMED로 전환 - * - 매일 MeetingLifecycleScheduler에서 호출됨 + * 장소 투표 마감 처리 + * - 장소 투표 마감 배치(PlaceVoteCloseService)에서 모임별로 호출 + * - Case A) 1명 이상 투표했다면 집계 1위 장소로 확정 + * - Case B) 아무도 투표하지 않았다면 장소 없이(null) 확정 */ - @Transactional - public void finalizeExpiredPlaceVotes() { - List votingMeetings = meetingRepository.findAllByStatus(MeetingStatus.VOTING); - if(votingMeetings.isEmpty()) { - return; - } - - // 모임별 장소 추천이 완료된 시각을 장소 투표 시작 시점으로 지정 - List meetingIds = votingMeetings.stream().map(Meeting::getId).toList(); - Map placeVoteStartedAtByMeetingId = recommendationRunRepository - .findAllByMeetingIdInAndStatus(meetingIds, RecommendationStatus.COMPLETED) - .stream() - .collect(Collectors.toMap( - RecommendationRun::getMeetingId, - RecommendationRun::getUpdatedAt, - (earlier, later) -> earlier.isAfter(later) ? earlier : later - )); - - LocalDateTime deadline = LocalDateTime.now().minusDays(placeVoteDeadlineDays); - - for (Meeting meeting : votingMeetings) { - LocalDateTime placeVoteStartedAt = placeVoteStartedAtByMeetingId.get(meeting.getId()); - - // 장소 투표 단계가 아니거나 마감 기한이 지나지 않았으면 스킵 - if(placeVoteStartedAt == null || placeVoteStartedAt.isAfter(deadline)) { - continue; - } + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean finalizeExpiredPlaceVote(Long meetingId) { + Meeting meeting = meetingRepository.findByIdForUpdate(meetingId) + .orElseThrow(() -> new CustomException(MEETING_NOT_FOUND)); - confirmScheduleIfNotConfirmed(meeting); - confirmMeetingPlace(meeting); + // 타겟 선정 후 락을 잡기 전까지 마지막 투표 및 모임 나가기로 확정(CONFIRMED)/종료(COMPLETED) 처리된 게 있는지 재확인 + if (meeting.getStatus() != MeetingStatus.VOTING) { + log.info("[장소 투표 마감 배치] 투표 진행 중(VOTING)이 아닌 모임 건너뜀 (meetingId={}, status={})", meetingId, meeting.getStatus()); + return false; } + + confirmMeeting(meeting); + return true; } // 일정 미확정 모임의 일정 확정 diff --git a/src/main/java/com/moive/MoiveBE/global/scheduler/MeetingLifecycleScheduler.java b/src/main/java/com/moive/MoiveBE/global/scheduler/MeetingLifecycleScheduler.java index 8a57eec..f0af3d6 100644 --- a/src/main/java/com/moive/MoiveBE/global/scheduler/MeetingLifecycleScheduler.java +++ b/src/main/java/com/moive/MoiveBE/global/scheduler/MeetingLifecycleScheduler.java @@ -1,9 +1,11 @@ package com.moive.MoiveBE.global.scheduler; import com.moive.MoiveBE.domain.meeting.service.MeetingService; -import com.moive.MoiveBE.domain.vote.service.VoteService; +import com.moive.MoiveBE.domain.vote.service.PlaceVoteCloseService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @@ -11,13 +13,18 @@ /** * 모임/투표 상태 처리 배치 + * - 매일 자정: 장소 투표 마감(VOTING -> CONFIRMED) → 모임 종료(CONFIRMED -> COMPLETED) */ @Component @RequiredArgsConstructor @Slf4j public class MeetingLifecycleScheduler { - private final VoteService voteService; + // 모임 종료 처리 최대 시도 횟수, 재시도 전 대기 시간(ms) + private static final int COMPLETE_MAX_ATTEMPTS = 3; + private long[] completeRetryBackoffMillis = {1_000L, 3_000L}; + + private final PlaceVoteCloseService placeVoteCloseService; private final MeetingService meetingService; @Scheduled(cron = "${meeting.daily-batch-cron}") @@ -27,15 +34,61 @@ public void run() { log.info("[모임/투표 배치] 시작 - 시작 시각: {}", startDateTime); try { - voteService.finalizeExpiredPlaceVotes(); - meetingService.completeElapsedMeetings(); - } catch (Exception e) { - log.error("[모임/투표 배치] 실행 중 오류 발생", e); - throw e; + // 장소 투표 마감 처리 + closeExpiredPlaceVotes(); + // 모임 종료 처리 + completeElapsedMeetings(); } finally { LocalDateTime endTime = LocalDateTime.now(); long duration = System.currentTimeMillis() - startMillis; log.info("[모임/투표 배치] 종료 - 종료 시각: {}, 소요 시간: {}ms", endTime, duration); } } + + // 장소 투표 마감 처리 + private void closeExpiredPlaceVotes() { + try { + placeVoteCloseService.finalizeExpiredPlaceVotes(); + } catch (Exception e) { + log.error("[모임/투표 배치] 장소 투표 마감 처리 실패", e); + } + } + + // 모임 종료 처리 + private void completeElapsedMeetings() { + for (int attempt = 1; attempt <= COMPLETE_MAX_ATTEMPTS; attempt++) { + try { + meetingService.completeElapsedMeetings(); + return; + } catch (Exception e) { + if (attempt == COMPLETE_MAX_ATTEMPTS) { + log.error("[모임/투표 배치] 모임 종료 처리 최종 실패 ({}/{}회 시도)", + attempt, COMPLETE_MAX_ATTEMPTS, e); + return; + } + log.warn("[모임/투표 배치] 모임 종료 처리 실패 ({}/{}회 시도, 재시도 예정)", attempt, COMPLETE_MAX_ATTEMPTS, e); + if (!sleepBeforeRetry(completeRetryBackoffMillis[attempt - 1])) { + return; + } + } + } + } + + // 서버 시작 시 모임 종료 처리 실행 + @EventListener(ApplicationReadyEvent.class) + public void completeElapsedMeetingsOnStartup() { + log.info("[모임/투표 배치] 서버 시작 - 누락된 모임 종료 처리 확인"); + completeElapsedMeetings(); + } + + private boolean sleepBeforeRetry(long millis) { + try { + Thread.sleep(millis); + return true; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("[모임/투표 배치] 모임 종료 처리 재시도 대기 중 중단됨"); + return false; + } + } } diff --git a/src/test/java/com/moive/MoiveBE/domain/meeting/repository/MeetingRepositoryTest.java b/src/test/java/com/moive/MoiveBE/domain/meeting/repository/MeetingRepositoryTest.java new file mode 100644 index 0000000..aaaa095 --- /dev/null +++ b/src/test/java/com/moive/MoiveBE/domain/meeting/repository/MeetingRepositoryTest.java @@ -0,0 +1,80 @@ +package com.moive.MoiveBE.domain.meeting.repository; + +import com.moive.MoiveBE.domain.meeting.entity.Meeting; +import com.moive.MoiveBE.domain.meeting.entity.MeetingStatus; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager; +import org.springframework.test.context.TestPropertySource; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@TestPropertySource(properties = { + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.sql.init.mode=never" +}) +class MeetingRepositoryTest { + + @Autowired + private MeetingRepository meetingRepository; + + @Autowired + private TestEntityManager em; + + private static final LocalDate TODAY = LocalDate.of(2026, 10, 10); + + /** + * updateStatusByStatusAndScheduledDateBefore (모임 종료 배치) + */ + + @Test + void 모임_일정이_기준_날짜_이전인_확정_모임만_상태를_변경한다() { + // given + Long elapsed = meeting(MeetingStatus.CONFIRMED, TODAY.minusDays(1)); // 어제 일정 + Long today = meeting(MeetingStatus.CONFIRMED, TODAY); // 오늘 일정은 아직 종료 x + Long voting = meeting(MeetingStatus.VOTING, TODAY.minusDays(1)); // 투표 중인 모임은 대상 x + Long noSchedule = meeting(MeetingStatus.CONFIRMED, null); // 일정 없이 확정된 모임은 대상 x + + LocalDateTime updatedAt = LocalDateTime.of(2026, 10, 10, 0, 0); + + // when + int updated = meetingRepository.updateStatusByStatusAndScheduledDateBefore( + MeetingStatus.CONFIRMED, MeetingStatus.COMPLETED, TODAY, updatedAt); + em.clear(); + + // then + assertThat(updated).isEqualTo(1); + assertThat(statusOf(elapsed)).isEqualTo(MeetingStatus.COMPLETED); + + // 벌크 UPDATE는 @PreUpdate를 거치지 않으므로 쿼리에서 직접 갱신한 updatedAt이 반영되어야 함 + assertThat(meetingRepository.findById(elapsed).orElseThrow().getUpdatedAt()).isEqualTo(updatedAt); + assertThat(statusOf(today)).isEqualTo(MeetingStatus.CONFIRMED); + assertThat(statusOf(voting)).isEqualTo(MeetingStatus.VOTING); + assertThat(statusOf(noSchedule)).isEqualTo(MeetingStatus.CONFIRMED); + } + + private Long meeting(MeetingStatus status, LocalDate scheduledDate) { + Meeting meeting = Meeting.create(1L, "테스트 모임", scheduledDate, + scheduledDate != null ? LocalTime.NOON : null, "test-invite-code"); + if (status != MeetingStatus.CONDITION_INPUT) { + meeting.transitionToVoting(); + } + if (status == MeetingStatus.CONFIRMED) { + meeting.confirmPlace(100L); + } + Long meetingId = em.persistAndFlush(meeting).getId(); + em.clear(); + return meetingId; + } + + private MeetingStatus statusOf(Long meetingId) { + return meetingRepository.findById(meetingId).orElseThrow().getStatus(); + } +} diff --git a/src/test/java/com/moive/MoiveBE/domain/meeting/repository/ParticipantRepositoryTest.java b/src/test/java/com/moive/MoiveBE/domain/meeting/repository/ParticipantRepositoryTest.java new file mode 100644 index 0000000..cc4ece7 --- /dev/null +++ b/src/test/java/com/moive/MoiveBE/domain/meeting/repository/ParticipantRepositoryTest.java @@ -0,0 +1,146 @@ +package com.moive.MoiveBE.domain.meeting.repository; + +import com.moive.MoiveBE.domain.meeting.entity.Participant; +import com.moive.MoiveBE.domain.meeting.entity.ParticipantState; +import com.moive.MoiveBE.domain.vote.entity.PlaceVote; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager; +import org.springframework.test.context.TestPropertySource; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@TestPropertySource(properties = { + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.sql.init.mode=never" +}) +class ParticipantRepositoryTest { + + @Autowired + private ParticipantRepository participantRepository; + + @Autowired + private TestEntityManager em; + + private static final Long MEETING_ID = 1L; + private static final Long OTHER_MEETING_ID = 99L; + private static final Long PLACE_ID = 1000L; + + /** + * countParticipantsYetToVote + */ + + @Test + void 투표_가능_참여자_중_투표하지_않은_인원만_센다() { + Long a = participant(MEETING_ID, 1L, ParticipantState.COND_DONE); + + participant(MEETING_ID, 2L, ParticipantState.COND_DONE); + participant(MEETING_ID, 3L, ParticipantState.COND_DONE); + vote(MEETING_ID, a); + + assertThat(countParticipantsYetToVote()).isEqualTo(2); + } + + @Test + void 투표_가능_참여자가_모두_투표했으면_0을_반환한다() { + Long a = participant(MEETING_ID, 1L, ParticipantState.COND_DONE); + Long b = participant(MEETING_ID, 2L, ParticipantState.COND_DONE); + + vote(MEETING_ID, a); + vote(MEETING_ID, b); + + assertThat(countParticipantsYetToVote()).isZero(); + } + + @Test + void 여러_장소에_투표한_참여자도_한_명의_투표자로_취급한다() { + Long a = participant(MEETING_ID, 1L, ParticipantState.COND_DONE); + + participant(MEETING_ID, 2L, ParticipantState.COND_DONE); + vote(MEETING_ID, a, PLACE_ID); + vote(MEETING_ID, a, PLACE_ID + 1); + + assertThat(countParticipantsYetToVote()).isEqualTo(1); + } + + @Test + void 투표하고_나간_참여자는_남은_미투표자_수에_영향을_주지_않는다() { + Long a = participant(MEETING_ID, 1L, ParticipantState.COND_DONE); + Long b = participant(MEETING_ID, 2L, ParticipantState.COND_DONE); + Long c = participant(MEETING_ID, 3L, ParticipantState.COND_DONE); + participant(MEETING_ID, 4L, ParticipantState.COND_DONE); + + vote(MEETING_ID, a); + leave(a); + vote(MEETING_ID, b); + vote(MEETING_ID, c); + + // D는 미투표 상태이므로 확정 대상 아님 + assertThat(countParticipantsYetToVote()).isEqualTo(1); + } + + @Test + void 투표한_참여자가_나간_뒤_남은_인원이_모두_투표하면_0을_반환한다() { + Long a = participant(MEETING_ID, 1L, ParticipantState.COND_DONE); + Long b = participant(MEETING_ID, 2L, ParticipantState.COND_DONE); + Long c = participant(MEETING_ID, 3L, ParticipantState.COND_DONE); + Long d = participant(MEETING_ID, 4L, ParticipantState.COND_DONE); + + vote(MEETING_ID, a); + vote(MEETING_ID, b); + vote(MEETING_ID, c); + leave(a); + vote(MEETING_ID, d); + + assertThat(countParticipantsYetToVote()).isZero(); + } + + @Test + void 투표하지_않고_나간_참여자는_세지_않는다() { + Long a = participant(MEETING_ID, 1L, ParticipantState.COND_DONE); + Long b = participant(MEETING_ID, 2L, ParticipantState.COND_DONE); + + vote(MEETING_ID, a); + leave(b); + + assertThat(countParticipantsYetToVote()).isZero(); + } + + @Test + void 나갔다가_다시_입장한_참여자는_이전_투표와_무관하게_신규_참여자로_제외된다() { + Long a = participant(MEETING_ID, 1L, ParticipantState.COND_DONE); + Long b = participant(MEETING_ID, 2L, ParticipantState.COND_DONE); + + vote(MEETING_ID, a); + leave(a); + participant(MEETING_ID, 1L, ParticipantState.NEW_RESTRICTED); // A 재입장 + vote(MEETING_ID, b); + + assertThat(countParticipantsYetToVote()).isZero(); + } + + private long countParticipantsYetToVote() { + return participantRepository.countParticipantsYetToVote(MEETING_ID, ParticipantState.NEW_RESTRICTED); + } + + private Long participant(Long meetingId, Long userId, ParticipantState state) { + return em.persistAndFlush(Participant.create(meetingId, userId, state)).getId(); + } + + private void leave(Long participantId) { + Participant participant = em.find(Participant.class, participantId); + participant.leave(); + em.flush(); + } + + private void vote(Long meetingId, Long participantId) { + vote(meetingId, participantId, PLACE_ID); + } + + private void vote(Long meetingId, Long participantId, Long recommendedPlaceId) { + em.persistAndFlush(PlaceVote.create(meetingId, participantId, recommendedPlaceId)); + } +} diff --git a/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingCompleteLeaveConcurrencyTest.java b/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingCompleteLeaveConcurrencyTest.java new file mode 100644 index 0000000..3f89e9d --- /dev/null +++ b/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingCompleteLeaveConcurrencyTest.java @@ -0,0 +1,107 @@ +package com.moive.MoiveBE.domain.meeting.service; + +import com.moive.MoiveBE.domain.meeting.entity.Meeting; +import com.moive.MoiveBE.domain.meeting.entity.MeetingStatus; +import com.moive.MoiveBE.domain.meeting.entity.Participant; +import com.moive.MoiveBE.domain.meeting.entity.ParticipantState; +import com.moive.MoiveBE.domain.meeting.repository.MeetingRepository; +import com.moive.MoiveBE.domain.meeting.repository.ParticipantRepository; +import com.moive.MoiveBE.domain.vote.service.VoteService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; + +/** + * [모임 종료 배치 ↔ 모임 나가기 API] 동시성 테스트 + * - '모임 나가기' 커밋 전 모임 종료 배치가 실행될 때, '모임 나가기'의 변경 사항이 덮어써지지 않는지 검증 + */ +@SpringBootTest +@ActiveProfiles("test") +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:meeting-complete;MODE=PostgreSQL;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.jpa.show-sql=false", + "spring.sql.init.mode=never" +}) +class MeetingCompleteLeaveConcurrencyTest { + + private static final long PAUSE_MILLIS = 300; + + @Autowired private MeetingService meetingService; + @Autowired private MeetingLeaveService meetingLeaveService; + @Autowired private MeetingRepository meetingRepository; + @Autowired private ParticipantRepository participantRepository; + + @MockitoBean private VoteService voteService; + + @AfterEach + void tearDown() { + participantRepository.deleteAllInBatch(); + meetingRepository.deleteAllInBatch(); + } + + @Test + void 모임_나가기가_커밋되기_전에_종료_배치가_실행돼도_나가기의_변경이_유지된다() throws Exception { + // given: 일정이 어제인 확정 모임, 모임장 A(userId 1) + B(userId 2) + Meeting meeting = Meeting.create(1L, "테스트 모임", LocalDate.now().minusDays(1), LocalTime.NOON, "test-invite-code"); + meeting.incrementParticipantCnt(); + meeting.transitionToVoting(); + meeting.confirmPlace(100L); + Long meetingId = meetingRepository.save(meeting).getId(); + participantRepository.save(Participant.create(meetingId, 1L, ParticipantState.CONFIRMED)); + participantRepository.save(Participant.create(meetingId, 2L, ParticipantState.CONFIRMED)); + + CountDownLatch leaving = new CountDownLatch(1); + doAnswer(invocation -> { + leaving.countDown(); + Thread.sleep(PAUSE_MILLIS); + return null; + }).when(voteService).confirmMeetingIfAllVoted(any()); + + // when: 모임장 A가 나가는 중(인원 감소 + 모임장 B로 승계, 커밋 전)에 종료 배치 실행 + ExecutorService executor = Executors.newFixedThreadPool(2); + Future leave = executor.submit(() -> { + SecurityContextHolder.getContext() + .setAuthentication(new UsernamePasswordAuthenticationToken(1L, null, List.of())); + try { + meetingLeaveService.leaveMeeting(meetingId); + } finally { + SecurityContextHolder.clearContext(); + } + }); + assertThat(leaving.await(5, TimeUnit.SECONDS)).isTrue(); // 모임 나가기가 '모임 수정 후~커밋 전' 상태가 될 때까지 대기 + Future complete = executor.submit(() -> meetingService.completeElapsedMeetings()); + leave.get(10, TimeUnit.SECONDS); + complete.get(10, TimeUnit.SECONDS); + executor.shutdown(); + + // then: 모임은 종료되고, 나가기의 변경(인원 감소, 모임장 승계)도 유지됨 + Meeting result = meetingRepository.findById(meetingId).orElseThrow(); + assertThat(result.getStatus()).isEqualTo(MeetingStatus.COMPLETED); + assertThat(result.getParticipantCnt()).isEqualTo(1); + assertThat(result.getCreatorUserId()).isEqualTo(2L); + } +} diff --git a/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingLeaveServiceTest.java b/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingLeaveServiceTest.java index dfc5d5f..7ddba6a 100644 --- a/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingLeaveServiceTest.java +++ b/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingLeaveServiceTest.java @@ -6,6 +6,7 @@ import com.moive.MoiveBE.domain.meeting.entity.ParticipantState; import com.moive.MoiveBE.domain.meeting.repository.MeetingRepository; import com.moive.MoiveBE.domain.meeting.repository.ParticipantRepository; +import com.moive.MoiveBE.domain.vote.service.VoteService; import com.moive.MoiveBE.global.exception.CustomErrorCode; import com.moive.MoiveBE.global.exception.CustomException; import org.junit.jupiter.api.AfterEach; @@ -30,6 +31,8 @@ class MeetingLeaveServiceTest { @Mock private MeetingRepository meetingRepository; @Mock private ParticipantRepository participantRepository; + @Mock private MeetingService meetingService; + @Mock private VoteService voteService; private MeetingLeaveService meetingLeaveService; private static final Long CURRENT_USER_ID = 1L; @@ -37,7 +40,7 @@ class MeetingLeaveServiceTest { @BeforeEach void setUp() { - meetingLeaveService = new MeetingLeaveService(meetingRepository, participantRepository); + meetingLeaveService = new MeetingLeaveService(meetingRepository, participantRepository, meetingService, voteService); SecurityContextHolder.getContext().setAuthentication( new UsernamePasswordAuthenticationToken( CURRENT_USER_ID, null, List.of(new SimpleGrantedAuthority("ROLE_USER")) @@ -57,7 +60,7 @@ void tearDown() { Participant me = mockParticipant(CURRENT_USER_ID, ParticipantState.COND_PENDING, false); Participant other = mockParticipant(2L, ParticipantState.COND_PENDING, false); - when(meetingRepository.findById(MEETING_ID)).thenReturn(Optional.of(meeting)); + when(meetingRepository.findByIdForUpdate(MEETING_ID)).thenReturn(Optional.of(meeting)); when(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(MEETING_ID, CURRENT_USER_ID)) .thenReturn(Optional.of(me)); when(participantRepository.findAllByMeetingIdAndLeftAtIsNullOrderByJoinedAtAsc(MEETING_ID)) @@ -77,7 +80,7 @@ void tearDown() { Participant me = mockParticipant(CURRENT_USER_ID, ParticipantState.COND_DONE, true); Participant other = mockParticipant(2L, ParticipantState.COND_PENDING, false); - when(meetingRepository.findById(MEETING_ID)).thenReturn(Optional.of(meeting)); + when(meetingRepository.findByIdForUpdate(MEETING_ID)).thenReturn(Optional.of(meeting)); when(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(MEETING_ID, CURRENT_USER_ID)) .thenReturn(Optional.of(me)); when(participantRepository.findAllByMeetingIdAndLeftAtIsNullOrderByJoinedAtAsc(MEETING_ID)) @@ -94,7 +97,7 @@ void tearDown() { Participant me = mockParticipant(CURRENT_USER_ID, ParticipantState.COND_PENDING, false); Participant next = mockParticipant(2L, ParticipantState.COND_PENDING, false); - when(meetingRepository.findById(MEETING_ID)).thenReturn(Optional.of(meeting)); + when(meetingRepository.findByIdForUpdate(MEETING_ID)).thenReturn(Optional.of(meeting)); when(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(MEETING_ID, CURRENT_USER_ID)) .thenReturn(Optional.of(me)); when(participantRepository.findAllByMeetingIdAndLeftAtIsNullOrderByJoinedAtAsc(MEETING_ID)) @@ -110,7 +113,7 @@ void tearDown() { Meeting meeting = mockMeeting(MEETING_ID, MeetingStatus.CONDITION_INPUT, CURRENT_USER_ID); Participant me = mockParticipant(CURRENT_USER_ID, ParticipantState.COND_PENDING, false); - when(meetingRepository.findById(MEETING_ID)).thenReturn(Optional.of(meeting)); + when(meetingRepository.findByIdForUpdate(MEETING_ID)).thenReturn(Optional.of(meeting)); when(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(MEETING_ID, CURRENT_USER_ID)) .thenReturn(Optional.of(me)); when(participantRepository.findAllByMeetingIdAndLeftAtIsNullOrderByJoinedAtAsc(MEETING_ID)) @@ -125,7 +128,7 @@ void tearDown() { @Test void 존재하지_않는_모임이면_MEETING_NOT_FOUND_예외가_발생한다() { - when(meetingRepository.findById(MEETING_ID)).thenReturn(Optional.empty()); + when(meetingRepository.findByIdForUpdate(MEETING_ID)).thenReturn(Optional.empty()); assertThatThrownBy(() -> meetingLeaveService.leaveMeeting(MEETING_ID)) .isInstanceOf(CustomException.class) @@ -136,7 +139,7 @@ void tearDown() { @Test void COMPLETED_모임에서_나가려하면_CANNOT_LEAVE_COMPLETED_MEETING_예외가_발생한다() { Meeting meeting = mockMeeting(MEETING_ID, MeetingStatus.COMPLETED, CURRENT_USER_ID); - when(meetingRepository.findById(MEETING_ID)).thenReturn(Optional.of(meeting)); + when(meetingRepository.findByIdForUpdate(MEETING_ID)).thenReturn(Optional.of(meeting)); assertThatThrownBy(() -> meetingLeaveService.leaveMeeting(MEETING_ID)) .isInstanceOf(CustomException.class) @@ -149,7 +152,7 @@ void tearDown() { @Test void 모임_참여자가_아니면_NOT_A_PARTICIPANT_예외가_발생한다() { Meeting meeting = mockMeeting(MEETING_ID, MeetingStatus.CONDITION_INPUT, 99L); - when(meetingRepository.findById(MEETING_ID)).thenReturn(Optional.of(meeting)); + when(meetingRepository.findByIdForUpdate(MEETING_ID)).thenReturn(Optional.of(meeting)); when(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(MEETING_ID, CURRENT_USER_ID)) .thenReturn(Optional.empty()); diff --git a/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingLeaveStartVotingTest.java b/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingLeaveStartVotingTest.java new file mode 100644 index 0000000..73012f8 --- /dev/null +++ b/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingLeaveStartVotingTest.java @@ -0,0 +1,123 @@ +package com.moive.MoiveBE.domain.meeting.service; + +import com.moive.MoiveBE.domain.meeting.entity.Meeting; +import com.moive.MoiveBE.domain.meeting.entity.MeetingStatus; +import com.moive.MoiveBE.domain.meeting.entity.Participant; +import com.moive.MoiveBE.domain.meeting.entity.ParticipantState; +import com.moive.MoiveBE.domain.meeting.repository.MeetingRepository; +import com.moive.MoiveBE.domain.meeting.repository.ParticipantRepository; +import com.moive.MoiveBE.domain.notification.service.NotificationService; +import com.moive.MoiveBE.domain.recommendation.service.AreaRecommendationService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * [모임 나가기 API] 모임 나가기 후 조건 입력 완료 여부 재판정 테스트 + * - 조건 입력 단계에서 참여자가 나가 남은 참여자가 모두 조건 입력을 마친 상태가 되면 => VOTING 전환 + 장소 추천 시작 여부 검증 + */ +@SpringBootTest +@ActiveProfiles("test") +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:leave-start-voting;MODE=PostgreSQL;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.datasource.hikari.maximum-pool-size=20", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.jpa.show-sql=false", + "spring.sql.init.mode=never" +}) +class MeetingLeaveStartVotingTest { + + @Autowired private MeetingLeaveService meetingLeaveService; + @Autowired private MeetingRepository meetingRepository; + @Autowired private ParticipantRepository participantRepository; + + @MockitoBean private AreaRecommendationService areaRecommendationService; + @MockitoBean private NotificationService notificationService; + + @AfterEach + void tearDown() { + participantRepository.deleteAllInBatch(); + meetingRepository.deleteAllInBatch(); + } + + @Test + void 조건을_입력하지_않은_마지막_참여자가_나가면_장소_투표가_시작된다() { + // given: A, B는 조건 입력 완료, C는 미입력 + Long meetingId = conditionInputMeeting(List.of(1L, 2L), List.of(3L)); + + // when: C가 모임을 나감 + asUser(3L, () -> meetingLeaveService.leaveMeeting(meetingId)); + + // then: 남은 A, B가 모두 조건 입력을 마친 상태 => 장소 투표 단계로 전환, 장소 추천 한 번 시작 + Meeting meeting = meetingRepository.findById(meetingId).orElseThrow(); + assertThat(meeting.getStatus()).isEqualTo(MeetingStatus.VOTING); + assertThat(meeting.getParticipantCnt()).isEqualTo(2); + assertThat(meeting.getSubmittedCnt()).isEqualTo(2); + verify(areaRecommendationService, times(1)).recommend(meetingId); + } + + @Test + void 조건을_입력한_참여자가_나가면_미입력자가_남아있으므로_장소_투표가_시작되지_않는다() { + // given: A, B는 조건 입력 완료, C는 미입력 + Long meetingId = conditionInputMeeting(List.of(1L, 2L), List.of(3L)); + + // when: 조건을 입력한 B가 모임을 나감 + asUser(2L, () -> meetingLeaveService.leaveMeeting(meetingId)); + + // then: C가 아직 미입력 => 조건 입력 단계 유지, 장소 추천 시작 x + Meeting meeting = meetingRepository.findById(meetingId).orElseThrow(); + assertThat(meeting.getStatus()).isEqualTo(MeetingStatus.CONDITION_INPUT); + assertThat(meeting.getParticipantCnt()).isEqualTo(2); + assertThat(meeting.getSubmittedCnt()).isEqualTo(1); + verify(areaRecommendationService, never()).recommend(anyLong()); + } + + private Long conditionInputMeeting(List submittedUserIds, List pendingUserIds) { + Meeting meeting = Meeting.create(submittedUserIds.get(0), "테스트 모임", LocalDate.now().plusDays(7), LocalTime.NOON, "test-invite-code"); + for (int i = 1; i < submittedUserIds.size() + pendingUserIds.size(); i++) { + meeting.incrementParticipantCnt(); + } + submittedUserIds.forEach(userId -> meeting.incrementSubmittedCnt()); + Long meetingId = meetingRepository.save(meeting).getId(); + + for (Long userId : submittedUserIds) { + Participant participant = Participant.create(meetingId, userId, ParticipantState.COND_PENDING); + participant.completeCondition(); + participantRepository.save(participant); + } + for (Long userId : pendingUserIds) { + participantRepository.save(Participant.create(meetingId, userId, ParticipantState.COND_PENDING)); + } + return meetingId; + } + + private void asUser(Long userId, Runnable task) { + SecurityContextHolder.getContext() + .setAuthentication(new UsernamePasswordAuthenticationToken(userId, null, List.of())); + try { + task.run(); + } finally { + SecurityContextHolder.clearContext(); + } + } +} diff --git a/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingPreferenceSubmitConcurrencyTest.java b/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingPreferenceSubmitConcurrencyTest.java new file mode 100644 index 0000000..79bfdba --- /dev/null +++ b/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingPreferenceSubmitConcurrencyTest.java @@ -0,0 +1,214 @@ +package com.moive.MoiveBE.domain.meeting.service; + +import com.moive.MoiveBE.domain.meeting.dto.SubmitPreferenceRequest; +import com.moive.MoiveBE.domain.meeting.entity.ActivityType; +import com.moive.MoiveBE.domain.meeting.entity.Meeting; +import com.moive.MoiveBE.domain.meeting.entity.MeetingStatus; +import com.moive.MoiveBE.domain.meeting.entity.Participant; +import com.moive.MoiveBE.domain.meeting.entity.ParticipantState; +import com.moive.MoiveBE.domain.meeting.repository.ActivityRepository; +import com.moive.MoiveBE.domain.meeting.repository.DateVoteRepository; +import com.moive.MoiveBE.domain.meeting.repository.MeetingRepository; +import com.moive.MoiveBE.domain.meeting.repository.ParticipantPreferenceRepository; +import com.moive.MoiveBE.domain.meeting.repository.ParticipantRepository; +import com.moive.MoiveBE.domain.meeting.repository.PreferenceActivityRepository; +import com.moive.MoiveBE.domain.notification.service.NotificationService; +import com.moive.MoiveBE.domain.recommendation.service.AreaRecommendationService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * [조건 제출 API] 동시성 테스트 + * - 여러 참여자가 동시에 조건을 제출해도 제출 인원이 정확히 집계되고, VOTING 전환 및 장소 추천이 한 번만 일어나는지 검증 + */ +@SpringBootTest +@ActiveProfiles("test") +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:preference-concurrency;MODE=PostgreSQL;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.datasource.hikari.maximum-pool-size=20", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.jpa.show-sql=false", + "spring.sql.init.mode=never" +}) +class MeetingPreferenceSubmitConcurrencyTest { + + private static final String INVITE_CODE = "test-invite-code"; + + @Autowired private MeetingService meetingService; + @Autowired private MeetingRepository meetingRepository; + @Autowired private ParticipantRepository participantRepository; + @Autowired private ParticipantPreferenceRepository participantPreferenceRepository; + @Autowired private PreferenceActivityRepository preferenceActivityRepository; + @Autowired private DateVoteRepository dateVoteRepository; + @Autowired private ActivityRepository activityRepository; + @Autowired private JdbcTemplate jdbcTemplate; + + @MockitoBean private AreaRecommendationService areaRecommendationService; + @MockitoBean private NotificationService notificationService; + + @BeforeEach + void setUp() { + jdbcTemplate.update("insert into activities (name) values (?)", ActivityType.CAFE_DESSERT.name()); + } + + @AfterEach + void tearDown() { + preferenceActivityRepository.deleteAllInBatch(); + participantPreferenceRepository.deleteAllInBatch(); + dateVoteRepository.deleteAllInBatch(); + participantRepository.deleteAllInBatch(); + meetingRepository.deleteAllInBatch(); + activityRepository.deleteAllInBatch(); + } + + @Test + void 참여자_전원이_동시에_조건을_제출해도_제출_인원이_정확하고_장소_추천은_한_번만_시작된다() throws Exception { + // given: 참여자 5명 모두 조건 미제출 + int participantCnt = 5; + Long meetingId = conditionInputMeetingWithParticipants(participantCnt); + + // when: 5명이 동시에 조건 제출 + List> results = submitConcurrently(meetingId, List.of(1L, 2L, 3L, 4L, 5L)); + for (Future result : results) { + result.get(10, TimeUnit.SECONDS); + } + + // then: 제출 인원: 5명, 모임: VOTING, 장소 추천: 한 번만 시작 + Meeting meeting = meetingRepository.findById(meetingId).orElseThrow(); + assertThat(meeting.getSubmittedCnt()).isEqualTo(participantCnt); + assertThat(meeting.getStatus()).isEqualTo(MeetingStatus.VOTING); + verify(areaRecommendationService, times(1)).recommend(meetingId); + + assertThat(participantRepository.findAllByMeetingIdAndLeftAtIsNull(meetingId)) + .extracting(Participant::getState) + .containsOnly(ParticipantState.COND_DONE); + assertThat(participantPreferenceRepository.count()).isEqualTo(participantCnt); + } + + @Test + void 마지막_제출자가_조건을_동시에_두_번_제출해도_제출_인원은_한_번만_증가하고_장소_추천은_한_번만_시작된다() throws Exception { + // given: 참여자 3명 중 2명은 조건 제출 완료, 3번 유저만 미제출 + Long meetingId = conditionInputMeetingWithParticipants(3); + submitConcurrently(meetingId, List.of(1L, 2L)).forEach(this::await); + + // when: 3번 유저가 같은 조건을 동시에 두 번 제출 + List> results = submitConcurrently(meetingId, List.of(3L, 3L)); + long succeeded = results.stream().filter(this::succeeded).count(); + + // then: 한 요청만 반영 (제출 인원: 3명, 3번 유저의 선호 조건: 1건, 장소 추천: 한 번만 시작) + assertThat(succeeded).isEqualTo(1); + Meeting meeting = meetingRepository.findById(meetingId).orElseThrow(); + assertThat(meeting.getSubmittedCnt()).isEqualTo(3); + assertThat(meeting.getStatus()).isEqualTo(MeetingStatus.VOTING); + verify(areaRecommendationService, times(1)).recommend(meetingId); + assertThat(participantPreferenceRepository.count()).isEqualTo(3); + } + + private Long conditionInputMeetingWithParticipants(int count) { + Meeting meeting = Meeting.create(1L, "테스트 모임", LocalDate.now().plusDays(7), LocalTime.NOON, INVITE_CODE); + for (int i = 1; i < count; i++) { + meeting.incrementParticipantCnt(); + } + Long meetingId = meetingRepository.save(meeting).getId(); + for (long userId = 1; userId <= count; userId++) { + participantRepository.save(Participant.create(meetingId, userId, ParticipantState.COND_PENDING)); + } + return meetingId; + } + + private List> submitConcurrently(Long meetingId, List userIds) throws InterruptedException { + ExecutorService executor = Executors.newFixedThreadPool(userIds.size()); + CountDownLatch ready = new CountDownLatch(userIds.size()); + CountDownLatch start = new CountDownLatch(1); + + List> results = new ArrayList<>(); + for (Long userId : userIds) { + results.add(executor.submit(asUser(userId, () -> { + ready.countDown(); + awaitLatch(start); + meetingService.submitPreference(meetingId, preferenceRequest()); + }))); + } + assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + executor.shutdown(); + assertThat(executor.awaitTermination(10, TimeUnit.SECONDS)).isTrue(); + return results; + } + + private SubmitPreferenceRequest preferenceRequest() { + return new SubmitPreferenceRequest( + null, + "합정역", + new BigDecimal("37.5540203935757"), + new BigDecimal("126.916389124374"), + 60, + List.of(ActivityType.CAFE_DESSERT) + ); + } + + private Runnable asUser(Long userId, Runnable task) { + return () -> { + SecurityContextHolder.getContext() + .setAuthentication(new UsernamePasswordAuthenticationToken(userId, null, List.of())); + try { + task.run(); + } finally { + SecurityContextHolder.clearContext(); + } + }; + } + + private void awaitLatch(CountDownLatch latch) { + try { + latch.await(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private void await(Future result) { + try { + result.get(10, TimeUnit.SECONDS); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private boolean succeeded(Future result) { + try { + result.get(10, TimeUnit.SECONDS); + return true; + } catch (Exception e) { + return false; + } + } +} diff --git a/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingServiceCompleteTest.java b/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingServiceCompleteTest.java index 28c30e0..2ef9a33 100644 --- a/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingServiceCompleteTest.java +++ b/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingServiceCompleteTest.java @@ -1,6 +1,5 @@ package com.moive.MoiveBE.domain.meeting.service; -import com.moive.MoiveBE.domain.meeting.entity.Meeting; import com.moive.MoiveBE.domain.meeting.entity.MeetingStatus; import com.moive.MoiveBE.domain.meeting.repository.ActivityRepository; import com.moive.MoiveBE.domain.meeting.repository.DateVoteRepository; @@ -16,11 +15,11 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.time.LocalDate; -import java.util.List; +import java.time.LocalDateTime; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -37,45 +36,16 @@ class MeetingServiceCompleteTest { @InjectMocks private MeetingService meetingService; @Test - void 일정이_지난_CONFIRMED_모임을_COMPLETED로_전환한다() { + void 일정이_오늘_이전인_CONFIRMED_모임을_COMPLETED로_일괄_변경하고_변경_건수를_반환한다() { // given - Meeting elapsed = mock(Meeting.class); - when(meetingRepository.findAllByStatusAndScheduledDateBefore(MeetingStatus.CONFIRMED, LocalDate.now())) - .thenReturn(List.of(elapsed)); + when(meetingRepository.updateStatusByStatusAndScheduledDateBefore( + eq(MeetingStatus.CONFIRMED), eq(MeetingStatus.COMPLETED), eq(LocalDate.now()), any(LocalDateTime.class))) + .thenReturn(2); // when - meetingService.completeElapsedMeetings(); + int completedCnt = meetingService.completeElapsedMeetings(); // then - verify(elapsed).complete(); - } - - @Test - void 대상_모임이_없으면_아무것도_하지_않는다() { - // given - when(meetingRepository.findAllByStatusAndScheduledDateBefore(MeetingStatus.CONFIRMED, LocalDate.now())) - .thenReturn(List.of()); - - // when - meetingService.completeElapsedMeetings(); - - // then - verifyNoInteractions(participantRepository, dateVoteRepository); - } - - @Test - void 여러_모임을_한번에_COMPLETED로_전환한다() { - // given - Meeting a = mock(Meeting.class); - Meeting b = mock(Meeting.class); - when(meetingRepository.findAllByStatusAndScheduledDateBefore(MeetingStatus.CONFIRMED, LocalDate.now())) - .thenReturn(List.of(a, b)); - - // when - meetingService.completeElapsedMeetings(); - - // then - verify(a).complete(); - verify(b).complete(); + assertThat(completedCnt).isEqualTo(2); } } diff --git a/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingServiceJoinTest.java b/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingServiceJoinTest.java index 01fd954..0a090a0 100644 --- a/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingServiceJoinTest.java +++ b/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingServiceJoinTest.java @@ -74,7 +74,7 @@ void tearDown() { // given Meeting meeting = mockMeeting(1L, MeetingStatus.CONDITION_INPUT, 3); - when(meetingRepository.findByInviteCode("ABC123XY")).thenReturn(Optional.of(meeting)); + when(meetingRepository.findByInviteCodeForUpdate("ABC123XY")).thenReturn(Optional.of(meeting)); when(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(1L, 2L)) .thenReturn(Optional.empty()); @@ -99,7 +99,7 @@ void tearDown() { // given Meeting meeting = mockMeeting(1L, MeetingStatus.VOTING, 5); - when(meetingRepository.findByInviteCode("CODE1234")).thenReturn(Optional.of(meeting)); + when(meetingRepository.findByInviteCodeForUpdate("CODE1234")).thenReturn(Optional.of(meeting)); when(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(1L, 2L)) .thenReturn(Optional.empty()); @@ -124,7 +124,7 @@ void tearDown() { when(existing.getId()).thenReturn(3L); when(existing.getState()).thenReturn(ParticipantState.COND_PENDING); - when(meetingRepository.findByInviteCode("ABC123XY")).thenReturn(Optional.of(meeting)); + when(meetingRepository.findByInviteCodeForUpdate("ABC123XY")).thenReturn(Optional.of(meeting)); when(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(1L, 2L)) .thenReturn(Optional.of(existing)); @@ -141,7 +141,7 @@ void tearDown() { @Test void 유효하지_않은_초대코드면_INVALID_INVITE_CODE_예외가_발생한다() { - when(meetingRepository.findByInviteCode("WRONGCOD")).thenReturn(Optional.empty()); + when(meetingRepository.findByInviteCodeForUpdate("WRONGCOD")).thenReturn(Optional.empty()); assertThatThrownBy(() -> meetingService.joinMeeting("WRONGCOD")) .isInstanceOf(CustomException.class) @@ -153,7 +153,7 @@ void tearDown() { void 정원이_가득_찬_모임이면_MEETING_FULL_예외가_발생한다() { Meeting meeting = mockMeeting(1L, MeetingStatus.CONDITION_INPUT, 10); - when(meetingRepository.findByInviteCode("FULLCODE")).thenReturn(Optional.of(meeting)); + when(meetingRepository.findByInviteCodeForUpdate("FULLCODE")).thenReturn(Optional.of(meeting)); when(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(1L, 2L)) .thenReturn(Optional.empty()); @@ -169,7 +169,7 @@ void tearDown() { void 완료된_모임이면_MEETING_COMPLETED_예외가_발생한다() { Meeting meeting = mockMeeting(1L, MeetingStatus.COMPLETED, 5); - when(meetingRepository.findByInviteCode("DONEXXXX")).thenReturn(Optional.of(meeting)); + when(meetingRepository.findByInviteCodeForUpdate("DONEXXXX")).thenReturn(Optional.of(meeting)); assertThatThrownBy(() -> meetingService.joinMeeting("DONEXXXX")) .isInstanceOf(CustomException.class) diff --git a/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingServicePreferenceTest.java b/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingServicePreferenceTest.java index e492f90..0d57bf7 100644 --- a/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingServicePreferenceTest.java +++ b/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingServicePreferenceTest.java @@ -115,7 +115,7 @@ private SubmitPreferenceRequest validRequest(boolean includeSchedules) { when(savedPref.getId()).thenReturn(100L); when(activity.getId()).thenReturn(1L); - when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting)); + when(meetingRepository.findByIdForUpdate(10L)).thenReturn(Optional.of(meeting)); when(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(10L, 1L)) .thenReturn(Optional.of(participant)); when(preferenceRepository.findByParticipantId(3L)).thenReturn(Optional.empty()); @@ -145,15 +145,15 @@ private SubmitPreferenceRequest validRequest(boolean includeSchedules) { when(savedPref.getId()).thenReturn(100L); when(activity.getId()).thenReturn(1L); - when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting)); + when(meetingRepository.findByIdForUpdate(10L)).thenReturn(Optional.of(meeting)); when(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(10L, 1L)) .thenReturn(Optional.of(participant)); when(preferenceRepository.findByParticipantId(3L)).thenReturn(Optional.empty()); when(preferenceRepository.save(any())).thenReturn(savedPref); when(activityRepository.findByName(any())).thenReturn(Optional.of(activity)); when(meeting.getSubmittedCnt()).thenReturn(2); - // 첫 호출(상태 체크)=CONDITION_INPUT, 이후 호출(응답 생성)=VOTING - when(meeting.getStatus()).thenReturn(MeetingStatus.CONDITION_INPUT, MeetingStatus.VOTING); + // 조건 입력 가능 여부 확인, 장소 투표 시작 여부 확인=CONDITION_INPUT, 이후 호출(응답 생성)=VOTING + when(meeting.getStatus()).thenReturn(MeetingStatus.CONDITION_INPUT, MeetingStatus.CONDITION_INPUT, MeetingStatus.VOTING); // when SubmitPreferenceResponse response = meetingService.submitPreference(10L, validRequest(true)); @@ -178,7 +178,7 @@ private SubmitPreferenceRequest validRequest(boolean includeSchedules) { when(savedPref.getId()).thenReturn(100L); when(activity.getId()).thenReturn(1L); - when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting)); + when(meetingRepository.findByIdForUpdate(10L)).thenReturn(Optional.of(meeting)); when(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(10L, 1L)) .thenReturn(Optional.of(participant)); when(preferenceRepository.findByParticipantId(3L)).thenReturn(Optional.empty()); @@ -204,7 +204,7 @@ private SubmitPreferenceRequest validRequest(boolean includeSchedules) { when(existingPref.getId()).thenReturn(100L); when(activity.getId()).thenReturn(1L); - when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting)); + when(meetingRepository.findByIdForUpdate(10L)).thenReturn(Optional.of(meeting)); when(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(10L, 1L)) .thenReturn(Optional.of(participant)); when(preferenceRepository.findByParticipantId(3L)).thenReturn(Optional.of(existingPref)); @@ -224,7 +224,7 @@ private SubmitPreferenceRequest validRequest(boolean includeSchedules) { @Test void 모임이_없으면_MEETING_NOT_FOUND() { - when(meetingRepository.findById(10L)).thenReturn(Optional.empty()); + when(meetingRepository.findByIdForUpdate(10L)).thenReturn(Optional.empty()); assertThatThrownBy(() -> meetingService.submitPreference(10L, validRequest(true))) .isInstanceOf(CustomException.class) @@ -235,7 +235,7 @@ private SubmitPreferenceRequest validRequest(boolean includeSchedules) { @Test void 모임_상태가_CONDITION_INPUT이_아니면_MEETING_STATUS_INVALID() { Meeting meeting = mockMeeting(MeetingStatus.VOTING, false, 2, 1); - when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting)); + when(meetingRepository.findByIdForUpdate(10L)).thenReturn(Optional.of(meeting)); assertThatThrownBy(() -> meetingService.submitPreference(10L, validRequest(true))) .isInstanceOf(CustomException.class) @@ -246,7 +246,7 @@ private SubmitPreferenceRequest validRequest(boolean includeSchedules) { @Test void 모임_참여자가_아니면_NOT_A_PARTICIPANT() { Meeting meeting = mockMeeting(MeetingStatus.CONDITION_INPUT, false, 2, 0); - when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting)); + when(meetingRepository.findByIdForUpdate(10L)).thenReturn(Optional.of(meeting)); when(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(10L, 1L)) .thenReturn(Optional.empty()); @@ -260,7 +260,7 @@ private SubmitPreferenceRequest validRequest(boolean includeSchedules) { void 출발지가_없으면_DEPARTURE_MISSING() { Meeting meeting = mockMeeting(MeetingStatus.CONDITION_INPUT, false, 2, 0); Participant participant = mockParticipant(false); - when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting)); + when(meetingRepository.findByIdForUpdate(10L)).thenReturn(Optional.of(meeting)); when(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(10L, 1L)) .thenReturn(Optional.of(participant)); @@ -280,7 +280,7 @@ private SubmitPreferenceRequest validRequest(boolean includeSchedules) { void 유효하지_않은_이동시간이면_MAX_TRAVEL_MINUTES_INVALID() { Meeting meeting = mockMeeting(MeetingStatus.CONDITION_INPUT, false, 2, 0); Participant participant = mockParticipant(false); - when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting)); + when(meetingRepository.findByIdForUpdate(10L)).thenReturn(Optional.of(meeting)); when(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(10L, 1L)) .thenReturn(Optional.of(participant)); @@ -303,7 +303,7 @@ private SubmitPreferenceRequest validRequest(boolean includeSchedules) { void 취향이_없으면_ACTIVITY_TYPES_EMPTY() { Meeting meeting = mockMeeting(MeetingStatus.CONDITION_INPUT, false, 2, 0); Participant participant = mockParticipant(false); - when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting)); + when(meetingRepository.findByIdForUpdate(10L)).thenReturn(Optional.of(meeting)); when(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(10L, 1L)) .thenReturn(Optional.of(participant)); @@ -326,7 +326,7 @@ private SubmitPreferenceRequest validRequest(boolean includeSchedules) { void hasSchedule_false인데_일정이_없으면_AVAILABLE_SCHEDULES_EMPTY() { Meeting meeting = mockMeeting(MeetingStatus.CONDITION_INPUT, false, 2, 0); Participant participant = mockParticipant(false); - when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting)); + when(meetingRepository.findByIdForUpdate(10L)).thenReturn(Optional.of(meeting)); when(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(10L, 1L)) .thenReturn(Optional.of(participant)); diff --git a/src/test/java/com/moive/MoiveBE/domain/vote/repository/PlaceVoteRepositoryTest.java b/src/test/java/com/moive/MoiveBE/domain/vote/repository/PlaceVoteRepositoryTest.java index 5d1ed56..0de8085 100644 --- a/src/test/java/com/moive/MoiveBE/domain/vote/repository/PlaceVoteRepositoryTest.java +++ b/src/test/java/com/moive/MoiveBE/domain/vote/repository/PlaceVoteRepositoryTest.java @@ -1,5 +1,7 @@ package com.moive.MoiveBE.domain.vote.repository; +import com.moive.MoiveBE.domain.meeting.entity.Participant; +import com.moive.MoiveBE.domain.meeting.entity.ParticipantState; import com.moive.MoiveBE.domain.recommendation.entity.RecommendedArea; import com.moive.MoiveBE.domain.recommendation.entity.RecommendedPlace; import com.moive.MoiveBE.domain.vote.dto.PlaceVoteSummary; @@ -33,10 +35,10 @@ class PlaceVoteRepositoryTest { private static final Long MEETING_ID = 1L; private static final Long OTHER_MEETING_ID = 99L; - // 참여자 ID - private static final Long A = 10L; - private static final Long B = 20L; - private static final Long C = 30L; + // 참여자 + private Long A; + private Long B; + private Long C; private static final Long RECOMMENDATION_RUN_ID = 1000L; @@ -46,7 +48,11 @@ class PlaceVoteRepositoryTest { private Long PLACE_3; @BeforeEach - void setUpRecommendedPlaces() { + void setUp() { + A = participant(MEETING_ID, 1L); + B = participant(MEETING_ID, 2L); + C = participant(MEETING_ID, 3L); + Long areaId = area(RECOMMENDATION_RUN_ID, "합정역"); PLACE_1 = place(areaId, "google-place-1", "카페", 3); PLACE_2 = place(areaId, "google-place-2", "레스토랑", 2); @@ -121,6 +127,16 @@ void setUpRecommendedPlaces() { assertThat(placeVoteRepository.countDistinctVoters(MEETING_ID)).isEqualTo(1); } + @Test + void 모임을_나간_참여자의_투표는_투표자_수에서_제외한다() { + vote(MEETING_ID, A, PLACE_1); + vote(MEETING_ID, B, PLACE_1); + vote(MEETING_ID, C, PLACE_2); + leave(B); + + assertThat(placeVoteRepository.countDistinctVoters(MEETING_ID)).isEqualTo(2); + } + /** * aggregateByPlace */ @@ -235,10 +251,60 @@ void setUpRecommendedPlaces() { assertThat(result).extracting(PlaceVoteSummary::recommendedPlaceId).containsExactly(duplicatedPlace1); } + @Test + void 모임을_나간_참여자의_투표는_장소별_득표수에서_제외한다() { + vote(MEETING_ID, A, PLACE_1); + vote(MEETING_ID, B, PLACE_1); + vote(MEETING_ID, C, PLACE_2); + leave(B); + + List result = placeVoteRepository.aggregateByPlace(MEETING_ID, A); + + assertThat(result).extracting(PlaceVoteSummary::recommendedPlaceId, PlaceVoteSummary::voterCnt) + .containsExactlyInAnyOrder( + tuple(PLACE_1, 1L), + tuple(PLACE_2, 1L) + ); + } + + @Test + void 모임을_나간_참여자만_투표한_장소는_집계_결과에_나타나지_않는다() { + vote(MEETING_ID, A, PLACE_1); + vote(MEETING_ID, B, PLACE_2); + leave(B); + + List result = placeVoteRepository.aggregateByPlace(MEETING_ID, A); + + assertThat(result).extracting(PlaceVoteSummary::recommendedPlaceId).containsExactly(PLACE_1); + } + + @Test + void 병합된_장소의_대표_id는_남은_참여자가_투표한_추천_장소_중_가장_작은_id다() { + // PLACE_1(더 작은 id)에는 나간 참여자만 투표, duplicatedPlace1에는 남은 참여자가 투표 + Long duplicatedPlace1 = duplicatedPlace("google-place-1"); + vote(MEETING_ID, B, PLACE_1); + vote(MEETING_ID, A, duplicatedPlace1); + leave(B); + + List result = placeVoteRepository.aggregateByPlace(MEETING_ID, A); + + assertThat(result).extracting(PlaceVoteSummary::recommendedPlaceId, PlaceVoteSummary::voterCnt) + .containsExactly(tuple(duplicatedPlace1, 1L)); + } + /** * helpers */ + private Long participant(Long meetingId, Long userId) { + return em.persistAndFlush(Participant.create(meetingId, userId, ParticipantState.COND_DONE)).getId(); + } + + private void leave(Long participantId) { + em.find(Participant.class, participantId).leave(); + em.flush(); + } + private Long area(Long recommendationRunId, String areaName) { return em.persistAndFlush(RecommendedArea.create(recommendationRunId, areaName)).getId(); } diff --git a/src/test/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteCloseBatchTest.java b/src/test/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteCloseBatchTest.java new file mode 100644 index 0000000..eb79fdb --- /dev/null +++ b/src/test/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteCloseBatchTest.java @@ -0,0 +1,148 @@ +package com.moive.MoiveBE.domain.vote.service; + +import com.moive.MoiveBE.domain.meeting.entity.DateVote; +import com.moive.MoiveBE.domain.meeting.entity.Meeting; +import com.moive.MoiveBE.domain.meeting.entity.MeetingStatus; +import com.moive.MoiveBE.domain.meeting.entity.Participant; +import com.moive.MoiveBE.domain.meeting.entity.ParticipantState; +import com.moive.MoiveBE.domain.meeting.repository.DateVoteRepository; +import com.moive.MoiveBE.domain.meeting.repository.MeetingRepository; +import com.moive.MoiveBE.domain.meeting.repository.ParticipantRepository; +import com.moive.MoiveBE.domain.notification.entity.NotificationType; +import com.moive.MoiveBE.domain.notification.service.NotificationService; +import com.moive.MoiveBE.domain.recommendation.client.GooglePlacesClient; +import com.moive.MoiveBE.domain.recommendation.entity.RecommendationRun; +import com.moive.MoiveBE.domain.recommendation.entity.RecommendedArea; +import com.moive.MoiveBE.domain.recommendation.entity.RecommendedPlace; +import com.moive.MoiveBE.domain.recommendation.repository.RecommendationRunRepository; +import com.moive.MoiveBE.domain.recommendation.repository.RecommendedAreaRepository; +import com.moive.MoiveBE.domain.recommendation.repository.RecommendedPlaceRepository; +import com.moive.MoiveBE.domain.vote.entity.PlaceVote; +import com.moive.MoiveBE.domain.vote.repository.PlaceVoteRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDate; +import java.time.LocalTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; + +/** + * [장소 투표 마감 배치] 통합 테스트 + * - PlaceVoteCloseService.finalizeExpiredPlaceVotes() → 모임별 트랜잭션(VoteService.finalizeExpiredPlaceVote) 연결 시 + * 모임마다 따로 커밋 및 롤백되는지 검증 + */ +@SpringBootTest +@ActiveProfiles("test") +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:place-vote-close;MODE=PostgreSQL;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.jpa.show-sql=false", + "spring.sql.init.mode=never", + "place-vote.deadline-days=0" //마감 대상으로 설정 +}) +class PlaceVoteCloseBatchTest { + + private static final LocalDate TOP_DATE = LocalDate.of(2026, 10, 3); + private static final LocalTime TOP_TIME = LocalTime.of(18, 0); + + @Autowired private PlaceVoteCloseService placeVoteCloseService; + @Autowired private MeetingRepository meetingRepository; + @Autowired private ParticipantRepository participantRepository; + @Autowired private DateVoteRepository dateVoteRepository; + @Autowired private PlaceVoteRepository placeVoteRepository; + @Autowired private RecommendationRunRepository recommendationRunRepository; + @Autowired private RecommendedAreaRepository recommendedAreaRepository; + @Autowired private RecommendedPlaceRepository recommendedPlaceRepository; + + @MockitoBean private GooglePlacesClient googlePlacesClient; + @MockitoBean private NotificationService notificationService; + + @AfterEach + void tearDown() { + placeVoteRepository.deleteAllInBatch(); + dateVoteRepository.deleteAllInBatch(); + recommendedPlaceRepository.deleteAllInBatch(); + recommendedAreaRepository.deleteAllInBatch(); + recommendationRunRepository.deleteAllInBatch(); + participantRepository.deleteAllInBatch(); + meetingRepository.deleteAllInBatch(); + } + + @Test + void 마감_배치에서_한_모임_처리가_실패해도_다른_모임의_확정은_커밋되고_실패한_모임만_롤백된다() { + // given + // - A, B, C: 장소 추천 완료 + 장소 투표 1건 → 마감 대상 o + // - B: 확정의 마지막 단계(확정 알림 발송)에서 예외 발생 + // - D: 장소 추천 미완료 → 마감 대상 x + Long meetingA = votingMeetingWithOneVote(true); + Long meetingB = votingMeetingWithOneVote(true); + Long meetingC = votingMeetingWithOneVote(true); + Long meetingD = votingMeetingWithOneVote(false); + doThrow(new IllegalStateException("확정 알림 저장 실패")) + .when(notificationService) + .sendNotification(anyLong(), eq(meetingB), eq(NotificationType.MEETING_CONFIRMED), anyString()); + + // when: 스케줄러가 호출하는 마감 배치 실행 + assertThatCode(() -> placeVoteCloseService.finalizeExpiredPlaceVotes()).doesNotThrowAnyException(); + + // then + // - A, C: 모임 확정 커밋됨 (B의 실패와 무관) + assertConfirmed(meetingA); + assertConfirmed(meetingC); + + // - B: 예외 전에 수행한 모임 확정까지 모두 롤백 → 다음 실행에서 다시 대상이 됨 + Meeting b = meetingRepository.findById(meetingB).orElseThrow(); + assertThat(b.getStatus()).isEqualTo(MeetingStatus.VOTING); + assertThat(b.getConfirmedPlaceId()).isNull(); + assertThat(b.getScheduledDate()).isNull(); + + // - D: 장소 투표 단계가 아니므로 처리하지 않음 + assertThat(meetingRepository.findById(meetingD).orElseThrow().getStatus()).isEqualTo(MeetingStatus.VOTING); + } + + private void assertConfirmed(Long meetingId) { + Meeting meeting = meetingRepository.findById(meetingId).orElseThrow(); + assertThat(meeting.getStatus()).isEqualTo(MeetingStatus.CONFIRMED); + assertThat(meeting.getConfirmedPlaceId()).isNotNull(); + assertThat(meeting.getScheduledDate()).isEqualTo(TOP_DATE); + assertThat(meeting.getScheduledTime()).isEqualTo(TOP_TIME); + } + + private Long votingMeetingWithOneVote(boolean recommendationCompleted) { + Meeting meeting = Meeting.create(1L, "테스트 모임", null, null, "test-invite-code"); + meeting.transitionToVoting(); + Long meetingId = meetingRepository.save(meeting).getId(); + + Long participantId = participantRepository.save( + Participant.create(meetingId, 1L, ParticipantState.VOTE_DONE)).getId(); + dateVoteRepository.save(DateVote.create(meetingId, participantId, TOP_DATE, TOP_TIME)); + + RecommendationRun run = RecommendationRun.create(meetingId); + if (recommendationCompleted) { + run.complete(); + } + Long runId = recommendationRunRepository.save(run).getId(); + Long areaId = recommendedAreaRepository.save(RecommendedArea.create(runId, "합정역")).getId(); + Long placeId = recommendedPlaceRepository.save( + RecommendedPlace.create(areaId, "google-place-" + meetingId, "카페", 3)).getId(); + + placeVoteRepository.save(PlaceVote.create(meetingId, participantId, placeId)); + + return meetingId; + } +} diff --git a/src/test/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteCloseServiceTest.java b/src/test/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteCloseServiceTest.java new file mode 100644 index 0000000..58a7cda --- /dev/null +++ b/src/test/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteCloseServiceTest.java @@ -0,0 +1,150 @@ +package com.moive.MoiveBE.domain.vote.service; + +import com.moive.MoiveBE.domain.meeting.entity.Meeting; +import com.moive.MoiveBE.domain.meeting.entity.MeetingStatus; +import com.moive.MoiveBE.domain.meeting.repository.MeetingRepository; +import com.moive.MoiveBE.domain.recommendation.entity.RecommendationRun; +import com.moive.MoiveBE.domain.recommendation.entity.RecommendationStatus; +import com.moive.MoiveBE.domain.recommendation.repository.RecommendationRunRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; + +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PlaceVoteCloseServiceTest { + + private static final int DEADLINE_DAYS = 3; + + @Mock private MeetingRepository meetingRepository; + @Mock private RecommendationRunRepository recommendationRunRepository; + @Mock private VoteService voteService; + + @InjectMocks private PlaceVoteCloseService placeVoteCloseService; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(placeVoteCloseService, "placeVoteDeadlineDays", DEADLINE_DAYS); + } + + @Test + void VOTING_상태인_모임이_없으면_아무것도_하지_않는다() { + when(meetingRepository.findAllByStatus(MeetingStatus.VOTING)).thenReturn(List.of()); + + placeVoteCloseService.finalizeExpiredPlaceVotes(); + + verifyNoInteractions(recommendationRunRepository, voteService); + } + + @Test + void 장소_추천이_완료되지_않은_모임은_대상에서_제외한다() { + // given: VOTING 상태지만 완료된 추천 실행이 아직 없음 (장소 투표 전) + stubVotingMeetings(10L); + when(recommendationRunRepository.findAllByMeetingIdInAndStatus(List.of(10L), RecommendationStatus.COMPLETED)) + .thenReturn(List.of()); + + placeVoteCloseService.finalizeExpiredPlaceVotes(); + + verifyNoInteractions(voteService); + } + + @Test + void 마감_기한이_지나지_않은_모임은_대상에서_제외한다() { + stubVotingMeetings(10L); + stubCompletedRuns(completedRunAt(10L, LocalDateTime.now().minusDays(DEADLINE_DAYS - 1))); + + placeVoteCloseService.finalizeExpiredPlaceVotes(); + + verifyNoInteractions(voteService); + } + + @Test + void 마감_기한이_지난_모임만_모임별로_마감_처리를_요청한다() { + // given: A=기한 안 지남, B&C=기한 지남 + stubVotingMeetings(21L, 22L, 23L); + stubCompletedRuns( + completedRunAt(21L, LocalDateTime.now().minusDays(DEADLINE_DAYS - 1)), + completedRunAt(22L, LocalDateTime.now().minusDays(DEADLINE_DAYS + 2)), + completedRunAt(23L, LocalDateTime.now().minusDays(DEADLINE_DAYS + 2)) + ); + + placeVoteCloseService.finalizeExpiredPlaceVotes(); + + verify(voteService, never()).finalizeExpiredPlaceVote(21L); + verify(voteService).finalizeExpiredPlaceVote(22L); + verify(voteService).finalizeExpiredPlaceVote(23L); + } + + @Test + void 한_모임의_마감_처리가_실패해도_나머지_모임은_계속_처리한다() { + // given: B 처리 중 예외 발생 + stubVotingMeetings(21L, 22L, 23L); + LocalDateTime expired = LocalDateTime.now().minusDays(DEADLINE_DAYS + 2); + stubCompletedRuns(completedRunAt(21L, expired), completedRunAt(22L, expired), completedRunAt(23L, expired)); + when(voteService.finalizeExpiredPlaceVote(21L)).thenReturn(true); + when(voteService.finalizeExpiredPlaceVote(22L)).thenThrow(new IllegalStateException("장소 정보 누락")); + when(voteService.finalizeExpiredPlaceVote(23L)).thenReturn(true); + + // when: 예외가 밖으로 전파되지 않음 + placeVoteCloseService.finalizeExpiredPlaceVotes(); + + // then: 실패한 B 이후의 C까지 처리 + verify(voteService).finalizeExpiredPlaceVote(21L); + verify(voteService).finalizeExpiredPlaceVote(22L); + verify(voteService).finalizeExpiredPlaceVote(23L); + } + + @Test + void 추천_실행이_여러_번_완료된_모임은_가장_늦게_완료된_시각을_장소_투표_시작_시점으로_본다() { + // given: 이전 추천은 기한이 지났지만, 가장 최근 추천은 기한 전 + stubVotingMeetings(10L); + stubCompletedRuns( + completedRunAt(10L, LocalDateTime.now().minusDays(DEADLINE_DAYS + 5)), + completedRunAt(10L, LocalDateTime.now().minusDays(DEADLINE_DAYS - 1)) + ); + + placeVoteCloseService.finalizeExpiredPlaceVotes(); + + verify(voteService, never()).finalizeExpiredPlaceVote(anyLong()); + } + + private void stubVotingMeetings(Long... meetingIds) { + List meetings = Arrays.stream(meetingIds) + .map(id -> { + Meeting meeting = mock(Meeting.class); + lenient().when(meeting.getId()).thenReturn(id); + return meeting; + }) + .toList(); + when(meetingRepository.findAllByStatus(MeetingStatus.VOTING)).thenReturn(meetings); + } + + private void stubCompletedRuns(RecommendationRun... runs) { + when(recommendationRunRepository.findAllByMeetingIdInAndStatus(anyList(), eq(RecommendationStatus.COMPLETED))) + .thenReturn(List.of(runs)); + } + + private RecommendationRun completedRunAt(Long meetingId, LocalDateTime updatedAt) { + RecommendationRun run = mock(RecommendationRun.class); + lenient().when(run.getMeetingId()).thenReturn(meetingId); + lenient().when(run.getUpdatedAt()).thenReturn(updatedAt); + return run; + } +} diff --git a/src/test/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteJoinLeaveConcurrencyTest.java b/src/test/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteJoinLeaveConcurrencyTest.java new file mode 100644 index 0000000..8ed6c6d --- /dev/null +++ b/src/test/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteJoinLeaveConcurrencyTest.java @@ -0,0 +1,209 @@ +package com.moive.MoiveBE.domain.vote.service; + +import com.moive.MoiveBE.domain.meeting.entity.Meeting; +import com.moive.MoiveBE.domain.meeting.entity.MeetingStatus; +import com.moive.MoiveBE.domain.meeting.entity.Participant; +import com.moive.MoiveBE.domain.meeting.entity.ParticipantState; +import com.moive.MoiveBE.domain.meeting.repository.MeetingRepository; +import com.moive.MoiveBE.domain.meeting.repository.ParticipantRepository; +import com.moive.MoiveBE.domain.meeting.service.MeetingLeaveService; +import com.moive.MoiveBE.domain.meeting.service.MeetingService; +import com.moive.MoiveBE.domain.notification.service.NotificationService; +import com.moive.MoiveBE.domain.recommendation.client.GooglePlacesClient; +import com.moive.MoiveBE.domain.recommendation.entity.RecommendationRun; +import com.moive.MoiveBE.domain.recommendation.entity.RecommendedArea; +import com.moive.MoiveBE.domain.recommendation.entity.RecommendedPlace; +import com.moive.MoiveBE.domain.recommendation.repository.RecommendationRunRepository; +import com.moive.MoiveBE.domain.recommendation.repository.RecommendedAreaRepository; +import com.moive.MoiveBE.domain.recommendation.repository.RecommendedPlaceRepository; +import com.moive.MoiveBE.domain.vote.dto.PlaceVoteRequest; +import com.moive.MoiveBE.domain.vote.entity.PlaceVote; +import com.moive.MoiveBE.domain.vote.repository.PlaceVoteRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; + +/** + * [장소 투표 확정 ↔ 모임 참여/모임 나가기 API] 동시성 테스트 + * - 마지막 장소 투표가 장소 확정을 커밋하기 전 모임 참여/모임 나가기 요청이 들어올 경우 확정 결과가 덮어써지지 않는지 검증 + */ +@SpringBootTest +@ActiveProfiles("test") +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:vote-concurrency;MODE=PostgreSQL;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.datasource.hikari.maximum-pool-size=20", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.jpa.show-sql=false", + "spring.sql.init.mode=never" +}) +class PlaceVoteJoinLeaveConcurrencyTest { + + private static final String INVITE_CODE = "test-invite-code"; + private static final long PAUSE_MILLIS = 300; + + @Autowired private VoteService voteService; + @Autowired private MeetingLeaveService meetingLeaveService; + @Autowired private MeetingService meetingService; + @Autowired private MeetingRepository meetingRepository; + @Autowired private ParticipantRepository participantRepository; + @Autowired private PlaceVoteRepository placeVoteRepository; + @Autowired private RecommendationRunRepository recommendationRunRepository; + @Autowired private RecommendedAreaRepository recommendedAreaRepository; + @Autowired private RecommendedPlaceRepository recommendedPlaceRepository; + + @MockitoBean private GooglePlacesClient googlePlacesClient; + @MockitoBean private NotificationService notificationService; + + @AfterEach + void tearDown() { + placeVoteRepository.deleteAllInBatch(); + recommendedPlaceRepository.deleteAllInBatch(); + recommendedAreaRepository.deleteAllInBatch(); + recommendationRunRepository.deleteAllInBatch(); + participantRepository.deleteAllInBatch(); + meetingRepository.deleteAllInBatch(); + } + + @Test + void 마지막_투표로_확정되는_중에_투표한_참여자가_나가도_확정이_유지된다() throws Exception { + // given: A, B는 투표 완료, C가 마지막 투표자 + Long meetingId = votingMeetingWithParticipants(3); + Long placeId = recommendedPlace(meetingId); + Long leavingParticipantId = participantIdOf(meetingId, 2L); + vote(meetingId, participantIdOf(meetingId, 1L), placeId); + vote(meetingId, leavingParticipantId, placeId); + int participantCntBefore = meetingRepository.findById(meetingId).orElseThrow().getParticipantCnt(); + + CountDownLatch confirming = pauseWhenConfirmNotificationSent(); + + // when: C의 마지막 투표로 장소 확정을 커밋하기 전에 B가 모임을 나감 + ExecutorService executor = Executors.newFixedThreadPool(2); + Future lastVote = executor.submit(asUser(3L, + () -> voteService.createPlaceVote(3L, meetingId, new PlaceVoteRequest(List.of(placeId))))); + assertThat(confirming.await(5, TimeUnit.SECONDS)).isTrue(); // 마지막 투표가 '장소 확정 후 ~ 커밋 전' 상태가 될 때까지 대기 + Future leave = executor.submit(asUser(2L, () -> meetingLeaveService.leaveMeeting(meetingId))); + lastVote.get(10, TimeUnit.SECONDS); + leave.get(10, TimeUnit.SECONDS); + executor.shutdown(); + + // then: 장소 확정은 유지되고, 모임 나가기 처리(인원 감소, leftAt)도 함께 반영됨 + Meeting meeting = meetingRepository.findById(meetingId).orElseThrow(); + assertThat(meeting.getStatus()).isEqualTo(MeetingStatus.CONFIRMED); + assertThat(meeting.getConfirmedPlaceId()).isEqualTo(placeId); + + assertThat(meeting.getParticipantCnt()).isEqualTo(participantCntBefore - 1); + Participant leftParticipant = participantRepository.findById(leavingParticipantId).orElseThrow(); + assertThat(leftParticipant.getLeftAt()).isNotNull(); + } + + @Test + void 마지막_투표로_확정되는_중에_새로운_참여자가_입장해도_확정이_유지된다() throws Exception { + // given: A, B는 투표 완료, C가 마지막 투표자 + Long meetingId = votingMeetingWithParticipants(3); + Long placeId = recommendedPlace(meetingId); + vote(meetingId, participantIdOf(meetingId, 1L), placeId); + vote(meetingId, participantIdOf(meetingId, 2L), placeId); + int participantCntBefore = meetingRepository.findById(meetingId).orElseThrow().getParticipantCnt(); + + CountDownLatch confirming = pauseWhenConfirmNotificationSent(); + + // when: C가 마지막으로 투표하며 장소 확정 커밋이 이뤄지기 전, D가 모임 참여 + ExecutorService executor = Executors.newFixedThreadPool(2); + Future lastVote = executor.submit(asUser(3L, + () -> voteService.createPlaceVote(3L, meetingId, new PlaceVoteRequest(List.of(placeId))))); + assertThat(confirming.await(5, TimeUnit.SECONDS)).isTrue(); // 마지막 투표가 "확정 후, 커밋 전" 상태가 될 때까지 대기 + Future join = executor.submit(asUser(4L, () -> meetingService.joinMeeting(INVITE_CODE))); + lastVote.get(10, TimeUnit.SECONDS); + join.get(10, TimeUnit.SECONDS); + executor.shutdown(); + + // then: 장소 확정은 유지되고, 모임 참여 처리(인원 증가, 신규 참여자)도 함께 반영됨 + Meeting meeting = meetingRepository.findById(meetingId).orElseThrow(); + assertThat(meeting.getStatus()).isEqualTo(MeetingStatus.CONFIRMED); + assertThat(meeting.getConfirmedPlaceId()).isEqualTo(placeId); + + assertThat(meeting.getParticipantCnt()).isEqualTo(participantCntBefore + 1); + assertThat(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(meetingId, 4L)) + .get() + .extracting(Participant::getState) + .isEqualTo(ParticipantState.NEW_RESTRICTED); + } + + private Long votingMeetingWithParticipants(int count) { + Meeting meeting = Meeting.create(1L, "테스트 모임", LocalDate.now().plusDays(7), LocalTime.NOON, INVITE_CODE); + for (int i = 1; i < count; i++) { + meeting.incrementParticipantCnt(); + } + meeting.transitionToVoting(); + Long meetingId = meetingRepository.save(meeting).getId(); + for (long userId = 1; userId <= count; userId++) { + participantRepository.save(Participant.create(meetingId, userId, ParticipantState.COND_DONE)); + } + return meetingId; + } + + private Long recommendedPlace(Long meetingId) { + RecommendationRun run = RecommendationRun.create(meetingId); + run.complete(); + Long runId = recommendationRunRepository.save(run).getId(); + Long areaId = recommendedAreaRepository.save(RecommendedArea.create(runId, "합정역")).getId(); + return recommendedPlaceRepository.save(RecommendedPlace.create(areaId, "google-place-1", "카페", 3)).getId(); + } + + private Long participantIdOf(Long meetingId, Long userId) { + return participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(meetingId, userId).orElseThrow().getId(); + } + + private void vote(Long meetingId, Long participantId, Long placeId) { + placeVoteRepository.save(PlaceVote.create(meetingId, participantId, placeId)); + } + + private CountDownLatch pauseWhenConfirmNotificationSent() { + CountDownLatch confirming = new CountDownLatch(1); + doAnswer(invocation -> { + if (confirming.getCount() > 0) { + confirming.countDown(); + Thread.sleep(PAUSE_MILLIS); + } + return null; + }).when(notificationService).sendNotification(anyLong(), anyLong(), any(), anyString()); + return confirming; + } + + private Runnable asUser(Long userId, Runnable task) { + return () -> { + SecurityContextHolder.getContext() + .setAuthentication(new UsernamePasswordAuthenticationToken(userId, null, List.of())); + try { + task.run(); + } finally { + SecurityContextHolder.clearContext(); + } + }; + } +} diff --git a/src/test/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteLeaveConfirmTest.java b/src/test/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteLeaveConfirmTest.java new file mode 100644 index 0000000..577ee3d --- /dev/null +++ b/src/test/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteLeaveConfirmTest.java @@ -0,0 +1,205 @@ +package com.moive.MoiveBE.domain.vote.service; + +import com.moive.MoiveBE.domain.meeting.entity.DateVote; +import com.moive.MoiveBE.domain.meeting.entity.Meeting; +import com.moive.MoiveBE.domain.meeting.entity.MeetingStatus; +import com.moive.MoiveBE.domain.meeting.entity.Participant; +import com.moive.MoiveBE.domain.meeting.entity.ParticipantState; +import com.moive.MoiveBE.domain.meeting.repository.DateVoteRepository; +import com.moive.MoiveBE.domain.meeting.repository.MeetingRepository; +import com.moive.MoiveBE.domain.meeting.repository.ParticipantRepository; +import com.moive.MoiveBE.domain.meeting.service.MeetingLeaveService; +import com.moive.MoiveBE.domain.notification.entity.NotificationType; +import com.moive.MoiveBE.domain.notification.service.NotificationService; +import com.moive.MoiveBE.domain.recommendation.client.GooglePlacesClient; +import com.moive.MoiveBE.domain.recommendation.entity.RecommendationRun; +import com.moive.MoiveBE.domain.recommendation.entity.RecommendedArea; +import com.moive.MoiveBE.domain.recommendation.entity.RecommendedPlace; +import com.moive.MoiveBE.domain.recommendation.repository.RecommendationRunRepository; +import com.moive.MoiveBE.domain.recommendation.repository.RecommendedAreaRepository; +import com.moive.MoiveBE.domain.recommendation.repository.RecommendedPlaceRepository; +import com.moive.MoiveBE.domain.vote.dto.PlaceVoteRequest; +import com.moive.MoiveBE.domain.vote.repository.PlaceVoteRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + + +/** + * [모임 나가기 API] 모임 나가기 후 모임 확정 재판정 테스트 + * - 장소 투표 단계에서 참여자가 나가 남은 투표 가능 참여자가 모두 투표를 마친 상태가 되면 모임이 확정되는지 검증 + */ +@SpringBootTest +@ActiveProfiles("test") +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:vote-concurrency;MODE=PostgreSQL;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.datasource.hikari.maximum-pool-size=20", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.jpa.show-sql=false", + "spring.sql.init.mode=never" +}) +class PlaceVoteLeaveConfirmTest { + + private static final LocalDate TOP_DATE = LocalDate.of(2026, 10, 3); + private static final LocalTime TOP_TIME = LocalTime.of(18, 0); + + @Autowired private VoteService voteService; + @Autowired private MeetingLeaveService meetingLeaveService; + @Autowired private MeetingRepository meetingRepository; + @Autowired private ParticipantRepository participantRepository; + @Autowired private DateVoteRepository dateVoteRepository; + @Autowired private PlaceVoteRepository placeVoteRepository; + @Autowired private RecommendationRunRepository recommendationRunRepository; + @Autowired private RecommendedAreaRepository recommendedAreaRepository; + @Autowired private RecommendedPlaceRepository recommendedPlaceRepository; + + @MockitoBean private GooglePlacesClient googlePlacesClient; + @MockitoBean private NotificationService notificationService; + + @AfterEach + void tearDown() { + placeVoteRepository.deleteAllInBatch(); + dateVoteRepository.deleteAllInBatch(); + recommendedPlaceRepository.deleteAllInBatch(); + recommendedAreaRepository.deleteAllInBatch(); + recommendationRunRepository.deleteAllInBatch(); + participantRepository.deleteAllInBatch(); + meetingRepository.deleteAllInBatch(); + } + + @Test + void 투표하지_않은_마지막_참여자가_나가면_모임이_즉시_확정된다() { + // given: A, B는 장소 투표 완료, C는 미투표 + Long meetingId = votingMeeting(3); + Long placeId = recommendedPlace(meetingId); + voteService.createPlaceVote(1L, meetingId, new PlaceVoteRequest(List.of(placeId))); + voteService.createPlaceVote(2L, meetingId, new PlaceVoteRequest(List.of(placeId))); + assertThat(statusOf(meetingId)).isEqualTo(MeetingStatus.VOTING); + + // when: C가 모임을 나감 + asUser(3L, () -> meetingLeaveService.leaveMeeting(meetingId)).run(); + + // then: 남은 A, B가 모두 투표한 상태 → 모임 확정 (일정, 장소) + Meeting meeting = meetingRepository.findById(meetingId).orElseThrow(); + assertThat(meeting.getStatus()).isEqualTo(MeetingStatus.CONFIRMED); + assertThat(meeting.getConfirmedPlaceId()).isEqualTo(placeId); + assertThat(meeting.getScheduledDate()).isEqualTo(TOP_DATE); + assertThat(meeting.getScheduledTime()).isEqualTo(TOP_TIME); + } + + @Test + void 투표_가능_참여자가_모두_나가_신규_참여자만_남으면_장소_없이_모임이_확정된다() { + // given: A, B(투표 가능), D(투표 시작 후 입장한 신규 참여자) (A만 장소 투표 완료) + Long meetingId = votingMeeting(2); + participantRepository.save(Participant.create(meetingId, 4L, ParticipantState.NEW_RESTRICTED)); + Long placeId = recommendedPlace(meetingId); + voteService.createPlaceVote(1L, meetingId, new PlaceVoteRequest(List.of(placeId))); + + // when: A, B 모두 나감 + asUser(1L, () -> meetingLeaveService.leaveMeeting(meetingId)).run(); + assertThat(statusOf(meetingId)).isEqualTo(MeetingStatus.VOTING); + asUser(2L, () -> meetingLeaveService.leaveMeeting(meetingId)).run(); + + // then: 더 이상 투표할 사람이 없으므로 모임 확정, 나간 A의 표는 제외되어 유효한 표가 없으므로 장소 null & 확정 알림 x + Meeting meeting = meetingRepository.findById(meetingId).orElseThrow(); + assertThat(meeting.getStatus()).isEqualTo(MeetingStatus.CONFIRMED); + assertThat(meeting.getConfirmedPlaceId()).isNull(); + verify(notificationService, never()) + .sendNotification(anyLong(), anyLong(), eq(NotificationType.MEETING_CONFIRMED), anyString()); + } + + @Test + void 장소_추천이_완료되기_전에는_신규_참여자만_남아도_모임을_확정하지_않는다() { + // given: 투표 단계지만 장소 추천이 아직 완료되지 않은 모임, A(투표 가능) + D(신규 참여자) + Long meetingId = votingMeeting(1); + participantRepository.save(Participant.create(meetingId, 4L, ParticipantState.NEW_RESTRICTED)); + + // when: A가 나감 + asUser(1L, () -> meetingLeaveService.leaveMeeting(meetingId)).run(); + + // then: 장소 투표 단계가 아니므로 확정하지 않음 (추천 완료 후 마감 배치에서 처리 예정) + assertThat(statusOf(meetingId)).isEqualTo(MeetingStatus.VOTING); + } + + @Test + void 이미_확정된_모임에서_참여자가_나가도_다시_확정하지_않는다() { + // given: A, B 모두 투표해 확정된 모임 + Long meetingId = votingMeeting(2); + Long placeId = recommendedPlace(meetingId); + voteService.createPlaceVote(1L, meetingId, new PlaceVoteRequest(List.of(placeId))); + voteService.createPlaceVote(2L, meetingId, new PlaceVoteRequest(List.of(placeId))); + assertThat(statusOf(meetingId)).isEqualTo(MeetingStatus.CONFIRMED); + clearInvocations(notificationService); + + // when: A가 나감 (나간 A의 표를 제외하면 순위가 달라질 수 있는 상황) + asUser(1L, () -> meetingLeaveService.leaveMeeting(meetingId)).run(); + + // then: 확정 결과는 유지, 확정 알림 재발송 x + Meeting meeting = meetingRepository.findById(meetingId).orElseThrow(); + assertThat(meeting.getStatus()).isEqualTo(MeetingStatus.CONFIRMED); + assertThat(meeting.getConfirmedPlaceId()).isEqualTo(placeId); + verify(notificationService, never()) + .sendNotification(anyLong(), anyLong(), eq(NotificationType.MEETING_CONFIRMED), anyString()); + } + + private Long votingMeeting(int count) { + Meeting meeting = Meeting.create(1L, "테스트 모임", null, null, "test-invite-code"); + for (int i = 1; i < count; i++) { + meeting.incrementParticipantCnt(); + } + meeting.transitionToVoting(); + Long meetingId = meetingRepository.save(meeting).getId(); + for (long userId = 1; userId <= count; userId++) { + Long participantId = participantRepository.save( + Participant.create(meetingId, userId, ParticipantState.COND_DONE)).getId(); + dateVoteRepository.save(DateVote.create(meetingId, participantId, TOP_DATE, TOP_TIME)); + } + return meetingId; + } + + private Long recommendedPlace(Long meetingId) { + RecommendationRun run = RecommendationRun.create(meetingId); + run.complete(); + Long runId = recommendationRunRepository.save(run).getId(); + Long areaId = recommendedAreaRepository.save(RecommendedArea.create(runId, "합정역")).getId(); + return recommendedPlaceRepository.save(RecommendedPlace.create(areaId, "google-place-1", "카페", 3)).getId(); + } + + private MeetingStatus statusOf(Long meetingId) { + return meetingRepository.findById(meetingId).orElseThrow().getStatus(); + } + + private Runnable asUser(Long userId, Runnable task) { + return () -> { + SecurityContextHolder.getContext() + .setAuthentication(new UsernamePasswordAuthenticationToken(userId, null, List.of())); + try { + task.run(); + } finally { + SecurityContextHolder.clearContext(); + } + }; + } +} diff --git a/src/test/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteMeetingConfirmTest.java b/src/test/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteMeetingConfirmTest.java new file mode 100644 index 0000000..2e1ffc9 --- /dev/null +++ b/src/test/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteMeetingConfirmTest.java @@ -0,0 +1,217 @@ +package com.moive.MoiveBE.domain.vote.service; + +import com.moive.MoiveBE.domain.meeting.entity.DateVote; +import com.moive.MoiveBE.domain.meeting.entity.Meeting; +import com.moive.MoiveBE.domain.meeting.entity.MeetingStatus; +import com.moive.MoiveBE.domain.meeting.entity.Participant; +import com.moive.MoiveBE.domain.meeting.entity.ParticipantState; +import com.moive.MoiveBE.domain.meeting.repository.DateVoteRepository; +import com.moive.MoiveBE.domain.meeting.repository.MeetingRepository; +import com.moive.MoiveBE.domain.meeting.repository.ParticipantRepository; +import com.moive.MoiveBE.domain.notification.service.NotificationService; +import com.moive.MoiveBE.domain.recommendation.client.GooglePlacesClient; +import com.moive.MoiveBE.domain.recommendation.entity.RecommendationRun; +import com.moive.MoiveBE.domain.recommendation.entity.RecommendedArea; +import com.moive.MoiveBE.domain.recommendation.entity.RecommendedPlace; +import com.moive.MoiveBE.domain.recommendation.repository.RecommendationRunRepository; +import com.moive.MoiveBE.domain.recommendation.repository.RecommendedAreaRepository; +import com.moive.MoiveBE.domain.recommendation.repository.RecommendedPlaceRepository; +import com.moive.MoiveBE.domain.vote.dto.PlaceVoteRequest; +import com.moive.MoiveBE.domain.vote.repository.PlaceVoteRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.verifyNoInteractions; + +/** + * [장소 투표 생성 API] 모임 확정(일정,장소) 테스트 + * - 모임 생성 시 일정 미정인 모임에서 마지막 장소 투표로 모임이 확정될 때, 일정(일정 투표 집계 1위)과 장소(장소 투표 득표 1위)가 함께 확정되는지 검증 + */ +@SpringBootTest +@ActiveProfiles("test") +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:vote-concurrency;MODE=PostgreSQL;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.datasource.hikari.maximum-pool-size=20", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.jpa.show-sql=false", + "spring.sql.init.mode=never" +}) +class PlaceVoteMeetingConfirmTest { + + private static final LocalDate TOP_DATE = LocalDate.of(2026, 10, 3); + private static final LocalTime TOP_TIME = LocalTime.of(18, 0); + + @Autowired private VoteService voteService; + @Autowired private MeetingRepository meetingRepository; + @Autowired private ParticipantRepository participantRepository; + @Autowired private DateVoteRepository dateVoteRepository; + @Autowired private PlaceVoteRepository placeVoteRepository; + @Autowired private RecommendationRunRepository recommendationRunRepository; + @Autowired private RecommendedAreaRepository recommendedAreaRepository; + @Autowired private RecommendedPlaceRepository recommendedPlaceRepository; + + @MockitoBean private GooglePlacesClient googlePlacesClient; + @MockitoBean private NotificationService notificationService; + + @AfterEach + void tearDown() { + placeVoteRepository.deleteAllInBatch(); + dateVoteRepository.deleteAllInBatch(); + recommendedPlaceRepository.deleteAllInBatch(); + recommendedAreaRepository.deleteAllInBatch(); + recommendationRunRepository.deleteAllInBatch(); + participantRepository.deleteAllInBatch(); + meetingRepository.deleteAllInBatch(); + } + + @Test + void 마지막으로_장소_투표하면_모임_일정과_장소가_함께_확정된다() { + // given: 일정 미정 모임, 조건 입력(일정 투표) 완료 + Long meetingId = votingMeetingWithoutSchedule(2); + + List placeIds = recommendedPlaces(meetingId, 2); + Long topPlaceId = placeIds.get(0); + Long otherPlaceId = placeIds.get(1); + + LocalDate otherDate = TOP_DATE.plusDays(1); + + dateVoteRepository.save(DateVote.create(meetingId, participantIdOf(meetingId, 1L), TOP_DATE, LocalTime.of(12, 0))); + dateVoteRepository.save(DateVote.create(meetingId, participantIdOf(meetingId, 2L), TOP_DATE, TOP_TIME)); + dateVoteRepository.save(DateVote.create(meetingId, participantIdOf(meetingId, 2L), otherDate, TOP_TIME)); + + // when: A는 두 장소 모두, B는 첫 번째 장소에 투표 (B가 마지막 투표자) + voteService.createPlaceVote(1L, meetingId, new PlaceVoteRequest(List.of(topPlaceId, otherPlaceId))); + Meeting afterFirstVote = meetingRepository.findById(meetingId).orElseThrow(); + voteService.createPlaceVote(2L, meetingId, new PlaceVoteRequest(List.of(topPlaceId))); + + // then: 첫 투표 후에는 아직 미확정 (일정, 장소 모두 비어 있음) + assertThat(afterFirstVote.getStatus()).isEqualTo(MeetingStatus.VOTING); + assertThat(afterFirstVote.getScheduledDate()).isNull(); + assertThat(afterFirstVote.getConfirmedPlaceId()).isNull(); + + // 마지막 투표로 모임 확정 + // - 장소: 득표 1위(topPlaceId: 2표, otherPlaceId: 1표) + // - 일정: 집계 1위 날짜(TOP_DATE: 2명) + 그 날짜의 가장 늦은 시간(TOP_TIME) + Meeting meeting = meetingRepository.findById(meetingId).orElseThrow(); + assertThat(meeting.getStatus()).isEqualTo(MeetingStatus.CONFIRMED); + assertThat(meeting.getConfirmedPlaceId()).isEqualTo(topPlaceId); + assertThat(meeting.getScheduledDate()).isEqualTo(TOP_DATE); + assertThat(meeting.getScheduledTime()).isEqualTo(TOP_TIME); + } + + @Test + void 모임을_나간_참여자의_투표는_모임_확정_시_장소_1위_계산에서_제외된다() { + // given: A, B, C 모두 조건 입력(일정 투표) 완료, 추천 장소 2개 (득표가 같으면 id가 작은 첫 번째 장소가 앞섬) + Long meetingId = votingMeetingWithoutSchedule(3); + List placeIds = recommendedPlaces(meetingId, 2); + Long firstPlaceId = placeIds.get(0); + Long secondPlaceId = placeIds.get(1); + for (long userId = 1; userId <= 3; userId++) { + dateVoteRepository.save(DateVote.create(meetingId, participantIdOf(meetingId, userId), TOP_DATE, TOP_TIME)); + } + + // A는 두 번째 장소에 투표한 뒤 모임을 나감 + voteService.createPlaceVote(1L, meetingId, new PlaceVoteRequest(List.of(secondPlaceId))); + Participant leaving = participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(meetingId, 1L).orElseThrow(); + leaving.leave(); + participantRepository.save(leaving); + + // when: B는 첫 번째 장소, C는 두 번째 장소에 투표 (C가 마지막 투표자) + voteService.createPlaceVote(2L, meetingId, new PlaceVoteRequest(List.of(firstPlaceId))); + voteService.createPlaceVote(3L, meetingId, new PlaceVoteRequest(List.of(secondPlaceId))); + + // then: 나간 A의 표를 제외하면 1:1 동점 → 첫 번째 장소로 확정 (A의 표를 포함하면 두 번째 장소가 2표로 확정됨) + Meeting meeting = meetingRepository.findById(meetingId).orElseThrow(); + assertThat(meeting.getStatus()).isEqualTo(MeetingStatus.CONFIRMED); + assertThat(meeting.getConfirmedPlaceId()).isEqualTo(firstPlaceId); + } + + @Test + void 마감_처리_시_일부만_투표했어도_일정과_현재까지의_득표_1위_장소로_모임이_확정된다() { + // given: A&B 일정 투표 완료, 장소 투표는 A만 완료 (마감 기한 경과 가정) + Long meetingId = votingMeetingWithoutSchedule(2); + List placeIds = recommendedPlaces(meetingId, 2); + Long votedPlaceId = placeIds.get(1); + dateVoteRepository.save(DateVote.create(meetingId, participantIdOf(meetingId, 1L), TOP_DATE, TOP_TIME)); + dateVoteRepository.save(DateVote.create(meetingId, participantIdOf(meetingId, 2L), TOP_DATE, TOP_TIME)); + voteService.createPlaceVote(1L, meetingId, new PlaceVoteRequest(List.of(votedPlaceId))); + + // when: 마감 배치가 이 모임을 처리 + boolean confirmed = voteService.finalizeExpiredPlaceVote(meetingId); + + // then + Meeting meeting = meetingRepository.findById(meetingId).orElseThrow(); + assertThat(confirmed).isTrue(); + assertThat(meeting.getStatus()).isEqualTo(MeetingStatus.CONFIRMED); + assertThat(meeting.getConfirmedPlaceId()).isEqualTo(votedPlaceId); + assertThat(meeting.getScheduledDate()).isEqualTo(TOP_DATE); + assertThat(meeting.getScheduledTime()).isEqualTo(TOP_TIME); + } + + @Test + void 마감_배치가_대상을_고른_뒤_마지막_투표로_확정된_모임은_다시_확정하지_않는다() { + // given: 배치가 VOTING 모임을 대상으로 고른 뒤, 처리하기 전에 마지막 투표로 모임이 확정됨 + Long meetingId = votingMeetingWithoutSchedule(2); + List placeIds = recommendedPlaces(meetingId, 2); + dateVoteRepository.save(DateVote.create(meetingId, participantIdOf(meetingId, 1L), TOP_DATE, TOP_TIME)); + dateVoteRepository.save(DateVote.create(meetingId, participantIdOf(meetingId, 2L), TOP_DATE, TOP_TIME)); + voteService.createPlaceVote(1L, meetingId, new PlaceVoteRequest(List.of(placeIds.get(0)))); + voteService.createPlaceVote(2L, meetingId, new PlaceVoteRequest(List.of(placeIds.get(0)))); + clearInvocations(notificationService); + + // when: 배치가 해당 모임 처리 + boolean confirmed = voteService.finalizeExpiredPlaceVote(meetingId); + + // then: 락을 잡은 뒤 최신 상태(CONFIRMED)를 보고 건너뜀 → 확정 결과 유지, 확정 알림 중복 발송 x + Meeting meeting = meetingRepository.findById(meetingId).orElseThrow(); + assertThat(confirmed).isFalse(); + assertThat(meeting.getConfirmedPlaceId()).isEqualTo(placeIds.get(0)); + verifyNoInteractions(notificationService); + } + + private Long votingMeetingWithoutSchedule(int count) { + Meeting meeting = Meeting.create(1L, "테스트 모임", null, null, "test-invite-code"); + for (int i = 1; i < count; i++) { + meeting.incrementParticipantCnt(); + } + meeting.transitionToVoting(); + Long meetingId = meetingRepository.save(meeting).getId(); + for (long userId = 1; userId <= count; userId++) { + participantRepository.save(Participant.create(meetingId, userId, ParticipantState.COND_DONE)); + } + return meetingId; + } + + private List recommendedPlaces(Long meetingId, int count) { + RecommendationRun run = RecommendationRun.create(meetingId); + run.complete(); + Long runId = recommendationRunRepository.save(run).getId(); + Long areaId = recommendedAreaRepository.save(RecommendedArea.create(runId, "합정역")).getId(); + List placeIds = new ArrayList<>(); + for (int i = 1; i <= count; i++) { + placeIds.add(recommendedPlaceRepository.save( + RecommendedPlace.create(areaId, "google-place-" + i, "카페", 3)).getId()); + } + return placeIds; + } + + private Long participantIdOf(Long meetingId, Long userId) { + return participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(meetingId, userId).orElseThrow().getId(); + } +} diff --git a/src/test/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteServiceConcurrencyTest.java b/src/test/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteServiceConcurrencyTest.java new file mode 100644 index 0000000..6f71ddc --- /dev/null +++ b/src/test/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteServiceConcurrencyTest.java @@ -0,0 +1,221 @@ +package com.moive.MoiveBE.domain.vote.service; + +import com.moive.MoiveBE.domain.meeting.entity.Meeting; +import com.moive.MoiveBE.domain.meeting.entity.MeetingStatus; +import com.moive.MoiveBE.domain.meeting.entity.Participant; +import com.moive.MoiveBE.domain.meeting.entity.ParticipantState; +import com.moive.MoiveBE.domain.meeting.repository.MeetingRepository; +import com.moive.MoiveBE.domain.meeting.repository.ParticipantRepository; +import com.moive.MoiveBE.domain.notification.service.NotificationService; +import com.moive.MoiveBE.domain.recommendation.client.GooglePlacesClient; +import com.moive.MoiveBE.domain.recommendation.entity.RecommendationRun; +import com.moive.MoiveBE.domain.recommendation.entity.RecommendedArea; +import com.moive.MoiveBE.domain.recommendation.entity.RecommendedPlace; +import com.moive.MoiveBE.domain.recommendation.repository.RecommendationRunRepository; +import com.moive.MoiveBE.domain.recommendation.repository.RecommendedAreaRepository; +import com.moive.MoiveBE.domain.recommendation.repository.RecommendedPlaceRepository; +import com.moive.MoiveBE.domain.vote.dto.PlaceVoteRequest; +import com.moive.MoiveBE.domain.vote.entity.PlaceVote; +import com.moive.MoiveBE.domain.vote.repository.PlaceVoteRepository; +import com.moive.MoiveBE.global.exception.CustomErrorCode; +import com.moive.MoiveBE.global.exception.CustomException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * [장소 투표 생성 API] 동시성 테스트 + */ +@SpringBootTest +@ActiveProfiles("test") +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:vote-concurrency;MODE=PostgreSQL;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.datasource.hikari.maximum-pool-size=20", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.jpa.show-sql=false", + "spring.sql.init.mode=never" +}) +class PlaceVoteServiceConcurrencyTest { + + private static final int VOTER_CNT = 10; + + @Autowired private VoteService voteService; + @Autowired private MeetingRepository meetingRepository; + @Autowired private ParticipantRepository participantRepository; + @Autowired private PlaceVoteRepository placeVoteRepository; + @Autowired private RecommendationRunRepository recommendationRunRepository; + @Autowired private RecommendedAreaRepository recommendedAreaRepository; + @Autowired private RecommendedPlaceRepository recommendedPlaceRepository; + + @MockitoBean private GooglePlacesClient googlePlacesClient; + @MockitoBean private NotificationService notificationService; + + private Long meetingId; + private List participants; + private List placeIds; + + @BeforeEach + void setUp() { + Meeting meeting = Meeting.create(1L, "장소 투표 동시성 테스트용 모임", LocalDate.now().plusDays(7), LocalTime.NOON, "test-invite-code"); + meeting.transitionToVoting(); + meetingId = meetingRepository.save(meeting).getId(); + + participants = new ArrayList<>(); + for (long userId = 1; userId <= VOTER_CNT; userId++) { + participants.add(participantRepository.save( + Participant.create(meetingId, userId, ParticipantState.COND_DONE))); + } + + RecommendationRun run = RecommendationRun.create(meetingId); + run.complete(); + Long runId = recommendationRunRepository.save(run).getId(); + Long areaId = recommendedAreaRepository.save(RecommendedArea.create(runId, "합정역")).getId(); + placeIds = List.of( + recommendedPlaceRepository.save(RecommendedPlace.create(areaId, "google-place-1", "카페", 3)).getId(), + recommendedPlaceRepository.save(RecommendedPlace.create(areaId, "google-place-2", "레스토랑", 2)).getId(), + recommendedPlaceRepository.save(RecommendedPlace.create(areaId, "google-place-3", "영화관", 1)).getId() + ); + } + + @AfterEach + void tearDown() { + placeVoteRepository.deleteAllInBatch(); + recommendedPlaceRepository.deleteAllInBatch(); + recommendedAreaRepository.deleteAllInBatch(); + recommendationRunRepository.deleteAllInBatch(); + participantRepository.deleteAllInBatch(); + meetingRepository.deleteAllInBatch(); + } + + @Test + void 전원이_동시에_투표해도_마지막_투표에서_장소가_한_번만_확정된다() throws InterruptedException { + // given: 투표 가능 인원 10명이 한 명도 투표하지 않은 상태 가정 + + // when: 10명이 동시에 투표 + List tasks = participants.stream() + .map(p -> () -> voteService.createPlaceVote( + p.getUserId(), meetingId, new PlaceVoteRequest(List.of(placeIds.get(0))))) + .toList(); + Result result = runConcurrently(tasks); + + // then: 모든 투표 성공 + 모임 장소 확정 + 참여자당 1회 확정 알림 + assertThat(result.successCnt()).isEqualTo(VOTER_CNT); + assertThat(placeVoteRepository.countDistinctVoters(meetingId)).isEqualTo(VOTER_CNT); + + Meeting meeting = meetingRepository.findById(meetingId).orElseThrow(); + assertThat(meeting.getStatus()).isEqualTo(MeetingStatus.CONFIRMED); + assertThat(meeting.getConfirmedPlaceId()).isEqualTo(placeIds.get(0)); + + assertThat(participantRepository.findAllByMeetingIdAndLeftAtIsNull(meetingId)) + .extracting(Participant::getState) + .containsOnly(ParticipantState.CONFIRMED); + verify(notificationService, times(VOTER_CNT)) + .sendNotification(anyLong(), anyLong(), any(), anyString()); + } + + @Test + void 같은_참여자가_서로_다른_장소로_동시에_여러_번_요청해도_한_번만_저장된다() throws InterruptedException { + // given: 같은 참여자가 서로 다른 장소 조합으로 5번 동시 요청 + Participant me = participants.get(0); + List> requests = List.of( + List.of(placeIds.get(0)), + List.of(placeIds.get(1)), + List.of(placeIds.get(2)), + List.of(placeIds.get(0), placeIds.get(1)), + List.of(placeIds.get(1), placeIds.get(2)) + ); + + // when + List tasks = requests.stream() + .map(ids -> () -> voteService.createPlaceVote( + me.getUserId(), meetingId, new PlaceVoteRequest(ids))) + .toList(); + Result result = runConcurrently(tasks); + + // then: 1건만 성공, 나머지는 기투표 예외 발생 + assertThat(result.successCnt()).isEqualTo(1); + assertThat(result.errorCnt(CustomErrorCode.PLACE_VOTE_ALREADY_DONE)).isEqualTo(requests.size() - 1); + + List myVotes = placeVoteRepository.findAll().stream() + .filter(v -> v.getParticipantId().equals(me.getId())) + .toList(); + assertThat(requests).anySatisfy(ids -> + assertThat(myVotes).extracting(PlaceVote::getRecommendedPlaceId) + .containsExactlyInAnyOrderElementsOf(ids)); + } + + // 모든 작업 동시 실행 후 성공 수/에러코드별 실패 수 집계 + private Result runConcurrently(List tasks) throws InterruptedException { + int threadCnt = tasks.size(); + ExecutorService executor = Executors.newFixedThreadPool(threadCnt); + CountDownLatch ready = new CountDownLatch(threadCnt); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threadCnt); + + AtomicInteger successCnt = new AtomicInteger(); + Map errorCnts = new ConcurrentHashMap<>(); + List unexpected = new ArrayList<>(); + + for (Runnable task : tasks) { + executor.submit(() -> { + ready.countDown(); + try { + start.await(); + task.run(); + successCnt.incrementAndGet(); + } catch (CustomException e) { + errorCnts.computeIfAbsent(e.getCustomErrorCode(), k -> new AtomicInteger()).incrementAndGet(); + } catch (Throwable t) { + synchronized (unexpected) { + unexpected.add(t); + } + } finally { + done.countDown(); + } + }); + } + + ready.await(); + start.countDown(); + assertThat(done.await(30, TimeUnit.SECONDS)).isTrue(); + executor.shutdown(); + + assertThat(unexpected).isEmpty(); + return new Result(successCnt.get(), errorCnts); + } + + private record Result(int successCnt, Map errorCnts) { + int errorCnt(CustomErrorCode code) { + AtomicInteger cnt = errorCnts.get(code); + return cnt == null ? 0 : cnt.get(); + } + } +} diff --git a/src/test/java/com/moive/MoiveBE/domain/vote/service/VoteServiceTest.java b/src/test/java/com/moive/MoiveBE/domain/vote/service/VoteServiceTest.java index 94345d8..844ae2f 100644 --- a/src/test/java/com/moive/MoiveBE/domain/vote/service/VoteServiceTest.java +++ b/src/test/java/com/moive/MoiveBE/domain/vote/service/VoteServiceTest.java @@ -29,19 +29,17 @@ import com.moive.MoiveBE.global.exception.CustomErrorCode; import com.moive.MoiveBE.global.exception.CustomException; import org.assertj.core.api.ThrowableAssert.ThrowingCallable; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Pageable; -import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.LocalDate; -import java.time.LocalDateTime; import java.time.LocalTime; import java.util.Arrays; import java.util.List; @@ -54,6 +52,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -83,11 +82,6 @@ class VoteServiceTest { @InjectMocks private VoteService voteService; - @BeforeEach - void setUp() { - ReflectionTestUtils.setField(voteService, "placeVoteDeadlineDays", 3); - } - /** * [일정 투표 현황 조회] 테스트 */ @@ -223,7 +217,7 @@ void setUp() { } @Test - void 일정_미확정이고_후보가_있으면_1위_일정으로_모임_일정이_확정된다() { + void 일정_미확정이어도_일정_투표_현황_조회는_집계만_하고_모임_일정을_확정하지_않는다() { // given: scheduledDate/scheduledTime 모두 null Meeting meeting = mock(Meeting.class); stubMeetingAndParticipant(meeting, MY_PARTICIPANT_ID); @@ -236,10 +230,12 @@ void setUp() { )); // when - voteService.getMeetingScheduleVoteResult(USER_ID, MEETING_ID); + DateVoteResultResponse response = voteService.getMeetingScheduleVoteResult(USER_ID, MEETING_ID); - // then: 1위(9/15 18:00)로 모임 일정이 확정됨 - verify(meeting).confirmSchedule(LocalDate.of(2026, 9, 15), LocalTime.of(18, 0)); + // then: 집계 결과 반환, 일정 확정은 진행x + assertThat(response.candidates()).extracting(DateVoteResultResponse.Candidate::meetingDate) + .containsExactly(LocalDate.of(2026, 9, 15), LocalDate.of(2026, 9, 12)); + verify(meeting, never()).confirmSchedule(any(), any()); } @Test @@ -267,7 +263,7 @@ void setUp() { void 이미_장소가_확정된_모임이면_PLACE_VOTE_CLOSED() { // given Meeting meeting = meetingWithStatus(MeetingStatus.CONFIRMED); - when(meetingRepository.findById(MEETING_ID)).thenReturn(Optional.of(meeting)); + when(meetingRepository.findByIdForUpdate(MEETING_ID)).thenReturn(Optional.of(meeting)); // when & then assertErrorCode( @@ -282,7 +278,7 @@ void setUp() { void 조건_입력_중인_모임이면_PLACE_VOTE_NOT_STARTED() { // given Meeting meeting = meetingWithStatus(MeetingStatus.CONDITION_INPUT); - when(meetingRepository.findById(MEETING_ID)).thenReturn(Optional.of(meeting)); + when(meetingRepository.findByIdForUpdate(MEETING_ID)).thenReturn(Optional.of(meeting)); // when & then assertErrorCode( @@ -297,7 +293,7 @@ void setUp() { void 장소_추천이_실행되지_않았다면_PLACE_VOTE_NOT_STARTED() { // given Meeting meeting = meetingWithStatus(MeetingStatus.VOTING); - when(meetingRepository.findById(MEETING_ID)).thenReturn(Optional.of(meeting)); + when(meetingRepository.findByIdForUpdate(MEETING_ID)).thenReturn(Optional.of(meeting)); when(recommendationRunRepository.findTopByMeetingIdAndStatusOrderByCreatedAtDesc(MEETING_ID, RecommendationStatus.COMPLETED)) .thenReturn(Optional.empty()); @@ -399,7 +395,7 @@ void setUp() { stubMeetingAndRecommendation(meeting, 101L, 102L, 103L); stubParticipant(); when(placeVoteRepository.existsByMeetingIdAndParticipantId(MEETING_ID, MY_PARTICIPANT_ID)).thenReturn(false); - when(placeVoteRepository.countDistinctVoters(MEETING_ID)).thenReturn(2L); + when(participantRepository.countParticipantsYetToVote(MEETING_ID, ParticipantState.NEW_RESTRICTED)).thenReturn(3L); // when: 101L 중복 선택 voteService.createPlaceVote(USER_ID, MEETING_ID, placeVoteRequest(101L, 102L, 101L)); @@ -429,8 +425,7 @@ void setUp() { .thenReturn(Optional.of(participant)); when(placeVoteRepository.existsByMeetingIdAndParticipantId(MEETING_ID, MY_PARTICIPANT_ID)).thenReturn(false); - when(placeVoteRepository.countDistinctVoters(MEETING_ID)).thenReturn(2L); - when(participantRepository.countByMeetingIdAndLeftAtIsNullAndStateNot(MEETING_ID, ParticipantState.NEW_RESTRICTED)).thenReturn(5L); + when(participantRepository.countParticipantsYetToVote(MEETING_ID, ParticipantState.NEW_RESTRICTED)).thenReturn(3L); // when voteService.createPlaceVote(USER_ID, MEETING_ID, placeVoteRequest(101L)); @@ -439,6 +434,23 @@ void setUp() { verify(participant).completeVote(); } + @Test + void 투표_가능_참여자_중_미투표자가_남아있으면_모임이_확정되지_않는다() { + // given + Meeting meeting = meetingWithStatus(MeetingStatus.VOTING); + stubMeetingAndRecommendation(meeting, 101L, 102L); + stubParticipant(); + when(placeVoteRepository.existsByMeetingIdAndParticipantId(MEETING_ID, MY_PARTICIPANT_ID)).thenReturn(false); + when(participantRepository.countParticipantsYetToVote(MEETING_ID, ParticipantState.NEW_RESTRICTED)).thenReturn(1L); + + // when + voteService.createPlaceVote(USER_ID, MEETING_ID, placeVoteRequest(101L)); + + // then + verify(meeting, never()).confirmPlace(any()); + verify(notificationService, never()).sendNotification(any(), any(), any(), any()); + } + @Test void 마지막_투표자가_투표하면_득표_1위_장소로_모임이_확정된다() { // given: 참여자 2명 모임, 마지막 투표 상황 가정 @@ -449,8 +461,7 @@ void setUp() { lenient().when(participantRepository.findAllByMeetingIdAndLeftAtIsNullOrderByJoinedAtAsc(MEETING_ID)) .thenReturn(List.of()); when(placeVoteRepository.existsByMeetingIdAndParticipantId(MEETING_ID, MY_PARTICIPANT_ID)).thenReturn(false); - when(placeVoteRepository.countDistinctVoters(MEETING_ID)).thenReturn(2L); - when(participantRepository.countByMeetingIdAndLeftAtIsNullAndStateNot(MEETING_ID, ParticipantState.NEW_RESTRICTED)).thenReturn(2L); + when(participantRepository.countParticipantsYetToVote(MEETING_ID, ParticipantState.NEW_RESTRICTED)).thenReturn(0L); // 투표 집계: 101L=2표 (1위), 102L=1표 // (확정 시점의 rankCandidates는 "조회하는 나"가 없는 배치성 호출이라 viewer 참여자 id는 구현 세부사항 -> any() 매칭) @@ -471,6 +482,62 @@ void setUp() { verify(meeting).confirmPlace(101L); } + @Test + void 마지막_투표자가_투표하면_투표_집계_결과_기반으로_모임_일정과_장소가_함께_확정된다() { + // given: 일정 미정 모임(scheduledDate/scheduledTime null), 마지막 투표 상황 + Meeting meeting = meetingWithStatus(MeetingStatus.VOTING); + stubMeetingAndRecommendation(meeting, 101L, 102L); + stubParticipant(); + when(placeVoteRepository.existsByMeetingIdAndParticipantId(MEETING_ID, MY_PARTICIPANT_ID)).thenReturn(false); + when(participantRepository.countParticipantsYetToVote(MEETING_ID, ParticipantState.NEW_RESTRICTED)).thenReturn(0L); + + // 일정 집계: 유효 참여자 기준 1위 10/3 18:00 + Participant active = mock(Participant.class); + when(active.getId()).thenReturn(500L); + when(participantRepository.findAllByMeetingIdAndLeftAtIsNull(MEETING_ID)).thenReturn(List.of(active)); + LocalDate topDate = LocalDate.of(2026, 10, 3); + LocalTime topTime = LocalTime.of(18, 0); + when(dateVoteRepository.aggregateTopDates(eq(MEETING_ID), any(), eq(List.of(500L)), any())) + .thenReturn(List.of(new DateVoteSummary(topDate, topTime, 3L, 0L))); + + // 장소 집계: 101L 1위 + when(placeVoteRepository.aggregateByPlace(eq(MEETING_ID), any())).thenReturn(List.of( + new PlaceVoteSummary(101L, 2L, 1L) + )); + RecommendedPlace place101 = recommendedPlaceWithId(101L, "gp-101", 1); + when(recommendedPlaceRepository.findAllById(anyList())).thenReturn(List.of(place101)); + lenient().when(googlePlacesClient.getPlaceLocation(any())).thenThrow(new CustomException(PLACE_INFO_LOOKUP_FAILED)); + + // when + voteService.createPlaceVote(USER_ID, MEETING_ID, placeVoteRequest(101L)); + + // then + InOrder inOrder = inOrder(meeting); + inOrder.verify(meeting).confirmSchedule(topDate, topTime); + inOrder.verify(meeting).confirmPlace(101L); + } + + @Test + void 모임_생성_시_일정이_확정된_모임은_마지막_투표로_확정될_때_일정_집계를_하지_않는다() { + // given: 일정이 이미 있는 모임, 마지막 투표 상황 + Meeting meeting = meetingWithStatus(MeetingStatus.VOTING); + when(meeting.getScheduledDate()).thenReturn(LocalDate.of(2026, 10, 3)); + when(meeting.getScheduledTime()).thenReturn(LocalTime.of(18, 0)); + stubMeetingAndRecommendation(meeting, 101L); + stubParticipant(); + when(placeVoteRepository.existsByMeetingIdAndParticipantId(MEETING_ID, MY_PARTICIPANT_ID)).thenReturn(false); + when(participantRepository.countParticipantsYetToVote(MEETING_ID, ParticipantState.NEW_RESTRICTED)).thenReturn(0L); + when(placeVoteRepository.aggregateByPlace(eq(MEETING_ID), any())).thenReturn(List.of()); + + // when + voteService.createPlaceVote(USER_ID, MEETING_ID, placeVoteRequest(101L)); + + // then: 일정은 그대로 두고 장소만 확정 + verifyNoInteractions(dateVoteRepository); + verify(meeting, never()).confirmSchedule(any(), any()); + verify(meeting).confirmPlace(null); + } + @Test void 마지막_투표자가_투표하면_전체_참여자에게_MEETING_CONFIRMED_알림이_발송된다() { // given: 참여자 2명 모임, 마지막 투표 상황 @@ -488,8 +555,7 @@ void setUp() { .thenReturn(List.of(p1, p2)); when(placeVoteRepository.existsByMeetingIdAndParticipantId(MEETING_ID, MY_PARTICIPANT_ID)).thenReturn(false); - when(placeVoteRepository.countDistinctVoters(MEETING_ID)).thenReturn(2L); - when(participantRepository.countByMeetingIdAndLeftAtIsNullAndStateNot(MEETING_ID, ParticipantState.NEW_RESTRICTED)).thenReturn(2L); + when(participantRepository.countParticipantsYetToVote(MEETING_ID, ParticipantState.NEW_RESTRICTED)).thenReturn(0L); when(placeVoteRepository.aggregateByPlace(eq(MEETING_ID), any())).thenReturn(List.of( new PlaceVoteSummary(101L, 2L, 1L), @@ -511,67 +577,15 @@ void setUp() { } /** - * [장소 투표 마감 배치] 테스트 + * [장소 투표 마감 처리 (모임 1건)] 테스트 + * - 대상 모임 선정 및 모임별 격리는 PlaceVoteCloseServiceTest에서 검증 */ @Test - void VOTING_상태인_모임이_없으면_아무것도_하지_않는다() { + void 마감_처리_시_1명이라도_투표했으면_그때까지의_집계_1위로_확정한다() { // given - when(meetingRepository.findAllByStatus(MeetingStatus.VOTING)).thenReturn(List.of()); - - // when - voteService.finalizeExpiredPlaceVotes(); - - // then - verifyNoInteractions(recommendationRunRepository, placeVoteRepository); - } - - @Test - void 장소_투표가_아직_시작되지_않은_모임은_대상에서_제외한다() { - // given: VOTING 상태지만 완료된 추천 실행이 아직 없음(추천 생성 전) - Meeting meeting = mock(Meeting.class); - lenient().when(meeting.getId()).thenReturn(MEETING_ID); - when(meetingRepository.findAllByStatus(MeetingStatus.VOTING)).thenReturn(List.of(meeting)); - when(recommendationRunRepository.findAllByMeetingIdInAndStatus(List.of(MEETING_ID), RecommendationStatus.COMPLETED)) - .thenReturn(List.of()); - - // when - voteService.finalizeExpiredPlaceVotes(); - - // then - verify(meeting, never()).confirmPlace(any()); - verifyNoInteractions(placeVoteRepository); - } - - @Test - void 마감_기한이_지나지_않았으면_대상에서_제외한다() { - // given - Meeting meeting = mock(Meeting.class); - lenient().when(meeting.getId()).thenReturn(MEETING_ID); - when(meetingRepository.findAllByStatus(MeetingStatus.VOTING)).thenReturn(List.of(meeting)); - - RecommendationRun freshRun = completedRunAt(MEETING_ID, LocalDateTime.now().minusDays(1)); - when(recommendationRunRepository.findAllByMeetingIdInAndStatus(List.of(MEETING_ID), RecommendationStatus.COMPLETED)) - .thenReturn(List.of(freshRun)); - - // when - voteService.finalizeExpiredPlaceVotes(); - - // then - verify(meeting, never()).confirmPlace(any()); - verifyNoInteractions(placeVoteRepository); - } - - @Test - void 마감_기한이_지났고_1명이라도_투표했으면_그때까지의_집계_1위로_확정한다() { - // given - Meeting meeting = mock(Meeting.class); - lenient().when(meeting.getId()).thenReturn(MEETING_ID); - when(meetingRepository.findAllByStatus(MeetingStatus.VOTING)).thenReturn(List.of(meeting)); - RecommendationRun expiredRun = completedRunAt(MEETING_ID, LocalDateTime.now().minusDays(4)); - when(recommendationRunRepository.findAllByMeetingIdInAndStatus(List.of(MEETING_ID), RecommendationStatus.COMPLETED)) - .thenReturn(List.of(expiredRun)); - + Meeting meeting = meetingWithStatus(MeetingStatus.VOTING); + when(meetingRepository.findByIdForUpdate(MEETING_ID)).thenReturn(Optional.of(meeting)); when(placeVoteRepository.aggregateByPlace(eq(MEETING_ID), any())).thenReturn(List.of( new PlaceVoteSummary(101L, 3L, 0L), new PlaceVoteSummary(102L, 1L, 0L) @@ -583,41 +597,34 @@ void setUp() { lenient().when(googlePlacesClient.getPlaceLocation(any())).thenThrow(new CustomException(PLACE_INFO_LOOKUP_FAILED)); // when - voteService.finalizeExpiredPlaceVotes(); + boolean confirmed = voteService.finalizeExpiredPlaceVote(MEETING_ID); // then + assertThat(confirmed).isTrue(); verify(meeting).confirmPlace(101L); } @Test - void 마감_기한이_지났고_아무도_투표하지_않았으면_장소_없이_상태만_확정한다() { + void 마감_처리_시_아무도_투표하지_않았으면_장소_없이_상태만_확정한다() { // given - Meeting meeting = mock(Meeting.class); - lenient().when(meeting.getId()).thenReturn(MEETING_ID); - when(meetingRepository.findAllByStatus(MeetingStatus.VOTING)).thenReturn(List.of(meeting)); - - RecommendationRun expiredRun = completedRunAt(MEETING_ID, LocalDateTime.now().minusDays(10)); - when(recommendationRunRepository.findAllByMeetingIdInAndStatus(List.of(MEETING_ID), RecommendationStatus.COMPLETED)) - .thenReturn(List.of(expiredRun)); + Meeting meeting = meetingWithStatus(MeetingStatus.VOTING); + when(meetingRepository.findByIdForUpdate(MEETING_ID)).thenReturn(Optional.of(meeting)); when(placeVoteRepository.aggregateByPlace(eq(MEETING_ID), any())).thenReturn(List.of()); // when - voteService.finalizeExpiredPlaceVotes(); + boolean confirmed = voteService.finalizeExpiredPlaceVote(MEETING_ID); // then: 장소 확정 없이(null) 상태만 확정, 장소 상세 조회 진행 x + assertThat(confirmed).isTrue(); verify(meeting).confirmPlace(null); verifyNoInteractions(recommendedPlaceRepository, googlePlacesClient); } @Test - void 마감_기한이_지났고_아무도_투표하지_않은_경우_일정이_미확정이면_일정_투표_집계_1위로_일정을_확정한다() { + void 마감_처리_시_일정이_미확정이면_일정_투표_집계_1위로_일정도_함께_확정한다() { // given: 일정 미확정 모임 + 전원 장소 투표 x - Meeting meeting = mock(Meeting.class); - lenient().when(meeting.getId()).thenReturn(MEETING_ID); - when(meetingRepository.findAllByStatus(MeetingStatus.VOTING)).thenReturn(List.of(meeting)); - RecommendationRun expiredRun = completedRunAt(MEETING_ID, LocalDateTime.now().minusDays(10)); - when(recommendationRunRepository.findAllByMeetingIdInAndStatus(List.of(MEETING_ID), RecommendationStatus.COMPLETED)) - .thenReturn(List.of(expiredRun)); + Meeting meeting = meetingWithStatus(MeetingStatus.VOTING); + when(meetingRepository.findByIdForUpdate(MEETING_ID)).thenReturn(Optional.of(meeting)); when(placeVoteRepository.aggregateByPlace(eq(MEETING_ID), any())).thenReturn(List.of()); Long activeParticipantId = 500L; @@ -631,7 +638,7 @@ void setUp() { .thenReturn(List.of(new DateVoteSummary(topDate, topTime, 3L, 0L))); // when - voteService.finalizeExpiredPlaceVotes(); + voteService.finalizeExpiredPlaceVote(MEETING_ID); // then: 일정은 일정 투표 집계 1위로 확정, 장소는 미정인 채로 모임 상태만 확정 verify(meeting).confirmSchedule(topDate, topTime); @@ -639,20 +646,16 @@ void setUp() { } @Test - void 마감_배치_시_이미_일정이_확정된_모임은_일정_투표_집계를_하지_않는다() { - // given: 모임 생성 시 일정을 확정한 모임 (또는 이미 일정 투표 현황 조회로 확정된 모임) - Meeting meeting = mock(Meeting.class); - lenient().when(meeting.getId()).thenReturn(MEETING_ID); + void 마감_처리_시_이미_일정이_확정된_모임은_일정_투표_집계를_하지_않는다() { + // given: 모임 생성 시 일정을 확정한 모임 + Meeting meeting = meetingWithStatus(MeetingStatus.VOTING); when(meeting.getScheduledDate()).thenReturn(LocalDate.of(2026, 10, 3)); when(meeting.getScheduledTime()).thenReturn(LocalTime.of(18, 0)); - when(meetingRepository.findAllByStatus(MeetingStatus.VOTING)).thenReturn(List.of(meeting)); - RecommendationRun expiredRun = completedRunAt(MEETING_ID, LocalDateTime.now().minusDays(10)); - when(recommendationRunRepository.findAllByMeetingIdInAndStatus(List.of(MEETING_ID), RecommendationStatus.COMPLETED)) - .thenReturn(List.of(expiredRun)); + when(meetingRepository.findByIdForUpdate(MEETING_ID)).thenReturn(Optional.of(meeting)); when(placeVoteRepository.aggregateByPlace(eq(MEETING_ID), any())).thenReturn(List.of()); // when - voteService.finalizeExpiredPlaceVotes(); + voteService.finalizeExpiredPlaceVote(MEETING_ID); // then verifyNoInteractions(dateVoteRepository); @@ -661,42 +664,32 @@ void setUp() { } @Test - void 여러_모임을_한번에_처리하며_각각_기한_경과_여부에_따라_다르게_처리한다() { - // given: A=아직 기한 안 지남(스킵), B=기한 지나고 투표 있음(확정), C=기한 지나고 투표 없음(장소 없이 확정) - Long meetingA = 21L, meetingB = 22L, meetingC = 23L; - Meeting a = mock(Meeting.class); - Meeting b = mock(Meeting.class); - Meeting c = mock(Meeting.class); - - lenient().when(a.getId()).thenReturn(meetingA); - lenient().when(b.getId()).thenReturn(meetingB); - lenient().when(c.getId()).thenReturn(meetingC); - when(meetingRepository.findAllByStatus(MeetingStatus.VOTING)).thenReturn(List.of(a, b, c)); - - RecommendationRun runA = completedRunAt(meetingA, LocalDateTime.now().minusDays(1)); - RecommendationRun runB = completedRunAt(meetingB, LocalDateTime.now().minusDays(5)); - RecommendationRun runC = completedRunAt(meetingC, LocalDateTime.now().minusDays(5)); + void 마감_처리_시_대상_조회_이후_이미_확정된_모임은_다시_확정하지_않는다() { + // given: 배치가 대상을 고른 뒤, 락을 잡기 전에 마지막 투표(또는 모임 나가기)로 확정 처리된 모임 + Meeting meeting = meetingWithStatus(MeetingStatus.CONFIRMED); + when(meetingRepository.findByIdForUpdate(MEETING_ID)).thenReturn(Optional.of(meeting)); - when(recommendationRunRepository.findAllByMeetingIdInAndStatus(List.of(meetingA, meetingB, meetingC), RecommendationStatus.COMPLETED)) - .thenReturn(List.of(runA, runB, runC)); + // when + boolean confirmed = voteService.finalizeExpiredPlaceVote(MEETING_ID); - when(placeVoteRepository.aggregateByPlace(eq(meetingB), any())).thenReturn(List.of( - new PlaceVoteSummary(201L, 1L, 0L) - )); - when(placeVoteRepository.aggregateByPlace(eq(meetingC), any())).thenReturn(List.of()); + // then: 확정, 알림 건너뜀 + assertThat(confirmed).isFalse(); + verify(meeting, never()).confirmPlace(any()); + verifyNoInteractions(placeVoteRepository, dateVoteRepository, notificationService); + } - RecommendedPlace place201 = recommendedPlaceWithId(201L, "gp-201", 1); - when(recommendedPlaceRepository.findAllById(anyList())).thenReturn(List.of(place201)); - when(participantRepository.findAllByMeetingIdAndLeftAtIsNull(meetingB)).thenReturn(List.of()); - when(participantPreferenceRepository.findAllByParticipantIdIn(anyList())).thenReturn(List.of()); + @Test + void 마감_처리_시_대상_조회_이후_종료된_모임은_확정하지_않는다() { + // given: 배치가 대상을 고른 뒤, 마지막 참여자가 나가 종료 처리된 모임 + Meeting meeting = meetingWithStatus(MeetingStatus.COMPLETED); + when(meetingRepository.findByIdForUpdate(MEETING_ID)).thenReturn(Optional.of(meeting)); // when - voteService.finalizeExpiredPlaceVotes(); + boolean confirmed = voteService.finalizeExpiredPlaceVote(MEETING_ID); - // then - verify(a, never()).confirmPlace(any()); - verify(b).confirmPlace(201L); - verify(c).confirmPlace(null); + // then: 종료된 모임을 CONFIRMED로 변경하지 않음 + assertThat(confirmed).isFalse(); + verify(meeting, never()).confirmPlace(any()); } /** @@ -912,7 +905,7 @@ private Meeting meetingWithStatus(MeetingStatus status) { } private void stubMeetingAndRecommendation(Meeting meeting, Long... validRecommendedPlaceIds) { - when(meetingRepository.findById(MEETING_ID)).thenReturn(Optional.of(meeting)); + when(meetingRepository.findByIdForUpdate(MEETING_ID)).thenReturn(Optional.of(meeting)); RecommendationRun run = mock(RecommendationRun.class); lenient().when(run.getId()).thenReturn(RECOMMENDATION_RUN_ID); @@ -934,13 +927,6 @@ private void stubMeetingAndAccess(Meeting meeting) { stubParticipant(); } - private RecommendationRun completedRunAt(Long meetingId, LocalDateTime updatedAt) { - RecommendationRun run = mock(RecommendationRun.class); - lenient().when(run.getMeetingId()).thenReturn(meetingId); - lenient().when(run.getUpdatedAt()).thenReturn(updatedAt); - return run; - } - private void stubRecommendedPlaces( Long placeId1, String googlePlaceId1, int preferenceMatchCnt1, Long placeId2, String googlePlaceId2, int preferenceMatchCnt2 diff --git a/src/test/java/com/moive/MoiveBE/global/scheduler/MeetingLifecycleSchedulerTest.java b/src/test/java/com/moive/MoiveBE/global/scheduler/MeetingLifecycleSchedulerTest.java new file mode 100644 index 0000000..241cd68 --- /dev/null +++ b/src/test/java/com/moive/MoiveBE/global/scheduler/MeetingLifecycleSchedulerTest.java @@ -0,0 +1,90 @@ +package com.moive.MoiveBE.global.scheduler; + +import com.moive.MoiveBE.domain.meeting.service.MeetingService; +import com.moive.MoiveBE.domain.vote.service.PlaceVoteCloseService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MeetingLifecycleSchedulerTest { + + @Mock private PlaceVoteCloseService placeVoteCloseService; + @Mock private MeetingService meetingService; + + @InjectMocks private MeetingLifecycleScheduler scheduler; + + @BeforeEach + void setUp() { + // 재시도 대기 시간 제거 + ReflectionTestUtils.setField(scheduler, "completeRetryBackoffMillis", new long[]{0L, 0L}); + } + + @Test + void 자정_배치는_장소_투표_마감_후_모임_종료_순서로_실행한다() { + scheduler.run(); + + InOrder inOrder = inOrder(placeVoteCloseService, meetingService); + inOrder.verify(placeVoteCloseService).finalizeExpiredPlaceVotes(); + inOrder.verify(meetingService).completeElapsedMeetings(); + } + + @Test + void 장소_투표_마감_단계가_실패해도_모임_종료_단계는_실행하고_예외를_전파하지_않는다() { + // given + doThrow(new DataAccessResourceFailureException("DB 연결 실패")) + .when(placeVoteCloseService).finalizeExpiredPlaceVotes(); + + // when & then + assertThatCode(() -> scheduler.run()).doesNotThrowAnyException(); + verify(meetingService).completeElapsedMeetings(); + } + + @Test + void 모임_종료_단계가_일시적으로_실패하면_재시도해_처리한다() { + // given: 두 번 실패 후 성공 + when(meetingService.completeElapsedMeetings()) + .thenThrow(new DataAccessResourceFailureException("DB 일시 오류")) + .thenThrow(new DataAccessResourceFailureException("DB 일시 오류")) + .thenReturn(2); + + // when + scheduler.run(); + + // then + verify(meetingService, times(3)).completeElapsedMeetings(); + } + + @Test + void 모임_종료_단계가_계속_실패하면_최대_3회까지만_시도하고_예외를_전파하지_않는다() { + // given + when(meetingService.completeElapsedMeetings()) + .thenThrow(new DataAccessResourceFailureException("DB 연결 실패")); + + // when & then + assertThatCode(() -> scheduler.run()).doesNotThrowAnyException(); + verify(meetingService, times(3)).completeElapsedMeetings(); + } + + @Test + void 서버_시작_시에는_모임_종료_단계만_실행한다() { + scheduler.completeElapsedMeetingsOnStartup(); + + verify(meetingService).completeElapsedMeetings(); + verifyNoInteractions(placeVoteCloseService); + } +}