Explorar o código

test(contract): S-cfc-296 notification.center.view TDD-RED 契约单测(SC-P-78 消息中心/通知查看:列表/已读/越权回归钉 + 分页/消息不存在404 缺口)

Sisyphus Agent hai 1 día
pai
achega
430cf73a6e

+ 313 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/NotificationCenterViewContractTest.java

@@ -0,0 +1,313 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+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 org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.Parameter;
+import java.util.Arrays;
+import java.util.Date;
+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.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:消息中心/通知查看(能力键 {@code notification.center.view},SC-P-78)。
+ *
+ * <p><b>契约来源</b>(需求 → 设计 → 用例):</p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-P-78 —— 打开消息中心 → 列表/已读;
+ *       边界:越权消息不可见;分页。模块:消息服务。</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.15 / §4.61.7 / §4.63.8 ——
+ *       {@code POST /api/notification/list}(通知列表,未读优先,倒序,{@code LIMIT 100})、
+ *       {@code POST /api/notification/read}(标记已读:{@code id} / {@code ids} / 空=全部已读)。</li>
+ *   <li>用例:{@code tests/test-cases/parent.md} TC-P-78 / TC-P-78-E / TC-P-78-P ——
+ *       正常流(列表/已读)、异常流(越权不可见 403、消息不存在 404、分页正常)、权限流(非归属数据 403/401)。</li>
+ * </ul>
+ *
+ * <p><b>实现现状(src/main 核实)</b>:{@code UserNotificationController}({@code /api/notification/list|read})
+ * 已在主链落地:{@code list} 按 {@code userId}(来自 JWT)过滤、未读优先倒序 {@code LIMIT 100};{@code read}
+ * 支持单条/批量/缺省全部,并对非本人通知不落已读。故「正常流/权限流」已实现(本测试作<b>回归钉</b>,GREEN)。</p>
+ *
+ * <p><b>本批次 RED 缺口</b>(断言契约所需行为缺失,GREEN 阶段实现使绿):</p>
+ * <ul>
+ *   <li><b>① 分页</b>(SC-P-78 边界「分页」、TC-P-78-E「分页正常」):当前 {@code list} 仅返回
+ *       {@code LIMIT 100} 的扁平数组,<b>无 {@code pageNum/pageSize} 入参、无分页结果</b>(无 {@code total/pageNum/pageSize})。</li>
+ *   <li><b>② 消息不存在 → 404</b>(TC-P-78-E「不存在 404」):当前 {@code read} 对不存在的 {@code id}
+ *       <b>静默返回 {@code success(true)}</b>,未返回 404。</li>
+ * </ul>
+ *
+ * <p><b>GREEN 升级指引</b>:实现者需 (a) 为 {@code /api/notification/list} 增加分页入参与分页结果
+ * ({@code pageNum/pageSize} → 返回含 {@code total/pageNum/pageSize} 的分页载体);(b) 为
+ * {@code /api/notification/read} 增加「消息不存在 → 404」({@code Result.notFound})。</p>
+ */
+class NotificationCenterViewContractTest extends AssessmentPlanContractTestBase {
+
+    static {
+        initTable(UserNotification.class);
+    }
+
+    // ==================================================================
+    // 正常流(已落地 · 回归钉 GREEN)
+    // ==================================================================
+
+    @Test
+    @DisplayName("SC-P-78 正常:通知列表 → 200 返回本人通知(未读优先倒序 LIMIT 100)")
+    void notificationList_正常_返回列表() throws Exception {
+        UserNotificationMapper mapper = mock(UserNotificationMapper.class);
+        UserNotificationController c = new UserNotificationController();
+        inject(c, "userNotificationMapper", mapper);
+
+        UserNotification unread = notif(1L, 10L, 0);
+        UserNotification read = notif(2L, 10L, 1);
+        when(mapper.selectList(any())).thenReturn(Arrays.asList(unread, read));
+
+        Result<List<UserNotification>> r = c.list(10L);
+        assertEquals(200, r.getCode());
+        assertEquals(2, r.getData().size());
+    }
+
+    @Test
+    @DisplayName("SC-P-78 正常:列表查询按 userId 过滤 + 未读优先(is_read ASC) + 倒序(created_at DESC) + LIMIT 100")
+    void notificationList_正常_未读优先倒序_LIMIT100() throws Exception {
+        UserNotificationMapper mapper = mock(UserNotificationMapper.class);
+        UserNotificationController c = new UserNotificationController();
+        inject(c, "userNotificationMapper", mapper);
+        when(mapper.selectList(any())).thenReturn(Arrays.asList());
+
+        c.list(10L);
+
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<Wrapper<UserNotification>> captor = ArgumentCaptor.forClass(Wrapper.class);
+        verify(mapper).selectList(captor.capture());
+        Wrapper<UserNotification> w = captor.getValue();
+
+        // 设计契约 §4.61.7:未读优先 + 倒序 + LIMIT 100
+        String seg = w.getSqlSegment().toLowerCase();
+        assertTrue(seg.contains("is_read"), "[notification.center.view] 列表应未读优先(is_read ASC)");
+        assertTrue(seg.contains("created_at"), "[notification.center.view] 列表应倒序(created_at DESC)");
+        String lastSql = lastSqlOf(w).toLowerCase();
+        assertTrue(lastSql.contains("limit 100"),
+                "[notification.center.view] 列表应 LIMIT 100(设计 §4.61.7),当前 lastSql=[" + lastSql + "]");
+    }
+
+    @Test
+    @DisplayName("SC-P-78 正常:单条标记已读(本人)→ 200 true,置已读并更新")
+    void notificationRead_正常_单条已读() throws Exception {
+        UserNotificationMapper mapper = mock(UserNotificationMapper.class);
+        UserNotificationController c = new UserNotificationController();
+        inject(c, "userNotificationMapper", mapper);
+
+        UserNotification n = notif(1L, 10L, 0);
+        when(mapper.selectById(1L)).thenReturn(n);
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("id", 1L);
+        Result<Boolean> r = c.read(10L, params);
+        assertEquals(200, r.getCode());
+        assertEquals(1, n.getIsRead());
+        verify(mapper).updateById(n);
+    }
+
+    @Test
+    @DisplayName("SC-P-78 正常:批量 ids 标记已读(本人)→ 200 true")
+    void notificationRead_正常_批量ids已读() throws Exception {
+        UserNotificationMapper mapper = mock(UserNotificationMapper.class);
+        UserNotificationController c = new UserNotificationController();
+        inject(c, "userNotificationMapper", mapper);
+
+        UserNotification n1 = notif(1L, 10L, 0);
+        UserNotification n2 = notif(2L, 10L, 0);
+        when(mapper.selectById(1L)).thenReturn(n1);
+        when(mapper.selectById(2L)).thenReturn(n2);
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("ids", Arrays.asList(1L, 2L));
+        Result<Boolean> r = c.read(10L, params);
+        assertEquals(200, r.getCode());
+        assertEquals(1, n1.getIsRead());
+        assertEquals(1, n2.getIsRead());
+        verify(mapper).updateById(n1);
+        verify(mapper).updateById(n2);
+    }
+
+    @Test
+    @DisplayName("SC-P-78 正常:缺省参数标记全部已读 → 200 true")
+    void notificationRead_正常_缺省全部已读() throws Exception {
+        UserNotificationMapper mapper = mock(UserNotificationMapper.class);
+        UserNotificationController c = new UserNotificationController();
+        inject(c, "userNotificationMapper", mapper);
+
+        UserNotification n1 = notif(1L, 10L, 0);
+        UserNotification n2 = notif(2L, 10L, 0);
+        when(mapper.selectList(any())).thenReturn(Arrays.asList(n1, n2));
+
+        Result<Boolean> r = c.read(10L, new HashMap<>());
+        assertEquals(200, r.getCode());
+        assertEquals(1, n1.getIsRead());
+        assertEquals(1, n2.getIsRead());
+        verify(mapper).updateById(n1);
+        verify(mapper).updateById(n2);
+    }
+
+    // ==================================================================
+    // 权限流(已落地 · 回归钉 GREEN)
+    // ==================================================================
+
+    @Test
+    @DisplayName("SC-P-78 权限:他人通知不可标记已读(越权不可见)→ 不改动且不更新")
+    void notificationRead_权限_他人通知不可见() throws Exception {
+        UserNotificationMapper mapper = mock(UserNotificationMapper.class);
+        UserNotificationController c = new UserNotificationController();
+        inject(c, "userNotificationMapper", mapper);
+
+        // 通知属于 999,当前用户是 10 → 越权,不得标记已读(与主链 SC-C-19 口径一致:200 但不改)
+        UserNotification other = notif(1L, 999L, 0);
+        when(mapper.selectById(1L)).thenReturn(other);
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("id", 1L);
+        Result<Boolean> r = c.read(10L, params);
+        assertEquals(200, r.getCode());
+        assertEquals(0, other.getIsRead(), "非本人通知不得标记已读");
+        verify(mapper, never()).updateById(other);
+    }
+
+    @Test
+    @DisplayName("SC-P-78 权限:列表 userId 取自 JWT(@RequestAttribute),客户端不可注入 → 越权不可见")
+    void notificationList_权限_userId取自JWT() throws Exception {
+        boolean userIdFromJwt = false;
+        for (Method m : UserNotificationController.class.getDeclaredMethods()) {
+            if (!"list".equals(m.getName())) {
+                continue;
+            }
+            for (Parameter p : m.getParameters()) {
+                RequestAttribute ra = p.getAnnotation(RequestAttribute.class);
+                if (ra != null && "userId".equals(ra.value())) {
+                    userIdFromJwt = true;
+                }
+            }
+        }
+        assertTrue(userIdFromJwt,
+                "[notification.center.view] 通知列表的 userId 应来自 @RequestAttribute(\"userId\")(JWT),"
+                        + "客户端不可注入 → 越权消息不可见(SC-P-78 边界)。");
+    }
+
+    // ==================================================================
+    // 异常流(RED 缺口 ②:消息不存在 → 404)
+    // ==================================================================
+
+    @Test
+    @DisplayName("SC-P-78 异常:消息 id 不存在 → 404(TC-P-78-E「不存在 404」)")
+    void notificationRead_异常_消息不存在_应404() throws Exception {
+        UserNotificationMapper mapper = mock(UserNotificationMapper.class);
+        UserNotificationController c = new UserNotificationController();
+        inject(c, "userNotificationMapper", mapper);
+
+        when(mapper.selectById(999L)).thenReturn(null); // 不存在
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("id", 999L);
+        Result<Boolean> r = c.read(10L, params);
+        assertEquals(404, r.getCode(),
+                "[notification.center.view] 消息 id 不存在应返回 404(TC-P-78-E),当前返回 code="
+                        + r.getCode() + "(静默 success,未实现 404)。");
+    }
+
+    // ==================================================================
+    // 边界流(RED 缺口 ①:分页)
+    // ==================================================================
+
+    @Test
+    @DisplayName("SC-P-78 边界:通知列表应支持分页(pageNum/pageSize 入参 + 分页结果)")
+    void notificationList_边界_应支持分页() {
+        boolean hasPageInput = false;
+        boolean hasPageOutput = false;
+        for (Method m : UserNotificationController.class.getDeclaredMethods()) {
+            if (!"list".equals(m.getName())) {
+                continue;
+            }
+            // 入参:分页参数(pageNum/pageSize)或请求体承载分页
+            if (m.getParameterCount() > 1) {
+                hasPageInput = true;
+            }
+            for (Parameter p : m.getParameters()) {
+                if (p.isAnnotationPresent(RequestBody.class)) {
+                    hasPageInput = true;
+                }
+                String pname = p.getName() == null ? "" : p.getName().toLowerCase();
+                if (pname.contains("page") || pname.contains("size")) {
+                    hasPageInput = true;
+                }
+            }
+            // 出参:分页结果(Page 或含 total/pageNum/pageSize 的分页载体)
+            String genericRet = m.getGenericReturnType().getTypeName().toLowerCase();
+            if (genericRet.contains("page")) {
+                hasPageOutput = true;
+            }
+        }
+        assertTrue(hasPageInput && hasPageOutput,
+                "[notification.center.view] 通知列表未支持分页(SC-P-78 边界「分页」/TC-P-78-E「分页正常」):"
+                        + "当前 list 仅返回 LIMIT 100 扁平数组,无 pageNum/pageSize 入参、无分页结果"
+                        + "(应返回含 total/pageNum/pageSize 的分页载体)。");
+    }
+
+    // ==================================================================
+    // helpers
+    // ==================================================================
+
+    private static UserNotification notif(Long id, Long userId, int isRead) {
+        UserNotification n = new UserNotification();
+        n.setId(id);
+        n.setUserId(userId);
+        n.setIsRead(isRead);
+        n.setCreatedAt(new Date());
+        return n;
+    }
+
+    /** 读取 MyBatis-Plus wrapper 的 lastSql(如 "LIMIT 100"),读取失败返回空串。 */
+    private static String lastSqlOf(Wrapper<?> w) {
+        try {
+            // 沿继承链找 AbstractWrapper 上的 lastSql 字段
+            Class<?> cls = w.getClass();
+            Field lastSqlField = null;
+            while (cls != null && lastSqlField == null) {
+                try {
+                    lastSqlField = cls.getDeclaredField("lastSql");
+                } catch (NoSuchFieldException e) {
+                    cls = cls.getSuperclass();
+                }
+            }
+            if (lastSqlField == null) {
+                return "";
+            }
+            lastSqlField.setAccessible(true);
+            Object shared = lastSqlField.get(w);
+            if (shared == null) {
+                return "";
+            }
+            Method getter = shared.getClass().getMethod("getStringValue");
+            Object v = getter.invoke(shared);
+            return v == null ? "" : v.toString();
+        } catch (Exception e) {
+            return "";
+        }
+    }
+}