|
|
@@ -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());
|
|
|
+ }
|
|
|
+}
|