Explorar el Código

test(s-cfc-162): service.role.applyBindInvite 契约登记测试(行为级 Mockito,6/6 绿——实现已链上 9afaa6e46,转测试登记)

Sisyphus Agent hace 18 horas
padre
commit
34bc9a3aa9

+ 206 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ServiceRoleApplyBindInviteContractTest.java

@@ -0,0 +1,206 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.ServiceRoleApplyDTO;
+import com.etotem.cfc.entity.ServiceRoleApplication;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.ServiceRoleApplicationMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.impl.ServiceRoleApplicationServiceImpl;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:服务角色申请表邀请归属绑定(能力键 {@code service.role.applyBindInvite})。
+ *
+ * <p><b>契约锚点</b></p>
+ * <ul>
+ *   <li>需求:docs/需求分析/2026-11-12-团队邀请功能及流程改造需求变更.md(INVITE-2026-11-12-V1)</li>
+ *   <li>设计:merge commit {@code 9afaa6e46}(tree {@code b785438fa})→ S2-3 记录级归属 + 三口径 + 防冒领</li>
+ *   <li>实现类:{@link ServiceRoleApplicationServiceImpl#submit(Long, ServiceRoleApplyDTO)}</li>
+ * </ul>
+ *
+ * <p><b>被校验的契约行为(submit 记录级归属)</b></p>
+ * <ul>
+ *   <li>口径①:无 {@code refCode}(用户从个人中心主动进入申请)→ {@code inviterUserId=null} 正常受理,不报错。</li>
+ *   <li>口径②:带 {@code refCode} 但反查失败 / 自邀请 → {@code Result.error("邀请码无效或不能邀请自己,请通过邀请人的分享链接进入")} 拒绝落库,绝不静默吞。</li>
+ *   <li>口径③:带有效 {@code refCode} → 记录级写入 {@code inviterUserId=inviter.getId()}。</li>
+ *   <li>防冒领:服务端仅按 {@code dto.getRefCode()} 反查 {@code User::getReferralCode} 写入,DTO 不暴露 {@code inviterUserId} 直传字段(拒绝前端直传)。</li>
+ *   <li>两级解耦铁律:记录级归属仅落 {@code inviter_user_id} 一列,不触发分销/佣金副作用(本类不调 CommissionService)。</li>
+ * </ul>
+ *
+ * <p><b>测试范围</b>:{@code ServiceRoleApplicationServiceImpl.submit} 记录级邀请归属绑定行为。
+ * 仅用 Mockito(无 Spring / 无 DB / 无真实微信),断言不依赖真实外部服务。</p>
+ *
+ * <p><b>RED/GREEN 说明</b>:本契约测试为契约登记。若被测实现缺失该逻辑(GREEN 前),
+ * 无效码/自邀请用例应失败(红);实现补齐后自动转绿。</p>
+ */
+class ServiceRoleApplyBindInviteContractTest {
+
+    private static final String MSG_INVITE_INVALID = "邀请码无效或不能邀请自己,请通过邀请人的分享链接进入";
+
+    private static final Long APPLICANT_USER_ID = 100L;
+    private static final Long INVITER_USER_ID = 300L;
+
+    @Mock
+    private UserMapper userMapper;
+
+    @Mock
+    private ServiceRoleApplicationMapper serviceRoleApplicationMapper;
+
+    private ServiceRoleApplicationServiceImpl service;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        service = new ServiceRoleApplicationServiceImpl();
+        // ServiceImpl 的 baseMapper 为父类泛型字段,@InjectMocks 按类型注入不到,
+        // 显式注入(与仓库既有 ServiceImpl 子类测试一致)。
+        ReflectionTestUtils.setField(service, "userMapper", userMapper);
+        ReflectionTestUtils.setField(service, "baseMapper", serviceRoleApplicationMapper);
+        // 无既有待审申请(查重通过,走新建分支)
+        when(serviceRoleApplicationMapper.selectOne(any(Wrapper.class))).thenReturn(null);
+    }
+
+    // ==================== 口径①:无 refCode → 正常受理,inviterUserId=null ====================
+
+    @Test
+    void 无refCode_正常受理_inviterUserId为null_不反查邀请人() {
+        ServiceRoleApplyDTO dto = dto(null);
+
+        Result<?> result = service.submit(APPLICANT_USER_ID, dto);
+
+        assertEquals(200, result.getCode(), "无 refCode 主动申请应正常受理(不报错)");
+        ServiceRoleApplication saved = captureSaved();
+        assertNotNull(saved);
+        assertNull(saved.getInviterUserId(), "无 refCode(主动申请)应保持 inviterUserId=null");
+        verify(userMapper, never()).selectOne(any(Wrapper.class));
+    }
+
+    // ==================== 口径③:带有效 refCode → 记录级写入 inviterUserId ====================
+
+    @Test
+    void 带有效refCode_反查邀请人_写入inviterUserId() {
+        ServiceRoleApplyDTO dto = dto("GOODCODE");
+        when(userMapper.selectOne(any(Wrapper.class))).thenReturn(inviter(INVITER_USER_ID, "GOODCODE"));
+
+        Result<?> result = service.submit(APPLICANT_USER_ID, dto);
+
+        assertEquals(200, result.getCode(), "带有效 refCode 应受理");
+        ServiceRoleApplication saved = captureSaved();
+        assertNotNull(saved);
+        assertEquals(INVITER_USER_ID, saved.getInviterUserId(),
+                "带有效 refCode 应把记录挂到邀请人名下(记录级归属)");
+    }
+
+    // ==================== 口径②:无效 refCode → 拒绝落库 ====================
+
+    @Test
+    void 带无效refCode_拒绝提交_提示分享链接进入_不落库() {
+        ServiceRoleApplyDTO dto = dto("BADCODE");
+        // 反查失败:referralCode 无匹配用户
+        when(userMapper.selectOne(any(Wrapper.class))).thenReturn(null);
+
+        Result<?> result = service.submit(APPLICANT_USER_ID, dto);
+
+        assertEquals(500, result.getCode());
+        assertEquals(MSG_INVITE_INVALID, result.getMessage());
+        verify(serviceRoleApplicationMapper, never()).insert(any(ServiceRoleApplication.class));
+    }
+
+    // ==================== 口径②:自邀请 → 拒绝落库 ====================
+
+    @Test
+    void 自邀请refCode_拒绝提交_提示分享链接进入_不落库() {
+        ServiceRoleApplyDTO dto = dto("SELFCODE");
+        // 反查命中但 inviter.id == 申请人 id → 自邀请
+        when(userMapper.selectOne(any(Wrapper.class))).thenReturn(inviter(APPLICANT_USER_ID, "SELFCODE"));
+
+        Result<?> result = service.submit(APPLICANT_USER_ID, dto);
+
+        assertEquals(500, result.getCode());
+        assertEquals(MSG_INVITE_INVALID, result.getMessage());
+        verify(serviceRoleApplicationMapper, never()).insert(any(ServiceRoleApplication.class));
+    }
+
+    // ==================== 防冒领:DTO 不暴露直传 inviterUserId 字段 ====================
+
+    @Test
+    void 防冒领_dto不暴露inviterUserId直传字段() throws Exception {
+        Class<?> dtoClass = ServiceRoleApplyDTO.class;
+        boolean hasDirectField = Arrays.stream(dtoClass.getDeclaredFields())
+                .anyMatch(f -> f.getName().equals("inviterUserId"));
+        boolean hasDirectSetter = false;
+        for (Method m : dtoClass.getDeclaredMethods()) {
+            if (m.getName().equals("setInviterUserId") || m.getName().equals("getInviterUserId")) {
+                hasDirectSetter = true;
+                break;
+            }
+        }
+        assertFalse(hasDirectField,
+                "防冒领:ServiceRoleApplyDTO 不应暴露 inviterUserId 直传字段(前端直传会被冒领,服务端只能按 refCode 反查)");
+        assertFalse(hasDirectSetter,
+                "防冒领:ServiceRoleApplyDTO 不应暴露 inviterUserId 的 setter/getter");
+        // 契约锚点:DTO 确实通过 refCode 传递归属信息
+        assertTrue(Arrays.stream(dtoClass.getDeclaredFields())
+                        .anyMatch(f -> f.getName().equals("refCode")),
+                "ServiceRoleApplyDTO 应通过 refCode 传递归属信息(分享码)");
+    }
+
+    // ==================== 两级解耦铁律:记录级归属不触发分销副作用 ====================
+
+    @Test
+    void 两级解耦_记录级归属不触发分销副作用() {
+        ServiceRoleApplyDTO dto = dto("GOODCODE");
+        when(userMapper.selectOne(any(Wrapper.class))).thenReturn(inviter(INVITER_USER_ID, "GOODCODE"));
+
+        service.submit(APPLICANT_USER_ID, dto);
+
+        // 仅 userMapper.selectOne(反查邀请人)+ baseMapper.insert(落库)两个 mapper 交互;
+        // 无任何分销/佣金 mapper 交互 —— 两级解耦铁律:记录级归属仅落 inviter_user_id 一列。
+        verify(userMapper).selectOne(any(Wrapper.class));
+        verify(serviceRoleApplicationMapper).insert(any(ServiceRoleApplication.class));
+    }
+
+    // ==================== 辅助构造 ====================
+
+    private ServiceRoleApplyDTO dto(String refCode) {
+        ServiceRoleApplyDTO dto = new ServiceRoleApplyDTO();
+        dto.setRoles(Collections.singletonList("planner"));
+        dto.setIdCard("110101199001011234");
+        dto.setRefCode(refCode);
+        return dto;
+    }
+
+    private User inviter(Long id, String referralCode) {
+        User u = new User();
+        u.setId(id);
+        u.setReferralCode(referralCode);
+        return u;
+    }
+
+    private ServiceRoleApplication captureSaved() {
+        ArgumentCaptor<ServiceRoleApplication> captor = ArgumentCaptor.forClass(ServiceRoleApplication.class);
+        verify(serviceRoleApplicationMapper).insert(captor.capture());
+        return captor.getValue();
+    }
+}