|
|
@@ -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();
|
|
|
+ }
|
|
|
+}
|