diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteResolveServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteResolveServiceTest.java new file mode 100644 index 00000000..644f4d5f --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteResolveServiceTest.java @@ -0,0 +1,238 @@ +package com.nanri.aiimage.modules.patroldelete.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.common.model.vo.ProductRiskCandidateVo; +import com.nanri.aiimage.common.model.vo.ProductRiskMatchShopsVo; +import com.nanri.aiimage.modules.patroldelete.mapper.PatrolDeleteConditionMapper; +import com.nanri.aiimage.modules.patroldelete.mapper.PatrolDeleteShopCandidateMapper; +import com.nanri.aiimage.modules.patroldelete.model.entity.PatrolDeleteShopCandidateEntity; +import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCandidateAddRequest; +import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskMatchShopsRequest; +import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopMatchResultVo; +import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopIndexService; +import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionStatus; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 巡店删除的备选店铺解析(2026-09 审查 G4:该模块此前零测试文件,且出过 + * "逐国上报失败仍判成功"的事故,先从有明确契约的 ResolveService 补起)。 + */ +class PatrolDeleteResolveServiceTest { + + @BeforeAll + static void initializeMybatisMetadata() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), ""), + PatrolDeleteShopCandidateEntity.class); + } + + private final PatrolDeleteShopCandidateMapper candidateMapper = mock(PatrolDeleteShopCandidateMapper.class); + private final PatrolDeleteConditionMapper conditionMapper = mock(PatrolDeleteConditionMapper.class); + private final ZiniaoShopSwitchService shopSwitchService = mock(ZiniaoShopSwitchService.class); + private final PlatformTransactionManager transactionManager = mock(PlatformTransactionManager.class); + + private PatrolDeleteResolveService service; + + @BeforeEach + void setUp() { + service = new PatrolDeleteResolveService(candidateMapper, conditionMapper, shopSwitchService, transactionManager); + when(transactionManager.getTransaction(any())).thenReturn(mock(TransactionStatus.class)); + when(shopSwitchService.normalizeShopName(anyString())).thenAnswer(invocation -> + ((String) invocation.getArgument(0)).trim()); + } + + private static ProductRiskCandidateAddRequest addRequest(Long userId, String shopName) { + ProductRiskCandidateAddRequest request = new ProductRiskCandidateAddRequest(); + request.setUserId(userId); + request.setShopName(shopName); + return request; + } + + private static ZiniaoShopMatchResultVo matched(String shopName) { + ZiniaoShopMatchResultVo vo = new ZiniaoShopMatchResultVo(); + vo.setMatched(true); + vo.setShopName(shopName); + vo.setMatchStatus(ZiniaoShopIndexService.MATCH_STATUS_MATCHED); + return vo; + } + + @Test + void listCandidatesRejectsInvalidUser() { + assertThrows(BusinessException.class, () -> service.listCandidates(null)); + assertThrows(BusinessException.class, () -> service.listCandidates(0L)); + verify(candidateMapper, never()).selectList(any()); + } + + @Test + @SuppressWarnings("unchecked") + void listCandidatesMapsRowsToViews() { + PatrolDeleteShopCandidateEntity row = new PatrolDeleteShopCandidateEntity(); + row.setId(7L); + row.setUserId(23L); + row.setShopName("测试店铺"); + row.setCreatedAt(LocalDateTime.now()); + when(candidateMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(row)); + + List views = service.listCandidates(23L); + + assertEquals(1, views.size()); + assertEquals(7L, views.getFirst().getId()); + assertEquals("测试店铺", views.getFirst().getShopName()); + } + + @Test + void addCandidateRejectsBlankShopName() { + when(shopSwitchService.normalizeShopName(" ")).thenReturn(""); + + assertThrows(BusinessException.class, () -> service.addCandidate(addRequest(23L, " "))); + verify(candidateMapper, never()).insert(any(PatrolDeleteShopCandidateEntity.class)); + } + + @Test + void addCandidateRejectsWhenIndexMisses() { + ZiniaoShopMatchResultVo miss = new ZiniaoShopMatchResultVo(); + miss.setMatched(false); + miss.setMatchMessage("店铺索引未命中,无法加入备选"); + when(shopSwitchService.findIndexedStoreByName("未收录店铺", false)).thenReturn(miss); + + BusinessException exception = assertThrows(BusinessException.class, + () -> service.addCandidate(addRequest(23L, "未收录店铺"))); + + assertEquals("店铺索引未命中,无法加入备选", exception.getMessage()); + verify(candidateMapper, never()).insert(any(PatrolDeleteShopCandidateEntity.class)); + } + + @Test + void addCandidateRejectsConflictStatus() { + ZiniaoShopMatchResultVo conflict = matched("同名店铺"); + conflict.setMatchStatus(ZiniaoShopIndexService.MATCH_STATUS_CONFLICT); + conflict.setMatchMessage("存在多个同名店铺,请人工确认"); + when(shopSwitchService.findIndexedStoreByName("同名店铺", false)).thenReturn(conflict); + + BusinessException exception = assertThrows(BusinessException.class, + () -> service.addCandidate(addRequest(23L, "同名店铺"))); + + assertEquals("存在多个同名店铺,请人工确认", exception.getMessage()); + } + + @Test + @SuppressWarnings("unchecked") + void addCandidateIsIdempotentForExistingShop() { + when(shopSwitchService.findIndexedStoreByName("已存在店铺", false)).thenReturn(matched("已存在店铺")); + PatrolDeleteShopCandidateEntity existing = new PatrolDeleteShopCandidateEntity(); + existing.setId(31L); + existing.setUserId(23L); + existing.setShopName("已存在店铺"); + existing.setCreatedAt(LocalDateTime.now()); + when(candidateMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(existing); + + ProductRiskCandidateVo vo = service.addCandidate(addRequest(23L, " 已存在店铺 ")); + + assertEquals(31L, vo.getId(), "重复添加返回既有记录"); + verify(candidateMapper, never()).insert(any(PatrolDeleteShopCandidateEntity.class)); + } + + @Test + @SuppressWarnings("unchecked") + void addCandidateInsertsWhenIndexMatched() { + when(shopSwitchService.findIndexedStoreByName("新店铺", false)).thenReturn(matched("新店铺")); + when(candidateMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null); + when(candidateMapper.insert(any(PatrolDeleteShopCandidateEntity.class))).thenAnswer(invocation -> { + PatrolDeleteShopCandidateEntity entity = invocation.getArgument(0); + entity.setId(88L); + return 1; + }); + + ProductRiskCandidateVo vo = service.addCandidate(addRequest(23L, "新店铺")); + + assertEquals(88L, vo.getId()); + assertEquals("新店铺", vo.getShopName()); + verify(candidateMapper).insert(any(PatrolDeleteShopCandidateEntity.class)); + } + + @Test + void deleteCandidateRejectsAnotherUsersRecord() { + PatrolDeleteShopCandidateEntity other = new PatrolDeleteShopCandidateEntity(); + other.setId(5L); + other.setUserId(99L); + when(candidateMapper.selectById(5L)).thenReturn(other); + + BusinessException exception = assertThrows(BusinessException.class, + () -> service.deleteCandidate(23L, 5L)); + + assertEquals("记录不存在", exception.getMessage(), "他人记录与不存在同文案,避免探测"); + verify(candidateMapper, never()).deleteById(5L); + } + + @Test + void deleteCandidateRemovesOwnRecord() { + PatrolDeleteShopCandidateEntity own = new PatrolDeleteShopCandidateEntity(); + own.setId(5L); + own.setUserId(23L); + when(candidateMapper.selectById(5L)).thenReturn(own); + + service.deleteCandidate(23L, 5L); + + verify(candidateMapper).deleteById(5L); + } + + @Test + void matchShopsRejectsEmptyAfterNormalization() { + ProductRiskMatchShopsRequest request = new ProductRiskMatchShopsRequest(); + request.setUserId(23L); + request.setShopNames(List.of(" ", "")); + + assertThrows(BusinessException.class, () -> service.matchShops(request)); + } + + @Test + void matchShopsDeduplicatesAndKeepsOrder() { + ProductRiskMatchShopsRequest request = new ProductRiskMatchShopsRequest(); + request.setUserId(23L); + request.setShopNames(List.of(" A ", "B", "A")); + when(shopSwitchService.findIndexedStoreByName(anyString(), anyBoolean())).thenAnswer(invocation -> + matched((String) invocation.getArgument(0))); + + ProductRiskMatchShopsVo vo = service.matchShops(request); + + assertEquals(2, vo.getItems().size(), "重复店铺名只匹配一次"); + assertEquals("A", vo.getItems().get(0).getShopName()); + assertEquals("B", vo.getItems().get(1).getShopName()); + } + + @Test + @SuppressWarnings("unchecked") + void countCandidatesTreatsNullCountAsZero() { + when(candidateMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(null); + + assertEquals(0L, service.countCandidates(23L)); + } + + @Test + void countCandidatesRejectsInvalidUser() { + assertThrows(BusinessException.class, () -> service.countCandidates(-1L)); + assertTrue(true); + } +}