test(G4): 取款/查询ASIN ResolveService 各补 13 个契约测试

取款:候选增删幂等与越权保护、按店铺名批量删的归一化去重、匹配去重保序。
查询ASIN:额外覆盖「国家 ASIN 清单」契约(国家顺序固定、空国家不出现、同国家去重),
该清单会整体推给 Python,格式错会直接导致采集错列。
This commit is contained in:
2026-09-14 07:11:59 +08:00
parent ef3a2c9bd6
commit ac36c08460
2 changed files with 458 additions and 0 deletions
@@ -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:该模块此前零测试文件)。
*
* <p>重点覆盖"创建任务时会整体推给 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<ProductRiskCandidateVo> 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<QueryAsinCountryAsinsDto> 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));
}
}
@@ -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:该模块此前零测试文件)。
*
* <p>覆盖要点:用户校验、索引未命中/同名冲突拒绝、重复添加幂等(不重复插入)、
* 他人记录不可删(与"不存在"同文案,避免探测)、按店铺名批量删的归一化与去重。
*/
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<ProductRiskCandidateVo> 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));
}
}