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