diff --git a/build.gradle b/build.gradle index ab727e3..4ff8b38 100644 --- a/build.gradle +++ b/build.gradle @@ -37,7 +37,11 @@ dependencies { // Test testImplementation platform('org.junit:junit-bom:5.10.2') testImplementation 'org.junit.jupiter:junit-jupiter' + testImplementation 'org.junit.platform:junit-platform-launcher' testImplementation 'org.mockito:mockito-core:5.10.0' + testImplementation 'org.mockito:mockito-junit-jupiter:5.10.0' + testImplementation 'org.assertj:assertj-core:3.25.3' + testImplementation "org.wiremock:wiremock:3.13.0" // Env implementation 'io.github.cdimascio:java-dotenv:5.2.2' diff --git a/src/main/java/org/sopt/makers/global/constant/DiscordConstant.java b/src/main/java/org/sopt/makers/global/constant/DiscordConstant.java new file mode 100644 index 0000000..3959fad --- /dev/null +++ b/src/main/java/org/sopt/makers/global/constant/DiscordConstant.java @@ -0,0 +1,25 @@ +package org.sopt.makers.global.constant; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class DiscordConstant { + // 컨텐츠 타입 + public static final String CONTENT_TYPE_JSON = "application/json"; + + // 날짜 형식 + public static final String DATE_FORMAT_PATTERN = "yyyy-MM-dd HH:mm:ss"; + public static final String TIMEZONE_SEOUL = "Asia/Seoul"; + + // 메시지 형식 + public static final String EMOJI_PREFIX = "🚨"; + public static final String TRUNCATION_SUFFIX = "..."; + + // Discord 리소스 + public static final String SENTRY_ICON_URL = "https://raw.githubusercontent.com/getsentry/sentry/master/src/sentry/static/sentry/images/sentry-glyph-black.png"; + + // Discord 임베드 제한 + public static final int MAX_TITLE_LENGTH = 256; + public static final int MAX_DESCRIPTION_LENGTH = 4096; +} diff --git a/src/main/java/org/sopt/makers/global/exception/checked/MessageBuildException.java b/src/main/java/org/sopt/makers/global/exception/checked/MessageBuildException.java new file mode 100644 index 0000000..ebd5c6b --- /dev/null +++ b/src/main/java/org/sopt/makers/global/exception/checked/MessageBuildException.java @@ -0,0 +1,13 @@ +package org.sopt.makers.global.exception.checked; + +import org.sopt.makers.global.exception.base.BaseErrorCode; + +public class MessageBuildException extends SentryCheckedException { + public MessageBuildException(BaseErrorCode errorCode) { + super(errorCode); + } + + public static MessageBuildException from(BaseErrorCode errorCode) { + return new MessageBuildException(errorCode); + } +} diff --git a/src/main/java/org/sopt/makers/global/exception/checked/SendException.java b/src/main/java/org/sopt/makers/global/exception/checked/SendException.java new file mode 100644 index 0000000..f02e934 --- /dev/null +++ b/src/main/java/org/sopt/makers/global/exception/checked/SendException.java @@ -0,0 +1,13 @@ +package org.sopt.makers.global.exception.checked; + +import org.sopt.makers.global.exception.base.BaseErrorCode; + +public class SendException extends SentryCheckedException { + public SendException(BaseErrorCode errorCode) { + super(errorCode); + } + + public static SendException from(BaseErrorCode errorCode) { + return new SendException(errorCode); + } +} diff --git a/src/main/java/org/sopt/makers/global/exception/checked/SlackMessageBuildException.java b/src/main/java/org/sopt/makers/global/exception/checked/SlackMessageBuildException.java deleted file mode 100644 index 7cbbd18..0000000 --- a/src/main/java/org/sopt/makers/global/exception/checked/SlackMessageBuildException.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.sopt.makers.global.exception.checked; - -import org.sopt.makers.global.exception.base.BaseErrorCode; - -public class SlackMessageBuildException extends SentryCheckedException { - public SlackMessageBuildException(BaseErrorCode errorCode) { - super(errorCode); - } - - public static SlackMessageBuildException from(BaseErrorCode errorCode) { - return new SlackMessageBuildException(errorCode); - } -} diff --git a/src/main/java/org/sopt/makers/global/exception/checked/SlackSendException.java b/src/main/java/org/sopt/makers/global/exception/checked/SlackSendException.java deleted file mode 100644 index 9298c0f..0000000 --- a/src/main/java/org/sopt/makers/global/exception/checked/SlackSendException.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.sopt.makers.global.exception.checked; - -import org.sopt.makers.global.exception.base.BaseErrorCode; - -public class SlackSendException extends SentryCheckedException { - public SlackSendException(BaseErrorCode errorCode) { - super(errorCode); - } - - public static SlackSendException from(BaseErrorCode errorCode) { - return new SlackSendException(errorCode); - } -} diff --git a/src/main/java/org/sopt/makers/global/exception/message/ErrorMessage.java b/src/main/java/org/sopt/makers/global/exception/message/ErrorMessage.java index c7f1043..4ef2916 100644 --- a/src/main/java/org/sopt/makers/global/exception/message/ErrorMessage.java +++ b/src/main/java/org/sopt/makers/global/exception/message/ErrorMessage.java @@ -10,18 +10,26 @@ public enum ErrorMessage implements BaseErrorCode { // ===== Webhook 관련 오류 ===== INVALID_ENV_PARAMETER(400, "환경 변수 입력값이 올바르지 않습니다.", "W4001"), - WEBHOOK_URL_NOT_FOUND(500, "Webhook URL을 찾을 수 없습니다.", "W5001"), + WEBHOOK_URL_NOT_FOUND(404, "Webhook URL을 찾을 수 없습니다.", "W4041"), // ===== Slack 및 일반 서비스 오류 ===== - INVALID_SLACK_PAYLOAD(400, "Slack 페이로드 형식이 잘못되었습니다.", "S4001"), - UNSUPPORTED_SERVICE_TYPE(400, "지원하지 않는 서비스 유형입니다.", "S4002"), SLACK_MESSAGE_BUILD_FAILED(500, "Slack 메시지 생성에 실패했습니다.", "S5001"), SLACK_SEND_INTERRUPTED(500, "Slack 알림 전송이 중단되었습니다.", "S5002"), SLACK_SERIALIZATION_FAILED(500, "Slack 메시지를 JSON으로 변환하는 중 오류가 발생했습니다.", "S5003"), SLACK_SEND_FAILED(502, "Slack 전송 요청에 실패했습니다.", "S5021"), SLACK_NETWORK_ERROR(503, "Slack 서버 연결에 실패했습니다.", "S5031"), + // ===== Discord 및 일반 서비스 오류 ===== + INVALID_DISCORD_PAYLOAD(400, "Discord 페이로드 형식이 잘못되었습니다.", "D4001"), + DISCORD_MESSAGE_BUILD_FAILED(500, "Discord 메시지 생성에 실패했습니다.", "D5001"), + DISCORD_SEND_INTERRUPTED(500, "Discord 알림 전송이 중단되었습니다.", "D5002"), + DISCORD_SERIALIZATION_FAILED(500, "Discord 메시지를 JSON으로 변환하는 중 오류가 발생했습니다.", "D5003"), + DISCORD_SEND_FAILED(502, "Discord 전송 요청에 실패했습니다.", "D5021"), + DISCORD_NETWORK_ERROR(503, "Discord 서버 연결에 실패했습니다.", "D5031"), + // ===== 공통 오류 ===== + UNSUPPORTED_SERVICE_TYPE(400, "지원하지 않는 서비스 유형입니다.", "C4001"), + INVALID_PAYLOAD(400, "페이로드 형식이 잘못되었습니다.", "C4002"), UNEXPECTED_SERVER_ERROR(500, "내부 서버 오류가 발생했습니다.", "C5001"); private final int status; diff --git a/src/main/java/org/sopt/makers/global/exception/unchecked/InvalidPayloadException.java b/src/main/java/org/sopt/makers/global/exception/unchecked/InvalidPayloadException.java new file mode 100644 index 0000000..a260b60 --- /dev/null +++ b/src/main/java/org/sopt/makers/global/exception/unchecked/InvalidPayloadException.java @@ -0,0 +1,13 @@ +package org.sopt.makers.global.exception.unchecked; + +import org.sopt.makers.global.exception.base.BaseErrorCode; + +public class InvalidPayloadException extends SentryUncheckedException { + public InvalidPayloadException(BaseErrorCode errorCode) { + super(errorCode); + } + + public static InvalidPayloadException from(BaseErrorCode errorCode) { + return new InvalidPayloadException(errorCode); + } +} diff --git a/src/main/java/org/sopt/makers/global/exception/unchecked/InvalidSlackPayloadException.java b/src/main/java/org/sopt/makers/global/exception/unchecked/InvalidSlackPayloadException.java deleted file mode 100644 index d1e856f..0000000 --- a/src/main/java/org/sopt/makers/global/exception/unchecked/InvalidSlackPayloadException.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.sopt.makers.global.exception.unchecked; - -import org.sopt.makers.global.exception.base.BaseErrorCode; - -public class InvalidSlackPayloadException extends SentryUncheckedException { - public InvalidSlackPayloadException(BaseErrorCode errorCode) { - super(errorCode); - } - - public static InvalidSlackPayloadException from(BaseErrorCode errorCode) { - return new InvalidSlackPayloadException(errorCode); - } -} diff --git a/src/main/java/org/sopt/makers/global/util/EnvUtil.java b/src/main/java/org/sopt/makers/global/util/EnvUtil.java index 170a9a3..f61f18d 100644 --- a/src/main/java/org/sopt/makers/global/util/EnvUtil.java +++ b/src/main/java/org/sopt/makers/global/util/EnvUtil.java @@ -15,12 +15,12 @@ public final class EnvUtil { private static final String SLACK_WEBHOOK_PREFIX = "SLACK_WEBHOOK_"; private static final String DISCORD_WEBHOOK_PREFIX = "DISCORD_WEBHOOK_"; - private static final Dotenv dotenv = Dotenv.configure().load(); + private static Dotenv dotenv; /** * 서비스 유형에 맞는 웹훅 URL 반환 * - * @param service 서비스 유형 (slack, discord 등) + * @param serviceType 서비스 유형 (slack, discord 등) * @param team 팀 이름 (crew, app 등) * @param stage 환경 (dev, prod) * @param type 서버 유형 (be, fe) @@ -28,20 +28,20 @@ public final class EnvUtil { * @throws UnsupportedServiceTypeException 지원하지 않는 서비스 유형인 경우 * @throws WebhookUrlNotFoundException 환경 변수를 찾을 수 없는 경우 */ - public static String getWebhookUrl(String service, String team, String stage, String type) { - if (service == null || team == null || stage == null || type == null) { - log.error("환경 변수 입력값이 null입니다: service={}, team={}, stage={}, type={}", service, team, stage, type); + public static String getWebhookUrl(String serviceType, String team, String stage, String type) { + if (serviceType == null || team == null || stage == null || type == null) { + log.error("환경 변수 입력값이 null입니다: serviceType={}, team={}, stage={}, type={}", serviceType, team, stage, type); throw InvalidEnvParameterException.from(ErrorMessage.INVALID_ENV_PARAMETER); } - String prefix = resolvePrefix(service.toLowerCase()); + String prefix = resolvePrefix(serviceType.toLowerCase()); String envKey = String.format("%s%s_%s_%s", prefix, team.toUpperCase(), stage.toUpperCase(), type.toUpperCase()); - String webhookUrl = dotenv.get(envKey); + String webhookUrl = getDotenv().get(envKey); if (webhookUrl == null || webhookUrl.isBlank()) { log.error("Webhook URL을 찾을 수 없습니다: {}", envKey); @@ -51,8 +51,20 @@ public static String getWebhookUrl(String service, String team, String stage, St return webhookUrl; } - private static String resolvePrefix(String service) { - return switch (service) { + public static void setDotenv(Dotenv customDotenv) { + dotenv = customDotenv; + } + + + private static synchronized Dotenv getDotenv() { + if (dotenv == null) { + dotenv = Dotenv.configure().load(); + } + return dotenv; + } + + private static String resolvePrefix(String serviceTypeLowerCase) { + return switch (serviceTypeLowerCase) { case "slack" -> SLACK_WEBHOOK_PREFIX; case "discord" -> DISCORD_WEBHOOK_PREFIX; default -> throw new UnsupportedServiceTypeException(ErrorMessage.UNSUPPORTED_SERVICE_TYPE); diff --git a/src/main/java/org/sopt/makers/handler/SentryWebhookHandler.java b/src/main/java/org/sopt/makers/handler/SentryWebhookHandler.java index 018a84a..0e7cf50 100644 --- a/src/main/java/org/sopt/makers/handler/SentryWebhookHandler.java +++ b/src/main/java/org/sopt/makers/handler/SentryWebhookHandler.java @@ -11,44 +11,34 @@ import org.sopt.makers.global.config.ObjectMapperConfig; import org.sopt.makers.global.exception.checked.SentryCheckedException; import org.sopt.makers.global.exception.message.ErrorMessage; -import org.sopt.makers.global.exception.unchecked.InvalidSlackPayloadException; import org.sopt.makers.global.exception.unchecked.SentryUncheckedException; -import org.sopt.makers.global.util.EnvUtil; -import org.sopt.makers.service.NotificationService; -import org.sopt.makers.service.NotificationServiceFactory; +import org.sopt.makers.service.NotificationProcessorService; +import org.sopt.makers.service.SentryEventExtractorService; import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; @Slf4j public class SentryWebhookHandler implements RequestHandler { - - private final ObjectMapper objectMapper; - - public SentryWebhookHandler() { - this.objectMapper = ObjectMapperConfig.getInstance(); - } + private final ObjectMapper objectMapper = ObjectMapperConfig.getInstance(); + private final SentryEventExtractorService eventExtractorService = new SentryEventExtractorService(objectMapper); + private final NotificationProcessorService notificationProcessorService = new NotificationProcessorService(objectMapper); @Override public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent apiGatewayEvent, Context context) { try { - // 웹훅 요청 처리 WebhookRequest webhookRequest = WebhookRequest.from(apiGatewayEvent); logWebhookReceived(webhookRequest); - // Sentry 이벤트 추출 - SentryEventDetail sentryEvent = extractSentryEvent(apiGatewayEvent.getBody()); + SentryEventDetail sentryEvent = eventExtractorService.extractEvent(apiGatewayEvent.getBody()); - // 알림 전송 - sendNotification(webhookRequest, sentryEvent); + notificationProcessorService.processNotification(webhookRequest, sentryEvent); - // 성공 응답 반환 return createApiGatewayResponse( HttpURLConnection.HTTP_OK, createSuccessResponseBody() @@ -72,45 +62,6 @@ private void logWebhookReceived(WebhookRequest webhookRequest) { ); } - private SentryEventDetail extractSentryEvent(String requestBody) { - JsonNode rootNode = parseRequestBody(requestBody); - JsonNode eventNode = rootNode.path("data").path("event"); - - log.info("rootNode 정보: {}", rootNode); - log.info("eventNode 정보: {}", eventNode); - - if (eventNode.isMissingNode() || eventNode.isEmpty()) { - log.error("[이벤트 데이터 누락] 요청 본문에 필수 이벤트 정보가 없습니다"); - throw InvalidSlackPayloadException.from(ErrorMessage.INVALID_SLACK_PAYLOAD); - } - - SentryEventDetail sentryEvent = SentryEventDetail.from(eventNode); - log.info("[이벤트 추출] issueId={}, level={}", sentryEvent.issueId(), sentryEvent.level()); - - return sentryEvent; - } - - private JsonNode parseRequestBody(String requestBody) { - try { - return objectMapper.readTree(requestBody); - } catch (Exception e) { - log.error("[요청 본문 파싱 실패] error={}", e.getMessage(), e); - throw InvalidSlackPayloadException.from(ErrorMessage.INVALID_SLACK_PAYLOAD); - } - } - - private void sendNotification(WebhookRequest request, SentryEventDetail event) throws SentryCheckedException { - String serviceType = request.serviceType(); - String team = request.team(); - String stage = request.stage(); - String type = request.type(); - - NotificationService notificationService = NotificationServiceFactory.createNotificationService(serviceType); - String webhookUrl = EnvUtil.getWebhookUrl(serviceType, team, stage, type); - - notificationService.sendNotification(team, type, stage, event, webhookUrl); - } - private APIGatewayProxyResponseEvent handleSentryCheckedException(SentryCheckedException e) { log.error("[처리 실패] code={}, message={}", e.getBaseErrorCode().getCode(), e.getMessage(), e); return createApiGatewayErrorResponse( diff --git a/src/main/java/org/sopt/makers/service/DiscordNotificationService.java b/src/main/java/org/sopt/makers/service/DiscordNotificationService.java new file mode 100644 index 0000000..378ec49 --- /dev/null +++ b/src/main/java/org/sopt/makers/service/DiscordNotificationService.java @@ -0,0 +1,188 @@ +package org.sopt.makers.service; + +import static org.sopt.makers.global.constant.DiscordConstant.*; + +import java.io.IOException; +import java.net.http.HttpResponse; +import java.time.DateTimeException; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +import org.sopt.makers.dto.SentryEventDetail; +import org.sopt.makers.global.constant.Color; +import org.sopt.makers.global.exception.checked.MessageBuildException; +import org.sopt.makers.global.exception.checked.SendException; +import org.sopt.makers.global.exception.checked.SentryCheckedException; +import org.sopt.makers.global.exception.message.ErrorMessage; +import org.sopt.makers.global.exception.unchecked.HttpRequestException; +import org.sopt.makers.global.util.HttpClientUtil; +import org.sopt.makers.vo.discord.embed.DiscordEmbed; +import org.sopt.makers.vo.discord.embed.DiscordEmbed.DiscordEmbedField; +import org.sopt.makers.vo.discord.message.DiscordMessage; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +public class DiscordNotificationService implements NotificationService { + private final ObjectMapper objectMapper; + + @Override + public void sendNotification(String team, String type, String stage, SentryEventDetail sentryEventDetail, + String webhookUrl) throws SentryCheckedException { + DiscordMessage discordMessage = createDiscordMessage(team, type, stage, sentryEventDetail); + sendDiscordMessage(discordMessage, webhookUrl, team, type, stage, sentryEventDetail); + } + + /** + * Discord 메시지 생성 + */ + private DiscordMessage createDiscordMessage(String team, String type, String stage, + SentryEventDetail sentryEventDetail) throws SentryCheckedException { + try { + return buildDiscordMessage(team, type, stage, sentryEventDetail); + } catch (DateTimeException e) { + log.error("Discord 메시지 생성 실패: team={}, type={}, stage={}, id={}, error={}", team, type, stage, + sentryEventDetail.issueId(), e.getMessage(), e); + throw MessageBuildException.from(ErrorMessage.DISCORD_MESSAGE_BUILD_FAILED); + } + } + + /** + * Discord 메시지를 실제로 전송 + */ + private void sendDiscordMessage(DiscordMessage discordMessage, String webhookUrl, String team, String type, + String stage, SentryEventDetail sentryEventDetail) throws SentryCheckedException { + try { + String jsonPayload = objectMapper.writeValueAsString(discordMessage); + log.info("Discord 메시지: {}", jsonPayload); + HttpResponse response = HttpClientUtil.sendPost(webhookUrl, CONTENT_TYPE_JSON, jsonPayload); + handleDiscordResponse(response, team, type, stage, sentryEventDetail); + } catch (HttpRequestException e) { + throw SendException.from(e.getBaseErrorCode()); + } catch (IOException e) { + throw SendException.from(ErrorMessage.DISCORD_SERIALIZATION_FAILED); + } + } + + /** + * Discord API 응답 처리 + */ + private void handleDiscordResponse(HttpResponse response, String team, String type, String stage, + SentryEventDetail sentryEventDetail) throws SendException { + // Discord는 204 No Content 응답 코드를 반환 + if (response.statusCode() != 204) { + String errorMsg = String.format("Discord API 응답 오류, status: %d, body: %s", response.statusCode(), + response.body()); + log.error("{}", errorMsg); + throw SendException.from(ErrorMessage.DISCORD_SEND_FAILED); + } + + log.info("[Discord 전송 완료] team={}, type={}, stage={}, id={}, statusCode={}", team, type, stage, + sentryEventDetail.issueId(), response.statusCode()); + } + + /** + * Discord 메시지 구성 - Slack과 동일한 메시지 내용을 Discord 메시지 형식으로 변환 + */ + private DiscordMessage buildDiscordMessage(String team, String type, String stage, + SentryEventDetail sentryEventDetail) { + String formattedDate = formatDateTime(sentryEventDetail.datetime()); + String colorHex = Color.getColorByLevel(sentryEventDetail.level()); + int colorInt = convertHexColorToInt(colorHex); + + String title = buildTitle(sentryEventDetail.message()); + List fields = buildFields(team, type, stage, formattedDate, sentryEventDetail.issueId(), + sentryEventDetail.level()); + String description = buildDescription(sentryEventDetail.title()); + + // Discord 임베드 생성 + DiscordEmbed embed = DiscordEmbed.newInstance( + title, + description, + sentryEventDetail.webUrl(), + colorInt, + fields); + + // Discord 메시지 생성 + return DiscordMessage.newInstance( + "Sentry", + SENTRY_ICON_URL, + List.of(embed)); + } + + /** + * 메시지 제목 구성 + */ + private String buildTitle(String message) { + String title = "%s %s".formatted(EMOJI_PREFIX, message); + + if (title.length() > MAX_TITLE_LENGTH) { + int maxContentLength = MAX_TITLE_LENGTH - TRUNCATION_SUFFIX.length(); + title = "%s %s%s".formatted( + EMOJI_PREFIX, + message.substring(0, maxContentLength - EMOJI_PREFIX.length() - 1), + TRUNCATION_SUFFIX + ); + } + + return title; + } + + /** + * 메시지 상세 필드 구성 + */ + private List buildFields(String team, String type, String stage, String date, String issueId, + String level) { + List fields = new ArrayList<>(); + fields.add(DiscordEmbedField.newInstance("Environment", stage, true)); + fields.add(DiscordEmbedField.newInstance("Team", team, true)); + fields.add(DiscordEmbedField.newInstance("Server Type", type, true)); + fields.add(DiscordEmbedField.newInstance("Issue Id", issueId, true)); + fields.add(DiscordEmbedField.newInstance("Happen", date, true)); + fields.add(DiscordEmbedField.newInstance("Level", level, true)); + return fields; + } + + /** + * 메시지 오류 세부 설명 구성 + */ + private String buildDescription(String title) { + String description = """ + **Error Details:** + ``` + %s + ```""".formatted(title.trim()); + + if (description.length() > MAX_DESCRIPTION_LENGTH) { + description = description.substring(0, MAX_DESCRIPTION_LENGTH - TRUNCATION_SUFFIX.length()) + + TRUNCATION_SUFFIX; + } + + return description; + } + + /** + * HEX 색상 코드를 Discord에서 사용하는 정수로 변환 + */ + private int convertHexColorToInt(String hexColor) { + String cleanHex = hexColor.startsWith("#") ? hexColor.substring(1) : hexColor; + return Integer.parseInt(cleanHex, 16); + } + + /** + * ISO 날짜 포맷팅 + */ + private String formatDateTime(String isoDatetime) { + OffsetDateTime utcTime = OffsetDateTime.parse(isoDatetime, DateTimeFormatter.ISO_DATE_TIME); + LocalDateTime koreaTime = utcTime.atZoneSameInstant(ZoneId.of(TIMEZONE_SEOUL)).toLocalDateTime(); + return koreaTime.format(DateTimeFormatter.ofPattern(DATE_FORMAT_PATTERN)); + } +} diff --git a/src/main/java/org/sopt/makers/service/NotificationContext.java b/src/main/java/org/sopt/makers/service/NotificationContext.java new file mode 100644 index 0000000..cbea328 --- /dev/null +++ b/src/main/java/org/sopt/makers/service/NotificationContext.java @@ -0,0 +1,16 @@ +package org.sopt.makers.service; + +import org.sopt.makers.dto.SentryEventDetail; +import org.sopt.makers.global.exception.checked.SentryCheckedException; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class NotificationContext { + private final NotificationService strategy; + + public void executeNotification(String team, String type, String stage, SentryEventDetail event, String webhookUrl) + throws SentryCheckedException { + strategy.sendNotification(team, type, stage, event, webhookUrl); + } +} diff --git a/src/main/java/org/sopt/makers/service/NotificationProcessorService.java b/src/main/java/org/sopt/makers/service/NotificationProcessorService.java new file mode 100644 index 0000000..3845cc8 --- /dev/null +++ b/src/main/java/org/sopt/makers/service/NotificationProcessorService.java @@ -0,0 +1,27 @@ +package org.sopt.makers.service; + +import org.sopt.makers.dto.SentryEventDetail; +import org.sopt.makers.dto.WebhookRequest; +import org.sopt.makers.global.exception.checked.SentryCheckedException; +import org.sopt.makers.global.util.EnvUtil; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class NotificationProcessorService { + private final ObjectMapper objectMapper; + + public void processNotification(WebhookRequest request, SentryEventDetail event) throws SentryCheckedException { + String serviceType = request.serviceType(); + String team = request.team(); + String stage = request.stage(); + String type = request.type(); + + String webhookUrl = EnvUtil.getWebhookUrl(serviceType, team, stage, type); + NotificationContext notificationContext = new NotificationContext( + NotificationServiceFactory.createService(serviceType, objectMapper)); + notificationContext.executeNotification(team, type, stage, event, webhookUrl); + } +} diff --git a/src/main/java/org/sopt/makers/service/NotificationService.java b/src/main/java/org/sopt/makers/service/NotificationService.java index 9d54f73..8c578de 100644 --- a/src/main/java/org/sopt/makers/service/NotificationService.java +++ b/src/main/java/org/sopt/makers/service/NotificationService.java @@ -2,8 +2,8 @@ import org.sopt.makers.dto.SentryEventDetail; import org.sopt.makers.global.exception.checked.SentryCheckedException; -import org.sopt.makers.global.exception.checked.SlackMessageBuildException; -import org.sopt.makers.global.exception.checked.SlackSendException; +import org.sopt.makers.global.exception.checked.MessageBuildException; +import org.sopt.makers.global.exception.checked.SendException; public interface NotificationService { /** @@ -14,8 +14,8 @@ public interface NotificationService { * @param stage 환경 * @param eventDetail 이벤트 상세 정보 * @param webhookUrl 웹훅 URL - * @throws SlackMessageBuildException 메시지 생성 실패 시 - * @throws SlackSendException 전송 실패 시 + * @throws MessageBuildException 메시지 생성 실패 시 + * @throws SendException 전송 실패 시 */ void sendNotification(String team, String type, String stage, SentryEventDetail eventDetail, String webhookUrl) throws SentryCheckedException; diff --git a/src/main/java/org/sopt/makers/service/NotificationServiceFactory.java b/src/main/java/org/sopt/makers/service/NotificationServiceFactory.java index 82529dc..3bfcb3e 100644 --- a/src/main/java/org/sopt/makers/service/NotificationServiceFactory.java +++ b/src/main/java/org/sopt/makers/service/NotificationServiceFactory.java @@ -2,10 +2,13 @@ import java.util.HashMap; import java.util.Map; +import java.util.function.Function; import org.sopt.makers.global.exception.message.ErrorMessage; import org.sopt.makers.global.exception.unchecked.UnsupportedServiceTypeException; +import com.fasterxml.jackson.databind.ObjectMapper; + import lombok.AccessLevel; import lombok.NoArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -13,26 +16,22 @@ @Slf4j @NoArgsConstructor(access = AccessLevel.PRIVATE) public class NotificationServiceFactory { - - private static final Map serviceMap = new HashMap<>(); + private static final Map> services = new HashMap<>(); static { - serviceMap.put("slack", new SlackNotificationService()); - // serviceMap.put("discord", new DiscordNotificationService()); + registerService("slack", SlackNotificationService::new); + registerService("discord", DiscordNotificationService::new); } - /** - * 서비스 유형에 맞는 알림 서비스 구현체 반환 - * - * @param serviceType 서비스 유형 (slack, discord) - * @return 알림 서비스 구현체 - * @throws UnsupportedServiceTypeException 지원하지 않는 서비스 유형인 경우 - */ - public static NotificationService createNotificationService(String serviceType) { - NotificationService notificationService = serviceMap.get(serviceType.toLowerCase()); - if (notificationService == null) { + public static NotificationService createService(String serviceType, ObjectMapper objectMapper) { + Function supplier = services.get(serviceType.toLowerCase()); + if (supplier == null) { throw new UnsupportedServiceTypeException(ErrorMessage.UNSUPPORTED_SERVICE_TYPE); } - return notificationService; + return supplier.apply(objectMapper); + } + + private static void registerService(String type, Function supplier) { + services.put(type.toLowerCase(), supplier); } } diff --git a/src/main/java/org/sopt/makers/service/SentryEventExtractorService.java b/src/main/java/org/sopt/makers/service/SentryEventExtractorService.java new file mode 100644 index 0000000..4849224 --- /dev/null +++ b/src/main/java/org/sopt/makers/service/SentryEventExtractorService.java @@ -0,0 +1,44 @@ +package org.sopt.makers.service; + +import org.sopt.makers.dto.SentryEventDetail; +import org.sopt.makers.global.exception.message.ErrorMessage; +import org.sopt.makers.global.exception.unchecked.InvalidPayloadException; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +public class SentryEventExtractorService { + private final ObjectMapper objectMapper; + + public SentryEventDetail extractEvent(String requestBody) { + JsonNode rootNode = parseRequestBody(requestBody); + JsonNode eventNode = rootNode.path("data").path("event"); + + log.info("rootNode 정보: {}", rootNode); + log.info("eventNode 정보: {}", eventNode); + + if (eventNode.isMissingNode() || eventNode.isEmpty()) { + log.error("[이벤트 데이터 누락] 요청 본문에 필수 이벤트 정보가 없습니다"); + throw InvalidPayloadException.from(ErrorMessage.INVALID_PAYLOAD); + } + + SentryEventDetail sentryEvent = SentryEventDetail.from(eventNode); + log.info("[이벤트 추출] issueId={}, level={}", sentryEvent.issueId(), sentryEvent.level()); + + return sentryEvent; + } + + private JsonNode parseRequestBody(String requestBody) { + try { + return objectMapper.readTree(requestBody); + } catch (Exception e) { + log.error("[요청 본문 파싱 실패] error={}", e.getMessage(), e); + throw InvalidPayloadException.from(ErrorMessage.INVALID_PAYLOAD); + } + } +} diff --git a/src/main/java/org/sopt/makers/service/SlackNotificationService.java b/src/main/java/org/sopt/makers/service/SlackNotificationService.java index 6f83c1b..dac72ce 100644 --- a/src/main/java/org/sopt/makers/service/SlackNotificationService.java +++ b/src/main/java/org/sopt/makers/service/SlackNotificationService.java @@ -16,8 +16,8 @@ import org.sopt.makers.global.config.ObjectMapperConfig; import org.sopt.makers.global.constant.Color; import org.sopt.makers.global.exception.checked.SentryCheckedException; -import org.sopt.makers.global.exception.checked.SlackMessageBuildException; -import org.sopt.makers.global.exception.checked.SlackSendException; +import org.sopt.makers.global.exception.checked.MessageBuildException; +import org.sopt.makers.global.exception.checked.SendException; import org.sopt.makers.global.exception.message.ErrorMessage; import org.sopt.makers.global.exception.unchecked.HttpRequestException; import org.sopt.makers.global.util.HttpClientUtil; @@ -32,17 +32,14 @@ import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @Slf4j +@RequiredArgsConstructor public class SlackNotificationService implements NotificationService { - private final ObjectMapper objectMapper; - public SlackNotificationService() { - this.objectMapper = ObjectMapperConfig.getInstance(); - } - @Override public void sendNotification(String team, String type, String stage, SentryEventDetail sentryEventDetail, String webhookUrl) throws SentryCheckedException { @@ -57,7 +54,7 @@ private SlackMessage createSlackMessage(String team, String type, String stage, } catch (DateTimeException e) { log.error("Slack 메시지 생성 실패: team={}, type={}, stage={}, id={}, error={}", team, type, stage, sentryEventDetail.issueId(), e.getMessage(), e); - throw SlackMessageBuildException.from(ErrorMessage.SLACK_MESSAGE_BUILD_FAILED); + throw MessageBuildException.from(ErrorMessage.SLACK_MESSAGE_BUILD_FAILED); } } @@ -69,19 +66,19 @@ private void sendSlackMessage(SlackMessage slackMessage, String webhookUrl, Stri HttpResponse response = HttpClientUtil.sendPost(webhookUrl, CONTENT_TYPE_JSON, jsonPayload); handleSlackResponse(response, team, type, stage, sentryEventDetail); } catch (HttpRequestException e) { - throw SlackSendException.from(e.getBaseErrorCode()); + throw SendException.from(e.getBaseErrorCode()); } catch (IOException e) { - throw SlackSendException.from(ErrorMessage.SLACK_SERIALIZATION_FAILED); + throw SendException.from(ErrorMessage.SLACK_SERIALIZATION_FAILED); } } private void handleSlackResponse(HttpResponse response, String team, String type, String stage, - SentryEventDetail sentryEventDetail) throws SlackSendException { + SentryEventDetail sentryEventDetail) throws SendException { if (response.statusCode() != 200 || !"ok".equalsIgnoreCase(response.body())) { String errorMsg = String.format("Slack API 응답 오류, status: %d, body: %s", response.statusCode(), response.body()); log.error("{}", errorMsg); - throw SlackSendException.from(ErrorMessage.SLACK_SEND_FAILED); + throw SendException.from(ErrorMessage.SLACK_SEND_FAILED); } log.info("[Slack 전송 완료] team={}, type={}, stage={}, id={}, statusCode={}", team, type, stage, diff --git a/src/main/java/org/sopt/makers/vo/discord/embed/DiscordEmbed.java b/src/main/java/org/sopt/makers/vo/discord/embed/DiscordEmbed.java new file mode 100644 index 0000000..410e4b3 --- /dev/null +++ b/src/main/java/org/sopt/makers/vo/discord/embed/DiscordEmbed.java @@ -0,0 +1,26 @@ +package org.sopt.makers.vo.discord.embed; + +import java.util.List; + +public record DiscordEmbed( + String title, + String description, + String url, + Integer color, + List fields +) { + public static DiscordEmbed newInstance(String title, String description, String url, Integer color, + List fields) { + return new DiscordEmbed(title, description, url, color, fields); + } + + public record DiscordEmbedField( + String name, + String value, + boolean inline + ) { + public static DiscordEmbedField newInstance(String name, String value, boolean inline) { + return new DiscordEmbedField(name, value, inline); + } + } +} diff --git a/src/main/java/org/sopt/makers/vo/discord/message/DiscordMessage.java b/src/main/java/org/sopt/makers/vo/discord/message/DiscordMessage.java new file mode 100644 index 0000000..a01d3ff --- /dev/null +++ b/src/main/java/org/sopt/makers/vo/discord/message/DiscordMessage.java @@ -0,0 +1,18 @@ +package org.sopt.makers.vo.discord.message; + +import java.util.List; + +import org.sopt.makers.vo.discord.embed.DiscordEmbed; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record DiscordMessage( + String username, + @JsonProperty("avatar_url") + String avatarUrl, + List embeds +) { + public static DiscordMessage newInstance(String username, String avatarUrl, List embeds) { + return new DiscordMessage(username, avatarUrl, embeds); + } +} diff --git a/src/test/java/org/sopt/makers/global/util/EnvUtilTest.java b/src/test/java/org/sopt/makers/global/util/EnvUtilTest.java index 0a00eb6..ba27f0b 100644 --- a/src/test/java/org/sopt/makers/global/util/EnvUtilTest.java +++ b/src/test/java/org/sopt/makers/global/util/EnvUtilTest.java @@ -19,19 +19,25 @@ import org.sopt.makers.global.exception.unchecked.UnsupportedServiceTypeException; import org.sopt.makers.global.exception.unchecked.WebhookUrlNotFoundException; +import io.github.cdimascio.dotenv.Dotenv; + @DisplayName("EnvUtil 테스트") class EnvUtilTest { - private static final String ENV_FILE_PATH = "src/test/resources/.env"; @BeforeAll static void setUp() throws IOException { String content = """ - SLACK_WEBHOOK_CREW_DEV_BE=https://hooks.slack.com/services/crew/dev/be - SLACK_WEBHOOK_APP_PROD_FE=https://hooks.slack.com/services/app/prod/fe - """; + SLACK_WEBHOOK_CREW_DEV_BE=https://hooks.slack.com/services/crew/dev/be + SLACK_WEBHOOK_APP_PROD_FE=https://hooks.slack.com/services/app/prod/fe + """; Files.createDirectories(Paths.get("src/test/resources")); Files.write(Paths.get(ENV_FILE_PATH), content.getBytes()); + + Dotenv testDotenv = Dotenv.configure() + .directory("src/test/resources") + .load(); + EnvUtil.setDotenv(testDotenv); } @AfterAll @@ -43,7 +49,7 @@ static void tearDown() throws IOException { @Test void testGetWebhookUrl_valid() { String expectedUrl = "https://hooks.slack.com/services/crew/dev/be"; - String actualUrl = TestEnvUtil.getWebhookUrl("slack", "crew", "dev", "be"); + String actualUrl = EnvUtil.getWebhookUrl("slack", "crew", "dev", "be"); assertEquals(expectedUrl, actualUrl); } @@ -52,7 +58,7 @@ void testGetWebhookUrl_valid() { @Test void testGetWebhookUrl_notFound() { WebhookUrlNotFoundException exception = assertThrows(WebhookUrlNotFoundException.class, () -> - TestEnvUtil.getWebhookUrl("slack", "crew", "prod", "be") + EnvUtil.getWebhookUrl("slack", "crew", "prod", "be") ); assertTrue(exception.getMessage().contains("Webhook URL을 찾을 수 없습니다.")); @@ -62,7 +68,7 @@ void testGetWebhookUrl_notFound() { @Test void testGetWebhookUrl_unsupportedServiceType() { assertThrows(UnsupportedServiceTypeException.class, () -> - TestEnvUtil.getWebhookUrl("telegram", "crew", "dev", "be") + EnvUtil.getWebhookUrl("telegram", "crew", "dev", "be") ); } @@ -71,7 +77,7 @@ void testGetWebhookUrl_unsupportedServiceType() { @MethodSource("provideNullParameters") void testGetWebhookUrl_withNullParameters_shouldThrow(String service, String team, String stage, String type) { assertThrows(InvalidEnvParameterException.class, () -> - TestEnvUtil.getWebhookUrl(service, team, stage, type) + EnvUtil.getWebhookUrl(service, team, stage, type) ); } @@ -87,12 +93,12 @@ void testGetWebhookUrl_withNullParameters_shouldThrow(String service, String tea }) void testGetWebhookUrl_caseInsensitive(String service, String team, String stage, String type) { if (team.equalsIgnoreCase("app")) { - String actualUrl = TestEnvUtil.getWebhookUrl(service, team, stage, type); + String actualUrl = EnvUtil.getWebhookUrl(service, team, stage, type); assertEquals("https://hooks.slack.com/services/app/prod/fe", actualUrl); return; } - String actualUrl = TestEnvUtil.getWebhookUrl(service, team, stage, type); + String actualUrl = EnvUtil.getWebhookUrl(service, team, stage, type); assertEquals("https://hooks.slack.com/services/crew/dev/be", actualUrl); } private static Stream provideNullParameters() { diff --git a/src/test/java/org/sopt/makers/global/util/TestEnvUtil.java b/src/test/java/org/sopt/makers/global/util/TestEnvUtil.java deleted file mode 100644 index 2c8302b..0000000 --- a/src/test/java/org/sopt/makers/global/util/TestEnvUtil.java +++ /dev/null @@ -1,56 +0,0 @@ -package org.sopt.makers.global.util; - -import org.sopt.makers.global.exception.message.ErrorMessage; -import org.sopt.makers.global.exception.unchecked.InvalidEnvParameterException; -import org.sopt.makers.global.exception.unchecked.UnsupportedServiceTypeException; -import org.sopt.makers.global.exception.unchecked.WebhookUrlNotFoundException; - -import io.github.cdimascio.dotenv.Dotenv; - -class TestEnvUtil { - private static final String SLACK_WEBHOOK_PREFIX = "SLACK_WEBHOOK_"; - private static final String DISCORD_WEBHOOK_PREFIX = "DISCORD_WEBHOOK_"; - private static final Dotenv dotenv = Dotenv.configure() - .directory("src/test/resources") - .load(); - - /** - * 서비스 유형에 맞는 웹훅 URL 반환 - * - * @param service 서비스 유형 (slack, discord 등) - * @param team 팀 이름 (crew, app 등) - * @param stage 환경 (dev, prod) - * @param type 서버 유형 (be, fe) - * @return 웹훅 URL - * @throws UnsupportedServiceTypeException 지원하지 않는 서비스 유형인 경우 - * @throws WebhookUrlNotFoundException 환경 변수를 찾을 수 없는 경우 - */ - public static String getWebhookUrl(String service, String team, String stage, String type) { - if (service == null || team == null || stage == null || type == null) { - throw InvalidEnvParameterException.from(ErrorMessage.INVALID_ENV_PARAMETER); - } - - String prefix = resolvePrefix(service.toLowerCase()); - String envKey = String.format("%s%s_%s_%s", - prefix, - team.toUpperCase(), - stage.toUpperCase(), - type.toUpperCase()); - - String webhookUrl = dotenv.get(envKey); - - if (webhookUrl == null || webhookUrl.isBlank()) { - throw new WebhookUrlNotFoundException(ErrorMessage.WEBHOOK_URL_NOT_FOUND); - } - - return webhookUrl; - } - - private static String resolvePrefix(String service) { - return switch (service) { - case "slack" -> SLACK_WEBHOOK_PREFIX; - case "discord" -> DISCORD_WEBHOOK_PREFIX; - default -> throw new UnsupportedServiceTypeException(ErrorMessage.UNSUPPORTED_SERVICE_TYPE); - }; - } -} diff --git a/src/test/java/org/sopt/makers/handler/FakeSentryPayload.java b/src/test/java/org/sopt/makers/handler/FakeSentryPayload.java new file mode 100644 index 0000000..7186704 --- /dev/null +++ b/src/test/java/org/sopt/makers/handler/FakeSentryPayload.java @@ -0,0 +1,424 @@ +package org.sopt.makers.handler; + +public final class FakeSentryPayload { + public static final String REAL_SENTRY_WEBHOOK_PAYLOAD = """ + { + "action": "triggered", + "installation": { + "uuid": "14570066-3c21-4c4d-8e9d-78e4995df1f3" + }, + "data": { + "event": { + "event_id": "93153fe001674c70b39af22c49db350b", + "project": 9999999999999999, + "release": "x.y.z", + "dist": null, + "platform": "other", + "message": "Authentication failed, token expired!", + "datetime": "2025-05-21T08:52:45.182000Z", + "tags": [ + [ + "browser", + "Chrome 60.0.3112" + ], + [ + "browser.name", + "Chrome" + ], + [ + "client_os", + "Mac OS X 10.12.6" + ], + [ + "client_os.name", + "Mac OS X" + ], + [ + "device", + "Mac" + ], + [ + "device.family", + "Mac" + ], + [ + "environment", + "production" + ], + [ + "level", + "error" + ], + [ + "os", + "Mac OS X 10.12.6" + ], + [ + "sample_event", + "yes" + ], + [ + "release", + "x.y.z" + ], + [ + "user", + "username:anonymous" + ], + [ + "server_name", + "server-example.com" + ], + [ + "url", + "http://localhost:8080/" + ] + ], + "_meta": { + "platform": { + "": { + "val": "java-logback" + } + } + }, + "_metrics": { + "bytes.stored.event": 33577 + }, + "_ref": 9999999999999999, + "_ref_version": 2, + "breadcrumbs": { + "values": [ + { + "level": "debug", + "message": "Querying for user.", + "timestamp": 1501799139, + "type": "default" + }, + { + "level": "debug", + "message": "User found: anonymous@example.com", + "timestamp": 1501799139, + "type": "default" + }, + { + "level": "info", + "message": "Loaded homepage content from memcached.", + "timestamp": 1501799139, + "type": "default" + }, + { + "level": "warning", + "message": "Sidebar content not in cache, hitting API server.", + "timestamp": 1501799139, + "type": "default" + } + ] + }, + "contexts": { + "browser": { + "browser": "Chrome 60.0.3112", + "name": "Chrome", + "type": "browser", + "version": "60.0.3112" + }, + "client_os": { + "name": "Mac OS X", + "os": "Mac OS X 10.12.6", + "type": "os", + "version": "10.12.6" + }, + "device": { + "brand": "Apple", + "family": "Mac", + "model": "Mac", + "type": "device" + } + }, + "culprit": "io.example.ApiRequest in perform", + "environment": "production", + "exception": { + "values": [ + { + "module": "io.example", + "stacktrace": { + "frames": [ + { + "abs_path": "Thread.java", + "addr_mode": null, + "colno": null, + "context_line": null, + "data": { + "category": "std", + "client_in_app": false + }, + "errors": null, + "filename": "Thread.java", + "function": "run", + "image_addr": null, + "in_app": false, + "instruction_addr": null, + "lineno": 748, + "lock": null, + "module": "java.lang.Thread", + "package": null, + "platform": null, + "post_context": null, + "pre_context": null, + "raw_function": null, + "source_link": null, + "symbol": null, + "symbol_addr": null, + "trust": null, + "vars": null + }, + { + "abs_path": "Application.java", + "addr_mode": null, + "colno": null, + "context_line": null, + "data": { + "category": "framework", + "client_in_app": true, + "orig_in_app": 1 + }, + "errors": null, + "filename": "Application.java", + "function": "home", + "image_addr": null, + "in_app": false, + "instruction_addr": null, + "lineno": 102, + "lock": null, + "module": "io.example.Application", + "package": null, + "platform": null, + "post_context": null, + "pre_context": null, + "raw_function": null, + "source_link": null, + "symbol": null, + "symbol_addr": null, + "trust": null, + "vars": null + }, + { + "abs_path": "Sidebar.java", + "addr_mode": null, + "colno": null, + "context_line": null, + "data": { + "category": "framework", + "client_in_app": true, + "orig_in_app": 1 + }, + "errors": null, + "filename": "Sidebar.java", + "function": "fetch", + "image_addr": null, + "in_app": false, + "instruction_addr": null, + "lineno": 5, + "lock": null, + "module": "io.example.Sidebar", + "package": null, + "platform": null, + "post_context": null, + "pre_context": null, + "raw_function": null, + "source_link": null, + "symbol": null, + "symbol_addr": null, + "trust": null, + "vars": null + }, + { + "abs_path": "ApiRequest.java", + "addr_mode": null, + "colno": null, + "context_line": null, + "data": { + "category": "framework", + "client_in_app": true, + "orig_in_app": 1 + }, + "errors": null, + "filename": "ApiRequest.java", + "function": "perform", + "image_addr": null, + "in_app": false, + "instruction_addr": null, + "lineno": 8, + "lock": null, + "module": "io.example.ApiRequest", + "package": null, + "platform": null, + "post_context": null, + "pre_context": null, + "raw_function": null, + "source_link": null, + "symbol": null, + "symbol_addr": null, + "trust": null, + "vars": null + } + ] + }, + "type": "ApiException", + "value": "Authentication failed, token expired!" + } + ] + }, + "extra": { + "emptyList": [], + "emptyMap": {}, + "length": 10837790, + "results": [ + 1, + 2, + 3, + 4, + 5 + ], + "session": { + "foo": "bar" + }, + "unauthorized": false, + "url": "http://example.org/foo/bar/" + }, + "fingerprint": [ + "{{ default }}" + ], + "grouping_config": { + "enhancements": "KLUv_SAYwQAAkwKRs25ld3N0eWxlOjIwMjMtMDEtMTGQ", + "id": "newstyle:2023-01-11" + }, + "hashes": [ + "438f4d33ad9cb76a7531995b59d34c96" + ], + "level": "error", + "location": "ApiRequest.java", + "logentry": { + "formatted": "Authentication failed, token expired!", + "message": null, + "params": null + }, + "logger": "", + "metadata": { + "filename": "ApiRequest.java", + "function": "perform", + "in_app_frame_mix": "system-only", + "type": "ApiException", + "value": "Authentication failed, token expired!" + }, + "modules": { + "my.package": "1.0.0" + }, + "nodestore_insert": 1747817625.45355, + "received": 1747817625.183474, + "request": { + "api_target": null, + "cookies": null, + "data": { + "logged_in": [ + "1" + ] + }, + "env": { + "AUTH_TYPE": null, + "LOCAL_ADDR": "0:0:0:0:0:0:0:1", + "LOCAL_NAME": "localhost", + "LOCAL_PORT": 8080, + "REMOTE_ADDR": "0:0:0:0:0:0:0:1", + "REMOTE_USER": null, + "REQUEST_ASYNC": false, + "REQUEST_SECURE": false, + "SERVER_NAME": "localhost", + "SERVER_PORT": 8080, + "SERVER_PROTOCOL": "HTTP/1.1" + }, + "fragment": null, + "headers": [ + [ + "Accept-Language", + "en-US,en;q=0.8" + ], + [ + "Host", + "localhost:8080" + ], + [ + "Upgrade-Insecure-Requests", + "1" + ], + [ + "Connection", + "keep-alive" + ], + [ + "Dnt", + "1" + ], + [ + "Cache-Control", + "max-age=0" + ], + [ + "Accept-Encoding", + "gzip, deflate, br" + ], + [ + "User-Agent", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.78 Safari/537.36" + ], + [ + "Accept", + "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8" + ] + ], + "inferred_content_type": "application/json", + "method": "GET", + "query_string": [ + [ + "logged_in", + "1" + ] + ], + "url": "http://localhost:8080/" + }, + "sdk": { + "integrations": null, + "name": "sentry-java", + "packages": null, + "version": "1.4.0-3ded0" + }, + "timestamp": 1747817565.182, + "title": "ApiException: Authentication failed, token expired!", + "type": "error", + "user": { + "data": { + "account_level": "premium" + }, + "email": "anonymous@example.com", + "ip_address": "0:0:0:0:0:0:0:1", + "sentry_user": "username:anonymous", + "username": "anonymous" + }, + "version": "5", + "url": "https://sentry.io/api/0/projects/example-org/example-project/events/93153fe001674c70b39af22c49db350b/", + "web_url": "https://sentry.io/organizations/example-org/issues/0000000000/events/93153fe001674c70b39af22c49db350b/", + "issue_url": "https://sentry.io/api/0/organizations/example-org/issues/0000000000/", + "issue_id": "0000000000" + }, + "triggered_rule": "example-alert-dev" + }, + "actor": { + "type": "application", + "id": "sentry", + "name": "Sentry" + } + } + """; + + private FakeSentryPayload() { + } +} diff --git a/src/test/java/org/sopt/makers/handler/SentryWebhookHandlerTest.java b/src/test/java/org/sopt/makers/handler/SentryWebhookHandlerTest.java new file mode 100644 index 0000000..ebfe69b --- /dev/null +++ b/src/test/java/org/sopt/makers/handler/SentryWebhookHandlerTest.java @@ -0,0 +1,485 @@ +package org.sopt.makers.handler; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.*; +import static org.assertj.core.api.Assertions.*; +import static org.sopt.makers.handler.FakeSentryPayload.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.sopt.makers.global.util.EnvUtil; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.common.ConsoleNotifier; + +import io.github.cdimascio.dotenv.Dotenv; + +@ExtendWith(MockitoExtension.class) +@DisplayName("SentryWebhookHandler 테스트") +class SentryWebhookHandlerTest { + private static final String ENV_FILE_PATH = "src/test/resources/.env"; + + @Mock + private Context lambdaContext; + + private WireMockServer wireMockServer; + private SentryWebhookHandler handler; + + @BeforeAll + static void setUpEnvironment() throws IOException { + String content = """ + SLACK_WEBHOOK_CREW_DEV_BE=http://localhost:8089/services/crew/dev/be + SLACK_WEBHOOK_APP_PROD_FE=http://localhost:8089/services/app/prod/fe + DISCORD_WEBHOOK_CREW_PROD_BE=http://localhost:8089/api/webhooks/123456789/crew-prod-be-token + DISCORD_WEBHOOK_APP_DEV_FE=http://localhost:8089/api/webhooks/987654321/app-dev-fe-token + """; + Files.createDirectories(Paths.get("src/test/resources")); + Files.write(Paths.get(ENV_FILE_PATH), content.getBytes()); + + Dotenv testDotenv = Dotenv.configure().directory("src/test/resources").load(); + EnvUtil.setDotenv(testDotenv); + } + + @AfterAll + static void tearDownEnvironment() throws IOException { + Files.deleteIfExists(Paths.get(ENV_FILE_PATH)); + } + + @BeforeEach + void setUp() { + wireMockServer = new WireMockServer(wireMockConfig().port(8089).notifier(new ConsoleNotifier(false))); + wireMockServer.start(); + + handler = new SentryWebhookHandler(); + setupDefaultApiResponses(); + } + + @AfterEach + void tearDown() { + if (wireMockServer != null) { + wireMockServer.stop(); + } + } + + @Nested + @DisplayName("Discord 알림 테스트") + class DiscordNotificationTests { + + @Test + @DisplayName("CREW PROD BE 환경으로 Discord 알림 전송 성공") + void shouldSendDiscordNotificationToCrewProdBe() { + // Given + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEvent("prod", "crew", "be", "discord"); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + + // Then + assertThat(response.getStatusCode()).isEqualTo(200); + + wireMockServer.verify(1, + postRequestedFor(urlPathMatching("/api/webhooks/123456789/crew-prod-be-token")) + .withHeader("Content-Type", equalTo("application/json")) + .withRequestBody(containing("embeds"))); + + wireMockServer.verify(0, postRequestedFor(urlPathMatching("/services/.*"))); + } + + @Test + @DisplayName("APP DEV FE 환경으로 Discord 알림 전송 성공") + void shouldSendDiscordNotificationToAppDevFe() { + // Given + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEvent("dev", "app", "fe", "discord"); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + + // Then + assertThat(response.getStatusCode()).isEqualTo(200); + + wireMockServer.verify(1, + postRequestedFor(urlPathMatching("/api/webhooks/987654321/app-dev-fe-token")) + .withHeader("Content-Type", equalTo("application/json")) + .withRequestBody(containing("embeds"))); + + wireMockServer.verify(0, postRequestedFor(urlPathMatching("/services/.*"))); + } + + @Test + @DisplayName("Discord API 404 에러 응답 처리") + void shouldHandleDiscordApiNotFoundError() { + // Given + wireMockServer.resetMappings(); + setupDefaultApiResponses(); + wireMockServer.stubFor( + post(urlPathMatching("/api/webhooks/.*")) + .willReturn(aResponse().withStatus(404) + .withHeader("Content-Type", "application/json") + .withBody("{\"message\": \"Unknown Webhook\", \"code\": 10015}")) + ); + + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEvent("prod", "crew", "be", "discord"); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + + // Then + assertThat(response.getStatusCode()).isEqualTo(502); + wireMockServer.verify(1, postRequestedFor(urlPathMatching("/api/webhooks/.*"))); + } + + @Test + @DisplayName("Discord API Rate Limiting 처리") + void shouldHandleDiscordRateLimiting() { + // Given + wireMockServer.resetMappings(); + setupDefaultApiResponses(); + wireMockServer.stubFor( + post(urlPathMatching("/api/webhooks/.*")) + .willReturn(aResponse().withStatus(429) + .withHeader("Retry-After", "5") + .withBody("{\"message\": \"You are being rate limited.\", \"retry_after\": 5000}")) + ); + + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEvent("prod", "crew", "be", "discord"); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + + // Then + assertThat(response.getStatusCode()).isEqualTo(502); + wireMockServer.verify(1, postRequestedFor(urlPathMatching("/api/webhooks/.*"))); + } + + @Test + @DisplayName("Critical 레벨 Sentry 이벤트 처리") + void shouldHandleCriticalSentryEventWithDiscord() { + // Given + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEventWithLevel("dev", "app", "fe", "discord", "fatal"); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + + // Then + assertThat(response.getStatusCode()).isEqualTo(200); + wireMockServer.verify(1, + postRequestedFor(urlPathMatching("/api/webhooks/.*")) + .withRequestBody(containing("embeds"))); + } + + @Test + @DisplayName("Warning 레벨 Sentry 이벤트 처리") + void shouldHandleWarningSentryEventWithDiscord() { + // Given + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEventWithLevel("dev", "app", "fe", "discord", "warning"); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + + // Then + assertThat(response.getStatusCode()).isEqualTo(200); + wireMockServer.verify(1, postRequestedFor(urlPathMatching("/api/webhooks/.*"))); + } + + @Test + @DisplayName("Info 레벨 Sentry 이벤트 처리") + void shouldHandleInfoSentryEventWithDiscord() { + // Given + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEventWithLevel("dev", "app", "fe", "discord", "info"); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + + // Then + assertThat(response.getStatusCode()).isEqualTo(200); + wireMockServer.verify(1, postRequestedFor(urlPathMatching("/api/webhooks/.*"))); + } + } + + @Nested + @DisplayName("Slack 알림 테스트") + class SlackNotificationTests { + + @Test + @DisplayName("CREW DEV BE 환경으로 Slack 알림 전송 성공") + void shouldSendSlackNotificationToCrewDevBe() { + // Given + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEvent("dev", "crew", "be", "slack"); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + + // Then + assertThat(response.getStatusCode()).isEqualTo(200); + + wireMockServer.verify(1, + postRequestedFor(urlPathMatching("/services/crew/dev/be")) + .withHeader("Content-Type", equalTo("application/json")) + .withRequestBody(containing("text")) // Slack은 "text" 필드 사용 + ); + + wireMockServer.verify(0, postRequestedFor(urlPathMatching("/api/webhooks/.*"))); + } + + @Test + @DisplayName("APP PROD FE 환경으로 Slack 알림 전송 성공") + void shouldSendSlackNotificationToAppProdFe() { + // Given + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEvent("prod", "app", "fe", "slack"); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + + // Then + assertThat(response.getStatusCode()).isEqualTo(200); + + wireMockServer.verify(1, + postRequestedFor(urlPathMatching("/services/app/prod/fe")) + .withHeader("Content-Type", equalTo("application/json")) + .withRequestBody(containing("text"))); + + wireMockServer.verify(0, postRequestedFor(urlPathMatching("/api/webhooks/.*"))); + } + + @Test + @DisplayName("Slack API 에러 응답 처리") + void shouldHandleSlackApiError() { + // Given - Slack API 에러 응답 설정 + wireMockServer.resetMappings(); + setupDefaultApiResponses(); + wireMockServer.stubFor( + post(urlPathMatching("/services/.*")) + .willReturn(aResponse().withStatus(400) + .withHeader("Content-Type", "text/plain") + .withBody("invalid_payload")) + ); + + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEvent("dev", "crew", "be", "slack"); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + + // Then + assertThat(response.getStatusCode()).isEqualTo(502); + + wireMockServer.verify(1, postRequestedFor(urlPathMatching("/services/.*"))); + } + + @Test + @DisplayName("네트워크 타임아웃 시나리오") + void shouldHandleNetworkTimeout() { + // Given + wireMockServer.resetMappings(); + setupDefaultApiResponses(); + wireMockServer.stubFor( + post(urlPathMatching("/api/webhooks/.*")) + .willReturn(aResponse().withStatus(200).withFixedDelay(10000)) // 10초 지연 + ); + + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEvent("prod", "crew", "be", "discord"); + + // When & Then + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + assertThat(response.getStatusCode()).isEqualTo(503); + } + + @Test + @DisplayName("Slack API Rate Limiting 처리") + void shouldHandleSlackRateLimiting() { + // Given - Slack Rate Limiting 응답 설정 + wireMockServer.resetMappings(); + setupDefaultApiResponses(); + wireMockServer.stubFor( + post(urlPathMatching("/services/.*")) + .willReturn(aResponse().withStatus(429) + .withHeader("Retry-After", "1") + .withBody("rate_limited")) + ); + + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEvent("dev", "crew", "be", "slack"); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + + // Then + assertThat(response.getStatusCode()).isEqualTo(502); + wireMockServer.verify(1, postRequestedFor(urlPathMatching("/services/.*"))); + } + + @Test + @DisplayName("Critical 레벨 Sentry 이벤트 처리") + void shouldHandleCriticalSentryEventWithSlack() { + // Given + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEventWithLevel("dev", "crew", "be", "slack", "fatal"); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + + // Then + assertThat(response.getStatusCode()).isEqualTo(200); + + wireMockServer.verify(1, + postRequestedFor(urlPathMatching("/services/.*")) + .withRequestBody(containing("attachments"))); + + wireMockServer.verify(0, postRequestedFor(urlPathMatching("/api/webhooks/.*"))); + } + + + @Test + @DisplayName("Warning 레벨 Sentry 이벤트 처리") + void shouldHandleWarningSentryEventWithSlack() { + // Given + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEventWithLevel("dev", "crew", "be", "slack", "warning"); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + + // Then + assertThat(response.getStatusCode()).isEqualTo(200); + wireMockServer.verify(1, postRequestedFor(urlPathMatching("/services/.*"))); + wireMockServer.verify(0, postRequestedFor(urlPathMatching("/api/webhooks/.*"))); + } + + @Test + @DisplayName("Info 레벨 Sentry 이벤트 처리") + void shouldHandleInfoSentryEventWithSlack() { + // Given + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEventWithLevel("dev", "crew", "be", "slack", "info"); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + + // Then + assertThat(response.getStatusCode()).isEqualTo(200); + wireMockServer.verify(1, postRequestedFor(urlPathMatching("/services/.*"))); + wireMockServer.verify(0, postRequestedFor(urlPathMatching("/api/webhooks/.*"))); + } + } + + @Nested + @DisplayName("에러 처리 테스트") + class ErrorHandlingTests { + + @Test + @DisplayName("잘못된 환경 파라미터 처리") + void shouldHandleInvalidEnvironmentParameters() { + // Given + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEvent("invalid", "invalid", "invalid", "discord"); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + + // Then + assertThat(response.getStatusCode()).isEqualTo(404); + } + + @Test + @DisplayName("PathParameters가 없는 경우 처리") + void shouldHandleMissingPathParameters() { + // Given + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEvent("prod", "crew", "be", "discord"); + apiEvent.setPathParameters(null); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + + // Then + assertThat(response.getStatusCode()).isEqualTo(400); + } + + @Test + @DisplayName("잘못된 JSON 페이로드 처리") + void shouldHandleInvalidJsonPayload() { + // Given + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEvent("prod", "crew", "be", "discord"); + apiEvent.setBody("{invalid json}"); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + + // Then + assertThat(response.getStatusCode()).isEqualTo(400); + } + + @Test + @DisplayName("빈 페이로드 처리") + void shouldHandleEmptyPayload() { + // Given + APIGatewayProxyRequestEvent apiEvent = createSentryWebhookEvent("prod", "crew", "be", "discord"); + apiEvent.setBody(null); + + // When + APIGatewayProxyResponseEvent response = handler.handleRequest(apiEvent, lambdaContext); + + // Then + assertThat(response.getStatusCode()).isEqualTo(400); + } + } + + private APIGatewayProxyRequestEvent createSentryWebhookEvent(String env, String team, String type, String service) { + APIGatewayProxyRequestEvent event = new APIGatewayProxyRequestEvent(); + event.setPath("/webhook"); + event.setHttpMethod("POST"); + + Map headers = new HashMap<>(); + headers.put("Content-Type", "application/json"); + event.setHeaders(headers); + + Map pathParams = new HashMap<>(); + pathParams.put("team", team); + pathParams.put("type", type); + pathParams.put("service", service); + event.setPathParameters(pathParams); + + event.setBody(REAL_SENTRY_WEBHOOK_PAYLOAD); + + APIGatewayProxyRequestEvent.ProxyRequestContext context = new APIGatewayProxyRequestEvent.ProxyRequestContext(); + context.setStage(env); + event.setRequestContext(context); + + return event; + } + + private APIGatewayProxyRequestEvent createSentryWebhookEventWithLevel(String env, String team, String type, String service, String level) { + APIGatewayProxyRequestEvent event = createSentryWebhookEvent(env, team, type, service); + + String payload = event.getBody().replace("\"level\": \"error\"", "\"level\": \"" + level + "\""); + event.setBody(payload); + + return event; + } + + private void setupDefaultApiResponses() { + wireMockServer.stubFor( + post(urlPathMatching("/api/webhooks/[0-9]+/.*")) + .willReturn(aResponse().withStatus(204) + .withHeader("Content-Type", "application/json") + .withBody("{\"id\": \"123456789\", \"type\": 1}")) + ); + + wireMockServer.stubFor( + post(urlPathMatching("/services/.*")) + .willReturn(aResponse().withStatus(200) + .withHeader("Content-Type", "text/plain") + .withBody("ok")) + ); + } +} diff --git a/src/test/java/org/sopt/makers/service/SentryEventExtractorServiceTest.java b/src/test/java/org/sopt/makers/service/SentryEventExtractorServiceTest.java new file mode 100644 index 0000000..a0f1a39 --- /dev/null +++ b/src/test/java/org/sopt/makers/service/SentryEventExtractorServiceTest.java @@ -0,0 +1,39 @@ +package org.sopt.makers.service; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.sopt.makers.dto.SentryEventDetail; +import org.sopt.makers.global.config.ObjectMapperConfig; +import org.sopt.makers.handler.FakeSentryPayload; + +import com.fasterxml.jackson.databind.ObjectMapper; + +@DisplayName("SentryEventExtractorService 테스트") +class SentryEventExtractorServiceTest { + private final ObjectMapper objectMapper = ObjectMapperConfig.getInstance(); + private final SentryEventExtractorService extractorService = new SentryEventExtractorService(objectMapper); + + @Test + @DisplayName("실제 Sentry 웹훅 데이터에서 이벤트 정보를 정확히 추출하는지 테스트") + void extractSentryEventFromRealPayload() { + // Given + String payload = FakeSentryPayload.REAL_SENTRY_WEBHOOK_PAYLOAD; + + // When + SentryEventDetail result = extractorService.extractEvent(payload); + + // Then + assertNotNull(result); + assertEquals("0000000000", result.issueId()); + assertEquals( + "https://sentry.io/organizations/example-org/issues/0000000000/events/93153fe001674c70b39af22c49db350b/", + result.webUrl()); + assertEquals("Authentication failed, token expired!", result.message()); + assertEquals("2025-05-21T08:52:45.182000Z", result.datetime()); + assertEquals("error", result.level()); + assertEquals("ApiException: Authentication failed, token expired!", result.title()); + } +} +