瀏覽代碼

merge: S-cfc-178 family.familyMembers.removeMember RED 契约测试合入主链

E2E Test Bot 2 小時之前
父節點
當前提交
14867308ba
共有 1 個文件被更改,包括 364 次插入 和 0 次删除
  1. 364 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/FamilyRemoveMemberContractTest.java

+ 364 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/FamilyRemoveMemberContractTest.java

@@ -0,0 +1,364 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.entity.Family;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.FamilyMapper;
+import com.etotem.cfc.mapper.FamilyMemberAttributesMapper;
+import com.etotem.cfc.mapper.FamilyMemberLogMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.FamilyMembershipMapper;
+import com.etotem.cfc.mapper.FiveDimensionScoreMapper;
+import com.etotem.cfc.mapper.HealthReportMapper;
+import com.etotem.cfc.mapper.MembershipLevelMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.CouponService;
+import com.etotem.cfc.service.FamilyFoodOverviewService;
+import com.etotem.cfc.service.FamilyMemberService;
+import com.etotem.cfc.service.FamilyRelationshipService;
+import com.etotem.cfc.service.MembershipService;
+import com.etotem.cfc.service.OnboardingService;
+import com.etotem.cfc.service.ReportMemberPermissionService;
+import com.etotem.cfc.service.SystemConfigService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.lang.reflect.Field;
+import java.util.Date;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:能力键 {@code family.familyMembers.removeMember}(移除/退出家庭成员后的会员身份重算)。
+ *
+ * <p><b>溯源·需求</b>:{@code tests/user-scenarios/应用场景总览.md} SC-P-06(移除/删除成员,业务域 A 家庭与成员管理)。
+ * <p><b>溯源·设计</b>:{@code docs/superpowers/api/API_REFERENCE.md} §4.2
+ * {@code POST /api/family/member/kick}(移除成员)/ {@code POST /api/family/member/leave}(主动退出家庭),
+ * 均已合入 origin/cfclub。</p>
+ *
+ * <p><b>用户实测缺陷</b>:用户被从家庭会员家庭移除后未恢复免费身份——
+ * {@code users.member_level / member_expire_time} 缓存残留(仍在 FAMILY),
+ * {@code MembershipService.getMemberLevel} 取 personal 与 family 高者仍返回 FAMILY。
+ * <b>期望</b>:kick/leave 后被踢用户如无个人独立更高等级会员则恢复 FREE。</p>
+ *
+ * <p><b>实现链现状(缺陷定位)</b>:
+ * <blockquote>
+ *   {@code FamilyMemberService.kickMember()} 仅调用 {@code membershipService.reconcileFamilyMembership(oldFamilyId)}
+ *   (会先迁移被踢用户 familyId → 初始家庭,再对原家庭查全部成员)——被踢用户已迁出,不在
+ *   {@code downgradeFamilyMembers} / {@code syncMembershipToFamilyMembers}(只升不降)的查询范围,
+ *   其 {@code users.member_level} 缓存原样残留;{@code leaveFamily()} 同样只核原家庭。
+ *   {@code MembershipService.getMemberLevel(userId)} 把缓存值当「个人维度」参与取高,
+ *   新家庭无有效会员(family=FREE)时仍返回缓存的 FAMILY。
+ * </blockquote></p>
+ *
+ * <p><b>修复方向(GREEN 升级指引)</b>:kick/leave 对被踢用户按「个人维度」重算会员缓存——
+ * 仅清除源自家庭会员的缓存(等级 + 到期时间),保留更高等级独立个人会员;若无独立更高等级则恢复 FREE。
+ * 本测试以「结果契约」断言(被踢用户落库的 member_level / getMemberLevel 返回值),不预设具体方法名,
+ * 实现即可自主选择改造路径(新方法或对新家庭维度的重核),只要结果符合契约即转绿。</p>
+ *
+ * <p><b>RED 预期</b>:本分支 src/main 未落地该重算逻辑——「恢复 FREE」「getMemberLevel 重归 FREE」
+ * 类用例应失败(红,4 例);「保留更高个人会员」「权限守卫」类用例当前已具备应为绿(用于锁定
+ * GREEN 不得误清个人高等级会员、不得放开权限规则)。纯 Mockito / 反射,无 Spring / 无 DB / 无外部服务。</p>
+ */
+class FamilyRemoveMemberContractTest {
+
+    private static final String LEVEL_FREE = "FREE";
+    private static final String LEVEL_FAMILY = "FAMILY";
+    private static final String LEVEL_LIFETIME = "LIFETIME";
+
+    private static final Long ADMIN_USER_ID = 1L;
+    private static final Long KICKED_USER_ID = 200L;
+    private static final Long OLD_FAMILY_ID = 100L;
+    private static final Long OWN_FAMILY_ID = 300L;
+    private static final Long MEMBER_ID = 105L;
+
+    // ---------------- FamilyMemberService 被测对象及其依赖 ----------------
+
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+    @Mock
+    private UserMapper userMapper;
+    @Mock
+    private FamilyMapper familyMapper;
+    @Mock
+    private FamilyMemberAttributesMapper familyMemberAttributesMapper;
+    @Mock
+    private OnboardingService onboardingService;
+    @Mock
+    private SystemConfigService systemConfigService;
+    @Mock
+    private FamilyRelationshipService familyRelationshipService;
+    @Mock
+    private FamilyMemberLogMapper familyMemberLogMapper;
+    @Mock
+    private CouponService couponService;
+    @Mock
+    private FiveDimensionScoreMapper fiveDimensionScoreMapper;
+    @Mock
+    private HealthReportMapper healthReportMapper;
+    @Mock
+    private MembershipService membershipService;
+    @Mock
+    private FamilyFoodOverviewService familyFoodOverviewService;
+    @Mock
+    private ReportMemberPermissionService reportMemberPermissionService;
+
+    @InjectMocks
+    private FamilyMemberService familyMemberService;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    // ==================== 正常流:踢出微信用户(kick) ====================
+
+    @Test
+    @DisplayName("正常流-1:踢出微信成员后,其家庭派生会员缓存应重算为 FREE(无独立更高等级时恢复免费身份)")
+    void kick微信用户_家庭派生会员缓存应重算为FREE() {
+        // 操作人 = 家庭创建者(管理员)
+        User admin = user(ADMIN_USER_ID, OLD_FAMILY_ID, "o_admin_001", LEVEL_FREE, null);
+        when(userMapper.selectById(ADMIN_USER_ID)).thenReturn(admin);
+        when(familyMapper.selectById(OLD_FAMILY_ID)).thenReturn(family(OLD_FAMILY_ID, ADMIN_USER_ID));
+        when(familyMemberMapper.selectById(MEMBER_ID))
+                .thenReturn(member(MEMBER_ID, OLD_FAMILY_ID, KICKED_USER_ID, "小李"));
+
+        // 被踢用户:微信登录用户,会员缓存为家庭派生(FAMILY + 未到期),且无独立更高等级个人会员
+        User kicked = user(KICKED_USER_ID, OLD_FAMILY_ID, "o_kicked_001", LEVEL_FAMILY, future());
+        when(userMapper.selectById(KICKED_USER_ID)).thenReturn(kicked);
+        // 被踢用户已有自己的初始家庭(资产随成员迁入)
+        when(familyMapper.selectOne(any())).thenReturn(family(OWN_FAMILY_ID, KICKED_USER_ID));
+
+        familyMemberService.kickMember(ADMIN_USER_ID, MEMBER_ID);
+
+        ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
+        verify(userMapper).updateById(captor.capture());
+        User persisted = captor.getValue();
+
+        assertEquals(LEVEL_FREE, persisted.getMemberLevel(),
+                "[family.familyMembers.removeMember] 踢出微信成员后,其 users.member_level 缓存应重算为 FREE"
+                        + "(仅清除源自家庭会员的缓存、恢复免费身份)——当前实现未落地:kickMember 只重核原家庭"
+                        + "(reconcileFamilyMembership(oldFamilyId)),被踢用户已迁出其家庭,缓存仍残留="
+                        + persisted.getMemberLevel() + ",期望=FREE(SC-P-06 / API §4.2 member/kick)。");
+        assertNull(persisted.getMemberExpireTime(),
+                "[family.familyMembers.removeMember] 被踢用户无独立更高等级会员时,member_expire_time 缓存应一并清除。");
+    }
+
+    @Test
+    @DisplayName("正常流-2:踢出持有独立更高等级个人会员(LIFETIME)的用户,LIFETIME 应保留不被误清")
+    void kick用户_独立LIFETIME个人会员应保留() {
+        User admin = user(ADMIN_USER_ID, OLD_FAMILY_ID, "o_admin_001", LEVEL_FREE, null);
+        when(userMapper.selectById(ADMIN_USER_ID)).thenReturn(admin);
+        when(familyMapper.selectById(OLD_FAMILY_ID)).thenReturn(family(OLD_FAMILY_ID, ADMIN_USER_ID));
+        when(familyMemberMapper.selectById(MEMBER_ID))
+                .thenReturn(member(MEMBER_ID, OLD_FAMILY_ID, KICKED_USER_ID, "小李"));
+
+        // 被踢用户自己购买了 LIFETIME(levelOrder 高于家庭派生的 FAMILY),属个人高等级会员
+        User kicked = user(KICKED_USER_ID, OLD_FAMILY_ID, "o_kicked_001", LEVEL_LIFETIME, future());
+        when(userMapper.selectById(KICKED_USER_ID)).thenReturn(kicked);
+        when(familyMapper.selectOne(any())).thenReturn(family(OWN_FAMILY_ID, KICKED_USER_ID));
+
+        familyMemberService.kickMember(ADMIN_USER_ID, MEMBER_ID);
+
+        ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
+        verify(userMapper).updateById(captor.capture());
+        User persisted = captor.getValue();
+
+        // 契约:仅清除「源自家庭会员」的缓存,更高等级独立个人会员必须保留(GREEN 不得一刀切清空)
+        assertEquals(LEVEL_LIFETIME, persisted.getMemberLevel(),
+                "[family.familyMembers.removeMember] 被踢用户独立 LIFETIME 个人会员应保留(仅清除家庭派生缓存)。");
+        assertNotNull(persisted.getMemberExpireTime(),
+                "[family.familyMembers.removeMember] 个人会员到期时间不应被清除。");
+    }
+
+    // ==================== 边界流:踢出非微信用户(回收箱) ====================
+
+    @Test
+    @DisplayName("边界流-1:踢出非微信用户进回收箱,其家庭派生会员缓存也应重算为 FREE")
+    void kick非微信用户进回收箱_家庭派生会员缓存应重算为FREE() {
+        User admin = user(ADMIN_USER_ID, OLD_FAMILY_ID, "o_admin_001", LEVEL_FREE, null);
+        when(userMapper.selectById(ADMIN_USER_ID)).thenReturn(admin);
+        when(familyMapper.selectById(OLD_FAMILY_ID)).thenReturn(family(OLD_FAMILY_ID, ADMIN_USER_ID));
+        when(familyMemberMapper.selectById(MEMBER_ID))
+                .thenReturn(member(MEMBER_ID, OLD_FAMILY_ID, KICKED_USER_ID, "小李"));
+
+        // 非微信用户(openid 为 phone: 前缀),会员缓存为家庭派生 FAMILY
+        User kicked = user(KICKED_USER_ID, OLD_FAMILY_ID, "phone:13800138000", LEVEL_FAMILY, future());
+        when(userMapper.selectById(KICKED_USER_ID)).thenReturn(kicked);
+
+        familyMemberService.kickMember(ADMIN_USER_ID, MEMBER_ID);
+
+        ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
+        verify(userMapper).updateById(captor.capture());
+        User persisted = captor.getValue();
+
+        assertEquals(LEVEL_FREE, persisted.getMemberLevel(),
+                "[family.familyMembers.removeMember] 踢出非微信用户进回收箱(familyId 置空)时,"
+                        + "其家庭派生会员缓存(users.member_level)也应重算为 FREE——当前仍残留="
+                        + persisted.getMemberLevel() + ",期望=FREE。");
+        assertNull(persisted.getFamilyId(), "非微信用户被踢后 familyId 应置空(进回收箱)。");
+    }
+
+    // ==================== 正常流:主动退出家庭(leave) ====================
+
+    @Test
+    @DisplayName("正常流-3:主动退出家庭后,其家庭派生会员缓存应重算为 FREE(恢复免费身份)")
+    void leave微信用户_家庭派生会员缓存应重算为FREE() {
+        User leaver = user(KICKED_USER_ID, OLD_FAMILY_ID, "o_kicked_001", LEVEL_FAMILY, future());
+        when(userMapper.selectById(KICKED_USER_ID)).thenReturn(leaver);
+        when(familyMapper.selectById(OLD_FAMILY_ID)).thenReturn(family(OLD_FAMILY_ID, ADMIN_USER_ID));
+        when(familyMemberMapper.selectOne(any()))
+                .thenReturn(member(MEMBER_ID, OLD_FAMILY_ID, KICKED_USER_ID, "小李"));
+        when(familyMapper.selectOne(any())).thenReturn(family(OWN_FAMILY_ID, KICKED_USER_ID));
+
+        familyMemberService.leaveFamily(KICKED_USER_ID);
+
+        ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
+        verify(userMapper).updateById(captor.capture());
+        User persisted = captor.getValue();
+
+        assertEquals(LEVEL_FREE, persisted.getMemberLevel(),
+                "[family.familyMembers.removeMember] 主动退出家庭后,其 users.member_level 缓存应重算为 FREE"
+                        + "(当前仅重核原家庭 reconcileFamilyMembership(oldFamilyId),退出者已不在范围,缓存仍残留="
+                        + persisted.getMemberLevel() + ",期望=FREE)(API §4.2 member/leave)。");
+        assertNull(persisted.getMemberExpireTime(),
+                "[family.familyMembers.removeMember] 退出用户无独立更高等级会员时,member_expire_time 缓存应一并清除。");
+    }
+
+    @Test
+    @DisplayName("边界流-2:主动退出家庭后,独立 LIFETIME 个人会员应保留")
+    void leave用户_独立LIFETIME个人会员应保留() {
+        User leaver = user(KICKED_USER_ID, OLD_FAMILY_ID, "o_kicked_001", LEVEL_LIFETIME, future());
+        when(userMapper.selectById(KICKED_USER_ID)).thenReturn(leaver);
+        when(familyMapper.selectById(OLD_FAMILY_ID)).thenReturn(family(OLD_FAMILY_ID, ADMIN_USER_ID));
+        when(familyMemberMapper.selectOne(any()))
+                .thenReturn(member(MEMBER_ID, OLD_FAMILY_ID, KICKED_USER_ID, "小李"));
+        when(familyMapper.selectOne(any())).thenReturn(family(OWN_FAMILY_ID, KICKED_USER_ID));
+
+        familyMemberService.leaveFamily(KICKED_USER_ID);
+
+        ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
+        verify(userMapper).updateById(captor.capture());
+        User persisted = captor.getValue();
+
+        assertEquals(LEVEL_LIFETIME, persisted.getMemberLevel(),
+                "[family.familyMembers.removeMember] 退出用户独立 LIFETIME 个人会员应保留。");
+        assertNotNull(persisted.getMemberExpireTime(),
+                "[family.familyMembers.removeMember] 个人会员到期时间不应被清除。");
+    }
+
+    // ==================== 用户可见症状:getMemberLevel 应重归个人维度 ====================
+
+    @Test
+    @DisplayName("用户症状:被移除用户(缓存残留 FAMILY + 新家庭无会员)getMemberLevel 应返回 FREE,而非 FAMILY")
+    void 被移除用户_getMemberLevel应重归FREE() {
+        // 真实 MembershipService + 3 个 mock mapper(无 Spring / 无 DB)
+        MembershipService membership = new MembershipService();
+        UserMapper um = mock(UserMapper.class);
+        FamilyMembershipMapper fmm = mock(FamilyMembershipMapper.class);
+        MembershipLevelMapper lmm = mock(MembershipLevelMapper.class);
+        setPrivateField(membership, "userMapper", um);
+        setPrivateField(membership, "membershipMapper", fmm);
+        setPrivateField(membership, "levelMapper", lmm);
+
+        // 被移除用户:已回到自己的初始家庭(familyId=300,无有效家庭会员),
+        // 但 users.member_level 仍残留家庭派生的 FAMILY(本次移出动作未重算缓存的缺陷现场)
+        User kicked = user(KICKED_USER_ID, OWN_FAMILY_ID, "o_kicked_001", LEVEL_FAMILY, future());
+        when(um.selectById(KICKED_USER_ID)).thenReturn(kicked);
+        when(fmm.selectOne(any())).thenReturn(null);          // 新家庭无有效会员 → family 维度 FREE
+        when(lmm.selectOne(any())).thenReturn(null);          // 未知等级按最低阶处理
+
+        assertEquals(LEVEL_FREE, membership.getMemberLevel(KICKED_USER_ID),
+                "[family.familyMembers.removeMember] 被移出家庭的用户(无独立更高等级个人会员)"
+                        + "getMemberLevel 应返回 FREE——用户实测缺陷:缓存残留导致仍返回 FAMILY(实际="
+                        + membership.getMemberLevel(KICKED_USER_ID) + ",期望=FREE)。");
+    }
+
+    // ==================== 权限流:踢出成员权限守卫(当前已具备,样式锚点) ====================
+
+    @Test
+    @DisplayName("权限流-1:踢出成员仅家庭创建者(管理员)可操作,非管理员拒绝")
+    void kick非创建者_拒绝() {
+        User operator = user(ADMIN_USER_ID, OLD_FAMILY_ID, "o_admin_001", LEVEL_FREE, null);
+        when(userMapper.selectById(ADMIN_USER_ID)).thenReturn(operator);
+        // 家庭创建者不是操作人
+        when(familyMapper.selectById(OLD_FAMILY_ID)).thenReturn(family(OLD_FAMILY_ID, 999L));
+
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> familyMemberService.kickMember(ADMIN_USER_ID, MEMBER_ID));
+        assertEquals("只有家庭管理员才能踢出成员", ex.getMessage());
+    }
+
+    @Test
+    @DisplayName("权限流-2:不能踢出家庭创建者本人(管理员需先转让)")
+    void kick创建者本人_拒绝() {
+        User operator = user(ADMIN_USER_ID, OLD_FAMILY_ID, "o_admin_001", LEVEL_FREE, null);
+        when(userMapper.selectById(ADMIN_USER_ID)).thenReturn(operator);
+        when(familyMapper.selectById(OLD_FAMILY_ID)).thenReturn(family(OLD_FAMILY_ID, ADMIN_USER_ID));
+        // 目标成员就是家庭创建者本人
+        when(familyMemberMapper.selectById(MEMBER_ID))
+                .thenReturn(member(MEMBER_ID, OLD_FAMILY_ID, ADMIN_USER_ID, "管理员"));
+
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> familyMemberService.kickMember(ADMIN_USER_ID, MEMBER_ID));
+        assertEquals("不能踢出家庭创建者,请先转让管理员权限", ex.getMessage());
+    }
+
+    // ==================== 辅助构造 ====================
+
+    private User user(Long id, Long familyId, String openid, String memberLevel, Date memberExpireTime) {
+        User u = new User();
+        u.setId(id);
+        u.setFamilyId(familyId);
+        u.setOpenid(openid);
+        u.setMemberLevel(memberLevel);
+        u.setMemberExpireTime(memberExpireTime);
+        u.setNickname("成员" + id);
+        return u;
+    }
+
+    private Family family(Long id, Long creatorId) {
+        Family f = new Family();
+        f.setId(id);
+        f.setName("契约测试家庭" + id);
+        f.setCreatorId(creatorId);
+        return f;
+    }
+
+    private FamilyMember member(Long id, Long familyId, Long userId, String nickname) {
+        FamilyMember m = new FamilyMember();
+        m.setId(id);
+        m.setFamilyId(familyId);
+        m.setUserId(userId);
+        m.setNickname(nickname);
+        m.setStatus("active");
+        return m;
+    }
+
+    /** 一年后到期(家庭会员未过期,缓存残留场景) */
+    private Date future() {
+        return new Date(System.currentTimeMillis() + 365L * 24 * 3600 * 1000);
+    }
+
+    private void setPrivateField(Object target, String name, Object value) {
+        try {
+            Field f = target.getClass().getDeclaredField(name);
+            f.setAccessible(true);
+            f.set(target, value);
+        } catch (Exception e) {
+            throw new IllegalStateException("反射注入失败: " + name, e);
+        }
+    }
+}