Bläddra i källkod

test(contract): 能力键 teacher.report.proxy 契约级单测(S-cfc-182)

SC-T-03 代传报告+确认入库 / SC-T-23 报告代传 三类流契约锚点:
- 正常流:端点契约(/report/proxy-parse、/proxy-confirm、/reassign、/transfer/* 7端点 @PostMapping)、
  isProxyUploader 角色闸门(teacher/admin/approved 营养师/approved 规划师)、canProxyForFamily 绑定家庭放行、
  auditConfirm 确认入库后报告 family_id/subjectId 归属落库(TR-13 §5 第4条)
- 异常流:非绑定家庭拒(SC-T-23 边界)、缺 draftId 拒绝不入库、discarded 草稿不可确认(SC-T-03 边界识别失败进草稿)
- 权限流:非授权角色拒绝、非创建者非 admin 确认他人草稿拒绝、subjectId 跨家庭拒绝(无跨家庭越权)、admin 豁免

实测:origin/cfclub 该能力链已完整落地,14 用例全绿(意外全绿,依任务注明交编排侧转测试登记)。
纯 Mockito+反射,无 Spring/DB/外部服务。未改动 src/main。
Sisyphus Agent 8 timmar sedan
förälder
incheckning
5d67a62f6

+ 428 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/TeacherReportProxyContractTest.java

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