diff --git a/README.md b/README.md
index 1dffd47..30eb047 100644
--- a/README.md
+++ b/README.md
@@ -73,7 +73,34 @@ credentials `freight/freight`).
The server starts on **http://localhost:8080**. On first boot, `data.sql` seeds sample ports, a
vessel, and containers.
-### 3. Try the API
+### 3. Configure Email (Optional - for invoice sending)
+
+The application includes email functionality using SMTP. For local development, use **MailHog** to capture emails without sending them:
+
+**Start MailHog:**
+
+```bash
+docker run --rm -p 1025:1025 -p 8025:8025 mailhog/mailhog
+```
+
+- SMTP server: `localhost:1025` (configured in `application.properties`)
+- Web UI: **http://localhost:8025** — view all captured emails in real-time
+
+The `application.properties` is pre-configured:
+```properties
+spring.mail.host=localhost
+spring.mail.port=1025
+app.email.enabled=true
+app.email.from-address=noreply@apgl-shipping.com
+app.email.reply-to=support@apgl-shipping.com
+```
+
+To disable email sending (e.g., in tests):
+```properties
+app.email.enabled=false
+```
+
+### 4. Try the API
**Create a vessel:**
@@ -116,6 +143,13 @@ curl -X POST http://localhost:8080/api/v1/freight-orders \
curl http://localhost:8080/api/v1/freight-orders/1/invoice --output invoice.pdf
```
+**Send invoice to customer email:**
+```bash
+curl -X POST http://localhost:8080/api/v1/invoices/1/send
+```
+
+(Requires order status = DELIVERED; email is sent to customer's registered email address)
+
## Running Tests
Tests use an **H2 in-memory database** — no PostgreSQL needed.
diff --git a/pom.xml b/pom.xml
index 011057a..b6ebe6f 100644
--- a/pom.xml
+++ b/pom.xml
@@ -21,6 +21,8 @@
21
+ 5.14.2
+ 1.15.10
@@ -91,6 +93,10 @@
javase
3.5.3
+
+ org.springframework.boot
+ spring-boot-starter-mail
+
com.itextpdf
@@ -114,6 +120,24 @@
rome
2.1.0
+
+ org.mockito
+ mockito-core
+ ${mockito.version}
+ test
+
+
+ org.mockito
+ mockito-junit-jupiter
+ ${mockito.version}
+ test
+
+
+ net.bytebuddy
+ byte-buddy
+ ${byte-buddy.version}
+ test
+
@@ -122,7 +146,27 @@
org.springframework.boot
spring-boot-maven-plugin
-
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+
+ org.projectlombok
+ lombok
+ ${lombok.version}
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+ -Dnet.bytebuddy.experimental=true
+
+
+
com.spotify.fmt
fmt-maven-plugin
diff --git a/src/main/java/com/shipping/freightops/config/EmailProperties.java b/src/main/java/com/shipping/freightops/config/EmailProperties.java
new file mode 100644
index 0000000..797b2f9
--- /dev/null
+++ b/src/main/java/com/shipping/freightops/config/EmailProperties.java
@@ -0,0 +1,42 @@
+package com.shipping.freightops.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@Component
+@ConfigurationProperties(prefix = "app.email")
+public class EmailProperties {
+
+ /** Whether email sending is enabled. Default: true */
+ private boolean enabled = true;
+
+ /** "From" address for all outgoing emails. Default: noreply@apgl-shipping.com */
+ private String fromAddress = "noreply@apgl-shipping.com";
+
+ /** "Reply-To" address for all outgoing emails. Default: support@apgl-shipping.com */
+ private String replyTo = "support@apgl-shipping.com";
+
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ public void setEnabled(boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ public String getFromAddress() {
+ return fromAddress;
+ }
+
+ public void setFromAddress(String fromAddress) {
+ this.fromAddress = fromAddress;
+ }
+
+ public String getReplyTo() {
+ return replyTo;
+ }
+
+ public void setReplyTo(String replyTo) {
+ this.replyTo = replyTo;
+ }
+}
diff --git a/src/main/java/com/shipping/freightops/controller/InvoiceController.java b/src/main/java/com/shipping/freightops/controller/InvoiceController.java
new file mode 100644
index 0000000..e57bda8
--- /dev/null
+++ b/src/main/java/com/shipping/freightops/controller/InvoiceController.java
@@ -0,0 +1,46 @@
+package com.shipping.freightops.controller;
+
+import com.itextpdf.text.DocumentException;
+import com.shipping.freightops.service.InvoiceEmailService;
+import com.shipping.freightops.service.InvoiceService;
+import java.io.FileNotFoundException;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.ContentDisposition;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/v1/invoices")
+public class InvoiceController {
+
+ @Autowired private InvoiceService invoiceService;
+
+ @Autowired private InvoiceEmailService invoiceEmailService;
+
+ @GetMapping("/{orderId}")
+ public ResponseEntity getInvoice(@PathVariable Long orderId)
+ throws DocumentException, FileNotFoundException {
+ byte[] pdfBytes = invoiceService.generateInvoice(orderId);
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_PDF);
+ headers.setContentDisposition(
+ ContentDisposition.attachment().filename("invoice-" + orderId + ".pdf").build());
+ headers.setContentLength(pdfBytes.length);
+
+ return ResponseEntity.ok().headers(headers).body(pdfBytes);
+ }
+
+ @PostMapping("/{orderId}/send")
+ public ResponseEntity sendInvoiceToCustomer(@PathVariable Long orderId)
+ throws DocumentException, FileNotFoundException {
+ invoiceEmailService.sendInvoiceToCustomer(orderId);
+ return ResponseEntity.ok("Invoice sent successfully to customer email");
+ }
+}
diff --git a/src/main/java/com/shipping/freightops/entity/TrackingEvent.java b/src/main/java/com/shipping/freightops/entity/TrackingEvent.java
index 1da9268..8084910 100644
--- a/src/main/java/com/shipping/freightops/entity/TrackingEvent.java
+++ b/src/main/java/com/shipping/freightops/entity/TrackingEvent.java
@@ -38,6 +38,7 @@ public TrackingEvent(
String performedBy,
LocalDateTime eventTime) {
this.freightOrder = freightOrder;
+ this.eventType = eventType;
this.description = description;
this.location = location;
this.performedBy = performedBy;
diff --git a/src/main/java/com/shipping/freightops/service/EmailService.java b/src/main/java/com/shipping/freightops/service/EmailService.java
new file mode 100644
index 0000000..d3bb1d8
--- /dev/null
+++ b/src/main/java/com/shipping/freightops/service/EmailService.java
@@ -0,0 +1,14 @@
+package com.shipping.freightops.service;
+
+public interface EmailService {
+
+ void sendEmail(String to, String subject, String body);
+
+ void sendEmailWithAttachment(
+ String to,
+ String subject,
+ String body,
+ String attachmentName,
+ byte[] attachmentContent,
+ String mimeType);
+}
diff --git a/src/main/java/com/shipping/freightops/service/InvoiceEmailService.java b/src/main/java/com/shipping/freightops/service/InvoiceEmailService.java
new file mode 100644
index 0000000..dbf3d4c
--- /dev/null
+++ b/src/main/java/com/shipping/freightops/service/InvoiceEmailService.java
@@ -0,0 +1,83 @@
+package com.shipping.freightops.service;
+
+import com.itextpdf.text.DocumentException;
+import com.shipping.freightops.entity.FreightOrder;
+import com.shipping.freightops.enums.OrderStatus;
+import com.shipping.freightops.repository.FreightOrderRepository;
+import java.io.FileNotFoundException;
+import java.time.LocalDate;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class InvoiceEmailService {
+
+ private final FreightOrderRepository freightOrderRepository;
+
+ private final InvoiceService invoiceService;
+
+ private final EmailService emailService;
+
+ @Transactional
+ public void sendInvoiceToCustomer(Long orderId) throws DocumentException, FileNotFoundException {
+ FreightOrder order = getOrderOrThrow(orderId);
+
+ validateDelivered(order);
+
+ byte[] invoicePdf = invoiceService.generateInvoice(orderId);
+
+ sendInvoiceEmail(order, invoicePdf);
+
+ log.info("Invoice sent to customer: {}", order.getCustomer().getEmail());
+ }
+
+ /** Fetch order or throw if not found */
+ private FreightOrder getOrderOrThrow(Long orderId) {
+ return freightOrderRepository
+ .findById(orderId)
+ .orElseThrow(() -> new IllegalArgumentException("Order not found: " + orderId));
+ }
+
+ /** Ensure order is in DELIVERED status */
+ private void validateDelivered(FreightOrder order) {
+ if (order.getStatus() != OrderStatus.DELIVERED) {
+ throw new IllegalStateException(
+ "Cannot send invoice for order in "
+ + order.getStatus()
+ + " status. Order must be DELIVERED.");
+ }
+ }
+
+ /** Build email request with subject, body, and attachment */
+ private void sendInvoiceEmail(FreightOrder order, byte[] invoicePdf) {
+ String invoiceNo = generateInvoiceNumber(order);
+ String voyageNo = generateVoyageNumber(order);
+ String subject = String.format("Invoice %s - Voyage %s", invoiceNo, voyageNo);
+ String body =
+ String.format(
+ "Dear %s,\n\nPlease find your invoice attached. Thank you for your business.\n\nBest regards,\nAPGL Freight Operations",
+ order.getCustomer().getCompanyName());
+
+ emailService.sendEmailWithAttachment(
+ order.getCustomer().getEmail(),
+ subject,
+ body,
+ invoiceNo + ".pdf",
+ invoicePdf,
+ "application/pdf");
+ }
+
+ /** Generate invoice number based on year and order ID */
+ private String generateInvoiceNumber(FreightOrder order) {
+ return String.format("INV-%d-%05d", LocalDate.now().getYear(), order.getId());
+ }
+
+ /** Generate voyage number in format VOY-XXXXX */
+ private String generateVoyageNumber(FreightOrder order) {
+ return String.format("VOY-%05d", order.getVoyage().getId());
+ }
+}
diff --git a/src/main/java/com/shipping/freightops/service/impl/NoOpEmailService.java b/src/main/java/com/shipping/freightops/service/impl/NoOpEmailService.java
new file mode 100644
index 0000000..b57accc
--- /dev/null
+++ b/src/main/java/com/shipping/freightops/service/impl/NoOpEmailService.java
@@ -0,0 +1,33 @@
+package com.shipping.freightops.service.impl;
+
+import com.shipping.freightops.service.EmailService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.mail.javamail.JavaMailSender;
+import org.springframework.stereotype.Service;
+
+@Slf4j
+@Service
+@ConditionalOnMissingBean(JavaMailSender.class)
+public class NoOpEmailService implements EmailService {
+
+ @Override
+ public void sendEmail(String to, String subject, String body) {
+ log.debug("NoOp email service: skipping email to {} with subject '{}'", to, subject);
+ }
+
+ @Override
+ public void sendEmailWithAttachment(
+ String to,
+ String subject,
+ String body,
+ String attachmentName,
+ byte[] attachmentContent,
+ String mimeType) {
+ log.debug(
+ "NoOp email service: skipping email to {} with subject '{}' and attachment '{}'",
+ to,
+ subject,
+ attachmentName);
+ }
+}
diff --git a/src/main/java/com/shipping/freightops/service/impl/SmtpEmailService.java b/src/main/java/com/shipping/freightops/service/impl/SmtpEmailService.java
new file mode 100644
index 0000000..58f16d7
--- /dev/null
+++ b/src/main/java/com/shipping/freightops/service/impl/SmtpEmailService.java
@@ -0,0 +1,113 @@
+package com.shipping.freightops.service.impl;
+
+import com.shipping.freightops.config.EmailProperties;
+import com.shipping.freightops.service.EmailService;
+import jakarta.activation.DataSource;
+import jakarta.mail.MessagingException;
+import jakarta.mail.internet.MimeMessage;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.mail.javamail.JavaMailSender;
+import org.springframework.mail.javamail.MimeMessageHelper;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+@Slf4j
+@Service
+@ConditionalOnBean(JavaMailSender.class)
+public class SmtpEmailService implements EmailService {
+
+ @Autowired private JavaMailSender mailSender;
+
+ @Autowired private EmailProperties emailProperties;
+
+ @Override
+ public void sendEmail(String to, String subject, String body) {
+ validateEmailInput(to, subject, body);
+
+ if (!emailProperties.isEnabled()) {
+ log.debug("Email sending is disabled. Skipping email to: {}", to);
+ return;
+ }
+
+ try {
+ MimeMessage message = mailSender.createMimeMessage();
+ MimeMessageHelper helper = new MimeMessageHelper(message, false, "UTF-8");
+
+ helper.setFrom(emailProperties.getFromAddress());
+ helper.setReplyTo(emailProperties.getReplyTo());
+ helper.setTo(to);
+ helper.setSubject(subject);
+ helper.setText(body, true); // true = HTML content
+
+ mailSender.send(message);
+ log.info("Email sent successfully to: {}", to);
+ } catch (MessagingException e) {
+ log.error("Failed to send email to: {}", to, e);
+ throw new RuntimeException("Email send failed: " + e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public void sendEmailWithAttachment(
+ String to,
+ String subject,
+ String body,
+ String attachmentName,
+ byte[] attachmentContent,
+ String mimeType) {
+ validateEmailInput(to, subject, body);
+ validateAttachmentInput(attachmentName, attachmentContent, mimeType);
+
+ if (!emailProperties.isEnabled()) {
+ log.debug("Email sending is disabled. Skipping email with attachment to: {}", to);
+ return;
+ }
+
+ try {
+ MimeMessage message = mailSender.createMimeMessage();
+ MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
+
+ helper.setFrom(emailProperties.getFromAddress());
+ helper.setReplyTo(emailProperties.getReplyTo());
+ helper.setTo(to);
+ helper.setSubject(subject);
+ helper.setText(body, true); // true = HTML content
+
+ DataSource dataSource =
+ new jakarta.mail.util.ByteArrayDataSource(attachmentContent, mimeType);
+ helper.addAttachment(attachmentName, dataSource);
+
+ mailSender.send(message);
+ log.info("Email with attachment '{}' sent successfully to: {}", attachmentName, to);
+ } catch (MessagingException e) {
+ log.error("Failed to send email with attachment to: {}", to, e);
+ throw new RuntimeException("Email send failed: " + e.getMessage(), e);
+ }
+ }
+
+ private void validateEmailInput(String to, String subject, String body) {
+ if (!StringUtils.hasText(to)) {
+ throw new IllegalArgumentException("Recipient email address cannot be null or empty");
+ }
+ if (!StringUtils.hasText(subject)) {
+ throw new IllegalArgumentException("Email subject cannot be null or empty");
+ }
+ if (!StringUtils.hasText(body)) {
+ throw new IllegalArgumentException("Email body cannot be null or empty");
+ }
+ }
+
+ private void validateAttachmentInput(String attachmentName, byte[] content, String mimeType) {
+ if (!StringUtils.hasText(attachmentName)) {
+ throw new IllegalArgumentException("Attachment name cannot be null or empty");
+ }
+ if (content == null || content.length == 0) {
+ throw new IllegalArgumentException("Attachment content cannot be null or empty");
+ }
+ if (!StringUtils.hasText(mimeType)) {
+ throw new IllegalArgumentException("MIME type cannot be null or empty");
+ }
+ }
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 72219b6..cac5285 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -35,3 +35,12 @@ app.news.feeds=https://gcaptain.com/feed/,https://theloadstar.com/feed/
app.news.max-headlines=5
app.news.connect-timeout=10
app.news.read-timeout=30
+# Email
+spring.mail.host=localhost
+spring.mail.port=1025
+spring.mail.username=
+spring.mail.password=
+# Email service
+app.email.enabled=true
+app.email.from-address=noreply@apgl-shipping.com
+app.email.reply-to=support@apgl-shipping.com
diff --git a/src/test/java/com/shipping/freightops/service/EmailServiceTest.java b/src/test/java/com/shipping/freightops/service/EmailServiceTest.java
new file mode 100644
index 0000000..d1aaec5
--- /dev/null
+++ b/src/test/java/com/shipping/freightops/service/EmailServiceTest.java
@@ -0,0 +1,94 @@
+package com.shipping.freightops.service;
+
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import com.shipping.freightops.config.EmailProperties;
+import com.shipping.freightops.service.impl.SmtpEmailService;
+import jakarta.mail.MessagingException;
+import jakarta.mail.internet.MimeMessage;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.mail.javamail.JavaMailSender;
+import org.springframework.test.util.ReflectionTestUtils;
+
+@ExtendWith(MockitoExtension.class)
+class EmailServiceTest {
+
+ @Mock private JavaMailSender mailSender;
+
+ @InjectMocks private SmtpEmailService emailService;
+
+ private EmailProperties emailProperties;
+
+ @BeforeEach
+ void setUp() {
+ emailProperties = new EmailProperties();
+ emailProperties.setEnabled(true);
+ emailProperties.setFromAddress("noreply@apgl-shipping.com");
+ emailProperties.setReplyTo("support@apgl-shipping.com");
+ ReflectionTestUtils.setField(emailService, "emailProperties", emailProperties);
+ }
+
+ @Test
+ @DisplayName("sends email when enabled")
+ void sendsEmailWhenEnabled() throws MessagingException {
+ MimeMessage mimeMessage = new MimeMessage((jakarta.mail.Session) null);
+ when(mailSender.createMimeMessage()).thenReturn(mimeMessage);
+
+ emailService.sendEmail("customer@example.com", "Test Subject", "Test Body");
+
+ verify(mailSender).send(mimeMessage);
+ }
+
+ @Test
+ @DisplayName("does not send email when disabled")
+ void doesNotSendEmailWhenDisabled() {
+ emailProperties.setEnabled(false);
+
+ emailService.sendEmail("customer@example.com", "Subject", "Body");
+
+ verifyNoInteractions(mailSender);
+ }
+
+ @Test
+ @DisplayName("sends email with attachment when enabled")
+ void sendsEmailWithAttachmentWhenEnabled() throws MessagingException {
+ MimeMessage mimeMessage = new MimeMessage((jakarta.mail.Session) null);
+ when(mailSender.createMimeMessage()).thenReturn(mimeMessage);
+
+ byte[] pdfContent = {(byte) 0x25, (byte) 0x50, (byte) 0x44, (byte) 0x46};
+ emailService.sendEmailWithAttachment(
+ "customer@example.com",
+ "Invoice",
+ "See attachment",
+ "invoice.pdf",
+ pdfContent,
+ "application/pdf");
+
+ verify(mailSender).send(mimeMessage);
+ }
+
+ @Test
+ @DisplayName("does not send email with attachment when disabled")
+ void doesNotSendEmailWithAttachmentWhenDisabled() {
+ emailProperties.setEnabled(false);
+
+ byte[] pdfContent = {(byte) 0x25, (byte) 0x50, (byte) 0x44, (byte) 0x46};
+ emailService.sendEmailWithAttachment(
+ "customer@example.com",
+ "Invoice",
+ "See attachment",
+ "invoice.pdf",
+ pdfContent,
+ "application/pdf");
+
+ verifyNoInteractions(mailSender);
+ }
+}
diff --git a/src/test/java/com/shipping/freightops/service/InvoiceEmailServiceTest.java b/src/test/java/com/shipping/freightops/service/InvoiceEmailServiceTest.java
new file mode 100644
index 0000000..5f81d6f
--- /dev/null
+++ b/src/test/java/com/shipping/freightops/service/InvoiceEmailServiceTest.java
@@ -0,0 +1,156 @@
+package com.shipping.freightops.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.shipping.freightops.entity.Agent;
+import com.shipping.freightops.entity.Container;
+import com.shipping.freightops.entity.Customer;
+import com.shipping.freightops.entity.FreightOrder;
+import com.shipping.freightops.entity.Port;
+import com.shipping.freightops.entity.Vessel;
+import com.shipping.freightops.entity.Voyage;
+import com.shipping.freightops.enums.AgentType;
+import com.shipping.freightops.enums.ContainerSize;
+import com.shipping.freightops.enums.ContainerType;
+import com.shipping.freightops.enums.OrderStatus;
+import com.shipping.freightops.repository.FreightOrderRepository;
+import java.math.BigDecimal;
+import java.util.Optional;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+class InvoiceEmailServiceTest {
+
+ @Mock private InvoiceService invoiceService;
+
+ @Mock private FreightOrderRepository freightOrderRepository;
+
+ @Mock private EmailService emailService;
+
+ @InjectMocks private InvoiceEmailService invoiceEmailService;
+
+ @Nested
+ @DisplayName("sendInvoiceToCustomer")
+ class SendInvoiceToCustomer {
+
+ @Test
+ @DisplayName("sends invoice PDF to customer email when order DELIVERED")
+ void sendsInvoiceWhenDelivered() throws Exception {
+ FreightOrder order = buildDeliveredOrder();
+ byte[] pdfBytes = {0x25, 0x50, 0x44, 0x46}; // PDF magic bytes
+
+ when(freightOrderRepository.findById(1L)).thenReturn(Optional.of(order));
+ when(invoiceService.generateInvoice(1L)).thenReturn(pdfBytes);
+
+ invoiceEmailService.sendInvoiceToCustomer(1L);
+
+ ArgumentCaptor toCaptor = ArgumentCaptor.forClass(String.class);
+ ArgumentCaptor subjectCaptor = ArgumentCaptor.forClass(String.class);
+ ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(String.class);
+ ArgumentCaptor attachmentNameCaptor = ArgumentCaptor.forClass(String.class);
+ ArgumentCaptor contentCaptor = ArgumentCaptor.forClass(byte[].class);
+ ArgumentCaptor mimeTypeCaptor = ArgumentCaptor.forClass(String.class);
+
+ verify(emailService)
+ .sendEmailWithAttachment(
+ toCaptor.capture(),
+ subjectCaptor.capture(),
+ bodyCaptor.capture(),
+ attachmentNameCaptor.capture(),
+ contentCaptor.capture(),
+ mimeTypeCaptor.capture());
+
+ assertThat(toCaptor.getValue()).isEqualTo("customer@example.com");
+ assertThat(subjectCaptor.getValue()).contains("Invoice").contains("Voyage");
+ assertThat(bodyCaptor.getValue()).contains("Acme Corp");
+ assertThat(attachmentNameCaptor.getValue()).endsWith(".pdf");
+ assertThat(mimeTypeCaptor.getValue()).isEqualTo("application/pdf");
+ assertThat(contentCaptor.getValue()).isNotEmpty();
+ }
+
+ @Test
+ @DisplayName("throws when order not found")
+ void throwsWhenOrderNotFound() {
+ when(freightOrderRepository.findById(99L)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> invoiceEmailService.sendInvoiceToCustomer(99L))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Order not found");
+ }
+
+ @Test
+ @DisplayName("throws when order not in DELIVERED status")
+ void throwsWhenNotDelivered() {
+ FreightOrder order = buildDeliveredOrder();
+ order.setStatus(OrderStatus.IN_TRANSIT);
+ when(freightOrderRepository.findById(1L)).thenReturn(Optional.of(order));
+
+ assertThatThrownBy(() -> invoiceEmailService.sendInvoiceToCustomer(1L))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("Cannot send invoice");
+ }
+ }
+
+ // ── HELPERS ────────────────────────────────────────────────
+
+ private FreightOrder buildDeliveredOrder() {
+ Customer customer = new Customer();
+ customer.setId(1L);
+ customer.setCompanyName("Acme Corp");
+ customer.setEmail("customer@example.com");
+ customer.setAddress("123 Main St");
+
+ Port port = new Port();
+ port.setId(1L);
+ port.setName("Port of Hamburg");
+
+ Vessel vessel = new Vessel();
+ vessel.setId(1L);
+ vessel.setName("Test Vessel");
+ vessel.setImoNumber("1234567");
+ vessel.setCapacityTeu(1000);
+
+ Voyage voyage = new Voyage();
+ voyage.setId(1L);
+ voyage.setVoyageNumber("V001");
+ voyage.setDeparturePort(port);
+ voyage.setArrivalPort(port);
+ voyage.setVessel(vessel);
+
+ Container container = new Container();
+ container.setId(1L);
+ container.setContainerCode("TEST123456");
+ container.setSize(ContainerSize.TWENTY_FOOT);
+ container.setType(ContainerType.DRY);
+
+ Agent agent = new Agent();
+ agent.setId(1L);
+ agent.setName("Test Agent");
+ agent.setEmail("agent@example.com");
+ agent.setType(AgentType.INTERNAL);
+
+ FreightOrder order = new FreightOrder();
+ order.setId(1L);
+ order.setCustomer(customer);
+ order.setVoyage(voyage);
+ order.setContainer(container);
+ order.setAgent(agent);
+ order.setOrderedBy("ops-team");
+ order.setStatus(OrderStatus.DELIVERED);
+ order.setBasePriceUsd(new BigDecimal("1000.00"));
+ order.setFinalPrice(new BigDecimal("1000.00"));
+ order.setDiscountPercent(BigDecimal.ZERO);
+
+ return order;
+ }
+}