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