package com.yunhai.svchall.controller.api; import com.yunhai.platform.apistd.spring.GlobalExceptionHandler; import com.yunhai.platform.auth.BoaUserContext; import com.yunhai.platform.auth.spring.BoaUserArgumentResolver; import com.yunhai.platform.auth.spring.CasSsoApiRedirectFilter; import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.svchall.entity.ServiceApply; import com.yunhai.svchall.repository.ServiceApplyRepository; import com.yunhai.svchall.service.ApplyViewGuard; import com.yunhai.svchall.service.FlowClient; import com.yunhai.svchall.service.impl.ServiceApplyService; import com.yunhai.svchall.service.impl.SvcApprovalClient; import org.junit.jupiter.api.AfterEach; 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.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyMap; 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.verifyNoInteractions; import static org.mockito.Mockito.when; 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.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** * P3.1 审批代理三层测试之 MVC 契约层:MockMvc standalone 显式装配 * {@link BoaUserArgumentResolver}(身份只来自 {@link BoaUserContext})与 * {@link GlobalExceptionHandler}(R3.0 P1-04:范本 NoticeApprovalControllerTest 是方法单测, * 证明不了解析器/advice 装配)。 *

* 断言口径分开:HTTP 状态恒 200(统一信封);未登录=业务码 3(FORBIDDEN); * 无学校=业务码 2(PARAM);拒绝路径审批出站必须为 0(verifyNoInteractions)。 * 另有一组挂真实 {@link CasSsoApiRedirectFilter} 的过滤链用例:API 头未登录=HTTP200+code302 * (平台 CAS 口径,非 HTTP 403——R3.0 勘误)。 */ @DisplayName("SvcApprovalController - MVC 契约(双闸/身份注入/信封映射)") class SvcApprovalControllerTest { private SvcApprovalClient approvalClient; private ServiceApplyRepository applyRepository; private ApplyViewGuard applyViewGuard; private ServiceApplyService applyService; private MockMvc mockMvc; @BeforeEach void setUp() { approvalClient = mock(SvcApprovalClient.class); applyRepository = mock(ServiceApplyRepository.class); FlowClient flowClient = mock(FlowClient.class); applyViewGuard = new ApplyViewGuard(flowClient); applyService = mock(ServiceApplyService.class); SvcApprovalController controller = new SvcApprovalController( approvalClient, applyRepository, applyViewGuard, applyService); mockMvc = MockMvcBuilders.standaloneSetup(controller) .setCustomArgumentResolvers(new BoaUserArgumentResolver()) .setControllerAdvice(new GlobalExceptionHandler(null), new com.yunhai.platform.apistd.spring.ApiResponseAdvice()) .build(); } @AfterEach void tearDown() { BoaUserContext.clear(); } /** 深桩用户(沿 ServiceApplyFlowSubmitTest 形态):roleIds 可指定以驱动 Guard 管理员分支 */ private BoaUser user(int id, int schId, Integer... roleIds) { BoaUser user = mock(BoaUser.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); when(user.getId()).thenReturn(id); when(user.getName()).thenReturn("张三"); when(user.getAccount()).thenReturn("zhangsan"); when(user.getCurrentRole().getSchId()).thenReturn(schId); when(user.getRoleIds()).thenReturn(roleIds == null || roleIds.length == 0 ? java.util.Collections.emptyList() : java.util.Arrays.asList(roleIds)); when(user.getType()).thenReturn(null); return user; } private ServiceApply apply(int id, int schId, Integer submitterId, Long instanceId) { ServiceApply apply = new ServiceApply(); org.springframework.test.util.ReflectionTestUtils.setField(apply, "id", id); apply.setSchId((long) schId); apply.setSubmitterId(submitterId); apply.setFlowInstanceId(instanceId); return apply; } private static Map byBizTasks(Long instanceId, Map... tasks) { Map m = new LinkedHashMap<>(); m.put("instanceId", instanceId); m.put("tasks", java.util.Arrays.asList(tasks)); return m; } private static Map task(int taskId) { Map t = new LinkedHashMap<>(); t.put("taskId", taskId); t.put("nodeName", "部门负责人"); return t; } private static Map row(int id) { Map m = new LinkedHashMap<>(); m.put("id", id); return m; } private static Map pair(Object value) { Map m = new LinkedHashMap<>(); m.put("total", 0L); m.put("list", value == null ? new java.util.ArrayList<>() : Collections.singletonList(value)); return m; } /** RecordUrl 换址应答 {pageUrl} */ private static Map pageUrl(String url) { Map m = new LinkedHashMap<>(); m.put("pageUrl", url); return m; } // ===== 身份层 ===== @Test @DisplayName("未登录(无 BoaUser):HTTP200 + 业务码 3,零出站") void todoWithoutLogin() throws Exception { mockMvc.perform(get("/api/apply/approval/Todo")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(3)); verifyNoInteractions(approvalClient); } @Test @DisplayName("会话无学校归属:HTTP200 + 业务码 2,零出站") void noSchoolIdentity() throws Exception { BoaUser user = mock(BoaUser.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); when(user.getId()).thenReturn(10006); when(user.getCurrentRole()).thenReturn(null); BoaUserContext.set(user); mockMvc.perform(get("/api/apply/approval/Todo")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(2)); verifyNoInteractions(approvalClient); } @Test @DisplayName("请求参数携带身份字段(operatorId/schId/…):一律拒绝,零出站") void identityParamsRejected() throws Exception { BoaUserContext.set(user(10006, 3)); mockMvc.perform(get("/api/apply/approval/Todo").param("operatorId", "1")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(2)); mockMvc.perform(get("/api/apply/approval/ByBiz").param("applyId", "42").param("schId", "3")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(2)); mockMvc.perform(post("/api/apply/approval/Approve") .param("applyId", "42").param("taskId", "442").param("operatorName", "李四") .contentType("application/json").content("{}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(2)); verifyNoInteractions(approvalClient, applyRepository); } // ===== Todo / My ===== @Test @DisplayName("Todo:会话身份出站(双值双查在 client),信封透传") void todoHappyPath() throws Exception { BoaUserContext.set(user(10006, 3)); Map data = new LinkedHashMap<>(); data.put("total", 0); data.put("content", new java.util.ArrayList()); when(approvalClient.todoTasks(3, 10006, 2, 10)).thenReturn(data); mockMvc.perform(get("/api/apply/approval/Todo").param("page", "2").param("size", "10")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.total").value(0)); } @Test @DisplayName("Done/Cc(P3.5):会话身份出站,信封透传,身份字段入参拒绝") void doneAndCcEndpoints() throws Exception { BoaUserContext.set(user(10006, 3)); Map data = new LinkedHashMap<>(); data.put("total", 0); data.put("content", new java.util.ArrayList<>()); when(approvalClient.doneTasks(3, 10006, 1, 10)).thenReturn(data); when(approvalClient.ccMine(3, 10006, 1, 10)).thenReturn(data); mockMvc.perform(get("/api/apply/approval/Done")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)); mockMvc.perform(get("/api/apply/approval/Cc")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)); mockMvc.perform(get("/api/apply/approval/Done").param("assigneeId", "1")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(2)); } @Test @DisplayName("My:{total,list} → FlowPage{total,content,pageIndex,pageSize,totalPages,hasNext} 显式映射") void myEnvelopeMapping() throws Exception { BoaUserContext.set(user(10006, 3)); Map local = new LinkedHashMap<>(); local.put("total", 21L); local.put("list", java.util.Arrays.asList(row(42), row(43))); when(applyService.myApplies(any(), any(), eq(3), eq(10))).thenReturn(local); mockMvc.perform(get("/api/apply/approval/My").param("page", "3").param("size", "10")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.total").value(21)) .andExpect(jsonPath("$.data.content.length()").value(2)) .andExpect(jsonPath("$.data.pageIndex").value(3)) .andExpect(jsonPath("$.data.pageSize").value(10)) .andExpect(jsonPath("$.data.totalPages").value(3)) .andExpect(jsonPath("$.data.hasNext").value(false)); verifyNoInteractions(approvalClient); } @Test @DisplayName("My 非法页码/页大小归一(page<1→1,size<1→10,size>100→100)") void myNormalizesPaging() throws Exception { BoaUserContext.set(user(10006, 3)); when(applyService.myApplies(any(), any(), anyInt(), anyInt())).thenReturn(pair(0L)); mockMvc.perform(get("/api/apply/approval/My").param("page", "0").param("size", "500")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.pageIndex").value(1)) .andExpect(jsonPath("$.data.pageSize").value(100)); } // ===== ByBiz 双闸 ===== @Test @DisplayName("ByBiz 跨校/不存在(findByIdAndSchId 空):业务码 2,零审批出站") void byBizCrossSchool() throws Exception { BoaUserContext.set(user(10006, 3)); when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.empty()); mockMvc.perform(get("/api/apply/approval/ByBiz").param("applyId", "42")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(2)); verifyNoInteractions(approvalClient); } @Test @DisplayName("ByBiz 同校陌生人(Guard 拒):业务码 3,零审批出站") void byBizStrangerRejected() throws Exception { BoaUserContext.set(user(10006, 3)); ServiceApply row = apply(42, 3, 20001, 139L); when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row)); // flowClient.enabled()=false → Guard 办理人分支 fail-closed;非提交人/非管理员 → 拒 mockMvc.perform(get("/api/apply/approval/ByBiz").param("applyId", "42")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(3)); verifyNoInteractions(approvalClient); } @Test @DisplayName("ByBiz 管理员(Guard 放行=只读可见):放行查询,出站=byBiz") void byBizAdminReadonly() throws Exception { BoaUserContext.set(user(10006, 3, 7)); ServiceApply row = apply(42, 3, 20001, 139L); when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row)); when(approvalClient.byBiz(3, 10006, "42")).thenReturn(byBizTasks(139L)); mockMvc.perform(get("/api/apply/approval/ByBiz").param("applyId", "42")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.instanceId").value(139)); } // ===== 写面双闸(Approve/Reject/TaskPageUrl) ===== @Test @DisplayName("Approve:归属预检不过(管理员可见但非办理人)→ 业务码 3,approve 零出站") void approveGuardFailsForNonAssigneeAdmin() throws Exception { BoaUserContext.set(user(10006, 3, 7)); ServiceApply row = apply(42, 3, 20001, 139L); when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row)); when(approvalClient.byBiz(3, 10006, "42")).thenReturn(byBizTasks(139L)); // tasks 空=非本人待办 mockMvc.perform(post("/api/apply/approval/Approve") .param("applyId", "42").param("taskId", "442") .contentType("application/json").content("{}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(3)); verify(approvalClient, never()).approve(any(), any(), any(), any(), any(), any()); } @Test @DisplayName("Approve:预检命中本人待办 → approve 一次(opinion/edgeName 从 body)") void approveHappyPath() throws Exception { BoaUserContext.set(user(10006, 3, 7)); ServiceApply row = apply(42, 3, 20001, 139L); when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row)); when(approvalClient.byBiz(3, 10006, "42")).thenReturn(byBizTasks(139L, task(442))); mockMvc.perform(post("/api/apply/approval/Approve") .param("applyId", "42").param("taskId", "442") .contentType("application/json") .content("{\"opinion\":\"同意\",\"edgeName\":\"通过\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)); verify(approvalClient).approve(442L, 3, 10006, "张三", "同意", "通过"); } @Test @DisplayName("Reject:空白 opinion 前置拒(业务码 2),零出站") void rejectBlankOpinion() throws Exception { BoaUserContext.set(user(10006, 3)); mockMvc.perform(post("/api/apply/approval/Reject") .param("applyId", "42").param("taskId", "442") .contentType("application/json").content("{\"opinion\":\" \"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(2)); verifyNoInteractions(approvalClient); } @Test @DisplayName("Reject:空 body/缺 opinion → 业务码 2") void rejectMissingBody() throws Exception { BoaUserContext.set(user(10006, 3)); mockMvc.perform(post("/api/apply/approval/Reject") .param("applyId", "42").param("taskId", "442") .contentType("application/json").content("{}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(2)); verifyNoInteractions(approvalClient); } @Test @DisplayName("Reject:预检+opinion 齐 → reject 一次") void rejectHappyPath() throws Exception { BoaUserContext.set(user(10006, 3)); ServiceApply row = apply(42, 3, 10006, 139L); // 提交人=本人(Guard owner 短路) when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row)); when(approvalClient.byBiz(3, 10006, "42")).thenReturn(byBizTasks(139L, task(442))); mockMvc.perform(post("/api/apply/approval/Reject") .param("applyId", "42").param("taskId", "442") .contentType("application/json").content("{\"opinion\":\"材料不全\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)); verify(approvalClient).reject(442L, 3, 10006, "张三", "材料不全"); } @Test @DisplayName("TaskPageUrl:非本人待办 → 业务码 3,taskPageUrl 零出站") void taskPageUrlNotMyTask() throws Exception { BoaUserContext.set(user(10006, 3)); ServiceApply row = apply(42, 3, 10006, 139L); when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row)); when(approvalClient.byBiz(3, 10006, "42")).thenReturn(byBizTasks(139L, task(999))); mockMvc.perform(get("/api/apply/approval/TaskPageUrl") .param("applyId", "42").param("taskId", "442")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(3)); verify(approvalClient, never()).taskPageUrl(any(), any()); } // ===== RecordUrl ===== @Test @DisplayName("RecordUrl:未进流程(instanceId 空)→ 业务码 2,零出站") void recordUrlNoInstance() throws Exception { BoaUserContext.set(user(10006, 3)); ServiceApply row = apply(42, 3, 10006, null); when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row)); mockMvc.perform(get("/api/apply/approval/RecordUrl").param("applyId", "42")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(2)); verifyNoInteractions(approvalClient); } @Test @DisplayName("RecordUrl:instanceId 形态换址(pageUrl 透传)") void recordUrlHappyPath() throws Exception { BoaUserContext.set(user(10006, 3)); ServiceApply row = apply(42, 3, 10006, 139L); when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row)); when(approvalClient.embedRecordUrl(3, 139L)).thenReturn(pageUrl("/embed/instance?token=x")); mockMvc.perform(get("/api/apply/approval/RecordUrl").param("applyId", "42")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.pageUrl").value("/embed/instance?token=x")); verify(approvalClient).embedRecordUrl(3, 139L); } // ===== 参数形态(与中心端点同构) ===== @Test @DisplayName("缺参 applyId:非 0 业务码(GlobalExceptionHandler 兜底,code=6),零出站") void missingParam() throws Exception { BoaUserContext.set(user(10006, 3)); mockMvc.perform(get("/api/apply/approval/ByBiz")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(6)); verifyNoInteractions(approvalClient); } @Test @DisplayName("非法数字 applyId=abc:非 0 业务码(code=6),零出站") void illegalNumber() throws Exception { BoaUserContext.set(user(10006, 3)); mockMvc.perform(get("/api/apply/approval/ByBiz").param("applyId", "abc")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(6)); verifyNoInteractions(approvalClient); } // ===== 真实 Servlet 过滤链(平台 CAS 口径) ===== /** * 挂真实 {@link CasSsoApiRedirectFilter}(yh-auth starter 同一实现类):API 头(XHR)未登录 * → HTTP 200 + code 302 + urlToRedirectTo(平台口径,不是 HTTP 403——R3.0 P1-04 勘误)。 * 无 API 头的请求过滤器直通(浏览器导航场景走 CAS 重定向链,另由整合测试覆盖)。 */ @Test void realFilterChainUnauthenticatedApiGetsCode302() throws Exception { CasSsoApiRedirectFilter filter = new CasSsoApiRedirectFilter( "X-Requested-With", "XMLHttpRequest", "http://sso.yunhaixingtong.com/cas", "http://localhost"); MockMvc withFilter = MockMvcBuilders.standaloneSetup( new SvcApprovalController(approvalClient, applyRepository, applyViewGuard, applyService)) .setCustomArgumentResolvers(new BoaUserArgumentResolver()) .setControllerAdvice(new GlobalExceptionHandler(null), new com.yunhai.platform.apistd.spring.ApiResponseAdvice()) .addFilter(filter, "/api/*") .build(); withFilter.perform(get("/api/apply/approval/Todo").header("X-Requested-With", "XMLHttpRequest")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(302)) .andExpect(jsonPath("$.urlToRedirectTo").exists()); verifyNoInteractions(approvalClient); } }