Просмотр исходного кода

merge(task/S-cfc-45): RED 契约级单测并入主链

Agnes Agent 5 дней назад
Родитель
Сommit
7e5883bb0d
1 измененных файлов с 203 добавлено и 0 удалено
  1. 203 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/TeamUserPortalContractTest.java

+ 203 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/TeamUserPortalContractTest.java

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