Просмотр исходного кода

S-cfc-2: 契约级单测补齐 guide.trainingPlan.getPlanList(TrainingPlanController#getPlanList,10 用例全绿)

- MockMvc standalone + mock TrainingPlanService,无 Spring 上下文/无 DB
- 覆盖:无 memberId 走 getPlansByGuide;body memberId / currentMemberId 回退 / body 优先;
  memberId 空串与非数字视为未传;空列表与完整列表响应契约;GET 405;缺 userId 属性 400
- 缺陷观察:handler 无控制器内角色校验且未入 LOGIN_REQUIRED_PATHS,仅名义受规划师角色约束
E2E Test Bot 1 неделя назад
Родитель
Сommit
be255fc490

+ 230 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/guide/TrainingPlanControllerGetPlanListTest.java

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