US-042 : Notification email lors de la création d'une commande
En tant que client Je veux recevoir un email récapitulatif immédiatement après avoir passé une commande Afin d'avoir une confirmation et les détails de ma commande
Critères d'acceptation :
NE PAS FAIRE - Exemple de mauvaise pratique
@Service
class OrderService(
private val orderRepository: OrderRepository,
private val productRepository: ProductRepository,
private val userRepository: UserRepository,
private val mailSender: JavaMailSender // ❌ Dépendance directe
) {
private val logger = LoggerFactory.getLogger(javaClass)
@Transactional
fun createOrder(dto: CreateOrderDto): Order {
// 1. Validation et création
val user = userRepository.findById(dto.userId)
.orElseThrow { UserNotFoundException(dto.userId) }
val order = Order(user = user)
dto.items.forEach { itemDto ->
val product = productRepository.findById(itemDto.productId)
.orElseThrow { ProductNotFoundException(itemDto.productId) }
if (product.stock < itemDto.quantity) {
throw InsufficientStockException(product.id!!)
}
product.decreaseStock(itemDto.quantity)
order.addItem(OrderItem(
product = product,
quantity = itemDto.quantity,
unitPrice = product.price
))
}
val savedOrder = orderRepository.save(order)
// ❌ PROBLÈME 1 : Logique métier mélangée avec l'envoi d'email
// ❌ PROBLÈME 2 : Si l'email échoue, la transaction est rollback
// ❌ PROBLÈME 3 : Impossible de tester la création sans email
// ❌ PROBLÈME 4 : Pour ajouter SMS, il faut modifier cette classe
try {
sendOrderConfirmationEmail(savedOrder)
} catch (e: Exception) {
logger.error("Failed to send email for order ${savedOrder.id}", e)
// Que faire ? Rollback ? Continuer ?
}
return savedOrder
}
private fun sendOrderConfirmationEmail(order: Order) {
val message = mailSender.createMimeMessage()
val helper = MimeMessageHelper(message, true, "UTF-8")
helper.setTo(order.user.email)
helper.setSubject("Order Confirmation #${order.id}")
helper.setText(buildEmailContent(order), true)
mailSender.send(message)
logger.info("Email sent for order ${order.id}")
}
private fun buildEmailContent(order: Order): String {
return """
<html>
<body>
<h1>Order Confirmation</h1>
<p>Order ID: ${order.id}</p>
<p>Total: €${order.totalAmount}</p>
</body>
</html>
""".trimIndent()
}
}
Problèmes de cette implémentation :
Transformer cette architecture monolithique en une architecture modulaire et découplée
src/main/kotlin/com/ecommerce/
├── order/ # Domaine Order
│ ├── domain/
│ │ ├── Order.kt
│ │ ├── OrderItem.kt
│ │ └── OrderStatus.kt
│ ├── dto/
│ │ ├── CreateOrderDto.kt
│ │ └── OrderResponseDto.kt
│ ├── repository/
│ │ └── OrderRepository.kt
│ ├── service/
│ │ └── OrderService.kt
│ ├── controller/
│ │ └── OrderController.kt
│ └── event/
│ └── OrderCreatedEvent.kt
│
├── notification/ # Domaine Notification
│ ├── domain/
│ │ ├── NotificationChannel.kt
│ │ └── NotificationLog.kt
│ ├── service/
│ │ ├── NotificationService.kt
│ │ └── sender/
│ │ ├── NotificationSender.kt (interface)
│ │ ├── EmailNotificationSender.kt
│ │ └── ConsoleNotificationSender.kt
│ ├── listener/
│ │ └── OrderNotificationListener.kt
│ ├── repository/
│ │ └── NotificationLogRepository.kt
│ └── config/
│ └── NotificationConfig.kt
│
├── product/ # Domaine Product
│ ├── domain/
│ ├── service/
│ └── ...
│
└── user/ # Domaine User
├── domain/
├── service/
└── ...
Principe de packaging par domaine (DDD-lite) :
Exercice 1 (20min) :
Réorganiser votre code existant selon cette structure :
Order* vers com.ecommerce.order.*Product* vers com.ecommerce.product.*User* vers com.ecommerce.user.*Validation :
mvn clean verify passe au vert
package com.ecommerce.order.event
import org.springframework.context.ApplicationEvent
import java.math.BigDecimal
import java.time.Instant
import java.util.*
/**
* Événement publié lors de la création d'une commande
*/
class OrderCreatedEvent(
source: Any,
val orderId: UUID,
val userId: UUID,
val userEmail: String,
val totalAmount: BigDecimal,
val items: List<OrderItemInfo>,
val createdAt: Instant = Instant.now()
) : ApplicationEvent(source) {
data class OrderItemInfo(
val productName: String,
val quantity: Int,
val unitPrice: BigDecimal
)
}
package com.ecommerce.order.service
import com.ecommerce.order.domain.Order
import com.ecommerce.order.domain.OrderItem
import com.ecommerce.order.dto.CreateOrderDto
import com.ecommerce.order.event.OrderCreatedEvent
import com.ecommerce.order.repository.OrderRepository
import com.ecommerce.product.repository.ProductRepository
import com.ecommerce.user.repository.UserRepository
import com.ecommerce.exception.*
import org.slf4j.LoggerFactory
import org.springframework.context.ApplicationEventPublisher
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.util.*
@Service
class OrderService(
private val orderRepository: OrderRepository,
private val productRepository: ProductRepository,
private val userRepository: UserRepository,
private val eventPublisher: ApplicationEventPublisher // Injection de l'event publisher
) {
private val logger = LoggerFactory.getLogger(javaClass)
@Transactional
fun createOrder(dto: CreateOrderDto): Order {
logger.info("Creating order for user ${dto.userId}")
// 1. Validation
val user = userRepository.findById(dto.userId)
.orElseThrow { UserNotFoundException(dto.userId) }
require(dto.items.isNotEmpty()) {
"Order must contain at least one item"
}
// 2. Création de la commande
val order = Order(user = user)
dto.items.forEach { itemDto ->
val product = productRepository.findById(itemDto.productId)
.orElseThrow { ProductNotFoundException(itemDto.productId) }
if (product.stock < itemDto.quantity) {
throw InsufficientStockException(product.id!!)
}
product.decreaseStock(itemDto.quantity)
order.addItem(OrderItem(
product = product,
quantity = itemDto.quantity,
unitPrice = product.price
))
}
// 3. Sauvegarde
val savedOrder = orderRepository.save(order)
logger.info("Order ${savedOrder.id} created successfully")
// 4. Publication de l'événement
// APRÈS le commit de la transaction (voir @TransactionalEventListener)
val event = OrderCreatedEvent(
source = this,
orderId = savedOrder.id!!,
userId = user.id!!,
userEmail = user.email,
totalAmount = savedOrder.totalAmount,
items = savedOrder.items.map { item ->
OrderCreatedEvent.OrderItemInfo(
productName = item.product.name,
quantity = item.quantity,
unitPrice = item.unitPrice
)
}
)
eventPublisher.publishEvent(event)
logger.info("OrderCreatedEvent published for order ${savedOrder.id}")
return savedOrder
}
fun getOrder(orderId: UUID): Order {
return orderRepository.findById(orderId)
.orElseThrow { OrderNotFoundException(orderId) }
}
fun getUserOrders(userId: UUID): List<Order> {
return orderRepository.findByUserId(userId)
}
}
Bénéfices du découplage par événements :
OrderService n'a aucune dépendance vers notificationOrderService
package com.ecommerce.notification.service.sender
import com.ecommerce.notification.domain.NotificationChannel
/**
* Contrat pour l'envoi de notifications
* Pattern Strategy
*/
interface NotificationSender {
/**
* Envoie une notification
* @param recipient Destinataire (email, numéro de téléphone...)
* @param subject Sujet de la notification
* @param content Contenu de la notification
*/
fun send(recipient: String, subject: String, content: String)
/**
* Canal supporté par cette implémentation
*/
fun getSupportedChannel(): NotificationChannel
/**
* Vérifie si l'envoi est disponible
*/
fun isAvailable(): Boolean
}
package com.ecommerce.notification.service.sender
import com.ecommerce.notification.domain.NotificationChannel
import org.slf4j.LoggerFactory
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.stereotype.Component
/**
* Implémentation de test qui affiche les notifications dans la console
* Activée quand notification.email.enabled=false
*/
@Component
@ConditionalOnProperty(
name = ["notification.email.enabled"],
havingValue = "false",
matchIfMissing = true
)
class ConsoleNotificationSender : NotificationSender {
private val logger = LoggerFactory.getLogger(javaClass)
override fun send(recipient: String, subject: String, content: String) {
logger.info("""
╔════════════════════════════════════════════════════════════════
║ 📧 CONSOLE EMAIL NOTIFICATION
╠════════════════════════════════════════════════════════════════
║ To: $recipient
║ Subject: $subject
╠════════════════════════════════════════════════════════════════
║ $content
╚════════════════════════════════════════════════════════════════
""".trimIndent())
}
override fun getSupportedChannel() = NotificationChannel.EMAIL
override fun isAvailable() = true
}
package com.ecommerce.notification.service.sender
import com.ecommerce.notification.domain.NotificationChannel
import jakarta.mail.internet.MimeMessage
import org.slf4j.LoggerFactory
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.mail.javamail.JavaMailSender
import org.springframework.mail.javamail.MimeMessageHelper
import org.springframework.stereotype.Component
/**
* Implémentation réelle avec JavaMailSender
* Activée quand notification.email.enabled=true
*/
@Component
@ConditionalOnProperty(
name = ["notification.email.enabled"],
havingValue = "true"
)
class EmailNotificationSender(
private val mailSender: JavaMailSender
) : NotificationSender {
private val logger = LoggerFactory.getLogger(javaClass)
override fun send(recipient: String, subject: String, content: String) {
try {
val message: MimeMessage = mailSender.createMimeMessage()
val helper = MimeMessageHelper(message, true, "UTF-8")
helper.setTo(recipient)
helper.setSubject(subject)
helper.setText(content, true) // true = HTML
mailSender.send(message)
logger.info("Email sent successfully to $recipient")
} catch (e: Exception) {
logger.error("Failed to send email to $recipient", e)
throw RuntimeException("Email sending failed", e)
}
}
override fun getSupportedChannel() = NotificationChannel.EMAIL
override fun isAvailable(): Boolean {
return try {
// Vérifier si le serveur SMTP est configuré
mailSender.createMimeMessage()
true
} catch (e: Exception) {
logger.warn("Email sender is not available", e)
false
}
}
}
package com.ecommerce.notification.service
import com.ecommerce.notification.domain.NotificationChannel
import com.ecommerce.notification.domain.NotificationLog
import com.ecommerce.notification.repository.NotificationLogRepository
import com.ecommerce.notification.service.sender.NotificationSender
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.time.Instant
/**
* Service de notification avec Pattern Factory
* Sélectionne automatiquement le bon sender selon le canal
*/
@Service
class NotificationService(
private val notificationSenders: List<NotificationSender>, // Spring injecte TOUS les senders
private val logRepository: NotificationLogRepository
) {
private val logger = LoggerFactory.getLogger(javaClass)
/**
* Envoie une notification via le canal spécifié
*/
fun sendNotification(
channel: NotificationChannel,
recipient: String,
subject: String,
content: String
) {
logger.info("Sending $channel notification to $recipient")
// Pattern Factory : sélectionner le bon sender
val sender = notificationSenders.firstOrNull {
it.getSupportedChannel() == channel
} ?: run {
logger.error("No sender found for channel $channel")
logFailure(channel, recipient, subject, "No sender available for this channel")
return
}
// Vérifier la disponibilité
if (!sender.isAvailable()) {
logger.warn("Sender for $channel is not available")
logFailure(channel, recipient, subject, "Sender unavailable")
return
}
// Envoi
try {
sender.send(recipient, subject, content)
logSuccess(channel, recipient, subject)
} catch (e: Exception) {
logger.error("Failed to send $channel notification to $recipient", e)
logFailure(channel, recipient, subject, e.message ?: "Unknown error")
}
}
private fun logSuccess(
channel: NotificationChannel,
recipient: String,
subject: String
) {
val log = NotificationLog(
channel = channel,
recipient = recipient,
subject = subject,
status = "SUCCESS",
errorMessage = null,
sentAt = Instant.now()
)
logRepository.save(log)
}
private fun logFailure(
channel: NotificationChannel,
recipient: String,
subject: String,
errorMessage: String
) {
val log = NotificationLog(
channel = channel,
recipient = recipient,
subject = subject,
status = "FAILED",
errorMessage = errorMessage,
sentAt = Instant.now()
)
logRepository.save(log)
}
/**
* Récupère l'historique des notifications pour un destinataire
*/
fun getNotificationHistory(recipient: String): List<NotificationLog> {
return logRepository.findByRecipientOrderBySentAtDesc(recipient)
}
}
package com.ecommerce.notification.domain
import jakarta.persistence.*
import java.time.Instant
import java.util.*
@Entity
@Table(name = "notification_logs")
class NotificationLog(
@Id
@GeneratedValue(strategy = GenerationType.UUID)
var id: UUID? = null,
@Enumerated(EnumType.STRING)
@Column(nullable = false)
val channel: NotificationChannel,
@Column(nullable = false)
val recipient: String,
@Column(nullable = false)
val subject: String,
@Column(nullable = false)
val status: String, // SUCCESS, FAILED
@Column(length = 1000)
val errorMessage: String?,
@Column(nullable = false)
val sentAt: Instant
)
enum class NotificationChannel {
EMAIL,
SMS,
PUSH
}
package com.ecommerce.notification.repository
import com.ecommerce.notification.domain.NotificationLog
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import java.util.*
@Repository
interface NotificationLogRepository : JpaRepository<NotificationLog, UUID> {
fun findByRecipientOrderBySentAtDesc(recipient: String): List<NotificationLog>
}
package com.ecommerce.notification.listener
import com.ecommerce.notification.domain.NotificationChannel
import com.ecommerce.notification.service.NotificationService
import com.ecommerce.order.event.OrderCreatedEvent
import org.slf4j.LoggerFactory
import org.springframework.scheduling.annotation.Async
import org.springframework.stereotype.Component
import org.springframework.transaction.event.TransactionalEventListener
import org.springframework.transaction.event.TransactionPhase
import java.math.BigDecimal
/**
* Écoute les événements OrderCreatedEvent et envoie des notifications
*
* @TransactionalEventListener : attend le COMMIT de la transaction
* @Async : traitement asynchrone (ne bloque pas la réponse HTTP)
*/
@Component
class OrderNotificationListener(
private val notificationService: NotificationService
) {
private val logger = LoggerFactory.getLogger(javaClass)
/**
* Gère l'événement de création de commande
* Phase AFTER_COMMIT : exécuté APRÈS le commit de la transaction
*/
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun handleOrderCreated(event: OrderCreatedEvent) {
logger.info("Received OrderCreatedEvent for order ${event.orderId}")
try {
// Envoi de la notification email
notificationService.sendNotification(
channel = NotificationChannel.EMAIL,
recipient = event.userEmail,
subject = "Order Confirmation #${event.orderId}",
content = buildEmailContent(event)
)
} catch (e: Exception) {
// Si l'email échoue, la commande reste créée
logger.error("Failed to send notification for order ${event.orderId}", e)
}
}
private fun buildEmailContent(event: OrderCreatedEvent): String {
val html = StringBuilder()
html.append("<!DOCTYPE html>")
html.append("<html><head><meta charset='UTF-8'></head><body>")
html.append("<h1>Order Confirmation</h1>")
html.append("<p>Thank you for your order!</p>")
html.append("<p><strong>Order ID:</strong> ${event.orderId}</p>")
html.append("<p><strong>Order Date:</strong> ${event.createdAt}</p>")
html.append("<h2>Order Details</h2>")
html.append("<table border='1' cellpadding='10' cellspacing='0'>")
html.append("<tr><th>Product</th><th>Quantity</th><th>Unit Price</th><th>Total</th></tr>")
event.items.forEach { item ->
val itemTotal = item.unitPrice.multiply(BigDecimal(item.quantity))
html.append("<tr>")
html.append("<td>${item.productName}</td>")
html.append("<td>${item.quantity}</td>")
html.append("<td>€${item.unitPrice}</td>")
html.append("<td>€$itemTotal</td>")
html.append("</tr>")
}
html.append("</table>")
html.append("<p><strong>Total Amount: €${event.totalAmount}</strong></p>")
html.append("<p>Thank you for your order!</p>")
html.append("</body></html>")
return html.toString()
}
}
package com.ecommerce.notification.config
import org.springframework.context.annotation.Configuration
import org.springframework.scheduling.annotation.EnableAsync
@Configuration
@EnableAsync
class NotificationConfig {
// Configuration par défaut de Spring pour @Async
// Un ThreadPoolTaskExecutor sera créé automatiquement
}
# application-dev.properties
notification.email.enabled=false # Console en dev
# application-test.properties
notification.email.enabled=false # Console en test
# application-prod.properties
notification.email.enabled=true # Vrai email en prod
# Configuration Spring Mail (seulement si enabled=true)
spring.mail.host=smtp.gmail.com
spring.mail.port=587
spring.mail.username=${SMTP_USERNAME}
spring.mail.password=${SMTP_PASSWORD}
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=true
package com.ecommerce.notification.service
import com.ecommerce.notification.domain.NotificationChannel
import com.ecommerce.notification.repository.NotificationLogRepository
import com.ecommerce.notification.service.sender.NotificationSender
import io.mockk.*
import org.assertj.core.api.Assertions.*
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
@DisplayName("NotificationService - Unit Tests")
class NotificationServiceTest {
private lateinit var logRepository: NotificationLogRepository
private lateinit var emailSender: NotificationSender
private lateinit var smsSender: NotificationSender
private lateinit var notificationService: NotificationService
@BeforeEach
fun setUp() {
logRepository = mockk(relaxed = true)
emailSender = mockk()
smsSender = mockk()
every { emailSender.getSupportedChannel() } returns NotificationChannel.EMAIL
every { smsSender.getSupportedChannel() } returns NotificationChannel.SMS
notificationService = NotificationService(
notificationSenders = listOf(emailSender, smsSender),
logRepository = logRepository
)
}
@Test
@DisplayName("Should send notification when sender is available")
fun `sendNotification with available sender should send successfully`() {
// Given
every { emailSender.isAvailable() } returns true
every { emailSender.send(any(), any(), any()) } just Runs
// When
notificationService.sendNotification(
NotificationChannel.EMAIL,
"test@example.com",
"Test Subject",
"Test Content"
)
// Then
verify(exactly = 1) {
emailSender.send("test@example.com", "Test Subject", "Test Content")
}
verify(exactly = 1) {
logRepository.save(match { it.status == "SUCCESS" })
}
}
@Test
@DisplayName("Should log failure when sender throws exception")
fun `sendNotification when sender fails should log error`() {
// Given
every { emailSender.isAvailable() } returns true
every { emailSender.send(any(), any(), any()) } throws RuntimeException("SMTP error")
// When
notificationService.sendNotification(
NotificationChannel.EMAIL,
"test@example.com",
"Test",
"Content"
)
// Then
verify(exactly = 1) {
logRepository.save(match {
it.status == "FAILED" && it.errorMessage?.contains("SMTP error") == true
})
}
}
@Test
@DisplayName("Should not send when no sender for channel")
fun `sendNotification with unsupported channel should log failure`() {
// Given
// Pas de sender pour PUSH
// When
notificationService.sendNotification(
NotificationChannel.PUSH,
"test@example.com",
"Test",
"Content"
)
// Then
verify(exactly = 0) {
emailSender.send(any(), any(), any())
smsSender.send(any(), any(), any())
}
verify(exactly = 1) {
logRepository.save(match {
it.status == "FAILED" &&
it.errorMessage?.contains("No sender available") == true
})
}
}
@Test
@DisplayName("Should not send when sender is unavailable")
fun `sendNotification when sender unavailable should log failure`() {
// Given
every { emailSender.isAvailable() } returns false
// When
notificationService.sendNotification(
NotificationChannel.EMAIL,
"test@example.com",
"Test",
"Content"
)
// Then
verify(exactly = 0) { emailSender.send(anyString(), anyString(), anyString()) }
verify(exactly = 1) {
logRepository.save(match {
it.status == "FAILED" &&
it.errorMessage?.contains("unavailable") == true
})
}
}
}
package com.ecommerce.order.controller
import com.ecommerce.order.event.OrderCreatedEvent
import org.assertj.core.api.Assertions.*
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Primary
import org.springframework.context.event.EventListener
import org.springframework.http.MediaType
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.post
import org.springframework.transaction.annotation.Transactional
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@Transactional
@DisplayName("OrderController - Integration Tests with Events")
class OrderControllerEventIntegrationTest {
@Autowired
private lateinit var mockMvc: MockMvc
@Autowired
private lateinit var testEventListener: TestEventListener
@BeforeEach
fun setUp() {
testEventListener.reset()
}
@Test
@DisplayName("POST /orders should publish OrderCreatedEvent")
fun `createOrder should publish event after successful creation`() {
// Given
val orderRequest = """
{
"userId": "${setupUserId()}",
"items": [
{
"productId": "${setupProductId()}",
"quantity": 2
}
]
}
""".trimIndent()
// When
mockMvc.post("/orders") {
contentType = MediaType.APPLICATION_JSON
content = orderRequest
}.andExpect {
status { isCreated() }
}
// Then - Vérifier que l'événement a été publié
Thread.sleep(500) // Attendre le traitement asynchrone
val events = testEventListener.getReceivedEvents()
assertThat(events).hasSize(1)
val event = events[0]
assertThat(event.userEmail).isNotEmpty()
assertThat(event.totalAmount).isGreaterThan(java.math.BigDecimal.ZERO)
assertThat(event.items).isNotEmpty()
}
private fun setupUserId(): String {
// Créer un utilisateur de test
// TODO: implémenter
return java.util.UUID.randomUUID().toString()
}
private fun setupProductId(): String {
// Créer un produit de test
// TODO: implémenter
return java.util.UUID.randomUUID().toString()
}
/**
* Configuration de test pour capturer les événements
*/
@TestConfiguration
class TestConfig {
@Bean
@Primary
fun testEventListener(): TestEventListener {
return TestEventListener()
}
}
/**
* Listener de test pour vérifier la publication d'événements
*/
class TestEventListener {
private val receivedEvents = mutableListOf<OrderCreatedEvent>()
@EventListener
fun handleEvent(event: OrderCreatedEvent) {
receivedEvents.add(event)
}
fun getReceivedEvents(): List<OrderCreatedEvent> = receivedEvents.toList()
fun reset() {
receivedEvents.clear()
}
}
}
package com.ecommerce.notification.listener
import com.ecommerce.notification.domain.NotificationChannel
import com.ecommerce.notification.service.NotificationService
import com.ecommerce.order.event.OrderCreatedEvent
import io.mockk.*
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import java.math.BigDecimal
import java.util.*
@DisplayName("OrderNotificationListener - Unit Tests")
class OrderNotificationListenerTest {
private lateinit var notificationService: NotificationService
private lateinit var listener: OrderNotificationListener
@BeforeEach
fun setUp() {
notificationService = mockk(relaxed = true)
listener = OrderNotificationListener(notificationService)
}
@Test
@DisplayName("Should send email notification when order is created")
fun `handleOrderCreated should send email notification`() {
// Given
val event = OrderCreatedEvent(
source = this,
orderId = UUID.randomUUID(),
userId = UUID.randomUUID(),
userEmail = "customer@example.com",
totalAmount = BigDecimal.valueOf(100.00),
items = emptyList()
)
// When
listener.handleOrderCreated(event)
// Then
verify(exactly = 1) {
notificationService.sendNotification(
NotificationChannel.EMAIL,
"customer@example.com",
match { it.contains("Order Confirmation") },
any()
)
}
}
@Test
@DisplayName("Should include order details in email content")
fun `handleOrderCreated should include all order information`() {
// Given
val orderId = UUID.randomUUID()
val event = OrderCreatedEvent(
source = this,
orderId = orderId,
userId = UUID.randomUUID(),
userEmail = "customer@example.com",
totalAmount = BigDecimal("250.00"),
items = listOf(
OrderCreatedEvent.OrderItemInfo("Product A", 2, BigDecimal("100.00")),
OrderCreatedEvent.OrderItemInfo("Product B", 1, BigDecimal("50.00"))
)
)
// When
listener.handleOrderCreated(event)
// Then
verify {
notificationService.sendNotification(
NotificationChannel.EMAIL,
"customer@example.com",
any(),
match { content ->
content.contains(orderId.toString()) &&
content.contains("Product A") &&
content.contains("Product B") &&
content.contains("250.00")
}
)
}
}
}
Exercice 2 (45min) :
Compléter la suite de tests :
ConsoleNotificationSenderValidation :
notificationChallenge : Ajouter un canal SMS sans modifier le code existant (principe Open/Closed)
package com.ecommerce.notification.service.sender
import com.ecommerce.notification.domain.NotificationChannel
import org.slf4j.LoggerFactory
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.stereotype.Component
@Component
@ConditionalOnProperty(
name = ["notification.sms.enabled"],
havingValue = "true"
)
class SmsNotificationSender : NotificationSender {
private val logger = LoggerFactory.getLogger(javaClass)
override fun send(recipient: String, subject: String, content: String) {
// Intégration avec Twilio, AWS SNS, etc.
logger.info("Sending SMS to $recipient: $content")
// Implémentation simplifiée pour la demo
}
override fun getSupportedChannel() = NotificationChannel.SMS
override fun isAvailable() = true
}
Points à noter :
NotificationServicenotification.sms.enabled)
Mailpit = Serveur SMTP de test avec interface web moderne
Ajouter ce service dans votre fichier docker-compose.yml :
mailpit:
image: axllent/mailpit:latest
container_name: ecommerce-mailpit
ports:
- "1025:1025" # SMTP
- "8025:8025" # Web UI
networks:
- ecommerce-network
# Démarrer Mailpit docker-compose up -d mailpit # Vérifier que Mailpit est démarré docker ps | grep mailpit
Modifier le fichier src/main/resources/application-dev.properties :
# Mailpit configuration spring.mail.host=localhost spring.mail.port=1025 spring.mail.username= spring.mail.password= spring.mail.properties.mail.smtp.auth=false spring.mail.properties.mail.smtp.starttls.enable=false # Notification settings notification.email.enabled=true notification.email.from=noreply@ecommerce-demo.com # Logs pour voir les envois logging.level.org.springframework.mail=DEBUG
Modifier également src/test/resources/application-test.properties :
# Mailpit pour les tests spring.mail.host=localhost spring.mail.port=1025 notification.email.enabled=true notification.email.from=test@ecommerce-demo.com
Modifier la méthode send() dans EmailNotificationSender.kt :
override fun send(recipient: String, subject: String, content: String) {
try {
val message = mailSender.createMimeMessage()
val helper = MimeMessageHelper(message, true, "UTF-8")
helper.setFrom(fromEmail)
helper.setTo(recipient)
helper.setSubject(subject)
helper.setText(content, true)
mailSender.send(message)
logger.info("✅ Email sent to: $recipient - Subject: $subject")
logger.info("🌐 View in Mailpit: http://localhost:8025")
} catch (e: Exception) {
logger.error("❌ Failed to send email to $recipient", e)
throw RuntimeException("Email sending failed", e)
}
}
# 1. Démarrer Mailpit (si pas déjà fait)
docker-compose up -d mailpit
# 2. Lancer l'application avec le profil dev
mvn spring-boot:run -P dev
# 3. Créer une commande pour déclencher l'envoi d'email
curl -X POST http://localhost:8080/api/orders \
-H "Content-Type: application/json" \
-d '{
"customerId": 1,
"customerEmail": "test@example.com",
"items": [
{
"productId": 1,
"quantity": 2,
"price": 29.99
}
]
}'
# 7.4 Ouvrir l'interface Mailpit
open http://localhost:8025
# Ou dans votre navigateur : http://localhost:8025
Vérifications à effectuer :
1. Ouvrir http://localhost:8025 dans votre navigateur 2. Vérifier qu'un email apparaît dans la liste 3. Cliquer sur l'email pour voir son contenu 4. Vérifier que le contenu HTML est correct 5. Vérifier le sujet : "Order Confirmation #XXX" 6. Vérifier les détails de la commande dans l'email
L'interface web de Mailpit (http://localhost:8025) permet de :
Mailpit est uniquement pour le développement !
En développement (Mailpit) :
spring.mail.host=localhost spring.mail.port=1025 notification.email.enabled=true
En production (SMTP réel - exemple avec Gmail) :
spring.mail.host=smtp.gmail.com
spring.mail.port=587
spring.mail.username=${SMTP_USERNAME}
spring.mail.password=${SMTP_PASSWORD}
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=true
notification.email.enabled=true
notification.email.from=${EMAIL_FROM}
Bonnes pratiques :
# Démarrer uniquement Mailpit docker-compose up -d mailpit # Voir les logs de Mailpit docker logs -f ecommerce-mailpit # Redémarrer Mailpit docker-compose restart mailpit # Arrêter Mailpit docker-compose stop mailpit # Supprimer le conteneur Mailpit docker-compose down mailpit
Problème : Les emails n'apparaissent pas dans Mailpit
# 1. Vérifier que Mailpit est démarré docker ps | grep mailpit # 2. Vérifier les logs de l'application # Rechercher : "Email sent to:" ou "Failed to send email" # 3. Vérifier que le profil dev est actif # Dans les logs au démarrage : "The following profiles are active: dev" # 4. Tester la connexion SMTP telnet localhost 1025
Problème : “Connection refused” sur le port 1025
# Vérifier que le port 1025 n'est pas déjà utilisé lsof -i :1025 # Si occupé, changer le port dans docker-compose.yml et application-dev.properties
Architecture modulaire (30min) :
Système de notification (2h) :
OrderCreatedEvent implémentéOrderServiceNotificationSender + 2 implémentations (Console + Email)NotificationService avec Pattern FactoryOrderNotificationListener avec @TransactionalEventListenerNotificationLog pour auditTests (1h) :
NotificationServiceOrderNotificationListenerDocumentation (30min) :
NotificationSender avec différentes implémentationsOrderService ne dépend que d'abstractionsNotificationSender