diff --git a/spring-boot-modules/spring-boot-validation/pom.xml b/spring-boot-modules/spring-boot-validation/pom.xml
index 711b22290045..a9422ef67920 100644
--- a/spring-boot-modules/spring-boot-validation/pom.xml
+++ b/spring-boot-modules/spring-boot-validation/pom.xml
@@ -30,6 +30,12 @@
com.h2database
h2
+
+
+ org.assertj
+ assertj-core
+ test
+
diff --git a/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/Application.java b/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/Application.java
new file mode 100644
index 000000000000..2d5dfc9f02d9
--- /dev/null
+++ b/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/Application.java
@@ -0,0 +1,14 @@
+package com.baeldung.customstatefulvalidation;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
+
+@SpringBootApplication
+@ConfigurationPropertiesScan
+public class Application {
+
+ public static void main(String[] args) {
+ SpringApplication.run(Application.class, args);
+ }
+}
diff --git a/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/configuration/TenantChannels.java b/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/configuration/TenantChannels.java
new file mode 100644
index 000000000000..b0338c59f499
--- /dev/null
+++ b/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/configuration/TenantChannels.java
@@ -0,0 +1,16 @@
+package com.baeldung.customstatefulvalidation.configuration;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@ConfigurationProperties("com.baeldung.tenant")
+public class TenantChannels {
+ private String[] channels;
+
+ public String[] getChannels() {
+ return channels;
+ }
+
+ public void setChannels(String[] channels) {
+ this.channels = channels;
+ }
+}
diff --git a/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/controllers/PurchaseOrderController.java b/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/controllers/PurchaseOrderController.java
new file mode 100644
index 000000000000..8d88e3f39c23
--- /dev/null
+++ b/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/controllers/PurchaseOrderController.java
@@ -0,0 +1,19 @@
+package com.baeldung.customstatefulvalidation.controllers;
+
+import com.baeldung.customstatefulvalidation.model.PurchaseOrderItem;
+import jakarta.validation.Valid;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+public class PurchaseOrderController {
+
+ @PostMapping("/api/purchasing/")
+ public ResponseEntity createPurchaseOrder(@Valid @RequestBody PurchaseOrderItem item) {
+ // start processing this purchase order and tell the caller we've accepted it
+
+ return ResponseEntity.accepted().build();
+ }
+}
diff --git a/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/model/PurchaseOrderItem.java b/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/model/PurchaseOrderItem.java
new file mode 100644
index 000000000000..f47dcf71ef60
--- /dev/null
+++ b/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/model/PurchaseOrderItem.java
@@ -0,0 +1,95 @@
+package com.baeldung.customstatefulvalidation.model;
+
+import com.baeldung.customstatefulvalidation.validators.AvailableChannel;
+import com.baeldung.customstatefulvalidation.validators.AvailableWarehouseRoute;
+import com.baeldung.customstatefulvalidation.validators.ChoosePacksOrIndividuals;
+import com.baeldung.customstatefulvalidation.validators.ProductCheckDigit;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
+
+@ChoosePacksOrIndividuals
+@AvailableWarehouseRoute
+public class PurchaseOrderItem {
+
+ @ProductCheckDigit
+ @NotNull
+ @Pattern(regexp = "A-\\d{8}-\\d")
+ private String productId;
+
+ private String sourceWarehouse;
+ private String destinationCountry;
+
+ @AvailableChannel
+ private String tenantChannel;
+
+ private int numberOfIndividuals;
+ private int numberOfPacks;
+ private int itemsPerPack;
+
+ @org.hibernate.validator.constraints.UUID
+ private String clientUuid;
+
+ public String getProductId() {
+ return productId;
+ }
+
+ public void setProductId(String productId) {
+ this.productId = productId;
+ }
+
+ public String getSourceWarehouse() {
+ return sourceWarehouse;
+ }
+
+ public void setSourceWarehouse(String sourceWarehouse) {
+ this.sourceWarehouse = sourceWarehouse;
+ }
+
+ public String getDestinationCountry() {
+ return destinationCountry;
+ }
+
+ public void setDestinationCountry(String destinationCountry) {
+ this.destinationCountry = destinationCountry;
+ }
+
+ public String getTenantChannel() {
+ return tenantChannel;
+ }
+
+ public void setTenantChannel(String tenantChannel) {
+ this.tenantChannel = tenantChannel;
+ }
+
+ public int getNumberOfIndividuals() {
+ return numberOfIndividuals;
+ }
+
+ public void setNumberOfIndividuals(int numberOfIndividuals) {
+ this.numberOfIndividuals = numberOfIndividuals;
+ }
+
+ public int getNumberOfPacks() {
+ return numberOfPacks;
+ }
+
+ public void setNumberOfPacks(int numberOfPacks) {
+ this.numberOfPacks = numberOfPacks;
+ }
+
+ public int getItemsPerPack() {
+ return itemsPerPack;
+ }
+
+ public void setItemsPerPack(int itemsPerPack) {
+ this.itemsPerPack = itemsPerPack;
+ }
+
+ public String getClientUuid() {
+ return clientUuid;
+ }
+
+ public void setClientUuid(String clientUuid) {
+ this.clientUuid = clientUuid;
+ }
+}
diff --git a/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/repository/WarehouseRouteRepository.java b/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/repository/WarehouseRouteRepository.java
new file mode 100644
index 000000000000..e9e094729386
--- /dev/null
+++ b/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/repository/WarehouseRouteRepository.java
@@ -0,0 +1,22 @@
+package com.baeldung.customstatefulvalidation.repository;
+
+import org.springframework.stereotype.Repository;
+
+import java.util.Set;
+import java.util.stream.Stream;
+
+import static java.util.stream.Collectors.toSet;
+
+@Repository
+public class WarehouseRouteRepository {
+ private Set availableRoutes = Stream.of(
+ "Springfield:USA",
+ "Hartley:USA",
+ "Gentoo:PL",
+ "Mercury:GR")
+ .collect(toSet());
+
+ public boolean isWarehouseRouteAvailable(String sourceWarehouse, String destinationCountry) {
+ return availableRoutes.contains(sourceWarehouse + ":" + destinationCountry);
+ }
+}
diff --git a/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/validators/AvailableChannel.java b/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/validators/AvailableChannel.java
new file mode 100644
index 000000000000..af4ea56a74a8
--- /dev/null
+++ b/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/validators/AvailableChannel.java
@@ -0,0 +1,18 @@
+package com.baeldung.customstatefulvalidation.validators;
+
+import jakarta.validation.Constraint;
+import jakarta.validation.Payload;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Constraint(validatedBy = AvailableChannelValidator.class)
+@Target({ ElementType.FIELD })
+@Retention(RetentionPolicy.RUNTIME)
+public @interface AvailableChannel {
+ String message() default "must be available tenant channel";
+ Class>[] groups() default {};
+ Class extends Payload>[] payload() default {};
+}
\ No newline at end of file
diff --git a/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/validators/AvailableChannelValidator.java b/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/validators/AvailableChannelValidator.java
new file mode 100644
index 000000000000..3c5325ded11c
--- /dev/null
+++ b/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/validators/AvailableChannelValidator.java
@@ -0,0 +1,30 @@
+package com.baeldung.customstatefulvalidation.validators;
+
+import com.baeldung.customstatefulvalidation.configuration.TenantChannels;
+import jakarta.validation.ConstraintValidator;
+import jakarta.validation.ConstraintValidatorContext;
+import org.springframework.beans.factory.annotation.Autowired;
+
+import java.util.Arrays;
+import java.util.Set;
+import java.util.stream.IntStream;
+
+import static java.util.stream.Collectors.toSet;
+
+public class AvailableChannelValidator implements ConstraintValidator {
+
+ @Autowired
+ private TenantChannels tenantChannels;
+
+ private Set channels;
+
+ @Override
+ public void initialize(AvailableChannel constraintAnnotation) {
+ channels = Arrays.stream(tenantChannels.getChannels()).collect(toSet());
+ }
+
+ @Override
+ public boolean isValid(String value, ConstraintValidatorContext context) {
+ return channels.contains(value);
+ }
+}
\ No newline at end of file
diff --git a/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/validators/AvailableWarehouseRoute.java b/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/validators/AvailableWarehouseRoute.java
new file mode 100644
index 000000000000..6eb7bd527640
--- /dev/null
+++ b/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/validators/AvailableWarehouseRoute.java
@@ -0,0 +1,18 @@
+package com.baeldung.customstatefulvalidation.validators;
+
+import jakarta.validation.Constraint;
+import jakarta.validation.Payload;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Constraint(validatedBy = AvailableWarehouseRouteValidator.class)
+@Target({ ElementType.TYPE })
+@Retention(RetentionPolicy.RUNTIME)
+public @interface AvailableWarehouseRoute {
+ String message() default "chosen warehouse route must be active";
+ Class>[] groups() default {};
+ Class extends Payload>[] payload() default {};
+}
\ No newline at end of file
diff --git a/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/validators/AvailableWarehouseRouteValidator.java b/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/validators/AvailableWarehouseRouteValidator.java
new file mode 100644
index 000000000000..1f70bff77926
--- /dev/null
+++ b/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/validators/AvailableWarehouseRouteValidator.java
@@ -0,0 +1,17 @@
+package com.baeldung.customstatefulvalidation.validators;
+
+import com.baeldung.customstatefulvalidation.model.PurchaseOrderItem;
+import com.baeldung.customstatefulvalidation.repository.WarehouseRouteRepository;
+import jakarta.validation.ConstraintValidator;
+import jakarta.validation.ConstraintValidatorContext;
+import org.springframework.beans.factory.annotation.Autowired;
+
+public class AvailableWarehouseRouteValidator implements ConstraintValidator {
+ @Autowired
+ private WarehouseRouteRepository warehouseRouteRepository;
+
+ @Override
+ public boolean isValid(PurchaseOrderItem value, ConstraintValidatorContext context) {
+ return warehouseRouteRepository.isWarehouseRouteAvailable(value.getSourceWarehouse(), value.getDestinationCountry());
+ }
+}
diff --git a/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/validators/ChoosePacksOrIndividuals.java b/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/validators/ChoosePacksOrIndividuals.java
new file mode 100644
index 000000000000..048fc4bc4302
--- /dev/null
+++ b/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/validators/ChoosePacksOrIndividuals.java
@@ -0,0 +1,18 @@
+package com.baeldung.customstatefulvalidation.validators;
+
+import jakarta.validation.Constraint;
+import jakarta.validation.Payload;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Constraint(validatedBy = ChoosePacksOrIndividualsValidator.class)
+@Target({ ElementType.TYPE })
+@Retention(RetentionPolicy.RUNTIME)
+public @interface ChoosePacksOrIndividuals {
+ String message() default "";
+ Class>[] groups() default {};
+ Class extends Payload>[] payload() default {};
+}
\ No newline at end of file
diff --git a/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/validators/ChoosePacksOrIndividualsValidator.java b/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/validators/ChoosePacksOrIndividualsValidator.java
new file mode 100644
index 000000000000..fdaf74dfca03
--- /dev/null
+++ b/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/validators/ChoosePacksOrIndividualsValidator.java
@@ -0,0 +1,45 @@
+package com.baeldung.customstatefulvalidation.validators;
+
+import com.baeldung.customstatefulvalidation.model.PurchaseOrderItem;
+import jakarta.validation.ConstraintValidator;
+import jakarta.validation.ConstraintValidatorContext;
+
+public class ChoosePacksOrIndividualsValidator implements ConstraintValidator {
+ @Override
+ public boolean isValid(PurchaseOrderItem value, ConstraintValidatorContext context) {
+ context.disableDefaultConstraintViolation();
+
+ boolean isValid = true;
+
+ if ((value.getNumberOfPacks() == 0) == (value.getNumberOfIndividuals() == 0)) {
+ isValid = false;
+ context.disableDefaultConstraintViolation();
+ // either both are zero, or both are turned on
+ if (value.getNumberOfPacks() == 0) {
+ context.buildConstraintViolationWithTemplate("must choose a quantity when no packs")
+ .addPropertyNode("numberOfIndividuals")
+ .addConstraintViolation();
+ context.buildConstraintViolationWithTemplate("must choose a quantity when no individuals")
+ .addPropertyNode("numberOfPacks")
+ .addConstraintViolation();
+ } else {
+ context.buildConstraintViolationWithTemplate("cannot be combined with number of packs")
+ .addPropertyNode("numberOfIndividuals")
+ .addConstraintViolation();
+ context.buildConstraintViolationWithTemplate("cannot be combined with number of individuals")
+ .addPropertyNode("numberOfPacks")
+ .addConstraintViolation();
+ }
+ }
+
+ if (value.getNumberOfPacks() > 0 && value.getItemsPerPack() == 0) {
+ isValid = false;
+
+ context.buildConstraintViolationWithTemplate("cannot be 0 when using packs")
+ .addPropertyNode("itemsPerPack")
+ .addConstraintViolation();
+ }
+
+ return isValid;
+ }
+}
diff --git a/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/validators/ProductCheckDigit.java b/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/validators/ProductCheckDigit.java
new file mode 100644
index 000000000000..de6fc83571b6
--- /dev/null
+++ b/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/validators/ProductCheckDigit.java
@@ -0,0 +1,18 @@
+package com.baeldung.customstatefulvalidation.validators;
+
+import jakarta.validation.Constraint;
+import jakarta.validation.Payload;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Constraint(validatedBy = ProductCheckDigitValidator.class)
+@Target({ ElementType.FIELD })
+@Retention(RetentionPolicy.RUNTIME)
+public @interface ProductCheckDigit {
+ String message() default "must have valid check digit";
+ Class>[] groups() default {};
+ Class extends Payload>[] payload() default {};
+}
\ No newline at end of file
diff --git a/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/validators/ProductCheckDigitValidator.java b/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/validators/ProductCheckDigitValidator.java
new file mode 100644
index 000000000000..559657ef49b7
--- /dev/null
+++ b/spring-boot-modules/spring-boot-validation/src/main/java/com/baeldung/customstatefulvalidation/validators/ProductCheckDigitValidator.java
@@ -0,0 +1,29 @@
+package com.baeldung.customstatefulvalidation.validators;
+
+import jakarta.validation.ConstraintValidator;
+import jakarta.validation.ConstraintValidatorContext;
+
+import java.util.stream.IntStream;
+
+public class ProductCheckDigitValidator implements ConstraintValidator {
+
+ @Override
+ public boolean isValid(String value, ConstraintValidatorContext context) {
+ if (value == null) {
+ return false;
+ }
+
+ String[] parts = value.split("-");
+
+ return parts.length == 3 && checkDigitMatches(parts[1], parts[2]);
+ }
+
+ private static boolean checkDigitMatches(String productCode, String checkDigit) {
+ int sumOfDigits = IntStream.range(0, productCode.length())
+ .map(character -> Character.getNumericValue(productCode.charAt(character)))
+ .sum();
+
+ int checkDigitProvided = Character.getNumericValue(checkDigit.charAt(0));
+ return checkDigitProvided == sumOfDigits % 10;
+ }
+}
diff --git a/spring-boot-modules/spring-boot-validation/src/main/resources/application.properties b/spring-boot-modules/spring-boot-validation/src/main/resources/application.properties
index 8fb4899b1057..e80180423422 100644
--- a/spring-boot-modules/spring-boot-validation/src/main/resources/application.properties
+++ b/spring-boot-modules/spring-boot-validation/src/main/resources/application.properties
@@ -8,3 +8,8 @@ spring.jpa.hibernate.ddl-auto=update
# Disable Hibernate validation
spring.jpa.properties.jakarta.persistence.validation.mode=none
+
+
+com.baeldung.tenant.channels[0]=retail
+com.baeldung.tenant.channels[1]=wholesale
+
diff --git a/spring-boot-modules/spring-boot-validation/src/test/java/com/baeldung/customstatefulvalidation/controllers/PurchaseOrderControllerIntegrationTest.java b/spring-boot-modules/spring-boot-validation/src/test/java/com/baeldung/customstatefulvalidation/controllers/PurchaseOrderControllerIntegrationTest.java
new file mode 100644
index 000000000000..a0e430dbdaa3
--- /dev/null
+++ b/spring-boot-modules/spring-boot-validation/src/test/java/com/baeldung/customstatefulvalidation/controllers/PurchaseOrderControllerIntegrationTest.java
@@ -0,0 +1,43 @@
+package com.baeldung.customstatefulvalidation.controllers;
+
+import com.baeldung.customstatefulvalidation.configuration.TenantChannels;
+import com.baeldung.customstatefulvalidation.repository.WarehouseRouteRepository;
+import com.fasterxml.jackson.databind.ObjectMapper;
+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.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+
+import static com.baeldung.customstatefulvalidation.model.PurchaseOrderItemFactory.createValidPurchaseOrderItem;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@AutoConfigureMockMvc
+@WebMvcTest({PurchaseOrderController.class, TenantChannels.class, WarehouseRouteRepository.class})
+class PurchaseOrderControllerIntegrationTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @Test
+ void whenSendBlankRequestThenInvalid() throws Exception {
+ mockMvc.perform(post("/api/purchasing/")
+ .content("{}")
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ void whenSendValidRequestThenAccepted() throws Exception {
+ mockMvc.perform(post("/api/purchasing/")
+ .content(objectMapper.writeValueAsString(createValidPurchaseOrderItem()))
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isAccepted());
+ }
+}
\ No newline at end of file
diff --git a/spring-boot-modules/spring-boot-validation/src/test/java/com/baeldung/customstatefulvalidation/model/PurchaseOrderItemFactory.java b/spring-boot-modules/spring-boot-validation/src/test/java/com/baeldung/customstatefulvalidation/model/PurchaseOrderItemFactory.java
new file mode 100644
index 000000000000..2157cf9e426f
--- /dev/null
+++ b/spring-boot-modules/spring-boot-validation/src/test/java/com/baeldung/customstatefulvalidation/model/PurchaseOrderItemFactory.java
@@ -0,0 +1,19 @@
+package com.baeldung.customstatefulvalidation.model;
+
+import java.util.UUID;
+
+public class PurchaseOrderItemFactory {
+
+ public static PurchaseOrderItem createValidPurchaseOrderItem() {
+ PurchaseOrderItem item = new PurchaseOrderItem();
+
+ item.setProductId("A-12345678-6");
+ item.setClientUuid(UUID.randomUUID().toString());
+ item.setNumberOfIndividuals(12);
+ item.setTenantChannel("retail");
+ item.setSourceWarehouse("Springfield");
+ item.setDestinationCountry("USA");
+
+ return item;
+ }
+}
diff --git a/spring-boot-modules/spring-boot-validation/src/test/java/com/baeldung/customstatefulvalidation/model/PurchaseOrderItemValidationUnitTest.java b/spring-boot-modules/spring-boot-validation/src/test/java/com/baeldung/customstatefulvalidation/model/PurchaseOrderItemValidationUnitTest.java
new file mode 100644
index 000000000000..a17e936d12e7
--- /dev/null
+++ b/spring-boot-modules/spring-boot-validation/src/test/java/com/baeldung/customstatefulvalidation/model/PurchaseOrderItemValidationUnitTest.java
@@ -0,0 +1,148 @@
+package com.baeldung.customstatefulvalidation.model;
+
+import jakarta.validation.ConstraintViolation;
+import jakarta.validation.Validator;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static com.baeldung.customstatefulvalidation.model.PurchaseOrderItemFactory.createValidPurchaseOrderItem;
+import static org.assertj.core.api.Assertions.assertThat;
+
+@SpringBootTest
+class PurchaseOrderItemValidationUnitTest {
+
+ @Autowired
+ private Validator validator;
+
+ @Test
+ void givenInvalidPurchaseOrderItem_thenInvalid() {
+ Set> violations = validator.validate(new PurchaseOrderItem());
+ assertThat(violations).isNotEmpty();
+ }
+
+ @Test
+ void givenInvalidProductId_thenProductIdInvalid() {
+ PurchaseOrderItem item = createValidPurchaseOrderItem();
+ item.setProductId("B-99-D");
+
+ Set> violations = validator.validate(item);
+
+ assertThat(collectViolations(violations))
+ .contains("productId: must match \"A-\\d{8}-\\d\"");
+ }
+
+ @Test
+ void givenValidProductId_thenProductIdIsValid() {
+ PurchaseOrderItem item = createValidPurchaseOrderItem();
+ item.setProductId("A-12345678-6");
+
+ Set> violations = validator.validate(item);
+ assertThat(violations).isEmpty();
+ }
+
+ @Test
+ void givenInvalidClientUuid_thenInvalid() {
+ PurchaseOrderItem item = createValidPurchaseOrderItem();
+ item.setClientUuid("not a uuid");
+
+ Set> violations = validator.validate(item);
+
+ assertThat(collectViolations(violations))
+ .contains("clientUuid: must be a valid UUID");
+ }
+
+ @Test
+ void givenProductIdWithInvalidCheckDigit_thenProductIdIsInvalid() {
+ PurchaseOrderItem item = createValidPurchaseOrderItem();
+ item.setProductId("A-12345678-1");
+
+ Set> violations = validator.validate(item);
+
+ assertThat(collectViolations(violations))
+ .containsExactly("productId: must have valid check digit");
+ }
+
+ @Test
+ void givenNullProductId_thenProductIdIsInvalid() {
+ PurchaseOrderItem item = createValidPurchaseOrderItem();
+ item.setProductId(null);
+
+ Set> violations = validator.validate(item);
+ assertThat(collectViolations(violations))
+ .containsExactly("productId: must have valid check digit",
+ "productId: must not be null");
+ }
+
+ @Test
+ void givenInvalidCombinationOfIndividualAndPack_thenInvalid() {
+ PurchaseOrderItem item = createValidPurchaseOrderItem();
+ item.setNumberOfIndividuals(10);
+ item.setNumberOfPacks(20);
+ item.setItemsPerPack(0);
+
+ Set> violations = validator.validate(item);
+ assertThat(collectViolations(violations))
+ .containsExactly("itemsPerPack: cannot be 0 when using packs",
+ "numberOfIndividuals: cannot be combined with number of packs",
+ "numberOfPacks: cannot be combined with number of individuals");
+ }
+
+ @Test
+ void givenInvalidCombinationOfPacksAndItemsPerPack_thenInvalid() {
+ PurchaseOrderItem item = createValidPurchaseOrderItem();
+ item.setNumberOfIndividuals(0);
+ item.setNumberOfPacks(20);
+ item.setItemsPerPack(0);
+
+ Set> violations = validator.validate(item);
+ assertThat(collectViolations(violations))
+ .containsExactly("itemsPerPack: cannot be 0 when using packs");
+ }
+
+ @Test
+ void givenNeitherPacksNorIndividuals_thenInvalid() {
+ PurchaseOrderItem item = createValidPurchaseOrderItem();
+ item.setNumberOfIndividuals(0);
+ item.setNumberOfPacks(0);
+ item.setItemsPerPack(0);
+
+ Set> violations = validator.validate(item);
+ assertThat(collectViolations(violations))
+ .containsExactly("numberOfIndividuals: must choose a quantity when no packs",
+ "numberOfPacks: must choose a quantity when no individuals");
+ }
+
+ @Test
+ void givenUnexpectedChannel_thenInvalid() {
+ PurchaseOrderItem item = createValidPurchaseOrderItem();
+ item.setTenantChannel("ebay");
+
+ Set> violations = validator.validate(item);
+ assertThat(collectViolations(violations))
+ .containsExactly("tenantChannel: must be available tenant channel");
+ }
+
+ @Test
+ void givenInvalidWarehouseRoute_thenInvalid() {
+ PurchaseOrderItem item = createValidPurchaseOrderItem();
+ item.setSourceWarehouse("Auberry");
+ item.setDestinationCountry("IT");
+
+ Set> violations = validator.validate(item);
+ assertThat(collectViolations(violations))
+ .containsExactly(": chosen warehouse route must be active");
+ }
+
+
+ private static List collectViolations(Set> violations) {
+ return violations.stream()
+ .map(violation -> violation.getPropertyPath() + ": " + violation.getMessage())
+ .sorted()
+ .collect(Collectors.toList());
+ }
+}
\ No newline at end of file