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

S-cfc-220: Implement RED contract test for child.notification.receive

Sisyphus Agent 2 часов назад
Родитель
Сommit
834cd07969

+ 221 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ChildNotificationReceiveContractTest.java

@@ -0,0 +1,221 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.UserNotificationController;
+import com.etotem.cfc.entity.UserNotification;
+import com.etotem.cfc.mapper.UserNotificationMapper;
+import com.etotem.cfc.service.NotificationDispatchService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.Parameter;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Scanner;
+
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:孩子接收消息通知(能力键 {@code child.notification.receive})。
+ *
+ * <p>契约来源(requirements + design):</p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-C-15「消息接收」、
+ *       SC-C-19「查看家人/规划师消息通知」——孩子收家长/规划师消息并标记已读,
+ *       异常/边界:越权消息不可见;分页正确。</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.15({@code POST /api/notification/list}、
+ *       {@code POST /api/notification/read})+ 消息服务(规划师/家长跨端触达)。</li>
+ * </ul>
+ *
+ * <p><b>实现现状(本分支 src/main 核实)</b>:通知链路全程按「账号级 userId」定向——
+ * <ul>
+ *   <li>{@code user_notifications} 表:{@code user_id BIGINT NOT NULL}(schema.sql §user_notifications),
+ *       无 {@code member_id} 列、无成员级索引;</li>
+ *   <li>{@link UserNotification} 实体:仅 {@code userId} 字段,无 {@code memberId};</li>
+ *   <li>{@link UserNotificationController}:{@code list(userId)} / {@code read(userId, params)}
+ *       均只取 {@code @RequestAttribute("userId")},无成员级参数;</li>
+ *   <li>{@link NotificationDispatchService#sendByTemplate} 全部重载要求 {@code Long userId} 接收方。</li>
+ * </ul>
+ * 而「孩子(不能登录用户)」在用户模型统一口径下为 {@code family_members.userId} 为空的
+ * 家庭成员,<b>没有 userId</b> → 站内通知无法按 userId 投递/查询/已读,成员级(memberId)
+ * 定向通道缺失,契约行为未落地。</p>
+ *
+ * <p><b>测试策略</b>:纯反射结构契约 + 少量 Mockito 行为锚点(无 Spring / 无 DB / 无外部服务),
+ * 能编译、能运行、断言失败(红),失败信息可读可贴。</p>
+ *
+ * <p><b>RED 预期</b>:成员级(memberId)投递/列表/已读三件套全部缺失 → 下方红测应失败;
+ * 既有 userId 级已读路径(正常流-1)与越权防护(权限流)为说明性绿锚点,标注 G-01 缺口边界。</p>
+ *
+ * <p><b>GREEN 升级指引</b>:实现者补 {@code user_notifications.member_id} 列(+索引)与实体字段、
+ * 提供成员级 list/read 入口与 {@code sendByMember} 投递后,本测试自动转绿。</p>
+ */
+class ChildNotificationReceiveContractTest {
+
+    // ------------------------------------------------------------------
+    // 正常流:成员级(memberId)接收链路三件套 —— 列表 / 已读 / 投递
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-1(绿锚点):userId 级标记已读路径仍可用(G-01 缺口仅限成员级)")
+    void read_byUserId_stillWorks() {
+        // 说明性绿锚点:账号级(有 userId 的能登录用户)通知已读不受本契约影响。
+        UserNotification n = new UserNotification();
+        n.setId(11L);
+        n.setUserId(CURRENT_USER_ID);
+        n.setIsRead(0);
+        when(mapper.selectById(11L)).thenReturn(n);
+
+        Result<Boolean> r = controller.read(CURRENT_USER_ID, params("id", 11L));
+
+        assertSame(Boolean.TRUE, r.getData(), "userId 级已读应返回 true");
+        assertSame(1, n.getIsRead(), "userId 级通知应被标记为已读");
+        verify(mapper).updateById(n);
+    }
+
+    @Test
+    @DisplayName("正常流-2:UserNotification 实体应承载 memberId(孩子成员定向接收)")
+    void entity_hasMemberId() {
+        Class<?> clazz = UserNotification.class;
+        boolean hasField = false;
+        for (Field f : clazz.getDeclaredFields()) {
+            if (f.getName().equals("memberId")) {
+                hasField = true;
+                break;
+            }
+        }
+        assertTrue(hasField,
+                "[child.notification.receive] UserNotification 缺少 memberId 字段:孩子(不能登录用户)"
+                        + "无 userId,契约要求通知可按家庭成员的 memberId 定向(SC-C-15/C-19)。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流-3:user_notifications 表应含 member_id 列与成员级索引")
+    void schemaSql_hasMemberIdColumn() {
+        String schema = readSchemaSql();
+        assertTrue(schema.contains("member_id"),
+                "[child.notification.receive] schema.sql 中 user_notifications 缺少 member_id 列:"
+                        + "孩子(不能登录用户)无 user_id,需成员级(member_id)定向存储(SC-C-15/C-19)。实现未落地。");
+        assertTrue(schema.contains("idx_member_read"),
+                "[child.notification.receive] schema.sql 中 user_notifications 缺少 idx_member_read 成员级索引:"
+                        + "契约要求按 member_id 列表查询并标记已读(SC-C-15/C-19)。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流-4:UserNotificationController 成员级列表入口应接受 memberId")
+    void controller_list_acceptsMemberId() {
+        Class<?> clazz = UserNotificationController.class;
+        boolean hasMemberParam = hasMethodTakingMemberId(clazz);
+        assertTrue(hasMemberParam,
+                "[child.notification.receive] UserNotificationController 无任何方法接受 memberId 参数:"
+                        + "孩子(不能登录用户)无法按家庭成员身份拉取自己的消息(SC-C-15/C-19)。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流-5:UserNotificationController 成员级已读入口应接受 memberId")
+    void controller_read_acceptsMemberId() {
+        Class<?> clazz = UserNotificationController.class;
+        boolean hasMemberParam = hasMethodTakingMemberId(clazz);
+        assertTrue(hasMemberParam,
+                "[child.notification.receive] UserNotificationController 无接受 memberId 的已读入口:"
+                        + "孩子(不能登录用户)无法标记家庭成员视角下的消息已读(SC-C-15/C-19)。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流-6:NotifyDispatchService 应提供成员级(memberId)定向投递 sendByMember")
+    void dispatchService_hasSendByMember() {
+        Class<?> clazz = NotificationDispatchService.class;
+        boolean hasSendByMember = false;
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().equals("sendByMember")) {
+                hasSendByMember = true;
+                break;
+            }
+        }
+        assertTrue(hasSendByMember,
+                "[child.notification.receive] NotificationDispatchService 缺少 sendByMember 成员级投递入口:"
+                        + "现有 sendByTemplate 全部要求接收方 userId(NotificationMessage(userId,...)),"
+                        + "孩子(不能登录用户)无 userId 无法被触达(SC-C-15/C-19)。实现未落地。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流 & 权限流:合并为单一契约断言——仅本人成员可读可见(SC-C-15/C-19 共条款)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("权限流(含边界):成员级列表/已读应拒绝跨成员越权(仅本人成员可读可见)")
+    void memberScope_rejectsCrossMember() {
+        // 边界:成员级列表若存在,必须按 memberId 过滤(memberList_scopedToOwnMember)
+        boolean hasMemberList = hasMethodTakingMemberId(UserNotificationController.class);
+        assertTrue(hasMemberList,
+                "[child.notification.receive] 无成员级列表入口(接受 memberId),无法验证『只返回本人成员通知"
+                        + ",越权消息不可见』(SC-C-15 异常/边界)。实现未落地。");
+        // 权限:成员级已读若存在,必须校验所有权(memberRead_ownerChecked / memberRead_notModifyOthers)
+        assertTrue(hasMemberList,
+                "[child.notification.receive] 无成员级列表/已读入口:『越权消息不可见』在成员维度"
+                        + "(不能登录用户间互不可读)无落点(SC-C-15/C-19 权限条款)。实现未落地。");
+    }
+
+    // ------------------------------------------------------------------
+    // Mockito 行为锚点(无 Spring / 无 DB)
+    // ------------------------------------------------------------------
+
+    @Mock
+    private UserNotificationMapper mapper;
+
+    @InjectMocks
+    private UserNotificationController controller;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    private static final Long CURRENT_USER_ID = 200L;
+
+    private Map<String, Object> params(String key, Object value) {
+        Map<String, Object> p = new HashMap<>();
+        p.put(key, value);
+        return p;
+    }
+
+    // ------------------------------------------------------------------
+    // 反射辅助
+    // ------------------------------------------------------------------
+
+    /** 控制器任意方法是否带有名为「memberId」的成员标识参数 */
+    private boolean hasMethodTakingMemberId(Class<?> clazz) {
+        for (Method m : clazz.getDeclaredMethods()) {
+            for (Parameter p : m.getParameters()) {
+                if (p.getName().equals("memberId")) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    private String readSchemaSql() {
+        try (InputStream is = getClass().getClassLoader().getResourceAsStream("schema.sql")) {
+            if (is == null) {
+                return "";
+            }
+            try (Scanner scanner = new Scanner(is, StandardCharsets.UTF_8.name())) {
+                scanner.useDelimiter("\\A");
+                return scanner.hasNext() ? scanner.next() : "";
+            }
+        } catch (Exception e) {
+            return "";
+        }
+    }
+}