|
|
@@ -0,0 +1,200 @@
|
|
|
+package com.etotem.cfc.controller.guide;
|
|
|
+
|
|
|
+import com.etotem.cfc.entity.TrainingPlan;
|
|
|
+import com.etotem.cfc.entity.TrainingPlanItem;
|
|
|
+import com.etotem.cfc.service.TrainingPlanService;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.mockito.Mock;
|
|
|
+import org.mockito.MockitoAnnotations;
|
|
|
+import org.springframework.http.MediaType;
|
|
|
+import org.springframework.test.util.ReflectionTestUtils;
|
|
|
+import org.springframework.test.web.servlet.MockMvc;
|
|
|
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
|
|
+
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.Collections;
|
|
|
+
|
|
|
+import static org.mockito.Mockito.never;
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
|
|
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
|
|
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
|
|
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 契约级单测:{@link TrainingPlanController#getPlanDetail}(能力键 guide.trainingPlan.getPlanDetail,
|
|
|
+ * 任务 S-cfc-6,应 TR-1 请求由测试角色补写)。
|
|
|
+ *
|
|
|
+ * 端点:POST /api/guide/training-plans/detail
|
|
|
+ *
|
|
|
+ * 契约要点(按 AGENTS.md 与 TR-1 详情):
|
|
|
+ * - 统一 @PostMapping(GET 应 405);
|
|
|
+ * - 统一 Result<T> 包装(成功 code=200;业务拒绝走 Result.error → code=500 + message);
|
|
|
+ * - planId 解析:body.planId 经 ParamUtils.getLong,null/空串/非数字均视为"未传" →
|
|
|
+ * Result.error("planId不能为空");
|
|
|
+ * - 方案不存在:getPlanById 返回 null → Result.error("方案不存在");
|
|
|
+ * - 成功响应:data 为 LinkedHashMap,含 plan(TrainingPlan)与 items(List<TrainingPlanItem>)两键;
|
|
|
+ * items 为空集合时返回空数组而非 null。
|
|
|
+ *
|
|
|
+ * 角色/权限说明(缺陷观察,不在本测试断言为红灯):
|
|
|
+ * - 本 handler 在控制器层不读取 @RequestAttribute("role"),也无 @RequestAttribute("userId"),
|
|
|
+ * 且未列入 JwtInterceptor 的 LOGIN_REQUIRED_PATHS;
|
|
|
+ * - "规划师角色约束"名义上成立,实际任何已认证角色(乃至匿名 userId=-1)均可到达 handler,
|
|
|
+ * 属潜在越权/鉴权缺口(与同文件 getPlanList 既有观察一致),已按 TR-1 要求记录并回写研发侧。
|
|
|
+ *
|
|
|
+ * 测试隔离:MockMvc standalone + mock TrainingPlanService,无 Spring 上下文 / 无 DB。
|
|
|
+ */
|
|
|
+class TrainingPlanControllerGetPlanDetailTest {
|
|
|
+
|
|
|
+ private static final String URL = "/api/guide/training-plans/detail";
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private TrainingPlanService trainingPlanService;
|
|
|
+
|
|
|
+ private MockMvc mockMvc;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ MockitoAnnotations.openMocks(this);
|
|
|
+ TrainingPlanController controller = new TrainingPlanController();
|
|
|
+ ReflectionTestUtils.setField(controller, "trainingPlanService", trainingPlanService);
|
|
|
+ mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
|
|
|
+ }
|
|
|
+
|
|
|
+ private TrainingPlan plan(long id, long guideId, Long memberId, String title, String status) {
|
|
|
+ TrainingPlan p = new TrainingPlan();
|
|
|
+ p.setId(id);
|
|
|
+ p.setGuideId(guideId);
|
|
|
+ p.setMemberId(memberId);
|
|
|
+ p.setTitle(title);
|
|
|
+ p.setStatus(status);
|
|
|
+ return p;
|
|
|
+ }
|
|
|
+
|
|
|
+ private TrainingPlanItem item(long id, long planId, int sequence, String taskTitle) {
|
|
|
+ TrainingPlanItem i = new TrainingPlanItem();
|
|
|
+ i.setId(id);
|
|
|
+ i.setPlanId(planId);
|
|
|
+ i.setSequence(sequence);
|
|
|
+ i.setTaskTitle(taskTitle);
|
|
|
+ return i;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 正常路径 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void getPlanDetail_planId有效_返回plan与items() throws Exception {
|
|
|
+ TrainingPlan plan = plan(9L, 7L, 20L, "暑期认知训练", "published");
|
|
|
+ TrainingPlanItem i1 = item(101L, 9L, 1, "舒尔特方格");
|
|
|
+ TrainingPlanItem i2 = item(102L, 9L, 2, "数字记忆");
|
|
|
+ when(trainingPlanService.getPlanById(9L)).thenReturn(plan);
|
|
|
+ when(trainingPlanService.getPlanItems(9L)).thenReturn(Arrays.asList(i1, i2));
|
|
|
+
|
|
|
+ mockMvc.perform(post(URL)
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
+ .content("{\"planId\":9}"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(200))
|
|
|
+ .andExpect(jsonPath("$.message").value("success"))
|
|
|
+ .andExpect(jsonPath("$.data.plan.id").value(9))
|
|
|
+ .andExpect(jsonPath("$.data.plan.title").value("暑期认知训练"))
|
|
|
+ .andExpect(jsonPath("$.data.plan.status").value("published"))
|
|
|
+ .andExpect(jsonPath("$.data.items.length()").value(2))
|
|
|
+ .andExpect(jsonPath("$.data.items[0].id").value(101))
|
|
|
+ .andExpect(jsonPath("$.data.items[0].taskTitle").value("舒尔特方格"))
|
|
|
+ .andExpect(jsonPath("$.data.items[1].sequence").value(2));
|
|
|
+
|
|
|
+ verify(trainingPlanService).getPlanById(9L);
|
|
|
+ verify(trainingPlanService).getPlanItems(9L);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== planId 解析边界 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void getPlanDetail_planId缺失_返回planId不能为空() throws Exception {
|
|
|
+ mockMvc.perform(post(URL)
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
+ .content("{}"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(500))
|
|
|
+ .andExpect(jsonPath("$.message").value("planId不能为空"));
|
|
|
+
|
|
|
+ verify(trainingPlanService, never()).getPlanById(org.mockito.ArgumentMatchers.anyLong());
|
|
|
+ verify(trainingPlanService, never()).getPlanItems(org.mockito.ArgumentMatchers.anyLong());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void getPlanDetail_planId为null_返回planId不能为空() throws Exception {
|
|
|
+ mockMvc.perform(post(URL)
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
+ .content("{\"planId\":null}"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(500))
|
|
|
+ .andExpect(jsonPath("$.message").value("planId不能为空"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void getPlanDetail_planId空字符串视为未传() throws Exception {
|
|
|
+ mockMvc.perform(post(URL)
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
+ .content("{\"planId\":\"\"}"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(500))
|
|
|
+ .andExpect(jsonPath("$.message").value("planId不能为空"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void getPlanDetail_planId非数字视为未传() throws Exception {
|
|
|
+ mockMvc.perform(post(URL)
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
+ .content("{\"planId\":\"abc\"}"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(500))
|
|
|
+ .andExpect(jsonPath("$.message").value("planId不能为空"));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 方案不存在 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void getPlanDetail_方案不存在_返回方案不存在() throws Exception {
|
|
|
+ when(trainingPlanService.getPlanById(999L)).thenReturn(null);
|
|
|
+
|
|
|
+ mockMvc.perform(post(URL)
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
+ .content("{\"planId\":999}"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(500))
|
|
|
+ .andExpect(jsonPath("$.message").value("方案不存在"));
|
|
|
+
|
|
|
+ verify(trainingPlanService).getPlanById(999L);
|
|
|
+ verify(trainingPlanService, never()).getPlanItems(org.mockito.ArgumentMatchers.anyLong());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 响应契约 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void getPlanDetail_空项目列表_items为空数组而非null() throws Exception {
|
|
|
+ TrainingPlan plan = plan(5L, 7L, null, "空方案", "draft");
|
|
|
+ when(trainingPlanService.getPlanById(5L)).thenReturn(plan);
|
|
|
+ when(trainingPlanService.getPlanItems(5L)).thenReturn(Collections.emptyList());
|
|
|
+
|
|
|
+ mockMvc.perform(post(URL)
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
+ .content("{\"planId\":5}"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(200))
|
|
|
+ .andExpect(jsonPath("$.data.plan.id").value(5))
|
|
|
+ .andExpect(jsonPath("$.data.items").isArray())
|
|
|
+ .andExpect(jsonPath("$.data.items").isEmpty());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 传输层契约 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void getPlanDetail_仅允许POST_GET请求返回405() throws Exception {
|
|
|
+ mockMvc.perform(get(URL))
|
|
|
+ .andExpect(status().isMethodNotAllowed());
|
|
|
+ }
|
|
|
+}
|