Răsfoiți Sursa

S-cfc-191: Add contract test for teacher.message.push (SC-T-19) [teacher.message.push]

Sisyphus Agent 9 ore în urmă
părinte
comite
23e2ddd66

+ 512 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/TeacherMessagePushContractTest.java

@@ -0,0 +1,512 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.UserNotificationController;
+import com.etotem.cfc.controller.guide.TeacherMessageController;
+import com.etotem.cfc.entity.TeacherMessage;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.TeacherMessageMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.PlannerCertificationGuard;
+import com.etotem.cfc.service.PlannerCertificationService;
+import com.etotem.cfc.service.TeacherMessageService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Scanner;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+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 teacher.message.push} —— 规划师→客户消息推送(SC-T-19)。
+ *
+ * <p><b>需求溯源</b></p>
+ * <ul>
+ *   <li>《应用场景总览》SC-T-19「消息推送客户」;sc-key-map 台账 SC-T-19(成长规划师·业务域 L)。</li>
+ *   <li>EVD-10 契约(77026df42)→ API 参考 §4.57 端点载体回填实测:
+ *       {@code TeacherMessageController /api/guide/messages/*}(send/reply/list/unread-count/my)
+ *       + {@code UserNotificationController /api/notification/*}。</li>
+ *   <li>API 参考 §5.4 接单闸门:{@code TeacherMessageController.reply} 需 {@code planner} 类别认证
+ *       ({@code PlannerCertificationGuard.assertCertified(teacherId, CATEGORY_PLANNER)})。</li>
+ * </ul>
+ *
+ * <p><b>被校验的契约行为</b></p>
+ * <ul>
+ *   <li>正常流:家庭主账号 {@code POST /api/guide/messages/send} 携带 familyId+teacherId+content
+ *       → 成功返回 messageId;规划师 {@code POST /api/guide/messages/{messageId}/reply}(teacher 角色 + 认证通过)
+ *       → 成功返回 true;{@code /list}(teacher 角色)返回分页 records/total;{@code /my} 回填规划师昵称;
+ *       {@code /unread-count}(teacher 角色)返回未读数。</li>
+ *   <li>异常流:{@code /send} 无 familyId → noFamily(code=5001)且不落库;缺 teacherId / 空 content
+ *       → error 且不落库;{@code /reply} 目标消息非本人(service 返回 false)→ 不写库。</li>
+ *   <li>权限流:{@code /reply}、{@code /list}、{@code /unread-count} 非 teacher 角色一律拒绝
+ *       ({@code RolesUtil.hasTeacherRole} 门禁);{@code /reply} 认证闸门拒绝时不调 service。</li>
+ *   <li>结构契约:Controller 端点映射、实体字段、schema.sql 建表。</li>
+ * </ul>
+ *
+ * <p><b>测试范围</b>:纯 Mockito / 反射,无 Spring 上下文 / 无 DB / 无外部服务。</p>
+ *
+ * <p><b>实现现状说明</b>:本契约端点载体在 src/main 已落地(TeacherMessageController /
+ * TeacherMessageService / TeacherMessage 实体 / teacher_messages 表 / UserNotificationController /
+ * PlannerCertificationGuard 均已存在),因此本类契约用例应全绿;
+ * 若编排侧判定为「契约新增能力(RED)」,则以本类为契约基准——任何端点缺失、角色门禁缺失、
+ * 认证闸门缺失、落库参数错误都会使对应用例转红。</p>
+ */
+class TeacherMessagePushContractTest {
+
+    private static final Long PARENT_ID = 1001L;
+    private static final Long FAMILY_ID = 2002L;
+    private static final Long TEACHER_ID = 3003L;
+    private static final Long MESSAGE_ID = 404L;
+    private static final String CONTENT = "孩子最近注意力不集中";
+    private static final String REPLY_CONTENT = "建议预约一次专注力测评";
+
+    private TeacherMessageController controller;
+    private TeacherMessageService service;
+    private TeacherMessageMapper mapper;
+    private UserMapper userMapper;
+    private PlannerCertificationGuard guard;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        controller = new TeacherMessageController();
+        service = new TeacherMessageService();
+        mapper = mock(TeacherMessageMapper.class);
+        userMapper = mock(UserMapper.class);
+        guard = mock(PlannerCertificationGuard.class);
+        inject(controller, "teacherMessageService", service);
+        inject(controller, "userMapper", userMapper);
+        inject(controller, "plannerCertificationGuard", guard);
+        inject(service, "teacherMessageMapper", mapper);
+    }
+
+    // ==================================================================
+    // 正常流:send(家庭主账号发送咨询)
+    // ==================================================================
+
+    @Test
+    @DisplayName("正常流:send 携带 familyId+teacherId+content → 成功返回 messageId 并落库")
+    void send_valid_persistsAndReturnsId() {
+        when(mapper.insert(any(TeacherMessage.class))).thenAnswer(inv -> {
+            TeacherMessage m = inv.getArgument(0);
+            m.setId(MESSAGE_ID);
+            return 1;
+        });
+
+        Map<String, Object> body = new HashMap<>();
+        body.put("teacherId", TEACHER_ID);
+        body.put("content", CONTENT);
+        Result<Long> r = controller.sendMessage(PARENT_ID, FAMILY_ID, body);
+
+        assertEquals(200, r.getCode());
+        assertEquals(MESSAGE_ID, r.getData());
+        verify(mapper).insert(any(TeacherMessage.class));
+    }
+
+    @Test
+    @DisplayName("正常流:send 落库的消息初始状态为 pending、含创建时间、归属正确")
+    void send_persistedMessage_hasPendingStatusAndOwnership() {
+        final TeacherMessage[] captured = new TeacherMessage[1];
+        when(mapper.insert(any(TeacherMessage.class))).thenAnswer(inv -> {
+            TeacherMessage m = inv.getArgument(0);
+            captured[0] = m;
+            m.setId(MESSAGE_ID);
+            return 1;
+        });
+
+        Map<String, Object> body = new HashMap<>();
+        body.put("teacherId", TEACHER_ID);
+        body.put("content", CONTENT);
+        controller.sendMessage(PARENT_ID, FAMILY_ID, body);
+
+        assertNotNull(captured[0]);
+        assertEquals(FAMILY_ID, captured[0].getFamilyId());
+        assertEquals(PARENT_ID, captured[0].getParentId());
+        assertEquals(TEACHER_ID, captured[0].getTeacherId());
+        assertEquals(CONTENT, captured[0].getContent());
+        assertEquals("pending", captured[0].getStatus());
+        assertNotNull(captured[0].getCreatedAt());
+    }
+
+    // ==================================================================
+    // 异常流:send 参数门禁
+    // ==================================================================
+
+    @Test
+    @DisplayName("异常流:send 无 familyId → noFamily(5001)且不落库")
+    void send_noFamily_rejectedNoPersist() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("teacherId", TEACHER_ID);
+        body.put("content", CONTENT);
+
+        Result<Long> r = controller.sendMessage(PARENT_ID, null, body);
+
+        assertEquals(5001, r.getCode());
+        verify(mapper, never()).insert(any(TeacherMessage.class));
+    }
+
+    @Test
+    @DisplayName("异常流:send 缺 teacherId → error 且不落库")
+    void send_missingTeacherId_rejectedNoPersist() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("content", CONTENT);
+
+        Result<Long> r = controller.sendMessage(PARENT_ID, FAMILY_ID, body);
+
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("teacherId"));
+        verify(mapper, never()).insert(any(TeacherMessage.class));
+    }
+
+    @Test
+    @DisplayName("异常流:send 空 content → error 且不落库")
+    void send_blankContent_rejectedNoPersist() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("teacherId", TEACHER_ID);
+        body.put("content", "   ");
+
+        Result<Long> r = controller.sendMessage(PARENT_ID, FAMILY_ID, body);
+
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("内容"));
+        verify(mapper, never()).insert(any(TeacherMessage.class));
+    }
+
+    // ==================================================================
+    // 正常流:reply(规划师回复,§5.4 认证闸门)
+    // ==================================================================
+
+    @Test
+    @DisplayName("正常流:reply teacher 角色 + 认证通过 → 成功并调 service.replyMessage")
+    void reply_teacherCertified_succeeds() {
+        when(guard.assertCertified(TEACHER_ID, PlannerCertificationService.CATEGORY_PLANNER))
+                .thenReturn(null);
+        when(mapper.selectById(MESSAGE_ID)).thenReturn(buildMessage(TEACHER_ID));
+        when(mapper.updateById(any(TeacherMessage.class))).thenReturn(1);
+
+        Map<String, String> body = new HashMap<>();
+        body.put("replyContent", REPLY_CONTENT);
+        Result<Boolean> r = controller.replyMessage(TEACHER_ID, "teacher", MESSAGE_ID, body, null);
+
+        assertEquals(200, r.getCode());
+        assertTrue(Boolean.TRUE.equals(r.getData()));
+        verify(mapper).updateById(any(TeacherMessage.class));
+    }
+
+    @Test
+    @DisplayName("正常流:reply 认证通过后 service 将消息置为 replied、含回复时间")
+    void reply_marksRepliedWithTime() {
+        when(guard.assertCertified(TEACHER_ID, PlannerCertificationService.CATEGORY_PLANNER))
+                .thenReturn(null);
+        TeacherMessage msg = buildMessage(TEACHER_ID);
+        when(mapper.selectById(MESSAGE_ID)).thenReturn(msg);
+
+        Map<String, String> body = new HashMap<>();
+        body.put("replyContent", REPLY_CONTENT);
+        controller.replyMessage(TEACHER_ID, "teacher", MESSAGE_ID, body, null);
+
+        assertEquals("replied", msg.getStatus());
+        assertEquals(REPLY_CONTENT, msg.getReplyContent());
+        assertNotNull(msg.getRepliedAt());
+    }
+
+    // ==================================================================
+    // 异常流:reply 越权与消息归属
+    // ==================================================================
+
+    @Test
+    @DisplayName("异常流:reply 目标消息非本人(teacherId 不匹配)→ service 返回 false 且不写库")
+    void reply_notOwnedMessage_returnsFalseNoPersist() {
+        when(guard.assertCertified(TEACHER_ID, PlannerCertificationService.CATEGORY_PLANNER))
+                .thenReturn(null);
+        // 消息归属另一个规划师
+        when(mapper.selectById(MESSAGE_ID)).thenReturn(buildMessage(9999L));
+
+        Map<String, String> body = new HashMap<>();
+        body.put("replyContent", REPLY_CONTENT);
+        Result<Boolean> r = controller.replyMessage(TEACHER_ID, "teacher", MESSAGE_ID, body, null);
+
+        assertEquals(200, r.getCode());
+        assertFalse(Boolean.TRUE.equals(r.getData()));
+        verify(mapper, never()).updateById(any(TeacherMessage.class));
+    }
+
+    @Test
+    @DisplayName("异常流:reply 目标消息不存在 → service 返回 false 且不写库")
+    void reply_missingMessage_returnsFalseNoPersist() {
+        when(guard.assertCertified(TEACHER_ID, PlannerCertificationService.CATEGORY_PLANNER))
+                .thenReturn(null);
+        when(mapper.selectById(MESSAGE_ID)).thenReturn(null);
+
+        Map<String, String> body = new HashMap<>();
+        body.put("replyContent", REPLY_CONTENT);
+        Result<Boolean> r = controller.replyMessage(TEACHER_ID, "teacher", MESSAGE_ID, body, null);
+
+        assertFalse(Boolean.TRUE.equals(r.getData()));
+        verify(mapper, never()).updateById(any(TeacherMessage.class));
+    }
+
+    // ==================================================================
+    // 权限流:非 teacher 角色拒绝 / 认证闸门拒绝
+    // ==================================================================
+
+    @Test
+    @DisplayName("权限流:reply 非 teacher 角色 → error『只有成长规划师可以回复』且不调闸门/不落库")
+    void reply_nonTeacherRole_rejected() {
+        Map<String, String> body = new HashMap<>();
+        body.put("replyContent", REPLY_CONTENT);
+
+        Result<Boolean> r = controller.replyMessage(TEACHER_ID, "parent", MESSAGE_ID, body, null);
+
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("只有成长规划师"));
+        verify(guard, never()).assertCertified(anyLong(), anyString());
+        verify(mapper, never()).updateById(any(TeacherMessage.class));
+    }
+
+    @Test
+    @DisplayName("权限流:reply 认证闸门拒绝 → 透传闸门 code/message 且不调 service")
+    void reply_guardDenied_propagatesAndSkipsService() {
+        when(guard.assertCertified(TEACHER_ID, PlannerCertificationService.CATEGORY_PLANNER))
+                .thenReturn(Result.error(403, "规划师未完成成长规划师基础认证,暂不可提供服务"));
+
+        Map<String, String> body = new HashMap<>();
+        body.put("replyContent", REPLY_CONTENT);
+        Result<Boolean> r = controller.replyMessage(TEACHER_ID, "teacher", MESSAGE_ID, body, null);
+
+        assertEquals(403, r.getCode());
+        assertTrue(r.getMessage().contains("规划师未完成"));
+        verify(mapper, never()).selectById(anyLong());
+        verify(mapper, never()).updateById(any(TeacherMessage.class));
+    }
+
+    @Test
+    @DisplayName("权限流:list 非 teacher 角色 → error『只有成长规划师可以查看』且不调 service")
+    void list_nonTeacherRole_rejected() {
+        Map<String, Object> params = new HashMap<>();
+        params.put("page", 1);
+        params.put("size", 10);
+
+        Result<?> r = controller.getMessages(TEACHER_ID, "parent", params, null);
+
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("只有成长规划师"));
+        verify(mapper, never()).selectPage(any(), any());
+    }
+
+    @Test
+    @DisplayName("权限流:unread-count 非 teacher 角色 → error 且不调 service")
+    void unreadCount_nonTeacherRole_rejected() {
+        Result<Long> r = controller.getUnreadCount(TEACHER_ID, "parent", null);
+
+        assertEquals(500, r.getCode());
+        verify(mapper, never()).selectCount(any());
+    }
+
+    // ==================================================================
+    // 正常流:list / unread-count / my
+    // ==================================================================
+
+    @Test
+    @DisplayName("正常流:list teacher 角色 → 返回 records/total 分页结构")
+    void list_teacherRole_returnsPagedStructure() {
+        com.baomidou.mybatisplus.extension.plugins.pagination.Page<TeacherMessage> page =
+                new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(1, 10);
+        page.setRecords(Collections.singletonList(buildMessage(TEACHER_ID)));
+        page.setTotal(1);
+        when(mapper.selectPage(any(), any())).thenReturn(page);
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("page", 1);
+        params.put("size", 10);
+
+        Result<?> r = controller.getMessages(TEACHER_ID, "teacher", params, null);
+
+        assertEquals(200, r.getCode());
+        assertNotNull(r.getData());
+        Map<?, ?> data = (Map<?, ?>) r.getData();
+        assertTrue(data.containsKey("records"));
+        assertTrue(data.containsKey("total"));
+        assertTrue(data.containsKey("pages"));
+        assertTrue(data.containsKey("current"));
+    }
+
+    @Test
+    @DisplayName("正常流:unread-count teacher 角色 → 返回未读数")
+    void unreadCount_teacherRole_returnsCount() {
+        when(mapper.selectCount(any())).thenReturn(3L);
+
+        Result<Long> r = controller.getUnreadCount(TEACHER_ID, "teacher", null);
+
+        assertEquals(200, r.getCode());
+        assertEquals(3L, r.getData());
+    }
+
+    @Test
+    @DisplayName("正常流:my → 回填规划师昵称(teacherName)")
+    void my_fillsTeacherName() {
+        TeacherMessage msg = buildMessage(TEACHER_ID);
+        when(mapper.selectList(any())).thenReturn(Collections.singletonList(msg));
+        User teacher = new User();
+        teacher.setId(TEACHER_ID);
+        teacher.setNickname("王老师");
+        when(userMapper.selectById(TEACHER_ID)).thenReturn(teacher);
+
+        Result<?> r = controller.getMyMessages(PARENT_ID);
+
+        assertEquals(200, r.getCode());
+        assertNotNull(r.getData());
+        assertNotNull(((java.util.List<?>) r.getData()).get(0));
+        TeacherMessage m = (TeacherMessage) ((java.util.List<?>) r.getData()).get(0);
+        assertEquals("王老师", m.getTeacherName());
+    }
+
+    // ==================================================================
+    // 结构契约:端点映射 / 实体字段 / 建表
+    // ==================================================================
+
+    @Test
+    @DisplayName("结构契约:TeacherMessageController 暴露 send/reply/list/unread-count/my 五个端点")
+    void controller_exposesContractEndpoints() {
+        Map<String, Boolean> found = new HashMap<>();
+        found.put("send", false);
+        found.put("reply", false);
+        found.put("list", false);
+        found.put("unread-count", false);
+        found.put("my", false);
+        for (Method m : TeacherMessageController.class.getDeclaredMethods()) {
+            PostMapping pm = m.getAnnotation(PostMapping.class);
+            if (pm == null) {
+                continue;
+            }
+            for (String p : pm.value()) {
+                for (String key : found.keySet()) {
+                    if (p != null && p.contains(key)) {
+                        found.put(key, true);
+                    }
+                }
+            }
+        }
+        for (Map.Entry<String, Boolean> e : found.entrySet()) {
+            assertTrue(e.getValue(),
+                    "[teacher.message.push] TeacherMessageController 缺少端点包含 '" + e.getKey()
+                            + "':契约 §4.57 SC-T-19 要求 send/reply/list/unread-count/my 五端点。");
+        }
+    }
+
+    @Test
+    @DisplayName("结构契约:UserNotificationController 暴露 /api/notification/* 基础路径 + list/read")
+    void notificationController_exposesContractEndpoints() {
+        RequestMapping base = UserNotificationController.class.getAnnotation(RequestMapping.class);
+        assertNotNull(base, "[teacher.message.push] UserNotificationController 缺少 @RequestMapping。");
+        assertTrue(base.value()[0].equals("/api/notification"),
+                "[teacher.message.push] UserNotificationController 基础路径应为 /api/notification,实际 "
+                        + base.value()[0]);
+
+        boolean hasList = false;
+        boolean hasRead = false;
+        for (Method m : UserNotificationController.class.getDeclaredMethods()) {
+            PostMapping pm = m.getAnnotation(PostMapping.class);
+            if (pm == null) {
+                continue;
+            }
+            for (String p : pm.value()) {
+                if (p != null && p.contains("list")) {
+                    hasList = true;
+                }
+                if (p != null && p.contains("read")) {
+                    hasRead = true;
+                }
+            }
+        }
+        assertTrue(hasList, "[teacher.message.push] UserNotificationController 缺少 /list 端点。");
+        assertTrue(hasRead, "[teacher.message.push] UserNotificationController 缺少 /read 端点。");
+    }
+
+    @Test
+    @DisplayName("结构契约:TeacherMessage 实体应承载消息推送核心字段")
+    void entity_carriesContractFields() {
+        assertTrue(hasField("familyId"), "[teacher.message.push] TeacherMessage 缺 familyId。");
+        assertTrue(hasField("parentId"), "[teacher.message.push] TeacherMessage 缺 parentId。");
+        assertTrue(hasField("teacherId"), "[teacher.message.push] TeacherMessage 缺 teacherId。");
+        assertTrue(hasField("content"), "[teacher.message.push] TeacherMessage 缺 content。");
+        assertTrue(hasField("replyContent"), "[teacher.message.push] TeacherMessage 缺 replyContent。");
+        assertTrue(hasField("status"), "[teacher.message.push] TeacherMessage 缺 status(pending/replied)。");
+        assertTrue(hasField("createdAt"), "[teacher.message.push] TeacherMessage 缺 createdAt。");
+        assertTrue(hasField("repliedAt"), "[teacher.message.push] TeacherMessage 缺 repliedAt。");
+    }
+
+    @Test
+    @DisplayName("结构契约:schema.sql 应落地 teacher_messages 与 user_notifications 建表")
+    void schemaSql_containsContractTables() {
+        String schema = readSchemaSql();
+        assertTrue(schema.contains("teacher_messages"),
+                "[teacher.message.push] schema.sql 缺少 teacher_messages 建表(§4.57 SC-T-19)。");
+        assertTrue(schema.contains("user_notifications"),
+                "[teacher.message.push] schema.sql 缺少 user_notifications 建表(§4.57 通知载体)。");
+    }
+
+    // ==================================================================
+    // 辅助构造
+    // ==================================================================
+
+    private TeacherMessage buildMessage(Long teacherId) {
+        TeacherMessage m = new TeacherMessage();
+        m.setId(MESSAGE_ID);
+        m.setFamilyId(FAMILY_ID);
+        m.setParentId(PARENT_ID);
+        m.setTeacherId(teacherId);
+        m.setContent(CONTENT);
+        m.setStatus("pending");
+        return m;
+    }
+
+    private void inject(Object target, String fieldName, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(fieldName);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+
+    private boolean hasField(String name) {
+        for (Field f : TeacherMessage.class.getDeclaredFields()) {
+            if (f.getName().equals(name)) {
+                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 "";
+        }
+    }
+}