diff --git a/src/main/java/com/casestudy/migroscouriertracking/common/exception/GlobalExceptionHandler.java b/src/main/java/com/casestudy/migroscouriertracking/common/exception/GlobalExceptionHandler.java index 4e2f05f..a4f95b7 100644 --- a/src/main/java/com/casestudy/migroscouriertracking/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/casestudy/migroscouriertracking/common/exception/GlobalExceptionHandler.java @@ -1,10 +1,7 @@ package com.casestudy.migroscouriertracking.common.exception; import com.casestudy.migroscouriertracking.common.model.CustomError; -import com.casestudy.migroscouriertracking.courier.exception.CourierNotFoundException; -import com.casestudy.migroscouriertracking.courier.exception.StoreFarAwayException; -import com.casestudy.migroscouriertracking.courier.exception.StoreNotFoundException; -import com.casestudy.migroscouriertracking.courier.exception.TimestampBeforeStoreCreateException; +import com.casestudy.migroscouriertracking.courier.exception.*; import jakarta.validation.ConstraintViolationException; import org.apache.commons.lang3.StringUtils; import org.springframework.http.HttpStatus; @@ -181,4 +178,22 @@ protected ResponseEntity handleTimestampBeforeStoreCreate(final Tim return new ResponseEntity<>(customError, HttpStatus.BAD_REQUEST); } + /** + * Handles StoreReentryTooSoonException thrown when a courier attempts to reenter the + * circumference of a store within a restricted time frame. + * + * @param ex the StoreReentryTooSoonException thrown + * @return ResponseEntity containing the custom error response with the exception message + */ + @ExceptionHandler(StoreReentryTooSoonException.class) + protected ResponseEntity handleStoreReentryTooSoon(final StoreReentryTooSoonException ex) { + CustomError customError = CustomError.builder() + .httpStatus(HttpStatus.CONFLICT) // Use CONFLICT status for reentry issues + .header(CustomError.Header.API_ERROR.getName()) + .message(ex.getMessage()) + .build(); + + return new ResponseEntity<>(customError, HttpStatus.CONFLICT); + } + } diff --git a/src/main/java/com/casestudy/migroscouriertracking/courier/exception/StoreReentryTooSoonException.java b/src/main/java/com/casestudy/migroscouriertracking/courier/exception/StoreReentryTooSoonException.java new file mode 100644 index 0000000..3a0f64a --- /dev/null +++ b/src/main/java/com/casestudy/migroscouriertracking/courier/exception/StoreReentryTooSoonException.java @@ -0,0 +1,18 @@ +package com.casestudy.migroscouriertracking.courier.exception; + +/** + * Exception thrown when a courier attempts to reenter the circumference of a store + * within a restricted time frame. + */ +public class StoreReentryTooSoonException extends RuntimeException { + + /** + * Constructs a new StoreReentryTooSoonException with the specified detail message. + * + * @param message the detail message explaining the reason for the exception + */ + public StoreReentryTooSoonException(String message) { + super(message); + } + +} diff --git a/src/main/java/com/casestudy/migroscouriertracking/courier/repository/CourierRepository.java b/src/main/java/com/casestudy/migroscouriertracking/courier/repository/CourierRepository.java index d6b16d0..fb2c63d 100644 --- a/src/main/java/com/casestudy/migroscouriertracking/courier/repository/CourierRepository.java +++ b/src/main/java/com/casestudy/migroscouriertracking/courier/repository/CourierRepository.java @@ -22,15 +22,15 @@ public interface CourierRepository extends JpaRepository /** * Finds a list of CourierEntities associated with the specified courier ID, store name, - * and within the provided timestamp range. + * and within the provided timestamp range, ordered by timestamp in descending order. * * @param courierId the unique identifier of the courier * @param storeName the name of the store * @param start the start timestamp of the range * @param end the end timestamp of the range - * @return a list of CourierEntities that match the given criteria + * @return a list of CourierEntities that match the given criteria, ordered by timestamp descending */ - List findByCourierIdAndStoreNameAndTimestampBetween(String courierId, String storeName, LocalDateTime start, LocalDateTime end); + List findByCourierIdAndStoreNameAndTimestampBetweenOrderByTimestampDesc(String courierId, String storeName, LocalDateTime start, LocalDateTime end); /** * Finds a list of CourierEntities associated with the specified courier ID and orders them by timestamp in ascending order. diff --git a/src/main/java/com/casestudy/migroscouriertracking/courier/service/CourierService.java b/src/main/java/com/casestudy/migroscouriertracking/courier/service/CourierService.java index b3e5405..8e9a72d 100644 --- a/src/main/java/com/casestudy/migroscouriertracking/courier/service/CourierService.java +++ b/src/main/java/com/casestudy/migroscouriertracking/courier/service/CourierService.java @@ -1,9 +1,6 @@ package com.casestudy.migroscouriertracking.courier.service; -import com.casestudy.migroscouriertracking.courier.exception.CourierNotFoundException; -import com.casestudy.migroscouriertracking.courier.exception.StoreFarAwayException; -import com.casestudy.migroscouriertracking.courier.exception.StoreNotFoundException; -import com.casestudy.migroscouriertracking.courier.exception.TimestampBeforeStoreCreateException; +import com.casestudy.migroscouriertracking.courier.exception.*; import com.casestudy.migroscouriertracking.courier.model.Courier; import com.casestudy.migroscouriertracking.courier.model.dto.request.LogCourierLocationRequest; import com.casestudy.migroscouriertracking.courier.model.dto.request.TravelQueryRequest; @@ -57,31 +54,32 @@ public void logCourierLocation(LogCourierLocationRequest logRequest) { Optional.ofNullable(stores) .orElseThrow(() -> new StoreNotFoundException("No stores found in the database.")); - boolean travelEntrySaved = stores.stream().anyMatch(store -> { - if (DistanceUtils.isWithinRadius(lat, lng, store.getLat(), store.getLng(), 100.0)) { - if (timestamp.isBefore(store.getCreatedAt())) { - throw new TimestampBeforeStoreCreateException("Timestamp is before store's creation time."); - } - - CourierEntity lastTravel = findLastTravelEntry(courierId, store.getName(), timestamp); - if (lastTravel == null || DistanceUtils.isMoreThanOneMinuteAgo(lastTravel.getTimestamp(), timestamp)) { - CourierEntity courier = CourierEntity.builder() - .courierId(courierId) - .lat(lat) - .lng(lng) - .storeName(store.getName()) - .timestamp(timestamp) - .build(); - courierRepository.save(courier); - return true; - } - } - return false; - }); - - if (!travelEntrySaved) { - throw new StoreFarAwayException("Courier is far away from all stores."); - } + stores.stream() + .filter(store -> DistanceUtils.isWithinRadius(lat, lng, store.getLat(), store.getLng(), 100.0)) + .findFirst() + .ifPresentOrElse(store -> { + if (timestamp.isBefore(store.getCreatedAt())) { + throw new TimestampBeforeStoreCreateException("Timestamp is before store's creation time."); + } + + // Find the last travel entry for the courier at this store + CourierEntity lastTravel = findLastTravelEntry(courierId, store.getName(), timestamp); + if (lastTravel == null || DistanceUtils.isMoreThanOneMinuteAgo(lastTravel.getTimestamp(), timestamp)) { + CourierEntity courier = CourierEntity.builder() + .courierId(courierId) + .lat(lat) + .lng(lng) + .storeName(store.getName()) + .timestamp(timestamp) + .build(); + courierRepository.save(courier); // Exit after saving the first valid entry + } else { + throw new StoreReentryTooSoonException("Reentry to the same store's circumference is too soon. Please wait before logging again."); + } + }, () -> { + throw new StoreFarAwayException("Courier is far away from all stores."); + }); + } /** @@ -94,9 +92,9 @@ public void logCourierLocation(LogCourierLocationRequest logRequest) { */ private CourierEntity findLastTravelEntry(String courierId, String storeName, LocalDateTime currentTimestamp) { LocalDateTime oneMinuteAgo = currentTimestamp.minusMinutes(1); - return courierRepository.findByCourierIdAndStoreNameAndTimestampBetween(courierId, storeName, oneMinuteAgo, currentTimestamp) + return courierRepository.findByCourierIdAndStoreNameAndTimestampBetweenOrderByTimestampDesc(courierId, storeName, oneMinuteAgo, currentTimestamp) .stream() - .max(Comparator.comparing(CourierEntity::getTimestamp)) + .findFirst() .orElse(null); } @@ -128,7 +126,7 @@ public List getTravelsByCourierIdStoreNameAndTimeRange(TravelQueryReque LocalDateTime start = request.getStart(); LocalDateTime end = request.getEnd(); - List entities = courierRepository.findByCourierIdAndStoreNameAndTimestampBetween(courierId, storeName, start, end); + List entities = courierRepository.findByCourierIdAndStoreNameAndTimestampBetweenOrderByTimestampDesc(courierId, storeName, start, end); Optional.ofNullable(entities) .filter(e -> !e.isEmpty()) .orElseThrow(() -> new CourierNotFoundException("No travels found for Courier ID " + courierId + " in store " + storeName + " between " + start + " and " + end + ".")); diff --git a/src/test/java/com/casestudy/migroscouriertracking/common/exception/GlobalExceptionHandlerTest.java b/src/test/java/com/casestudy/migroscouriertracking/common/exception/GlobalExceptionHandlerTest.java index 2803a6b..64a4945 100644 --- a/src/test/java/com/casestudy/migroscouriertracking/common/exception/GlobalExceptionHandlerTest.java +++ b/src/test/java/com/casestudy/migroscouriertracking/common/exception/GlobalExceptionHandlerTest.java @@ -2,10 +2,7 @@ import com.casestudy.migroscouriertracking.base.AbstractRestControllerTest; import com.casestudy.migroscouriertracking.common.model.CustomError; -import com.casestudy.migroscouriertracking.courier.exception.CourierNotFoundException; -import com.casestudy.migroscouriertracking.courier.exception.StoreFarAwayException; -import com.casestudy.migroscouriertracking.courier.exception.StoreNotFoundException; -import com.casestudy.migroscouriertracking.courier.exception.TimestampBeforeStoreCreateException; +import com.casestudy.migroscouriertracking.courier.exception.*; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; import jakarta.validation.Path; @@ -261,7 +258,28 @@ void givenTimestampBeforeStoreCreateException_whenHandleTimestampBeforeStoreCrea checkCustomError(expectedError, actualError); } + @Test + @DisplayName("Given StoreReentryTooSoonException - When HandleStoreReentryTooSoon - Then Return RespondWithConflict") + void givenStoreReentryTooSoonException_whenHandleStoreReentryTooSoon_thenReturnRespondWithConflict() { + + // Given + StoreReentryTooSoonException ex = new StoreReentryTooSoonException("Reentry to the store is too soon"); + + CustomError expectedError = CustomError.builder() + .httpStatus(HttpStatus.CONFLICT) + .header(CustomError.Header.API_ERROR.getName()) + .message("Reentry to the store is too soon") + .isSuccess(false) + .build(); + + // When + ResponseEntity responseEntity = globalExceptionHandler.handleStoreReentryTooSoon(ex); + // Then + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + CustomError actualError = responseEntity.getBody(); + checkCustomError(expectedError, actualError); + } private void checkCustomError(CustomError expectedError, CustomError actualError) { diff --git a/src/test/java/com/casestudy/migroscouriertracking/courier/service/CourierServiceTest.java b/src/test/java/com/casestudy/migroscouriertracking/courier/service/CourierServiceTest.java index 8cde859..60a41f0 100644 --- a/src/test/java/com/casestudy/migroscouriertracking/courier/service/CourierServiceTest.java +++ b/src/test/java/com/casestudy/migroscouriertracking/courier/service/CourierServiceTest.java @@ -2,6 +2,7 @@ import com.casestudy.migroscouriertracking.base.AbstractBaseServiceTest; import com.casestudy.migroscouriertracking.courier.exception.StoreFarAwayException; +import com.casestudy.migroscouriertracking.courier.exception.StoreReentryTooSoonException; import com.casestudy.migroscouriertracking.courier.exception.TimestampBeforeStoreCreateException; import com.casestudy.migroscouriertracking.courier.model.Courier; import com.casestudy.migroscouriertracking.courier.model.dto.request.LogCourierLocationRequest; @@ -23,6 +24,7 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -156,6 +158,63 @@ void logCourierLocation_shouldThrowStoreFarAwayException_ifCourierIsFarAwayFromA } + @Test + void logCourierLocation_shouldThrowStoreReentryTooSoonException_ifReenteringSameStoreTooSoon() { + + // Given + String courierId = UUID.randomUUID().toString(); + double lat = 37.7749; + double lng = -122.4194; + LocalDateTime now = LocalDateTime.now(); + LocalDateTime lastEntryTimestamp = now.minusSeconds(30); // Last entry within 30 seconds + + LogCourierLocationRequest logRequest = LogCourierLocationRequest.builder() + .courierId(courierId) + .lat(lat) + .lng(lng) + .timestamp(now) + .build(); + + StoreEntity store = StoreEntity.builder() + .id(UUID.randomUUID().toString()) + .name("store1") + .lat(37.7750) + .lng(-122.4183) + .createdAt(now.minusMinutes(10)) + .build(); + + CourierEntity lastTravelEntry = CourierEntity.builder() + .id(UUID.randomUUID().toString()) + .courierId(courierId) + .lat(lat) + .lng(lng) + .storeName(store.getName()) + .timestamp(lastEntryTimestamp) + .build(); + + // When + when(storeRepository.findAll()).thenReturn(List.of(store)); + when(courierRepository.findByCourierIdAndStoreNameAndTimestampBetweenOrderByTimestampDesc( + eq(courierId), + eq(store.getName()), + any(LocalDateTime.class), + any(LocalDateTime.class) + )).thenReturn(List.of(lastTravelEntry)); + + // Then + assertThrows(StoreReentryTooSoonException.class, () -> courierService.logCourierLocation(logRequest)); + + // Verify + verify(storeRepository).findAll(); + verify(courierRepository).findByCourierIdAndStoreNameAndTimestampBetweenOrderByTimestampDesc( + eq(courierId), + eq(store.getName()), + any(LocalDateTime.class), + any(LocalDateTime.class) + ); + + } + @Test void getPastTravelsByCourierId_shouldReturnTravelsForGivenCourierId() { @@ -212,7 +271,7 @@ void getTravelsByCourierIdStoreNameAndTimeRange_shouldReturnTravelsWithinTimeRan List couriers = courierEntityToCourierMapper.map(courierEntities); // When - when(courierRepository.findByCourierIdAndStoreNameAndTimestampBetween(request.getCourierId(), request.getStoreName(), request.getStart(), request.getEnd())).thenReturn(courierEntities); + when(courierRepository.findByCourierIdAndStoreNameAndTimestampBetweenOrderByTimestampDesc(request.getCourierId(), request.getStoreName(), request.getStart(), request.getEnd())).thenReturn(courierEntities); // Then List result = courierService.getTravelsByCourierIdStoreNameAndTimeRange(request); @@ -221,7 +280,7 @@ void getTravelsByCourierIdStoreNameAndTimeRange_shouldReturnTravelsWithinTimeRan assertEquals(couriers.get(0).getCourierId(), result.get(0).getCourierId()); // Verify - verify(courierRepository).findByCourierIdAndStoreNameAndTimestampBetween(request.getCourierId(), request.getStoreName(), request.getStart(), request.getEnd()); + verify(courierRepository).findByCourierIdAndStoreNameAndTimestampBetweenOrderByTimestampDesc(request.getCourierId(), request.getStoreName(), request.getStart(), request.getEnd()); }