From 969bd6b81e7d600b93c6573c0d64c9304cc21a2c Mon Sep 17 00:00:00 2001 From: Mateus Pontes Date: Fri, 18 Oct 2024 19:29:36 -0300 Subject: [PATCH 1/2] [gateway] refactor: Fix log message --- .../ecommerce/gateway/controller/GroupAPIDocsController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gateway/src/main/java/br/com/ecommerce/gateway/controller/GroupAPIDocsController.java b/gateway/src/main/java/br/com/ecommerce/gateway/controller/GroupAPIDocsController.java index 4940d78..98b19ba 100644 --- a/gateway/src/main/java/br/com/ecommerce/gateway/controller/GroupAPIDocsController.java +++ b/gateway/src/main/java/br/com/ecommerce/gateway/controller/GroupAPIDocsController.java @@ -48,7 +48,7 @@ public Map swaggerConfig(ServerHttpRequest serverHttpRequest) th String.format("%s/%s/v3/api-docs", url, service), "Microservice: " + service.toUpperCase()))); swaggerConfig.put("urls", swaggerUrls); - log.debug("ROTAS: {}", swaggerConfig); + log.debug("ROUTES: {}", swaggerConfig); return swaggerConfig; } } \ No newline at end of file From deed4b9d5234a8bee534e0726533835ee3931255 Mon Sep 17 00:00:00 2001 From: Mateus Pontes Date: Fri, 18 Oct 2024 19:41:26 -0300 Subject: [PATCH 2/2] [products] feat: It is now possible to schedule promotions --- .../product/AdminProductController.java | 35 +- .../api/dto/product/CompletePriceDataDTO.java | 4 +- .../api/dto/product/EndOfPromotionDTO.java | 4 +- .../api/dto/product/SchedulePromotionDTO.java | 24 + .../product/SchedulePromotionResponseDTO.java | 18 + .../api/dto/product/SimplePriceDataDTO.java | 3 +- .../api/dto/product/StockWriteOffDTO.java | 2 +- .../api/dto/product/UpdatePriceDTO.java | 5 +- .../product/UpdatePromotionalPriceDTO.java | 17 + .../products/api/mapper/PriceMapper.java | 16 +- .../products/api/mapper/ProductMapper.java | 5 + .../api/openapi/IAdminProductController.java | 114 ++++- .../business/service/PriceJobService.java | 70 --- .../business/service/ProductService.java | 80 +++- .../business/service/PromotionService.java | 45 +- .../infra/config/PromotionsConfig.java | 8 +- .../products/infra/entity/product/Price.java | 53 ++- .../infra/entity/product/Product.java | 8 +- .../infra/repository/ProductRepository.java | 15 +- .../products/infra/scheduling/PriceJob.java | 21 - .../scheduling/jobs/EndPromotionJob.java | 37 ++ .../scheduling/jobs/StartPromotionJob.java | 31 ++ .../scheduler/PriceJobScheduler.java | 117 +++++ .../scheduler/PromotionSchedulerConfig.java | 31 ++ ...AdminProductControllerIntegrationTest.java | 436 +++++++++++++++--- .../service/ProductServiceUnitTest.java | 171 ++++--- .../unit/infra/entity/product/PriceTest.java | 132 +++++- 27 files changed, 1158 insertions(+), 344 deletions(-) create mode 100644 products/src/main/java/br/com/ecommerce/products/api/dto/product/SchedulePromotionDTO.java create mode 100644 products/src/main/java/br/com/ecommerce/products/api/dto/product/SchedulePromotionResponseDTO.java create mode 100644 products/src/main/java/br/com/ecommerce/products/api/dto/product/UpdatePromotionalPriceDTO.java delete mode 100644 products/src/main/java/br/com/ecommerce/products/business/service/PriceJobService.java delete mode 100644 products/src/main/java/br/com/ecommerce/products/infra/scheduling/PriceJob.java create mode 100644 products/src/main/java/br/com/ecommerce/products/infra/scheduling/jobs/EndPromotionJob.java create mode 100644 products/src/main/java/br/com/ecommerce/products/infra/scheduling/jobs/StartPromotionJob.java create mode 100644 products/src/main/java/br/com/ecommerce/products/infra/scheduling/scheduler/PriceJobScheduler.java create mode 100644 products/src/main/java/br/com/ecommerce/products/infra/scheduling/scheduler/PromotionSchedulerConfig.java diff --git a/products/src/main/java/br/com/ecommerce/products/api/controller/product/AdminProductController.java b/products/src/main/java/br/com/ecommerce/products/api/controller/product/AdminProductController.java index 03e33b1..fc93aa7 100644 --- a/products/src/main/java/br/com/ecommerce/products/api/controller/product/AdminProductController.java +++ b/products/src/main/java/br/com/ecommerce/products/api/controller/product/AdminProductController.java @@ -19,16 +19,21 @@ import br.com.ecommerce.products.api.dto.product.DataProductStockDTO; import br.com.ecommerce.products.api.dto.product.DataStockDTO; import br.com.ecommerce.products.api.dto.product.EndOfPromotionDTO; +import br.com.ecommerce.products.api.dto.product.SchedulePromotionDTO; +import br.com.ecommerce.products.api.dto.product.SchedulePromotionResponseDTO; import br.com.ecommerce.products.api.dto.product.UpdatePriceDTO; import br.com.ecommerce.products.api.dto.product.UpdateProductDTO; import br.com.ecommerce.products.api.dto.product.UpdateProductImagesResponseDTO; import br.com.ecommerce.products.api.dto.product.UpdateProductPriceResponseDTO; import br.com.ecommerce.products.api.dto.product.UpdateProductResponseDTO; +import br.com.ecommerce.products.api.dto.product.UpdatePromotionalPriceDTO; import br.com.ecommerce.products.api.openapi.IAdminProductController; import br.com.ecommerce.products.business.service.ProductService; import jakarta.validation.Valid; import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @AllArgsConstructor @RestController @RequestMapping("/admin/products") @@ -69,24 +74,40 @@ public ResponseEntity updateStock( @PutMapping("/{productId}/prices") public ResponseEntity updatePrice( @PathVariable Long productId, - @RequestBody UpdatePriceDTO dto + @RequestBody @Valid UpdatePriceDTO dto ) { return ResponseEntity.ok(service.updateProductPrice(productId, dto)); } - @PutMapping("/{productId}/prices/switch-to-promotional") - public ResponseEntity switchCurrentPriceToPromotionalPrice( + @PutMapping("/{productId}/prices/promotion") + public ResponseEntity updatePromotionalPrice( + @PathVariable Long productId, + @RequestBody @Valid UpdatePromotionalPriceDTO dto + ) { + return ResponseEntity.ok(service.updateProductPricePromotional(productId, dto)); + } + + @PutMapping("/{productId}/prices/promotion/start") + public ResponseEntity iniciatePromotion( @PathVariable Long productId, @RequestBody @Valid EndOfPromotionDTO requestBody ) { - return ResponseEntity.ok(service.switchCurrentPriceToPromotional(productId, requestBody.getEndOfPromotion())); + return ResponseEntity.ok(service.startPromotionImediatly(productId, requestBody.getEndPromotion())); + } + + @PutMapping("/{productId}/prices/promotion/schedule") + public ResponseEntity schedulePromotion( + @PathVariable Long productId, + @RequestBody @Valid SchedulePromotionDTO requestBody + ) { + return ResponseEntity.ok(service.schedulePromotion(productId, requestBody)); } - @PutMapping("/{productId}/prices/switch-to-original") - public ResponseEntity switchCurrentPriceToOriginalPrice( + @PutMapping("/{productId}/prices/promotion/end") + public ResponseEntity finalizePromotion( @PathVariable Long productId ) { - return ResponseEntity.ok(service.switchCurrentPriceToOriginal(productId)); + return ResponseEntity.ok(service.closePromotion(productId)); } @PatchMapping("/{productId}/images") diff --git a/products/src/main/java/br/com/ecommerce/products/api/dto/product/CompletePriceDataDTO.java b/products/src/main/java/br/com/ecommerce/products/api/dto/product/CompletePriceDataDTO.java index 148fb18..8df971b 100644 --- a/products/src/main/java/br/com/ecommerce/products/api/dto/product/CompletePriceDataDTO.java +++ b/products/src/main/java/br/com/ecommerce/products/api/dto/product/CompletePriceDataDTO.java @@ -19,5 +19,7 @@ public class CompletePriceDataDTO { private BigDecimal promotionalPrice; private boolean onPromotion; @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm") - private LocalDateTime endOfPromotion; + private LocalDateTime startPromotion; + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm") + private LocalDateTime endPromotion; } \ No newline at end of file diff --git a/products/src/main/java/br/com/ecommerce/products/api/dto/product/EndOfPromotionDTO.java b/products/src/main/java/br/com/ecommerce/products/api/dto/product/EndOfPromotionDTO.java index e05444c..9550d36 100644 --- a/products/src/main/java/br/com/ecommerce/products/api/dto/product/EndOfPromotionDTO.java +++ b/products/src/main/java/br/com/ecommerce/products/api/dto/product/EndOfPromotionDTO.java @@ -2,6 +2,7 @@ import java.time.LocalDateTime; +import jakarta.validation.constraints.FutureOrPresent; import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Getter; @@ -13,5 +14,6 @@ public class EndOfPromotionDTO { @NotNull - private LocalDateTime endOfPromotion; + @FutureOrPresent + private LocalDateTime endPromotion; } \ No newline at end of file diff --git a/products/src/main/java/br/com/ecommerce/products/api/dto/product/SchedulePromotionDTO.java b/products/src/main/java/br/com/ecommerce/products/api/dto/product/SchedulePromotionDTO.java new file mode 100644 index 0000000..57bdce1 --- /dev/null +++ b/products/src/main/java/br/com/ecommerce/products/api/dto/product/SchedulePromotionDTO.java @@ -0,0 +1,24 @@ +package br.com.ecommerce.products.api.dto.product; + +import java.time.LocalDateTime; + +import jakarta.validation.constraints.Future; +import jakarta.validation.constraints.FutureOrPresent; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class SchedulePromotionDTO { + + @FutureOrPresent + @NotNull + private LocalDateTime start; + + @Future + @NotNull + private LocalDateTime end; +} \ No newline at end of file diff --git a/products/src/main/java/br/com/ecommerce/products/api/dto/product/SchedulePromotionResponseDTO.java b/products/src/main/java/br/com/ecommerce/products/api/dto/product/SchedulePromotionResponseDTO.java new file mode 100644 index 0000000..3cb09af --- /dev/null +++ b/products/src/main/java/br/com/ecommerce/products/api/dto/product/SchedulePromotionResponseDTO.java @@ -0,0 +1,18 @@ +package br.com.ecommerce.products.api.dto.product; + +import br.com.ecommerce.products.infra.entity.product.Price; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@Getter +@ToString +@NoArgsConstructor +@AllArgsConstructor +public class SchedulePromotionResponseDTO { + + private Long id; + private String name; + private Price price; +} \ No newline at end of file diff --git a/products/src/main/java/br/com/ecommerce/products/api/dto/product/SimplePriceDataDTO.java b/products/src/main/java/br/com/ecommerce/products/api/dto/product/SimplePriceDataDTO.java index 0d3b0fb..083b1c4 100644 --- a/products/src/main/java/br/com/ecommerce/products/api/dto/product/SimplePriceDataDTO.java +++ b/products/src/main/java/br/com/ecommerce/products/api/dto/product/SimplePriceDataDTO.java @@ -16,5 +16,6 @@ public class SimplePriceDataDTO implements Serializable { private BigDecimal currentPrice; private BigDecimal originalPrice; private boolean onPromotion; - private LocalDateTime endOfPromotion; + private LocalDateTime startPromotion; + private LocalDateTime endPromotion; } \ No newline at end of file diff --git a/products/src/main/java/br/com/ecommerce/products/api/dto/product/StockWriteOffDTO.java b/products/src/main/java/br/com/ecommerce/products/api/dto/product/StockWriteOffDTO.java index f56cbf5..79819f5 100644 --- a/products/src/main/java/br/com/ecommerce/products/api/dto/product/StockWriteOffDTO.java +++ b/products/src/main/java/br/com/ecommerce/products/api/dto/product/StockWriteOffDTO.java @@ -18,6 +18,6 @@ public class StockWriteOffDTO { public StockWriteOffDTO(Long productId, Integer unit) { this.productId = productId; - this.unit = Math.negateExact(unit); + this.unit = unit > 0 ? Math.negateExact(unit) : unit; } } \ No newline at end of file diff --git a/products/src/main/java/br/com/ecommerce/products/api/dto/product/UpdatePriceDTO.java b/products/src/main/java/br/com/ecommerce/products/api/dto/product/UpdatePriceDTO.java index a46930f..f3bd1be 100644 --- a/products/src/main/java/br/com/ecommerce/products/api/dto/product/UpdatePriceDTO.java +++ b/products/src/main/java/br/com/ecommerce/products/api/dto/product/UpdatePriceDTO.java @@ -2,6 +2,7 @@ import java.math.BigDecimal; +import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Getter; @@ -13,6 +14,6 @@ public class UpdatePriceDTO { @NotNull - private BigDecimal originalPrice; - private BigDecimal promotionalPrice; + @Min(value = 0, message = "Price cannot be equal to or less than zero") + private BigDecimal price; } \ No newline at end of file diff --git a/products/src/main/java/br/com/ecommerce/products/api/dto/product/UpdatePromotionalPriceDTO.java b/products/src/main/java/br/com/ecommerce/products/api/dto/product/UpdatePromotionalPriceDTO.java new file mode 100644 index 0000000..e3cf69e --- /dev/null +++ b/products/src/main/java/br/com/ecommerce/products/api/dto/product/UpdatePromotionalPriceDTO.java @@ -0,0 +1,17 @@ +package br.com.ecommerce.products.api.dto.product; + +import java.math.BigDecimal; + +import jakarta.validation.constraints.Min; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class UpdatePromotionalPriceDTO { + + @Min(value = 0, message = "Price cannot be equal to or less than zero") + private BigDecimal price; +} \ No newline at end of file diff --git a/products/src/main/java/br/com/ecommerce/products/api/mapper/PriceMapper.java b/products/src/main/java/br/com/ecommerce/products/api/mapper/PriceMapper.java index 62f1f06..8426888 100644 --- a/products/src/main/java/br/com/ecommerce/products/api/mapper/PriceMapper.java +++ b/products/src/main/java/br/com/ecommerce/products/api/mapper/PriceMapper.java @@ -13,9 +13,13 @@ public class PriceMapper { public Price toPrice(UpdatePriceDTO data) { - return Optional.ofNullable(data) - .map(p -> new Price(p.getOriginalPrice(), data.getPromotionalPrice())) - .orElse(null); + return new Price(data.getPrice()); + } + + public Price toPriceWithPromotionalPrice(Price origin, UpdatePriceDTO data) { + Price price = new Price(origin.getOriginalPrice()); + price.setPromotionalPrice(data.getPrice()); + return price; } public CompletePriceDataDTO toCompletePriceDataDTO(Price data) { @@ -25,7 +29,8 @@ public CompletePriceDataDTO toCompletePriceDataDTO(Price data) { p.getOriginalPrice(), p.getPromotionalPrice(), p.isOnPromotion(), - p.getEndOfPromotion())) + p.getStartPromotion(), + p.getEndPromotion())) .orElse(null); } @@ -35,7 +40,8 @@ public SimplePriceDataDTO toSimplePriceDataDTO(Price data) { p.getCurrentPrice(), p.getOriginalPrice(), data.isOnPromotion(), - data.getEndOfPromotion())) + data.getStartPromotion(), + data.getEndPromotion())) .orElse(null); } } \ No newline at end of file diff --git a/products/src/main/java/br/com/ecommerce/products/api/mapper/ProductMapper.java b/products/src/main/java/br/com/ecommerce/products/api/mapper/ProductMapper.java index 3fd94ac..0ade433 100644 --- a/products/src/main/java/br/com/ecommerce/products/api/mapper/ProductMapper.java +++ b/products/src/main/java/br/com/ecommerce/products/api/mapper/ProductMapper.java @@ -12,6 +12,7 @@ import br.com.ecommerce.products.api.dto.product.DataProductPriceDTO; import br.com.ecommerce.products.api.dto.product.DataStockDTO; import br.com.ecommerce.products.api.dto.product.InternalProductDataDTO; +import br.com.ecommerce.products.api.dto.product.SchedulePromotionResponseDTO; import br.com.ecommerce.products.api.dto.product.SimplePriceDataDTO; import br.com.ecommerce.products.api.dto.product.UpdateProductImagesResponseDTO; import br.com.ecommerce.products.api.dto.product.UpdateProductPriceResponseDTO; @@ -102,4 +103,8 @@ public DataProductPriceDTO toProductPriceDTO(Product data) { .map(p -> new DataProductPriceDTO(p.getId(), p.getPrice().getCurrentPrice())) .orElse(null); } + + public SchedulePromotionResponseDTO toSchedulePromotionResponseDTO(Product data) { + return new SchedulePromotionResponseDTO(data.getId(), data.getName(), data.getPrice()); + } } \ No newline at end of file diff --git a/products/src/main/java/br/com/ecommerce/products/api/openapi/IAdminProductController.java b/products/src/main/java/br/com/ecommerce/products/api/openapi/IAdminProductController.java index 8c9f0bc..fef007d 100644 --- a/products/src/main/java/br/com/ecommerce/products/api/openapi/IAdminProductController.java +++ b/products/src/main/java/br/com/ecommerce/products/api/openapi/IAdminProductController.java @@ -15,11 +15,14 @@ import br.com.ecommerce.products.api.dto.product.DataProductStockDTO; import br.com.ecommerce.products.api.dto.product.DataStockDTO; import br.com.ecommerce.products.api.dto.product.EndOfPromotionDTO; +import br.com.ecommerce.products.api.dto.product.SchedulePromotionDTO; +import br.com.ecommerce.products.api.dto.product.SchedulePromotionResponseDTO; import br.com.ecommerce.products.api.dto.product.UpdatePriceDTO; import br.com.ecommerce.products.api.dto.product.UpdateProductDTO; import br.com.ecommerce.products.api.dto.product.UpdateProductImagesResponseDTO; import br.com.ecommerce.products.api.dto.product.UpdateProductPriceResponseDTO; import br.com.ecommerce.products.api.dto.product.UpdateProductResponseDTO; +import br.com.ecommerce.products.api.dto.product.UpdatePromotionalPriceDTO; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; @@ -160,26 +163,27 @@ public ResponseEntity updateStock( ); @Operation( - summary = "Update product prices", + summary = "Update product price", description = """ - Updates the prices of a product. + Updates the default price of a product, the `originalPrice` attribute. Along with it, the + `currentPrice` attribute, which represents the final value of the product. + + ### Requirements + 1. When you set a new price, you are not just changing the `originalPrice` attribute, you are changing the entire + the Price object. All data related to it will be lost, such as promotional price, promotion, dates + start and end of promotion. ### Validations: - 1. **Negative values are not allowed**: All prices must be positive numbers. - 2. **'promotionalPrice' must be less than 'originalPrice'**: The promotional price, if present, must - always be less than the original price. - - ### Optional Fields: - - `promotionalPrice`: Can be null. + 1. **Negative or zero values ​​are not allowed**: The price must be a positive value greater than 0. ### Notes: - `currentPrice`: This is considered the final price of the product. By default, the `currentPrice` is equal to the `originalPrice`. If you want the promotional price to be considered - the current price, use the endpoint `/products/{productId}/prices/switch-to-promotional` to create - a promotion period for the product. The promotion sets the `currentPrice` to the value of `promotionalPrice` for a - determined period. + the current price, use the endpoint `/products/{productId}/prices/promotion` to create a promotion or + use the `/products/{productId}/prices/promotion/schedule` endpoint to schedule a promotion. + The promotion sets the `currentPrice` to the value of `promotionalPrice` for a determined period. """, responses = { @ApiResponse( @@ -202,6 +206,40 @@ public ResponseEntity updatePrice( @RequestBody UpdatePriceDTO dto ); + @Operation( + summary = "Update product promotional price", + description = + """ + Updates the promotional price of a product. + + ### Validations: + + 1. **Null is allowed**: This is a way for you to remove the value from `promotionalPrice` + 2. **Negative or zero values ​​are not allowed**: The price must be a positive value greater than 0. + 3. **Must be less than 'originalPrice'**: The promotional price, must always be less than the original + price. + """, + responses = { + @ApiResponse( + description = "Success", + responseCode = "200", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SimpleDataDepartmentDTO.class) + )), + @ApiResponse( + description = "Invalid field values", + responseCode = "400", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ResponseError.class) + )) + }) + public ResponseEntity updatePromotionalPrice( + @PathVariable Long productId, + @RequestBody UpdatePromotionalPriceDTO dto + ); + @Operation( summary = "Start a promotion", description = @@ -212,7 +250,7 @@ automatically exits the promotion, returning to the original price (`originalPri ### Requirements: - The `promotionalPrice` must be set on the product before using this endpoint. - - An expiration date (`endOfPromotion`) for the promotion must be provided. + - An expiration date (`endPromotion`) for the promotion must be provided. ### Transition Rules: - **During the promotion**: The `currentPrice` reflects the value of `promotionalPrice`. @@ -238,19 +276,61 @@ automatically exits the promotion, returning to the original price (`originalPri schema = @Schema(implementation = ResponseError.class) )) }) - public ResponseEntity switchCurrentPriceToPromotionalPrice( + public ResponseEntity iniciatePromotion( @PathVariable Long productId, @RequestBody @Valid EndOfPromotionDTO requestBody ); + + @Operation( + summary = "Schedule promotion", + description = + """ + It does the same thing as the `Start a promotion` endpoint, with the difference that you can provide a + promotion start date and an end date. The system takes care of the rest, automating the + the beginning and end of the promotion. + + ### Requisitos: + - `promotionalPrice` must be set in the product before using this endpoint. + - An expiration date (`endPromotion`) must be provided for the promotion. + + ### Transition Rules: + -**During the promotion**: The `currentPrice` reflects the value of `promotionalPrice`. + -**After promotion ends**: The `current Price` reverts to the `original Price` value. + + ### Validações: + 1. **Past dates are not allowed**: The due date must be in the future. + 2. **The promotional price must be lower than the original price**. + """, + responses = { + @ApiResponse( + description = "Success", + responseCode = "200", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SimpleDataDepartmentDTO.class) + )), + @ApiResponse( + description = "Invalid field values", + responseCode = "400", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ResponseError.class) + )) + }) + public ResponseEntity schedulePromotion( + @PathVariable Long productId, + @RequestBody @Valid SchedulePromotionDTO requestBody + ); @Operation( summary = "End promotion", description = """ - Returns the product to its normal state, restoring the `currentPrice` to the value of `originalPrice`. + Força o término da promoção de um produto. Retorna o produto ao seu estado normal, restaurando + `currentPrice` para o valor de `originalPrice`. - - The final price of the product will be updated to the original value that was configured before the promotion. - - No additional information needs to be provided for this operation. + - The final price of the product will be updated to the original value that was configured before the + promotion. """, responses = { @ApiResponse( @@ -261,7 +341,7 @@ public ResponseEntity switchCurrentPriceToPromoti schema = @Schema(implementation = SimpleDataDepartmentDTO.class) )) }) - public ResponseEntity switchCurrentPriceToOriginalPrice(@PathVariable Long productId); + public ResponseEntity finalizePromotion(@PathVariable Long productId); @Operation( summary = "Add main product image", diff --git a/products/src/main/java/br/com/ecommerce/products/business/service/PriceJobService.java b/products/src/main/java/br/com/ecommerce/products/business/service/PriceJobService.java deleted file mode 100644 index 6ac81fd..0000000 --- a/products/src/main/java/br/com/ecommerce/products/business/service/PriceJobService.java +++ /dev/null @@ -1,70 +0,0 @@ -package br.com.ecommerce.products.business.service; - -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.Date; - -import org.quartz.JobBuilder; -import org.quartz.JobDetail; -import org.quartz.JobKey; -import org.quartz.Scheduler; -import org.quartz.SchedulerException; -import org.quartz.Trigger; -import org.quartz.TriggerBuilder; -import org.springframework.stereotype.Service; - -import br.com.ecommerce.products.infra.scheduling.PriceJob; -import lombok.AllArgsConstructor; - -@Service -@AllArgsConstructor -public class PriceJobService { - - private final Scheduler scheduler; - - - public void createScheduleForEndOfPromotion(Long productId, LocalDateTime endTime) { - String jobName = String.format("job_%d", productId); - JobKey target = new JobKey(jobName, "promotionJobs"); - if (this.existsJob(target)) this.removeRedundantSchedulePromotion(productId); - - JobDetail jobDetail = JobBuilder.newJob().ofType(PriceJob.class) - .withIdentity(jobName, "promotionJobs") - .usingJobData("productId", productId) - .storeDurably() - .build(); - - Trigger trigger = TriggerBuilder.newTrigger() - .withIdentity("trigger_" + productId, "promotionTriggers") - .startAt(Date.from(endTime.atZone(ZoneId.systemDefault()).toInstant())) - .build(); - - try { - scheduler.scheduleJob(jobDetail, trigger); - - } catch (SchedulerException ex) { - throw new RuntimeException("Cannot possible create job", ex); - } - } - - public void removeRedundantSchedulePromotion(Long productId) { - try { - String jobName = String.format("job_%d", productId); - JobKey target = new JobKey(jobName, "promotionJobs"); - if (this.existsJob(target)) { - scheduler.deleteJob(target); - } - } catch (SchedulerException ex) { - throw new RuntimeException("An error occurred while deleting job", ex); - } - } - - private boolean existsJob(JobKey jobKey) { - try { - return scheduler.checkExists(jobKey); - - } catch (SchedulerException ex) { - throw new RuntimeException("An error occurred while checking the existence of a job", ex); - } - } -} \ No newline at end of file diff --git a/products/src/main/java/br/com/ecommerce/products/business/service/ProductService.java b/products/src/main/java/br/com/ecommerce/products/business/service/ProductService.java index 3ead193..356c452 100644 --- a/products/src/main/java/br/com/ecommerce/products/business/service/ProductService.java +++ b/products/src/main/java/br/com/ecommerce/products/business/service/ProductService.java @@ -20,12 +20,15 @@ import br.com.ecommerce.products.api.dto.product.DataStockDTO; import br.com.ecommerce.products.api.dto.product.InternalProductDataDTO; import br.com.ecommerce.products.api.dto.product.ProductUnitsRequestedDTO; +import br.com.ecommerce.products.api.dto.product.SchedulePromotionDTO; +import br.com.ecommerce.products.api.dto.product.SchedulePromotionResponseDTO; import br.com.ecommerce.products.api.dto.product.StockWriteOffDTO; import br.com.ecommerce.products.api.dto.product.UpdatePriceDTO; import br.com.ecommerce.products.api.dto.product.UpdateProductDTO; import br.com.ecommerce.products.api.dto.product.UpdateProductImagesResponseDTO; import br.com.ecommerce.products.api.dto.product.UpdateProductPriceResponseDTO; import br.com.ecommerce.products.api.dto.product.UpdateProductResponseDTO; +import br.com.ecommerce.products.api.dto.product.UpdatePromotionalPriceDTO; import br.com.ecommerce.products.api.mapper.PriceMapper; import br.com.ecommerce.products.api.mapper.ProductMapper; import br.com.ecommerce.products.api.mapper.StockMapper; @@ -42,6 +45,8 @@ import br.com.ecommerce.products.infra.repository.CategoryRepository; import br.com.ecommerce.products.infra.repository.ManufacturerRepository; import br.com.ecommerce.products.infra.repository.ProductRepository; +import br.com.ecommerce.products.infra.scheduling.scheduler.PriceJobScheduler; +import br.com.ecommerce.products.infra.scheduling.scheduler.PriceJobScheduler.PromotionOperation; import jakarta.persistence.EntityNotFoundException; import jakarta.transaction.Transactional; import lombok.AllArgsConstructor; @@ -64,7 +69,9 @@ public class ProductService { private final UniqueNameProductValidator uniqueNameValidator; - private final PriceJobService priceScheduler; + private final PriceJobScheduler priceScheduler; + + private final PromotionService promotionService; @Cacheable(cacheNames = CacheName.PRODUCTS, key = "#id") @@ -135,10 +142,10 @@ public UpdateProductResponseDTO updateProductData(Long id, UpdateProductDTO dto) @CacheEvict(cacheNames = CacheName.PRODUCTS, key = "#id") public UpdateProductPriceResponseDTO updateProductPrice(Long id, UpdatePriceDTO dto) { return productRepository.findById(id) - .map(p -> { + .map(product -> { Price newPrice = priceMapper.toPrice(dto); - p.updatePrice(newPrice); - return productRepository.save(p); + product.updatePrice(newPrice); + return productRepository.save(product); }) .map(this::createUpdateProductPriceResponseDTO) .orElseThrow(ProductNotFoundException::new); @@ -146,30 +153,59 @@ public UpdateProductPriceResponseDTO updateProductPrice(Long id, UpdatePriceDTO @Transactional @CacheEvict(cacheNames = CacheName.PRODUCTS, key = "#id") - public UpdateProductPriceResponseDTO switchCurrentPriceToOriginal(Long id) { + public UpdateProductPriceResponseDTO updateProductPricePromotional(Long id, UpdatePromotionalPriceDTO dto) { return productRepository.findById(id) - .map(p -> { - p.switchPriceToOriginal(); - return productRepository.save(p); + .map(product -> { + product.getPrice().setPromotionalPrice(dto.getPrice()); + return productRepository.save(product); }) - .stream() - .peek(product -> priceScheduler.removeRedundantSchedulePromotion(product.getId())) - .findFirst() .map(this::createUpdateProductPriceResponseDTO) .orElseThrow(ProductNotFoundException::new); } @Transactional @CacheEvict(cacheNames = CacheName.PRODUCTS, key = "#id") - public UpdateProductPriceResponseDTO switchCurrentPriceToPromotional(Long id, LocalDateTime endOfPromotion) { + public UpdateProductPriceResponseDTO startPromotionImediatly(Long id, LocalDateTime endPromotion) { return productRepository.findById(id) .map(product -> { - product.switchPriceToPromotional(endOfPromotion); - return productRepository.save(product); + product.getPrice().setEndPromotion(endPromotion); + product.startPromotion(); + productRepository.save(product); + + priceScheduler.createSchedulerForPromotionEnd(product.getId(), endPromotion); + promotionService.createCacheForProductOnPromotion(product); + return product; + }) + .map(this::createUpdateProductPriceResponseDTO) + .orElseThrow(ProductNotFoundException::new); + } + + @Transactional + @CacheEvict(cacheNames = CacheName.PRODUCTS, key = "#id") + public SchedulePromotionResponseDTO schedulePromotion(Long id, SchedulePromotionDTO data) { + return productRepository.findById(id) + .map(product -> { + product.getPrice().setStartPromotion(data.getStart()); + product.getPrice().setEndPromotion(data.getEnd()); + product = productRepository.save(product); + + priceScheduler.createSchedulerForPromotionStart(product.getId(), data.getStart(), data.getEnd()); + return product; + }) + .map(productMapper::toSchedulePromotionResponseDTO) + .orElseThrow(ProductNotFoundException::new); + } + + @Transactional + @CacheEvict(cacheNames = CacheName.PRODUCTS, key = "#id") + public UpdateProductPriceResponseDTO closePromotion(Long id) { + return productRepository.findById(id) + .map(product -> { + product.endPromotion(); + product = productRepository.save(product); + priceScheduler.removeRedundantSchedulePromotion(product.getId(), PromotionOperation.END_PROMOTION); + return product; }) - .stream() - .peek(p -> priceScheduler.createScheduleForEndOfPromotion(id, endOfPromotion)) - .findFirst() .map(this::createUpdateProductPriceResponseDTO) .orElseThrow(ProductNotFoundException::new); } @@ -191,13 +227,11 @@ public DataProductStockDTO updateStockByProductId(Long id, DataStockDTO dto) { @CacheEvict(cacheNames = CacheName.PRODUCTS, allEntries = true) public void updateStocks(List dto) { Map writeOffValueMap = dto.stream() - .collect(Collectors.toMap( - StockWriteOffDTO::getProductId, StockWriteOffDTO::getUnit)); + .collect(Collectors.toMap(StockWriteOffDTO::getProductId, p -> (p.getUnit() < 0) ? p.getUnit() : Math.negateExact(p.getUnit()))); - productRepository.findAllById(dto.stream() - .map(StockWriteOffDTO::getProductId) - .toList()) - .forEach(p -> p.updateStock(writeOffValueMap.get(p.getId()))); + Set ids = dto.stream().map(StockWriteOffDTO::getProductId).collect(Collectors.toSet()); + productRepository.findAllById(ids) + .forEach(p -> p.updateStock(writeOffValueMap.get(p.getId()))); } @Transactional diff --git a/products/src/main/java/br/com/ecommerce/products/business/service/PromotionService.java b/products/src/main/java/br/com/ecommerce/products/business/service/PromotionService.java index 328f493..0d20419 100644 --- a/products/src/main/java/br/com/ecommerce/products/business/service/PromotionService.java +++ b/products/src/main/java/br/com/ecommerce/products/business/service/PromotionService.java @@ -8,9 +8,12 @@ import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; +import br.com.ecommerce.products.api.dto.product.DataProductDTO; import br.com.ecommerce.products.api.mapper.factory.ProductDTOFactory; import br.com.ecommerce.products.infra.config.CacheName; +import br.com.ecommerce.products.infra.entity.product.Product; import br.com.ecommerce.products.infra.repository.ProductRepository; +import br.com.ecommerce.products.infra.scheduling.scheduler.PriceJobScheduler; import jakarta.transaction.Transactional; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -22,34 +25,48 @@ public class PromotionService { private final ProductRepository productRepository; private final ProductDTOFactory productDtoFactory; - private final PriceJobService jobService; + private final PriceJobScheduler priceJobScheduler; private final CacheManager cacheManager; @Transactional public void removeExpiredPromotions() { - productRepository.findAllWithExpiredPromotions(LocalDateTime.now()) - .forEach(product -> product.switchPriceToOriginal()); + productRepository.findAllExpiredPromotions(LocalDateTime.now()) + .forEach(product -> product.endPromotion()); } - public void createSchedulerForPromotionsThatWillExpire() { + public void createScheduleForPromotionsThatWillStart() { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime oneHourLater = now.plusHours(1); + productRepository.findAllByPromotionStartingBetween(now, oneHourLater) + .forEach(product -> priceJobScheduler.createSchedulerForPromotionStart( + product.getId(), + product.getPrice().getStartPromotion(), + product.getPrice().getEndPromotion())); + } + + public void createScheduleForPromotionsThatWillExpire() { LocalDateTime now = LocalDateTime.now(); LocalDateTime oneHourLate = now.plusHours(1); productRepository.findAllOnPromotionEndingBetween(now, oneHourLate) - .forEach(product -> - jobService.createScheduleForEndOfPromotion(product.getId(), product.getPrice().getEndOfPromotion())); + .forEach(product -> priceJobScheduler.createSchedulerForPromotionEnd( + product.getId(), + product.getPrice().getEndPromotion())); } - @Async - @Transactional - public void createCacheForProductsOnPromotion() { + + public void createCacheForProductOnPromotion(Product product) { Cache productCache = Optional.ofNullable(cacheManager.getCache(CacheName.PRODUCTS)) .orElseThrow(() -> new RuntimeException("Could not create cache")); - productRepository.findAllPromotionalProducts(LocalDateTime.now()) - .forEach(product -> { - var dataToCache = productDtoFactory.createDataProductDTO(product); - productCache.put(product.getId(), dataToCache); - }); + DataProductDTO productData = productDtoFactory.createDataProductDTO(product); + productCache.put(product.getId(), productData); + } + + @Async + @Transactional + public void createCacheForProductsOnPromotion() { + productRepository.findAllByEndOfPromotionIsAfterOf(LocalDateTime.now()) + .forEach(this::createCacheForProductOnPromotion); } } \ No newline at end of file diff --git a/products/src/main/java/br/com/ecommerce/products/infra/config/PromotionsConfig.java b/products/src/main/java/br/com/ecommerce/products/infra/config/PromotionsConfig.java index 804b649..502b7b3 100644 --- a/products/src/main/java/br/com/ecommerce/products/infra/config/PromotionsConfig.java +++ b/products/src/main/java/br/com/ecommerce/products/infra/config/PromotionsConfig.java @@ -2,7 +2,6 @@ import org.springframework.context.annotation.Profile; import org.springframework.scheduling.annotation.EnableScheduling; -import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import br.com.ecommerce.products.business.service.PromotionService; @@ -24,12 +23,7 @@ public class PromotionsConfig { @PostConstruct public void removeExpiredPromotions() { this.promotionService.removeExpiredPromotions(); - this.promotionService.createSchedulerForPromotionsThatWillExpire(); - } - - @Scheduled(cron = "0 0 * * * *") - public void schedulePromotionReverser() { - this.promotionService.createSchedulerForPromotionsThatWillExpire(); + this.promotionService.createScheduleForPromotionsThatWillExpire(); } @PostConstruct diff --git a/products/src/main/java/br/com/ecommerce/products/infra/entity/product/Price.java b/products/src/main/java/br/com/ecommerce/products/infra/entity/product/Price.java index ef24272..f0ca526 100644 --- a/products/src/main/java/br/com/ecommerce/products/infra/entity/product/Price.java +++ b/products/src/main/java/br/com/ecommerce/products/infra/entity/product/Price.java @@ -8,7 +8,9 @@ import jakarta.persistence.Embeddable; import lombok.Getter; import lombok.ToString; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Getter @ToString @Embeddable @@ -17,7 +19,8 @@ public class Price implements Serializable { private BigDecimal currentPrice; private BigDecimal originalPrice; private BigDecimal promotionalPrice; - private LocalDateTime endOfPromotion; + private LocalDateTime startPromotion; + private LocalDateTime endPromotion; private boolean onPromotion; public Price() { @@ -30,46 +33,56 @@ public Price(BigDecimal originalPrice) { this.onPromotion = false; } - public Price(BigDecimal originalPrice, BigDecimal promotionalPrice) { - this.originalPrice = checkPrice(originalPrice); - this.promotionalPrice = checkPromotionPrice(promotionalPrice); - this.currentPrice = originalPrice; - this.onPromotion = false; + public void setPromotionalPrice(BigDecimal newPromotionalPrice) { + this.promotionalPrice = this.checkPromotionPrice(newPromotionalPrice); } - - public void currentToOriginal() { + public void closePromotion() { this.currentPrice = this.originalPrice; - this.endOfPromotion = null; + this.endPromotion = null; + this.startPromotion = null; this.onPromotion = false; } - - public void currentToPromotional(LocalDateTime endPromotion) { - this.endOfPromotion = Optional.ofNullable(endPromotion) - .filter(date -> date.isAfter(LocalDateTime.now())) - .orElseThrow(() -> new IllegalArgumentException("Enter a valid end date for the promotion")); + public void initiateAPromotion() { this.currentPrice = Optional.ofNullable(this.promotionalPrice) .filter(promo -> promo.compareTo(BigDecimal.ZERO) > 0) - .orElseThrow(() -> new IllegalArgumentException("It is not possible to change the current price as the " + - "promotional price is null")); + .orElseThrow(() -> new IllegalArgumentException( + "Unable to start a promotion because the promotional price is null")); this.onPromotion = true; } + public void setStartPromotion(LocalDateTime start) { + if (this.isPastDate(start)) + throw new IllegalArgumentException("Invalid start date, please enter a future date"); + if (this.promotionalPrice == null) + throw new IllegalArgumentException( + "It is not possible to schedule a promotion while the promotional price of the product is non-existent"); + this.startPromotion = start; + } + + public void setEndPromotion(LocalDateTime end) { + if (this.isPastDate(end)) throw new IllegalArgumentException("Invalid end date, please enter a future date"); + this.endPromotion = end; + } + private BigDecimal checkPrice(BigDecimal price) { return Optional.ofNullable(price) .filter(original -> original.compareTo(BigDecimal.ZERO) > 0) - .orElseThrow(() -> new IllegalArgumentException( - "Price must be a positive value")); + .orElseThrow(() -> new IllegalArgumentException("Price must be a positive value")); } private BigDecimal checkPromotionPrice(BigDecimal promotional) { if (promotional == null) return null; return Optional.of(promotional) - .filter(promo -> promo.compareTo(this.originalPrice) < 0) .filter(promo -> promo.compareTo(BigDecimal.ZERO) > 0) + .filter(promo -> promo.compareTo(this.originalPrice) < 0) .orElseThrow(() -> new IllegalArgumentException( - "Promotional price must be a lower than original Price")); + "The promotional price must be lower than the original price and must not be equal to zero")); + } + + private boolean isPastDate(LocalDateTime date) { + return date.isBefore(LocalDateTime.now()); } } \ No newline at end of file diff --git a/products/src/main/java/br/com/ecommerce/products/infra/entity/product/Product.java b/products/src/main/java/br/com/ecommerce/products/infra/entity/product/Product.java index 9246946..bf93df4 100644 --- a/products/src/main/java/br/com/ecommerce/products/infra/entity/product/Product.java +++ b/products/src/main/java/br/com/ecommerce/products/infra/entity/product/Product.java @@ -105,12 +105,12 @@ public void updatePrice(Price price) { this.price = price; } - public void switchPriceToOriginal() { - this.price.currentToOriginal(); + public void endPromotion() { + this.price.closePromotion(); } - public void switchPriceToPromotional(LocalDateTime endOfPromotion) { - this.price.currentToPromotional(endOfPromotion); + public void startPromotion() { + this.price.initiateAPromotion(); } private T notNull(T attribute, String attributeName) { diff --git a/products/src/main/java/br/com/ecommerce/products/infra/repository/ProductRepository.java b/products/src/main/java/br/com/ecommerce/products/infra/repository/ProductRepository.java index c4057ce..f1cc468 100644 --- a/products/src/main/java/br/com/ecommerce/products/infra/repository/ProductRepository.java +++ b/products/src/main/java/br/com/ecommerce/products/infra/repository/ProductRepository.java @@ -32,14 +32,15 @@ Page findAllByParams( boolean existsByName(String name); - @Query("SELECT p FROM Product p WHERE p.price.onPromotion = true AND p.price.endOfPromotion BETWEEN :init AND :hoursLater") - Set findAllOnPromotionEndingBetween(LocalDateTime init, LocalDateTime hoursLater); + @Query("SELECT p FROM Product p WHERE p.price.onPromotion = true AND p.price.startPromotion BETWEEN :init AND :end") + Set findAllByPromotionStartingBetween(LocalDateTime init, LocalDateTime end); - @Query("SELECT p FROM Product p WHERE p.price.onPromotion = true AND p.price.endOfPromotion < :now") - Set findAllWithExpiredPromotions(LocalDateTime now); + @Query("SELECT p FROM Product p WHERE p.price.onPromotion = true AND p.price.endPromotion BETWEEN :init AND :hoursLater") + Set findAllOnPromotionEndingBetween(LocalDateTime init, LocalDateTime hoursLater); - @Query("SELECT p FROM Product p WHERE p.price.onPromotion = true AND p.price.endOfPromotion > :now") - Set findAllPromotionalProducts(LocalDateTime now); - + @Query("SELECT p FROM Product p WHERE p.price.onPromotion = true AND p.price.endPromotion < :now") + Set findAllExpiredPromotions(LocalDateTime now); + @Query("SELECT p FROM Product p WHERE p.price.onPromotion = true AND p.price.endPromotion > :targetDate") + Set findAllByEndOfPromotionIsAfterOf(LocalDateTime targetDate); } \ No newline at end of file diff --git a/products/src/main/java/br/com/ecommerce/products/infra/scheduling/PriceJob.java b/products/src/main/java/br/com/ecommerce/products/infra/scheduling/PriceJob.java deleted file mode 100644 index 3747b66..0000000 --- a/products/src/main/java/br/com/ecommerce/products/infra/scheduling/PriceJob.java +++ /dev/null @@ -1,21 +0,0 @@ -package br.com.ecommerce.products.infra.scheduling; - -import org.quartz.Job; -import org.quartz.JobExecutionContext; -import org.quartz.JobExecutionException; -import org.springframework.stereotype.Component; - -import br.com.ecommerce.products.business.service.ProductService; -import lombok.AllArgsConstructor; - -@Component -@AllArgsConstructor -public class PriceJob implements Job { - - private final ProductService productService; - - public void execute(JobExecutionContext context) throws JobExecutionException { - Long productId = context.getJobDetail().getJobDataMap().getLong("productId"); - productService.switchCurrentPriceToOriginal(productId); - } -} \ No newline at end of file diff --git a/products/src/main/java/br/com/ecommerce/products/infra/scheduling/jobs/EndPromotionJob.java b/products/src/main/java/br/com/ecommerce/products/infra/scheduling/jobs/EndPromotionJob.java new file mode 100644 index 0000000..018f3df --- /dev/null +++ b/products/src/main/java/br/com/ecommerce/products/infra/scheduling/jobs/EndPromotionJob.java @@ -0,0 +1,37 @@ +package br.com.ecommerce.products.infra.scheduling.jobs; + +import java.time.LocalDateTime; + +import org.quartz.Job; +import org.quartz.JobExecutionContext; +import org.quartz.JobExecutionException; +import org.quartz.Scheduler; +import org.quartz.SchedulerException; +import org.springframework.stereotype.Component; + +import br.com.ecommerce.products.business.service.ProductService; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@AllArgsConstructor +public class EndPromotionJob implements Job { + + private final ProductService productService; + private final Scheduler scheduler; + + + public void execute(JobExecutionContext context) throws JobExecutionException { + log.debug("THE END OF THE PROMOTION HAS BEEN TRIGGERED: {}", LocalDateTime.now()); + Long productId = context.getJobDetail().getJobDataMap().getLong("productId"); + productService.closePromotion(productId); + + try { + scheduler.deleteJob(context.getJobDetail().getKey()); + } catch (SchedulerException e) { + log.debug("Unable to delete current job"); + e.printStackTrace(); + } + } +} \ No newline at end of file diff --git a/products/src/main/java/br/com/ecommerce/products/infra/scheduling/jobs/StartPromotionJob.java b/products/src/main/java/br/com/ecommerce/products/infra/scheduling/jobs/StartPromotionJob.java new file mode 100644 index 0000000..6f67d53 --- /dev/null +++ b/products/src/main/java/br/com/ecommerce/products/infra/scheduling/jobs/StartPromotionJob.java @@ -0,0 +1,31 @@ +package br.com.ecommerce.products.infra.scheduling.jobs; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +import org.quartz.Job; +import org.quartz.JobExecutionContext; +import org.quartz.JobExecutionException; +import org.springframework.stereotype.Component; + +import br.com.ecommerce.products.business.service.ProductService; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@AllArgsConstructor +public class StartPromotionJob implements Job { + + private final ProductService productService; + + + public void execute(JobExecutionContext context) throws JobExecutionException { + log.debug("THE START OF THE PROMOTION HAS STARTED: {}", LocalDateTime.now()); + Long productId = context.getJobDetail().getJobDataMap().getLong("productId"); + String endPromotionString = context.getJobDetail().getJobDataMap().getString("endPromotion"); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm"); + LocalDateTime endPromotion = LocalDateTime.parse(endPromotionString, formatter); + productService.startPromotionImediatly(productId, endPromotion); + } +} \ No newline at end of file diff --git a/products/src/main/java/br/com/ecommerce/products/infra/scheduling/scheduler/PriceJobScheduler.java b/products/src/main/java/br/com/ecommerce/products/infra/scheduling/scheduler/PriceJobScheduler.java new file mode 100644 index 0000000..6040826 --- /dev/null +++ b/products/src/main/java/br/com/ecommerce/products/infra/scheduling/scheduler/PriceJobScheduler.java @@ -0,0 +1,117 @@ +package br.com.ecommerce.products.infra.scheduling.scheduler; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; + +import org.quartz.JobBuilder; +import org.quartz.JobDetail; +import org.quartz.JobKey; +import org.quartz.Scheduler; +import org.quartz.SchedulerException; +import org.quartz.Trigger; +import org.quartz.TriggerBuilder; +import org.springframework.stereotype.Service; + +import br.com.ecommerce.products.infra.scheduling.jobs.EndPromotionJob; +import br.com.ecommerce.products.infra.scheduling.jobs.StartPromotionJob; +import lombok.AllArgsConstructor; + +@Service +@AllArgsConstructor +public class PriceJobScheduler { + + private final Scheduler scheduler; + + + public void removeRedundantSchedulePromotion(JobKey target) { + try { + if (this.scheduler.checkExists(target)) { + try { + scheduler.deleteJob(target); + } catch (Exception ex) { + throw new RuntimeException("An error occurred while deleting job", ex); + } + } + } catch (SchedulerException ex) { + throw new RuntimeException("An error occurred while checking the existence of a job", ex); + } + } + + public void removeRedundantSchedulePromotion(Long productId, PromotionOperation operation) { + JobKey target = this.createJobKey(productId, operation); + this.removeRedundantSchedulePromotion(target); + } + + public void createSchedulerForPromotionStart(Long productId, LocalDateTime start, LocalDateTime end) { + PromotionOperation promotionOperation = PromotionOperation.START_PROMOTION; + JobKey target = this.createJobKey(productId, promotionOperation); + this.removeRedundantSchedulePromotion(target); + + JobDetail jobDetail = JobBuilder.newJob().ofType(StartPromotionJob.class) + .withIdentity(target.getName(), promotionOperation.groupName) + .usingJobData("productId", productId) + .usingJobData("endPromotion", end.toString()) + .storeDurably() + .build(); + + Trigger trigger = this.createTrigger(productId, start, promotionOperation); + this.createJob(jobDetail, trigger); + } + + public void createSchedulerForPromotionEnd(Long productId, LocalDateTime end) { + PromotionOperation operation = PromotionOperation.END_PROMOTION; + JobKey target = this.createJobKey(productId, operation); + this.removeRedundantSchedulePromotion(target); + + JobDetail jobDetail = JobBuilder.newJob().ofType(EndPromotionJob.class) + .withIdentity(target.getName(), operation.groupName) + .usingJobData("productId", productId) + .storeDurably() + .build(); + Trigger trigger = this.createTrigger(productId, end, operation); + + this.createJob(jobDetail, trigger); + } + + private JobKey createJobKey(Long productId, PromotionOperation promotionOperation) { + return new JobKey(promotionOperation.jobName + "_" + productId, promotionOperation.groupName); + } + + private Trigger createTrigger(Long productId, LocalDateTime date, PromotionOperation operation) { + return TriggerBuilder.newTrigger() + .withIdentity(operation.triggerName + "_" + productId, operation.groupName) + .startAt(Date.from(date.atZone(ZoneId.systemDefault()).toInstant())) + .build(); + } + + private void createJob(JobDetail jobDetail, Trigger trigger) { + try { + scheduler.scheduleJob(jobDetail, trigger); + + } catch (SchedulerException ex) { + throw new RuntimeException("Cannot possible create job", ex); + } + } + + public enum PromotionOperation { + START_PROMOTION( + "job_startPromotion", + "startPromotionGroup", + "startPromotionTrigger"), + END_PROMOTION( + "job_endPromotion", + "endPromotionGroup", + "endPromotionTrigger"); + + private String jobName; + private String groupName; + private String triggerName; + + PromotionOperation(String jobName, String jobGroup, String triggerName) { + this.jobName = jobName; + this.groupName = jobGroup; + this.triggerName = triggerName; + } + } +} \ No newline at end of file diff --git a/products/src/main/java/br/com/ecommerce/products/infra/scheduling/scheduler/PromotionSchedulerConfig.java b/products/src/main/java/br/com/ecommerce/products/infra/scheduling/scheduler/PromotionSchedulerConfig.java new file mode 100644 index 0000000..628a81f --- /dev/null +++ b/products/src/main/java/br/com/ecommerce/products/infra/scheduling/scheduler/PromotionSchedulerConfig.java @@ -0,0 +1,31 @@ +package br.com.ecommerce.products.infra.scheduling.scheduler; + +import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import br.com.ecommerce.products.business.service.PromotionService; +import jakarta.annotation.PostConstruct; +import lombok.AllArgsConstructor; + +@Profile("!test") +@Component +@EnableScheduling +@AllArgsConstructor +public class PromotionSchedulerConfig { + + private final PromotionService promotionService; + + + @Scheduled(cron = "0 0 * * * *") + public void scheduleStarOfPromotions() { + this.promotionService.createScheduleForPromotionsThatWillStart(); + } + + @PostConstruct + @Scheduled(cron = "0 0 * * * *") + public void scheduleEndOfPromotions() { + this.promotionService.createScheduleForPromotionsThatWillExpire(); + } +} \ No newline at end of file diff --git a/products/src/test/java/br/com/ecommerce/products/integration/api/controller/product/AdminProductControllerIntegrationTest.java b/products/src/test/java/br/com/ecommerce/products/integration/api/controller/product/AdminProductControllerIntegrationTest.java index b60df90..992f3d5 100644 --- a/products/src/test/java/br/com/ecommerce/products/integration/api/controller/product/AdminProductControllerIntegrationTest.java +++ b/products/src/test/java/br/com/ecommerce/products/integration/api/controller/product/AdminProductControllerIntegrationTest.java @@ -1,19 +1,24 @@ package br.com.ecommerce.products.integration.api.controller.product; +import static org.junit.Assert.assertNotEquals; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.List; import java.util.stream.Stream; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.json.JacksonTester; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.annotation.Rollback; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; @@ -24,6 +29,7 @@ import br.com.ecommerce.products.api.dto.product.CreateProductDTO; import br.com.ecommerce.products.api.dto.product.DataStockDTO; import br.com.ecommerce.products.api.dto.product.EndOfPromotionDTO; +import br.com.ecommerce.products.api.dto.product.SchedulePromotionDTO; import br.com.ecommerce.products.api.dto.product.UpdatePriceDTO; import br.com.ecommerce.products.api.dto.product.UpdateProductDTO; import br.com.ecommerce.products.infra.entity.category.Category; @@ -47,7 +53,9 @@ import br.com.ecommerce.products.utils.util.ProductUtils; import br.com.ecommerce.products.utils.util.RandomUtils; import br.com.ecommerce.products.utils.util.StockUtils; +import lombok.extern.slf4j.Slf4j; +@Slf4j @ControllerIntegrationTest class AdminProductControllerIntegrationTest { @@ -72,6 +80,8 @@ class AdminProductControllerIntegrationTest { private JacksonTester updatePriceDTOJson; @Autowired private JacksonTester endOfPromotionDTOJson; + @Autowired + private JacksonTester schedulePromotionDTOJson; @BeforeAll static void setup( @@ -405,16 +415,14 @@ void updateStockTest03_withUnauthorizedRoles() throws Exception { act.andExpect(status().isForbidden()); } + @Test @Rollback - @TestCustomWithMockUser(roles = {"ADMIN", "EMPLOYEE"}) + @WithMockUser(roles = "ADMIN") void updatePriceTest01_withValidValues() throws Exception { // arrange Long productId = 1L; String path = String.format("%s/%s/prices", basePath, productId); - var requestBody = new UpdatePriceDTO( - BigDecimal.valueOf(200), - BigDecimal.valueOf(100) - ); + var requestBody = new UpdatePriceDTO(BigDecimal.valueOf(200)); // act var requestMock = MockMvcRequestBuilders.put(path) @@ -427,21 +435,22 @@ void updatePriceTest01_withValidValues() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.id").isNumber()) .andExpect(jsonPath("$.name").isNotEmpty()) - .andExpect(jsonPath("$.price.originalPrice").value(requestBody.getOriginalPrice())) - .andExpect(jsonPath("$.price.promotionalPrice").value(requestBody.getPromotionalPrice())) - .andExpect(jsonPath("$.price.currentPrice").value(requestBody.getOriginalPrice())); + .andExpect(jsonPath("$.price.currentPrice").value(requestBody.getPrice())) + .andExpect(jsonPath("$.price.originalPrice").value(requestBody.getPrice())) + .andExpect(jsonPath("$.price.promotionalPrice").isEmpty()) + .andExpect(jsonPath("$.price.onPromotion").value(false)) + .andExpect(jsonPath("$.price.startPromotion").isEmpty()) + .andExpect(jsonPath("$.price.endPromotion").isEmpty()); } + @Test @Rollback - @TestCustomWithMockUser(roles = {"ADMIN", "EMPLOYEE"}) + @WithMockUser(roles = "ADMIN") void updatePriceTest02_withInvalidValues() throws Exception { // arrange Long productId = 1L; String path = String.format("%s/%s/prices", basePath, productId); - var requestBody = new UpdatePriceDTO( - null, - BigDecimal.valueOf(100) - ); + var requestBody = new UpdatePriceDTO(null); // act var requestMock = MockMvcRequestBuilders.put(path) @@ -453,16 +462,15 @@ void updatePriceTest02_withInvalidValues() throws Exception { act.andExpect(status().isBadRequest()); } + @Test @Rollback - @TestCustomWithMockUser(roles = {"ADMIN", "EMPLOYEE"}) + @WithMockUser(roles = "ADMIN") void updatePriceTest03_withBothValuesEqual() throws Exception { // arrange - Long productId = 1L; + Product productPersisted = productsPersisted.get(0); + Long productId = productPersisted.getId(); String path = String.format("%s/%s/prices", basePath, productId); - var requestBody = new UpdatePriceDTO( - BigDecimal.valueOf(100), - BigDecimal.valueOf(100) - ); + var requestBody = new UpdatePriceDTO(BigDecimal.valueOf(Math.negateExact(1))); // act var requestMock = MockMvcRequestBuilders.put(path) @@ -474,16 +482,18 @@ void updatePriceTest03_withBothValuesEqual() throws Exception { act.andExpect(status().isBadRequest()); } + @Test @Rollback - @TestCustomWithMockUser(roles = {"ADMIN", "EMPLOYEE"}) - void updatePriceTest04_withOriginalPriceLowerThanPromotionalPrice01() throws Exception { + @WithMockUser(roles = "ADMIN") + @DisplayName("Should not cause errors when setting the original price lower than the current promotional price") + void updatePriceTest04_withOriginalPriceLowerThanPromotionalPrice() throws Exception { // arrange - Long productId = 1L; - String path = String.format("%s/%s/prices", basePath, productId); - var requestBody = new UpdatePriceDTO( - BigDecimal.valueOf(99), - BigDecimal.valueOf(100) - ); + final Product productPersisted = productsPersisted.get(0); + final Long productId = productPersisted.getId(); + final String path = String.format("%s/%s/prices", basePath, productId); + + final BigDecimal newPrice = productPersisted.getPrice().getPromotionalPrice(); + final var requestBody = new UpdatePriceDTO(newPrice); // new price is the same as the promotional price // act var requestMock = MockMvcRequestBuilders.put(path) @@ -492,12 +502,20 @@ void updatePriceTest04_withOriginalPriceLowerThanPromotionalPrice01() throws Exc ResultActions act = mvc.perform(requestMock); // assert - act.andExpect(status().isBadRequest()); + act.andExpect(status().isOk()) + .andExpect(jsonPath("$.id").isNumber()) + .andExpect(jsonPath("$.name").isNotEmpty()) + .andExpect(jsonPath("$.price.currentPrice").value(requestBody.getPrice())) + .andExpect(jsonPath("$.price.originalPrice").value(requestBody.getPrice())) + .andExpect(jsonPath("$.price.promotionalPrice").isEmpty()) + .andExpect(jsonPath("$.price.onPromotion").value(false)) + .andExpect(jsonPath("$.price.startPromotion").isEmpty()) + .andExpect(jsonPath("$.price.endPromotion").isEmpty()); } @Rollback - @TestCustomWithMockUser(roles = {"CLIENT"}) - void updatePriceTest05_withOriginalPriceLowerThanPromotionalPrice() throws Exception { + @TestCustomWithMockUser(roles = {"ADMIN", "EMPLOYEE"}) + void updatePriceTest05_withAllAllowedRoles() throws Exception { // arrange Long productId = 1L; String path = String.format("%s/%s/prices", basePath, productId); @@ -508,7 +526,8 @@ void updatePriceTest05_withOriginalPriceLowerThanPromotionalPrice() throws Excep ResultActions act = mvc.perform(requestMock); // assert - act.andExpect(status().isForbidden()); + final var responseStatusCode = act.andReturn().getResponse().getStatus(); + assertNotEquals(403, responseStatusCode); } @Rollback @@ -527,43 +546,142 @@ void updatePriceTest06_withUnauthorizedRoles() throws Exception { act.andExpect(status().isForbidden()); } + @Test @Rollback - @TestCustomWithMockUser(roles = {"ADMIN", "EMPLOYEE"}) - void switchCurrentPriceToOriginalTest01() throws Exception { + @WithMockUser(roles = "ADMIN") + void updatePromotionalPriceTest01_withValidValues() throws Exception { // arrange - Product product = productsPersisted.get(0); - Long productId = product.getId(); - BigDecimal originalPrice = product.getPrice().getOriginalPrice(); - BigDecimal promotionalPrice = product.getPrice().getPromotionalPrice(); + final Product producPersisted = productsPersisted.get(0); + final var productId = producPersisted.getId(); + final var promotionalPrice = producPersisted.getPrice().getPromotionalPrice(); + final String path = String.format("%s/%s/prices/promotion", basePath, productId); + final var requestBody = new UpdatePriceDTO(promotionalPrice.divide(BigDecimal.valueOf(2))); - // act - String path = String.format("%s/%s/prices/switch-to-original", basePath, productId); var requestMock = MockMvcRequestBuilders.put(path) - .contentType(MediaType.APPLICATION_JSON); + .contentType(MediaType.APPLICATION_JSON) + .content(updatePriceDTOJson.write(requestBody).getJson()); ResultActions act = mvc.perform(requestMock); + final var expectedCurrentPrice = producPersisted.getPrice().getOriginalPrice().doubleValue(); + final var expectedOriginalPrice = producPersisted.getPrice().getOriginalPrice().doubleValue(); + final var expectedPromotionalPrice = requestBody.getPrice().doubleValue(); + // assert act .andExpect(status().isOk()) .andExpect(jsonPath("$.id").isNumber()) .andExpect(jsonPath("$.name").isNotEmpty()) - .andExpect(jsonPath("$.price.currentPrice").value(originalPrice.doubleValue())) - .andExpect(jsonPath("$.price.originalPrice").value(originalPrice.doubleValue())) - .andExpect(jsonPath("$.price.promotionalPrice").value(promotionalPrice.doubleValue())) + .andExpect(jsonPath("$.price.currentPrice").value(expectedCurrentPrice)) + .andExpect(jsonPath("$.price.originalPrice").value(expectedOriginalPrice)) + .andExpect(jsonPath("$.price.promotionalPrice").value(expectedPromotionalPrice)) .andExpect(jsonPath("$.price.onPromotion").value(false)) - .andExpect(jsonPath("$.price.endOfPromotion").isEmpty()); + .andExpect(jsonPath("$.price.startPromotion").isEmpty()) + .andExpect(jsonPath("$.price.endPromotion").isEmpty()); + } + + @Test + @Rollback + @WithMockUser(roles = "ADMIN") + void updatePromotionalPriceTest02_withPromotionalPriceGreaterThanOriginalPrice() throws Exception { + // arrange + final Product producPersisted = productsPersisted.get(0); + final var productId = producPersisted.getId(); + final var originalPrice = producPersisted.getPrice().getOriginalPrice(); + final String path = String.format("%s/%s/prices/promotion", basePath, productId); + final var requestBodyWithEqualOriginalPrice = new UpdatePriceDTO(originalPrice); + final var requestBodyWithGreaterOriginalPrice = new UpdatePriceDTO(originalPrice.add(BigDecimal.ONE)); + + // act + var requestMock_equalsOriginalPrice = MockMvcRequestBuilders.put(path) + .contentType(MediaType.APPLICATION_JSON) + .content(updatePriceDTOJson.write(requestBodyWithEqualOriginalPrice).getJson()); + ResultActions act_equalOriginalPrice = mvc.perform(requestMock_equalsOriginalPrice); + + var requestMock_greaterOriginalPrice = MockMvcRequestBuilders.put(path) + .contentType(MediaType.APPLICATION_JSON) + .content(updatePriceDTOJson.write(requestBodyWithGreaterOriginalPrice).getJson()); + ResultActions act_greaterOriginalPrice = mvc.perform(requestMock_greaterOriginalPrice); + + // assert + act_equalOriginalPrice.andExpect(status().isBadRequest()); + act_greaterOriginalPrice.andExpect(status().isBadRequest()); + } + + @Test + @Rollback + @WithMockUser(roles = "ADMIN") + void updatePromotionalPriceTest03_acceptsNullAsValue() throws Exception { + // arrange + final Product producPersisted = productsPersisted.get(0); + final var productId = producPersisted.getId(); + final String path = String.format("%s/%s/prices/promotion", basePath, productId); + final var requestBodyWithEqualOriginalPrice = new UpdatePriceDTO(null); + + // act + var requestMock_equalsOriginalPrice = MockMvcRequestBuilders.put(path) + .contentType(MediaType.APPLICATION_JSON) + .content(updatePriceDTOJson.write(requestBodyWithEqualOriginalPrice).getJson()); + ResultActions act_equalOriginalPrice = mvc.perform(requestMock_equalsOriginalPrice); + + // assert + act_equalOriginalPrice.andExpect(status().isOk()) + .andExpect(jsonPath("$.price.promotionalPrice").isEmpty()); + } + + @Test + @Rollback + @WithMockUser(roles = "ADMIN") + void updatePromotionalPriceTest04_withInvalidValues() throws Exception { + // arrange + final Product producPersisted = productsPersisted.get(0); + final var productId = producPersisted.getId(); + final var promotionalPrice = producPersisted.getPrice().getPromotionalPrice(); + final String path = String.format("%s/%s/prices/promotion", basePath, productId); + final var requestBody_withZero = new UpdatePriceDTO(BigDecimal.ZERO); // with zero + final var requestBody_withNegative = new UpdatePriceDTO(promotionalPrice.negate()); // with negative + + // act + var requestMock_equalsOriginalPrice = MockMvcRequestBuilders.put(path) + .contentType(MediaType.APPLICATION_JSON) + .content(updatePriceDTOJson.write(requestBody_withZero).getJson()); + ResultActions act_withZero = mvc.perform(requestMock_equalsOriginalPrice); + + var requestMock_greaterOriginalPrice = MockMvcRequestBuilders.put(path) + .contentType(MediaType.APPLICATION_JSON) + .content(updatePriceDTOJson.write(requestBody_withNegative).getJson()); + ResultActions act_withNegative = mvc.perform(requestMock_greaterOriginalPrice); + + // assert + act_withZero.andExpect(status().isBadRequest()); + act_withNegative.andExpect(status().isBadRequest()); + } + + @Rollback + @TestCustomWithMockUser(roles = {"ADMIN", "EMPLOYEE"}) + void updatePromotionalPriceTest05_withAllAllowedRoles() throws Exception { + // arrange + Long productId = 1L; + String path = String.format("%s/%s/prices", basePath, productId); + + // act + var requestMock = MockMvcRequestBuilders.put(path) + .contentType(MediaType.APPLICATION_JSON); + ResultActions act = mvc.perform(requestMock); + + // assert + final var responseStatusCode = act.andReturn().getResponse().getStatus(); + assertNotEquals(403, responseStatusCode); } @Rollback @TestCustomWithMockUser(roles = {"CLIENT"}) - void switchCurrentPriceToOriginalTest02_withUnauthorizedRoles() throws Exception { + void updatePromotionalPriceTest06_withUnauthorizedRoles() throws Exception { // arrange - Product product = productsPersisted.get(0); - Long productId = product.getId(); - + Long productId = 1L; + String path = String.format("%s/%s/prices", basePath, productId); + // act - String path = String.format("%s/%s/prices/switch-to-original", basePath, productId); var requestMock = MockMvcRequestBuilders.put(path) .contentType(MediaType.APPLICATION_JSON); ResultActions act = mvc.perform(requestMock); @@ -572,20 +690,21 @@ void switchCurrentPriceToOriginalTest02_withUnauthorizedRoles() throws Exception act.andExpect(status().isForbidden()); } + @Test @Rollback - @TestCustomWithMockUser(roles = {"ADMIN", "EMPLOYEE"}) - void switchCurrentPriceToPromotionalPriceTest01() throws Exception { + @WithMockUser(roles = "ADMIN") + void iniciatePromotionTest01() throws Exception { // arrange - Product product = productsPersisted.get(0); - Long productId = product.getId(); - BigDecimal originalPrice = product.getPrice().getOriginalPrice(); - BigDecimal promotionalPrice = product.getPrice().getPromotionalPrice(); + final Product product = productsPersisted.get(0); + final var productId = product.getId(); + final var originalPrice = product.getPrice().getOriginalPrice(); + final var promotionalPrice = product.getPrice().getPromotionalPrice(); - LocalDateTime date = LocalDateTime.now().plusDays(10).withSecond(00).withNano(0); - EndOfPromotionDTO requestBody = new EndOfPromotionDTO(date); + final LocalDateTime date = LocalDateTime.now().plusDays(10).withSecond(00).withNano(0); + final EndOfPromotionDTO requestBody = new EndOfPromotionDTO(date); // act - String path = String.format("%s/%s/prices/switch-to-promotional", basePath, productId); + String path = String.format("%s/%s/prices/promotion/start", basePath, productId); var requestMock = MockMvcRequestBuilders.put(path) .contentType(MediaType.APPLICATION_JSON) .content(endOfPromotionDTOJson.write(requestBody).getJson()); @@ -600,18 +719,213 @@ void switchCurrentPriceToPromotionalPriceTest01() throws Exception { .andExpect(jsonPath("$.price.promotionalPrice").value(promotionalPrice.doubleValue())) .andExpect(jsonPath("$.price.currentPrice").value(promotionalPrice.doubleValue())) .andExpect(jsonPath("$.price.onPromotion").value(true)) - .andExpect(jsonPath("$.price.endOfPromotion").value(requestBody.getEndOfPromotion().toString())); + .andExpect(jsonPath("$.price.startPromotion").isEmpty()) + .andExpect(jsonPath("$.price.endPromotion").value(requestBody.getEndPromotion().toString())); + } + + @Test + @Rollback + @WithMockUser(roles = "ADMIN") + void iniciatePromotionTest02_withPastDate() throws Exception { + // arrange + final Product product = productsPersisted.get(0); + final var productId = product.getId(); + + final LocalDateTime date = LocalDateTime.now().minusDays(10); + final EndOfPromotionDTO requestBody = new EndOfPromotionDTO(date); + + // act + String path = String.format("%s/%s/prices/promotion/start", basePath, productId); + var requestMock = MockMvcRequestBuilders.put(path) + .contentType(MediaType.APPLICATION_JSON) + .content(endOfPromotionDTOJson.write(requestBody).getJson()); + ResultActions act = mvc.perform(requestMock); + + // assert + act.andExpect(status().isBadRequest()); + } + + @Rollback + @TestCustomWithMockUser(roles = {"ADMIN", "EMPLOYEE"}) + void iniciatePromotionTest04_withAllAllowedRoles() throws Exception { + // arrange + Long productId = 1L; + String path = String.format("%s/%s/prices/promotion/start", basePath, productId); + + // act + var requestMock = MockMvcRequestBuilders.put(path) + .contentType(MediaType.APPLICATION_JSON); + ResultActions act = mvc.perform(requestMock); + + // assert + final var responseStatusCode = act.andReturn().getResponse().getStatus(); + assertNotEquals(403, responseStatusCode); + } + + @Rollback + @TestCustomWithMockUser(roles = {"CLIENT"}) + void iniciatePromotionTest05_withUnauthorizedRoles() throws Exception { + // arrange + Long productId = 1L; + String path = String.format("%s/%s/prices/promotion/start", basePath, productId); + + // act + var requestMock = MockMvcRequestBuilders.put(path) + .contentType(MediaType.APPLICATION_JSON); + ResultActions act = mvc.perform(requestMock); + + // assert + act.andExpect(status().isForbidden()); + } + + @Test + @Rollback + @WithMockUser(roles = "ADMIN") + void schedulePromotionTest01() throws Exception { + // arrange + final Product product = productsPersisted.get(0); + final var productId = product.getId(); + + final LocalDateTime start = LocalDateTime.now().plusDays(1).withSecond(00).withNano(0); + final LocalDateTime end = LocalDateTime.now().plusDays(10).withSecond(00).withNano(0); + final SchedulePromotionDTO requestBody = new SchedulePromotionDTO(start, end); + + // act + final String path = String.format("%s/%s/prices/promotion/schedule", basePath, productId); + var requestMock = MockMvcRequestBuilders.put(path) + .contentType(MediaType.APPLICATION_JSON) + .content(schedulePromotionDTOJson.write(requestBody).getJson()); + ResultActions act = mvc.perform(requestMock); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"); + + // assert + act + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").isNumber()) + .andExpect(jsonPath("$.name").isNotEmpty()) + .andExpect(jsonPath("$.price.onPromotion").value(false)) + .andExpect(jsonPath("$.price.startPromotion").value(start.format(formatter))) + .andExpect(jsonPath("$.price.endPromotion").value(end.format(formatter))); + } + + @Test + @Rollback + @WithMockUser(roles = "ADMIN") + void schedulePromotionTest01_withPastDates() throws Exception { + // arrange + final Product product = productsPersisted.get(0); + final var productId = product.getId(); + + final LocalDateTime pastDate = LocalDateTime.now().minusDays(1); + final LocalDateTime validDate = LocalDateTime.now().plusDays(1).withSecond(00).withNano(0); + final SchedulePromotionDTO requestBody_1 = new SchedulePromotionDTO(validDate, pastDate); + final SchedulePromotionDTO requestBody_2 = new SchedulePromotionDTO(pastDate, validDate); + + // act + final String path = String.format("%s/%s/prices/promotion/schedule", basePath, productId); + var requestMock_1 = MockMvcRequestBuilders.put(path) + .contentType(MediaType.APPLICATION_JSON) + .content(schedulePromotionDTOJson.write(requestBody_1).getJson()); + var requestMock_2 = MockMvcRequestBuilders.put(path) + .contentType(MediaType.APPLICATION_JSON) + .content(schedulePromotionDTOJson.write(requestBody_2).getJson()); + ResultActions act_1 = mvc.perform(requestMock_1); + ResultActions act_2 = mvc.perform(requestMock_2); + + // assert + act_1.andExpect(status().isBadRequest()); + act_2.andExpect(status().isBadRequest()); + } + + @Rollback + @TestCustomWithMockUser(roles = {"ADMIN", "EMPLOYEE"}) + void schedulePromotionTest03_withAllAllowedRoles() throws Exception { + // arrange + Long productId = 1L; + String path = String.format("%s/%s/prices/promotion/schedule", basePath, productId); + + // act + var requestMock = MockMvcRequestBuilders.put(path) + .contentType(MediaType.APPLICATION_JSON); + ResultActions act = mvc.perform(requestMock); + + // assert + final var responseStatusCode = act.andReturn().getResponse().getStatus(); + assertNotEquals(403, responseStatusCode); } @Rollback @TestCustomWithMockUser(roles = {"CLIENT"}) - void switchCurrentPriceToPromotionalPriceTest02_withUnauthorizedRoles() throws Exception { + void schedulePromotionTest04_withUnauthorizedRoles() throws Exception { + // arrange + Long productId = 1L; + String path = String.format("%s/%s/prices/promotion/schedule", basePath, productId); + + // act + var requestMock = MockMvcRequestBuilders.put(path) + .contentType(MediaType.APPLICATION_JSON); + ResultActions act = mvc.perform(requestMock); + + // assert + act.andExpect(status().isForbidden()); + } + + @Test + @Rollback + @WithMockUser(roles = "ADMIN") + void finalizePromotionTest01() throws Exception { + // arrange + final Product product = productsPersisted.get(0); + final var productId = product.getId(); + final var originalPrice = product.getPrice().getOriginalPrice(); + final var promotionalPrice = product.getPrice().getPromotionalPrice(); + + // act + String path = String.format("%s/%s/prices/promotion/end", basePath, productId); + var requestMock = MockMvcRequestBuilders.put(path) + .contentType(MediaType.APPLICATION_JSON); + ResultActions act = mvc.perform(requestMock); + + // assert + act + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").isNumber()) + .andExpect(jsonPath("$.name").isNotEmpty()) + .andExpect(jsonPath("$.price.currentPrice").value(originalPrice.doubleValue())) + .andExpect(jsonPath("$.price.originalPrice").value(originalPrice.doubleValue())) + .andExpect(jsonPath("$.price.promotionalPrice").value(promotionalPrice.doubleValue())) + .andExpect(jsonPath("$.price.onPromotion").value(false)) + .andExpect(jsonPath("$.price.startPromotion").isEmpty()) + .andExpect(jsonPath("$.price.endPromotion").isEmpty()); + } + + @Rollback + @TestCustomWithMockUser(roles = {"ADMIN", "EMPLOYEE"}) + void finalizePromotionTest02_withUnauthorizedRoles() throws Exception { // arrange Product product = productsPersisted.get(0); Long productId = product.getId(); + + // act + String path = String.format("%s/%s/prices/promotion/end", basePath, productId); + var requestMock = MockMvcRequestBuilders.put(path) + .contentType(MediaType.APPLICATION_JSON); + ResultActions act = mvc.perform(requestMock); + + // assert + final var responseStatusCode = act.andReturn().getResponse().getStatus(); + assertNotEquals(403, responseStatusCode); + } + @Rollback + @TestCustomWithMockUser(roles = {"CLIENT"}) + void finalizePromotionTest03_withUnauthorizedRoles() throws Exception { + // arrange + Product product = productsPersisted.get(0); + Long productId = product.getId(); + // act - String path = String.format("%s/%s/prices/switch-to-promotional", basePath, productId); + String path = String.format("%s/%s/prices/promotion/end", basePath, productId); var requestMock = MockMvcRequestBuilders.put(path) .contentType(MediaType.APPLICATION_JSON); ResultActions act = mvc.perform(requestMock); diff --git a/products/src/test/java/br/com/ecommerce/products/unit/business/service/ProductServiceUnitTest.java b/products/src/test/java/br/com/ecommerce/products/unit/business/service/ProductServiceUnitTest.java index 1cea957..eff4722 100644 --- a/products/src/test/java/br/com/ecommerce/products/unit/business/service/ProductServiceUnitTest.java +++ b/products/src/test/java/br/com/ecommerce/products/unit/business/service/ProductServiceUnitTest.java @@ -1,11 +1,12 @@ package br.com.ecommerce.products.unit.business.service; +import static org.junit.Assert.assertNull; import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anySet; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -23,20 +24,21 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.quartz.Scheduler; -import br.com.ecommerce.products.api.dto.product.CompletePriceDataDTO; import br.com.ecommerce.products.api.dto.product.DataStockDTO; import br.com.ecommerce.products.api.dto.product.StockWriteOffDTO; import br.com.ecommerce.products.api.dto.product.UpdatePriceDTO; import br.com.ecommerce.products.api.dto.product.UpdateProductDTO; import br.com.ecommerce.products.api.dto.product.UpdateProductPriceResponseDTO; import br.com.ecommerce.products.api.dto.product.UpdateProductResponseDTO; +import br.com.ecommerce.products.api.dto.product.UpdatePromotionalPriceDTO; import br.com.ecommerce.products.api.mapper.PriceMapper; import br.com.ecommerce.products.api.mapper.ProductMapper; import br.com.ecommerce.products.api.mapper.StockMapper; import br.com.ecommerce.products.api.mapper.factory.ProductDTOFactory; -import br.com.ecommerce.products.business.service.PriceJobService; import br.com.ecommerce.products.business.service.ProductService; +import br.com.ecommerce.products.business.service.PromotionService; import br.com.ecommerce.products.business.validator.UniqueNameProductValidator; import br.com.ecommerce.products.infra.entity.product.Price; import br.com.ecommerce.products.infra.entity.product.Product; @@ -45,6 +47,7 @@ import br.com.ecommerce.products.infra.repository.CategoryRepository; import br.com.ecommerce.products.infra.repository.ManufacturerRepository; import br.com.ecommerce.products.infra.repository.ProductRepository; +import br.com.ecommerce.products.infra.scheduling.scheduler.PriceJobScheduler; import br.com.ecommerce.products.utils.builder.ProductTestBuilder; @ExtendWith(MockitoExtension.class) @@ -60,6 +63,11 @@ class ProductServiceUnitTest { private CategoryRepository categoryRepository; @Mock private ManufacturerRepository manufacturerRepository; + @Mock + private Scheduler scheduler; + @Mock + private PromotionService promotionService; + @Mock private ProductDTOFactory dtoFactory; @@ -75,7 +83,7 @@ class ProductServiceUnitTest { private UniqueNameProductValidator uniqueNameValidator; @Mock - private PriceJobService priceScheduler; + private PriceJobScheduler priceScheduler; @InjectMocks private ProductService service; @@ -140,7 +148,8 @@ void updateProductDataTest02() { @DisplayName("Unit - updateProductPrice - Must update product price by product ID") void updateProductPriceTest() { // arrange - Price price = new Price(BigDecimal.valueOf(50), BigDecimal.valueOf(25)); + Price price = new Price(BigDecimal.valueOf(50)); + price.setPromotionalPrice(BigDecimal.valueOf(25)); Product product = new ProductTestBuilder() .price(price) .build(); @@ -148,9 +157,8 @@ void updateProductPriceTest() { when(repository.findById(anyLong())) .thenReturn(Optional.of(product)); - Price newPrice = new Price(BigDecimal.valueOf(100), BigDecimal.valueOf(50)); - UpdatePriceDTO requestBody = new UpdatePriceDTO( - newPrice.getOriginalPrice(), newPrice.getPromotionalPrice()); + Price newPrice = new Price(BigDecimal.valueOf(100)); + UpdatePriceDTO requestBody = new UpdatePriceDTO(newPrice.getOriginalPrice()); when(priceMapper.toPrice(eq(requestBody))) .thenReturn(newPrice); @@ -158,9 +166,8 @@ void updateProductPriceTest() { when(repository.save(eq(product))) .thenReturn(product); - UpdateProductPriceResponseDTO response = new UpdateProductPriceResponseDTO(); when(productMapper.toUpdateProductPriceResponseDTO(eq(product), any())) - .thenReturn(response); + .thenReturn(new UpdateProductPriceResponseDTO()); // act service.updateProductPrice(1L, requestBody); @@ -170,56 +177,56 @@ void updateProductPriceTest() { .toUpdateProductPriceResponseDTO(productCaptor.capture(), any()); Price result = productCaptor.getValue().getPrice(); - assertEquals(requestBody.getOriginalPrice(), result.getOriginalPrice()); - assertEquals(requestBody.getOriginalPrice(), result.getCurrentPrice()); + assertEquals(requestBody.getPrice(), result.getOriginalPrice()); + assertEquals(requestBody.getPrice(), result.getCurrentPrice()); + assertNull(result.getPromotionalPrice()); + assertNull(result.getStartPromotion()); + assertNull(result.getEndPromotion()); } @Test - @DisplayName("Unit - updateProductPrice - Must update product price by product ID") - void switchCurrentPriceToOriginalPriceTest() { + @DisplayName("Unit - updateProductPricePromotional - Must update product `promotionalPrice`") + void updateProductPricePromotional() { // arrange - Price price = new Price(BigDecimal.valueOf(50), BigDecimal.valueOf(25)); + Price price = new Price(BigDecimal.valueOf(50)); Product product = new ProductTestBuilder() .price(price) .build(); - + when(repository.findById(anyLong())) .thenReturn(Optional.of(product)); - + + UpdatePromotionalPriceDTO requestBody = new UpdatePromotionalPriceDTO(BigDecimal.valueOf(price.getCurrentPrice().intValue() / 2)); + when(repository.save(eq(product))) .thenReturn(product); - - Price productPrice = product.getPrice(); -; - var response = new UpdateProductPriceResponseDTO( - product.getId(), - product.getName(), - new CompletePriceDataDTO( - productPrice.getCurrentPrice(), - productPrice.getOriginalPrice(), - productPrice.getPromotionalPrice(), - productPrice.isOnPromotion(), - productPrice.getEndOfPromotion()) - ); + + UpdateProductPriceResponseDTO response = new UpdateProductPriceResponseDTO(); when(productMapper.toUpdateProductPriceResponseDTO(eq(product), any())) .thenReturn(response); + final BigDecimal expectedCurrentPrice = price.getOriginalPrice(); + final BigDecimal expectedOriginalPrice = expectedCurrentPrice; + final BigDecimal expectedPromotionalPrice = requestBody.getPrice(); + // act - service.switchCurrentPriceToOriginal(1L); + service.updateProductPricePromotional(1L, requestBody); // assert - verify(productMapper) - .toUpdateProductPriceResponseDTO(productCaptor.capture(), any()); + verify(repository).save(productCaptor.capture()); Price result = productCaptor.getValue().getPrice(); - assertEquals(price.getOriginalPrice(), result.getCurrentPrice()); + assertEquals(expectedCurrentPrice, result.getCurrentPrice()); + assertEquals(expectedOriginalPrice, result.getOriginalPrice()); + assertEquals(expectedPromotionalPrice, result.getPromotionalPrice()); } @Test - @DisplayName("Unit - updateProductPrice - Must update product price by product ID") - void switchCurrentPriceToPromotionalPriceTest() { + @DisplayName("Unit - startPromotionImediatly") + void startPromotionImediatlyTest() { // arrange - Price price = new Price(BigDecimal.valueOf(50), BigDecimal.valueOf(25)); + Price price = new Price(BigDecimal.valueOf(50)); + price.setPromotionalPrice(BigDecimal.valueOf(25)); Product product = new ProductTestBuilder() .price(price) .build(); @@ -231,29 +238,64 @@ void switchCurrentPriceToPromotionalPriceTest() { when(repository.save(eq(product))) .thenReturn(product); - Price productPrice = product.getPrice(); - var response = new UpdateProductPriceResponseDTO( - product.getId(), - product.getName(), - new CompletePriceDataDTO( - productPrice.getCurrentPrice(), - productPrice.getOriginalPrice(), - productPrice.getPromotionalPrice(), - productPrice.isOnPromotion(), - productPrice.getEndOfPromotion()) - ); when(productMapper.toUpdateProductPriceResponseDTO(eq(product), any())) - .thenReturn(response); + .thenReturn(new UpdateProductPriceResponseDTO()); // act - service.switchCurrentPriceToPromotional(1L, endOfPromotion); + service.startPromotionImediatly(1L, endOfPromotion); + verify(productMapper) + .toUpdateProductPriceResponseDTO(productCaptor.capture(), any()); + Price result = productCaptor.getValue().getPrice(); + + final var expectedCurrentPrice = price.getPromotionalPrice(); + final var expectedOriginalPrice = price.getOriginalPrice(); + final var expectedPromotionalPrice = price.getPromotionalPrice();; // assert + assertEquals(expectedCurrentPrice, result.getCurrentPrice()); + assertEquals(expectedOriginalPrice, result.getOriginalPrice()); + assertEquals(expectedPromotionalPrice, result.getPromotionalPrice()); + assertEquals(endOfPromotion, result.getEndPromotion()); + assertNull(result.getStartPromotion()); + } + + @Test + @DisplayName("Unit - endPromotion - End of promotion period") + void endPromotionTest() { + // arrange + Price price = new Price(BigDecimal.valueOf(50)); + price.setPromotionalPrice(BigDecimal.valueOf(25)); + Product product = new ProductTestBuilder() + .price(price) + .build(); + + when(repository.findById(anyLong())) + .thenReturn(Optional.of(product)); + + when(repository.save(eq(product))) + .thenReturn(product); + + when(productMapper.toUpdateProductPriceResponseDTO(eq(product), any())) + .thenReturn(new UpdateProductPriceResponseDTO()); + + // act + service.closePromotion(1L); verify(productMapper) .toUpdateProductPriceResponseDTO(productCaptor.capture(), any()); Price result = productCaptor.getValue().getPrice(); - assertEquals(price.getPromotionalPrice(), result.getCurrentPrice()); + final var expectedCurrentPrice = price.getOriginalPrice(); + final var expectedOriginalPrice = price.getOriginalPrice(); + final var expectedPromotionalPrice = price.getPromotionalPrice(); + + // assert + assertEquals(price.getOriginalPrice(), result.getCurrentPrice()); + + assertEquals(expectedCurrentPrice, result.getCurrentPrice()); + assertEquals(expectedOriginalPrice, result.getOriginalPrice()); + assertEquals(expectedPromotionalPrice, result.getPromotionalPrice()); + assertNull(result.getEndPromotion()); + assertNull(result.getStartPromotion()); } @Test @@ -290,29 +332,40 @@ void updateStockByProductIdTest01() { @DisplayName("Unit - updateStocks - Must update stocks of multiple products") void updateStocksTes01() { // arrange - List products = List.of( + List products_1 = List.of( new ProductTestBuilder().id(1L).stock(new Stock(100)).build(), new ProductTestBuilder().id(2L).stock(new Stock(200)).build(), new ProductTestBuilder().id(3L).stock(new Stock(300)).build() ); + List products_2 = products_1.stream().toList(); - List stockWriteOff = List.of( + List stockWriteOff_1 = List.of( new StockWriteOffDTO(1L, 100), new StockWriteOffDTO(2L, 200), new StockWriteOffDTO(3L, 300) ); + List stockWriteOff_2 = stockWriteOff_1.stream() + .map(s -> new StockWriteOffDTO(s.getProductId(), Math.negateExact(s.getUnit()))) + .toList(); - when(repository.findAllById(anyList())) - .thenReturn(products); + when(repository.findAllById(anySet())).thenReturn(products_1); + when(repository.findAllById(anySet())).thenReturn(products_2); // act - service.updateStocks(stockWriteOff); + service.updateStocks(stockWriteOff_1); + service.updateStocks(stockWriteOff_2); + final int ZERO = 0; // assert assertAll( - () -> assertEquals(0, products.get(0).getStock().getUnit()), - () -> assertEquals(0, products.get(1).getStock().getUnit()), - () -> assertEquals(0, products.get(2).getStock().getUnit()) + () -> assertEquals(ZERO, products_1.get(0).getStock().getUnit()), + () -> assertEquals(ZERO, products_1.get(1).getStock().getUnit()), + () -> assertEquals(ZERO, products_1.get(2).getStock().getUnit()) + ); + assertAll( + () -> assertEquals(ZERO, products_2.get(0).getStock().getUnit()), + () -> assertEquals(ZERO, products_2.get(1).getStock().getUnit()), + () -> assertEquals(ZERO, products_2.get(2).getStock().getUnit()) ); } } \ No newline at end of file diff --git a/products/src/test/java/br/com/ecommerce/products/unit/infra/entity/product/PriceTest.java b/products/src/test/java/br/com/ecommerce/products/unit/infra/entity/product/PriceTest.java index 08b8218..bf0b5c8 100644 --- a/products/src/test/java/br/com/ecommerce/products/unit/infra/entity/product/PriceTest.java +++ b/products/src/test/java/br/com/ecommerce/products/unit/infra/entity/product/PriceTest.java @@ -25,7 +25,7 @@ void testCreatePrice_withValidValue() { Price price = new Price(positiveOriginalPrice); assertEquals(positiveOriginalPrice, price.getOriginalPrice()); assertEquals(expectedCurrentPrice, price.getCurrentPrice()); - assertEquals(expectedEndPromotion, price.getEndOfPromotion()); + assertEquals(expectedEndPromotion, price.getEndPromotion()); assertFalse(price.isOnPromotion()); }); } @@ -37,7 +37,8 @@ void testCreatePrice_withValidValues() { final BigDecimal expectedCurrentPrice = positiveOriginalPrice; assertDoesNotThrow(() -> { - Price price = new Price(positiveOriginalPrice, positivePromotionalPrice); + Price price = new Price(positiveOriginalPrice); + price.setPromotionalPrice(positivePromotionalPrice); assertEquals(positiveOriginalPrice, price.getOriginalPrice()); assertEquals(positivePromotionalPrice, price.getPromotionalPrice()); assertEquals(expectedCurrentPrice, price.getCurrentPrice()); @@ -49,30 +50,28 @@ void testCreatePrice_onlyWithOriginalPriceValid() { final BigDecimal positiveOriginalPrice = BigDecimal.valueOf(50); assertDoesNotThrow(() -> { - Price price = new Price(positiveOriginalPrice, null); + Price price = new Price(positiveOriginalPrice); assertEquals(positiveOriginalPrice, price.getOriginalPrice()); }); } @Test - void testCreatePrice_onlyWithOriginalPriceNull() { - final BigDecimal nullValue = BigDecimal.valueOf(0); + void testCreatePrice_withOriginalPriceEqualsZeroAndLowerThanZero() { + // arrange + final BigDecimal nullValue = null; + final BigDecimal zero = BigDecimal.ZERO; + final BigDecimal negative = BigDecimal.valueOf(50).negate(); + + // assert assertThrows( IllegalArgumentException.class, - () -> new Price(nullValue, null)); - } - - @Test - void testCreatePrice_withOriginalPriceNegative() { - final BigDecimal negative = BigDecimal.valueOf(-50); + () -> new Price(nullValue)); assertThrows( IllegalArgumentException.class, - () -> new Price(negative, null)); - - final BigDecimal zero = BigDecimal.valueOf(0); + () -> new Price(zero)); assertThrows( IllegalArgumentException.class, - () -> new Price(zero, null)); + () -> new Price(negative)); } @Test @@ -83,28 +82,115 @@ void testCreatePrice_withPromotionalEqualsOrGreaterThanOriginalPrice() { assertThrows( IllegalArgumentException.class, - () -> new Price(original, promotionalEquals)); + () -> { + Price price = new Price(original); + price.setPromotionalPrice(promotionalEquals); + }); assertThrows( IllegalArgumentException.class, - () -> new Price(original, promotionalGreaterThan)); + () -> { + Price price = new Price(original); + price.setPromotionalPrice(promotionalGreaterThan); + }); } @Test - void testSwitchCurrentPrice() { + void testSetPromotionalPrice() { + // arrange + final Price price = new Price(BigDecimal.TEN); + final var originalPrice = price.getOriginalPrice(); + + // act and assert + final var greaterThanOriginalPrice = originalPrice.multiply(BigDecimal.TEN); + + assertDoesNotThrow(() -> price.setPromotionalPrice(null)); // accepts null + + assertThrows( + IllegalArgumentException.class, + () -> price.setPromotionalPrice(BigDecimal.ZERO)); + + assertThrows( + IllegalArgumentException.class, + () -> price.setPromotionalPrice(BigDecimal.TEN.negate())); + + assertThrows( + IllegalArgumentException.class, + () -> price.setPromotionalPrice(greaterThanOriginalPrice)); + } + + @Test + void testSetStartPromotion() { + // arrange + final Price price = new Price(BigDecimal.TEN); + + // act and assert + final var pastDate = LocalDateTime.now().minusDays(1); + assertThrows(IllegalArgumentException.class, () -> price.setStartPromotion(pastDate)); + + // the product does not have a defined promotionalPrice + final var validDate = LocalDateTime.now().plusDays(1); + assertThrows(IllegalArgumentException.class, () -> price.setStartPromotion(validDate)); + } + + @Test + void testSetEndPromotion() { + // arrange + final Price price = new Price(BigDecimal.TEN); + + // act and assert + final var pastDate = LocalDateTime.now().minusDays(1); + assertThrows(IllegalArgumentException.class, () -> price.setStartPromotion(pastDate)); + } + + @Test + void testInitiateAPromotion01() { + // arrange final BigDecimal original = BigDecimal.valueOf(50); final BigDecimal promotional = original.divide(BigDecimal.valueOf(2)); - final Price price = new Price(original, promotional); final LocalDateTime endOfPromotion = LocalDateTime.now().plusDays(1); + final Price price = new Price(original); + price.setPromotionalPrice(promotional); - price.currentToPromotional(endOfPromotion); + // act + price.setEndPromotion(endOfPromotion); + price.initiateAPromotion(); + + // assert assertEquals(price.getPromotionalPrice(), price.getCurrentPrice()); - assertEquals(endOfPromotion, price.getEndOfPromotion()); + assertEquals(endOfPromotion, price.getEndPromotion()); assertTrue(price.isOnPromotion()); + } + + @Test + void testInitiateAPromotion02_withoutAPromotionalPrice() { + // arrange + final BigDecimal original = BigDecimal.valueOf(50); + final LocalDateTime endOfPromotion = LocalDateTime.now().plusDays(1); + final Price price = new Price(original); + + // act + price.setEndPromotion(endOfPromotion); + assertThrows(IllegalArgumentException.class, () -> price.initiateAPromotion()); + } + + @Test + void testClosePromotion() { + // arrange + final BigDecimal original = BigDecimal.valueOf(50); + final BigDecimal promotional = original.divide(BigDecimal.valueOf(2)); + final LocalDateTime endOfPromotion = LocalDateTime.now().plusDays(1); + final Price price = new Price(original); + price.setPromotionalPrice(promotional); - price.currentToOriginal(); + // act + price.setEndPromotion(endOfPromotion); + price.initiateAPromotion(); + price.closePromotion(); + + // assert assertEquals(price.getOriginalPrice(), price.getCurrentPrice()); - assertEquals(null, price.getEndOfPromotion()); + assertEquals(null, price.getEndPromotion()); assertFalse(price.isOnPromotion()); } } \ No newline at end of file