|
@@ -0,0 +1,203 @@
|
|
|
|
|
+package com.etotem.cfc.unit;
|
|
|
|
|
+
|
|
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
|
|
+import com.etotem.cfc.controller.DistributionInviteController;
|
|
|
|
|
+import com.etotem.cfc.entity.DistributionRelation;
|
|
|
|
|
+import com.etotem.cfc.entity.DistributionSystem;
|
|
|
|
|
+import com.etotem.cfc.entity.User;
|
|
|
|
|
+import com.etotem.cfc.mapper.DistributionRelationMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.DistributionSystemMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.UserMapper;
|
|
|
|
|
+import com.etotem.cfc.service.DistributionService;
|
|
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
|
|
+import org.mockito.ArgumentCaptor;
|
|
|
|
|
+
|
|
|
|
|
+import java.lang.reflect.Field;
|
|
|
|
|
+import java.util.Collections;
|
|
|
|
|
+import java.util.HashMap;
|
|
|
|
|
+import java.util.Map;
|
|
|
|
|
+
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
|
|
+import static org.mockito.Mockito.mock;
|
|
|
|
|
+import static org.mockito.Mockito.never;
|
|
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 能力键 <b>team.user.portal</b>(单元 team)——T6 用户端团队门户 契约级单测(TDD RED,任务号 S-cfc-45)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>需求:docs/superpowers/specs/2026-11-07-referral-team-profit-sharing.md v0.7
|
|
|
|
|
+ * {@code §12-T6}(团队成员分润账单 / 星级展示 / 团队邀请入口(§5) / 提现入口)+
|
|
|
|
|
+ * {@code §4.6} 团队体系完整流程 + {@code §7.3/7.4} 账单字段(契约中后端账表面)+ 提现入口(§11 拍板 #7)。</p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>RED 依据(契约新增能力点,实现未落地/边界未覆盖):</b></p>
|
|
|
|
|
+ * <ul>
|
|
|
|
|
+ * <li><b>场景A 推荐人绑定缺失(§5.1 场景A / §5.3 / §9#10)</b>:契约要求「未注册用户扫团队邀请码注册时
|
|
|
|
|
+ * 同时①绑定 {@code user.referrerId}=邀请人(可拿 F03a/F03b)②加入团队树」。
|
|
|
|
|
+ * {@link DistributionService#registerByInviteCode} 是 §5.3 明确的场景A载体,当前实现
|
|
|
|
|
+ * <b>只插入团队关系、从不写 {@code user.referrerId}</b> → 用例1 断言失败 = 真实缺陷。</li>
|
|
|
|
|
+ * <li><b>团队邀请校验缺「团队业绩」预览(§5.2)</b>:契约要求校验返回「团队名 / 邀请人 / 团队业绩」预览,
|
|
|
|
|
+ * 现有 validate 资产只返回 valid/inviteCode/systemId/inviterId/teamName → 用例4 断言失败。</li>
|
|
|
|
|
+ * </ul>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>其余用例为既有契约<b>兼容基线</b>(场景B 幂等提示 / 场景B 不改推荐人关系),GREEN 阶段防止回归。</p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>纯 Mockito,无 Spring 上下文 / 无 DB / 不依赖微信或外部服务。</p>
|
|
|
|
|
+ */
|
|
|
|
|
+class TeamUserPortalContractTest {
|
|
|
|
|
+
|
|
|
|
|
+ private DistributionService distributionService;
|
|
|
|
|
+ private DistributionRelationMapper relationMapper;
|
|
|
|
|
+ private DistributionSystemMapper systemMapper;
|
|
|
|
|
+ private UserMapper userMapper;
|
|
|
|
|
+
|
|
|
|
|
+ private DistributionService distributionServiceMock;
|
|
|
|
|
+
|
|
|
|
|
+ @BeforeEach
|
|
|
|
|
+ void setUp() throws Exception {
|
|
|
|
|
+ distributionService = new DistributionService();
|
|
|
|
|
+ relationMapper = mock(DistributionRelationMapper.class);
|
|
|
|
|
+ systemMapper = mock(DistributionSystemMapper.class);
|
|
|
|
|
+ userMapper = mock(UserMapper.class);
|
|
|
|
|
+ inject(distributionService, "distributionRelationMapper", relationMapper);
|
|
|
|
|
+ inject(distributionService, "distributionSystemMapper", systemMapper);
|
|
|
|
|
+ inject(distributionService, "userMapper", userMapper);
|
|
|
|
|
+
|
|
|
|
|
+ distributionServiceMock = mock(DistributionService.class);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static void inject(Object target, String field, Object value) throws Exception {
|
|
|
|
|
+ Field f = target.getClass().getDeclaredField(field);
|
|
|
|
|
+ f.setAccessible(true);
|
|
|
|
|
+ f.set(target, value);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private DistributionRelation relation(Long id, Long userId, Long parentId, int depth, String inviteCode) {
|
|
|
|
|
+ DistributionRelation r = new DistributionRelation();
|
|
|
|
|
+ r.setId(id);
|
|
|
|
|
+ r.setSystemId(1L);
|
|
|
|
|
+ r.setUserId(userId);
|
|
|
|
|
+ r.setParentId(parentId);
|
|
|
|
|
+ r.setDepth(depth);
|
|
|
|
|
+ r.setInviteCode(inviteCode);
|
|
|
|
|
+ return r;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+ // 团队邀请入口 §5.1 场景A:未注册用户扫码注册 → 推荐人关系 + 团队关系 同时建立(§9#10)
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 契约 AC(§5.1 场景A / §9#10):新用户通过团队邀请码入团后,{@code user.referrerId} 必须 =
|
|
|
|
|
+ * 邀请人(F03a/F03b 可得)。
|
|
|
|
|
+ * 当前实现 {@link DistributionService#registerByInviteCode} 仅写团队关系、不写 referrerId → 本用例 RED。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void registerByInviteCode_scenarioA_bindsReferrerIdForNewUser() {
|
|
|
|
|
+ DistributionRelation inviter = relation(1L, 100L, null, 0, "AB12CD34");
|
|
|
|
|
+ // 第1次 selectOne:按邀请码命中邀请人;第2次 selectOne:新用户(200)尚未在团队 → null
|
|
|
|
|
+ when(relationMapper.selectOne(any())).thenReturn(inviter, null);
|
|
|
|
|
+
|
|
|
|
|
+ User joiner = new User();
|
|
|
|
|
+ joiner.setId(200L);
|
|
|
|
|
+ when(userMapper.selectById(200L)).thenReturn(joiner);
|
|
|
|
|
+
|
|
|
|
|
+ Result<String> r = distributionService.registerByInviteCode("AB12CD34", 200L);
|
|
|
|
|
+ assertEquals(200, r.getCode(), "场景A:邀请码有效时应入团成功");
|
|
|
|
|
+
|
|
|
|
|
+ // 基线(PASS):团队关系已建立 parent=邀请人, depth=邀请人depth+1
|
|
|
|
|
+ ArgumentCaptor<DistributionRelation> captor = ArgumentCaptor.forClass(DistributionRelation.class);
|
|
|
|
|
+ verify(relationMapper).insert(captor.capture());
|
|
|
|
|
+ DistributionRelation inserted = captor.getValue();
|
|
|
|
|
+ assertEquals(Long.valueOf(100L), inserted.getParentId(), "契约:新用户团队父节点=邀请人(§5.1场景A②)");
|
|
|
|
|
+ assertEquals(Integer.valueOf(1), inserted.getDepth(), "契约:新用户 depth=邀请人depth+1(§5.1场景A②)");
|
|
|
|
|
+
|
|
|
|
|
+ // RED:契约要求同时绑定推荐人关系(§5.1场景A① / §9#10)——实现未落地,referrerId 为 null
|
|
|
|
|
+ User persisted = userMapper.selectById(200L);
|
|
|
|
|
+ assertNotNull(persisted, "契约:入团后用户应可读回");
|
|
|
|
|
+ assertEquals(Long.valueOf(100L), persisted.getReferrerId(),
|
|
|
|
|
+ "契约:场景A 注册须绑定 user.referrerId=邀请人(§5.1场景A①/§9#10),F03a/F03b 可得");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+ // 团队邀请入口 §5.1 场景B:已加入平台老用户 → 加入/已在团队提示(§9#9 幂等)
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 契约 AC(§5.2 / §9#9):已在团队的用户重复扫码 → 提示/跳过,不重复插入团队关系。
|
|
|
|
|
+ * 当前实现返回「用户已在该销售体系中」错误提示且不插入 → 符合契约,PASS 基线。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void registerByInviteCode_scenarioB_duplicateJoinIsPromptedWithoutDoubleInsert() {
|
|
|
|
|
+ DistributionRelation inviter = relation(1L, 100L, null, 0, "AB12CD34");
|
|
|
|
|
+ DistributionRelation existing = relation(2L, 200L, 100L, 1, null);
|
|
|
|
|
+ // 第1次:按邀请码命中邀请人;第2次:用户(200)已在团队 → 命中 existing
|
|
|
|
|
+ when(relationMapper.selectOne(any())).thenReturn(inviter, existing);
|
|
|
|
|
+
|
|
|
|
|
+ Result<String> r = distributionService.registerByInviteCode("AB12CD34", 200L);
|
|
|
|
|
+
|
|
|
|
|
+ assertTrue(r.getCode() != 200, "契约:已在团队 → 提示/跳过(§5.2 幂等、§9#9)");
|
|
|
|
|
+ verify(relationMapper, never()).insert(any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 契约 AC(§5.1 场景B / §9#9):老用户加入团队仅建团队关系,绝不改动既有推荐人关系。
|
|
|
|
|
+ * 当前 {@link DistributionService#addRelation} 只插 distribution_relations、不碰 userMapper → PASS 基线。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void addRelation_scenarioB_joinsTeamWithoutTouchingReferralRelation() {
|
|
|
|
|
+ DistributionRelation parent = relation(2L, 200L, 100L, 2, null);
|
|
|
|
|
+ // 第1次:已存在校验(member 300) → null;第2次:上级节点(member 200)查询 → parent
|
|
|
|
|
+ when(relationMapper.selectOne(any())).thenReturn(null, parent);
|
|
|
|
|
+
|
|
|
|
|
+ Result<Void> r = distributionService.addRelation(1L, 300L, 200L);
|
|
|
|
|
+ assertEquals(200, r.getCode(), "契约:场景B 老用户应按 parentId 直接入团");
|
|
|
|
|
+
|
|
|
|
|
+ ArgumentCaptor<DistributionRelation> captor = ArgumentCaptor.forClass(DistributionRelation.class);
|
|
|
|
|
+ verify(relationMapper).insert(captor.capture());
|
|
|
|
|
+ DistributionRelation inserted = captor.getValue();
|
|
|
|
|
+ assertEquals(Long.valueOf(200L), inserted.getParentId(), "契约:场景B 团队父节点=邀请人");
|
|
|
|
|
+ assertEquals(Integer.valueOf(3), inserted.getDepth(), "契约:场景B depth=上级depth+1");
|
|
|
|
|
+
|
|
|
|
|
+ // 契约:仅建团队关系,推荐人关系(user.referrerId)不被改动(§9#9)
|
|
|
|
|
+ verify(userMapper, never()).updateById(any());
|
|
|
|
|
+ verify(userMapper, never()).insert(any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+ // 团队邀请入口 §5.2:校验返回须含「团队业绩」预览
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 契约 AC(§5.2):团队邀请校验返回「团队名 / 邀请人 / 团队业绩」预览。
|
|
|
|
|
+ * 现有 validate 资产仅返回 valid/inviteCode/systemId/inviterId/teamName,缺团队业绩预览 → 本用例 RED。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void inviteValidate_returnsTeamPerformancePreview() throws Exception {
|
|
|
|
|
+ DistributionInviteController controller = new DistributionInviteController();
|
|
|
|
|
+ inject(controller, "distributionService", distributionServiceMock);
|
|
|
|
|
+
|
|
|
|
|
+ DistributionRelation inviter = relation(1L, 100L, null, 0, "AB12CD34");
|
|
|
|
|
+ when(distributionServiceMock.findByInviteCode("AB12CD34")).thenReturn(Result.success(inviter));
|
|
|
|
|
+ DistributionSystem team = new DistributionSystem();
|
|
|
|
|
+ team.setId(1L);
|
|
|
|
|
+ team.setName("测试服务团队");
|
|
|
|
|
+ when(distributionServiceMock.getUserTeams(100L))
|
|
|
|
|
+ .thenReturn(Result.success(Collections.singletonList(team)));
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> params = new HashMap<>();
|
|
|
|
|
+ params.put("inviteCode", "AB12CD34");
|
|
|
|
|
+ Result<Map<String, Object>> result = controller.validate(params);
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(200, result.getCode(), "契约:有效邀请码校验应成功");
|
|
|
|
|
+ Map<String, Object> data = result.getData();
|
|
|
|
|
+ assertNotNull(data, "契约:校验成功应返回预览数据");
|
|
|
|
|
+ // RED:契约要求团队业绩预览(§5.2),当前实现缺失
|
|
|
|
|
+ assertTrue(data.containsKey("teamPerformance"),
|
|
|
|
|
+ "契约:团队邀请校验须返回「团队业绩」预览(§5.2),当前 validate 仅返回 valid/inviteCode/systemId/inviterId/teamName");
|
|
|
|
|
+ }
|
|
|
|
|
+}
|