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