package com.ruoyi.app.flashdelivery.service; import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ruoyi.app.flashdelivery.dto.FlashDeliveryAddressRequest; import com.ruoyi.app.flashdelivery.dto.FlashDeliveryAddressChangeRequest; import com.ruoyi.app.flashdelivery.dto.FlashDeliveryAddressConfirmRequest; import com.ruoyi.app.flashdelivery.dto.FlashDeliveryTipAddRequest; import com.ruoyi.app.flashdelivery.dto.FlashDeliveryQuoteView; import com.ruoyi.app.flashdelivery.dto.FlashDeliveryCreateRequest; import com.ruoyi.app.flashdelivery.dto.FlashDeliveryProofRequest; import com.ruoyi.app.flashdelivery.dto.FlashDeliveryPricingRequest; import com.ruoyi.app.flashdelivery.dto.FlashDeliveryQuoteRequest; import com.ruoyi.app.flashdelivery.dto.FlashDeliveryDeliverRequest; import com.ruoyi.app.flashdelivery.dto.FlashDeliveryRiderOrderPageView; import com.ruoyi.app.flashdelivery.exception.FlashDeliveryQuoteChangedException; import com.ruoyi.app.flashdelivery.route.FlashDeliveryRouteService; import com.ruoyi.app.flashdelivery.route.RouteDistance; import com.ruoyi.app.order.RiderDeliveryExclusivityService; import com.ruoyi.app.order.RiderDeliveryLockService; import com.ruoyi.system.domain.InfoUser; import com.ruoyi.system.domain.flash.FlashDeliveryOrder; import com.ruoyi.system.domain.flash.FlashDeliveryOrderImage; import com.ruoyi.system.domain.flash.FlashDeliveryPricing; import com.ruoyi.system.mapper.InfoUserMapper; import com.ruoyi.system.mapper.flash.FlashDeliveryOrderImageMapper; import com.ruoyi.system.mapper.flash.FlashDeliveryOrderLogMapper; import com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper; import com.ruoyi.system.mapper.flash.FlashDeliveryPricingMapper; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.AfterAll; import org.mockito.ArgumentCaptor; import org.mockito.InOrder; import org.mockito.MockedStatic; import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; import org.springframework.beans.factory.support.DefaultListableBeanFactory; import org.springframework.context.support.StaticMessageSource; import com.ruoyi.common.core.domain.entity.SysDictData; import com.ruoyi.common.utils.DictUtils; import com.ruoyi.common.utils.spring.SpringUtils; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.util.List; import java.util.Date; import java.util.Calendar; import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.ACCEPTED; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import com.ruoyi.common.exception.ServiceException; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.doThrow; class FlashDeliveryApplicationServiceTest { private static ConfigurableListableBeanFactory originalBeanFactory; @BeforeAll static void initializeMessages() { originalBeanFactory = (ConfigurableListableBeanFactory) ReflectionTestUtils.getField(SpringUtils.class, "beanFactory"); DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory(); StaticMessageSource messageSource = new StaticMessageSource(); messageSource.setUseCodeAsDefaultMessage(true); beanFactory.registerSingleton("messageSource", messageSource); new SpringUtils().postProcessBeanFactory(beanFactory); } @AfterAll static void restoreMessages() { new SpringUtils().postProcessBeanFactory(originalBeanFactory); } @Test void quoteUsesServerRouteAndCurrentTimePricing() { Fixture fixture = new Fixture(); when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing())); when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3001, 600, "ROUTE")); var quote = fixture.service.quote(request()); assertEquals(3001, quote.getDistanceMeters()); assertEquals(90L, quote.getAmount()); assertEquals(1L, quote.getPricingId()); assertEquals("00:00", quote.getStartTime()); assertEquals("24:00", quote.getEndTime()); assertEquals("ROUTE", quote.getDistanceSource()); } @Test void quoteRejectsRoutesLongerThanFortyKilometres() { Fixture fixture = new Fixture(); when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing())); when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(40001, 1800, "ROUTE")); assertThrows(ServiceException.class, () -> fixture.service.quote(request())); } @Test void wrongDeliveryPinCannotPersistProofOrChangeState() { Fixture fixture = new Fixture(); InfoUser rider = new InfoUser(); rider.setUserId(8L); rider.setUserType("2"); when(fixture.userMapper.selectInfoUserByUserId(8L)).thenReturn(rider); FlashDeliveryOrder order = new FlashDeliveryOrder(); order.setId(10L); order.setRiderId(8L); order.setStatus("PICKED_UP"); order.setPinRequired(true); order.setDeliveryPinCode("4821"); when(fixture.orderMapper.selectById(10L)).thenReturn(order); FlashDeliveryDeliverRequest request = new FlashDeliveryDeliverRequest(); request.setPinCode("1234"); request.setImageUrls(List.of("https://example.com/delivery.jpg")); assertThrows(ServiceException.class, () -> fixture.service.deliver(8L, 10L, request)); verify(fixture.imageMapper, never()).insert(any(com.ruoyi.system.domain.flash.FlashDeliveryOrderImage.class)); verify(fixture.orderMapper, never()).transitionByRider(any(), any(), any(), any(), any(), any()); } @Test void correctDeliveryPinPersistsDeliveryProofAndChangesState() { Fixture fixture = new Fixture(); InfoUser rider = new InfoUser(); rider.setUserId(8L); rider.setUserType("2"); when(fixture.userMapper.selectInfoUserByUserId(8L)).thenReturn(rider); FlashDeliveryOrder order = new FlashDeliveryOrder(); order.setId(10L); order.setRiderId(8L); order.setStatus("PICKED_UP"); order.setPinRequired(true); order.setDeliveryPinCode("4821"); when(fixture.orderMapper.selectById(10L)).thenReturn(order); when(fixture.orderMapper.transitionByRider(eq(10L), eq(8L), eq("PICKED_UP"), eq("DELIVERED"), eq("delivered_at"), any())).thenReturn(1); FlashDeliveryDeliverRequest request = new FlashDeliveryDeliverRequest(); request.setPinCode("4821"); request.setImageUrls(List.of("https://example.com/delivery.jpg")); fixture.service.deliver(8L, 10L, request); ArgumentCaptor image = ArgumentCaptor.forClass(com.ruoyi.system.domain.flash.FlashDeliveryOrderImage.class); verify(fixture.imageMapper).insert(image.capture()); assertEquals("DELIVERY", image.getValue().getProofType()); assertEquals("RIDER", image.getValue().getOperatorType()); } @Test void scheduledCreateGeneratesUserOnlyPinAndStoresRelativeSenderProof() { Fixture fixture = new Fixture(); when(fixture.userMapper.selectOrdinaryUserIdsByNormalizedPhone("0922222222")) .thenReturn(List.of(88L)); when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing())); when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE")); doAnswer(invocation -> { FlashDeliveryOrder order = invocation.getArgument(0); order.setId(99L); return 1; }).when(fixture.orderMapper).insert(any(FlashDeliveryOrder.class)); when(fixture.imageMapper.selectList(any(Wrapper.class))).thenReturn(List.of()); when(fixture.logMapper.selectList(any(Wrapper.class))).thenReturn(List.of()); FlashDeliveryCreateRequest request = createRequest(); request.setSenderImageUrls(List.of("/profile/upload/2026/09/07/sender.jpg")); var result = fixture.service.create(7L, request); assertEquals(4, result.getDeliveryPinCode().length()); ArgumentCaptor image = ArgumentCaptor.forClass(com.ruoyi.system.domain.flash.FlashDeliveryOrderImage.class); verify(fixture.imageMapper).insert(image.capture()); assertEquals("SENDER", image.getValue().getProofType()); assertEquals("USER", image.getValue().getOperatorType()); assertEquals("/profile/upload/2026/09/07/sender.jpg", image.getValue().getImageUrl()); ArgumentCaptor saved = ArgumentCaptor.forClass(FlashDeliveryOrder.class); verify(fixture.orderMapper).insert(saved.capture()); assertEquals("SCHEDULED", saved.getValue().getDeliveryMode()); assertEquals("NORMAL", saved.getValue().getDeliveryType()); assertEquals("DOCUMENT", saved.getValue().getPackageType()); assertEquals(1, saved.getValue().getQuantity()); assertEquals("UP_TO_5_KG", saved.getValue().getWeightRange()); assertEquals("30 x 20 x 10 cm", saved.getValue().getSpecification()); assertEquals(88L, saved.getValue().getReceiverUserId()); assertEquals(1L, saved.getValue().getPricingId()); assertEquals("00:00", saved.getValue().getPricingStartTime()); assertEquals("24:00", saved.getValue().getPricingEndTime()); assertEquals(new BigDecimal("3.00"), saved.getValue().getStartingDistance()); assertEquals(90L, saved.getValue().getStartingFare()); assertEquals(new BigDecimal("1.00"), saved.getValue().getDistance()); assertEquals(15L, saved.getValue().getFreight()); assertEquals(0L, saved.getValue().getDistanceFee()); assertEquals(90L, saved.getValue().getBaseDeliveryFee()); assertEquals(new BigDecimal("20.00"), saved.getValue().getUrgentRate()); assertEquals(10L, saved.getValue().getMinimumUrgentFee()); assertEquals(0L, saved.getValue().getUrgentFee()); assertEquals(0L, saved.getValue().getTipAmount()); } @Test @SuppressWarnings({"rawtypes", "unchecked"}) void userListOnlyFiltersByOwnerAndReturnsCardFields() { Fixture fixture = new Fixture(); FlashDeliveryOrder order = order(10L, "COMPLETED"); order.setWeightRange("OVER_5_TO_10_KG"); Page source = new Page<>(1, 10, 1); source.setRecords(List.of(order)); when(fixture.orderMapper.selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class), any(Wrapper.class))).thenReturn(source); var result = fixture.service.userOrders(7L, 1, 10, null); ArgumentCaptor query = ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.query.QueryWrapper.class); verify(fixture.orderMapper).selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class), query.capture()); String sql = query.getValue().getSqlSegment().toLowerCase(); var parameters = query.getValue().getParamNameValuePairs().values(); assertTrue(sql.contains("user_id")); assertFalse(sql.contains("status")); assertFalse(sql.contains("service_type")); assertTrue(parameters.contains(7L)); assertEquals("pickup road", result.getRecords().get(0).getPickupAddress()); assertEquals("OVER_5_TO_10_KG", result.getRecords().get(0).getWeightRange()); assertEquals(112L, result.getRecords().get(0).getAmount()); } @Test void createDoesNotBindReceiverWhenNormalizedPhoneMatchesMultipleUsers() { Fixture fixture = new Fixture(); when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing())); when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE")); when(fixture.userMapper.selectOrdinaryUserIdsByNormalizedPhone("886912345678")) .thenReturn(List.of(88L, 99L)); doAnswer(invocation -> { FlashDeliveryOrder order = invocation.getArgument(0); order.setId(99L); return 1; }).when(fixture.orderMapper).insert(any(FlashDeliveryOrder.class)); FlashDeliveryCreateRequest request = createRequest(); request.getDelivery().setPhone("+886 912-345-678"); fixture.service.create(7L, request); ArgumentCaptor saved = ArgumentCaptor.forClass(FlashDeliveryOrder.class); verify(fixture.orderMapper).insert(saved.capture()); assertNull(saved.getValue().getReceiverUserId()); } @Test void createLeavesReceiverUnboundWhenPhoneHasNoRegisteredUser() { Fixture fixture = new Fixture(); when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing())); when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE")); when(fixture.userMapper.selectOrdinaryUserIdsByNormalizedPhone("0922222222")) .thenReturn(List.of()); doAnswer(invocation -> { FlashDeliveryOrder order = invocation.getArgument(0); order.setId(99L); return 1; }).when(fixture.orderMapper).insert(any(FlashDeliveryOrder.class)); fixture.service.create(7L, createRequest()); ArgumentCaptor saved = ArgumentCaptor.forClass(FlashDeliveryOrder.class); verify(fixture.orderMapper).insert(saved.capture()); assertNull(saved.getValue().getReceiverUserId()); } @Test @SuppressWarnings({"rawtypes", "unchecked"}) void receiverListFiltersByCreationTimeReceiverBinding() { Fixture fixture = new Fixture(); when(fixture.orderMapper.selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class), any(Wrapper.class))).thenReturn(new Page()); fixture.service.userOrders(88L, 1, 10, "receiver"); ArgumentCaptor query = ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.query.QueryWrapper.class); verify(fixture.orderMapper).selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class), query.capture()); assertTrue(query.getValue().getSqlSegment().toLowerCase().contains("receiver_user_id")); assertTrue(query.getValue().getParamNameValuePairs().values().contains(88L)); } @Test @SuppressWarnings({"rawtypes", "unchecked"}) void riderNewTaskUsesFoodOrderParametersAndReturnsPrototypeCardSummary() { Fixture fixture = new Fixture(); rider(fixture, 8L); FlashDeliveryOrder order = order(10L, "WAITING_ACCEPTANCE"); order.setWeightRange("OVER_10_TO_15_KG"); Page source = new Page<>(1, 10, 1); source.setRecords(List.of(order)); when(fixture.orderMapper.selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class), any(Wrapper.class))).thenReturn(source); when(fixture.orderMapper.selectObjs(any(Wrapper.class))).thenReturn(List.of(168L)); SysDictData distanceLimit = new SysDictData(); distanceLimit.setDictValue("5"); FlashDeliveryRiderOrderPageView result; try (MockedStatic dictionary = mockStatic(DictUtils.class)) { dictionary.when(() -> DictUtils.getDictCache("sys_qs_newtask_distance")) .thenReturn(List.of(distanceLimit)); result = fixture.service.riderOrders(8L, 1, 10, "newTask", new BigDecimal("121.5000"), new BigDecimal("25.0000")); } assertEquals(1L, result.getNearbyTaskCount()); assertEquals(168L, result.getHighestOrderAmount()); assertEquals("pickup road", result.getRecords().get(0).getPickupAddress()); assertEquals("delivery road", result.getRecords().get(0).getDeliveryAddress()); assertEquals("OVER_10_TO_15_KG", result.getRecords().get(0).getWeightRange()); assertTrue(result.getRecords().get(0).getPickupDistanceMeters() > 0); assertTrue(result.getRecords().get(0).getPinRequired()); ArgumentCaptor query = ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.query.QueryWrapper.class); verify(fixture.orderMapper).selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class), query.capture()); String sql = query.getValue().getSqlSegment().toLowerCase(); var parameters = query.getValue().getParamNameValuePairs().values(); assertTrue(sql.contains("status")); assertTrue(sql.contains("rider_id is null")); assertTrue(sql.contains("scheduled_pickup_start_at")); assertTrue(sql.contains("st_distance_sphere")); assertTrue(parameters.contains("WAITING_ACCEPTANCE")); assertTrue(parameters.contains("NOW")); assertTrue(parameters.stream().anyMatch(Date.class::isInstance)); ArgumentCaptor summaryQuery = ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.query.QueryWrapper.class); verify(fixture.orderMapper).selectObjs(summaryQuery.capture()); String summarySql = summaryQuery.getValue().getSqlSegment().toLowerCase(); var summaryParameters = summaryQuery.getValue().getParamNameValuePairs().values(); assertTrue(summarySql.contains("rider_id is null")); assertTrue(summarySql.contains("scheduled_pickup_start_at")); assertTrue(summarySql.contains("st_distance_sphere")); assertTrue(summaryParameters.contains("WAITING_ACCEPTANCE")); } @Test @SuppressWarnings({"rawtypes", "unchecked"}) void riderNewTaskWithoutPositionFallsBackToNewestFirst() { Fixture fixture = new Fixture(); rider(fixture, 8L); when(fixture.orderMapper.selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class), any(Wrapper.class))).thenReturn(new Page()); when(fixture.orderMapper.selectObjs(any(Wrapper.class))).thenReturn(List.of()); fixture.service.riderOrders(8L, 1, 10, "newTask", null, null); ArgumentCaptor query = ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.query.QueryWrapper.class); verify(fixture.orderMapper).selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class), query.capture()); String sql = query.getValue().getSqlSegment().toLowerCase(); assertTrue(sql.contains("order by create_time desc")); assertFalse(sql.contains("st_distance_sphere")); } @Test @SuppressWarnings({"rawtypes", "unchecked"}) void riderTabsMapStatusesBeforePagingAndRestrictAssignedTabsToRider() { assertRiderTab("toPickup", List.of("ACCEPTED")); assertRiderTab("delivering", List.of("PICKED_UP")); assertRiderTab("completed", List.of("DELIVERED", "COMPLETED")); assertRiderTab("cancelled", List.of("CANCELLED")); } @Test void riderListRejectsUnknownTabAndUnpairedOrInvalidCoordinates() { Fixture fixture = new Fixture(); rider(fixture, 8L); assertThrows(ServiceException.class, () -> fixture.service.riderOrders(8L, 1, 10, "refund", null, null)); assertThrows(ServiceException.class, () -> fixture.service.riderOrders(8L, 1, 10, null, null, null)); assertThrows(ServiceException.class, () -> fixture.service.riderOrders(8L, 1, 10, "newTask", BigDecimal.ONE, null)); assertThrows(ServiceException.class, () -> fixture.service.riderOrders(8L, 1, 10, "newTask", new BigDecimal("181"), BigDecimal.ZERO)); verify(fixture.orderMapper, never()).selectPage(any(), any()); } @Test void flashNewTaskListAndAcceptRequireFlashDeliveryType() { Fixture fixture = new Fixture(); InfoUser rider = new InfoUser(); rider.setUserId(8L); rider.setUserType("2"); rider.setDeliveryType("FOOD"); when(fixture.userMapper.selectInfoUserByUserId(8L)).thenReturn(rider); assertThrows(ServiceException.class, () -> fixture.service.riderOrders(8L, 1, 10, "newTask", null, null)); assertThrows(ServiceException.class, () -> fixture.service.accept(8L, 10L)); verify(fixture.orderMapper, never()).selectPage(any(), any()); verify(fixture.orderMapper, never()).accept(any(), any(), any()); } @Test void riderDetailHidesWaitingFlashOrdersFromNonFlashRiders() { Fixture fixture = new Fixture(); InfoUser rider = new InfoUser(); rider.setUserId(8L); rider.setUserType("2"); rider.setDeliveryType("FOOD"); when(fixture.userMapper.selectInfoUserByUserId(8L)).thenReturn(rider); when(fixture.orderMapper.selectById(10L)).thenReturn(order(10L, "WAITING_ACCEPTANCE")); assertThrows(ServiceException.class, () -> fixture.service.riderDetail(8L, 10L)); verify(fixture.imageMapper, never()).selectList(any()); } @Test @SuppressWarnings({"rawtypes", "unchecked"}) void enabledFlashRiderStillListsNewTasks() { Fixture fixture = new Fixture(); InfoUser rider = new InfoUser(); rider.setUserId(8L); rider.setUserType("2"); rider.setDeliveryType("FLASH"); when(fixture.userMapper.selectInfoUserByUserId(8L)).thenReturn(rider); when(fixture.orderMapper.selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class), any(Wrapper.class))).thenReturn(new Page()); fixture.service.riderOrders(8L, 1, 10, "newTask", null, null); verify(fixture.orderMapper).selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class), any(Wrapper.class)); } @Test @SuppressWarnings({"rawtypes", "unchecked"}) void ownOrderTabsRemainAccessibleRegardlessOfDeliveryType() { Fixture fixture = new Fixture(); InfoUser rider = new InfoUser(); rider.setUserId(8L); rider.setUserType("2"); rider.setDeliveryType("FOOD"); when(fixture.userMapper.selectInfoUserByUserId(8L)).thenReturn(rider); when(fixture.orderMapper.selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class), any(Wrapper.class))).thenReturn(new Page()); fixture.service.riderOrders(8L, 1, 10, "toPickup", null, null); verify(fixture.orderMapper).selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class), any(Wrapper.class)); } @Test void assignedRiderTabsIgnoreLocationParameters() { Fixture fixture = new Fixture(); rider(fixture, 8L); when(fixture.orderMapper.selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class), any(Wrapper.class))).thenReturn(new Page()); fixture.service.riderOrders(8L, 1, 10, "toPickup", new BigDecimal("181"), new BigDecimal("91")); verify(fixture.orderMapper).selectPage(any(), any()); } @Test void preAcceptRiderDetailIsDirectAndShowsTextAddressWithoutSensitiveFields() { Fixture fixture = new Fixture(); rider(fixture, 8L); FlashDeliveryOrder order = order(10L, "WAITING_ACCEPTANCE"); order.setWeightRange("OVER_15_TO_20_KG"); when(fixture.orderMapper.selectById(10L)).thenReturn(order); var result = fixture.service.riderDetail(8L, 10L); assertEquals(10L, result.getId()); assertEquals("pickup road", result.getPickup().getAddress()); assertEquals("pickup door", result.getPickup().getAddressDetail()); assertEquals("delivery road", result.getDelivery().getAddress()); assertEquals("delivery door", result.getDelivery().getAddressDetail()); assertEquals("OVER_15_TO_20_KG", result.getWeightRange()); assertNull(result.getPickup().getName()); assertNull(result.getPickup().getPhone()); assertNull(result.getPickup().getLongitude()); assertNull(result.getPickup().getLatitude()); assertNull(result.getDelivery().getName()); assertNull(result.getDelivery().getPhone()); assertNull(result.getDelivery().getLongitude()); assertNull(result.getDelivery().getLatitude()); assertNull(result.getDeliveryPinCode()); assertNull(result.getUserNote()); assertTrue(result.getSenderImageUrls().isEmpty()); assertTrue(result.getPickupImageUrls().isEmpty()); assertTrue(result.getDeliveryImageUrls().isEmpty()); verify(fixture.imageMapper, never()).selectList(any()); verify(fixture.logMapper, never()).selectList(any()); } @Test void userDetailReturnsDirectOrderAndGroupsImageUrlsWithoutRawLogs() { Fixture fixture = new Fixture(); FlashDeliveryOrder order = order(10L, ACCEPTED); order.setRiderId(null); order.setWeightRange("OVER_5_TO_10_KG"); when(fixture.orderMapper.selectById(10L)).thenReturn(order); when(fixture.imageMapper.selectList(any(Wrapper.class))).thenReturn(List.of( image("SENDER", "https://example.com/sender.jpg"), image("PICKUP", "https://example.com/pickup.jpg"), image("DELIVERY", "https://example.com/delivery.jpg"))); var result = fixture.service.userDetail(7L, 10L); assertEquals(10L, result.getId()); assertEquals(List.of("https://example.com/sender.jpg"), result.getSenderImageUrls()); assertEquals(List.of("https://example.com/pickup.jpg"), result.getPickupImageUrls()); assertEquals(List.of("https://example.com/delivery.jpg"), result.getDeliveryImageUrls()); assertEquals("4821", result.getDeliveryPinCode()); assertEquals("OVER_5_TO_10_KG", result.getWeightRange()); verify(fixture.logMapper, never()).selectList(any()); } @Test void matchedReceiverCanViewAndConfirmButCannotCancelOrder() { Fixture fixture = new Fixture(); FlashDeliveryOrder order = order(10L, "DELIVERED"); order.setReceiverUserId(88L); when(fixture.orderMapper.selectById(10L)).thenReturn(order); when(fixture.orderMapper.transitionByParticipant(eq(10L), eq(88L), eq("DELIVERED"), eq("COMPLETED"), any(Date.class))).thenReturn(1); when(fixture.imageMapper.selectList(any(Wrapper.class))).thenReturn(List.of()); assertDoesNotThrow(() -> fixture.service.userDetail(88L, 10L)); assertDoesNotThrow(() -> fixture.service.confirmReceipt(88L, 10L)); assertThrows(ServiceException.class, () -> fixture.service.userCancel(88L, 10L, new com.ruoyi.app.flashdelivery.dto.FlashDeliveryReasonRequest())); verify(fixture.orderMapper).transitionByParticipant(eq(10L), eq(88L), eq("DELIVERED"), eq("COMPLETED"), any(Date.class)); } @Test void orderWithoutCreationTimeReceiverBindingIsNotClaimedLater() { Fixture fixture = new Fixture(); FlashDeliveryOrder historicalOrder = order(10L, "DELIVERED"); historicalOrder.setReceiverUserId(null); when(fixture.orderMapper.selectById(10L)).thenReturn(historicalOrder); assertThrows(ServiceException.class, () -> fixture.service.userDetail(88L, 10L)); verify(fixture.userMapper, never()).selectOrdinaryUserIdsByNormalizedPhone(any()); } @Test void pickupRejectsMissingProofBeforeChangingState() { Fixture fixture = new Fixture(); InfoUser rider = new InfoUser(); rider.setUserId(8L); rider.setUserType("2"); when(fixture.userMapper.selectInfoUserByUserId(8L)).thenReturn(rider); FlashDeliveryProofRequest request = new FlashDeliveryProofRequest(); request.setImageUrls(List.of()); assertThrows(RuntimeException.class, () -> fixture.service.pickup(8L, 10L, request)); verify(fixture.orderMapper, never()).transitionByRider(any(), any(), any(), any(), any(), any()); } @Test void atomicAcceptFailureDoesNotBindSecondRider() { Fixture fixture = new Fixture(); InfoUser rider = new InfoUser(); rider.setUserId(8L); rider.setUserType("2"); rider.setDeliveryType("FLASH"); when(fixture.userMapper.selectInfoUserByUserId(8L)).thenReturn(rider); when(fixture.orderMapper.selectById(10L)).thenReturn(order(10L, "WAITING_ACCEPTANCE")); when(fixture.orderMapper.accept(eq(10L), eq(8L), any())).thenReturn(0); assertThrows(RuntimeException.class, () -> fixture.service.accept(8L, 10L)); } @Test void flashAcceptUsesRiderLockAndChecksExclusivityBeforeAtomicUpdate() { Fixture fixture = new Fixture(); rider(fixture, 8L); FlashDeliveryOrder waiting = order(10L, "WAITING_ACCEPTANCE"); FlashDeliveryOrder accepted = order(10L, ACCEPTED); accepted.setRiderId(8L); when(fixture.orderMapper.selectById(10L)).thenReturn(waiting, accepted); when(fixture.orderMapper.accept(eq(10L), eq(8L), any(Date.class))).thenReturn(1); fixture.service.accept(8L, 10L); InOrder order = inOrder(fixture.lockService, fixture.exclusivityService, fixture.orderMapper); order.verify(fixture.lockService).withLock(eq(8L), any(RiderDeliveryLockService.LockedCall.class)); order.verify(fixture.exclusivityService).assertCanAcceptFlash(8L, "NORMAL"); order.verify(fixture.orderMapper).accept(eq(10L), eq(8L), any(Date.class)); } @Test void flashExclusiveConflictDoesNotUpdateOrder() { Fixture fixture = new Fixture(); rider(fixture, 8L); when(fixture.orderMapper.selectById(10L)).thenReturn(order(10L, "WAITING_ACCEPTANCE")); doThrow(new ServiceException("exclusive")) .when(fixture.exclusivityService).assertCanAcceptFlash(8L, "NORMAL"); assertThrows(ServiceException.class, () -> fixture.service.accept(8L, 10L)); verify(fixture.orderMapper, never()).accept(any(), any(), any()); } @Test void oversizedProofUrlIsRejectedBeforeStateTransition() { Fixture fixture = new Fixture(); InfoUser rider = new InfoUser(); rider.setUserId(8L); rider.setUserType("2"); when(fixture.userMapper.selectInfoUserByUserId(8L)).thenReturn(rider); FlashDeliveryProofRequest request = new FlashDeliveryProofRequest(); request.setImageUrls(List.of("https://example.com/" + "a".repeat(1000))); assertThrows(RuntimeException.class, () -> fixture.service.pickup(8L, 10L, request)); verify(fixture.orderMapper, never()).transitionByRider(any(), any(), any(), any(), any(), any()); } @Test void missingServiceTypeReturnsBusinessErrorInsteadOfNullPointer() { Fixture fixture = new Fixture(); FlashDeliveryQuoteRequest request = request(); request.setServiceType(null); assertThrows(ServiceException.class, () -> fixture.service.quote(request)); } @Test void equalTextAddressesAreRejectedEvenWhenCoordinatesDiffer() { Fixture fixture = new Fixture(); FlashDeliveryQuoteRequest request = request(); request.getDelivery().setAddress(request.getPickup().getAddress()); request.getDelivery().setAddressDetail(request.getPickup().getAddressDetail()); assertThrows(ServiceException.class, () -> fixture.service.quote(request)); } @Test void concurrentPricingUpdateIsRejected() { Fixture fixture = new Fixture(); when(fixture.pricingMapper.selectById(1L)).thenReturn(pricing()); when(fixture.pricingMapper.countOverlapping("00:00", "12:00", 1L)).thenReturn(0); when(fixture.pricingMapper.update(isNull(), any(Wrapper.class))).thenReturn(0); FlashDeliveryPricingRequest request = pricingRequest(); request.setEndTime("12:00"); request.setConfigVersion(1); assertThrows(ServiceException.class, () -> fixture.service.updatePricing(1L, 1L, request)); verify(fixture.pricingMapper, never()).updateById(any(FlashDeliveryPricing.class)); } @Test void overlappingPricingPeriodIsRejectedBeforeInsert() { Fixture fixture = new Fixture(); FlashDeliveryPricingRequest request = pricingRequest(); when(fixture.pricingMapper.countOverlapping("00:00", "24:00", null)).thenReturn(1); assertThrows(ServiceException.class, () -> fixture.service.createPricing(1L, request)); request.setDistance(new BigDecimal("0.004")); assertThrows(ServiceException.class, () -> fixture.service.createPricing(1L, request)); verify(fixture.pricingMapper, never()).insert(any(FlashDeliveryPricing.class)); } @Test void pricingDistanceHalfCentimetreNormalizesToDatabaseMinimum() { Fixture fixture = new Fixture(); FlashDeliveryPricingRequest request = pricingRequest(); request.setDistance(new BigDecimal("0.005")); fixture.service.createPricing(1L, request); ArgumentCaptor saved = ArgumentCaptor.forClass(FlashDeliveryPricing.class); verify(fixture.pricingMapper).insert(saved.capture()); assertEquals(new BigDecimal("0.01"), saved.getValue().getDistance()); } @Test void pricingDistanceThatRoundsToZeroIsRejectedBeforePersistence() { Fixture fixture = new Fixture(); FlashDeliveryPricingRequest request = pricingRequest(); request.setDistance(new BigDecimal("0.001")); assertThrows(ServiceException.class, () -> fixture.service.createPricing(1L, request)); verify(fixture.pricingMapper, never()).insert(any(FlashDeliveryPricing.class)); } @Test void createPricingPersistsImmediatelyActiveTimePeriod() { Fixture fixture = new Fixture(); FlashDeliveryPricingRequest request = pricingRequest(); when(fixture.pricingMapper.countOverlapping("00:00", "24:00", null)).thenReturn(0); fixture.service.createPricing(7L, request); ArgumentCaptor saved = ArgumentCaptor.forClass(FlashDeliveryPricing.class); verify(fixture.pricingMapper).insert(saved.capture()); assertEquals(1, saved.getValue().getConfigVersion()); assertEquals(7L, saved.getValue().getUpdatedBy()); assertEquals(90L, saved.getValue().getStartingFare()); InOrder writeOrder = inOrder(fixture.pricingMapper); writeOrder.verify(fixture.pricingMapper) .countOverlapping("00:00", "24:00", null); writeOrder.verify(fixture.pricingMapper).insert(any(FlashDeliveryPricing.class)); } @Test void homeOmitsServiceWithoutCurrentPricingPeriod() { Fixture fixture = new Fixture(); assertTrue(fixture.service.home().getServices().isEmpty()); } @Test void quoteWithoutCurrentPricingPeriodFailsBeforeRouteLookup() { Fixture fixture = new Fixture(); assertThrows(ServiceException.class, () -> fixture.service.quote(request())); verify(fixture.routeService, never()).calculate(any(), any()); } @Test void multipleCurrentPricingPeriodsFailExplicitlyBeforeRouteLookup() { Fixture fixture = new Fixture(); when(fixture.pricingMapper.selectAtTime(anyString())) .thenReturn(List.of(pricing(), pricing())); assertThrows(ServiceException.class, () -> fixture.service.quote(request())); verify(fixture.routeService, never()).calculate(any(), any()); } @Test void updatingPricingChecksOverlapBeforeVersionedWrite() { Fixture fixture = new Fixture(); FlashDeliveryPricing existing = pricing(); when(fixture.pricingMapper.selectById(1L)).thenReturn(existing); when(fixture.pricingMapper.update(isNull(), any(Wrapper.class))).thenReturn(1); FlashDeliveryPricingRequest request = pricingRequest(); request.setConfigVersion(1); fixture.service.updatePricing(9L, 1L, request); InOrder writeOrder = inOrder(fixture.pricingMapper); writeOrder.verify(fixture.pricingMapper) .countOverlapping("00:00", "24:00", 1L); writeOrder.verify(fixture.pricingMapper).update(isNull(), any(Wrapper.class)); } @Test void deletePricingRemovesConfigurationImmediately() { Fixture fixture = new Fixture(); when(fixture.pricingMapper.selectById(18L)).thenReturn(pricing()); when(fixture.pricingMapper.deleteById(18L)).thenReturn(1); fixture.service.deletePricing(18L); verify(fixture.pricingMapper).deleteById(18L); } @Test void urgentQuoteReturnsSeparatedFeesAndUsesScheduledPickupTime() { Fixture fixture = new Fixture(); when(fixture.pricingMapper.selectAtTime("16:30")).thenReturn(List.of(pricing())); when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(6000, 900, "ROUTE")); FlashDeliveryQuoteRequest request = request(); request.setDeliveryType("URGENT"); request.setTipAmount(5L); request.setDeliveryMode("SCHEDULED"); Calendar calendar = Calendar.getInstance(); calendar.add(Calendar.DAY_OF_MONTH, 1); calendar.set(Calendar.HOUR_OF_DAY, 16); calendar.set(Calendar.MINUTE, 30); calendar.set(Calendar.SECOND, 0); calendar.set(Calendar.MILLISECOND, 0); request.setScheduledPickupStartAt(calendar.getTime()); request.setScheduledPickupEndAt(new Date(calendar.getTimeInMillis() + 30L * 60L * 1000L)); var quote = fixture.service.quote(request); assertEquals(45L, quote.getDistanceFee()); assertEquals(135L, quote.getBaseDeliveryFee()); assertEquals(27L, quote.getUrgentFee()); assertEquals(5L, quote.getTipAmount()); assertEquals(167L, quote.getAmount()); verify(fixture.pricingMapper).selectAtTime("16:30"); } @Test void createRejectsChangedQuoteAndReturnsLatestQuoteWithoutInsert() { Fixture fixture = new Fixture(); when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing())); when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE")); FlashDeliveryCreateRequest request = createRequest(); request.setQuotedAmount(91L); FlashDeliveryQuoteChangedException error = assertThrows(FlashDeliveryQuoteChangedException.class, () -> fixture.service.create(7L, request)); assertEquals(90L, error.getLatestQuote().getAmount()); assertEquals(1L, error.getLatestQuote().getPricingId()); verify(fixture.orderMapper, never()).insert(any(FlashDeliveryOrder.class)); } @Test void createDefaultsPayTypeToCashAndStoresTransferSelection() { Fixture fixture = new Fixture(); when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing())); when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE")); doAnswer(invocation -> { FlashDeliveryOrder order = invocation.getArgument(0); order.setId(99L); return 1; }).when(fixture.orderMapper).insert(any(FlashDeliveryOrder.class)); when(fixture.imageMapper.selectList(any(Wrapper.class))).thenReturn(List.of()); when(fixture.logMapper.selectList(any(Wrapper.class))).thenReturn(List.of()); var defaultResult = fixture.service.create(7L, createRequest()); FlashDeliveryCreateRequest transfer = createRequest(); transfer.setPayType("6"); var transferResult = fixture.service.create(7L, transfer); ArgumentCaptor saved = ArgumentCaptor.forClass(FlashDeliveryOrder.class); verify(fixture.orderMapper, times(2)).insert(saved.capture()); assertEquals("4", saved.getAllValues().get(0).getPayType()); assertEquals("6", saved.getAllValues().get(1).getPayType()); assertEquals("4", defaultResult.getPayType()); assertEquals("6", transferResult.getPayType()); } @Test void createRejectsUnsupportedPayType() { Fixture fixture = new Fixture(); when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing())); when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE")); FlashDeliveryCreateRequest request = createRequest(); request.setPayType("2"); assertThrows(ServiceException.class, () -> fixture.service.create(7L, request)); verify(fixture.orderMapper, never()).insert(any(FlashDeliveryOrder.class)); } @Test void quoteValidatesDeliveryLevelAndCompleteItemInformation() { Fixture fixture = new Fixture(); FlashDeliveryQuoteRequest request = request(); request.setDeliveryType("FAST"); FlashDeliveryQuoteRequest invalidDeliveryType = request; assertThrows(ServiceException.class, () -> fixture.service.quote(invalidDeliveryType)); request = request(); request.setQuantity(0); FlashDeliveryQuoteRequest invalidQuantity = request; assertThrows(ServiceException.class, () -> fixture.service.quote(invalidQuantity)); request = request(); request.setWeightRange("SMALL"); FlashDeliveryQuoteRequest oldWeightRange = request; assertThrows(ServiceException.class, () -> fixture.service.quote(oldWeightRange)); } @Test void quoteAcceptsAllFourWeightRanges() { Fixture fixture = new Fixture(); when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing())); when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE")); for (String weightRange : List.of("UP_TO_5_KG", "OVER_5_TO_10_KG", "OVER_10_TO_15_KG", "OVER_15_TO_20_KG")) { FlashDeliveryQuoteRequest request = request(); request.setWeightRange(weightRange); var quote = assertDoesNotThrow(() -> fixture.service.quote(request), weightRange); assertEquals(90L, quote.getAmount(), weightRange); } } @Test void createPersistsAndReturnsEveryWeightRange() { for (String weightRange : List.of("UP_TO_5_KG", "OVER_5_TO_10_KG", "OVER_10_TO_15_KG", "OVER_15_TO_20_KG")) { Fixture fixture = new Fixture(); when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing())); when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE")); doAnswer(invocation -> { FlashDeliveryOrder order = invocation.getArgument(0); order.setId(99L); return 1; }).when(fixture.orderMapper).insert(any(FlashDeliveryOrder.class)); when(fixture.imageMapper.selectList(any(Wrapper.class))).thenReturn(List.of()); when(fixture.logMapper.selectList(any(Wrapper.class))).thenReturn(List.of()); FlashDeliveryCreateRequest request = createRequest(); request.setWeightRange(weightRange); var result = fixture.service.create(7L, request); ArgumentCaptor saved = ArgumentCaptor.forClass(FlashDeliveryOrder.class); verify(fixture.orderMapper).insert(saved.capture()); assertEquals(weightRange, saved.getValue().getWeightRange()); assertEquals(weightRange, result.getWeightRange()); } } @Test void quoteRejectsMissingAndUnknownWeightRanges() { Fixture fixture = new Fixture(); for (String weightRange : new String[]{null, "", "SMALL", "OVER_20_KG"}) { FlashDeliveryQuoteRequest request = request(); request.setWeightRange(weightRange); assertThrows(ServiceException.class, () -> fixture.service.quote(request), String.valueOf(weightRange)); } } private FlashDeliveryQuoteRequest request() { FlashDeliveryQuoteRequest request = new FlashDeliveryQuoteRequest(); request.setServiceType("HELP_SEND"); request.setDeliveryType("NORMAL"); request.setPackageType("DOCUMENT"); request.setQuantity(1); request.setWeightRange("UP_TO_5_KG"); request.setSpecification("30 x 20 x 10 cm"); request.setTipAmount(0L); request.setDeliveryMode("NOW"); request.setPickup(address("A", "0911111111", "25.0330", "121.5645")); request.setDelivery(address("B", "0922222222", "25.0478", "121.5319")); request.getDelivery().setAddress("second road"); return request; } private FlashDeliveryCreateRequest createRequest() { FlashDeliveryCreateRequest request = new FlashDeliveryCreateRequest(); FlashDeliveryQuoteRequest quote = request(); request.setServiceType(quote.getServiceType()); request.setDeliveryType(quote.getDeliveryType()); request.setPackageType(quote.getPackageType()); request.setQuantity(quote.getQuantity()); request.setWeightRange(quote.getWeightRange()); request.setSpecification(quote.getSpecification()); request.setTipAmount(quote.getTipAmount()); request.setPickup(quote.getPickup()); request.setDelivery(quote.getDelivery()); request.setClientRequestId("flash-scheduled-001"); request.setDeliveryMode("SCHEDULED"); Date start = new Date(System.currentTimeMillis() + 60L * 60L * 1000L); request.setScheduledPickupStartAt(start); request.setScheduledPickupEndAt(new Date(start.getTime() + 30L * 60L * 1000L)); request.setPinRequired(true); request.setPricingId(1L); request.setPricingVersion(1); request.setQuotedBaseDeliveryFee(90L); request.setQuotedDistanceFee(0L); request.setQuotedUrgentFee(0L); request.setQuotedAmount(90L); request.setSenderImageUrls(List.of("https://example.com/sender.jpg")); return request; } private FlashDeliveryAddressRequest address(String name, String phone, String lat, String lon) { FlashDeliveryAddressRequest address = new FlashDeliveryAddressRequest(); address.setName(name); address.setPhone(phone); address.setAddress("road"); address.setAddressDetail("door"); address.setLatitude(new BigDecimal(lat)); address.setLongitude(new BigDecimal(lon)); return address; } private FlashDeliveryPricing pricing() { FlashDeliveryPricing pricing = new FlashDeliveryPricing(); pricing.setId(1L); pricing.setStartTime("00:00"); pricing.setEndTime("24:00"); pricing.setStartingDistance(new BigDecimal("3.00")); pricing.setStartingFare(90L); pricing.setDistance(new BigDecimal("1.00")); pricing.setFreight(15L); pricing.setUrgentRate(new BigDecimal("20.00")); pricing.setMinimumUrgentFee(10L); pricing.setConfigVersion(1); return pricing; } private FlashDeliveryPricingRequest pricingRequest() { FlashDeliveryPricingRequest request = new FlashDeliveryPricingRequest(); request.setStartTime("00:00"); request.setEndTime("24:00"); request.setStartingDistance(new BigDecimal("3.00")); request.setStartingFare(90L); request.setDistance(new BigDecimal("1.00")); request.setFreight(15L); request.setUrgentRate(new BigDecimal("20.00")); request.setMinimumUrgentFee(10L); return request; } @SuppressWarnings({"rawtypes", "unchecked"}) private void assertRiderTab(String tab, List statuses) { Fixture fixture = new Fixture(); rider(fixture, 8L); when(fixture.orderMapper.selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class), any(Wrapper.class))).thenReturn(new Page()); fixture.service.riderOrders(8L, 1, 10, tab, null, null); ArgumentCaptor query = ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.query.QueryWrapper.class); verify(fixture.orderMapper).selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class), query.capture()); // MyBatis-Plus 的条件参数在渲染 SQL 段时才写入参数表,必须先取 SQL 再断言参数。 String sql = query.getValue().getSqlSegment(); assertTrue(sql.contains("rider_id"), tab); var parameters = query.getValue().getParamNameValuePairs().values(); assertTrue(parameters.contains(8L), tab); for (String status : statuses) assertTrue(parameters.contains(status), tab + ":" + status); } private void rider(Fixture fixture, Long riderId) { InfoUser rider = new InfoUser(); rider.setUserId(riderId); rider.setUserType("2"); rider.setDeliveryType("FLASH"); when(fixture.userMapper.selectInfoUserByUserId(riderId)).thenReturn(rider); } private FlashDeliveryOrder order(Long id, String status) { FlashDeliveryOrder order = new FlashDeliveryOrder(); order.setId(id); order.setOrderNo("FL-20260813-021"); order.setUserId(7L); order.setServiceType("HELP_SEND"); order.setDeliveryType("NORMAL"); order.setStatus(status); order.setPackageType("DOCUMENT"); order.setQuantity(1); order.setWeightRange("UP_TO_5_KG"); order.setDeliveryMode("NOW"); order.setPinRequired(true); order.setDeliveryPinCode("4821"); order.setPickupName("pickup contact"); order.setPickupPhone("0911111111"); order.setPickupAddress("pickup road"); order.setPickupAddressDetail("pickup door"); order.setPickupCity("台北市"); order.setPickupArea("中山區"); order.setPickupLongitude(new BigDecimal("121.5100")); order.setPickupLatitude(new BigDecimal("25.0100")); order.setDeliveryName("delivery contact"); order.setDeliveryPhone("0922222222"); order.setDeliveryAddress("delivery road"); order.setDeliveryAddressDetail("delivery door"); order.setDeliveryCity("台北市"); order.setDeliveryArea("大安區"); order.setDeliveryLongitude(new BigDecimal("121.5200")); order.setDeliveryLatitude(new BigDecimal("25.0200")); order.setDistanceMeters(3200); order.setEstimatedDurationSeconds(2100); order.setDistanceFee(12L); order.setBaseDeliveryFee(102L); order.setUrgentRate(new BigDecimal("20.00")); order.setMinimumUrgentFee(10L); order.setUrgentFee(0L); order.setTipAmount(10L); order.setAmount(112L); order.setCurrency("TWD"); order.setUserNote("private note"); order.setCreateTime(new Date()); return order; } private FlashDeliveryOrderImage image(String proofType, String url) { FlashDeliveryOrderImage image = new FlashDeliveryOrderImage(); image.setProofType(proofType); image.setImageUrl(url); return image; } @Test void missingOrInvalidBodyOrderIdIsRejectedBeforeDatabaseAccess() { Fixture fixture = new Fixture(); for (Long orderId : java.util.Arrays.asList(null, 0L, -1L)) { ServiceException addressFailure = assertThrows(ServiceException.class, () -> fixture.service.updateAddress(7L, orderId, new FlashDeliveryAddressConfirmRequest())); ServiceException tipFailure = assertThrows(ServiceException.class, () -> fixture.service.addTip(7L, orderId, tipRequest(20L, 2))); assertEquals("flash.delivery.order.not.found", addressFailure.getMessage()); assertEquals("flash.delivery.order.not.found", tipFailure.getMessage()); } org.mockito.Mockito.verifyNoInteractions(fixture.orderMapper); } @Test void addressQuoteUsesLatestPricingForSlotTimeAndDoesNotWrite() { Fixture fixture = new Fixture(); FlashDeliveryOrder order = editableOrder(fixture); // 已到预约时间也允许待接单订单改地址;报价读取该时点的最新运价而非订单旧快照。 order.setDeliveryMode("SCHEDULED"); order.setScheduledPickupStartAt(new Date(1)); order.setScheduledPickupEndAt(new Date(1800001)); FlashDeliveryQuoteView quote = fixture.service.quoteAddress(7L, 10L, addressChange()); assertEquals(4500, quote.getDistanceMeters()); assertEquals(900, quote.getEstimatedDurationSeconds()); assertEquals(23L, quote.getDistanceFee()); assertEquals(113L, quote.getBaseDeliveryFee()); assertEquals(10L, quote.getTipAmount()); assertEquals(123L, quote.getAmount()); assertEquals(2, quote.getOrderVersion()); assertEquals(1, quote.getPricingVersion()); assertEquals("pickup road", order.getPickupAddress()); // 改址报价必须查询运价表(按定价时点取最新),不再沿用订单快照 verify(fixture.pricingMapper).selectAtTime(anyString()); verify(fixture.orderMapper, never()).updateWaitingAddress(any(), any()); verify(fixture.logMapper, never()).insert(any(com.ruoyi.system.domain.flash.FlashDeliveryOrderLog.class)); } @Test void addressSaveUpdatesBothEndsRouteFeesAndReceiverTogether() { Fixture fixture = new Fixture(); FlashDeliveryOrder order = editableOrder(fixture); when(fixture.userMapper.selectOrdinaryUserIdsByNormalizedPhone("0922222222")) .thenReturn(List.of(88L)); when(fixture.orderMapper.updateWaitingAddress(any(), eq(2))).thenReturn(1); FlashDeliveryAddressConfirmRequest input = confirmAddress(fixture); input.getPickup().setName(" new sender "); input.getPickup().setAddressDetail(null); input.getDelivery().setName("new receiver"); var view = fixture.service.updateAddress(7L, 10L, input); assertEquals("new sender", view.getPickup().getName()); assertEquals("new receiver", view.getDelivery().getName()); assertNull(view.getPickup().getAddressDetail()); assertEquals(4500, view.getDistanceMeters()); assertEquals(123L, view.getAmount()); assertEquals(3, view.getOrderVersion()); assertEquals(88L, order.getReceiverUserId()); assertEquals(10L, order.getTipAmount()); assertEquals("WAITING_ACCEPTANCE", order.getStatus()); verify(fixture.orderMapper).updateWaitingAddress(order, 2); verify(fixture.logMapper).insert(any(com.ruoyi.system.domain.flash.FlashDeliveryOrderLog.class)); } @Test void addressChangeClearsFormerReceiverWhenNewPhoneDoesNotMatch() { Fixture fixture = new Fixture(); FlashDeliveryOrder order = editableOrder(fixture); order.setReceiverUserId(88L); when(fixture.orderMapper.updateWaitingAddress(any(), eq(2))).thenReturn(1); fixture.service.updateAddress(7L, 10L, confirmAddress(fixture)); assertNull(order.getReceiverUserId()); } @Test void addressQuotePreservesUrgentRulesAndExistingTip() { Fixture fixture = new Fixture(); FlashDeliveryOrder order = editableOrder(fixture); order.setDeliveryType("URGENT"); var quote = fixture.service.quoteAddress(7L, 10L, addressChange()); assertEquals(23L, quote.getUrgentFee()); assertEquals(146L, quote.getAmount()); assertEquals(10L, quote.getTipAmount()); } @Test void changedRouteOrUnconfirmedPriceReturnsLatestQuoteWithoutSaving() { Fixture fixture = new Fixture(); editableOrder(fixture); FlashDeliveryAddressConfirmRequest input = confirmAddress(fixture); input.setQuotedAmount(1L); var exception = assertThrows(FlashDeliveryQuoteChangedException.class, () -> fixture.service.updateAddress(7L, 10L, input)); assertEquals(123L, exception.getLatestQuote().getAmount()); input.setQuotedAmount(123L); input.setQuotedDistanceMeters(4499); assertThrows(FlashDeliveryQuoteChangedException.class, () -> fixture.service.updateAddress(7L, 10L, input)); input.setQuotedDistanceMeters(4500); input.setQuotedUrgentFee(null); assertThrows(FlashDeliveryQuoteChangedException.class, () -> fixture.service.updateAddress(7L, 10L, input)); verify(fixture.orderMapper, never()).updateWaitingAddress(any(), any()); verify(fixture.logMapper, never()).insert(any(com.ruoyi.system.domain.flash.FlashDeliveryOrderLog.class)); } @Test void receiverAndOtherUsersCannotQuoteEditOrAddTip() { Fixture fixture = new Fixture(); FlashDeliveryOrder order = editableOrder(fixture); order.setReceiverUserId(88L); for (Long user : List.of(88L, 99L)) { assertThrows(ServiceException.class, () -> fixture.service.quoteAddress(user, 10L, addressChange())); assertThrows(ServiceException.class, () -> fixture.service.updateAddress(user, 10L, new FlashDeliveryAddressConfirmRequest())); assertThrows(ServiceException.class, () -> fixture.service.addTip(user, 10L, tipRequest(5L, 2))); } verify(fixture.routeService, never()).calculate(any(), any()); verify(fixture.orderMapper, never()).updateWaitingTip(any(), any()); } @Test void everyNonWaitingStateAndAssignedRiderRejectsBothChanges() { for (String status : List.of("ACCEPTED", "PICKED_UP", "DELIVERED", "COMPLETED", "CANCELLED")) { Fixture fixture = new Fixture(); FlashDeliveryOrder order = editableOrder(fixture); order.setStatus(status); assertThrows(ServiceException.class, () -> fixture.service.quoteAddress(7L, 10L, addressChange())); assertThrows(ServiceException.class, () -> fixture.service.updateAddress(7L, 10L, new FlashDeliveryAddressConfirmRequest())); assertThrows(ServiceException.class, () -> fixture.service.addTip(7L, 10L, tipRequest(5L, 2))); } Fixture fixture = new Fixture(); editableOrder(fixture).setRiderId(8L); assertThrows(ServiceException.class, () -> fixture.service.quoteAddress(7L, 10L, addressChange())); assertThrows(ServiceException.class, () -> fixture.service.addTip(7L, 10L, tipRequest(5L, 2))); } @Test void staleOrMissingVersionCannotOverwriteNewerOrder() { Fixture fixture = new Fixture(); editableOrder(fixture); FlashDeliveryAddressChangeRequest input = addressChange(); input.setOrderVersion(1); assertThrows(ServiceException.class, () -> fixture.service.quoteAddress(7L, 10L, input)); for (Integer version : java.util.Arrays.asList(null, -1, 1)) { assertThrows(ServiceException.class, () -> fixture.service.addTip(7L, 10L, tipRequest(5L, version))); } verify(fixture.orderMapper, never()).updateWaitingTip(any(), any()); } @Test void addressValidationRejectsSameInvalidAndOverDistanceEndpoints() { Fixture fixture = new Fixture(); editableOrder(fixture); FlashDeliveryAddressChangeRequest input = addressChange(); input.setDelivery(input.getPickup()); assertThrows(ServiceException.class, () -> fixture.service.quoteAddress(7L, 10L, input)); input.setDelivery(request().getDelivery()); input.getPickup().setPhone(""); assertThrows(ServiceException.class, () -> fixture.service.quoteAddress(7L, 10L, input)); when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(40001, 1000, "GOOGLE")); assertThrows(ServiceException.class, () -> fixture.service.quoteAddress(7L, 10L, addressChange())); verify(fixture.orderMapper, never()).updateWaitingAddress(any(), any()); } @Test void riderAcceptanceBetweenReadAndAddressSaveRejectsWriteWithoutLog() { Fixture fixture = new Fixture(); editableOrder(fixture); FlashDeliveryAddressConfirmRequest input = confirmAddress(fixture); when(fixture.orderMapper.updateWaitingAddress(any(), eq(2))).thenReturn(0); ServiceException exception = assertThrows(ServiceException.class, () -> fixture.service.updateAddress(7L, 10L, input)); assertEquals("flash.delivery.state.changed", exception.getMessage()); verify(fixture.logMapper, never()).insert(any(com.ruoyi.system.domain.flash.FlashDeliveryOrderLog.class)); } @Test void addingTipOnlyIncreasesTipAndTotalAndRejectsSameVersionRetry() { Fixture fixture = new Fixture(); FlashDeliveryOrder order = editableOrder(fixture); when(fixture.orderMapper.updateWaitingTip(any(), eq(2))).thenReturn(1); var view = fixture.service.addTip(7L, 10L, tipRequest(20L, 2)); assertEquals(30L, view.getTipAmount()); assertEquals(132L, view.getAmount()); assertEquals(102L, view.getBaseDeliveryFee()); assertEquals(3200, view.getDistanceMeters()); assertEquals(3, view.getOrderVersion()); assertThrows(ServiceException.class, () -> fixture.service.addTip(7L, 10L, tipRequest(20L, 2))); assertEquals(30L, order.getTipAmount()); verify(fixture.orderMapper).updateWaitingTip(any(), eq(2)); verify(fixture.routeService, never()).calculate(any(), any()); verify(fixture.pricingMapper, never()).selectAtTime(anyString()); } @Test void tipChangeInvalidatesEarlierAddressConfirmation() { Fixture fixture = new Fixture(); editableOrder(fixture); FlashDeliveryAddressConfirmRequest confirmation = confirmAddress(fixture); when(fixture.orderMapper.updateWaitingTip(any(), eq(2))).thenReturn(1); fixture.service.addTip(7L, 10L, tipRequest(20L, 2)); assertThrows(ServiceException.class, () -> fixture.service.updateAddress(7L, 10L, confirmation)); verify(fixture.orderMapper, never()).updateWaitingAddress(any(), any()); } @Test void additionalTipRejectsFractionalJsonBeforeWriting() throws Exception { Fixture fixture = new Fixture(); editableOrder(fixture); when(fixture.orderMapper.updateWaitingTip(any(), eq(2))).thenReturn(1); FlashDeliveryTipAddRequest input = new com.fasterxml.jackson.databind.ObjectMapper().readValue( "{\"orderVersion\":2,\"additionalTipAmount\":1.9}", FlashDeliveryTipAddRequest.class); ServiceException exception = assertThrows(ServiceException.class, () -> fixture.service.addTip(7L, 10L, input)); assertEquals("flash.delivery.tip.additional.invalid", exception.getMessage()); verify(fixture.orderMapper, never()).updateWaitingTip(any(), any()); } @Test void additionalTipRejectsJsonAmountBeyondLongRange() throws Exception { Fixture fixture = new Fixture(); editableOrder(fixture); FlashDeliveryTipAddRequest input = new com.fasterxml.jackson.databind.ObjectMapper().readValue( "{\"orderVersion\":2,\"additionalTipAmount\":1e100}", FlashDeliveryTipAddRequest.class); ServiceException exception = assertThrows(ServiceException.class, () -> fixture.service.addTip(7L, 10L, input)); assertEquals("flash.delivery.tip.additional.invalid", exception.getMessage()); verify(fixture.orderMapper, never()).updateWaitingTip(any(), any()); } @Test void additionalTipRejectsZeroNegativeNullAndOverflow() { Fixture fixture = new Fixture(); editableOrder(fixture); for (Long value : java.util.Arrays.asList(null, 0L, -1L, Long.MAX_VALUE)) { assertThrows(ServiceException.class, () -> fixture.service.addTip(7L, 10L, tipRequest(value, 2))); } verify(fixture.orderMapper, never()).updateWaitingTip(any(), any()); } @Test void riderAcceptanceBetweenReadAndTipSaveRejectsWriteWithoutLog() { Fixture fixture = new Fixture(); editableOrder(fixture); when(fixture.orderMapper.updateWaitingTip(any(), eq(2))).thenReturn(0); assertThrows(ServiceException.class, () -> fixture.service.addTip(7L, 10L, tipRequest(20L, 2))); verify(fixture.logMapper, never()).insert(any(com.ruoyi.system.domain.flash.FlashDeliveryOrderLog.class)); } private FlashDeliveryOrder editableOrder(Fixture fixture) { FlashDeliveryOrder order = order(10L, "WAITING_ACCEPTANCE"); order.setRiderId(null); order.setVersion(2); order.setPricingId(1L); order.setPricingVersion(1); order.setPricingStartTime("00:00"); order.setPricingEndTime("24:00"); order.setStartingDistance(new BigDecimal("3.00")); order.setStartingFare(90L); order.setDistance(new BigDecimal("1.00")); order.setFreight(15L); when(fixture.orderMapper.selectById(10L)).thenReturn(order); when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(4500, 900, "GOOGLE")); // 031 后改址按订单定价时点读最新运价:stub 与订单快照同值,金额断言维持不变 when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing())); return order; } private FlashDeliveryAddressChangeRequest addressChange() { FlashDeliveryAddressChangeRequest input = new FlashDeliveryAddressChangeRequest(); input.setOrderVersion(2); input.setPickup(request().getPickup()); input.setDelivery(request().getDelivery()); return input; } private FlashDeliveryAddressConfirmRequest confirmAddress(Fixture fixture) { FlashDeliveryAddressChangeRequest change = addressChange(); FlashDeliveryQuoteView quote = fixture.service.quoteAddress(7L, 10L, change); FlashDeliveryAddressConfirmRequest input = new FlashDeliveryAddressConfirmRequest(); input.setOrderVersion(quote.getOrderVersion()); input.setPickup(change.getPickup()); input.setDelivery(change.getDelivery()); input.setQuotedDistanceMeters(quote.getDistanceMeters()); input.setQuotedBaseDeliveryFee(quote.getBaseDeliveryFee()); input.setQuotedDistanceFee(quote.getDistanceFee()); input.setQuotedUrgentFee(quote.getUrgentFee()); input.setQuotedAmount(quote.getAmount()); return input; } private FlashDeliveryTipAddRequest tipRequest(Long amount, Integer version) { FlashDeliveryTipAddRequest input = new FlashDeliveryTipAddRequest(); input.setAdditionalTipAmount(amount == null ? null : BigDecimal.valueOf(amount)); input.setOrderVersion(version); return input; } private static class Fixture { final FlashDeliveryOrderMapper orderMapper = mock(FlashDeliveryOrderMapper.class); final FlashDeliveryPricingMapper pricingMapper = mock(FlashDeliveryPricingMapper.class); final FlashDeliveryOrderImageMapper imageMapper = mock(FlashDeliveryOrderImageMapper.class); final FlashDeliveryOrderLogMapper logMapper = mock(FlashDeliveryOrderLogMapper.class); final InfoUserMapper userMapper = mock(InfoUserMapper.class); final FlashDeliveryRouteService routeService = mock(FlashDeliveryRouteService.class); final RiderDeliveryLockService lockService = mock(RiderDeliveryLockService.class); final RiderDeliveryExclusivityService exclusivityService = mock(RiderDeliveryExclusivityService.class); final FlashDeliveryApplicationService service = new FlashDeliveryApplicationService( orderMapper, pricingMapper, imageMapper, logMapper, userMapper, routeService, new FlashDeliveryPricingCalculator(), lockService, exclusivityService); Fixture() { when(lockService.withLock(any(), any())).thenAnswer(invocation -> { RiderDeliveryLockService.LockedCall call = invocation.getArgument(1); return call.call(); }); } } }