diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinResolveServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinResolveServiceTest.java new file mode 100644 index 00000000..ce20de68 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinResolveServiceTest.java @@ -0,0 +1,233 @@ +package com.nanri.aiimage.modules.queryasin.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.productrisk.model.dto.ProductRiskCandidateAddRequest; +import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskMatchShopsRequest; +import com.nanri.aiimage.modules.queryasin.mapper.QueryAsinShopCandidateMapper; +import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountryAsinsDto; +import com.nanri.aiimage.modules.queryasin.model.entity.QueryAsinShopCandidateEntity; +import com.nanri.aiimage.modules.shopkey.mapper.QueryAsinMapper; +import com.nanri.aiimage.modules.shopkey.model.entity.QueryAsinEntity; +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 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; + +/** + * 查询 ASIN 工具的备选店铺/国家清单解析(2026-09 审查 G4:该模块此前零测试文件)。 + * + *

重点覆盖"创建任务时会整体推给 Python 的国家 ASIN 清单"这一下游契约: + * 国家顺序固定(DE/UK/FR/IT/ES)、空国家不出现、同国家内去重。 + */ +class QueryAsinResolveServiceTest { + + @BeforeAll + static void initializeMybatisMetadata() { + MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), ""); + TableInfoHelper.initTableInfo(assistant, QueryAsinShopCandidateEntity.class); + TableInfoHelper.initTableInfo(assistant, QueryAsinEntity.class); + } + + private final QueryAsinShopCandidateMapper candidateMapper = mock(QueryAsinShopCandidateMapper.class); + private final ZiniaoShopSwitchService shopSwitchService = mock(ZiniaoShopSwitchService.class); + private final QueryAsinMapper queryAsinMapper = mock(QueryAsinMapper.class); + private final PlatformTransactionManager transactionManager = mock(PlatformTransactionManager.class); + + private QueryAsinResolveService service; + + @BeforeEach + void setUp() { + service = new QueryAsinResolveService(candidateMapper, shopSwitchService, queryAsinMapper, transactionManager); + 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)); + } + + @Test + @SuppressWarnings("unchecked") + void listCandidatesMapsRowsToViews() { + QueryAsinShopCandidateEntity row = new QueryAsinShopCandidateEntity(); + row.setId(12L); + 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(12L, 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(QueryAsinShopCandidateEntity.class)); + } + + @Test + void addCandidateRejectsWhenIndexMisses() { + ZiniaoShopMatchResultVo miss = new ZiniaoShopMatchResultVo(); + miss.setMatched(false); + when(shopSwitchService.findIndexedStoreByName("未收录", false)).thenReturn(miss); + + assertThrows(BusinessException.class, () -> service.addCandidate(addRequest(23L, "未收录"))); + verify(candidateMapper, never()).insert(any(QueryAsinShopCandidateEntity.class)); + } + + @Test + void addCandidateRejectsConflictStatus() { + ZiniaoShopMatchResultVo conflict = matched("同名店铺"); + conflict.setMatchStatus(ZiniaoShopIndexService.MATCH_STATUS_CONFLICT); + when(shopSwitchService.findIndexedStoreByName("同名店铺", false)).thenReturn(conflict); + + assertThrows(BusinessException.class, () -> service.addCandidate(addRequest(23L, "同名店铺"))); + } + + @Test + @SuppressWarnings("unchecked") + void addCandidateIsIdempotentForExistingShop() { + when(shopSwitchService.findIndexedStoreByName("已存在", false)).thenReturn(matched("已存在")); + QueryAsinShopCandidateEntity existing = new QueryAsinShopCandidateEntity(); + existing.setId(56L); + existing.setUserId(23L); + existing.setShopName("已存在"); + existing.setCreatedAt(LocalDateTime.now()); + when(candidateMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(existing); + + ProductRiskCandidateVo vo = service.addCandidate(addRequest(23L, "已存在")); + + assertEquals(56L, vo.getId()); + verify(candidateMapper, never()).insert(any(QueryAsinShopCandidateEntity.class)); + } + + @Test + void deleteCandidateRejectsAnotherUsersRecordWithSameMessage() { + QueryAsinShopCandidateEntity other = new QueryAsinShopCandidateEntity(); + other.setId(4L); + other.setUserId(88L); + when(candidateMapper.selectById(4L)).thenReturn(other); + + BusinessException exception = assertThrows(BusinessException.class, + () -> service.deleteCandidate(23L, 4L)); + + assertEquals("记录不存在", exception.getMessage()); + verify(candidateMapper, never()).deleteById(any(Long.class)); + } + + @Test + void loadQueryAsinsForShopRejectsInvalidUser() { + assertThrows(BusinessException.class, () -> service.loadQueryAsinsForShop(0L, "店铺")); + } + + @Test + void loadQueryAsinsForShopReturnsEmptyForBlankShopName() { + assertTrue(service.loadQueryAsinsForShop(23L, " ").isEmpty()); + verify(queryAsinMapper, never()).selectList(any()); + } + + @Test + @SuppressWarnings("unchecked") + void loadQueryAsinsForShopGroupsByCountryInFixedOrderAndDeduplicates() { + QueryAsinEntity row = new QueryAsinEntity(); + row.setId(1L); + row.setShopName("查询店铺"); + row.setAsinDe("B01"); + row.setAsinUk("B02"); + row.setAsinEs("B03"); + QueryAsinEntity duplicate = new QueryAsinEntity(); + duplicate.setId(2L); + duplicate.setShopName("查询店铺"); + duplicate.setAsinDe("B01"); + duplicate.setAsinUk("B04"); + when(queryAsinMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(row, duplicate)); + + List groups = service.loadQueryAsinsForShop(23L, "查询店铺"); + + assertEquals(3, groups.size(), "空国家不出现"); + assertEquals(List.of("DE", "UK", "ES"), groups.stream().map(QueryAsinCountryAsinsDto::getCountry).toList(), + "国家顺序固定为 DE/UK/FR/IT/ES 的子序列"); + assertEquals(List.of("B01"), groups.get(0).getAsins(), "同国家内去重"); + assertEquals(List.of("B02", "B04"), groups.get(1).getAsins()); + } + + @Test + void matchShopsRejectsEmptyAfterNormalization() { + ProductRiskMatchShopsRequest request = new ProductRiskMatchShopsRequest(); + request.setUserId(23L); + request.setShopNames(List.of(" ", "")); + + assertThrows(BusinessException.class, () -> service.matchShops(request)); + } + + @Test + @SuppressWarnings("unchecked") + void matchShopsDeduplicatesKeepingFirstOccurrenceOrder() { + ProductRiskMatchShopsRequest request = new ProductRiskMatchShopsRequest(); + request.setUserId(23L); + request.setShopNames(List.of("A", " B ", "A")); + when(queryAsinMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of()); + 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)); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/withdraw/service/WithdrawResolveServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/withdraw/service/WithdrawResolveServiceTest.java new file mode 100644 index 00000000..789fae92 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/withdraw/service/WithdrawResolveServiceTest.java @@ -0,0 +1,225 @@ +package com.nanri.aiimage.modules.withdraw.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.productrisk.model.dto.ProductRiskCandidateAddRequest; +import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskMatchShopsRequest; +import com.nanri.aiimage.modules.withdraw.mapper.WithdrawShopCandidateMapper; +import com.nanri.aiimage.modules.withdraw.model.entity.WithdrawShopCandidateEntity; +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.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeAll; + +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.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:该模块此前零测试文件)。 + * + *

覆盖要点:用户校验、索引未命中/同名冲突拒绝、重复添加幂等(不重复插入)、 + * 他人记录不可删(与"不存在"同文案,避免探测)、按店铺名批量删的归一化与去重。 + */ +class WithdrawResolveServiceTest { + + @BeforeAll + static void initializeMybatisMetadata() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), ""), + WithdrawShopCandidateEntity.class); + } + + private final WithdrawShopCandidateMapper candidateMapper = mock(WithdrawShopCandidateMapper.class); + private final ZiniaoShopSwitchService shopSwitchService = mock(ZiniaoShopSwitchService.class); + + private WithdrawResolveService service; + + @BeforeEach + void setUp() { + service = new WithdrawResolveService(candidateMapper, shopSwitchService); + 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)); + } + + @Test + @SuppressWarnings("unchecked") + void listCandidatesMapsRowsToViews() { + WithdrawShopCandidateEntity row = new WithdrawShopCandidateEntity(); + row.setId(9L); + 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(9L, 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(WithdrawShopCandidateEntity.class)); + } + + @Test + void addCandidateRejectsWhenIndexMissesWithoutMessage() { + ZiniaoShopMatchResultVo miss = new ZiniaoShopMatchResultVo(); + miss.setMatched(false); + when(shopSwitchService.findIndexedStoreByName("未收录", false)).thenReturn(miss); + + BusinessException exception = assertThrows(BusinessException.class, + () -> service.addCandidate(addRequest(23L, "未收录"))); + + assertEquals("店铺索引未命中,无法加入备选区", exception.getMessage(), "无提示时用默认文案"); + } + + @Test + void addCandidateRejectsConflictStatus() { + ZiniaoShopMatchResultVo conflict = matched("同名店铺"); + conflict.setMatchStatus(ZiniaoShopIndexService.MATCH_STATUS_CONFLICT); + conflict.setMatchMessage("存在多个同名店铺,请人工确认"); + when(shopSwitchService.findIndexedStoreByName("同名店铺", false)).thenReturn(conflict); + + assertThrows(BusinessException.class, () -> service.addCandidate(addRequest(23L, "同名店铺"))); + verify(candidateMapper, never()).insert(any(WithdrawShopCandidateEntity.class)); + } + + @Test + @SuppressWarnings("unchecked") + void addCandidateIsIdempotentForExistingShop() { + when(shopSwitchService.findIndexedStoreByName("已存在", false)).thenReturn(matched("已存在")); + WithdrawShopCandidateEntity existing = new WithdrawShopCandidateEntity(); + existing.setId(41L); + existing.setUserId(23L); + existing.setShopName("已存在"); + existing.setCreatedAt(LocalDateTime.now()); + when(candidateMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(existing); + + ProductRiskCandidateVo vo = service.addCandidate(addRequest(23L, " 已存在 ")); + + assertEquals(41L, vo.getId()); + verify(candidateMapper, never()).insert(any(WithdrawShopCandidateEntity.class)); + } + + @Test + @SuppressWarnings("unchecked") + void addCandidateInsertsWhenAbsent() { + when(shopSwitchService.findIndexedStoreByName("新店铺", false)).thenReturn(matched("新店铺")); + when(candidateMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null); + when(candidateMapper.insert(any(WithdrawShopCandidateEntity.class))).thenAnswer(invocation -> { + WithdrawShopCandidateEntity entity = invocation.getArgument(0); + entity.setId(77L); + return 1; + }); + + ProductRiskCandidateVo vo = service.addCandidate(addRequest(23L, "新店铺")); + + assertEquals(77L, vo.getId()); + assertEquals("新店铺", vo.getShopName()); + } + + @Test + void deleteCandidateRejectsAnotherUsersRecordWithSameMessage() { + WithdrawShopCandidateEntity other = new WithdrawShopCandidateEntity(); + other.setId(3L); + other.setUserId(99L); + when(candidateMapper.selectById(3L)).thenReturn(other); + + BusinessException exception = assertThrows(BusinessException.class, + () -> service.deleteCandidate(23L, 3L)); + + assertEquals("记录不存在", exception.getMessage()); + verify(candidateMapper, never()).deleteById(any(Long.class)); + } + + @Test + void deleteCandidatesByShopNamesSkipsBlankAndNoOpsWhenEmpty() { + service.deleteCandidatesByShopNames(23L, List.of(" ", "")); + + verify(candidateMapper, never()).delete(any()); + } + + @Test + void deleteCandidatesByShopNamesNormalizesAndUsesInClause() { + when(candidateMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(2); + + service.deleteCandidatesByShopNames(23L, List.of(" A ", "A", "B")); + + verify(candidateMapper).delete(any(LambdaQueryWrapper.class)); + } + + @Test + void matchShopsRejectsEmptyAfterNormalization() { + ProductRiskMatchShopsRequest request = new ProductRiskMatchShopsRequest(); + request.setUserId(23L); + request.setShopNames(List.of(" ")); + + assertThrows(BusinessException.class, () -> service.matchShops(request)); + } + + @Test + void matchShopsDeduplicatesKeepingFirstOccurrenceOrder() { + ProductRiskMatchShopsRequest request = new ProductRiskMatchShopsRequest(); + request.setUserId(23L); + request.setShopNames(List.of(" B ", "A", "B")); + when(shopSwitchService.findIndexedStoreByName(anyString(), anyBoolean())).thenAnswer(invocation -> + matched((String) invocation.getArgument(0))); + + ProductRiskMatchShopsVo vo = service.matchShops(request); + + assertEquals(2, vo.getItems().size()); + assertEquals("B", vo.getItems().get(0).getShopName()); + assertEquals("A", vo.getItems().get(1).getShopName()); + } + + @Test + @SuppressWarnings("unchecked") + void countCandidatesTreatsNullCountAsZero() { + when(candidateMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(null); + + assertEquals(0L, service.countCandidates(23L)); + } +}