|
@@ -0,0 +1,428 @@
|
|
|
|
|
+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.FamilyMember;
|
|
|
|
|
+import com.etotem.cfc.entity.HealthReport;
|
|
|
|
|
+import com.etotem.cfc.entity.HealthReportDraft;
|
|
|
|
|
+import com.etotem.cfc.entity.User;
|
|
|
|
|
+import com.etotem.cfc.mapper.FamilyMemberMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.HealthReportDraftMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.HealthReportMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.UserMapper;
|
|
|
|
|
+import com.etotem.cfc.service.HealthReportService;
|
|
|
|
|
+import com.etotem.cfc.service.UserService;
|
|
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
|
|
+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 org.mockito.ArgumentCaptor;
|
|
|
|
|
+import org.springframework.test.util.ReflectionTestUtils;
|
|
|
|
|
+import org.springframework.web.bind.annotation.PostMapping;
|
|
|
|
|
+import org.springframework.web.bind.annotation.RequestMapping;
|
|
|
|
|
+
|
|
|
|
|
+import java.lang.reflect.Field;
|
|
|
|
|
+import java.lang.reflect.Method;
|
|
|
|
|
+import java.lang.reflect.Modifier;
|
|
|
|
|
+import java.util.Arrays;
|
|
|
|
|
+import java.util.Collections;
|
|
|
|
|
+import java.util.List;
|
|
|
|
|
+import java.util.Map;
|
|
|
|
|
+
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
|
|
|
+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.doAnswer;
|
|
|
|
|
+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 teacher.report.proxy}(SC-T-03 代传报告+确认入库 ⭐ / SC-T-23 报告代传 ⭐)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>契约来源</b></p>
|
|
|
|
|
+ * <ul>
|
|
|
|
|
+ * <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-T-03(上传 PDF+选孩子 → 上传/预览/确认 → 入孩子档案;
|
|
|
|
|
+ * 边界:识别失败进草稿;确认前可取消不入库)、SC-T-23(上传 PDF+选孩子 → 解析 → 关联孩子档案;
|
|
|
|
|
+ * 边界:非绑定家庭拒;识别失败进草稿);</li>
|
|
|
|
|
+ * <li>设计:{@code docs/superpowers/specs/2026-11-07-planner-zone-contract.md}(EVD-10 §2 SC-T-03/T-23 端点核验表、
|
|
|
|
|
+ * §5 转测试侧 TR-13 第 4 条「代传归属——/report/proxy-parse + /report/proxy-confirm 代传代确认后报告归属
|
|
|
|
|
+ * family_id/member_id 正确落库,无跨家庭越权」);{@code docs/superpowers/api/API_REFERENCE.md} §4.57。</li>
|
|
|
|
|
+ * </ul>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>端点载体(SC-T-03/T-23 主载体)</b>:{@link HealthReportController} {@code /api/health/report/*}:
|
|
|
|
|
+ * {@code proxy-parse}、{@code proxy-confirm}、{@code reassign}、{@code transfer/request}、{@code transfer/approve}、
|
|
|
|
|
+ * {@code transfer/deny}、{@code transfer/list}。</p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>实现现状(S-cfc-182 实测)</b>:该能力链在 origin/cfclub 已完整落地——proxy-parse 含角色闸门
|
|
|
|
|
+ * {@code isProxyUploader}(admin/teacher/approved 营养师/approved 规划师)+ 家庭绑定校验 {@code canProxyForFamily}
|
|
|
|
|
+ * + 草稿两阶段(解析失败 markDiscarded 不入库);proxy-confirm 经 {@code HealthReportService#auditConfirm}
|
|
|
|
|
+ * 校验草稿归属与 subjectId 跨家庭;transfer/* 与 reassign 路由齐备。既有测试
|
|
|
|
|
+ * {@code HealthReportControllerProxyParseBindingTest}(B2-2 绑定校验)、{@code HealthReportAuditContractTest}
|
|
|
|
|
+ * (auditConfirm 全流)、{@code ReportTransferContractTest}(transfer/*)已有覆盖。故本文件以
|
|
|
|
|
+ * {@code teacher.report.proxy} 能力键视角补齐 SC-T-03/23 三类流契约锚点,<b>预计全绿</b>——
|
|
|
|
|
+ * 依任务 S-cfc-182 执行要求「若实测发现实现已存在(测试意外全绿),在汇报中注明并交编排侧转测试登记」,
|
|
|
|
|
+ * 本文件作为该键契约测试登记入库;若未来行为回退,用例即转红暴露。</p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>测试风格</b>:纯 Mockito + 反射,无 Spring 上下文 / 无 DB / 无外部服务(与同批 RED 契约测试一致)。</p>
|
|
|
|
|
+ */
|
|
|
|
|
+class TeacherReportProxyContractTest {
|
|
|
|
|
+
|
|
|
|
|
+ private static final String CONTROLLER_FQCN = "com.etotem.cfc.controller.HealthReportController";
|
|
|
|
|
+ private static final String CLASS_PREFIX = "/api/health";
|
|
|
|
|
+
|
|
|
|
|
+ /** 端点契约表:方法名 → @PostMapping 路径(SC-T-03/T-23 主载体) */
|
|
|
|
|
+ private static final Object[][] PROXY_ENDPOINT_ROWS = {
|
|
|
|
|
+ {"proxyParsePreview", "/report/proxy-parse"},
|
|
|
|
|
+ {"proxyConfirm", "/report/proxy-confirm"},
|
|
|
|
|
+ {"reassignReport", "/report/reassign"},
|
|
|
|
|
+ {"requestReportTransfer", "/report/transfer/request"},
|
|
|
|
|
+ {"approveReportTransfer", "/report/transfer/approve"},
|
|
|
|
|
+ {"denyReportTransfer", "/report/transfer/deny"},
|
|
|
|
|
+ {"listReportTransfers", "/report/transfer/list"},
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ private HealthReportController controller;
|
|
|
|
|
+ private HealthReportService service;
|
|
|
|
|
+ private HealthReportDraftMapper draftMapper;
|
|
|
|
|
+ private FamilyMemberMapper familyMemberMapper;
|
|
|
|
|
+ private HealthReportMapper healthReportMapper;
|
|
|
|
|
+ private ObjectMapper objectMapper;
|
|
|
|
|
+
|
|
|
|
|
+ @BeforeAll
|
|
|
|
|
+ static void initMybatisPlusTableInfo() {
|
|
|
|
|
+ // auditConfirm 内部 Lambda wrapper 需要 TableInfo 缓存(与 HealthReportAuditContractTest 一致)
|
|
|
|
|
+ MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
|
|
|
|
|
+ TableInfoHelper.initTableInfo(assistant, HealthReportDraft.class);
|
|
|
|
|
+ TableInfoHelper.initTableInfo(assistant, FamilyMember.class);
|
|
|
|
|
+ TableInfoHelper.initTableInfo(assistant, User.class);
|
|
|
|
|
+ TableInfoHelper.initTableInfo(assistant, HealthReport.class);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @BeforeEach
|
|
|
|
|
+ void setUp() throws Exception {
|
|
|
|
|
+ controller = new HealthReportController();
|
|
|
|
|
+ service = new HealthReportService();
|
|
|
|
|
+ injectAllDependencies(service);
|
|
|
|
|
+ draftMapper = (HealthReportDraftMapper) readField(service, "draftMapper");
|
|
|
|
|
+ familyMemberMapper = (FamilyMemberMapper) readField(service, "familyMemberMapper");
|
|
|
|
|
+ healthReportMapper = (HealthReportMapper) readField(service, "healthReportMapper");
|
|
|
|
|
+ objectMapper = (ObjectMapper) readField(service, "objectMapper");
|
|
|
|
|
+ doAnswer(inv -> {
|
|
|
|
|
+ HealthReport report = inv.getArgument(0);
|
|
|
|
|
+ report.setId(1000L);
|
|
|
|
|
+ return 1;
|
|
|
|
|
+ }).when(healthReportMapper).insert(any(HealthReport.class));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 正常流:端点契约 ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-1:HealthReportController 存在且类级映射前缀为 /api/health")
|
|
|
|
|
+ void controller_classMappingExists() {
|
|
|
|
|
+ Class<?> clazz = loadClass(CONTROLLER_FQCN);
|
|
|
|
|
+ assertNotNull(clazz,
|
|
|
|
|
+ "[teacher.report.proxy] HealthReportController 类不存在(SC-T-03/23 主载体)。");
|
|
|
|
|
+ RequestMapping rm = clazz.getAnnotation(RequestMapping.class);
|
|
|
|
|
+ assertNotNull(rm,
|
|
|
|
|
+ "[teacher.report.proxy] HealthReportController 应声明类级 @RequestMapping 前缀。");
|
|
|
|
|
+ assertTrue(Arrays.asList(rm.value()).contains(CLASS_PREFIX),
|
|
|
|
|
+ "[teacher.report.proxy] 类级前缀应为 " + CLASS_PREFIX + "(端点全路径 = /api/health/report/*)。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-2:代传/转移 7 个端点应存在且均为 @PostMapping")
|
|
|
|
|
+ void proxyEndpoints_existWithPostMapping() {
|
|
|
|
|
+ Class<?> clazz = loadClass(CONTROLLER_FQCN);
|
|
|
|
|
+ assertNotNull(clazz);
|
|
|
|
|
+ for (Object[] row : PROXY_ENDPOINT_ROWS) {
|
|
|
|
|
+ String methodName = (String) row[0];
|
|
|
|
|
+ String path = (String) row[1];
|
|
|
|
|
+ Method m = findDeclaredMethod(clazz, methodName);
|
|
|
|
|
+ assertNotNull(m,
|
|
|
|
|
+ "[teacher.report.proxy] HealthReportController 缺少端点方法 " + methodName + "(SC-T-03/23 主载体)。");
|
|
|
|
|
+ PostMapping pm = m.getAnnotation(PostMapping.class);
|
|
|
|
|
+ assertNotNull(pm,
|
|
|
|
|
+ "[teacher.report.proxy] " + methodName + " 应为 @PostMapping(项目统一 POST 约定)。");
|
|
|
|
|
+ assertEquals(1, pm.value().length, "[teacher.report.proxy] " + methodName + " 应显式声明路径。");
|
|
|
|
|
+ assertEquals(path, pm.value()[0],
|
|
|
|
|
+ "[teacher.report.proxy] " + methodName + " 应映射 " + path + "(SC-T-03/T-23 端点核验表)。");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 正常流:角色闸门(isProxyUploader) ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-3:teacher 角色应具备代传资格")
|
|
|
|
|
+ void isProxyUploader_teacherAllowed() throws Exception {
|
|
|
|
|
+ assertTrue(invokeIsProxyUploader("teacher", null, null),
|
|
|
|
|
+ "[teacher.report.proxy] teacher 角色应可代传报告(SC-T-23 报告代传)。");
|
|
|
|
|
+ assertTrue(invokeIsProxyUploader("parent", Collections.singletonList("teacher"), null),
|
|
|
|
|
+ "[teacher.report.proxy] roles 含 teacher 也应放行。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-4:admin / approved 营养师 / approved 规划师应具备代传资格")
|
|
|
|
|
+ void isProxyUploader_adminAndApprovedProfessionalsAllowed() throws Exception {
|
|
|
|
|
+ assertTrue(invokeIsProxyUploader("admin", null, null),
|
|
|
|
|
+ "[teacher.report.proxy] admin 应可代传任意报告。");
|
|
|
|
|
+ assertTrue(invokeIsProxyUploader("parent", Collections.singletonList("admin"), null),
|
|
|
|
|
+ "[teacher.report.proxy] roles 含 admin 也应放行。");
|
|
|
|
|
+
|
|
|
|
|
+ User approvedNutritionist = new User();
|
|
|
|
|
+ approvedNutritionist.setNutritionistStatus("approved");
|
|
|
|
|
+ assertTrue(invokeIsProxyUploader("nutritionist", null, approvedNutritionist),
|
|
|
|
|
+ "[teacher.report.proxy] approved 营养师(nutritionistStatus=approved)应可代传报告。");
|
|
|
|
|
+
|
|
|
|
|
+ User approvedPlanner = new User();
|
|
|
|
|
+ approvedPlanner.setVendorType("planner");
|
|
|
|
|
+ approvedPlanner.setVendorStatus("approved");
|
|
|
|
|
+ assertTrue(invokeIsProxyUploader("parent", null, approvedPlanner),
|
|
|
|
|
+ "[teacher.report.proxy] approved 规划师(vendorType=planner + vendorStatus=approved)应可代传报告。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 正常流:绑定家庭放行 + 确认归属落库 ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-5:规划师代传绑定家庭应放行")
|
|
|
|
|
+ void canProxyForFamily_boundFamilyAllowed() throws Exception {
|
|
|
|
|
+ UserService us = mock(UserService.class);
|
|
|
|
|
+ ReflectionTestUtils.setField(controller, "userService", us);
|
|
|
|
|
+ when(us.getEffectiveFamilyIds(1L)).thenReturn(Collections.singleton(5L));
|
|
|
|
|
+
|
|
|
|
|
+ assertTrue(invokeCanProxyForFamily(1L, "teacher", Collections.singletonList("teacher"), 5L),
|
|
|
|
|
+ "[teacher.report.proxy] 规划师代传其绑定家庭(guide_families 有效集内)应放行。");
|
|
|
|
|
+ verify(us).getEffectiveFamilyIds(1L);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-6:admin 代传任意家庭应放行(平台角色免绑定校验)")
|
|
|
|
|
+ void canProxyForFamily_adminAllowed() throws Exception {
|
|
|
|
|
+ UserService us = mock(UserService.class);
|
|
|
|
|
+ ReflectionTestUtils.setField(controller, "userService", us);
|
|
|
|
|
+
|
|
|
|
|
+ assertTrue(invokeCanProxyForFamily(1L, "admin", Collections.singletonList("admin"), 999L),
|
|
|
|
|
+ "[teacher.report.proxy] 平台角色(admin)应可代传任意家庭。");
|
|
|
|
|
+ verify(us, never()).getEffectiveFamilyIds(anyLong());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-7:代确认入库后报告归属 family_id/subjectId 落库(TR-13 §5 第4条)")
|
|
|
|
|
+ void auditConfirm_reportBindsToSameFamilyMember() throws Exception {
|
|
|
|
|
+ Long draftId = 1L;
|
|
|
|
|
+ Long subjectId = 55L;
|
|
|
|
|
+ when(draftMapper.selectById(draftId)).thenReturn(pendingDraft(draftId, 7L, 1L));
|
|
|
|
|
+ stubPayloadAndSameFamilySubject(subjectId, 1L);
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> result = service.auditConfirm(draftId, subjectId, 7L, false);
|
|
|
|
|
+
|
|
|
|
|
+ assertNotNull(result);
|
|
|
|
|
+ assertEquals(1000L, (Long) result.get("reportId"));
|
|
|
|
|
+ assertEquals(subjectId, (Long) result.get("subjectId"), "代确认后报告应归属所选家庭成员 subjectId");
|
|
|
|
|
+ assertEquals("confirmed", result.get("status"));
|
|
|
|
|
+
|
|
|
|
|
+ ArgumentCaptor<HealthReport> captor = ArgumentCaptor.forClass(HealthReport.class);
|
|
|
|
|
+ verify(healthReportMapper).insert(captor.capture());
|
|
|
|
|
+ assertEquals(1L, captor.getValue().getFamilyId(),
|
|
|
|
|
+ "代确认入库后报告 family_id 应等于草稿所属家庭(归属正确落库,TR-13 §5 第4条)");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 异常流:边界拒绝 ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("异常流-1:规划师代传非绑定家庭应被拒绝(SC-T-23 边界:非绑定家庭拒)")
|
|
|
|
|
+ void canProxyForFamily_unboundFamilyDenied() throws Exception {
|
|
|
|
|
+ UserService us = mock(UserService.class);
|
|
|
|
|
+ ReflectionTestUtils.setField(controller, "userService", us);
|
|
|
|
|
+ when(us.getEffectiveFamilyIds(1L)).thenReturn(Collections.singleton(5L));
|
|
|
|
|
+
|
|
|
|
|
+ assertFalse(invokeCanProxyForFamily(1L, "teacher", Collections.singletonList("teacher"), 99L),
|
|
|
|
|
+ "[teacher.report.proxy] 规划师代传非绑定家庭必须被拒绝(familyId 不在有效家庭集内)。");
|
|
|
|
|
+ verify(us).getEffectiveFamilyIds(1L);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("异常流-2:代确认缺 draftId 应拒绝不入库(确认前不入库,SC-T-03 边界)")
|
|
|
|
|
+ void proxyConfirm_missingDraftId_returnsError() throws Exception {
|
|
|
|
|
+ UserMapper um = mock(UserMapper.class);
|
|
|
|
|
+ ReflectionTestUtils.setField(controller, "userMapper", um);
|
|
|
|
|
+ when(um.selectById(1L)).thenReturn(new User());
|
|
|
|
|
+
|
|
|
|
|
+ Result<?> result = invokeProxyConfirm(Collections.<String, Object>emptyMap(), 1L, "teacher");
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(500, result.getCode());
|
|
|
|
|
+ assertNotNull(result.getMessage());
|
|
|
|
|
+ assertTrue(result.getMessage().contains("draftId"),
|
|
|
|
|
+ "缺 draftId 应提示 draftId 不能为空(确认动作不落库)");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("异常流-3:识别失败/已取消草稿(discarded)不可确认入库(SC-T-03 边界:识别失败进草稿,确认前不入库)")
|
|
|
|
|
+ void auditConfirm_discardedDraft_notConfirmable() {
|
|
|
|
|
+ when(draftMapper.selectById(1L)).thenReturn(draft(1L, 7L, 1L, "discarded"));
|
|
|
|
|
+
|
|
|
|
|
+ IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
|
|
|
|
+ () -> service.auditConfirm(1L, 55L, 7L, false));
|
|
|
|
|
+ assertTrue(ex.getMessage().contains("不存在") || ex.getMessage().contains("过期"),
|
|
|
|
|
+ "discarded 草稿应等价于不可确认(识别失败/取消后不得入库)");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 权限流:无跨家庭越权(TR-13 §5 第4条) ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("权限流-1:非授权角色(普通家长)代确认应被拒绝")
|
|
|
|
|
+ void proxyConfirm_nonProxyRole_denied() throws Exception {
|
|
|
|
|
+ UserMapper um = mock(UserMapper.class);
|
|
|
|
|
+ ReflectionTestUtils.setField(controller, "userMapper", um);
|
|
|
|
|
+ when(um.selectById(1L)).thenReturn(new User());
|
|
|
|
|
+
|
|
|
|
|
+ Result<?> result = invokeProxyConfirm(Collections.<String, Object>emptyMap(), 1L, "parent");
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(500, result.getCode());
|
|
|
|
|
+ assertNotNull(result.getMessage());
|
|
|
|
|
+ assertTrue(result.getMessage().contains("无权限"),
|
|
|
|
|
+ "非授权角色(parent)代确认必须返回无权限");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("权限流-2:非创建者且非 admin 确认他人代传草稿 → 拒绝(无跨家庭越权)")
|
|
|
|
|
+ void auditConfirm_nonCreatorNonAdmin_denied() {
|
|
|
|
|
+ when(draftMapper.selectById(1L)).thenReturn(pendingDraft(1L, 7L, 1L));
|
|
|
|
|
+
|
|
|
|
|
+ IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
|
|
|
|
+ () -> service.auditConfirm(1L, 55L, 8L, false));
|
|
|
|
|
+ assertTrue(ex.getMessage().contains("无权"),
|
|
|
|
|
+ "非创建者非 admin 确认他人代传草稿必须被拒绝(代传草稿只能由代传人本人或平台角色确认)");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("权限流-3:subjectId 跨家庭 → 拒绝(报告归属不得跨家庭,A 家庭报告不能确认到 B 家庭成员)")
|
|
|
|
|
+ void auditConfirm_crossFamilySubject_denied() {
|
|
|
|
|
+ when(draftMapper.selectById(1L)).thenReturn(pendingDraft(1L, 7L, 1L));
|
|
|
|
|
+ FamilyMember otherFamily = new FamilyMember();
|
|
|
|
|
+ otherFamily.setId(55L);
|
|
|
|
|
+ otherFamily.setFamilyId(2L);
|
|
|
|
|
+ when(familyMemberMapper.selectById(55L)).thenReturn(otherFamily);
|
|
|
|
|
+
|
|
|
|
|
+ IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
|
|
|
|
+ () -> service.auditConfirm(1L, 55L, 7L, false));
|
|
|
|
|
+ assertTrue(ex.getMessage().contains("不属于"),
|
|
|
|
|
+ "跨家庭 subjectId 必须被拒绝(所选成员不属于该草稿所属家庭)");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("权限流-4:admin 可确认任意创建者草稿(平台角色豁免,代传兜底)")
|
|
|
|
|
+ void auditConfirm_adminCanConfirmAnyDraft() throws Exception {
|
|
|
|
|
+ Long draftId = 1L;
|
|
|
|
|
+ Long subjectId = 55L;
|
|
|
|
|
+ when(draftMapper.selectById(draftId)).thenReturn(pendingDraft(draftId, 7L, 1L));
|
|
|
|
|
+ stubPayloadAndSameFamilySubject(subjectId, 1L);
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> result = service.auditConfirm(draftId, subjectId, 99L, true);
|
|
|
|
|
+
|
|
|
|
|
+ assertNotNull(result);
|
|
|
|
|
+ assertEquals("confirmed", result.get("status"),
|
|
|
|
|
+ "平台角色(isAdmin=true)可确认任意创建者的代传草稿");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 反射 / 构造辅助 ====================
|
|
|
|
|
+
|
|
|
|
|
+ private boolean invokeIsProxyUploader(String role, List<String> roles, User user) throws Exception {
|
|
|
|
|
+ Method m = HealthReportController.class.getDeclaredMethod(
|
|
|
|
|
+ "isProxyUploader", String.class, List.class, User.class);
|
|
|
|
|
+ m.setAccessible(true);
|
|
|
|
|
+ return (Boolean) m.invoke(controller, role, roles, user);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private boolean invokeCanProxyForFamily(Long userId, String role, List<String> roles, Long familyId)
|
|
|
|
|
+ throws Exception {
|
|
|
|
|
+ Method m = HealthReportController.class.getDeclaredMethod(
|
|
|
|
|
+ "canProxyForFamily", Long.class, String.class, List.class, Long.class);
|
|
|
|
|
+ m.setAccessible(true);
|
|
|
|
|
+ return (Boolean) m.invoke(controller, userId, role, roles, familyId);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
|
|
+ private Result<Map<String, Object>> invokeProxyConfirm(Map<String, Object> params, Long userId, String role)
|
|
|
|
|
+ throws Exception {
|
|
|
|
|
+ Method m = HealthReportController.class.getDeclaredMethod(
|
|
|
|
|
+ "proxyConfirm", Map.class, Long.class, String.class, List.class);
|
|
|
|
|
+ m.setAccessible(true);
|
|
|
|
|
+ return (Result<Map<String, Object>>) m.invoke(
|
|
|
|
|
+ controller, params, userId, role, Collections.<String>emptyList());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private HealthReportDraft pendingDraft(Long draftId, Long creatorUserId, Long familyId) {
|
|
|
|
|
+ return draft(draftId, creatorUserId, familyId, "pending");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private HealthReportDraft draft(Long draftId, Long creatorUserId, Long familyId, String status) {
|
|
|
|
|
+ HealthReportDraft draft = new HealthReportDraft();
|
|
|
|
|
+ draft.setId(draftId);
|
|
|
|
|
+ draft.setUserId(creatorUserId);
|
|
|
|
|
+ draft.setFamilyId(familyId);
|
|
|
|
|
+ draft.setStatus(status);
|
|
|
|
|
+ draft.setPayloadJson("{\"summary\":{\"personName\":\"小李\"}}");
|
|
|
|
|
+ return draft;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void stubPayloadAndSameFamilySubject(Long subjectId, Long familyId) throws Exception {
|
|
|
|
|
+ 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);
|
|
|
|
|
+
|
|
|
|
|
+ FamilyMember member = new FamilyMember();
|
|
|
|
|
+ member.setId(subjectId);
|
|
|
|
|
+ member.setFamilyId(familyId);
|
|
|
|
|
+ when(familyMemberMapper.selectById(subjectId)).thenReturn(member);
|
|
|
|
|
+ // 绑定后补写时序的读:返回 null 即可让 bind 链路尽早返回
|
|
|
|
|
+ when(healthReportMapper.selectById(1000L)).thenReturn(null);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static Object readField(Object target, String name) throws Exception {
|
|
|
|
|
+ Field f = target.getClass().getDeclaredField(name);
|
|
|
|
|
+ f.setAccessible(true);
|
|
|
|
|
+ return f.get(target);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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()));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static Class<?> loadClass(String name) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
|
|
|
|
|
+ } catch (ClassNotFoundException e) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static Method findDeclaredMethod(Class<?> clazz, String name) {
|
|
|
|
|
+ for (Method m : clazz.getDeclaredMethods()) {
|
|
|
|
|
+ if (m.getName().equals(name)) {
|
|
|
|
|
+ return m;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|