Sfoglia il codice sorgente

merge: 合入远端 cfclub 进度后推送 S-cfc-151 契约测试

Sisyphus Agent 1 giorno fa
parent
commit
7008727b62

+ 374 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/HealthReportDetailContractTest.java

@@ -0,0 +1,374 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.HealthReportController;
+import com.etotem.cfc.dto.ParsedReportPayload;
+import com.etotem.cfc.entity.HealthDiseaseRisk;
+import com.etotem.cfc.entity.HealthGutFlora;
+import com.etotem.cfc.entity.HealthIndicator;
+import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.mapper.HealthDiseaseRiskMapper;
+import com.etotem.cfc.mapper.HealthGutFloraMapper;
+import com.etotem.cfc.mapper.HealthIndicatorMapper;
+import com.etotem.cfc.mapper.HealthReportMapper;
+import com.etotem.cfc.service.HealthReportService;
+import com.etotem.cfc.service.ReportBlockService;
+import com.etotem.cfc.service.ReportMemberPermissionService;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 能力键 {@code health.report.detail} 契约级单测(S-cfc-146,TDD-RED 阶段)。
+ *
+ * <p><b>契约溯源</b></p>
+ * <ul>
+ *   <li>需求:docs/tdd/SRS.md «三、未覆盖控制器» HealthReportController(59 方法,0 覆盖)</li>
+ *   <li>设计:{@link HealthReportController#getReportDetail}
+ *       {@code @PostMapping("/report/detail")}(前缀 /api/health)+ {@link HealthReportService#getReportDetail}</li>
+ *   <li>契约:docs/superpowers/api/API_REFERENCE.md §4.59.2(SC-C-06 / SC-X-02)——
+ *       {@code POST /api/health/report/detail} 请求 {@code {reportId}};
+ *       响应 {@code Result<Map>}:报告详情(含指标明细 + 菌群数据 + 疾病风险评估);
+ *       权限校验(本人/管理员/被授权成员),无权返回「无权查看该报告」</li>
+ *   <li>消费侧:cfc-frontend/pages/health/gut-flora-detail.vue loadData() 读取
+ *       {@code report / indicators / gutFlora / diseaseRisks / reportInterpretation / payload.summary}</li>
+ * </ul>
+ *
+ * <p><b>覆盖流</b>(纯 Mockito + 反射,无 Spring 上下文 / 无 DB / 无外部服务,
+ * 风格对齐同批 {@code TaskCompleteScC02ContractTest} 与 {@code HealthReportAuditContractTest}):</p>
+ * <ul>
+ *   <li><b>控制器正常流</b>:管理员可见全部报告;本人可见无归属报告(subjectId=null);被授权成员可见;报告自带
+ *       family_id 且 {@code familyId} 属性缺省时以报告家庭为权限作用域;</li>
+ *   <li><b>控制器异常流</b>:reportId 缺失/非数字 → 「reportId不能为空」;报告不存在 → 「报告不存在」;
+ *       无归属报告且非本人非管理员 → 「无权查看该报告」;有归属但无授权关系 → 「无权查看该报告」;
+ *       报告无家庭且 {@code familyId} 缺省 → 「无权查看该报告」;</li>
+ *   <li><b>Service 内容契约</b>:响应 Map 必须含 {@code report / indicators / gutFlora / diseaseRisks / blocks};
+ *       有肠道年龄/类型时生成 {@code reportInterpretation.gutAgeAssessment};
+ *       有 {@code payload_json} 时解析出嵌套 {@code payload}。</li>
+ * </ul>
+ */
+class HealthReportDetailContractTest {
+
+    private static final Long FAMILY_ID = 1L;
+    private static final Long VIEWER_USER_ID = 7L;
+    private static final Long SUBJECT_MEMBER_ID = 100L;
+
+    private HealthReportController controller;
+    private HealthReportService service;
+
+    /** controller 层转发的 mock(与 service 层真实实例隔离) */
+    private HealthReportService healthReportServiceMock;
+    private ReportMemberPermissionService permissionServiceMock;
+
+    // service 层真实实例的 mock 依赖
+    private HealthReportMapper healthReportMapper;
+    private HealthIndicatorMapper healthIndicatorMapper;
+    private HealthGutFloraMapper healthGutFloraMapper;
+    private HealthDiseaseRiskMapper healthDiseaseRiskMapper;
+    private ReportBlockService reportBlockService;
+    private com.fasterxml.jackson.databind.ObjectMapper objectMapper;
+
+    @BeforeAll
+    static void initMybatisPlusTableInfo() {
+        // 纯 Mockito 下 LambdaQueryWrapper 需要 TableInfo 缓存(与 HealthReportAuditContractTest 一致)
+        MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
+        TableInfoHelper.initTableInfo(assistant, HealthReport.class);
+        TableInfoHelper.initTableInfo(assistant, HealthIndicator.class);
+        TableInfoHelper.initTableInfo(assistant, HealthGutFlora.class);
+        TableInfoHelper.initTableInfo(assistant, HealthDiseaseRisk.class);
+    }
+
+    @BeforeEach
+    void setUp() throws Exception {
+        // ============ service 层:真实 HealthReportService + mock 依赖 ============
+        service = new HealthReportService();
+        injectAllDependencies(service);
+        healthReportMapper = (HealthReportMapper) readField(service, "healthReportMapper");
+        healthIndicatorMapper = (HealthIndicatorMapper) readField(service, "healthIndicatorMapper");
+        healthGutFloraMapper = (HealthGutFloraMapper) readField(service, "healthGutFloraMapper");
+        healthDiseaseRiskMapper = (HealthDiseaseRiskMapper) readField(service, "healthDiseaseRiskMapper");
+        reportBlockService = (ReportBlockService) readField(service, "reportBlockService");
+        objectMapper = (com.fasterxml.jackson.databind.ObjectMapper) readField(service, "objectMapper");
+
+        // ============ controller 层:真实 HealthReportController + 仅注入 getReportDetail 依赖 ============
+        // 注意:不用全字段反射注入——控制器含 @Value String 字段(uploadBaseDir/langgraphBaseUrl),
+        // Mockito 无法 mock final 类 String;只注入该 handler 触及的两个 @Resource 依赖即可。
+        controller = new HealthReportController();
+        healthReportServiceMock = mock(HealthReportService.class);
+        permissionServiceMock = mock(ReportMemberPermissionService.class);
+        inject(controller, "healthReportService", healthReportServiceMock);
+        inject(controller, "reportMemberPermissionService", permissionServiceMock);
+    }
+
+    // ==================== 控制器契约 · 正常流(API_REFERENCE §4.59.2) ====================
+
+    @Test
+    @DisplayName("正常流-1:管理员/被授权成员查看报告 → Result.success(完整详情 Map)")
+    void controller_正常流_管理员查看报告_返回完整详情() {
+        when(healthReportServiceMock.getReportById(1001L))
+                .thenReturn(report(1001L, SUBJECT_MEMBER_ID, 999L, FAMILY_ID));
+        when(permissionServiceMock.canViewMemberReports(VIEWER_USER_ID, SUBJECT_MEMBER_ID, FAMILY_ID))
+                .thenReturn(true);
+        Map<String, Object> detail = detailMap();
+        when(healthReportServiceMock.getReportDetail(1001L)).thenReturn(detail);
+
+        Result<Map<String, Object>> result = controller.getReportDetail(paramsOf(1001L), VIEWER_USER_ID, FAMILY_ID);
+
+        assertEquals(Integer.valueOf(200), result.getCode(), "契约:查看有权限的报告应返回 code=200");
+        assertEquals("success", result.getMessage(), "契约:成功响应 message=success");
+        assertNotNull(result.getData(), "契约:data 必须为报告详情 Map");
+        assertSame(detail, result.getData(), "契约:data 必须是 Service 组装的完整详情");
+        verify(healthReportServiceMock).getReportDetail(1001L);
+    }
+
+    @Test
+    @DisplayName("正常流-2:本人查看无归属报告(subjectId=null)→ 放行,不触发家庭管理员判定")
+    void controller_正常流_本人查看无归属报告_返回详情() {
+        when(healthReportServiceMock.getReportById(1002L))
+                .thenReturn(report(1002L, null, VIEWER_USER_ID, FAMILY_ID));
+        when(healthReportServiceMock.getReportDetail(1002L)).thenReturn(detailMap());
+
+        Result<Map<String, Object>> result = controller.getReportDetail(paramsOf(1002L), VIEWER_USER_ID, FAMILY_ID);
+
+        assertEquals(Integer.valueOf(200), result.getCode(), "契约:本人查看自己上传的无归属报告应放行");
+        verify(healthReportServiceMock).getReportDetail(1002L);
+        verify(permissionServiceMock, never()).isFamilyAdmin(anyLong(), anyLong());
+    }
+
+    @Test
+    @DisplayName("正常流-3:被授权成员查看 → canViewMemberReports=true 放行")
+    void controller_正常流_被授权成员查看_返回详情() {
+        when(healthReportServiceMock.getReportById(1003L))
+                .thenReturn(report(1003L, SUBJECT_MEMBER_ID, 999L, FAMILY_ID));
+        when(permissionServiceMock.canViewMemberReports(VIEWER_USER_ID, SUBJECT_MEMBER_ID, FAMILY_ID))
+                .thenReturn(true);
+        when(healthReportServiceMock.getReportDetail(1003L)).thenReturn(detailMap());
+
+        Result<Map<String, Object>> result = controller.getReportDetail(paramsOf(1003L), VIEWER_USER_ID, FAMILY_ID);
+
+        assertEquals(Integer.valueOf(200), result.getCode(), "契约:被授权成员应可见该报告详情");
+        verify(permissionServiceMock).canViewMemberReports(VIEWER_USER_ID, SUBJECT_MEMBER_ID, FAMILY_ID);
+    }
+
+    @Test
+    @DisplayName("正常流-4:报告自带 family_id、familyId 属性缺省 → 以报告家庭为权限作用域")
+    void controller_正常流_报告自带familyId_属性缺省_以报告家庭为作用域() {
+        Long reportFamily = 5L;
+        when(healthReportServiceMock.getReportById(1004L))
+                .thenReturn(report(1004L, SUBJECT_MEMBER_ID, 999L, reportFamily));
+        when(permissionServiceMock.canViewMemberReports(VIEWER_USER_ID, SUBJECT_MEMBER_ID, reportFamily))
+                .thenReturn(true);
+        when(healthReportServiceMock.getReportDetail(1004L)).thenReturn(detailMap());
+
+        // @RequestAttribute familyId = null(如跨家庭 token / 取不到上下文)→ 回退报告自身 family_id
+        Result<Map<String, Object>> result = controller.getReportDetail(paramsOf(1004L), VIEWER_USER_ID, null);
+
+        assertEquals(Integer.valueOf(200), result.getCode(), "契约:报告自带 family_id 时应以其为作用域放行");
+        verify(permissionServiceMock).canViewMemberReports(VIEWER_USER_ID, SUBJECT_MEMBER_ID, reportFamily);
+    }
+
+    // ==================== 控制器契约 · 异常流 ====================
+
+    @Test
+    @DisplayName("异常流-1:reportId 缺失 → 「reportId不能为空」,不触达 Service")
+    void controller_异常流_reportId缺失_返回reportId不能为空() {
+        Result<Map<String, Object>> result = controller.getReportDetail(new HashMap<String, Object>(), VIEWER_USER_ID, FAMILY_ID);
+
+        assertEquals("reportId不能为空", result.getMessage(), "契约:未传 reportId 必须提示,不得静默查询");
+        verify(healthReportServiceMock, never()).getReportById(anyLong());
+        verify(healthReportServiceMock, never()).getReportDetail(anyLong());
+    }
+
+    @Test
+    @DisplayName("异常流-2:reportId 非数字 → 视为未传,返回「reportId不能为空」")
+    void controller_异常流_reportId非数字_视为未传() {
+        Result<Map<String, Object>> result = controller.getReportDetail(paramsOf("abc"), VIEWER_USER_ID, FAMILY_ID);
+
+        assertEquals("reportId不能为空", result.getMessage(), "契约:非数字 reportId 应安全解析为未传");
+        verify(healthReportServiceMock, never()).getReportById(anyLong());
+    }
+
+    @Test
+    @DisplayName("异常流-3:报告不存在 → 「报告不存在」,不组装详情")
+    void controller_异常流_报告不存在_返回报告不存在() {
+        when(healthReportServiceMock.getReportById(9999L)).thenReturn(null);
+
+        Result<Map<String, Object>> result = controller.getReportDetail(paramsOf(9999L), VIEWER_USER_ID, FAMILY_ID);
+
+        assertEquals("报告不存在", result.getMessage(), "契约:报告不存在必须明确提示");
+        verify(healthReportServiceMock, never()).getReportDetail(anyLong());
+    }
+
+    @Test
+    @DisplayName("异常流-4:无归属报告且非本人非管理员 → 「无权查看该报告」")
+    void controller_异常流_无归属报告非本人非管理员_无权查看() {
+        when(healthReportServiceMock.getReportById(1005L))
+                .thenReturn(report(1005L, null, 99L, FAMILY_ID)); // 上传者 99 ≠ 查看者 7
+        when(permissionServiceMock.isFamilyAdmin(VIEWER_USER_ID, FAMILY_ID)).thenReturn(false);
+
+        Result<Map<String, Object>> result = controller.getReportDetail(paramsOf(1005L), VIEWER_USER_ID, FAMILY_ID);
+
+        assertEquals("无权查看该报告", result.getMessage(), "契约:无归属报告非本人非管理员必须拒绝");
+        verify(healthReportServiceMock, never()).getReportDetail(anyLong());
+    }
+
+    @Test
+    @DisplayName("异常流-5:有归属但无授权关系 → 「无权查看该报告」")
+    void controller_异常流_有归属无授权关系_无权查看() {
+        when(healthReportServiceMock.getReportById(1006L))
+                .thenReturn(report(1006L, SUBJECT_MEMBER_ID, 99L, FAMILY_ID));
+        when(permissionServiceMock.canViewMemberReports(VIEWER_USER_ID, SUBJECT_MEMBER_ID, FAMILY_ID))
+                .thenReturn(false);
+
+        Result<Map<String, Object>> result = controller.getReportDetail(paramsOf(1006L), VIEWER_USER_ID, FAMILY_ID);
+
+        assertEquals("无权查看该报告", result.getMessage(), "契约:无本人/管理员/被授权关系必须拒绝");
+        verify(healthReportServiceMock, never()).getReportDetail(anyLong());
+    }
+
+    @Test
+    @DisplayName("异常流-6:报告无家庭且 familyId 属性缺省 → 「无权查看该报告」(无作用域即拒绝)")
+    void controller_异常流_报告无家庭且属性缺省_无权查看() {
+        when(healthReportServiceMock.getReportById(1007L))
+                .thenReturn(report(1007L, SUBJECT_MEMBER_ID, VIEWER_USER_ID, null));
+
+        Result<Map<String, Object>> result = controller.getReportDetail(paramsOf(1007L), VIEWER_USER_ID, null);
+
+        assertEquals("无权查看该报告", result.getMessage(), "契约:无家庭作用域必须拒绝,不泄露数据");
+        verify(healthReportServiceMock, never()).getReportDetail(anyLong());
+    }
+
+    // ==================== Service 内容契约(响应 Map 组成) ====================
+
+    @Test
+    @DisplayName("内容契约-1:详情 Map 必须含 report/indicators/gutFlora/diseaseRisks/blocks")
+    void service_返回报告_指标明细_菌群数据_疾病风险_展示块() {
+        HealthReport report = report(1001L, SUBJECT_MEMBER_ID, VIEWER_USER_ID, FAMILY_ID);
+        when(healthReportMapper.selectById(1001L)).thenReturn(report);
+        when(healthIndicatorMapper.selectList(any())).thenReturn(Collections.singletonList(new HealthIndicator()));
+        when(healthGutFloraMapper.selectList(any())).thenReturn(Collections.singletonList(new HealthGutFlora()));
+        when(healthDiseaseRiskMapper.selectList(any())).thenReturn(Collections.singletonList(new HealthDiseaseRisk()));
+        when(reportBlockService.getBlocks(report.getReportType(), 1001L)).thenReturn(new ArrayList<>());
+
+        Map<String, Object> detail = service.getReportDetail(1001L);
+
+        assertNotNull(detail.get("report"), "契约:详情必须包含 report 实体");
+        assertNotNull(detail.get("indicators"), "契约:详情必须包含指标明细 indicators");
+        assertEquals(1, ((List<?>) detail.get("indicators")).size(), "指标明细应原样透传");
+        assertNotNull(detail.get("gutFlora"), "契约:详情必须包含菌群数据 gutFlora");
+        assertEquals(1, ((List<?>) detail.get("gutFlora")).size(), "菌群数据应原样透传");
+        assertNotNull(detail.get("diseaseRisks"), "契约:详情必须包含疾病风险评估 diseaseRisks");
+        assertEquals(1, ((List<?>) detail.get("diseaseRisks")).size(), "疾病风险应原样透传");
+        assertNotNull(detail.get("blocks"), "契约:详情必须包含通用展示块 blocks");
+    }
+
+    @Test
+    @DisplayName("内容契约-2:有肠道年龄/类型 → 生成 reportInterpretation.gutAgeAssessment")
+    void service_有肠道年龄_生成报告解读() {
+        HealthReport report = report(1001L, SUBJECT_MEMBER_ID, VIEWER_USER_ID, FAMILY_ID);
+        report.setGutAge("28");
+        report.setGutType("平衡型");
+        when(healthReportMapper.selectById(1001L)).thenReturn(report);
+
+        Map<String, Object> detail = service.getReportDetail(1001L);
+
+        @SuppressWarnings("unchecked")
+        Map<String, Object> interp = (Map<String, Object>) detail.get("reportInterpretation");
+        assertNotNull(interp, "契约:有肠道年龄/类型时必须生成 reportInterpretation");
+        String text = (String) interp.get("gutAgeAssessment");
+        assertNotNull(text, "契约:reportInterpretation.gutAgeAssessment 不得为空");
+        assertTrue(text.contains("28"), "解读文本应包含肠道年龄, 实际: " + text);
+    }
+
+    @Test
+    @DisplayName("内容契约-3:有 payload_json → 解析出嵌套 payload(前端取 payload.summary 补全 gender/age)")
+    void service_有payloadJson_解析为payload() throws Exception {
+        HealthReport report = report(1001L, SUBJECT_MEMBER_ID, VIEWER_USER_ID, FAMILY_ID);
+        report.setPayloadJson("{\"summary\":{\"personName\":\"小李\"}}");
+        when(healthReportMapper.selectById(1001L)).thenReturn(report);
+
+        ParsedReportPayload.Payload payload = new ParsedReportPayload.Payload();
+        ParsedReportPayload.Summary summary = new ParsedReportPayload.Summary();
+        summary.setPersonName("小李");
+        payload.setSummary(summary);
+        when(objectMapper.readValue(anyString(), eq(ParsedReportPayload.Payload.class))).thenReturn(payload);
+
+        Map<String, Object> detail = service.getReportDetail(1001L);
+
+        assertSame(payload, detail.get("payload"), "契约:payload_json 必须解析为嵌套 payload 结构");
+    }
+
+    // ==================== 辅助构造 ====================
+
+    private HealthReport report(Long id, Long subjectId, Long uploaderUserId, Long familyId) {
+        HealthReport r = new HealthReport();
+        r.setId(id);
+        r.setSubjectId(subjectId);
+        r.setUserId(uploaderUserId);
+        r.setFamilyId(familyId);
+        r.setReportType("gut_flora");
+        return r;
+    }
+
+    private Map<String, Object> paramsOf(Object reportId) {
+        Map<String, Object> p = new HashMap<>();
+        p.put("reportId", reportId);
+        return p;
+    }
+
+    private Map<String, Object> detailMap() {
+        Map<String, Object> d = new HashMap<>();
+        d.put("report", new HealthReport());
+        d.put("indicators", new ArrayList<>());
+        d.put("gutFlora", new ArrayList<>());
+        d.put("diseaseRisks", new ArrayList<>());
+        return d;
+    }
+
+    private Object readField(Object target, String name) throws Exception {
+        Field f = target.getClass().getDeclaredField(name);
+        f.setAccessible(true);
+        return f.get(target);
+    }
+
+    private static void inject(Object target, String field, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(field);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+
+    private static void injectAllDependencies(Object target) throws Exception {
+        for (Field f : target.getClass().getDeclaredFields()) {
+            if (Modifier.isStatic(f.getModifiers())) {
+                continue; // 跳过 log 等 static final
+            }
+            f.setAccessible(true);
+            f.set(target, mock(f.getType()));
+        }
+    }
+}

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