From f0c354958cc63b84b647a148f4c47f43937f3ede Mon Sep 17 00:00:00 2001 From: Mohamed Hassan Date: Sat, 3 May 2025 04:37:03 +0300 Subject: [PATCH 1/8] feat: Implement profit by category report endpoint --- .../api/DTOs/ProfitByCategoryDTO.java | 20 +++++++++ .../controllers/ProfitReportController.java | 15 +++++++ .../repositories/SalesLineItemRepository.java | 16 +++++++ .../services/ProfitAnalyticsService.java | 44 +++++++++++++++++++ 4 files changed, 95 insertions(+) create mode 100644 src/main/java/com/Podzilla/analytics/api/DTOs/ProfitByCategoryDTO.java diff --git a/src/main/java/com/Podzilla/analytics/api/DTOs/ProfitByCategoryDTO.java b/src/main/java/com/Podzilla/analytics/api/DTOs/ProfitByCategoryDTO.java new file mode 100644 index 0000000..54231c4 --- /dev/null +++ b/src/main/java/com/Podzilla/analytics/api/DTOs/ProfitByCategoryDTO.java @@ -0,0 +1,20 @@ +package com.Podzilla.analytics.api.DTOs; + +import java.math.BigDecimal; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ProfitByCategoryDTO { + private String category; + private BigDecimal totalRevenue; + private BigDecimal totalCost; + private BigDecimal grossProfit; + private BigDecimal grossProfitMargin; +} \ No newline at end of file diff --git a/src/main/java/com/Podzilla/analytics/api/controllers/ProfitReportController.java b/src/main/java/com/Podzilla/analytics/api/controllers/ProfitReportController.java index 15485be..672a0b8 100644 --- a/src/main/java/com/Podzilla/analytics/api/controllers/ProfitReportController.java +++ b/src/main/java/com/Podzilla/analytics/api/controllers/ProfitReportController.java @@ -1,14 +1,29 @@ package com.Podzilla.analytics.api.controllers; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import com.Podzilla.analytics.api.DTOs.ProfitByCategoryDTO; import com.Podzilla.analytics.services.ProfitAnalyticsService; import lombok.RequiredArgsConstructor; +import java.time.LocalDate; +import java.util.List; + @RequiredArgsConstructor @RestController @RequestMapping("profit") public class ProfitReportController { private final ProfitAnalyticsService profitAnalyticsService; + + @GetMapping("/by-category") + public ResponseEntity> getProfitByCategory( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) { + + List profitData = profitAnalyticsService.getProfitByCategory(startDate, endDate); + return ResponseEntity.ok(profitData); + } } \ No newline at end of file diff --git a/src/main/java/com/Podzilla/analytics/repositories/SalesLineItemRepository.java b/src/main/java/com/Podzilla/analytics/repositories/SalesLineItemRepository.java index 0a0f331..f8d31bb 100644 --- a/src/main/java/com/Podzilla/analytics/repositories/SalesLineItemRepository.java +++ b/src/main/java/com/Podzilla/analytics/repositories/SalesLineItemRepository.java @@ -1,8 +1,24 @@ package com.Podzilla.analytics.repositories; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.Podzilla.analytics.models.SalesLineItem; +import java.time.LocalDateTime; +import java.util.List; + public interface SalesLineItemRepository extends JpaRepository { + + @Query("SELECT sli.product.category as category, " + + "SUM(sli.quantity * sli.pricePerUnit) as totalRevenue, " + + "SUM(sli.quantity * sli.product.cost) as totalCost " + + "FROM SalesLineItem sli " + + "WHERE sli.order.orderPlacedTimestamp BETWEEN :startDate AND :endDate " + + "AND sli.order.status = 'COMPLETED' " + + "GROUP BY sli.product.category") + List findSalesByCategoryBetweenDates( + @Param("startDate") LocalDateTime startDate, + @Param("endDate") LocalDateTime endDate); } \ No newline at end of file diff --git a/src/main/java/com/Podzilla/analytics/services/ProfitAnalyticsService.java b/src/main/java/com/Podzilla/analytics/services/ProfitAnalyticsService.java index 8d2d603..c9f335c 100644 --- a/src/main/java/com/Podzilla/analytics/services/ProfitAnalyticsService.java +++ b/src/main/java/com/Podzilla/analytics/services/ProfitAnalyticsService.java @@ -2,9 +2,53 @@ import org.springframework.stereotype.Service; +import com.Podzilla.analytics.api.DTOs.ProfitByCategoryDTO; +import com.Podzilla.analytics.repositories.SalesLineItemRepository; + import lombok.RequiredArgsConstructor; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; + @RequiredArgsConstructor @Service public class ProfitAnalyticsService { + private final SalesLineItemRepository salesLineItemRepository; + + public List getProfitByCategory(LocalDate startDate, LocalDate endDate) { + // Convert LocalDate to LocalDateTime for start of day and end of day + LocalDateTime startDateTime = startDate.atStartOfDay(); + LocalDateTime endDateTime = endDate.atTime(LocalTime.MAX); + + List salesData = salesLineItemRepository.findSalesByCategoryBetweenDates(startDateTime, endDateTime); + List result = new ArrayList<>(); + + for (Object[] data : salesData) { + String category = (String) data[0]; + BigDecimal totalRevenue = (BigDecimal) data[1]; + BigDecimal totalCost = (BigDecimal) data[2]; + BigDecimal grossProfit = totalRevenue.subtract(totalCost); + + BigDecimal grossProfitMargin = BigDecimal.ZERO; + if (totalRevenue.compareTo(BigDecimal.ZERO) > 0) { + grossProfitMargin = grossProfit.divide(totalRevenue, 4, RoundingMode.HALF_UP) + .multiply(new BigDecimal("100")); + } + + result.add(ProfitByCategoryDTO.builder() + .category(category) + .totalRevenue(totalRevenue) + .totalCost(totalCost) + .grossProfit(grossProfit) + .grossProfitMargin(grossProfitMargin) + .build()); + } + + return result; + } } \ No newline at end of file From 8c9d5bff8440f31fb7bbb70a54eca5ebd6063fa0 Mon Sep 17 00:00:00 2001 From: Mohamed Hassan Date: Sun, 11 May 2025 02:44:00 +0300 Subject: [PATCH 2/8] refactor: Update controller annotations and enhance Order model with JPA annotations --- .../FulfillmentReportController.java | 6 ++++-- .../api/controllers/OrderReportController.java | 5 +++-- .../com/Podzilla/analytics/models/Order.java | 18 ++++++++++++++++-- .../repositories/OrderRepository.java | 2 +- .../services/FulfillmentAnalyticsService.java | 2 +- .../services/OrderAnalyticsService.java | 2 +- 6 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/Podzilla/analytics/api/controllers/FulfillmentReportController.java b/src/main/java/com/Podzilla/analytics/api/controllers/FulfillmentReportController.java index 8f518ea..b3d6357 100644 --- a/src/main/java/com/Podzilla/analytics/api/controllers/FulfillmentReportController.java +++ b/src/main/java/com/Podzilla/analytics/api/controllers/FulfillmentReportController.java @@ -1,6 +1,8 @@ package com.Podzilla.analytics.api.controllers; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + import com.Podzilla.analytics.services.FulfillmentAnalyticsService; @@ -11,4 +13,4 @@ @RequestMapping("fulfillment") public class FulfillmentReportController { private final FulfillmentAnalyticsService fulfillmentAnalyticsService; -} \ No newline at end of file +} diff --git a/src/main/java/com/Podzilla/analytics/api/controllers/OrderReportController.java b/src/main/java/com/Podzilla/analytics/api/controllers/OrderReportController.java index 8110b47..645f676 100644 --- a/src/main/java/com/Podzilla/analytics/api/controllers/OrderReportController.java +++ b/src/main/java/com/Podzilla/analytics/api/controllers/OrderReportController.java @@ -1,6 +1,7 @@ package com.Podzilla.analytics.api.controllers; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; import com.Podzilla.analytics.services.OrderAnalyticsService; @@ -11,4 +12,4 @@ @RequestMapping("/orders") public class OrderReportController { private final OrderAnalyticsService orderAnalyticsService; -} \ No newline at end of file +} diff --git a/src/main/java/com/Podzilla/analytics/models/Order.java b/src/main/java/com/Podzilla/analytics/models/Order.java index 6e644dd..f3ec9b4 100644 --- a/src/main/java/com/Podzilla/analytics/models/Order.java +++ b/src/main/java/com/Podzilla/analytics/models/Order.java @@ -4,8 +4,22 @@ import java.time.LocalDateTime; import java.util.List; -import jakarta.persistence.*; -import lombok.*; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; @Entity @Table(name = "orders") diff --git a/src/main/java/com/Podzilla/analytics/repositories/OrderRepository.java b/src/main/java/com/Podzilla/analytics/repositories/OrderRepository.java index 09d556b..f0960d6 100644 --- a/src/main/java/com/Podzilla/analytics/repositories/OrderRepository.java +++ b/src/main/java/com/Podzilla/analytics/repositories/OrderRepository.java @@ -5,4 +5,4 @@ import com.Podzilla.analytics.models.Order; public interface OrderRepository extends JpaRepository { -} \ No newline at end of file +} diff --git a/src/main/java/com/Podzilla/analytics/services/FulfillmentAnalyticsService.java b/src/main/java/com/Podzilla/analytics/services/FulfillmentAnalyticsService.java index 70cb564..196d38f 100644 --- a/src/main/java/com/Podzilla/analytics/services/FulfillmentAnalyticsService.java +++ b/src/main/java/com/Podzilla/analytics/services/FulfillmentAnalyticsService.java @@ -8,4 +8,4 @@ @RequiredArgsConstructor @Service public class FulfillmentAnalyticsService { -} \ No newline at end of file +} diff --git a/src/main/java/com/Podzilla/analytics/services/OrderAnalyticsService.java b/src/main/java/com/Podzilla/analytics/services/OrderAnalyticsService.java index 94c0c91..afadc56 100644 --- a/src/main/java/com/Podzilla/analytics/services/OrderAnalyticsService.java +++ b/src/main/java/com/Podzilla/analytics/services/OrderAnalyticsService.java @@ -8,4 +8,4 @@ @RequiredArgsConstructor @Service public class OrderAnalyticsService { -} \ No newline at end of file +} From 16d9e42b2f91e78141f97390733063a5392b3753 Mon Sep 17 00:00:00 2001 From: Mohamed Hassan Date: Sun, 11 May 2025 03:06:55 +0300 Subject: [PATCH 3/8] refactor: update imports for consistency --- .../api/DTOs/ProfitByCategoryDTO.java | 4 +-- .../controllers/ProfitReportController.java | 24 ++++++++++----- .../repositories/SalesLineItemRepository.java | 2 -- .../services/ProfitAnalyticsService.java | 30 ++++++++++++------- 4 files changed, 37 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/Podzilla/analytics/api/DTOs/ProfitByCategoryDTO.java b/src/main/java/com/Podzilla/analytics/api/DTOs/ProfitByCategoryDTO.java index 54231c4..e58ffd1 100644 --- a/src/main/java/com/Podzilla/analytics/api/DTOs/ProfitByCategoryDTO.java +++ b/src/main/java/com/Podzilla/analytics/api/DTOs/ProfitByCategoryDTO.java @@ -1,4 +1,4 @@ -package com.Podzilla.analytics.api.DTOs; +package com.Podzilla.analytics.api.dtos; import java.math.BigDecimal; @@ -17,4 +17,4 @@ public class ProfitByCategoryDTO { private BigDecimal totalCost; private BigDecimal grossProfit; private BigDecimal grossProfitMargin; -} \ No newline at end of file +} diff --git a/src/main/java/com/Podzilla/analytics/api/controllers/ProfitReportController.java b/src/main/java/com/Podzilla/analytics/api/controllers/ProfitReportController.java index 259c478..f9721af 100644 --- a/src/main/java/com/Podzilla/analytics/api/controllers/ProfitReportController.java +++ b/src/main/java/com/Podzilla/analytics/api/controllers/ProfitReportController.java @@ -2,9 +2,12 @@ import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; -import com.Podzilla.analytics.api.DTOs.ProfitByCategoryDTO; +import com.Podzilla.analytics.api.dtos.ProfitByCategoryDTO; import com.Podzilla.analytics.services.ProfitAnalyticsService; import lombok.RequiredArgsConstructor; @@ -17,13 +20,18 @@ @RequestMapping("/profit") public class ProfitReportController { private final ProfitAnalyticsService profitAnalyticsService; - + @GetMapping("/by-category") public ResponseEntity> getProfitByCategory( - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) { - - List profitData = profitAnalyticsService.getProfitByCategory(startDate, endDate); + @RequestParam + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + final LocalDate startDate, + @RequestParam + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + final LocalDate endDate) { + + List profitData = + profitAnalyticsService.getProfitByCategory(startDate, endDate); return ResponseEntity.ok(profitData); } -} \ No newline at end of file +} diff --git a/src/main/java/com/Podzilla/analytics/repositories/SalesLineItemRepository.java b/src/main/java/com/Podzilla/analytics/repositories/SalesLineItemRepository.java index c5f8664..87728db 100644 --- a/src/main/java/com/Podzilla/analytics/repositories/SalesLineItemRepository.java +++ b/src/main/java/com/Podzilla/analytics/repositories/SalesLineItemRepository.java @@ -9,8 +9,6 @@ import java.time.LocalDateTime; import java.util.List; -public interface SalesLineItemRepository - extends JpaRepository { public interface SalesLineItemRepository extends JpaRepository { @Query("SELECT sli.product.category as category, " diff --git a/src/main/java/com/Podzilla/analytics/services/ProfitAnalyticsService.java b/src/main/java/com/Podzilla/analytics/services/ProfitAnalyticsService.java index c9f335c..cb671cb 100644 --- a/src/main/java/com/Podzilla/analytics/services/ProfitAnalyticsService.java +++ b/src/main/java/com/Podzilla/analytics/services/ProfitAnalyticsService.java @@ -2,7 +2,7 @@ import org.springframework.stereotype.Service; -import com.Podzilla.analytics.api.DTOs.ProfitByCategoryDTO; +import com.Podzilla.analytics.api.dtos.ProfitByCategoryDTO; import com.Podzilla.analytics.repositories.SalesLineItemRepository; import lombok.RequiredArgsConstructor; @@ -19,27 +19,35 @@ @Service public class ProfitAnalyticsService { private final SalesLineItemRepository salesLineItemRepository; - - public List getProfitByCategory(LocalDate startDate, LocalDate endDate) { + // Precision constant for percentage calculations + private static final int PERCENTAGE_PRECISION = 4; + + public List getProfitByCategory( + final LocalDate startDate, + final LocalDate endDate) { // Convert LocalDate to LocalDateTime for start of day and end of day LocalDateTime startDateTime = startDate.atStartOfDay(); LocalDateTime endDateTime = endDate.atTime(LocalTime.MAX); - - List salesData = salesLineItemRepository.findSalesByCategoryBetweenDates(startDateTime, endDateTime); + + List salesData = salesLineItemRepository + .findSalesByCategoryBetweenDates(startDateTime, endDateTime); List result = new ArrayList<>(); - + for (Object[] data : salesData) { String category = (String) data[0]; BigDecimal totalRevenue = (BigDecimal) data[1]; BigDecimal totalCost = (BigDecimal) data[2]; BigDecimal grossProfit = totalRevenue.subtract(totalCost); - + BigDecimal grossProfitMargin = BigDecimal.ZERO; if (totalRevenue.compareTo(BigDecimal.ZERO) > 0) { - grossProfitMargin = grossProfit.divide(totalRevenue, 4, RoundingMode.HALF_UP) + // Using decimal places for percentage calculation + grossProfitMargin = grossProfit + .divide(totalRevenue, PERCENTAGE_PRECISION, + RoundingMode.HALF_UP) .multiply(new BigDecimal("100")); } - + result.add(ProfitByCategoryDTO.builder() .category(category) .totalRevenue(totalRevenue) @@ -48,7 +56,7 @@ public List getProfitByCategory(LocalDate startDate, LocalD .grossProfitMargin(grossProfitMargin) .build()); } - + return result; } -} \ No newline at end of file +} From 60ba43d207d12b81fe61336ddc5d98677f6c97c2 Mon Sep 17 00:00:00 2001 From: Mohamed Hassan Date: Sun, 11 May 2025 04:07:49 +0300 Subject: [PATCH 4/8] refactor: update ProfitReportController to use ProfitByCategory with DateRangeRequest --- .../api/DTOs/ProfitByCategoryDTO.java | 20 ------- .../api/DTOs/profit/ProfitByCategory.java | 35 +++++++++++ .../controllers/ProfitReportController.java | 28 +++++---- .../profit/ProfitByCategoryProjection.java | 12 ++++ .../repositories/SalesLineItemRepository.java | 10 ++-- .../services/ProfitAnalyticsService.java | 59 ++++++++++--------- 6 files changed, 98 insertions(+), 66 deletions(-) delete mode 100644 src/main/java/com/Podzilla/analytics/api/DTOs/ProfitByCategoryDTO.java create mode 100644 src/main/java/com/Podzilla/analytics/api/DTOs/profit/ProfitByCategory.java create mode 100644 src/main/java/com/Podzilla/analytics/api/projections/profit/ProfitByCategoryProjection.java diff --git a/src/main/java/com/Podzilla/analytics/api/DTOs/ProfitByCategoryDTO.java b/src/main/java/com/Podzilla/analytics/api/DTOs/ProfitByCategoryDTO.java deleted file mode 100644 index e58ffd1..0000000 --- a/src/main/java/com/Podzilla/analytics/api/DTOs/ProfitByCategoryDTO.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.Podzilla.analytics.api.dtos; - -import java.math.BigDecimal; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class ProfitByCategoryDTO { - private String category; - private BigDecimal totalRevenue; - private BigDecimal totalCost; - private BigDecimal grossProfit; - private BigDecimal grossProfitMargin; -} diff --git a/src/main/java/com/Podzilla/analytics/api/DTOs/profit/ProfitByCategory.java b/src/main/java/com/Podzilla/analytics/api/DTOs/profit/ProfitByCategory.java new file mode 100644 index 0000000..f38927f --- /dev/null +++ b/src/main/java/com/Podzilla/analytics/api/DTOs/profit/ProfitByCategory.java @@ -0,0 +1,35 @@ +package com.Podzilla.analytics.api.dtos.profit; + +import java.math.BigDecimal; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ProfitByCategory { + + @Schema(description = "Product category name", example = "Electronics") + private String category; + + @Schema(description = "Total revenue for the category in the given period", + example = "15000.50") + private BigDecimal totalRevenue; + + @Schema(description = "Total cost for the category in the given period", + example = "10000.25") + private BigDecimal totalCost; + + @Schema(description = "Gross profit (revenue - cost)", + example = "5000.25") + private BigDecimal grossProfit; + + @Schema(description = "Gross profit margin percentage", + example = "33.33") + private BigDecimal grossProfitMargin; +} diff --git a/src/main/java/com/Podzilla/analytics/api/controllers/ProfitReportController.java b/src/main/java/com/Podzilla/analytics/api/controllers/ProfitReportController.java index f9721af..1e06ff2 100644 --- a/src/main/java/com/Podzilla/analytics/api/controllers/ProfitReportController.java +++ b/src/main/java/com/Podzilla/analytics/api/controllers/ProfitReportController.java @@ -1,18 +1,19 @@ package com.Podzilla.analytics.api.controllers; -import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import com.Podzilla.analytics.api.dtos.ProfitByCategoryDTO; +import com.Podzilla.analytics.api.dtos.DateRangeRequest; +import com.Podzilla.analytics.api.dtos.profit.ProfitByCategory; import com.Podzilla.analytics.services.ProfitAnalyticsService; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import java.time.LocalDate; import java.util.List; @RequiredArgsConstructor @@ -21,17 +22,18 @@ public class ProfitReportController { private final ProfitAnalyticsService profitAnalyticsService; + @Operation( + summary = "Get profit by product category", + description = "Returns the revenue, cost, and profit metrics " + + "grouped by product category") @GetMapping("/by-category") - public ResponseEntity> getProfitByCategory( - @RequestParam - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) - final LocalDate startDate, - @RequestParam - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) - final LocalDate endDate) { + public ResponseEntity> getProfitByCategory( + @Valid @ModelAttribute final DateRangeRequest request) { - List profitData = - profitAnalyticsService.getProfitByCategory(startDate, endDate); + List profitData = + profitAnalyticsService.getProfitByCategory( + request.getStartDate(), + request.getEndDate()); return ResponseEntity.ok(profitData); } } diff --git a/src/main/java/com/Podzilla/analytics/api/projections/profit/ProfitByCategoryProjection.java b/src/main/java/com/Podzilla/analytics/api/projections/profit/ProfitByCategoryProjection.java new file mode 100644 index 0000000..23a8968 --- /dev/null +++ b/src/main/java/com/Podzilla/analytics/api/projections/profit/ProfitByCategoryProjection.java @@ -0,0 +1,12 @@ +package com.Podzilla.analytics.api.projections.profit; + +import java.math.BigDecimal; + +/** + * Projection interface for profit by category query results + */ +public interface ProfitByCategoryProjection { + String getCategory(); + BigDecimal getTotalRevenue(); + BigDecimal getTotalCost(); +} diff --git a/src/main/java/com/Podzilla/analytics/repositories/SalesLineItemRepository.java b/src/main/java/com/Podzilla/analytics/repositories/SalesLineItemRepository.java index 87728db..b4b9ac8 100644 --- a/src/main/java/com/Podzilla/analytics/repositories/SalesLineItemRepository.java +++ b/src/main/java/com/Podzilla/analytics/repositories/SalesLineItemRepository.java @@ -5,20 +5,22 @@ import org.springframework.data.repository.query.Param; import com.Podzilla.analytics.models.SalesLineItem; +import com.Podzilla.analytics.api.projections.profit.ProfitByCategoryProjection; import java.time.LocalDateTime; import java.util.List; -public interface SalesLineItemRepository extends JpaRepository { - +public interface SalesLineItemRepository + extends JpaRepository { @Query("SELECT sli.product.category as category, " + "SUM(sli.quantity * sli.pricePerUnit) as totalRevenue, " + "SUM(sli.quantity * sli.product.cost) as totalCost " + "FROM SalesLineItem sli " - + "WHERE sli.order.orderPlacedTimestamp BETWEEN :startDate AND :endDate " + + "WHERE sli.order.orderPlacedTimestamp BETWEEN " + + ":startDate AND :endDate " + "AND sli.order.status = 'COMPLETED' " + "GROUP BY sli.product.category") - List findSalesByCategoryBetweenDates( + List findSalesByCategoryBetweenDates( @Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate); } diff --git a/src/main/java/com/Podzilla/analytics/services/ProfitAnalyticsService.java b/src/main/java/com/Podzilla/analytics/services/ProfitAnalyticsService.java index cb671cb..85d3fb3 100644 --- a/src/main/java/com/Podzilla/analytics/services/ProfitAnalyticsService.java +++ b/src/main/java/com/Podzilla/analytics/services/ProfitAnalyticsService.java @@ -2,7 +2,8 @@ import org.springframework.stereotype.Service; -import com.Podzilla.analytics.api.dtos.ProfitByCategoryDTO; +import com.Podzilla.analytics.api.dtos.profit.ProfitByCategory; +import com.Podzilla.analytics.api.projections.profit.ProfitByCategoryProjection; import com.Podzilla.analytics.repositories.SalesLineItemRepository; import lombok.RequiredArgsConstructor; @@ -12,8 +13,8 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; -import java.util.ArrayList; import java.util.List; +import java.util.stream.Collectors; @RequiredArgsConstructor @Service @@ -22,41 +23,41 @@ public class ProfitAnalyticsService { // Precision constant for percentage calculations private static final int PERCENTAGE_PRECISION = 4; - public List getProfitByCategory( + public List getProfitByCategory( final LocalDate startDate, final LocalDate endDate) { // Convert LocalDate to LocalDateTime for start of day and end of day LocalDateTime startDateTime = startDate.atStartOfDay(); LocalDateTime endDateTime = endDate.atTime(LocalTime.MAX); - List salesData = salesLineItemRepository + List salesData = salesLineItemRepository .findSalesByCategoryBetweenDates(startDateTime, endDateTime); - List result = new ArrayList<>(); - - for (Object[] data : salesData) { - String category = (String) data[0]; - BigDecimal totalRevenue = (BigDecimal) data[1]; - BigDecimal totalCost = (BigDecimal) data[2]; - BigDecimal grossProfit = totalRevenue.subtract(totalCost); - - BigDecimal grossProfitMargin = BigDecimal.ZERO; - if (totalRevenue.compareTo(BigDecimal.ZERO) > 0) { - // Using decimal places for percentage calculation - grossProfitMargin = grossProfit - .divide(totalRevenue, PERCENTAGE_PRECISION, - RoundingMode.HALF_UP) - .multiply(new BigDecimal("100")); - } - - result.add(ProfitByCategoryDTO.builder() - .category(category) - .totalRevenue(totalRevenue) - .totalCost(totalCost) - .grossProfit(grossProfit) - .grossProfitMargin(grossProfitMargin) - .build()); + + return salesData.stream() + .map(this::convertToDTO) + .collect(Collectors.toList()); + } + + private ProfitByCategory convertToDTO( + final ProfitByCategoryProjection projection) { + BigDecimal totalRevenue = projection.getTotalRevenue(); + BigDecimal totalCost = projection.getTotalCost(); + BigDecimal grossProfit = totalRevenue.subtract(totalCost); + + BigDecimal grossProfitMargin = BigDecimal.ZERO; + if (totalRevenue.compareTo(BigDecimal.ZERO) > 0) { + grossProfitMargin = grossProfit + .divide(totalRevenue, PERCENTAGE_PRECISION, + RoundingMode.HALF_UP) + .multiply(new BigDecimal("100")); } - return result; + return ProfitByCategory.builder() + .category(projection.getCategory()) + .totalRevenue(totalRevenue) + .totalCost(totalCost) + .grossProfit(grossProfit) + .grossProfitMargin(grossProfitMargin) + .build(); } } From c8795310571660549607cd468d7d352b94b4ea8c Mon Sep 17 00:00:00 2001 From: Mohamed Hassan Date: Sun, 11 May 2025 04:51:50 +0300 Subject: [PATCH 5/8] refactor: enhance FulfillmentReportController with improved error handling and validation for request parameters --- .../FulfillmentReportController.java | 83 +++-- .../FulfillmentReportControllerTest.java | 326 +++++++++--------- 2 files changed, 215 insertions(+), 194 deletions(-) diff --git a/src/main/java/com/Podzilla/analytics/api/controllers/FulfillmentReportController.java b/src/main/java/com/Podzilla/analytics/api/controllers/FulfillmentReportController.java index 1777f3c..75f363c 100644 --- a/src/main/java/com/Podzilla/analytics/api/controllers/FulfillmentReportController.java +++ b/src/main/java/com/Podzilla/analytics/api/controllers/FulfillmentReportController.java @@ -1,15 +1,19 @@ package com.Podzilla.analytics.api.controllers; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +// import org.springframework.web.bind.MethodArgumentNotValidException; +// import org.springframework.web.bind.MissingServletRequestParameterException; +// import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +// import org.springframework.web.method.annotation. +// MethodArgumentTypeMismatchException; -import com.Podzilla.analytics.api.dtos.fulfillment -.FulfillmentPlaceToShipRequest; -import com.Podzilla.analytics.api.dtos.fulfillment -.FulfillmentShipToDeliverRequest; +import com.Podzilla.analytics.api.dtos.fulfillment.FulfillmentPlaceToShipRequest; +import com.Podzilla.analytics.api.dtos.fulfillment.FulfillmentShipToDeliverRequest; import com.Podzilla.analytics.api.dtos.fulfillment.FulfillmentTimeResponse; import com.Podzilla.analytics.services.FulfillmentAnalyticsService; @@ -18,6 +22,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import java.util.Collections; import java.util.List; @Slf4j @@ -27,37 +32,69 @@ public class FulfillmentReportController { private final FulfillmentAnalyticsService fulfillmentAnalyticsService; - @Operation(summary = "Get average time from order placement to shipping", - description = "Returns the average time (in hours) between when" + @Operation( + summary = "Get average time from order placement to shipping", + description = "Returns the average time (in hours) between when" + " an order was placed and when it was shipped, grouped" - + " by the specified dimension") + + " by the specified dimension" + ) @GetMapping("/place-to-ship-time") public ResponseEntity> getPlaceToShipTime( @Valid @ModelAttribute final FulfillmentPlaceToShipRequest req) { - List reportData = fulfillmentAnalyticsService - .getPlaceToShipTimeResponse( - req.getStartDate(), - req.getEndDate(), - req.getGroupBy()); - return ResponseEntity.ok(reportData); + if (req.getGroupBy() == null || req.getStartDate() == null + || req.getEndDate() == null) { + log.warn("Missing required parameter: groupBy"); + return createErrorResponse(HttpStatus.BAD_REQUEST); + } + + try { + final List reportData = + fulfillmentAnalyticsService.getPlaceToShipTimeResponse( + req.getStartDate(), + req.getEndDate(), + req.getGroupBy()); + return ResponseEntity.ok(reportData); + } catch (Exception ex) { + log.error("Place-ship error", ex); + return createErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR); + } } - @Operation(summary = "Get average time from shipping to delivery", - description = "Returns the average time (in hours) between when" + + @Operation( + summary = "Get average time from shipping to delivery", + description = "Returns the average time (in hours) between when" + " an order was shipped and when it was delivered, grouped" - + " by the specified dimension") + + " by the specified dimension" + ) @GetMapping("/ship-to-deliver-time") public ResponseEntity> getShipToDeliverTime( @Valid @ModelAttribute final FulfillmentShipToDeliverRequest req) { - log.debug(req.toString()); + if (req.getGroupBy() == null || req.getStartDate() == null + || req.getEndDate() == null) { + log.warn("Missing required parameter: groupBy"); + return createErrorResponse(HttpStatus.BAD_REQUEST); + } + + try { + final List reportData = + fulfillmentAnalyticsService.getShipToDeliverTimeResponse( + req.getStartDate(), + req.getEndDate(), + req.getGroupBy()); + return ResponseEntity.ok(reportData); + } catch (Exception ex) { + log.error("Ship-deliver error", ex); + return createErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR); + } + } - List reportData = fulfillmentAnalyticsService - .getShipToDeliverTimeResponse( - req.getStartDate(), - req.getEndDate(), - req.getGroupBy()); - return ResponseEntity.ok(reportData); + + private ResponseEntity> createErrorResponse( + final HttpStatus status) { + return ResponseEntity.status(status) + .body(Collections.emptyList()); } } diff --git a/src/test/java/com/Podzilla/analytics/api/controllers/FulfillmentReportControllerTest.java b/src/test/java/com/Podzilla/analytics/api/controllers/FulfillmentReportControllerTest.java index a72a54e..be1dec3 100644 --- a/src/test/java/com/Podzilla/analytics/api/controllers/FulfillmentReportControllerTest.java +++ b/src/test/java/com/Podzilla/analytics/api/controllers/FulfillmentReportControllerTest.java @@ -1,11 +1,11 @@ package com.Podzilla.analytics.api.controllers; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +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.LocalDate; @@ -15,21 +15,29 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; -import com.Podzilla.analytics.api.dtos.fulfillment.FulfillmentPlaceToShipRequest; -import com.Podzilla.analytics.api.dtos.fulfillment.FulfillmentShipToDeliverRequest; +import com.Podzilla.analytics.api.dtos.fulfillment.FulfillmentPlaceToShipRequest.PlaceToShipGroupBy; import com.Podzilla.analytics.api.dtos.fulfillment.FulfillmentShipToDeliverRequest.ShipToDeliverGroupBy; import com.Podzilla.analytics.api.dtos.fulfillment.FulfillmentTimeResponse; -import com.Podzilla.analytics.api.dtos.fulfillment.FulfillmentPlaceToShipRequest.PlaceToShipGroupBy; import com.Podzilla.analytics.services.FulfillmentAnalyticsService; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +@WebMvcTest(FulfillmentReportController.class) public class FulfillmentReportControllerTest { - private FulfillmentReportController controller; + @Autowired + private MockMvc mockMvc; + + @MockBean private FulfillmentAnalyticsService mockService; + private ObjectMapper objectMapper; private LocalDate startDate; private LocalDate endDate; private List overallTimeResponses; @@ -38,8 +46,8 @@ public class FulfillmentReportControllerTest { @BeforeEach public void setup() { - mockService = mock(FulfillmentAnalyticsService.class); - controller = new FulfillmentReportController(mockService); + objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); startDate = LocalDate.of(2024, 1, 1); endDate = LocalDate.of(2024, 1, 31); @@ -73,179 +81,163 @@ public void setup() { } @Test - public void testGetPlaceToShipTime_Overall() { + public void testGetPlaceToShipTime_Overall() throws Exception { // Configure mock service when(mockService.getPlaceToShipTimeResponse( startDate, endDate, PlaceToShipGroupBy.OVERALL)) .thenReturn(overallTimeResponses); - // Create request - FulfillmentPlaceToShipRequest request = new FulfillmentPlaceToShipRequest( - startDate, endDate, PlaceToShipGroupBy.OVERALL); - - // Execute the method - ResponseEntity> response = controller.getPlaceToShipTime(request); - - // Verify response - assertEquals(HttpStatus.OK, response.getStatusCode()); - assertEquals(overallTimeResponses, response.getBody()); - assertEquals(PlaceToShipGroupBy.OVERALL.toString(), response.getBody().get(0).getGroupByValue().toString()); - assertEquals(BigDecimal.valueOf(24.5), response.getBody().get(0).getAverageDuration()); + // Execute and verify + mockMvc.perform(get("/fulfillment/place-to-ship-time") + .param("startDate", startDate.toString()) + .param("endDate", endDate.toString()) + .param("groupBy", PlaceToShipGroupBy.OVERALL.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$[0].groupByValue").value("OVERALL")) + .andExpect(jsonPath("$[0].averageDuration").value(24.5)); } @Test - public void testGetPlaceToShipTime_ByRegion() { + public void testGetPlaceToShipTime_ByRegion() throws Exception { // Configure mock service when(mockService.getPlaceToShipTimeResponse( startDate, endDate, PlaceToShipGroupBy.REGION)) .thenReturn(regionTimeResponses); - // Create request - FulfillmentPlaceToShipRequest request = new FulfillmentPlaceToShipRequest( - startDate, endDate, PlaceToShipGroupBy.REGION); - - // Execute the method - ResponseEntity> response = controller.getPlaceToShipTime(request); - - // Verify response - assertEquals(HttpStatus.OK, response.getStatusCode()); - assertEquals(regionTimeResponses, response.getBody()); - assertEquals("RegionID_1", response.getBody().get(0).getGroupByValue()); - assertEquals("RegionID_2", response.getBody().get(1).getGroupByValue()); + // Execute and verify + mockMvc.perform(get("/fulfillment/place-to-ship-time") + .param("startDate", startDate.toString()) + .param("endDate", endDate.toString()) + .param("groupBy", PlaceToShipGroupBy.REGION.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$[0].groupByValue").value("RegionID_1")) + .andExpect(jsonPath("$[1].groupByValue").value("RegionID_2")); } @Test - public void testGetShipToDeliverTime_Overall() { + public void testGetShipToDeliverTime_Overall() throws Exception { // Configure mock service when(mockService.getShipToDeliverTimeResponse( startDate, endDate, ShipToDeliverGroupBy.OVERALL)) .thenReturn(overallTimeResponses); - // Create request - FulfillmentShipToDeliverRequest request = new FulfillmentShipToDeliverRequest( - startDate, endDate, ShipToDeliverGroupBy.OVERALL); - - // Execute the method - ResponseEntity> response = controller.getShipToDeliverTime(request); - - // Verify response - assertEquals(HttpStatus.OK, response.getStatusCode()); - assertEquals(overallTimeResponses, response.getBody()); - assertEquals(ShipToDeliverGroupBy.OVERALL.toString(), response.getBody().get(0).getGroupByValue().toString()); + // Execute and verify + mockMvc.perform(get("/fulfillment/ship-to-deliver-time") + .param("startDate", startDate.toString()) + .param("endDate", endDate.toString()) + .param("groupBy", ShipToDeliverGroupBy.OVERALL.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$[0].groupByValue").value("OVERALL")); } @Test - public void testGetShipToDeliverTime_ByRegion() { + public void testGetShipToDeliverTime_ByRegion() throws Exception { // Configure mock service when(mockService.getShipToDeliverTimeResponse( startDate, endDate, ShipToDeliverGroupBy.REGION)) .thenReturn(regionTimeResponses); - // Create request - FulfillmentShipToDeliverRequest request = new FulfillmentShipToDeliverRequest( - startDate, endDate, ShipToDeliverGroupBy.REGION); - - // Execute the method - ResponseEntity> response = controller.getShipToDeliverTime(request); - - // Verify response - assertEquals(HttpStatus.OK, response.getStatusCode()); - assertEquals(regionTimeResponses, response.getBody()); - assertEquals("RegionID_1", response.getBody().get(0).getGroupByValue()); - assertEquals("RegionID_2", response.getBody().get(1).getGroupByValue()); + // Execute and verify + mockMvc.perform(get("/fulfillment/ship-to-deliver-time") + .param("startDate", startDate.toString()) + .param("endDate", endDate.toString()) + .param("groupBy", ShipToDeliverGroupBy.REGION.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$[0].groupByValue").value("RegionID_1")) + .andExpect(jsonPath("$[1].groupByValue").value("RegionID_2")); } @Test - public void testGetShipToDeliverTime_ByCourier() { + public void testGetShipToDeliverTime_ByCourier() throws Exception { // Configure mock service when(mockService.getShipToDeliverTimeResponse( startDate, endDate, ShipToDeliverGroupBy.COURIER)) .thenReturn(courierTimeResponses); - // Create request - FulfillmentShipToDeliverRequest request = new FulfillmentShipToDeliverRequest( - startDate, endDate, ShipToDeliverGroupBy.COURIER); - - // Execute the method - ResponseEntity> response = controller.getShipToDeliverTime(request); - - // Verify response - assertEquals(HttpStatus.OK, response.getStatusCode()); - assertEquals(courierTimeResponses, response.getBody()); - assertEquals("CourierID_1", response.getBody().get(0).getGroupByValue()); - assertEquals("CourierID_2", response.getBody().get(1).getGroupByValue()); + // Execute and verify + mockMvc.perform(get("/fulfillment/ship-to-deliver-time") + .param("startDate", startDate.toString()) + .param("endDate", endDate.toString()) + .param("groupBy", ShipToDeliverGroupBy.COURIER.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$[0].groupByValue").value("CourierID_1")) + .andExpect(jsonPath("$[1].groupByValue").value("CourierID_2")); } // Edge case tests @Test - public void testGetPlaceToShipTime_EmptyResponse() { + public void testGetPlaceToShipTime_EmptyResponse() throws Exception { // Configure mock service to return empty list when(mockService.getPlaceToShipTimeResponse( startDate, endDate, PlaceToShipGroupBy.OVERALL)) .thenReturn(Collections.emptyList()); - // Create request - FulfillmentPlaceToShipRequest request = new FulfillmentPlaceToShipRequest( - startDate, endDate, PlaceToShipGroupBy.OVERALL); - - // Execute the method - ResponseEntity> response = controller.getPlaceToShipTime(request); - - // Verify response - assertEquals(HttpStatus.OK, response.getStatusCode()); - assertNotNull(response.getBody()); - assertTrue(response.getBody().isEmpty()); + // Execute and verify + mockMvc.perform(get("/fulfillment/place-to-ship-time") + .param("startDate", startDate.toString()) + .param("endDate", endDate.toString()) + .param("groupBy", PlaceToShipGroupBy.OVERALL.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$").isEmpty()); } @Test - public void testGetShipToDeliverTime_EmptyResponse() { + public void testGetShipToDeliverTime_EmptyResponse() throws Exception { // Configure mock service to return empty list when(mockService.getShipToDeliverTimeResponse( startDate, endDate, ShipToDeliverGroupBy.OVERALL)) .thenReturn(Collections.emptyList()); - // Create request - FulfillmentShipToDeliverRequest request = new FulfillmentShipToDeliverRequest( - startDate, endDate, ShipToDeliverGroupBy.OVERALL); - - // Execute the method - ResponseEntity> response = controller.getShipToDeliverTime(request); - - // Verify response - assertEquals(HttpStatus.OK, response.getStatusCode()); - assertNotNull(response.getBody()); - assertTrue(response.getBody().isEmpty()); + // Execute and verify + mockMvc.perform(get("/fulfillment/ship-to-deliver-time") + .param("startDate", startDate.toString()) + .param("endDate", endDate.toString()) + .param("groupBy", ShipToDeliverGroupBy.OVERALL.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$").isEmpty()); } - // @Test - // public void testGetPlaceToShipTime_InvalidGroupBy() { - // // Create request with invalid groupBy - // FulfillmentPlaceToShipRequest request = new FulfillmentPlaceToShipRequest( - // startDate, endDate, null); - - // // Execute the method - should return bad request due to validation error - // ResponseEntity> response = controller.getPlaceToShipTime(request); - - // // Verify response - // assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - // } - - // @Test - // public void testGetShipToDeliverTime_InvalidGroupBy() { - // // Create request with invalid groupBy - // FulfillmentShipToDeliverRequest request = new FulfillmentShipToDeliverRequest( - // startDate, endDate, null); - - // // Execute the method - should return bad request due to validation error - // ResponseEntity> response = controller.getShipToDeliverTime(request); + @Test + public void testGetPlaceToShipTime_InvalidGroupBy() throws Exception { + // Execute with missing required parameter - should return bad request + mockMvc.perform(get("/fulfillment/place-to-ship-time") + .param("startDate", startDate.toString()) + .param("endDate", endDate.toString()) + .param("groupBy", "INVALID_VALUE") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } - // // Verify response - // assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - // } + @Test + public void testGetShipToDeliverTime_InvalidGroupBy() throws Exception { + // Execute with missing required parameter - should return bad request + mockMvc.perform(get("/fulfillment/ship-to-deliver-time") + .param("startDate", startDate.toString()) + .param("endDate", endDate.toString()) + .param("groupBy", "INVALID_VALUE") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } @Test - public void testGetPlaceToShipTime_SameDayRange() { + public void testGetPlaceToShipTime_SameDayRange() throws Exception { // Test same start and end date LocalDate sameDate = LocalDate.of(2024, 1, 1); @@ -254,20 +246,19 @@ public void testGetPlaceToShipTime_SameDayRange() { sameDate, sameDate, PlaceToShipGroupBy.OVERALL)) .thenReturn(overallTimeResponses); - // Create request with same start and end date - FulfillmentPlaceToShipRequest request = new FulfillmentPlaceToShipRequest( - sameDate, sameDate, PlaceToShipGroupBy.OVERALL); - - // Execute the method - ResponseEntity> response = controller.getPlaceToShipTime(request); - - // Verify response - assertEquals(HttpStatus.OK, response.getStatusCode()); - assertEquals(overallTimeResponses, response.getBody()); + // Execute and verify + mockMvc.perform(get("/fulfillment/place-to-ship-time") + .param("startDate", sameDate.toString()) + .param("endDate", sameDate.toString()) + .param("groupBy", PlaceToShipGroupBy.OVERALL.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$[0].groupByValue").value("OVERALL")); } @Test - public void testGetShipToDeliverTime_SameDayRange() { + public void testGetShipToDeliverTime_SameDayRange() throws Exception { // Test same start and end date LocalDate sameDate = LocalDate.of(2024, 1, 1); @@ -276,20 +267,19 @@ public void testGetShipToDeliverTime_SameDayRange() { sameDate, sameDate, ShipToDeliverGroupBy.OVERALL)) .thenReturn(overallTimeResponses); - // Create request with same start and end date - FulfillmentShipToDeliverRequest request = new FulfillmentShipToDeliverRequest( - sameDate, sameDate, ShipToDeliverGroupBy.OVERALL); - - // Execute the method - ResponseEntity> response = controller.getShipToDeliverTime(request); - - // Verify response - assertEquals(HttpStatus.OK, response.getStatusCode()); - assertEquals(overallTimeResponses, response.getBody()); + // Execute and verify + mockMvc.perform(get("/fulfillment/ship-to-deliver-time") + .param("startDate", sameDate.toString()) + .param("endDate", sameDate.toString()) + .param("groupBy", ShipToDeliverGroupBy.OVERALL.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$[0].groupByValue").value("OVERALL")); } @Test - public void testGetPlaceToShipTime_FutureDates() { + public void testGetPlaceToShipTime_FutureDates() throws Exception { // Test future dates LocalDate futureStart = LocalDate.now().plusDays(1); LocalDate futureEnd = LocalDate.now().plusDays(30); @@ -299,37 +289,31 @@ public void testGetPlaceToShipTime_FutureDates() { futureStart, futureEnd, PlaceToShipGroupBy.OVERALL)) .thenReturn(Collections.emptyList()); - // Create request with future dates - FulfillmentPlaceToShipRequest request = new FulfillmentPlaceToShipRequest( - futureStart, futureEnd, PlaceToShipGroupBy.OVERALL); - - // Execute the method - ResponseEntity> response = controller.getPlaceToShipTime(request); - - // Verify response - assertEquals(HttpStatus.OK, response.getStatusCode()); - assertNotNull(response.getBody()); - assertTrue(response.getBody().isEmpty()); + // Execute and verify + mockMvc.perform(get("/fulfillment/place-to-ship-time") + .param("startDate", futureStart.toString()) + .param("endDate", futureEnd.toString()) + .param("groupBy", PlaceToShipGroupBy.OVERALL.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$").isEmpty()); } @Test - public void testGetShipToDeliverTime_ServiceException() { + public void testGetShipToDeliverTime_ServiceException() throws Exception { // Configure mock service to throw exception when(mockService.getShipToDeliverTimeResponse( any(), any(), any())) .thenThrow(new RuntimeException("Service error")); - // Create request - FulfillmentShipToDeliverRequest request = new FulfillmentShipToDeliverRequest( - startDate, endDate, ShipToDeliverGroupBy.OVERALL); - - // Execute the method - controller should handle exception - // Note: Actual behavior depends on how controller handles exceptions - // This might need adjustment based on actual implementation - try { - controller.getShipToDeliverTime(request); - } catch (RuntimeException e) { - assertEquals("Service error", e.getMessage()); - } + // Execute and verify - controller should handle exception with 500 status + mockMvc.perform(get("/fulfillment/ship-to-deliver-time") + .param("startDate", startDate.toString()) + .param("endDate", endDate.toString()) + .param("groupBy", ShipToDeliverGroupBy.OVERALL.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isInternalServerError()); } } \ No newline at end of file From 217f0a940888e72eb0e14df81bb423c2c8d358f5 Mon Sep 17 00:00:00 2001 From: Mohamed Hassan Date: Sun, 11 May 2025 05:13:17 +0300 Subject: [PATCH 6/8] refactor: enhance ProfitReportController with request validation and error handling for profit data retrieval and add integration tests --- .../FulfillmentReportController.java | 4 +- .../controllers/ProfitReportController.java | 41 +++- .../ProfitReportControllerTest.java | 201 ++++++++++++++++++ 3 files changed, 239 insertions(+), 7 deletions(-) create mode 100644 src/test/java/com/Podzilla/analytics/api/controllers/ProfitReportControllerTest.java diff --git a/src/main/java/com/Podzilla/analytics/api/controllers/FulfillmentReportController.java b/src/main/java/com/Podzilla/analytics/api/controllers/FulfillmentReportController.java index 75f363c..94c6643 100644 --- a/src/main/java/com/Podzilla/analytics/api/controllers/FulfillmentReportController.java +++ b/src/main/java/com/Podzilla/analytics/api/controllers/FulfillmentReportController.java @@ -61,7 +61,7 @@ public ResponseEntity> getPlaceToShipTime( } } - + @Operation( summary = "Get average time from shipping to delivery", description = "Returns the average time (in hours) between when" @@ -91,7 +91,7 @@ public ResponseEntity> getShipToDeliverTime( } } - + private ResponseEntity> createErrorResponse( final HttpStatus status) { return ResponseEntity.status(status) diff --git a/src/main/java/com/Podzilla/analytics/api/controllers/ProfitReportController.java b/src/main/java/com/Podzilla/analytics/api/controllers/ProfitReportController.java index 1e06ff2..c79fb21 100644 --- a/src/main/java/com/Podzilla/analytics/api/controllers/ProfitReportController.java +++ b/src/main/java/com/Podzilla/analytics/api/controllers/ProfitReportController.java @@ -1,5 +1,6 @@ package com.Podzilla.analytics.api.controllers; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; @@ -11,17 +12,27 @@ import com.Podzilla.analytics.services.ProfitAnalyticsService; import io.swagger.v3.oas.annotations.Operation; +// import io.swagger.v3.oas.annotations.responses.ApiResponse; +// import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import java.util.Collections; import java.util.List; +/** + * REST controller for profit analytics operations. + * Provides endpoints to analyze revenue, cost, and profit metrics. + */ +@Slf4j @RequiredArgsConstructor @RestController @RequestMapping("/profit") public class ProfitReportController { private final ProfitAnalyticsService profitAnalyticsService; + @Operation( summary = "Get profit by product category", description = "Returns the revenue, cost, and profit metrics " @@ -30,10 +41,30 @@ public class ProfitReportController { public ResponseEntity> getProfitByCategory( @Valid @ModelAttribute final DateRangeRequest request) { - List profitData = - profitAnalyticsService.getProfitByCategory( - request.getStartDate(), - request.getEndDate()); - return ResponseEntity.ok(profitData); + // Validate request parameters + if (request.getStartDate() == null || request.getEndDate() == null) { + log.warn("Missing date parameters"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Collections.emptyList()); + } + + // Validate date range + if (request.getStartDate().isAfter(request.getEndDate())) { + log.warn("Invalid date range: start date after end date"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Collections.emptyList()); + } + + try { + List profitData = + profitAnalyticsService.getProfitByCategory( + request.getStartDate(), + request.getEndDate()); + return ResponseEntity.ok(profitData); + } catch (Exception ex) { + log.error("Error getting profit data", ex); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Collections.emptyList()); + } } } diff --git a/src/test/java/com/Podzilla/analytics/api/controllers/ProfitReportControllerTest.java b/src/test/java/com/Podzilla/analytics/api/controllers/ProfitReportControllerTest.java new file mode 100644 index 0000000..13277fb --- /dev/null +++ b/src/test/java/com/Podzilla/analytics/api/controllers/ProfitReportControllerTest.java @@ -0,0 +1,201 @@ +package com.Podzilla.analytics.api.controllers; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +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.LocalDate; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import com.Podzilla.analytics.api.dtos.profit.ProfitByCategory; +import com.Podzilla.analytics.services.ProfitAnalyticsService; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +@WebMvcTest(ProfitReportController.class) +public class ProfitReportControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private ProfitAnalyticsService mockService; + + private ObjectMapper objectMapper; + private LocalDate startDate; + private LocalDate endDate; + private List profitData; + + @BeforeEach + public void setup() { + objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + + startDate = LocalDate.of(2024, 1, 1); + endDate = LocalDate.of(2024, 1, 31); + + // Setup test data + profitData = Arrays.asList( + ProfitByCategory.builder() + .category("Electronics") + .totalRevenue(BigDecimal.valueOf(10000.50)) + .totalCost(BigDecimal.valueOf(7500.25)) + .grossProfit(BigDecimal.valueOf(2500.25)) + .grossProfitMargin(BigDecimal.valueOf(25.00)) + .build(), + ProfitByCategory.builder() + .category("Clothing") + .totalRevenue(BigDecimal.valueOf(5500.75)) + .totalCost(BigDecimal.valueOf(3000.50)) + .grossProfit(BigDecimal.valueOf(2500.25)) + .grossProfitMargin(BigDecimal.valueOf(45.45)) + .build()); + } + + @Test + public void testGetProfitByCategory_Success() throws Exception { + // Configure mock service + when(mockService.getProfitByCategory(startDate, endDate)) + .thenReturn(profitData); + + // Execute and verify + mockMvc.perform(get("/profit/by-category") + .param("startDate", startDate.toString()) + .param("endDate", endDate.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$[0].category").value("Electronics")) + .andExpect(jsonPath("$[0].totalRevenue").value(10000.50)) + .andExpect(jsonPath("$[0].grossProfit").value(2500.25)) + .andExpect(jsonPath("$[1].category").value("Clothing")) + .andExpect(jsonPath("$[1].grossProfitMargin").value(45.45)); + } + + @Test + public void testGetProfitByCategory_EmptyResult() throws Exception { + // Configure mock service to return empty list + when(mockService.getProfitByCategory(startDate, endDate)) + .thenReturn(Collections.emptyList()); + + // Execute and verify + mockMvc.perform(get("/profit/by-category") + .param("startDate", startDate.toString()) + .param("endDate", endDate.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$").isEmpty()); + } + + @Test + public void testGetProfitByCategory_MissingStartDate() throws Exception { + // Execute with missing required parameter - should return bad request + mockMvc.perform(get("/profit/by-category") + .param("endDate", endDate.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + + @Test + public void testGetProfitByCategory_MissingEndDate() throws Exception { + // Execute with missing required parameter - should return bad request + mockMvc.perform(get("/profit/by-category") + .param("startDate", startDate.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + + @Test + public void testGetProfitByCategory_InvalidDateFormat() throws Exception { + // Execute with invalid date format - should return bad request + mockMvc.perform(get("/profit/by-category") + .param("startDate", "2024-01-01") + .param("endDate", "invalid-date") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + + @Test + public void testGetProfitByCategory_StartDateAfterEndDate() throws Exception { + // Set up dates where start is after end + LocalDate invalidStart = LocalDate.of(2024, 2, 1); + LocalDate invalidEnd = LocalDate.of(2024, 1, 1); + + // Execute with invalid date range - depends on how controller/validation handles this + mockMvc.perform(get("/profit/by-category") + .param("startDate", invalidStart.toString()) + .param("endDate", invalidEnd.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + + @Test + public void testGetProfitByCategory_FutureDateRange() throws Exception { + // Set up future dates + LocalDate futureStart = LocalDate.now().plusDays(1); + LocalDate futureEnd = LocalDate.now().plusDays(30); + + // Configure mock service - should return empty data for future dates + when(mockService.getProfitByCategory(futureStart, futureEnd)) + .thenReturn(Collections.emptyList()); + + // Execute and verify + mockMvc.perform(get("/profit/by-category") + .param("startDate", futureStart.toString()) + .param("endDate", futureEnd.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$").isEmpty()); + } + + @Test + public void testGetProfitByCategory_SameDayRange() throws Exception { + // Test same start and end date + LocalDate sameDate = LocalDate.of(2024, 1, 1); + + // Configure mock service + when(mockService.getProfitByCategory(sameDate, sameDate)) + .thenReturn(profitData); + + // Execute and verify + mockMvc.perform(get("/profit/by-category") + .param("startDate", sameDate.toString()) + .param("endDate", sameDate.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$[0].category").value("Electronics")); + } + + @Test + public void testGetProfitByCategory_ServiceException() throws Exception { + // Configure mock service to throw exception + when(mockService.getProfitByCategory(any(), any())) + .thenThrow(new RuntimeException("Service error")); + + // Execute and verify - controller should handle exception with 500 status + mockMvc.perform(get("/profit/by-category") + .param("startDate", startDate.toString()) + .param("endDate", endDate.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isInternalServerError()); + } +} \ No newline at end of file From f18029f3cea7f75aaa6c32935b791c00c5af5972 Mon Sep 17 00:00:00 2001 From: Mohamed Hassan Date: Mon, 12 May 2025 15:35:26 +0300 Subject: [PATCH 7/8] refactor: simplify FulfillmentReportController and ProfitReportController by removing redundant error handling and enhancing request validation --- pom.xml | 4 ++ .../FulfillmentReportController.java | 55 ++++--------------- .../controllers/ProfitReportController.java | 32 ++--------- .../FulfillmentPlaceToShipRequest.java | 2 +- .../FulfillmentShipToDeliverRequest.java | 2 +- .../validators/DateRangeValidator.java | 24 ++++++-- .../FulfillmentReportControllerTest.java | 2 - 7 files changed, 42 insertions(+), 79 deletions(-) diff --git a/pom.xml b/pom.xml index b9290bf..d43d998 100644 --- a/pom.xml +++ b/pom.xml @@ -84,6 +84,10 @@ springdoc-openapi-starter-webmvc-ui 2.5.0 + + org.springframework.boot + spring-boot-starter-validation + diff --git a/src/main/java/com/Podzilla/analytics/api/controllers/FulfillmentReportController.java b/src/main/java/com/Podzilla/analytics/api/controllers/FulfillmentReportController.java index 94c6643..030c5ab 100644 --- a/src/main/java/com/Podzilla/analytics/api/controllers/FulfillmentReportController.java +++ b/src/main/java/com/Podzilla/analytics/api/controllers/FulfillmentReportController.java @@ -1,6 +1,5 @@ package com.Podzilla.analytics.api.controllers; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; // import org.springframework.web.bind.MethodArgumentNotValidException; // import org.springframework.web.bind.MissingServletRequestParameterException; @@ -22,7 +21,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import java.util.Collections; import java.util.List; @Slf4j @@ -42,23 +40,12 @@ public class FulfillmentReportController { public ResponseEntity> getPlaceToShipTime( @Valid @ModelAttribute final FulfillmentPlaceToShipRequest req) { - if (req.getGroupBy() == null || req.getStartDate() == null - || req.getEndDate() == null) { - log.warn("Missing required parameter: groupBy"); - return createErrorResponse(HttpStatus.BAD_REQUEST); - } - - try { - final List reportData = - fulfillmentAnalyticsService.getPlaceToShipTimeResponse( - req.getStartDate(), - req.getEndDate(), - req.getGroupBy()); - return ResponseEntity.ok(reportData); - } catch (Exception ex) { - log.error("Place-ship error", ex); - return createErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR); - } + final List reportData = + fulfillmentAnalyticsService.getPlaceToShipTimeResponse( + req.getStartDate(), + req.getEndDate(), + req.getGroupBy()); + return ResponseEntity.ok(reportData); } @@ -72,29 +59,11 @@ public ResponseEntity> getPlaceToShipTime( public ResponseEntity> getShipToDeliverTime( @Valid @ModelAttribute final FulfillmentShipToDeliverRequest req) { - if (req.getGroupBy() == null || req.getStartDate() == null - || req.getEndDate() == null) { - log.warn("Missing required parameter: groupBy"); - return createErrorResponse(HttpStatus.BAD_REQUEST); - } - - try { - final List reportData = - fulfillmentAnalyticsService.getShipToDeliverTimeResponse( - req.getStartDate(), - req.getEndDate(), - req.getGroupBy()); - return ResponseEntity.ok(reportData); - } catch (Exception ex) { - log.error("Ship-deliver error", ex); - return createErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR); - } - } - - - private ResponseEntity> createErrorResponse( - final HttpStatus status) { - return ResponseEntity.status(status) - .body(Collections.emptyList()); + final List reportData = + fulfillmentAnalyticsService.getShipToDeliverTimeResponse( + req.getStartDate(), + req.getEndDate(), + req.getGroupBy()); + return ResponseEntity.ok(reportData); } } diff --git a/src/main/java/com/Podzilla/analytics/api/controllers/ProfitReportController.java b/src/main/java/com/Podzilla/analytics/api/controllers/ProfitReportController.java index c79fb21..2ec724a 100644 --- a/src/main/java/com/Podzilla/analytics/api/controllers/ProfitReportController.java +++ b/src/main/java/com/Podzilla/analytics/api/controllers/ProfitReportController.java @@ -1,6 +1,5 @@ package com.Podzilla.analytics.api.controllers; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; @@ -18,7 +17,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import java.util.Collections; import java.util.List; /** @@ -41,30 +39,10 @@ public class ProfitReportController { public ResponseEntity> getProfitByCategory( @Valid @ModelAttribute final DateRangeRequest request) { - // Validate request parameters - if (request.getStartDate() == null || request.getEndDate() == null) { - log.warn("Missing date parameters"); - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Collections.emptyList()); - } - - // Validate date range - if (request.getStartDate().isAfter(request.getEndDate())) { - log.warn("Invalid date range: start date after end date"); - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Collections.emptyList()); - } - - try { - List profitData = - profitAnalyticsService.getProfitByCategory( - request.getStartDate(), - request.getEndDate()); - return ResponseEntity.ok(profitData); - } catch (Exception ex) { - log.error("Error getting profit data", ex); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Collections.emptyList()); - } + List profitData = + profitAnalyticsService.getProfitByCategory( + request.getStartDate(), + request.getEndDate()); + return ResponseEntity.ok(profitData); } } diff --git a/src/main/java/com/Podzilla/analytics/api/dtos/fulfillment/FulfillmentPlaceToShipRequest.java b/src/main/java/com/Podzilla/analytics/api/dtos/fulfillment/FulfillmentPlaceToShipRequest.java index 6af9a21..1a2bd8f 100644 --- a/src/main/java/com/Podzilla/analytics/api/dtos/fulfillment/FulfillmentPlaceToShipRequest.java +++ b/src/main/java/com/Podzilla/analytics/api/dtos/fulfillment/FulfillmentPlaceToShipRequest.java @@ -40,6 +40,6 @@ public enum PlaceToShipGroupBy { @NotNull(message = "groupBy is required") @Schema(description = "How to group the results (OVERALL, REGION, COURIER " + "depending on endpoint)", example = "OVERALL", required = true) - private PlaceToShipGroupBy groupBy = PlaceToShipGroupBy.OVERALL; + private PlaceToShipGroupBy groupBy; } diff --git a/src/main/java/com/Podzilla/analytics/api/dtos/fulfillment/FulfillmentShipToDeliverRequest.java b/src/main/java/com/Podzilla/analytics/api/dtos/fulfillment/FulfillmentShipToDeliverRequest.java index bb368bc..c87b2aa 100644 --- a/src/main/java/com/Podzilla/analytics/api/dtos/fulfillment/FulfillmentShipToDeliverRequest.java +++ b/src/main/java/com/Podzilla/analytics/api/dtos/fulfillment/FulfillmentShipToDeliverRequest.java @@ -44,5 +44,5 @@ public enum ShipToDeliverGroupBy { @NotNull(message = "groupBy is required") @Schema(description = "How to group the results (OVERALL, REGION, COURIER " + "depending on endpoint)", example = "OVERALL", required = true) - private ShipToDeliverGroupBy groupBy = ShipToDeliverGroupBy.OVERALL; + private ShipToDeliverGroupBy groupBy; } diff --git a/src/main/java/com/Podzilla/analytics/validation/validators/DateRangeValidator.java b/src/main/java/com/Podzilla/analytics/validation/validators/DateRangeValidator.java index 079bd20..26fa7cb 100644 --- a/src/main/java/com/Podzilla/analytics/validation/validators/DateRangeValidator.java +++ b/src/main/java/com/Podzilla/analytics/validation/validators/DateRangeValidator.java @@ -1,19 +1,33 @@ package com.Podzilla.analytics.validation.validators; -import com.Podzilla.analytics.api.dtos.DateRangeRequest; +import java.time.LocalDate; +import java.lang.reflect.Method; + import com.Podzilla.analytics.validation.annotations.ValidDateRange; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; public final class DateRangeValidator implements - ConstraintValidator { + ConstraintValidator { @Override - public boolean isValid(final DateRangeRequest request, + public boolean isValid(final Object value, final ConstraintValidatorContext context) { - if (request.getStartDate() == null || request.getEndDate() == null) { + if (value == null) { return true; } - return request.getEndDate().isAfter(request.getStartDate()); + + try { + Method getStartDate = value.getClass().getMethod("getStartDate"); + Method getEndDate = value.getClass().getMethod("getEndDate"); + LocalDate startDate = (LocalDate) getStartDate.invoke(value); + LocalDate endDate = (LocalDate) getEndDate.invoke(value); + if (startDate == null || endDate == null) { + return true; // Let @NotNull handle this + } + return !endDate.isBefore(startDate); + } catch (Exception e) { + return false; + } } } diff --git a/src/test/java/com/Podzilla/analytics/api/controllers/FulfillmentReportControllerTest.java b/src/test/java/com/Podzilla/analytics/api/controllers/FulfillmentReportControllerTest.java index be1dec3..33fcfae 100644 --- a/src/test/java/com/Podzilla/analytics/api/controllers/FulfillmentReportControllerTest.java +++ b/src/test/java/com/Podzilla/analytics/api/controllers/FulfillmentReportControllerTest.java @@ -220,7 +220,6 @@ public void testGetPlaceToShipTime_InvalidGroupBy() throws Exception { mockMvc.perform(get("/fulfillment/place-to-ship-time") .param("startDate", startDate.toString()) .param("endDate", endDate.toString()) - .param("groupBy", "INVALID_VALUE") .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isBadRequest()); } @@ -231,7 +230,6 @@ public void testGetShipToDeliverTime_InvalidGroupBy() throws Exception { mockMvc.perform(get("/fulfillment/ship-to-deliver-time") .param("startDate", startDate.toString()) .param("endDate", endDate.toString()) - .param("groupBy", "INVALID_VALUE") .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isBadRequest()); } From db55d30d1b3e822778b49d39e7b8af09f25baec8 Mon Sep 17 00:00:00 2001 From: Mohamed Hassan Date: Fri, 16 May 2025 19:05:52 +0300 Subject: [PATCH 8/8] chore: add H2 database dependency for testing and refactor FulfillmentReportControllerTest and ProfitReportControllerTest to use TestRestTemplate for HTTP requests --- pom.xml | 5 + .../FulfillmentReportControllerTest.java | 393 ++++++++++++------ .../ProfitReportControllerTest.java | 249 +++++++---- src/test/resources/application.properties | 13 + 4 files changed, 438 insertions(+), 222 deletions(-) create mode 100644 src/test/resources/application.properties diff --git a/pom.xml b/pom.xml index d43d998..0f46d74 100644 --- a/pom.xml +++ b/pom.xml @@ -74,6 +74,11 @@ spring-rabbit-test test + + com.h2database + h2 + test + jakarta.validation jakarta.validation-api diff --git a/src/test/java/com/Podzilla/analytics/api/controllers/FulfillmentReportControllerTest.java b/src/test/java/com/Podzilla/analytics/api/controllers/FulfillmentReportControllerTest.java index 33fcfae..e2eb7d8 100644 --- a/src/test/java/com/Podzilla/analytics/api/controllers/FulfillmentReportControllerTest.java +++ b/src/test/java/com/Podzilla/analytics/api/controllers/FulfillmentReportControllerTest.java @@ -1,11 +1,8 @@ package com.Podzilla.analytics.api.controllers; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; -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.LocalDate; @@ -16,10 +13,14 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.MockMvc; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.util.UriComponentsBuilder; import com.Podzilla.analytics.api.dtos.fulfillment.FulfillmentPlaceToShipRequest.PlaceToShipGroupBy; import com.Podzilla.analytics.api.dtos.fulfillment.FulfillmentShipToDeliverRequest.ShipToDeliverGroupBy; @@ -28,11 +29,11 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -@WebMvcTest(FulfillmentReportController.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) public class FulfillmentReportControllerTest { @Autowired - private MockMvc mockMvc; + private TestRestTemplate restTemplate; @MockBean private FulfillmentAnalyticsService mockService; @@ -81,161 +82,242 @@ public void setup() { } @Test - public void testGetPlaceToShipTime_Overall() throws Exception { + public void testGetPlaceToShipTime_Overall() { // Configure mock service when(mockService.getPlaceToShipTimeResponse( startDate, endDate, PlaceToShipGroupBy.OVERALL)) .thenReturn(overallTimeResponses); - // Execute and verify - mockMvc.perform(get("/fulfillment/place-to-ship-time") - .param("startDate", startDate.toString()) - .param("endDate", endDate.toString()) - .param("groupBy", PlaceToShipGroupBy.OVERALL.toString()) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$[0].groupByValue").value("OVERALL")) - .andExpect(jsonPath("$[0].averageDuration").value(24.5)); + // Build URL with query parameters + String url = UriComponentsBuilder.fromPath("/fulfillment/place-to-ship-time") + .queryParam("startDate", startDate.toString()) + .queryParam("endDate", endDate.toString()) + .queryParam("groupBy", PlaceToShipGroupBy.OVERALL.toString()) + .toUriString(); + + // Execute request + ResponseEntity> response = restTemplate.exchange( + url, + HttpMethod.GET, + null, + new ParameterizedTypeReference>() {}); + + // Verify + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().size()).isEqualTo(1); + assertThat(response.getBody().get(0).getGroupByValue()).isEqualTo("OVERALL"); + assertThat(response.getBody().get(0).getAverageDuration()).isEqualTo(BigDecimal.valueOf(24.5)); } @Test - public void testGetPlaceToShipTime_ByRegion() throws Exception { + public void testGetPlaceToShipTime_ByRegion() { // Configure mock service when(mockService.getPlaceToShipTimeResponse( startDate, endDate, PlaceToShipGroupBy.REGION)) .thenReturn(regionTimeResponses); - // Execute and verify - mockMvc.perform(get("/fulfillment/place-to-ship-time") - .param("startDate", startDate.toString()) - .param("endDate", endDate.toString()) - .param("groupBy", PlaceToShipGroupBy.REGION.toString()) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$[0].groupByValue").value("RegionID_1")) - .andExpect(jsonPath("$[1].groupByValue").value("RegionID_2")); + // Build URL with query parameters + String url = UriComponentsBuilder.fromPath("/fulfillment/place-to-ship-time") + .queryParam("startDate", startDate.toString()) + .queryParam("endDate", endDate.toString()) + .queryParam("groupBy", PlaceToShipGroupBy.REGION.toString()) + .toUriString(); + + // Execute request + ResponseEntity> response = restTemplate.exchange( + url, + HttpMethod.GET, + null, + new ParameterizedTypeReference>() {}); + + // Verify + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().size()).isEqualTo(2); + assertThat(response.getBody().get(0).getGroupByValue()).isEqualTo("RegionID_1"); + assertThat(response.getBody().get(1).getGroupByValue()).isEqualTo("RegionID_2"); } @Test - public void testGetShipToDeliverTime_Overall() throws Exception { + public void testGetShipToDeliverTime_Overall() { // Configure mock service when(mockService.getShipToDeliverTimeResponse( startDate, endDate, ShipToDeliverGroupBy.OVERALL)) .thenReturn(overallTimeResponses); - // Execute and verify - mockMvc.perform(get("/fulfillment/ship-to-deliver-time") - .param("startDate", startDate.toString()) - .param("endDate", endDate.toString()) - .param("groupBy", ShipToDeliverGroupBy.OVERALL.toString()) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$[0].groupByValue").value("OVERALL")); + // Build URL with query parameters + String url = UriComponentsBuilder.fromPath("/fulfillment/ship-to-deliver-time") + .queryParam("startDate", startDate.toString()) + .queryParam("endDate", endDate.toString()) + .queryParam("groupBy", ShipToDeliverGroupBy.OVERALL.toString()) + .toUriString(); + + // Execute request + ResponseEntity> response = restTemplate.exchange( + url, + HttpMethod.GET, + null, + new ParameterizedTypeReference>() {}); + + // Verify + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().get(0).getGroupByValue()).isEqualTo("OVERALL"); } @Test - public void testGetShipToDeliverTime_ByRegion() throws Exception { + public void testGetShipToDeliverTime_ByRegion() { // Configure mock service when(mockService.getShipToDeliverTimeResponse( startDate, endDate, ShipToDeliverGroupBy.REGION)) .thenReturn(regionTimeResponses); - // Execute and verify - mockMvc.perform(get("/fulfillment/ship-to-deliver-time") - .param("startDate", startDate.toString()) - .param("endDate", endDate.toString()) - .param("groupBy", ShipToDeliverGroupBy.REGION.toString()) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$[0].groupByValue").value("RegionID_1")) - .andExpect(jsonPath("$[1].groupByValue").value("RegionID_2")); + // Build URL with query parameters + String url = UriComponentsBuilder.fromPath("/fulfillment/ship-to-deliver-time") + .queryParam("startDate", startDate.toString()) + .queryParam("endDate", endDate.toString()) + .queryParam("groupBy", ShipToDeliverGroupBy.REGION.toString()) + .toUriString(); + + // Execute request + ResponseEntity> response = restTemplate.exchange( + url, + HttpMethod.GET, + null, + new ParameterizedTypeReference>() {}); + + // Verify + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().get(0).getGroupByValue()).isEqualTo("RegionID_1"); + assertThat(response.getBody().get(1).getGroupByValue()).isEqualTo("RegionID_2"); } @Test - public void testGetShipToDeliverTime_ByCourier() throws Exception { + public void testGetShipToDeliverTime_ByCourier() { // Configure mock service when(mockService.getShipToDeliverTimeResponse( startDate, endDate, ShipToDeliverGroupBy.COURIER)) .thenReturn(courierTimeResponses); - // Execute and verify - mockMvc.perform(get("/fulfillment/ship-to-deliver-time") - .param("startDate", startDate.toString()) - .param("endDate", endDate.toString()) - .param("groupBy", ShipToDeliverGroupBy.COURIER.toString()) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$[0].groupByValue").value("CourierID_1")) - .andExpect(jsonPath("$[1].groupByValue").value("CourierID_2")); + // Build URL with query parameters + String url = UriComponentsBuilder.fromPath("/fulfillment/ship-to-deliver-time") + .queryParam("startDate", startDate.toString()) + .queryParam("endDate", endDate.toString()) + .queryParam("groupBy", ShipToDeliverGroupBy.COURIER.toString()) + .toUriString(); + + // Execute request + ResponseEntity> response = restTemplate.exchange( + url, + HttpMethod.GET, + null, + new ParameterizedTypeReference>() {}); + + // Verify + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().get(0).getGroupByValue()).isEqualTo("CourierID_1"); + assertThat(response.getBody().get(1).getGroupByValue()).isEqualTo("CourierID_2"); } // Edge case tests @Test - public void testGetPlaceToShipTime_EmptyResponse() throws Exception { + public void testGetPlaceToShipTime_EmptyResponse() { // Configure mock service to return empty list when(mockService.getPlaceToShipTimeResponse( startDate, endDate, PlaceToShipGroupBy.OVERALL)) .thenReturn(Collections.emptyList()); - // Execute and verify - mockMvc.perform(get("/fulfillment/place-to-ship-time") - .param("startDate", startDate.toString()) - .param("endDate", endDate.toString()) - .param("groupBy", PlaceToShipGroupBy.OVERALL.toString()) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$").isArray()) - .andExpect(jsonPath("$").isEmpty()); + // Build URL with query parameters + String url = UriComponentsBuilder.fromPath("/fulfillment/place-to-ship-time") + .queryParam("startDate", startDate.toString()) + .queryParam("endDate", endDate.toString()) + .queryParam("groupBy", PlaceToShipGroupBy.OVERALL.toString()) + .toUriString(); + + // Execute request + ResponseEntity> response = restTemplate.exchange( + url, + HttpMethod.GET, + null, + new ParameterizedTypeReference>() {}); + + // Verify + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody()).isEmpty(); } @Test - public void testGetShipToDeliverTime_EmptyResponse() throws Exception { + public void testGetShipToDeliverTime_EmptyResponse() { // Configure mock service to return empty list when(mockService.getShipToDeliverTimeResponse( startDate, endDate, ShipToDeliverGroupBy.OVERALL)) .thenReturn(Collections.emptyList()); - // Execute and verify - mockMvc.perform(get("/fulfillment/ship-to-deliver-time") - .param("startDate", startDate.toString()) - .param("endDate", endDate.toString()) - .param("groupBy", ShipToDeliverGroupBy.OVERALL.toString()) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$").isArray()) - .andExpect(jsonPath("$").isEmpty()); + // Build URL with query parameters + String url = UriComponentsBuilder.fromPath("/fulfillment/ship-to-deliver-time") + .queryParam("startDate", startDate.toString()) + .queryParam("endDate", endDate.toString()) + .queryParam("groupBy", ShipToDeliverGroupBy.OVERALL.toString()) + .toUriString(); + + // Execute request + ResponseEntity> response = restTemplate.exchange( + url, + HttpMethod.GET, + null, + new ParameterizedTypeReference>() {}); + + // Verify + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody()).isEmpty(); } @Test - public void testGetPlaceToShipTime_InvalidGroupBy() throws Exception { - // Execute with missing required parameter - should return bad request - mockMvc.perform(get("/fulfillment/place-to-ship-time") - .param("startDate", startDate.toString()) - .param("endDate", endDate.toString()) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isBadRequest()); + public void testGetPlaceToShipTime_InvalidGroupBy() { + // Build URL without groupBy param + String url = UriComponentsBuilder.fromPath("/fulfillment/place-to-ship-time") + .queryParam("startDate", startDate.toString()) + .queryParam("endDate", endDate.toString()) + .toUriString(); + + // Execute request + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.GET, + null, + String.class); + + // Verify + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); } @Test - public void testGetShipToDeliverTime_InvalidGroupBy() throws Exception { - // Execute with missing required parameter - should return bad request - mockMvc.perform(get("/fulfillment/ship-to-deliver-time") - .param("startDate", startDate.toString()) - .param("endDate", endDate.toString()) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isBadRequest()); + public void testGetShipToDeliverTime_InvalidGroupBy() { + // Build URL without groupBy param + String url = UriComponentsBuilder.fromPath("/fulfillment/ship-to-deliver-time") + .queryParam("startDate", startDate.toString()) + .queryParam("endDate", endDate.toString()) + .toUriString(); + + // Execute request + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.GET, + null, + String.class); + + // Verify + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); } @Test - public void testGetPlaceToShipTime_SameDayRange() throws Exception { + public void testGetPlaceToShipTime_SameDayRange() { // Test same start and end date LocalDate sameDate = LocalDate.of(2024, 1, 1); @@ -244,19 +326,28 @@ public void testGetPlaceToShipTime_SameDayRange() throws Exception { sameDate, sameDate, PlaceToShipGroupBy.OVERALL)) .thenReturn(overallTimeResponses); - // Execute and verify - mockMvc.perform(get("/fulfillment/place-to-ship-time") - .param("startDate", sameDate.toString()) - .param("endDate", sameDate.toString()) - .param("groupBy", PlaceToShipGroupBy.OVERALL.toString()) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$[0].groupByValue").value("OVERALL")); + // Build URL with query parameters + String url = UriComponentsBuilder.fromPath("/fulfillment/place-to-ship-time") + .queryParam("startDate", sameDate.toString()) + .queryParam("endDate", sameDate.toString()) + .queryParam("groupBy", PlaceToShipGroupBy.OVERALL.toString()) + .toUriString(); + + // Execute request + ResponseEntity> response = restTemplate.exchange( + url, + HttpMethod.GET, + null, + new ParameterizedTypeReference>() {}); + + // Verify + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().get(0).getGroupByValue()).isEqualTo("OVERALL"); } @Test - public void testGetShipToDeliverTime_SameDayRange() throws Exception { + public void testGetShipToDeliverTime_SameDayRange() { // Test same start and end date LocalDate sameDate = LocalDate.of(2024, 1, 1); @@ -265,19 +356,28 @@ public void testGetShipToDeliverTime_SameDayRange() throws Exception { sameDate, sameDate, ShipToDeliverGroupBy.OVERALL)) .thenReturn(overallTimeResponses); - // Execute and verify - mockMvc.perform(get("/fulfillment/ship-to-deliver-time") - .param("startDate", sameDate.toString()) - .param("endDate", sameDate.toString()) - .param("groupBy", ShipToDeliverGroupBy.OVERALL.toString()) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$[0].groupByValue").value("OVERALL")); + // Build URL with query parameters + String url = UriComponentsBuilder.fromPath("/fulfillment/ship-to-deliver-time") + .queryParam("startDate", sameDate.toString()) + .queryParam("endDate", sameDate.toString()) + .queryParam("groupBy", ShipToDeliverGroupBy.OVERALL.toString()) + .toUriString(); + + // Execute request + ResponseEntity> response = restTemplate.exchange( + url, + HttpMethod.GET, + null, + new ParameterizedTypeReference>() {}); + + // Verify + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().get(0).getGroupByValue()).isEqualTo("OVERALL"); } @Test - public void testGetPlaceToShipTime_FutureDates() throws Exception { + public void testGetPlaceToShipTime_FutureDates() { // Test future dates LocalDate futureStart = LocalDate.now().plusDays(1); LocalDate futureEnd = LocalDate.now().plusDays(30); @@ -287,31 +387,48 @@ public void testGetPlaceToShipTime_FutureDates() throws Exception { futureStart, futureEnd, PlaceToShipGroupBy.OVERALL)) .thenReturn(Collections.emptyList()); - // Execute and verify - mockMvc.perform(get("/fulfillment/place-to-ship-time") - .param("startDate", futureStart.toString()) - .param("endDate", futureEnd.toString()) - .param("groupBy", PlaceToShipGroupBy.OVERALL.toString()) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$").isArray()) - .andExpect(jsonPath("$").isEmpty()); + // Build URL with query parameters + String url = UriComponentsBuilder.fromPath("/fulfillment/place-to-ship-time") + .queryParam("startDate", futureStart.toString()) + .queryParam("endDate", futureEnd.toString()) + .queryParam("groupBy", PlaceToShipGroupBy.OVERALL.toString()) + .toUriString(); + + // Execute request + ResponseEntity> response = restTemplate.exchange( + url, + HttpMethod.GET, + null, + new ParameterizedTypeReference>() {}); + + // Verify + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody()).isEmpty(); } @Test - public void testGetShipToDeliverTime_ServiceException() throws Exception { + public void testGetShipToDeliverTime_ServiceException() { // Configure mock service to throw exception when(mockService.getShipToDeliverTimeResponse( any(), any(), any())) .thenThrow(new RuntimeException("Service error")); - // Execute and verify - controller should handle exception with 500 status - mockMvc.perform(get("/fulfillment/ship-to-deliver-time") - .param("startDate", startDate.toString()) - .param("endDate", endDate.toString()) - .param("groupBy", ShipToDeliverGroupBy.OVERALL.toString()) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isInternalServerError()); + // Build URL with query parameters + String url = UriComponentsBuilder.fromPath("/fulfillment/ship-to-deliver-time") + .queryParam("startDate", startDate.toString()) + .queryParam("endDate", endDate.toString()) + .queryParam("groupBy", ShipToDeliverGroupBy.OVERALL.toString()) + .toUriString(); + + // Execute request + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.GET, + null, + String.class); + + // Verify + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); } } \ No newline at end of file diff --git a/src/test/java/com/Podzilla/analytics/api/controllers/ProfitReportControllerTest.java b/src/test/java/com/Podzilla/analytics/api/controllers/ProfitReportControllerTest.java index 13277fb..63abe1a 100644 --- a/src/test/java/com/Podzilla/analytics/api/controllers/ProfitReportControllerTest.java +++ b/src/test/java/com/Podzilla/analytics/api/controllers/ProfitReportControllerTest.java @@ -1,11 +1,8 @@ package com.Podzilla.analytics.api.controllers; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; -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.LocalDate; @@ -16,21 +13,25 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.MockMvc; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.util.UriComponentsBuilder; import com.Podzilla.analytics.api.dtos.profit.ProfitByCategory; import com.Podzilla.analytics.services.ProfitAnalyticsService; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -@WebMvcTest(ProfitReportController.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) public class ProfitReportControllerTest { @Autowired - private MockMvc mockMvc; + private TestRestTemplate restTemplate; @MockBean private ProfitAnalyticsService mockService; @@ -67,86 +68,140 @@ public void setup() { } @Test - public void testGetProfitByCategory_Success() throws Exception { + public void testGetProfitByCategory_Success() { // Configure mock service when(mockService.getProfitByCategory(startDate, endDate)) .thenReturn(profitData); - // Execute and verify - mockMvc.perform(get("/profit/by-category") - .param("startDate", startDate.toString()) - .param("endDate", endDate.toString()) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$[0].category").value("Electronics")) - .andExpect(jsonPath("$[0].totalRevenue").value(10000.50)) - .andExpect(jsonPath("$[0].grossProfit").value(2500.25)) - .andExpect(jsonPath("$[1].category").value("Clothing")) - .andExpect(jsonPath("$[1].grossProfitMargin").value(45.45)); + // Build URL with query parameters + String url = UriComponentsBuilder.fromPath("/profit/by-category") + .queryParam("startDate", startDate.toString()) + .queryParam("endDate", endDate.toString()) + .toUriString(); + + // Execute request + ResponseEntity> response = restTemplate.exchange( + url, + HttpMethod.GET, + null, + new ParameterizedTypeReference>() {}); + + // Verify + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().size()).isEqualTo(2); + assertThat(response.getBody().get(0).getCategory()).isEqualTo("Electronics"); + assertThat(response.getBody().get(0).getTotalRevenue()).isEqualTo(BigDecimal.valueOf(10000.50)); + assertThat(response.getBody().get(0).getGrossProfit()).isEqualTo(BigDecimal.valueOf(2500.25)); + assertThat(response.getBody().get(1).getCategory()).isEqualTo("Clothing"); + assertThat(response.getBody().get(1).getGrossProfitMargin()).isEqualTo(BigDecimal.valueOf(45.45)); } @Test - public void testGetProfitByCategory_EmptyResult() throws Exception { + public void testGetProfitByCategory_EmptyResult() { // Configure mock service to return empty list when(mockService.getProfitByCategory(startDate, endDate)) .thenReturn(Collections.emptyList()); - // Execute and verify - mockMvc.perform(get("/profit/by-category") - .param("startDate", startDate.toString()) - .param("endDate", endDate.toString()) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$").isArray()) - .andExpect(jsonPath("$").isEmpty()); + // Build URL with query parameters + String url = UriComponentsBuilder.fromPath("/profit/by-category") + .queryParam("startDate", startDate.toString()) + .queryParam("endDate", endDate.toString()) + .toUriString(); + + // Execute request + ResponseEntity> response = restTemplate.exchange( + url, + HttpMethod.GET, + null, + new ParameterizedTypeReference>() {}); + + // Verify + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody()).isEmpty(); } @Test - public void testGetProfitByCategory_MissingStartDate() throws Exception { - // Execute with missing required parameter - should return bad request - mockMvc.perform(get("/profit/by-category") - .param("endDate", endDate.toString()) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isBadRequest()); + public void testGetProfitByCategory_MissingStartDate() { + // Build URL with missing required parameter + String url = UriComponentsBuilder.fromPath("/profit/by-category") + .queryParam("endDate", endDate.toString()) + .toUriString(); + + // Execute request + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.GET, + null, + String.class); + + // Verify + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); } @Test - public void testGetProfitByCategory_MissingEndDate() throws Exception { - // Execute with missing required parameter - should return bad request - mockMvc.perform(get("/profit/by-category") - .param("startDate", startDate.toString()) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isBadRequest()); + public void testGetProfitByCategory_MissingEndDate() { + // Build URL with missing required parameter + String url = UriComponentsBuilder.fromPath("/profit/by-category") + .queryParam("startDate", startDate.toString()) + .toUriString(); + + // Execute request + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.GET, + null, + String.class); + + // Verify + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); } @Test - public void testGetProfitByCategory_InvalidDateFormat() throws Exception { - // Execute with invalid date format - should return bad request - mockMvc.perform(get("/profit/by-category") - .param("startDate", "2024-01-01") - .param("endDate", "invalid-date") - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isBadRequest()); + public void testGetProfitByCategory_InvalidDateFormat() { + // Build URL with invalid date format + String url = UriComponentsBuilder.fromPath("/profit/by-category") + .queryParam("startDate", "2024-01-01") + .queryParam("endDate", "invalid-date") + .toUriString(); + + // Execute request + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.GET, + null, + String.class); + + // Verify + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); } @Test - public void testGetProfitByCategory_StartDateAfterEndDate() throws Exception { + public void testGetProfitByCategory_StartDateAfterEndDate() { // Set up dates where start is after end LocalDate invalidStart = LocalDate.of(2024, 2, 1); LocalDate invalidEnd = LocalDate.of(2024, 1, 1); - // Execute with invalid date range - depends on how controller/validation handles this - mockMvc.perform(get("/profit/by-category") - .param("startDate", invalidStart.toString()) - .param("endDate", invalidEnd.toString()) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isBadRequest()); + // Build URL with invalid date range + String url = UriComponentsBuilder.fromPath("/profit/by-category") + .queryParam("startDate", invalidStart.toString()) + .queryParam("endDate", invalidEnd.toString()) + .toUriString(); + + // Execute request + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.GET, + null, + String.class); + + // Verify + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); } @Test - public void testGetProfitByCategory_FutureDateRange() throws Exception { + public void testGetProfitByCategory_FutureDateRange() { // Set up future dates LocalDate futureStart = LocalDate.now().plusDays(1); LocalDate futureEnd = LocalDate.now().plusDays(30); @@ -155,19 +210,27 @@ public void testGetProfitByCategory_FutureDateRange() throws Exception { when(mockService.getProfitByCategory(futureStart, futureEnd)) .thenReturn(Collections.emptyList()); - // Execute and verify - mockMvc.perform(get("/profit/by-category") - .param("startDate", futureStart.toString()) - .param("endDate", futureEnd.toString()) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$").isArray()) - .andExpect(jsonPath("$").isEmpty()); + // Build URL with future date range + String url = UriComponentsBuilder.fromPath("/profit/by-category") + .queryParam("startDate", futureStart.toString()) + .queryParam("endDate", futureEnd.toString()) + .toUriString(); + + // Execute request + ResponseEntity> response = restTemplate.exchange( + url, + HttpMethod.GET, + null, + new ParameterizedTypeReference>() {}); + + // Verify + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody()).isEmpty(); } @Test - public void testGetProfitByCategory_SameDayRange() throws Exception { + public void testGetProfitByCategory_SameDayRange() { // Test same start and end date LocalDate sameDate = LocalDate.of(2024, 1, 1); @@ -175,27 +238,45 @@ public void testGetProfitByCategory_SameDayRange() throws Exception { when(mockService.getProfitByCategory(sameDate, sameDate)) .thenReturn(profitData); - // Execute and verify - mockMvc.perform(get("/profit/by-category") - .param("startDate", sameDate.toString()) - .param("endDate", sameDate.toString()) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$[0].category").value("Electronics")); + // Build URL with same day for start and end + String url = UriComponentsBuilder.fromPath("/profit/by-category") + .queryParam("startDate", sameDate.toString()) + .queryParam("endDate", sameDate.toString()) + .toUriString(); + + // Execute request + ResponseEntity> response = restTemplate.exchange( + url, + HttpMethod.GET, + null, + new ParameterizedTypeReference>() {}); + + // Verify + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().get(0).getCategory()).isEqualTo("Electronics"); } @Test - public void testGetProfitByCategory_ServiceException() throws Exception { + public void testGetProfitByCategory_ServiceException() { // Configure mock service to throw exception when(mockService.getProfitByCategory(any(), any())) .thenThrow(new RuntimeException("Service error")); - // Execute and verify - controller should handle exception with 500 status - mockMvc.perform(get("/profit/by-category") - .param("startDate", startDate.toString()) - .param("endDate", endDate.toString()) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isInternalServerError()); + // Build URL with query parameters + String url = UriComponentsBuilder.fromPath("/profit/by-category") + .queryParam("startDate", startDate.toString()) + .queryParam("endDate", endDate.toString()) + .toUriString(); + + // Execute request + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.GET, + null, + String.class); + + // Verify + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); } } \ No newline at end of file diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties new file mode 100644 index 0000000..286cb1a --- /dev/null +++ b/src/test/resources/application.properties @@ -0,0 +1,13 @@ +# Test Database Configuration +spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=PostgreSQL +spring.datasource.username=sa +spring.datasource.password= +spring.datasource.driver-class-name=org.h2.Driver +# JPA/Hibernate Configuration +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.format_sql=true +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +# H2 Console (optional, for debugging) +spring.h2.console.enabled=true +spring.h2.console.path=/h2-console \ No newline at end of file