|
@@ -0,0 +1,230 @@
|
|
|
|
|
+package com.etotem.cfc.controller.guide;
|
|
|
|
|
+
|
|
|
|
|
+import com.etotem.cfc.entity.TrainingPlan;
|
|
|
|
|
+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.ArgumentMatchers.eq;
|
|
|
|
|
+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#getPlanList}(能力键 guide.trainingPlan.getPlanList)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 端点:POST /api/guide/training-plans/list
|
|
|
|
|
+ *
|
|
|
|
|
+ * 契约要点(按 AGENTS.md):
|
|
|
|
|
+ * - 统一 @PostMapping(GET 应 405);
|
|
|
|
|
+ * - 统一 Result<T> 包装(code=200 / message=success / data=方案列表);
|
|
|
|
|
+ * - memberId 解析:body.memberId 优先,其次 @RequestAttribute currentMemberId 回退,
|
|
|
|
|
+ * 两者皆无时查规划师全部方案;null/空串/非数字均视为"未传"(ParamUtils 语义)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 角色/权限说明(缺陷观察,不在此断言为绿灯):
|
|
|
|
|
+ * - 本 handler 在控制器层不做角色校验(不读 @RequestAttribute("role")),
|
|
|
|
|
+ * 也未列入 JwtInterceptor 的 LOGIN_REQUIRED_PATHS;
|
|
|
|
|
+ * - 因此"受规划师角色调用约束"仅在名义上成立,实际任何已认证角色(甚至匿名
|
|
|
|
|
+ * userId=-1)都可到达 handler,属潜在越权/鉴权缺口,已记录并交编排侧决策。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 测试隔离:MockMvc standalone + mock TrainingPlanService,无 Spring 上下文 / 无 DB。
|
|
|
|
|
+ */
|
|
|
|
|
+class TrainingPlanControllerGetPlanListTest {
|
|
|
|
|
+
|
|
|
|
|
+ private static final String URL = "/api/guide/training-plans/list";
|
|
|
|
|
+
|
|
|
|
|
+ @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;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 正常路径 ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void getPlanList_未传memberId_按规划师查全部方案() throws Exception {
|
|
|
|
|
+ TrainingPlan plan = plan(1L, 7L, null, "暑期认知训练", "draft");
|
|
|
|
|
+ when(trainingPlanService.getPlansByGuide(7L)).thenReturn(Collections.singletonList(plan));
|
|
|
|
|
+
|
|
|
|
|
+ mockMvc.perform(post(URL)
|
|
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
|
|
+ .content("{}")
|
|
|
|
|
+ .requestAttr("userId", 7L))
|
|
|
|
|
+ .andExpect(status().isOk())
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(200))
|
|
|
|
|
+ .andExpect(jsonPath("$.message").value("success"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data[0].id").value(1))
|
|
|
|
|
+ .andExpect(jsonPath("$.data[0].title").value("暑期认知训练"));
|
|
|
|
|
+
|
|
|
|
|
+ verify(trainingPlanService).getPlansByGuide(7L);
|
|
|
|
|
+ verify(trainingPlanService, never()).getPlansByGuideAndChild(eq(7L), eq(null));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void getPlanList_body传memberId_按成员查方案() throws Exception {
|
|
|
|
|
+ TrainingPlan plan = plan(2L, 7L, 20L, "注意力专项", "published");
|
|
|
|
|
+ when(trainingPlanService.getPlansByGuideAndChild(7L, 20L))
|
|
|
|
|
+ .thenReturn(Collections.singletonList(plan));
|
|
|
|
|
+
|
|
|
|
|
+ mockMvc.perform(post(URL)
|
|
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
|
|
+ .content("{\"memberId\":20}")
|
|
|
|
|
+ .requestAttr("userId", 7L))
|
|
|
|
|
+ .andExpect(status().isOk())
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(200))
|
|
|
|
|
+ .andExpect(jsonPath("$.data[0].id").value(2))
|
|
|
|
|
+ .andExpect(jsonPath("$.data[0].status").value("published"));
|
|
|
|
|
+
|
|
|
|
|
+ verify(trainingPlanService).getPlansByGuideAndChild(7L, 20L);
|
|
|
|
|
+ verify(trainingPlanService, never()).getPlansByGuide(7L);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== memberId 解析边界 ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void getPlanList_无bodymemberId回退currentMemberId属性() throws Exception {
|
|
|
|
|
+ when(trainingPlanService.getPlansByGuideAndChild(7L, 77L))
|
|
|
|
|
+ .thenReturn(Collections.emptyList());
|
|
|
|
|
+
|
|
|
|
|
+ mockMvc.perform(post(URL)
|
|
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
|
|
+ .content("{}")
|
|
|
|
|
+ .requestAttr("userId", 7L)
|
|
|
|
|
+ .requestAttr("currentMemberId", 77L))
|
|
|
|
|
+ .andExpect(status().isOk())
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(200))
|
|
|
|
|
+ .andExpect(jsonPath("$.data").isArray())
|
|
|
|
|
+ .andExpect(jsonPath("$.data").isEmpty());
|
|
|
|
|
+
|
|
|
|
|
+ verify(trainingPlanService).getPlansByGuideAndChild(7L, 77L);
|
|
|
|
|
+ verify(trainingPlanService, never()).getPlansByGuide(7L);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void getPlanList_bodymemberId优先于currentMemberId() throws Exception {
|
|
|
|
|
+ when(trainingPlanService.getPlansByGuideAndChild(7L, 20L))
|
|
|
|
|
+ .thenReturn(Collections.emptyList());
|
|
|
|
|
+
|
|
|
|
|
+ mockMvc.perform(post(URL)
|
|
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
|
|
+ .content("{\"memberId\":20}")
|
|
|
|
|
+ .requestAttr("userId", 7L)
|
|
|
|
|
+ .requestAttr("currentMemberId", 77L))
|
|
|
|
|
+ .andExpect(status().isOk())
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(200));
|
|
|
|
|
+
|
|
|
|
|
+ verify(trainingPlanService).getPlansByGuideAndChild(7L, 20L);
|
|
|
|
|
+ verify(trainingPlanService, never()).getPlansByGuide(7L);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void getPlanList_memberId空字符串视为未传() throws Exception {
|
|
|
|
|
+ when(trainingPlanService.getPlansByGuide(7L)).thenReturn(Collections.emptyList());
|
|
|
|
|
+
|
|
|
|
|
+ mockMvc.perform(post(URL)
|
|
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
|
|
+ .content("{\"memberId\":\"\"}")
|
|
|
|
|
+ .requestAttr("userId", 7L))
|
|
|
|
|
+ .andExpect(status().isOk())
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(200));
|
|
|
|
|
+
|
|
|
|
|
+ verify(trainingPlanService).getPlansByGuide(7L);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void getPlanList_memberId非数字视为未传() throws Exception {
|
|
|
|
|
+ when(trainingPlanService.getPlansByGuide(7L)).thenReturn(Collections.emptyList());
|
|
|
|
|
+
|
|
|
|
|
+ mockMvc.perform(post(URL)
|
|
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
|
|
+ .content("{\"memberId\":\"abc\"}")
|
|
|
|
|
+ .requestAttr("userId", 7L))
|
|
|
|
|
+ .andExpect(status().isOk())
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(200));
|
|
|
|
|
+
|
|
|
|
|
+ verify(trainingPlanService).getPlansByGuide(7L);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 响应契约 ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void getPlanList_空列表返回空data() throws Exception {
|
|
|
|
|
+ when(trainingPlanService.getPlansByGuide(7L)).thenReturn(Collections.emptyList());
|
|
|
|
|
+
|
|
|
|
|
+ mockMvc.perform(post(URL)
|
|
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
|
|
+ .content("{}")
|
|
|
|
|
+ .requestAttr("userId", 7L))
|
|
|
|
|
+ .andExpect(status().isOk())
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(200))
|
|
|
|
|
+ .andExpect(jsonPath("$.message").value("success"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data").isArray())
|
|
|
|
|
+ .andExpect(jsonPath("$.data").isEmpty());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void getPlanList_返回完整方案列表() throws Exception {
|
|
|
|
|
+ TrainingPlan a = plan(1L, 7L, null, "方案A", "draft");
|
|
|
|
|
+ TrainingPlan b = plan(2L, 7L, 20L, "方案B", "published");
|
|
|
|
|
+ when(trainingPlanService.getPlansByGuide(7L)).thenReturn(Arrays.asList(a, b));
|
|
|
|
|
+
|
|
|
|
|
+ mockMvc.perform(post(URL)
|
|
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
|
|
+ .content("{}")
|
|
|
|
|
+ .requestAttr("userId", 7L))
|
|
|
|
|
+ .andExpect(status().isOk())
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(200))
|
|
|
|
|
+ .andExpect(jsonPath("$.data.length()").value(2))
|
|
|
|
|
+ .andExpect(jsonPath("$.data[0].id").value(1))
|
|
|
|
|
+ .andExpect(jsonPath("$.data[1].id").value(2))
|
|
|
|
|
+ .andExpect(jsonPath("$.data[1].childId").value(20));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 传输层契约 ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void getPlanList_仅允许POST_GET请求返回405() throws Exception {
|
|
|
|
|
+ mockMvc.perform(get(URL))
|
|
|
|
|
+ .andExpect(status().isMethodNotAllowed());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void getPlanList_缺userId属性_请求被拒400() throws Exception {
|
|
|
|
|
+ // @RequestAttribute("userId") 为必需:handler 依赖 JwtInterceptor 注入该属性。
|
|
|
|
|
+ // 缺失时 Spring 抛 ServletRequestBindingException,由 DefaultHandlerExceptionResolver 映射为 400。
|
|
|
|
|
+ mockMvc.perform(post(URL)
|
|
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
|
|
+ .content("{}"))
|
|
|
|
|
+ .andExpect(status().isBadRequest());
|
|
|
|
|
+ }
|
|
|
|
|
+}
|