From e81c2f7fc0e1ed1f459fca797c508cfc28f71458 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A7=84?= <140797244+taejinn@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:36:46 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=ED=8C=80=EC=9B=90=20=EB=AA=A8=EC=A7=91?= =?UTF-8?q?=20=EB=A7=88=EA=B0=90=20=EB=B0=B0=EC=B9=98=20=EC=8B=A4=ED=8C=A8?= =?UTF-8?q?=EB=A5=BC=20=EB=AA=A8=EC=A7=91=20=EB=8B=A8=EC=9C=84=EB=A1=9C=20?= =?UTF-8?q?=EA=B2=A9=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...amRecruitmentDeadlineCloseCoordinator.java | 60 ++++ ...TeamRecruitmentDeadlineCloseProcessor.java | 41 +-- .../TeamRecruitmentDeadlineScheduler.java | 4 +- ...cruitmentDeadlineCloseIntegrationTest.java | 282 ++++++++++++++++++ ...cruitmentDeadlineCloseCoordinatorTest.java | 114 +++++++ ...RecruitmentDeadlineCloseProcessorTest.java | 63 +--- 6 files changed, 473 insertions(+), 91 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/domain/team/recruitment/scheduler/TeamRecruitmentDeadlineCloseCoordinator.java create mode 100644 src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentDeadlineCloseIntegrationTest.java create mode 100644 src/test/java/in/koreatech/koin/unit/domain/team/recruitment/scheduler/TeamRecruitmentDeadlineCloseCoordinatorTest.java diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/scheduler/TeamRecruitmentDeadlineCloseCoordinator.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/scheduler/TeamRecruitmentDeadlineCloseCoordinator.java new file mode 100644 index 0000000000..195bb2e42e --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/scheduler/TeamRecruitmentDeadlineCloseCoordinator.java @@ -0,0 +1,60 @@ +package in.koreatech.koin.domain.team.recruitment.scheduler; + +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentStatus.RECRUITING; + +import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitment; +import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentRepository; +import java.time.Clock; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class TeamRecruitmentDeadlineCloseCoordinator { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private static final int CANDIDATE_BATCH_SIZE = 100; + private static final Pageable CANDIDATE_PAGE = PageRequest.of( + 0, + CANDIDATE_BATCH_SIZE, + Sort.by(Sort.Direction.ASC, "id") + ); + + private final TeamRecruitmentRepository recruitmentRepository; + private final TeamRecruitmentDeadlineCloseProcessor closeProcessor; + private final Clock clock; + + public void closeExpiredRecruitments() { + LocalDate today = LocalDate.now(clock.withZone(KST)); + Page candidatePage = recruitmentRepository + .findAllByStatusAndDeadlineDateBefore(RECRUITING, today, CANDIDATE_PAGE); + if (candidatePage == null) { + return; + } + + List candidateIds = candidatePage + .getContent() + .stream() + .map(TeamRecruitment::getId) + .filter(Objects::nonNull) + .toList(); + + for (Integer recruitmentId : candidateIds) { + try { + closeProcessor.closeIfExpired(recruitmentId, today); + } catch (Exception exception) { + log.error("팀원 모집글 마감 처리에 실패했습니다. recruitmentId={}", recruitmentId, exception); + } + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/scheduler/TeamRecruitmentDeadlineCloseProcessor.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/scheduler/TeamRecruitmentDeadlineCloseProcessor.java index ee4517e223..eb0a9a6afa 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/scheduler/TeamRecruitmentDeadlineCloseProcessor.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/scheduler/TeamRecruitmentDeadlineCloseProcessor.java @@ -24,35 +24,21 @@ import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentNotificationRepository; import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentOutboxEventRepository; import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentRepository; -import java.time.Clock; import java.time.LocalDate; -import java.time.ZoneId; import java.util.List; -import java.util.Objects; import java.util.Optional; import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -@Slf4j @Component @RequiredArgsConstructor public class TeamRecruitmentDeadlineCloseProcessor { - private static final ZoneId KST = ZoneId.of("Asia/Seoul"); - private static final int CANDIDATE_BATCH_SIZE = 100; - private static final Pageable CANDIDATE_PAGE = PageRequest.of( - 0, - CANDIDATE_BATCH_SIZE, - Sort.by(Sort.Direction.ASC, "id") - ); - private static final String RECRUITMENT_CLOSED_REASON = "RECRUITMENT_CLOSED"; private static final String OUTBOX_EVENT_TYPE = "TEAM_RECRUITMENT_NOTIFICATION"; private static final String AGGREGATE_TYPE = "TEAM_RECRUITMENT"; @@ -61,30 +47,9 @@ public class TeamRecruitmentDeadlineCloseProcessor { private final TeamRecruitmentChatRoomRepository chatRoomRepository; private final TeamRecruitmentNotificationRepository notificationRepository; private final TeamRecruitmentOutboxEventRepository outboxEventRepository; - private final Clock clock; - - @Transactional - public void closeExpiredRecruitments() { - LocalDate today = LocalDate.now(clock.withZone(KST)); - Page candidatePage = recruitmentRepository - .findAllByStatusAndDeadlineDateBefore(RECRUITING, today, CANDIDATE_PAGE); - if (candidatePage == null) { - return; - } - - List candidateIds = candidatePage - .getContent() - .stream() - .map(TeamRecruitment::getId) - .filter(Objects::nonNull) - .toList(); - - for (Integer recruitmentId : candidateIds) { - closeIfExpired(recruitmentId, today); - } - } - private void closeIfExpired(Integer recruitmentId, LocalDate today) { + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void closeIfExpired(Integer recruitmentId, LocalDate today) { Optional lockedRecruitment = recruitmentRepository.findByIdWithLock(recruitmentId); if (lockedRecruitment.isEmpty()) { return; diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/scheduler/TeamRecruitmentDeadlineScheduler.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/scheduler/TeamRecruitmentDeadlineScheduler.java index c934821c56..2548888094 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/scheduler/TeamRecruitmentDeadlineScheduler.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/scheduler/TeamRecruitmentDeadlineScheduler.java @@ -10,12 +10,12 @@ @RequiredArgsConstructor public class TeamRecruitmentDeadlineScheduler { - private final TeamRecruitmentDeadlineCloseProcessor deadlineCloseProcessor; + private final TeamRecruitmentDeadlineCloseCoordinator deadlineCloseCoordinator; @Scheduled(fixedDelayString = "${team-recruitment.deadline-scheduler.fixed-delay-ms:60000}") public void closeExpiredRecruitments() { try { - deadlineCloseProcessor.closeExpiredRecruitments(); + deadlineCloseCoordinator.closeExpiredRecruitments(); } catch (Exception exception) { log.error("팀원 모집 마감 스케줄러 처리 중 오류가 발생했습니다.", exception); } diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentDeadlineCloseIntegrationTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentDeadlineCloseIntegrationTest.java new file mode 100644 index 0000000000..93f52a1faf --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentDeadlineCloseIntegrationTest.java @@ -0,0 +1,282 @@ +package in.koreatech.koin.acceptance.domain; + +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentApplicationStatus.ACCEPTED; +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentApplicationStatus.PENDING; +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentApplicationStatus.REJECTED; +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentCategory.PROJECT; +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentChatRoomStatus.ACTIVE; +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentChatRoomStatus.READ_ONLY; +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentChatRoomType.DIRECT; +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentChatRoomType.TEAM; +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentMeetingType.ONLINE; +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentNotificationTargetType.MY_APPLICATIONS; +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentNotificationType.APPLICATION_REJECTED; +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentStatus.CLOSED; +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentStatus.RECRUITING; +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentType.GENERAL; +import static org.assertj.core.api.Assertions.assertThat; + +import in.koreatech.koin.acceptance.AcceptanceTest; +import in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentApplicationStatus; +import in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentChatRoomType; +import in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentOutboxEventStatus; +import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitment; +import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentApplication; +import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentChatRoom; +import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentOutboxEvent; +import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentApplicationRepository; +import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentChatRoomRepository; +import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentNotificationRepository; +import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentOutboxEventRepository; +import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentRepository; +import in.koreatech.koin.domain.team.recruitment.scheduler.TeamRecruitmentDeadlineCloseCoordinator; +import in.koreatech.koin.domain.team.recruitment.scheduler.TeamRecruitmentDeadlineScheduler; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.model.UserType; +import in.koreatech.koin.domain.user.repository.UserRepository; +import java.time.LocalDate; +import java.time.ZoneId; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; + +class TeamRecruitmentDeadlineCloseIntegrationTest extends AcceptanceTest { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + @Autowired + private PlatformTransactionManager transactionManager; + + @Autowired + private TeamRecruitmentDeadlineCloseCoordinator coordinator; + + @Autowired + private TeamRecruitmentRepository recruitmentRepository; + + @Autowired + private TeamRecruitmentApplicationRepository applicationRepository; + + @Autowired + private TeamRecruitmentChatRoomRepository chatRoomRepository; + + @Autowired + private TeamRecruitmentNotificationRepository notificationRepository; + + @Autowired + private TeamRecruitmentOutboxEventRepository outboxEventRepository; + + @Autowired + private UserRepository userRepository; + + @MockBean + private TeamRecruitmentDeadlineScheduler deadlineScheduler; + + @AfterEach + void cleanUp() { + clear(); + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + void 손상된_모집의_변경은_전부_롤백되고_다음_정상_모집은_마감된다() { + clear(); + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); + LocalDate today = LocalDate.now(clock.withZone(KST)); + Scenario scenario = transactionTemplate.execute(status -> seed(today)); + + coordinator.closeExpiredRecruitments(); + + transactionTemplate.executeWithoutResult(status -> assertResults(scenario)); + } + + private Scenario seed(LocalDate today) { + User author = userRepository.save(user()); + User failedAcceptedApplicant = userRepository.save(user()); + User failedPendingApplicant = userRepository.save(user()); + User successfulPendingApplicant = userRepository.save(user()); + + TeamRecruitment failedRecruitment = recruitment(author, today, "손상 모집", 1); + TeamRecruitment successfulRecruitment = recruitment(author, today, "정상 모집", 0); + recruitmentRepository.save(failedRecruitment); + recruitmentRepository.save(successfulRecruitment); + + TeamRecruitmentApplication failedAccepted = application( + failedRecruitment, + failedAcceptedApplicant, + ACCEPTED + ); + TeamRecruitmentApplication failedPending = application( + failedRecruitment, + failedPendingApplicant, + PENDING + ); + TeamRecruitmentApplication successfulPending = application( + successfulRecruitment, + successfulPendingApplicant, + PENDING + ); + applicationRepository.save(failedAccepted); + applicationRepository.save(failedPending); + applicationRepository.save(successfulPending); + + TeamRecruitmentChatRoom failedDirectRoom = chatRoom( + failedRecruitment, + failedPending, + DIRECT, + "APPLICATION:" + failedPending.getId() + ); + TeamRecruitmentChatRoom successfulTeamRoom = chatRoom( + successfulRecruitment, + null, + TEAM, + "TEAM" + ); + chatRoomRepository.save(failedDirectRoom); + chatRoomRepository.save(successfulTeamRoom); + + return new Scenario( + failedRecruitment.getId(), + failedAccepted.getId(), + failedPending.getId(), + failedDirectRoom.getId(), + successfulRecruitment.getId(), + successfulPending.getId(), + successfulPendingApplicant.getId(), + successfulTeamRoom.getId() + ); + } + + private void assertResults(Scenario scenario) { + TeamRecruitment failedRecruitment = recruitmentRepository.findById(scenario.failedRecruitmentId()) + .orElseThrow(); + TeamRecruitmentApplication failedAccepted = applicationRepository.findById(scenario.failedAcceptedId()) + .orElseThrow(); + TeamRecruitmentApplication failedPending = applicationRepository.findById(scenario.failedPendingId()) + .orElseThrow(); + TeamRecruitmentChatRoom failedDirectRoom = chatRoomRepository.findById(scenario.failedDirectRoomId()) + .orElseThrow(); + + assertThat(failedRecruitment.getStatus()).isEqualTo(RECRUITING); + assertThat(failedAccepted.getStatus()).isEqualTo(ACCEPTED); + assertThat(failedPending.getStatus()).isEqualTo(PENDING); + assertThat(failedPending.getDecisionReason()).isNull(); + assertThat(failedDirectRoom.getStatus()).isEqualTo(ACTIVE); + assertThat(outboxEventRepository.findByEventKey(rejectionEventKey(scenario.failedPendingId()))) + .isEmpty(); + assertThat(notificationRepository.findForOutbox( + failedPending.getApplicant().getId(), + APPLICATION_REJECTED, + MY_APPLICATIONS, + scenario.failedRecruitmentId(), + scenario.failedPendingId(), + null + )).isEmpty(); + + TeamRecruitment successfulRecruitment = recruitmentRepository.findById(scenario.successfulRecruitmentId()) + .orElseThrow(); + TeamRecruitmentApplication successfulPending = applicationRepository.findById( + scenario.successfulPendingId() + ).orElseThrow(); + TeamRecruitmentChatRoom successfulTeamRoom = chatRoomRepository.findById(scenario.successfulTeamRoomId()) + .orElseThrow(); + + assertThat(successfulRecruitment.getStatus()).isEqualTo(CLOSED); + assertThat(successfulPending.getStatus()).isEqualTo(REJECTED); + assertThat(successfulPending.getDecisionReason()).isEqualTo("RECRUITMENT_CLOSED"); + assertThat(successfulTeamRoom.getStatus()).isEqualTo(READ_ONLY); + assertThat(notificationRepository.findForOutbox( + scenario.successfulApplicantId(), + APPLICATION_REJECTED, + MY_APPLICATIONS, + scenario.successfulRecruitmentId(), + scenario.successfulPendingId(), + null + )).hasSize(1); + TeamRecruitmentOutboxEvent successfulOutbox = outboxEventRepository + .findByEventKey(rejectionEventKey(scenario.successfulPendingId())) + .orElseThrow(); + assertThat(successfulOutbox.getStatus()).isEqualTo(TeamRecruitmentOutboxEventStatus.PENDING); + } + + private User user() { + return User.builder() + .loginPw("password") + .userType(UserType.STUDENT) + .isAuthed(true) + .isDeleted(false) + .build(); + } + + private TeamRecruitment recruitment( + User author, + LocalDate today, + String title, + int currentParticipants + ) { + return TeamRecruitment.builder() + .author(author) + .category(PROJECT) + .title(title) + .meetingType(ONLINE) + .activityStartDate(today.plusDays(1)) + .activityEndDate(today.plusDays(2)) + .deadlineDate(today.minusDays(1)) + .recruitmentType(GENERAL) + .maxParticipants(5) + .currentParticipants(currentParticipants) + .description("마감 트랜잭션 격리 테스트") + .status(RECRUITING) + .build(); + } + + private TeamRecruitmentApplication application( + TeamRecruitment recruitment, + User applicant, + TeamRecruitmentApplicationStatus status + ) { + return TeamRecruitmentApplication.builder() + .recruitment(recruitment) + .applicant(applicant) + .motivation("지원 동기") + .availability("가능") + .status(status) + .profileSnapshot("{}") + .build(); + } + + private TeamRecruitmentChatRoom chatRoom( + TeamRecruitment recruitment, + TeamRecruitmentApplication application, + TeamRecruitmentChatRoomType roomType, + String roomScopeKey + ) { + return TeamRecruitmentChatRoom.builder() + .recruitment(recruitment) + .roomScopeKey(roomScopeKey) + .roomType(roomType) + .application(application) + .status(ACTIVE) + .build(); + } + + private String rejectionEventKey(Integer applicationId) { + return "team-recruitment:application:" + applicationId + ":APPLICATION_REJECTED"; + } + + private record Scenario( + Integer failedRecruitmentId, + Integer failedAcceptedId, + Integer failedPendingId, + Integer failedDirectRoomId, + Integer successfulRecruitmentId, + Integer successfulPendingId, + Integer successfulApplicantId, + Integer successfulTeamRoomId + ) { + } +} diff --git a/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/scheduler/TeamRecruitmentDeadlineCloseCoordinatorTest.java b/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/scheduler/TeamRecruitmentDeadlineCloseCoordinatorTest.java new file mode 100644 index 0000000000..6056f4bca2 --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/scheduler/TeamRecruitmentDeadlineCloseCoordinatorTest.java @@ -0,0 +1,114 @@ +package in.koreatech.koin.unit.domain.team.recruitment.scheduler; + +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentStatus.RECRUITING; +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.doThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitment; +import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentRepository; +import in.koreatech.koin.domain.team.recruitment.scheduler.TeamRecruitmentDeadlineCloseCoordinator; +import in.koreatech.koin.domain.team.recruitment.scheduler.TeamRecruitmentDeadlineCloseProcessor; +import in.koreatech.koin.unit.fixture.UserFixture; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; +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.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +@ExtendWith(MockitoExtension.class) +class TeamRecruitmentDeadlineCloseCoordinatorTest { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private static final LocalDate TODAY = LocalDate.of(2026, 8, 28); + + @Mock + private TeamRecruitmentRepository recruitmentRepository; + + @Mock + private TeamRecruitmentDeadlineCloseProcessor closeProcessor; + + @Mock + private Clock clock; + + @InjectMocks + private TeamRecruitmentDeadlineCloseCoordinator coordinator; + + @BeforeEach + void setUpClock() { + Clock fixedClock = Clock.fixed(Instant.parse("2026-08-28T03:00:00Z"), KST); + when(clock.withZone(KST)).thenReturn(fixedClock); + } + + @Test + void 한_모집의_마감이_실패해도_다음_모집을_계속_처리한다() { + TeamRecruitment first = recruitment(1); + TeamRecruitment second = recruitment(2); + when(recruitmentRepository.findAllByStatusAndDeadlineDateBefore( + eq(RECRUITING), + eq(TODAY), + any(Pageable.class) + )).thenReturn(new PageImpl<>(List.of(first, second))); + doThrow(new IllegalStateException("마감 실패")) + .when(closeProcessor).closeIfExpired(1, TODAY); + + coordinator.closeExpiredRecruitments(); + + InOrder processingOrder = inOrder(closeProcessor); + processingOrder.verify(closeProcessor).closeIfExpired(1, TODAY); + processingOrder.verify(closeProcessor).closeIfExpired(2, TODAY); + } + + @Test + void 한_번에_ID_오름차순으로_최대_100개를_조회한다() { + when(recruitmentRepository.findAllByStatusAndDeadlineDateBefore( + eq(RECRUITING), + eq(TODAY), + any(Pageable.class) + )).thenReturn(new PageImpl<>(List.of())); + + coordinator.closeExpiredRecruitments(); + + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + verify(recruitmentRepository).findAllByStatusAndDeadlineDateBefore( + eq(RECRUITING), + eq(TODAY), + pageableCaptor.capture() + ); + Pageable pageable = pageableCaptor.getValue(); + assertThat(pageable.getPageNumber()).isZero(); + assertThat(pageable.getPageSize()).isEqualTo(100); + assertThat(pageable.getSort().getOrderFor("id")) + .extracting(Sort.Order::getDirection) + .isEqualTo(Sort.Direction.ASC); + } + + private TeamRecruitment recruitment(Integer id) { + return TeamRecruitment.builder() + .id(id) + .author(UserFixture.id_설정_코인_유저(id)) + .activityStartDate(TODAY.plusDays(1)) + .activityEndDate(TODAY.plusDays(10)) + .deadlineDate(TODAY.minusDays(1)) + .maxParticipants(5) + .currentParticipants(0) + .status(RECRUITING) + .description("모집 내용") + .build(); + } +} diff --git a/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/scheduler/TeamRecruitmentDeadlineCloseProcessorTest.java b/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/scheduler/TeamRecruitmentDeadlineCloseProcessorTest.java index 843cdf150d..1459a176ec 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/scheduler/TeamRecruitmentDeadlineCloseProcessorTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/scheduler/TeamRecruitmentDeadlineCloseProcessorTest.java @@ -12,7 +12,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.ArgumentMatchers.eq; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.never; @@ -32,13 +31,9 @@ import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentRepository; import in.koreatech.koin.domain.team.recruitment.scheduler.TeamRecruitmentDeadlineCloseProcessor; import in.koreatech.koin.unit.fixture.UserFixture; -import java.time.Clock; -import java.time.Instant; import java.time.LocalDate; -import java.time.ZoneId; import java.util.List; import java.util.Optional; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -53,7 +48,6 @@ @ExtendWith(MockitoExtension.class) class TeamRecruitmentDeadlineCloseProcessorTest { - private static final ZoneId KST = ZoneId.of("Asia/Seoul"); private static final LocalDate TODAY = LocalDate.of(2026, 8, 28); @Mock @@ -71,20 +65,9 @@ class TeamRecruitmentDeadlineCloseProcessorTest { @Mock private TeamRecruitmentOutboxEventRepository outboxEventRepository; - @Mock - private Clock clock; - @InjectMocks private TeamRecruitmentDeadlineCloseProcessor processor; - private Clock fixedClock; - - @BeforeEach - void setUpClock() { - fixedClock = Clock.fixed(Instant.parse("2026-08-28T03:00:00Z"), KST); - when(clock.withZone(KST)).thenReturn(fixedClock); - } - @Nested class CloseExpiredRecruitments { @@ -95,7 +78,7 @@ class CloseExpiredRecruitments { TeamRecruitmentApplication accepted = application(12, recruitment, ACCEPTED); TeamRecruitmentChatRoom teamRoom = chatRoom(21, recruitment, TEAM, ACTIVE); TeamRecruitmentChatRoom directRoom = chatRoom(22, recruitment, DIRECT, ACTIVE); - stubCandidates(recruitment); + stubLockedRecruitment(recruitment); stubApplications(pending, accepted); when(chatRoomRepository.findByRecruitment_IdAndRoomScopeKey(1, "TEAM")) .thenReturn(Optional.of(teamRoom)); @@ -111,7 +94,7 @@ class CloseExpiredRecruitments { return notification; }); - processor.closeExpiredRecruitments(); + processor.closeIfExpired(1, TODAY); assertThat(recruitment.getStatus()).isEqualTo(CLOSED); assertThat(pending.getStatus()).isEqualTo(REJECTED); @@ -137,13 +120,12 @@ class CloseExpiredRecruitments { void 이미_마감된_모집은_다시_처리하지_않는다() { TeamRecruitment recruitment = recruitment(1, TODAY.minusDays(1)); TeamRecruitmentApplication pending = application(11, recruitment, PENDING); - stubCandidates(recruitment); - when(recruitmentRepository.findByIdWithLock(1)).thenReturn(Optional.of(recruitment)); + stubLockedRecruitment(recruitment); - processor.closeExpiredRecruitments(); + processor.closeIfExpired(1, TODAY); clearInvocations(applicationRepository, chatRoomRepository, notificationRepository, outboxEventRepository); - processor.closeExpiredRecruitments(); + processor.closeIfExpired(1, TODAY); assertThat(recruitment.getStatus()).isEqualTo(CLOSED); assertThat(pending.getStatus()).isEqualTo(PENDING); @@ -157,7 +139,7 @@ class CloseExpiredRecruitments { void 승인된_지원자가_있는데_TEAM_채팅방이_없으면_내부_무결성_예외를_던진다() { TeamRecruitment recruitment = recruitment(1, TODAY.minusDays(1)); TeamRecruitmentApplication accepted = application(12, recruitment, ACCEPTED); - stubCandidates(recruitment); + stubLockedRecruitment(recruitment); when(applicationRepository.findAllByRecruitment_IdAndStatusIn( 1, List.of(PENDING), @@ -174,7 +156,7 @@ class CloseExpiredRecruitments { IllegalStateException exception = assertThrows( IllegalStateException.class, - () -> processor.closeExpiredRecruitments() + () -> processor.closeIfExpired(1, TODAY) ); assertThat(exception).hasMessageContaining("TEAM 채팅방"); @@ -185,7 +167,7 @@ class CloseExpiredRecruitments { @Test void 승인된_지원자가_없으면_TEAM_채팅방이_없어도_정상_마감한다() { TeamRecruitment recruitment = recruitment(1, TODAY.minusDays(1)); - stubCandidates(recruitment); + stubLockedRecruitment(recruitment); when(applicationRepository.findAllByRecruitment_IdAndStatusIn( 1, List.of(PENDING), @@ -200,7 +182,7 @@ class CloseExpiredRecruitments { .thenReturn(Optional.empty()); when(chatRoomRepository.findAllByRecruitment_Id(1)).thenReturn(List.of()); - processor.closeExpiredRecruitments(); + processor.closeIfExpired(1, TODAY); assertThat(recruitment.getStatus()).isEqualTo(CLOSED); verify(notificationRepository, never()).save(any()); @@ -214,9 +196,9 @@ class SkipNonExpiredRecruitments { @Test void 오늘이_마감일이면_모집을_닫지_않는다() { TeamRecruitment recruitment = recruitment(1, TODAY); - stubCandidates(recruitment); + stubLockedRecruitment(recruitment); - processor.closeExpiredRecruitments(); + processor.closeIfExpired(1, TODAY); assertThat(recruitment.getStatus()).isEqualTo(RECRUITING); verify(applicationRepository, never()).findAllByRecruitment_IdAndStatusIn(any(), anyList(), any(Pageable.class)); @@ -224,28 +206,7 @@ class SkipNonExpiredRecruitments { } } - @Test - void DB에서_마감일_조건을_적용해_앞선_미만료_모집이_많아도_만료_모집을_조회한다() { - TeamRecruitment expiredRecruitment = recruitment(101, TODAY.minusDays(1)); - stubCandidates(expiredRecruitment); - - processor.closeExpiredRecruitments(); - - assertThat(expiredRecruitment.getStatus()).isEqualTo(CLOSED); - verify(recruitmentRepository).findAllByStatusAndDeadlineDateBefore( - eq(RECRUITING), - eq(TODAY), - any(Pageable.class) - ); - } - - private void stubCandidates(TeamRecruitment recruitment) { - when(recruitmentRepository.findAllByStatusAndDeadlineDateBefore( - eq(RECRUITING), - eq(TODAY), - any(Pageable.class) - )) - .thenReturn(new PageImpl<>(List.of(recruitment))); + private void stubLockedRecruitment(TeamRecruitment recruitment) { when(recruitmentRepository.findByIdWithLock(recruitment.getId())) .thenReturn(Optional.of(recruitment)); }