Pārlūkot izejas kodu

test(S-cfc-29): 契约级单测覆盖能力键 task.history.view(POST /api/tasks/history 任务历史查询分页+category过滤)

- 新增 TaskControllerGetTaskHistoryTest:正常路径(默认/显式分页、category 过滤、memberId 回退 currentMemberId)、空态、传输层契约(统一 @PostMapping GET->405、@RequestBody 缺失->400)、直接调用统一 Result<T> 形状
- 对应 SC-P-94「完成任务→历史完成任务页面」+ 需求变更 A;handler 无角色检查,角色由 JwtInterceptor/Service 承担(teacher|admin 不适用本层)
- 测试隔离:MockMvc standalone + mock TaskService,无 Spring 上下文/无 DB/无外部服务
- 运行结果:Tests run: 7, Failures: 0, Errors: 0, Skipped: 0(src/main 零改动,未暴露缺陷)
Agnes Agent 6 dienas atpakaļ
vecāks
revīzija
4adb7895c

+ 224 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/task/TaskControllerGetTaskHistoryTest.java

@@ -0,0 +1,224 @@
+package com.etotem.cfc.controller.task;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.service.TaskService;
+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.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.ArgumentMatchers.any;
+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;
+
+/**
+ * TDD 覆盖驱动(S-cfc-29 / 能力键 task.history.view):
+ * POST /api/tasks/history 获取任务历史(分页 + 可选 category 过滤)的契约级单测。
+ *
+ * 契约(对应 SC-P-94「我的成就·完成任务 → 历史完成任务页面」、API_REFERENCE.md 任务域、
+ * TaskController.getTaskHistory):
+ * - 正常路径:body.memberId 明确传入 → taskService.getTaskHistory(memberId, page, size) → Result&lt;Page&lt;Task&gt;&gt; code=200
+ * - 正常路径·分类:body.category 非空 → 走 taskService.getTaskHistoryByCategory(memberId, page, size, category)
+ * - 分页边界:body.page/size 显式传入 → 原样传给 Service;不传 → 默认 page=1 / size=10
+ * - 成员边界:body.memberId 缺省时回退 JwtInterceptor 注入的 currentMemberId 属性
+ * - 空态:无完成记录 → 统一 Result code=200 且 data.total=0(前端据此展示「暂无已完成任务」空态)
+ * - 传输层契约:统一 @PostMapping(GET 同路径 → 405); @RequestBody 缺失 → 400
+ * - 角色/权限口径:getTaskHistory handler 无 @RequestAttribute("role"/"roles") 检查,角色不在本层判定;
+ *   授权由 JwtInterceptor(有效 token 注入 currentMemberId)+ Service 层成员归属校验承担,
+ *   故本测试覆盖 memberId 解析与传递正确性而非角色分支(teacher|admin 不适用本 handler)。
+ *
+ * 测试隔离:MockMvc standalone + mock TaskService,无 Spring 上下文 / 无 DB / 无外部服务(微信等)。
+ */
+public class TaskControllerGetTaskHistoryTest {
+
+    private static final String URL = "/api/tasks/history";
+
+    @Mock
+    private TaskService taskService;
+
+    private MockMvc mockMvc;
+    private TaskController controller;
+
+    @BeforeEach
+    public void setUp() {
+        MockitoAnnotations.openMocks(this);
+        controller = new TaskController();
+        // getTaskHistory 仅依赖 taskService;其余 @Resource 字段本方法不触碰,保持 null 无碍
+        ReflectionTestUtils.setField(controller, "taskService", taskService);
+        mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
+    }
+
+    // ==================== 正常路径:默认分页 + 空态 ====================
+
+    @Test
+    public void getTaskHistory_success_defaultPageSize_returnsPage() throws Exception {
+        Long memberId = 11L;
+        Page<Task> mockPage = new Page<>();
+        mockPage.setTotal(0); // 空态:无完成任务
+        when(taskService.getTaskHistory(eq(memberId), eq(1), eq(10))).thenReturn(mockPage);
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("memberId", memberId);
+
+        mockMvc.perform(post(URL)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(jsonBody(params)))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.message").value("success"))
+                .andExpect(jsonPath("$.data.total").value(0));
+
+        verify(taskService).getTaskHistory(eq(memberId), eq(1), eq(10));
+        verify(taskService, never()).getTaskHistoryByCategory(any(), any(), any(), any());
+    }
+
+    // ==================== 正常路径:显式分页 ====================
+
+    @Test
+    public void getTaskHistory_success_explicitPageSize_forwardedToService() throws Exception {
+        Long memberId = 11L;
+        Page<Task> mockPage = new Page<>();
+        mockPage.setTotal(2);
+        when(taskService.getTaskHistory(eq(memberId), eq(2), eq(20))).thenReturn(mockPage);
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("memberId", memberId);
+        params.put("page", 2);
+        params.put("size", 20);
+
+        mockMvc.perform(post(URL)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(jsonBody(params)))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.total").value(2));
+
+        verify(taskService).getTaskHistory(eq(memberId), eq(2), eq(20));
+    }
+
+    // ==================== 正常路径:category 过滤 ====================
+
+    @Test
+    public void getTaskHistory_success_withCategory_usesByCategory() throws Exception {
+        Long memberId = 11L;
+        String category = "学习类";
+        Page<Task> mockPage = new Page<>();
+        mockPage.setTotal(5);
+        when(taskService.getTaskHistoryByCategory(eq(memberId), eq(1), eq(10), eq(category))).thenReturn(mockPage);
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("memberId", memberId);
+        params.put("category", category);
+
+        mockMvc.perform(post(URL)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(jsonBody(params)))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.total").value(5));
+
+        verify(taskService).getTaskHistoryByCategory(eq(memberId), eq(1), eq(10), eq(category));
+        verify(taskService, never()).getTaskHistory(any(), any(), any());
+    }
+
+    // ==================== 关键边界:memberId 回退 currentMemberId ====================
+
+    @Test
+    public void getTaskHistory_memberIdMissing_fallsBackToCurrentMemberId() throws Exception {
+        Long currentMemberId = 99L;
+        Page<Task> mockPage = new Page<>();
+        mockPage.setTotal(1);
+        when(taskService.getTaskHistory(eq(currentMemberId), eq(1), eq(10))).thenReturn(mockPage);
+
+        Map<String, Object> params = new HashMap<>(); // 不传 memberId
+
+        mockMvc.perform(post(URL)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(jsonBody(params))
+                        .requestAttr("currentMemberId", currentMemberId)) // JwtInterceptor 注入
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200));
+
+        verify(taskService).getTaskHistory(eq(currentMemberId), eq(1), eq(10));
+    }
+
+    // ==================== 传输层契约 ====================
+
+    @Test
+    public void getTaskHistory_onlyPostAllowed_getReturns405() throws Exception {
+        mockMvc.perform(get(URL))
+                .andExpect(status().isMethodNotAllowed());
+    }
+
+    @Test
+    public void getTaskHistory_missingBody_returns400() throws Exception {
+        // @RequestBody Map 默认 required=true:无 Body → HttpMessageNotReadableException → 400
+        mockMvc.perform(post(URL)
+                        .contentType(MediaType.APPLICATION_JSON))
+                .andExpect(status().isBadRequest());
+
+        verify(taskService, never()).getTaskHistory(any(), any(), any());
+        verify(taskService, never()).getTaskHistoryByCategory(any(), any(), any(), any());
+    }
+
+    // ==================== 直接调用级:统一 Result<T> ====================
+
+    @Test
+    public void getTaskHistory_directCall_uniformResultShape() {
+        Long memberId = 11L;
+        Page<Task> mockPage = new Page<>();
+        mockPage.setTotal(1);
+        when(taskService.getTaskHistory(memberId, 1, 10)).thenReturn(mockPage);
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("memberId", memberId);
+
+        Result<Page<Task>> result = controller.getTaskHistory(params, 99L);
+
+        assertNotNull(result);
+        assertEquals(Integer.valueOf(200), result.getCode());
+        assertEquals("success", result.getMessage());
+        assertEquals(mockPage, result.getData());
+        verify(taskService).getTaskHistory(memberId, 1, 10);
+    }
+
+    // ==================== 工具方法 ====================
+
+    private String jsonBody(Map<String, Object> params) {
+        if (params == null || params.isEmpty()) {
+            return "{}";
+        }
+        StringBuilder sb = new StringBuilder("{");
+        boolean first = true;
+        for (Map.Entry<String, Object> entry : params.entrySet()) {
+            if (!first) {
+                sb.append(", ");
+            }
+            first = false;
+            Object value = entry.getValue();
+            sb.append("\"").append(entry.getKey()).append("\": ");
+            if (value instanceof String) {
+                sb.append("\"").append(value).append("\"");
+            } else {
+                sb.append(value);
+            }
+        }
+        return sb.append("}").toString();
+    }
+}