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[] 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[] 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[] 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[] 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