package com.ruoyi.system.service; import static org.junit.jupiter.api.Assertions.assertEquals; 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 static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.Set; import java.util.concurrent.atomic.AtomicLong; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; import org.springframework.beans.factory.support.DefaultListableBeanFactory; import org.springframework.context.support.StaticMessageSource; import org.springframework.test.util.ReflectionTestUtils; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.utils.spring.SpringUtils; import com.ruoyi.system.domain.FoodSpecRelation; import com.ruoyi.system.domain.FoodSpecs; import com.ruoyi.system.domain.FoodSpecsValue; import com.ruoyi.system.domain.PosFenlei; import com.ruoyi.system.domain.PosFood; import com.ruoyi.system.dto.menu.StoreMenuCopyExecuteRequest; import com.ruoyi.system.dto.menu.StoreMenuCopyPreviewRequest; import com.ruoyi.system.dto.menu.StoreMenuCopyPreviewResponse; import com.ruoyi.system.dto.menu.StoreMenuCopyResult; import com.ruoyi.system.mapper.FoodSpecRelationMapper; import com.ruoyi.system.mapper.FoodSpecsMapper; import com.ruoyi.system.mapper.FoodSpecsValueMapper; import com.ruoyi.system.mapper.PosFenleiMapper; import com.ruoyi.system.mapper.PosFoodMapper; import com.ruoyi.system.service.impl.StoreMenuCopyServiceImpl; @ExtendWith(MockitoExtension.class) class StoreMenuCopyServiceTest { private static final Long USER_ID = 9L; private static final Long SOURCE_STORE_ID = 1L; private static final Long TARGET_STORE_ID = 2L; 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); messageSource.addMessage("menu.copy.store.required", Locale.getDefault(), "门店不能为空"); messageSource.addMessage("menu.copy.store.same", Locale.getDefault(), "源门店与目标门店不能相同"); messageSource.addMessage("menu.copy.strategy.invalid", Locale.getDefault(), "冲突处理方式无效"); messageSource.addMessage("merchant.store.access.denied", Locale.getDefault(), "无门店权限"); beanFactory.registerSingleton("messageSource", messageSource); new SpringUtils().postProcessBeanFactory(beanFactory); } @AfterAll static void restoreMessages() { new SpringUtils().postProcessBeanFactory(originalBeanFactory); } @Mock private MerchantStoreAccessService accessService; @Mock private PosFenleiMapper categoryMapper; @Mock private PosFoodMapper foodMapper; @Mock private FoodSpecsMapper specsMapper; @Mock private FoodSpecsValueMapper specsValueMapper; @Mock private FoodSpecRelationMapper relationMapper; @InjectMocks private StoreMenuCopyServiceImpl service; @BeforeEach void setUp() { lenient().when(accessService.getAccessibleStoreIds(USER_ID)) .thenReturn(Set.of(SOURCE_STORE_ID, TARGET_STORE_ID)); lenient().when(categoryMapper.selectList(any())).thenReturn(CollectionsFixture.emptyCategories()); lenient().when(foodMapper.selectList(any())).thenReturn(CollectionsFixture.emptyFoods()); lenient().when(specsMapper.selectList(any())).thenReturn(CollectionsFixture.emptySpecs()); lenient().when(specsValueMapper.selectList(any())).thenReturn(CollectionsFixture.emptySpecValues()); lenient().when(relationMapper.selectList(any())).thenReturn(CollectionsFixture.emptyRelations()); } @Test void preview_shouldReportAddedMergedIdenticalAndDifferentItems() { PosFenlei sourceDrinks = category(11L, SOURCE_STORE_ID, "饮品", "2"); PosFenlei sourceMeals = category(12L, SOURCE_STORE_ID, "主食", "2"); PosFenlei targetDrinks = category(21L, TARGET_STORE_ID, "饮品", "2"); when(categoryMapper.selectList(any())).thenReturn(List.of(sourceDrinks, sourceMeals, targetDrinks)); PosFood sourceCoffee = food(101L, SOURCE_STORE_ID, 11L, "咖啡", "2", "100"); PosFood targetCoffee = food(201L, TARGET_STORE_ID, 21L, "咖啡", "2", "100"); PosFood sourceTea = food(102L, SOURCE_STORE_ID, 11L, "红茶", "2", "50"); PosFood targetTea = food(202L, TARGET_STORE_ID, 21L, "红茶", "2", "45"); PosFood sourceBurger = food(103L, SOURCE_STORE_ID, 12L, "汉堡", "2", "80"); when(foodMapper.selectList(any())).thenReturn( List.of(sourceCoffee, sourceTea, sourceBurger, targetCoffee, targetTea)); StoreMenuCopyPreviewResponse response = service.preview(USER_ID, previewRequest()); assertEquals(1, response.getAddedCategoryCount()); assertEquals(1, response.getMergedCategoryCount()); assertEquals(1, response.getAddedProductCount()); assertEquals(1, response.getIdenticalProductCount()); assertEquals(1, response.getDifferentProductCount()); assertEquals("红茶", response.getDifferences().get(0).getName()); assertEquals(List.of("price"), response.getDifferences().get(0).getFields().stream() .map(StoreMenuCopyPreviewResponse.FieldDifference::getField) .toList()); assertEquals("45", response.getDifferences().get(0).getFields().get(0).getTargetValue()); assertEquals("50", response.getDifferences().get(0).getFields().get(0).getSourceValue()); } @Test void preview_shouldTreatSameNameInDifferentLanguageAsNewProduct() { when(categoryMapper.selectList(any())).thenReturn(List.of( category(11L, SOURCE_STORE_ID, "饮品", "2"), category(21L, TARGET_STORE_ID, "饮品", "2"))); when(foodMapper.selectList(any())).thenReturn(List.of( food(101L, SOURCE_STORE_ID, 11L, "Cola", "1", "40"), food(201L, TARGET_STORE_ID, 21L, "Cola", "2", "40"))); StoreMenuCopyPreviewResponse response = service.preview(USER_ID, previewRequest()); assertEquals(1, response.getAddedProductCount()); assertEquals(0, response.getIdenticalProductCount()); assertEquals(0, response.getDifferentProductCount()); } @Test void preview_shouldCountDuplicateSourceCategoriesOnlyOnce() { when(categoryMapper.selectList(any())).thenReturn(List.of( category(11L, SOURCE_STORE_ID, "饮品", "2"), category(12L, SOURCE_STORE_ID, "饮品", "2"))); StoreMenuCopyPreviewResponse response = service.preview(USER_ID, previewRequest()); assertEquals(1, response.getAddedCategoryCount()); assertEquals(0, response.getMergedCategoryCount()); } @Test void preview_shouldRejectMissingStoreBeforeReadingMenu() { StoreMenuCopyPreviewRequest request = new StoreMenuCopyPreviewRequest(); request.setTargetStoreId(TARGET_STORE_ID); assertThrows(ServiceException.class, () -> service.preview(USER_ID, request)); verifyNoInteractions(accessService, categoryMapper, foodMapper, specsMapper, specsValueMapper, relationMapper); } @Test void preview_shouldRejectSameStoreBeforeReadingMenu() { StoreMenuCopyPreviewRequest request = new StoreMenuCopyPreviewRequest(); request.setSourceStoreId(SOURCE_STORE_ID); request.setTargetStoreId(SOURCE_STORE_ID); assertThrows(ServiceException.class, () -> service.preview(USER_ID, request)); verifyNoInteractions(categoryMapper, foodMapper, specsMapper, specsValueMapper, relationMapper); } @Test void preview_shouldRejectWhenEitherStoreIsOutsideCurrentAccessScope() { when(accessService.getAccessibleStoreIds(USER_ID)).thenReturn(Set.of(SOURCE_STORE_ID)); assertThrows(ServiceException.class, () -> service.preview(USER_ID, previewRequest())); verifyNoInteractions(categoryMapper, foodMapper, specsMapper, specsValueMapper, relationMapper); } @Test void execute_shouldCopyEmptyStoreMenuWithStatusesAndSpecs() { PosFenlei sourceCategory = category(11L, SOURCE_STORE_ID, "饮品", "2"); PosFood sourceFood = food(101L, SOURCE_STORE_ID, 11L, "咖啡", "2", "100"); sourceFood.setStackingUp("1"); sourceFood.setToExamine("0"); FoodSpecs sourceSpecs = specs(301L, SOURCE_STORE_ID, "大小", "2"); FoodSpecsValue sourceValue = specValue(401L, 301L, "大杯", "10"); FoodSpecRelation sourceRelation = relation(501L, 101L, 301L); when(categoryMapper.selectList(any())).thenReturn(List.of(sourceCategory)); when(foodMapper.selectList(any())).thenReturn(List.of(sourceFood)); when(specsMapper.selectList(any())).thenReturn(List.of(sourceSpecs)); when(specsValueMapper.selectList(any())).thenReturn(List.of(sourceValue)); when(relationMapper.selectList(any())).thenReturn(List.of(sourceRelation)); AtomicLong ids = new AtomicLong(1000L); doAnswer(invocation -> assignId(invocation.getArgument(0), ids.incrementAndGet())) .when(categoryMapper).insert(any(PosFenlei.class)); doAnswer(invocation -> assignId(invocation.getArgument(0), ids.incrementAndGet())) .when(specsMapper).insert(any(FoodSpecs.class)); doAnswer(invocation -> assignId(invocation.getArgument(0), ids.incrementAndGet())) .when(specsValueMapper).insert(any(FoodSpecsValue.class)); doAnswer(invocation -> assignId(invocation.getArgument(0), ids.incrementAndGet())) .when(foodMapper).insert(any(PosFood.class)); when(relationMapper.insert(any(FoodSpecRelation.class))).thenReturn(1); StoreMenuCopyResult result = service.execute(USER_ID, executeRequest("KEEP_TARGET")); assertEquals(1, result.getAddedCategoryCount()); assertEquals(1, result.getAddedProductCount()); assertEquals(0, result.getSkippedProductCount()); assertEquals(0, result.getOverwrittenProductCount()); ArgumentCaptor foodCaptor = ArgumentCaptor.forClass(PosFood.class); verify(foodMapper).insert(foodCaptor.capture()); PosFood insertedFood = foodCaptor.getValue(); assertEquals(TARGET_STORE_ID, insertedFood.getMdid()); assertEquals("1", insertedFood.getStackingUp()); assertEquals("0", insertedFood.getToExamine()); ArgumentCaptor specsCaptor = ArgumentCaptor.forClass(FoodSpecs.class); verify(specsMapper).insert(specsCaptor.capture()); assertEquals(TARGET_STORE_ID, specsCaptor.getValue().getMdId()); ArgumentCaptor valueCaptor = ArgumentCaptor.forClass(FoodSpecsValue.class); verify(specsValueMapper).insert(valueCaptor.capture()); assertEquals(specsCaptor.getValue().getId(), valueCaptor.getValue().getParentId()); ArgumentCaptor relationCaptor = ArgumentCaptor.forClass(FoodSpecRelation.class); verify(relationMapper).insert(relationCaptor.capture()); assertEquals(insertedFood.getId(), relationCaptor.getValue().getFoodId()); assertEquals(specsCaptor.getValue().getId(), relationCaptor.getValue().getSpecsId()); } @Test void execute_shouldKeepDifferentTargetProductByDefault() { stubOneDifferentProductWithoutSpecs(); StoreMenuCopyResult result = service.execute(USER_ID, executeRequest(null)); assertEquals(1, result.getSkippedProductCount()); assertEquals(0, result.getOverwrittenProductCount()); verify(foodMapper, never()).updateMenuCopyContent(any(PosFood.class)); } @Test void execute_shouldOverwriteContentAndRelationsWithoutChangingTargetStatus() { PosFenlei sourceCategory = category(11L, SOURCE_STORE_ID, "饮品", "2"); PosFenlei targetCategory = category(21L, TARGET_STORE_ID, "饮品", "2"); PosFood sourceFood = food(101L, SOURCE_STORE_ID, 11L, "红茶", "2", "50"); sourceFood.setImage("source.png"); sourceFood.setStackingUp("0"); sourceFood.setToExamine("1"); PosFood targetFood = food(201L, TARGET_STORE_ID, 21L, "红茶", "2", "45"); targetFood.setImage("target.png"); targetFood.setStackingUp("1"); targetFood.setToExamine("0"); FoodSpecs sourceSpecs = specs(301L, SOURCE_STORE_ID, "大小", "2"); FoodSpecs targetSpecs = specs(302L, TARGET_STORE_ID, "大小", "2"); when(categoryMapper.selectList(any())).thenReturn(List.of(sourceCategory, targetCategory)); when(foodMapper.selectList(any())).thenReturn(List.of(sourceFood, targetFood)); when(specsMapper.selectList(any())).thenReturn(List.of(sourceSpecs, targetSpecs)); when(relationMapper.selectList(any())).thenReturn(List.of(relation(501L, 101L, 301L))); when(foodMapper.updateMenuCopyContent(any(PosFood.class))).thenReturn(1); when(relationMapper.delete(any())).thenReturn(1); when(relationMapper.insert(any(FoodSpecRelation.class))).thenReturn(1); StoreMenuCopyResult result = service.execute(USER_ID, executeRequest("OVERWRITE_TARGET")); assertEquals(1, result.getOverwrittenProductCount()); ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(PosFood.class); verify(foodMapper).updateMenuCopyContent(updateCaptor.capture()); PosFood update = updateCaptor.getValue(); assertEquals(201L, update.getId()); assertEquals(new BigDecimal("50"), update.getPrice()); assertEquals("source.png", update.getImage()); assertNull(update.getStackingUp()); assertNull(update.getToExamine()); verify(specsMapper, never()).insert(any(FoodSpecs.class)); verify(specsValueMapper, never()).insert(any(FoodSpecsValue.class)); ArgumentCaptor relationCaptor = ArgumentCaptor.forClass(FoodSpecRelation.class); verify(relationMapper).insert(relationCaptor.capture()); assertEquals(201L, relationCaptor.getValue().getFoodId()); assertEquals(302L, relationCaptor.getValue().getSpecsId()); } @Test void execute_shouldBeIdempotentWhenTargetAlreadyMatches() { PosFenlei sourceCategory = category(11L, SOURCE_STORE_ID, "饮品", "2"); PosFenlei targetCategory = category(21L, TARGET_STORE_ID, "饮品", "2"); PosFood sourceFood = food(101L, SOURCE_STORE_ID, 11L, "咖啡", "2", "100"); PosFood targetFood = food(201L, TARGET_STORE_ID, 21L, "咖啡", "2", "100"); when(categoryMapper.selectList(any())).thenReturn(List.of(sourceCategory, targetCategory)); when(foodMapper.selectList(any())).thenReturn(List.of(sourceFood, targetFood)); StoreMenuCopyResult result = service.execute(USER_ID, executeRequest("OVERWRITE_TARGET")); assertEquals(0, result.getAddedCategoryCount()); assertEquals(0, result.getAddedProductCount()); assertEquals(1, result.getSkippedProductCount()); assertEquals(0, result.getOverwrittenProductCount()); verify(categoryMapper, never()).insert(any(PosFenlei.class)); verify(foodMapper, never()).insert(any(PosFood.class)); verify(foodMapper, never()).updateMenuCopyContent(any(PosFood.class)); } @Test void execute_shouldRejectUnknownConflictStrategyBeforeReadingMenu() { assertThrows(ServiceException.class, () -> service.execute(USER_ID, executeRequest("OVERWRITE_EVERYTHING"))); verifyNoInteractions(categoryMapper, foodMapper, specsMapper, specsValueMapper, relationMapper); } private void stubOneDifferentProductWithoutSpecs() { when(categoryMapper.selectList(any())).thenReturn(List.of( category(11L, SOURCE_STORE_ID, "饮品", "2"), category(21L, TARGET_STORE_ID, "饮品", "2"))); when(foodMapper.selectList(any())).thenReturn(List.of( food(101L, SOURCE_STORE_ID, 11L, "红茶", "2", "50"), food(201L, TARGET_STORE_ID, 21L, "红茶", "2", "45"))); } private StoreMenuCopyPreviewRequest previewRequest() { StoreMenuCopyPreviewRequest request = new StoreMenuCopyPreviewRequest(); request.setSourceStoreId(SOURCE_STORE_ID); request.setTargetStoreId(TARGET_STORE_ID); return request; } private StoreMenuCopyExecuteRequest executeRequest(String strategy) { StoreMenuCopyExecuteRequest request = new StoreMenuCopyExecuteRequest(); request.setSourceStoreId(SOURCE_STORE_ID); request.setTargetStoreId(TARGET_STORE_ID); request.setConflictStrategy(strategy); return request; } private PosFenlei category(Long id, Long storeId, String name, String language) { PosFenlei category = new PosFenlei(); category.setId(id); category.setMendid(storeId); category.setName(name); category.setLanguage(language); category.setImg("category.png"); category.setSort("1"); return category; } private PosFood food(Long id, Long storeId, Long categoryId, String name, String language, String price) { PosFood food = new PosFood(); food.setId(id); food.setMdid(storeId); food.setFlId(categoryId); food.setName(name); food.setLanguage(language); food.setPrice(new BigDecimal(price)); food.setImage("food.png"); food.setIntroduce("介绍"); food.setFoodSku("[]"); food.setSort(1L); food.setRecommend(0L); food.setStackingUp("0"); food.setToExamine("1"); return food; } private FoodSpecs specs(Long id, Long storeId, String title, String language) { FoodSpecs specs = new FoodSpecs(); specs.setId(id); specs.setMdId(storeId); specs.setTitle(title); specs.setLanguage(language); specs.setType("1"); specs.setState("1"); specs.setSort(1); specs.setIsOpen(true); specs.setIsDelete(false); return specs; } private FoodSpecsValue specValue(Long id, Long parentId, String name, String price) { FoodSpecsValue value = new FoodSpecsValue(); value.setId(id); value.setParentId(parentId); value.setName(name); value.setPrice(new BigDecimal(price)); value.setState("1"); value.setIsOpen(true); return value; } private FoodSpecRelation relation(Long id, Long foodId, Long specsId) { FoodSpecRelation relation = new FoodSpecRelation(); relation.setId(id); relation.setFoodId(foodId); relation.setSpecsId(specsId); return relation; } private int assignId(Object entity, long id) { if (entity instanceof PosFenlei category) { category.setId(id); } else if (entity instanceof PosFood food) { food.setId(id); } else if (entity instanceof FoodSpecs specs) { specs.setId(id); } else if (entity instanceof FoodSpecsValue value) { value.setId(id); } return 1; } private static final class CollectionsFixture { private CollectionsFixture() { } private static List emptyCategories() { return new ArrayList<>(); } private static List emptyFoods() { return new ArrayList<>(); } private static List emptySpecs() { return new ArrayList<>(); } private static List emptySpecValues() { return new ArrayList<>(); } private static List emptyRelations() { return new ArrayList<>(); } } }