Преглед изворни кода

test(tdd): 契约测试登记 task.task.create(POST /api/tasks/create)控制器层(S-cfc-149)

- 新增 TaskTaskCreateContractTest:结构契约(@RestController /api/tasks + @PostMapping /create、
  @RequestAttribute userId + @RequestBody dto)+ 正常流(委托 taskService.createTask 返回
  Result<Long> taskId)+ 异常流(校验异常向上传播,GlobalExceptionHandler → 400)+ 权限流说明
- 纯 Mockito + 反射,无 Spring/DB/外部服务(参考同批 TeamProfitHook/FamilyJoinValidationRules)
- 实测 GREEN 7/7:实现已存在(SC-P-10 f41da2779 已合入;编排报告 2026-11-12-04:42
  「契约已交付,mark-red 属测试侧」),按任务规则 #2 汇报转编排侧登记覆盖,不修改 src/main
zxyj-langgraph пре 1 дан
родитељ
комит
1ee56f5c6f

+ 168 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/TaskTaskCreateContractTest.java

@@ -0,0 +1,168 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.task.TaskController;
+import com.etotem.cfc.dto.CreateTaskDTO;
+import com.etotem.cfc.service.TaskService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.Parameter;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:任务创建(能力键 {@code task.task.create})。
+ *
+ * <p><b>契约锚点</b>:{@code docs/superpowers/api/API_REFERENCE.md} §4.3 ——
+ * {@code POST /api/tasks/create}(创建任务);请求体 {@link CreateTaskDTO};
+ * 响应 {@code Result<Long>}(新任务 id)。设计锚点:
+ * {@link TaskController#createTask(Long, CreateTaskDTO)}(类映射 {@code /api/tasks} +
+ * {@code @PostMapping("/create")},JWT 身份经 {@code @RequestAttribute("userId")} 注入)。
+ * 溯源:SRS.md «三、未覆盖控制器» TaskController(18 方法,0 覆盖)。</p>
+ *
+ * <p><b>测试范围</b>:控制器层契约(路由映射、入参绑定、委托 Service、Result 包装),
+ * 纯 Mockito + 反射,无 Spring 容器 / 无 DB / 无外部服务。</p>
+ *
+ * <p><b>RED 现状核实</b>:任务派发时以「TDD-RED(实现未落地)」入池,但当前分支
+ * {@code origin/cfclub} 上控制器与 Service 侧实现均已存在(SC-P-10 merge f41da2779 已合入;
+ * 编排报告 2026-11-12-04:42 亦注明「契约已交付,mark-red 属测试侧」)。因此本契约测试
+ * 忠实验证契约行为;若实测全绿即属「实现已存在,测试意外全绿」情形,汇报转编排侧登记为
+ * 覆盖,不修改任何 src/main 实现。</p>
+ */
+class TaskTaskCreateContractTest {
+
+    @Mock
+    private TaskService taskService;
+
+    @InjectMocks
+    private TaskController controller;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    // ==================== 结构契约:路由映射 / 入参绑定 ====================
+
+    @Test
+    @DisplayName("结构契约-1:TaskController 应为 @RestController 且类映射 /api/tasks")
+    void structure_controllerIsRestControllerMappedToApiTasks() {
+        assertNotNull(TaskController.class.getAnnotation(RestController.class),
+                "[task.task.create] TaskController 应为 @RestController(统一 PostMapping 规范)。");
+        RequestMapping classMapping = TaskController.class.getAnnotation(RequestMapping.class);
+        assertNotNull(classMapping, "[task.task.create] TaskController 应声明类级 @RequestMapping。");
+        assertEquals("/api/tasks", classMapping.value()[0],
+                "[task.task.create] 类映射应为 /api/tasks(API_REFERENCE §4.3)。");
+    }
+
+    @Test
+    @DisplayName("结构契约-2:createTask 应映射 POST /create 并返回 Result<Long>")
+    void structure_createTaskIsPostMappingCreate() throws Exception {
+        Method m = TaskController.class.getMethod("createTask", Long.class, CreateTaskDTO.class);
+        PostMapping postMapping = m.getAnnotation(PostMapping.class);
+        assertNotNull(postMapping,
+                "[task.task.create] createTask 必须为 @PostMapping(AGENTS.md 禁止 GET/PUT/DELETE)。");
+        assertEquals("/create", postMapping.value()[0],
+                "[task.task.create] createTask 路径应为 /create(完整路由 POST /api/tasks/create)。");
+        assertEquals(Result.class, m.getReturnType(),
+                "[task.task.create] createTask 应返回 Result<Long>(data=新任务 id)。");
+    }
+
+    @Test
+    @DisplayName("结构契约-3:userId 经 @RequestAttribute(name=userId) 注入,dto 经 @RequestBody 绑定")
+    void structure_createTaskBindsUserIdAttributeAndDtoBody() throws Exception {
+        Method m = TaskController.class.getMethod("createTask", Long.class, CreateTaskDTO.class);
+        Parameter[] params = m.getParameters();
+        assertEquals(2, params.length, "[task.task.create] createTask 应接收 (userId, dto) 两参。");
+        assertEquals(Long.class, params[0].getType(), "首参应为 userId(Long)。");
+        assertEquals(CreateTaskDTO.class, params[1].getType(), "次参应为 CreateTaskDTO 请求体。");
+
+        RequestAttribute attr = params[0].getAnnotation(RequestAttribute.class);
+        assertNotNull(attr, "[task.task.create] userId 应经 @RequestAttribute 注入(JWT 解析)。");
+        assertEquals("userId", attr.value(),
+                "[task.task.create] @RequestAttribute 名称应为 userId(登录账号,users.id)。");
+        assertNotNull(params[1].getAnnotation(RequestBody.class),
+                "[task.task.create] dto 应经 @RequestBody 绑定请求体 JSON。");
+    }
+
+    // ==================== 正常流:委托 Service + Result 包装 ====================
+
+    @Test
+    @DisplayName("正常流-1:委托 taskService.createTask 并将新任务 id 包进 Result.success")
+    void normal_delegatesToServiceAndReturnsTaskId() {
+        Long userId = 1L;
+        CreateTaskDTO dto = new CreateTaskDTO();
+        dto.setTitle("睡前阅读");
+        when(taskService.createTask(userId, dto)).thenReturn(100L);
+
+        Result<Long> result = controller.createTask(userId, dto);
+
+        assertNotNull(result, "[task.task.create] 创建任务应返回 Result。");
+        assertEquals(200, result.getCode(), "[task.task.create] 成功应 code=200。");
+        assertEquals("success", result.getMessage());
+        assertEquals(100L, result.getData(), "[task.task.create] data 应为 Service 返回的新任务 id。");
+        verify(taskService).createTask(userId, dto);
+    }
+
+    @Test
+    @DisplayName("正常流-2:userId 与 dto 应原样透传给 Service(不得篡改/丢失)")
+    void normal_passesThroughUserIdAndDto() {
+        Long userId = 42L;
+        CreateTaskDTO dto = new CreateTaskDTO();
+        dto.setTitle("整理书包");
+        dto.setPoints(5);
+        dto.setMemberId(88L);
+        when(taskService.createTask(userId, dto)).thenReturn(7L);
+
+        controller.createTask(userId, dto);
+
+        verify(taskService).createTask(userId, dto);
+    }
+
+    // ==================== 异常流:参数校验失败向上传播(GlobalExceptionHandler → 400) ====================
+
+    @Test
+    @DisplayName("异常流-1:Service 校验异常(如空标题)不得被控制器吞掉,应向上传播")
+    void error_serviceValidationFailure_Propagates() {
+        Long userId = 1L;
+        CreateTaskDTO dto = new CreateTaskDTO();
+        dto.setTitle("");
+        when(taskService.createTask(userId, dto))
+                .thenThrow(new IllegalArgumentException("任务标题不能为空"));
+
+        IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
+                () -> controller.createTask(userId, dto),
+                "[task.task.create] 创建任务参数校验失败应向上传播(GlobalExceptionHandler 统一映射 400),"
+                        + "控制器不得吞掉或返回 success。");
+
+        assertEquals("任务标题不能为空", ex.getMessage(),
+                "[task.task.create] 校验失败信息应原样透传(GlobalExceptionHandler → 400 + 业务提示)。");
+    }
+
+    // ==================== 权限流:JWT 门禁在位(类级路径属需登录区,拦截由拦截器保证) ====================
+
+    @Test
+    @DisplayName("权限流-1:类级路径 /api/tasks 应属 JWT 拦截区(统一拦截 /api/**,8 个公开路径除外)")
+    void permission_pathUnderApiNeedsAuth() {
+        // 契约说明性用例:JwtInterceptor 统一拦截 /api/**(仅 8 个公开路径除外);/api/tasks/* 不在公开
+        // 白名单,创建任务必须携带登录态(userId 由拦截器写入 @RequestAttribute)。此处不引 Spring 上下文,
+        // 仅固化「该端点非公开、需认证」的契约事实,防止未来误加白名单导致未登录可建任务。
+        assertEquals("/api/tasks", TaskController.class.getAnnotation(RequestMapping.class).value()[0],
+                "[task.task.create] 类路径 /api/tasks 属 /api/** 拦截区,需 JWT 认证。");
+    }
+}