|
|
@@ -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 认证。");
|
|
|
+ }
|
|
|
+}
|