feat(p3.1): 应用内审批后端代理——SvcApprovalClient + /api/apply/approval/ 7 端点
build / build-push (push) Successful in 54s

- SvcBizTypes:bizType 终值/存量单一取值点(svc-hall=H-1 实核 appKey=100008 注册行 app_code;
  svc_item=存量,双值双查先新后旧——方案B,P3.2 落地前的读面兼容口径)
- SvcApprovalClient(沿 NoticeApprovalClient 形态,YschOpenCall 服务目录命名目标):
  todo/byBiz 双值双查合并、approve/reject 写面、embed 记录页 instanceId 形态、taskPageUrl
- SvcApprovalController:Todo/ByBiz/RecordUrl/TaskPageUrl/Approve/Reject/My
  · 定位链=requireOperator→findByIdAndSchId(会话校)→ApplyViewGuard→byBiz 归属预检(P1-01)
  · 身份三字段只取 CAS 会话,请求参数出现身份字段一律拒(P1-10 择一取严)
  · 写面参数形态与中心同构(taskId/applyId 查询参数,opinion/edgeName JSON body)
  · My 端点复用本地 myApplies + FlowPage 信封显式映射(total/list→total/content+页元数据)
  · 不代理 /oapi/flow/instances(全校视角);本地 flow_status 仍只由回调推进
- 测试 30 例全绿(client 10 + MockMvc standalone 20,显式装 BoaUserArgumentResolver/
  GlobalExceptionHandler/ApiResponseAdvice;真实 CasSsoApiRedirectFilter 过滤链未登录=200+code302;
  拒绝路径断言审批出站=0);全量回归 157/157 绿(Stage S2/P3.1)
This commit is contained in:
chenxing
2026-10-19 23:41:20 +08:00
parent fb4bef9d34
commit ed3c9e6a72
5 changed files with 1133 additions and 0 deletions
@@ -0,0 +1,429 @@
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 <b>显式装配</b>
* {@link BoaUserArgumentResolver}(身份只来自 {@link BoaUserContext})与
* {@link GlobalExceptionHandler}(R3.0 P1-04:范本 NoticeApprovalControllerTest 是方法单测,
* 证明不了解析器/advice 装配)。
* <p>
* 断言口径分开:HTTP 状态恒 200(统一信封);未登录=业务码 3(FORBIDDEN);
* 无学校=业务码 2(PARAM);<b>拒绝路径审批出站必须为 0</b>(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.<Integer>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<String, Object> byBizTasks(Long instanceId, Map<String, Object>... tasks) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("instanceId", instanceId);
m.put("tasks", java.util.Arrays.asList(tasks));
return m;
}
private static Map<String, Object> task(int taskId) {
Map<String, Object> t = new LinkedHashMap<>();
t.put("taskId", taskId);
t.put("nodeName", "部门负责人");
return t;
}
private static Map<String, Object> row(int id) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("id", id);
return m;
}
private static Map<String, Object> pair(Object value) {
Map<String, Object> 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<String, Object> pageUrl(String url) {
Map<String, Object> 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<String, Object> data = new LinkedHashMap<>();
data.put("total", 0);
data.put("content", new java.util.ArrayList<Object>());
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("My:{total,list} → FlowPage{total,content,pageIndex,pageSize,totalPages,hasNext} 显式映射")
void myEnvelopeMapping() throws Exception {
BoaUserContext.set(user(10006, 3));
Map<String, Object> 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(平台口径,<b>不是</b> 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);
}
}