|
|
@@ -0,0 +1,520 @@
|
|
|
+package com.payment.platform.module.payment.unified.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
+import com.payment.platform.common.exception.BusinessException;
|
|
|
+import com.payment.platform.common.response.PageResult;
|
|
|
+import com.payment.platform.module.payment.unified.dto.query.UnifiedMerchantQueryDTO;
|
|
|
+import com.payment.platform.module.payment.unified.dto.req.UnifiedMerchantSaveDTO;
|
|
|
+import com.payment.platform.module.payment.unified.dto.vo.UnifiedMerchantVO;
|
|
|
+import com.payment.platform.module.payment.unified.entity.UnifiedMerchantEntity;
|
|
|
+import com.payment.platform.module.payment.unified.entity.UnifiedOrderEntity;
|
|
|
+import com.payment.platform.module.payment.unified.entity.UnifiedRefundEntity;
|
|
|
+import com.payment.platform.module.payment.unified.mapper.UnifiedMerchantMapper;
|
|
|
+import com.payment.platform.module.payment.unified.mapper.UnifiedOrderMapper;
|
|
|
+import com.payment.platform.module.payment.unified.mapper.UnifiedRefundMapper;
|
|
|
+import org.apache.ibatis.builder.MapperBuilderAssistant;
|
|
|
+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.Mock;
|
|
|
+import org.mockito.junit.jupiter.MockitoExtension;
|
|
|
+
|
|
|
+import java.lang.reflect.Field;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertSame;
|
|
|
+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.doReturn;
|
|
|
+import static org.mockito.Mockito.never;
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
+import static org.mockito.Mockito.verifyNoInteractions;
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 商户配置服务。
|
|
|
+ *
|
|
|
+ * <p>本类盯的是几处「不会报错但会错」的地方:
|
|
|
+ * <ol>
|
|
|
+ * <li>一个租户多条配置时由系统**随便挑一条** —— 钱会进另一个商户号,全程无报错;</li>
|
|
|
+ * <li>修改时允许改归属 —— 已建订单带的是旧 {@code tenant_id},两边列表各看一半;</li>
|
|
|
+ * <li>修改时把留空的 {@code app_secret} 写成空串 —— 该商户之后所有请求签名失败;</li>
|
|
|
+ * <li>删除仍有交易记录的商户 —— 历史单失去配置,对账/退款都定位不到上游。</li>
|
|
|
+ * </ol>
|
|
|
+ */
|
|
|
+@ExtendWith(MockitoExtension.class)
|
|
|
+class UnifiedMerchantServiceTest {
|
|
|
+
|
|
|
+ @Mock private UnifiedMerchantMapper merchantMapper;
|
|
|
+ @Mock private UnifiedOrderMapper orderMapper;
|
|
|
+ @Mock private UnifiedRefundMapper refundMapper;
|
|
|
+
|
|
|
+ private UnifiedMerchantService service;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ service = new UnifiedMerchantService(merchantMapper, orderMapper, refundMapper);
|
|
|
+ // 初始化 MyBatis-Plus lambda 元数据缓存,使 LambdaQueryWrapper.getSqlSegment() 可在无 Spring 上下文的单测中工作
|
|
|
+ // (同款引导见 HarMerchantServiceTest / LsyMerchantServiceTest。)
|
|
|
+ MybatisConfiguration configuration = new MybatisConfiguration();
|
|
|
+ TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), UnifiedMerchantEntity.class);
|
|
|
+ TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), UnifiedOrderEntity.class);
|
|
|
+ TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), UnifiedRefundEntity.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 工具 ──────────────────────────────
|
|
|
+
|
|
|
+ private static UnifiedMerchantEntity merchant(Long id, Long tenantId, String mchNo, String status) {
|
|
|
+ UnifiedMerchantEntity e = new UnifiedMerchantEntity();
|
|
|
+ e.setId(id);
|
|
|
+ e.setTenantId(tenantId);
|
|
|
+ e.setMchNo(mchNo);
|
|
|
+ e.setAppId("app-" + mchNo);
|
|
|
+ e.setAppSecret("OLD-SECRET");
|
|
|
+ e.setMerchantName("商户" + mchNo);
|
|
|
+ e.setChannelUserIfCode("fuioupay");
|
|
|
+ e.setStatus(status);
|
|
|
+ return e;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static UnifiedMerchantSaveDTO createDto() {
|
|
|
+ UnifiedMerchantSaveDTO dto = new UnifiedMerchantSaveDTO();
|
|
|
+ dto.setTenantId(7L);
|
|
|
+ dto.setMchNo("M001");
|
|
|
+ dto.setAppId("app-1");
|
|
|
+ dto.setAppSecret("NEW-SECRET");
|
|
|
+ dto.setMerchantName("测试商户");
|
|
|
+ return dto;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ─────────────────── 一、按租户选配置:0 / 1 / 多条 ───────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void resolveForTenant_singleRow_returnsIt() {
|
|
|
+ UnifiedMerchantEntity only = merchant(11L, 2L, "M001", "0");
|
|
|
+ when(merchantMapper.selectList(any())).thenReturn(List.of(only));
|
|
|
+
|
|
|
+ assertSame(only, service.resolveForTenant(2L, null));
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 404 而不是 403:调用方据此区分「还没报备」与「报备了但不是你的」。 */
|
|
|
+ @Test
|
|
|
+ void resolveForTenant_zeroRows_throws404() {
|
|
|
+ when(merchantMapper.selectList(any())).thenReturn(List.of());
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class,
|
|
|
+ () -> service.resolveForTenant(2L, null));
|
|
|
+
|
|
|
+ assertEquals(404, e.getCode(), "实测消息: " + e.getMessage());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 多条 ⇒ **400 要求指定**,绝不 LIMIT 1 随便挑一条。
|
|
|
+ *
|
|
|
+ * 挑错了不会报错,只会把钱收进另一个商户号 —— 到对账那天才看得见。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void resolveForTenant_multipleRows_throws400_mentionsMchNo() {
|
|
|
+ when(merchantMapper.selectList(any())).thenReturn(List.of(
|
|
|
+ merchant(11L, 2L, "M001", "0"),
|
|
|
+ merchant(12L, 2L, "M002", "0")));
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class,
|
|
|
+ () -> service.resolveForTenant(2L, null));
|
|
|
+
|
|
|
+ assertEquals(400, e.getCode(), "多条是入参缺项,不是找不到,实测消息: " + e.getMessage());
|
|
|
+ assertTrue(e.getMessage().contains("指定 mch_no"),
|
|
|
+ "报错必须告诉调用方补哪个字段,实测消息: " + e.getMessage());
|
|
|
+ verify(merchantMapper, never()).selectOne(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 停用的商户不得继续接单:spec §7.1 要求新建订单/退款前强制 {@code status='0'}。 */
|
|
|
+ @Test
|
|
|
+ void resolveForTenant_disabledSingleRow_throws400_merchantDisabled() {
|
|
|
+ when(merchantMapper.selectList(any())).thenReturn(List.of(merchant(11L, 2L, "M001", "1")));
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class,
|
|
|
+ () -> service.resolveForTenant(2L, null));
|
|
|
+
|
|
|
+ assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
|
|
|
+ assertTrue(e.getMessage().contains("商户已停用"), "实测消息: " + e.getMessage());
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 指定商户号时必须带 tenant_id:少这个条件,租户 A 能拿租户 B 的号下单。 */
|
|
|
+ @Test
|
|
|
+ void resolveForTenant_withMchNo_scopedByTenantAndMchNo() {
|
|
|
+ UnifiedMerchantEntity row = merchant(11L, 2L, "M001", "0");
|
|
|
+ when(merchantMapper.selectOne(any())).thenReturn(row);
|
|
|
+
|
|
|
+ assertSame(row, service.resolveForTenant(2L, "M001"));
|
|
|
+
|
|
|
+ @SuppressWarnings({"unchecked", "rawtypes"})
|
|
|
+ ArgumentCaptor<LambdaQueryWrapper<UnifiedMerchantEntity>> cap =
|
|
|
+ ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
|
|
+ verify(merchantMapper).selectOne(cap.capture());
|
|
|
+ String where = cap.getValue().getSqlSegment();
|
|
|
+ assertTrue(where.contains("tenant_id"), "实测 WHERE: " + where);
|
|
|
+ assertTrue(where.contains("mch_no"), "实测 WHERE: " + where);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 「不存在」与「存在但不是本租户的」应答一致(都 404),不构成跨租户探针。 */
|
|
|
+ @Test
|
|
|
+ void resolveForTenant_withMchNo_missing_throws404() {
|
|
|
+ when(merchantMapper.selectOne(any())).thenReturn(null);
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class,
|
|
|
+ () -> service.resolveForTenant(2L, "M999"));
|
|
|
+
|
|
|
+ assertEquals(404, e.getCode(), "实测消息: " + e.getMessage());
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 没有归属租户 ⇒ 403 且**先于查库**:这种主体会被拦截器跳过过滤。 */
|
|
|
+ @Test
|
|
|
+ void resolveForTenant_nullTenant_throws403_withoutQuerying() {
|
|
|
+ BusinessException e = assertThrows(BusinessException.class,
|
|
|
+ () -> service.resolveForTenant(null, "M001"));
|
|
|
+
|
|
|
+ assertEquals(403, e.getCode(), "实测消息: " + e.getMessage());
|
|
|
+ verifyNoInteractions(merchantMapper);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void listByTenant_nullTenant_returnsEmptyWithoutQuerying() {
|
|
|
+ assertTrue(service.listByTenant(null).isEmpty());
|
|
|
+ verify(merchantMapper, never()).selectList(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void requireEnabled_disabled_throws400_merchantDisabled() {
|
|
|
+ BusinessException e = assertThrows(BusinessException.class,
|
|
|
+ () -> service.requireEnabled(merchant(11L, 2L, "M001", "1")));
|
|
|
+
|
|
|
+ assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
|
|
|
+ assertTrue(e.getMessage().contains("商户已停用"), "实测消息: " + e.getMessage());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void requireEnabled_enabled_passes() {
|
|
|
+ service.requireEnabled(merchant(11L, 2L, "M001", "0"));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ───────────────────────── 二、按编号 / 主键寻址 ─────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void requireByMchNo_found_returnsRow() {
|
|
|
+ UnifiedMerchantEntity row = merchant(11L, 2L, "M001", "0");
|
|
|
+ when(merchantMapper.selectOne(any())).thenReturn(row);
|
|
|
+
|
|
|
+ assertSame(row, service.requireByMchNo("M001"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void requireByMchNo_missing_throws404() {
|
|
|
+ when(merchantMapper.selectOne(any())).thenReturn(null);
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class,
|
|
|
+ () -> service.requireByMchNo("M999"));
|
|
|
+
|
|
|
+ assertEquals(404, e.getCode(), "实测消息: " + e.getMessage());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void requireByMchNo_blank_throws400_withoutQuerying() {
|
|
|
+ BusinessException e = assertThrows(BusinessException.class,
|
|
|
+ () -> service.requireByMchNo(" "));
|
|
|
+
|
|
|
+ assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
|
|
|
+ verifyNoInteractions(merchantMapper);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void requireById_missing_throws404() {
|
|
|
+ when(merchantMapper.selectById(11L)).thenReturn(null);
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class, () -> service.requireById(11L));
|
|
|
+
|
|
|
+ assertEquals(404, e.getCode(), "实测消息: " + e.getMessage());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void requireById_nullId_throws400_withoutQuerying() {
|
|
|
+ BusinessException e = assertThrows(BusinessException.class, () -> service.requireById(null));
|
|
|
+
|
|
|
+ assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
|
|
|
+ verifyNoInteractions(merchantMapper);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ───────────────────────────── 三、新增 ─────────────────────────────
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 缺 tenant_id 必须**拒绝**。
|
|
|
+ *
|
|
|
+ * 放过去的后果不是报错而是静默错位:AutoFillMetaObjectHandler 会拿超管自己的
|
|
|
+ * tenantId 填上,本想给租户 7 建的号落在超管名下。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void save_create_missingTenantId_throws400_withoutInsert() {
|
|
|
+ UnifiedMerchantSaveDTO dto = createDto();
|
|
|
+ dto.setTenantId(null);
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class, () -> service.save(dto));
|
|
|
+
|
|
|
+ assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
|
|
|
+ verify(merchantMapper, never()).insert(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void save_create_duplicateMchNo_throws400_withoutInsert() {
|
|
|
+ when(merchantMapper.selectCount(any())).thenReturn(1L);
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class, () -> service.save(createDto()));
|
|
|
+
|
|
|
+ assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
|
|
|
+ assertTrue(e.getMessage().contains("商户号已存在"), "实测消息: " + e.getMessage());
|
|
|
+ verify(merchantMapper, never()).insert(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 新增时 {@code app_secret} 必填(spec §7.1);空值放过去只会在上游签名阶段失败。 */
|
|
|
+ @Test
|
|
|
+ void save_create_blankAppSecret_throws400_withoutInsert() {
|
|
|
+ UnifiedMerchantSaveDTO dto = createDto();
|
|
|
+ dto.setAppSecret(" ");
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class, () -> service.save(dto));
|
|
|
+
|
|
|
+ assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
|
|
|
+ assertTrue(e.getMessage().contains("appSecret"), "实测消息: " + e.getMessage());
|
|
|
+ verify(merchantMapper, never()).insert(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void save_create_success_insertsWithChosenTenantAndSecret() {
|
|
|
+ when(merchantMapper.selectCount(any())).thenReturn(0L);
|
|
|
+
|
|
|
+ service.save(createDto());
|
|
|
+
|
|
|
+ ArgumentCaptor<UnifiedMerchantEntity> captor = ArgumentCaptor.forClass(UnifiedMerchantEntity.class);
|
|
|
+ verify(merchantMapper).insert(captor.capture());
|
|
|
+ UnifiedMerchantEntity inserted = captor.getValue();
|
|
|
+ assertEquals(7L, inserted.getTenantId(), "归属必须是超管选的那个租户");
|
|
|
+ assertEquals("M001", inserted.getMchNo());
|
|
|
+ assertEquals("NEW-SECRET", inserted.getAppSecret());
|
|
|
+ assertNull(inserted.getId(), "主键交给 MyBatis-Plus ASSIGN_ID(雪花)生成");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ───────────────────────────── 四、修改 ─────────────────────────────
|
|
|
+
|
|
|
+ /** 改归属等于让同一笔业务横跨两个租户:请求里的 tenantId 必须被忽略。 */
|
|
|
+ @Test
|
|
|
+ void save_update_keepsOriginalTenant_ignoresRequestedOne() {
|
|
|
+ UnifiedMerchantEntity existing = merchant(5L, 1L, "M001", "0");
|
|
|
+ when(merchantMapper.selectById(5L)).thenReturn(existing);
|
|
|
+
|
|
|
+ UnifiedMerchantSaveDTO dto = createDto();
|
|
|
+ dto.setId(5L);
|
|
|
+ dto.setTenantId(9L); // 试图改归属
|
|
|
+ dto.setMchNo("M001"); // 编号未变 ⇒ 不触发查重
|
|
|
+ service.save(dto);
|
|
|
+
|
|
|
+ ArgumentCaptor<UnifiedMerchantEntity> captor = ArgumentCaptor.forClass(UnifiedMerchantEntity.class);
|
|
|
+ verify(merchantMapper).updateById(captor.capture());
|
|
|
+ assertEquals(1L, captor.getValue().getTenantId(), "归属必须保持原值");
|
|
|
+ verify(merchantMapper, never()).insert(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 留空 = 不修改。写成空串会让该商户之后所有上游请求签名失败。 */
|
|
|
+ @Test
|
|
|
+ void save_update_blankAppSecret_keepsOriginal() {
|
|
|
+ UnifiedMerchantEntity existing = merchant(5L, 1L, "M001", "0");
|
|
|
+ existing.setAppSecret("OLD-SECRET");
|
|
|
+ when(merchantMapper.selectById(5L)).thenReturn(existing);
|
|
|
+
|
|
|
+ UnifiedMerchantSaveDTO dto = createDto();
|
|
|
+ dto.setId(5L);
|
|
|
+ dto.setMchNo("M001");
|
|
|
+ dto.setAppSecret(" ");
|
|
|
+ service.save(dto);
|
|
|
+
|
|
|
+ ArgumentCaptor<UnifiedMerchantEntity> captor = ArgumentCaptor.forClass(UnifiedMerchantEntity.class);
|
|
|
+ verify(merchantMapper).updateById(captor.capture());
|
|
|
+ assertEquals("OLD-SECRET", captor.getValue().getAppSecret(), "空白 appSecret 必须保留原值");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void save_update_nonBlankAppSecret_overwrites() {
|
|
|
+ UnifiedMerchantEntity existing = merchant(5L, 1L, "M001", "0");
|
|
|
+ existing.setAppSecret("OLD-SECRET");
|
|
|
+ when(merchantMapper.selectById(5L)).thenReturn(existing);
|
|
|
+
|
|
|
+ UnifiedMerchantSaveDTO dto = createDto();
|
|
|
+ dto.setId(5L);
|
|
|
+ dto.setMchNo("M001");
|
|
|
+ dto.setAppSecret("NEW-SECRET");
|
|
|
+ service.save(dto);
|
|
|
+
|
|
|
+ ArgumentCaptor<UnifiedMerchantEntity> captor = ArgumentCaptor.forClass(UnifiedMerchantEntity.class);
|
|
|
+ verify(merchantMapper).updateById(captor.capture());
|
|
|
+ assertEquals("NEW-SECRET", captor.getValue().getAppSecret());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void save_update_missingRow_throws404_withoutUpdate() {
|
|
|
+ when(merchantMapper.selectById(5L)).thenReturn(null);
|
|
|
+
|
|
|
+ UnifiedMerchantSaveDTO dto = createDto();
|
|
|
+ dto.setId(5L);
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class, () -> service.save(dto));
|
|
|
+
|
|
|
+ assertEquals(404, e.getCode(), "实测消息: " + e.getMessage());
|
|
|
+ verify(merchantMapper, never()).updateById(any());
|
|
|
+ verify(merchantMapper, never()).insert(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ───────────────────────────── 五、删除 ─────────────────────────────
|
|
|
+
|
|
|
+ /** 有订单 ⇒ 拒删:历史单失去配置,退款/对账都定位不到上游。 */
|
|
|
+ @Test
|
|
|
+ void delete_withOrders_throws400_andNeverDeletes() {
|
|
|
+ when(merchantMapper.selectById(5L)).thenReturn(merchant(5L, 2L, "M001", "0"));
|
|
|
+ when(orderMapper.selectCount(any())).thenReturn(1L);
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class, () -> service.delete(5L));
|
|
|
+
|
|
|
+ assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
|
|
|
+ assertTrue(e.getMessage().contains("已有交易,不能删除,请停用"), "实测消息: " + e.getMessage());
|
|
|
+ verify(merchantMapper, never()).deleteById(any());
|
|
|
+ verifyNoInteractions(refundMapper);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 有退款也一样 —— 退款行只存 mch_no 快照,但仍是这笔商户的交易历史。 */
|
|
|
+ @Test
|
|
|
+ void delete_withRefunds_throws400_andNeverDeletes() {
|
|
|
+ when(merchantMapper.selectById(5L)).thenReturn(merchant(5L, 2L, "M001", "0"));
|
|
|
+ when(orderMapper.selectCount(any())).thenReturn(0L);
|
|
|
+ when(refundMapper.selectCount(any())).thenReturn(1L);
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class, () -> service.delete(5L));
|
|
|
+
|
|
|
+ assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
|
|
|
+ assertTrue(e.getMessage().contains("已有交易,不能删除,请停用"), "实测消息: " + e.getMessage());
|
|
|
+ verify(merchantMapper, never()).deleteById(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void delete_noTransactions_deletesById() {
|
|
|
+ when(merchantMapper.selectById(5L)).thenReturn(merchant(5L, 2L, "M001", "0"));
|
|
|
+ when(orderMapper.selectCount(any())).thenReturn(0L);
|
|
|
+ when(refundMapper.selectCount(any())).thenReturn(0L);
|
|
|
+
|
|
|
+ service.delete(5L);
|
|
|
+
|
|
|
+ verify(merchantMapper).deleteById(5L);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 交易按 {@code merchant_id} 判、退款按 {@code mch_no} 判 —— 两列写在各自的 wrapper 里。 */
|
|
|
+ @Test
|
|
|
+ void delete_transactionChecks_useMerchantIdAndMchNo() {
|
|
|
+ when(merchantMapper.selectById(5L)).thenReturn(merchant(5L, 2L, "M001", "0"));
|
|
|
+ when(orderMapper.selectCount(any())).thenReturn(0L);
|
|
|
+ when(refundMapper.selectCount(any())).thenReturn(0L);
|
|
|
+
|
|
|
+ service.delete(5L);
|
|
|
+
|
|
|
+ @SuppressWarnings({"unchecked", "rawtypes"})
|
|
|
+ ArgumentCaptor<LambdaQueryWrapper<UnifiedOrderEntity>> orderCap =
|
|
|
+ ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
|
|
+ verify(orderMapper).selectCount(orderCap.capture());
|
|
|
+ assertTrue(orderCap.getValue().getSqlSegment().contains("merchant_id"),
|
|
|
+ "实测 WHERE: " + orderCap.getValue().getSqlSegment());
|
|
|
+
|
|
|
+ @SuppressWarnings({"unchecked", "rawtypes"})
|
|
|
+ ArgumentCaptor<LambdaQueryWrapper<UnifiedRefundEntity>> refundCap =
|
|
|
+ ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
|
|
+ verify(refundMapper).selectCount(refundCap.capture());
|
|
|
+ assertTrue(refundCap.getValue().getSqlSegment().contains("mch_no"),
|
|
|
+ "实测 WHERE: " + refundCap.getValue().getSqlSegment());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ─────────────────────────── 六、列表与 VO ───────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void list_mapsEntitiesToVoPage() {
|
|
|
+ UnifiedMerchantEntity row = merchant(11L, 2L, "M001", "0");
|
|
|
+ com.baomidou.mybatisplus.extension.plugins.pagination.Page<UnifiedMerchantEntity> page =
|
|
|
+ new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(1, 20);
|
|
|
+ page.setRecords(List.of(row));
|
|
|
+ page.setTotal(1L);
|
|
|
+ doReturn(page).when(merchantMapper).selectPage(any(), any());
|
|
|
+
|
|
|
+ UnifiedMerchantQueryDTO q = new UnifiedMerchantQueryDTO();
|
|
|
+ q.setPageNo(1);
|
|
|
+ q.setPageSize(20);
|
|
|
+ PageResult<UnifiedMerchantVO> result = service.list(q);
|
|
|
+
|
|
|
+ assertEquals(1, result.getPageNo());
|
|
|
+ assertEquals(20, result.getPageSize());
|
|
|
+ assertEquals(1L, result.getTotal());
|
|
|
+ assertEquals(1, result.getList().size());
|
|
|
+ assertEquals("M001", result.getList().get(0).getMchNo());
|
|
|
+ assertTrue(result.getList().get(0).isAppSecretSet());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 硬约束:VO **没有** {@code appSecret} 字段,只给 {@code appSecretSet}。
|
|
|
+ *
|
|
|
+ * <p>反射断言字段不存在,再用真实序列化确认密钥明文不出现在报文里 ——
|
|
|
+ * 只断言字段名的话,有人加一个 {@code getAppSecret()} 方法照样泄漏。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void vo_neverExposesAppSecret_butReportsWhetherItIsSet() throws Exception {
|
|
|
+ UnifiedMerchantEntity entity = merchant(1234567890123456789L, 2L, "M001", "0");
|
|
|
+ entity.setAppSecret("S3CRET-VALUE");
|
|
|
+ entity.setDescription("备注");
|
|
|
+
|
|
|
+ UnifiedMerchantVO vo = UnifiedMerchantVO.of(entity);
|
|
|
+ assertTrue(vo.isAppSecretSet());
|
|
|
+ assertEquals(2L, vo.getTenantId());
|
|
|
+ assertEquals("M001", vo.getMchNo());
|
|
|
+
|
|
|
+ for (Field f : UnifiedMerchantVO.class.getDeclaredFields()) {
|
|
|
+ assertFalse("appSecret".equalsIgnoreCase(f.getName()),
|
|
|
+ "VO 不得声明 appSecret 字段:读懂它的人就是攻击者");
|
|
|
+ }
|
|
|
+
|
|
|
+ String json = new ObjectMapper().writeValueAsString(vo);
|
|
|
+ assertFalse(json.contains("S3CRET-VALUE"), "序列化报文不得含密钥明文: " + json);
|
|
|
+ assertTrue(json.contains("\"appSecretSet\":true"), "实测 JSON: " + json);
|
|
|
+ assertTrue(json.contains("\"id\":\"1234567890123456789\""),
|
|
|
+ "19 位雪花 id 必须序列化成字符串,实测 JSON: " + json);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 正对照:只断言「没有 appSecret」的话,一个永远返回 null 的 of() 也会绿。 */
|
|
|
+ @Test
|
|
|
+ void vo_copiesTheDisplayFields() throws Exception {
|
|
|
+ UnifiedMerchantEntity entity = merchant(11L, 2L, "M001", "0");
|
|
|
+ entity.setDescription("备注");
|
|
|
+
|
|
|
+ UnifiedMerchantVO vo = UnifiedMerchantVO.of(entity);
|
|
|
+
|
|
|
+ assertEquals("app-M001", vo.getAppId());
|
|
|
+ assertEquals("商户M001", vo.getMerchantName());
|
|
|
+ assertEquals("fuioupay", vo.getChannelUserIfCode());
|
|
|
+ assertEquals("0", vo.getStatus());
|
|
|
+ assertEquals("备注", vo.getDescription());
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 空实体不能抛 NPE —— 调用方只在有行时使用,但 of(null) 的语义要稳定。 */
|
|
|
+ @Test
|
|
|
+ void vo_ofNull_returnsNull() {
|
|
|
+ assertNull(UnifiedMerchantVO.of(null));
|
|
|
+ }
|
|
|
+}
|