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 717f2a963..6e7840e7f 100644 --- a/src/main/java/com/example/solidconnection/admin/service/AdminGpaScoreService.java +++ b/src/main/java/com/example/solidconnection/admin/service/AdminGpaScoreService.java @@ -8,6 +8,9 @@ import com.example.solidconnection.admin.dto.ScoreSearchCondition; import com.example.solidconnection.application.domain.Gpa; 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.exception.CustomException; import com.example.solidconnection.score.domain.GpaScore; import com.example.solidconnection.score.repository.GpaScoreRepository; @@ -22,6 +25,7 @@ public class AdminGpaScoreService { private final GpaScoreRepository gpaScoreRepository; + private final DiscordNotifier discordNotifier; @Transactional(readOnly = true) public Page searchGpaScores(ScoreSearchCondition scoreSearchCondition, Pageable pageable) { @@ -41,6 +45,18 @@ public GpaScoreResponse updateGpaScore(Long gpaScoreId, GpaScoreUpdateRequest re request.verifyStatus(), request.verifyStatus() == VerifyStatus.REJECTED ? request.rejectedReason() : null ); + publishReaction(gpaScoreId, 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(); + case PENDING -> null; + }; + if (emoji != null) { + discordNotifier.addReaction(DiscordNotificationType.GPA_SCORE, gpaScoreId, emoji); + } + } } 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 0aa4a859d..159091149 100644 --- a/src/main/java/com/example/solidconnection/admin/service/AdminLanguageTestScoreService.java +++ b/src/main/java/com/example/solidconnection/admin/service/AdminLanguageTestScoreService.java @@ -8,6 +8,9 @@ import com.example.solidconnection.admin.dto.ScoreSearchCondition; import com.example.solidconnection.application.domain.LanguageTest; 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.exception.CustomException; import com.example.solidconnection.score.domain.LanguageTestScore; import com.example.solidconnection.score.repository.LanguageTestScoreRepository; @@ -22,6 +25,7 @@ public class AdminLanguageTestScoreService { private final LanguageTestScoreRepository languageTestScoreRepository; + private final DiscordNotifier discordNotifier; @Transactional(readOnly = true) public Page searchLanguageTestScores(ScoreSearchCondition scoreSearchCondition, Pageable pageable) { @@ -41,6 +45,18 @@ public LanguageTestScoreResponse updateLanguageTestScore(Long languageTestScoreI request.verifyStatus(), request.verifyStatus() == VerifyStatus.REJECTED ? request.rejectedReason() : null ); + publishReaction(languageTestScoreId, 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(); + case PENDING -> null; + }; + if (emoji != null) { + discordNotifier.addReaction(DiscordNotificationType.LANGUAGE_TEST_SCORE, languageTestScoreId, emoji); + } + } } 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 8289a0f7f..33563faa9 100644 --- a/src/main/java/com/example/solidconnection/admin/service/AdminMentorApplicationService.java +++ b/src/main/java/com/example/solidconnection/admin/service/AdminMentorApplicationService.java @@ -9,6 +9,9 @@ import com.example.solidconnection.admin.dto.MentorApplicationRejectRequest; import com.example.solidconnection.admin.dto.MentorApplicationSearchCondition; 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.exception.CustomException; import com.example.solidconnection.mentor.domain.Mentor; import com.example.solidconnection.mentor.domain.MentorApplication; @@ -35,6 +38,7 @@ public class AdminMentorApplicationService { private final HostUniversityRepository hostUniversityRepository; private final SiteUserRepository siteUserRepository; private final MentorRepository mentorRepository; + private final DiscordNotifier discordNotifier; @Transactional(readOnly = true) public Page searchMentorApplications( @@ -63,6 +67,7 @@ public void approveMentorApplication(Long mentorApplicationId) { ); mentorRepository.save(mentor); + publishReaction(mentorApplicationId, DiscordReactionEmoji.APPROVED.getValue()); } private void validateUserCanCreateMentor(long siteUserId) { @@ -80,6 +85,11 @@ public void rejectMentorApplication( .orElseThrow(() -> new CustomException(MENTOR_APPLICATION_NOT_FOUND)); mentorApplication.reject(request.rejectedReason()); + publishReaction(mentorApplicationId, DiscordReactionEmoji.REJECTED.getValue()); + } + + private void publishReaction(long mentorApplicationId, String emoji) { + discordNotifier.addReaction(DiscordNotificationType.MENTOR_APPLICATION, mentorApplicationId, emoji); } @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 c93c6eacb..31e41a295 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 @@ -32,4 +32,12 @@ public RestTemplate discordWebhookRestTemplate() { 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); + } } diff --git a/src/main/java/com/example/solidconnection/common/config/datasource/DataSourceProxyConfig.java b/src/main/java/com/example/solidconnection/common/config/datasource/DataSourceProxyConfig.java index 603346503..a203cdf2c 100644 --- a/src/main/java/com/example/solidconnection/common/config/datasource/DataSourceProxyConfig.java +++ b/src/main/java/com/example/solidconnection/common/config/datasource/DataSourceProxyConfig.java @@ -8,6 +8,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.flyway.FlywayDataSource; import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; +import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; @@ -29,12 +30,18 @@ public class DataSourceProxyConfig { private final QueryMetricsListener queryMetricsListener; @Bean - @Primary - public DataSource proxyDataSource(DataSourceProperties props) { - DataSource dataSource = props.initializeDataSourceBuilder().build(); + @ConfigurationProperties("spring.datasource.hikari") + public HikariDataSource hikariDataSource(DataSourceProperties props) { + return props.initializeDataSourceBuilder() + .type(HikariDataSource.class) + .build(); + } + @Bean + @Primary + public DataSource proxyDataSource(HikariDataSource hikariDataSource) { return ProxyDataSourceBuilder - .create(dataSource) + .create(hikariDataSource) .listener(queryMetricsListener) .name("main") .build(); diff --git a/src/main/java/com/example/solidconnection/common/discord/DiscordMessageResponse.java b/src/main/java/com/example/solidconnection/common/discord/DiscordMessageResponse.java new file mode 100644 index 000000000..f240dd9ec --- /dev/null +++ b/src/main/java/com/example/solidconnection/common/discord/DiscordMessageResponse.java @@ -0,0 +1,9 @@ +package com.example.solidconnection.common.discord; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record DiscordMessageResponse( + String id, + @JsonProperty("channel_id") String channelId +) { +} 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 64d9b683b..51e5c5e7e 100644 --- a/src/main/java/com/example/solidconnection/common/discord/DiscordNotifier.java +++ b/src/main/java/com/example/solidconnection/common/discord/DiscordNotifier.java @@ -1,5 +1,6 @@ package com.example.solidconnection.common.discord; +import com.example.solidconnection.common.discord.service.DiscordNotificationService; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Async; @@ -14,6 +15,8 @@ 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; @Value("${discord.webhook-url:}") private String webhookUrl; @@ -29,6 +32,26 @@ public void notify(DiscordNotificationType type, String applicantInfo) { discordWebhookSender.send(webhookUrl, buildMessage(type, applicantInfo)); } + public void notify(DiscordNotificationType type, long reviewId, String applicantInfo) { + if (webhookUrl.isBlank() || "local".equalsIgnoreCase(environment)) { + return; + } + DiscordMessageResponse response = discordWebhookSender.sendAndGetMessage( + webhookUrl, + buildMessage(type, applicantInfo) + ); + discordNotificationService.save(type, reviewId, response.channelId(), response.id()); + } + + public void addReaction(DiscordNotificationType type, long reviewId, String emoji) { + discordNotificationService.findByReviewTypeAndReviewId(type, reviewId) + .ifPresent(message -> discordReactionClient.addReaction( + message.getDiscordChannelId(), + message.getDiscordMessageId(), + emoji + )); + } + private String buildMessage(DiscordNotificationType type, String applicantInfo) { String body = "%s 검수 요청이 등록되었습니다.\n신청자: %s\n관리자 페이지: %s" .formatted(type.getDisplayName(), applicantInfo, ADMIN_PAGE_URL); diff --git a/src/main/java/com/example/solidconnection/common/discord/DiscordReactionClient.java b/src/main/java/com/example/solidconnection/common/discord/DiscordReactionClient.java new file mode 100644 index 000000000..7c0033fb9 --- /dev/null +++ b/src/main/java/com/example/solidconnection/common/discord/DiscordReactionClient.java @@ -0,0 +1,35 @@ +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/DiscordReactionEmoji.java new file mode 100644 index 000000000..bd1852fad --- /dev/null +++ b/src/main/java/com/example/solidconnection/common/discord/DiscordReactionEmoji.java @@ -0,0 +1,17 @@ +package com.example.solidconnection.common.discord; + +import lombok.Getter; + +@Getter +public enum DiscordReactionEmoji { + + APPROVED("✅"), + REJECTED("❌"), + ; + + private final String value; + + DiscordReactionEmoji(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 eeb957e9b..9446d072c 100644 --- a/src/main/java/com/example/solidconnection/common/discord/DiscordWebhookSender.java +++ b/src/main/java/com/example/solidconnection/common/discord/DiscordWebhookSender.java @@ -2,8 +2,8 @@ import java.util.List; import java.util.Map; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; @@ -18,12 +18,15 @@ * - webhook url 은 경로에 인증 토큰을 포함하므로 로그와 메트릭에 남기지 않는다. * */ @Component -@RequiredArgsConstructor @Slf4j public class DiscordWebhookSender { private final RestTemplate discordWebhookRestTemplate; + public DiscordWebhookSender(@Qualifier("discordWebhookRestTemplate") RestTemplate discordWebhookRestTemplate) { + this.discordWebhookRestTemplate = discordWebhookRestTemplate; + } + public boolean send(String webhookUrl, String content) { return send(webhookUrl, content, List.of()); } @@ -50,6 +53,15 @@ public boolean send(String webhookUrl, String content, List mentionableR } } + public DiscordMessageResponse sendAndGetMessage(String webhookUrl, String content) { + String waitUrl = webhookUrl + (webhookUrl.contains("?") ? "&wait=true" : "?wait=true"); + return discordWebhookRestTemplate.postForObject( + waitUrl, + buildRequest(content, List.of()), + DiscordMessageResponse.class + ); + } + private HttpEntity> buildRequest(String content, List mentionableRoleIds) { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); diff --git a/src/main/java/com/example/solidconnection/common/discord/domain/DiscordNotification.java b/src/main/java/com/example/solidconnection/common/discord/domain/DiscordNotification.java new file mode 100644 index 000000000..88ee369b9 --- /dev/null +++ b/src/main/java/com/example/solidconnection/common/discord/domain/DiscordNotification.java @@ -0,0 +1,68 @@ +package com.example.solidconnection.common.discord.domain; + +import com.example.solidconnection.common.BaseEntity; +import com.example.solidconnection.common.discord.DiscordNotificationType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "discord_notification", + uniqueConstraints = @UniqueConstraint( + name = "uk_discord_notification_target", + columnNames = {"review_type", "review_id"} + ) +) +public class DiscordNotification extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Enumerated(EnumType.STRING) + @Column(name = "review_type", nullable = false, length = 32) + private DiscordNotificationType reviewType; + + @Column(name = "review_id", nullable = false) + private long reviewId; + + @Column(name = "discord_channel_id", nullable = false, length = 32) + private String discordChannelId; + + @Column(name = "discord_message_id", nullable = false, length = 32) + private String discordMessageId; + + private DiscordNotification( + DiscordNotificationType reviewType, + long reviewId, + String discordChannelId, + String discordMessageId + ) { + this.reviewType = reviewType; + this.reviewId = reviewId; + this.discordChannelId = discordChannelId; + this.discordMessageId = discordMessageId; + } + + public static DiscordNotification of( + DiscordNotificationType reviewType, + long reviewId, + String discordChannelId, + String discordMessageId + ) { + return new DiscordNotification(reviewType, reviewId, discordChannelId, discordMessageId); + } +} diff --git a/src/main/java/com/example/solidconnection/common/discord/repository/DiscordNotificationRepository.java b/src/main/java/com/example/solidconnection/common/discord/repository/DiscordNotificationRepository.java new file mode 100644 index 000000000..35aee0b2f --- /dev/null +++ b/src/main/java/com/example/solidconnection/common/discord/repository/DiscordNotificationRepository.java @@ -0,0 +1,14 @@ +package com.example.solidconnection.common.discord.repository; + +import com.example.solidconnection.common.discord.DiscordNotificationType; +import com.example.solidconnection.common.discord.domain.DiscordNotification; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface DiscordNotificationRepository extends JpaRepository { + + Optional findByReviewTypeAndReviewId( + DiscordNotificationType reviewType, + long reviewId + ); +} diff --git a/src/main/java/com/example/solidconnection/common/discord/service/DiscordNotificationService.java b/src/main/java/com/example/solidconnection/common/discord/service/DiscordNotificationService.java new file mode 100644 index 000000000..d1ec81bf8 --- /dev/null +++ b/src/main/java/com/example/solidconnection/common/discord/service/DiscordNotificationService.java @@ -0,0 +1,34 @@ +package com.example.solidconnection.common.discord.service; + +import com.example.solidconnection.common.discord.DiscordNotificationType; +import com.example.solidconnection.common.discord.domain.DiscordNotification; +import com.example.solidconnection.common.discord.repository.DiscordNotificationRepository; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class DiscordNotificationService { + + private final DiscordNotificationRepository discordNotificationRepository; + + @Transactional + public void save( + DiscordNotificationType reviewType, + long reviewId, + String channelId, + String messageId + ) { + discordNotificationRepository.save(DiscordNotification.of(reviewType, reviewId, channelId, messageId)); + } + + @Transactional(readOnly = true) + public Optional findByReviewTypeAndReviewId( + DiscordNotificationType reviewType, + long reviewId + ) { + return discordNotificationRepository.findByReviewTypeAndReviewId(reviewType, reviewId); + } +} diff --git a/src/main/java/com/example/solidconnection/mentor/service/MentorApplicationService.java b/src/main/java/com/example/solidconnection/mentor/service/MentorApplicationService.java index 8dd6e9075..48679daf1 100644 --- a/src/main/java/com/example/solidconnection/mentor/service/MentorApplicationService.java +++ b/src/main/java/com/example/solidconnection/mentor/service/MentorApplicationService.java @@ -62,8 +62,12 @@ public void submitMentorApplication( term.getId(), mentorApplicationRequest.exchangeStatus() ); - mentorApplicationRepository.save(mentorApplication); - discordNotifier.notify(DiscordNotificationType.MENTOR_APPLICATION, siteUser.getNickname()); + MentorApplication savedMentorApplication = mentorApplicationRepository.save(mentorApplication); + discordNotifier.notify( + DiscordNotificationType.MENTOR_APPLICATION, + savedMentorApplication.getId(), + siteUser.getNickname() + ); } private void ensureNoPendingOrApprovedMentorApplication(long siteUserId) { diff --git a/src/main/java/com/example/solidconnection/score/service/ScoreService.java b/src/main/java/com/example/solidconnection/score/service/ScoreService.java index 2ea786c38..d84aeee7f 100644 --- a/src/main/java/com/example/solidconnection/score/service/ScoreService.java +++ b/src/main/java/com/example/solidconnection/score/service/ScoreService.java @@ -49,7 +49,7 @@ public Long submitGpaScore(long siteUserId, GpaScoreRequest gpaScoreRequest, Mul Gpa gpa = new Gpa(gpaScoreRequest.gpa(), gpaScoreRequest.gpaCriteria(), uploadedFile.fileUrl()); GpaScore newGpaScore = new GpaScore(gpa, siteUser); GpaScore savedNewGpaScore = gpaScoreRepository.save(newGpaScore); - discordNotifier.notify(DiscordNotificationType.GPA_SCORE, siteUser.getNickname()); + discordNotifier.notify(DiscordNotificationType.GPA_SCORE, savedNewGpaScore.getId(), siteUser.getNickname()); return savedNewGpaScore.getId(); } @@ -62,7 +62,7 @@ public Long submitLanguageTestScore(long siteUserId, LanguageTestScoreRequest la languageTestScoreRequest.languageTestScore(), uploadedFile.fileUrl()); LanguageTestScore newScore = new LanguageTestScore(languageTest, siteUser); LanguageTestScore savedNewScore = languageTestScoreRepository.save(newScore); - discordNotifier.notify(DiscordNotificationType.LANGUAGE_TEST_SCORE, siteUser.getNickname()); + discordNotifier.notify(DiscordNotificationType.LANGUAGE_TEST_SCORE, savedNewScore.getId(), siteUser.getNickname()); return savedNewScore.getId(); } diff --git a/src/main/resources/db/migration/V59__add_discord_notification.sql b/src/main/resources/db/migration/V59__add_discord_notification.sql new file mode 100644 index 000000000..9365a31d0 --- /dev/null +++ b/src/main/resources/db/migration/V59__add_discord_notification.sql @@ -0,0 +1,11 @@ +CREATE TABLE discord_notification ( + id BIGINT NOT NULL AUTO_INCREMENT, + review_type VARCHAR(50) NOT NULL, + review_id BIGINT NOT NULL, + discord_channel_id VARCHAR(30) NOT NULL, + discord_message_id VARCHAR(30) NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + CONSTRAINT pk_discord_notification PRIMARY KEY (id), + CONSTRAINT uk_discord_notification_target UNIQUE (review_type, review_id) +); diff --git a/src/test/java/com/example/solidconnection/common/discord/DiscordNotificationServiceTest.java b/src/test/java/com/example/solidconnection/common/discord/DiscordNotificationServiceTest.java new file mode 100644 index 000000000..faf3d455b --- /dev/null +++ b/src/test/java/com/example/solidconnection/common/discord/DiscordNotificationServiceTest.java @@ -0,0 +1,69 @@ +package com.example.solidconnection.common.discord; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +import com.example.solidconnection.common.discord.domain.DiscordNotification; +import com.example.solidconnection.common.discord.repository.DiscordNotificationRepository; +import com.example.solidconnection.common.discord.service.DiscordNotificationService; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class DiscordNotificationServiceTest { + + @Mock + private DiscordNotificationRepository discordNotificationRepository; + + @InjectMocks + private DiscordNotificationService discordNotificationService; + + @Test + void 검수별_디스코드_메시지_식별자를_저장한다() { + // given + DiscordNotificationType reviewType = DiscordNotificationType.GPA_SCORE; + long reviewId = 1L; + ArgumentCaptor notificationCaptor = ArgumentCaptor.forClass(DiscordNotification.class); + + // when + discordNotificationService.save(reviewType, reviewId, "channel-id", "message-id"); + + // then + then(discordNotificationRepository).should().save(notificationCaptor.capture()); + DiscordNotification notification = notificationCaptor.getValue(); + assertThat(notification.getReviewType()).isEqualTo(reviewType); + assertThat(notification.getReviewId()).isEqualTo(reviewId); + assertThat(notification.getDiscordChannelId()).isEqualTo("channel-id"); + assertThat(notification.getDiscordMessageId()).isEqualTo("message-id"); + } + + @Test + void 검수별_디스코드_메시지를_조회한다() { + // given + DiscordNotification notification = DiscordNotification.of( + DiscordNotificationType.GPA_SCORE, + 1L, + "channel-id", + "message-id" + ); + given(discordNotificationRepository.findByReviewTypeAndReviewId( + DiscordNotificationType.GPA_SCORE, + 1L + )).willReturn(Optional.of(notification)); + + // when + Optional result = discordNotificationService.findByReviewTypeAndReviewId( + DiscordNotificationType.GPA_SCORE, + 1L + ); + + // then + assertThat(result).contains(notification); + } +} diff --git a/src/test/java/com/example/solidconnection/common/discord/DiscordReactionClientTest.java b/src/test/java/com/example/solidconnection/common/discord/DiscordReactionClientTest.java new file mode 100644 index 000000000..b9281f5f0 --- /dev/null +++ b/src/test/java/com/example/solidconnection/common/discord/DiscordReactionClientTest.java @@ -0,0 +1,41 @@ +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 e5c00f741..2fc1d9b50 100644 --- a/src/test/java/com/example/solidconnection/common/discord/DiscordWebhookSenderTest.java +++ b/src/test/java/com/example/solidconnection/common/discord/DiscordWebhookSenderTest.java @@ -44,6 +44,21 @@ void setUp() { return requestCaptor.getValue(); } + @Test + void 디스코드_메시지_식별자_확보를_위해_wait_true로_전송한다() { + // given + String waitUrl = WEBHOOK_URL + "?wait=true"; + DiscordMessageResponse expected = new DiscordMessageResponse("message-id", "channel-id"); + when(restTemplate.postForObject(eq(waitUrl), any(), eq(DiscordMessageResponse.class))) + .thenReturn(expected); + + // when + DiscordMessageResponse response = discordWebhookSender.sendAndGetMessage(WEBHOOK_URL, CONTENT); + + // then + assertThat(response).isEqualTo(expected); + } + @Nested @DisplayName("메시지 전송") class 메시지를_전송한다 { diff --git a/src/test/java/com/example/solidconnection/mentor/service/MentorApplicationServiceTest.java b/src/test/java/com/example/solidconnection/mentor/service/MentorApplicationServiceTest.java index 31792e6a8..eb29176c2 100644 --- a/src/test/java/com/example/solidconnection/mentor/service/MentorApplicationServiceTest.java +++ b/src/test/java/com/example/solidconnection/mentor/service/MentorApplicationServiceTest.java @@ -9,6 +9,7 @@ import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; import static org.mockito.BDDMockito.given; +import com.example.solidconnection.common.discord.DiscordNotifier; import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.mentor.domain.MentorApplicationStatus; import com.example.solidconnection.mentor.domain.UniversitySelectType; @@ -54,6 +55,9 @@ public class MentorApplicationServiceTest { @MockitoBean private S3Service s3Service; + @MockitoBean + private DiscordNotifier discordNotifier; + private SiteUser user; private Term term; diff --git a/src/test/java/com/example/solidconnection/score/service/ScoreServiceTest.java b/src/test/java/com/example/solidconnection/score/service/ScoreServiceTest.java index c4d25ce9c..89cc8535e 100644 --- a/src/test/java/com/example/solidconnection/score/service/ScoreServiceTest.java +++ b/src/test/java/com/example/solidconnection/score/service/ScoreServiceTest.java @@ -5,6 +5,7 @@ import static org.mockito.BDDMockito.given; import com.example.solidconnection.common.VerifyStatus; +import com.example.solidconnection.common.discord.DiscordNotifier; import com.example.solidconnection.common.exception.ErrorCode; import com.example.solidconnection.s3.domain.UploadPath; import com.example.solidconnection.s3.dto.UploadedFileUrlResponse; @@ -49,6 +50,9 @@ class ScoreServiceTest { @MockitoBean private S3Service s3Service; + @MockitoBean + private DiscordNotifier discordNotifier; + @Autowired private SiteUserFixture siteUserFixture; diff --git a/src/test/java/com/example/solidconnection/support/MySQLTestContainer.java b/src/test/java/com/example/solidconnection/support/MySQLTestContainer.java index 0c59077c0..22d6252b5 100644 --- a/src/test/java/com/example/solidconnection/support/MySQLTestContainer.java +++ b/src/test/java/com/example/solidconnection/support/MySQLTestContainer.java @@ -18,7 +18,9 @@ public void initialize(ConfigurableApplicationContext applicationContext) { TestPropertyValues.of( "spring.datasource.url=" + CONTAINER.getJdbcUrl(), "spring.datasource.username=" + CONTAINER.getUsername(), - "spring.datasource.password=" + CONTAINER.getPassword() + "spring.datasource.password=" + CONTAINER.getPassword(), + "spring.datasource.hikari.maximum-pool-size=5", + "spring.datasource.hikari.minimum-idle=1" ).applyTo(applicationContext.getEnvironment()); } }