From cce6dc59862e4437ddacce298c435948a60ec0ed Mon Sep 17 00:00:00 2001 From: tkahng Date: Fri, 29 May 2026 21:45:39 -0700 Subject: [PATCH 1/3] feat: decouple fulfillment email side-effects via domain events FulfillmentShippedEvent and FulfillmentDeliveredEvent records replace direct EmailService calls in FulfillmentService. FulfillmentEmailEventListener handles delivery after transaction commit (@TransactionalEventListener + @Async), matching the existing OrderEmailEventListener pattern. --- .../event/FulfillmentDeliveredEvent.java | 7 ++ .../event/FulfillmentEmailEventListener.java | 41 +++++++++++ .../event/FulfillmentShippedEvent.java | 10 +++ .../service/FulfillmentService.java | 49 +++++++------ .../FulfillmentEmailEventListenerTest.java | 73 +++++++++++++++++++ 5 files changed, 157 insertions(+), 23 deletions(-) create mode 100644 src/main/java/io/k2dv/garden/fulfillment/event/FulfillmentDeliveredEvent.java create mode 100644 src/main/java/io/k2dv/garden/fulfillment/event/FulfillmentEmailEventListener.java create mode 100644 src/main/java/io/k2dv/garden/fulfillment/event/FulfillmentShippedEvent.java create mode 100644 src/test/java/io/k2dv/garden/fulfillment/event/FulfillmentEmailEventListenerTest.java diff --git a/src/main/java/io/k2dv/garden/fulfillment/event/FulfillmentDeliveredEvent.java b/src/main/java/io/k2dv/garden/fulfillment/event/FulfillmentDeliveredEvent.java new file mode 100644 index 0000000..2a3bde0 --- /dev/null +++ b/src/main/java/io/k2dv/garden/fulfillment/event/FulfillmentDeliveredEvent.java @@ -0,0 +1,7 @@ +package io.k2dv.garden.fulfillment.event; + +public record FulfillmentDeliveredEvent( + String to, + String orderRef, + String frontendUrl +) {} diff --git a/src/main/java/io/k2dv/garden/fulfillment/event/FulfillmentEmailEventListener.java b/src/main/java/io/k2dv/garden/fulfillment/event/FulfillmentEmailEventListener.java new file mode 100644 index 0000000..482a3ab --- /dev/null +++ b/src/main/java/io/k2dv/garden/fulfillment/event/FulfillmentEmailEventListener.java @@ -0,0 +1,41 @@ +package io.k2dv.garden.fulfillment.event; + +import io.k2dv.garden.auth.service.EmailService; +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; + +@Component +@RequiredArgsConstructor +@Slf4j +public class FulfillmentEmailEventListener { + + private final EmailService emailService; + + @Async("emailExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onFulfillmentShipped(FulfillmentShippedEvent event) { + try { + emailService.sendShippingNotification( + event.to(), event.orderRef(), event.trackingNumber(), + event.trackingCompany(), event.trackingUrl(), event.frontendUrl()); + } catch (Exception e) { + log.error("Failed to send shipping notification to {} for order {}: {}", + event.to(), event.orderRef(), e.getMessage(), e); + } + } + + @Async("emailExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onFulfillmentDelivered(FulfillmentDeliveredEvent event) { + try { + emailService.sendOrderDelivered(event.to(), event.orderRef(), null, event.frontendUrl()); + } catch (Exception e) { + log.error("Failed to send delivery notification to {} for order {}: {}", + event.to(), event.orderRef(), e.getMessage(), e); + } + } +} diff --git a/src/main/java/io/k2dv/garden/fulfillment/event/FulfillmentShippedEvent.java b/src/main/java/io/k2dv/garden/fulfillment/event/FulfillmentShippedEvent.java new file mode 100644 index 0000000..e52ea9d --- /dev/null +++ b/src/main/java/io/k2dv/garden/fulfillment/event/FulfillmentShippedEvent.java @@ -0,0 +1,10 @@ +package io.k2dv.garden.fulfillment.event; + +public record FulfillmentShippedEvent( + String to, + String orderRef, + String trackingNumber, + String trackingCompany, + String trackingUrl, + String frontendUrl +) {} diff --git a/src/main/java/io/k2dv/garden/fulfillment/service/FulfillmentService.java b/src/main/java/io/k2dv/garden/fulfillment/service/FulfillmentService.java index 6b5a017..e88b1ae 100644 --- a/src/main/java/io/k2dv/garden/fulfillment/service/FulfillmentService.java +++ b/src/main/java/io/k2dv/garden/fulfillment/service/FulfillmentService.java @@ -1,16 +1,20 @@ package io.k2dv.garden.fulfillment.service; +import io.k2dv.garden.config.AppProperties; import io.k2dv.garden.fulfillment.dto.CreateFulfillmentRequest; import io.k2dv.garden.fulfillment.dto.FulfillmentItemResponse; import io.k2dv.garden.fulfillment.dto.FulfillmentResponse; import io.k2dv.garden.fulfillment.dto.UpdateFulfillmentRequest; +import io.k2dv.garden.fulfillment.event.FulfillmentDeliveredEvent; +import io.k2dv.garden.fulfillment.event.FulfillmentShippedEvent; import io.k2dv.garden.fulfillment.model.Fulfillment; import io.k2dv.garden.fulfillment.model.FulfillmentItem; import io.k2dv.garden.fulfillment.model.FulfillmentStatus; -import io.k2dv.garden.auth.service.EmailService; -import io.k2dv.garden.config.AppProperties; import io.k2dv.garden.fulfillment.repository.FulfillmentItemRepository; import io.k2dv.garden.fulfillment.repository.FulfillmentRepository; +import io.k2dv.garden.notification.model.NotificationType; +import io.k2dv.garden.notification.service.NotificationPreferenceService; +import io.k2dv.garden.order.model.Order; import io.k2dv.garden.order.model.OrderEventType; import io.k2dv.garden.order.model.OrderItem; import io.k2dv.garden.order.model.OrderStatus; @@ -22,16 +26,13 @@ import io.k2dv.garden.shared.exception.ValidationException; import io.k2dv.garden.user.model.User; import io.k2dv.garden.user.repository.UserRepository; -import io.k2dv.garden.notification.model.NotificationType; -import io.k2dv.garden.notification.service.NotificationPreferenceService; import io.k2dv.garden.webhook.model.WebhookEventType; import io.k2dv.garden.webhook.service.OutboundWebhookService; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import io.k2dv.garden.order.model.Order; - import java.util.List; import java.util.Map; import java.util.UUID; @@ -53,10 +54,10 @@ public class FulfillmentService { private final OrderItemRepository orderItemRepo; private final OrderEventService orderEventService; private final UserRepository userRepo; - private final EmailService emailService; private final AppProperties appProperties; private final OutboundWebhookService outboundWebhookService; private final NotificationPreferenceService notificationPreferenceService; + private final ApplicationEventPublisher eventPublisher; /** * Records a new shipment against a paid order, validating that each requested line-item quantity @@ -157,13 +158,13 @@ public FulfillmentResponse update(UUID orderId, UUID fulfillmentId, UpdateFulfil "Fulfillment updated", null, "admin", null); if (transitioningToShipped) { - sendShippingNotificationEmail(orderId, f); + publishShippedEvent(orderId, f); outboundWebhookService.scheduleDelivery(WebhookEventType.FULFILLMENT_SHIPPED, Map.of("orderId", orderId.toString(), "fulfillmentId", f.getId().toString(), "trackingNumber", f.getTrackingNumber() != null ? f.getTrackingNumber() : "")); } if (transitioningToDelivered) { - sendDeliveredEmail(orderId); + publishDeliveredEvent(orderId); outboundWebhookService.scheduleDelivery(WebhookEventType.FULFILLMENT_DELIVERED, Map.of("orderId", orderId.toString(), "fulfillmentId", f.getId().toString())); } @@ -201,34 +202,36 @@ public FulfillmentResponse getById(UUID orderId, UUID fulfillmentId) { return toResponse(f); } - private void sendDeliveredEmail(UUID orderId) { + private void publishDeliveredEvent(UUID orderId) { orderRepo.findById(orderId).ifPresent(order -> { if (!notificationPreferenceService.isEnabled(order.getUserId(), NotificationType.ORDER_DELIVERED)) return; - String to = order.getGuestEmail() != null ? order.getGuestEmail() - : (order.getUserId() != null - ? userRepo.findById(order.getUserId()).map(User::getEmail).orElse(null) - : null); + String to = resolveEmail(order); if (to == null) return; String orderRef = "#" + orderId.toString().substring(0, 8).toUpperCase(); - emailService.sendOrderDelivered(to, orderRef, null, appProperties.getFrontendUrl()); + eventPublisher.publishEvent(new FulfillmentDeliveredEvent(to, orderRef, appProperties.getFrontendUrl())); }); } - private void sendShippingNotificationEmail(UUID orderId, Fulfillment f) { + private void publishShippedEvent(UUID orderId, Fulfillment f) { orderRepo.findById(orderId).ifPresent(order -> { if (!notificationPreferenceService.isEnabled(order.getUserId(), NotificationType.ORDER_SHIPPED)) return; - String to = order.getGuestEmail() != null ? order.getGuestEmail() - : (order.getUserId() != null - ? userRepo.findById(order.getUserId()).map(User::getEmail).orElse(null) - : null); + String to = resolveEmail(order); if (to == null) return; String orderRef = "#" + orderId.toString().substring(0, 8).toUpperCase(); - emailService.sendShippingNotification(to, orderRef, - f.getTrackingNumber(), f.getTrackingCompany(), f.getTrackingUrl(), - appProperties.getFrontendUrl()); + eventPublisher.publishEvent(new FulfillmentShippedEvent( + to, orderRef, f.getTrackingNumber(), f.getTrackingCompany(), + f.getTrackingUrl(), appProperties.getFrontendUrl())); }); } + private String resolveEmail(Order order) { + if (order.getGuestEmail() != null) return order.getGuestEmail(); + if (order.getUserId() != null) { + return userRepo.findById(order.getUserId()).map(User::getEmail).orElse(null); + } + return null; + } + private void recalculateOrderStatus(UUID orderId) { List orderItems = orderItemRepo.findByOrderId(orderId); if (orderItems.isEmpty()) return; diff --git a/src/test/java/io/k2dv/garden/fulfillment/event/FulfillmentEmailEventListenerTest.java b/src/test/java/io/k2dv/garden/fulfillment/event/FulfillmentEmailEventListenerTest.java new file mode 100644 index 0000000..28fee3b --- /dev/null +++ b/src/test/java/io/k2dv/garden/fulfillment/event/FulfillmentEmailEventListenerTest.java @@ -0,0 +1,73 @@ +package io.k2dv.garden.fulfillment.event; + +import io.k2dv.garden.auth.service.EmailService; +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.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class FulfillmentEmailEventListenerTest { + + @Mock + EmailService emailService; + + @InjectMocks + FulfillmentEmailEventListener listener; + + @Test + void onFulfillmentShipped_delegatesToEmailService() { + var event = new FulfillmentShippedEvent( + "buyer@example.com", "#ABC123", "1Z9999", "UPS", + "https://ups.com/track?n=1Z9999", "http://localhost:3000"); + + listener.onFulfillmentShipped(event); + + verify(emailService).sendShippingNotification( + "buyer@example.com", "#ABC123", "1Z9999", "UPS", + "https://ups.com/track?n=1Z9999", "http://localhost:3000"); + } + + @Test + void onFulfillmentDelivered_delegatesToEmailService() { + var event = new FulfillmentDeliveredEvent( + "buyer@example.com", "#ABC123", "http://localhost:3000"); + + listener.onFulfillmentDelivered(event); + + verify(emailService).sendOrderDelivered( + "buyer@example.com", "#ABC123", null, "http://localhost:3000"); + } + + @Test + void onFulfillmentShipped_emailServiceThrows_doesNotPropagate() { + var event = new FulfillmentShippedEvent( + "bad@example.com", "#XYZ", null, null, null, "http://localhost:3000"); + doThrow(new RuntimeException("SMTP down")) + .when(emailService).sendShippingNotification( + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + + assertThatCode(() -> listener.onFulfillmentShipped(event)) + .doesNotThrowAnyException(); + } + + @Test + void onFulfillmentDelivered_emailServiceThrows_doesNotPropagate() { + var event = new FulfillmentDeliveredEvent( + "bad@example.com", "#XYZ", "http://localhost:3000"); + doThrow(new RuntimeException("SMTP down")) + .when(emailService).sendOrderDelivered( + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + + assertThatCode(() -> listener.onFulfillmentDelivered(event)) + .doesNotThrowAnyException(); + } +} From 727f461c619f3fa96c2309f32a960858b86268c4 Mon Sep 17 00:00:00 2001 From: tkahng Date: Sat, 30 May 2026 00:25:19 -0700 Subject: [PATCH 2/3] fix: update IT tests to assert on published events, not async email calls FulfillmentServiceIT and NotificationPreferenceGateIT were verifying emailService mock calls that now fire asynchronously after transaction commit. Switched to asserting on FulfillmentShippedEvent / FulfillmentDeliveredEvent via @RecordApplicationEvents, matching the existing pattern used for OrderConfirmedEvent / OrderCancelledEvent. --- .../service/FulfillmentServiceIT.java | 47 +++++++++---------- .../service/NotificationPreferenceGateIT.java | 14 +++--- 2 files changed, 29 insertions(+), 32 deletions(-) diff --git a/src/test/java/io/k2dv/garden/fulfillment/service/FulfillmentServiceIT.java b/src/test/java/io/k2dv/garden/fulfillment/service/FulfillmentServiceIT.java index 1658f7b..e92b85a 100644 --- a/src/test/java/io/k2dv/garden/fulfillment/service/FulfillmentServiceIT.java +++ b/src/test/java/io/k2dv/garden/fulfillment/service/FulfillmentServiceIT.java @@ -9,6 +9,8 @@ import io.k2dv.garden.fulfillment.dto.FulfillmentItemRequest; import io.k2dv.garden.fulfillment.dto.FulfillmentResponse; import io.k2dv.garden.fulfillment.dto.UpdateFulfillmentRequest; +import io.k2dv.garden.fulfillment.event.FulfillmentDeliveredEvent; +import io.k2dv.garden.fulfillment.event.FulfillmentShippedEvent; import io.k2dv.garden.fulfillment.model.FulfillmentStatus; import io.k2dv.garden.inventory.model.InventoryLevel; import io.k2dv.garden.inventory.model.Location; @@ -36,6 +38,8 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.event.ApplicationEvents; +import org.springframework.test.context.event.RecordApplicationEvents; import java.math.BigDecimal; import java.util.List; @@ -44,12 +48,8 @@ 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.ArgumentMatchers.isNull; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; +@RecordApplicationEvents class FulfillmentServiceIT extends AbstractIntegrationTest { @Autowired FulfillmentService fulfillmentService; @@ -64,6 +64,7 @@ class FulfillmentServiceIT extends AbstractIntegrationTest { @Autowired InventoryItemRepository inventoryItemRepo; @Autowired InventoryLevelRepository levelRepo; @MockitoBean EmailService emailService; + @Autowired ApplicationEvents applicationEvents; private static final AtomicInteger counter = new AtomicInteger(0); @@ -248,13 +249,15 @@ void update_pendingToShipped_sendsShippingNotificationToCustomer() { new UpdateFulfillmentRequest(FulfillmentStatus.SHIPPED, null, null, null, null)); String orderRef = "#" + order.getId().toString().substring(0, 8).toUpperCase(); - verify(emailService).sendShippingNotification( - eq(adminUser.getEmail()), - eq(orderRef), - eq("T-SHIP"), - eq("UPS"), - eq("https://track.example/T-SHIP"), - eq("http://localhost:3000")); + assertThat(applicationEvents.stream(FulfillmentShippedEvent.class)) + .singleElement() + .satisfies(event -> { + assertThat(event.to()).isEqualTo(adminUser.getEmail()); + assertThat(event.orderRef()).isEqualTo(orderRef); + assertThat(event.trackingNumber()).isEqualTo("T-SHIP"); + assertThat(event.trackingCompany()).isEqualTo("UPS"); + assertThat(event.trackingUrl()).isEqualTo("https://track.example/T-SHIP"); + }); } @Test @@ -271,13 +274,7 @@ void update_reapplyingShippedStatus_doesNotResendShippingNotification() { fulfillmentService.update(order.getId(), f.id(), new UpdateFulfillmentRequest(FulfillmentStatus.SHIPPED, null, null, null, null)); - verify(emailService, times(1)).sendShippingNotification( - eq(adminUser.getEmail()), - eq("#" + order.getId().toString().substring(0, 8).toUpperCase()), - eq("T-ONCE"), - eq("UPS"), - isNull(), - eq("http://localhost:3000")); + assertThat(applicationEvents.stream(FulfillmentShippedEvent.class).count()).isEqualTo(1); } @Test @@ -310,11 +307,13 @@ void update_shippedToDelivered_sendsDeliveredNotificationToCustomer() { fulfillmentService.update(order.getId(), f.id(), new UpdateFulfillmentRequest(FulfillmentStatus.DELIVERED, null, null, null, null)); - verify(emailService).sendOrderDelivered( - eq(adminUser.getEmail()), - eq("#" + order.getId().toString().substring(0, 8).toUpperCase()), - isNull(), - eq("http://localhost:3000")); + String orderRef = "#" + order.getId().toString().substring(0, 8).toUpperCase(); + assertThat(applicationEvents.stream(FulfillmentDeliveredEvent.class)) + .singleElement() + .satisfies(event -> { + assertThat(event.to()).isEqualTo(adminUser.getEmail()); + assertThat(event.orderRef()).isEqualTo(orderRef); + }); } @Test diff --git a/src/test/java/io/k2dv/garden/notification/service/NotificationPreferenceGateIT.java b/src/test/java/io/k2dv/garden/notification/service/NotificationPreferenceGateIT.java index 006e245..8c77223 100644 --- a/src/test/java/io/k2dv/garden/notification/service/NotificationPreferenceGateIT.java +++ b/src/test/java/io/k2dv/garden/notification/service/NotificationPreferenceGateIT.java @@ -31,6 +31,8 @@ import io.k2dv.garden.product.model.ProductStatus; import io.k2dv.garden.product.service.ProductService; import io.k2dv.garden.product.service.VariantService; +import io.k2dv.garden.fulfillment.event.FulfillmentDeliveredEvent; +import io.k2dv.garden.fulfillment.event.FulfillmentShippedEvent; import io.k2dv.garden.order.event.OrderCancelledEvent; import io.k2dv.garden.order.event.OrderConfirmedEvent; import io.k2dv.garden.shared.AbstractIntegrationTest; @@ -50,10 +52,6 @@ import java.util.concurrent.atomic.AtomicInteger; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.contains; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; /** * Verifies that notification preference gates suppress or allow emails @@ -175,7 +173,7 @@ void orderShipped_enabled_sendsNotification() { fulfillmentService.update(order.getId(), f.id(), new UpdateFulfillmentRequest(FulfillmentStatus.SHIPPED, null, null, null, null)); - verify(emailService).sendShippingNotification(any(), any(), any(), any(), any(), any()); + assertThat(applicationEvents.stream(FulfillmentShippedEvent.class).count()).isEqualTo(1); } @Test @@ -191,7 +189,7 @@ void orderShipped_disabled_suppressesEmail() { fulfillmentService.update(order.getId(), f.id(), new UpdateFulfillmentRequest(FulfillmentStatus.SHIPPED, null, null, null, null)); - verify(emailService, never()).sendShippingNotification(any(), any(), any(), any(), any(), any()); + assertThat(applicationEvents.stream(FulfillmentShippedEvent.class).count()).isZero(); } // ─── ORDER_DELIVERED gate ───────────────────────────────────────────────── @@ -208,7 +206,7 @@ void orderDelivered_enabled_sendsNotification() { fulfillmentService.update(order.getId(), f.id(), new UpdateFulfillmentRequest(FulfillmentStatus.DELIVERED, null, null, null, null)); - verify(emailService).sendOrderDelivered(any(), any(), any(), any()); + assertThat(applicationEvents.stream(FulfillmentDeliveredEvent.class).count()).isEqualTo(1); } @Test @@ -226,6 +224,6 @@ void orderDelivered_disabled_suppressesEmail() { fulfillmentService.update(order.getId(), f.id(), new UpdateFulfillmentRequest(FulfillmentStatus.DELIVERED, null, null, null, null)); - verify(emailService, never()).sendOrderDelivered(any(), any(), any(), any()); + assertThat(applicationEvents.stream(FulfillmentDeliveredEvent.class).count()).isZero(); } } From 370607ea9cf260f54e4e26f602e9bbb32407d03a Mon Sep 17 00:00:00 2001 From: tkahng Date: Sat, 30 May 2026 08:28:16 -0700 Subject: [PATCH 3/3] fix: convert remaining Mockito verify calls to event assertions in FulfillmentServiceIT --- .../fulfillment/service/FulfillmentServiceIT.java | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/test/java/io/k2dv/garden/fulfillment/service/FulfillmentServiceIT.java b/src/test/java/io/k2dv/garden/fulfillment/service/FulfillmentServiceIT.java index e92b85a..bb5756c 100644 --- a/src/test/java/io/k2dv/garden/fulfillment/service/FulfillmentServiceIT.java +++ b/src/test/java/io/k2dv/garden/fulfillment/service/FulfillmentServiceIT.java @@ -328,18 +328,8 @@ void update_cancelledFulfillment_doesNotSendShippingOrDeliveredNotification() { fulfillmentService.update(order.getId(), f.id(), new UpdateFulfillmentRequest(FulfillmentStatus.CANCELLED, null, null, null, null)); - verify(emailService, never()).sendShippingNotification( - eq(adminUser.getEmail()), - eq("#" + order.getId().toString().substring(0, 8).toUpperCase()), - eq("T-CANCEL"), - isNull(), - isNull(), - eq("http://localhost:3000")); - verify(emailService, never()).sendOrderDelivered( - eq(adminUser.getEmail()), - eq("#" + order.getId().toString().substring(0, 8).toUpperCase()), - isNull(), - eq("http://localhost:3000")); + assertThat(applicationEvents.stream(FulfillmentShippedEvent.class).count()).isZero(); + assertThat(applicationEvents.stream(FulfillmentDeliveredEvent.class).count()).isZero(); } @Test