From 43271d8c6aab7470b374dcf8a87189245d98622f Mon Sep 17 00:00:00 2001 From: podlLev Date: Mon, 27 Jul 2026 09:36:46 +0300 Subject: [PATCH 1/7] feat(mail): add VerificationEmailRequestedEvent and PasswordResetEmailRequestedEvent --- .../event/PasswordResetEmailRequestedEvent.java | 12 ++++++++++++ .../event/VerificationEmailRequestedEvent.java | 12 ++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 src/main/java/com/weatherviewer/event/PasswordResetEmailRequestedEvent.java create mode 100644 src/main/java/com/weatherviewer/event/VerificationEmailRequestedEvent.java diff --git a/src/main/java/com/weatherviewer/event/PasswordResetEmailRequestedEvent.java b/src/main/java/com/weatherviewer/event/PasswordResetEmailRequestedEvent.java new file mode 100644 index 0000000..1f07d48 --- /dev/null +++ b/src/main/java/com/weatherviewer/event/PasswordResetEmailRequestedEvent.java @@ -0,0 +1,12 @@ +package com.weatherviewer.event; + +/** + * Published by {@link com.weatherviewer.service.impl.VerificationServiceImpl} + * once a password-reset token has been persisted. Consumed by + * {@link com.weatherviewer.service.impl.MailEventListener}, which sends the + * actual email asynchronously and only after the surrounding transaction + * commits — so a rolled-back reset request never results in an email + * pointing at a token that was never saved. + */ +public record PasswordResetEmailRequestedEvent(String email, String firstName, String resetLink) { +} diff --git a/src/main/java/com/weatherviewer/event/VerificationEmailRequestedEvent.java b/src/main/java/com/weatherviewer/event/VerificationEmailRequestedEvent.java new file mode 100644 index 0000000..d72edf3 --- /dev/null +++ b/src/main/java/com/weatherviewer/event/VerificationEmailRequestedEvent.java @@ -0,0 +1,12 @@ +package com.weatherviewer.event; + +/** + * Published by {@link com.weatherviewer.service.impl.VerificationServiceImpl} + * once a verification token has been persisted. Consumed by + * {@link com.weatherviewer.service.impl.MailEventListener}, which sends the + * actual email asynchronously and only after the surrounding transaction + * commits — so a rolled-back sign-up never results in an email pointing at + * a token that was never saved. + */ +public record VerificationEmailRequestedEvent(String email, String firstName, String verificationLink) { +} From 8eec66224e6c9290d9b1841df85e648ef5011ba1 Mon Sep 17 00:00:00 2001 From: podlLev Date: Mon, 27 Jul 2026 09:37:25 +0300 Subject: [PATCH 2/7] feat(mail): dispatch mail via events instead of calling MailService directly --- .../service/impl/VerificationServiceImpl.java | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/weatherviewer/service/impl/VerificationServiceImpl.java b/src/main/java/com/weatherviewer/service/impl/VerificationServiceImpl.java index fc60ac5..cff158b 100644 --- a/src/main/java/com/weatherviewer/service/impl/VerificationServiceImpl.java +++ b/src/main/java/com/weatherviewer/service/impl/VerificationServiceImpl.java @@ -1,5 +1,7 @@ package com.weatherviewer.service.impl; +import com.weatherviewer.event.PasswordResetEmailRequestedEvent; +import com.weatherviewer.event.VerificationEmailRequestedEvent; import com.weatherviewer.exception.InvalidTokenException; import com.weatherviewer.model.User; import com.weatherviewer.model.VerificationToken; @@ -7,10 +9,10 @@ import com.weatherviewer.model.enums.UserStatus; import com.weatherviewer.repository.UserRepository; import com.weatherviewer.repository.VerificationTokenRepository; -import com.weatherviewer.service.MailService; import com.weatherviewer.service.VerificationService; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -28,6 +30,13 @@ * emailed link. Issuing a new token for a given user/purpose first * invalidates any earlier unused ones of that same type, so only the most * recently emailed link ever works. + *

+ * The actual email is not sent from here. Instead, an + * {@code *EmailRequestedEvent} is published, which + * {@link MailEventListener} picks up asynchronously and only after this + * method's transaction has committed — so a rollback (or a slow/unreachable + * mail server) can never desync the token that was persisted from the link + * that was emailed. */ @Service @Slf4j @@ -37,7 +46,7 @@ public class VerificationServiceImpl implements VerificationService { private final UserRepository userRepository; private final VerificationTokenRepository tokenRepository; - private final MailService mailService; + private final ApplicationEventPublisher eventPublisher; private final PasswordEncoder passwordEncoder; private final String baseUrl; private final long verificationTtlHours; @@ -45,14 +54,14 @@ public class VerificationServiceImpl implements VerificationService { public VerificationServiceImpl(UserRepository userRepository, VerificationTokenRepository tokenRepository, - MailService mailService, + ApplicationEventPublisher eventPublisher, PasswordEncoder passwordEncoder, @Value("${app.base-url:http://localhost:8080}") String baseUrl, @Value("${app.verification.token-ttl-hours:24}") long verificationTtlHours, @Value("${app.password-reset.token-ttl-hours:1}") long passwordResetTtlHours) { this.userRepository = userRepository; this.tokenRepository = tokenRepository; - this.mailService = mailService; + this.eventPublisher = eventPublisher; this.passwordEncoder = passwordEncoder; this.baseUrl = baseUrl; this.verificationTtlHours = verificationTtlHours; @@ -71,7 +80,7 @@ public void sendVerificationEmail(User user) { .queryParam("token", rawToken) .toUriString(); - mailService.sendVerificationEmail(user.getEmail(), user.getFirstName(), link); + eventPublisher.publishEvent(new VerificationEmailRequestedEvent(user.getEmail(), user.getFirstName(), link)); } @Override @@ -98,7 +107,7 @@ public void requestPasswordReset(String email) { .queryParam("token", rawToken) .toUriString(); - mailService.sendPasswordResetEmail(user.getEmail(), user.getFirstName(), link); + eventPublisher.publishEvent(new PasswordResetEmailRequestedEvent(user.getEmail(), user.getFirstName(), link)); }, () -> log.info("Password reset requested for unregistered email={}", email)); } From 191cc60d05ff4e7a8b2907021262874669db9fb5 Mon Sep 17 00:00:00 2001 From: podlLev Date: Mon, 27 Jul 2026 09:38:29 +0300 Subject: [PATCH 3/7] feat(mail): add MailEventListener to send mail async after transaction commit --- .../service/impl/MailEventListener.java | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 src/main/java/com/weatherviewer/service/impl/MailEventListener.java diff --git a/src/main/java/com/weatherviewer/service/impl/MailEventListener.java b/src/main/java/com/weatherviewer/service/impl/MailEventListener.java new file mode 100644 index 0000000..ee8403b --- /dev/null +++ b/src/main/java/com/weatherviewer/service/impl/MailEventListener.java @@ -0,0 +1,57 @@ +package com.weatherviewer.service.impl; + +import com.weatherviewer.event.PasswordResetEmailRequestedEvent; +import com.weatherviewer.event.VerificationEmailRequestedEvent; +import com.weatherviewer.service.MailService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * Bridges {@code *EmailRequestedEvent}s raised by + * {@link VerificationServiceImpl} to actual sends via {@link MailService}. + *

+ * Two things happen here that matter for reliability: + *

+ * {@link MailService} itself still retries transient SMTP failures and + * never throws, so a failure here is logged by it directly and does not + * propagate any further. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class MailEventListener { + + private final MailService mailService; + + @Async("mailTaskExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onVerificationEmailRequested(VerificationEmailRequestedEvent event) { + log.debug("Dispatching verification email to {}", event.email()); + mailService.sendVerificationEmail(event.email(), event.firstName(), event.verificationLink()); + } + + @Async("mailTaskExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onPasswordResetEmailRequested(PasswordResetEmailRequestedEvent event) { + log.debug("Dispatching password reset email to {}", event.email()); + mailService.sendPasswordResetEmail(event.email(), event.firstName(), event.resetLink()); + } + +} From a2868365d142fed3d16f697a9064020d843be652 Mon Sep 17 00:00:00 2001 From: podlLev Date: Mon, 27 Jul 2026 09:38:49 +0300 Subject: [PATCH 4/7] config: add mailTaskExecutor bean and enable @Async support --- .../WeatherViewerApplication.java | 7 ++++++- .../com/weatherviewer/config/AppConfig.java | 18 ++++++++++++++++++ .../com/weatherviewer/mapper/UserMapper.java | 2 ++ src/main/resources/application.properties | 8 ++++++++ 4 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/weatherviewer/WeatherViewerApplication.java b/src/main/java/com/weatherviewer/WeatherViewerApplication.java index aff32cc..79f5158 100644 --- a/src/main/java/com/weatherviewer/WeatherViewerApplication.java +++ b/src/main/java/com/weatherviewer/WeatherViewerApplication.java @@ -3,6 +3,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cache.annotation.EnableCaching; +import org.springframework.scheduling.annotation.EnableAsync; import java.util.TimeZone; @@ -12,10 +13,14 @@ * Bootstraps the application context and enables Spring's caching * abstraction ({@link EnableCaching}), which backs the weather/forecast/ * geocoding response caching in - * {@link com.weatherviewer.service.integration.WeatherApiCache}. + * {@link com.weatherviewer.service.integration.WeatherApiCache}, and + * Spring's {@code @Async} support ({@link EnableAsync}), which backs + * asynchronous mail dispatch in + * {@link com.weatherviewer.service.impl.MailEventListener}. */ @SpringBootApplication @EnableCaching +@EnableAsync public class WeatherViewerApplication { /** diff --git a/src/main/java/com/weatherviewer/config/AppConfig.java b/src/main/java/com/weatherviewer/config/AppConfig.java index 33df369..1cf5621 100644 --- a/src/main/java/com/weatherviewer/config/AppConfig.java +++ b/src/main/java/com/weatherviewer/config/AppConfig.java @@ -72,4 +72,22 @@ public ExecutorService weatherFetchExecutor( return Executors.newFixedThreadPool(poolSize, new CustomizableThreadFactory("weather-fetch-")); } + /** + * Dedicated, bounded thread pool used by + * {@link com.weatherviewer.service.impl.MailEventListener} to send + * verification/password-reset emails off the request thread. + *

+ * Kept separate from {@link #weatherFetchExecutor} for the same reason + * that one is kept separate from the JVM-wide common pool: a burst of + * sign-ups or reset requests (each possibly retried a few times against + * a slow SMTP server) shouldn't be able to starve weather-dashboard + * fetches, or vice versa. Sized via {@code mail.async.pool-size} + * (default 5) and shut down automatically on context close. + */ + @Bean(name = "mailTaskExecutor", destroyMethod = "shutdown") + public ExecutorService mailTaskExecutor( + @Value("${mail.async.pool-size:5}") int poolSize) { + return Executors.newFixedThreadPool(poolSize, new CustomizableThreadFactory("mail-send-")); + } + } diff --git a/src/main/java/com/weatherviewer/mapper/UserMapper.java b/src/main/java/com/weatherviewer/mapper/UserMapper.java index 31bb030..4fa4781 100644 --- a/src/main/java/com/weatherviewer/mapper/UserMapper.java +++ b/src/main/java/com/weatherviewer/mapper/UserMapper.java @@ -28,6 +28,8 @@ public interface UserMapper { @Mapping(target = "createdAt", ignore = true) @Mapping(target = "units", ignore = true) @Mapping(target = "locations", ignore = true) + @Mapping(target = "lockedUntil", ignore = true) + @Mapping(target = "failedLoginAttempts", ignore = true) User fromRecord(CreateUserDto createUserDto); /** diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 4cc8610..6d8610d 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -82,6 +82,12 @@ resilience4j.circuitbreaker.instances.weatherApi.wait-duration-in-open-state=30s resilience4j.circuitbreaker.instances.weatherApi.permitted-number-of-calls-in-half-open-state=5 resilience4j.circuitbreaker.instances.weatherApi.automatic-transition-from-open-to-half-open-enabled=true +# --- Resilience (retry around SMTP sends; see MailServiceImpl) --- +resilience4j.retry.instances.mail.max-attempts=3 +resilience4j.retry.instances.mail.wait-duration=500ms +resilience4j.retry.instances.mail.enable-exponential-backoff=true +resilience4j.retry.instances.mail.exponential-backoff-multiplier=2 + # --- Redis / caching --- spring.data.redis.host=${SPRING_DATA_REDIS_HOST:localhost} spring.data.redis.port=${SPRING_DATA_REDIS_PORT:6379} @@ -100,6 +106,8 @@ spring.mail.properties.mail.smtp.timeout=5000 spring.mail.properties.mail.smtp.writetimeout=5000 app.mail.from=${MAIL_FROM:no-reply@weatherviewer.local} +mail.async.pool-size=5 + # --- Links embedded in emailed verification / reset tokens --- app.base-url=${APP_BASE_URL:http://localhost:8080} app.verification.token-ttl-hours=24 From c3f0f8608fdaaaf10e118e94224743d50177fdfc Mon Sep 17 00:00:00 2001 From: podlLev Date: Mon, 27 Jul 2026 09:39:08 +0300 Subject: [PATCH 5/7] feat(mail): retry transient SMTP failures via resilience4j --- .../weatherviewer/service/MailService.java | 12 ++++- .../service/impl/MailServiceImpl.java | 46 ++++++++++++++----- 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/weatherviewer/service/MailService.java b/src/main/java/com/weatherviewer/service/MailService.java index 280e0cb..f168916 100644 --- a/src/main/java/com/weatherviewer/service/MailService.java +++ b/src/main/java/com/weatherviewer/service/MailService.java @@ -2,8 +2,16 @@ /** * Outbound transactional email. Implementations must never let a mail - * provider outage break the calling request (sign-up, password reset) — - * failures are logged and swallowed rather than propagated. + * provider outage break the calling request — failures are retried a few + * times, then logged and swallowed rather than propagated. + *

+ * In practice these methods are only ever invoked by + * {@link com.weatherviewer.service.impl.MailEventListener}, asynchronously + * and after the transaction that created the underlying token has + * committed (see {@link com.weatherviewer.event.VerificationEmailRequestedEvent} + * / {@link com.weatherviewer.event.PasswordResetEmailRequestedEvent}), so a + * slow or unreachable mail server never adds latency to — or breaks — the + * sign-up, verification, or password-reset request itself. */ public interface MailService { diff --git a/src/main/java/com/weatherviewer/service/impl/MailServiceImpl.java b/src/main/java/com/weatherviewer/service/impl/MailServiceImpl.java index be96b35..47bc009 100644 --- a/src/main/java/com/weatherviewer/service/impl/MailServiceImpl.java +++ b/src/main/java/com/weatherviewer/service/impl/MailServiceImpl.java @@ -1,6 +1,8 @@ package com.weatherviewer.service.impl; import com.weatherviewer.service.MailService; +import io.github.resilience4j.retry.Retry; +import io.github.resilience4j.retry.RetryRegistry; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.mail.MailException; @@ -12,21 +14,34 @@ * {@link MailService} implementation backed by {@link JavaMailSender}. *

* Emails are plain text on purpose (no external images/styles to fetch, - * nothing for a mail client to block). A misconfigured or unreachable SMTP - * server never bubbles up as a 500 to the user — sign-up and password - * reset both still succeed, they just log a warning that no email went - * out, so the account/token itself is never lost because of a mail outage. + * nothing for a mail client to block). Transient SMTP failures are retried + * via the {@code mail} Resilience4j retry instance (same pattern as the + * {@code weatherApi} retry around the OpenWeatherMap client) before giving + * up; a misconfigured or unreachable SMTP server never bubbles up past this + * class. Retry is applied programmatically here, rather than via + * {@code @Retry}, because the send happens on a private helper called from + * within this same bean — an annotation-based retry would be silently + * skipped by Spring AOP's proxy on that kind of self-invocation. + *

+ * Sending itself is now invoked off the request thread: {@link MailService} + * is only ever called by {@link MailEventListener}, asynchronously, after + * the transaction that created the underlying token has committed. That + * keeps a mail outage from adding latency to (or breaking) sign-up, + * verification, or password-reset requests. */ @Service @Slf4j public class MailServiceImpl implements MailService { private final JavaMailSender mailSender; + private final Retry retry; private final String fromAddress; public MailServiceImpl(JavaMailSender mailSender, + RetryRegistry retryRegistry, @Value("${app.mail.from:no-reply@weatherviewer.local}") String fromAddress) { this.mailSender = mailSender; + this.retry = retryRegistry.retry("mail"); this.fromAddress = fromAddress; } @@ -55,17 +70,26 @@ public void sendPasswordResetEmail(String to, String firstName, String resetLink send(to, subject, body); } + /** + * Sends the message, retrying transient SMTP failures via the {@code mail} + * Resilience4j retry instance. If every attempt fails, the failure is + * logged and swallowed here rather than propagated — callers (in + * practice, {@link MailEventListener} running on its own thread) never + * need to handle a mail-specific exception. + */ private void send(String to, String subject, String body) { try { - SimpleMailMessage message = new SimpleMailMessage(); - message.setFrom(fromAddress); - message.setTo(to); - message.setSubject(subject); - message.setText(body); - mailSender.send(message); + retry.executeRunnable(() -> { + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(fromAddress); + message.setTo(to); + message.setSubject(subject); + message.setText(body); + mailSender.send(message); + }); log.info("Sent email '{}' to {}", subject, to); } catch (MailException e) { - log.warn("Failed to send email '{}' to {}: {}", subject, to, e.getMessage()); + log.warn("Failed to send email '{}' to {} after retries: {}", subject, to, e.getMessage()); } } From a41573706e871e6f633401b5a2a2e26c470370d5 Mon Sep 17 00:00:00 2001 From: podlLev Date: Mon, 27 Jul 2026 09:39:30 +0300 Subject: [PATCH 6/7] test(mail): update MailServiceImplTest and VerificationServiceImplTest for new constructors --- .../service/impl/MailServiceImplTest.java | 11 +++++- .../impl/VerificationServiceImplTest.java | 35 ++++++++++++------- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/src/test/java/com/weatherviewer/service/impl/MailServiceImplTest.java b/src/test/java/com/weatherviewer/service/impl/MailServiceImplTest.java index a7ed306..56bd0dd 100644 --- a/src/test/java/com/weatherviewer/service/impl/MailServiceImplTest.java +++ b/src/test/java/com/weatherviewer/service/impl/MailServiceImplTest.java @@ -1,5 +1,7 @@ package com.weatherviewer.service.impl; +import io.github.resilience4j.retry.RetryConfig; +import io.github.resilience4j.retry.RetryRegistry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -11,6 +13,8 @@ import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSender; +import java.time.Duration; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.mockito.ArgumentMatchers.any; @@ -29,7 +33,12 @@ class MailServiceImplTest { @BeforeEach void setUp() { - service = new MailServiceImpl(mailSender, FROM_ADDRESS); + RetryConfig fastRetryConfig = RetryConfig.custom() + .maxAttempts(2) + .waitDuration(Duration.ZERO) + .build(); + RetryRegistry retryRegistry = RetryRegistry.of(fastRetryConfig); + service = new MailServiceImpl(mailSender, retryRegistry, FROM_ADDRESS); } @Test diff --git a/src/test/java/com/weatherviewer/service/impl/VerificationServiceImplTest.java b/src/test/java/com/weatherviewer/service/impl/VerificationServiceImplTest.java index 147a062..171c40c 100644 --- a/src/test/java/com/weatherviewer/service/impl/VerificationServiceImplTest.java +++ b/src/test/java/com/weatherviewer/service/impl/VerificationServiceImplTest.java @@ -1,5 +1,7 @@ package com.weatherviewer.service.impl; +import com.weatherviewer.event.PasswordResetEmailRequestedEvent; +import com.weatherviewer.event.VerificationEmailRequestedEvent; import com.weatherviewer.exception.InvalidTokenException; import com.weatherviewer.model.User; import com.weatherviewer.model.VerificationToken; @@ -8,13 +10,13 @@ import com.weatherviewer.model.enums.UserStatus; import com.weatherviewer.repository.UserRepository; import com.weatherviewer.repository.VerificationTokenRepository; -import com.weatherviewer.service.MailService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.crypto.password.PasswordEncoder; import java.time.LocalDateTime; @@ -23,7 +25,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -40,7 +41,7 @@ class VerificationServiceImplTest { private VerificationTokenRepository tokenRepository; @Mock - private MailService mailService; + private ApplicationEventPublisher eventPublisher; @Mock private PasswordEncoder passwordEncoder; @@ -49,7 +50,7 @@ class VerificationServiceImplTest { @BeforeEach void setUp() { - service = new VerificationServiceImpl(userRepository, tokenRepository, mailService, passwordEncoder, + service = new VerificationServiceImpl(userRepository, tokenRepository, eventPublisher, passwordEncoder, BASE_URL, VERIFICATION_TTL_HOURS, PASSWORD_RESET_TTL_HOURS); } @@ -103,12 +104,15 @@ void sendVerificationEmail_sendsMailWithLinkContainingBaseUrlAndToken() { verify(tokenRepository).save(tokenCaptor.capture()); String rawToken = tokenCaptor.getValue().getToken(); - ArgumentCaptor linkCaptor = ArgumentCaptor.forClass(String.class); - verify(mailService).sendVerificationEmail(eq("john@example.com"), eq("John"), linkCaptor.capture()); + ArgumentCaptor eventCaptor = + ArgumentCaptor.forClass(VerificationEmailRequestedEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); - String link = linkCaptor.getValue(); - assertThat(link).startsWith(BASE_URL + "/verify-email?token="); - assertThat(link).contains(rawToken); + VerificationEmailRequestedEvent event = eventCaptor.getValue(); + assertThat(event.email()).isEqualTo("john@example.com"); + assertThat(event.firstName()).isEqualTo("John"); + assertThat(event.verificationLink()).startsWith(BASE_URL + "/verify-email?token="); + assertThat(event.verificationLink()).contains(rawToken); } @Test @@ -220,9 +224,14 @@ void requestPasswordReset_existingEmail_invalidatesPriorTokensAndSendsMail() { assertThat(tokenCaptor.getValue().getExpiresAt()) .isBefore(LocalDateTime.now().plusHours(PASSWORD_RESET_TTL_HOURS + 1)); - ArgumentCaptor linkCaptor = ArgumentCaptor.forClass(String.class); - verify(mailService).sendPasswordResetEmail(eq("john@example.com"), eq("John"), linkCaptor.capture()); - assertThat(linkCaptor.getValue()).startsWith(BASE_URL + "/reset-password?token="); + ArgumentCaptor eventCaptor = + ArgumentCaptor.forClass(PasswordResetEmailRequestedEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + + PasswordResetEmailRequestedEvent event = eventCaptor.getValue(); + assertThat(event.email()).isEqualTo("john@example.com"); + assertThat(event.firstName()).isEqualTo("John"); + assertThat(event.resetLink()).startsWith(BASE_URL + "/reset-password?token="); } @Test @@ -233,7 +242,7 @@ void requestPasswordReset_unregisteredEmail_doesNothingSilently() { verify(tokenRepository, never()).invalidateActiveTokens(any(UUID.class), any(TokenType.class)); verify(tokenRepository, never()).save(any(VerificationToken.class)); - verify(mailService, never()).sendPasswordResetEmail(anyString(), anyString(), anyString()); + verify(eventPublisher, never()).publishEvent(any()); } @Test From d5ff373a922971bfd51c9f3b25ba330659dfb44e Mon Sep 17 00:00:00 2001 From: podlLev Date: Mon, 27 Jul 2026 09:39:41 +0300 Subject: [PATCH 7/7] test(mail): add MailEventListenerTest --- .../service/impl/MailEventListenerTest.java | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 src/test/java/com/weatherviewer/service/impl/MailEventListenerTest.java diff --git a/src/test/java/com/weatherviewer/service/impl/MailEventListenerTest.java b/src/test/java/com/weatherviewer/service/impl/MailEventListenerTest.java new file mode 100644 index 0000000..d6fc3cf --- /dev/null +++ b/src/test/java/com/weatherviewer/service/impl/MailEventListenerTest.java @@ -0,0 +1,54 @@ +package com.weatherviewer.service.impl; + +import com.weatherviewer.event.PasswordResetEmailRequestedEvent; +import com.weatherviewer.event.VerificationEmailRequestedEvent; +import com.weatherviewer.service.MailService; +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 static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +/** + * These tests call the listener methods directly (as the {@code @Async} / + * {@code @TransactionalEventListener} machinery only applies through a live + * Spring context) — the behavior worth unit-testing here is simply "each + * event is unpacked into the right {@link MailService} call". + */ +@ExtendWith(MockitoExtension.class) +class MailEventListenerTest { + + @Mock + private MailService mailService; + + @InjectMocks + private MailEventListener listener; + + @Test + void onVerificationEmailRequested_delegatesToMailService() { + VerificationEmailRequestedEvent event = new VerificationEmailRequestedEvent( + "john@example.com", "John", "https://weatherviewer.local/verify-email?token=abc"); + + listener.onVerificationEmailRequested(event); + + verify(mailService).sendVerificationEmail( + "john@example.com", "John", "https://weatherviewer.local/verify-email?token=abc"); + verifyNoMoreInteractions(mailService); + } + + @Test + void onPasswordResetEmailRequested_delegatesToMailService() { + PasswordResetEmailRequestedEvent event = new PasswordResetEmailRequestedEvent( + "jane@example.com", "Jane", "https://weatherviewer.local/reset-password?token=xyz"); + + listener.onPasswordResetEmailRequested(event); + + verify(mailService).sendPasswordResetEmail( + "jane@example.com", "Jane", "https://weatherviewer.local/reset-password?token=xyz"); + verifyNoMoreInteractions(mailService); + } + +}