Quellcode durchsuchen

test(RED): S-cfc-258 family.familyMembers.list 契约级单测(TDD-RED)

能力键 family.familyMembers.list(SC-P-04 家庭成员列表查看):成员列表含角色/关系/在线,
边界仅本家庭成员可见 + 分页/搜索。

新增 FamilyMembersListScP04ContractTest(8 例,预期红 5 绿 3,纯 Mockito/反射,无 Spring/DB/外部服务):

RED 契约缺口(断言实现未落地):
- 正常流-1:带 page/pageSize 应返回分页结构(total+list),当前忽略并返回全量裸 List
- 正常流-2:带 keyword 应按昵称过滤,当前忽略仍返回全量未过滤
- 正常流-3(结构):FamilyMemberService 应提供 keyword/String 参数或 search 命名的列表方法,当前仅 listMembers(Long)/listMembersOfFamily/listMembersWithFamily
- 正常流-4(结构):FamilyMemberVO 应含在线状态字段 online/onlineStatus/lastActiveAt,当前全部缺失
- 异常流-1:非法分页参数(page=0/pageSize=-1)应 400 校验拦截,当前静默忽略返回 200

GREEN 基线锚点(锁定既有实现不被 GREEN 误伤):
- 基线-1:匿名 userId<=0 → 401
- 基线-2:指定非本家庭 familyId → 403(canAccessFamily 已具备)
- 基线-3:无分页/搜索参数时返回全量成员列表(兼容现状语义)

实测 mvn -Dtest=FamilyMembersListScP04ContractTest test:Tests run 8, Failures 5, Errors 0(红=RED 完成)。

溯源:tests/user-scenarios/应用场景总览.md SC-P-04;
docs/tdd/orchestration/sc-key-map.md SC-P-04→family.familyMembers.list→S-cfc-258;
API_REFERENCE §4.2;FamilyMembersController.listMembers + 分页/搜索。

仅新增 src/test 测试文件,未改 src/main。GREEN 实现指引自类注释。
E2E Test Bot vor 1 Tag
Ursprung
Commit
f99bdfc6df

+ 271 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/FamilyMembersListScP04ContractTest.java

@@ -0,0 +1,271 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.family.FamilyMembersController;
+import com.etotem.cfc.dto.FamilyMemberVO;
+import com.etotem.cfc.service.FamilyMemberService;
+import com.etotem.cfc.service.UnlockGateService;
+import com.etotem.cfc.service.UserService;
+import com.etotem.cfc.mapper.UserMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+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.anyLong;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:家庭成员列表查看(能力键 {@code family.familyMembers.list},SC-P-04)。
+ *
+ * <p><b>契约锚点</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-P-04(家庭成员列表查看——家庭首页 →
+ *       成员列表,<b>含角色/关系/在线</b>;边界:<b>仅本家庭成员可见;分页/搜索</b>)</li>
+ *   <li>键映射:{@code docs/tdd/orchestration/sc-key-map.md} —— SC-P-04 → {@code family.familyMembers.list}
+ *       → S-cfc-258「成员列表(角色/关系/在线),仅本家庭成员可见」</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.2({@code POST /api/family/member/list}
+ *       统一入口)+ {@code FamilyMembersController#listMembers}(行为契约直接调用真实控制器方法,
+ *       纯 Mockito / 无 Spring / 无 DB / 无外部服务)</li>
+ * </ul>
+ *
+ * <p><b>红线目标(RED 依据,工作树 = origin/cfclub @ {@code 75fdb050a})</b></p>
+ * <ol>
+ *   <li><b>在线状态未落地</b>:SC-P-04 要求成员列表含「在线」状态,
+ *       {@link FamilyMemberVO} 当前<b>无</b> online/onlineStatus/lastActiveAt 等任一在线字段 → 红</li>
+ *   <li><b>分页未落地</b>:{@code /api/family/member/list} 契约支持 {@code page/pageSize} 并返回分页结构
+ *       (total + 当前页),当前 {@code listMembers} 对分页参数<b>静默忽略并返回全量裸 List</b> → 红</li>
+ *   <li><b>搜索未落地</b>:契约支持 {@code keyword} 按昵称/姓名过滤,当前 {@code listMembers}
+ *       对 keyword <b>静默忽略仍返回全量未过滤</b>,{@code FamilyMemberService} 亦无
+ *       keyword/search 重载 → 红</li>
+ * </ol>
+ *
+ * <p><b>基线钉住(绿,用于锁定既有实现不被 GREEN 误伤)</b></p>
+ * <ul>
+ *   <li>权限流:匿名(userId ≤ 0)→ 401;指定非本家庭 familyId → 403({@code canAccessFamily}
+ *       已实现「仅本家庭成员可见」的既有门禁)</li>
+ *   <li>正常流:无分页/搜索参数时返回全量成员列表(兼容现状语义)</li>
+ * </ul>
+ *
+ * <p><b>GREEN 升级指引</b>:在 {@code FamilyMemberService} 增加分页+搜索能力(如
+ * {@code listMembers(userId, keyword, page, pageSize)} 返回含 total/list 的分页结构)并在
+ * {@code FamilyMembersController#listMembers} 解析 {@code keyword/page/pageSize} 参数转发,同时在
+ * {@code FamilyMemberVO} 补充在线状态字段(online/onlineStatus + lastActiveAt,口径可落登录 token
+ * 或最后活跃时间),本类自动转绿(在线字段与分页结构断言通过)。</p>
+ *
+ * <p><b>测试范围</b>:仅覆盖 {@code family.familyMembers.list} 键契约(SC-P-04);
+ * 关系图(SC-P-07 {@code family.family.relationGraph} / S-cfc-259)与成员权限设置(SC-P-08
+ * {@code family.member.update} / S-cfc-260)为独立键,不在本类重复覆盖。</p>
+ */
+class FamilyMembersListScP04ContractTest {
+
+    private static final Long VIEWER_USER_ID = 100L;
+    private static final Long OWN_FAMILY_ID = 1L;
+    /** 非本家庭:仅本家庭成员可见边界的目标(契约要求 403) */
+    private static final Long FOREIGN_FAMILY_ID = 99L;
+
+    private FamilyMemberService familyMemberService;
+    private UserService userService;
+    private FamilyMembersController controller;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        controller = new FamilyMembersController();
+        familyMemberService = mock(FamilyMemberService.class);
+        userService = mock(UserService.class);
+        inject(controller, "familyMemberService", familyMemberService);
+        inject(controller, "unlockGateService", mock(UnlockGateService.class));
+        inject(controller, "userService", userService);
+        inject(controller, "userMapper", mock(UserMapper.class));
+    }
+
+    // ==================== 正常流-1:分页契约(RED) ====================
+
+    @Test
+    @DisplayName("正常流-1:带 page/pageSize 请求成员列表,应返回分页结构(total + 当前页),而非全量裸 List")
+    void 分页参数_应返回分页结构而非全量裸列表() {
+        when(familyMemberService.listMembers(VIEWER_USER_ID)).thenReturn(memberList(3));
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("page", 1);
+        params.put("pageSize", 2);
+
+        Result<?> r = controller.listMembers(params, VIEWER_USER_ID, "parent", Collections.<String>emptyList());
+
+        assertEquals(200, r.getCode());
+        assertNotNull(r.getData());
+        assertTrue(r.getData() instanceof Map,
+                "[family.familyMembers.list] 带 page/pageSize 的成员列表应返回分页结构(含 total/当前页),"
+                        + "当前 listMembers 忽略分页参数并返回全量裸 List,分页未落地(SC-P-04)。");
+        Map<?, ?> page = (Map<?, ?>) r.getData();
+        assertTrue(page.containsKey("total"),
+                "[family.familyMembers.list] 分页结构应含 total(成员总数),当前缺失(SC-P-04)。");
+        assertTrue(page.containsKey("list") || page.containsKey("records"),
+                "[family.familyMembers.list] 分页结构应含 list/records(当前页数据),当前缺失(SC-P-04)。");
+    }
+
+    // ==================== 正常流-2:搜索契约(RED) ====================
+
+    @Test
+    @DisplayName("正常流-2:带 keyword 请求成员列表,应按昵称过滤(仅返回匹配成员)")
+    void 搜索参数_应按关键字过滤成员() {
+        when(familyMemberService.listMembers(VIEWER_USER_ID)).thenReturn(memberList(3));
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("keyword", "王");
+
+        Result<?> r = controller.listMembers(params, VIEWER_USER_ID, "parent", Collections.<String>emptyList());
+
+        assertEquals(200, r.getCode());
+        assertTrue(r.getData() instanceof Map,
+                "[family.familyMembers.list] 带 keyword 的成员列表应返回过滤后的分页结构,"
+                        + "当前 listMembers 忽略 keyword 并返回全量未过滤 List,搜索未落地(SC-P-04)。");
+        Map<?, ?> page = (Map<?, ?>) r.getData();
+        Object items = page.get("list") != null ? page.get("list") : page.get("records");
+        assertNotNull(items,
+                "[family.familyMembers.list] 搜索响应应包含当前页成员数据,当前缺失(SC-P-04)。");
+        for (Object item : (List<?>) items) {
+            FamilyMemberVO vo = (FamilyMemberVO) item;
+            assertTrue(vo.getNickname() != null && vo.getNickname().contains("王"),
+                    "[family.familyMembers.list] keyword=王 搜索应只返回昵称匹配成员,"
+                            + "当前返回了未匹配成员 " + vo.getNickname() + ",搜索未落地(SC-P-04)。");
+        }
+    }
+
+    @Test
+    @DisplayName("正常流-3:FamilyMemberService 应提供 keyword 搜索重载(结构契约)")
+    void service_应提供关键字搜索重载() {
+        assertTrue(hasKeywordOrSearchMethod(FamilyMemberService.class),
+                "[family.familyMembers.list] FamilyMemberService 应提供带 keyword/String 参数或 search 名称的"
+                        + "成员列表方法(当前仅 listMembers(Long)/listMembersOfFamily/listMembersWithFamily),"
+                        + "搜索能力未落地(SC-P-04)。");
+    }
+
+    // ==================== 正常流-4 结构契约:在线状态(RED) ====================
+
+    @Test
+    @DisplayName("正常流-4:FamilyMemberVO 应含「在线」状态字段(结构契约)")
+    void vo_应含在线状态字段() {
+        boolean hasOnlineField = false;
+        for (Field f : FamilyMemberVO.class.getDeclaredFields()) {
+            if ("online".equals(f.getName())
+                    || "onlineStatus".equals(f.getName())
+                    || "lastActiveAt".equals(f.getName())
+                    || "lastSeenAt".equals(f.getName())) {
+                hasOnlineField = true;
+                break;
+            }
+        }
+        assertTrue(hasOnlineField,
+                "[family.familyMembers.list] FamilyMemberVO 应含在线状态字段(online/onlineStatus/lastActiveAt),"
+                        + "SC-P-04 要求成员列表展示「在线」状态,当前 VO 全部缺失,在线状态未落地。");
+    }
+
+    // ==================== 异常流-1:非法分页参数(RED) ====================
+
+    @Test
+    @DisplayName("异常流-1:page/pageSize 非法(0 或负数)应被校验拦截,而非静默忽略")
+    void 非法分页参数_应校验拦截() {
+        when(familyMemberService.listMembers(VIEWER_USER_ID)).thenReturn(memberList(3));
+
+        Map<String, Object> bad = new HashMap<>();
+        bad.put("page", 0);
+        bad.put("pageSize", -1);
+
+        Result<?> r = controller.listMembers(bad, VIEWER_USER_ID, "parent", Collections.<String>emptyList());
+
+        assertEquals(400, r.getCode(),
+                "[family.familyMembers.list] 非法分页参数(page=0/pageSize=-1)应返回 400 校验失败,"
+                        + "当前 listMembers 静默忽略并返回全量 200,分页参数校验未落地(SC-P-04 边界)。");
+    }
+
+    // ==================== 基线-1:仅本家庭成员可见(权限流,现有实现已具备,绿锚点) ====================
+
+    @Test
+    @DisplayName("基线-1:匿名请求(userId ≤ 0)→ 401 请先登录")
+    void 基线_匿名请求401() {
+        Result<?> r = controller.listMembers(new HashMap<String, Object>(), -1L, "parent", Collections.<String>emptyList());
+        assertEquals(401, r.getCode());
+    }
+
+    @Test
+    @DisplayName("基线-2:指定非本家庭 familyId → 403 无权访问该家庭数据")
+    void 基线_越权指定其他家庭403() {
+        when(userService.getEffectiveFamilyIds(VIEWER_USER_ID)).thenReturn(
+                Collections.<Long>singleton(OWN_FAMILY_ID));
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("familyId", FOREIGN_FAMILY_ID);
+
+        Result<?> r = controller.listMembers(params, VIEWER_USER_ID, "parent", Collections.<String>emptyList());
+        assertEquals(403, r.getCode());
+    }
+
+    @Test
+    @DisplayName("基线-3:无分页/搜索参数时返回全量成员列表(兼容现状语义)")
+    void 基线_无参数返回全量成员列表() {
+        when(familyMemberService.listMembers(VIEWER_USER_ID)).thenReturn(memberList(2));
+
+        Result<?> r = controller.listMembers(new HashMap<String, Object>(), VIEWER_USER_ID, "parent",
+                Collections.<String>emptyList());
+
+        assertEquals(200, r.getCode());
+        assertTrue(r.getData() instanceof List);
+        assertEquals(2, ((List<?>) r.getData()).size());
+    }
+
+    // ==================== 辅助 ====================
+
+    private List<FamilyMemberVO> memberList(int n) {
+        List<FamilyMemberVO> list = new ArrayList<>();
+        for (int i = 1; i <= n; i++) {
+            FamilyMemberVO vo = new FamilyMemberVO();
+            vo.setId((long) i);
+            // i=1:王小明;i=2:李四;i=3:王二 → 搜索契约「王」应命中 2 个
+            vo.setNickname(i == 1 ? "王小明" : (i == 2 ? "李四" : "王二"));
+            vo.setEffectiveRole("parent");
+            vo.setRoleLabel(i == 1 ? "爸爸" : (i == 2 ? "妈妈" : "儿子"));
+            list.add(vo);
+        }
+        return list;
+    }
+
+    /**
+     * 是否声明了成员列表的 keyword 搜索入口。
+     * <p>口径:方法名含 search/bykeyword,或方法名以 list 开头且带 String(keyword)参数。
+     * <p>刻意不把 logMemberAction 等非列表方法算作搜索入口(避免假绿)。</p>
+     */
+    private static boolean hasKeywordOrSearchMethod(Class<?> clazz) {
+        for (Method m : clazz.getDeclaredMethods()) {
+            String name = m.getName().toLowerCase();
+            if (name.contains("search") || name.contains("bykeyword")) {
+                return true;
+            }
+            if (name.startsWith("list")) {
+                for (Class<?> p : m.getParameterTypes()) {
+                    if (p == String.class) {
+                        return true;
+                    }
+                }
+            }
+        }
+        return false;
+    }
+
+    private static void inject(Object target, String fieldName, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(fieldName);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+}