| 1 | package com.rbctcsworld.ecommerce.review; | |
| 2 | ||
| 3 | import com.rbctcsworld.ecommerce.auth.AppUser; | |
| 4 | import com.rbctcsworld.ecommerce.auth.UserRepository; | |
| 5 | import com.rbctcsworld.ecommerce.common.exception.BusinessRuleException; | |
| 6 | import com.rbctcsworld.ecommerce.common.exception.ConflictException; | |
| 7 | import com.rbctcsworld.ecommerce.common.exception.ForbiddenException; | |
| 8 | import com.rbctcsworld.ecommerce.common.exception.NotFoundException; | |
| 9 | import com.rbctcsworld.ecommerce.order.OrderRepository; | |
| 10 | import com.rbctcsworld.ecommerce.order.OrderStatus; | |
| 11 | import com.rbctcsworld.ecommerce.product.Product; | |
| 12 | import com.rbctcsworld.ecommerce.product.ProductRepository; | |
| 13 | import com.rbctcsworld.ecommerce.review.ReviewDtos.ProductReviews; | |
| 14 | import com.rbctcsworld.ecommerce.review.ReviewDtos.RatingSummary; | |
| 15 | import com.rbctcsworld.ecommerce.review.ReviewDtos.ReviewResponse; | |
| 16 | import org.springframework.stereotype.Service; | |
| 17 | import org.springframework.transaction.annotation.Transactional; | |
| 18 | ||
| 19 | import java.math.BigDecimal; | |
| 20 | import java.math.RoundingMode; | |
| 21 | import java.time.Clock; | |
| 22 | import java.time.LocalDateTime; | |
| 23 | import java.util.Comparator; | |
| 24 | import java.util.LinkedHashMap; | |
| 25 | import java.util.List; | |
| 26 | import java.util.Map; | |
| 27 | import java.util.Set; | |
| 28 | import java.util.stream.Collectors; | |
| 29 | ||
| 30 | /** | |
| 31 | * Business rules: | |
| 32 | * 1. Rating 1..5, title <= 100, body <= 2000 characters (400). Blank title/body are stored as null. | |
| 33 | * 2. Verified purchase only: the customer must have RECEIVED the product (an order with it that is | |
| 34 | * DELIVERED, RETURN_REQUESTED or RETURNED). Otherwise 403. | |
| 35 | * 3. One review per customer per product (409). The customer may edit or delete it. | |
| 36 | * 4. Another customer's review id behaves as "not found" (404, IDOR protection). | |
| 37 | * 5. Only PUBLISHED reviews are listed and counted. An admin can hide / re-publish (moderation); | |
| 38 | * editing a hidden review does not publish it again. | |
| 39 | * 6. The product's averageRating (1 decimal, HALF_UP) and reviewCount are recalculated in the same | |
| 40 | * transaction as every change, while the product row is locked (SELECT ... FOR UPDATE), so two | |
| 41 | * reviews saved at the same moment can never leave a wrong average. | |
| 42 | * 7. Public responses show a masked reviewer name, never the e-mail address. | |
| 43 | */ | |
| 44 | @Service | |
| 45 | @Transactional | |
| 46 | public class ReviewService { | |
| 47 | ||
| 48 | static final Set<String> RECEIVED = Set.of(OrderStatus.DELIVERED, OrderStatus.RETURN_REQUESTED, OrderStatus.RETURNED); | |
| 49 | public static final List<String> SORTS = List.of("newest", "highest", "lowest"); | |
| 50 | ||
| 51 | private final ReviewRepository reviews; | |
| 52 | private final ProductRepository products; | |
| 53 | private final OrderRepository orders; | |
| 54 | private final UserRepository users; | |
| 55 | private final Clock clock; | |
| 56 | ||
| 57 | public ReviewService(ReviewRepository reviews, ProductRepository products, OrderRepository orders, | |
| 58 | UserRepository users, Clock clock) { | |
| 59 | this.reviews = reviews; | |
| 60 | this.products = products; | |
| 61 | this.orders = orders; | |
| 62 | this.users = users; | |
| 63 | this.clock = clock; | |
| 64 | } | |
| 65 | ||
| 66 | // ---------- customer ---------- | |
| 67 | ||
| 68 | public ReviewResponse create(String email, Long productId, int rating, String title, String body) { | |
| 69 | AppUser user = user(email); | |
| 70 | Product product = products.lockById(productId) | |
| 71 | .filter(Product::isActive) | |
| 72 |
1
1. lambda$create$0 : replaced return value with null for com/rbctcsworld/ecommerce/review/ReviewService::lambda$create$0 → KILLED |
.orElseThrow(() -> new NotFoundException("Product not found: " + productId)); |
| 73 |
1
1. create : removed conditional - replaced equality check with false → KILLED |
if (!orders.hasReceived(user.getId(), productId, RECEIVED)) { |
| 74 | throw new ForbiddenException("Only customers who received this product can review it"); | |
| 75 | } | |
| 76 |
1
1. create : removed conditional - replaced equality check with false → KILLED |
if (reviews.existsByUserIdAndProductId(user.getId(), productId)) { |
| 77 | throw new ConflictException("You have already reviewed this product - edit your review instead"); | |
| 78 | } | |
| 79 | Review r = reviews.saveAndFlush(new Review(productId, user.getId(), rating, clean(title), clean(body), now())); | |
| 80 |
1
1. create : removed call to com/rbctcsworld/ecommerce/review/ReviewService::recalculate → KILLED |
recalculate(product); |
| 81 |
1
1. create : replaced return value with null for com/rbctcsworld/ecommerce/review/ReviewService::create → KILLED |
return toResponse(r, email); |
| 82 | } | |
| 83 | ||
| 84 | public ReviewResponse update(String email, Long reviewId, int rating, String title, String body) { | |
| 85 | AppUser user = user(email); | |
| 86 | Review r = owned(reviewId, user.getId()); | |
| 87 | Product product = lockedProduct(r.getProductId()); | |
| 88 |
1
1. update : removed call to com/rbctcsworld/ecommerce/review/Review::edit → KILLED |
r.edit(rating, clean(title), clean(body), now()); |
| 89 | reviews.saveAndFlush(r); | |
| 90 |
1
1. update : removed call to com/rbctcsworld/ecommerce/review/ReviewService::recalculate → KILLED |
recalculate(product); |
| 91 |
1
1. update : replaced return value with null for com/rbctcsworld/ecommerce/review/ReviewService::update → KILLED |
return toResponse(r, email); |
| 92 | } | |
| 93 | ||
| 94 | public void delete(String email, Long reviewId) { | |
| 95 | AppUser user = user(email); | |
| 96 | Review r = owned(reviewId, user.getId()); | |
| 97 | Product product = lockedProduct(r.getProductId()); | |
| 98 |
1
1. delete : removed call to com/rbctcsworld/ecommerce/review/ReviewRepository::delete → KILLED |
reviews.delete(r); |
| 99 |
1
1. delete : removed call to com/rbctcsworld/ecommerce/review/ReviewRepository::flush → KILLED |
reviews.flush(); |
| 100 |
1
1. delete : removed call to com/rbctcsworld/ecommerce/review/ReviewService::recalculate → KILLED |
recalculate(product); |
| 101 | } | |
| 102 | ||
| 103 | @Transactional(readOnly = true) | |
| 104 | public List<ReviewResponse> mine(String email) { | |
| 105 |
2
1. mine : replaced return value with Collections.emptyList for com/rbctcsworld/ecommerce/review/ReviewService::mine → NO_COVERAGE 2. lambda$mine$1 : replaced return value with null for com/rbctcsworld/ecommerce/review/ReviewService::lambda$mine$1 → NO_COVERAGE |
return reviews.findByUserIdOrderByIdDesc(user(email).getId()).stream().map(r -> toResponse(r, email)).toList(); |
| 106 | } | |
| 107 | ||
| 108 | // ---------- public ---------- | |
| 109 | ||
| 110 | @Transactional(readOnly = true) | |
| 111 | public ProductReviews forProduct(Long productId, String sort) { | |
| 112 |
2
1. forProduct : removed conditional - replaced equality check with false → KILLED 2. forProduct : removed conditional - replaced equality check with false → KILLED |
String s = sort == null || sort.isBlank() ? "newest" : sort.trim().toLowerCase(); |
| 113 |
1
1. forProduct : removed conditional - replaced equality check with false → KILLED |
if (!SORTS.contains(s)) { |
| 114 | throw new BusinessRuleException("sort must be one of " + SORTS); | |
| 115 | } | |
| 116 | Product product = products.findByIdAndActiveTrue(productId) | |
| 117 |
1
1. lambda$forProduct$2 : replaced return value with null for com/rbctcsworld/ecommerce/review/ReviewService::lambda$forProduct$2 → NO_COVERAGE |
.orElseThrow(() -> new NotFoundException("Product not found: " + productId)); |
| 118 | List<Review> published = reviews.findByProductIdAndStatus(productId, Review.PUBLISHED); | |
| 119 | Map<Long, String> names = reviewerNames(published); | |
| 120 | List<ReviewResponse> list = published.stream() | |
| 121 | .sorted(comparator(s)) | |
| 122 |
1
1. lambda$forProduct$3 : replaced return value with null for com/rbctcsworld/ecommerce/review/ReviewService::lambda$forProduct$3 → KILLED |
.map(r -> toResponse(r, names.get(r.getUserId()))) |
| 123 | .toList(); | |
| 124 | RatingSummary summary = summarize(reviews.ratingHistogram(productId)); | |
| 125 |
1
1. forProduct : replaced return value with null for com/rbctcsworld/ecommerce/review/ReviewService::forProduct → KILLED |
return new ProductReviews(product.getId(), summary.average(), summary.count(), summary.distribution(), list); |
| 126 | } | |
| 127 | ||
| 128 | // ---------- admin moderation ---------- | |
| 129 | ||
| 130 | @Transactional(readOnly = true) | |
| 131 | public List<ReviewResponse> adminList(String status) { | |
| 132 | List<Review> list; | |
| 133 |
2
1. adminList : removed conditional - replaced equality check with false → KILLED 2. adminList : removed conditional - replaced equality check with false → KILLED |
if (status == null || status.isBlank()) { |
| 134 | list = reviews.findAllByOrderByIdDesc(); | |
| 135 | } else { | |
| 136 | String st = status.trim().toUpperCase(); | |
| 137 |
2
1. adminList : removed conditional - replaced equality check with false → KILLED 2. adminList : removed conditional - replaced equality check with false → KILLED |
if (!st.equals(Review.PUBLISHED) && !st.equals(Review.HIDDEN)) { |
| 138 | throw new BusinessRuleException("status must be PUBLISHED or HIDDEN"); | |
| 139 | } | |
| 140 | list = reviews.findByStatusOrderByIdDesc(st); | |
| 141 | } | |
| 142 | Map<Long, String> names = reviewerNames(list); | |
| 143 |
2
1. adminList : replaced return value with Collections.emptyList for com/rbctcsworld/ecommerce/review/ReviewService::adminList → KILLED 2. lambda$adminList$4 : replaced return value with null for com/rbctcsworld/ecommerce/review/ReviewService::lambda$adminList$4 → KILLED |
return list.stream().map(r -> toResponse(r, names.get(r.getUserId()))).toList(); |
| 144 | } | |
| 145 | ||
| 146 | public ReviewResponse moderate(Long reviewId, String newStatus) { | |
| 147 |
1
1. lambda$moderate$5 : replaced return value with null for com/rbctcsworld/ecommerce/review/ReviewService::lambda$moderate$5 → NO_COVERAGE |
Review r = reviews.findById(reviewId).orElseThrow(() -> new NotFoundException("Review not found: " + reviewId)); |
| 148 | Product product = lockedProduct(r.getProductId()); | |
| 149 |
1
1. moderate : removed call to com/rbctcsworld/ecommerce/review/Review::setStatus → NO_COVERAGE |
r.setStatus(newStatus); |
| 150 | reviews.saveAndFlush(r); | |
| 151 |
1
1. moderate : removed call to com/rbctcsworld/ecommerce/review/ReviewService::recalculate → NO_COVERAGE |
recalculate(product); |
| 152 |
1
1. moderate : replaced return value with null for com/rbctcsworld/ecommerce/review/ReviewService::moderate → NO_COVERAGE |
return toResponse(r, users.findById(r.getUserId()).map(AppUser::getEmail).orElse(null)); |
| 153 | } | |
| 154 | ||
| 155 | // ---------- rating maths (pure, unit tested) ---------- | |
| 156 | ||
| 157 | /** Turns [rating, count] rows into average (1 decimal, HALF_UP), total count and a 5..1 distribution. */ | |
| 158 | static RatingSummary summarize(List<Object[]> histogram) { | |
| 159 | Map<Integer, Long> distribution = new LinkedHashMap<>(); | |
| 160 |
3
1. summarize : Changed increment from -1 to 1 → TIMED_OUT 2. summarize : removed conditional - replaced comparison check with false → KILLED 3. summarize : changed conditional boundary → KILLED |
for (int star = 5; star >= 1; star--) distribution.put(star, 0L); |
| 161 | long count = 0; | |
| 162 | long sum = 0; | |
| 163 | for (Object[] row : histogram) { | |
| 164 | int star = ((Number) row[0]).intValue(); | |
| 165 | long n = ((Number) row[1]).longValue(); | |
| 166 | distribution.put(star, n); | |
| 167 |
1
1. summarize : Replaced long addition with subtraction → KILLED |
count += n; |
| 168 |
2
1. summarize : Replaced long addition with subtraction → KILLED 2. summarize : Replaced long multiplication with division → KILLED |
sum += star * n; |
| 169 | } | |
| 170 |
1
1. summarize : removed conditional - replaced equality check with false → KILLED |
BigDecimal average = count == 0 |
| 171 | ? BigDecimal.ZERO.setScale(1) | |
| 172 | : BigDecimal.valueOf(sum).divide(BigDecimal.valueOf(count), 1, RoundingMode.HALF_UP); | |
| 173 |
1
1. summarize : replaced return value with null for com/rbctcsworld/ecommerce/review/ReviewService::summarize → KILLED |
return new RatingSummary(average, (int) count, distribution); |
| 174 | } | |
| 175 | ||
| 176 | /** "jane.doe@x.com" -> "ja***". Short local parts keep only the first character. */ | |
| 177 | static String mask(String email) { | |
| 178 |
3
1. mask : replaced return value with "" for com/rbctcsworld/ecommerce/review/ReviewService::mask → KILLED 2. mask : removed conditional - replaced equality check with false → KILLED 3. mask : removed conditional - replaced equality check with false → KILLED |
if (email == null || email.isBlank()) return "Customer"; |
| 179 | String local = email.substring(0, Math.max(email.indexOf('@'), 0)); | |
| 180 |
1
1. mask : removed conditional - replaced equality check with false → KILLED |
if (local.isEmpty()) local = email; |
| 181 |
3
1. mask : removed conditional - replaced comparison check with false → KILLED 2. mask : replaced return value with "" for com/rbctcsworld/ecommerce/review/ReviewService::mask → KILLED 3. mask : changed conditional boundary → KILLED |
return (local.length() <= 2 ? local.substring(0, 1) : local.substring(0, 2)) + "***"; |
| 182 | } | |
| 183 | ||
| 184 | // ---------- helpers ---------- | |
| 185 | ||
| 186 | private void recalculate(Product product) { | |
| 187 | RatingSummary s = summarize(reviews.ratingHistogram(product.getId())); | |
| 188 |
1
1. recalculate : removed call to com/rbctcsworld/ecommerce/product/Product::applyRating → KILLED |
product.applyRating(s.average(), s.count()); |
| 189 | products.save(product); | |
| 190 | } | |
| 191 | ||
| 192 | private static Comparator<Review> comparator(String sort) { | |
| 193 | Comparator<Review> newest = Comparator.comparing(Review::getCreatedAt).thenComparing(Review::getId).reversed(); | |
| 194 |
1
1. comparator : replaced return value with null for com/rbctcsworld/ecommerce/review/ReviewService::comparator → KILLED |
return switch (sort) { |
| 195 | case "highest" -> Comparator.comparingInt(Review::getRating).reversed().thenComparing(newest); | |
| 196 | case "lowest" -> Comparator.comparingInt(Review::getRating).thenComparing(newest); | |
| 197 | default -> newest; | |
| 198 | }; | |
| 199 | } | |
| 200 | ||
| 201 | private Map<Long, String> reviewerNames(List<Review> list) { | |
| 202 | Set<Long> ids = list.stream().map(Review::getUserId).collect(Collectors.toSet()); | |
| 203 |
2
1. lambda$reviewerNames$6 : replaced return value with "" for com/rbctcsworld/ecommerce/review/ReviewService::lambda$reviewerNames$6 → NO_COVERAGE 2. reviewerNames : replaced return value with Collections.emptyMap for com/rbctcsworld/ecommerce/review/ReviewService::reviewerNames → KILLED |
return users.findAllById(ids).stream().collect(Collectors.toMap(AppUser::getId, AppUser::getEmail, (a, b) -> a)); |
| 204 | } | |
| 205 | ||
| 206 | private Product lockedProduct(Long productId) { | |
| 207 |
2
1. lambda$lockedProduct$7 : replaced return value with null for com/rbctcsworld/ecommerce/review/ReviewService::lambda$lockedProduct$7 → NO_COVERAGE 2. lockedProduct : replaced return value with null for com/rbctcsworld/ecommerce/review/ReviewService::lockedProduct → KILLED |
return products.lockById(productId).orElseThrow(() -> new NotFoundException("Product not found: " + productId)); |
| 208 | } | |
| 209 | ||
| 210 | private Review owned(Long reviewId, Long userId) { | |
| 211 |
1
1. owned : replaced return value with null for com/rbctcsworld/ecommerce/review/ReviewService::owned → KILLED |
return reviews.findByIdAndUserId(reviewId, userId) |
| 212 |
1
1. lambda$owned$8 : replaced return value with null for com/rbctcsworld/ecommerce/review/ReviewService::lambda$owned$8 → KILLED |
.orElseThrow(() -> new NotFoundException("Review not found: " + reviewId)); |
| 213 | } | |
| 214 | ||
| 215 | private ReviewResponse toResponse(Review r, String reviewerEmail) { | |
| 216 |
1
1. toResponse : replaced return value with null for com/rbctcsworld/ecommerce/review/ReviewService::toResponse → KILLED |
return new ReviewResponse(r.getId(), r.getProductId(), r.getRating(), r.getTitle(), r.getBody(), |
| 217 | mask(reviewerEmail), true, r.getStatus(), r.getCreatedAt(), r.getUpdatedAt()); | |
| 218 | } | |
| 219 | ||
| 220 | private static String clean(String s) { | |
| 221 |
3
1. clean : removed conditional - replaced equality check with false → KILLED 2. clean : replaced return value with "" for com/rbctcsworld/ecommerce/review/ReviewService::clean → KILLED 3. clean : removed conditional - replaced equality check with false → KILLED |
return s == null || s.isBlank() ? null : s.trim(); |
| 222 | } | |
| 223 | ||
| 224 | private LocalDateTime now() { | |
| 225 |
1
1. now : replaced return value with null for com/rbctcsworld/ecommerce/review/ReviewService::now → KILLED |
return LocalDateTime.now(clock); |
| 226 | } | |
| 227 | ||
| 228 | private AppUser user(String email) { | |
| 229 |
2
1. lambda$user$9 : replaced return value with null for com/rbctcsworld/ecommerce/review/ReviewService::lambda$user$9 → NO_COVERAGE 2. user : replaced return value with null for com/rbctcsworld/ecommerce/review/ReviewService::user → KILLED |
return users.findByEmail(email).orElseThrow(() -> new NotFoundException("User not found")); |
| 230 | } | |
| 231 | ||
| 232 | } | |
Mutations | ||
| 72 |
1.1 |
|
| 73 |
1.1 |
|
| 76 |
1.1 |
|
| 80 |
1.1 |
|
| 81 |
1.1 |
|
| 88 |
1.1 |
|
| 90 |
1.1 |
|
| 91 |
1.1 |
|
| 98 |
1.1 |
|
| 99 |
1.1 |
|
| 100 |
1.1 |
|
| 105 |
1.1 2.2 |
|
| 112 |
1.1 2.2 |
|
| 113 |
1.1 |
|
| 117 |
1.1 |
|
| 122 |
1.1 |
|
| 125 |
1.1 |
|
| 133 |
1.1 2.2 |
|
| 137 |
1.1 2.2 |
|
| 143 |
1.1 2.2 |
|
| 147 |
1.1 |
|
| 149 |
1.1 |
|
| 151 |
1.1 |
|
| 152 |
1.1 |
|
| 160 |
1.1 2.2 3.3 |
|
| 167 |
1.1 |
|
| 168 |
1.1 2.2 |
|
| 170 |
1.1 |
|
| 173 |
1.1 |
|
| 178 |
1.1 2.2 3.3 |
|
| 180 |
1.1 |
|
| 181 |
1.1 2.2 3.3 |
|
| 188 |
1.1 |
|
| 194 |
1.1 |
|
| 203 |
1.1 2.2 |
|
| 207 |
1.1 2.2 |
|
| 211 |
1.1 |
|
| 212 |
1.1 |
|
| 216 |
1.1 |
|
| 221 |
1.1 2.2 3.3 |
|
| 225 |
1.1 |
|
| 229 |
1.1 2.2 |