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

test(S-cfc-19): 补齐 health.report.upload(HealthDimensionController /api/dimension/upload)契约级单测 21 例全绿,仅测试不改实现

Agnes Agent пре 6 дана
родитељ
комит
c11295c1cd

+ 371 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/HealthDimensionControllerUploadContractTest.java

@@ -0,0 +1,371 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.dto.DimensionUploadVO;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.service.DimensionScoreService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+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.ResultActions;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.math.BigDecimal;
+
+import static org.hamcrest.Matchers.nullValue;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+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.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * TDD 覆盖驱动(S-cfc-19 / 能力键 health.report.upload,原始阶段 GREEN → 补齐契约级单测):
+ * HealthDimensionController POST /api/dimension/upload(手动录入维度评分)HTTP 契约级单测。
+ *
+ * 与既有 {@code integration/controller/HealthDimensionControllerTest} 的区别:后者是
+ * {@code @SpringBootTest} 直接调用 controller 方法(不经 HTTP 层,discovery 记为 direct),
+ * 本类走 MockMvc standalone 真实请求链路,钉住「JSON 绑定 + @RequestAttribute 注入 +
+ * Result 信封序列化 + 路由方法约束」这些控制器契约。
+ *
+ * 溯源说明(务必同步编排侧):
+ * - 任务卡引用「docs/tdd/SRS.md §5.5 health.report.upload」,但当前 checkout 的 SRS.md 只有
+ *   一/二/三/四/五章,无 §5.5,也无 health.report.upload 字样(契约仍为 ⏳未契约化,P0 用例 0)。
+ *   因此本测试的契约以「现有实现 + DimensionUploadVO + Result 信封 + AGENTS.md 约定」为准,
+ *   并在下方逐条记录缺口,不擅自新增规格。
+ * - 能力键 health.report.upload 在 discovery 中是按方法名 upload 命中的;该 handler 实际语义是
+ *   「维度评分录入」,PDF 报告上传对应 HealthReportController POST /api/health/report/upload
+ *   (已由 S-cfc-18 HealthReportControllerUploadTest 覆盖,本类不重复)。
+ *
+ * 契约(HealthDimensionController.upload:118-128):
+ * - 请求:application/json,body 为 DimensionUploadVO(memberId / dimension / score 必填,
+ *   sourceType / confidence / recordDate 可选);userId 由 JwtInterceptor 注入
+ *   @RequestAttribute("userId")(本层只接收、不参与业务判断)。
+ * - 正常路径:三必填齐全 → dimensionScoreService.uploadScore(vo) → Result.success(null),
+ *   envelope code=200 / message="success" / data=null。
+ * - 边界:三个必填任一为 null → Result.error("memberId, dimension, score不能为空"),
+ *   且不得调用 Service(先校验后落库)。
+ * - 角色/权限:本层不读 @RequestAttribute("role"),家长/规划师(teacher)/管理员(admin) 同一路径;
+ *   授权由 JwtInterceptor(有效 token → userId)+ FamilyAccessInterceptor(familyId 归属)承担。
+ * - 路由约定:统一 @PostMapping;GET 同路径必须 405。
+ *
+ * 缺陷观察(只记录、不改 src/main,交编排侧转 GREEN 决策):
+ * D1【无分值区间校验】score 只判 null:-1、1000 均原样进入 Service;Service 侧
+ *   BigDecimal.intValue() 会截断小数(80.9 → 80),区间与精度契约未定义。
+ * D2【无维度白名单】dimension 只判 null:空串 ""、任意未注册维度(如 not_a_dimension)均放行。
+ * D3【无日期格式校验】recordDate 原样透传,非法格式在 Service 的 java.sql.Date.valueOf 抛
+ *   IllegalArgumentException,Controller 层不转 Result.error。
+ * D4【无成员归属校验】body 的 memberId 未与当前 userId 家庭比对(FamilyAccessInterceptor 仅校验
+ *   familyId,不校验 memberId),任意登录用户可对任意 memberId 写评分 → 水平越权风险。
+ * D5【错误码非 4xx】必填缺失返回 Result.error(String) → envelope code=500(B-3 同类口径要求 400),
+ *   HTTP 状态恒 200(项目统一 Result 包装)。
+ * D6【框架级 400/415 无信封】缺 body / JSON 语法错误 / score 非数字 / Content-Type 不支持 /
+ *   缺失 @RequestAttribute("userId")(ServletRequestBindingException)均由 Spring MVC 直接返回
+ *   400/415,无 Result 信封(生产环境另有 GlobalExceptionHandler 归一,但 standalone 不加载,
+ *   此处钉住 MVC 层原始行为)。
+ *
+ * 测试隔离:MockMvc standalone + mock DimensionScoreService / FamilyMemberMapper,
+ * 无 Spring 上下文、无 DB、无微信/LangGraph 等外部服务。
+ */
+public class HealthDimensionControllerUploadContractTest {
+
+    private static final String URL = "/api/dimension/upload";
+    private static final long USER_ID = 1L;
+
+    @Mock
+    private DimensionScoreService dimensionScoreService;
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+
+    private MockMvc mockMvc;
+
+    @BeforeEach
+    public void setUp() {
+        MockitoAnnotations.openMocks(this);
+        HealthDimensionController controller = new HealthDimensionController();
+        ReflectionTestUtils.setField(controller, "dimensionScoreService", dimensionScoreService);
+        ReflectionTestUtils.setField(controller, "familyMemberMapper", familyMemberMapper);
+        mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
+    }
+
+    // ==================== 夹具 ====================
+
+    private static String validBody() {
+        return "{"
+                + "\"memberId\":10,"
+                + "\"dimension\":\"sleep\","
+                + "\"score\":80.5,"
+                + "\"sourceType\":\"VOICE\","
+                + "\"confidence\":0.8,"
+                + "\"recordDate\":\"2026-11-06\""
+                + "}";
+    }
+
+    private ResultActions postJson(String json) throws Exception {
+        return mockMvc.perform(post(URL)
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(json)
+                .requestAttr("userId", USER_ID));
+    }
+
+    private DimensionUploadVO captureUploadedVo() {
+        ArgumentCaptor<DimensionUploadVO> captor = ArgumentCaptor.forClass(DimensionUploadVO.class);
+        verify(dimensionScoreService).uploadScore(captor.capture());
+        return captor.getValue();
+    }
+
+    // ==================== 正常路径 ====================
+
+    @Test
+    public void upload_validPayload_returnsEnvelopeSuccess_andForwardsAllFields() throws Exception {
+        postJson(validBody())
+                .andExpect(status().isOk())
+                .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.message").value("success"))
+                .andExpect(jsonPath("$.data").value(nullValue()));
+
+        DimensionUploadVO forwarded = captureUploadedVo();
+        assertEquals(Long.valueOf(10L), forwarded.getMemberId());
+        assertEquals("sleep", forwarded.getDimension());
+        assertNotNull(forwarded.getScore());
+        assertEquals(0, new BigDecimal("80.5").compareTo(forwarded.getScore()));
+        assertEquals("VOICE", forwarded.getSourceType());
+        assertNotNull(forwarded.getConfidence());
+        assertEquals(0, new BigDecimal("0.8").compareTo(forwarded.getConfidence()));
+        assertEquals("2026-11-06", forwarded.getRecordDate());
+    }
+
+    @Test
+    public void upload_minimalPayload_forwardsNullOptionalFieldsWithoutDefaults() throws Exception {
+        // 可选字段默认值(sourceType=MANUAL / tier / assessDate=今天)由 Service 承担,
+        // Controller 只做透传,不得在 DTO 层补默认值。
+        postJson("{\"memberId\":10,\"dimension\":\"sleep\",\"score\":80}")
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200));
+
+        DimensionUploadVO forwarded = captureUploadedVo();
+        assertNull(forwarded.getSourceType());
+        assertNull(forwarded.getConfidence());
+        assertNull(forwarded.getRecordDate());
+    }
+
+    @Test
+    public void upload_decimalScore_notTruncatedAtController() throws Exception {
+        // 截断只允许发生在 Service(intValue),Controller 必须保留原始精度与 scale
+        postJson("{\"memberId\":10,\"dimension\":\"growth\",\"score\":80.9}")
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200));
+
+        DimensionUploadVO forwarded = captureUploadedVo();
+        assertEquals(0, new BigDecimal("80.9").compareTo(forwarded.getScore()));
+        assertEquals(1, forwarded.getScore().scale());
+    }
+
+    // ==================== 关键边界:必填校验 ====================
+
+    @Test
+    public void upload_missingMemberId_rejectedBeforeServiceCall() throws Exception {
+        postJson("{\"dimension\":\"sleep\",\"score\":80}")
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.message").value("memberId, dimension, score不能为空"));
+
+        verify(dimensionScoreService, never()).uploadScore(any());
+    }
+
+    @Test
+    public void upload_missingDimension_rejectedBeforeServiceCall() throws Exception {
+        postJson("{\"memberId\":10,\"score\":80}")
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.message").value("memberId, dimension, score不能为空"));
+
+        verify(dimensionScoreService, never()).uploadScore(any());
+    }
+
+    @Test
+    public void upload_missingScore_rejectedBeforeServiceCall() throws Exception {
+        postJson("{\"memberId\":10,\"dimension\":\"sleep\"}")
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.message").value("memberId, dimension, score不能为空"));
+
+        verify(dimensionScoreService, never()).uploadScore(any());
+    }
+
+    @Test
+    public void upload_allFieldsAbsent_rejected() throws Exception {
+        postJson("{}")
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.message").value("memberId, dimension, score不能为空"));
+
+        verify(dimensionScoreService, never()).uploadScore(any());
+    }
+
+    @Test
+    public void upload_blankDimensionString_accepted_pinsObservedGap() throws Exception {
+        // D2:dimension 只判 null,空串同样放行入库
+        postJson("{\"memberId\":10,\"dimension\":\"\",\"score\":80}")
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200));
+
+        assertEquals("", captureUploadedVo().getDimension());
+    }
+
+    @Test
+    public void upload_unknownDimension_accepted_pinsObservedGap() throws Exception {
+        // D2:无维度白名单,未注册维度直接入库
+        postJson("{\"memberId\":10,\"dimension\":\"not_a_dimension\",\"score\":80}")
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200));
+
+        assertEquals("not_a_dimension", captureUploadedVo().getDimension());
+    }
+
+    // ==================== 关键边界:分值区间 ====================
+
+    @Test
+    public void upload_zeroScore_acceptedBoundary() throws Exception {
+        postJson("{\"memberId\":10,\"dimension\":\"sleep\",\"score\":0}")
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200));
+
+        assertEquals(0, BigDecimal.ZERO.compareTo(captureUploadedVo().getScore()));
+    }
+
+    @Test
+    public void upload_hundredScore_acceptedBoundary() throws Exception {
+        postJson("{\"memberId\":10,\"dimension\":\"sleep\",\"score\":100}")
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200));
+
+        assertEquals(0, new BigDecimal("100").compareTo(captureUploadedVo().getScore()));
+    }
+
+    @Test
+    public void upload_outOfRangeScores_notRejected_pinsObservedGap() throws Exception {
+        // D1:score 无 [0,100] 区间校验,负数与超上限均原样透传
+        postJson("{\"memberId\":10,\"dimension\":\"sleep\",\"score\":-1}")
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200));
+
+        postJson("{\"memberId\":10,\"dimension\":\"sleep\",\"score\":1000}")
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200));
+
+        verify(dimensionScoreService, times(2)).uploadScore(any());
+    }
+
+    // ==================== 关键边界:请求形态(框架层) ====================
+
+    @Test
+    public void upload_nonNumericScore_rejectedByJackson() throws Exception {
+        postJson("{\"memberId\":10,\"dimension\":\"sleep\",\"score\":\"abc\"}")
+                .andExpect(status().isBadRequest());
+
+        verify(dimensionScoreService, never()).uploadScore(any());
+    }
+
+    @Test
+    public void upload_malformedJson_rejected() throws Exception {
+        postJson("{\"memberId\":10,")
+                .andExpect(status().isBadRequest());
+
+        verify(dimensionScoreService, never()).uploadScore(any());
+    }
+
+    @Test
+    public void upload_missingBody_rejected() throws Exception {
+        mockMvc.perform(post(URL)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .requestAttr("userId", USER_ID))
+                .andExpect(status().isBadRequest());
+
+        verify(dimensionScoreService, never()).uploadScore(any());
+    }
+
+    @Test
+    public void upload_unsupportedContentType_rejected() throws Exception {
+        mockMvc.perform(post(URL)
+                        .contentType(MediaType.TEXT_PLAIN)
+                        .content(validBody())
+                        .requestAttr("userId", USER_ID))
+                .andExpect(status().isUnsupportedMediaType());
+
+        verify(dimensionScoreService, never()).uploadScore(any());
+    }
+
+    @Test
+    public void upload_getMethod_notAllowed() throws Exception {
+        // AGENTS.md:接口统一 @PostMapping,同路径 GET 必须 405
+        mockMvc.perform(get(URL).requestAttr("userId", USER_ID))
+                .andExpect(status().isMethodNotAllowed());
+
+        verify(dimensionScoreService, never()).uploadScore(any());
+    }
+
+    // ==================== 角色 / 权限 ====================
+
+    @Test
+    public void upload_missingUserIdAttribute_failsClosedWithoutServiceCall() throws Exception {
+        // userId 由 JwtInterceptor 注入;缺失即说明未过认证链路(生产环境会被拦截器提前 401)。
+        // MVC 层把缺失的 @RequestAttribute 归为 ServletRequestBindingException → HTTP 400,不落业务。
+        mockMvc.perform(post(URL)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(validBody()))
+                .andExpect(status().isBadRequest());
+
+        verify(dimensionScoreService, never()).uploadScore(any());
+    }
+
+    @Test
+    public void upload_teacherRole_notGatedByRoleAttribute() throws Exception {
+        // 规划师(teacher) 与本层无角色分流,走同一 handler;授权在 JwtInterceptor + 家庭归属层
+        mockMvc.perform(post(URL)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(validBody())
+                        .requestAttr("userId", USER_ID)
+                        .requestAttr("role", "teacher"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200));
+
+        verify(dimensionScoreService).uploadScore(any());
+    }
+
+    @Test
+    public void upload_adminRole_notGatedByRoleAttribute() throws Exception {
+        mockMvc.perform(post(URL)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(validBody())
+                        .requestAttr("userId", USER_ID)
+                        .requestAttr("role", "admin"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200));
+
+        verify(dimensionScoreService).uploadScore(any());
+    }
+
+    @Test
+    public void upload_arbitraryMemberId_noOwnershipCheck_pinsObservedGap() throws Exception {
+        // D4:memberId 取自 body,Controller 未与当前 userId 的家庭做归属比对;
+        // FamilyAccessInterceptor 只校验 body/query/path 中的 familyId,本请求不带 familyId 即直通。
+        postJson("{\"memberId\":999,\"dimension\":\"sleep\",\"score\":80}")
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200));
+
+        assertEquals(Long.valueOf(999L), captureUploadedVo().getMemberId());
+    }
+}