diff --git a/src/main/java/com/example/solidconnection/admin/service/AdminGpaScoreService.java b/src/main/java/com/example/solidconnection/admin/service/AdminGpaScoreService.java index 6e7840e7f..064e07f89 100644 --- a/src/main/java/com/example/solidconnection/admin/service/AdminGpaScoreService.java +++ b/src/main/java/com/example/solidconnection/admin/service/AdminGpaScoreService.java @@ -1,6 +1,7 @@ package com.example.solidconnection.admin.service; import static com.example.solidconnection.common.exception.ErrorCode.GPA_SCORE_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; import com.example.solidconnection.admin.dto.GpaScoreResponse; import com.example.solidconnection.admin.dto.GpaScoreSearchResponse; @@ -10,10 +11,12 @@ import com.example.solidconnection.common.VerifyStatus; import com.example.solidconnection.common.discord.DiscordNotificationType; import com.example.solidconnection.common.discord.DiscordNotifier; -import com.example.solidconnection.common.discord.DiscordReactionEmoji; +import com.example.solidconnection.common.discord.DiscordReviewMarker; import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.score.domain.GpaScore; import com.example.solidconnection.score.repository.GpaScoreRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -25,6 +28,7 @@ public class AdminGpaScoreService { private final GpaScoreRepository gpaScoreRepository; + private final SiteUserRepository siteUserRepository; private final DiscordNotifier discordNotifier; @Transactional(readOnly = true) @@ -45,18 +49,26 @@ public GpaScoreResponse updateGpaScore(Long gpaScoreId, GpaScoreUpdateRequest re request.verifyStatus(), request.verifyStatus() == VerifyStatus.REJECTED ? request.rejectedReason() : null ); - publishReaction(gpaScoreId, request.verifyStatus()); + publishReviewResult(gpaScore, request.verifyStatus()); return GpaScoreResponse.from(gpaScore); } - private void publishReaction(long gpaScoreId, VerifyStatus verifyStatus) { - String emoji = switch (verifyStatus) { - case APPROVED -> DiscordReactionEmoji.APPROVED.getValue(); - case REJECTED -> DiscordReactionEmoji.REJECTED.getValue(); + private void publishReviewResult(GpaScore gpaScore, VerifyStatus verifyStatus) { + String marker = switch (verifyStatus) { + case APPROVED -> DiscordReviewMarker.APPROVED.getValue(); + case REJECTED -> DiscordReviewMarker.REJECTED.getValue(); case PENDING -> null; }; - if (emoji != null) { - discordNotifier.addReaction(DiscordNotificationType.GPA_SCORE, gpaScoreId, emoji); + if (marker == null) { + return; } + SiteUser siteUser = siteUserRepository.findById(gpaScore.getSiteUserId()) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + discordNotifier.markReviewResult( + DiscordNotificationType.GPA_SCORE, + gpaScore.getId(), + siteUser.getNickname(), + marker + ); } } diff --git a/src/main/java/com/example/solidconnection/admin/service/AdminLanguageTestScoreService.java b/src/main/java/com/example/solidconnection/admin/service/AdminLanguageTestScoreService.java index 159091149..d52966f30 100644 --- a/src/main/java/com/example/solidconnection/admin/service/AdminLanguageTestScoreService.java +++ b/src/main/java/com/example/solidconnection/admin/service/AdminLanguageTestScoreService.java @@ -1,6 +1,7 @@ package com.example.solidconnection.admin.service; import static com.example.solidconnection.common.exception.ErrorCode.LANGUAGE_TEST_SCORE_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; import com.example.solidconnection.admin.dto.LanguageTestScoreResponse; import com.example.solidconnection.admin.dto.LanguageTestScoreSearchResponse; @@ -10,10 +11,12 @@ import com.example.solidconnection.common.VerifyStatus; import com.example.solidconnection.common.discord.DiscordNotificationType; import com.example.solidconnection.common.discord.DiscordNotifier; -import com.example.solidconnection.common.discord.DiscordReactionEmoji; +import com.example.solidconnection.common.discord.DiscordReviewMarker; import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.score.domain.LanguageTestScore; import com.example.solidconnection.score.repository.LanguageTestScoreRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -25,6 +28,7 @@ public class AdminLanguageTestScoreService { private final LanguageTestScoreRepository languageTestScoreRepository; + private final SiteUserRepository siteUserRepository; private final DiscordNotifier discordNotifier; @Transactional(readOnly = true) @@ -45,18 +49,26 @@ public LanguageTestScoreResponse updateLanguageTestScore(Long languageTestScoreI request.verifyStatus(), request.verifyStatus() == VerifyStatus.REJECTED ? request.rejectedReason() : null ); - publishReaction(languageTestScoreId, request.verifyStatus()); + publishReviewResult(languageTestScore, request.verifyStatus()); return LanguageTestScoreResponse.from(languageTestScore); } - private void publishReaction(long languageTestScoreId, VerifyStatus verifyStatus) { - String emoji = switch (verifyStatus) { - case APPROVED -> DiscordReactionEmoji.APPROVED.getValue(); - case REJECTED -> DiscordReactionEmoji.REJECTED.getValue(); + private void publishReviewResult(LanguageTestScore languageTestScore, VerifyStatus verifyStatus) { + String marker = switch (verifyStatus) { + case APPROVED -> DiscordReviewMarker.APPROVED.getValue(); + case REJECTED -> DiscordReviewMarker.REJECTED.getValue(); case PENDING -> null; }; - if (emoji != null) { - discordNotifier.addReaction(DiscordNotificationType.LANGUAGE_TEST_SCORE, languageTestScoreId, emoji); + if (marker == null) { + return; } + SiteUser siteUser = siteUserRepository.findById(languageTestScore.getSiteUserId()) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + discordNotifier.markReviewResult( + DiscordNotificationType.LANGUAGE_TEST_SCORE, + languageTestScore.getId(), + siteUser.getNickname(), + marker + ); } } diff --git a/src/main/java/com/example/solidconnection/admin/service/AdminMentorApplicationService.java b/src/main/java/com/example/solidconnection/admin/service/AdminMentorApplicationService.java index 33563faa9..a2cbed69a 100644 --- a/src/main/java/com/example/solidconnection/admin/service/AdminMentorApplicationService.java +++ b/src/main/java/com/example/solidconnection/admin/service/AdminMentorApplicationService.java @@ -11,7 +11,7 @@ import com.example.solidconnection.admin.dto.MentorApplicationSearchResponse; import com.example.solidconnection.common.discord.DiscordNotificationType; import com.example.solidconnection.common.discord.DiscordNotifier; -import com.example.solidconnection.common.discord.DiscordReactionEmoji; +import com.example.solidconnection.common.discord.DiscordReviewMarker; import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.mentor.domain.Mentor; import com.example.solidconnection.mentor.domain.MentorApplication; @@ -67,7 +67,7 @@ public void approveMentorApplication(Long mentorApplicationId) { ); mentorRepository.save(mentor); - publishReaction(mentorApplicationId, DiscordReactionEmoji.APPROVED.getValue()); + publishReviewResult(mentorApplicationId, siteUser.getNickname(), DiscordReviewMarker.APPROVED.getValue()); } private void validateUserCanCreateMentor(long siteUserId) { @@ -85,11 +85,18 @@ public void rejectMentorApplication( .orElseThrow(() -> new CustomException(MENTOR_APPLICATION_NOT_FOUND)); mentorApplication.reject(request.rejectedReason()); - publishReaction(mentorApplicationId, DiscordReactionEmoji.REJECTED.getValue()); + SiteUser siteUser = siteUserRepository.findById(mentorApplication.getSiteUserId()) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + publishReviewResult(mentorApplicationId, siteUser.getNickname(), DiscordReviewMarker.REJECTED.getValue()); } - private void publishReaction(long mentorApplicationId, String emoji) { - discordNotifier.addReaction(DiscordNotificationType.MENTOR_APPLICATION, mentorApplicationId, emoji); + private void publishReviewResult(long mentorApplicationId, String applicantInfo, String marker) { + discordNotifier.markReviewResult( + DiscordNotificationType.MENTOR_APPLICATION, + mentorApplicationId, + applicantInfo, + marker + ); } @Transactional(readOnly = true) diff --git a/src/main/java/com/example/solidconnection/common/config/client/RestTemplateConfig.java b/src/main/java/com/example/solidconnection/common/config/client/RestTemplateConfig.java index 31e41a295..893b1976e 100644 --- a/src/main/java/com/example/solidconnection/common/config/client/RestTemplateConfig.java +++ b/src/main/java/com/example/solidconnection/common/config/client/RestTemplateConfig.java @@ -1,10 +1,11 @@ package com.example.solidconnection.common.config.client; import java.time.Duration; +import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder; +import org.springframework.boot.http.client.ClientHttpRequestFactorySettings; import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.web.client.RestTemplate; @Configuration @@ -24,20 +25,14 @@ public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) { * - Discord webhook url 은 경로에 인증 토큰을 포함한다. * - RestTemplateBuilder 로 만든 RestTemplate 은 observation 이 적용되어 요청 url 이 메트릭 태그로 남을 수 있으므로, * webhook 전송에는 observation 이 붙지 않는 별도 인스턴스를 사용한다. + * - 메시지 편집에 PATCH 를 사용하는데, SimpleClientHttpRequestFactory 는 PATCH 를 지원하지 않으므로 + * JDK HttpClient 기반 팩토리를 사용한다. * */ @Bean public RestTemplate discordWebhookRestTemplate() { - SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); - requestFactory.setConnectTimeout((int) TIMEOUT.toMillis()); - requestFactory.setReadTimeout((int) TIMEOUT.toMillis()); - return new RestTemplate(requestFactory); - } - - @Bean - public RestTemplate discordBotRestTemplate() { - SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); - requestFactory.setConnectTimeout((int) TIMEOUT.toMillis()); - requestFactory.setReadTimeout((int) TIMEOUT.toMillis()); - return new RestTemplate(requestFactory); + return new RestTemplate(ClientHttpRequestFactoryBuilder.jdk() + .build(ClientHttpRequestFactorySettings.defaults() + .withConnectTimeout(TIMEOUT) + .withReadTimeout(TIMEOUT))); } } diff --git a/src/main/java/com/example/solidconnection/common/discord/DiscordNotifier.java b/src/main/java/com/example/solidconnection/common/discord/DiscordNotifier.java index 51e5c5e7e..6c15e24a4 100644 --- a/src/main/java/com/example/solidconnection/common/discord/DiscordNotifier.java +++ b/src/main/java/com/example/solidconnection/common/discord/DiscordNotifier.java @@ -1,39 +1,48 @@ package com.example.solidconnection.common.discord; +import com.example.solidconnection.common.discord.domain.DiscordNotification; import com.example.solidconnection.common.discord.service.DiscordNotificationService; -import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpClientErrorException; @Component -@RequiredArgsConstructor @EnableAsync +@Slf4j public class DiscordNotifier { private static final String ADMIN_PAGE_URL = "https://www.admins.solid-connection.com"; private final DiscordWebhookSender discordWebhookSender; private final DiscordNotificationService discordNotificationService; - private final DiscordReactionClient discordReactionClient; + private final String webhookUrl; + private final String environment; - @Value("${discord.webhook-url:}") - private String webhookUrl; - - @Value("${spring.profiles.active:}") - private String environment; + public DiscordNotifier( + DiscordWebhookSender discordWebhookSender, + DiscordNotificationService discordNotificationService, + @Value("${discord.webhook-url:}") String webhookUrl, + @Value("${spring.profiles.active:}") String environment + ) { + this.discordWebhookSender = discordWebhookSender; + this.discordNotificationService = discordNotificationService; + this.webhookUrl = webhookUrl; + this.environment = environment; + } @Async public void notify(DiscordNotificationType type, String applicantInfo) { - if (webhookUrl.isBlank() || "local".equalsIgnoreCase(environment)) { + if (isNotifyDisabled()) { return; } discordWebhookSender.send(webhookUrl, buildMessage(type, applicantInfo)); } public void notify(DiscordNotificationType type, long reviewId, String applicantInfo) { - if (webhookUrl.isBlank() || "local".equalsIgnoreCase(environment)) { + if (isNotifyDisabled()) { return; } DiscordMessageResponse response = discordWebhookSender.sendAndGetMessage( @@ -43,13 +52,50 @@ public void notify(DiscordNotificationType type, long reviewId, String applicant discordNotificationService.save(type, reviewId, response.channelId(), response.id()); } - public void addReaction(DiscordNotificationType type, long reviewId, String emoji) { + /* + * - 검수 결과를 알림 메시지 앞에 마커로 표시한다. + * - 봇 권한이 필요한 반응 대신, webhook 이 보낸 메시지를 직접 편집한다. + * - 검수 시점 데이터로 본문을 다시 만들어 통째로 덮어쓰므로 마커가 누적되지 않는다. + * */ + public void markReviewResult( + DiscordNotificationType type, + long reviewId, + String applicantInfo, + String marker + ) { + if (isNotifyDisabled()) { + return; + } discordNotificationService.findByReviewTypeAndReviewId(type, reviewId) - .ifPresent(message -> discordReactionClient.addReaction( - message.getDiscordChannelId(), - message.getDiscordMessageId(), - emoji - )); + .ifPresent(notification -> editMessage(notification, type, applicantInfo, marker)); + } + + private void editMessage( + DiscordNotification notification, + DiscordNotificationType type, + String applicantInfo, + String marker + ) { + try { + discordWebhookSender.editMessage( + webhookUrl, + notification.getDiscordMessageId(), + marker + " " + buildMessage(type, applicantInfo) + ); + } catch (HttpClientErrorException.NotFound e) { + /* + * - 편집 대상이 사라진 경우로, 재시도해도 성공하지 않으므로 검수를 실패시키지 않는다. + * - 디스코드는 메시지 부재(10008)와 webhook 부재(10015)를 모두 404 로 응답한다. + * webhook 부재는 설정 사고이므로 구분할 수 있도록 응답 본문을 남긴다. + * - 응답 본문에는 webhook url 이 포함되지 않으므로 토큰이 노출되지 않는다. + * */ + log.warn("검수 결과를 표시하지 못했습니다. reviewType={}, reviewId={}, discordError={}", + type, notification.getReviewId(), e.getResponseBodyAsString()); + } + } + + private boolean isNotifyDisabled() { + return webhookUrl.isBlank() || "local".equalsIgnoreCase(environment); } private String buildMessage(DiscordNotificationType type, String applicantInfo) { diff --git a/src/main/java/com/example/solidconnection/common/discord/DiscordReactionClient.java b/src/main/java/com/example/solidconnection/common/discord/DiscordReactionClient.java deleted file mode 100644 index 7c0033fb9..000000000 --- a/src/main/java/com/example/solidconnection/common/discord/DiscordReactionClient.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.example.solidconnection.common.discord; - -import java.nio.charset.StandardCharsets; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; -import org.springframework.web.util.UriUtils; - -@Component -public class DiscordReactionClient { - - private static final String REACTION_URL = - "https://discord.com/api/v10/channels/%s/messages/%s/reactions/%s/@me"; - - private final RestTemplate discordBotRestTemplate; - - @Value("${discord.bot-token:}") - private String botToken; - - public DiscordReactionClient(@Qualifier("discordBotRestTemplate") RestTemplate discordBotRestTemplate) { - this.discordBotRestTemplate = discordBotRestTemplate; - } - - public void addReaction(String channelId, String messageId, String emoji) { - String encodedEmoji = UriUtils.encodePathSegment(emoji, StandardCharsets.UTF_8); - String url = REACTION_URL.formatted(channelId, messageId, encodedEmoji); - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", "Bot " + botToken); - discordBotRestTemplate.exchange(url, HttpMethod.PUT, new HttpEntity<>(headers), Void.class); - } -} diff --git a/src/main/java/com/example/solidconnection/common/discord/DiscordReactionEmoji.java b/src/main/java/com/example/solidconnection/common/discord/DiscordReviewMarker.java similarity index 72% rename from src/main/java/com/example/solidconnection/common/discord/DiscordReactionEmoji.java rename to src/main/java/com/example/solidconnection/common/discord/DiscordReviewMarker.java index bd1852fad..6d34fe52c 100644 --- a/src/main/java/com/example/solidconnection/common/discord/DiscordReactionEmoji.java +++ b/src/main/java/com/example/solidconnection/common/discord/DiscordReviewMarker.java @@ -3,7 +3,7 @@ import lombok.Getter; @Getter -public enum DiscordReactionEmoji { +public enum DiscordReviewMarker { APPROVED("✅"), REJECTED("❌"), @@ -11,7 +11,7 @@ public enum DiscordReactionEmoji { private final String value; - DiscordReactionEmoji(String value) { + DiscordReviewMarker(String value) { this.value = value; } } diff --git a/src/main/java/com/example/solidconnection/common/discord/DiscordWebhookSender.java b/src/main/java/com/example/solidconnection/common/discord/DiscordWebhookSender.java index 9446d072c..425b7d735 100644 --- a/src/main/java/com/example/solidconnection/common/discord/DiscordWebhookSender.java +++ b/src/main/java/com/example/solidconnection/common/discord/DiscordWebhookSender.java @@ -6,14 +6,16 @@ import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClientResponseException; import org.springframework.web.client.RestTemplate; /* - * - Discord Webhook 으로 메시지를 전송한다. - * - 알림 전송 실패가 호출한 기능을 실패시키지 않도록 예외를 격리하고, 전송 여부를 반환해 후속 처리를 맡긴다. + * - Discord Webhook 으로 메시지를 전송하고 편집한다. + * - send() 만 예외를 격리하고 전송 여부를 반환한다. + * sendAndGetMessage() 와 editMessage() 는 호출한 기능이 결과를 알아야 하므로 예외를 전파한다. * - 채널별로 webhook url 이 다르므로 url 을 인자로 받는다. * - webhook url 은 경로에 인증 토큰을 포함하므로 로그와 메트릭에 남기지 않는다. * */ @@ -62,6 +64,28 @@ public DiscordMessageResponse sendAndGetMessage(String webhookUrl, String conten ); } + /* + * - webhook 이 보낸 메시지는 봇 권한 없이 webhook 토큰만으로 편집할 수 있다. + * - PATCH 는 content 를 통째로 덮어쓴다. + * */ + public void editMessage(String webhookUrl, String messageId, String content) { + discordWebhookRestTemplate.exchange( + messageUrl(webhookUrl, messageId), + HttpMethod.PATCH, + buildRequest(content, List.of()), + Void.class + ); + } + + // webhook url 에 쿼리스트링이 붙어 있을 수 있으므로 경로 뒤에 이어붙이지 않는다. + private String messageUrl(String webhookUrl, String messageId) { + int queryIndex = webhookUrl.indexOf('?'); + if (queryIndex < 0) { + return webhookUrl + "/messages/" + messageId; + } + return webhookUrl.substring(0, queryIndex) + "/messages/" + messageId + webhookUrl.substring(queryIndex); + } + private HttpEntity> buildRequest(String content, List mentionableRoleIds) { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); diff --git a/src/test/java/com/example/solidconnection/admin/service/AdminGpaScoreServiceTest.java b/src/test/java/com/example/solidconnection/admin/service/AdminGpaScoreServiceTest.java index 50c12c4b3..e8f25dbba 100644 --- a/src/test/java/com/example/solidconnection/admin/service/AdminGpaScoreServiceTest.java +++ b/src/test/java/com/example/solidconnection/admin/service/AdminGpaScoreServiceTest.java @@ -1,5 +1,7 @@ package com.example.solidconnection.admin.service; +import com.example.solidconnection.common.discord.DiscordNotifier; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import static com.example.solidconnection.common.exception.ErrorCode.GPA_SCORE_NOT_FOUND; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; @@ -34,6 +36,12 @@ class AdminGpaScoreServiceTest { @Autowired private AdminGpaScoreService adminGpaScoreService; + /* + * - 테스트 설정의 discord.webhook-url 이 비어 있지 않아 실제 요청이 나갈 수 있으므로 목으로 대체한다. + * */ + @MockitoBean + private DiscordNotifier discordNotifier; + @Autowired private SiteUserFixture siteUserFixture; diff --git a/src/test/java/com/example/solidconnection/admin/service/AdminLanguageTestScoreServiceTest.java b/src/test/java/com/example/solidconnection/admin/service/AdminLanguageTestScoreServiceTest.java index e0afc9610..c888cf48b 100644 --- a/src/test/java/com/example/solidconnection/admin/service/AdminLanguageTestScoreServiceTest.java +++ b/src/test/java/com/example/solidconnection/admin/service/AdminLanguageTestScoreServiceTest.java @@ -1,5 +1,7 @@ package com.example.solidconnection.admin.service; +import com.example.solidconnection.common.discord.DiscordNotifier; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import static com.example.solidconnection.common.exception.ErrorCode.LANGUAGE_TEST_SCORE_NOT_FOUND; import static com.example.solidconnection.university.domain.LanguageTestType.TOEIC; import static org.assertj.core.api.Assertions.assertThat; @@ -35,6 +37,12 @@ class AdminLanguageTestScoreServiceTest { @Autowired private AdminLanguageTestScoreService adminLanguageTestScoreService; + /* + * - 테스트 설정의 discord.webhook-url 이 비어 있지 않아 실제 요청이 나갈 수 있으므로 목으로 대체한다. + * */ + @MockitoBean + private DiscordNotifier discordNotifier; + @Autowired private SiteUserFixture siteUserFixture; diff --git a/src/test/java/com/example/solidconnection/admin/service/AdminMentorApplicationServiceTest.java b/src/test/java/com/example/solidconnection/admin/service/AdminMentorApplicationServiceTest.java index a65718443..3c91304e5 100644 --- a/src/test/java/com/example/solidconnection/admin/service/AdminMentorApplicationServiceTest.java +++ b/src/test/java/com/example/solidconnection/admin/service/AdminMentorApplicationServiceTest.java @@ -1,5 +1,7 @@ package com.example.solidconnection.admin.service; +import com.example.solidconnection.common.discord.DiscordNotifier; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_ALREADY_EXISTS; import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_APPLICATION_ALREADY_CONFIRMED; import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_APPLICATION_NOT_FOUND; @@ -50,6 +52,12 @@ class AdminMentorApplicationServiceTest { @Autowired private AdminMentorApplicationService adminMentorApplicationService; + /* + * - 테스트 설정의 discord.webhook-url 이 비어 있지 않아 실제 요청이 나갈 수 있으므로 목으로 대체한다. + * */ + @MockitoBean + private DiscordNotifier discordNotifier; + @Autowired private SiteUserFixture siteUserFixture; diff --git a/src/test/java/com/example/solidconnection/common/discord/DiscordNotifierTest.java b/src/test/java/com/example/solidconnection/common/discord/DiscordNotifierTest.java new file mode 100644 index 000000000..924fcc62b --- /dev/null +++ b/src/test/java/com/example/solidconnection/common/discord/DiscordNotifierTest.java @@ -0,0 +1,178 @@ +package com.example.solidconnection.common.discord; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.never; + +import com.example.solidconnection.common.discord.domain.DiscordNotification; +import com.example.solidconnection.common.discord.service.DiscordNotificationService; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; + +@ExtendWith(MockitoExtension.class) +@DisplayName("DiscordNotifier 테스트") +class DiscordNotifierTest { + + private static final String WEBHOOK_URL = "https://discord.test/webhooks/channel"; + private static final String MESSAGE_ID = "message-id"; + private static final long REVIEW_ID = 1L; + private static final DiscordNotificationType REVIEW_TYPE = DiscordNotificationType.GPA_SCORE; + + @Mock + private DiscordWebhookSender discordWebhookSender; + + @Mock + private DiscordNotificationService discordNotificationService; + + private DiscordNotifier discordNotifier; + + @BeforeEach + void setUp() { + discordNotifier = notifierWith(WEBHOOK_URL, "dev"); + } + + private DiscordNotifier notifierWith(String webhookUrl, String environment) { + return new DiscordNotifier(discordWebhookSender, discordNotificationService, webhookUrl, environment); + } + + private void 알림이_저장되어_있다() { + DiscordNotification notification = DiscordNotification.of(REVIEW_TYPE, REVIEW_ID, "channel-id", MESSAGE_ID); + given(discordNotificationService.findByReviewTypeAndReviewId(REVIEW_TYPE, REVIEW_ID)) + .willReturn(Optional.of(notification)); + } + + @Nested + @DisplayName("검수 결과 표시") + class 검수_결과를_표시한다 { + + @Test + void 마커를_붙인_본문으로_원본_메시지를_한_번만_편집한다() { + // given + 알림이_저장되어_있다(); + ArgumentCaptor contentCaptor = ArgumentCaptor.forClass(String.class); + + // when + discordNotifier.markReviewResult(REVIEW_TYPE, REVIEW_ID, "홍길동", DiscordReviewMarker.APPROVED.getValue()); + + // then + then(discordWebhookSender).should() + .editMessage(eq(WEBHOOK_URL), eq(MESSAGE_ID), contentCaptor.capture()); + String content = contentCaptor.getValue(); + assertThat(content).startsWith("✅ ").contains("홍길동").contains("[개발 서버 알림입니다]"); + } + + @Test + void 재검수하면_마커가_누적되지_않고_최종_결과만_남는다() { + // given + 알림이_저장되어_있다(); + ArgumentCaptor contentCaptor = ArgumentCaptor.forClass(String.class); + + // when + discordNotifier.markReviewResult(REVIEW_TYPE, REVIEW_ID, "홍길동", DiscordReviewMarker.REJECTED.getValue()); + + // then + then(discordWebhookSender).should() + .editMessage(anyString(), anyString(), contentCaptor.capture()); + String content = contentCaptor.getValue(); + assertThat(content).startsWith("❌ "); + assertThat(content).doesNotContain("✅"); + } + } + + @Nested + @DisplayName("전송하지 않는 경우") + class 전송하지_않는다 { + + @Test + void webhook_url_이_없으면_조회조차_하지_않는다() { + // given + discordNotifier = notifierWith("", "dev"); + + // when + discordNotifier.markReviewResult(REVIEW_TYPE, REVIEW_ID, "홍길동", DiscordReviewMarker.APPROVED.getValue()); + + // then + then(discordNotificationService).should(never()).findByReviewTypeAndReviewId(any(), anyLong()); + then(discordWebhookSender).should(never()).editMessage(anyString(), anyString(), anyString()); + } + + @Test + void local_환경이면_조회조차_하지_않는다() { + // given + discordNotifier = notifierWith(WEBHOOK_URL, "local"); + + // when + discordNotifier.markReviewResult(REVIEW_TYPE, REVIEW_ID, "홍길동", DiscordReviewMarker.APPROVED.getValue()); + + // then + then(discordNotificationService).should(never()).findByReviewTypeAndReviewId(any(), anyLong()); + then(discordWebhookSender).should(never()).editMessage(anyString(), anyString(), anyString()); + } + + @Test + void 저장된_알림이_없으면_편집하지_않는다() { + // given + given(discordNotificationService.findByReviewTypeAndReviewId(REVIEW_TYPE, REVIEW_ID)) + .willReturn(Optional.empty()); + + // when + discordNotifier.markReviewResult(REVIEW_TYPE, REVIEW_ID, "홍길동", DiscordReviewMarker.APPROVED.getValue()); + + // then + then(discordWebhookSender).should(never()).editMessage(anyString(), anyString(), anyString()); + } + } + + @Nested + @DisplayName("편집 실패 처리") + class 편집_실패를_처리한다 { + + @Test + void 편집_대상이_없으면_검수를_실패시키지_않는다() { + // given + 알림이_저장되어_있다(); + HttpClientErrorException notFound = HttpClientErrorException.create( + HttpStatus.NOT_FOUND, "Not Found", null, null, null); + willThrow(notFound) + .given(discordWebhookSender).editMessage(anyString(), anyString(), anyString()); + + // when & then + assertThatCode(() -> discordNotifier.markReviewResult( + REVIEW_TYPE, REVIEW_ID, "홍길동", DiscordReviewMarker.APPROVED.getValue())) + .doesNotThrowAnyException(); + } + + @Test + void 디스코드_장애는_그대로_전파한다() { + // given + 알림이_저장되어_있다(); + HttpServerErrorException serverError = HttpServerErrorException.create( + HttpStatus.INTERNAL_SERVER_ERROR, "Internal Server Error", null, null, null); + willThrow(serverError) + .given(discordWebhookSender).editMessage(anyString(), anyString(), anyString()); + + // when & then + assertThatThrownBy(() -> discordNotifier.markReviewResult( + REVIEW_TYPE, REVIEW_ID, "홍길동", DiscordReviewMarker.APPROVED.getValue())) + .isInstanceOf(HttpServerErrorException.class); + } + } +} diff --git a/src/test/java/com/example/solidconnection/common/discord/DiscordReactionClientTest.java b/src/test/java/com/example/solidconnection/common/discord/DiscordReactionClientTest.java deleted file mode 100644 index b9281f5f0..000000000 --- a/src/test/java/com/example/solidconnection/common/discord/DiscordReactionClientTest.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.example.solidconnection.common.discord; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; - -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpMethod; -import org.springframework.web.client.RestTemplate; -import org.springframework.test.util.ReflectionTestUtils; - -class DiscordReactionClientTest { - - @Test - void 승인_이모지를_봇_권한으로_추가한다() { - // given - RestTemplate restTemplate = mock(RestTemplate.class); - DiscordReactionClient discordReactionClient = new DiscordReactionClient(restTemplate); - ReflectionTestUtils.setField(discordReactionClient, "botToken", "bot-token"); - ArgumentCaptor> requestCaptor = ArgumentCaptor.forClass(HttpEntity.class); - - // when - discordReactionClient.addReaction( - "channel-id", - "message-id", - DiscordReactionEmoji.APPROVED.getValue() - ); - - // then - verify(restTemplate).exchange( - eq("https://discord.com/api/v10/channels/channel-id/messages/message-id/reactions/%E2%9C%85/@me"), - eq(HttpMethod.PUT), - requestCaptor.capture(), - eq(Void.class) - ); - assertThat(requestCaptor.getValue().getHeaders().getFirst("Authorization")).isEqualTo("Bot bot-token"); - } -} diff --git a/src/test/java/com/example/solidconnection/common/discord/DiscordWebhookSenderTest.java b/src/test/java/com/example/solidconnection/common/discord/DiscordWebhookSenderTest.java index 2fc1d9b50..0f5158a9d 100644 --- a/src/test/java/com/example/solidconnection/common/discord/DiscordWebhookSenderTest.java +++ b/src/test/java/com/example/solidconnection/common/discord/DiscordWebhookSenderTest.java @@ -1,6 +1,7 @@ package com.example.solidconnection.common.discord; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertAll; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -18,6 +19,7 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestTemplate; @@ -107,6 +109,65 @@ class 메시지를_전송한다 { } } + @Nested + @DisplayName("메시지 편집") + class 메시지를_편집한다 { + + private static final String MESSAGE_ID = "message-id"; + + private HttpEntity> 편집된_요청(String expectedUrl) { + ArgumentCaptor>> requestCaptor = ArgumentCaptor.forClass(HttpEntity.class); + verify(restTemplate).exchange(eq(expectedUrl), eq(HttpMethod.PATCH), requestCaptor.capture(), eq(Void.class)); + return requestCaptor.getValue(); + } + + @Test + void 메시지_경로로_content_를_전송한다() { + // when + discordWebhookSender.editMessage(WEBHOOK_URL, MESSAGE_ID, CONTENT); + + // then + HttpEntity> request = 편집된_요청(WEBHOOK_URL + "/messages/" + MESSAGE_ID); + assertAll( + () -> assertThat(request.getBody()).containsEntry("content", CONTENT), + () -> assertThat(request.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON) + ); + } + + @Test + void 멘션_허용_범위를_전송할_때와_동일하게_제한한다() { + // when + discordWebhookSender.editMessage(WEBHOOK_URL, MESSAGE_ID, "@everyone 검수 완료"); + + // then + assertThat(편집된_요청(WEBHOOK_URL + "/messages/" + MESSAGE_ID).getBody()) + .containsEntry("allowed_mentions", Map.of("parse", List.of(), "roles", List.of())); + } + + @Test + void webhook_url_에_쿼리스트링이_있으면_경로_뒤가_아니라_쿼리_앞에_붙인다() { + // given + String urlWithQuery = WEBHOOK_URL + "?thread_id=123"; + + // when + discordWebhookSender.editMessage(urlWithQuery, MESSAGE_ID, CONTENT); + + // then + 편집된_요청(WEBHOOK_URL + "/messages/" + MESSAGE_ID + "?thread_id=123"); + } + + @Test + void 편집이_실패하면_예외를_전파한다() { + // given + when(restTemplate.exchange(anyString(), eq(HttpMethod.PATCH), any(), eq(Void.class))) + .thenThrow(new RestClientException("discord unavailable")); + + // when & then + assertThatThrownBy(() -> discordWebhookSender.editMessage(WEBHOOK_URL, MESSAGE_ID, CONTENT)) + .isInstanceOf(RestClientException.class); + } + } + @Nested @DisplayName("멘션 허용 범위") class 멘션_허용_범위를_제한한다 {