From 3301fea2cac7a3eecb9949394c3eebdb2a0b2d78 Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Wed, 8 Oct 2025 14:50:51 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EB=B0=9C=EC=86=A1=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/wellmeet/config/EmailConfig.java | 17 ++ .../com/wellmeet/exception/ErrorCode.java | 2 + .../consumer/dto/NotificationInfo.java | 5 + .../consumer/dto/NotificationMessage.java | 6 + .../email/domain/EmailSubscription.java | 34 ++++ .../EmailSubscriptionRepository.java | 10 ++ .../email/sender/EmailSender.java | 58 +++++++ .../email/sender/MailTransport.java | 31 ++++ .../email/sender/MailViewRenderer.java | 38 +++++ .../webpush/domain/PushSubscription.java | 3 - .../templates/email-notification.html | 77 +++++++++ .../java/com/wellmeet/BaseControllerTest.java | 3 +- .../java/com/wellmeet/BaseServiceTest.java | 3 +- .../WellmeetNotificationApplicationTests.java | 3 +- .../com/wellmeet/config/EmailTestConfig.java | 18 ++ .../com/wellmeet/email/EmailSenderTest.java | 156 ++++++++++++++++++ 16 files changed, 458 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/wellmeet/config/EmailConfig.java create mode 100644 src/main/java/com/wellmeet/notification/email/domain/EmailSubscription.java create mode 100644 src/main/java/com/wellmeet/notification/email/repository/EmailSubscriptionRepository.java create mode 100644 src/main/java/com/wellmeet/notification/email/sender/EmailSender.java create mode 100644 src/main/java/com/wellmeet/notification/email/sender/MailTransport.java create mode 100644 src/main/java/com/wellmeet/notification/email/sender/MailViewRenderer.java create mode 100644 src/main/resources/templates/email-notification.html create mode 100644 src/test/java/com/wellmeet/config/EmailTestConfig.java create mode 100644 src/test/java/com/wellmeet/email/EmailSenderTest.java diff --git a/src/main/java/com/wellmeet/config/EmailConfig.java b/src/main/java/com/wellmeet/config/EmailConfig.java new file mode 100644 index 0000000..cc56ec4 --- /dev/null +++ b/src/main/java/com/wellmeet/config/EmailConfig.java @@ -0,0 +1,17 @@ +package com.wellmeet.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; + +@Configuration +@Profile("!test") +public class EmailConfig { + + @Bean + public JavaMailSender javaMailSender() { + return new JavaMailSenderImpl(); + } +} diff --git a/src/main/java/com/wellmeet/exception/ErrorCode.java b/src/main/java/com/wellmeet/exception/ErrorCode.java index 924fdca..05d7adc 100644 --- a/src/main/java/com/wellmeet/exception/ErrorCode.java +++ b/src/main/java/com/wellmeet/exception/ErrorCode.java @@ -6,6 +6,7 @@ @Getter public enum ErrorCode { + EMAIL_NOT_FOUND(HttpStatus.NOT_FOUND, "이메일 정보를 찾을 수 없습니다."), SUBSCRIPTION_NOT_FOUND(HttpStatus.NOT_FOUND, "구독 정보를 찾을 수 없습니다."), FIELD_ERROR(HttpStatus.BAD_REQUEST, "입력이 잘못되었습니다."), @@ -16,6 +17,7 @@ public enum ErrorCode { INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."), CORS_ORIGIN_EMPTY(HttpStatus.INTERNAL_SERVER_ERROR, "CORS Origin 은 적어도 한 개 있어야 합니다"), WEB_PUSH_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "웹 푸시 전송에 실패했습니다."), + EMAIL_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "이메일 전송에 실패했습니다."), SENDER_NOT_FOUND(HttpStatus.BAD_REQUEST, "알림을 발송할 수 없습니다."), TEMPLATE_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR, "알림 템플릿을 찾을 수 없습니다."); diff --git a/src/main/java/com/wellmeet/notification/consumer/dto/NotificationInfo.java b/src/main/java/com/wellmeet/notification/consumer/dto/NotificationInfo.java index e6a226f..8876498 100644 --- a/src/main/java/com/wellmeet/notification/consumer/dto/NotificationInfo.java +++ b/src/main/java/com/wellmeet/notification/consumer/dto/NotificationInfo.java @@ -9,4 +9,9 @@ public class NotificationInfo { private NotificationType type; private String recipient; + + public NotificationInfo(NotificationType type, String recipient) { + this.type = type; + this.recipient = recipient; + } } diff --git a/src/main/java/com/wellmeet/notification/consumer/dto/NotificationMessage.java b/src/main/java/com/wellmeet/notification/consumer/dto/NotificationMessage.java index e8086cc..3ae7607 100644 --- a/src/main/java/com/wellmeet/notification/consumer/dto/NotificationMessage.java +++ b/src/main/java/com/wellmeet/notification/consumer/dto/NotificationMessage.java @@ -12,6 +12,12 @@ public class NotificationMessage { private NotificationInfo notification; private Map payload; + public NotificationMessage(MessageHeader header, NotificationInfo notification, Map payload) { + this.header = header; + this.notification = notification; + this.payload = payload; + } + public String getRecipient() { return notification.getRecipient(); } diff --git a/src/main/java/com/wellmeet/notification/email/domain/EmailSubscription.java b/src/main/java/com/wellmeet/notification/email/domain/EmailSubscription.java new file mode 100644 index 0000000..d207f50 --- /dev/null +++ b/src/main/java/com/wellmeet/notification/email/domain/EmailSubscription.java @@ -0,0 +1,34 @@ +package com.wellmeet.notification.email.domain; + +import com.wellmeet.common.domain.BaseEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class EmailSubscription extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull + private String userId; + + @NotNull + @Email + private String email; + + public EmailSubscription(String userId, String email) { + this.userId = userId; + this.email = email; + } +} diff --git a/src/main/java/com/wellmeet/notification/email/repository/EmailSubscriptionRepository.java b/src/main/java/com/wellmeet/notification/email/repository/EmailSubscriptionRepository.java new file mode 100644 index 0000000..e0700dd --- /dev/null +++ b/src/main/java/com/wellmeet/notification/email/repository/EmailSubscriptionRepository.java @@ -0,0 +1,10 @@ +package com.wellmeet.notification.email.repository; + +import com.wellmeet.notification.email.domain.EmailSubscription; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface EmailSubscriptionRepository extends JpaRepository { + + Optional findByUserId(String userId); +} diff --git a/src/main/java/com/wellmeet/notification/email/sender/EmailSender.java b/src/main/java/com/wellmeet/notification/email/sender/EmailSender.java new file mode 100644 index 0000000..fd326de --- /dev/null +++ b/src/main/java/com/wellmeet/notification/email/sender/EmailSender.java @@ -0,0 +1,58 @@ +package com.wellmeet.notification.email.sender; + +import com.wellmeet.exception.ErrorCode; +import com.wellmeet.exception.WellMeetNotificationException; +import com.wellmeet.notification.Sender; +import com.wellmeet.notification.consumer.dto.NotificationMessage; +import com.wellmeet.notification.domain.NotificationChannel; +import com.wellmeet.notification.email.domain.EmailSubscription; +import com.wellmeet.notification.email.repository.EmailSubscriptionRepository; +import com.wellmeet.notification.template.NotificationTemplateData; +import com.wellmeet.notification.template.NotificationTemplateFactory; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class EmailSender implements Sender { + + private final EmailSubscriptionRepository emailSubscriptionRepository; + private final NotificationTemplateFactory templateFactory; + private final MailViewRenderer mailViewRenderer; + private final MailTransport mailTransport; + + @Override + public boolean isEnabled(NotificationChannel channel) { + return NotificationChannel.EMAIL == channel; + } + + @Override + public void send(NotificationMessage message) { + String userId = message.getNotification().getRecipient(); + EmailSubscription subscription = emailSubscriptionRepository.findByUserId(userId) + .orElseThrow(() -> new WellMeetNotificationException(ErrorCode.EMAIL_NOT_FOUND)); + + NotificationTemplateData templateData = templateFactory.createTemplateData( + message.getNotification().getType(), + message.getPayload() + ); + + String subject = templateData.title(); + String htmlContent = buildHtmlContent(templateData); + sendEmail(subscription.getEmail(), subject, htmlContent); + } + + private String buildHtmlContent(NotificationTemplateData templateData) { + Map variables = Map.of( + "title", templateData.title(), + "body", templateData.body(), + "url", templateData.url() + ); + return mailViewRenderer.render("email-notification.html", variables); + } + + private void sendEmail(String to, String subject, String htmlContent) { + mailTransport.send(to, subject, htmlContent); + } +} diff --git a/src/main/java/com/wellmeet/notification/email/sender/MailTransport.java b/src/main/java/com/wellmeet/notification/email/sender/MailTransport.java new file mode 100644 index 0000000..2e71591 --- /dev/null +++ b/src/main/java/com/wellmeet/notification/email/sender/MailTransport.java @@ -0,0 +1,31 @@ +package com.wellmeet.notification.email.sender; + +import com.wellmeet.exception.ErrorCode; +import com.wellmeet.exception.WellMeetNotificationException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MailTransport { + + private final JavaMailSender javaMailSender; + + public void send(String to, String subject, String htmlContent) { + try { + MimeMessage mimeMessage = javaMailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8"); + + helper.setTo(to); + helper.setSubject(subject); + helper.setText(htmlContent, true); + + javaMailSender.send(mimeMessage); + } catch (Exception e) { + throw new WellMeetNotificationException(ErrorCode.EMAIL_SEND_FAILED); + } + } +} diff --git a/src/main/java/com/wellmeet/notification/email/sender/MailViewRenderer.java b/src/main/java/com/wellmeet/notification/email/sender/MailViewRenderer.java new file mode 100644 index 0000000..0558b8d --- /dev/null +++ b/src/main/java/com/wellmeet/notification/email/sender/MailViewRenderer.java @@ -0,0 +1,38 @@ +package com.wellmeet.notification.email.sender; + +import com.wellmeet.exception.ErrorCode; +import com.wellmeet.exception.WellMeetNotificationException; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; +import org.springframework.util.StreamUtils; + +@Component +public class MailViewRenderer { + + private static final String TEMPLATE_BASE_PATH = "templates/"; + + public String render(String templateName, Map variables) { + String template = loadTemplate(templateName); + return replacePlaceholders(template, variables); + } + + private String loadTemplate(String templateName) { + try { + ClassPathResource resource = new ClassPathResource(TEMPLATE_BASE_PATH + templateName); + return StreamUtils.copyToString(resource.getInputStream(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new WellMeetNotificationException(ErrorCode.EMAIL_SEND_FAILED); + } + } + + private String replacePlaceholders(String template, Map variables) { + String result = template; + for (Map.Entry entry : variables.entrySet()) { + result = result.replace("{" + entry.getKey() + "}", entry.getValue()); + } + return result; + } +} diff --git a/src/main/java/com/wellmeet/notification/webpush/domain/PushSubscription.java b/src/main/java/com/wellmeet/notification/webpush/domain/PushSubscription.java index 9520142..e086565 100644 --- a/src/main/java/com/wellmeet/notification/webpush/domain/PushSubscription.java +++ b/src/main/java/com/wellmeet/notification/webpush/domain/PushSubscription.java @@ -31,14 +31,11 @@ public class PushSubscription extends BaseEntity { @NotNull private String auth; - private boolean active; - public PushSubscription(String userId, String endpoint, String p256dh, String auth) { this.userId = userId; this.endpoint = endpoint; this.p256dh = p256dh; this.auth = auth; - this.active = true; } public void update(PushSubscription updatedSubscription) { diff --git a/src/main/resources/templates/email-notification.html b/src/main/resources/templates/email-notification.html new file mode 100644 index 0000000..4b6f47e --- /dev/null +++ b/src/main/resources/templates/email-notification.html @@ -0,0 +1,77 @@ + + + + + + + +
+
+

{title}

+
+
+

{body}

+
+ + +
+ + diff --git a/src/test/java/com/wellmeet/BaseControllerTest.java b/src/test/java/com/wellmeet/BaseControllerTest.java index 3371b5f..0a47de4 100644 --- a/src/test/java/com/wellmeet/BaseControllerTest.java +++ b/src/test/java/com/wellmeet/BaseControllerTest.java @@ -1,5 +1,6 @@ package com.wellmeet; +import com.wellmeet.config.EmailTestConfig; import com.wellmeet.config.WebPushTestConfig; import com.wellmeet.notification.webpush.repository.PushSubscriptionRepository; import io.restassured.RestAssured; @@ -18,7 +19,7 @@ @ExtendWith(DataBaseCleaner.class) @ActiveProfiles("test") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@Import(WebPushTestConfig.class) +@Import({WebPushTestConfig.class, EmailTestConfig.class}) public abstract class BaseControllerTest { @Autowired diff --git a/src/test/java/com/wellmeet/BaseServiceTest.java b/src/test/java/com/wellmeet/BaseServiceTest.java index 38e8b0a..32e3083 100644 --- a/src/test/java/com/wellmeet/BaseServiceTest.java +++ b/src/test/java/com/wellmeet/BaseServiceTest.java @@ -1,5 +1,6 @@ package com.wellmeet; +import com.wellmeet.config.EmailTestConfig; import com.wellmeet.config.WebPushTestConfig; import com.wellmeet.notification.webpush.repository.PushSubscriptionRepository; import org.junit.jupiter.api.extension.ExtendWith; @@ -11,7 +12,7 @@ @ExtendWith(DataBaseCleaner.class) @ActiveProfiles("test") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) -@Import(WebPushTestConfig.class) +@Import({WebPushTestConfig.class, EmailTestConfig.class}) public abstract class BaseServiceTest { @Autowired diff --git a/src/test/java/com/wellmeet/WellmeetNotificationApplicationTests.java b/src/test/java/com/wellmeet/WellmeetNotificationApplicationTests.java index 40ac6ac..1f43427 100644 --- a/src/test/java/com/wellmeet/WellmeetNotificationApplicationTests.java +++ b/src/test/java/com/wellmeet/WellmeetNotificationApplicationTests.java @@ -1,5 +1,6 @@ package com.wellmeet; +import com.wellmeet.config.EmailTestConfig; import com.wellmeet.config.WebPushTestConfig; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; @@ -8,7 +9,7 @@ @SpringBootTest @ActiveProfiles("test") -@Import(WebPushTestConfig.class) +@Import({WebPushTestConfig.class, EmailTestConfig.class}) class WellmeetNotificationApplicationTests { @Test diff --git a/src/test/java/com/wellmeet/config/EmailTestConfig.java b/src/test/java/com/wellmeet/config/EmailTestConfig.java new file mode 100644 index 0000000..83d8a08 --- /dev/null +++ b/src/test/java/com/wellmeet/config/EmailTestConfig.java @@ -0,0 +1,18 @@ +package com.wellmeet.config; + +import static org.mockito.Mockito.mock; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.mail.javamail.JavaMailSender; + +@TestConfiguration +public class EmailTestConfig { + + @Bean + @Primary + public JavaMailSender javaMailSender() { + return mock(JavaMailSender.class); + } +} diff --git a/src/test/java/com/wellmeet/email/EmailSenderTest.java b/src/test/java/com/wellmeet/email/EmailSenderTest.java new file mode 100644 index 0000000..607edd7 --- /dev/null +++ b/src/test/java/com/wellmeet/email/EmailSenderTest.java @@ -0,0 +1,156 @@ +package com.wellmeet.email; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.wellmeet.config.EmailTestConfig; +import com.wellmeet.exception.ErrorCode; +import com.wellmeet.exception.WellMeetNotificationException; +import com.wellmeet.notification.consumer.dto.NotificationInfo; +import com.wellmeet.notification.consumer.dto.NotificationMessage; +import com.wellmeet.notification.consumer.dto.NotificationType; +import com.wellmeet.notification.domain.NotificationChannel; +import com.wellmeet.notification.email.domain.EmailSubscription; +import com.wellmeet.notification.email.repository.EmailSubscriptionRepository; +import com.wellmeet.notification.email.sender.EmailSender; +import com.wellmeet.notification.email.sender.MailTransport; +import com.wellmeet.notification.email.sender.MailViewRenderer; +import com.wellmeet.notification.template.NotificationTemplateData; +import com.wellmeet.notification.template.NotificationTemplateFactory; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +@ExtendWith(MockitoExtension.class) +@ActiveProfiles("test") +@Import(EmailTestConfig.class) +class EmailSenderTest { + + @Mock + private EmailSubscriptionRepository emailSubscriptionRepository; + + @Mock + private NotificationTemplateFactory templateFactory; + + @Mock + private MailViewRenderer mailViewRenderer; + + @Mock + private MailTransport mailTransport; + + @InjectMocks + private EmailSender emailSender; + + @Nested + class IsEnabled { + + @Test + void EMAIL_채널이면_true를_반환한다() { + assertThatCode(() -> { + boolean enabled = emailSender.isEnabled(NotificationChannel.EMAIL); + assert enabled; + }).doesNotThrowAnyException(); + } + + @Test + void WEB_PUSH_채널이면_false를_반환한다() { + assertThatCode(() -> { + boolean enabled = emailSender.isEnabled(NotificationChannel.WEB_PUSH); + assert !enabled; + }).doesNotThrowAnyException(); + } + } + + @Nested + class Send { + + @Test + void 이메일을_성공적으로_발송한다() { + NotificationMessage notificationMessage = createNotificationMessage(); + NotificationTemplateData templateData = createTemplateData(); + EmailSubscription subscription = new EmailSubscription("user123", "test@example.com"); + + when(emailSubscriptionRepository.findByUserId("user123")).thenReturn(Optional.of(subscription)); + when(templateFactory.createTemplateData( + NotificationType.RESERVATION_CREATED, + notificationMessage.getPayload() + )).thenReturn(templateData); + when(mailViewRenderer.render(any(), any())).thenReturn("test"); + doNothing().when(mailTransport).send(any(), any(), any()); + + assertThatCode(() -> emailSender.send(notificationMessage)) + .doesNotThrowAnyException(); + + verify(mailTransport).send(any(), any(), any()); + } + + @Test + void 이메일_발송_실패시_예외를_던진다() { + NotificationMessage notificationMessage = createNotificationMessage(); + NotificationTemplateData templateData = createTemplateData(); + EmailSubscription subscription = new EmailSubscription("user123", "test@example.com"); + + when(emailSubscriptionRepository.findByUserId("user123")).thenReturn(Optional.of(subscription)); + when(templateFactory.createTemplateData( + NotificationType.RESERVATION_CREATED, + notificationMessage.getPayload() + )).thenReturn(templateData); + when(mailViewRenderer.render(any(), any())).thenReturn("test"); + doThrow(new WellMeetNotificationException(ErrorCode.EMAIL_SEND_FAILED)) + .when(mailTransport).send(any(), any(), any()); + + assertThatThrownBy(() -> emailSender.send(notificationMessage)) + .isInstanceOf(WellMeetNotificationException.class) + .hasMessageContaining(ErrorCode.EMAIL_SEND_FAILED.getMessage()); + } + + @Test + void 이메일_구독_정보가_없으면_예외를_던진다() { + NotificationMessage notificationMessage = createNotificationMessage(); + + when(emailSubscriptionRepository.findByUserId("user123")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> emailSender.send(notificationMessage)) + .isInstanceOf(WellMeetNotificationException.class) + .hasMessageContaining(ErrorCode.SUBSCRIPTION_NOT_FOUND.getMessage()); + } + } + + private NotificationMessage createNotificationMessage() { + NotificationInfo notificationInfo = new NotificationInfo( + NotificationType.RESERVATION_CREATED, + "user123" + ); + + return new NotificationMessage( + null, + notificationInfo, + Map.of( + "restaurantName", "테스트 식당", + "reservationTime", "2025-10-08 18:00", + "reservationId", "123" + ) + ); + } + + private NotificationTemplateData createTemplateData() { + return new NotificationTemplateData( + "새로운 예약이 접수되었습니다", + "테스트 식당에 새로운 예약이 접수되었습니다. 예약 시간: 2025-10-08 18:00", + "/reservations/123", + true + ); + } +} From 2b55937f7ae0e98eb1d522163e10b752540c5ca8 Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Wed, 8 Oct 2025 14:54:29 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/java/com/wellmeet/email/EmailSenderTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/wellmeet/email/EmailSenderTest.java b/src/test/java/com/wellmeet/email/EmailSenderTest.java index 607edd7..e30cb0d 100644 --- a/src/test/java/com/wellmeet/email/EmailSenderTest.java +++ b/src/test/java/com/wellmeet/email/EmailSenderTest.java @@ -124,7 +124,7 @@ class Send { assertThatThrownBy(() -> emailSender.send(notificationMessage)) .isInstanceOf(WellMeetNotificationException.class) - .hasMessageContaining(ErrorCode.SUBSCRIPTION_NOT_FOUND.getMessage()); + .hasMessageContaining(ErrorCode.EMAIL_NOT_FOUND.getMessage()); } } From c44fe14697e615682a89c803271e516f01e8e0fe Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Wed, 8 Oct 2025 14:57:15 +0900 Subject: [PATCH 3/4] =?UTF-8?q?refactor:=20=EB=A1=9C=EA=B9=85=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/wellmeet/notification/email/sender/MailTransport.java | 3 +++ .../wellmeet/notification/email/sender/MailViewRenderer.java | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/main/java/com/wellmeet/notification/email/sender/MailTransport.java b/src/main/java/com/wellmeet/notification/email/sender/MailTransport.java index 2e71591..3a832e4 100644 --- a/src/main/java/com/wellmeet/notification/email/sender/MailTransport.java +++ b/src/main/java/com/wellmeet/notification/email/sender/MailTransport.java @@ -4,10 +4,12 @@ import com.wellmeet.exception.WellMeetNotificationException; import jakarta.mail.internet.MimeMessage; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.stereotype.Component; +@Slf4j @Component @RequiredArgsConstructor public class MailTransport { @@ -25,6 +27,7 @@ public void send(String to, String subject, String htmlContent) { javaMailSender.send(mimeMessage); } catch (Exception e) { + log.error("이메일 전송에 실패했습니다. to: {}, subject: {}", to, subject, e); throw new WellMeetNotificationException(ErrorCode.EMAIL_SEND_FAILED); } } diff --git a/src/main/java/com/wellmeet/notification/email/sender/MailViewRenderer.java b/src/main/java/com/wellmeet/notification/email/sender/MailViewRenderer.java index 0558b8d..39664d7 100644 --- a/src/main/java/com/wellmeet/notification/email/sender/MailViewRenderer.java +++ b/src/main/java/com/wellmeet/notification/email/sender/MailViewRenderer.java @@ -5,10 +5,12 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Map; +import lombok.extern.slf4j.Slf4j; import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Component; import org.springframework.util.StreamUtils; +@Slf4j @Component public class MailViewRenderer { @@ -24,6 +26,7 @@ private String loadTemplate(String templateName) { ClassPathResource resource = new ClassPathResource(TEMPLATE_BASE_PATH + templateName); return StreamUtils.copyToString(resource.getInputStream(), StandardCharsets.UTF_8); } catch (IOException e) { + log.error("이메일 템플릿 로딩에 실패했습니다. templateName: {}", templateName, e); throw new WellMeetNotificationException(ErrorCode.EMAIL_SEND_FAILED); } } From 0e98361b92040c15faef34a070a3ce6e6eca8ec2 Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Wed, 8 Oct 2025 15:17:37 +0900 Subject: [PATCH 4/4] =?UTF-8?q?refactor:=20=EB=A7=A4=EC=B2=98=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../email/sender/MailViewRenderer.java | 24 +++-- .../com/wellmeet/email/EmailSenderTest.java | 16 +-- .../wellmeet/email/MailViewRendererTest.java | 99 +++++++++++++++++++ .../nested-placeholder-template.html | 6 ++ .../templates/special-chars-template.html | 6 ++ .../resources/templates/test-template.html | 12 +++ 6 files changed, 148 insertions(+), 15 deletions(-) create mode 100644 src/test/java/com/wellmeet/email/MailViewRendererTest.java create mode 100644 src/test/resources/templates/nested-placeholder-template.html create mode 100644 src/test/resources/templates/special-chars-template.html create mode 100644 src/test/resources/templates/test-template.html diff --git a/src/main/java/com/wellmeet/notification/email/sender/MailViewRenderer.java b/src/main/java/com/wellmeet/notification/email/sender/MailViewRenderer.java index 39664d7..60589a8 100644 --- a/src/main/java/com/wellmeet/notification/email/sender/MailViewRenderer.java +++ b/src/main/java/com/wellmeet/notification/email/sender/MailViewRenderer.java @@ -3,8 +3,11 @@ import com.wellmeet.exception.ErrorCode; import com.wellmeet.exception.WellMeetNotificationException; import java.io.IOException; +import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import lombok.extern.slf4j.Slf4j; import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Component; @@ -15,6 +18,7 @@ public class MailViewRenderer { private static final String TEMPLATE_BASE_PATH = "templates/"; + private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{([^}]+)\\}"); public String render(String templateName, Map variables) { String template = loadTemplate(templateName); @@ -22,9 +26,9 @@ public String render(String templateName, Map variables) { } private String loadTemplate(String templateName) { - try { - ClassPathResource resource = new ClassPathResource(TEMPLATE_BASE_PATH + templateName); - return StreamUtils.copyToString(resource.getInputStream(), StandardCharsets.UTF_8); + ClassPathResource resource = new ClassPathResource(TEMPLATE_BASE_PATH + templateName); + try (InputStream inputStream = resource.getInputStream()) { + return StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8); } catch (IOException e) { log.error("이메일 템플릿 로딩에 실패했습니다. templateName: {}", templateName, e); throw new WellMeetNotificationException(ErrorCode.EMAIL_SEND_FAILED); @@ -32,10 +36,16 @@ private String loadTemplate(String templateName) { } private String replacePlaceholders(String template, Map variables) { - String result = template; - for (Map.Entry entry : variables.entrySet()) { - result = result.replace("{" + entry.getKey() + "}", entry.getValue()); + Matcher matcher = PLACEHOLDER_PATTERN.matcher(template); + StringBuilder result = new StringBuilder(); + + while (matcher.find()) { + String key = matcher.group(1); + String value = variables.getOrDefault(key, matcher.group(0)); + matcher.appendReplacement(result, Matcher.quoteReplacement(value)); } - return result; + matcher.appendTail(result); + + return result.toString(); } } diff --git a/src/test/java/com/wellmeet/email/EmailSenderTest.java b/src/test/java/com/wellmeet/email/EmailSenderTest.java index e30cb0d..1b62646 100644 --- a/src/test/java/com/wellmeet/email/EmailSenderTest.java +++ b/src/test/java/com/wellmeet/email/EmailSenderTest.java @@ -1,5 +1,6 @@ package com.wellmeet.email; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; @@ -58,18 +59,17 @@ class IsEnabled { @Test void EMAIL_채널이면_true를_반환한다() { - assertThatCode(() -> { - boolean enabled = emailSender.isEnabled(NotificationChannel.EMAIL); - assert enabled; - }).doesNotThrowAnyException(); + boolean enabled = emailSender.isEnabled(NotificationChannel.EMAIL); + + assertThat(enabled).isTrue(); + } @Test void WEB_PUSH_채널이면_false를_반환한다() { - assertThatCode(() -> { - boolean enabled = emailSender.isEnabled(NotificationChannel.WEB_PUSH); - assert !enabled; - }).doesNotThrowAnyException(); + boolean enabled = emailSender.isEnabled(NotificationChannel.WEB_PUSH); + + assertThat(enabled).isFalse(); } } diff --git a/src/test/java/com/wellmeet/email/MailViewRendererTest.java b/src/test/java/com/wellmeet/email/MailViewRendererTest.java new file mode 100644 index 0000000..101fdc2 --- /dev/null +++ b/src/test/java/com/wellmeet/email/MailViewRendererTest.java @@ -0,0 +1,99 @@ +package com.wellmeet.email; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.wellmeet.exception.WellMeetNotificationException; +import com.wellmeet.notification.email.sender.MailViewRenderer; +import java.util.Map; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class MailViewRendererTest { + + @InjectMocks + private MailViewRenderer mailViewRenderer; + + @Nested + class Render { + + @Test + void 플레이스홀더를_정상적으로_치환한다() { + Map variables = Map.of( + "title", "테스트 제목", + "body", "테스트 내용" + ); + + String result = mailViewRenderer.render("test-template.html", variables); + + assertThat(result).contains("테스트 제목") + .contains("테스트 내용") + .doesNotContain("{title}") + .doesNotContain("{body}"); + } + + @Test + void 존재하지_않는_키는_원본_플레이스홀더를_유지한다() { + Map variables = Map.of( + "title", "테스트 제목", + "body", "테스트 내용" + ); + + String result = mailViewRenderer.render("test-template.html", variables); + + assertThat(result).contains("{unknown}"); + } + + @Test + void 값에_특수문자가_포함되어도_안전하게_치환한다() { + Map variables = Map.of( + "content", "$100 & " + ); + + String result = mailViewRenderer.render("special-chars-template.html", variables); + + assertThat(result).contains("$100") + .contains("&") + .contains("