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