From 3448c9267ca75cf32b6f20b3329c4e7367839583 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 23:08:31 +0900 Subject: [PATCH 01/32] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20ID?= =?UTF-8?q?=20=ED=95=98=EC=9D=B4=ED=94=88=EC=9D=84=20=EC=9D=B4=EB=AA=A8?= =?UTF-8?q?=EC=A7=80=EB=A1=9C=20=EC=98=A4=EC=9D=B8=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=88=98=EC=A0=95=20(#2383)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: initialize issue #2370 * fix: 로그인 ID 하이픈 이모지 오탐 수정 --- .../global/validation/NotEmojiValidator.java | 19 +++---- .../koin/acceptance/domain/UserApiTest.java | 35 +++++++++++++ .../validation/NotEmojiValidatorTest.java | 52 +++++++++++++++++++ 3 files changed, 97 insertions(+), 9 deletions(-) create mode 100644 src/test/java/in/koreatech/koin/global/validation/NotEmojiValidatorTest.java diff --git a/src/main/java/in/koreatech/koin/global/validation/NotEmojiValidator.java b/src/main/java/in/koreatech/koin/global/validation/NotEmojiValidator.java index 850bc2280a..11067f2120 100644 --- a/src/main/java/in/koreatech/koin/global/validation/NotEmojiValidator.java +++ b/src/main/java/in/koreatech/koin/global/validation/NotEmojiValidator.java @@ -1,8 +1,5 @@ package in.koreatech.koin.global.validation; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - import org.springframework.stereotype.Component; import jakarta.validation.ConstraintValidator; @@ -11,7 +8,7 @@ @Component public class NotEmojiValidator implements ConstraintValidator { - private static final Pattern EMOJI_PATTERN = Pattern.compile("[\\uD83C-\\uDBFF\\uDC00-\\uDFFF]+"); + private static final int EMOJI_VARIATION_SELECTOR = 0xFE0F; @Override public void initialize(NotEmoji constraintAnnotation) { @@ -23,10 +20,14 @@ public boolean isValid(String field, ConstraintValidatorContext constraintValida if (field == null) { return true; } - Matcher emojiMatcher = EMOJI_PATTERN.matcher(field); - if (emojiMatcher.find()) { - return false; - } - return true; + return field.codePoints().noneMatch(codePoint -> + Character.isSupplementaryCodePoint(codePoint) + || codePoint == EMOJI_VARIATION_SELECTOR + || isIsolatedSurrogate(codePoint) + ); + } + + private static boolean isIsolatedSurrogate(int codePoint) { + return codePoint >= Character.MIN_SURROGATE && codePoint <= Character.MAX_SURROGATE; } } diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/UserApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/UserApiTest.java index 3181c170be..2ac6a07790 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/UserApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/UserApiTest.java @@ -13,6 +13,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; import in.koreatech.koin.acceptance.AcceptanceTest; import in.koreatech.koin.acceptance.fixture.DepartmentAcceptanceFixture; @@ -38,6 +39,9 @@ class UserApiTest extends AcceptanceTest { @Autowired private UserRepository userRepository; + @Autowired + private PasswordEncoder passwordEncoder; + @Autowired private JwtProvider jwtProvider; @@ -76,6 +80,37 @@ void setup() { .andExpect(status().isCreated()); } + @Test + void 하이픈이_포함된_아이디로_로그인한다() throws Exception { + String loginId = "user-1"; + String password = "1234"; + userRepository.save(User.builder() + .loginPw(passwordEncoder.encode(password)) + .nickname("하이픈") + .name("하이픈사용자") + .phoneNumber("01098765432") + .userType(UserType.GENERAL) + .email("hyphen@koreatech.ac.kr") + .loginId(loginId) + .isAuthed(true) + .isDeleted(false) + .build() + ); + + mockMvc.perform( + post("/v2/users/login") + .content(""" + { + "login_id" : "%s", + "login_pw" : "%s" + } + """.formatted(loginId, password)) + .contentType(MediaType.APPLICATION_JSON) + .header("User-Agent", userFixture.맥북userAgent헤더()) + ) + .andExpect(status().isCreated()); + } + // 영양사로 넘길 예정 @Test void 영양사가_로그인을_진행한다() throws Exception { diff --git a/src/test/java/in/koreatech/koin/global/validation/NotEmojiValidatorTest.java b/src/test/java/in/koreatech/koin/global/validation/NotEmojiValidatorTest.java new file mode 100644 index 0000000000..800e3ec5c6 --- /dev/null +++ b/src/test/java/in/koreatech/koin/global/validation/NotEmojiValidatorTest.java @@ -0,0 +1,52 @@ +package in.koreatech.koin.global.validation; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class NotEmojiValidatorTest { + + private final NotEmojiValidator validator = new NotEmojiValidator(); + + @Test + void ASCII_하이픈을_허용한다() { + assertThat(validator.isValid("user-1", null)).isTrue(); + } + + @ParameterizedTest + @ValueSource(strings = {"abc_d", "abc.d", "abc123"}) + void 회원가입_아이디에_허용된_문자를_허용한다(String field) { + assertThat(validator.isValid(field, null)).isTrue(); + } + + @ParameterizedTest + @ValueSource(strings = {"😀", "abc😀d", "🫠", "❤️", "1️⃣"}) + void 실제_이모지를_거절한다(String field) { + assertThat(validator.isValid(field, null)).isFalse(); + } + + @ParameterizedTest + @ValueSource(ints = {0xD83D, 0xDC00}) + void 고립된_surrogate를_거절한다(int surrogate) { + String field = new String(new char[]{(char)surrogate}); + + assertThat(validator.isValid(field, null)).isFalse(); + } + + @Test + void variation_selector가_없는_기존_BMP_문자는_허용한다() { + assertThat(validator.isValid("☕", null)).isTrue(); + } + + @Test + void 빈_문자열은_허용한다() { + assertThat(validator.isValid("", null)).isTrue(); + } + + @Test + void null은_허용한다() { + assertThat(validator.isValid(null, null)).isTrue(); + } +} From 276ec5026a54896b7390870ba09693f1d03407ad 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 23:25:02 +0900 Subject: [PATCH 02/32] =?UTF-8?q?fix:=20DIRECT=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=EB=B0=A9=20=EC=83=9D=EC=84=B1=EC=9D=84=20=EB=8F=99=EC=8B=9C?= =?UTF-8?q?=EC=84=B1=20=EC=95=88=EC=A0=84=ED=95=9C=20get-or-create?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95=20(#2384)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: initialize issue #2371 * fix: DIRECT 채팅방 생성을 동시성 안전하게 처리 --- .../service/TeamRecruitmentChatService.java | 9 +- ...mRecruitmentDirectChatConcurrencyTest.java | 224 ++++++++++++++++++ .../TeamRecruitmentChatServiceTest.java | 35 ++- 3 files changed, 254 insertions(+), 14 deletions(-) create mode 100644 src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentDirectChatConcurrencyTest.java diff --git a/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentChatService.java b/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentChatService.java index 6a286b5211..b28a4927ca 100644 --- a/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentChatService.java +++ b/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentChatService.java @@ -94,12 +94,13 @@ public ChatRoomResponse getChatRoom(Integer userId, Integer recruitmentId, Integ @Transactional public DirectChatRoomCreationResult getOrCreateDirectChatRoom(Integer userId, Integer recruitmentId, Integer applicationId) { - TeamRecruitmentApplication application = applicationRepository.findById(applicationId) - .orElseThrow(() -> CustomException.of(TEAM_RECRUITMENT_APPLICATION_NOT_FOUND)); - - TeamRecruitment recruitment = recruitmentRepository.findById(recruitmentId) + TeamRecruitment recruitment = recruitmentRepository.findByIdWithLock(recruitmentId) .orElseThrow(() -> CustomException.of(TEAM_RECRUITMENT_NOT_FOUND)); + TeamRecruitmentApplication application = applicationRepository + .findByIdAndRecruitmentIdWithLock(applicationId, recruitmentId) + .orElseThrow(() -> CustomException.of(TEAM_RECRUITMENT_APPLICATION_NOT_FOUND)); + if (!application.getRecruitment().getId().equals(recruitmentId)) { throw CustomException.of(TEAM_RECRUITMENT_APPLICATION_NOT_FOUND); } diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentDirectChatConcurrencyTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentDirectChatConcurrencyTest.java new file mode 100644 index 0000000000..02f235c5a8 --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentDirectChatConcurrencyTest.java @@ -0,0 +1,224 @@ +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.TeamRecruitmentCategory.PROJECT; +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentChatRoomType.DIRECT; +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentMeetingType.ONLINE; +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentStatus.RECRUITING; +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentType.GENERAL; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +import java.time.LocalDate; +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.atomic.AtomicReference; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.transaction.TestTransaction; +import org.springframework.test.web.servlet.MvcResult; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import in.koreatech.koin.acceptance.AcceptanceTest; +import in.koreatech.koin.acceptance.fixture.DepartmentAcceptanceFixture; +import in.koreatech.koin.acceptance.fixture.UserAcceptanceFixture; +import in.koreatech.koin.domain.student.model.Department; +import in.koreatech.koin.domain.student.model.Student; +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.repository.TeamRecruitmentApplicationRepository; +import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentRepository; + +@Import(TeamRecruitmentDirectChatConcurrencyTest.DirectRoomSaveBarrier.class) +class TeamRecruitmentDirectChatConcurrencyTest extends AcceptanceTest { + + private static final int CONCURRENT_REQUEST_COUNT = 8; + + @Autowired + private UserAcceptanceFixture userFixture; + + @Autowired + private DepartmentAcceptanceFixture departmentFixture; + + @Autowired + private TeamRecruitmentRepository recruitmentRepository; + + @Autowired + private TeamRecruitmentApplicationRepository applicationRepository; + + @Autowired + private DirectRoomSaveBarrier directRoomSaveBarrier; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private ObjectMapper objectMapper; + + private TeamRecruitment recruitment; + private TeamRecruitmentApplication application; + private String authorToken; + + @BeforeEach + void setUp() { + clear(); + Department department = departmentFixture.컴퓨터공학부(); + Student author = userFixture.준호_학생(department, null); + Student applicant = userFixture.성빈_학생(department); + authorToken = userFixture.getToken(author.getUser()); + + recruitment = recruitmentRepository.save(TeamRecruitment.builder() + .author(author.getUser()) + .category(PROJECT) + .title("동시성 테스트 모집글") + .meetingType(ONLINE) + .activityStartDate(LocalDate.now(clock).plusDays(2)) + .activityEndDate(LocalDate.now(clock).plusDays(10)) + .deadlineDate(LocalDate.now(clock).plusDays(1)) + .recruitmentType(GENERAL) + .maxParticipants(2) + .currentParticipants(1) + .description("동일한 지원서로 DIRECT 채팅방을 동시에 생성한다.") + .status(RECRUITING) + .build()); + application = applicationRepository.save(TeamRecruitmentApplication.builder() + .recruitment(recruitment) + .applicant(applicant.getUser()) + .motivation("지원 동기") + .availability("월수금 20시 이후") + .status(ACCEPTED) + .profileSnapshot("{}") + .snapshotVersion(1) + .build()); + } + + @Test + void 동일한_지원서의_DIRECT_채팅방_생성_요청_2건은_하나의_방으로_수렴한다() throws Exception { + assertConcurrentGetOrCreate(2); + } + + @Test + void 동일한_지원서의_DIRECT_채팅방_생성_요청_N건은_하나의_방으로_수렴한다() throws Exception { + assertConcurrentGetOrCreate(CONCURRENT_REQUEST_COUNT); + } + + private void assertConcurrentGetOrCreate(int requestCount) throws Exception { + directRoomSaveBarrier.expectInsertions(requestCount); + + entityManager.flush(); + TestTransaction.flagForCommit(); + TestTransaction.end(); + + List results = performConcurrentRequests(requestCount); + List statuses = results.stream() + .map(result -> result.getResponse().getStatus()) + .toList(); + List chatRoomIds = new ArrayList<>(); + for (MvcResult result : results) { + JsonNode response = objectMapper.readTree(result.getResponse().getContentAsString()); + chatRoomIds.add(response.path("chat_room_id").asInt()); + } + + assertThat(statuses).containsOnly(200, 201); + assertThat(statuses).containsExactlyInAnyOrderElementsOf(expectedStatuses(requestCount)); + assertThat(chatRoomIds).doesNotContain(0).allMatch(chatRoomIds.get(0)::equals); + + Integer chatRoomCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM team_recruitment_chat_room + WHERE recruitment_id = ? + AND application_id = ? + AND room_type = 'DIRECT' + """, Integer.class, recruitment.getId(), application.getId()); + Integer memberCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM team_recruitment_chat_member + WHERE chat_room_id = ? + """, Integer.class, chatRoomIds.get(0)); + + assertThat(chatRoomCount).isOne(); + assertThat(memberCount).isEqualTo(2); + } + + private List performConcurrentRequests(int requestCount) throws Exception { + ExecutorService executor = Executors.newFixedThreadPool(requestCount); + CountDownLatch ready = new CountDownLatch(requestCount); + CountDownLatch start = new CountDownLatch(1); + try { + List> futures = new ArrayList<>(); + for (int i = 0; i < requestCount; i++) { + futures.add(executor.submit(() -> { + ready.countDown(); + if (!start.await(5, SECONDS)) { + throw new IllegalStateException("동시 요청 시작 대기 시간이 초과되었습니다."); + } + return mockMvc.perform(post( + "/chatroom/team-recruitment/{recruitmentId}/applications/{applicationId}/direct", + recruitment.getId(), application.getId()) + .header("Authorization", "Bearer " + authorToken)) + .andReturn(); + })); + } + assertThat(ready.await(5, SECONDS)).isTrue(); + start.countDown(); + + List results = new ArrayList<>(); + for (Future future : futures) { + results.add(future.get(15, SECONDS)); + } + return results; + } finally { + executor.shutdownNow(); + assertThat(executor.awaitTermination(5, SECONDS)).isTrue(); + } + } + + private List expectedStatuses(int requestCount) { + List statuses = new ArrayList<>(); + statuses.add(201); + for (int i = 1; i < requestCount; i++) { + statuses.add(200); + } + return statuses; + } + + @Aspect + static class DirectRoomSaveBarrier { + + private final AtomicReference insertions = + new AtomicReference<>(new CountDownLatch(0)); + + void expectInsertions(int requestCount) { + insertions.set(new CountDownLatch(requestCount)); + } + + @Around("bean(teamRecruitmentChatRoomRepository) && execution(* save(..)) && args(chatRoom)") + Object awaitConcurrentInsertions( + ProceedingJoinPoint joinPoint, + TeamRecruitmentChatRoom chatRoom + ) throws Throwable { + if (chatRoom.getRoomType() == DIRECT) { + CountDownLatch latch = insertions.get(); + latch.countDown(); + // 수정 전에는 모든 요청이 insert까지 도달하고, 수정 후에는 최초 요청만 도달한다. + latch.await(1, SECONDS); + } + return joinPoint.proceed(); + } + } +} diff --git a/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java index b16ccc029c..8b5e6b9bd9 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -10,6 +11,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; +import org.mockito.InOrder; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -117,8 +119,10 @@ class TeamRecruitmentChatServiceTest { void 지원서가_다른_모집글_소속이면_404를_반환한다() { TeamRecruitmentApplication application = mock(TeamRecruitmentApplication.class); TeamRecruitment wrongRecruitment = mock(TeamRecruitment.class); - when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(application)); - when(recruitmentRepository.findById(RECRUITMENT_ID)).thenReturn(Optional.of(mock(TeamRecruitment.class))); + when(recruitmentRepository.findByIdWithLock(RECRUITMENT_ID)) + .thenReturn(Optional.of(mock(TeamRecruitment.class))); + when(applicationRepository.findByIdAndRecruitmentIdWithLock(APPLICATION_ID, RECRUITMENT_ID)) + .thenReturn(Optional.of(application)); when(application.getRecruitment()).thenReturn(wrongRecruitment); when(wrongRecruitment.getId()).thenReturn(99); @@ -133,8 +137,9 @@ class TeamRecruitmentChatServiceTest { User author = UserFixture.id_설정_코인_유저(USER_ID); TeamRecruitmentApplication application = mock(TeamRecruitmentApplication.class); TeamRecruitment recruitment = mock(TeamRecruitment.class); - when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(application)); - when(recruitmentRepository.findById(RECRUITMENT_ID)).thenReturn(Optional.of(recruitment)); + when(recruitmentRepository.findByIdWithLock(RECRUITMENT_ID)).thenReturn(Optional.of(recruitment)); + when(applicationRepository.findByIdAndRecruitmentIdWithLock(APPLICATION_ID, RECRUITMENT_ID)) + .thenReturn(Optional.of(application)); when(application.getRecruitment()).thenReturn(recruitment); when(recruitment.getId()).thenReturn(RECRUITMENT_ID); when(recruitment.getAuthor()).thenReturn(author); @@ -151,8 +156,9 @@ class TeamRecruitmentChatServiceTest { User author = UserFixture.id_설정_코인_유저(USER_ID); TeamRecruitmentApplication application = mock(TeamRecruitmentApplication.class); TeamRecruitment recruitment = mock(TeamRecruitment.class); - when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(application)); - when(recruitmentRepository.findById(RECRUITMENT_ID)).thenReturn(Optional.of(recruitment)); + when(recruitmentRepository.findByIdWithLock(RECRUITMENT_ID)).thenReturn(Optional.of(recruitment)); + when(applicationRepository.findByIdAndRecruitmentIdWithLock(APPLICATION_ID, RECRUITMENT_ID)) + .thenReturn(Optional.of(application)); when(application.getRecruitment()).thenReturn(recruitment); when(recruitment.getId()).thenReturn(RECRUITMENT_ID); when(recruitment.getAuthor()).thenReturn(author); @@ -170,8 +176,9 @@ class TeamRecruitmentChatServiceTest { User otherAuthor = UserFixture.id_설정_코인_유저(OTHER_USER_ID); TeamRecruitmentApplication application = mock(TeamRecruitmentApplication.class); TeamRecruitment recruitment = mock(TeamRecruitment.class); - when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(application)); - when(recruitmentRepository.findById(RECRUITMENT_ID)).thenReturn(Optional.of(recruitment)); + when(recruitmentRepository.findByIdWithLock(RECRUITMENT_ID)).thenReturn(Optional.of(recruitment)); + when(applicationRepository.findByIdAndRecruitmentIdWithLock(APPLICATION_ID, RECRUITMENT_ID)) + .thenReturn(Optional.of(application)); when(application.getRecruitment()).thenReturn(recruitment); when(recruitment.getId()).thenReturn(RECRUITMENT_ID); when(recruitment.getAuthor()).thenReturn(otherAuthor); @@ -190,8 +197,9 @@ class TeamRecruitmentChatServiceTest { TeamRecruitment recruitment = mock(TeamRecruitment.class); TeamRecruitmentChatRoom existingRoom = mock(TeamRecruitmentChatRoom.class); - when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(application)); - when(recruitmentRepository.findById(RECRUITMENT_ID)).thenReturn(Optional.of(recruitment)); + when(recruitmentRepository.findByIdWithLock(RECRUITMENT_ID)).thenReturn(Optional.of(recruitment)); + when(applicationRepository.findByIdAndRecruitmentIdWithLock(APPLICATION_ID, RECRUITMENT_ID)) + .thenReturn(Optional.of(application)); when(application.getRecruitment()).thenReturn(recruitment); when(recruitment.getId()).thenReturn(RECRUITMENT_ID); when(application.getStatus()).thenReturn(TeamRecruitmentApplicationStatus.ACCEPTED); @@ -210,6 +218,13 @@ class TeamRecruitmentChatServiceTest { assertThat(result.isNew()).isFalse(); assertThat(result.response().chatRoomId()).isEqualTo(CHAT_ROOM_ID); assertThat(result.response().roomName()).isEqualTo(counterpart.getNickname()); + + InOrder lockOrder = inOrder(recruitmentRepository, applicationRepository, chatRoomRepository); + lockOrder.verify(recruitmentRepository).findByIdWithLock(RECRUITMENT_ID); + lockOrder.verify(applicationRepository) + .findByIdAndRecruitmentIdWithLock(APPLICATION_ID, RECRUITMENT_ID); + lockOrder.verify(chatRoomRepository).findByRecruitment_IdAndApplication_IdAndRoomType( + RECRUITMENT_ID, APPLICATION_ID, TeamRecruitmentChatRoomType.DIRECT); } @Test From 170c414bd32ad2cd03798b1bea01164494ce4421 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 23:35:49 +0900 Subject: [PATCH 03/32] =?UTF-8?q?fix:=20=EA=B0=9C=EC=9D=B8=20=EC=B1=84?= =?UTF-8?q?=ED=8C=85=20CTA=EC=99=80=20DIRECT=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=EC=9D=84=20=EB=8F=99=EC=9D=BC=20=EC=A0=95?= =?UTF-8?q?=EC=B1=85=EC=9C=BC=EB=A1=9C=20=EC=A0=95=EB=A0=AC=20(#2385)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: initialize issue #2372 * fix: 개인 채팅 생성 정책과 CTA 정렬 (#2372) --- .../team/recruitment/dto/ApplicantDetail.java | 7 +- .../recruitment/dto/ApplicantSummary.java | 7 +- .../TeamRecruitmentDirectChatPolicy.java | 42 ++++ ...eamRecruitmentApplicationQueryService.java | 37 ++- .../controller/TeamRecruitmentChatApi.java | 7 +- .../service/TeamRecruitmentChatService.java | 57 +++-- ...TeamRecruitmentApplicationFlowApiTest.java | 166 +++++++++++++ .../TeamRecruitmentDirectChatPolicyTest.java | 115 +++++++++ ...ecruitmentApplicationQueryServiceTest.java | 220 +++++++++++++++++- .../TeamRecruitmentChatServiceTest.java | 20 +- 10 files changed, 646 insertions(+), 32 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/domain/team/recruitment/model/TeamRecruitmentDirectChatPolicy.java create mode 100644 src/test/java/in/koreatech/koin/unit/domain/team/recruitment/model/TeamRecruitmentDirectChatPolicyTest.java diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/ApplicantDetail.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/ApplicantDetail.java index 96801b24b5..ec5651639b 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/ApplicantDetail.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/ApplicantDetail.java @@ -31,7 +31,12 @@ public record ApplicantDetail( @Schema(description = "현재 사용자가 승인/거절할 수 있는지", example = "true", requiredMode = REQUIRED) Boolean canDecide, - @Schema(description = "개인 채팅을 시작할 수 있는지", example = "false", requiredMode = REQUIRED) + @Schema( + description = "기존 DIRECT 방이 있거나, 마감일이 지나지 않은 RECRUITING 상태이거나, " + + "정원 충족으로 마감되어 ACTIVE TEAM 방이 있을 때 개인 채팅을 시작할 수 있는지", + example = "false", + requiredMode = REQUIRED + ) Boolean canOpenDirectChat ) { } diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/ApplicantSummary.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/ApplicantSummary.java index ed3f54667d..30be458231 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/ApplicantSummary.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/ApplicantSummary.java @@ -28,7 +28,12 @@ public record ApplicantSummary( @Schema(description = "지원 상태", example = "PENDING", requiredMode = REQUIRED) TeamRecruitmentApplicationStatus status, - @Schema(description = "개인 채팅 시작 가능 여부", example = "false", requiredMode = REQUIRED) + @Schema( + description = "기존 DIRECT 방이 있거나, 마감일이 지나지 않은 RECRUITING 상태이거나, " + + "정원 충족으로 마감되어 ACTIVE TEAM 방이 있을 때 개인 채팅 시작 가능 여부", + example = "false", + requiredMode = REQUIRED + ) Boolean canOpenDirectChat ) { } diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/model/TeamRecruitmentDirectChatPolicy.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/model/TeamRecruitmentDirectChatPolicy.java new file mode 100644 index 0000000000..2ecbd1559e --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/model/TeamRecruitmentDirectChatPolicy.java @@ -0,0 +1,42 @@ +package in.koreatech.koin.domain.team.recruitment.model; + +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentApplicationStatus.ACCEPTED; +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentChatRoomType.TEAM; +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentStatus.CLOSED; + +import in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentApplicationStatus; +import java.time.LocalDate; +import java.util.Objects; + +public final class TeamRecruitmentDirectChatPolicy { + + private TeamRecruitmentDirectChatPolicy() { + } + + public static boolean canOpenDirectChat( + TeamRecruitmentApplicationStatus applicationStatus, + boolean hasExistingDirectChat, + TeamRecruitment recruitment, + TeamRecruitmentChatRoom teamChatRoom, + LocalDate today + ) { + if (applicationStatus != ACCEPTED) { + return false; + } + if (hasExistingDirectChat) { + return true; + } + if (recruitment == null || recruitment.isDeleted()) { + return false; + } + Objects.requireNonNull(today, "today must not be null"); + if (recruitment.isRecruiting()) { + return recruitment.getDeadlineDate() == null + || !today.isAfter(recruitment.getDeadlineDate()); + } + return recruitment.getStatus() == CLOSED + && teamChatRoom != null + && teamChatRoom.getRoomType() == TEAM + && teamChatRoom.isActive(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/service/TeamRecruitmentApplicationQueryService.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/service/TeamRecruitmentApplicationQueryService.java index 6d000f9a4b..d80e267cb2 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/service/TeamRecruitmentApplicationQueryService.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/service/TeamRecruitmentApplicationQueryService.java @@ -31,6 +31,7 @@ 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.TeamRecruitmentDirectChatPolicy; import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentRole; import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentApplicationRepository; import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentChatRoomRepository; @@ -125,8 +126,10 @@ public ApplicantListResponse getApplications( selectedStatuses, pageRequest(criteria, TeamRecruitmentApplicationSort.LATEST_DESC, false) ); + LocalDate today = today(); + AcceptedChatRooms acceptedChatRooms = findAcceptedChatRooms(applications.getContent()); List content = applications.getContent().stream() - .map(this::toApplicantSummary) + .map(application -> toApplicantSummary(application, acceptedChatRooms, today)) .toList(); return new ApplicantListResponse( toApplicantRecruitment(recruitment), @@ -156,6 +159,9 @@ public ApplicantDetail getApplicationDetail( boolean canDecide = application.getStatus() == PENDING && recruitment.getStatus() == RECRUITING && !isPastDeadline(recruitment); + LocalDate today = today(); + boolean canOpenDirectChat = application.getStatus() == ACCEPTED + && canOpenDirectChat(application, findAcceptedChatRooms(List.of(application)), today); return new ApplicantDetail( application.getId(), application.getStatus(), @@ -164,7 +170,7 @@ public ApplicantDetail getApplicationDetail( application.getAvailability(), toApplicationRole(application.getRole()), canDecide, - application.getStatus() == ACCEPTED + canOpenDirectChat ); } @@ -306,7 +312,11 @@ private AcceptedChatRooms findAcceptedChatRooms(List return new AcceptedChatRooms(teamRoomsByRecruitmentId, directRoomsByApplicationId); } - private ApplicantSummary toApplicantSummary(TeamRecruitmentApplication application) { + private ApplicantSummary toApplicantSummary( + TeamRecruitmentApplication application, + AcceptedChatRooms acceptedChatRooms, + LocalDate today + ) { ProfileSnapshot snapshot = readProfileSnapshot(application.getProfileSnapshot()); return new ApplicantSummary( application.getId(), @@ -315,10 +325,25 @@ private ApplicantSummary toApplicantSummary(TeamRecruitmentApplication applicati snapshot.studentYear(), toApplicationRole(application.getRole()), application.getStatus(), - application.getStatus() == ACCEPTED + canOpenDirectChat(application, acceptedChatRooms, today) ); } + private boolean canOpenDirectChat( + TeamRecruitmentApplication application, + AcceptedChatRooms acceptedChatRooms, + LocalDate today + ) { + TeamRecruitment recruitment = application.getRecruitment(); + boolean hasExistingDirectChat = application.getId() != null + && acceptedChatRooms.directRoomsByApplicationId().containsKey(application.getId()); + TeamRecruitmentChatRoom teamChatRoom = recruitment.getId() == null + ? null + : acceptedChatRooms.teamRoomsByRecruitmentId().get(recruitment.getId()); + return TeamRecruitmentDirectChatPolicy.canOpenDirectChat( + application.getStatus(), hasExistingDirectChat, recruitment, teamChatRoom, today); + } + private RecruitmentCard toRecruitmentCard(TeamRecruitment recruitment) { return new RecruitmentCard( recruitment.getId(), @@ -409,6 +434,10 @@ private boolean isPastDeadline(TeamRecruitment recruitment) { && LocalDate.now(clock.withZone(KST)).isAfter(recruitment.getDeadlineDate()); } + private LocalDate today() { + return LocalDate.now(clock.withZone(KST)); + } + private ProfileSnapshot readProfileSnapshot(String profileSnapshot) { if (profileSnapshot == null || profileSnapshot.isBlank()) { throw new IllegalStateException("팀 모집 지원 프로필 snapshot이 비어 있습니다."); diff --git a/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentChatApi.java b/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentChatApi.java index 61bf270815..e7e4f6dae1 100644 --- a/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentChatApi.java +++ b/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentChatApi.java @@ -60,7 +60,12 @@ ResponseEntity getChatRoom( UNAUTHORIZED_USER, FORBIDDEN_USER_TYPE, }) - @Operation(summary = "지원자와 개인 채팅방 생성 또는 조회") + @Operation( + summary = "지원자와 개인 채팅방 생성 또는 조회", + description = "ACCEPTED 지원서만 대상입니다. 기존 DIRECT 채팅방이 있으면 모집 상태와 관계없이 기존 방을 반환합니다. " + + "기존 방이 없을 때는 마감일이 지나지 않은 RECRUITING 상태이거나 " + + "정원 충족으로 마감되어 ACTIVE 상태인 TEAM 채팅방이 있는 경우에만 새 방을 생성합니다." + ) ResponseEntity getOrCreateDirectChatRoom( Integer userId, @PathVariable Integer recruitmentId, diff --git a/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentChatService.java b/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentChatService.java index b28a4927ca..f127a0aa03 100644 --- a/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentChatService.java +++ b/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentChatService.java @@ -1,10 +1,14 @@ package in.koreatech.koin.domain.teamrecruitment.service; +import java.time.Clock; +import java.time.LocalDate; +import java.time.ZoneId; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -14,6 +18,7 @@ import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentChatMember; import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentChatMessage; import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentChatRoom; +import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentDirectChatPolicy; import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentNotification; import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentOutboxEvent; import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentApplicationRepository; @@ -46,6 +51,7 @@ @Transactional(readOnly = true) public class TeamRecruitmentChatService { + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); private static final String OUTBOX_EVENT_TYPE = "TEAM_RECRUITMENT_NOTIFICATION"; private static final String AGGREGATE_TYPE = "TEAM_RECRUITMENT"; @@ -57,6 +63,7 @@ public class TeamRecruitmentChatService { private final TeamRecruitmentNotificationRepository notificationRepository; private final TeamRecruitmentOutboxEventRepository outboxEventRepository; private final ObjectMapper objectMapper; + private final Clock clock; public ChatRoomResponse getChatRoom(Integer userId, Integer recruitmentId, Integer chatRoomId) { TeamRecruitmentChatRoom chatRoom = chatRoomRepository.findById(chatRoomId) @@ -113,31 +120,39 @@ public DirectChatRoomCreationResult getOrCreateDirectChatRoom(Integer userId, In throw CustomException.of(TEAM_RECRUITMENT_APPLICATION_NOT_ACCEPTED); } - if (!recruitment.isRecruiting()) { + User counterpartUser = application.getApplicant(); + Optional existingDirectChatRoom = chatRoomRepository + .findByRecruitment_IdAndApplication_IdAndRoomType( + recruitmentId, applicationId, TeamRecruitmentChatRoomType.DIRECT); + if (existingDirectChatRoom.isPresent()) { + return new DirectChatRoomCreationResult( + DirectChatRoomResponse.of(existingDirectChatRoom.get(), counterpartUser), false); + } + + TeamRecruitmentChatRoom teamChatRoom = chatRoomRepository + .findByRecruitment_IdAndRoomScopeKey(recruitmentId, TeamRecruitmentChatRoom.TEAM_ROOM_SCOPE_KEY) + .filter(room -> room.getRoomType() == TeamRecruitmentChatRoomType.TEAM) + .orElse(null); + LocalDate today = LocalDate.now(clock.withZone(KST)); + if (!TeamRecruitmentDirectChatPolicy.canOpenDirectChat( + application.getStatus(), false, recruitment, teamChatRoom, today)) { throw CustomException.of(TEAM_RECRUITMENT_CLOSED); } - User counterpartUser = application.getApplicant(); + TeamRecruitmentChatRoom chatRoom = TeamRecruitmentChatRoom.builder() + .recruitment(recruitment) + .roomScopeKey("DIRECT-" + applicationId) + .roomType(TeamRecruitmentChatRoomType.DIRECT) + .application(application) + .build(); + chatRoom = chatRoomRepository.save(chatRoom); - return chatRoomRepository.findByRecruitment_IdAndApplication_IdAndRoomType( - recruitmentId, applicationId, TeamRecruitmentChatRoomType.DIRECT) - .map(existing -> new DirectChatRoomCreationResult(DirectChatRoomResponse.of(existing, counterpartUser), false)) - .orElseGet(() -> { - TeamRecruitmentChatRoom chatRoom = TeamRecruitmentChatRoom.builder() - .recruitment(recruitment) - .roomScopeKey("DIRECT-" + applicationId) - .roomType(TeamRecruitmentChatRoomType.DIRECT) - .application(application) - .build(); - chatRoom = chatRoomRepository.save(chatRoom); - - memberRepository.save(TeamRecruitmentChatMember.builder() - .chatRoom(chatRoom).user(recruitment.getAuthor()).build()); - memberRepository.save(TeamRecruitmentChatMember.builder() - .chatRoom(chatRoom).user(counterpartUser).build()); - - return new DirectChatRoomCreationResult(DirectChatRoomResponse.of(chatRoom, counterpartUser), true); - }); + memberRepository.save(TeamRecruitmentChatMember.builder() + .chatRoom(chatRoom).user(recruitment.getAuthor()).build()); + memberRepository.save(TeamRecruitmentChatMember.builder() + .chatRoom(chatRoom).user(counterpartUser).build()); + + return new DirectChatRoomCreationResult(DirectChatRoomResponse.of(chatRoom, counterpartUser), true); } @Transactional diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentApplicationFlowApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentApplicationFlowApiTest.java index 8e9d07ccb1..b9d8ca80bd 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentApplicationFlowApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentApplicationFlowApiTest.java @@ -5,6 +5,8 @@ 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.TeamRecruitmentStatus.CLOSED; @@ -12,6 +14,7 @@ import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentType.GENERAL; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.nullValue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; @@ -162,6 +165,152 @@ void setUp() { .andExpect(jsonPath("$.applications[0].team_chat_room_id").value(teamRoom.getId())); } + @Test + void 수동_마감된_ACCEPTED_지원서는_DIRECT_CTA와_생성_조건이_모두_닫힌다() throws Exception { + TeamRecruitment recruitment = recruitmentContext.recruitment(); + TeamRecruitmentApplication application = savePendingApplication(recruitment); + + accept(recruitment, application, authorToken) + .andExpect(status().isNoContent()); + + mockMvc.perform(put("/team-recruitments/{id}/close", recruitment.getId()) + .header("Authorization", "Bearer " + authorToken)) + .andExpect(status().isNoContent()); + + mockMvc.perform(get("/team-recruitments/{recruitmentId}/applications", recruitment.getId()) + .header("Authorization", "Bearer " + authorToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.applications[0].status").value("ACCEPTED")) + .andExpect(jsonPath("$.applications[0].can_open_direct_chat").value(false)); + + mockMvc.perform(post("/chatroom/team-recruitment/{recruitmentId}/applications/{applicationId}/direct", + recruitment.getId(), application.getId()) + .header("Authorization", "Bearer " + authorToken)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("TEAM_RECRUITMENT_CLOSED")); + } + + @Test + void 정원충족으로_자동_마감된_ACCEPTED_지원서는_ACTIVE_TEAM_방이_있어_DIRECT를_생성할_수_있다() throws Exception { + RecruitmentContext lastSeatContext = saveRecruitmentAndTeamRoom("한 명 모집 개인 채팅", 1); + TeamRecruitment recruitment = lastSeatContext.recruitment(); + TeamRecruitmentApplication application = savePendingApplication(recruitment); + + accept(recruitment, application, authorToken) + .andExpect(status().isNoContent()); + + mockMvc.perform(get("/team-recruitments/{recruitmentId}/applications", recruitment.getId()) + .header("Authorization", "Bearer " + authorToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.applications[0].status").value("ACCEPTED")) + .andExpect(jsonPath("$.applications[0].can_open_direct_chat").value(true)); + + mockMvc.perform(post("/chatroom/team-recruitment/{recruitmentId}/applications/{applicationId}/direct", + recruitment.getId(), application.getId()) + .header("Authorization", "Bearer " + authorToken)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.chat_room_id").isNumber()) + .andExpect(jsonPath("$.room_type").value("DIRECT")) + .andExpect(jsonPath("$.status").value("ACTIVE")); + } + + @Test + void 마감일이_지난_RECRUITING_모집글은_스케줄러_전에도_DIRECT_CTA와_생성이_차단된다() throws Exception { + TeamRecruitment recruitment = recruitmentContext.recruitment(); + TeamRecruitmentApplication application = savePendingApplication(recruitment); + + accept(recruitment, application, authorToken) + .andExpect(status().isNoContent()); + expireRecruitment(recruitment); + entityManager.flush(); + entityManager.clear(); + + mockMvc.perform(get("/team-recruitments/{recruitmentId}/applications", recruitment.getId()) + .header("Authorization", "Bearer " + authorToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.applications[0].status").value("ACCEPTED")) + .andExpect(jsonPath("$.applications[0].can_open_direct_chat").value(false)); + + mockMvc.perform(post("/chatroom/team-recruitment/{recruitmentId}/applications/{applicationId}/direct", + recruitment.getId(), application.getId()) + .header("Authorization", "Bearer " + authorToken)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("TEAM_RECRUITMENT_CLOSED")); + } + + @Test + void 마감일이_지난_RECRUITING_모집글의_기존_DIRECT_방은_계속_반환된다() throws Exception { + TeamRecruitment recruitment = recruitmentContext.recruitment(); + TeamRecruitmentApplication application = savePendingApplication(recruitment); + + accept(recruitment, application, authorToken) + .andExpect(status().isNoContent()); + mockMvc.perform(post( + "/chatroom/team-recruitment/{recruitmentId}/applications/{applicationId}/direct", + recruitment.getId(), application.getId()) + .header("Authorization", "Bearer " + authorToken)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.room_type").value("DIRECT")); + entityManager.flush(); + Integer directRoomId = chatRoomRepository + .findByRecruitment_IdAndApplication_IdAndRoomType(recruitment.getId(), application.getId(), DIRECT) + .orElseThrow() + .getId(); + expireRecruitment(recruitment); + entityManager.flush(); + entityManager.clear(); + + mockMvc.perform(post("/chatroom/team-recruitment/{recruitmentId}/applications/{applicationId}/direct", + recruitment.getId(), application.getId()) + .header("Authorization", "Bearer " + authorToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.chat_room_id").value(directRoomId)); + + mockMvc.perform(get("/chatroom/team-recruitment/{recruitmentId}/{chatRoomId}", + recruitment.getId(), directRoomId) + .header("Authorization", "Bearer " + authorToken)) + .andExpect(status().isOk()); + } + + @Test + void 삭제된_모집글의_기존_DIRECT_방은_재요청과_조회가_가능하다() throws Exception { + TeamRecruitment recruitment = recruitmentContext.recruitment(); + TeamRecruitmentApplication application = savePendingApplication(recruitment); + + accept(recruitment, application, authorToken) + .andExpect(status().isNoContent()); + mockMvc.perform(post("/chatroom/team-recruitment/{recruitmentId}/applications/{applicationId}/direct", + recruitment.getId(), application.getId()) + .header("Authorization", "Bearer " + authorToken)) + .andExpect(status().isCreated()); + + entityManager.flush(); + entityManager.clear(); + TeamRecruitmentChatRoom directRoom = chatRoomRepository + .findByRecruitment_IdAndApplication_IdAndRoomType(recruitment.getId(), application.getId(), DIRECT) + .orElseThrow(); + + mockMvc.perform(delete("/team-recruitments/{id}", recruitment.getId()) + .header("Authorization", "Bearer " + authorToken)) + .andExpect(status().isNoContent()); + + assertThat(chatRoomRepository.findById(directRoom.getId()).orElseThrow().getStatus()) + .isEqualTo(READ_ONLY); + + mockMvc.perform(post("/chatroom/team-recruitment/{recruitmentId}/applications/{applicationId}/direct", + recruitment.getId(), application.getId()) + .header("Authorization", "Bearer " + authorToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.chat_room_id").value(directRoom.getId())) + .andExpect(jsonPath("$.status").value("READ_ONLY")); + + mockMvc.perform(get("/chatroom/team-recruitment/{recruitmentId}/{chatRoomId}", + recruitment.getId(), directRoom.getId()) + .header("Authorization", "Bearer " + authorToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("READ_ONLY")); + } + @Test void 다른_모집글의_applicationId로_조회하거나_상태를_변경할_수_없다() throws Exception { RecruitmentContext otherContext = saveRecruitmentAndTeamRoom("다른 팀원 모집", 2); @@ -451,6 +600,23 @@ private TeamRecruitment saveRecruitment(String title, int maxParticipants) { .build()); } + private void expireRecruitment(TeamRecruitment recruitment) { + recruitment.modify( + recruitment.getCategory(), + recruitment.getTitle(), + recruitment.getMeetingType(), + recruitment.getActivityStartDate(), + recruitment.getActivityEndDate(), + LocalDate.now(clock).minusDays(1), + recruitment.getRecruitmentType(), + recruitment.getMaxParticipants(), + recruitment.getDescription(), + recruitment.getRelatedUrl(), + recruitment.getQualification() + ); + recruitmentRepository.save(recruitment); + } + private TeamRecruitmentApplication savePendingApplication(TeamRecruitment recruitment) { return applicationRepository.save(TeamRecruitmentApplication.builder() .recruitment(recruitment) diff --git a/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/model/TeamRecruitmentDirectChatPolicyTest.java b/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/model/TeamRecruitmentDirectChatPolicyTest.java new file mode 100644 index 0000000000..d472623aff --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/model/TeamRecruitmentDirectChatPolicyTest.java @@ -0,0 +1,115 @@ +package in.koreatech.koin.unit.domain.team.recruitment.model; + +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.TeamRecruitmentChatRoomStatus.ACTIVE; +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentChatRoomStatus.READ_ONLY; +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentChatRoomType.TEAM; +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.domain.team.recruitment.enums.TeamRecruitmentApplicationStatus; +import in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentChatRoomStatus; +import in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentStatus; +import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitment; +import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentChatRoom; +import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentDirectChatPolicy; +import in.koreatech.koin.unit.fixture.UserFixture; +import java.time.LocalDate; +import org.junit.jupiter.api.Test; + +class TeamRecruitmentDirectChatPolicyTest { + + private static final LocalDate TODAY = LocalDate.of(2026, 8, 28); + + @Test + void 모집_중인_ACCEPTED_지원서는_TEAM_방이_없어도_DIRECT를_열수있다() { + assertThat(canOpen(ACCEPTED, false, recruitment(RECRUITING), null)).isTrue(); + } + + @Test + void 마감일이_지난_RECRUITING_모집글은_ACTIVE_TEAM_방이_있어도_DIRECT를_열수없다() { + TeamRecruitment recruitment = recruitment(RECRUITING, LocalDate.of(2026, 8, 27)); + + assertThat(canOpen(ACCEPTED, false, recruitment, teamRoom(recruitment, ACTIVE))).isFalse(); + } + + @Test + void 정원충족으로_마감된_ACCEPTED_지원서는_ACTIVE_TEAM_방이_있으면_DIRECT를_열수있다() { + TeamRecruitment recruitment = recruitment(CLOSED); + + assertThat(canOpen(ACCEPTED, false, recruitment, teamRoom(recruitment, ACTIVE))).isTrue(); + } + + @Test + void 수동_기한_마감된_ACCEPTED_지원서는_READ_ONLY_TEAM_방이면_DIRECT를_열수없다() { + TeamRecruitment recruitment = recruitment(CLOSED); + + assertThat(canOpen(ACCEPTED, false, recruitment, teamRoom(recruitment, READ_ONLY))).isFalse(); + } + + @Test + void 삭제된_모집글은_ACTIVE_TEAM_방이_남아도_신규_DIRECT를_열수없다() { + TeamRecruitment deleted = recruitment(CLOSED); + deleted.markDeleted(LocalDate.of(2026, 8, 28).atStartOfDay()); + + assertThat(canOpen(ACCEPTED, false, deleted, teamRoom(deleted, ACTIVE))).isFalse(); + } + + @Test + void 기존_DIRECT_방은_모집글_상태와_관계없이_열수있다() { + TeamRecruitment recruitment = recruitment(CLOSED); + + assertThat(canOpen(ACCEPTED, true, recruitment, teamRoom(recruitment, READ_ONLY))).isTrue(); + } + + @Test + void ACCEPTED가_아니면_모집_중이어도_DIRECT를_열수없다() { + assertThat(canOpen(PENDING, false, recruitment(RECRUITING), null)).isFalse(); + } + + private boolean canOpen( + TeamRecruitmentApplicationStatus applicationStatus, + boolean hasExistingDirectChat, + TeamRecruitment recruitment, + TeamRecruitmentChatRoom teamChatRoom + ) { + return TeamRecruitmentDirectChatPolicy.canOpenDirectChat( + applicationStatus, hasExistingDirectChat, recruitment, teamChatRoom, TODAY); + } + + private TeamRecruitment recruitment(TeamRecruitmentStatus status) { + return recruitment(status, LocalDate.of(2026, 8, 31)); + } + + private TeamRecruitment recruitment(TeamRecruitmentStatus status, LocalDate deadlineDate) { + return TeamRecruitment.builder() + .id(10) + .author(UserFixture.id_설정_코인_유저(1)) + .title("팀원 모집") + .activityStartDate(LocalDate.of(2026, 9, 1)) + .activityEndDate(LocalDate.of(2026, 9, 30)) + .deadlineDate(deadlineDate) + .recruitmentType(GENERAL) + .maxParticipants(5) + .currentParticipants(0) + .description("모집 내용") + .status(status) + .build(); + } + + private TeamRecruitmentChatRoom teamRoom( + TeamRecruitment recruitment, + TeamRecruitmentChatRoomStatus status + ) { + return TeamRecruitmentChatRoom.builder() + .id(40) + .recruitment(recruitment) + .roomScopeKey("TEAM") + .roomType(TEAM) + .status(status) + .build(); + } +} diff --git a/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/service/TeamRecruitmentApplicationQueryServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/service/TeamRecruitmentApplicationQueryServiceTest.java index 6e00392892..49edcdf8fd 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/service/TeamRecruitmentApplicationQueryServiceTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/service/TeamRecruitmentApplicationQueryServiceTest.java @@ -4,6 +4,7 @@ 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.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.TeamRecruitmentStatus.CLOSED; @@ -23,6 +24,7 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import in.koreatech.koin.domain.student.model.Student; import in.koreatech.koin.domain.student.repository.StudentRepository; +import in.koreatech.koin.domain.team.recruitment.dto.ApplicantDetail; import in.koreatech.koin.domain.team.recruitment.dto.ApplicantListResponse; import in.koreatech.koin.domain.team.recruitment.dto.ApplicantSummary; import in.koreatech.koin.domain.team.recruitment.dto.MyApplication; @@ -31,6 +33,8 @@ import in.koreatech.koin.domain.team.recruitment.dto.RecruitmentRole; import in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentApplicationSort; import in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentApplicationStatus; +import in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentChatRoomStatus; +import in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentStatus; 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; @@ -476,6 +480,191 @@ void setUpClock() { assertThat(response.currentPage()).isEqualTo(3); } + @Test + void 수동_마감된_모집글의_ACCEPTED_지원자는_기존방이_없으면_DIRECT_CTA가_닫힌다() { + TeamRecruitment recruitment = recruitment(); + recruitment.close(); + TeamRecruitmentApplication accepted = applicationWithSnapshot(20, recruitment, ACCEPTED); + + when(studentRepository.getById(AUTHOR_ID)).thenReturn(null); + when(recruitmentRepository.findById(RECRUITMENT_ID)).thenReturn(Optional.of(recruitment)); + when(applicationRepository.countByRecruitment_IdAndStatusIn(eq(RECRUITMENT_ID), any())) + .thenReturn(1L); + when(applicationRepository.findAllByRecruitment_IdAndStatusIn( + eq(RECRUITMENT_ID), + any(), + any(Pageable.class) + )).thenReturn(new PageImpl<>(List.of(accepted))); + when(chatRoomRepository.findAllByRecruitment_IdInAndRoomScopeKeyAndRoomType( + List.of(RECRUITMENT_ID), + "TEAM", + TEAM + )).thenReturn(List.of(teamRoom(recruitment, READ_ONLY))); + when(chatRoomRepository.findAllByApplication_IdInAndRoomType(List.of(20), DIRECT)) + .thenReturn(List.of()); + + ApplicantListResponse response = queryService.getApplications( + RECRUITMENT_ID, + List.of(ACCEPTED), + 1, + 10, + AUTHOR_ID + ); + + assertThat(response.applications().get(0).canOpenDirectChat()).isFalse(); + } + + @Test + void 정원충족으로_자동_마감된_모집글은_ACTIVE_TEAM_방이_있으면_DIRECT_CTA가_열린다() { + TeamRecruitment recruitment = recruitment(1, 1, CLOSED); + TeamRecruitmentApplication accepted = applicationWithSnapshot(20, recruitment, ACCEPTED); + + when(studentRepository.getById(AUTHOR_ID)).thenReturn(null); + when(recruitmentRepository.findById(RECRUITMENT_ID)).thenReturn(Optional.of(recruitment)); + when(applicationRepository.countByRecruitment_IdAndStatusIn(eq(RECRUITMENT_ID), any())) + .thenReturn(1L); + when(applicationRepository.findAllByRecruitment_IdAndStatusIn( + eq(RECRUITMENT_ID), + any(), + any(Pageable.class) + )).thenReturn(new PageImpl<>(List.of(accepted))); + when(chatRoomRepository.findAllByRecruitment_IdInAndRoomScopeKeyAndRoomType( + List.of(RECRUITMENT_ID), + "TEAM", + TEAM + )).thenReturn(List.of(teamRoom(recruitment, ACTIVE))); + when(chatRoomRepository.findAllByApplication_IdInAndRoomType(List.of(20), DIRECT)) + .thenReturn(List.of()); + + ApplicantListResponse response = queryService.getApplications( + RECRUITMENT_ID, + List.of(ACCEPTED), + 1, + 10, + AUTHOR_ID + ); + + assertThat(response.applications().get(0).canOpenDirectChat()).isTrue(); + } + + @Test + void 마감일이_지난_RECRUITING_모집글은_ACTIVE_TEAM_방이_있어도_DIRECT_CTA가_닫힌다() { + TeamRecruitment recruitment = recruitment(LocalDate.of(2026, 8, 27)); + TeamRecruitmentApplication accepted = applicationWithSnapshot(20, recruitment, ACCEPTED); + + when(studentRepository.getById(AUTHOR_ID)).thenReturn(null); + when(recruitmentRepository.findById(RECRUITMENT_ID)).thenReturn(Optional.of(recruitment)); + when(applicationRepository.countByRecruitment_IdAndStatusIn(eq(RECRUITMENT_ID), any())) + .thenReturn(1L); + when(applicationRepository.findAllByRecruitment_IdAndStatusIn( + eq(RECRUITMENT_ID), + any(), + any(Pageable.class) + )).thenReturn(new PageImpl<>(List.of(accepted))); + when(chatRoomRepository.findAllByRecruitment_IdInAndRoomScopeKeyAndRoomType( + List.of(RECRUITMENT_ID), + "TEAM", + TEAM + )).thenReturn(List.of(teamRoom(recruitment, ACTIVE))); + when(chatRoomRepository.findAllByApplication_IdInAndRoomType(List.of(20), DIRECT)) + .thenReturn(List.of()); + + ApplicantListResponse response = queryService.getApplications( + RECRUITMENT_ID, + List.of(ACCEPTED), + 1, + 10, + AUTHOR_ID + ); + + assertThat(response.applications().get(0).canOpenDirectChat()).isFalse(); + } + + @Test + void 마감된_모집글의_기존_DIRECT_방은_DIRECT_CTA를_계속_연다() { + TeamRecruitment recruitment = recruitment(); + recruitment.close(); + TeamRecruitmentApplication accepted = applicationWithSnapshot(20, recruitment, ACCEPTED); + TeamRecruitmentChatRoom existingDirect = TeamRecruitmentChatRoom.builder() + .id(DIRECT_ROOM_ID) + .recruitment(recruitment) + .roomScopeKey("DIRECT-20") + .roomType(DIRECT) + .application(accepted) + .status(READ_ONLY) + .build(); + + when(studentRepository.getById(AUTHOR_ID)).thenReturn(null); + when(recruitmentRepository.findById(RECRUITMENT_ID)).thenReturn(Optional.of(recruitment)); + when(applicationRepository.countByRecruitment_IdAndStatusIn(eq(RECRUITMENT_ID), any())) + .thenReturn(1L); + when(applicationRepository.findAllByRecruitment_IdAndStatusIn( + eq(RECRUITMENT_ID), + any(), + any(Pageable.class) + )).thenReturn(new PageImpl<>(List.of(accepted))); + when(chatRoomRepository.findAllByRecruitment_IdInAndRoomScopeKeyAndRoomType( + List.of(RECRUITMENT_ID), + "TEAM", + TEAM + )).thenReturn(List.of(teamRoom(recruitment, READ_ONLY))); + when(chatRoomRepository.findAllByApplication_IdInAndRoomType(List.of(20), DIRECT)) + .thenReturn(List.of(existingDirect)); + + ApplicantListResponse response = queryService.getApplications( + RECRUITMENT_ID, + List.of(ACCEPTED), + 1, + 10, + AUTHOR_ID + ); + + assertThat(response.applications().get(0).canOpenDirectChat()).isTrue(); + } + + @Test + void 지원서_상세도_마감된_모집글에서_기존방이_없으면_DIRECT_CTA가_닫힌다() { + TeamRecruitment recruitment = recruitment(); + recruitment.close(); + TeamRecruitmentApplication accepted = applicationWithSnapshot(20, recruitment, ACCEPTED); + + when(studentRepository.getById(AUTHOR_ID)).thenReturn(null); + when(recruitmentRepository.findById(RECRUITMENT_ID)).thenReturn(Optional.of(recruitment)); + when(applicationRepository.findById(20)).thenReturn(Optional.of(accepted)); + when(chatRoomRepository.findAllByRecruitment_IdInAndRoomScopeKeyAndRoomType( + List.of(RECRUITMENT_ID), + "TEAM", + TEAM + )).thenReturn(List.of(teamRoom(recruitment, READ_ONLY))); + when(chatRoomRepository.findAllByApplication_IdInAndRoomType(List.of(20), DIRECT)) + .thenReturn(List.of()); + + ApplicantDetail response = queryService.getApplicationDetail(RECRUITMENT_ID, 20, AUTHOR_ID); + + assertThat(response.canOpenDirectChat()).isFalse(); + } + + @Test + void 지원서_상세도_마감일이_지난_RECRUITING_모집글은_ACTIVE_TEAM_방이_있어도_DIRECT_CTA가_닫힌다() { + TeamRecruitment recruitment = recruitment(LocalDate.of(2026, 8, 27)); + TeamRecruitmentApplication accepted = applicationWithSnapshot(20, recruitment, ACCEPTED); + + when(studentRepository.getById(AUTHOR_ID)).thenReturn(null); + when(recruitmentRepository.findById(RECRUITMENT_ID)).thenReturn(Optional.of(recruitment)); + when(applicationRepository.findById(20)).thenReturn(Optional.of(accepted)); + when(chatRoomRepository.findAllByRecruitment_IdInAndRoomScopeKeyAndRoomType( + List.of(RECRUITMENT_ID), + "TEAM", + TEAM + )).thenReturn(List.of(teamRoom(recruitment, ACTIVE))); + when(chatRoomRepository.findAllByApplication_IdInAndRoomType(List.of(20), DIRECT)) + .thenReturn(List.of()); + + ApplicantDetail response = queryService.getApplicationDetail(RECRUITMENT_ID, 20, AUTHOR_ID); + + assertThat(response.canOpenDirectChat()).isFalse(); + } + @Test void ACCEPTED인데_TEAM_room이_없으면_내부_무결성_예외가_발생한다() { TeamRecruitment recruitment = recruitment(); @@ -557,12 +746,19 @@ void setUpClock() { } private TeamRecruitmentChatRoom teamRoom(TeamRecruitment recruitment) { + return teamRoom(recruitment, ACTIVE); + } + + private TeamRecruitmentChatRoom teamRoom( + TeamRecruitment recruitment, + TeamRecruitmentChatRoomStatus status + ) { return TeamRecruitmentChatRoom.builder() .id(TEAM_ROOM_ID) .recruitment(recruitment) .roomScopeKey("TEAM") .roomType(TEAM) - .status(ACTIVE) + .status(status) .build(); } @@ -571,6 +767,23 @@ private TeamRecruitment recruitment() { } private TeamRecruitment recruitment(LocalDate deadlineDate) { + return recruitment(5, 0, RECRUITING, deadlineDate); + } + + private TeamRecruitment recruitment( + Integer maxParticipants, + Integer currentParticipants, + TeamRecruitmentStatus status + ) { + return recruitment(maxParticipants, currentParticipants, status, LocalDate.of(2026, 8, 31)); + } + + private TeamRecruitment recruitment( + Integer maxParticipants, + Integer currentParticipants, + TeamRecruitmentStatus status, + LocalDate deadlineDate + ) { return TeamRecruitment.builder() .id(RECRUITMENT_ID) .author(UserFixture.id_설정_코인_유저(1)) @@ -579,9 +792,10 @@ private TeamRecruitment recruitment(LocalDate deadlineDate) { .activityEndDate(LocalDate.of(2026, 9, 30)) .deadlineDate(deadlineDate) .recruitmentType(GENERAL) - .maxParticipants(5) - .currentParticipants(0) + .maxParticipants(maxParticipants) + .currentParticipants(currentParticipants) .description("모집 내용") + .status(status) .build(); } diff --git a/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java index 8b5e6b9bd9..c974285250 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java @@ -2,12 +2,18 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -23,6 +29,7 @@ import in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentApplicationStatus; import in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentChatRoomStatus; import in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentChatRoomType; +import in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentStatus; 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.TeamRecruitmentChatMember; @@ -48,6 +55,9 @@ class TeamRecruitmentChatServiceTest { private static final Integer RECRUITMENT_ID = 10; private static final Integer CHAT_ROOM_ID = 20; private static final Integer APPLICATION_ID = 30; + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private static final Clock FIXED_CLOCK = Clock.fixed( + Instant.parse("2026-08-28T03:00:00Z"), KST); @Mock private TeamRecruitmentRepository recruitmentRepository; @@ -73,9 +83,17 @@ class TeamRecruitmentChatServiceTest { @Mock private ObjectMapper objectMapper; + @Mock + private Clock clock; + @InjectMocks private TeamRecruitmentChatService chatService; + @BeforeEach + void setUpClock() { + lenient().when(clock.withZone(KST)).thenReturn(FIXED_CLOCK); + } + @Test void 존재하지_않는_채팅방_조회시_404를_반환한다() { when(chatRoomRepository.findById(CHAT_ROOM_ID)).thenReturn(Optional.empty()); @@ -163,6 +181,7 @@ class TeamRecruitmentChatServiceTest { when(recruitment.getId()).thenReturn(RECRUITMENT_ID); when(recruitment.getAuthor()).thenReturn(author); when(application.getStatus()).thenReturn(TeamRecruitmentApplicationStatus.ACCEPTED); + when(recruitment.getStatus()).thenReturn(TeamRecruitmentStatus.CLOSED); when(recruitment.isRecruiting()).thenReturn(false); assertThatThrownBy(() -> chatService.getOrCreateDirectChatRoom(USER_ID, RECRUITMENT_ID, APPLICATION_ID)) @@ -203,7 +222,6 @@ class TeamRecruitmentChatServiceTest { when(application.getRecruitment()).thenReturn(recruitment); when(recruitment.getId()).thenReturn(RECRUITMENT_ID); when(application.getStatus()).thenReturn(TeamRecruitmentApplicationStatus.ACCEPTED); - when(recruitment.isRecruiting()).thenReturn(true); when(recruitment.getAuthor()).thenReturn(author); when(application.getApplicant()).thenReturn(counterpart); when(chatRoomRepository.findByRecruitment_IdAndApplication_IdAndRoomType( From c3b41ded86192ed068fda7cb59d92b7e9ae91f86 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 23:49:19 +0900 Subject: [PATCH 04/32] =?UTF-8?q?fix:=20TEAM=20=EC=B1=84=ED=8C=85=EB=B0=A9?= =?UTF-8?q?=20=EC=9D=B8=EC=9B=90=20=EC=83=81=ED=95=9C=EC=9D=84=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=20=EB=A9=A4=EB=B2=84=20=EA=B8=B0=EC=A4=80=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EA=B3=84=EC=82=B0=20(#2386)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: initialize issue #2373 * fix: TEAM 채팅방 최대 인원에 작성자 포함 --- .../controller/TeamRecruitmentApi.java | 17 ++-- .../recruitment/dto/ApplicantRecruitment.java | 4 +- .../dto/CreateRecruitmentRequest.java | 7 +- .../recruitment/dto/CreatedRecruitment.java | 4 +- .../team/recruitment/dto/RecruitmentCard.java | 4 +- .../recruitment/dto/RecruitmentDetail.java | 4 +- .../team/recruitment/dto/RoleInput.java | 2 +- .../dto/UpdateRecruitmentRequest.java | 7 +- .../team/recruitment/dto/UpdateRoleInput.java | 2 +- .../teamrecruitment/dto/ChatRoomResponse.java | 10 +- .../service/TeamRecruitmentChatService.java | 6 +- .../TeamRecruitmentQueryServiceTest.java | 13 +++ .../TeamRecruitmentChatServiceTest.java | 91 +++++++++++++++++++ 13 files changed, 145 insertions(+), 26 deletions(-) diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/controller/TeamRecruitmentApi.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/controller/TeamRecruitmentApi.java index 0203eb7fa8..fdb58de5f3 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/controller/TeamRecruitmentApi.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/controller/TeamRecruitmentApi.java @@ -99,10 +99,13 @@ ResponseEntity getRecruitments( }) @Operation(summary = "모집글 작성", description = """ ### 모집글 작성 - - `recruitment_type=ROLE_BASED`은 `roles`를 1~5개 보내며 전체 정원은 역할 정원의 합으로 계산됩니다. - - 전체 정원은 10명을 넘을 수 없습니다. 역할별 정원의 합도 같은 상한을 지켜야 합니다. + - `recruitment_type=ROLE_BASED`은 `roles`를 1~5개 보내며 작성자를 제외한 지원자 모집 정원은 + 역할별 지원자 모집 정원의 합으로 계산됩니다. + - 작성자를 제외한 지원자 모집 정원은 10명을 넘을 수 없습니다. + 역할별 지원자 모집 정원의 합도 같은 상한을 지켜야 합니다. - 역할명은 앞뒤 공백을 제거해 저장하며, 대소문자와 악센트만 다른 이름도 중복으로 봅니다. - - `recruitment_type=GENERAL`은 `max_participants`를 보내고 `roles`는 빈 배열로 보내셔야 합니다. + - `recruitment_type=GENERAL`은 작성자를 제외한 지원자 모집 정원인 `max_participants`를 보내고 + `roles`는 빈 배열로 보내셔야 합니다. - 지원 마감일은 활동 시작일 이하, 활동 시작일은 활동 종료일 이하여야 합니다. - 모집글과 TEAM 채팅방을 같은 트랜잭션에서 생성하고 작성자를 최초 채팅방 멤버로 추가합니다. - 별도의 팀 채팅방 생성 API는 없습니다. @@ -152,11 +155,13 @@ ResponseEntity getRecruitment( ### 모집글 수정 - 작성자만 수정할 수 있습니다. - 기존 역할은 `id`를 반드시 보내고 새 역할은 `id`를 생략하셔야 합니다. - - 지원자가 있는 역할은 삭제, 이름 변경, 정원 축소를 할 수 없습니다. + - 지원자가 있는 역할은 삭제, 이름 변경, 지원자 정원 축소를 할 수 없습니다. - 기존 역할의 표시 순서는 유지되며, 새 역할은 기존 역할 뒤에 요청 배열 순서대로 추가됩니다. - - 전체 정원은 10명을 넘을 수 없습니다. + - `recruitment_type=GENERAL`의 `max_participants`는 작성자를 제외한 지원자 모집 정원입니다. + - `recruitment_type=ROLE_BASED`의 지원자 모집 정원은 역할별 지원자 모집 정원의 합으로 계산됩니다. + - 작성자를 제외한 지원자 모집 정원은 10명을 넘을 수 없습니다. - 역할명은 앞뒤 공백을 제거해 저장하며, 대소문자와 악센트만 다른 이름도 중복으로 봅니다. - - 정원을 이미 승인된 인원과 같게 줄이면 그 자리에서 마감됩니다. + - 지원자 정원을 이미 승인된 인원과 같게 줄이면 그 자리에서 마감됩니다. - 마감된 모집글은 수정할 수 없습니다. """) @PutMapping("/{recruitmentId}") diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/ApplicantRecruitment.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/ApplicantRecruitment.java index 3f752ca2cf..01d674ff4c 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/ApplicantRecruitment.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/ApplicantRecruitment.java @@ -50,10 +50,10 @@ public record ApplicantRecruitment( @Schema(description = "모집 유형", example = "ROLE_BASED", requiredMode = REQUIRED) TeamRecruitmentType recruitmentType, - @Schema(description = "승인된 전체 지원자 수", example = "2", requiredMode = REQUIRED) + @Schema(description = "작성자를 제외한 승인된 전체 지원자 수", example = "2", requiredMode = REQUIRED) Integer currentParticipants, - @Schema(description = "전체 모집 정원", example = "5", requiredMode = REQUIRED) + @Schema(description = "작성자를 제외한 전체 모집 정원", example = "5", requiredMode = REQUIRED) Integer maxParticipants, @Schema(description = "역할 목록", requiredMode = REQUIRED) diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/CreateRecruitmentRequest.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/CreateRecruitmentRequest.java index fd3477ae36..283b941e91 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/CreateRecruitmentRequest.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/CreateRecruitmentRequest.java @@ -57,12 +57,13 @@ public record CreateRecruitmentRequest( @NotNull TeamRecruitmentType recruitmentType, - @Schema(description = "전체 모집 정원. GENERAL 모집만 사용하며 ROLE_BASED 모집은 역할 정원의 합으로 계산됩니다.", + @Schema(description = "작성자를 제외한 지원자 모집 정원. GENERAL 모집만 사용하며 " + + "ROLE_BASED 모집은 역할별 지원자 모집 정원의 합으로 계산됩니다.", example = "5", nullable = true, requiredMode = NOT_REQUIRED) Integer maxParticipants, - @Schema(description = "역할 목록. ROLE_BASED 모집은 1~5개이며 역할별 정원의 합은 최대 10명입니다. " - + "GENERAL 모집은 빈 배열입니다.", requiredMode = REQUIRED) + @Schema(description = "역할 목록. ROLE_BASED 모집은 1~5개이며 역할별 지원자 모집 정원의 합은 최대 10명입니다. " + + "작성자는 지원자 모집 정원에 포함되지 않습니다. GENERAL 모집은 빈 배열입니다.", requiredMode = REQUIRED) @NotNull List<@NotNull @Valid RoleInput> roles, diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/CreatedRecruitment.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/CreatedRecruitment.java index 09356266d8..e6dcff74de 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/CreatedRecruitment.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/CreatedRecruitment.java @@ -50,10 +50,10 @@ public record CreatedRecruitment( @Schema(description = "모집 유형", example = "ROLE_BASED", requiredMode = REQUIRED) TeamRecruitmentType recruitmentType, - @Schema(description = "승인된 전체 지원자 수", example = "2", requiredMode = REQUIRED) + @Schema(description = "작성자를 제외한 승인된 전체 지원자 수", example = "2", requiredMode = REQUIRED) Integer currentParticipants, - @Schema(description = "전체 모집 정원", example = "5", requiredMode = REQUIRED) + @Schema(description = "작성자를 제외한 전체 모집 정원", example = "5", requiredMode = REQUIRED) Integer maxParticipants, @Schema(description = "역할 목록", requiredMode = REQUIRED) diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/RecruitmentCard.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/RecruitmentCard.java index e4b0855af9..b974af9c07 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/RecruitmentCard.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/RecruitmentCard.java @@ -50,10 +50,10 @@ public record RecruitmentCard( @Schema(description = "모집 유형", example = "ROLE_BASED", requiredMode = REQUIRED) TeamRecruitmentType recruitmentType, - @Schema(description = "승인된 전체 지원자 수", example = "2", requiredMode = REQUIRED) + @Schema(description = "작성자를 제외한 승인된 전체 지원자 수", example = "2", requiredMode = REQUIRED) Integer currentParticipants, - @Schema(description = "전체 모집 정원", example = "5", requiredMode = REQUIRED) + @Schema(description = "작성자를 제외한 전체 모집 정원", example = "5", requiredMode = REQUIRED) Integer maxParticipants, @Schema(description = "역할 목록", requiredMode = REQUIRED) diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/RecruitmentDetail.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/RecruitmentDetail.java index 642f68a21a..60e852ff5e 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/RecruitmentDetail.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/RecruitmentDetail.java @@ -53,10 +53,10 @@ public record RecruitmentDetail( @Schema(description = "모집 유형", example = "ROLE_BASED", requiredMode = REQUIRED) TeamRecruitmentType recruitmentType, - @Schema(description = "승인된 전체 지원자 수", example = "2", requiredMode = REQUIRED) + @Schema(description = "작성자를 제외한 승인된 전체 지원자 수", example = "2", requiredMode = REQUIRED) Integer currentParticipants, - @Schema(description = "전체 모집 정원", example = "5", requiredMode = REQUIRED) + @Schema(description = "작성자를 제외한 전체 모집 정원", example = "5", requiredMode = REQUIRED) Integer maxParticipants, @Schema(description = "역할 목록", requiredMode = REQUIRED) diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/RoleInput.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/RoleInput.java index c13fc9800b..4a05941901 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/RoleInput.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/RoleInput.java @@ -19,7 +19,7 @@ public record RoleInput( @Size(min = 1, max = 10) String name, - @Schema(description = "역할 정원", example = "1", requiredMode = REQUIRED) + @Schema(description = "해당 역할로 모집할 지원자 정원", example = "1", requiredMode = REQUIRED) @NotNull @Min(1) @Max(10) diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/UpdateRecruitmentRequest.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/UpdateRecruitmentRequest.java index af64107854..4399ecd413 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/UpdateRecruitmentRequest.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/UpdateRecruitmentRequest.java @@ -54,13 +54,14 @@ public record UpdateRecruitmentRequest( @NotNull TeamRecruitmentType recruitmentType, - @Schema(description = "전체 모집 정원. GENERAL 모집만 사용하며 ROLE_BASED 모집은 역할 정원의 합으로 계산됩니다.", + @Schema(description = "작성자를 제외한 지원자 모집 정원. GENERAL 모집만 사용하며 " + + "ROLE_BASED 모집은 역할별 지원자 모집 정원의 합으로 계산됩니다.", example = "5", nullable = true, requiredMode = NOT_REQUIRED) Integer maxParticipants, @Schema(description = "역할 목록. 기존 역할은 id를 반드시 보내고 새 역할은 id를 생략합니다. " - + "ROLE_BASED 모집은 1~5개이며 역할별 정원의 합은 최대 10명입니다. " - + "GENERAL 모집은 빈 배열입니다.", requiredMode = REQUIRED) + + "ROLE_BASED 모집은 1~5개이며 역할별 지원자 모집 정원의 합은 최대 10명입니다. " + + "작성자는 지원자 모집 정원에 포함되지 않습니다. GENERAL 모집은 빈 배열입니다.", requiredMode = REQUIRED) @NotNull List<@NotNull @Valid UpdateRoleInput> roles, diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/UpdateRoleInput.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/UpdateRoleInput.java index 63677d351c..70232330e8 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/UpdateRoleInput.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/UpdateRoleInput.java @@ -23,7 +23,7 @@ public record UpdateRoleInput( @Size(min = 1, max = 10) String name, - @Schema(description = "역할 정원", example = "1", requiredMode = REQUIRED) + @Schema(description = "해당 역할로 모집할 지원자 정원", example = "1", requiredMode = REQUIRED) @NotNull @Min(1) @Max(10) diff --git a/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/ChatRoomResponse.java b/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/ChatRoomResponse.java index 6c10e6907e..9f6ed9d0dd 100644 --- a/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/ChatRoomResponse.java +++ b/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/ChatRoomResponse.java @@ -21,10 +21,16 @@ public record ChatRoomResponse( @Schema(description = "채팅방 상태", example = "ACTIVE") String status, - @Schema(description = "현재 멤버 수", example = "3") + @Schema( + description = "현재 채팅방 멤버 수. TEAM은 작성자와 승인된 지원자를 포함하고, DIRECT는 항상 2", + example = "3" + ) int memberCount, - @Schema(description = "최대 멤버 수", example = "6") + @Schema( + description = "최대 채팅방 멤버 수. TEAM은 모집 정원에 작성자 1명을 더한 값이고, DIRECT는 항상 2", + example = "6" + ) int maxMemberCount, @Schema(description = "상대방 정보 (DIRECT만 non-null)") diff --git a/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentChatService.java b/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentChatService.java index f127a0aa03..29ba6d27b2 100644 --- a/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentChatService.java +++ b/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentChatService.java @@ -54,6 +54,8 @@ public class TeamRecruitmentChatService { private static final ZoneId KST = ZoneId.of("Asia/Seoul"); private static final String OUTBOX_EVENT_TYPE = "TEAM_RECRUITMENT_NOTIFICATION"; private static final String AGGREGATE_TYPE = "TEAM_RECRUITMENT"; + private static final int DIRECT_MEMBER_COUNT = 2; + private static final int TEAM_AUTHOR_COUNT = 1; private final TeamRecruitmentRepository recruitmentRepository; private final TeamRecruitmentApplicationRepository applicationRepository; @@ -93,8 +95,8 @@ public ChatRoomResponse getChatRoom(Integer userId, Integer recruitmentId, Integ : chatRoom.getRecruitment().getTitle(); int maxMemberCount = chatRoom.getRoomType() == TeamRecruitmentChatRoomType.DIRECT - ? 2 - : chatRoom.getRecruitment().getMaxParticipants(); + ? DIRECT_MEMBER_COUNT + : chatRoom.getRecruitment().getMaxParticipants() + TEAM_AUTHOR_COUNT; return ChatRoomResponse.of(chatRoom, roomName, memberCount, maxMemberCount, counterpart); } diff --git a/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/service/TeamRecruitmentQueryServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/service/TeamRecruitmentQueryServiceTest.java index f4c05d97e2..8bbd16b5fb 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/service/TeamRecruitmentQueryServiceTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/service/TeamRecruitmentQueryServiceTest.java @@ -171,6 +171,19 @@ private RecruitmentDetail detail(Integer userId) { return teamRecruitmentQueryService.getRecruitment(RECRUITMENT_ID, userId); } + @Test + @DisplayName("모집글 인원은 작성자를 제외한 승인 지원자 1명 중 1명이다") + void recruitmentParticipantCountExcludesAuthor() { + recruitment = recruitment(GENERAL, 1, 1, TODAY.plusDays(3)); + givenFound(); + + RecruitmentDetail response = detail(null); + + assertThat(response) + .extracting(RecruitmentDetail::currentParticipants, RecruitmentDetail::maxParticipants) + .containsExactly(1, 1); + } + @Nested @DisplayName("지원 불가 사유") class ApplyBlockReason { diff --git a/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java index c974285250..093d03ee41 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java @@ -11,6 +11,7 @@ import java.time.Clock; import java.time.Instant; import java.time.ZoneId; +import java.util.List; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; @@ -30,10 +31,12 @@ import in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentChatRoomStatus; import in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentChatRoomType; import in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentStatus; +import in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentType; 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.TeamRecruitmentChatMember; import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentChatRoom; +import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentRole; import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentApplicationRepository; import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentChatMemberRepository; import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentChatMessageRepository; @@ -41,6 +44,7 @@ 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.teamrecruitment.dto.ChatRoomResponse; import in.koreatech.koin.domain.teamrecruitment.dto.CreateChatMessageRequest; import in.koreatech.koin.domain.teamrecruitment.dto.DirectChatRoomCreationResult; import in.koreatech.koin.domain.teamrecruitment.service.TeamRecruitmentChatService; @@ -133,6 +137,48 @@ void setUpClock() { .isEqualTo(ApiResponseCode.TEAM_RECRUITMENT_CHAT_FORBIDDEN)); } + @Test + void GENERAL_TEAM_채팅방에_작성자만_있으면_인원은_2명_중_1명이다() { + ChatRoomResponse response = getChatRoom( + TeamRecruitmentType.GENERAL, + TeamRecruitmentChatRoomType.TEAM, + 1, + 1 + ); + + assertThat(response) + .extracting(ChatRoomResponse::memberCount, ChatRoomResponse::maxMemberCount) + .containsExactly(1, 2); + } + + @Test + void ROLE_BASED_TEAM_채팅방에_마지막_지원자가_참여하면_인원은_2명_중_2명이다() { + ChatRoomResponse response = getChatRoom( + TeamRecruitmentType.ROLE_BASED, + TeamRecruitmentChatRoomType.TEAM, + 1, + 2 + ); + + assertThat(response) + .extracting(ChatRoomResponse::memberCount, ChatRoomResponse::maxMemberCount) + .containsExactly(2, 2); + } + + @Test + void DIRECT_채팅방_인원은_항상_2명_중_2명이다() { + ChatRoomResponse response = getChatRoom( + TeamRecruitmentType.GENERAL, + TeamRecruitmentChatRoomType.DIRECT, + 1, + 2 + ); + + assertThat(response) + .extracting(ChatRoomResponse::memberCount, ChatRoomResponse::maxMemberCount) + .containsExactly(2, 2); + } + @Test void 지원서가_다른_모집글_소속이면_404를_반환한다() { TeamRecruitmentApplication application = mock(TeamRecruitmentApplication.class); @@ -332,4 +378,49 @@ void setUpClock() { .satisfies(e -> assertThat(((CustomException) e).getErrorCode()) .isEqualTo(ApiResponseCode.TEAM_RECRUITMENT_CHAT_READ_ONLY)); } + + private ChatRoomResponse getChatRoom( + TeamRecruitmentType recruitmentType, + TeamRecruitmentChatRoomType roomType, + int maxParticipants, + long memberCount + ) { + User currentUser = UserFixture.id_설정_코인_유저(USER_ID); + User counterpart = UserFixture.id_설정_코인_유저(OTHER_USER_ID); + int approvedParticipants = Math.toIntExact(memberCount) - 1; + TeamRecruitment recruitment = TeamRecruitment.builder() + .id(RECRUITMENT_ID) + .author(currentUser) + .title("팀원 모집") + .recruitmentType(recruitmentType) + .maxParticipants(maxParticipants) + .currentParticipants(approvedParticipants) + .build(); + if (recruitmentType == TeamRecruitmentType.ROLE_BASED) { + recruitment.addRole(TeamRecruitmentRole.builder() + .name("백엔드") + .maxParticipants(maxParticipants) + .currentParticipants(approvedParticipants) + .displayOrder(1) + .build()); + } + TeamRecruitmentChatRoom chatRoom = TeamRecruitmentChatRoom.builder() + .id(CHAT_ROOM_ID) + .recruitment(recruitment) + .roomScopeKey(roomType.name()) + .roomType(roomType) + .build(); + + when(chatRoomRepository.findById(CHAT_ROOM_ID)).thenReturn(Optional.of(chatRoom)); + when(memberRepository.existsByChatRoom_IdAndUser_Id(CHAT_ROOM_ID, USER_ID)).thenReturn(true); + when(memberRepository.countByChatRoom_Id(CHAT_ROOM_ID)).thenReturn(memberCount); + if (roomType == TeamRecruitmentChatRoomType.DIRECT) { + when(memberRepository.findAllByChatRoom_Id(CHAT_ROOM_ID)).thenReturn(List.of( + TeamRecruitmentChatMember.builder().chatRoom(chatRoom).user(currentUser).build(), + TeamRecruitmentChatMember.builder().chatRoom(chatRoom).user(counterpart).build() + )); + } + + return chatService.getChatRoom(USER_ID, RECRUITMENT_ID, CHAT_ROOM_ID); + } } From 20dabebe9e92f8f40dd5e3f22577923e6d45d85f 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 23:58:44 +0900 Subject: [PATCH 05/32] fix: make team recruitment notification actions idempotent (#2387) --- ...TeamRecruitmentNotificationRepository.java | 30 ++++- .../TeamRecruitmentNotificationApi.java | 12 +- .../TeamRecruitmentNotificationService.java | 13 +- .../TeamRecruitmentArticleFlowApiTest.java | 113 ++++++++++++++++++ ...eamRecruitmentNotificationServiceTest.java | 82 +++++++++---- 5 files changed, 207 insertions(+), 43 deletions(-) diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/repository/TeamRecruitmentNotificationRepository.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/repository/TeamRecruitmentNotificationRepository.java index fbe53937c6..5ae58f0542 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/repository/TeamRecruitmentNotificationRepository.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/repository/TeamRecruitmentNotificationRepository.java @@ -3,8 +3,10 @@ import in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentNotificationType; import in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentNotificationTargetType; import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentNotification; +import java.time.LocalDateTime; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; import org.springframework.data.repository.query.Param; @@ -29,7 +31,33 @@ Page findAllByRecipient_IdAndIsDeletedFalseOrderByI long countByRecipient_IdAndReadAtIsNullAndIsDeletedFalse(Integer recipientId); - Optional findByIdAndRecipient_Id(Integer id, Integer recipientId); + @Modifying(clearAutomatically = true) + @Query(""" + UPDATE TeamRecruitmentNotification notification + SET notification.readAt = :readAt + WHERE notification.recipient.id = :recipientId + AND notification.id = :notificationId + AND notification.readAt IS NULL + AND notification.isDeleted = false + """) + void updateReadAtByRecipientIdAndNotificationId( + @Param("recipientId") Integer recipientId, + @Param("notificationId") Integer notificationId, + @Param("readAt") LocalDateTime readAt + ); + + @Modifying(clearAutomatically = true) + @Query(""" + UPDATE TeamRecruitmentNotification notification + SET notification.isDeleted = true + WHERE notification.recipient.id = :recipientId + AND notification.id = :notificationId + AND notification.isDeleted = false + """) + void updateIsDeletedByRecipientIdAndNotificationId( + @Param("recipientId") Integer recipientId, + @Param("notificationId") Integer notificationId + ); List findAllByRecipient_IdAndIsDeletedFalse(Integer recipientId); diff --git a/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentNotificationApi.java b/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentNotificationApi.java index 611e170ff9..8e653d1d7b 100644 --- a/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentNotificationApi.java +++ b/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentNotificationApi.java @@ -3,7 +3,6 @@ import static in.koreatech.koin.global.code.ApiResponseCode.FORBIDDEN_USER_TYPE; import static in.koreatech.koin.global.code.ApiResponseCode.NO_CONTENT; import static in.koreatech.koin.global.code.ApiResponseCode.OK; -import static in.koreatech.koin.global.code.ApiResponseCode.TEAM_RECRUITMENT_NOTIFICATION_NOT_FOUND; import static in.koreatech.koin.global.code.ApiResponseCode.UNAUTHORIZED_USER; import org.springframework.http.ResponseEntity; @@ -32,11 +31,14 @@ ResponseEntity getNotifications( @ApiResponseCodes({ NO_CONTENT, - TEAM_RECRUITMENT_NOTIFICATION_NOT_FOUND, UNAUTHORIZED_USER, FORBIDDEN_USER_TYPE, }) - @Operation(summary = "알림 읽음 처리") + @Operation(summary = "알림 읽음 처리", description = """ + ### 알림 읽음 처리 + - 본인에게 온 삭제되지 않은 알림만 읽음 처리하며, 타 사용자 알림은 변경하지 않습니다. + - 이미 읽었거나 삭제되었거나 대상이 없어도 204를 반환합니다. + """) ResponseEntity markAsRead( Integer userId, @PathVariable Integer notificationId @@ -52,14 +54,14 @@ ResponseEntity markAsRead( @ApiResponseCodes({ NO_CONTENT, - TEAM_RECRUITMENT_NOTIFICATION_NOT_FOUND, UNAUTHORIZED_USER, FORBIDDEN_USER_TYPE, }) @Operation(summary = "알림 개별 삭제", description = """ ### 알림 개별 삭제 - 알림을 삭제 처리하여 목록에서 제외합니다. - - 본인에게 온 알림만 삭제할 수 있으며, 그 외에는 404를 반환합니다. + - 본인에게 온 삭제되지 않은 알림만 삭제 처리하며, 타 사용자 알림은 변경하지 않습니다. + - 이미 삭제되었거나 대상이 없어도 204를 반환합니다. """) ResponseEntity delete( Integer userId, diff --git a/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentNotificationService.java b/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentNotificationService.java index 8831efba30..6c0e7790eb 100644 --- a/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentNotificationService.java +++ b/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentNotificationService.java @@ -13,11 +13,8 @@ import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentNotificationRepository; import in.koreatech.koin.domain.teamrecruitment.dto.TeamRecruitmentNotificationResponse; import in.koreatech.koin.domain.teamrecruitment.dto.TeamRecruitmentNotificationsResponse; -import in.koreatech.koin.global.exception.CustomException; import lombok.RequiredArgsConstructor; -import static in.koreatech.koin.global.code.ApiResponseCode.TEAM_RECRUITMENT_NOTIFICATION_NOT_FOUND; - @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -50,18 +47,12 @@ public TeamRecruitmentNotificationsResponse getNotifications(Integer userId, int @Transactional public void markAsRead(Integer userId, Integer notificationId) { - TeamRecruitmentNotification notification = notificationRepository - .findByIdAndRecipient_Id(notificationId, userId) - .orElseThrow(() -> CustomException.of(TEAM_RECRUITMENT_NOTIFICATION_NOT_FOUND)); - notification.markAsRead(LocalDateTime.now()); + notificationRepository.updateReadAtByRecipientIdAndNotificationId(userId, notificationId, LocalDateTime.now()); } @Transactional public void delete(Integer userId, Integer notificationId) { - TeamRecruitmentNotification notification = notificationRepository - .findByIdAndRecipient_Id(notificationId, userId) - .orElseThrow(() -> CustomException.of(TEAM_RECRUITMENT_NOTIFICATION_NOT_FOUND)); - notification.delete(); + notificationRepository.updateIsDeletedByRecipientIdAndNotificationId(userId, notificationId); } @Transactional diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentArticleFlowApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentArticleFlowApiTest.java index 04e75af30c..8a10705eb8 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentArticleFlowApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentArticleFlowApiTest.java @@ -8,6 +8,7 @@ import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentChatRoomStatus.READ_ONLY; 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.DELETED; @@ -18,11 +19,13 @@ import static org.assertj.core.api.Assertions.tuple; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import org.junit.jupiter.api.BeforeEach; @@ -37,6 +40,7 @@ import in.koreatech.koin.acceptance.fixture.UserAcceptanceFixture; import in.koreatech.koin.domain.student.model.Department; import in.koreatech.koin.domain.student.model.Student; +import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentApplicationStatus; import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitment; import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentApplication; @@ -101,6 +105,7 @@ class TeamRecruitmentArticleFlowApiTest extends AcceptanceTest { private Student author; private Student applicant; private String authorToken; + private String applicantToken; @BeforeEach void setUp() { @@ -109,6 +114,7 @@ void setUp() { author = userFixture.준호_학생(department, null); applicant = userFixture.성빈_학생(department); authorToken = userFixture.getToken(author.getUser()); + applicantToken = userFixture.getToken(applicant.getUser()); profileRepository.save(TeamRecruitmentProfile.builder() .user(applicant.getUser()) .profileNickname("지원자") @@ -161,6 +167,96 @@ void setUp() { .anyMatch(message -> message.contains("삭제되어")); } + @Test + @DisplayName("알림 단건 읽음 처리는 수신자와 미삭제 최초 읽음만 변경하고 반복 호출에도 읽은 시각을 보존한다") + void 알림_단건_읽음_처리_멱등성() throws Exception { + TeamRecruitment recruitment = saveGeneralRecruitment("알림 읽음", 3, 0); + TeamRecruitmentNotification unread = saveNotification(recruitment, applicant.getUser(), null, false); + TeamRecruitmentNotification deletedUnread = saveNotification(recruitment, applicant.getUser(), null, true); + TeamRecruitmentNotification otherUserUnread = saveNotification(recruitment, author.getUser(), null, false); + entityManager.flush(); + + mockMvc.perform(post("/team-recruitments/notifications/{notificationId}/read", unread.getId()) + .header("Authorization", "Bearer " + applicantToken)) + .andExpect(status().isNoContent()); + + entityManager.clear(); + LocalDateTime firstReadAt = notificationRepository.findById(unread.getId()).orElseThrow().getReadAt(); + assertThat(firstReadAt).isNotNull(); + + mockMvc.perform(post("/team-recruitments/notifications/{notificationId}/read", unread.getId()) + .header("Authorization", "Bearer " + applicantToken)) + .andExpect(status().isNoContent()); + mockMvc.perform(post("/team-recruitments/notifications/{notificationId}/read", deletedUnread.getId()) + .header("Authorization", "Bearer " + applicantToken)) + .andExpect(status().isNoContent()); + mockMvc.perform(post("/team-recruitments/notifications/{notificationId}/read", otherUserUnread.getId()) + .header("Authorization", "Bearer " + applicantToken)) + .andExpect(status().isNoContent()); + mockMvc.perform(post("/team-recruitments/notifications/{notificationId}/read", 999999) + .header("Authorization", "Bearer " + applicantToken)) + .andExpect(status().isNoContent()); + + entityManager.clear(); + assertThat(notificationRepository.findById(unread.getId()).orElseThrow().getReadAt()) + .isEqualTo(firstReadAt); + assertThat(notificationRepository.findById(deletedUnread.getId()).orElseThrow().getReadAt()) + .isNull(); + assertThat(notificationRepository.findById(otherUserUnread.getId()).orElseThrow().getReadAt()) + .isNull(); + assertThat(notificationRepository.countByRecipient_IdAndIsDeletedFalse(applicant.getUser().getId())) + .isEqualTo(1L); + mockMvc.perform(get("/team-recruitments/notifications") + .header("Authorization", "Bearer " + applicantToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.total_count").value(1)) + .andExpect(jsonPath("$.unread_count").value(0)) + .andExpect(jsonPath("$.notifications.length()").value(1)) + .andExpect(jsonPath("$.notifications[0].id").value(unread.getId())); + } + + @Test + @DisplayName("알림 단건 삭제는 수신자와 미삭제 알림만 변경하고 반복·대상 없음에도 204를 반환한다") + void 알림_단건_삭제_멱등성() throws Exception { + TeamRecruitment recruitment = saveGeneralRecruitment("알림 삭제", 3, 0); + TeamRecruitmentNotification unread = saveNotification(recruitment, applicant.getUser(), null, false); + TeamRecruitmentNotification deleted = saveNotification(recruitment, applicant.getUser(), null, true); + TeamRecruitmentNotification otherUser = saveNotification(recruitment, author.getUser(), null, false); + entityManager.flush(); + + mockMvc.perform(delete("/team-recruitments/notifications/{notificationId}", unread.getId()) + .header("Authorization", "Bearer " + applicantToken)) + .andExpect(status().isNoContent()); + mockMvc.perform(delete("/team-recruitments/notifications/{notificationId}", unread.getId()) + .header("Authorization", "Bearer " + applicantToken)) + .andExpect(status().isNoContent()); + mockMvc.perform(delete("/team-recruitments/notifications/{notificationId}", deleted.getId()) + .header("Authorization", "Bearer " + applicantToken)) + .andExpect(status().isNoContent()); + mockMvc.perform(delete("/team-recruitments/notifications/{notificationId}", otherUser.getId()) + .header("Authorization", "Bearer " + applicantToken)) + .andExpect(status().isNoContent()); + mockMvc.perform(delete("/team-recruitments/notifications/{notificationId}", 999999) + .header("Authorization", "Bearer " + applicantToken)) + .andExpect(status().isNoContent()); + + entityManager.clear(); + assertThat(notificationRepository.findById(unread.getId()).orElseThrow().getIsDeleted()) + .isTrue(); + assertThat(notificationRepository.findById(deleted.getId()).orElseThrow().getIsDeleted()) + .isTrue(); + assertThat(notificationRepository.findById(otherUser.getId()).orElseThrow().getIsDeleted()) + .isFalse(); + assertThat(notificationRepository.countByRecipient_IdAndIsDeletedFalse(applicant.getUser().getId())) + .isZero(); + mockMvc.perform(get("/team-recruitments/notifications") + .header("Authorization", "Bearer " + applicantToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.total_count").value(0)) + .andExpect(jsonPath("$.unread_count").value(0)) + .andExpect(jsonPath("$.notifications.length()").value(0)); + } + @Test @DisplayName("정원을 승인 인원과 같게 줄이면 마감되지만 TEAM 채팅방은 ACTIVE 를 유지한다") void 정원_충족_자동_마감() throws Exception { @@ -406,6 +502,23 @@ private TeamRecruitmentApplication saveApplication( .build()); } + private TeamRecruitmentNotification saveNotification( + TeamRecruitment recruitment, + User recipient, + LocalDateTime readAt, + boolean isDeleted + ) { + return notificationRepository.save(TeamRecruitmentNotification.builder() + .recipient(recipient) + .type(APPLICATION_REJECTED) + .targetType(MY_APPLICATIONS) + .messagePreview("알림") + .recruitment(recruitment) + .readAt(readAt) + .isDeleted(isDeleted) + .build()); + } + private String generalBody(int maxParticipants) { return """ { diff --git a/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentNotificationServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentNotificationServiceTest.java index 4b8916a28f..0139f79aed 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentNotificationServiceTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentNotificationServiceTest.java @@ -1,15 +1,14 @@ package in.koreatech.koin.unit.domain.teamrecruitment.service; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.List; -import java.util.Optional; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -19,9 +18,6 @@ import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; -import in.koreatech.koin.global.code.ApiResponseCode; -import in.koreatech.koin.global.exception.CustomException; - import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentNotification; import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentNotificationRepository; import in.koreatech.koin.domain.teamrecruitment.dto.TeamRecruitmentNotificationsResponse; @@ -64,14 +60,20 @@ class TeamRecruitmentNotificationServiceTest { } @Test - void 존재하지_않는_알림을_읽음_처리하면_404를_반환한다() { - when(notificationRepository.findByIdAndRecipient_Id(NOTIFICATION_ID, USER_ID)) - .thenReturn(Optional.empty()); - - assertThatThrownBy(() -> notificationService.markAsRead(USER_ID, NOTIFICATION_ID)) - .isInstanceOf(CustomException.class) - .satisfies(e -> assertThat(((CustomException) e).getErrorCode()) - .isEqualTo(ApiResponseCode.TEAM_RECRUITMENT_NOTIFICATION_NOT_FOUND)); + void 개별_읽음_처리는_수신자_범위의_원자적_업데이트를_호출한다() { + notificationService.markAsRead(USER_ID, NOTIFICATION_ID); + + verify(notificationRepository).updateReadAtByRecipientIdAndNotificationId( + eq(USER_ID), eq(NOTIFICATION_ID), any()); + } + + @Test + void 개별_읽음_처리를_반복해도_예외가_발생하지_않는다() { + notificationService.markAsRead(USER_ID, NOTIFICATION_ID); + notificationService.markAsRead(USER_ID, NOTIFICATION_ID); + + verify(notificationRepository, times(2)).updateReadAtByRecipientIdAndNotificationId( + eq(USER_ID), eq(NOTIFICATION_ID), any()); } @Test @@ -89,25 +91,53 @@ class TeamRecruitmentNotificationServiceTest { } @Test - void 개별_삭제시_해당_알림만_삭제한다() { - TeamRecruitmentNotification notification = mock(TeamRecruitmentNotification.class); - when(notificationRepository.findByIdAndRecipient_Id(NOTIFICATION_ID, USER_ID)) - .thenReturn(Optional.of(notification)); + void 개별_삭제는_수신자_범위의_원자적_업데이트를_호출한다() { + notificationService.delete(USER_ID, NOTIFICATION_ID); + + verify(notificationRepository).updateIsDeletedByRecipientIdAndNotificationId(USER_ID, NOTIFICATION_ID); + } + + @Test + void 개별_삭제를_반복해도_예외가_발생하지_않는다() { + notificationService.delete(USER_ID, NOTIFICATION_ID); + notificationService.delete(USER_ID, NOTIFICATION_ID); + verify(notificationRepository, times(2)) + .updateIsDeletedByRecipientIdAndNotificationId(USER_ID, NOTIFICATION_ID); + } + + @Test + void 존재하지_않는_알림을_읽음_처리해도_예외가_발생하지_않는다() { + notificationService.markAsRead(USER_ID, NOTIFICATION_ID); + + verify(notificationRepository).updateReadAtByRecipientIdAndNotificationId( + eq(USER_ID), eq(NOTIFICATION_ID), any()); + } + + @Test + void 타_사용자_알림을_읽음_처리해도_예외가_발생하지_않는다() { + Integer otherUserId = 2; + + notificationService.markAsRead(otherUserId, NOTIFICATION_ID); + + verify(notificationRepository).updateReadAtByRecipientIdAndNotificationId( + eq(otherUserId), eq(NOTIFICATION_ID), any()); + } + + @Test + void 존재하지_않는_알림을_삭제해도_예외가_발생하지_않는다() { notificationService.delete(USER_ID, NOTIFICATION_ID); - verify(notification).delete(); + verify(notificationRepository).updateIsDeletedByRecipientIdAndNotificationId(USER_ID, NOTIFICATION_ID); } @Test - void 존재하지_않는_알림을_삭제하면_404를_반환한다() { - when(notificationRepository.findByIdAndRecipient_Id(NOTIFICATION_ID, USER_ID)) - .thenReturn(Optional.empty()); - - assertThatThrownBy(() -> notificationService.delete(USER_ID, NOTIFICATION_ID)) - .isInstanceOf(CustomException.class) - .satisfies(e -> assertThat(((CustomException) e).getErrorCode()) - .isEqualTo(ApiResponseCode.TEAM_RECRUITMENT_NOTIFICATION_NOT_FOUND)); + void 타_사용자_알림을_삭제해도_예외가_발생하지_않는다() { + Integer otherUserId = 2; + + notificationService.delete(otherUserId, NOTIFICATION_ID); + + verify(notificationRepository).updateIsDeletedByRecipientIdAndNotificationId(otherUserId, NOTIFICATION_ID); } @Test From b78fa4a9602cb4bc6db0f3618e539efec7c5543c 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: Thu, 3 Sep 2026 00:11:29 +0900 Subject: [PATCH 06/32] =?UTF-8?q?fix:=20=ED=8C=80=EC=9B=90=20=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EC=A1=B0=ED=9A=8C=20=EC=9D=91=EB=8B=B5=EC=9D=98=20?= =?UTF-8?q?d=5Fday=20=EA=B3=84=EC=82=B0=20=EA=B3=84=EC=95=BD=EC=9D=84=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=ED=99=94=20(#2388)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: initialize issue #2375 * fix: 팀원 모집 d_day를 모집 상태 기준으로 통일 --- .../controller/TeamRecruitmentApi.java | 3 +- .../recruitment/dto/ApplicantRecruitment.java | 3 +- .../recruitment/dto/CreatedRecruitment.java | 3 +- .../team/recruitment/dto/RecruitmentCard.java | 3 +- .../recruitment/dto/RecruitmentCards.java | 16 ++++-- .../recruitment/dto/RecruitmentDetail.java | 3 +- ...eamRecruitmentApplicationQueryService.java | 57 ++++++++----------- .../service/TeamRecruitmentQueryService.java | 20 +++++-- ...TeamRecruitmentArticleContractApiTest.java | 14 +++++ .../recruitment/dto/RecruitmentCardsTest.java | 27 ++++++++- ...ecruitmentApplicationQueryServiceTest.java | 50 ++++++++++++++++ .../TeamRecruitmentQueryServiceTest.java | 39 +++++++++++++ 12 files changed, 188 insertions(+), 50 deletions(-) diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/controller/TeamRecruitmentApi.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/controller/TeamRecruitmentApi.java index fdb58de5f3..a101d9be56 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/controller/TeamRecruitmentApi.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/controller/TeamRecruitmentApi.java @@ -126,7 +126,8 @@ ResponseEntity createRecruitment( - 비로그인 조회가 가능합니다. - 로그인한 경우 `is_author`, `can_apply`, `apply_block_reason`, `application`, `can_manage_applicants`, 팀 채팅방 정보를 함께 반환합니다. - - `d_day`는 지원 마감일 기준이며 마감일이 지나면 null입니다. + - `d_day`는 KST 기준 현재 날짜와 지원 마감일의 차이입니다. + - 유효 모집 상태가 `RECRUITING`이고 마감일이 오늘 또는 미래일 때만 반환하며, `CLOSED`/`DELETED`, null 마감일 또는 마감일 경과 시 `null`입니다. - 삭제된 모집글은 404를 반환합니다. """) @GetMapping("/{recruitmentId}") diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/ApplicantRecruitment.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/ApplicantRecruitment.java index 01d674ff4c..faaaf6eefb 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/ApplicantRecruitment.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/ApplicantRecruitment.java @@ -41,7 +41,8 @@ public record ApplicantRecruitment( @JsonFormat(pattern = "yyyy-MM-dd") LocalDate deadlineDate, - @Schema(description = "D-day", example = "8", nullable = true, requiredMode = REQUIRED) + @Schema(description = "D-day. 모집 상태가 RECRUITING이고 마감일이 오늘 또는 미래일 때만 반환되며, CLOSED/DELETED, null 마감일 또는 마감일 경과 시 null입니다.", + example = "8", nullable = true, requiredMode = REQUIRED) Integer dDay, @Schema(description = "모집 상태", example = "RECRUITING", requiredMode = REQUIRED) diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/CreatedRecruitment.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/CreatedRecruitment.java index e6dcff74de..ed4916bfda 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/CreatedRecruitment.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/CreatedRecruitment.java @@ -41,7 +41,8 @@ public record CreatedRecruitment( @JsonFormat(pattern = "yyyy-MM-dd") LocalDate deadlineDate, - @Schema(description = "D-day, 모집 마감 후 null", example = "8", nullable = true, requiredMode = REQUIRED) + @Schema(description = "D-day. 모집 상태가 RECRUITING이고 마감일이 오늘 또는 미래일 때만 반환되며, CLOSED/DELETED, null 마감일 또는 마감일 경과 시 null입니다.", + example = "8", nullable = true, requiredMode = REQUIRED) Integer dDay, @Schema(description = "모집 상태", example = "RECRUITING", requiredMode = REQUIRED) diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/RecruitmentCard.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/RecruitmentCard.java index b974af9c07..338133d7eb 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/RecruitmentCard.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/RecruitmentCard.java @@ -41,7 +41,8 @@ public record RecruitmentCard( @JsonFormat(pattern = "yyyy-MM-dd") LocalDate deadlineDate, - @Schema(description = "D-day, 모집 마감 후 null", example = "8", nullable = true, requiredMode = REQUIRED) + @Schema(description = "D-day. 모집 상태가 RECRUITING이고 마감일이 오늘 또는 미래일 때만 반환되며, CLOSED/DELETED, null 마감일 또는 마감일 경과 시 null입니다.", + example = "8", nullable = true, requiredMode = REQUIRED) Integer dDay, @Schema(description = "모집 상태", example = "RECRUITING", requiredMode = REQUIRED) diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/RecruitmentCards.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/RecruitmentCards.java index 22059a826c..af2bb260ed 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/RecruitmentCards.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/RecruitmentCards.java @@ -3,6 +3,7 @@ import java.time.LocalDate; import java.time.temporal.ChronoUnit; +import in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentStatus; import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitment; import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentRole; @@ -24,7 +25,7 @@ public static RecruitmentCard of(TeamRecruitment recruitment, LocalDate today) { recruitment.getActivityStartDate(), recruitment.getActivityEndDate(), recruitment.getDeadlineDate(), - dDayOf(recruitment.getDeadlineDate(), today), + dDayOf(recruitment.getStatus(), recruitment.getDeadlineDate(), today), recruitment.getStatus(), recruitment.getRecruitmentType(), recruitment.getCurrentParticipants(), @@ -47,10 +48,17 @@ private static boolean isRecruitmentClosed(TeamRecruitment recruitment, LocalDat } /** - * 지원 마감일이 지나면 null 이다. + * 모집 상태가 RECRUITING이고 지원 마감일이 지나지 않았을 때만 D-day를 반환한다. */ - public static Integer dDayOf(LocalDate deadlineDate, LocalDate today) { - if (deadlineDate == null || today == null || today.isAfter(deadlineDate)) { + public static Integer dDayOf( + TeamRecruitmentStatus status, + LocalDate deadlineDate, + LocalDate today + ) { + if (status != TeamRecruitmentStatus.RECRUITING + || deadlineDate == null + || today == null + || today.isAfter(deadlineDate)) { return null; } return (int) ChronoUnit.DAYS.between(today, deadlineDate); diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/RecruitmentDetail.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/RecruitmentDetail.java index 60e852ff5e..df66f2ca39 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/RecruitmentDetail.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/RecruitmentDetail.java @@ -44,7 +44,8 @@ public record RecruitmentDetail( @JsonFormat(pattern = "yyyy-MM-dd") LocalDate deadlineDate, - @Schema(description = "D-day, 모집 마감 후 null", example = "8", nullable = true, requiredMode = REQUIRED) + @Schema(description = "D-day. 모집 상태가 RECRUITING이고 마감일이 오늘 또는 미래일 때만 반환되며, CLOSED/DELETED, null 마감일 또는 마감일 경과 시 null입니다.", + example = "8", nullable = true, requiredMode = REQUIRED) Integer dDay, @Schema(description = "모집 상태", example = "RECRUITING", requiredMode = REQUIRED) diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/service/TeamRecruitmentApplicationQueryService.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/service/TeamRecruitmentApplicationQueryService.java index d80e267cb2..add77c1050 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/service/TeamRecruitmentApplicationQueryService.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/service/TeamRecruitmentApplicationQueryService.java @@ -23,6 +23,7 @@ import in.koreatech.koin.domain.team.recruitment.dto.MyApplicationListResponse; import in.koreatech.koin.domain.team.recruitment.dto.ProfileSnapshot; import in.koreatech.koin.domain.team.recruitment.dto.RecruitmentCard; +import in.koreatech.koin.domain.team.recruitment.dto.RecruitmentCards; import in.koreatech.koin.domain.team.recruitment.dto.RecruitmentRole; import in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentApplicationSort; import in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentApplicationStatus; @@ -41,7 +42,6 @@ import java.time.Clock; import java.time.LocalDate; import java.time.ZoneId; -import java.time.temporal.ChronoUnit; import java.util.Collection; import java.util.List; import java.util.Map; @@ -92,8 +92,9 @@ public MyApplicationListResponse getMyApplications( pageRequest(criteria, sort, true) ); AcceptedChatRooms acceptedChatRooms = findAcceptedChatRooms(applications.getContent()); + LocalDate today = today(); List content = applications.getContent().stream() - .map(application -> toMyApplication(application, acceptedChatRooms)) + .map(application -> toMyApplication(application, acceptedChatRooms, today)) .toList(); return new MyApplicationListResponse( content, @@ -132,7 +133,7 @@ public ApplicantListResponse getApplications( .map(application -> toApplicantSummary(application, acceptedChatRooms, today)) .toList(); return new ApplicantListResponse( - toApplicantRecruitment(recruitment), + toApplicantRecruitment(recruitment, today), content, totalCount, content.size(), @@ -156,10 +157,10 @@ public ApplicantDetail getApplicationDetail( .orElseThrow(() -> CustomException.of(TEAM_RECRUITMENT_APPLICATION_NOT_FOUND)); validateApplicationBelongsToRecruitment(application, recruitmentId); + LocalDate today = today(); boolean canDecide = application.getStatus() == PENDING && recruitment.getStatus() == RECRUITING - && !isPastDeadline(recruitment); - LocalDate today = today(); + && !isPastDeadline(recruitment, today); boolean canOpenDirectChat = application.getStatus() == ACCEPTED && canOpenDirectChat(application, findAcceptedChatRooms(List.of(application)), today); return new ApplicantDetail( @@ -237,7 +238,8 @@ private int safeInt(long value) { private MyApplication toMyApplication( TeamRecruitmentApplication application, - AcceptedChatRooms acceptedChatRooms + AcceptedChatRooms acceptedChatRooms, + LocalDate today ) { TeamRecruitment recruitment = application.getRecruitment(); boolean accepted = application.getStatus() == ACCEPTED; @@ -258,7 +260,7 @@ private MyApplication toMyApplication( accepted && teamRoom != null ? teamRoom.getId() : null, directRoom == null ? null : directRoom.getId(), toApplicationRole(application.getRole()), - toRecruitmentCard(recruitment) + toRecruitmentCard(recruitment, today) ); } @@ -344,7 +346,8 @@ private boolean canOpenDirectChat( application.getStatus(), hasExistingDirectChat, recruitment, teamChatRoom, today); } - private RecruitmentCard toRecruitmentCard(TeamRecruitment recruitment) { + private RecruitmentCard toRecruitmentCard(TeamRecruitment recruitment, LocalDate today) { + TeamRecruitmentStatus status = effectiveStatus(recruitment, today); return new RecruitmentCard( recruitment.getId(), recruitment.getCategory(), @@ -353,17 +356,18 @@ private RecruitmentCard toRecruitmentCard(TeamRecruitment recruitment) { recruitment.getActivityStartDate(), recruitment.getActivityEndDate(), recruitment.getDeadlineDate(), - dDay(recruitment), - effectiveStatus(recruitment), + RecruitmentCards.dDayOf(status, recruitment.getDeadlineDate(), today), + status, recruitment.getRecruitmentType(), recruitment.getCurrentParticipants(), recruitment.getMaxParticipants(), - toRecruitmentRoles(recruitment) + toRecruitmentRoles(recruitment, today) ); } - private ApplicantRecruitment toApplicantRecruitment(TeamRecruitment recruitment) { + private ApplicantRecruitment toApplicantRecruitment(TeamRecruitment recruitment, LocalDate today) { Optional teamRoom = findTeamRoom(recruitment.getId()); + TeamRecruitmentStatus status = effectiveStatus(recruitment, today); return new ApplicantRecruitment( recruitment.getId(), recruitment.getCategory(), @@ -372,22 +376,22 @@ private ApplicantRecruitment toApplicantRecruitment(TeamRecruitment recruitment) recruitment.getActivityStartDate(), recruitment.getActivityEndDate(), recruitment.getDeadlineDate(), - dDay(recruitment), - effectiveStatus(recruitment), + RecruitmentCards.dDayOf(status, recruitment.getDeadlineDate(), today), + status, recruitment.getRecruitmentType(), recruitment.getCurrentParticipants(), recruitment.getMaxParticipants(), - toRecruitmentRoles(recruitment), + toRecruitmentRoles(recruitment, today), teamRoom.isPresent(), teamRoom.map(TeamRecruitmentChatRoom::getId).orElse(null) ); } - private List toRecruitmentRoles(TeamRecruitment recruitment) { + private List toRecruitmentRoles(TeamRecruitment recruitment, LocalDate today) { if (recruitment.getRoles() == null) { return List.of(); } - boolean recruitmentClosed = recruitment.getStatus() != RECRUITING || isPastDeadline(recruitment); + boolean recruitmentClosed = recruitment.getStatus() != RECRUITING || isPastDeadline(recruitment, today); return recruitment.getRoles().stream() .map(role -> new RecruitmentRole( role.getId(), @@ -399,8 +403,8 @@ private List toRecruitmentRoles(TeamRecruitment recruitment) { .toList(); } - private TeamRecruitmentStatus effectiveStatus(TeamRecruitment recruitment) { - if (recruitment.getStatus() == RECRUITING && isPastDeadline(recruitment)) { + private TeamRecruitmentStatus effectiveStatus(TeamRecruitment recruitment, LocalDate today) { + if (recruitment.getStatus() == RECRUITING && isPastDeadline(recruitment, today)) { return TeamRecruitmentStatus.CLOSED; } return recruitment.getStatus(); @@ -418,20 +422,9 @@ private Optional findTeamRoom(Integer recruitmentId) { .filter(room -> room.getRoomType() == TeamRecruitmentChatRoomType.TEAM); } - private Integer dDay(TeamRecruitment recruitment) { - if (recruitment.getStatus() != RECRUITING || recruitment.getDeadlineDate() == null) { - return null; - } - LocalDate today = LocalDate.now(clock.withZone(KST)); - if (today.isAfter(recruitment.getDeadlineDate())) { - return null; - } - return Math.toIntExact(ChronoUnit.DAYS.between(today, recruitment.getDeadlineDate())); - } - - private boolean isPastDeadline(TeamRecruitment recruitment) { + private boolean isPastDeadline(TeamRecruitment recruitment, LocalDate today) { return recruitment.getDeadlineDate() != null - && LocalDate.now(clock.withZone(KST)).isAfter(recruitment.getDeadlineDate()); + && today.isAfter(recruitment.getDeadlineDate()); } private LocalDate today() { diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/service/TeamRecruitmentQueryService.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/service/TeamRecruitmentQueryService.java index 4de88b3932..4660006b87 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/service/TeamRecruitmentQueryService.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/service/TeamRecruitmentQueryService.java @@ -106,17 +106,24 @@ public RecruitmentDetail getRecruitment(Integer recruitmentId, Integer userId) { if (recruitment.isDeleted()) { throw CustomException.of(TEAM_RECRUITMENT_NOT_FOUND, "recruitmentId: " + recruitmentId); } - return toDetail(recruitment, userId); + LocalDate today = today(); + return toDetail(recruitment, userId, today); } - private RecruitmentDetail toDetail(TeamRecruitment recruitment, Integer userId) { - RecruitmentCard card = RecruitmentCards.of(recruitment, today()); + private RecruitmentDetail toDetail(TeamRecruitment recruitment, Integer userId, LocalDate today) { + RecruitmentCard card = RecruitmentCards.of(recruitment, today); boolean isAuthor = userId != null && recruitment.getAuthor().getId().equals(userId); Optional myApplication = userId == null ? Optional.empty() : applicationRepository.findByRecruitment_IdAndApplicant_Id(recruitment.getId(), userId); - TeamRecruitmentApplyBlockReason blockReason = applyBlockReasonOf(recruitment, userId, isAuthor, myApplication); + TeamRecruitmentApplyBlockReason blockReason = applyBlockReasonOf( + recruitment, + userId, + isAuthor, + myApplication, + today + ); boolean accepted = myApplication .map(application -> application.getStatus() == ACCEPTED) .orElse(false); @@ -162,7 +169,8 @@ private TeamRecruitmentApplyBlockReason applyBlockReasonOf( TeamRecruitment recruitment, Integer userId, boolean isAuthor, - Optional myApplication + Optional myApplication, + LocalDate today ) { if (recruitment.isDeleted()) { return RECRUITMENT_DELETED; @@ -179,7 +187,7 @@ private TeamRecruitmentApplyBlockReason applyBlockReasonOf( if (!recruitment.isRecruiting()) { return RECRUITMENT_CLOSED; } - if (today().isAfter(recruitment.getDeadlineDate())) { + if (today.isAfter(recruitment.getDeadlineDate())) { return DEADLINE_PASSED; } if (isFullyClosed(recruitment)) { diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentArticleContractApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentArticleContractApiTest.java index c3ed7581b4..42773b2ff4 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentArticleContractApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentArticleContractApiTest.java @@ -2,6 +2,7 @@ import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentCategory.PROJECT; import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentMeetingType.ONLINE; +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; @@ -374,6 +375,19 @@ void anonymousDetail() throws Exception { .andExpect(jsonPath("$.team_chat_available").value(false)); } + @Test + @DisplayName("CLOSED 모집글은 미래 마감일이어도 d_day가 없다") + void closedFutureDeadlineHasNoDday() throws Exception { + TeamRecruitment recruitment = saveRecruitment(author, "마감된 상세"); + recruitment.close(); + entityManager.flush(); + + mockMvc.perform(get("/team-recruitments/{id}", recruitment.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(CLOSED.name())) + .andExpect(jsonPath("$.d_day").doesNotExist()); + } + @Test @DisplayName("작성자 조회는 관리 권한과 팀 채팅방 정보를 받는다") void authorDetail() throws Exception { diff --git a/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/dto/RecruitmentCardsTest.java b/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/dto/RecruitmentCardsTest.java index 1941c9485c..972b552a5e 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/dto/RecruitmentCardsTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/dto/RecruitmentCardsTest.java @@ -1,5 +1,8 @@ package in.koreatech.koin.unit.domain.team.recruitment.dto; +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentStatus.CLOSED; +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentStatus.DELETED; +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentStatus.RECRUITING; import static org.assertj.core.api.Assertions.assertThat; import java.time.LocalDate; @@ -17,18 +20,36 @@ class RecruitmentCardsTest { @Test @DisplayName("마감일까지 남은 일수를 반환한다") void returnsDaysUntilDeadline() { - assertThat(RecruitmentCards.dDayOf(DEADLINE, DEADLINE.minusDays(8))).isEqualTo(8); + assertThat(RecruitmentCards.dDayOf(RECRUITING, DEADLINE, DEADLINE.minusDays(8))).isEqualTo(8); } @Test @DisplayName("마감일 당일은 0을 반환한다") void returnsZeroOnDeadline() { - assertThat(RecruitmentCards.dDayOf(DEADLINE, DEADLINE)).isZero(); + assertThat(RecruitmentCards.dDayOf(RECRUITING, DEADLINE, DEADLINE)).isZero(); } @Test @DisplayName("마감일이 지나면 null을 반환한다") void returnsNullAfterDeadline() { - assertThat(RecruitmentCards.dDayOf(DEADLINE, DEADLINE.plusDays(1))).isNull(); + assertThat(RecruitmentCards.dDayOf(RECRUITING, DEADLINE, DEADLINE.plusDays(1))).isNull(); + } + + @Test + @DisplayName("마감 상태가 CLOSED이면 미래 마감일이어도 null을 반환한다") + void returnsNullForClosedRecruitment() { + assertThat(RecruitmentCards.dDayOf(CLOSED, DEADLINE, DEADLINE.minusDays(8))).isNull(); + } + + @Test + @DisplayName("마감 상태가 DELETED이면 미래 마감일이어도 null을 반환한다") + void returnsNullForDeletedRecruitment() { + assertThat(RecruitmentCards.dDayOf(DELETED, DEADLINE, DEADLINE.minusDays(8))).isNull(); + } + + @Test + @DisplayName("마감일이 null이면 모집 중이어도 null을 반환한다") + void returnsNullForNullDeadline() { + assertThat(RecruitmentCards.dDayOf(RECRUITING, null, DEADLINE.minusDays(8))).isNull(); } } diff --git a/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/service/TeamRecruitmentApplicationQueryServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/service/TeamRecruitmentApplicationQueryServiceTest.java index 49edcdf8fd..1bd7dc7cf9 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/service/TeamRecruitmentApplicationQueryServiceTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/service/TeamRecruitmentApplicationQueryServiceTest.java @@ -285,6 +285,56 @@ void setUpClock() { .containsExactly(true, true); } + @Test + void CLOSED_모집글은_미래_마감일이어도_두_목록에서_dDay가_null이다() { + TeamRecruitment recruitment = recruitment(); + recruitment.close(); + TeamRecruitmentApplication application = applicationWithSnapshot(20, recruitment, PENDING); + + when(studentRepository.getById(APPLICANT_ID)).thenReturn(null); + when(applicationRepository.countByApplicant_IdAndStatusIn(eq(APPLICANT_ID), any())) + .thenReturn(1L); + when(applicationRepository.findAllByApplicant_IdAndStatusIn( + eq(APPLICANT_ID), + any(), + any(Pageable.class) + )).thenReturn(new PageImpl<>(List.of(application))); + + MyApplicationListResponse myResponse = queryService.getMyApplications( + List.of(PENDING), + TeamRecruitmentApplicationSort.LATEST_DESC, + 1, + 10, + APPLICANT_ID + ); + + assertThat(myResponse.applications().get(0).recruitment().status()).isEqualTo(CLOSED); + assertThat(myResponse.applications().get(0).recruitment().dDay()).isNull(); + + when(studentRepository.getById(AUTHOR_ID)).thenReturn(null); + when(recruitmentRepository.findById(RECRUITMENT_ID)).thenReturn(Optional.of(recruitment)); + when(applicationRepository.countByRecruitment_IdAndStatusIn(eq(RECRUITMENT_ID), any())) + .thenReturn(1L); + when(applicationRepository.findAllByRecruitment_IdAndStatusIn( + eq(RECRUITMENT_ID), + any(), + any(Pageable.class) + )).thenReturn(new PageImpl<>(List.of(application))); + when(chatRoomRepository.findByRecruitment_IdAndRoomScopeKey(RECRUITMENT_ID, "TEAM")) + .thenReturn(Optional.empty()); + + ApplicantListResponse authorResponse = queryService.getApplications( + RECRUITMENT_ID, + List.of(PENDING), + 1, + 10, + AUTHOR_ID + ); + + assertThat(authorResponse.recruitment().status()).isEqualTo(CLOSED); + assertThat(authorResponse.recruitment().dDay()).isNull(); + } + @Test void 역할_하나가_정원에_도달해도_마감_전_모집글은_RECRUITING이고_해당_역할만_닫힌다() { TeamRecruitment recruitment = recruitment(); diff --git a/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/service/TeamRecruitmentQueryServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/service/TeamRecruitmentQueryServiceTest.java index 8bbd16b5fb..3510f5e6bc 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/service/TeamRecruitmentQueryServiceTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/service/TeamRecruitmentQueryServiceTest.java @@ -18,7 +18,10 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.time.Clock; @@ -64,6 +67,7 @@ class TeamRecruitmentQueryServiceTest { private static final Integer RECRUITMENT_ID = 17; private static final Integer ROLE_ID = 4; private static final Integer TEAM_ROOM_ID = 31; + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); private static final LocalDate TODAY = LocalDate.of(2026, 8, 30); @InjectMocks @@ -218,6 +222,41 @@ void recruitmentClosed() { assertThat(detail(VIEWER_ID).applyBlockReason()).isEqualTo(RECRUITMENT_CLOSED); } + @Test + @DisplayName("마감 상태이면 미래 마감일이어도 d_day는 null 이다") + void dDayIsNullForClosedRecruitmentWithFutureDeadline() { + ReflectionTestUtils.setField(recruitment, "status", CLOSED); + givenFound(); + + assertThat(detail(VIEWER_ID).dDay()).isNull(); + } + + @Test + @DisplayName("상세 응답은 카드와 지원 가능 여부 계산에 같은 KST 기준일을 사용한다") + void detailUsesOneKstDateForCardAndApplyEligibility() { + recruitment = recruitment(ROLE_BASED, 3, 0, TODAY); + givenFound(); + givenNoApplication(); + givenHasProfile(true); + + Clock beforeMidnight = Clock.fixed( + Instant.parse("2026-08-30T14:59:00Z"), + KST + ); + Clock afterMidnight = Clock.fixed( + Instant.parse("2026-08-30T15:01:00Z"), + KST + ); + doReturn(beforeMidnight, afterMidnight).when(clock).withZone(KST); + + RecruitmentDetail response = detail(VIEWER_ID); + + assertThat(response.dDay()).isZero(); + assertThat(response.applyBlockReason()).isNull(); + assertThat(response.canApply()).isTrue(); + verify(clock, times(1)).withZone(KST); + } + @Test @DisplayName("지원 마감일이 지나면 DEADLINE_PASSED 이다") void deadlinePassed() { From 199149c987364651c0e2e9062f196c60cb57dcae 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: Thu, 3 Sep 2026 00:20:03 +0900 Subject: [PATCH 07/32] =?UTF-8?q?fix:=20=ED=8C=80=EC=9B=90=20=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=EA=B8=80=20=EC=97=B0=EC=86=8D=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80=20(#2389)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/TeamRecruitmentApi.java | 3 + .../service/TeamRecruitmentService.java | 5 ++ ...TeamRecruitmentArticleContractApiTest.java | 67 +++++++++++++++++++ 3 files changed, 75 insertions(+) diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/controller/TeamRecruitmentApi.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/controller/TeamRecruitmentApi.java index a101d9be56..4fe26b8897 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/controller/TeamRecruitmentApi.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/controller/TeamRecruitmentApi.java @@ -9,6 +9,7 @@ import static in.koreatech.koin.global.code.ApiResponseCode.NOT_FOUND_USER; import static in.koreatech.koin.global.code.ApiResponseCode.NO_CONTENT; import static in.koreatech.koin.global.code.ApiResponseCode.OK; +import static in.koreatech.koin.global.code.ApiResponseCode.REQUEST_TOO_FAST; import static in.koreatech.koin.global.code.ApiResponseCode.TEAM_RECRUITMENT_CLOSED; import static in.koreatech.koin.global.code.ApiResponseCode.TEAM_RECRUITMENT_FORBIDDEN; import static in.koreatech.koin.global.code.ApiResponseCode.TEAM_RECRUITMENT_INVALID_DEADLINE_DATE; @@ -96,6 +97,7 @@ ResponseEntity getRecruitments( NOT_FOUND_USER, UNAUTHORIZED_USER, FORBIDDEN_USER_TYPE, + REQUEST_TOO_FAST, }) @Operation(summary = "모집글 작성", description = """ ### 모집글 작성 @@ -109,6 +111,7 @@ ResponseEntity getRecruitments( - 지원 마감일은 활동 시작일 이하, 활동 시작일은 활동 종료일 이하여야 합니다. - 모집글과 TEAM 채팅방을 같은 트랜잭션에서 생성하고 작성자를 최초 채팅방 멤버로 추가합니다. - 별도의 팀 채팅방 생성 API는 없습니다. + - 같은 사용자가 동일한 요청을 300ms 안에 반복하면 두 번째 요청은 `409 REQUEST_TOO_FAST`를 반환합니다. - 생성된 모집글 id만 반환합니다. """) @PostMapping diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/service/TeamRecruitmentService.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/service/TeamRecruitmentService.java index 4a1f62a375..bf4cab5bb9 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/service/TeamRecruitmentService.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/service/TeamRecruitmentService.java @@ -43,6 +43,7 @@ import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentRepository; import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.global.duplicate.DuplicateGuard; import in.koreatech.koin.global.exception.CustomException; import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; @@ -78,6 +79,10 @@ public class TeamRecruitmentService { * 모집글과 TEAM 채팅방, 작성자 멤버를 같은 트랜잭션에서 생성한다. */ @Transactional + @DuplicateGuard( + key = "'team-recruitment:create:' + #userId + ':' + #request.toString()", + timeoutSeconds = 300 + ) public IdResponse createRecruitment(Integer userId, CreateRecruitmentRequest request) { User author = userRepository.getById(userId); TeamRecruitment recruitment = request.toEntity(author); diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentArticleContractApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentArticleContractApiTest.java index 42773b2ff4..fbc6dbc609 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentArticleContractApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentArticleContractApiTest.java @@ -205,6 +205,73 @@ void createsGeneralRecruitment() throws Exception { .andExpect(status().isCreated()); } + @Test + @DisplayName("같은 사용자의 동일한 모집글 생성 요청이 연속되면 두 번째 요청은 409이고 side effect는 한 번만 발생한다") + void rejectsImmediateDuplicateRequestWithoutDuplicateSideEffects() throws Exception { + String body = generalBody(5); + + mockMvc.perform(post("/team-recruitments") + .header("Authorization", "Bearer " + authorToken) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isCreated()); + + mockMvc.perform(post("/team-recruitments") + .header("Authorization", "Bearer " + authorToken) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("REQUEST_TOO_FAST")); + + entityManager.flush(); + entityManager.clear(); + assertThat(recruitmentGraphRowCounts()).containsExactly(1L, 0L, 1L, 1L); + } + + @Test + @DisplayName("같은 사용자의 서로 다른 모집글 생성 요청은 연속되어도 허용한다") + void allowsImmediateRequestWithDifferentPayload() throws Exception { + mockMvc.perform(post("/team-recruitments") + .header("Authorization", "Bearer " + authorToken) + .contentType(MediaType.APPLICATION_JSON) + .content(generalBody(4))) + .andExpect(status().isCreated()); + + mockMvc.perform(post("/team-recruitments") + .header("Authorization", "Bearer " + authorToken) + .contentType(MediaType.APPLICATION_JSON) + .content(generalBody(5))) + .andExpect(status().isCreated()); + + entityManager.flush(); + entityManager.clear(); + assertThat(recruitmentGraphRowCounts()).containsExactly(2L, 0L, 2L, 2L); + } + + @Test + @DisplayName("동일한 모집글 생성 요청도 300ms가 지나면 허용한다") + void allowsSameRequestAfterDuplicateGuardWindow() throws Exception { + String body = generalBody(5); + + mockMvc.perform(post("/team-recruitments") + .header("Authorization", "Bearer " + authorToken) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isCreated()); + + Thread.sleep(350); + + mockMvc.perform(post("/team-recruitments") + .header("Authorization", "Bearer " + authorToken) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isCreated()); + + entityManager.flush(); + entityManager.clear(); + assertThat(recruitmentGraphRowCounts()).containsExactly(2L, 0L, 2L, 2L); + } + @Test @DisplayName("미인증 요청은 401 이다") void unauthenticated() throws Exception { From 20049952dd9b707a3edc9e064ec009a91791869b 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: Thu, 3 Sep 2026 00:29:47 +0900 Subject: [PATCH 08/32] =?UTF-8?q?fix:=20=ED=8C=80=EC=9B=90=20=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EB=A7=88=EA=B0=90=20=EB=B0=B0=EC=B9=98=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=EB=A5=BC=20=EB=AA=A8=EC=A7=91=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=EB=A1=9C=20=EA=B2=A9=EB=A6=AC=20(#2394)?= 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)); } From 0bb7977e248050eecfeac5dfd065686916075313 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: Thu, 3 Sep 2026 00:40:55 +0900 Subject: [PATCH 09/32] fix: enforce upload URL request metadata (#2395) --- .../s3/dto/UploadUrlRequest.java | 15 ++- .../dto/UploadUrlRequestValidationTest.java | 124 ++++++++++++++++++ 2 files changed, 136 insertions(+), 3 deletions(-) create mode 100644 src/test/java/in/koreatech/koin/unit/infrastructure/s3/dto/UploadUrlRequestValidationTest.java diff --git a/src/main/java/in/koreatech/koin/infrastructure/s3/dto/UploadUrlRequest.java b/src/main/java/in/koreatech/koin/infrastructure/s3/dto/UploadUrlRequest.java index fc008bbcf7..c3974f3c67 100644 --- a/src/main/java/in/koreatech/koin/infrastructure/s3/dto/UploadUrlRequest.java +++ b/src/main/java/in/koreatech/koin/infrastructure/s3/dto/UploadUrlRequest.java @@ -1,19 +1,28 @@ package in.koreatech.koin.infrastructure.s3.dto; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; @JsonNaming(SnakeCaseStrategy.class) public record UploadUrlRequest( - @Schema(description = "파일 크기", example = "1000") + @Schema(description = "파일 크기", example = "1000", requiredMode = REQUIRED) + @NotNull(message = "파일 크기는 필수입니다.") + @Positive(message = "파일 크기는 0보다 커야 합니다.") Integer contentLength, - @Schema(description = "파일 타입", example = "image/png") + @Schema(description = "파일 타입", example = "image/png", requiredMode = REQUIRED) + @NotBlank(message = "파일 타입은 필수입니다.") String contentType, - @Schema(description = "파일 이름", example = "hello.png") + @Schema(description = "파일 이름", example = "hello.png", requiredMode = REQUIRED) + @NotBlank(message = "파일 이름은 필수입니다.") String fileName ) { diff --git a/src/test/java/in/koreatech/koin/unit/infrastructure/s3/dto/UploadUrlRequestValidationTest.java b/src/test/java/in/koreatech/koin/unit/infrastructure/s3/dto/UploadUrlRequestValidationTest.java new file mode 100644 index 0000000000..d6d4c7f342 --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/infrastructure/s3/dto/UploadUrlRequestValidationTest.java @@ -0,0 +1,124 @@ +package in.koreatech.koin.unit.infrastructure.s3.dto; + +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.hasSize; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.core.MethodParameter; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import in.koreatech.koin.domain.upload.controller.UploadController; +import in.koreatech.koin.global.auth.Auth; +import in.koreatech.koin.global.exception.GlobalExceptionHandler; +import in.koreatech.koin.infrastructure.s3.dto.UploadUrlResponse; +import in.koreatech.koin.infrastructure.s3.service.UploadService; + +class UploadUrlRequestValidationTest { + + private final UploadService uploadService = mock(UploadService.class); + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + HandlerMethodArgumentResolver authResolver = new HandlerMethodArgumentResolver() { + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(Auth.class); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + return 1; + } + }; + mockMvc = MockMvcBuilders.standaloneSetup(new UploadController(uploadService)) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(authResolver) + .setValidator(validator) + .build(); + + when(uploadService.getPresignedUrl(any(), any())).thenReturn( + new UploadUrlResponse( + "https://s3.example/pre-signed", + "https://static.example/upload.png", + java.time.LocalDateTime.of(2026, 9, 2, 12, 0) + ) + ); + } + + @Test + void 모든_필수_필드가_없으면_표준_검증_오류를_반환한다() throws Exception { + mockMvc.perform(post("/OWNERS/upload/url") + .contentType(APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_REQUEST_BODY")) + .andExpect(jsonPath("$.message").isNotEmpty()) + .andExpect(jsonPath("$.errorTraceId").isNotEmpty()) + .andExpect(jsonPath("$.fieldErrors").isArray()) + .andExpect(jsonPath("$.fieldErrors", hasSize(3))) + .andExpect(jsonPath("$.fieldErrors[*].field", containsInAnyOrder( + "content_length", "content_type", "file_name"))); + } + + @ParameterizedTest + @ValueSource(ints = {0, -1}) + void 파일_크기는_양수여야_한다(int contentLength) throws Exception { + mockMvc.perform(post("/OWNERS/upload/url") + .contentType(APPLICATION_JSON) + .content(validBody(contentLength, "image/png", "hello.png"))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_REQUEST_BODY")) + .andExpect(jsonPath("$.fieldErrors[0].field").value("content_length")) + .andExpect(jsonPath("$.fieldErrors[0].constraint").value("Positive")); + } + + @Test + void 파일_타입과_이름은_공백일_수_없다() throws Exception { + mockMvc.perform(post("/OWNERS/upload/url") + .contentType(APPLICATION_JSON) + .content(validBody(1000, " ", " "))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_REQUEST_BODY")) + .andExpect(jsonPath("$.fieldErrors", hasSize(2))) + .andExpect(jsonPath("$.fieldErrors[*].field", containsInAnyOrder( + "content_type", "file_name"))); + } + + @Test + void 세_필드가_유효하면_기존_업로드_URL_응답을_반환한다() throws Exception { + mockMvc.perform(post("/OWNERS/upload/url") + .contentType(APPLICATION_JSON) + .content(validBody(1000, "application/octet-stream", "hello.bin"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.pre_signed_url").value("https://s3.example/pre-signed")); + } + + private String validBody(Integer contentLength, String contentType, String fileName) { + return "{\"content_length\":%d,\"content_type\":\"%s\",\"file_name\":\"%s\"}" + .formatted(contentLength, contentType, fileName); + } +} From e447c44aa963ebe311bb5108e377764d02c1a0f0 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: Thu, 3 Sep 2026 00:50:52 +0900 Subject: [PATCH 10/32] =?UTF-8?q?fix:=20OpenAPI=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=20dialect=EC=99=80=20=EC=B0=B8=EC=A1=B0=20=EC=A0=95?= =?UTF-8?q?=ED=95=A9=EC=84=B1=20=EB=B3=B4=EC=9E=A5=20(#2404)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../koin/global/config/SwaggerConfig.java | 21 +++++- src/main/resources/application.yml | 2 + .../CampusOpenApiSchemaContractTest.java | 71 +++++++++++++++++++ 3 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 src/test/java/in/koreatech/koin/acceptance/domain/CampusOpenApiSchemaContractTest.java diff --git a/src/main/java/in/koreatech/koin/global/config/SwaggerConfig.java b/src/main/java/in/koreatech/koin/global/config/SwaggerConfig.java index 6f39654265..4680951bac 100644 --- a/src/main/java/in/koreatech/koin/global/config/SwaggerConfig.java +++ b/src/main/java/in/koreatech/koin/global/config/SwaggerConfig.java @@ -2,10 +2,14 @@ import java.time.LocalDate; +import org.springdoc.core.customizers.GlobalOpenApiCustomizer; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import in.koreatech.koin.global.exception.ErrorResponse; +import io.swagger.v3.core.converter.ModelConverters; +import io.swagger.v3.core.converter.ResolvedSchema; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; @@ -38,13 +42,28 @@ public OpenAPI openAPI() { Server server = new Server(); server.setUrl(serverUrl); return new OpenAPI() - .openapi("3.1.0") + .openapi("3.0.3") .info(apiInfo()) .addSecurityItem(securityRequirement) .components(components) .addServersItem(server); } + @Bean + public GlobalOpenApiCustomizer errorResponseSchemaCustomizer() { + ResolvedSchema errorResponseSchema = ModelConverters.getInstance() + .readAllAsResolvedSchema(ErrorResponse.class); + return openApi -> { + Components components = openApi.getComponents(); + if (components == null) { + components = new Components(); + openApi.setComponents(components); + } + components.addSchemas(ErrorResponse.class.getSimpleName(), errorResponseSchema.schema); + errorResponseSchema.referencedSchemas.forEach(components::addSchemas); + }; + } + private Info apiInfo() { return new Info() .title("KOIN API") diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1db9a597c4..d17568159a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -104,6 +104,8 @@ cors: springdoc: use-fqn: true + api-docs: + version: OPENAPI_3_0 cloudfront: distribution-id: ${CLOUDFRONT_DISTRIBUTION_ID} diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/CampusOpenApiSchemaContractTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/CampusOpenApiSchemaContractTest.java new file mode 100644 index 0000000000..442fa50bc4 --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/domain/CampusOpenApiSchemaContractTest.java @@ -0,0 +1,71 @@ +package in.koreatech.koin.acceptance.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MvcResult; + +import com.fasterxml.jackson.databind.JsonNode; + +import in.koreatech.koin.acceptance.AcceptanceTest; +import in.koreatech.koin.acceptance.support.JsonAssertions; + +class CampusOpenApiSchemaContractTest extends AcceptanceTest { + + private static final String CAMPUS_GROUP = "3. Campus API"; + + @Test + void openapi_document_uses_the_declared_3_0_dialect() throws Exception { + JsonNode openApi = campusOpenApi(); + + assertThat(openApi.path("openapi").asText()).isEqualTo("3.0.3"); + } + + @Test + void every_local_schema_reference_is_resolved() throws Exception { + JsonNode openApi = campusOpenApi(); + List references = new ArrayList<>(); + collectReferences(openApi, references); + + List unresolved = references.stream() + .filter(reference -> reference.startsWith("#/components/schemas/")) + .filter(reference -> openApi.at(reference.substring(1)).isMissingNode()) + .distinct() + .toList(); + + assertThat(unresolved).isEmpty(); + } + + @Test + void error_response_models_are_registered_as_components() throws Exception { + JsonNode schemas = campusOpenApi().path("components").path("schemas"); + + assertThat(schemas.fieldNames()).toIterable() + .contains("ErrorResponse", "FieldError"); + } + + private JsonNode campusOpenApi() throws Exception { + MvcResult result = mockMvc.perform(get("/v3/api-docs/{group}", CAMPUS_GROUP)) + .andExpect(status().isOk()) + .andReturn(); + return JsonAssertions.convertJsonNode(result); + } + + private void collectReferences(JsonNode node, List references) { + if (node.isObject()) { + if (node.has("$ref")) { + references.add(node.path("$ref").asText()); + } + node.elements().forEachRemaining(child -> collectReferences(child, references)); + return; + } + if (node.isArray()) { + node.elements().forEachRemaining(child -> collectReferences(child, references)); + } + } +} From e77f271d3d3559c2c330f2b906e6e28b9bcb0064 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: Thu, 3 Sep 2026 01:06:27 +0900 Subject: [PATCH 11/32] =?UTF-8?q?fix:=20OpenAPI=20=EC=9D=91=EB=8B=B5=20sta?= =?UTF-8?q?tus=EC=99=80=20no-content=20=EC=A0=95=EA=B7=9C=ED=99=94=20(#239?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ApiResponseCodesOperationCustomizer.java | 165 ++++++++++--- .../domain/CampusOpenApiContractTest.java | 66 ++++++ ...iResponseCodesOperationCustomizerTest.java | 220 ++++++++++++++++++ 3 files changed, 424 insertions(+), 27 deletions(-) create mode 100644 src/test/java/in/koreatech/koin/acceptance/domain/CampusOpenApiContractTest.java create mode 100644 src/test/java/in/koreatech/koin/unit/global/code/ApiResponseCodesOperationCustomizerTest.java diff --git a/src/main/java/in/koreatech/koin/global/code/ApiResponseCodesOperationCustomizer.java b/src/main/java/in/koreatech/koin/global/code/ApiResponseCodesOperationCustomizer.java index 65bf8ea28d..95e69ba108 100644 --- a/src/main/java/in/koreatech/koin/global/code/ApiResponseCodesOperationCustomizer.java +++ b/src/main/java/in/koreatech/koin/global/code/ApiResponseCodesOperationCustomizer.java @@ -11,7 +11,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.function.Supplier; +import java.util.stream.Collectors; import java.util.stream.Stream; import org.springdoc.core.customizers.OperationCustomizer; @@ -27,6 +27,7 @@ import io.swagger.v3.core.converter.ModelConverters; import io.swagger.v3.core.converter.ResolvedSchema; import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.examples.Example; import io.swagger.v3.oas.models.media.Content; import io.swagger.v3.oas.models.media.MediaType; import io.swagger.v3.oas.models.media.Schema; @@ -52,18 +53,36 @@ public Operation customize(Operation operation, HandlerMethod handler) { } ApiResponses responses = operation.getResponses(); + if (responses == null) { + responses = new ApiResponses(); + operation.setResponses(responses); + } + ApiResponses targetResponses = responses; Type returnType = getActualResponseType(handler); - ApiResponseCode[] codes = ann.value(); - for (int i = 0; i < codes.length; i++) { - ApiResponseCode code = codes[i]; - String key = String.format("%d) %d", i + 1, code.getHttpStatus().value()); - responses.put(key, createApiResponse( - code.getMessage(), - () -> createResponseBody(code, handler, returnType) + Map> codesByStatus = Arrays.stream(ann.value()) + .collect(Collectors.groupingBy( + code -> code.getHttpStatus().value(), + LinkedHashMap::new, + Collectors.toList() )); + + ApiResponse springdocSuccessResponse = responses.get("200"); + boolean hasExplicitSuccessResponse = codesByStatus.keySet().stream() + .anyMatch(status -> status >= 200 && status < 300); + if (hasExplicitSuccessResponse && !codesByStatus.containsKey(200)) { + responses.remove("200"); } + codesByStatus.forEach((status, codes) -> { + String statusKey = String.valueOf(status); + ApiResponse existingResponse = targetResponses.get(statusKey); + targetResponses.put( + statusKey, + createApiResponse(codes, handler, returnType, existingResponse, springdocSuccessResponse) + ); + }); + return operation; } @@ -79,29 +98,78 @@ private Type getActualResponseType(HandlerMethod handler) { } private ApiResponse createApiResponse( - String description, - Supplier supplier + List codes, + HandlerMethod handler, + Type returnType, + ApiResponse existingResponse, + ApiResponse springdocSuccessResponse ) { - return new ApiResponse() - .description(description) - .content(new Content().addMediaType(APPLICATION_JSON_VALUE, supplier.get())); + ApiResponse apiResponse = existingResponse == null ? new ApiResponse() : existingResponse; + String description = codes.stream() + .map(code -> code.getCode() + ": " + code.getMessage()) + .collect(Collectors.joining("\n")); + apiResponse.setDescription(mergeDescriptions(apiResponse.getDescription(), description)); + + ApiResponseCode firstCode = codes.get(0); + if (firstCode.getHttpStatus().is2xxSuccessful()) { + mergeSuccessResponse(apiResponse, springdocSuccessResponse, returnType, firstCode); + } else { + mergeErrorResponse(apiResponse, codes, handler); + } + return apiResponse; } - private MediaType createResponseBody( - ApiResponseCode code, - HandlerMethod handler, - Type returnType + private String mergeDescriptions(String existingDescription, String generatedDescription) { + if (existingDescription == null || existingDescription.isBlank()) { + return generatedDescription; + } + if (generatedDescription == null || generatedDescription.isBlank() + || existingDescription.equals(generatedDescription) + || existingDescription.endsWith("\n" + generatedDescription)) { + return existingDescription; + } + return existingDescription + "\n" + generatedDescription; + } + + private void mergeSuccessResponse( + ApiResponse response, + ApiResponse springdocSuccessResponse, + Type returnType, + ApiResponseCode code ) { - if (code.getHttpStatus().is2xxSuccessful()) { - return new MediaType().schema(loadSchema(returnType)); + if (isNoContentResponse(returnType) || code.getHttpStatus().value() == 204) { + response.setContent(null); + return; } - if (code == ApiResponseCode.INVALID_REQUEST_BODY) { - return createErrorMediaType(createInvalidRequestBodyErrorExample(code, handler)); + if (hasSchema(response.getContent())) { + return; + } + if (springdocSuccessResponse != null && hasSchema(springdocSuccessResponse.getContent())) { + response.setContent(springdocSuccessResponse.getContent()); + return; } - return createErrorMediaType(createGenericErrorExample(code)); + response.setContent(new Content().addMediaType( + APPLICATION_JSON_VALUE, + new MediaType().schema(loadSchema(returnType)) + )); + } + + private boolean hasSchema(Content content) { + return content != null && content.values().stream() + .anyMatch(mediaType -> mediaType != null && mediaType.getSchema() != null); + } + + private boolean isNoContentResponse(Type returnType) { + return returnType.equals(Void.class) || returnType.equals(void.class); } - private Map createGenericErrorExample(ApiResponseCode code) { + private Map createErrorExample( + ApiResponseCode code, + HandlerMethod handler + ) { + if (code == ApiResponseCode.INVALID_REQUEST_BODY) { + return createInvalidRequestBodyErrorExample(code, handler); + } return Map.of( "code", code.getCode(), "message", code.getMessage(), @@ -202,9 +270,52 @@ private static Schema loadSchema(Type type) { return resolvedSchema.schema; } - private MediaType createErrorMediaType(Map example) { - MediaType mt = new MediaType().schema(errorSchema); - mt.example(example); - return mt; + private void mergeErrorResponse( + ApiResponse response, + List codes, + HandlerMethod handler + ) { + Content content = response.getContent(); + if (content == null) { + content = new Content(); + response.setContent(content); + } + + MediaType mediaType = content.get(APPLICATION_JSON_VALUE); + if (mediaType == null) { + mediaType = new MediaType(); + content.addMediaType(APPLICATION_JSON_VALUE, mediaType); + } + if (mediaType.getSchema() == null) { + mediaType.setSchema(errorSchema); + } + + Map examples = mediaType.getExamples() == null + ? new LinkedHashMap<>() + : new LinkedHashMap<>(mediaType.getExamples()); + moveSingleExampleToNamedExamples(mediaType, examples); + for (ApiResponseCode code : codes) { + examples.putIfAbsent( + code.getCode(), + new Example() + .summary(code.getCode()) + .description(code.getMessage()) + .value(createErrorExample(code, handler)) + ); + } + mediaType.setExamples(examples); + } + + private void moveSingleExampleToNamedExamples(MediaType mediaType, Map examples) { + if (mediaType.getExample() == null) { + return; + } + String exampleName = "default"; + int suffix = 2; + while (examples.containsKey(exampleName)) { + exampleName = "default-" + suffix++; + } + examples.put(exampleName, new Example().value(mediaType.getExample())); + mediaType.setExample(null); } } diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/CampusOpenApiContractTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/CampusOpenApiContractTest.java new file mode 100644 index 0000000000..2af0945d4a --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/domain/CampusOpenApiContractTest.java @@ -0,0 +1,66 @@ +package in.koreatech.koin.acceptance.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.Iterator; + +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MvcResult; + +import com.fasterxml.jackson.databind.JsonNode; + +import in.koreatech.koin.acceptance.AcceptanceTest; +import in.koreatech.koin.acceptance.support.JsonAssertions; + +class CampusOpenApiContractTest extends AcceptanceTest { + + private static final String CAMPUS_GROUP = "3. Campus API"; + + @Test + void root_list_success_schema_is_preserved() throws Exception { + JsonNode openApi = campusOpenApi(); + JsonNode response = openApi.at("/paths/~1callvan~1notifications/get/responses/200"); + + assertThat(response.isMissingNode()).isFalse(); + JsonNode schema = findResponseSchema(response); + assertThat(schema.path("type").asText()).isEqualTo("array"); + assertThat(schema.path("items").path("$ref").asText()).endsWith("CallvanNotificationResponse"); + } + + @Test + void explicit_created_and_no_content_responses_do_not_keep_phantom_200() throws Exception { + JsonNode openApi = campusOpenApi(); + JsonNode created = openApi.at("/paths/~1callvan/post/responses"); + JsonNode noContent = openApi.at( + "/paths/~1callvan~1notifications~1mark-all-read/post/responses"); + + assertThat(created.has("200")).isFalse(); + assertThat(created.has("201")).isTrue(); + assertThat(findResponseSchema(created.path("201")).path("$ref").asText()) + .endsWith("CallvanPostCreateResponse"); + assertThat(noContent.has("200")).isFalse(); + assertThat(noContent.has("204")).isTrue(); + assertThat(noContent.path("204").has("content")).isFalse(); + } + + private JsonNode campusOpenApi() throws Exception { + MvcResult result = mockMvc.perform(get("/v3/api-docs/{group}", CAMPUS_GROUP)) + .andExpect(status().isOk()) + .andReturn(); + return JsonAssertions.convertJsonNode(result); + } + + private JsonNode findResponseSchema(JsonNode response) { + Iterator mediaTypes = response.path("content").elements(); + while (mediaTypes.hasNext()) { + JsonNode schema = mediaTypes.next().path("schema"); + if (!schema.isMissingNode()) { + return schema; + } + } + throw new AssertionError("Response content does not contain a schema"); + } + +} diff --git a/src/test/java/in/koreatech/koin/unit/global/code/ApiResponseCodesOperationCustomizerTest.java b/src/test/java/in/koreatech/koin/unit/global/code/ApiResponseCodesOperationCustomizerTest.java new file mode 100644 index 0000000000..8f71719ace --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/global/code/ApiResponseCodesOperationCustomizerTest.java @@ -0,0 +1,220 @@ +package in.koreatech.koin.unit.global.code; + +import static in.koreatech.koin.global.code.ApiResponseCode.CREATED; +import static in.koreatech.koin.global.code.ApiResponseCode.ILLEGAL_ARGUMENT; +import static in.koreatech.koin.global.code.ApiResponseCode.INVALID_REQUEST_BODY; +import static in.koreatech.koin.global.code.ApiResponseCode.NO_CONTENT; +import static in.koreatech.koin.global.code.ApiResponseCode.OK; +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.springframework.http.ResponseEntity; +import org.springframework.web.method.HandlerMethod; + +import in.koreatech.koin.domain.teamrecruitment.controller.TeamRecruitmentChatController; +import in.koreatech.koin.global.code.ApiResponseCodes; +import in.koreatech.koin.global.code.ApiResponseCodesOperationCustomizer; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.examples.Example; +import io.swagger.v3.oas.models.media.ArraySchema; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.responses.ApiResponses; + +class ApiResponseCodesOperationCustomizerTest { + + private final ApiResponseCodesOperationCustomizer customizer = new ApiResponseCodesOperationCustomizer(); + + @Test + void same_status_codes_are_grouped_and_no_content_has_no_media_type() throws Exception { + Operation operation = new Operation().responses(new ApiResponses()); + Method method = SampleController.class.getDeclaredMethod("update"); + + customizer.customize(operation, new HandlerMethod(new SampleController(), method)); + + assertThat(operation.getResponses()).containsOnlyKeys("204", "400"); + + ApiResponse noContent = operation.getResponses().get("204"); + assertThat(noContent.getContent()).isNull(); + + ApiResponse badRequest = operation.getResponses().get("400"); + MediaType mediaType = badRequest.getContent().get("application/json"); + assertThat(mediaType.getExamples()).containsOnlyKeys("INVALID_REQUEST_BODY", "ILLEGAL_ARGUMENT"); + assertThat(((Map) mediaType.getExamples().get("INVALID_REQUEST_BODY").getValue()).get("code")) + .isEqualTo("INVALID_REQUEST_BODY"); + assertThat(((Map) mediaType.getExamples().get("ILLEGAL_ARGUMENT").getValue()).get("code")) + .isEqualTo("ILLEGAL_ARGUMENT"); + } + + @Test + void void_success_response_has_no_media_type_even_when_status_is_not_204() throws Exception { + Operation operation = new Operation().responses(new ApiResponses()); + Method method = SampleController.class.getDeclaredMethod("acknowledge"); + + customizer.customize(operation, new HandlerMethod(new SampleController(), method)); + + assertThat(operation.getResponses().get("200").getContent()).isNull(); + } + + @Test + void team_recruitment_chat_annotation_uses_standard_status_keys() throws Exception { + Operation operation = new Operation().responses(new ApiResponses()); + Method method = TeamRecruitmentChatController.class.getMethod( + "getOrCreateDirectChatRoom", Integer.class, Integer.class, Integer.class); + + customizer.customize( + operation, + new HandlerMethod(new TeamRecruitmentChatController(null), method) + ); + + assertThat(operation.getResponses()).containsOnlyKeys("200", "201", "401", "403", "404", "409"); + } + + @Test + void existing_root_list_success_schema_and_description_are_preserved() throws Exception { + Content springdocContent = new Content().addMediaType( + "*/*", + new MediaType().schema(new ArraySchema().items( + new Schema<>().$ref("#/components/schemas/SampleResponse"))) + ); + ApiResponse springdocResponse = new ApiResponse() + .description("Springdoc success") + .content(springdocContent); + Operation operation = new Operation().responses( + new ApiResponses().addApiResponse("200", springdocResponse)); + Method method = SampleController.class.getDeclaredMethod("list"); + + customizer.customize(operation, new HandlerMethod(new SampleController(), method)); + + ApiResponse response = operation.getResponses().get("200"); + assertThat(response.getContent()).isSameAs(springdocContent); + assertThat(response.getDescription()).contains("Springdoc success", "OK:"); + Schema schema = response.getContent().get("*/*").getSchema(); + assertThat(schema.getType()).isEqualTo("array"); + assertThat(schema.getItems().get$ref()).isEqualTo("#/components/schemas/SampleResponse"); + } + + @Test + void phantom_200_is_removed_and_generated_schema_is_moved_to_created_response() throws Exception { + Content springdocContent = new Content().addMediaType( + "*/*", + new MediaType().schema(new Schema<>().$ref("#/components/schemas/SampleResponse")) + ); + Operation operation = new Operation().responses(new ApiResponses().addApiResponse( + "200", + new ApiResponse().description("Springdoc success").content(springdocContent) + )); + Method method = SampleController.class.getDeclaredMethod("create"); + + customizer.customize(operation, new HandlerMethod(new SampleController(), method)); + + assertThat(operation.getResponses()).containsOnlyKeys("201"); + assertThat(operation.getResponses().get("201").getContent()).isSameAs(springdocContent); + } + + @Test + void existing_created_schema_and_description_take_precedence_over_phantom_200() throws Exception { + Content springdocContent = new Content().addMediaType( + "*/*", + new MediaType().schema(new Schema<>().$ref("#/components/schemas/SampleResponse")) + ); + Content documentedCreatedContent = new Content().addMediaType( + "application/json", + new MediaType().schema(new Schema<>().$ref("#/components/schemas/DocumentedCreatedResponse")) + ); + Operation operation = new Operation().responses(new ApiResponses() + .addApiResponse("200", new ApiResponse().description("Springdoc success").content(springdocContent)) + .addApiResponse("201", new ApiResponse() + .description("Manually documented creation") + .content(documentedCreatedContent))); + Method method = SampleController.class.getDeclaredMethod("create"); + + customizer.customize(operation, new HandlerMethod(new SampleController(), method)); + + assertThat(operation.getResponses()).containsOnlyKeys("201"); + ApiResponse created = operation.getResponses().get("201"); + assertThat(created.getContent()).isSameAs(documentedCreatedContent); + assertThat(created.getDescription()).contains("Manually documented creation", "CREATED:"); + } + + @Test + void phantom_200_is_removed_from_no_content_response() throws Exception { + Operation operation = new Operation().responses(new ApiResponses().addApiResponse( + "200", + new ApiResponse().description("Springdoc success") + )); + Method method = SampleController.class.getDeclaredMethod("remove"); + + customizer.customize(operation, new HandlerMethod(new SampleController(), method)); + + assertThat(operation.getResponses()).containsOnlyKeys("204"); + assertThat(operation.getResponses().get("204").getContent()).isNull(); + } + + @Test + void existing_error_examples_are_preserved_when_domain_examples_are_merged() throws Exception { + Example existing = new Example().summary("manually documented example"); + Map singleExample = Map.of("source", "manual"); + Schema existingSchema = new Schema<>().type("object"); + MediaType existingMediaType = new MediaType() + .schema(existingSchema) + .example(singleExample) + .addExamples("ILLEGAL_ARGUMENT", existing); + Operation operation = new Operation().responses(new ApiResponses().addApiResponse( + "400", + new ApiResponse() + .description("Manually documented bad request") + .content(new Content().addMediaType("application/json", existingMediaType)) + )); + Method method = SampleController.class.getDeclaredMethod("update"); + + customizer.customize(operation, new HandlerMethod(new SampleController(), method)); + + ApiResponse response = operation.getResponses().get("400"); + assertThat(response.getDescription()).contains("Manually documented bad request", "INVALID_REQUEST_BODY:"); + assertThat(response.getContent().get("application/json").getSchema()).isSameAs(existingSchema); + assertThat(response.getContent().get("application/json").getExamples()) + .containsEntry("ILLEGAL_ARGUMENT", existing) + .containsKey("INVALID_REQUEST_BODY"); + assertThat(response.getContent().get("application/json").getExample()).isNull(); + assertThat(response.getContent().get("application/json").getExamples().values()) + .extracting(Example::getValue) + .contains(singleExample); + } + + private static class SampleController { + + @ApiResponseCodes({NO_CONTENT, INVALID_REQUEST_BODY, ILLEGAL_ARGUMENT}) + ResponseEntity update() { + return ResponseEntity.noContent().build(); + } + + @ApiResponseCodes(OK) + ResponseEntity acknowledge() { + return ResponseEntity.ok().build(); + } + + @ApiResponseCodes(OK) + ResponseEntity> list() { + return ResponseEntity.ok(List.of()); + } + + @ApiResponseCodes(CREATED) + ResponseEntity create() { + return ResponseEntity.status(201).body(new SampleResponse("created")); + } + + @ApiResponseCodes(NO_CONTENT) + ResponseEntity remove() { + return ResponseEntity.noContent().build(); + } + } + + private record SampleResponse(String value) {} +} From cefcda7a0bda7ba4bf441c3a197c2cc089c1217f 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: Thu, 3 Sep 2026 01:22:50 +0900 Subject: [PATCH 12/32] =?UTF-8?q?docs:=20=ED=8C=80=EC=9B=90=20=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20Java=20Swagger=EC=99=80=20=EA=B3=B5=EA=B0=9C=20API?= =?UTF-8?q?=20=EA=B3=84=EC=95=BD=20=EB=8F=99=EA=B8=B0=ED=99=94=20(#2391)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: initialize issue #2378 * docs: 팀원 모집 OpenAPI 계약 동기화 --- .../team/recruitment/dto/ApplicationRole.java | 1 + .../recruitment/dto/RecruitmentDetail.java | 5 +- .../controller/TeamRecruitmentChatApi.java | 9 +- .../TeamRecruitmentChatController.java | 6 +- .../dto/ChatMessageResponse.java | 16 +- .../teamrecruitment/dto/ChatRoomResponse.java | 23 +- .../dto/CreateChatMessageRequest.java | 5 +- .../dto/DirectChatRoomResponse.java | 15 +- .../TeamRecruitmentNotificationResponse.java | 25 +- .../TeamRecruitmentNotificationsResponse.java | 13 +- .../global/config/SwaggerGroupConfig.java | 11 +- .../TeamRecruitmentOpenApiCustomizer.java | 120 +++++++ .../TeamRecruitmentOpenApiContractTest.java | 309 ++++++++++++++++++ .../TeamRecruitmentOpenApiCustomizerTest.java | 40 +++ 14 files changed, 546 insertions(+), 52 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/global/config/TeamRecruitmentOpenApiCustomizer.java create mode 100644 src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentOpenApiContractTest.java create mode 100644 src/test/java/in/koreatech/koin/unit/global/config/TeamRecruitmentOpenApiCustomizerTest.java diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/ApplicationRole.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/ApplicationRole.java index 0677d4315f..f5523a1c98 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/ApplicationRole.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/ApplicationRole.java @@ -8,6 +8,7 @@ import io.swagger.v3.oas.annotations.media.Schema; @JsonNaming(SnakeCaseStrategy.class) +@Schema(description = "지원자가 선택한 역할") public record ApplicationRole( @Schema(description = "역할 ID", example = "1", requiredMode = REQUIRED) Integer id, diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/RecruitmentDetail.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/RecruitmentDetail.java index df66f2ca39..c319957efe 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/RecruitmentDetail.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/RecruitmentDetail.java @@ -85,7 +85,9 @@ public record RecruitmentDetail( @Schema(description = "지원 가능 여부", example = "true", requiredMode = REQUIRED) Boolean canApply, - @Schema(description = "지원 불가 사유. 지원 가능하면 null입니다.", nullable = true, requiredMode = REQUIRED) + @Schema(description = "지원 불가 사유. 지원 가능하면 null입니다. 여러 사유가 겹치면 " + + "LOGIN_REQUIRED > OWN_RECRUITMENT > ALREADY_APPLIED > RECRUITMENT_CLOSED > DEADLINE_PASSED > " + + "ROLE_CLOSED > PROFILE_REQUIRED 순으로 반환합니다.", nullable = true, requiredMode = REQUIRED) TeamRecruitmentApplyBlockReason applyBlockReason, @Schema(description = "내 지원 정보. 미지원자는 null입니다.", nullable = true, requiredMode = REQUIRED) @@ -102,6 +104,7 @@ public record RecruitmentDetail( Integer teamChatRoomId ) { @JsonNaming(SnakeCaseStrategy.class) + @Schema(description = "현재 사용자의 지원 정보") public record AppliedApplication( @Schema(description = "지원서 ID", example = "51", requiredMode = REQUIRED) Integer applicationId, diff --git a/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentChatApi.java b/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentChatApi.java index e7e4f6dae1..c7045b3a7d 100644 --- a/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentChatApi.java +++ b/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentChatApi.java @@ -85,9 +85,12 @@ ResponseEntity> getMessages( Integer userId, @PathVariable Integer recruitmentId, @PathVariable Integer chatRoomId, - @Parameter(schema = @Schema(minimum = "1")) @RequestParam(required = false) Integer afterMessageId, - @Parameter(schema = @Schema(minimum = "1")) @RequestParam(required = false) Integer beforeMessageId, - @Parameter(schema = @Schema(minimum = "1", maximum = "200")) @RequestParam(defaultValue = "100") int limit + @Parameter(schema = @Schema(minimum = "1")) + @RequestParam(name = "afterMessageId", required = false) Integer afterMessageId, + @Parameter(schema = @Schema(minimum = "1")) + @RequestParam(name = "beforeMessageId", required = false) Integer beforeMessageId, + @Parameter(schema = @Schema(minimum = "1", maximum = "200")) + @RequestParam(name = "limit", defaultValue = "100") int limit ); @ApiResponseCodes({ diff --git a/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentChatController.java b/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentChatController.java index b63a5124e7..b9bbaae96a 100644 --- a/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentChatController.java +++ b/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentChatController.java @@ -56,9 +56,9 @@ public ResponseEntity> getMessages( @Auth(permit = {STUDENT}) Integer userId, @PathVariable Integer recruitmentId, @PathVariable Integer chatRoomId, - @RequestParam(required = false) Integer afterMessageId, - @RequestParam(required = false) Integer beforeMessageId, - @RequestParam(defaultValue = "100") int limit + @RequestParam(name = "afterMessageId", required = false) Integer afterMessageId, + @RequestParam(name = "beforeMessageId", required = false) Integer beforeMessageId, + @RequestParam(name = "limit", defaultValue = "100") int limit ) { return ResponseEntity.ok(chatService.getMessages(userId, recruitmentId, chatRoomId, afterMessageId, beforeMessageId, limit)); } diff --git a/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/ChatMessageResponse.java b/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/ChatMessageResponse.java index e6fdfc6b4c..3478467e89 100644 --- a/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/ChatMessageResponse.java +++ b/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/ChatMessageResponse.java @@ -1,6 +1,7 @@ package in.koreatech.koin.domain.teamrecruitment.dto; import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import java.time.LocalDateTime; @@ -11,25 +12,26 @@ @JsonNaming(SnakeCaseStrategy.class) public record ChatMessageResponse( - @Schema(description = "메시지 ID", example = "901") + @Schema(description = "메시지 ID", example = "901", requiredMode = REQUIRED) Integer messageId, - @Schema(description = "발신자 유저 ID", example = "22") + @Schema(description = "발신자 유저 ID", example = "22", requiredMode = REQUIRED) Integer userId, - @Schema(description = "발신자 닉네임", example = "김철수") + @Schema(description = "발신자 닉네임", example = "김철수", requiredMode = REQUIRED) String userNickname, - @Schema(description = "메시지 내용 (is_image=true이면 file_url)", example = "안녕하세요!") + @Schema(description = "메시지 내용 (is_image=true이면 file_url)", example = "안녕하세요!", requiredMode = REQUIRED) String content, - @Schema(description = "메시지 전송 시각 (KST LocalDateTime)", example = "2026-08-26T11:20:30.123456") + @Schema(description = "메시지 전송 시각 (KST LocalDateTime, ISO-8601)", + example = "2026-08-26T11:20:30.123456", format = "date-time", requiredMode = REQUIRED) LocalDateTime timestamp, - @Schema(description = "이미지 메시지 여부", example = "false") + @Schema(description = "이미지 메시지 여부", example = "false", requiredMode = REQUIRED) Boolean isImage, - @Schema(description = "아직 읽지 않은 다른 멤버 수", example = "2") + @Schema(description = "아직 읽지 않은 다른 멤버 수", example = "2", requiredMode = REQUIRED) int unreadCount ) { public static ChatMessageResponse of(TeamRecruitmentChatMessage message, int unreadCount) { diff --git a/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/ChatRoomResponse.java b/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/ChatRoomResponse.java index 9f6ed9d0dd..35daf60afb 100644 --- a/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/ChatRoomResponse.java +++ b/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/ChatRoomResponse.java @@ -1,6 +1,7 @@ package in.koreatech.koin.domain.teamrecruitment.dto; import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import com.fasterxml.jackson.databind.annotation.JsonNaming; @@ -9,38 +10,42 @@ @JsonNaming(SnakeCaseStrategy.class) public record ChatRoomResponse( - @Schema(description = "채팅방 ID", example = "31") + @Schema(description = "채팅방 ID", example = "31", requiredMode = REQUIRED) Integer chatRoomId, - @Schema(description = "채팅방 이름", example = "AI 아이디어 공모전 팀원 모집") + @Schema(description = "채팅방 이름", example = "AI 아이디어 공모전 팀원 모집", requiredMode = REQUIRED) String roomName, - @Schema(description = "채팅방 타입", example = "TEAM") + @Schema(description = "채팅방 타입", example = "TEAM", requiredMode = REQUIRED) String roomType, - @Schema(description = "채팅방 상태", example = "ACTIVE") + @Schema(description = "채팅방 상태", example = "ACTIVE", requiredMode = REQUIRED) String status, @Schema( description = "현재 채팅방 멤버 수. TEAM은 작성자와 승인된 지원자를 포함하고, DIRECT는 항상 2", - example = "3" + example = "3", + requiredMode = REQUIRED ) int memberCount, @Schema( description = "최대 채팅방 멤버 수. TEAM은 모집 정원에 작성자 1명을 더한 값이고, DIRECT는 항상 2", - example = "6" + example = "6", + requiredMode = REQUIRED ) int maxMemberCount, - @Schema(description = "상대방 정보 (DIRECT만 non-null)") + @Schema(description = "상대방 정보. TEAM은 null이고 DIRECT는 상대방 정보를 반환합니다.", nullable = true, + requiredMode = REQUIRED) Counterpart counterpart ) { + @Schema(description = "상대방 정보") public record Counterpart( - @Schema(description = "상대방 유저 ID", example = "22") + @Schema(description = "상대방 유저 ID", example = "22", requiredMode = REQUIRED) Integer id, - @Schema(description = "상대방 닉네임", example = "김철수") + @Schema(description = "상대방 닉네임", example = "김철수", requiredMode = REQUIRED) String nickname ) {} diff --git a/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/CreateChatMessageRequest.java b/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/CreateChatMessageRequest.java index 44ed498c19..ecbc83e28d 100644 --- a/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/CreateChatMessageRequest.java +++ b/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/CreateChatMessageRequest.java @@ -1,6 +1,7 @@ package in.koreatech.koin.domain.teamrecruitment.dto; import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import com.fasterxml.jackson.databind.annotation.JsonNaming; @@ -10,11 +11,11 @@ @JsonNaming(SnakeCaseStrategy.class) public record CreateChatMessageRequest( - @Schema(description = "메시지 내용 (is_image=true이면 file_url)", example = "안녕하세요!") + @Schema(description = "메시지 내용 (is_image=true이면 file_url)", example = "안녕하세요!", requiredMode = REQUIRED) @NotBlank String content, - @Schema(description = "이미지 메시지 여부", example = "false") + @Schema(description = "이미지 메시지 여부", example = "false", requiredMode = REQUIRED) @NotNull Boolean isImage ) { diff --git a/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/DirectChatRoomResponse.java b/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/DirectChatRoomResponse.java index d6465fc738..9fec14b40c 100644 --- a/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/DirectChatRoomResponse.java +++ b/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/DirectChatRoomResponse.java @@ -1,6 +1,7 @@ package in.koreatech.koin.domain.teamrecruitment.dto; import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import com.fasterxml.jackson.databind.annotation.JsonNaming; @@ -10,26 +11,26 @@ @JsonNaming(SnakeCaseStrategy.class) public record DirectChatRoomResponse( - @Schema(description = "채팅방 ID", example = "71") + @Schema(description = "채팅방 ID", example = "71", requiredMode = REQUIRED) Integer chatRoomId, - @Schema(description = "상대방 닉네임", example = "김철수") + @Schema(description = "상대방 닉네임", example = "김철수", requiredMode = REQUIRED) String roomName, - @Schema(description = "채팅방 타입", example = "DIRECT") + @Schema(description = "채팅방 타입", example = "DIRECT", requiredMode = REQUIRED) String roomType, - @Schema(description = "채팅방 상태", example = "ACTIVE") + @Schema(description = "채팅방 상태", example = "ACTIVE", requiredMode = REQUIRED) String status, - @Schema(description = "상대방 정보") + @Schema(description = "상대방 정보", requiredMode = REQUIRED) Counterpart counterpart ) { public record Counterpart( - @Schema(description = "상대방 유저 ID", example = "22") + @Schema(description = "상대방 유저 ID", example = "22", requiredMode = REQUIRED) Integer id, - @Schema(description = "상대방 닉네임", example = "김철수") + @Schema(description = "상대방 닉네임", example = "김철수", requiredMode = REQUIRED) String nickname ) {} diff --git a/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/TeamRecruitmentNotificationResponse.java b/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/TeamRecruitmentNotificationResponse.java index 0ef4b67612..6966ff0314 100644 --- a/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/TeamRecruitmentNotificationResponse.java +++ b/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/TeamRecruitmentNotificationResponse.java @@ -1,6 +1,7 @@ package in.koreatech.koin.domain.teamrecruitment.dto; import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import java.time.LocalDateTime; @@ -11,34 +12,38 @@ @JsonNaming(SnakeCaseStrategy.class) public record TeamRecruitmentNotificationResponse( - @Schema(description = "알림 ID", example = "101") + @Schema(description = "알림 ID", example = "101", requiredMode = REQUIRED) Integer id, - @Schema(description = "알림 타입", example = "NEW_APPLICATION") + @Schema(description = "알림 타입", example = "NEW_APPLICATION", requiredMode = REQUIRED) String type, - @Schema(description = "이동 대상 타입", example = "APPLICANT_MANAGEMENT") + @Schema(description = "이동 대상 타입", example = "APPLICANT_MANAGEMENT", requiredMode = REQUIRED) String targetType, - @Schema(description = "모집글 ID", example = "17") + @Schema(description = "모집글 ID", example = "17", requiredMode = REQUIRED) Integer recruitmentId, - @Schema(description = "지원서 ID", example = "51") + @Schema(description = "지원서 ID. 지원서와 관련 없는 알림은 null입니다.", example = "51", nullable = true, + requiredMode = REQUIRED) Integer applicationId, - @Schema(description = "채팅방 ID", example = "31") + @Schema(description = "채팅방 ID. 채팅방과 관련 없는 알림은 null입니다.", example = "31", nullable = true, + requiredMode = REQUIRED) Integer chatRoomId, - @Schema(description = "발신자 닉네임 (NEW_CHAT_MESSAGE 전용)", example = "홍길동") + @Schema(description = "발신자 닉네임. NEW_CHAT_MESSAGE가 아니면 null입니다.", example = "홍길동", + nullable = true, requiredMode = REQUIRED) String senderNickname, - @Schema(description = "메시지 미리보기", example = "새로운 지원자가 있어요.") + @Schema(description = "메시지 미리보기", example = "새로운 지원자가 있어요.", requiredMode = REQUIRED) String messagePreview, - @Schema(description = "읽음 여부", example = "false") + @Schema(description = "읽음 여부", example = "false", requiredMode = REQUIRED) Boolean isRead, - @Schema(description = "생성 일시") + @Schema(description = "생성 일시 (KST LocalDateTime, ISO-8601)", + example = "2026-08-26T11:20:30.123456", format = "date-time", requiredMode = REQUIRED) LocalDateTime createdAt ) { public static TeamRecruitmentNotificationResponse from(TeamRecruitmentNotification notification) { diff --git a/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/TeamRecruitmentNotificationsResponse.java b/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/TeamRecruitmentNotificationsResponse.java index 7f7e912143..d21f51ae48 100644 --- a/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/TeamRecruitmentNotificationsResponse.java +++ b/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/TeamRecruitmentNotificationsResponse.java @@ -1,6 +1,7 @@ package in.koreatech.koin.domain.teamrecruitment.dto; import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import java.util.List; @@ -10,22 +11,22 @@ @JsonNaming(SnakeCaseStrategy.class) public record TeamRecruitmentNotificationsResponse( - @Schema(description = "알림 목록") + @Schema(description = "알림 목록", requiredMode = REQUIRED) List notifications, - @Schema(description = "전체 미읽음 알림 수", example = "3") + @Schema(description = "전체 미읽음 알림 수", example = "3", requiredMode = REQUIRED) long unreadCount, - @Schema(description = "전체 알림 수", example = "24") + @Schema(description = "전체 알림 수", example = "24", requiredMode = REQUIRED) long totalCount, - @Schema(description = "현재 페이지 알림 수", example = "10") + @Schema(description = "현재 페이지 알림 수", example = "10", requiredMode = REQUIRED) int currentCount, - @Schema(description = "전체 페이지 수", example = "3") + @Schema(description = "전체 페이지 수", example = "3", requiredMode = REQUIRED) int totalPage, - @Schema(description = "현재 페이지 (1부터 시작)", example = "1") + @Schema(description = "현재 페이지 (1부터 시작)", example = "1", requiredMode = REQUIRED) int currentPage ) { } diff --git a/src/main/java/in/koreatech/koin/global/config/SwaggerGroupConfig.java b/src/main/java/in/koreatech/koin/global/config/SwaggerGroupConfig.java index 76b4eef1a7..086d79919a 100644 --- a/src/main/java/in/koreatech/koin/global/config/SwaggerGroupConfig.java +++ b/src/main/java/in/koreatech/koin/global/config/SwaggerGroupConfig.java @@ -46,9 +46,9 @@ public GroupedOpenApi businessApi() { @Bean public GroupedOpenApi campusApi() { - return createGroupedOpenApi( - "3. Campus API", - new String[] { + return GroupedOpenApi.builder() + .group("3. Campus API") + .packagesToScan( "in.koreatech.koin.domain.bus", "in.koreatech.koin.domain.community.article", "in.koreatech.koin.domain.community.keyword", @@ -62,7 +62,10 @@ public GroupedOpenApi campusApi() { "in.koreatech.koin.domain.team", "in.koreatech.koin.domain.teamrecruitment", "in.koreatech.koin.domain.weather" - }); + ) + .addOperationCustomizer(customizer) + .addOpenApiCustomizer(new TeamRecruitmentOpenApiCustomizer()) + .build(); } @Bean diff --git a/src/main/java/in/koreatech/koin/global/config/TeamRecruitmentOpenApiCustomizer.java b/src/main/java/in/koreatech/koin/global/config/TeamRecruitmentOpenApiCustomizer.java new file mode 100644 index 0000000000..69a0a9394e --- /dev/null +++ b/src/main/java/in/koreatech/koin/global/config/TeamRecruitmentOpenApiCustomizer.java @@ -0,0 +1,120 @@ +package in.koreatech.koin.global.config; + +import java.util.List; +import java.util.Map; + +import org.springdoc.core.customizers.OpenApiCustomizer; + +import io.swagger.v3.core.util.Json; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.responses.ApiResponse; + +public class TeamRecruitmentOpenApiCustomizer implements OpenApiCustomizer { + + private static final String ISO_LOCAL_DATE_TIME_PATTERN = + "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,9})?$"; + private static final String SPACE_LOCAL_DATE_TIME_PATTERN = + "^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$"; + + private static final List NULLABLE_REFERENCES = List.of( + new NullableReference("ChatRoomResponse", "counterpart"), + new NullableReference("ApplicationCreatedResponse", "role"), + new NullableReference("ApplicantDetail", "role"), + new NullableReference("ApplicantSummary", "role"), + new NullableReference("MyApplication", "role"), + new NullableReference("RecruitmentDetail", "apply_block_reason"), + new NullableReference("RecruitmentDetail", "application") + ); + + @Override + public void customise(OpenAPI openApi) { + if (openApi.getComponents() == null || openApi.getComponents().getSchemas() == null) { + return; + } + Map schemas = openApi.getComponents().getSchemas(); + NULLABLE_REFERENCES.forEach(reference -> makeNullable(schemas, reference)); + configureDateTime( + schemas, + "TeamRecruitmentNotificationResponse", + "created_at", + ISO_LOCAL_DATE_TIME_PATTERN + ); + configureDateTime(schemas, "ApplicationCreatedResponse", "created_at", SPACE_LOCAL_DATE_TIME_PATTERN); + configureDateTime(schemas, "RecruitmentDetail", "created_at", SPACE_LOCAL_DATE_TIME_PATTERN); + configureChatMessageTimestamp(openApi, schemas); + } + + private void makeNullable(Map schemas, NullableReference reference) { + Schema property = propertyOf(schemas, reference.schemaName(), reference.propertyName()); + if (property == null) { + return; + } + Schema source = property; + if (property.get$ref() != null && !property.get$ref().isBlank()) { + String schemaName = property.get$ref().substring(property.get$ref().lastIndexOf('/') + 1); + source = schemas.get(schemaName); + } + if (source == null) { + return; + } + Schema nullableSchema = Json.mapper().convertValue(source, Schema.class); + nullableSchema.setNullable(true); + if (property.getDescription() != null) { + nullableSchema.setDescription(property.getDescription()); + } + Schema owner = schemas.get(reference.schemaName()); + owner.addProperty(reference.propertyName(), nullableSchema); + } + + private void configureChatMessageTimestamp(OpenAPI openApi, Map schemas) { + if (openApi.getPaths() == null) { + return; + } + PathItem path = openApi.getPaths() + .get("/chatroom/team-recruitment/{recruitmentId}/{chatRoomId}/messages"); + if (path == null || path.getPost() == null) { + return; + } + ApiResponse ok = path.getPost().getResponses().get("200"); + if (ok == null || ok.getContent() == null || ok.getContent().isEmpty()) { + return; + } + MediaType mediaType = ok.getContent().get("application/json"); + if (mediaType == null) { + mediaType = ok.getContent().values().iterator().next(); + } + Schema response = mediaType.getSchema(); + if (response == null || response.get$ref() == null) { + return; + } + String schemaName = response.get$ref().substring(response.get$ref().lastIndexOf('/') + 1); + configureDateTime(schemas, schemaName, "timestamp", ISO_LOCAL_DATE_TIME_PATTERN); + } + + private void configureDateTime( + Map schemas, + String schemaName, + String propertyName, + String pattern + ) { + Schema dateTime = propertyOf(schemas, schemaName, propertyName); + if (dateTime == null) { + return; + } + dateTime.setFormat(null); + dateTime.setPattern(pattern); + } + + private Schema propertyOf(Map schemas, String schemaName, String propertyName) { + Schema schema = schemas.get(schemaName); + return schema == null || schema.getProperties() == null + ? null + : (Schema)schema.getProperties().get(propertyName); + } + + private record NullableReference(String schemaName, String propertyName) { + } +} diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentOpenApiContractTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentOpenApiContractTest.java new file mode 100644 index 0000000000..13cb37dc8f --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentOpenApiContractTest.java @@ -0,0 +1,309 @@ +package in.koreatech.koin.acceptance.domain; + +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentApplicationStatus.PENDING; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.net.URI; +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import in.koreatech.koin.acceptance.AcceptanceTest; +import in.koreatech.koin.domain.team.recruitment.dto.ApplicationCreatedResponse; +import in.koreatech.koin.domain.teamrecruitment.dto.ChatMessageResponse; +import in.koreatech.koin.domain.teamrecruitment.dto.ChatRoomResponse; +import in.koreatech.koin.domain.teamrecruitment.dto.TeamRecruitmentNotificationResponse; + +class TeamRecruitmentOpenApiContractTest extends AcceptanceTest { + + private static final String ISO_LOCAL_DATE_TIME_PATTERN = + "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,9})?$"; + private static final String SPACE_LOCAL_DATE_TIME_PATTERN = + "^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$"; + + private static final String APPLY_BLOCK_REASON_PRIORITY = "LOGIN_REQUIRED > OWN_RECRUITMENT > " + + "ALREADY_APPLIED > RECRUITMENT_CLOSED > DEADLINE_PASSED > ROLE_CLOSED > PROFILE_REQUIRED"; + + private static final Set HTTP_METHODS = Set.of("get", "post", "put", "patch", "delete"); + + private static final Set TEAM_RECRUITMENT_OPERATIONS = Set.of( + "GET /team-recruitments", + "POST /team-recruitments", + "GET /team-recruitments/{recruitmentId}", + "PUT /team-recruitments/{recruitmentId}", + "DELETE /team-recruitments/{recruitmentId}", + "PUT /team-recruitments/{recruitmentId}/close", + "GET /team-recruitments/me/created", + "POST /team-recruitments/{recruitmentId}/applications", + "GET /team-recruitments/me/applications", + "GET /team-recruitments/{recruitmentId}/applications", + "GET /team-recruitments/{recruitmentId}/applications/{applicationId}", + "PUT /team-recruitments/{recruitmentId}/applications/{applicationId}/status", + "GET /team-recruitment-profiles/me", + "PUT /team-recruitment-profiles/me", + "GET /chatroom/team-recruitment/{recruitmentId}/{chatRoomId}", + "POST /chatroom/team-recruitment/{recruitmentId}/applications/{applicationId}/direct", + "GET /chatroom/team-recruitment/{recruitmentId}/{chatRoomId}/messages", + "POST /chatroom/team-recruitment/{recruitmentId}/{chatRoomId}/messages", + "GET /team-recruitments/notifications", + "POST /team-recruitments/notifications/{notificationId}/read", + "POST /team-recruitments/notifications/mark-all-read", + "DELETE /team-recruitments/notifications/{notificationId}", + "DELETE /team-recruitments/notifications" + ); + + @Autowired + private ObjectMapper objectMapper; + + @Test + void teamRecruitmentContractMatchesRuntime() throws Exception { + JsonNode openApi = openApi("/v3/api-docs/3.%20Campus%20API"); + + assertThat(openApi.path("openapi").asText()).isEqualTo("3.0.3"); + assertThat(teamRecruitmentOperations(openApi)).containsExactlyInAnyOrderElementsOf( + TEAM_RECRUITMENT_OPERATIONS + ); + + JsonNode chatRoom = schema(openApi, "ChatRoomResponse"); + assertRequired(chatRoom, "chat_room_id", "room_name", "room_type", "status", "member_count", + "max_member_count", "counterpart"); + assertNullable(chatRoom, "counterpart"); + assertInlineObject(chatRoom, "counterpart", "id", "nickname"); + + assertRequiredNullable(schema(openApi, "ApplicationCreatedResponse"), "role"); + assertInlineObject(schema(openApi, "ApplicationCreatedResponse"), "role", "id", "name"); + assertRequiredNullable(schema(openApi, "ApplicantDetail"), "role"); + assertRequiredNullable(schema(openApi, "ApplicantSummary"), "role"); + assertRequiredNullable(schema(openApi, "MyApplication"), "role"); + + JsonNode directChatRoom = schema(openApi, "DirectChatRoomResponse"); + assertRequired(directChatRoom, "chat_room_id", "room_name", "room_type", "status", "counterpart"); + assertNonNullableObject(openApi, directChatRoom, "counterpart", "id", "nickname"); + + JsonNode chatMessage = responseSchema(openApi, + "/chatroom/team-recruitment/{recruitmentId}/{chatRoomId}/messages", "post", "200"); + assertRequired(chatMessage, "message_id", "user_id", "user_nickname", "content", "timestamp", + "is_image", "unread_count"); + assertRequired(schema(openApi, "CreateChatMessageRequest"), "content", "is_image"); + + JsonNode notification = schema(openApi, "TeamRecruitmentNotificationResponse"); + assertRequired(notification, "id", "type", "target_type", "recruitment_id", "application_id", + "chat_room_id", "sender_nickname", "message_preview", "is_read", "created_at"); + assertNullable(notification, "application_id"); + assertNullable(notification, "chat_room_id"); + assertNullable(notification, "sender_nickname"); + assertDateTime(notification, "created_at", ISO_LOCAL_DATE_TIME_PATTERN); + assertRequired(schema(openApi, "TeamRecruitmentNotificationsResponse"), "notifications", "unread_count", + "total_count", "current_count", "total_page", "current_page"); + + assertDateTime(chatMessage, "timestamp", ISO_LOCAL_DATE_TIME_PATTERN); + assertDateTime(schema(openApi, "ApplicationCreatedResponse"), "created_at", + SPACE_LOCAL_DATE_TIME_PATTERN); + assertDateTime(schema(openApi, "RecruitmentDetail"), "created_at", + SPACE_LOCAL_DATE_TIME_PATTERN); + assertRequiredNullable(schema(openApi, "RecruitmentCard"), "d_day"); + assertRequiredNullable(schema(openApi, "RecruitmentDetail"), "d_day"); + assertRequiredNullable(schema(openApi, "CreatedRecruitment"), "d_day"); + assertRequiredNullable(schema(openApi, "ApplicantRecruitment"), "d_day"); + + assertQueryParameterNames(openApi); + + String blockReasonDescription = schema(openApi, "RecruitmentDetail") + .path("properties") + .path("apply_block_reason") + .path("description") + .asText(); + assertThat(blockReasonDescription).contains(APPLY_BLOCK_REASON_PRIORITY); + assertInlineEnum(schema(openApi, "RecruitmentDetail"), "apply_block_reason", + "RECRUITMENT_DELETED", "LOGIN_REQUIRED", "OWN_RECRUITMENT", "ALREADY_APPLIED", + "RECRUITMENT_CLOSED", "DEADLINE_PASSED", "ROLE_CLOSED", "PROFILE_REQUIRED"); + assertRequiredNullable(schema(openApi, "RecruitmentDetail"), "application"); + assertInlineObject(schema(openApi, "RecruitmentDetail"), "application", "application_id", "status"); + } + + @Test + void teamRecruitmentCustomizerIsLimitedToCampusGroup() throws Exception { + JsonNode defaultOpenApi = openApi("/v3/api-docs"); + JsonNode counterpart = schema(defaultOpenApi, "ChatRoomResponse") + .path("properties") + .path("counterpart"); + + assertThat(counterpart.path("$ref").asText()).startsWith("#/components/schemas/"); + assertThat(counterpart.has("allOf")).isFalse(); + } + + @Test + void teamRecruitmentDtosMatchRuntimeJson() { + LocalDateTime timestamp = LocalDateTime.of(2026, 8, 26, 11, 20, 30, 123_456_000); + + JsonNode application = objectMapper.valueToTree( + new ApplicationCreatedResponse(51, 17, PENDING, null, timestamp)); + JsonNode chatRoom = objectMapper.valueToTree( + new ChatRoomResponse(31, "팀 채팅방", "TEAM", "ACTIVE", 1, 2, null)); + JsonNode notification = objectMapper.valueToTree(new TeamRecruitmentNotificationResponse( + 101, "NEW_APPLICATION", "APPLICANT_MANAGEMENT", 17, null, null, null, + "새로운 지원자가 있어요.", false, timestamp)); + JsonNode message = objectMapper.valueToTree( + new ChatMessageResponse(901, 22, "김철수", "안녕하세요!", timestamp, false, 0)); + + assertThat(application.path("role").isNull()).isTrue(); + assertThat(application.path("created_at").asText()).isEqualTo("2026-08-26 11:20:30"); + assertThat(chatRoom.path("counterpart").isNull()).isTrue(); + assertThat(notification.path("application_id").isNull()).isTrue(); + assertThat(notification.path("chat_room_id").isNull()).isTrue(); + assertThat(notification.path("sender_nickname").isNull()).isTrue(); + assertThat(notification.path("created_at").asText()).isEqualTo("2026-08-26T11:20:30.123456"); + assertThat(message.path("timestamp").asText()).isEqualTo("2026-08-26T11:20:30.123456"); + } + + private JsonNode openApi(String path) throws Exception { + String document = mockMvc.perform(get(URI.create(path))) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + return objectMapper.readTree(document); + } + + private Set teamRecruitmentOperations(JsonNode openApi) { + Set operations = new HashSet<>(); + Iterator> paths = openApi.path("paths").fields(); + while (paths.hasNext()) { + Map.Entry path = paths.next(); + if (!isTeamRecruitmentPath(path.getKey())) { + continue; + } + path.getValue().fieldNames().forEachRemaining(method -> { + if (HTTP_METHODS.contains(method)) { + operations.add(method.toUpperCase() + " " + path.getKey()); + } + }); + } + return operations; + } + + private boolean isTeamRecruitmentPath(String path) { + return path.startsWith("/team-recruitments") + || path.startsWith("/team-recruitment-profiles") + || path.startsWith("/chatroom/team-recruitment"); + } + + private JsonNode schema(JsonNode openApi, String name) { + JsonNode schema = openApi.path("components").path("schemas").path(name); + assertThat(schema.isMissingNode()) + .as("OpenAPI component schema %s", name) + .isFalse(); + return schema; + } + + private JsonNode responseSchema(JsonNode openApi, String path, String method, String status) { + JsonNode content = openApi.path("paths") + .path(path) + .path(method) + .path("responses") + .path(status) + .path("content"); + JsonNode mediaType = content.path("application/json"); + if (mediaType.isMissingNode()) { + mediaType = content.elements().next(); + } + JsonNode responseSchema = mediaType.path("schema"); + String reference = responseSchema.path("$ref").asText(); + assertThat(reference) + .as("response schema: %s", responseSchema) + .startsWith("#/components/schemas/"); + return schema(openApi, reference.substring(reference.lastIndexOf('/') + 1)); + } + + private void assertRequiredNullable(JsonNode schema, String property) { + assertRequired(schema, property); + assertNullable(schema, property); + } + + private void assertRequired(JsonNode schema, String... properties) { + assertThat(schema.path("required")) + .extracting(JsonNode::asText) + .contains(properties); + } + + private void assertNullable(JsonNode schema, String property) { + JsonNode propertySchema = schema.path("properties").path(property); + assertThat(propertySchema.path("nullable").asBoolean()) + .as("%s must be nullable: %s", property, propertySchema) + .isTrue(); + assertThat(propertySchema.path("type").asText()) + .as("%s must declare its type next to nullable: %s", property, propertySchema) + .isNotBlank(); + assertThat(propertySchema.has("$ref")).isFalse(); + assertThat(propertySchema.has("allOf")).isFalse(); + } + + private void assertInlineObject(JsonNode schema, String property, String... requiredProperties) { + JsonNode propertySchema = schema.path("properties").path(property); + assertThat(propertySchema.path("type").asText()).isEqualTo("object"); + assertThat(propertySchema.path("properties").fieldNames()) + .toIterable() + .contains(requiredProperties); + assertRequired(propertySchema, requiredProperties); + } + + private void assertNonNullableObject( + JsonNode openApi, + JsonNode schema, + String property, + String... requiredProperties + ) { + JsonNode propertySchema = schema.path("properties").path(property); + String reference = propertySchema.path("$ref").asText(); + JsonNode effectiveSchema = reference.isBlank() + ? propertySchema + : openApi.at(reference.substring(1)); + + assertThat(propertySchema.path("nullable").asBoolean()).isFalse(); + assertThat(effectiveSchema.path("nullable").asBoolean()).isFalse(); + assertThat(effectiveSchema.path("type").asText()).isEqualTo("object"); + assertThat(effectiveSchema.path("properties").fieldNames()) + .toIterable() + .contains(requiredProperties); + assertRequired(effectiveSchema, requiredProperties); + } + + private void assertInlineEnum(JsonNode schema, String property, String... values) { + JsonNode propertySchema = schema.path("properties").path(property); + assertThat(propertySchema.path("type").asText()).isEqualTo("string"); + assertThat(propertySchema.path("enum")) + .extracting(JsonNode::asText) + .containsExactly(values); + } + + private void assertQueryParameterNames(JsonNode openApi) { + JsonNode parameters = openApi.path("paths") + .path("/chatroom/team-recruitment/{recruitmentId}/{chatRoomId}/messages") + .path("get") + .path("parameters"); + Set queryNames = new HashSet<>(); + parameters.elements().forEachRemaining(parameter -> { + if ("query".equals(parameter.path("in").asText())) { + queryNames.add(parameter.path("name").asText()); + } + }); + assertThat(queryNames).containsExactlyInAnyOrder("afterMessageId", "beforeMessageId", "limit"); + } + + private void assertDateTime(JsonNode schema, String property, String pattern) { + JsonNode dateTime = schema.path("properties").path(property); + assertThat(dateTime.path("type").asText()).as("date-time schema: %s", dateTime).isEqualTo("string"); + assertThat(dateTime.path("format").isMissingNode()).as("date-time schema: %s", dateTime).isTrue(); + assertThat(dateTime.path("pattern").asText()).as("date-time schema: %s", dateTime).isEqualTo(pattern); + } +} diff --git a/src/test/java/in/koreatech/koin/unit/global/config/TeamRecruitmentOpenApiCustomizerTest.java b/src/test/java/in/koreatech/koin/unit/global/config/TeamRecruitmentOpenApiCustomizerTest.java new file mode 100644 index 0000000000..71a88dae65 --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/global/config/TeamRecruitmentOpenApiCustomizerTest.java @@ -0,0 +1,40 @@ +package in.koreatech.koin.unit.global.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import in.koreatech.koin.global.config.TeamRecruitmentOpenApiCustomizer; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.media.IntegerSchema; +import io.swagger.v3.oas.models.media.ObjectSchema; +import io.swagger.v3.oas.models.media.Schema; + +class TeamRecruitmentOpenApiCustomizerTest { + + @Test + void nullablePropertyIsInlinedWithoutMutatingItsSharedComponent() { + ObjectSchema component = new ObjectSchema(); + component.addProperty("id", new IntegerSchema()); + component.setRequired(List.of("id")); + component.setNullable(true); + ObjectSchema response = new ObjectSchema(); + response.addProperty("role", new Schema<>().$ref("#/components/schemas/ApplicationRole")); + Components components = new Components() + .addSchemas("ApplicationRole", component) + .addSchemas("ApplicationCreatedResponse", response); + + new TeamRecruitmentOpenApiCustomizer().customise(new OpenAPI().components(components)); + + Schema inline = (Schema)response.getProperties().get("role"); + assertThat(inline).isNotSameAs(component); + assertThat(inline.getType()).isEqualTo("object"); + assertThat(inline.getProperties()).containsKey("id").isNotSameAs(component.getProperties()); + assertThat(inline.getRequired()).containsExactly("id").isNotSameAs(component.getRequired()); + assertThat(inline.getNullable()).isTrue(); + assertThat(component.getNullable()).isTrue(); + } +} From 814d4570ba951267a64a958feb1a03627bc30023 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: Thu, 3 Sep 2026 01:32:25 +0900 Subject: [PATCH 13/32] test: add team recruitment chat HTTP acceptance tests (#2392) --- .../domain/TeamRecruitmentChatApiTest.java | 434 ++++++++++++++++++ 1 file changed, 434 insertions(+) create mode 100644 src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentChatApiTest.java diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentChatApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentChatApiTest.java new file mode 100644 index 0000000000..361553bea2 --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentChatApiTest.java @@ -0,0 +1,434 @@ +package in.koreatech.koin.acceptance.domain; + +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.TEAM; +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentMeetingType.ONLINE; +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentNotificationTargetType.CHAT_ROOM; +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentNotificationType.NEW_CHAT_MESSAGE; +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentOutboxEventStatus.PENDING; +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 static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDate; +import java.util.List; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MvcResult; + +import in.koreatech.koin.acceptance.AcceptanceTest; +import in.koreatech.koin.acceptance.fixture.DepartmentAcceptanceFixture; +import in.koreatech.koin.acceptance.fixture.UserAcceptanceFixture; +import in.koreatech.koin.domain.student.model.Department; +import in.koreatech.koin.domain.student.model.Student; +import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitment; +import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentChatMember; +import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentChatMessage; +import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentChatRoom; +import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentNotification; +import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentOutboxEvent; +import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentChatMemberRepository; +import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentChatMessageRepository; +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.user.model.User; +import in.koreatech.koin.domain.user.model.UserType; +import in.koreatech.koin.domain.user.repository.UserRepository; + +class TeamRecruitmentChatApiTest extends AcceptanceTest { + + @Autowired + private UserAcceptanceFixture userFixture; + + @Autowired + private DepartmentAcceptanceFixture departmentFixture; + + @Autowired + private TeamRecruitmentRepository recruitmentRepository; + + @Autowired + private TeamRecruitmentChatRoomRepository chatRoomRepository; + + @Autowired + private TeamRecruitmentChatMemberRepository chatMemberRepository; + + @Autowired + private TeamRecruitmentChatMessageRepository chatMessageRepository; + + @Autowired + private TeamRecruitmentNotificationRepository notificationRepository; + + @Autowired + private TeamRecruitmentOutboxEventRepository outboxEventRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ObjectMapper objectMapper; + + private Student author; + private Student applicant; + private User outsider; + private String authorToken; + private String applicantToken; + private String outsiderToken; + + @BeforeEach + void setUp() { + clear(); + Department department = departmentFixture.컴퓨터공학부(); + author = userFixture.준호_학생(department, null); + applicant = userFixture.성빈_학생(department); + outsider = userRepository.save(User.builder() + .name("테스트용_외부") + .nickname("외부조회자") + .phoneNumber("01000000099") + .email("outsider@koreatech.ac.kr") + .loginId("outsider") + .loginPw("1234") + .userType(UserType.STUDENT) + .isAuthed(true) + .isDeleted(false) + .build()); + authorToken = userFixture.getToken(author.getUser()); + applicantToken = userFixture.getToken(applicant.getUser()); + outsiderToken = userFixture.getToken(outsider); + } + + @Test + @DisplayName("TEAM 채팅방은 실제 HTTP에서 멤버만 조회할 수 있다") + void teamChatRoomRequiresMembership() throws Exception { + TeamRecruitment recruitment = saveRecruitment("TEAM 채팅 HTTP"); + TeamRecruitmentChatRoom room = saveTeamRoom(recruitment, ACTIVE); + + mockMvc.perform(get("/chatroom/team-recruitment/{recruitmentId}/{chatRoomId}", recruitment.getId(), room.getId()) + .header("Authorization", "Bearer " + applicantToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.chat_room_id").value(room.getId())) + .andExpect(jsonPath("$.room_name").value(recruitment.getTitle())) + .andExpect(jsonPath("$.room_type").value("TEAM")) + .andExpect(jsonPath("$.status").value("ACTIVE")) + .andExpect(jsonPath("$.member_count").value(2)); + + mockMvc.perform(get("/chatroom/team-recruitment/{recruitmentId}/{chatRoomId}", recruitment.getId(), room.getId())) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("UNAUTHORIZED_USER")); + + mockMvc.perform(get("/chatroom/team-recruitment/{recruitmentId}/{chatRoomId}", recruitment.getId(), room.getId()) + .header("Authorization", "Bearer " + outsiderToken)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("TEAM_RECRUITMENT_CHAT_FORBIDDEN")); + + mockMvc.perform(get("/chatroom/team-recruitment/{recruitmentId}/{chatRoomId}", recruitment.getId() + 1, room.getId()) + .header("Authorization", "Bearer " + applicantToken)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("TEAM_RECRUITMENT_CHAT_NOT_FOUND")); + } + + @Test + @DisplayName("TEAM 채팅은 메시지 polling cursor와 unread read를 실제 HTTP로 반영하고 알림 outbox를 만든다") + void teamChatMessagePollingAndNotificationOutbox() throws Exception { + TeamRecruitment recruitment = saveRecruitment("메시지 polling"); + TeamRecruitmentChatRoom room = saveTeamRoom(recruitment, ACTIVE); + entityManager.flush(); + + MvcResult firstMessageResult = sendMessage(room, recruitment, authorToken, "첫 번째 메시지") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message_id").isNumber()) + .andExpect(jsonPath("$.user_nickname").value(author.getUser().getNickname())) + .andExpect(jsonPath("$.content").value("첫 번째 메시지")) + .andExpect(jsonPath("$.is_image").value(false)) + .andExpect(jsonPath("$.unread_count").value(1)) + .andReturn(); + + entityManager.flush(); + TeamRecruitmentChatMessage firstMessage = chatMessageRepository + .findTopByChatRoom_IdOrderByIdDesc(room.getId()) + .orElseThrow(); + Integer firstMessageId = firstMessage.getId(); + assertThat(firstMessageResult.getResponse().getContentAsString()).contains("\"message_id\":" + firstMessageId); + + mockMvc.perform(get("/chatroom/team-recruitment/{recruitmentId}/{chatRoomId}/messages", + recruitment.getId(), room.getId()) + .header("Authorization", "Bearer " + applicantToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].message_id").value(firstMessageId)) + .andExpect(jsonPath("$[0].content").value("첫 번째 메시지")) + .andExpect(jsonPath("$[0].unread_count").value(0)); + + assertThat(chatMemberRepository.findByChatRoom_IdAndUser_Id(room.getId(), applicant.getUser().getId()) + .orElseThrow().getLastReadMessageId()).isEqualTo(firstMessageId); + + MvcResult secondMessageResult = sendMessage(room, recruitment, applicantToken, "두 번째 메시지") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").value("두 번째 메시지")) + .andExpect(jsonPath("$.unread_count").value(1)) + .andReturn(); + entityManager.flush(); + Integer secondMessageId = chatMessageRepository + .findTopByChatRoom_IdOrderByIdDesc(room.getId()) + .orElseThrow().getId(); + assertThat(secondMessageResult.getResponse().getContentAsString()).contains("\"message_id\":" + secondMessageId); + + MvcResult thirdMessageResult = sendMessage(room, recruitment, authorToken, "세 번째 메시지") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").value("세 번째 메시지")) + .andExpect(jsonPath("$.unread_count").value(1)) + .andReturn(); + entityManager.flush(); + Integer thirdMessageId = chatMessageRepository + .findTopByChatRoom_IdOrderByIdDesc(room.getId()) + .orElseThrow().getId(); + assertThat(thirdMessageResult.getResponse().getContentAsString()).contains("\"message_id\":" + thirdMessageId); + + MvcResult fourthMessageResult = sendMessage(room, recruitment, authorToken, "네 번째 메시지") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").value("네 번째 메시지")) + .andExpect(jsonPath("$.unread_count").value(1)) + .andReturn(); + entityManager.flush(); + Integer fourthMessageId = chatMessageRepository + .findTopByChatRoom_IdOrderByIdDesc(room.getId()) + .orElseThrow().getId(); + assertThat(fourthMessageResult.getResponse().getContentAsString()).contains("\"message_id\":" + fourthMessageId); + + List applicantNotifications = + notificationRepository.findAllByRecipient_IdAndIsDeletedFalse(applicant.getUser().getId()); + assertThat(applicantNotifications) + .hasSize(3) + .allSatisfy(notification -> { + assertThat(notification.getRecipient().getId()).isEqualTo(applicant.getUser().getId()); + assertThat(notification.getType()).isEqualTo(NEW_CHAT_MESSAGE); + assertThat(notification.getTargetType()).isEqualTo(CHAT_ROOM); + assertThat(notification.getRecruitment().getId()).isEqualTo(recruitment.getId()); + assertThat(notification.getChatRoom().getId()).isEqualTo(room.getId()); + }) + .extracting(TeamRecruitmentNotification::getMessagePreview) + .containsExactlyInAnyOrder("첫 번째 메시지", "세 번째 메시지", "네 번째 메시지"); + assertThat(notificationRepository.findAllByRecipient_IdAndIsDeletedFalse(author.getUser().getId())) + .singleElement() + .satisfies(notification -> { + assertThat(notification.getRecipient().getId()).isEqualTo(author.getUser().getId()); + assertThat(notification.getType()).isEqualTo(NEW_CHAT_MESSAGE); + assertThat(notification.getTargetType()).isEqualTo(CHAT_ROOM); + assertThat(notification.getRecruitment().getId()).isEqualTo(recruitment.getId()); + assertThat(notification.getChatRoom().getId()).isEqualTo(room.getId()); + assertThat(notification.getMessagePreview()).isEqualTo("두 번째 메시지"); + }); + assertThat(entityManager.createQuery("select count(notification) from TeamRecruitmentNotification notification", + Long.class).getSingleResult()).isEqualTo(4L); + + List outboxEvents = outboxEventRepository.findAllByStatusOrderByIdAsc(PENDING); + assertThat(entityManager.createQuery("select count(outbox) from TeamRecruitmentOutboxEvent outbox", + Long.class).getSingleResult()).isEqualTo(4L); + assertThat(outboxEvents) + .hasSize(4) + .extracting(TeamRecruitmentOutboxEvent::getEventKey) + .containsExactlyInAnyOrder( + chatMessageEventKey(firstMessageId, applicant.getUser().getId()), + chatMessageEventKey(secondMessageId, author.getUser().getId()), + chatMessageEventKey(thirdMessageId, applicant.getUser().getId()), + chatMessageEventKey(fourthMessageId, applicant.getUser().getId()) + ); + assertChatNotificationOutbox( + firstMessageId, applicant.getUser().getId(), author.getUser().getNickname(), + "첫 번째 메시지", recruitment, room + ); + assertChatNotificationOutbox( + secondMessageId, author.getUser().getId(), applicant.getUser().getNickname(), + "두 번째 메시지", recruitment, room + ); + assertChatNotificationOutbox( + thirdMessageId, applicant.getUser().getId(), author.getUser().getNickname(), + "세 번째 메시지", recruitment, room + ); + assertChatNotificationOutbox( + fourthMessageId, applicant.getUser().getId(), author.getUser().getNickname(), + "네 번째 메시지", recruitment, room + ); + + mockMvc.perform(get("/chatroom/team-recruitment/{recruitmentId}/{chatRoomId}/messages", + recruitment.getId(), room.getId()) + .param("afterMessageId", firstMessageId.toString()) + .param("limit", "2") + .header("Authorization", "Bearer " + applicantToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0].message_id").value(secondMessageId)) + .andExpect(jsonPath("$[1].message_id").value(thirdMessageId)); + assertThat(chatMemberRepository.findByChatRoom_IdAndUser_Id(room.getId(), applicant.getUser().getId()) + .orElseThrow().getLastReadMessageId()).isEqualTo(thirdMessageId); + + mockMvc.perform(get("/chatroom/team-recruitment/{recruitmentId}/{chatRoomId}/messages", + recruitment.getId(), room.getId()) + .param("beforeMessageId", fourthMessageId.toString()) + .param("limit", "2") + .header("Authorization", "Bearer " + applicantToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0].message_id").value(secondMessageId)) + .andExpect(jsonPath("$[1].message_id").value(thirdMessageId)); + assertThat(chatMemberRepository.findByChatRoom_IdAndUser_Id(room.getId(), applicant.getUser().getId()) + .orElseThrow().getLastReadMessageId()).isEqualTo(thirdMessageId); + + mockMvc.perform(get("/chatroom/team-recruitment/{recruitmentId}/{chatRoomId}/messages", + recruitment.getId(), room.getId()) + .param("afterMessageId", firstMessageId.toString()) + .param("beforeMessageId", fourthMessageId.toString()) + .header("Authorization", "Bearer " + applicantToken)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("ILLEGAL_ARGUMENT")); + + mockMvc.perform(get("/chatroom/team-recruitment/{recruitmentId}/{chatRoomId}/messages", + recruitment.getId(), room.getId()) + .param("afterMessageId", thirdMessageId.toString()) + .param("limit", "2") + .header("Authorization", "Bearer " + applicantToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].message_id").value(fourthMessageId)); + assertThat(chatMemberRepository.findByChatRoom_IdAndUser_Id(room.getId(), applicant.getUser().getId()) + .orElseThrow().getLastReadMessageId()).isEqualTo(fourthMessageId); + } + + @Test + @DisplayName("READ_ONLY TEAM 채팅방은 메시지 전송만 차단하고 기존 메시지 조회는 허용한다") + void readOnlyTeamChatIsReadableButNotWritable() throws Exception { + TeamRecruitment recruitment = saveRecruitment("읽기 전용 채팅"); + TeamRecruitmentChatRoom room = saveTeamRoom(recruitment, READ_ONLY); + TeamRecruitmentChatMessage existingMessage = chatMessageRepository.save(TeamRecruitmentChatMessage.builder() + .chatRoom(room) + .sender(author.getUser()) + .senderNickname(author.getUser().getNickname()) + .content("마감 전 메시지") + .isImage(false) + .build()); + entityManager.flush(); + + sendMessage(room, recruitment, authorToken, "마감 후 메시지") + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("TEAM_RECRUITMENT_CHAT_READ_ONLY")); + + mockMvc.perform(get("/chatroom/team-recruitment/{recruitmentId}/{chatRoomId}/messages", + recruitment.getId(), room.getId()) + .header("Authorization", "Bearer " + applicantToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].message_id").value(existingMessage.getId())) + .andExpect(jsonPath("$[0].content").value("마감 전 메시지")); + } + + private void assertChatNotificationOutbox( + Integer messageId, + Integer recipientId, + String senderNickname, + String messagePreview, + TeamRecruitment recruitment, + TeamRecruitmentChatRoom room + ) throws Exception { + TeamRecruitmentOutboxEvent outbox = outboxEventRepository + .findByEventKey(chatMessageEventKey(messageId, recipientId)) + .orElseThrow(); + assertThat(outbox.getStatus()).isEqualTo(PENDING); + assertThat(outbox.getEventType()).isEqualTo("TEAM_RECRUITMENT_NOTIFICATION"); + assertThat(outbox.getAggregateType()).isEqualTo("TEAM_RECRUITMENT"); + assertThat(outbox.getAggregateId()).isEqualTo(recruitment.getId()); + + JsonNode payload = objectMapper.readTree(outbox.getPayload()); + assertThat(payload.get("type").asText()).isEqualTo("NEW_CHAT_MESSAGE"); + assertThat(payload.get("target_type").asText()).isEqualTo("CHAT_ROOM"); + assertThat(payload.get("recipient_id").asInt()).isEqualTo(recipientId); + assertThat(payload.get("recruitment_id").asInt()).isEqualTo(recruitment.getId()); + assertThat(payload.get("application_id").isNull()).isTrue(); + assertThat(payload.get("chat_room_id").asInt()).isEqualTo(room.getId()); + assertThat(payload.get("message_preview").asText()).isEqualTo(messagePreview); + + Integer notificationId = payload.get("notification_id").asInt(); + TeamRecruitmentNotification notification = notificationRepository.findById(notificationId).orElseThrow(); + assertThat(notification.getRecipient().getId()).isEqualTo(recipientId); + assertThat(notification.getType()).isEqualTo(NEW_CHAT_MESSAGE); + assertThat(notification.getTargetType()).isEqualTo(CHAT_ROOM); + assertThat(notification.getRecruitment().getId()).isEqualTo(recruitment.getId()); + assertThat(notification.getApplication()).isNull(); + assertThat(notification.getChatRoom().getId()).isEqualTo(room.getId()); + assertThat(notification.getSenderNickname()).isEqualTo(senderNickname); + assertThat(notification.getMessagePreview()).isEqualTo(messagePreview); + } + + private String chatMessageEventKey(Integer messageId, Integer recipientId) { + return "team-recruitment:chat-message:" + messageId + ":" + recipientId; + } + + private TeamRecruitment saveRecruitment(String title) { + return recruitmentRepository.save(TeamRecruitment.builder() + .author(author.getUser()) + .category(PROJECT) + .title(title) + .meetingType(ONLINE) + .activityStartDate(LocalDate.now(clock).plusDays(2)) + .activityEndDate(LocalDate.now(clock).plusDays(10)) + .deadlineDate(LocalDate.now(clock).plusDays(1)) + .recruitmentType(GENERAL) + .maxParticipants(3) + .currentParticipants(0) + .description("모집 내용") + .status(RECRUITING) + .build()); + } + + private TeamRecruitmentChatRoom saveTeamRoom( + TeamRecruitment recruitment, + in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentChatRoomStatus status + ) { + TeamRecruitmentChatRoom room = chatRoomRepository.save(TeamRecruitmentChatRoom.builder() + .recruitment(recruitment) + .roomScopeKey("TEAM") + .roomType(TEAM) + .status(status) + .build()); + chatMemberRepository.save(TeamRecruitmentChatMember.builder() + .chatRoom(room) + .user(author.getUser()) + .build()); + chatMemberRepository.save(TeamRecruitmentChatMember.builder() + .chatRoom(room) + .user(applicant.getUser()) + .build()); + return room; + } + + private org.springframework.test.web.servlet.ResultActions sendMessage( + TeamRecruitmentChatRoom room, + TeamRecruitment recruitment, + String token, + String content + ) throws Exception { + return mockMvc.perform(post("/chatroom/team-recruitment/{recruitmentId}/{chatRoomId}/messages", + recruitment.getId(), room.getId()) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "content": "%s", + "is_image": false + } + """.formatted(content))); + } + +} From f19c2541dd80785ce2b6758c65824a98beed4c6a 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: Thu, 3 Sep 2026 01:42:00 +0900 Subject: [PATCH 14/32] =?UTF-8?q?test:=20=ED=8C=80=EC=9B=90=20=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EC=A7=80=EC=9B=90=20=EB=8F=99=EC=8B=9C=EC=84=B1=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20(#2393)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...RecruitmentApplicationConcurrencyTest.java | 470 ++++++++++++++++++ 1 file changed, 470 insertions(+) create mode 100644 src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentApplicationConcurrencyTest.java diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentApplicationConcurrencyTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentApplicationConcurrencyTest.java new file mode 100644 index 0000000000..b76d8192e9 --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentApplicationConcurrencyTest.java @@ -0,0 +1,470 @@ +package in.koreatech.koin.acceptance.domain; + +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentApplicationStatus.PENDING; +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.TeamRecruitmentChatRoomType.TEAM; +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentMeetingType.ONLINE; +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentStatus.RECRUITING; +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentType.ROLE_BASED; +import static in.koreatech.koin.domain.user.model.UserIdentity.UNDERGRADUATE; +import static in.koreatech.koin.domain.user.model.UserType.STUDENT; +import static in.koreatech.koin.global.code.ApiResponseCode.TEAM_RECRUITMENT_APPLICATION_DUPLICATE; +import static in.koreatech.koin.global.code.ApiResponseCode.TEAM_RECRUITMENT_CLOSED; +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; +import java.util.List; +import java.util.concurrent.Callable; +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 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.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; + +import in.koreatech.koin.acceptance.AcceptanceTest; +import in.koreatech.koin.acceptance.fixture.DepartmentAcceptanceFixture; +import in.koreatech.koin.acceptance.fixture.UserAcceptanceFixture; +import in.koreatech.koin.domain.student.model.Department; +import in.koreatech.koin.domain.student.model.Student; +import in.koreatech.koin.domain.student.repository.StudentRepository; +import in.koreatech.koin.domain.team.recruitment.dto.CreateApplicationRequest; +import in.koreatech.koin.domain.team.recruitment.dto.UpdateApplicationStatusRequest; +import in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentApplicationStatus; +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.TeamRecruitmentChatMember; +import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentChatRoom; +import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentProfile; +import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentRole; +import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentApplicationRepository; +import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentChatMemberRepository; +import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentChatRoomRepository; +import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentProfileRepository; +import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentRepository; +import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentRoleRepository; +import in.koreatech.koin.domain.team.recruitment.service.TeamRecruitmentApplicationService; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.global.exception.CustomException; + +@Transactional(propagation = Propagation.NOT_SUPPORTED) +class TeamRecruitmentApplicationConcurrencyTest extends AcceptanceTest { + + private static final int CONCURRENCY = 2; + private static final long TIMEOUT_SECONDS = 10; + + @Autowired + private UserAcceptanceFixture userFixture; + + @Autowired + private DepartmentAcceptanceFixture departmentFixture; + + @Autowired + private TeamRecruitmentApplicationService applicationService; + + @Autowired + private TeamRecruitmentRepository recruitmentRepository; + + @Autowired + private TeamRecruitmentRoleRepository roleRepository; + + @Autowired + private TeamRecruitmentProfileRepository profileRepository; + + @Autowired + private TeamRecruitmentApplicationRepository applicationRepository; + + @Autowired + private TeamRecruitmentChatRoomRepository chatRoomRepository; + + @Autowired + private TeamRecruitmentChatMemberRepository chatMemberRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private StudentRepository studentRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private PlatformTransactionManager transactionManager; + + private TransactionTemplate transactionTemplate; + private Integer recruitmentId; + private Integer roleId; + private Integer teamRoomId; + private Integer authorId; + private Integer firstApplicantId; + private Integer secondApplicantId; + + @BeforeEach + void setUp() { + clear(); + transactionTemplate = new TransactionTemplate(transactionManager); + transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + transactionTemplate.executeWithoutResult(status -> saveRoleBasedRecruitment()); + } + + @AfterEach + void tearDown() { + clear(); + } + + @Test + void 같은_지원자가_동시에_지원해도_지원서와_알림은_한_번만_생성된다() throws Exception { + List attempts = runConcurrently( + () -> applicationService.createApplication( + new CreateApplicationRequest(roleId, "첫 번째 동시 지원", "월수금 저녁"), + recruitmentId, + firstApplicantId + ), + () -> applicationService.createApplication( + new CreateApplicationRequest(roleId, "두 번째 동시 지원", "화목 저녁"), + recruitmentId, + firstApplicantId + ) + ); + + assertThat(attempts).filteredOn(Attempt::isSuccess).hasSize(1); + assertThat(attempts).filteredOn(attempt -> !attempt.isSuccess()) + .singleElement() + .satisfies(attempt -> assertThat(attempt.failure()) + .isInstanceOfSatisfying(CustomException.class, exception -> assertThat(exception.getErrorCode()) + .isEqualTo(TEAM_RECRUITMENT_APPLICATION_DUPLICATE))); + + transactionTemplate.executeWithoutResult(status -> { + assertThat(count("SELECT COUNT(*) FROM team_recruitment_application WHERE recruitment_id = ?", + recruitmentId)).isOne(); + assertThat(count(""" + SELECT COUNT(*) + FROM team_recruitment_application + WHERE recruitment_id = ? AND applicant_id = ? AND role_id = ? AND status = 'PENDING' + """, recruitmentId, firstApplicantId, roleId)).isOne(); + Integer applicationId = jdbcTemplate.queryForObject(""" + SELECT id + FROM team_recruitment_application + WHERE recruitment_id = ? AND applicant_id = ? + """, Integer.class, recruitmentId, firstApplicantId); + assertThat(count(""" + SELECT COUNT(*) + FROM team_recruitment_notification + WHERE recruitment_id = ? AND recipient_id = ? AND application_id = ? + AND type = 'NEW_APPLICATION' AND target_type = 'APPLICANT_MANAGEMENT' + """, recruitmentId, authorId, applicationId)).isOne(); + assertThat(countLinkedNotificationOutbox()).isOne(); + assertThat(count("SELECT COUNT(*) FROM team_recruitment_chat_member WHERE chat_room_id = ?", + teamRoomId)).isOne(); + }); + } + + @Test + void 마지막_한_자리를_동시에_승인해도_한_명만_승인되고_중복_후속_작업이_생기지_않는다() throws Exception { + ApplicationIds applications = transactionTemplate.execute(status -> savePendingApplications()); + + List attempts = runConcurrently( + () -> { + applicationService.updateApplicationStatus( + new UpdateApplicationStatusRequest(TeamRecruitmentApplicationStatus.ACCEPTED), + recruitmentId, + applications.first(), + authorId + ); + return null; + }, + () -> { + applicationService.updateApplicationStatus( + new UpdateApplicationStatusRequest(TeamRecruitmentApplicationStatus.ACCEPTED), + recruitmentId, + applications.second(), + authorId + ); + return null; + } + ); + + assertThat(attempts).filteredOn(Attempt::isSuccess).hasSize(1); + assertThat(attempts).filteredOn(attempt -> !attempt.isSuccess()) + .singleElement() + .satisfies(attempt -> assertThat(attempt.failure()) + .isInstanceOfSatisfying(CustomException.class, exception -> assertThat(exception.getErrorCode()) + .isEqualTo(TEAM_RECRUITMENT_CLOSED))); + + transactionTemplate.executeWithoutResult(status -> { + assertThat(jdbcTemplate.queryForList(""" + SELECT status + FROM team_recruitment_application + WHERE recruitment_id = ? + """, String.class, recruitmentId)) + .containsExactlyInAnyOrder("ACCEPTED", "REJECTED"); + assertThat(count(""" + SELECT COUNT(*) + FROM team_recruitment + WHERE id = ? AND status = 'CLOSED' AND current_participants = 1 AND max_participants = 1 + """, recruitmentId)).isOne(); + assertThat(count(""" + SELECT COUNT(*) + FROM team_recruitment_role + WHERE id = ? AND current_participants = 1 AND max_participants = 1 + """, roleId)).isOne(); + + Integer acceptedApplicantId = jdbcTemplate.queryForObject(""" + SELECT applicant_id + FROM team_recruitment_application + WHERE recruitment_id = ? AND status = 'ACCEPTED' + """, Integer.class, recruitmentId); + assertThat(jdbcTemplate.queryForList(""" + SELECT user_id + FROM team_recruitment_chat_member + WHERE chat_room_id = ? + """, Integer.class, teamRoomId)) + .containsExactlyInAnyOrder(authorId, acceptedApplicantId); + + assertThat(jdbcTemplate.queryForList(""" + SELECT type + FROM team_recruitment_notification + WHERE recruitment_id = ? + """, String.class, recruitmentId)) + .containsExactlyInAnyOrder( + "APPLICATION_ACCEPTED", + "APPLICATION_REJECTED", + "RECRUITMENT_CLOSED" + ); + assertThat(jdbcTemplate.queryForList(""" + SELECT SUBSTRING_INDEX(event_key, ':', -1) + FROM team_recruitment_outbox_event + WHERE aggregate_id = ? + """, String.class, recruitmentId)) + .containsExactlyInAnyOrder( + "APPLICATION_ACCEPTED", + "APPLICATION_REJECTED", + "RECRUITMENT_CLOSED" + ); + assertThat(countLinkedNotificationOutbox()).isEqualTo(3); + }); + } + + private void saveRoleBasedRecruitment() { + Department department = departmentFixture.컴퓨터공학부(); + Student author = userFixture.준호_학생(department, null); + Student firstApplicant = userFixture.성빈_학생(department); + Student secondApplicant = saveSecondApplicant(department); + saveProfile(firstApplicant, "첫 지원자"); + saveProfile(secondApplicant, "두 번째 지원자"); + + TeamRecruitment recruitment = recruitmentRepository.save(TeamRecruitment.builder() + .author(author.getUser()) + .category(PROJECT) + .title("동시성 검증 모집") + .meetingType(ONLINE) + .activityStartDate(LocalDate.now(clock).plusDays(2)) + .activityEndDate(LocalDate.now(clock).plusDays(10)) + .deadlineDate(LocalDate.now(clock).plusDays(1)) + .recruitmentType(ROLE_BASED) + .maxParticipants(1) + .currentParticipants(0) + .description("한 명을 모집합니다.") + .status(RECRUITING) + .build()); + TeamRecruitmentRole role = roleRepository.save(TeamRecruitmentRole.builder() + .recruitment(recruitment) + .name("백엔드") + .maxParticipants(1) + .currentParticipants(0) + .displayOrder(1) + .build()); + TeamRecruitmentChatRoom teamRoom = chatRoomRepository.save(TeamRecruitmentChatRoom.builder() + .recruitment(recruitment) + .roomScopeKey("TEAM") + .roomType(TEAM) + .status(ACTIVE) + .build()); + chatMemberRepository.save(TeamRecruitmentChatMember.builder() + .chatRoom(teamRoom) + .user(author.getUser()) + .build()); + + recruitmentId = recruitment.getId(); + roleId = role.getId(); + teamRoomId = teamRoom.getId(); + authorId = author.getUser().getId(); + firstApplicantId = firstApplicant.getUser().getId(); + secondApplicantId = secondApplicant.getUser().getId(); + } + + private void saveProfile(Student applicant, String nickname) { + profileRepository.save(TeamRecruitmentProfile.builder() + .user(applicant.getUser()) + .profileNickname(nickname) + .preferredRole("백엔드") + .selfIntroduction("동시성 테스트 지원자") + .build()); + } + + private Student saveSecondApplicant(Department department) { + return studentRepository.save(Student.builder() + .studentNumber("2023999999") + .department(department) + .userIdentity(UNDERGRADUATE) + .isGraduated(false) + .user(User.builder() + .loginPw("test-password") + .name("두 번째 동시성 지원자") + .anonymousNickname("익명_동시성지원자") + .userType(STUDENT) + .isAuthed(true) + .isDeleted(false) + .build()) + .build()); + } + + private ApplicationIds savePendingApplications() { + TeamRecruitment recruitment = recruitmentRepository.findById(recruitmentId).orElseThrow(); + TeamRecruitmentRole role = roleRepository.findById(roleId).orElseThrow(); + TeamRecruitmentApplication first = applicationRepository.save(pendingApplication( + recruitment, + role, + firstApplicantId, + "첫 지원서" + )); + TeamRecruitmentApplication second = applicationRepository.save(pendingApplication( + recruitment, + role, + secondApplicantId, + "두 번째 지원서" + )); + return new ApplicationIds(first.getId(), second.getId()); + } + + private TeamRecruitmentApplication pendingApplication( + TeamRecruitment recruitment, + TeamRecruitmentRole role, + Integer applicantId, + String motivation + ) { + return TeamRecruitmentApplication.builder() + .recruitment(recruitment) + .applicant(userRepository.getById(applicantId)) + .role(role) + .motivation(motivation) + .availability("평일 저녁") + .status(PENDING) + .profileSnapshot(""" + { + "nickname": "지원자", + "department": "컴퓨터공학부", + "student_year": 2023, + "preferred_role": "백엔드", + "skills": [], + "activities": [], + "self_introduction": "소개" + } + """) + .snapshotVersion(1) + .build(); + } + + private List runConcurrently(Callable first, Callable second) throws Exception { + ExecutorService executor = Executors.newFixedThreadPool(CONCURRENCY); + CountDownLatch ready = new CountDownLatch(CONCURRENCY); + CountDownLatch start = new CountDownLatch(1); + try { + Future firstFuture = executor.submit(awaitStart(ready, start, first)); + Future secondFuture = executor.submit(awaitStart(ready, start, second)); + assertThat(ready.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + return List.of( + firstFuture.get(TIMEOUT_SECONDS, TimeUnit.SECONDS), + secondFuture.get(TIMEOUT_SECONDS, TimeUnit.SECONDS) + ); + } finally { + start.countDown(); + executor.shutdownNow(); + } + } + + private Callable awaitStart(CountDownLatch ready, CountDownLatch start, Callable action) { + return () -> { + ready.countDown(); + if (!start.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + throw new IllegalStateException("동시 실행 시작 신호를 기다리다 시간 초과했습니다."); + } + try { + action.call(); + return Attempt.success(); + } catch (Throwable failure) { + return Attempt.failure(failure); + } + }; + } + + private long count(String sql, Object... arguments) { + Long result = jdbcTemplate.queryForObject(sql, Long.class, arguments); + return result == null ? 0 : result; + } + + private long countLinkedNotificationOutbox() { + return count(""" + SELECT COUNT(*) + FROM team_recruitment_notification notification + JOIN team_recruitment_outbox_event outbox + ON outbox.event_key = CONCAT( + 'team-recruitment:application:', notification.application_id, ':', notification.type + ) + WHERE notification.recruitment_id = ? + AND outbox.aggregate_id = notification.recruitment_id + AND outbox.event_type = 'TEAM_RECRUITMENT_NOTIFICATION' + AND outbox.aggregate_type = 'TEAM_RECRUITMENT' + AND JSON_UNQUOTE(JSON_EXTRACT(outbox.payload, '$.type')) = notification.type + AND JSON_UNQUOTE(JSON_EXTRACT(outbox.payload, '$.target_type')) = notification.target_type + AND CAST(JSON_UNQUOTE(JSON_EXTRACT(outbox.payload, '$.recipient_id')) AS UNSIGNED) + = notification.recipient_id + AND CAST(JSON_UNQUOTE(JSON_EXTRACT(outbox.payload, '$.recruitment_id')) AS UNSIGNED) + = notification.recruitment_id + AND CAST(JSON_UNQUOTE(JSON_EXTRACT(outbox.payload, '$.application_id')) AS UNSIGNED) + = notification.application_id + AND CAST(JSON_UNQUOTE(JSON_EXTRACT(outbox.payload, '$.notification_id')) AS UNSIGNED) + = notification.id + AND JSON_UNQUOTE(JSON_EXTRACT(outbox.payload, '$.message_preview')) + = notification.message_preview + AND ( + (notification.chat_room_id IS NULL + AND JSON_TYPE(JSON_EXTRACT(outbox.payload, '$.chat_room_id')) = 'NULL') + OR CAST(JSON_UNQUOTE(JSON_EXTRACT(outbox.payload, '$.chat_room_id')) AS UNSIGNED) + = notification.chat_room_id + ) + """, recruitmentId); + } + + private record ApplicationIds(Integer first, Integer second) { + } + + private record Attempt(Throwable failure) { + + static Attempt success() { + return new Attempt(null); + } + + static Attempt failure(Throwable failure) { + return new Attempt(failure); + } + + boolean isSuccess() { + return failure == null; + } + } +} From 67422e676241f02d25704f88ff235824e3583237 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: Thu, 3 Sep 2026 01:51:51 +0900 Subject: [PATCH 15/32] test: add team recruitment notification HTTP acceptance tests (#2405) --- .../TeamRecruitmentNotificationApiTest.java | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentNotificationApiTest.java diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentNotificationApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentNotificationApiTest.java new file mode 100644 index 0000000000..bc2f4357fe --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentNotificationApiTest.java @@ -0,0 +1,204 @@ +package in.koreatech.koin.acceptance.domain; + +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentCategory.PROJECT; +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.RECRUITING; +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentType.GENERAL; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.everyItem; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import in.koreatech.koin.acceptance.AcceptanceTest; +import in.koreatech.koin.acceptance.fixture.DepartmentAcceptanceFixture; +import in.koreatech.koin.acceptance.fixture.UserAcceptanceFixture; +import in.koreatech.koin.domain.student.model.Department; +import in.koreatech.koin.domain.student.model.Student; +import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitment; +import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentNotification; +import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentNotificationRepository; +import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentRepository; +import in.koreatech.koin.domain.user.model.User; + +class TeamRecruitmentNotificationApiTest extends AcceptanceTest { + + @Autowired + private UserAcceptanceFixture userFixture; + + @Autowired + private DepartmentAcceptanceFixture departmentFixture; + + @Autowired + private TeamRecruitmentRepository recruitmentRepository; + + @Autowired + private TeamRecruitmentNotificationRepository notificationRepository; + + private Student author; + private Student applicant; + private String authorToken; + private String applicantToken; + + @BeforeEach + void setUp() { + clear(); + Department department = departmentFixture.컴퓨터공학부(); + author = userFixture.준호_학생(department, null); + applicant = userFixture.성빈_학생(department); + authorToken = userFixture.getToken(author.getUser()); + applicantToken = userFixture.getToken(applicant.getUser()); + } + + @Test + @DisplayName("팀원 모집 알림 목록은 사용자별로 격리되고 페이지·전체 읽음·전체 삭제가 HTTP로 동작한다") + void notificationListIsolationPaginationAndBulkActions() throws Exception { + TeamRecruitment recruitment = saveRecruitment("알림 목록"); + TeamRecruitmentNotification first = saveNotification(recruitment, applicant.getUser(), "첫 알림"); + TeamRecruitmentNotification second = saveNotification(recruitment, applicant.getUser(), "둘째 알림"); + TeamRecruitmentNotification third = saveNotification(recruitment, applicant.getUser(), "셋째 알림"); + TeamRecruitmentNotification authorNotification = saveNotification(recruitment, author.getUser(), "작성자 알림"); + entityManager.flush(); + + List applicantNotificationIds = List.of(first.getId(), second.getId(), third.getId()).stream() + .sorted(Comparator.reverseOrder()) + .toList(); + + mockMvc.perform(get("/team-recruitments/notifications") + .param("page", "1") + .param("limit", "2") + .header("Authorization", "Bearer " + applicantToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.total_count").value(3)) + .andExpect(jsonPath("$.current_count").value(2)) + .andExpect(jsonPath("$.total_page").value(2)) + .andExpect(jsonPath("$.current_page").value(1)) + .andExpect(jsonPath("$.unread_count").value(3)) + .andExpect(jsonPath("$.notifications.length()").value(2)) + .andExpect(jsonPath("$.notifications[0].id").value(applicantNotificationIds.get(0))) + .andExpect(jsonPath("$.notifications[1].id").value(applicantNotificationIds.get(1))); + + mockMvc.perform(get("/team-recruitments/notifications") + .param("page", "2") + .param("limit", "2") + .header("Authorization", "Bearer " + applicantToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.total_count").value(3)) + .andExpect(jsonPath("$.current_count").value(1)) + .andExpect(jsonPath("$.total_page").value(2)) + .andExpect(jsonPath("$.current_page").value(2)) + .andExpect(jsonPath("$.unread_count").value(3)) + .andExpect(jsonPath("$.notifications[0].id").value(applicantNotificationIds.get(2))); + + mockMvc.perform(get("/team-recruitments/notifications") + .header("Authorization", "Bearer " + authorToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.total_count").value(1)) + .andExpect(jsonPath("$.unread_count").value(1)) + .andExpect(jsonPath("$.notifications.length()").value(1)) + .andExpect(jsonPath("$.notifications[0].id").value(authorNotification.getId())) + .andExpect(jsonPath("$.notifications[0].message_preview").value("작성자 알림")); + + mockMvc.perform(post("/team-recruitments/notifications/mark-all-read") + .header("Authorization", "Bearer " + applicantToken)) + .andExpect(status().isNoContent()); + entityManager.flush(); + entityManager.clear(); + List firstReadAt = applicantNotificationIds.stream() + .map(id -> notificationRepository.findById(id).orElseThrow().getReadAt()) + .toList(); + assertThat(firstReadAt).doesNotContainNull(); + + mockMvc.perform(post("/team-recruitments/notifications/mark-all-read") + .header("Authorization", "Bearer " + applicantToken)) + .andExpect(status().isNoContent()); + entityManager.flush(); + entityManager.clear(); + assertThat(applicantNotificationIds.stream() + .map(id -> notificationRepository.findById(id).orElseThrow().getReadAt()) + .toList()).containsExactlyElementsOf(firstReadAt); + assertThat(notificationRepository.findById(authorNotification.getId()).orElseThrow().getReadAt()).isNull(); + + mockMvc.perform(get("/team-recruitments/notifications") + .header("Authorization", "Bearer " + applicantToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.unread_count").value(0)) + .andExpect(jsonPath("$.notifications[*].is_read").value(everyItem(equalTo(true)))); + + mockMvc.perform(delete("/team-recruitments/notifications") + .header("Authorization", "Bearer " + applicantToken)) + .andExpect(status().isNoContent()); + entityManager.flush(); + entityManager.clear(); + + mockMvc.perform(delete("/team-recruitments/notifications") + .header("Authorization", "Bearer " + applicantToken)) + .andExpect(status().isNoContent()); + entityManager.flush(); + entityManager.clear(); + assertThat(applicantNotificationIds) + .allSatisfy(id -> assertThat(notificationRepository.findById(id).orElseThrow().getIsDeleted()).isTrue()); + assertThat(notificationRepository.findById(authorNotification.getId()).orElseThrow().getIsDeleted()).isFalse(); + + mockMvc.perform(get("/team-recruitments/notifications") + .header("Authorization", "Bearer " + applicantToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.total_count").value(0)) + .andExpect(jsonPath("$.unread_count").value(0)) + .andExpect(jsonPath("$.notifications").isEmpty()); + + mockMvc.perform(get("/team-recruitments/notifications") + .header("Authorization", "Bearer " + authorToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.total_count").value(1)) + .andExpect(jsonPath("$.unread_count").value(1)) + .andExpect(jsonPath("$.notifications[0].id").value(authorNotification.getId())) + .andExpect(jsonPath("$.notifications[0].is_read").value(false)); + } + + private TeamRecruitment saveRecruitment(String title) { + return recruitmentRepository.save(TeamRecruitment.builder() + .author(author.getUser()) + .category(PROJECT) + .title(title) + .meetingType(ONLINE) + .activityStartDate(LocalDate.now(clock).plusDays(2)) + .activityEndDate(LocalDate.now(clock).plusDays(10)) + .deadlineDate(LocalDate.now(clock).plusDays(1)) + .recruitmentType(GENERAL) + .maxParticipants(3) + .currentParticipants(0) + .description("모집 내용") + .status(RECRUITING) + .build()); + } + + private TeamRecruitmentNotification saveNotification( + TeamRecruitment recruitment, + User recipient, + String messagePreview + ) { + return notificationRepository.save(TeamRecruitmentNotification.builder() + .recipient(recipient) + .type(APPLICATION_REJECTED) + .targetType(MY_APPLICATIONS) + .messagePreview(messagePreview) + .recruitment(recruitment) + .build()); + } +} From 3175139c9c4c7cb31f81b237f310673b1bd56ed1 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: Thu, 3 Sep 2026 02:13:49 +0900 Subject: [PATCH 16/32] =?UTF-8?q?test:=20ROLE=5FBASED=20=EC=A7=80=EC=9B=90?= =?UTF-8?q?=20=EC=97=AC=EC=A0=95=20=EA=B2=80=EC=A6=9D=20(#2406)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...TeamRecruitmentApplicationFlowApiTest.java | 212 +++++++++++++++++- 1 file changed, 211 insertions(+), 1 deletion(-) diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentApplicationFlowApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentApplicationFlowApiTest.java index b9d8ca80bd..e2d9cc6f1e 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentApplicationFlowApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentApplicationFlowApiTest.java @@ -12,6 +12,8 @@ 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 in.koreatech.koin.domain.user.model.UserIdentity.UNDERGRADUATE; +import static in.koreatech.koin.domain.user.model.UserType.STUDENT; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.nullValue; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; @@ -22,11 +24,16 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.time.LocalDate; +import java.util.List; +import java.util.stream.StreamSupport; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.ResultActions; import in.koreatech.koin.acceptance.AcceptanceTest; @@ -34,16 +41,20 @@ import in.koreatech.koin.acceptance.fixture.UserAcceptanceFixture; import in.koreatech.koin.domain.student.model.Department; import in.koreatech.koin.domain.student.model.Student; +import in.koreatech.koin.domain.student.repository.StudentRepository; 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.TeamRecruitmentChatMember; import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentChatRoom; import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentProfile; +import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentRole; import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentApplicationRepository; import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentChatMemberRepository; import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentChatRoomRepository; import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentProfileRepository; import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentRepository; +import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentRoleRepository; +import in.koreatech.koin.domain.user.model.User; class TeamRecruitmentApplicationFlowApiTest extends AcceptanceTest { @@ -59,6 +70,12 @@ class TeamRecruitmentApplicationFlowApiTest extends AcceptanceTest { @Autowired private TeamRecruitmentProfileRepository profileRepository; + @Autowired + private TeamRecruitmentRoleRepository roleRepository; + + @Autowired + private StudentRepository studentRepository; + @Autowired private TeamRecruitmentApplicationRepository applicationRepository; @@ -68,16 +85,20 @@ class TeamRecruitmentApplicationFlowApiTest extends AcceptanceTest { @Autowired private TeamRecruitmentChatMemberRepository chatMemberRepository; + @Autowired + private ObjectMapper objectMapper; + private Student author; private Student applicant; private String authorToken; private String applicantToken; + private Department department; private RecruitmentContext recruitmentContext; @BeforeEach void setUp() { clear(); - Department department = departmentFixture.컴퓨터공학부(); + department = departmentFixture.컴퓨터공학부(); author = userFixture.준호_학생(department, null); applicant = userFixture.성빈_학생(department); authorToken = userFixture.getToken(author.getUser()); @@ -91,6 +112,135 @@ void setUp() { recruitmentContext = saveRecruitmentAndTeamRoom("팀원 모집", 2); } + @Test + void ROLE_BASED_모집은_지원한_역할만_정원에_따라_마감한다() throws Exception { + MvcResult creationResult = mockMvc.perform(post("/team-recruitments") + .header("Authorization", "Bearer " + authorToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "category": "PROJECT", + "title": "ROLE_BASED 지원 여정", + "meeting_type": "ONLINE", + "activity_start_date": "%s", + "activity_end_date": "%s", + "deadline_date": "%s", + "recruitment_type": "ROLE_BASED", + "roles": [ + {"name": "백엔드", "max_participants": 1}, + {"name": "프론트엔드", "max_participants": 1} + ], + "description": "역할 기반 모집" + } + """.formatted( + LocalDate.now(clock).plusDays(2), + LocalDate.now(clock).plusDays(10), + LocalDate.now(clock).plusDays(1) + ))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").isNumber()) + .andReturn(); + + Integer recruitmentId = objectMapper + .readTree(creationResult.getResponse().getContentAsString()) + .path("id") + .asInt(); + assertThat(recruitmentId).isPositive(); + + TeamRecruitment recruitment = recruitmentRepository.findById(recruitmentId).orElseThrow(); + assertThat(recruitment.getRecruitmentType()).isEqualTo( + in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentType.ROLE_BASED + ); + List roles = roleRepository + .findAllByRecruitment_IdOrderByDisplayOrderAsc(recruitmentId); + assertThat(roles).hasSize(2); + TeamRecruitmentRole role = roles.stream() + .filter(candidate -> candidate.getName().equals("백엔드")) + .findFirst() + .orElseThrow(); + TeamRecruitmentRole frontendRole = roles.stream() + .filter(candidate -> candidate.getName().equals("프론트엔드")) + .findFirst() + .orElseThrow(); + assertThat(role.getId()).isNotEqualTo(frontendRole.getId()); + + List chatRooms = chatRoomRepository.findAllByRecruitment_Id(recruitmentId); + assertThat(chatRooms).filteredOn(chatRoom -> chatRoom.getRoomType() == TEAM).hasSize(1); + TeamRecruitmentChatRoom teamRoom = chatRooms.stream() + .filter(chatRoom -> chatRoom.getRoomType() == TEAM) + .findFirst() + .orElseThrow(); + assertThat(teamRoom.getRecruitment().getId()).isEqualTo(recruitmentId); + + applyForRole(recruitment, role, applicantToken, "첫 지원자") + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.role.id").value(role.getId())) + .andExpect(jsonPath("$.role.name").value("백엔드")); + TeamRecruitmentApplication rejectedApplication = applicationRepository + .findByRecruitment_IdAndApplicant_Id(recruitment.getId(), applicant.getUser().getId()) + .orElseThrow(); + reject(recruitment, rejectedApplication, authorToken) + .andExpect(status().isNoContent()); + + Student acceptedApplicant = saveSecondApplicant(); + String acceptedApplicantToken = userFixture.getToken(acceptedApplicant.getUser()); + saveProfile(acceptedApplicant, "승인 지원자"); + applyForRole(recruitment, role, acceptedApplicantToken, "두 번째 지원자") + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.role.id").value(role.getId())) + .andExpect(jsonPath("$.role.name").value("백엔드")); + TeamRecruitmentApplication acceptedApplication = applicationRepository + .findByRecruitment_IdAndApplicant_Id(recruitment.getId(), acceptedApplicant.getUser().getId()) + .orElseThrow(); + accept(recruitment, acceptedApplication, authorToken) + .andExpect(status().isNoContent()); + + entityManager.flush(); + entityManager.clear(); + + MvcResult detailResult = mockMvc.perform(get("/team-recruitments/{id}", recruitmentId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(recruitmentId)) + .andExpect(jsonPath("$.recruitment_type").value("ROLE_BASED")) + .andExpect(jsonPath("$.status").value("RECRUITING")) + .andExpect(jsonPath("$.current_participants").value(1)) + .andExpect(jsonPath("$.max_participants").value(2)) + .andReturn(); + + JsonNode detail = objectMapper.readTree(detailResult.getResponse().getContentAsString()); + assertThat(detail.path("roles").size()).isEqualTo(2); + assertRole(detail, role.getId(), "백엔드", 1, 1, true); + assertRole(detail, frontendRole.getId(), "프론트엔드", 0, 1, false); + + assertThat(applicationRepository.findById(rejectedApplication.getId()).orElseThrow().getStatus()) + .isEqualTo(REJECTED); + assertThat(applicationRepository.findById(acceptedApplication.getId()).orElseThrow().getStatus()) + .isEqualTo(ACCEPTED); + assertThat(chatRoomRepository.findById(teamRoom.getId()).orElseThrow().getStatus()).isEqualTo(ACTIVE); + assertThat(chatMemberRepository.findAllByChatRoom_Id(teamRoom.getId())) + .extracting(member -> member.getUser().getId()) + .containsExactlyInAnyOrder(author.getUser().getId(), acceptedApplicant.getUser().getId()); + } + + private void assertRole( + JsonNode recruitmentDetail, + Integer roleId, + String roleName, + int currentParticipants, + int maxParticipants, + boolean closed + ) { + JsonNode role = StreamSupport.stream(recruitmentDetail.path("roles").spliterator(), false) + .filter(candidate -> candidate.path("id").asInt() == roleId) + .findFirst() + .orElseThrow(() -> new AssertionError("응답에서 role id를 찾을 수 없습니다: " + roleId)); + assertThat(role.path("id").asInt()).isEqualTo(roleId); + assertThat(role.path("name").asText()).isEqualTo(roleName); + assertThat(role.path("current_participants").asInt()).isEqualTo(currentParticipants); + assertThat(role.path("max_participants").asInt()).isEqualTo(maxParticipants); + assertThat(role.path("is_closed").asBoolean()).isEqualTo(closed); + } + @Test void 모집글과_TEAM_방이_생성된_상태에서_지원하고_승인하면_TEAM_방에_참여한다() throws Exception { TeamRecruitment recruitment = recruitmentContext.recruitment(); @@ -552,6 +702,24 @@ private ResultActions apply( """)); } + private ResultActions applyForRole( + TeamRecruitment recruitment, + TeamRecruitmentRole role, + String token, + String motivation + ) throws Exception { + return mockMvc.perform(post("/team-recruitments/{recruitmentId}/applications", recruitment.getId()) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "role_id": %d, + "motivation": "%s", + "availability": "평일 저녁" + } + """.formatted(role.getId(), motivation))); + } + private ResultActions accept( TeamRecruitment recruitment, TeamRecruitmentApplication application, @@ -568,6 +736,48 @@ private ResultActions accept( """)); } + private ResultActions reject( + TeamRecruitment recruitment, + TeamRecruitmentApplication application, + String token + ) throws Exception { + return mockMvc.perform(put("/team-recruitments/{recruitmentId}/applications/{applicationId}/status", + recruitment.getId(), application.getId()) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "status": "REJECTED" + } + """)); + } + + private Student saveSecondApplicant() { + return studentRepository.save(Student.builder() + .studentNumber("2023999999") + .department(department) + .userIdentity(UNDERGRADUATE) + .isGraduated(false) + .user(User.builder() + .loginPw("test-password") + .name("ROLE_BASED 승인 지원자") + .anonymousNickname("익명_역할지원자") + .userType(STUDENT) + .isAuthed(true) + .isDeleted(false) + .build()) + .build()); + } + + private void saveProfile(Student student, String nickname) { + profileRepository.save(TeamRecruitmentProfile.builder() + .user(student.getUser()) + .profileNickname(nickname) + .preferredRole("백엔드") + .selfIntroduction("ROLE_BASED 지원자") + .build()); + } + private RecruitmentContext saveRecruitmentAndTeamRoom(String title, int maxParticipants) { TeamRecruitment recruitment = saveRecruitment(title, maxParticipants); TeamRecruitmentChatRoom teamRoom = chatRoomRepository.save(TeamRecruitmentChatRoom.builder() From fda3ad89bad789bd6d2848b041151374047e38c7 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: Thu, 3 Sep 2026 13:32:32 +0900 Subject: [PATCH 17/32] =?UTF-8?q?feat:=20=ED=8C=80=EC=9B=90=20=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EC=B1=84=ED=8C=85=EB=B0=A9=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84=20(#2408)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TeamRecruitmentChatMemberRepository.java | 22 +++ .../TeamRecruitmentChatMessageRepository.java | 35 +++++ .../controller/TeamRecruitmentChatApi.java | 13 ++ .../TeamRecruitmentChatController.java | 18 ++- ...amRecruitmentChatRoomListItemResponse.java | 84 ++++++++++++ .../service/TeamRecruitmentChatService.java | 62 +++++++++ .../TeamRecruitmentOpenApiCustomizer.java | 6 + .../domain/TeamRecruitmentChatApiTest.java | 127 ++++++++++++++++++ .../TeamRecruitmentOpenApiContractTest.java | 23 ++++ .../TeamRecruitmentChatServiceTest.java | 89 ++++++++++++ 10 files changed, 474 insertions(+), 5 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/TeamRecruitmentChatRoomListItemResponse.java diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/repository/TeamRecruitmentChatMemberRepository.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/repository/TeamRecruitmentChatMemberRepository.java index d2decda7f3..421cb3fe82 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/repository/TeamRecruitmentChatMemberRepository.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/repository/TeamRecruitmentChatMemberRepository.java @@ -1,8 +1,11 @@ package in.koreatech.koin.domain.team.recruitment.repository; import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentChatMember; +import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; +import java.util.Collection; import java.util.List; import java.util.Optional; @@ -16,5 +19,24 @@ public interface TeamRecruitmentChatMemberRepository extends Repository findAllByChatRoom_Id(Integer chatRoomId); + @Query(""" + SELECT member + FROM TeamRecruitmentChatMember member + JOIN FETCH member.chatRoom chatRoom + JOIN FETCH chatRoom.recruitment + WHERE member.user.id = :userId + """) + List findAllByUserIdWithChatRoomAndRecruitment(@Param("userId") Integer userId); + + @Query(""" + SELECT member + FROM TeamRecruitmentChatMember member + JOIN FETCH member.user + WHERE member.chatRoom.id IN :chatRoomIds + """) + List findAllWithUsersByChatRoomIds( + @Param("chatRoomIds") Collection chatRoomIds + ); + long countByChatRoom_Id(Integer chatRoomId); } diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/repository/TeamRecruitmentChatMessageRepository.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/repository/TeamRecruitmentChatMessageRepository.java index c0fb70887d..3d57c95611 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/repository/TeamRecruitmentChatMessageRepository.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/repository/TeamRecruitmentChatMessageRepository.java @@ -2,8 +2,11 @@ import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentChatMessage; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; +import java.util.Collection; import java.util.List; import java.util.Optional; @@ -13,6 +16,31 @@ public interface TeamRecruitmentChatMessageRepository extends Repository findLatestByChatRoomIds( + @Param("chatRoomIds") Collection chatRoomIds + ); + + @Query(""" + SELECT message.chatRoom.id AS chatRoomId, COUNT(message.id) AS unreadMessageCount + FROM TeamRecruitmentChatMessage message + JOIN message.chatRoom.members member + WHERE member.user.id = :userId + AND message.sender.id <> :userId + AND (member.lastReadMessageId IS NULL OR message.id > member.lastReadMessageId) + GROUP BY message.chatRoom.id + """) + List countUnreadMessagesByUserId(@Param("userId") Integer userId); + /** Returns the initial page in ascending message-id order. */ List findAllByChatRoom_IdOrderByIdAsc( Integer chatRoomId, @@ -32,4 +60,11 @@ List findAllByChatRoom_IdAndIdLessThanOrderByIdDesc( Integer beforeMessageId, Pageable pageable ); + + interface ChatRoomUnreadCount { + + Integer getChatRoomId(); + + long getUnreadMessageCount(); + } } diff --git a/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentChatApi.java b/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentChatApi.java index c7045b3a7d..e1c549ad89 100644 --- a/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentChatApi.java +++ b/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentChatApi.java @@ -26,6 +26,7 @@ import in.koreatech.koin.domain.teamrecruitment.dto.ChatRoomResponse; import in.koreatech.koin.domain.teamrecruitment.dto.CreateChatMessageRequest; import in.koreatech.koin.domain.teamrecruitment.dto.DirectChatRoomResponse; +import in.koreatech.koin.domain.teamrecruitment.dto.TeamRecruitmentChatRoomListItemResponse; import in.koreatech.koin.global.code.ApiResponseCodes; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -35,6 +36,18 @@ @Tag(name = "(Normal) Team Recruitment Chat: 팀원 모집 채팅", description = "팀원 모집 채팅 API") public interface TeamRecruitmentChatApi { + @ApiResponseCodes({ + OK, + UNAUTHORIZED_USER, + FORBIDDEN_USER_TYPE, + }) + @Operation( + summary = "내 팀원 모집 채팅방 목록 조회", + description = "현재 사용자가 멤버인 TEAM/DIRECT 채팅방을 최근 메시지순으로 반환합니다. " + + "메시지가 없는 방과 READ_ONLY 방도 포함합니다." + ) + ResponseEntity> getChatRooms(Integer userId); + @ApiResponseCodes({ OK, TEAM_RECRUITMENT_CHAT_NOT_FOUND, diff --git a/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentChatController.java b/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentChatController.java index b9bbaae96a..6170f8df66 100644 --- a/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentChatController.java +++ b/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentChatController.java @@ -17,6 +17,7 @@ import in.koreatech.koin.domain.teamrecruitment.dto.CreateChatMessageRequest; import in.koreatech.koin.domain.teamrecruitment.dto.DirectChatRoomCreationResult; import in.koreatech.koin.domain.teamrecruitment.dto.DirectChatRoomResponse; +import in.koreatech.koin.domain.teamrecruitment.dto.TeamRecruitmentChatRoomListItemResponse; import in.koreatech.koin.domain.teamrecruitment.service.TeamRecruitmentChatService; import in.koreatech.koin.global.auth.Auth; @@ -26,12 +27,19 @@ @RestController @RequiredArgsConstructor -@RequestMapping("/chatroom/team-recruitment/{recruitmentId}") +@RequestMapping("/chatroom/team-recruitment") public class TeamRecruitmentChatController implements TeamRecruitmentChatApi { private final TeamRecruitmentChatService chatService; - @GetMapping("/{chatRoomId}") + @GetMapping + public ResponseEntity> getChatRooms( + @Auth(permit = {STUDENT}) Integer userId + ) { + return ResponseEntity.ok(chatService.getChatRooms(userId)); + } + + @GetMapping("/{recruitmentId}/{chatRoomId}") public ResponseEntity getChatRoom( @Auth(permit = {STUDENT}) Integer userId, @PathVariable Integer recruitmentId, @@ -40,7 +48,7 @@ public ResponseEntity getChatRoom( return ResponseEntity.ok(chatService.getChatRoom(userId, recruitmentId, chatRoomId)); } - @PostMapping("/applications/{applicationId}/direct") + @PostMapping("/{recruitmentId}/applications/{applicationId}/direct") public ResponseEntity getOrCreateDirectChatRoom( @Auth(permit = {STUDENT}) Integer userId, @PathVariable Integer recruitmentId, @@ -51,7 +59,7 @@ public ResponseEntity getOrCreateDirectChatRoom( return ResponseEntity.status(status).body(result.response()); } - @GetMapping("/{chatRoomId}/messages") + @GetMapping("/{recruitmentId}/{chatRoomId}/messages") public ResponseEntity> getMessages( @Auth(permit = {STUDENT}) Integer userId, @PathVariable Integer recruitmentId, @@ -63,7 +71,7 @@ public ResponseEntity> getMessages( return ResponseEntity.ok(chatService.getMessages(userId, recruitmentId, chatRoomId, afterMessageId, beforeMessageId, limit)); } - @PostMapping("/{chatRoomId}/messages") + @PostMapping("/{recruitmentId}/{chatRoomId}/messages") public ResponseEntity createMessage( @Auth(permit = {STUDENT}) Integer userId, @PathVariable Integer recruitmentId, diff --git a/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/TeamRecruitmentChatRoomListItemResponse.java b/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/TeamRecruitmentChatRoomListItemResponse.java new file mode 100644 index 0000000000..b20a1906ba --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/TeamRecruitmentChatRoomListItemResponse.java @@ -0,0 +1,84 @@ +package in.koreatech.koin.domain.teamrecruitment.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentChatRoomType; +import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentChatMessage; +import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentChatRoom; +import in.koreatech.koin.domain.user.model.User; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record TeamRecruitmentChatRoomListItemResponse( + @Schema(description = "모집글 ID", example = "17", requiredMode = REQUIRED) + Integer recruitmentId, + + @Schema(description = "채팅방 ID", example = "31", requiredMode = REQUIRED) + Integer chatRoomId, + + @Schema(description = "채팅방 이름. TEAM은 모집글 제목, DIRECT는 상대방 닉네임입니다.", + example = "AI 아이디어 공모전 팀원 모집", requiredMode = REQUIRED) + String roomName, + + @Schema(description = "채팅방 타입", example = "TEAM", requiredMode = REQUIRED) + String roomType, + + @Schema(description = "채팅방 상태", example = "ACTIVE", requiredMode = REQUIRED) + String status, + + @Schema(description = "DIRECT 상대방 유저 ID. TEAM은 null입니다.", example = "22", + nullable = true, requiredMode = REQUIRED) + Integer counterpartId, + + @Schema(description = "DIRECT 상대방 닉네임. TEAM은 null입니다.", example = "김철수", + nullable = true, requiredMode = REQUIRED) + String counterpartNickname, + + @Schema(description = "최근 메시지 ID. 메시지가 없으면 null입니다.", example = "901", + nullable = true, requiredMode = REQUIRED) + Integer lastMessageId, + + @Schema(description = "최근 메시지 내용. 메시지가 없으면 null입니다.", example = "안녕하세요!", + nullable = true, requiredMode = REQUIRED) + String lastMessageContent, + + @Schema(description = "최근 메시지 전송 시각. 메시지가 없으면 null입니다.", + example = "2026-08-26T11:20:30.123456", nullable = true, requiredMode = REQUIRED) + LocalDateTime lastMessageAt, + + @Schema(description = "최근 메시지의 이미지 여부. 메시지가 없으면 null입니다.", example = "false", + nullable = true, requiredMode = REQUIRED) + Boolean lastMessageIsImage, + + @Schema(description = "현재 사용자가 읽지 않은 메시지 수", example = "2", requiredMode = REQUIRED) + int unreadMessageCount +) { + + public static TeamRecruitmentChatRoomListItemResponse of( + TeamRecruitmentChatRoom chatRoom, + User counterpart, + TeamRecruitmentChatMessage lastMessage, + int unreadMessageCount + ) { + boolean direct = chatRoom.getRoomType() == TeamRecruitmentChatRoomType.DIRECT; + return new TeamRecruitmentChatRoomListItemResponse( + chatRoom.getRecruitment().getId(), + chatRoom.getId(), + direct && counterpart != null ? counterpart.getNickname() : chatRoom.getRecruitment().getTitle(), + chatRoom.getRoomType().name(), + chatRoom.getStatus().name(), + direct && counterpart != null ? counterpart.getId() : null, + direct && counterpart != null ? counterpart.getNickname() : null, + lastMessage == null ? null : lastMessage.getId(), + lastMessage == null ? null : lastMessage.getContent(), + lastMessage == null ? null : lastMessage.getCreatedAt(), + lastMessage == null ? null : lastMessage.getIsImage(), + unreadMessageCount + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentChatService.java b/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentChatService.java index 29ba6d27b2..0d1076c651 100644 --- a/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentChatService.java +++ b/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentChatService.java @@ -5,10 +5,13 @@ import java.time.ZoneId; import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -33,6 +36,7 @@ import in.koreatech.koin.domain.teamrecruitment.dto.CreateChatMessageRequest; import in.koreatech.koin.domain.teamrecruitment.dto.DirectChatRoomCreationResult; import in.koreatech.koin.domain.teamrecruitment.dto.DirectChatRoomResponse; +import in.koreatech.koin.domain.teamrecruitment.dto.TeamRecruitmentChatRoomListItemResponse; import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.global.exception.CustomException; import lombok.RequiredArgsConstructor; @@ -67,6 +71,64 @@ public class TeamRecruitmentChatService { private final ObjectMapper objectMapper; private final Clock clock; + public List getChatRooms(Integer userId) { + List memberships = + memberRepository.findAllByUserIdWithChatRoomAndRecruitment(userId); + if (memberships.isEmpty()) { + return List.of(); + } + + List chatRooms = memberships.stream() + .map(TeamRecruitmentChatMember::getChatRoom) + .toList(); + List chatRoomIds = chatRooms.stream() + .map(TeamRecruitmentChatRoom::getId) + .toList(); + List directChatRoomIds = chatRooms.stream() + .filter(room -> room.getRoomType() == TeamRecruitmentChatRoomType.DIRECT) + .map(TeamRecruitmentChatRoom::getId) + .toList(); + + Map counterpartsByChatRoomId = directChatRoomIds.isEmpty() + ? Map.of() + : memberRepository.findAllWithUsersByChatRoomIds(directChatRoomIds).stream() + .filter(member -> !member.getUser().getId().equals(userId)) + .collect(Collectors.toMap( + member -> member.getChatRoom().getId(), + TeamRecruitmentChatMember::getUser, + (first, ignored) -> first + )); + Map latestMessagesByChatRoomId = messageRepository + .findLatestByChatRoomIds(chatRoomIds).stream() + .collect(Collectors.toMap( + message -> message.getChatRoom().getId(), + Function.identity() + )); + Map unreadCountsByChatRoomId = messageRepository.countUnreadMessagesByUserId(userId).stream() + .collect(Collectors.toMap( + TeamRecruitmentChatMessageRepository.ChatRoomUnreadCount::getChatRoomId, + TeamRecruitmentChatMessageRepository.ChatRoomUnreadCount::getUnreadMessageCount + )); + + return chatRooms.stream() + .map(chatRoom -> TeamRecruitmentChatRoomListItemResponse.of( + chatRoom, + counterpartsByChatRoomId.get(chatRoom.getId()), + latestMessagesByChatRoomId.get(chatRoom.getId()), + Math.toIntExact(unreadCountsByChatRoomId.getOrDefault(chatRoom.getId(), 0L)) + )) + .sorted(Comparator + .comparing( + TeamRecruitmentChatRoomListItemResponse::lastMessageAt, + Comparator.nullsLast(Comparator.reverseOrder()) + ) + .thenComparing( + TeamRecruitmentChatRoomListItemResponse::chatRoomId, + Comparator.reverseOrder() + )) + .toList(); + } + public ChatRoomResponse getChatRoom(Integer userId, Integer recruitmentId, Integer chatRoomId) { TeamRecruitmentChatRoom chatRoom = chatRoomRepository.findById(chatRoomId) .orElseThrow(() -> CustomException.of(TEAM_RECRUITMENT_CHAT_NOT_FOUND)); diff --git a/src/main/java/in/koreatech/koin/global/config/TeamRecruitmentOpenApiCustomizer.java b/src/main/java/in/koreatech/koin/global/config/TeamRecruitmentOpenApiCustomizer.java index 69a0a9394e..6b95ae8a3e 100644 --- a/src/main/java/in/koreatech/koin/global/config/TeamRecruitmentOpenApiCustomizer.java +++ b/src/main/java/in/koreatech/koin/global/config/TeamRecruitmentOpenApiCustomizer.java @@ -44,6 +44,12 @@ public void customise(OpenAPI openApi) { ); configureDateTime(schemas, "ApplicationCreatedResponse", "created_at", SPACE_LOCAL_DATE_TIME_PATTERN); configureDateTime(schemas, "RecruitmentDetail", "created_at", SPACE_LOCAL_DATE_TIME_PATTERN); + configureDateTime( + schemas, + "TeamRecruitmentChatRoomListItemResponse", + "last_message_at", + ISO_LOCAL_DATE_TIME_PATTERN + ); configureChatMessageTimestamp(openApi, schemas); } diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentChatApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentChatApiTest.java index 361553bea2..ddd5168fa5 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentChatApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentChatApiTest.java @@ -1,8 +1,10 @@ package in.koreatech.koin.acceptance.domain; import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentCategory.PROJECT; +import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentApplicationStatus.ACCEPTED; 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.CHAT_ROOM; @@ -11,6 +13,7 @@ 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 static org.hamcrest.Matchers.nullValue; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -34,11 +37,13 @@ import in.koreatech.koin.domain.student.model.Department; import in.koreatech.koin.domain.student.model.Student; 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.TeamRecruitmentChatMember; import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentChatMessage; import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentChatRoom; import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentNotification; 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.TeamRecruitmentChatMemberRepository; import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentChatMessageRepository; import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentChatRoomRepository; @@ -60,6 +65,9 @@ class TeamRecruitmentChatApiTest extends AcceptanceTest { @Autowired private TeamRecruitmentRepository recruitmentRepository; + @Autowired + private TeamRecruitmentApplicationRepository applicationRepository; + @Autowired private TeamRecruitmentChatRoomRepository chatRoomRepository; @@ -140,6 +148,73 @@ void teamChatRoomRequiresMembership() throws Exception { .andExpect(jsonPath("$.code").value("TEAM_RECRUITMENT_CHAT_NOT_FOUND")); } + @Test + @DisplayName("내 팀원 모집 채팅방 목록은 TEAM과 DIRECT를 최근 메시지순으로 반환한다") + void listMyTeamRecruitmentChatRooms() throws Exception { + TeamRecruitment recruitment = saveRecruitment("목록에서 보이는 팀 모집"); + TeamRecruitmentChatRoom teamRoom = saveTeamRoom(recruitment, ACTIVE); + TeamRecruitmentApplication application = saveAcceptedApplication(recruitment); + TeamRecruitmentChatRoom directRoom = saveDirectRoom(recruitment, application, READ_ONLY); + + TeamRecruitmentChatMessage readTeamMessage = saveMessage(teamRoom, author.getUser(), "이미 읽은 팀 메시지", false); + TeamRecruitmentChatMember applicantTeamMember = chatMemberRepository + .findByChatRoom_IdAndUser_Id(teamRoom.getId(), applicant.getUser().getId()) + .orElseThrow(); + applicantTeamMember.advanceLastReadMessageId(readTeamMessage.getId()); + TeamRecruitmentChatMessage unreadTeamMessage = saveMessage( + teamRoom, author.getUser(), "새로운 팀 메시지", false); + TeamRecruitmentChatMessage latestDirectMessage = saveMessage( + directRoom, applicant.getUser(), "https://static.koreatech.in/team-chat/image.png", true); + + TeamRecruitment emptyRecruitment = saveRecruitment("아직 대화가 없는 팀 모집"); + TeamRecruitmentChatRoom emptyRoom = saveTeamRoom(emptyRecruitment, ACTIVE); + entityManager.flush(); + + mockMvc.perform(get("/chatroom/team-recruitment") + .header("Authorization", "Bearer " + applicantToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(3)) + .andExpect(jsonPath("$[0].recruitment_id").value(recruitment.getId())) + .andExpect(jsonPath("$[0].chat_room_id").value(directRoom.getId())) + .andExpect(jsonPath("$[0].room_name").value(author.getUser().getNickname())) + .andExpect(jsonPath("$[0].room_type").value("DIRECT")) + .andExpect(jsonPath("$[0].status").value("READ_ONLY")) + .andExpect(jsonPath("$[0].counterpart_id").value(author.getUser().getId())) + .andExpect(jsonPath("$[0].counterpart_nickname").value(author.getUser().getNickname())) + .andExpect(jsonPath("$[0].last_message_id").value(latestDirectMessage.getId())) + .andExpect(jsonPath("$[0].last_message_content") + .value("https://static.koreatech.in/team-chat/image.png")) + .andExpect(jsonPath("$[0].last_message_at").isString()) + .andExpect(jsonPath("$[0].last_message_is_image").value(true)) + .andExpect(jsonPath("$[0].unread_message_count").value(0)) + .andExpect(jsonPath("$[1].recruitment_id").value(recruitment.getId())) + .andExpect(jsonPath("$[1].chat_room_id").value(teamRoom.getId())) + .andExpect(jsonPath("$[1].room_name").value(recruitment.getTitle())) + .andExpect(jsonPath("$[1].room_type").value("TEAM")) + .andExpect(jsonPath("$[1].counterpart_id").value(nullValue())) + .andExpect(jsonPath("$[1].counterpart_nickname").value(nullValue())) + .andExpect(jsonPath("$[1].last_message_id").value(unreadTeamMessage.getId())) + .andExpect(jsonPath("$[1].last_message_content").value("새로운 팀 메시지")) + .andExpect(jsonPath("$[1].last_message_is_image").value(false)) + .andExpect(jsonPath("$[1].unread_message_count").value(1)) + .andExpect(jsonPath("$[2].recruitment_id").value(emptyRecruitment.getId())) + .andExpect(jsonPath("$[2].chat_room_id").value(emptyRoom.getId())) + .andExpect(jsonPath("$[2].last_message_id").value(nullValue())) + .andExpect(jsonPath("$[2].last_message_content").value(nullValue())) + .andExpect(jsonPath("$[2].last_message_at").value(nullValue())) + .andExpect(jsonPath("$[2].last_message_is_image").value(nullValue())) + .andExpect(jsonPath("$[2].unread_message_count").value(0)); + + mockMvc.perform(get("/chatroom/team-recruitment") + .header("Authorization", "Bearer " + outsiderToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(0)); + + mockMvc.perform(get("/chatroom/team-recruitment")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("UNAUTHORIZED_USER")); + } + @Test @DisplayName("TEAM 채팅은 메시지 polling cursor와 unread read를 실제 HTTP로 반영하고 알림 outbox를 만든다") void teamChatMessagePollingAndNotificationOutbox() throws Exception { @@ -413,6 +488,58 @@ private TeamRecruitmentChatRoom saveTeamRoom( return room; } + private TeamRecruitmentApplication saveAcceptedApplication(TeamRecruitment recruitment) { + return applicationRepository.save(TeamRecruitmentApplication.builder() + .recruitment(recruitment) + .applicant(applicant.getUser()) + .motivation("지원 동기") + .availability("월수금 20시 이후") + .status(ACCEPTED) + .profileSnapshot("{}") + .snapshotVersion(1) + .build()); + } + + private TeamRecruitmentChatRoom saveDirectRoom( + TeamRecruitment recruitment, + TeamRecruitmentApplication application, + in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentChatRoomStatus status + ) { + TeamRecruitmentChatRoom room = chatRoomRepository.save(TeamRecruitmentChatRoom.builder() + .recruitment(recruitment) + .roomScopeKey("DIRECT-" + application.getId()) + .roomType(DIRECT) + .application(application) + .status(status) + .build()); + chatMemberRepository.save(TeamRecruitmentChatMember.builder() + .chatRoom(room) + .user(author.getUser()) + .build()); + chatMemberRepository.save(TeamRecruitmentChatMember.builder() + .chatRoom(room) + .user(applicant.getUser()) + .build()); + return room; + } + + private TeamRecruitmentChatMessage saveMessage( + TeamRecruitmentChatRoom room, + User sender, + String content, + boolean isImage + ) { + TeamRecruitmentChatMessage message = chatMessageRepository.save(TeamRecruitmentChatMessage.builder() + .chatRoom(room) + .sender(sender) + .senderNickname(sender.getNickname()) + .content(content) + .isImage(isImage) + .build()); + entityManager.flush(); + return message; + } + private org.springframework.test.web.servlet.ResultActions sendMessage( TeamRecruitmentChatRoom room, TeamRecruitment recruitment, diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentOpenApiContractTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentOpenApiContractTest.java index 13cb37dc8f..52776e15f2 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentOpenApiContractTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentOpenApiContractTest.java @@ -22,6 +22,7 @@ import in.koreatech.koin.domain.team.recruitment.dto.ApplicationCreatedResponse; import in.koreatech.koin.domain.teamrecruitment.dto.ChatMessageResponse; import in.koreatech.koin.domain.teamrecruitment.dto.ChatRoomResponse; +import in.koreatech.koin.domain.teamrecruitment.dto.TeamRecruitmentChatRoomListItemResponse; import in.koreatech.koin.domain.teamrecruitment.dto.TeamRecruitmentNotificationResponse; class TeamRecruitmentOpenApiContractTest extends AcceptanceTest { @@ -51,6 +52,7 @@ class TeamRecruitmentOpenApiContractTest extends AcceptanceTest { "PUT /team-recruitments/{recruitmentId}/applications/{applicationId}/status", "GET /team-recruitment-profiles/me", "PUT /team-recruitment-profiles/me", + "GET /chatroom/team-recruitment", "GET /chatroom/team-recruitment/{recruitmentId}/{chatRoomId}", "POST /chatroom/team-recruitment/{recruitmentId}/applications/{applicationId}/direct", "GET /chatroom/team-recruitment/{recruitmentId}/{chatRoomId}/messages", @@ -80,6 +82,18 @@ void teamRecruitmentContractMatchesRuntime() throws Exception { assertNullable(chatRoom, "counterpart"); assertInlineObject(chatRoom, "counterpart", "id", "nickname"); + JsonNode chatRoomListItem = schema(openApi, "TeamRecruitmentChatRoomListItemResponse"); + assertRequired(chatRoomListItem, "recruitment_id", "chat_room_id", "room_name", "room_type", "status", + "counterpart_id", "counterpart_nickname", "last_message_id", "last_message_content", "last_message_at", + "last_message_is_image", "unread_message_count"); + assertNullable(chatRoomListItem, "counterpart_id"); + assertNullable(chatRoomListItem, "counterpart_nickname"); + assertNullable(chatRoomListItem, "last_message_id"); + assertNullable(chatRoomListItem, "last_message_content"); + assertNullable(chatRoomListItem, "last_message_at"); + assertNullable(chatRoomListItem, "last_message_is_image"); + assertDateTime(chatRoomListItem, "last_message_at", ISO_LOCAL_DATE_TIME_PATTERN); + assertRequiredNullable(schema(openApi, "ApplicationCreatedResponse"), "role"); assertInlineObject(schema(openApi, "ApplicationCreatedResponse"), "role", "id", "name"); assertRequiredNullable(schema(openApi, "ApplicantDetail"), "role"); @@ -150,6 +164,9 @@ void teamRecruitmentDtosMatchRuntimeJson() { new ApplicationCreatedResponse(51, 17, PENDING, null, timestamp)); JsonNode chatRoom = objectMapper.valueToTree( new ChatRoomResponse(31, "팀 채팅방", "TEAM", "ACTIVE", 1, 2, null)); + JsonNode chatRoomListItem = objectMapper.valueToTree(new TeamRecruitmentChatRoomListItemResponse( + 17, 31, "팀 채팅방", "TEAM", "ACTIVE", null, null, + null, null, null, null, 0)); JsonNode notification = objectMapper.valueToTree(new TeamRecruitmentNotificationResponse( 101, "NEW_APPLICATION", "APPLICANT_MANAGEMENT", 17, null, null, null, "새로운 지원자가 있어요.", false, timestamp)); @@ -159,6 +176,12 @@ void teamRecruitmentDtosMatchRuntimeJson() { assertThat(application.path("role").isNull()).isTrue(); assertThat(application.path("created_at").asText()).isEqualTo("2026-08-26 11:20:30"); assertThat(chatRoom.path("counterpart").isNull()).isTrue(); + assertThat(chatRoomListItem.path("counterpart_id").isNull()).isTrue(); + assertThat(chatRoomListItem.path("counterpart_nickname").isNull()).isTrue(); + assertThat(chatRoomListItem.path("last_message_id").isNull()).isTrue(); + assertThat(chatRoomListItem.path("last_message_content").isNull()).isTrue(); + assertThat(chatRoomListItem.path("last_message_at").isNull()).isTrue(); + assertThat(chatRoomListItem.path("last_message_is_image").isNull()).isTrue(); assertThat(notification.path("application_id").isNull()).isTrue(); assertThat(notification.path("chat_room_id").isNull()).isTrue(); assertThat(notification.path("sender_nickname").isNull()).isTrue(); diff --git a/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java index 093d03ee41..9c76602565 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java @@ -6,10 +6,12 @@ import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import java.time.Clock; import java.time.Instant; +import java.time.LocalDateTime; import java.time.ZoneId; import java.util.List; import java.util.Optional; @@ -35,11 +37,13 @@ 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.TeamRecruitmentChatMember; +import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentChatMessage; import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentChatRoom; import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentRole; import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentApplicationRepository; import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentChatMemberRepository; import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentChatMessageRepository; +import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentChatMessageRepository.ChatRoomUnreadCount; 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; @@ -47,6 +51,7 @@ import in.koreatech.koin.domain.teamrecruitment.dto.ChatRoomResponse; import in.koreatech.koin.domain.teamrecruitment.dto.CreateChatMessageRequest; import in.koreatech.koin.domain.teamrecruitment.dto.DirectChatRoomCreationResult; +import in.koreatech.koin.domain.teamrecruitment.dto.TeamRecruitmentChatRoomListItemResponse; import in.koreatech.koin.domain.teamrecruitment.service.TeamRecruitmentChatService; import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.unit.fixture.UserFixture; @@ -137,6 +142,90 @@ void setUpClock() { .isEqualTo(ApiResponseCode.TEAM_RECRUITMENT_CHAT_FORBIDDEN)); } + @Test + void 내_채팅방_목록은_최근_메시지순으로_TEAM과_DIRECT_정보를_반환한다() { + TeamRecruitment recruitment = mock(TeamRecruitment.class); + TeamRecruitmentChatRoom teamRoom = mock(TeamRecruitmentChatRoom.class); + TeamRecruitmentChatRoom directRoom = mock(TeamRecruitmentChatRoom.class); + TeamRecruitmentChatMember currentTeamMember = mock(TeamRecruitmentChatMember.class); + TeamRecruitmentChatMember currentDirectMember = mock(TeamRecruitmentChatMember.class); + TeamRecruitmentChatMember counterpartMember = mock(TeamRecruitmentChatMember.class); + TeamRecruitmentChatMessage teamMessage = mock(TeamRecruitmentChatMessage.class); + TeamRecruitmentChatMessage directMessage = mock(TeamRecruitmentChatMessage.class); + ChatRoomUnreadCount unreadCount = mock(ChatRoomUnreadCount.class); + User currentUser = UserFixture.id_설정_코인_유저(USER_ID); + User counterpart = UserFixture.id_설정_코인_유저(OTHER_USER_ID); + LocalDateTime teamMessageAt = LocalDateTime.of(2026, 8, 28, 12, 0); + LocalDateTime directMessageAt = teamMessageAt.plusMinutes(1); + + when(currentTeamMember.getChatRoom()).thenReturn(teamRoom); + when(currentDirectMember.getChatRoom()).thenReturn(directRoom); + when(currentDirectMember.getUser()).thenReturn(currentUser); + when(teamRoom.getId()).thenReturn(20); + when(directRoom.getId()).thenReturn(21); + when(teamRoom.getRecruitment()).thenReturn(recruitment); + when(directRoom.getRecruitment()).thenReturn(recruitment); + when(recruitment.getId()).thenReturn(RECRUITMENT_ID); + when(recruitment.getTitle()).thenReturn("팀원 모집"); + when(teamRoom.getRoomType()).thenReturn(TeamRecruitmentChatRoomType.TEAM); + when(directRoom.getRoomType()).thenReturn(TeamRecruitmentChatRoomType.DIRECT); + when(teamRoom.getStatus()).thenReturn(TeamRecruitmentChatRoomStatus.ACTIVE); + when(directRoom.getStatus()).thenReturn(TeamRecruitmentChatRoomStatus.READ_ONLY); + when(counterpartMember.getChatRoom()).thenReturn(directRoom); + when(counterpartMember.getUser()).thenReturn(counterpart); + when(teamMessage.getChatRoom()).thenReturn(teamRoom); + when(teamMessage.getId()).thenReturn(100); + when(teamMessage.getContent()).thenReturn("팀 메시지"); + when(teamMessage.getCreatedAt()).thenReturn(teamMessageAt); + when(teamMessage.getIsImage()).thenReturn(false); + when(directMessage.getChatRoom()).thenReturn(directRoom); + when(directMessage.getId()).thenReturn(101); + when(directMessage.getContent()).thenReturn("개인 메시지"); + when(directMessage.getCreatedAt()).thenReturn(directMessageAt); + when(directMessage.getIsImage()).thenReturn(false); + when(unreadCount.getChatRoomId()).thenReturn(20); + when(unreadCount.getUnreadMessageCount()).thenReturn(2L); + when(memberRepository.findAllByUserIdWithChatRoomAndRecruitment(USER_ID)) + .thenReturn(List.of(currentTeamMember, currentDirectMember)); + when(memberRepository.findAllWithUsersByChatRoomIds(List.of(21))) + .thenReturn(List.of(currentDirectMember, counterpartMember)); + when(messageRepository.findLatestByChatRoomIds(List.of(20, 21))) + .thenReturn(List.of(teamMessage, directMessage)); + when(messageRepository.countUnreadMessagesByUserId(USER_ID)).thenReturn(List.of(unreadCount)); + + List responses = chatService.getChatRooms(USER_ID); + + assertThat(responses).extracting(TeamRecruitmentChatRoomListItemResponse::chatRoomId) + .containsExactly(directRoom.getId(), teamRoom.getId()); + assertThat(responses.get(0)) + .extracting( + TeamRecruitmentChatRoomListItemResponse::roomName, + TeamRecruitmentChatRoomListItemResponse::counterpartId, + TeamRecruitmentChatRoomListItemResponse::counterpartNickname, + TeamRecruitmentChatRoomListItemResponse::lastMessageId, + TeamRecruitmentChatRoomListItemResponse::unreadMessageCount + ) + .containsExactly(counterpart.getNickname(), OTHER_USER_ID, counterpart.getNickname(), 101, 0); + assertThat(responses.get(1)) + .extracting( + TeamRecruitmentChatRoomListItemResponse::roomName, + TeamRecruitmentChatRoomListItemResponse::counterpartId, + TeamRecruitmentChatRoomListItemResponse::counterpartNickname, + TeamRecruitmentChatRoomListItemResponse::lastMessageId, + TeamRecruitmentChatRoomListItemResponse::unreadMessageCount + ) + .containsExactly("팀원 모집", null, null, 100, 2); + } + + @Test + void 내_채팅방이_없으면_빈_목록을_반환한다() { + when(memberRepository.findAllByUserIdWithChatRoomAndRecruitment(USER_ID)).thenReturn(List.of()); + + assertThat(chatService.getChatRooms(USER_ID)).isEmpty(); + + verifyNoInteractions(messageRepository); + } + @Test void GENERAL_TEAM_채팅방에_작성자만_있으면_인원은_2명_중_1명이다() { ChatRoomResponse response = getChatRoom( From 488cb6a5b7fbb2feb70fa8af956a9b72a3d08ee7 Mon Sep 17 00:00:00 2001 From: dnjswldnd-3513 Date: Tue, 8 Sep 2026 20:18:47 +0900 Subject: [PATCH 18/32] =?UTF-8?q?fix:=20=EB=8B=89=EB=84=A4=EC=9E=84=20?= =?UTF-8?q?=EB=AF=B8=EC=84=A4=EC=A0=95=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EC=9D=98=20=EC=B1=84=ED=8C=85=EB=B0=A9=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=A0=84=EC=86=A1=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User.nickname이 null인 경우 getDisplayNickname()으로 fallback 처리 --- .../domain/teamrecruitment/dto/DirectChatRoomResponse.java | 4 ++-- .../teamrecruitment/service/TeamRecruitmentChatService.java | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/DirectChatRoomResponse.java b/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/DirectChatRoomResponse.java index d6465fc738..4f2022f7d5 100644 --- a/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/DirectChatRoomResponse.java +++ b/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/DirectChatRoomResponse.java @@ -36,10 +36,10 @@ public record Counterpart( public static DirectChatRoomResponse of(TeamRecruitmentChatRoom chatRoom, User counterpartUser) { return new DirectChatRoomResponse( chatRoom.getId(), - counterpartUser.getNickname(), + counterpartUser.getDisplayNickname(), chatRoom.getRoomType().name(), chatRoom.getStatus().name(), - new Counterpart(counterpartUser.getId(), counterpartUser.getNickname()) + new Counterpart(counterpartUser.getId(), counterpartUser.getDisplayNickname()) ); } } diff --git a/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentChatService.java b/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentChatService.java index 6a286b5211..e8e4ce23cc 100644 --- a/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentChatService.java +++ b/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentChatService.java @@ -77,7 +77,7 @@ public ChatRoomResponse getChatRoom(Integer userId, Integer recruitmentId, Integ counterpart = memberRepository.findAllByChatRoom_Id(chatRoomId).stream() .filter(m -> !m.getUser().getId().equals(userId)) .findFirst() - .map(m -> new ChatRoomResponse.Counterpart(m.getUser().getId(), m.getUser().getNickname())) + .map(m -> new ChatRoomResponse.Counterpart(m.getUser().getId(), m.getUser().getDisplayNickname())) .orElse(null); } @@ -224,7 +224,7 @@ public ChatMessageResponse createMessage(Integer userId, Integer recruitmentId, TeamRecruitmentChatMessage.builder() .chatRoom(chatRoom) .sender(sender) - .senderNickname(sender.getNickname()) + .senderNickname(sender.getDisplayNickname()) .content(request.content()) .isImage(request.isImage()) .build()); From b1f9ce6054b6989ba00c4bec24e0d23bc0d8430a Mon Sep 17 00:00:00 2001 From: dnjswldnd-3513 Date: Tue, 8 Sep 2026 20:28:00 +0900 Subject: [PATCH 19/32] =?UTF-8?q?test:=20=EB=8B=89=EB=84=A4=EC=9E=84=20?= =?UTF-8?q?=EB=AF=B8=EC=84=A4=EC=A0=95=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EC=9D=98=20=EC=B1=84=ED=8C=85=EB=B0=A9=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=A0=84=EC=86=A1=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TeamRecruitmentChatServiceTest.java | 66 +++++++++++++++++++ .../koin/unit/fixture/UserFixture.java | 15 +++++ 2 files changed, 81 insertions(+) diff --git a/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java index b16ccc029c..56af6d5d7e 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java @@ -2,11 +2,17 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; +import org.mockito.ArgumentCaptor; + import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -24,6 +30,7 @@ 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.TeamRecruitmentChatMember; +import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentChatMessage; import in.koreatech.koin.domain.team.recruitment.model.TeamRecruitmentChatRoom; import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentApplicationRepository; import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentChatMemberRepository; @@ -279,6 +286,65 @@ class TeamRecruitmentChatServiceTest { .isEqualTo(ApiResponseCode.TEAM_RECRUITMENT_CHAT_NOT_FOUND)); } + @Test + void 닉네임_미설정_사용자가_포함된_DIRECT_채팅방_조회시_nickname이_익명_사용자로_응답된다() { + User author = UserFixture.id_설정_코인_유저(USER_ID); + User anonymousCounterpart = UserFixture.닉네임_없는_코인_유저(OTHER_USER_ID); + TeamRecruitmentApplication application = mock(TeamRecruitmentApplication.class); + TeamRecruitment recruitment = mock(TeamRecruitment.class); + TeamRecruitmentChatRoom existingRoom = mock(TeamRecruitmentChatRoom.class); + + when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(application)); + when(recruitmentRepository.findById(RECRUITMENT_ID)).thenReturn(Optional.of(recruitment)); + when(application.getRecruitment()).thenReturn(recruitment); + when(recruitment.getId()).thenReturn(RECRUITMENT_ID); + when(application.getStatus()).thenReturn(TeamRecruitmentApplicationStatus.ACCEPTED); + when(recruitment.isRecruiting()).thenReturn(true); + when(recruitment.getAuthor()).thenReturn(author); + when(application.getApplicant()).thenReturn(anonymousCounterpart); + when(chatRoomRepository.findByRecruitment_IdAndApplication_IdAndRoomType( + RECRUITMENT_ID, APPLICATION_ID, TeamRecruitmentChatRoomType.DIRECT)) + .thenReturn(Optional.of(existingRoom)); + when(existingRoom.getId()).thenReturn(CHAT_ROOM_ID); + when(existingRoom.getRoomType()).thenReturn(TeamRecruitmentChatRoomType.DIRECT); + when(existingRoom.getStatus()).thenReturn(TeamRecruitmentChatRoomStatus.ACTIVE); + + DirectChatRoomCreationResult result = chatService.getOrCreateDirectChatRoom(USER_ID, RECRUITMENT_ID, APPLICATION_ID); + + assertThat(result.response().roomName()).isEqualTo(anonymousCounterpart.getDisplayNickname()); + assertThat(result.response().counterpart().nickname()).isEqualTo(anonymousCounterpart.getDisplayNickname()); + } + + @Test + void 닉네임_미설정_사용자의_메시지_전송시_senderNickname이_익명_사용자로_저장된다() { + User anonymousSender = UserFixture.닉네임_없는_코인_유저(USER_ID); + TeamRecruitmentChatRoom chatRoom = mock(TeamRecruitmentChatRoom.class); + TeamRecruitment recruitment = mock(TeamRecruitment.class); + TeamRecruitmentChatMember senderMember = mock(TeamRecruitmentChatMember.class); + TeamRecruitmentChatMessage savedMessage = mock(TeamRecruitmentChatMessage.class); + + when(chatRoomRepository.findById(CHAT_ROOM_ID)).thenReturn(Optional.of(chatRoom)); + when(chatRoom.getRecruitment()).thenReturn(recruitment); + when(recruitment.getId()).thenReturn(RECRUITMENT_ID); + when(memberRepository.findByChatRoom_IdAndUser_Id(CHAT_ROOM_ID, USER_ID)).thenReturn(Optional.of(senderMember)); + when(chatRoom.isActive()).thenReturn(true); + when(senderMember.getUser()).thenReturn(anonymousSender); + when(messageRepository.save(any(TeamRecruitmentChatMessage.class))).thenReturn(savedMessage); + when(savedMessage.getId()).thenReturn(100); + when(memberRepository.findAllByChatRoom_Id(CHAT_ROOM_ID)).thenReturn(List.of()); + when(savedMessage.getContent()).thenReturn("안녕"); + when(savedMessage.getSender()).thenReturn(anonymousSender); + when(savedMessage.getSenderNickname()).thenReturn(anonymousSender.getDisplayNickname()); + when(savedMessage.getCreatedAt()).thenReturn(LocalDateTime.now()); + when(savedMessage.getIsImage()).thenReturn(false); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TeamRecruitmentChatMessage.class); + chatService.createMessage(USER_ID, RECRUITMENT_ID, CHAT_ROOM_ID, new CreateChatMessageRequest("안녕", false)); + verify(messageRepository).save(captor.capture()); + + assertThat(captor.getValue().getSenderNickname()).isEqualTo(anonymousSender.getDisplayNickname()); + } + @Test void READ_ONLY_채팅방에_메시지_전송시_409를_반환한다() { TeamRecruitmentChatRoom chatRoom = mock(TeamRecruitmentChatRoom.class); diff --git a/src/test/java/in/koreatech/koin/unit/fixture/UserFixture.java b/src/test/java/in/koreatech/koin/unit/fixture/UserFixture.java index 7b304561c0..860c838b18 100644 --- a/src/test/java/in/koreatech/koin/unit/fixture/UserFixture.java +++ b/src/test/java/in/koreatech/koin/unit/fixture/UserFixture.java @@ -70,4 +70,19 @@ private UserFixture() {} .isDeleted(false) .build(); } + + public static User 닉네임_없는_코인_유저(Integer id) { + return User.builder() + .id(id) + .name("이름") + .phoneNumber("01012345678") + .email("test2@koreatech.ac.kr") + .loginId("test_id2") + .loginPw("test_pw2") + .gender(MAN) + .userType(GENERAL) + .isAuthed(true) + .isDeleted(false) + .build(); + } } From 8222a7f3335f94292a8ccae3ced7af3a03864d63 Mon Sep 17 00:00:00 2001 From: Comot <105335121+insik03@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:54:12 +0900 Subject: [PATCH 20/32] =?UTF-8?q?fix:=20=ED=8C=80=EC=9B=90=20=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EC=A7=80=EC=9B=90=20=EB=A7=88=EA=B0=90=EC=9D=BC?= =?UTF-8?q?=EC=9D=98=20=ED=99=9C=EB=8F=99=20=EC=8B=9C=EC=9E=91=EC=9D=BC=20?= =?UTF-8?q?=EC=A0=9C=ED=95=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 지원 마감일이 활동 시작일 이하여야 한다는 제한을 검증 로직과 DB CHECK 양쪽에서 제거한다. 활동 시작 이후까지 지원을 받는 모집글이 있어 마감일은 활동 기간과 무관하게 받는다. 활동 시작일 <= 활동 종료일 제약은 유지한다. - validatePeriod 를 validateActivityPeriod 로 바꿔 마감일 비교 제거 - V7 마이그레이션으로 chk_team_recruitment_dates 재정의 - 도달 불가능해진 TEAM_RECRUITMENT_INVALID_DEADLINE_DATE 제거 Co-Authored-By: Claude Opus 5 --- .../controller/TeamRecruitmentApi.java | 5 +-- .../dto/CreateRecruitmentRequest.java | 4 +-- .../dto/RecruitmentRequestValidator.java | 13 ++++--- .../dto/UpdateRecruitmentRequest.java | 4 +-- .../koin/global/code/ApiResponseCode.java | 1 - ...__relax_team_recruitment_deadline_date.sql | 9 +++++ ...TeamRecruitmentArticleContractApiTest.java | 36 +++++++++++++++++-- .../TeamRecruitmentMigrationTest.java | 7 ++++ .../dto/CreateRecruitmentRequestTest.java | 18 ++++++---- 9 files changed, 72 insertions(+), 25 deletions(-) create mode 100644 src/main/resources/db/migration/V7__relax_team_recruitment_deadline_date.sql diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/controller/TeamRecruitmentApi.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/controller/TeamRecruitmentApi.java index 4fe26b8897..a7b0cd3263 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/controller/TeamRecruitmentApi.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/controller/TeamRecruitmentApi.java @@ -12,7 +12,6 @@ import static in.koreatech.koin.global.code.ApiResponseCode.REQUEST_TOO_FAST; import static in.koreatech.koin.global.code.ApiResponseCode.TEAM_RECRUITMENT_CLOSED; import static in.koreatech.koin.global.code.ApiResponseCode.TEAM_RECRUITMENT_FORBIDDEN; -import static in.koreatech.koin.global.code.ApiResponseCode.TEAM_RECRUITMENT_INVALID_DEADLINE_DATE; import static in.koreatech.koin.global.code.ApiResponseCode.TEAM_RECRUITMENT_INVALID_ROLE_COMPOSITION; import static in.koreatech.koin.global.code.ApiResponseCode.TEAM_RECRUITMENT_MAX_PARTICIPANTS_BELOW_ACCEPTED; import static in.koreatech.koin.global.code.ApiResponseCode.TEAM_RECRUITMENT_NOT_FOUND; @@ -90,7 +89,6 @@ ResponseEntity getRecruitments( @ApiResponseCodes({ CREATED, - TEAM_RECRUITMENT_INVALID_DEADLINE_DATE, TEAM_RECRUITMENT_INVALID_ROLE_COMPOSITION, INVALID_START_DATE_AFTER_END_DATE, INVALID_REQUEST_BODY, @@ -108,7 +106,7 @@ ResponseEntity getRecruitments( - 역할명은 앞뒤 공백을 제거해 저장하며, 대소문자와 악센트만 다른 이름도 중복으로 봅니다. - `recruitment_type=GENERAL`은 작성자를 제외한 지원자 모집 정원인 `max_participants`를 보내고 `roles`는 빈 배열로 보내셔야 합니다. - - 지원 마감일은 활동 시작일 이하, 활동 시작일은 활동 종료일 이하여야 합니다. + - 활동 시작일은 활동 종료일 이하여야 합니다. 지원 마감일은 활동 기간과의 순서 제한이 없습니다. - 모집글과 TEAM 채팅방을 같은 트랜잭션에서 생성하고 작성자를 최초 채팅방 멤버로 추가합니다. - 별도의 팀 채팅방 생성 API는 없습니다. - 같은 사용자가 동일한 요청을 300ms 안에 반복하면 두 번째 요청은 `409 REQUEST_TOO_FAST`를 반환합니다. @@ -148,7 +146,6 @@ ResponseEntity getRecruitment( TEAM_RECRUITMENT_ROLE_UPDATE_NOT_ALLOWED, TEAM_RECRUITMENT_MAX_PARTICIPANTS_BELOW_ACCEPTED, TEAM_RECRUITMENT_TYPE_CHANGE_NOT_ALLOWED, - TEAM_RECRUITMENT_INVALID_DEADLINE_DATE, TEAM_RECRUITMENT_INVALID_ROLE_COMPOSITION, INVALID_START_DATE_AFTER_END_DATE, INVALID_REQUEST_BODY, diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/CreateRecruitmentRequest.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/CreateRecruitmentRequest.java index 283b941e91..994482430a 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/CreateRecruitmentRequest.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/CreateRecruitmentRequest.java @@ -48,7 +48,7 @@ public record CreateRecruitmentRequest( @NotNull LocalDate activityEndDate, - @Schema(description = "지원 마감일. 활동 시작일 이하여야 합니다.", example = "2026-09-03", requiredMode = REQUIRED) + @Schema(description = "지원 마감일", example = "2026-09-03", requiredMode = REQUIRED) @JsonFormat(pattern = "yyyy-MM-dd") @NotNull LocalDate deadlineDate, @@ -82,7 +82,7 @@ public record CreateRecruitmentRequest( String qualification ) { public CreateRecruitmentRequest { - RecruitmentRequestValidator.validatePeriod(activityStartDate, activityEndDate, deadlineDate); + RecruitmentRequestValidator.validateActivityPeriod(activityStartDate, activityEndDate); RecruitmentRequestValidator.validateRoleComposition(recruitmentType, roles, maxParticipants); if (RecruitmentRequestValidator.isCompleteRoleList(roles, RoleInput::isComplete)) { RecruitmentRequestValidator.validateDistinctRoleNames(roles.stream().map(RoleInput::name).toList()); diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/RecruitmentRequestValidator.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/RecruitmentRequestValidator.java index 726829494c..1f87a485ae 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/RecruitmentRequestValidator.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/RecruitmentRequestValidator.java @@ -2,7 +2,6 @@ import static in.koreatech.koin.global.code.ApiResponseCode.INVALID_REQUEST_BODY; import static in.koreatech.koin.global.code.ApiResponseCode.INVALID_START_DATE_AFTER_END_DATE; -import static in.koreatech.koin.global.code.ApiResponseCode.TEAM_RECRUITMENT_INVALID_DEADLINE_DATE; import static in.koreatech.koin.global.code.ApiResponseCode.TEAM_RECRUITMENT_INVALID_ROLE_COMPOSITION; import java.text.Normalizer; @@ -30,16 +29,16 @@ final class RecruitmentRequestValidator { private RecruitmentRequestValidator() { } - static void validatePeriod(LocalDate activityStartDate, LocalDate activityEndDate, LocalDate deadlineDate) { - if (activityStartDate == null) { + /** + * 지원 마감일은 활동 기간과 무관하게 받는다. 활동 시작 이후까지 지원을 받는 모집글이 있어 순서를 제한하지 않는다. + */ + static void validateActivityPeriod(LocalDate activityStartDate, LocalDate activityEndDate) { + if (activityStartDate == null || activityEndDate == null) { return; } - if (activityEndDate != null && activityEndDate.isBefore(activityStartDate)) { + if (activityEndDate.isBefore(activityStartDate)) { throw CustomException.of(INVALID_START_DATE_AFTER_END_DATE); } - if (deadlineDate != null && deadlineDate.isAfter(activityStartDate)) { - throw CustomException.of(TEAM_RECRUITMENT_INVALID_DEADLINE_DATE); - } } /** diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/UpdateRecruitmentRequest.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/UpdateRecruitmentRequest.java index 4399ecd413..6b069273ac 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/UpdateRecruitmentRequest.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/UpdateRecruitmentRequest.java @@ -45,7 +45,7 @@ public record UpdateRecruitmentRequest( @NotNull LocalDate activityEndDate, - @Schema(description = "지원 마감일. 활동 시작일 이하여야 합니다.", example = "2026-09-03", requiredMode = REQUIRED) + @Schema(description = "지원 마감일", example = "2026-09-03", requiredMode = REQUIRED) @JsonFormat(pattern = "yyyy-MM-dd") @NotNull LocalDate deadlineDate, @@ -80,7 +80,7 @@ public record UpdateRecruitmentRequest( String qualification ) { public UpdateRecruitmentRequest { - RecruitmentRequestValidator.validatePeriod(activityStartDate, activityEndDate, deadlineDate); + RecruitmentRequestValidator.validateActivityPeriod(activityStartDate, activityEndDate); RecruitmentRequestValidator.validateRoleComposition(recruitmentType, roles, maxParticipants); if (RecruitmentRequestValidator.isCompleteRoleList(roles, UpdateRoleInput::isComplete)) { RecruitmentRequestValidator.validateDistinctRoleNames(roles.stream().map(UpdateRoleInput::name).toList()); diff --git a/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java b/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java index b85f38fff9..ee1ca4b361 100644 --- a/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java +++ b/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java @@ -95,7 +95,6 @@ public enum ApiResponseCode { CALLVAN_REPORT_ALREADY_PROCESSED(HttpStatus.BAD_REQUEST, "이미 처리된 콜벤 신고입니다."), TEAM_RECRUITMENT_ACTIVITY_END_DATE_REQUIRED(HttpStatus.BAD_REQUEST, "진행 중인 활동이 아닌 경우, 활동 종료일은 필수입니다."), TEAM_RECRUITMENT_ACTIVITY_END_DATE_MUST_BE_NULL(HttpStatus.BAD_REQUEST, "진행 중인 활동인 경우, 활동 종료일은 입력하면 안 됩니다."), - TEAM_RECRUITMENT_INVALID_DEADLINE_DATE(HttpStatus.BAD_REQUEST, "지원 마감일은 활동 시작일 이하여야 합니다."), TEAM_RECRUITMENT_INVALID_ROLE_COMPOSITION(HttpStatus.BAD_REQUEST, "모집 유형에 맞지 않는 역할 또는 정원 구성입니다."), /** diff --git a/src/main/resources/db/migration/V7__relax_team_recruitment_deadline_date.sql b/src/main/resources/db/migration/V7__relax_team_recruitment_deadline_date.sql new file mode 100644 index 0000000000..fb776a09ba --- /dev/null +++ b/src/main/resources/db/migration/V7__relax_team_recruitment_deadline_date.sql @@ -0,0 +1,9 @@ +-- Immutable follow-up migration. +-- 지원 마감일과 활동 시작일의 순서 제한을 제거한다. 활동 시작 이후까지 지원을 받는 모집글이 있어 +-- 마감일은 활동 기간과 무관하게 받는다. 활동 시작일 <= 활동 종료일 제약은 그대로 유지한다. +ALTER TABLE `team_recruitment` + DROP CHECK `chk_team_recruitment_dates`; + +ALTER TABLE `team_recruitment` + ADD CONSTRAINT `chk_team_recruitment_dates` + CHECK (`activity_start_date` <= `activity_end_date`); diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentArticleContractApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentArticleContractApiTest.java index fbc6dbc609..ed8ee48417 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentArticleContractApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentArticleContractApiTest.java @@ -334,12 +334,12 @@ void duplicateRoleNameKeyAfterMaxParticipantsOnCreate() throws Exception { } @Test - @DisplayName("지원 마감일이 활동 시작일보다 이후면 400 이다") + @DisplayName("지원 마감일이 활동 시작일보다 이후여도 201 이다") void deadlineAfterActivityStart() throws Exception { String body = """ { "category": "STUDY", - "title": "잘못된 기간", + "title": "활동 시작 이후 마감", "meeting_type": "ONLINE", "activity_start_date": "%s", "activity_end_date": "%s", @@ -356,12 +356,42 @@ void deadlineAfterActivityStart() throws Exception { LocalDate.now(clock).plusDays(20), LocalDate.now(clock).plusDays(11)); + mockMvc.perform(post("/team-recruitments") + .header("Authorization", "Bearer " + authorToken) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isCreated()); + } + + @Test + @DisplayName("활동 종료일이 활동 시작일보다 이전이면 400 이다") + void activityEndBeforeActivityStart() throws Exception { + String body = """ + { + "category": "STUDY", + "title": "잘못된 기간", + "meeting_type": "ONLINE", + "activity_start_date": "%s", + "activity_end_date": "%s", + "deadline_date": "%s", + "recruitment_type": "GENERAL", + "max_participants": 3, + "roles": [], + "description": "설명", + "related_url": null, + "qualification": null + } + """.formatted( + LocalDate.now(clock).plusDays(20), + LocalDate.now(clock).plusDays(10), + LocalDate.now(clock).plusDays(5)); + mockMvc.perform(post("/team-recruitments") .header("Authorization", "Bearer " + authorToken) .contentType(MediaType.APPLICATION_JSON) .content(body)) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value("TEAM_RECRUITMENT_INVALID_DEADLINE_DATE")); + .andExpect(jsonPath("$.code").value("INVALID_START_DATE_AFTER_END_DATE")); } } diff --git a/src/test/java/in/koreatech/koin/acceptance/migration/TeamRecruitmentMigrationTest.java b/src/test/java/in/koreatech/koin/acceptance/migration/TeamRecruitmentMigrationTest.java index 45198f8522..899e1352e3 100644 --- a/src/test/java/in/koreatech/koin/acceptance/migration/TeamRecruitmentMigrationTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/migration/TeamRecruitmentMigrationTest.java @@ -91,6 +91,13 @@ static void migrate() { )).toLowerCase(Locale.ROOT)) .contains("deleted") .contains("deleted_at"); + assertThat(queryString(connection, checkConstraintQuery( + "team_recruitment", + "chk_team_recruitment_dates" + )).toLowerCase(Locale.ROOT)) + .contains("activity_start_date") + .contains("activity_end_date") + .doesNotContain("deadline_date"); assertThat(queryString(connection, checkConstraintQuery( "team_recruitment_chat_room", "chk_team_recruitment_chat_room_application_scope" diff --git a/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/dto/CreateRecruitmentRequestTest.java b/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/dto/CreateRecruitmentRequestTest.java index 3d21eb21aa..3c9bf14328 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/dto/CreateRecruitmentRequestTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/dto/CreateRecruitmentRequestTest.java @@ -2,7 +2,6 @@ import static in.koreatech.koin.global.code.ApiResponseCode.INVALID_REQUEST_BODY; import static in.koreatech.koin.global.code.ApiResponseCode.INVALID_START_DATE_AFTER_END_DATE; -import static in.koreatech.koin.global.code.ApiResponseCode.TEAM_RECRUITMENT_INVALID_DEADLINE_DATE; import static in.koreatech.koin.global.code.ApiResponseCode.TEAM_RECRUITMENT_INVALID_ROLE_COMPOSITION; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; @@ -232,12 +231,19 @@ void activityStartCanEqualActivityEnd() { } @Test - @DisplayName("지원 마감일이 활동 시작일보다 이후이면 예외가 발생한다") - void deadlineAfterActivityStartDateFails() { - assertThatThrownBy(() -> create( + @DisplayName("지원 마감일이 활동 시작일보다 이후여도 된다") + void deadlineCanBeAfterActivityStartDate() { + assertThatCode(() -> create( TeamRecruitmentType.ROLE_BASED, null, ROLES, ACTIVITY_START, ACTIVITY_END, ACTIVITY_START.plusDays(1))) - .isInstanceOf(CustomException.class) - .hasFieldOrPropertyWithValue("errorCode", TEAM_RECRUITMENT_INVALID_DEADLINE_DATE); + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("지원 마감일이 활동 종료일보다 이후여도 된다") + void deadlineCanBeAfterActivityEndDate() { + assertThatCode(() -> create( + TeamRecruitmentType.ROLE_BASED, null, ROLES, ACTIVITY_START, ACTIVITY_END, ACTIVITY_END.plusDays(1))) + .doesNotThrowAnyException(); } @Test From 198759552a8493b9d442d2f001de3e6c76a4e7d4 Mon Sep 17 00:00:00 2001 From: dnjswldnd-3513 Date: Wed, 9 Sep 2026 13:11:35 +0900 Subject: [PATCH 21/32] =?UTF-8?q?fix:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20=EB=8B=89?= =?UTF-8?q?=EB=84=A4=EC=9E=84=20=EB=AF=B8=EC=84=A4=EC=A0=95=20=EC=83=81?= =?UTF-8?q?=EB=8C=80=EB=B0=A9=20nickname=20null=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...amRecruitmentChatRoomListItemResponse.java | 4 +-- .../TeamRecruitmentChatServiceTest.java | 35 ++++++++++++++++++- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/TeamRecruitmentChatRoomListItemResponse.java b/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/TeamRecruitmentChatRoomListItemResponse.java index b20a1906ba..c261332d7a 100644 --- a/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/TeamRecruitmentChatRoomListItemResponse.java +++ b/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/TeamRecruitmentChatRoomListItemResponse.java @@ -69,11 +69,11 @@ public static TeamRecruitmentChatRoomListItemResponse of( return new TeamRecruitmentChatRoomListItemResponse( chatRoom.getRecruitment().getId(), chatRoom.getId(), - direct && counterpart != null ? counterpart.getNickname() : chatRoom.getRecruitment().getTitle(), + direct && counterpart != null ? counterpart.getDisplayNickname() : chatRoom.getRecruitment().getTitle(), chatRoom.getRoomType().name(), chatRoom.getStatus().name(), direct && counterpart != null ? counterpart.getId() : null, - direct && counterpart != null ? counterpart.getNickname() : null, + direct && counterpart != null ? counterpart.getDisplayNickname() : null, lastMessage == null ? null : lastMessage.getId(), lastMessage == null ? null : lastMessage.getContent(), lastMessage == null ? null : lastMessage.getCreatedAt(), diff --git a/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java index be00130b0a..903800d772 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java @@ -207,7 +207,7 @@ void setUpClock() { TeamRecruitmentChatRoomListItemResponse::lastMessageId, TeamRecruitmentChatRoomListItemResponse::unreadMessageCount ) - .containsExactly(counterpart.getNickname(), OTHER_USER_ID, counterpart.getNickname(), 101, 0); + .containsExactly(counterpart.getDisplayNickname(), OTHER_USER_ID, counterpart.getDisplayNickname(), 101, 0); assertThat(responses.get(1)) .extracting( TeamRecruitmentChatRoomListItemResponse::roomName, @@ -228,6 +228,39 @@ void setUpClock() { verifyNoInteractions(messageRepository); } + @Test + void 닉네임_미설정_사용자가_상대방인_DIRECT_채팅방_목록_조회시_counterpartNickname이_null이_아니다() { + TeamRecruitment recruitment = mock(TeamRecruitment.class); + TeamRecruitmentChatRoom directRoom = mock(TeamRecruitmentChatRoom.class); + TeamRecruitmentChatMember currentMember = mock(TeamRecruitmentChatMember.class); + TeamRecruitmentChatMember counterpartMember = mock(TeamRecruitmentChatMember.class); + User currentUser = UserFixture.id_설정_코인_유저(USER_ID); + User anonymousCounterpart = UserFixture.닉네임_없는_코인_유저(OTHER_USER_ID); + + when(currentMember.getChatRoom()).thenReturn(directRoom); + when(currentMember.getUser()).thenReturn(currentUser); + when(directRoom.getId()).thenReturn(21); + when(directRoom.getRecruitment()).thenReturn(recruitment); + when(directRoom.getRoomType()).thenReturn(TeamRecruitmentChatRoomType.DIRECT); + when(directRoom.getStatus()).thenReturn(TeamRecruitmentChatRoomStatus.ACTIVE); + when(counterpartMember.getChatRoom()).thenReturn(directRoom); + when(counterpartMember.getUser()).thenReturn(anonymousCounterpart); + when(recruitment.getId()).thenReturn(RECRUITMENT_ID); + when(memberRepository.findAllByUserIdWithChatRoomAndRecruitment(USER_ID)) + .thenReturn(List.of(currentMember)); + when(memberRepository.findAllWithUsersByChatRoomIds(List.of(21))) + .thenReturn(List.of(currentMember, counterpartMember)); + when(messageRepository.findLatestByChatRoomIds(List.of(21))).thenReturn(List.of()); + when(messageRepository.countUnreadMessagesByUserId(USER_ID)).thenReturn(List.of()); + + List responses = chatService.getChatRooms(USER_ID); + + assertThat(responses).hasSize(1); + assertThat(responses.get(0).roomName()).isEqualTo(anonymousCounterpart.getDisplayNickname()); + assertThat(responses.get(0).counterpartNickname()).isEqualTo(anonymousCounterpart.getDisplayNickname()); + assertThat(responses.get(0).counterpartNickname()).isNotNull(); + } + @Test void GENERAL_TEAM_채팅방에_작성자만_있으면_인원은_2명_중_1명이다() { ChatRoomResponse response = getChatRoom( From 50514cb39f0a05c082d821908eb105a84c055585 Mon Sep 17 00:00:00 2001 From: dnjswldnd-3513 Date: Wed, 9 Sep 2026 13:18:06 +0900 Subject: [PATCH 22/32] =?UTF-8?q?fix:=20=EC=B1=84=ED=8C=85=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EC=95=8C=EB=A6=BC=20outbox=20=EC=A7=81?= =?UTF-8?q?=EB=A0=AC=ED=99=94=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20KOIN=20?= =?UTF-8?q?=ED=91=9C=EC=A4=80=20=EC=98=88=EC=99=B8=20=EB=AF=B8=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../teamrecruitment/service/TeamRecruitmentChatService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentChatService.java b/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentChatService.java index 4d892a0e58..f010b7719a 100644 --- a/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentChatService.java +++ b/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentChatService.java @@ -376,7 +376,7 @@ private void saveChatMessageNotifications( .status(PENDING) .build()); } catch (JsonProcessingException e) { - throw new IllegalStateException("채팅 메시지 알림 outbox payload를 직렬화할 수 없습니다.", e); + throw CustomException.of(INTERNAL_SERVER_ERROR); } } } From c3a242d0bb2f6b3421ac72ff18793e7a128bd685 Mon Sep 17 00:00:00 2001 From: dnjswldnd-3513 Date: Wed, 9 Sep 2026 16:47:11 +0900 Subject: [PATCH 23/32] =?UTF-8?q?fix:=20=ED=8C=80=EC=9B=90=20=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EB=A7=88=EA=B0=90=20=EC=8B=9C=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=EB=B0=A9=20=EC=9D=BD=EA=B8=B0=20=EC=A0=84=EC=9A=A9=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model/TeamRecruitmentChatRoom.java | 4 ---- .../TeamRecruitmentDeadlineCloseProcessor.java | 15 --------------- .../service/TeamRecruitmentClosureService.java | 16 +--------------- .../TeamRecruitmentApplicationFlowApiTest.java | 7 +++---- .../TeamRecruitmentArticleFlowApiTest.java | 7 +++---- ...mRecruitmentDeadlineCloseIntegrationTest.java | 3 +-- ...eamRecruitmentDeadlineCloseProcessorTest.java | 7 ++----- 7 files changed, 10 insertions(+), 49 deletions(-) diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/model/TeamRecruitmentChatRoom.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/model/TeamRecruitmentChatRoom.java index d4cb22717a..3139334077 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/model/TeamRecruitmentChatRoom.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/model/TeamRecruitmentChatRoom.java @@ -125,10 +125,6 @@ public void removeMember(TeamRecruitmentChatMember member) { } } - public void markReadOnly() { - this.status = TeamRecruitmentChatRoomStatus.READ_ONLY; - } - @Transient public boolean isActive() { return status == TeamRecruitmentChatRoomStatus.ACTIVE; 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 eb0a9a6afa..68f7dd4b3b 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 @@ -2,7 +2,6 @@ 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.TeamRecruitmentChatRoomStatus.ACTIVE; import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentChatRoomType.TEAM; import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentNotificationTargetType.CHAT_ROOM; import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentNotificationTargetType.MY_APPLICATIONS; @@ -69,7 +68,6 @@ public void closeIfExpired(Integer recruitmentId, LocalDate today) { recruitment.close(); recruitmentRepository.save(recruitment); rejectPendingApplications(recruitment, pendingApplications); - markRoomsReadOnly(recruitmentId); notifyRejectedApplications(recruitment, pendingApplications); notifyAcceptedMembers(recruitment, teamRoom, acceptedApplications); } @@ -105,19 +103,6 @@ private void rejectPendingApplications( } } - private void markRoomsReadOnly(Integer recruitmentId) { - List chatRooms = chatRoomRepository.findAllByRecruitment_Id(recruitmentId); - if (chatRooms == null) { - return; - } - for (TeamRecruitmentChatRoom chatRoom : chatRooms) { - if (chatRoom.getStatus() == ACTIVE) { - chatRoom.markReadOnly(); - chatRoomRepository.save(chatRoom); - } - } - } - private void notifyRejectedApplications( TeamRecruitment recruitment, List pendingApplications diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/service/TeamRecruitmentClosureService.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/service/TeamRecruitmentClosureService.java index 8ca9fec474..83d6d84a90 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/service/TeamRecruitmentClosureService.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/service/TeamRecruitmentClosureService.java @@ -2,7 +2,6 @@ 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.TeamRecruitmentChatRoomStatus.ACTIVE; import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentNotificationTargetType.CHAT_ROOM; import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentNotificationTargetType.MY_APPLICATIONS; import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentNotificationType.APPLICATION_REJECTED; @@ -55,15 +54,13 @@ public class TeamRecruitmentClosureService { private final TeamRecruitmentOutboxEventRepository outboxEventRepository; /** - * 대기 중인 지원서를 거절하고, 모든 채팅방을 읽기 전용으로 바꾸고, - * 대기 중이던 지원자와 승인된 팀원에게 알림을 남긴다. + * 대기 중인 지원서를 거절하고, 대기 중이던 지원자와 승인된 팀원에게 알림을 남긴다. */ public void onClosed(TeamRecruitment recruitment) { List pending = findApplications(recruitment.getId(), PENDING); List accepted = findApplications(recruitment.getId(), ACCEPTED); rejectAll(pending, RECRUITMENT_CLOSED_REASON); - markRoomsReadOnly(recruitment.getId()); notifyRejected(recruitment, pending, closedRejectedMessage(recruitment)); notifyAccepted(recruitment, accepted, RECRUITMENT_CLOSED, closedMessage(recruitment)); } @@ -76,7 +73,6 @@ public void onDeleted(TeamRecruitment recruitment) { List accepted = findApplications(recruitment.getId(), ACCEPTED); rejectAll(pending, RECRUITMENT_DELETED_REASON); - markRoomsReadOnly(recruitment.getId()); notifyRejected(recruitment, pending, deletedRejectedMessage(recruitment)); notifyAccepted(recruitment, accepted, RECRUITMENT_DELETED, deletedMessage(recruitment)); } @@ -106,16 +102,6 @@ private void rejectAll(List applications, String rea applications.forEach(application -> application.reject(reason)); } - private void markRoomsReadOnly(Integer recruitmentId) { - List chatRooms = chatRoomRepository.findAllByRecruitment_Id(recruitmentId); - if (chatRooms == null) { - return; - } - chatRooms.stream() - .filter(chatRoom -> chatRoom.getStatus() == ACTIVE) - .forEach(TeamRecruitmentChatRoom::markReadOnly); - } - private void notifyRejected( TeamRecruitment recruitment, List pending, diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentApplicationFlowApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentApplicationFlowApiTest.java index e2d9cc6f1e..979e0588c9 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentApplicationFlowApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentApplicationFlowApiTest.java @@ -5,7 +5,6 @@ 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; @@ -445,20 +444,20 @@ private void assertRole( .andExpect(status().isNoContent()); assertThat(chatRoomRepository.findById(directRoom.getId()).orElseThrow().getStatus()) - .isEqualTo(READ_ONLY); + .isEqualTo(ACTIVE); mockMvc.perform(post("/chatroom/team-recruitment/{recruitmentId}/applications/{applicationId}/direct", recruitment.getId(), application.getId()) .header("Authorization", "Bearer " + authorToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.chat_room_id").value(directRoom.getId())) - .andExpect(jsonPath("$.status").value("READ_ONLY")); + .andExpect(jsonPath("$.status").value("ACTIVE")); mockMvc.perform(get("/chatroom/team-recruitment/{recruitmentId}/{chatRoomId}", recruitment.getId(), directRoom.getId()) .header("Authorization", "Bearer " + authorToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.status").value("READ_ONLY")); + .andExpect(jsonPath("$.status").value("ACTIVE")); } @Test diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentArticleFlowApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentArticleFlowApiTest.java index 8a10705eb8..e9d7cf5658 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentArticleFlowApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentArticleFlowApiTest.java @@ -5,7 +5,6 @@ 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.TEAM; import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentMeetingType.ONLINE; import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentNotificationTargetType.MY_APPLICATIONS; @@ -124,7 +123,7 @@ void setUp() { } @Test - @DisplayName("수동 마감하면 대기 지원서가 거절되고 채팅방이 READ_ONLY 로 바뀌며 알림이 남는다") + @DisplayName("수동 마감하면 대기 지원서가 거절되고 채팅방은 ACTIVE 유지되며 알림이 남는다") void 수동_마감_후속_처리() throws Exception { TeamRecruitment recruitment = saveGeneralRecruitment("수동 마감", 3, 0); TeamRecruitmentChatRoom teamRoom = saveTeamRoom(recruitment); @@ -139,7 +138,7 @@ void setUp() { assertThat(applicationRepository.findById(application.getId()).orElseThrow().getStatus()) .isEqualTo(REJECTED); assertThat(chatRoomRepository.findById(teamRoom.getId()).orElseThrow().getStatus()) - .isEqualTo(READ_ONLY); + .isEqualTo(ACTIVE); assertThat(notificationRepository.findAllByRecipient_IdAndIsDeletedFalse(applicant.getUser().getId())) .extracting(TeamRecruitmentNotification::getType) .contains(APPLICATION_REJECTED); @@ -161,7 +160,7 @@ void setUp() { assertThat(applicationRepository.findById(application.getId()).orElseThrow().getDecisionReason()) .isEqualTo("RECRUITMENT_DELETED"); assertThat(chatRoomRepository.findById(teamRoom.getId()).orElseThrow().getStatus()) - .isEqualTo(READ_ONLY); + .isEqualTo(ACTIVE); assertThat(notificationRepository.findAllByRecipient_IdAndIsDeletedFalse(applicant.getUser().getId())) .extracting(TeamRecruitmentNotification::getMessagePreview) .anyMatch(message -> message.contains("삭제되어")); diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentDeadlineCloseIntegrationTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentDeadlineCloseIntegrationTest.java index 93f52a1faf..13bdecc3a9 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentDeadlineCloseIntegrationTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentDeadlineCloseIntegrationTest.java @@ -5,7 +5,6 @@ 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; @@ -188,7 +187,7 @@ private void assertResults(Scenario scenario) { assertThat(successfulRecruitment.getStatus()).isEqualTo(CLOSED); assertThat(successfulPending.getStatus()).isEqualTo(REJECTED); assertThat(successfulPending.getDecisionReason()).isEqualTo("RECRUITMENT_CLOSED"); - assertThat(successfulTeamRoom.getStatus()).isEqualTo(READ_ONLY); + assertThat(successfulTeamRoom.getStatus()).isEqualTo(ACTIVE); assertThat(notificationRepository.findForOutbox( scenario.successfulApplicantId(), APPLICATION_REJECTED, 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 1459a176ec..34820f67ad 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 @@ -4,7 +4,6 @@ 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.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.TeamRecruitmentStatus.CLOSED; @@ -100,11 +99,9 @@ class CloseExpiredRecruitments { assertThat(pending.getStatus()).isEqualTo(REJECTED); assertThat(pending.getDecisionReason()).isEqualTo("RECRUITMENT_CLOSED"); assertThat(accepted.getStatus()).isEqualTo(ACCEPTED); - assertThat(teamRoom.getStatus()).isEqualTo(READ_ONLY); - assertThat(directRoom.getStatus()).isEqualTo(READ_ONLY); + assertThat(teamRoom.getStatus()).isEqualTo(ACTIVE); + assertThat(directRoom.getStatus()).isEqualTo(ACTIVE); verify(applicationRepository).save(pending); - verify(chatRoomRepository).save(teamRoom); - verify(chatRoomRepository).save(directRoom); verify(notificationRepository, org.mockito.Mockito.times(2)).save(any()); verify(outboxEventRepository, org.mockito.Mockito.times(2)).save(any()); ArgumentCaptor outboxCaptor = From 509471a2913acc14105ca06eb545578044e30db8 Mon Sep 17 00:00:00 2001 From: dnjswldnd-3513 Date: Wed, 9 Sep 2026 16:56:58 +0900 Subject: [PATCH 24/32] =?UTF-8?q?test:=20=EB=A7=88=EA=B0=90=20=EC=8B=9C=20?= =?UTF-8?q?=EC=B1=84=ED=8C=85=EB=B0=A9=20save=20=EB=AF=B8=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9D=B4=EB=A6=84=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../recruitment/model/TeamRecruitmentDirectChatPolicyTest.java | 2 +- .../scheduler/TeamRecruitmentDeadlineCloseProcessorTest.java | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/model/TeamRecruitmentDirectChatPolicyTest.java b/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/model/TeamRecruitmentDirectChatPolicyTest.java index d472623aff..73d7b8be4e 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/model/TeamRecruitmentDirectChatPolicyTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/model/TeamRecruitmentDirectChatPolicyTest.java @@ -44,7 +44,7 @@ class TeamRecruitmentDirectChatPolicyTest { } @Test - void 수동_기한_마감된_ACCEPTED_지원서는_READ_ONLY_TEAM_방이면_DIRECT를_열수없다() { + void READ_ONLY_TEAM_방이면_신규_DIRECT를_열수없다() { TeamRecruitment recruitment = recruitment(CLOSED); assertThat(canOpen(ACCEPTED, false, recruitment, teamRoom(recruitment, READ_ONLY))).isFalse(); 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 34820f67ad..58754e761b 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 @@ -102,6 +102,7 @@ class CloseExpiredRecruitments { assertThat(teamRoom.getStatus()).isEqualTo(ACTIVE); assertThat(directRoom.getStatus()).isEqualTo(ACTIVE); verify(applicationRepository).save(pending); + verify(chatRoomRepository, never()).save(any()); verify(notificationRepository, org.mockito.Mockito.times(2)).save(any()); verify(outboxEventRepository, org.mockito.Mockito.times(2)).save(any()); ArgumentCaptor outboxCaptor = From c011d61641c93cd6cd310f191b7fc99986d9a0e2 Mon Sep 17 00:00:00 2001 From: dnjswldnd-3513 Date: Wed, 9 Sep 2026 17:00:04 +0900 Subject: [PATCH 25/32] =?UTF-8?q?test:=20=EB=A7=88=EA=B0=90=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=ED=9B=84=20=EC=82=AC=EC=9A=A9=EB=90=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=20chatRoomRepository=20stub=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scheduler/TeamRecruitmentDeadlineCloseProcessorTest.java | 3 --- 1 file changed, 3 deletions(-) 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 58754e761b..b273cae2a3 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 @@ -81,7 +81,6 @@ class CloseExpiredRecruitments { stubApplications(pending, accepted); when(chatRoomRepository.findByRecruitment_IdAndRoomScopeKey(1, "TEAM")) .thenReturn(Optional.of(teamRoom)); - when(chatRoomRepository.findAllByRecruitment_Id(1)).thenReturn(List.of(teamRoom, directRoom)); when(outboxEventRepository.findByEventKey(any())).thenReturn(Optional.empty()); when(notificationRepository.save(any())).thenAnswer(invocation -> { TeamRecruitmentNotification notification = invocation.getArgument(0); @@ -150,7 +149,6 @@ class CloseExpiredRecruitments { )).thenReturn(new PageImpl<>(List.of(accepted))); when(chatRoomRepository.findByRecruitment_IdAndRoomScopeKey(1, "TEAM")) .thenReturn(Optional.empty()); - when(chatRoomRepository.findAllByRecruitment_Id(1)).thenReturn(List.of()); IllegalStateException exception = assertThrows( IllegalStateException.class, @@ -178,7 +176,6 @@ class CloseExpiredRecruitments { )).thenReturn(new PageImpl<>(List.of())); when(chatRoomRepository.findByRecruitment_IdAndRoomScopeKey(1, "TEAM")) .thenReturn(Optional.empty()); - when(chatRoomRepository.findAllByRecruitment_Id(1)).thenReturn(List.of()); processor.closeIfExpired(1, TODAY); From 3e97e2834fcb91683c263bfaf256bdf196e47fa9 Mon Sep 17 00:00:00 2001 From: dnjswldnd-3513 Date: Wed, 9 Sep 2026 17:18:04 +0900 Subject: [PATCH 26/32] =?UTF-8?q?test:=20=EC=88=98=EB=8F=99=20=EB=A7=88?= =?UTF-8?q?=EA=B0=90=20=ED=9B=84=20=EC=B1=84=ED=8C=85=EB=B0=A9=20ACTIVE=20?= =?UTF-8?q?=EC=9C=A0=EC=A7=80=EC=97=90=20=EB=94=B0=EB=A5=B8=20DIRECT=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EA=B0=80=EB=8A=A5=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/TeamRecruitmentApplicationFlowApiTest.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentApplicationFlowApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentApplicationFlowApiTest.java index 979e0588c9..3450689a9e 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentApplicationFlowApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentApplicationFlowApiTest.java @@ -315,7 +315,7 @@ private void assertRole( } @Test - void 수동_마감된_ACCEPTED_지원서는_DIRECT_CTA와_생성_조건이_모두_닫힌다() throws Exception { + void 수동_마감된_ACCEPTED_지원서는_ACTIVE_TEAM_방이_있어_DIRECT를_생성할_수_있다() throws Exception { TeamRecruitment recruitment = recruitmentContext.recruitment(); TeamRecruitmentApplication application = savePendingApplication(recruitment); @@ -330,13 +330,15 @@ private void assertRole( .header("Authorization", "Bearer " + authorToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.applications[0].status").value("ACCEPTED")) - .andExpect(jsonPath("$.applications[0].can_open_direct_chat").value(false)); + .andExpect(jsonPath("$.applications[0].can_open_direct_chat").value(true)); mockMvc.perform(post("/chatroom/team-recruitment/{recruitmentId}/applications/{applicationId}/direct", recruitment.getId(), application.getId()) .header("Authorization", "Bearer " + authorToken)) - .andExpect(status().isConflict()) - .andExpect(jsonPath("$.code").value("TEAM_RECRUITMENT_CLOSED")); + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.chat_room_id").isNumber()) + .andExpect(jsonPath("$.room_type").value("DIRECT")) + .andExpect(jsonPath("$.status").value("ACTIVE")); } @Test From 8d556c146d168da7a005535afec3da77ed16b2c1 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: Fri, 11 Sep 2026 13:41:11 +0900 Subject: [PATCH 27/32] =?UTF-8?q?fix:=20=EC=A3=BC=EB=AC=B8=20=EC=9D=B4?= =?UTF-8?q?=EB=A0=A5=EC=9D=B4=20=EC=9E=88=EB=8A=94=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=20=EC=8B=9C=20=EC=99=B8=EB=9E=98=20=ED=82=A4?= =?UTF-8?q?=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95=20(#2423)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 회원 탈퇴 시 주문과 결제 멱등키 참조 정리 * test: 회원 탈퇴 커밋과 동일 정보 재등록 검증 --- ...__fix_user_withdrawal_order_references.sql | 9 + .../UserWithdrawalMigrationTest.java | 341 ++++++++++++++++++ 2 files changed, 350 insertions(+) create mode 100644 src/main/resources/db/migration/V8__fix_user_withdrawal_order_references.sql create mode 100644 src/test/java/in/koreatech/koin/acceptance/migration/UserWithdrawalMigrationTest.java diff --git a/src/main/resources/db/migration/V8__fix_user_withdrawal_order_references.sql b/src/main/resources/db/migration/V8__fix_user_withdrawal_order_references.sql new file mode 100644 index 0000000000..dcd23b29f3 --- /dev/null +++ b/src/main/resources/db/migration/V8__fix_user_withdrawal_order_references.sql @@ -0,0 +1,9 @@ +ALTER TABLE `order` + DROP FOREIGN KEY `fk_order_user`, + ADD CONSTRAINT `fk_order_user_withdrawal` + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL; + +ALTER TABLE `payment_idempotency_key` + DROP FOREIGN KEY `fk_user`, + ADD CONSTRAINT `fk_payment_idempotency_key_user` + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE; diff --git a/src/test/java/in/koreatech/koin/acceptance/migration/UserWithdrawalMigrationTest.java b/src/test/java/in/koreatech/koin/acceptance/migration/UserWithdrawalMigrationTest.java new file mode 100644 index 0000000000..c15fc00cea --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/migration/UserWithdrawalMigrationTest.java @@ -0,0 +1,341 @@ +package in.koreatech.koin.acceptance.migration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +import org.flywaydb.core.Flyway; +import org.hibernate.Session; +import org.hibernate.SessionFactory; +import org.hibernate.cfg.Configuration; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.jpa.repository.support.JpaRepositoryFactory; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import in.koreatech.koin.domain.owner.repository.OwnerRepository; +import in.koreatech.koin.domain.student.repository.StudentRepository; +import in.koreatech.koin.domain.timetableV2.repository.TimetableFrameRepositoryV2; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.domain.user.service.RefreshTokenService; +import in.koreatech.koin.domain.user.service.UserService; +import in.koreatech.koin.domain.user.service.UserValidationService; +import in.koreatech.koin.domain.user.verification.service.UserVerificationService; +import in.koreatech.koin.global.auth.JwtProvider; + +@Testcontainers +class UserWithdrawalMigrationTest { + + @Container + private static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.0.29") + .withDatabaseName("user_withdrawal_migration") + .withUsername("test") + .withPassword("test"); + + @BeforeAll + static void migrateExistingData() throws SQLException { + Flyway.configure() + .dataSource(MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword()) + .locations("classpath:db/migration") + .target("7") + .load() + .migrate(); + + try (Connection connection = getConnection()) { + execute(connection, + """ + INSERT INTO users (id, password, user_type, anonymous_nickname) + VALUES (101, 'test', 'GENERAL', '탈퇴회원'), + (102, 'test', 'GENERAL', '다른회원'), + (103, 'test', 'GENERAL', '멱등키회원'), + (104, 'test', 'GENERAL', '신규회원'), + (105, 'test', 'GENERAL', '롤백회원') + """, + """ + INSERT INTO `order` (id, order_type, phone_number, total_price, user_id, is_deleted) + VALUES ('active-order', 'DELIVERY', '01000000000', 12000, 101, 0), + ('deleted-order', 'TAKEOUT', '01000000000', 8000, 101, 1), + ('other-order', 'TAKEOUT', '01000000001', 9000, 102, 0), + ('rollback-order', 'TAKEOUT', '01000000002', 10000, 105, 0) + """, + """ + INSERT INTO order_delivery (order_id, address, delivery_tip) + VALUES ('active-order', '테스트 배달 주소', 1000) + """, + """ + INSERT INTO order_takeout (order_id, to_owner) + VALUES ('deleted-order', '테스트 요청') + """, + """ + INSERT INTO order_menu (id, menu_name, menu_price, quantity, order_id) + VALUES (201, '테스트 메뉴', 11000, 1, 'active-order') + """, + """ + INSERT INTO order_menu_option + (id, option_name, option_price, quantity, order_menu_id, option_group_name) + VALUES (301, '테스트 옵션', 0, 1, 201, '테스트 옵션 그룹') + """, + """ + INSERT INTO payment + (id, payment_key, amount, status, method, requested_at, approved_at, order_id) + VALUES (401, 'test-active-payment', 12000, 'DONE', 'CARD', NOW(), NOW(), 'active-order'), + (402, 'test-deleted-payment', 8000, 'CANCELED', 'CARD', NOW(), NOW(), 'deleted-order') + """, + """ + INSERT INTO payment_cancel + (id, transaction_key, cancel_reason, cancel_amount, canceled_at, payment_id) + VALUES (501, 'test-cancel', '테스트 취소', 8000, NOW(), 402) + """, + """ + INSERT INTO payment_idempotency_key (user_id, idempotency_key) + VALUES (101, 'withdrawal-key'), (102, 'other-key'), + (103, 'key-only'), (105, 'rollback-key') + """ + ); + + assertThatThrownBy(() -> execute(connection, "DELETE FROM users WHERE id = 101")) + .isInstanceOf(SQLException.class) + .hasMessageContaining("fk_order_user"); + assertThatThrownBy(() -> execute(connection, "DELETE FROM users WHERE id = 103")) + .isInstanceOf(SQLException.class) + .hasMessageContaining("fk_user"); + } + + Flyway.configure() + .dataSource(MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword()) + .locations("classpath:db/migration") + .load() + .migrate(); + } + + @Test + void 탈퇴하면_기존_주문과_결제를_보존하고_회원_참조와_멱등키를_정리한다() throws SQLException { + deleteUserAndCommit(101); + + try (Connection connection = getConnection()) { + assertThat(queryInt(connection, "SELECT COUNT(*) FROM users WHERE id = 101")).isZero(); + assertThat(queryInt(connection, """ + SELECT COUNT(*) FROM `order` + WHERE id IN ('active-order', 'deleted-order') AND user_id IS NULL + """)).isEqualTo(2); + assertThat(queryInt(connection, """ + SELECT COUNT(*) FROM `order` + WHERE (id = 'active-order' AND is_deleted = 0 AND total_price = 12000) + OR (id = 'deleted-order' AND is_deleted = 1 AND total_price = 8000) + """)).isEqualTo(2); + assertThat(queryString(connection, """ + SELECT address FROM order_delivery WHERE order_id = 'active-order' + """)).isEqualTo("테스트 배달 주소"); + assertThat(queryString(connection, """ + SELECT to_owner FROM order_takeout WHERE order_id = 'deleted-order' + """)).isEqualTo("테스트 요청"); + assertThat(queryString(connection, "SELECT menu_name FROM order_menu WHERE id = 201")) + .isEqualTo("테스트 메뉴"); + assertThat(queryString(connection, "SELECT option_name FROM order_menu_option WHERE id = 301")) + .isEqualTo("테스트 옵션"); + assertThat(queryInt(connection, """ + SELECT COUNT(*) FROM payment + WHERE (id = 401 AND order_id = 'active-order' AND amount = 12000 AND status = 'DONE') + OR (id = 402 AND order_id = 'deleted-order' AND amount = 8000 AND status = 'CANCELED') + """)).isEqualTo(2); + assertThat(queryInt(connection, "SELECT cancel_amount FROM payment_cancel WHERE id = 501")) + .isEqualTo(8000); + assertThat(queryInt(connection, """ + SELECT COUNT(*) FROM payment_idempotency_key WHERE user_id = 101 + """)).isZero(); + assertThat(queryInt(connection, "SELECT user_id FROM `order` WHERE id = 'other-order'")) + .isEqualTo(102); + assertThat(queryString(connection, """ + SELECT idempotency_key FROM payment_idempotency_key WHERE user_id = 102 + """)).isEqualTo("other-key"); + } + } + + @Test + void 주문없이_결제_멱등키만_있는_회원이_탈퇴한다() throws SQLException { + deleteUserAndCommit(103); + + try (Connection connection = getConnection()) { + assertThat(queryInt(connection, "SELECT COUNT(*) FROM users WHERE id = 103")).isZero(); + assertThat(queryInt(connection, """ + SELECT COUNT(*) FROM payment_idempotency_key WHERE user_id = 103 + """)).isZero(); + } + } + + @Test + void 주문과_결제_멱등키가_없는_회원이_탈퇴한다() throws SQLException { + deleteUserAndCommit(104); + + try (Connection connection = getConnection()) { + assertThat(queryInt(connection, "SELECT COUNT(*) FROM users WHERE id = 104")).isZero(); + } + } + + @Test + void 탈퇴_서비스가_회원을_물리_삭제하고_같은_정보로_재등록해도_이전_주문이_연결되지_않는다() throws SQLException { + String insertUser = """ + INSERT INTO users + (password, user_type, anonymous_nickname, nickname, phone_number, email, user_id) + VALUES ('test', 'GENERAL', '재등록익명', '재등록회원', '01000000106', + 'withdrawal@example.com', 'withdrawal-test') + """; + int withdrawnUserId; + try (Connection connection = getConnection()) { + execute(connection, insertUser); + withdrawnUserId = queryInt(connection, + "SELECT id FROM users WHERE email = 'withdrawal@example.com'"); + execute(connection, + """ + INSERT INTO `order` (id, order_type, phone_number, user_id) + VALUES ('reregister-order', 'TAKEOUT', '01000000106', %d) + """.formatted(withdrawnUserId), + """ + INSERT INTO payment_idempotency_key (user_id, idempotency_key) + VALUES (%d, 'reregister-key') + """.formatted(withdrawnUserId) + ); + } + + // Flyway 스키마에서 실제 UserService와 JPA 저장소를 사용하고 커밋 시점의 DELETE까지 검증한다. + try (SessionFactory sessionFactory = new Configuration() + .addAnnotatedClass(User.class) + .setProperty("hibernate.connection.url", MYSQL.getJdbcUrl()) + .setProperty("hibernate.connection.username", MYSQL.getUsername()) + .setProperty("hibernate.connection.password", MYSQL.getPassword()) + .setProperty("hibernate.hbm2ddl.auto", "none") + .buildSessionFactory(); + Session session = sessionFactory.openSession()) { + UserRepository userRepository = new JpaRepositoryFactory(session).getRepository(UserRepository.class); + UserService userService = new UserService( + userRepository, + mock(StudentRepository.class), + mock(OwnerRepository.class), + mock(UserVerificationService.class), + mock(TimetableFrameRepositoryV2.class), + mock(ApplicationEventPublisher.class), + mock(UserValidationService.class), + mock(RefreshTokenService.class), + mock(JwtProvider.class), + mock(PasswordEncoder.class) + ); + session.beginTransaction(); + try { + userService.withdraw(withdrawnUserId); + session.getTransaction().commit(); + } catch (RuntimeException exception) { + if (session.getTransaction().isActive()) { + session.getTransaction().rollback(); + } + throw exception; + } + } + + try (Connection connection = getConnection()) { + assertThat(queryInt(connection, "SELECT COUNT(*) FROM users WHERE id = " + withdrawnUserId)).isZero(); + assertThat(queryInt(connection, + "SELECT COUNT(*) FROM payment_idempotency_key WHERE user_id = " + withdrawnUserId)).isZero(); + execute(connection, insertUser); + assertThat(queryInt(connection, + "SELECT id FROM users WHERE email = 'withdrawal@example.com'")).isNotEqualTo(withdrawnUserId); + assertThat(queryInt(connection, """ + SELECT COUNT(*) FROM `order` WHERE id = 'reregister-order' AND user_id IS NULL + """)).isOne(); + } + } + + @Test + void 탈퇴를_롤백하면_주문_참조와_결제_멱등키도_복구된다() throws SQLException { + try (Connection connection = getConnection()) { + connection.setAutoCommit(false); + try { + execute(connection, "DELETE FROM users WHERE id = 105"); + assertThat(queryInt(connection, """ + SELECT COUNT(*) FROM `order` WHERE id = 'rollback-order' AND user_id IS NULL + """)).isOne(); + assertThat(queryInt(connection, """ + SELECT COUNT(*) FROM payment_idempotency_key WHERE user_id = 105 + """)).isZero(); + } finally { + connection.rollback(); + } + } + + try (Connection connection = getConnection()) { + assertThat(queryInt(connection, "SELECT COUNT(*) FROM users WHERE id = 105")).isOne(); + assertThat(queryInt(connection, "SELECT user_id FROM `order` WHERE id = 'rollback-order'")) + .isEqualTo(105); + assertThat(queryString(connection, """ + SELECT idempotency_key FROM payment_idempotency_key WHERE user_id = 105 + """)).isEqualTo("rollback-key"); + } + } + + @Test + void 존재하지_않는_회원의_주문과_결제_멱등키는_저장할_수_없다() throws SQLException { + try (Connection connection = getConnection()) { + assertThatThrownBy(() -> execute(connection, """ + INSERT INTO `order` (id, order_type, phone_number, user_id) + VALUES ('invalid-order', 'TAKEOUT', '01000000000', 999) + """)) + .isInstanceOf(SQLException.class) + .hasMessageContaining("fk_order_user_withdrawal"); + assertThatThrownBy(() -> execute(connection, """ + INSERT INTO payment_idempotency_key (user_id, idempotency_key) + VALUES (999, 'invalid-key') + """)) + .isInstanceOf(SQLException.class) + .hasMessageContaining("fk_payment_idempotency_key_user"); + } + } + + private static Connection getConnection() throws SQLException { + return DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword()); + } + + private void deleteUserAndCommit(int userId) throws SQLException { + try (Connection connection = getConnection()) { + connection.setAutoCommit(false); + try { + execute(connection, "DELETE FROM users WHERE id = " + userId); + connection.commit(); + } catch (SQLException e) { + connection.rollback(); + throw e; + } + } + } + + private static void execute(Connection connection, String... queries) throws SQLException { + try (Statement statement = connection.createStatement()) { + for (String query : queries) { + statement.executeUpdate(query); + } + } + } + + private int queryInt(Connection connection, String query) throws SQLException { + try (Statement statement = connection.createStatement(); ResultSet result = statement.executeQuery(query)) { + assertThat(result.next()).isTrue(); + return result.getInt(1); + } + } + + private String queryString(Connection connection, String query) throws SQLException { + try (Statement statement = connection.createStatement(); ResultSet result = statement.executeQuery(query)) { + assertThat(result.next()).isTrue(); + return result.getString(1); + } + } +} From d343aca52f6541e485e512575b50a0300c391336 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: Fri, 11 Sep 2026 13:55:32 +0900 Subject: [PATCH 28/32] =?UTF-8?q?fix:=20=EC=85=94=ED=8B=80=20=EC=9E=94?= =?UTF-8?q?=EC=97=AC=20=EC=8B=9C=EA=B0=84=20=EC=A0=95=EB=A0=AC=20=EC=A4=91?= =?UTF-8?q?=20null=20=EB=8F=84=EC=B0=A9=20=EC=8B=9C=EA=B0=81=20NPE=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80=20(#2410)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 셔틀 잔여 시간 조회 시 null 도착 시각을 정렬 전에 제외하고, 메서드 자체의 중복 제거와 시간순 정렬을 유지합니다. 서비스 직접 호출 및 기존 응답 경로의 회귀 테스트를 포함합니다. --- .../service/shuttle/ShuttleBusService.java | 1 + .../domain/bus/ShuttleBusRemainTimeTest.java | 205 ++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 src/test/java/in/koreatech/koin/unit/domain/bus/ShuttleBusRemainTimeTest.java diff --git a/src/main/java/in/koreatech/koin/domain/bus/service/shuttle/ShuttleBusService.java b/src/main/java/in/koreatech/koin/domain/bus/service/shuttle/ShuttleBusService.java index 1185212cf0..b8c2cf0114 100644 --- a/src/main/java/in/koreatech/koin/domain/bus/service/shuttle/ShuttleBusService.java +++ b/src/main/java/in/koreatech/koin/domain/bus/service/shuttle/ShuttleBusService.java @@ -72,6 +72,7 @@ public List getShuttleBusRemainTimes(BusType busType, BusStation .filter(route -> route.isRunning(clock)) .filter(route -> route.isCorrectRoute(depart, arrival, clock)) .map(route -> route.getRemainTime(depart)) + .filter(remainTime -> remainTime.getBusArrivalTime() != null) .distinct() .sorted() .toList(); diff --git a/src/test/java/in/koreatech/koin/unit/domain/bus/ShuttleBusRemainTimeTest.java b/src/test/java/in/koreatech/koin/unit/domain/bus/ShuttleBusRemainTimeTest.java new file mode 100644 index 0000000000..40d7402f90 --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/domain/bus/ShuttleBusRemainTimeTest.java @@ -0,0 +1,205 @@ +package in.koreatech.koin.unit.domain.bus; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalTime; +import java.time.ZoneId; +import java.util.ArrayList; +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.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.BeanUtils; +import org.springframework.test.util.ReflectionTestUtils; + +import in.koreatech.koin.domain.bus.dto.BusRemainTimeResponse; +import in.koreatech.koin.domain.bus.dto.BusRemainTimeResponse.InnerBusResponse; +import in.koreatech.koin.domain.bus.enums.BusStation; +import in.koreatech.koin.domain.bus.enums.BusType; +import in.koreatech.koin.domain.bus.enums.ShuttleRouteType; +import in.koreatech.koin.domain.bus.service.BusNoticeRepository; +import in.koreatech.koin.domain.bus.service.BusService; +import in.koreatech.koin.domain.bus.service.city.CityBusService; +import in.koreatech.koin.domain.bus.service.express.ExpressBusService; +import in.koreatech.koin.domain.bus.service.model.BusRemainTime; +import in.koreatech.koin.domain.bus.service.shuttle.ShuttleBusRepository; +import in.koreatech.koin.domain.bus.service.shuttle.ShuttleBusService; +import in.koreatech.koin.domain.bus.service.shuttle.model.ArrivalNode; +import in.koreatech.koin.domain.bus.service.shuttle.model.Route; +import in.koreatech.koin.domain.version.model.Version; +import in.koreatech.koin.domain.version.model.VersionType; +import in.koreatech.koin.domain.version.service.VersionService; + +@ExtendWith(MockitoExtension.class) +class ShuttleBusRemainTimeTest { + + private static final String SEMESTER = "정규학기"; + private static final Clock CLOCK = Clock.fixed( + Instant.parse("2026-09-05T02:25:00Z"), ZoneId.of("Asia/Seoul")); + + @Mock + private ShuttleBusRepository shuttleBusRepository; + + @Mock + private VersionService versionService; + + @Mock + private BusNoticeRepository busNoticeRepository; + + @Mock + private ExpressBusService expressBusService; + + @Mock + private CityBusService cityBusService; + + private BusService busService; + private ShuttleBusService shuttleBusService; + + @BeforeEach + void setUp() { + when(versionService.getVersionEntity(VersionType.SHUTTLE)) + .thenReturn(Version.builder().title(SEMESTER).build()); + shuttleBusService = new ShuttleBusService(versionService, shuttleBusRepository, CLOCK); + busService = new BusService( + CLOCK, busNoticeRepository, versionService, List.of(), expressBusService, cityBusService, shuttleBusService); + } + + @Test + void 토요일_순환버스와_중복_정류장이_있는_주말버스를_함께_조회한다() { + // 공개 시간표의 토요일 오후 순환 노선과 하교 3회 구조를 재현한다. + Route cycle = route(ShuttleRouteType.SHUTTLE, null, + node("한기대", "14:00"), node("2캠퍼스", null), node("터미널", "14:25"), + node("천안역", "14:30"), node("한기대", "15:00")); + Route weekend = route(ShuttleRouteType.WEEKEND, "하교", + node("한기대", "19:10"), node("천안역B", "19:40"), node("터미널", "19:50"), + node("두정역", "하차"), node("터미널", null), node("천안역A", null), node("한기대", null)); + when(shuttleBusRepository.findAllBySemesterTypeAndRouteType(SEMESTER, ShuttleRouteType.SHUTTLE)) + .thenReturn(List.of(cycle)); + when(shuttleBusRepository.findAllBySemesterTypeAndRouteType(SEMESTER, ShuttleRouteType.WEEKEND)) + .thenReturn(List.of(weekend)); + + BusRemainTimeResponse response = getRemainTime(BusType.SHUTTLE); + + assertThat(response).isEqualTo(new BusRemainTimeResponse( + "shuttle", new InnerBusResponse(null, 10800L), null)); + } + + @ParameterizedTest + @EnumSource(value = BusType.class, names = {"SHUTTLE", "COMMUTING"}) + void 무효_시간을_제외하고_중복없는_가까운_두_버스를_반환한다(BusType busType) { + ShuttleRouteType type = routeType(busType); + givenRoutes(busType, List.of( + departure(type, "15:00"), + duplicateDeparture(type, null), + departure(type, "14:25"), + duplicateDeparture(type, "정차"), + departure(type, "14:25"), + departure(type, "16:00"), + departure(type, "10:00") + )); + + BusRemainTimeResponse response = getRemainTime(busType); + + assertThat(response).isEqualTo(new BusRemainTimeResponse( + busType.getName(), new InnerBusResponse(null, 10800L), new InnerBusResponse(null, 12900L))); + } + + @ParameterizedTest + @EnumSource(value = BusType.class, names = {"SHUTTLE", "COMMUTING"}) + void 셔틀_서비스는_null_시각을_제외하고_중복없는_시간순_목록을_반환한다(BusType busType) { + ShuttleRouteType type = routeType(busType); + givenRoutes(busType, List.of( + departure(type, "15:00"), + duplicateDeparture(type, null), + departure(type, "14:25"), + duplicateDeparture(type, "정차"), + departure(type, "14:25"), + departure(type, "16:00") + )); + + List remainTimes = shuttleBusService.getShuttleBusRemainTimes( + busType, BusStation.TERMINAL, BusStation.KOREATECH); + + assertThat(remainTimes) + .extracting(BusRemainTime::getBusArrivalTime) + .containsExactly(LocalTime.of(14, 25), LocalTime.of(15, 0), LocalTime.of(16, 0)); + } + + @ParameterizedTest + @EnumSource(value = BusType.class, names = {"SHUTTLE", "COMMUTING"}) + void 셔틀_서비스는_null_시각만_있으면_빈_목록을_반환한다(BusType busType) { + ShuttleRouteType type = routeType(busType); + givenRoutes(busType, List.of(duplicateDeparture(type, null), duplicateDeparture(type, "정차"))); + + List remainTimes = shuttleBusService.getShuttleBusRemainTimes( + busType, BusStation.TERMINAL, BusStation.KOREATECH); + + assertThat(remainTimes).isEmpty(); + } + + @ParameterizedTest + @EnumSource(value = BusType.class, names = {"SHUTTLE", "COMMUTING"}) + void 계산할_수_없는_시간과_운행_종료_항목만_있으면_빈_응답을_반환한다(BusType busType) { + ShuttleRouteType type = routeType(busType); + givenRoutes(busType, List.of( + duplicateDeparture(type, null), duplicateDeparture(type, "정차"), departure(type, "10:00"))); + + assertThat(getRemainTime(busType)).isEqualTo(new BusRemainTimeResponse(busType.getName(), null, null)); + } + + @ParameterizedTest + @EnumSource(value = BusType.class, names = {"SHUTTLE", "COMMUTING"}) + void 노선이_없으면_빈_응답을_반환한다(BusType busType) { + givenRoutes(busType, List.of()); + + assertThat(getRemainTime(busType)).isEqualTo(new BusRemainTimeResponse(busType.getName(), null, null)); + } + + private BusRemainTimeResponse getRemainTime(BusType busType) { + return busService.getBusRemainTime(busType, BusStation.TERMINAL, BusStation.KOREATECH); + } + + private void givenRoutes(BusType busType, List routes) { + when(shuttleBusRepository.findAllBySemesterTypeAndRouteType(SEMESTER, routeType(busType))) + .thenReturn(routes); + } + + private ShuttleRouteType routeType(BusType busType) { + return busType == BusType.SHUTTLE ? ShuttleRouteType.SHUTTLE : ShuttleRouteType.WEEKDAYS; + } + + private Route departure(ShuttleRouteType type, String time) { + return route(type, "등교", node("터미널", time), node("한기대", "도착")); + } + + private Route duplicateDeparture(ShuttleRouteType type, String firstTime) { + // 노선 판정과 시각 추출의 선택 불일치로 null 시각 객체가 생기는 경계를 재현한다. + return route(type, "등교", node("터미널", firstTime), node("터미널", "19:50"), node("한기대", "도착")); + } + + private Route route(ShuttleRouteType type, String direction, ArrivalNode... nodes) { + Route route = BeanUtils.instantiateClass(Route.class); + ReflectionTestUtils.setField(route, "routeName", "테스트 노선"); + ReflectionTestUtils.setField(route, "routeType", type); + ReflectionTestUtils.setField(route, "routeInfo", direction); + ReflectionTestUtils.setField(route, "routeDetail", direction); + ReflectionTestUtils.setField(route, "runningDays", List.of("SAT")); + ReflectionTestUtils.setField(route, "arrivalNodes", new ArrayList<>(List.of(nodes))); + return route; + } + + private ArrivalNode node(String name, String time) { + ArrivalNode node = BeanUtils.instantiateClass(ArrivalNode.class); + ReflectionTestUtils.setField(node, "nodeName", name); + ReflectionTestUtils.setField(node, "arrivalTime", time); + return node; + } +} From b02935d0e9edc82c9ecb2964fb1dc853c777d0f9 Mon Sep 17 00:00:00 2001 From: dnjswldnd-3513 Date: Sat, 12 Sep 2026 11:20:04 +0900 Subject: [PATCH 29/32] =?UTF-8?q?refactor:=20TeamRecruitmentChatRoom=20REA?= =?UTF-8?q?D=5FONLY=20=EC=83=81=ED=83=9C=20=EC=99=84=EC=A0=84=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TeamRecruitmentChatRoomStatus에서 READ_ONLY enum 값 제거 - TeamRecruitmentChatRoom의 isActive() 메서드 제거 - TeamRecruitmentChatService에서 READ_ONLY 메시지 전송 차단 로직 제거 - TeamRecruitmentDirectChatPolicy에서 isActive() 체크 제거 - ChatRoomResponse, DirectChatRoomResponse, TeamRecruitmentChatRoomListItemResponse에서 status 필드 제거 - TeamRecruitmentChatApi에서 TEAM_RECRUITMENT_CHAT_READ_ONLY 응답 코드 제거 - ApiResponseCode에서 TEAM_RECRUITMENT_CHAT_READ_ONLY 제거 - 관련 테스트 삭제 및 READ_ONLY → ACTIVE 변경 --- .../enums/TeamRecruitmentChatRoomStatus.java | 3 +- .../model/TeamRecruitmentChatRoom.java | 5 -- .../TeamRecruitmentDirectChatPolicy.java | 3 +- .../controller/TeamRecruitmentChatApi.java | 4 +- .../teamrecruitment/dto/ChatRoomResponse.java | 4 -- .../dto/DirectChatRoomResponse.java | 4 -- ...amRecruitmentChatRoomListItemResponse.java | 4 -- .../service/TeamRecruitmentChatService.java | 4 -- .../koin/global/code/ApiResponseCode.java | 1 - .../domain/TeamRecruitmentChatApiTest.java | 32 +--------- .../TeamRecruitmentDirectChatPolicyTest.java | 10 +-- ...ecruitmentApplicationQueryServiceTest.java | 61 +------------------ .../TeamRecruitmentChatServiceTest.java | 24 +------- 13 files changed, 8 insertions(+), 151 deletions(-) diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/enums/TeamRecruitmentChatRoomStatus.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/enums/TeamRecruitmentChatRoomStatus.java index 7aef747020..960a4a48b7 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/enums/TeamRecruitmentChatRoomStatus.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/enums/TeamRecruitmentChatRoomStatus.java @@ -1,6 +1,5 @@ package in.koreatech.koin.domain.team.recruitment.enums; public enum TeamRecruitmentChatRoomStatus { - ACTIVE, - READ_ONLY + ACTIVE } diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/model/TeamRecruitmentChatRoom.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/model/TeamRecruitmentChatRoom.java index 3139334077..d5168bf7fc 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/model/TeamRecruitmentChatRoom.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/model/TeamRecruitmentChatRoom.java @@ -14,7 +14,6 @@ import jakarta.persistence.OneToMany; import jakarta.persistence.OrderBy; import jakarta.persistence.Table; -import jakarta.persistence.Transient; import jakarta.persistence.UniqueConstraint; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; @@ -125,8 +124,4 @@ public void removeMember(TeamRecruitmentChatMember member) { } } - @Transient - public boolean isActive() { - return status == TeamRecruitmentChatRoomStatus.ACTIVE; - } } diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/model/TeamRecruitmentDirectChatPolicy.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/model/TeamRecruitmentDirectChatPolicy.java index 2ecbd1559e..81d093bfef 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/model/TeamRecruitmentDirectChatPolicy.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/model/TeamRecruitmentDirectChatPolicy.java @@ -36,7 +36,6 @@ public static boolean canOpenDirectChat( } return recruitment.getStatus() == CLOSED && teamChatRoom != null - && teamChatRoom.getRoomType() == TEAM - && teamChatRoom.isActive(); + && teamChatRoom.getRoomType() == TEAM; } } diff --git a/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentChatApi.java b/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentChatApi.java index e1c549ad89..1d397a640c 100644 --- a/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentChatApi.java +++ b/src/main/java/in/koreatech/koin/domain/teamrecruitment/controller/TeamRecruitmentChatApi.java @@ -9,7 +9,6 @@ import static in.koreatech.koin.global.code.ApiResponseCode.TEAM_RECRUITMENT_APPLICATION_NOT_FOUND; import static in.koreatech.koin.global.code.ApiResponseCode.TEAM_RECRUITMENT_CHAT_FORBIDDEN; import static in.koreatech.koin.global.code.ApiResponseCode.TEAM_RECRUITMENT_CHAT_NOT_FOUND; -import static in.koreatech.koin.global.code.ApiResponseCode.TEAM_RECRUITMENT_CHAT_READ_ONLY; import static in.koreatech.koin.global.code.ApiResponseCode.TEAM_RECRUITMENT_CLOSED; import static in.koreatech.koin.global.code.ApiResponseCode.TEAM_RECRUITMENT_FORBIDDEN; import static in.koreatech.koin.global.code.ApiResponseCode.TEAM_RECRUITMENT_NOT_FOUND; @@ -44,7 +43,7 @@ public interface TeamRecruitmentChatApi { @Operation( summary = "내 팀원 모집 채팅방 목록 조회", description = "현재 사용자가 멤버인 TEAM/DIRECT 채팅방을 최근 메시지순으로 반환합니다. " - + "메시지가 없는 방과 READ_ONLY 방도 포함합니다." + + "메시지가 없는 방도 포함합니다." ) ResponseEntity> getChatRooms(Integer userId); @@ -111,7 +110,6 @@ ResponseEntity> getMessages( INVALID_REQUEST_BODY, TEAM_RECRUITMENT_CHAT_NOT_FOUND, TEAM_RECRUITMENT_CHAT_FORBIDDEN, - TEAM_RECRUITMENT_CHAT_READ_ONLY, UNAUTHORIZED_USER, FORBIDDEN_USER_TYPE, }) diff --git a/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/ChatRoomResponse.java b/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/ChatRoomResponse.java index 35daf60afb..31b4105a47 100644 --- a/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/ChatRoomResponse.java +++ b/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/ChatRoomResponse.java @@ -19,9 +19,6 @@ public record ChatRoomResponse( @Schema(description = "채팅방 타입", example = "TEAM", requiredMode = REQUIRED) String roomType, - @Schema(description = "채팅방 상태", example = "ACTIVE", requiredMode = REQUIRED) - String status, - @Schema( description = "현재 채팅방 멤버 수. TEAM은 작성자와 승인된 지원자를 포함하고, DIRECT는 항상 2", example = "3", @@ -60,7 +57,6 @@ public static ChatRoomResponse of( chatRoom.getId(), roomName, chatRoom.getRoomType().name(), - chatRoom.getStatus().name(), memberCount, maxMemberCount, counterpart diff --git a/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/DirectChatRoomResponse.java b/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/DirectChatRoomResponse.java index d65d705aa7..d06093e493 100644 --- a/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/DirectChatRoomResponse.java +++ b/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/DirectChatRoomResponse.java @@ -20,9 +20,6 @@ public record DirectChatRoomResponse( @Schema(description = "채팅방 타입", example = "DIRECT", requiredMode = REQUIRED) String roomType, - @Schema(description = "채팅방 상태", example = "ACTIVE", requiredMode = REQUIRED) - String status, - @Schema(description = "상대방 정보", requiredMode = REQUIRED) Counterpart counterpart ) { @@ -39,7 +36,6 @@ public static DirectChatRoomResponse of(TeamRecruitmentChatRoom chatRoom, User c chatRoom.getId(), counterpartUser.getDisplayNickname(), chatRoom.getRoomType().name(), - chatRoom.getStatus().name(), new Counterpart(counterpartUser.getId(), counterpartUser.getDisplayNickname()) ); } diff --git a/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/TeamRecruitmentChatRoomListItemResponse.java b/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/TeamRecruitmentChatRoomListItemResponse.java index c261332d7a..937f562a5f 100644 --- a/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/TeamRecruitmentChatRoomListItemResponse.java +++ b/src/main/java/in/koreatech/koin/domain/teamrecruitment/dto/TeamRecruitmentChatRoomListItemResponse.java @@ -28,9 +28,6 @@ public record TeamRecruitmentChatRoomListItemResponse( @Schema(description = "채팅방 타입", example = "TEAM", requiredMode = REQUIRED) String roomType, - @Schema(description = "채팅방 상태", example = "ACTIVE", requiredMode = REQUIRED) - String status, - @Schema(description = "DIRECT 상대방 유저 ID. TEAM은 null입니다.", example = "22", nullable = true, requiredMode = REQUIRED) Integer counterpartId, @@ -71,7 +68,6 @@ public static TeamRecruitmentChatRoomListItemResponse of( chatRoom.getId(), direct && counterpart != null ? counterpart.getDisplayNickname() : chatRoom.getRecruitment().getTitle(), chatRoom.getRoomType().name(), - chatRoom.getStatus().name(), direct && counterpart != null ? counterpart.getId() : null, direct && counterpart != null ? counterpart.getDisplayNickname() : null, lastMessage == null ? null : lastMessage.getId(), diff --git a/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentChatService.java b/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentChatService.java index f010b7719a..7861590dfc 100644 --- a/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentChatService.java +++ b/src/main/java/in/koreatech/koin/domain/teamrecruitment/service/TeamRecruitmentChatService.java @@ -294,10 +294,6 @@ public ChatMessageResponse createMessage(Integer userId, Integer recruitmentId, TeamRecruitmentChatMember senderMember = memberRepository.findByChatRoom_IdAndUser_Id(chatRoomId, userId) .orElseThrow(() -> CustomException.of(TEAM_RECRUITMENT_CHAT_FORBIDDEN)); - if (!chatRoom.isActive()) { - throw CustomException.of(TEAM_RECRUITMENT_CHAT_READ_ONLY); - } - User sender = senderMember.getUser(); TeamRecruitmentChatMessage message = messageRepository.save( diff --git a/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java b/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java index ee1ca4b361..0a905c8bfe 100644 --- a/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java +++ b/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java @@ -186,7 +186,6 @@ public enum ApiResponseCode { TEAM_RECRUITMENT_ROLE_UPDATE_NOT_ALLOWED(HttpStatus.CONFLICT, "지원자가 있는 역할은 삭제, 이름 변경, 정원 축소를 할 수 없습니다."), TEAM_RECRUITMENT_MAX_PARTICIPANTS_BELOW_ACCEPTED(HttpStatus.CONFLICT, "승인된 인원보다 적은 정원으로 수정할 수 없습니다."), TEAM_RECRUITMENT_TYPE_CHANGE_NOT_ALLOWED(HttpStatus.CONFLICT, "지원자가 있으면 모집 유형을 변경할 수 없습니다."), - TEAM_RECRUITMENT_CHAT_READ_ONLY(HttpStatus.CONFLICT, "읽기 전용 채팅방입니다."), TEAM_RECRUITMENT_APPLICATION_NOT_ACCEPTED(HttpStatus.CONFLICT, "승인된 지원서가 아닙니다."), /** diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentChatApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentChatApiTest.java index ddd5168fa5..682c58c41e 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentChatApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentChatApiTest.java @@ -3,7 +3,6 @@ import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentCategory.PROJECT; import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentApplicationStatus.ACCEPTED; 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; @@ -130,7 +129,6 @@ void teamChatRoomRequiresMembership() throws Exception { .andExpect(jsonPath("$.chat_room_id").value(room.getId())) .andExpect(jsonPath("$.room_name").value(recruitment.getTitle())) .andExpect(jsonPath("$.room_type").value("TEAM")) - .andExpect(jsonPath("$.status").value("ACTIVE")) .andExpect(jsonPath("$.member_count").value(2)); mockMvc.perform(get("/chatroom/team-recruitment/{recruitmentId}/{chatRoomId}", recruitment.getId(), room.getId())) @@ -154,7 +152,7 @@ void listMyTeamRecruitmentChatRooms() throws Exception { TeamRecruitment recruitment = saveRecruitment("목록에서 보이는 팀 모집"); TeamRecruitmentChatRoom teamRoom = saveTeamRoom(recruitment, ACTIVE); TeamRecruitmentApplication application = saveAcceptedApplication(recruitment); - TeamRecruitmentChatRoom directRoom = saveDirectRoom(recruitment, application, READ_ONLY); + TeamRecruitmentChatRoom directRoom = saveDirectRoom(recruitment, application, ACTIVE); TeamRecruitmentChatMessage readTeamMessage = saveMessage(teamRoom, author.getUser(), "이미 읽은 팀 메시지", false); TeamRecruitmentChatMember applicantTeamMember = chatMemberRepository @@ -178,7 +176,6 @@ void listMyTeamRecruitmentChatRooms() throws Exception { .andExpect(jsonPath("$[0].chat_room_id").value(directRoom.getId())) .andExpect(jsonPath("$[0].room_name").value(author.getUser().getNickname())) .andExpect(jsonPath("$[0].room_type").value("DIRECT")) - .andExpect(jsonPath("$[0].status").value("READ_ONLY")) .andExpect(jsonPath("$[0].counterpart_id").value(author.getUser().getId())) .andExpect(jsonPath("$[0].counterpart_nickname").value(author.getUser().getNickname())) .andExpect(jsonPath("$[0].last_message_id").value(latestDirectMessage.getId())) @@ -382,33 +379,6 @@ void teamChatMessagePollingAndNotificationOutbox() throws Exception { .orElseThrow().getLastReadMessageId()).isEqualTo(fourthMessageId); } - @Test - @DisplayName("READ_ONLY TEAM 채팅방은 메시지 전송만 차단하고 기존 메시지 조회는 허용한다") - void readOnlyTeamChatIsReadableButNotWritable() throws Exception { - TeamRecruitment recruitment = saveRecruitment("읽기 전용 채팅"); - TeamRecruitmentChatRoom room = saveTeamRoom(recruitment, READ_ONLY); - TeamRecruitmentChatMessage existingMessage = chatMessageRepository.save(TeamRecruitmentChatMessage.builder() - .chatRoom(room) - .sender(author.getUser()) - .senderNickname(author.getUser().getNickname()) - .content("마감 전 메시지") - .isImage(false) - .build()); - entityManager.flush(); - - sendMessage(room, recruitment, authorToken, "마감 후 메시지") - .andExpect(status().isConflict()) - .andExpect(jsonPath("$.code").value("TEAM_RECRUITMENT_CHAT_READ_ONLY")); - - mockMvc.perform(get("/chatroom/team-recruitment/{recruitmentId}/{chatRoomId}/messages", - recruitment.getId(), room.getId()) - .header("Authorization", "Bearer " + applicantToken)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(1)) - .andExpect(jsonPath("$[0].message_id").value(existingMessage.getId())) - .andExpect(jsonPath("$[0].content").value("마감 전 메시지")); - } - private void assertChatNotificationOutbox( Integer messageId, Integer recipientId, diff --git a/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/model/TeamRecruitmentDirectChatPolicyTest.java b/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/model/TeamRecruitmentDirectChatPolicyTest.java index 73d7b8be4e..222a075638 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/model/TeamRecruitmentDirectChatPolicyTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/model/TeamRecruitmentDirectChatPolicyTest.java @@ -3,7 +3,6 @@ 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.TeamRecruitmentChatRoomStatus.ACTIVE; -import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentChatRoomStatus.READ_ONLY; import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentChatRoomType.TEAM; import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentStatus.CLOSED; import static in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentStatus.RECRUITING; @@ -43,13 +42,6 @@ class TeamRecruitmentDirectChatPolicyTest { assertThat(canOpen(ACCEPTED, false, recruitment, teamRoom(recruitment, ACTIVE))).isTrue(); } - @Test - void READ_ONLY_TEAM_방이면_신규_DIRECT를_열수없다() { - TeamRecruitment recruitment = recruitment(CLOSED); - - assertThat(canOpen(ACCEPTED, false, recruitment, teamRoom(recruitment, READ_ONLY))).isFalse(); - } - @Test void 삭제된_모집글은_ACTIVE_TEAM_방이_남아도_신규_DIRECT를_열수없다() { TeamRecruitment deleted = recruitment(CLOSED); @@ -62,7 +54,7 @@ class TeamRecruitmentDirectChatPolicyTest { void 기존_DIRECT_방은_모집글_상태와_관계없이_열수있다() { TeamRecruitment recruitment = recruitment(CLOSED); - assertThat(canOpen(ACCEPTED, true, recruitment, teamRoom(recruitment, READ_ONLY))).isTrue(); + assertThat(canOpen(ACCEPTED, true, recruitment, teamRoom(recruitment, ACTIVE))).isTrue(); } @Test diff --git a/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/service/TeamRecruitmentApplicationQueryServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/service/TeamRecruitmentApplicationQueryServiceTest.java index 1bd7dc7cf9..a72bf80aa9 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/service/TeamRecruitmentApplicationQueryServiceTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/team/recruitment/service/TeamRecruitmentApplicationQueryServiceTest.java @@ -4,7 +4,6 @@ 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.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.TeamRecruitmentStatus.CLOSED; @@ -530,40 +529,6 @@ void setUpClock() { assertThat(response.currentPage()).isEqualTo(3); } - @Test - void 수동_마감된_모집글의_ACCEPTED_지원자는_기존방이_없으면_DIRECT_CTA가_닫힌다() { - TeamRecruitment recruitment = recruitment(); - recruitment.close(); - TeamRecruitmentApplication accepted = applicationWithSnapshot(20, recruitment, ACCEPTED); - - when(studentRepository.getById(AUTHOR_ID)).thenReturn(null); - when(recruitmentRepository.findById(RECRUITMENT_ID)).thenReturn(Optional.of(recruitment)); - when(applicationRepository.countByRecruitment_IdAndStatusIn(eq(RECRUITMENT_ID), any())) - .thenReturn(1L); - when(applicationRepository.findAllByRecruitment_IdAndStatusIn( - eq(RECRUITMENT_ID), - any(), - any(Pageable.class) - )).thenReturn(new PageImpl<>(List.of(accepted))); - when(chatRoomRepository.findAllByRecruitment_IdInAndRoomScopeKeyAndRoomType( - List.of(RECRUITMENT_ID), - "TEAM", - TEAM - )).thenReturn(List.of(teamRoom(recruitment, READ_ONLY))); - when(chatRoomRepository.findAllByApplication_IdInAndRoomType(List.of(20), DIRECT)) - .thenReturn(List.of()); - - ApplicantListResponse response = queryService.getApplications( - RECRUITMENT_ID, - List.of(ACCEPTED), - 1, - 10, - AUTHOR_ID - ); - - assertThat(response.applications().get(0).canOpenDirectChat()).isFalse(); - } - @Test void 정원충족으로_자동_마감된_모집글은_ACTIVE_TEAM_방이_있으면_DIRECT_CTA가_열린다() { TeamRecruitment recruitment = recruitment(1, 1, CLOSED); @@ -641,7 +606,7 @@ void setUpClock() { .roomScopeKey("DIRECT-20") .roomType(DIRECT) .application(accepted) - .status(READ_ONLY) + .status(ACTIVE) .build(); when(studentRepository.getById(AUTHOR_ID)).thenReturn(null); @@ -657,7 +622,7 @@ void setUpClock() { List.of(RECRUITMENT_ID), "TEAM", TEAM - )).thenReturn(List.of(teamRoom(recruitment, READ_ONLY))); + )).thenReturn(List.of(teamRoom(recruitment, ACTIVE))); when(chatRoomRepository.findAllByApplication_IdInAndRoomType(List.of(20), DIRECT)) .thenReturn(List.of(existingDirect)); @@ -672,28 +637,6 @@ void setUpClock() { assertThat(response.applications().get(0).canOpenDirectChat()).isTrue(); } - @Test - void 지원서_상세도_마감된_모집글에서_기존방이_없으면_DIRECT_CTA가_닫힌다() { - TeamRecruitment recruitment = recruitment(); - recruitment.close(); - TeamRecruitmentApplication accepted = applicationWithSnapshot(20, recruitment, ACCEPTED); - - when(studentRepository.getById(AUTHOR_ID)).thenReturn(null); - when(recruitmentRepository.findById(RECRUITMENT_ID)).thenReturn(Optional.of(recruitment)); - when(applicationRepository.findById(20)).thenReturn(Optional.of(accepted)); - when(chatRoomRepository.findAllByRecruitment_IdInAndRoomScopeKeyAndRoomType( - List.of(RECRUITMENT_ID), - "TEAM", - TEAM - )).thenReturn(List.of(teamRoom(recruitment, READ_ONLY))); - when(chatRoomRepository.findAllByApplication_IdInAndRoomType(List.of(20), DIRECT)) - .thenReturn(List.of()); - - ApplicantDetail response = queryService.getApplicationDetail(RECRUITMENT_ID, 20, AUTHOR_ID); - - assertThat(response.canOpenDirectChat()).isFalse(); - } - @Test void 지원서_상세도_마감일이_지난_RECRUITING_모집글은_ACTIVE_TEAM_방이_있어도_DIRECT_CTA가_닫힌다() { TeamRecruitment recruitment = recruitment(LocalDate.of(2026, 8, 27)); diff --git a/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java index 903800d772..f92365059c 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java @@ -172,7 +172,7 @@ void setUpClock() { when(teamRoom.getRoomType()).thenReturn(TeamRecruitmentChatRoomType.TEAM); when(directRoom.getRoomType()).thenReturn(TeamRecruitmentChatRoomType.DIRECT); when(teamRoom.getStatus()).thenReturn(TeamRecruitmentChatRoomStatus.ACTIVE); - when(directRoom.getStatus()).thenReturn(TeamRecruitmentChatRoomStatus.READ_ONLY); + when(directRoom.getStatus()).thenReturn(TeamRecruitmentChatRoomStatus.ACTIVE); when(counterpartMember.getChatRoom()).thenReturn(directRoom); when(counterpartMember.getUser()).thenReturn(counterpart); when(teamMessage.getChatRoom()).thenReturn(teamRoom); @@ -523,7 +523,6 @@ void setUpClock() { when(chatRoom.getRecruitment()).thenReturn(recruitment); when(recruitment.getId()).thenReturn(RECRUITMENT_ID); when(memberRepository.findByChatRoom_IdAndUser_Id(CHAT_ROOM_ID, USER_ID)).thenReturn(Optional.of(senderMember)); - when(chatRoom.isActive()).thenReturn(true); when(senderMember.getUser()).thenReturn(anonymousSender); when(messageRepository.save(any(TeamRecruitmentChatMessage.class))).thenReturn(savedMessage); when(savedMessage.getId()).thenReturn(100); @@ -541,27 +540,6 @@ void setUpClock() { assertThat(captor.getValue().getSenderNickname()).isEqualTo(anonymousSender.getDisplayNickname()); } - @Test - void READ_ONLY_채팅방에_메시지_전송시_409를_반환한다() { - TeamRecruitmentChatRoom chatRoom = mock(TeamRecruitmentChatRoom.class); - TeamRecruitmentChatMember member = mock(TeamRecruitmentChatMember.class); - TeamRecruitment recruitment = mock(TeamRecruitment.class); - - when(chatRoomRepository.findById(CHAT_ROOM_ID)).thenReturn(Optional.of(chatRoom)); - when(chatRoom.getRecruitment()).thenReturn(recruitment); - when(recruitment.getId()).thenReturn(RECRUITMENT_ID); - when(memberRepository.findByChatRoom_IdAndUser_Id(CHAT_ROOM_ID, USER_ID)) - .thenReturn(Optional.of(member)); - when(chatRoom.isActive()).thenReturn(false); - - assertThatThrownBy(() -> chatService.createMessage( - USER_ID, RECRUITMENT_ID, CHAT_ROOM_ID, - new CreateChatMessageRequest("안녕", false))) - .isInstanceOf(CustomException.class) - .satisfies(e -> assertThat(((CustomException) e).getErrorCode()) - .isEqualTo(ApiResponseCode.TEAM_RECRUITMENT_CHAT_READ_ONLY)); - } - private ChatRoomResponse getChatRoom( TeamRecruitmentType recruitmentType, TeamRecruitmentChatRoomType roomType, From a6495a8b3f8877bfd14557da8ad7de29e76e1edf Mon Sep 17 00:00:00 2001 From: dnjswldnd-3513 Date: Sat, 12 Sep 2026 11:26:22 +0900 Subject: [PATCH 30/32] =?UTF-8?q?refactor:=20TeamRecruitmentChatRoom=20REA?= =?UTF-8?q?D=5FONLY=20=EC=83=81=ED=83=9C=20=EC=99=84=EC=A0=84=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TeamRecruitmentChatRoomStatus에서 READ_ONLY enum 값 제거 - TeamRecruitmentChatRoom의 isActive() 메서드 제거 - TeamRecruitmentChatService에서 READ_ONLY 메시지 전송 차단 로직 제거 - TeamRecruitmentDirectChatPolicy에서 isActive() 체크 제거 - ChatRoomResponse, DirectChatRoomResponse, TeamRecruitmentChatRoomListItemResponse에서 status 필드 제거 - TeamRecruitmentChatApi에서 TEAM_RECRUITMENT_CHAT_READ_ONLY 응답 코드 제거 - ApiResponseCode에서 TEAM_RECRUITMENT_CHAT_READ_ONLY 제거 - V9 마이그레이션: 기존 READ_ONLY 행 ACTIVE로 업데이트 - 관련 테스트 삭제 및 READ_ONLY → ACTIVE 변경 - OpenAPI 컨트랙트 테스트에서 status 필드 assertion 제거 --- ...9__migrate_chat_room_status_read_only_to_active.sql | 3 +++ .../domain/TeamRecruitmentOpenApiContractTest.java | 10 +++++----- 2 files changed, 8 insertions(+), 5 deletions(-) create mode 100644 src/main/resources/db/migration/V9__migrate_chat_room_status_read_only_to_active.sql diff --git a/src/main/resources/db/migration/V9__migrate_chat_room_status_read_only_to_active.sql b/src/main/resources/db/migration/V9__migrate_chat_room_status_read_only_to_active.sql new file mode 100644 index 0000000000..eaf2cb041c --- /dev/null +++ b/src/main/resources/db/migration/V9__migrate_chat_room_status_read_only_to_active.sql @@ -0,0 +1,3 @@ +UPDATE team_recruitment_chat_room +SET status = 'ACTIVE' +WHERE status = 'READ_ONLY'; diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentOpenApiContractTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentOpenApiContractTest.java index 52776e15f2..3a762a8f68 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentOpenApiContractTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentOpenApiContractTest.java @@ -77,13 +77,13 @@ void teamRecruitmentContractMatchesRuntime() throws Exception { ); JsonNode chatRoom = schema(openApi, "ChatRoomResponse"); - assertRequired(chatRoom, "chat_room_id", "room_name", "room_type", "status", "member_count", + assertRequired(chatRoom, "chat_room_id", "room_name", "room_type", "member_count", "max_member_count", "counterpart"); assertNullable(chatRoom, "counterpart"); assertInlineObject(chatRoom, "counterpart", "id", "nickname"); JsonNode chatRoomListItem = schema(openApi, "TeamRecruitmentChatRoomListItemResponse"); - assertRequired(chatRoomListItem, "recruitment_id", "chat_room_id", "room_name", "room_type", "status", + assertRequired(chatRoomListItem, "recruitment_id", "chat_room_id", "room_name", "room_type", "counterpart_id", "counterpart_nickname", "last_message_id", "last_message_content", "last_message_at", "last_message_is_image", "unread_message_count"); assertNullable(chatRoomListItem, "counterpart_id"); @@ -101,7 +101,7 @@ void teamRecruitmentContractMatchesRuntime() throws Exception { assertRequiredNullable(schema(openApi, "MyApplication"), "role"); JsonNode directChatRoom = schema(openApi, "DirectChatRoomResponse"); - assertRequired(directChatRoom, "chat_room_id", "room_name", "room_type", "status", "counterpart"); + assertRequired(directChatRoom, "chat_room_id", "room_name", "room_type", "counterpart"); assertNonNullableObject(openApi, directChatRoom, "counterpart", "id", "nickname"); JsonNode chatMessage = responseSchema(openApi, @@ -163,9 +163,9 @@ void teamRecruitmentDtosMatchRuntimeJson() { JsonNode application = objectMapper.valueToTree( new ApplicationCreatedResponse(51, 17, PENDING, null, timestamp)); JsonNode chatRoom = objectMapper.valueToTree( - new ChatRoomResponse(31, "팀 채팅방", "TEAM", "ACTIVE", 1, 2, null)); + new ChatRoomResponse(31, "팀 채팅방", "TEAM", 1, 2, null)); JsonNode chatRoomListItem = objectMapper.valueToTree(new TeamRecruitmentChatRoomListItemResponse( - 17, 31, "팀 채팅방", "TEAM", "ACTIVE", null, null, + 17, 31, "팀 채팅방", "TEAM", null, null, null, null, null, null, 0)); JsonNode notification = objectMapper.valueToTree(new TeamRecruitmentNotificationResponse( 101, "NEW_APPLICATION", "APPLICANT_MANAGEMENT", 17, null, null, null, From ba9b0e74873c8a10593c9a0af64f13b10de5fa36 Mon Sep 17 00:00:00 2001 From: dnjswldnd-3513 Date: Sat, 12 Sep 2026 12:04:11 +0900 Subject: [PATCH 31/32] =?UTF-8?q?fix:=20getStatus()=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=ED=95=9C=20stub=20=EB=B0=8F=20status=20DTO=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20assertion=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TeamRecruitmentChatServiceTest: getStatus() mock 제거 (STRICT_STUBS 오류) - TeamRecruitmentApplicationFlowApiTest: DirectChatRoomResponse/ChatRoomResponse에서 제거된 status 필드 assertion 제거 --- .../TeamRecruitmentApplicationFlowApiTest.java | 12 ++++-------- .../service/TeamRecruitmentChatServiceTest.java | 6 ------ 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentApplicationFlowApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentApplicationFlowApiTest.java index 3450689a9e..02c89cf9cd 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentApplicationFlowApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentApplicationFlowApiTest.java @@ -337,8 +337,7 @@ private void assertRole( .header("Authorization", "Bearer " + authorToken)) .andExpect(status().isCreated()) .andExpect(jsonPath("$.chat_room_id").isNumber()) - .andExpect(jsonPath("$.room_type").value("DIRECT")) - .andExpect(jsonPath("$.status").value("ACTIVE")); + .andExpect(jsonPath("$.room_type").value("DIRECT")); } @Test @@ -361,8 +360,7 @@ private void assertRole( .header("Authorization", "Bearer " + authorToken)) .andExpect(status().isCreated()) .andExpect(jsonPath("$.chat_room_id").isNumber()) - .andExpect(jsonPath("$.room_type").value("DIRECT")) - .andExpect(jsonPath("$.status").value("ACTIVE")); + .andExpect(jsonPath("$.room_type").value("DIRECT")); } @Test @@ -452,14 +450,12 @@ private void assertRole( recruitment.getId(), application.getId()) .header("Authorization", "Bearer " + authorToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.chat_room_id").value(directRoom.getId())) - .andExpect(jsonPath("$.status").value("ACTIVE")); + .andExpect(jsonPath("$.chat_room_id").value(directRoom.getId())); mockMvc.perform(get("/chatroom/team-recruitment/{recruitmentId}/{chatRoomId}", recruitment.getId(), directRoom.getId()) .header("Authorization", "Bearer " + authorToken)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.status").value("ACTIVE")); + .andExpect(status().isOk()); } @Test diff --git a/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java index f92365059c..08637adc24 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/teamrecruitment/service/TeamRecruitmentChatServiceTest.java @@ -32,7 +32,6 @@ import in.koreatech.koin.global.exception.CustomException; import in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentApplicationStatus; -import in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentChatRoomStatus; import in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentChatRoomType; import in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentStatus; import in.koreatech.koin.domain.team.recruitment.enums.TeamRecruitmentType; @@ -171,8 +170,6 @@ void setUpClock() { when(recruitment.getTitle()).thenReturn("팀원 모집"); when(teamRoom.getRoomType()).thenReturn(TeamRecruitmentChatRoomType.TEAM); when(directRoom.getRoomType()).thenReturn(TeamRecruitmentChatRoomType.DIRECT); - when(teamRoom.getStatus()).thenReturn(TeamRecruitmentChatRoomStatus.ACTIVE); - when(directRoom.getStatus()).thenReturn(TeamRecruitmentChatRoomStatus.ACTIVE); when(counterpartMember.getChatRoom()).thenReturn(directRoom); when(counterpartMember.getUser()).thenReturn(counterpart); when(teamMessage.getChatRoom()).thenReturn(teamRoom); @@ -242,7 +239,6 @@ void setUpClock() { when(directRoom.getId()).thenReturn(21); when(directRoom.getRecruitment()).thenReturn(recruitment); when(directRoom.getRoomType()).thenReturn(TeamRecruitmentChatRoomType.DIRECT); - when(directRoom.getStatus()).thenReturn(TeamRecruitmentChatRoomStatus.ACTIVE); when(counterpartMember.getChatRoom()).thenReturn(directRoom); when(counterpartMember.getUser()).thenReturn(anonymousCounterpart); when(recruitment.getId()).thenReturn(RECRUITMENT_ID); @@ -399,7 +395,6 @@ void setUpClock() { .thenReturn(Optional.of(existingRoom)); when(existingRoom.getId()).thenReturn(CHAT_ROOM_ID); when(existingRoom.getRoomType()).thenReturn(TeamRecruitmentChatRoomType.DIRECT); - when(existingRoom.getStatus()).thenReturn(TeamRecruitmentChatRoomStatus.ACTIVE); DirectChatRoomCreationResult result = chatService.getOrCreateDirectChatRoom(USER_ID, RECRUITMENT_ID, APPLICATION_ID); @@ -503,7 +498,6 @@ void setUpClock() { .thenReturn(Optional.of(existingRoom)); when(existingRoom.getId()).thenReturn(CHAT_ROOM_ID); when(existingRoom.getRoomType()).thenReturn(TeamRecruitmentChatRoomType.DIRECT); - when(existingRoom.getStatus()).thenReturn(TeamRecruitmentChatRoomStatus.ACTIVE); DirectChatRoomCreationResult result = chatService.getOrCreateDirectChatRoom(USER_ID, RECRUITMENT_ID, APPLICATION_ID); From 989f1dea0b597f471c506479788b5487dc06aa99 Mon Sep 17 00:00:00 2001 From: insik03 Date: Sun, 13 Sep 2026 00:02:03 +0900 Subject: [PATCH 32/32] =?UTF-8?q?fix:=20=ED=8C=80=EC=9B=90=20=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=ED=94=84=EB=A1=9C=ED=95=84=20=ED=99=9C=EB=8F=99=20?= =?UTF-8?q?=EC=84=A4=EB=AA=85=20=EA=B8=80=EC=9E=90=EC=88=98=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C=EC=9D=84=20500=EC=9E=90=EC=97=90=EC=84=9C=201000?= =?UTF-8?q?=EC=9E=90=EB=A1=9C=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 디자인 UI는 1000자 입력을 허용하지만 백엔드 검증/DB 컬럼은 500자로 고정되어 1000자에 가깝게 입력 시 400 에러가 발생하던 문제를 수정한다. - ProfileActivityInput, TeamRecruitmentProfileActivity 의 @Size 를 1000으로 확장 - team_recruitment_profile_activity.description 컬럼을 VARCHAR(1000)으로 확장하는 V10 마이그레이션 추가 - 1000자 성공 / 1001자 400 회귀 테스트 및 Swagger maxLength 계약 테스트 추가 close #2428 Co-Authored-By: Claude Sonnet 5 --- .../recruitment/dto/ProfileActivityInput.java | 2 +- .../model/TeamRecruitmentProfileActivity.java | 4 +- ...cruitment_profile_activity_description.sql | 2 + .../TeamRecruitmentOpenApiContractTest.java | 6 +++ .../domain/TeamRecruitmentProfileApiTest.java | 47 +++++++++++++++++++ 5 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 src/main/resources/db/migration/V10__widen_team_recruitment_profile_activity_description.sql diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/ProfileActivityInput.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/ProfileActivityInput.java index 593d2a077e..f44fbd9d80 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/ProfileActivityInput.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/dto/ProfileActivityInput.java @@ -42,7 +42,7 @@ public record ProfileActivityInput( @Schema(description = "활동 설명", example = "기획 담당", requiredMode = REQUIRED) @NotBlank - @Size(min = 1, max = 500) + @Size(min = 1, max = 1000) String description ) { public ProfileActivityInput { diff --git a/src/main/java/in/koreatech/koin/domain/team/recruitment/model/TeamRecruitmentProfileActivity.java b/src/main/java/in/koreatech/koin/domain/team/recruitment/model/TeamRecruitmentProfileActivity.java index ba74c2f5ab..0636263f30 100644 --- a/src/main/java/in/koreatech/koin/domain/team/recruitment/model/TeamRecruitmentProfileActivity.java +++ b/src/main/java/in/koreatech/koin/domain/team/recruitment/model/TeamRecruitmentProfileActivity.java @@ -59,8 +59,8 @@ public class TeamRecruitmentProfileActivity extends BaseEntity { private Boolean isOngoing; @NotNull - @Size(max = 500) - @Column(name = "description", nullable = false, length = 500) + @Size(max = 1000) + @Column(name = "description", nullable = false, length = 1000) private String description; @NotNull diff --git a/src/main/resources/db/migration/V10__widen_team_recruitment_profile_activity_description.sql b/src/main/resources/db/migration/V10__widen_team_recruitment_profile_activity_description.sql new file mode 100644 index 0000000000..f845a33838 --- /dev/null +++ b/src/main/resources/db/migration/V10__widen_team_recruitment_profile_activity_description.sql @@ -0,0 +1,2 @@ +ALTER TABLE `team_recruitment_profile_activity` + MODIFY COLUMN `description` VARCHAR(1000) NOT NULL; diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentOpenApiContractTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentOpenApiContractTest.java index 3a762a8f68..c3f3296abe 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentOpenApiContractTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentOpenApiContractTest.java @@ -143,6 +143,12 @@ void teamRecruitmentContractMatchesRuntime() throws Exception { "RECRUITMENT_CLOSED", "DEADLINE_PASSED", "ROLE_CLOSED", "PROFILE_REQUIRED"); assertRequiredNullable(schema(openApi, "RecruitmentDetail"), "application"); assertInlineObject(schema(openApi, "RecruitmentDetail"), "application", "application_id", "status"); + + assertThat(schema(openApi, "ProfileActivityInput") + .path("properties") + .path("description") + .path("maxLength") + .asInt()).isEqualTo(1000); } @Test diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentProfileApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentProfileApiTest.java index 10cc1cf05b..c5f2baab93 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentProfileApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/TeamRecruitmentProfileApiTest.java @@ -120,6 +120,26 @@ private static String activityBody(String endedAt, boolean isOngoing) { """.formatted(endedAt, isOngoing); } + private static String activityDescriptionBody(String description) { + return """ + { + "profile_nickname": "홍길동", + "preferred_role": "기획", + "skills": [], + "activities": [ + { + "title": "활동", + "started_at": "2025-03-03", + "ended_at": "2025-05-05", + "is_ongoing": false, + "description": "%s" + } + ], + "self_introduction": "소개" + } + """.formatted(description); + } + @Nested @DisplayName("GET /team-recruitment-profiles/me") class GetProfile { @@ -275,6 +295,33 @@ void endDateBeforeStartDate() throws Exception { .andExpect(jsonPath("$.code").value("INVALID_START_DATE_AFTER_END_DATE")); } + @Test + @DisplayName("활동 설명이 1000자이면 저장된다") + void activityDescriptionAtMaxLengthSucceeds() throws Exception { + String description = "가".repeat(1000); + + mockMvc.perform(put(URL) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(activityDescriptionBody(description))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.activities[0].description").value(description)); + } + + @Test + @DisplayName("활동 설명이 1000자를 넘으면 400 이다") + void activityDescriptionOverMaxLengthFails() throws Exception { + String description = "가".repeat(1001); + + mockMvc.perform(put(URL) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(activityDescriptionBody(description))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_REQUEST_BODY")) + .andExpect(jsonPath("$.fieldErrors[0].field").value("activities[0].description")); + } + @Test @DisplayName("닉네임이 20자를 넘으면 400 이다") void nicknameTooLong() throws Exception {