NotificationService.java

1
package com.rbctcsworld.ecommerce.notification;
2
3
import com.rbctcsworld.ecommerce.auth.AppUser;
4
import com.rbctcsworld.ecommerce.auth.UserRepository;
5
import com.rbctcsworld.ecommerce.common.exception.ConflictException;
6
import com.rbctcsworld.ecommerce.order.CustomerOrder;
7
import com.rbctcsworld.ecommerce.order.OrderItem;
8
import com.rbctcsworld.ecommerce.order.OrderRepository;
9
import org.springframework.context.event.EventListener;
10
import org.springframework.stereotype.Service;
11
import org.springframework.transaction.annotation.Propagation;
12
import org.springframework.transaction.annotation.Transactional;
13
14
import java.math.BigDecimal;
15
import java.math.RoundingMode;
16
import java.time.Clock;
17
import java.time.LocalDateTime;
18
import java.util.List;
19
import java.util.stream.Collectors;
20
21
/**
22
 * Writes customer e-mails into the outbox (table notifications) and lets admins see and re-queue them.
23
 *
24
 * The listener runs synchronously INSIDE the transaction that published the event (MANDATORY): the e-mail
25
 * row is committed together with the order change, or not at all. Sending happens later, outside that
26
 * transaction (NotificationDeliveryService), so a slow or broken mail server never blocks a checkout.
27
 */
28
@Service
29
public class NotificationService {
30
31
    record Email(String subject, String body) {
32
    }
33
34
    private final NotificationRepository notifications;
35
    private final OrderRepository orders;
36
    private final UserRepository users;
37
    private final Clock clock;
38
39
    public NotificationService(NotificationRepository notifications, OrderRepository orders, UserRepository users,
40
                               Clock clock) {
41
        this.notifications = notifications;
42
        this.orders = orders;
43
        this.users = users;
44
        this.clock = clock;
45
    }
46
47
    @EventListener
48
    @Transactional(propagation = Propagation.MANDATORY)
49
    public void on(OrderNotificationEvent event) {
50
        CustomerOrder order = orders.findById(event.orderId()).orElseThrow();
51
        String recipient = users.findById(order.getUserId()).map(AppUser::getEmail).orElse(null);
52 1 1. on : removed conditional - replaced equality check with false → KILLED
        if (recipient == null) return;                               // account deleted: nobody to tell
53
        Email email = compose(event, order);
54
        notifications.save(new Notification(order.getId(), recipient, event.type().name(), email.subject(),
55
                email.body(), LocalDateTime.now(clock)));
56
    }
57
58
    /** Account e-mails (no order): queued in the same transaction as the account change. */
59
    @EventListener
60
    @Transactional(propagation = Propagation.MANDATORY)
61
    public void on(AccountEmailEvent event) {
62
        notifications.save(new Notification(null, event.recipient(), event.type().name(), event.subject(),
63
                event.body(), LocalDateTime.now(clock)));
64
    }
65
66
    /** Plain-text e-mails: readable in every mail client, nothing to render or sanitise. */
67
    static Email compose(OrderNotificationEvent event, CustomerOrder o) {
68
        String no = o.getOrderNumber();
69 1 1. compose : replaced return value with null for com/rbctcsworld/ecommerce/notification/NotificationService::compose → KILLED
        return switch (event.type()) {
70
            case ORDER_PLACED -> new Email("Order " + no + " received - " + money(o.getTotal()),
71
                    "Thank you for your order " + no + ".\n\n" + lines(o.getItems())
72
                            + "\n" + totals(o) + shipsTo(o)
73
                            + "\nNext step: pay the order in My orders. We reserve the items for you.");
74
            case PAYMENT_RECEIVED -> new Email("Payment received for order " + no,
75
                    "We received your payment of " + money(o.getTotal()) + " for order " + no
76
                            + ".\nWe will e-mail you again when it ships.");
77
            case ORDER_SHIPPED -> new Email("Order " + no + " is on its way",
78
                    "Order " + no + " has shipped with " + o.getCarrier() + ".\nTracking number: "
79
                            + o.getTrackingNumber() + "\nYou can follow it under My orders.");
80
            case ORDER_DELIVERED -> new Email("Order " + no + " was delivered",
81
                    "Order " + no + " was delivered. Enjoy!\nSomething wrong? You can request a return within 30 days"
82
                            + " under My orders.");
83
            case ORDER_CANCELLED -> new Email("Order " + no + " was cancelled",
84 1 1. compose : removed conditional - replaced equality check with false → KILLED
                    "Order " + no + " was cancelled." + (event.amount() == null ? ""
85
                            : "\nWe refunded " + money(event.amount()) + " to your card; it can take a few days to appear."));
86
            case RETURN_APPROVED -> new Email("Return approved for order " + no,
87
                    "Your return for order " + no + " was approved.\nRefund: " + money(event.amount())
88
                            + " to your card; it can take a few days to appear.");
89
            case RETURN_REJECTED -> new Email("Return for order " + no + " was not approved",
90
                    "Your return for order " + no + " was not approved. Questions? Reply to this e-mail.");
91
        };
92
    }
93
94
    static String money(BigDecimal amount) {
95 1 1. money : replaced return value with "" for com/rbctcsworld/ecommerce/notification/NotificationService::money → KILLED
        return "$" + amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
96
    }
97
98
    private static String lines(List<OrderItem> items) {
99 1 1. lines : replaced return value with "" for com/rbctcsworld/ecommerce/notification/NotificationService::lines → KILLED
        return items.stream()
100 1 1. lambda$lines$0 : replaced return value with "" for com/rbctcsworld/ecommerce/notification/NotificationService::lambda$lines$0 → KILLED
                .map(i -> "  " + i.getQuantity() + " x " + i.getProductName() + "  " + money(i.getLineTotal()))
101
                .collect(Collectors.joining("\n")) + "\n";
102
    }
103
104
    /** "Ships to: ..." - no phone number in an e-mail; empty for orders placed before addresses existed. */
105
    private static String shipsTo(CustomerOrder o) {
106 2 1. shipsTo : replaced return value with "" for com/rbctcsworld/ecommerce/notification/NotificationService::shipsTo → KILLED
2. shipsTo : removed conditional - replaced equality check with false → KILLED
        return o.getShippingAddress() == null ? "" : "Ships to: " + o.getShippingAddress().oneLine() + "\n";
107
    }
108
109
    private static String totals(CustomerOrder o) {
110
        StringBuilder s = new StringBuilder("Subtotal " + money(o.getSubtotal()) + "\n");
111 2 1. totals : removed conditional - replaced comparison check with false → KILLED
2. totals : changed conditional boundary → KILLED
        if (o.getDiscount().signum() > 0) {
112
            s.append("Discount -").append(money(o.getDiscount())).append(" (").append(o.getCouponCode()).append(")\n");
113
        }
114 1 1. totals : replaced return value with "" for com/rbctcsworld/ecommerce/notification/NotificationService::totals → KILLED
        return s.append("Shipping ").append(money(o.getShippingFee())).append("\nTax ").append(money(o.getTax()))
115
                .append("\nTotal ").append(money(o.getTotal())).append("\n").toString();
116
    }
117
118
    // ---------------------------------------------------------------- admin
119
120
    @Transactional(readOnly = true)
121
    public List<Notification> list(String status, Long orderId) {
122 2 1. list : replaced return value with Collections.emptyList for com/rbctcsworld/ecommerce/notification/NotificationService::list → KILLED
2. list : removed conditional - replaced equality check with false → KILLED
        if (orderId != null) return notifications.findByOrderIdOrderByIdAsc(orderId);
123 3 1. list : removed conditional - replaced equality check with false → KILLED
2. list : removed conditional - replaced equality check with false → KILLED
3. list : replaced return value with Collections.emptyList for com/rbctcsworld/ecommerce/notification/NotificationService::list → KILLED
        if (status != null && !status.isBlank()) return notifications.findTop100ByStatusOrderByIdDesc(status.trim().toUpperCase());
124 1 1. list : replaced return value with Collections.emptyList for com/rbctcsworld/ecommerce/notification/NotificationService::list → KILLED
        return notifications.findTop100ByOrderByIdDesc();
125
    }
126
127
    /** 409 unless FAILED: a pending or sent e-mail is never sent twice by an admin click. */
128
    @Transactional
129
    public void retry(Long id) {
130 1 1. retry : removed conditional - replaced equality check with false → KILLED
        if (notifications.requeue(id, LocalDateTime.now(clock)) == 0) {
131
            throw new ConflictException("Only a FAILED notification can be sent again");
132
        }
133
    }
134
}

Mutations

52

1.1
Location : on
Killed by : com.rbctcsworld.ecommerce.notification.NotificationServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.notification.NotificationServiceTest]/[method:deletedAccountGetsNoEmail()]
removed conditional - replaced equality check with false → KILLED

69

1.1
Location : compose
Killed by : com.rbctcsworld.ecommerce.notification.NotificationServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.notification.NotificationServiceTest]/[method:returnApprovedStatesTheRefund()]
replaced return value with null for com/rbctcsworld/ecommerce/notification/NotificationService::compose → KILLED

84

1.1
Location : compose
Killed by : com.rbctcsworld.ecommerce.notification.NotificationServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.notification.NotificationServiceTest]/[method:cancelledWithAndWithoutRefund()]
removed conditional - replaced equality check with false → KILLED

95

1.1
Location : money
Killed by : com.rbctcsworld.ecommerce.notification.NotificationServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.notification.NotificationServiceTest]/[method:moneyAlwaysHasTwoDecimals()]
replaced return value with "" for com/rbctcsworld/ecommerce/notification/NotificationService::money → KILLED

99

1.1
Location : lines
Killed by : com.rbctcsworld.ecommerce.notification.NotificationServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.notification.NotificationServiceTest]/[method:orderPlacedListsItemsAndTheWholePriceBreakdown()]
replaced return value with "" for com/rbctcsworld/ecommerce/notification/NotificationService::lines → KILLED

100

1.1
Location : lambda$lines$0
Killed by : com.rbctcsworld.ecommerce.notification.NotificationServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.notification.NotificationServiceTest]/[method:orderPlacedListsItemsAndTheWholePriceBreakdown()]
replaced return value with "" for com/rbctcsworld/ecommerce/notification/NotificationService::lambda$lines$0 → KILLED

106

1.1
Location : shipsTo
Killed by : com.rbctcsworld.ecommerce.notification.NotificationServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.notification.NotificationServiceTest]/[method:orderPlacedSaysWhereItShipsButNotThePhoneNumber()]
replaced return value with "" for com/rbctcsworld/ecommerce/notification/NotificationService::shipsTo → KILLED

2.2
Location : shipsTo
Killed by : com.rbctcsworld.ecommerce.notification.NotificationServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.notification.NotificationServiceTest]/[method:orderPlacedListsItemsAndTheWholePriceBreakdown()]
removed conditional - replaced equality check with false → KILLED

111

1.1
Location : totals
Killed by : com.rbctcsworld.ecommerce.notification.NotificationServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.notification.NotificationServiceTest]/[method:orderPlacedListsItemsAndTheWholePriceBreakdown()]
removed conditional - replaced comparison check with false → KILLED

2.2
Location : totals
Killed by : com.rbctcsworld.ecommerce.notification.NotificationServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.notification.NotificationServiceTest]/[method:noDiscountLineWithoutACoupon()]
changed conditional boundary → KILLED

114

1.1
Location : totals
Killed by : com.rbctcsworld.ecommerce.notification.NotificationServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.notification.NotificationServiceTest]/[method:orderPlacedListsItemsAndTheWholePriceBreakdown()]
replaced return value with "" for com/rbctcsworld/ecommerce/notification/NotificationService::totals → KILLED

122

1.1
Location : list
Killed by : com.rbctcsworld.ecommerce.notification.NotificationServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.notification.NotificationServiceTest]/[method:adminListByOrderByStatusOrLatest()]
replaced return value with Collections.emptyList for com/rbctcsworld/ecommerce/notification/NotificationService::list → KILLED

2.2
Location : list
Killed by : com.rbctcsworld.ecommerce.notification.NotificationServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.notification.NotificationServiceTest]/[method:adminListByOrderByStatusOrLatest()]
removed conditional - replaced equality check with false → KILLED

123

1.1
Location : list
Killed by : com.rbctcsworld.ecommerce.notification.NotificationServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.notification.NotificationServiceTest]/[method:adminListByOrderByStatusOrLatest()]
removed conditional - replaced equality check with false → KILLED

2.2
Location : list
Killed by : com.rbctcsworld.ecommerce.notification.NotificationServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.notification.NotificationServiceTest]/[method:adminListByOrderByStatusOrLatest()]
removed conditional - replaced equality check with false → KILLED

3.3
Location : list
Killed by : com.rbctcsworld.ecommerce.notification.NotificationServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.notification.NotificationServiceTest]/[method:adminListByOrderByStatusOrLatest()]
replaced return value with Collections.emptyList for com/rbctcsworld/ecommerce/notification/NotificationService::list → KILLED

124

1.1
Location : list
Killed by : com.rbctcsworld.ecommerce.notification.NotificationServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.notification.NotificationServiceTest]/[method:adminListByOrderByStatusOrLatest()]
replaced return value with Collections.emptyList for com/rbctcsworld/ecommerce/notification/NotificationService::list → KILLED

130

1.1
Location : retry
Killed by : com.rbctcsworld.ecommerce.notification.NotificationServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.notification.NotificationServiceTest]/[method:onlyFailedNotificationsCanBeSentAgain()]
removed conditional - replaced equality check with false → KILLED

Active mutators

Tests examined


Report generated by PIT 1.30.0 support