feat: 办事大厅后端源码首次入库(事项/字典/表单/申报 + 流程回调)
build / build-push (push) Failing after 39s

This commit is contained in:
chenxing
2026-09-15 11:00:00 +08:00
parent 1eeb7a4276
commit d47951abd7
70 changed files with 7569 additions and 0 deletions
@@ -0,0 +1,151 @@
package com.yunhai.svchall.config;
import com.yunhai.svchall.service.impl.FlowCallbackService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.mock.web.MockFilterChain;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 回调验签过滤器单测(对齐 Notice FlowCallbackVerifyFilterTest):
* <b>注册探活 ping 直接 200</b>(探活签名是字面量 "ping" 无法验签,flow-center 实测把 5xx 判不可达);
* 缺头/坏时间戳/±5 分钟窗外/无注册/坏签名 → 401;合法签名放行。
*/
class FlowCallbackVerifyFilterTest {
private static final String SECRET = "unit-test-secret";
private static final String BODY =
"{\"eventId\":\"e-9\",\"event\":\"flow.completed\",\"flowGuid\":\"fg-1\",\"schId\":1,"
+ "\"instanceId\":9001,\"bizType\":\"svc_item\",\"bizId\":\"42\",\"instanceVersion\":4}";
private FlowCallbackService callbackService;
private Filter filter;
@BeforeEach
void setUp() {
callbackService = mock(FlowCallbackService.class);
FlowCallbackFilterConfiguration configuration =
new FlowCallbackFilterConfiguration(callbackService, new com.fasterxml.jackson.databind.ObjectMapper());
FilterRegistrationBean<?> bean = configuration.flowCallbackVerifyFilter();
filter = bean.getFilter();
when(callbackService.secretOf("fg-1")).thenReturn(SECRET);
}
@Test
void pingIsAnswered200WithoutVerification() throws Exception {
MockHttpServletResponse response = doFilter(
request("{\"event\":\"ping\",\"flowGuid\":\"fg-1\"}", "1", "n", "bad-signature"), new MockFilterChain());
assertEquals(200, response.getStatus());
assertEquals("{\"ok\":true}", response.getContentAsString());
}
@Test
void pingWithoutHeadersStill200() throws Exception {
MockHttpServletResponse response = doFilter(
request("{\"event\":\"ping\",\"flowGuid\":\"fg-1\"}", null, null, null), new MockFilterChain());
assertEquals(200, response.getStatus());
}
@Test
void missingHeadersIs401() throws Exception {
MockHttpServletResponse response = doFilter(request(BODY, null, null, null), new MockFilterChain());
assertEquals(401, response.getStatus());
}
@Test
void badTimestampIs401() throws Exception {
MockHttpServletResponse response = doFilter(
request(BODY, "not-a-number", "n", "sig"), new MockFilterChain());
assertEquals(401, response.getStatus());
}
@Test
void staleTimestampBeyondFiveMinutesIs401() throws Exception {
String stale = String.valueOf(System.currentTimeMillis() - 6 * 60 * 1000L);
MockHttpServletResponse response = doFilter(
request(BODY, stale, "n", "sig"), new MockFilterChain());
assertEquals(401, response.getStatus());
}
@Test
void unknownRegistrationIs401() throws Exception {
when(callbackService.secretOf("fg-1")).thenReturn(null);
MockHttpServletResponse response = doFilter(signedRequest(SECRET), new MockFilterChain());
assertEquals(401, response.getStatus());
}
@Test
void badSignatureIs401() throws Exception {
MockHttpServletResponse response = doFilter(signedRequest(null), new MockFilterChain());
assertEquals(401, response.getStatus());
}
@Test
void validSignaturePassesThrough() throws Exception {
MockFilterChain chain = new MockFilterChain();
MockHttpServletResponse response = doFilter(signedRequest(SECRET), chain);
assertEquals(200, response.getStatus());
assertNotNull(chain.getRequest(), "合法签名应放行到后续链路");
}
@Test
void nonPostIs405() throws Exception {
MockHttpServletRequest request = request(BODY, "1", "n", "sig");
request.setMethod("GET");
MockHttpServletResponse response = doFilter(request, new MockFilterChain());
assertEquals(405, response.getStatus());
}
// ===== helpers =====
private MockHttpServletRequest request(String body, String ts, String nonce, String signature) {
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/callback/flow");
request.setContent(body.getBytes(StandardCharsets.UTF_8));
if (ts != null) {
request.addHeader("X-Flw-EventId", "e-9");
request.addHeader("X-Flw-Timestamp", ts);
request.addHeader("X-Flw-Nonce", nonce);
request.addHeader("X-Flw-Signature", signature);
}
return request;
}
/** 头齐全、时间戳取当前时刻;secret 为 null 时签名置垃圾值 */
private MockHttpServletRequest signedRequest(String secret) {
String ts = String.valueOf(System.currentTimeMillis());
String signature;
if (secret == null) {
signature = "garbage-signature";
} else {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
signature = Base64.getEncoder().encodeToString(
mac.doFinal((ts + ".nonce-1." + BODY).getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
return request(BODY, ts, "nonce-1", signature);
}
private MockHttpServletResponse doFilter(MockHttpServletRequest request, FilterChain chain) throws Exception {
MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilter(request, response, chain);
return response;
}
}
@@ -0,0 +1,55 @@
package com.yunhai.svchall.controller.callback;
import com.yunhai.svchall.service.impl.FlowCallbackService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
/**
* 回调接收端点返回语义单测(契约冻结 D2,免对账前提):
* 成功/毒消息 → 200(毒消息由 service 静默消化,不抛);
* TransientCallbackException / 意外 RuntimeException → 5xx——flow-center 持续重试直至送达。
*/
class FlowCallbackControllerTest {
private FlowCallbackService callbackService;
private FlowCallbackController controller;
@BeforeEach
void setUp() {
callbackService = mock(FlowCallbackService.class);
controller = new FlowCallbackController(callbackService);
}
@Test
void successReturns200() {
ResponseEntity<String> resp = controller.flow("{\"event\":\"flow.completed\"}".getBytes(StandardCharsets.UTF_8));
assertEquals(HttpStatus.OK, resp.getStatusCode());
assertEquals("{\"ok\":true}", resp.getBody());
}
@Test
void transientFailureReturns5xx() {
doThrow(new FlowCallbackService.TransientCallbackException("db down", new RuntimeException("db down")))
.when(callbackService).process("{\"event\":\"flow.completed\"}");
ResponseEntity<String> resp = controller.flow("{\"event\":\"flow.completed\"}".getBytes(StandardCharsets.UTF_8));
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, resp.getStatusCode());
assertEquals("{\"ok\":false}", resp.getBody());
}
@Test
void unexpectedExceptionAlsoReturns5xx() {
// 意外异常同样按瞬时失败处理(宁可多重试一次,不可丢事件)
doThrow(new IllegalStateException("boom"))
.when(callbackService).process("{\"event\":\"flow.completed\"}");
ResponseEntity<String> resp = controller.flow("{\"event\":\"flow.completed\"}".getBytes(StandardCharsets.UTF_8));
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, resp.getStatusCode());
}
}
@@ -0,0 +1,118 @@
package com.yunhai.svchall.job;
import com.yunhai.svchall.entity.ServiceApply;
import com.yunhai.svchall.enums.ApplyFlowStatusEnum;
import com.yunhai.svchall.repository.ServiceApplyRepository;
import com.yunhai.svchall.service.FlowClient;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.data.domain.Pageable;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
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.when;
/**
* 对账兜底 Job 降级单测(契约冻结"消费方免每日对账"):
* reconcile-enabled=false(默认)时整体不跑——flow-center 持续重试+死信+挂起告警为主链路,
* 本 Job 仅作应急闸(如大面积漂移事故时置 true 手工兜底)。开关开启时保留原漂移修正语义。
*/
class FlowReconcileJobTest {
private ServiceApplyRepository applyRepository;
private FlowClient flowClient;
@BeforeEach
void setUp() {
applyRepository = mock(ServiceApplyRepository.class);
flowClient = mock(FlowClient.class);
}
private ServiceApply apply(int status, Integer lastVersion) {
ServiceApply apply = new ServiceApply();
ReflectionTestUtils.setField(apply, "id", 42);
apply.setSchId(1L);
apply.setFlowInstanceId(9011L);
apply.setFlowStatus(status);
apply.setLastInstanceVersion(lastVersion);
return apply;
}
@Test
void disabledByDefaultJobDoesNothing() {
FlowReconcileJob job = new FlowReconcileJob(applyRepository, flowClient, false);
job.reconcile();
verify(flowClient, never()).enabled();
verify(applyRepository, never()).findByFlowStatus(anyInt(), any(Pageable.class));
}
@Test
void enabledJobFixesDriftWithTerminalFinishTime() {
when(flowClient.enabled()).thenReturn(true);
ServiceApply apply = apply(ApplyFlowStatusEnum.APPROVING.getCode(), 1);
when(applyRepository.findByFlowStatus(eq(ApplyFlowStatusEnum.APPROVING.getCode()), any(Pageable.class)))
.thenReturn(Collections.singletonList(apply));
Map<String, Object> remote = new HashMap<>();
remote.put("status", 20);
remote.put("version", 4);
when(flowClient.getInstanceByBiz(1L, "svc_item", "42")).thenReturn(remote);
new FlowReconcileJob(applyRepository, flowClient, true).reconcile();
assertEquals(ApplyFlowStatusEnum.COMPLETED.getCode(), apply.getFlowStatus());
assertEquals(Integer.valueOf(4), apply.getLastInstanceVersion());
assertNotNull(apply.getFinishTime(), "终态补 finishTime");
verify(applyRepository).save(apply);
}
@Test
void enabledJobSkipsWhenNoDrift() {
when(flowClient.enabled()).thenReturn(true);
ServiceApply apply = apply(ApplyFlowStatusEnum.APPROVING.getCode(), 2);
when(applyRepository.findByFlowStatus(anyInt(), any(Pageable.class)))
.thenReturn(Collections.singletonList(apply));
Map<String, Object> remote = new HashMap<>();
remote.put("status", 10);
remote.put("version", 2);
when(flowClient.getInstanceByBiz(anyLong(), anyString(), anyString())).thenReturn(remote);
new FlowReconcileJob(applyRepository, flowClient, true).reconcile();
verify(applyRepository, never()).save(any());
}
@Test
void enabledJobSkipsWhenFlowCenterDisabled() {
when(flowClient.enabled()).thenReturn(false);
new FlowReconcileJob(applyRepository, flowClient, true).reconcile();
verify(applyRepository, never()).findByFlowStatus(anyInt(), any(Pageable.class));
}
@Test
void enabledJobToleratesPerApplyFailure() {
when(flowClient.enabled()).thenReturn(true);
ServiceApply apply = apply(ApplyFlowStatusEnum.APPROVING.getCode(), 1);
List<ServiceApply> list = Collections.singletonList(apply);
when(applyRepository.findByFlowStatus(anyInt(), any(Pageable.class))).thenReturn(list);
when(flowClient.getInstanceByBiz(anyLong(), anyString(), anyString()))
.thenThrow(new RuntimeException("boom"));
new FlowReconcileJob(applyRepository, flowClient, true).reconcile();
verify(applyRepository, never()).save(any());
}
}
@@ -0,0 +1,135 @@
package com.yunhai.svchall.service;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.svchall.entity.ServiceApply;
import com.yunhai.svchall.service.impl.FlowCallbackService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 办件详情放行守卫单测(BP-R2-3:原先"同校即放行"越权——任意同校登录者可看他人表单与审批意见)。
* 三分支放行:提交人本人 / 管理员(type=1 或 roleId∈{2,5,7})/ 办理人(assigneeIds 实时含当前用户,
* 60s 缓存+fail-closed)。其余同校普通用户 403"查不到"。
*/
@DisplayName("ApplyViewGuard - 办件详情三分支放行(BP-R2-3)")
class ApplyViewGuardTest {
private FlowClient flowClient;
private ApplyViewGuard guard;
private ServiceApply apply;
@BeforeEach
void setUp() {
flowClient = mock(FlowClient.class);
guard = new ApplyViewGuard(flowClient);
when(flowClient.enabled()).thenReturn(true);
apply = new ServiceApply();
ReflectionTestUtils.setField(apply, "id", 42);
apply.setSchId(1L);
apply.setSiteCode("S1");
apply.setSubmitterId(9);
apply.setFlowInstanceId(9001L);
}
private BoaUser user(int id) {
BoaUser u = new BoaUser();
ReflectionTestUtils.setField(u, "id", id);
return u;
}
private BoaUser userWithRoles(int id, Integer type, Integer... roleIds) {
BoaUser u = user(id);
if (type != null) {
ReflectionTestUtils.setField(u, "type", type);
}
if (roleIds != null) {
ReflectionTestUtils.setField(u, "roleIds", Arrays.asList(roleIds));
}
return u;
}
private void assignees(Integer... ids) {
Map<String, Object> instance = new HashMap<>();
instance.put("assigneeIds", Arrays.asList(ids));
when(flowClient.getInstanceByBiz(anyLong(), anyString(), anyString())).thenReturn(instance);
}
@Test
@DisplayName("提交人本人放行(不调流程中心)")
void ownerPasses_withoutFlowCall() {
assertDoesNotThrow(() -> guard.requireVisible(apply, user(9)));
verify(flowClient, times(0)).getInstanceByBiz(anyLong(), anyString(), anyString());
}
@Test
@DisplayName("超管(type=1)放行")
void superAdminPasses() {
assertDoesNotThrow(() -> guard.requireVisible(apply, userWithRoles(77, 1)));
}
@Test
@DisplayName("校级管理员角色白名单 roleId∈{2,5,7} 放行")
void adminRoleWhitelistPasses() {
assertDoesNotThrow(() -> guard.requireVisible(apply, userWithRoles(11, null, 5)));
assertDoesNotThrow(() -> guard.requireVisible(apply, userWithRoles(12, null, 2, 9)));
}
@Test
@DisplayName("办理人(assigneeIds 含当前用户)放行")
void assigneePasses() {
assignees(30, 31);
assertDoesNotThrow(() -> guard.requireVisible(apply, user(30)));
verify(flowClient).getInstanceByBiz(eq(1L), eq(FlowCallbackService.BIZ_TYPE), eq("42"));
}
@Test
@DisplayName("同校普通用户(无管理角色、非办理人)→ 403 查不到语义(越权收紧核心断言)")
void plainSameSchoolUserDenied() {
assignees(30, 31); // 流程中心正常返回,但不含用户 50
BizException e = assertThrows(BizException.class, () -> guard.requireVisible(apply, userWithRoles(50, null, 9)));
org.junit.jupiter.api.Assertions.assertTrue(e.getMessage().contains("办件不存在或无权查看"));
}
@Test
@DisplayName("流程中心不可达 → fail-closed 拒绝(不因异常放行)")
void flowCenterDownFailClosed() {
when(flowClient.getInstanceByBiz(anyLong(), anyString(), anyString()))
.thenThrow(new RuntimeException("connection refused"));
assertThrows(BizException.class, () -> guard.requireVisible(apply, user(50)));
}
@Test
@DisplayName("assigneeIds 60s 缓存:同一办件第二次判定不再出站")
void assigneeQueryCached() {
assignees(30);
assertDoesNotThrow(() -> guard.requireVisible(apply, user(30)));
assertDoesNotThrow(() -> guard.requireVisible(apply, user(30)));
verify(flowClient, times(1)).getInstanceByBiz(anyLong(), anyString(), anyString());
}
@Test
@DisplayName("未提交成功(flowInstanceId 空)非本人/管理员不可见")
void pendingApplyOnlyOwnerAndAdmin() {
apply.setFlowInstanceId(null);
assertThrows(BizException.class, () -> guard.requireVisible(apply, user(30)));
verify(flowClient, times(0)).getInstanceByBiz(anyLong(), anyString(), anyString());
}
}
@@ -0,0 +1,126 @@
package com.yunhai.svchall.service.impl;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.openapi.TargetSpec;
import com.yunhai.platform.openapi.YschOpenCall;
import com.yunhai.svchall.model.svc.SvcModels;
import com.yunhai.svchall.service.SiteDirectory;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
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.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* CMS 站点目录接缝契约测试(P0-1 逃逸根因的回归网:此前两侧契约相反且零测试)。
* 冻结 CMS {@code GET /oapi/site/list} 出参形态(与 CMS 侧 SiteOapiIT 互为咬合哨兵):
* <ul>
* <li>resolveSite:不带 schId 拉全量,data[] 元素键=siteCode/name/schId;</li>
* <li>listSites:透传 schId query;</li>
* <li>fail-closed:CMS 不可达 → resolveSite=null / listSites 抛业务异常。</li>
* </ul>
* 若 CMS 改出参键名/形态,本类与 SiteOapiIT 至少一边红——两仓同步改。
*/
@DisplayName("CmsSiteDirectoryImpl - CMS /oapi/site/list 契约咬合")
class CmsSiteDirectoryImplTest {
private static final String PATH = "/oapi/site/list";
private static final Class<SvcModels.SiteOptionVo> VO_TYPE = SvcModels.SiteOptionVo.class;
private YschOpenCall openCall;
private CmsSiteDirectoryImpl directory;
@BeforeEach
void setUp() {
openCall = mock(YschOpenCall.class);
directory = new CmsSiteDirectoryImpl(openCall, "http://localhost:8072/cms/");
}
private static SvcModels.SiteOptionVo vo(String code, String name, Long schId) {
SvcModels.SiteOptionVo vo = new SvcModels.SiteOptionVo();
vo.setSiteCode(code);
vo.setName(name);
vo.setSchId(schId);
return vo;
}
@Test
@DisplayName("resolveSite:全量拉取(不带 schId)+ 出参三键解析命中 siteCode")
void resolveSite_parsesFrozenShape() {
List<SvcModels.SiteOptionVo> rows = Arrays.asList(vo("S1", "站点一", 1L), vo("S2", "站点二", 2L));
when(openCall.getList(any(TargetSpec.class), eq(PATH), eq(Collections.emptyMap()), eq(VO_TYPE))).thenReturn(rows);
SiteDirectory.SiteInfo site = directory.resolveSite("S1");
assertEquals("S1", site.getSiteCode());
assertEquals("站点一", site.getName());
assertEquals(1L, site.getSchId());
// 全量结果缓存:第二次解析不再出站
directory.resolveSite("S2");
verify(openCall, times(1)).getList(any(TargetSpec.class), eq(PATH), eq(Collections.emptyMap()), eq(VO_TYPE));
}
@Test
@DisplayName("resolveSite:schId 数字串形态(Long 反序列化)")
void resolveSite_parsesNumericSchId() {
when(openCall.getList(any(TargetSpec.class), eq(PATH), eq(Collections.emptyMap()), eq(VO_TYPE)))
.thenReturn(Collections.singletonList(vo("S1", "站点一", 1L)));
assertEquals(1L, directory.resolveSite("S1").getSchId());
}
@Test
@DisplayName("resolveSite:CMS 不可达 → null(fail-closed)")
void resolveSite_failClosedWhenCmsDown() {
when(openCall.getList(any(TargetSpec.class), eq(PATH), eq(Collections.emptyMap()), eq(VO_TYPE)))
.thenThrow(new RuntimeException("connection refused"));
assertNull(directory.resolveSite("S1"));
BizException e = assertThrows(BizException.class, () -> directory.requireSite("S1"));
assertTrue(e.getMessage().contains("站点不存在或站点服务不可达"));
}
@Test
@DisplayName("resolveSite:全量无命中 code → null;空 siteCode → null 不出站")
void resolveSite_missReturnsNull() {
when(openCall.getList(any(TargetSpec.class), eq(PATH), eq(Collections.emptyMap()), eq(VO_TYPE)))
.thenReturn(Collections.singletonList(vo("S1", "站点一", 1L)));
assertNull(directory.resolveSite("NOPE"));
assertNull(directory.resolveSite(" "));
verify(openCall, times(1)).getList(any(TargetSpec.class), eq(PATH), eq(Collections.emptyMap()), eq(VO_TYPE));
}
@Test
@DisplayName("listSites:透传 schId query;不可达抛业务异常")
void listSites_passesSchIdAndFailsClosed() {
when(openCall.getList(any(TargetSpec.class), eq(PATH),
eq(Collections.singletonMap("schId", "1")), eq(VO_TYPE)))
.thenReturn(Collections.singletonList(vo("S1", "站点一", 1L)));
List<SvcModels.SiteOptionVo> sites = directory.listSites(1L);
assertEquals(1, sites.size());
assertEquals("S1", sites.get(0).getSiteCode());
YschOpenCall dead = mock(YschOpenCall.class);
when(dead.getList(any(TargetSpec.class), eq(PATH),
eq(Collections.singletonMap("schId", "2")), eq(VO_TYPE)))
.thenThrow(new RuntimeException("timeout"));
assertThrows(BizException.class,
() -> new CmsSiteDirectoryImpl(dead, "http://localhost:8072/cms").listSites(2L));
}
@Test
@DisplayName("listSites:schId 为空 → PARAM_ERROR")
void listSites_rejectsNullSchId() {
assertThrows(BizException.class, () -> directory.listSites(null));
}
}
@@ -0,0 +1,287 @@
package com.yunhai.svchall.service.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.svchall.entity.ServiceApply;
import com.yunhai.svchall.entity.ServiceFlowEvent;
import com.yunhai.svchall.enums.ApplyFlowStatusEnum;
import com.yunhai.svchall.repository.ServiceApplyRepository;
import com.yunhai.svchall.repository.ServiceFlowEventRepository;
import com.yunhai.svchall.repository.ServiceFlowRegRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Optional;
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.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 回调事件处理单测(对齐 Notice FlowCallbackServiceTest,5xx 语义 D2 核心断言):
* 六事件映射;eventId 重复/并发重复跳过;<b>乱序闸门严格小于(同版本事件不丢)</b>;
* 按 payload.bizId 定位办件(findByIdForUpdate 行锁);毒 JSON/缺字段/非 svc_item/ping 静默;
* DB 异常 → TransientCallbackException(→ controller 回 5xx 触发 flow-center 持续重试)。
*/
class FlowCallbackServiceTest {
private ServiceFlowEventRepository eventRepository;
private ServiceFlowRegRepository regRepository;
private ServiceApplyRepository applyRepository;
private FlowCallbackService service;
private ServiceApply apply;
@BeforeEach
void setUp() {
eventRepository = mock(ServiceFlowEventRepository.class);
regRepository = mock(ServiceFlowRegRepository.class);
applyRepository = mock(ServiceApplyRepository.class);
service = new FlowCallbackService(eventRepository, regRepository, applyRepository, new ObjectMapper());
apply = new ServiceApply();
ReflectionTestUtils.setField(apply, "id", 42);
apply.setSchId(1L);
apply.setItemId(7);
apply.setItemCode("ITEM-1");
apply.setItemTitle("测试事项");
apply.setSubmitterId(9);
apply.setSubmitterName("张三");
apply.setFlowGuid("fg-1");
apply.setFlowStatus(ApplyFlowStatusEnum.APPROVING.getCode());
apply.setLastInstanceVersion(1);
when(applyRepository.findByIdForUpdate(42)).thenReturn(Optional.of(apply));
}
// ===== 六事件映射 =====
@Test
void submitted_taskCreated_taskHandled_mapToApproving() {
for (String event : new String[]{"flow.submitted", "flow.task.created", "flow.task.handled"}) {
ServiceApply a = freshApply();
service.process(body(event, 2));
assertEquals(ApplyFlowStatusEnum.APPROVING.getCode(), a.getFlowStatus(), event);
assertNull(a.getFinishTime(), event + " 非终态不应置 finishTime");
}
}
@Test
void completed_mapsToCompletedAndStampsFinishTime() {
service.process(body("flow.completed", 4));
assertEquals(ApplyFlowStatusEnum.COMPLETED.getCode(), apply.getFlowStatus());
assertNotNull(apply.getFinishTime());
}
@Test
void rejected_mapsToRejectedAndStampsFinishTime() {
service.process(body("flow.rejected", 4));
assertEquals(ApplyFlowStatusEnum.REJECTED.getCode(), apply.getFlowStatus());
assertNotNull(apply.getFinishTime());
}
@Test
void withdrawn_mapsToWithdrawnAndStampsFinishTime() {
service.process(body("flow.withdrawn", 4));
assertEquals(ApplyFlowStatusEnum.WITHDRAWN.getCode(), apply.getFlowStatus());
assertNotNull(apply.getFinishTime());
}
// ===== 乱序闸门:严格小于(<),同版本不丢 =====
@Test
void sameVersionEventIsProcessed_notDiscarded() {
// flow-center 实测同版本多事件(submitted+task.created 同 v1)——旧 <= 闸门会误杀
apply.setLastInstanceVersion(1);
service.process(body("flow.task.created", 1));
assertEquals(ApplyFlowStatusEnum.APPROVING.getCode(), apply.getFlowStatus());
verify(applyRepository).save(apply);
}
@Test
void olderVersionEventIsDiscarded() {
apply.setLastInstanceVersion(3);
service.process(body("flow.completed", 2));
assertEquals(ApplyFlowStatusEnum.APPROVING.getCode(), apply.getFlowStatus());
verify(applyRepository, never()).save(any());
}
@Test
void missingVersionIsProcessedWithoutInventingVersion() {
// version 缺失放行推进,但不伪造 last+1
service.process(bodyWithout("instanceVersion"));
verify(applyRepository).save(apply);
assertEquals(Integer.valueOf(1), apply.getLastInstanceVersion());
}
// ===== eventId 去重(两道:先查后落,uk 兜底并发)=====
@Test
void duplicateEventIdIsSkipped() {
when(eventRepository.existsByEventId("e-1")).thenReturn(true);
service.process(body("flow.completed", 4));
verify(applyRepository, never()).save(any());
assertEquals(ApplyFlowStatusEnum.APPROVING.getCode(), apply.getFlowStatus());
}
@Test
void concurrentDuplicateViaUniqueConstraintIsSkipped() {
doThrow(new DataIntegrityViolationException("uk")).when(eventRepository).saveAndFlush(any(ServiceFlowEvent.class));
service.process(body("flow.completed", 4));
verify(applyRepository, never()).save(any());
assertEquals(ApplyFlowStatusEnum.APPROVING.getCode(), apply.getFlowStatus());
}
// ===== 定位:按 payload.bizId + 行锁;bizType 守卫 =====
@Test
void locateByBizIdWithPessimisticLock() {
service.process(body("flow.completed", 4));
verify(applyRepository).findByIdForUpdate(42);
verify(applyRepository, never()).findBySchIdAndFlowInstanceId(any(), any());
}
@Test
void nonSvcItemBizTypeIsSilentlyDropped() {
service.process(body("flow.completed", 4).replace("\"svc_item\"", "\"notice_item\""));
verify(applyRepository, never()).findByIdForUpdate(any());
verify(applyRepository, never()).save(any());
}
@Test
void unknownBizIdIsSilentlyDropped() {
when(applyRepository.findByIdForUpdate(42)).thenReturn(Optional.empty());
service.process(body("flow.completed", 4));
verify(applyRepository, never()).save(any());
}
@Test
void nonNumericBizIdIsSilentlyDropped() {
service.process(body("flow.completed", 4).replace("\"bizId\":\"42\"", "\"bizId\":\"abc\""));
verify(applyRepository, never()).findByIdForUpdate(any());
}
// ===== 毒消息/未知事件/ping:静默(协议层不可重试,防无限重放)=====
@Test
void poisonJsonIsSilentlyIgnored() {
service.process("not-json{{");
verify(applyRepository, never()).save(any());
}
@Test
void missingRequiredFieldIsSilentlyIgnored() {
service.process(body("flow.completed", 4).replace("\"bizId\":\"42\",", ""));
verify(applyRepository, never()).save(any());
}
@Test
void unknownEventTypeIsSilentlyIgnored() {
service.process(body("flow.handed", 2));
verify(applyRepository, never()).save(any());
}
@Test
void pingEventIsSilentlyIgnored() {
// 过滤器已直通 200,这里兜底防穿透
service.process(body("ping", 0));
verify(applyRepository, never()).findByIdForUpdate(any());
}
// ===== 5xx 语义:DB 瞬时失败 → TransientCallbackException =====
@Test
void transientDbFailureThrowsTransientCallbackException() {
doThrow(new RuntimeException("connection reset")).when(applyRepository).save(any(ServiceApply.class));
assertThrows(FlowCallbackService.TransientCallbackException.class,
() -> service.process(body("flow.completed", 4)));
}
// ===== 终态明细快照 =====
@Test
void terminalEventWritesResultSnapshot() {
service.process(body("flow.completed", 4));
assertNotNull(apply.getFlowResultJson());
assertEquals(ApplyFlowStatusEnum.COMPLETED.getCode(), apply.getFlowStatus());
}
// ===== P1-3 终态不回退守卫:同版本事件完成顺序倒置 =====
@Test
void terminalRow_rejectsNonTerminalEvent_sameVersionInversion() {
// completed(v4) 先落库(20 + result),迟到的 task.handled(v4) 不得打回 10、不得覆盖结果
service.process(body("e-a", "flow.completed", 4));
assertEquals(ApplyFlowStatusEnum.COMPLETED.getCode(), apply.getFlowStatus());
String frozen = apply.getFlowResultJson();
Mockito.clearInvocations(applyRepository);
service.process(body("e-b", "flow.task.handled", 4)); // 不抛 → 200
assertEquals(ApplyFlowStatusEnum.COMPLETED.getCode(), apply.getFlowStatus(), "终态行拒绝非终态事件");
assertEquals(frozen, apply.getFlowResultJson(), "已落定 flow_result_json 不得被 handled 明细覆盖");
verify(applyRepository, never()).save(any());
}
@Test
void terminalRow_rejectsNonTerminalEvent_higherVersion() {
service.process(body("e-c", "flow.rejected", 4));
assertEquals(ApplyFlowStatusEnum.REJECTED.getCode(), apply.getFlowStatus());
service.process(body("e-d", "flow.task.handled", 5));
assertEquals(ApplyFlowStatusEnum.REJECTED.getCode(), apply.getFlowStatus(),
"终态后任何非终态事件都不回退(重提走 resubmit 先置 10,不冲突)");
}
@Test
void rejectedTerminal_frozenResultNotOverwritten_byOtherTerminal() {
service.process(body("e-e", "flow.rejected", 4));
assertEquals(ApplyFlowStatusEnum.REJECTED.getCode(), apply.getFlowStatus());
String frozen = apply.getFlowResultJson();
service.process(body("e-f", "flow.withdrawn", 4));
assertEquals(ApplyFlowStatusEnum.REJECTED.getCode(), apply.getFlowStatus());
assertEquals(frozen, apply.getFlowResultJson(), "已终态不被另一终态改写");
}
// ===== helpers =====
private ServiceApply freshApply() {
ServiceApply a = new ServiceApply();
ReflectionTestUtils.setField(a, "id", 42);
a.setSchId(1L);
a.setItemId(7);
a.setItemCode("ITEM-1");
a.setItemTitle("测试事项");
a.setSubmitterId(9);
a.setSubmitterName("张三");
a.setFlowGuid("fg-1");
a.setFlowStatus(ApplyFlowStatusEnum.APPROVING.getCode());
a.setLastInstanceVersion(1);
when(applyRepository.findByIdForUpdate(42)).thenReturn(Optional.of(a));
return a;
}
private String body(String event, Integer version) {
return body("e-1", event, version);
}
private String body(String eventId, String event, Integer version) {
return "{\"eventId\":\"" + eventId + "\",\"event\":\"" + event + "\",\"schId\":1,\"instanceId\":9001,"
+ "\"bizType\":\"svc_item\",\"bizId\":\"42\",\"flowGuid\":\"fg-1\","
+ "\"instanceVersion\":" + version + ","
+ "\"data\":{\"result\":\"同意\",\"opinion\":\"情况属实\"}}";
}
private String bodyWithout(String field) {
String b = body("flow.task.created", 2);
String needle = "\"" + field + "\":2,";
assertEquals(true, b.contains(needle), "测试体应包含待删字段 " + needle);
return b.replace(needle, "");
}
}
@@ -0,0 +1,302 @@
package com.yunhai.svchall.service.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.svchall.entity.ServiceApply;
import com.yunhai.svchall.entity.ServiceItem;
import com.yunhai.svchall.enums.ApplyFlowStatusEnum;
import com.yunhai.svchall.enums.ServiceItemStatusEnum;
import com.yunhai.svchall.model.svc.SvcModels.ApplySubmitRequest;
import com.yunhai.svchall.model.svc.SvcModels.ApplyVo;
import com.yunhai.svchall.repository.ServiceApplyRepository;
import com.yunhai.svchall.repository.ServiceItemRepository;
import com.yunhai.svchall.service.FlowClient;
import com.yunhai.svchall.service.SiteDirectory;
import com.yunhai.svchall.service.SiteGuard;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.time.LocalDateTime;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.timeout;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 提交链路单测(验收报告 §四.1/§四.2 两大实测坑的回归网):
* 代发起出站不进 DB 事务(AFTER_COMMIT + 独立线程;无活动事务时同步兜底执行);
* 回填走窄列 markApproving(终态不回退由 JPQL 守卫,此处验证调用契约);
* 代发起失败保持 PENDING 可重试;enabled=false 降级;retry 既有规则。
*/
class ServiceApplyFlowSubmitTest {
private ServiceApplyRepository applyRepository;
private ServiceItemRepository itemRepository;
private ServiceFormService formService;
private AttachmentService attachmentService;
private FlowClient flowClient;
private SiteGuard siteGuard;
private SiteDirectory siteDirectory;
private ServiceApplyService service;
private ServiceApply apply;
@BeforeEach
void setUp() {
applyRepository = mock(ServiceApplyRepository.class);
itemRepository = mock(ServiceItemRepository.class);
formService = mock(ServiceFormService.class);
attachmentService = mock(AttachmentService.class);
flowClient = mock(FlowClient.class);
siteGuard = mock(SiteGuard.class);
siteDirectory = mock(SiteDirectory.class);
service = new ServiceApplyService(applyRepository, itemRepository, formService,
attachmentService, flowClient, siteGuard, siteDirectory,
new com.yunhai.svchall.service.ApplyViewGuard(flowClient), new ObjectMapper(),
new org.springframework.transaction.support.TransactionTemplate(
org.mockito.Mockito.mock(org.springframework.transaction.PlatformTransactionManager.class)));
ReflectionTestUtils.setField(service, "frontBaseUrl", "http://localhost:8087");
apply = new ServiceApply();
ReflectionTestUtils.setField(apply, "id", 42);
apply.setSchId(1L);
apply.setSiteCode("S1");
apply.setItemId(7);
apply.setItemCode("ITEM-1");
apply.setItemTitle("测试事项");
apply.setSubmitterId(9);
apply.setSubmitterName("张三");
apply.setFlowGuid("fg-1");
apply.setFlowStatus(ApplyFlowStatusEnum.PENDING.getCode());
apply.setLastInstanceVersion(0);
when(applyRepository.save(any(ServiceApply.class))).thenAnswer(inv -> {
ServiceApply a = inv.getArgument(0);
if (a.getId() == null) {
ReflectionTestUtils.setField(a, "id", 42); // 模拟 JPA persist 赋 id
}
return a;
});
when(applyRepository.findById(42)).thenReturn(Optional.of(apply));
when(flowClient.enabled()).thenReturn(true);
when(flowClient.apply(anyLong(), anyString(), anyString(), anyString(), anyString(),
any(), any(), anyInt(), anyString(), any()))
.thenReturn(new FlowClient.ApplyResult(9011L, false));
when(siteDirectory.requireSite("S1")).thenReturn(new SiteDirectory.SiteInfo("S1", "站点一", 1L));
ServiceItem item = new ServiceItem();
item.setStatus(ServiceItemStatusEnum.PUBLISHED.getCode());
item.setItemCode("ITEM-1");
item.setTitle("测试事项");
item.setFlowGuid("fg-1");
when(itemRepository.findBySchIdAndSiteCodeAndItemCode(1L, "S1", "ITEM-1"))
.thenReturn(Optional.of(item));
when(attachmentService.applyAttachments(anyLong(), anyInt())).thenReturn(new java.util.ArrayList<>());
}
@AfterEach
void tearDown() {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.clearSynchronization();
}
}
private BoaUser user(int id, int schId) {
BoaUser user = mock(BoaUser.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
when(user.getId()).thenReturn(id);
when(user.getCurrentRole().getSchId()).thenReturn(schId);
return user;
}
private ApplySubmitRequest request() {
ApplySubmitRequest req = new ApplySubmitRequest();
ReflectionTestUtils.setField(req, "siteCode", "S1");
ReflectionTestUtils.setField(req, "itemCode", "ITEM-1");
return req;
}
// ===== 提交:代发起出站 + 窄列回填 =====
@Test
void submitWithoutTransactionAppliesImmediately() {
// 无活动事务(测试/兜底路径)→ 立即执行,语义与异步路径一致
ApplyVo vo = service.submit(user(9, 1), request());
assertEquals(ApplyFlowStatusEnum.PENDING.getCode(), vo.getFlowStatus(), "提交响应=PENDING 行,状态异步翻转");
ArgumentCaptor<String> bizId = ArgumentCaptor.forClass(String.class);
verify(flowClient).apply(eq(1L), eq("fg-1"), eq("svc_item"), bizId.capture(),
anyString(), any(), any(), eq(9), eq("张三"), any());
assertEquals("42", bizId.getValue(), "bizId=办件 id(uk 幂等键)");
verify(applyRepository).markApproving(eq(42), eq(9011L), any(LocalDateTime.class));
}
@Test
void submitPassesBizUrlInFrontendHashQueryForm() {
// bizUrl 落地页是 svc-hall 前端的 hash 路由 /apply-detail?id=(routes.ts path + Apply.vue
// router.replace 同形);拼成路径参数 /apply-detail/{id} 会 no-match 空白页(走查 BP-R3-2)
service.submit(user(9, 1), request());
ArgumentCaptor<String> bizUrl = ArgumentCaptor.forClass(String.class);
verify(flowClient).apply(eq(1L), eq("fg-1"), eq("svc_item"), eq("42"),
anyString(), bizUrl.capture(), any(), eq(9), eq("张三"), any());
assertEquals("http://localhost:8087/#/apply-detail?id=42", bizUrl.getValue());
}
@Test
void submitRegistersAfterCommitAndAppliesOnSeparateThread() {
TransactionSynchronizationManager.initSynchronization();
service.submit(user(9, 1), request());
verify(flowClient, never()).apply(anyLong(), anyString(), anyString(), anyString(),
any(), any(), any(), anyInt(), anyString(), any());
// 事务提交前不出站;手动触发 afterCommit 模拟提交成功
for (TransactionSynchronization sync : TransactionSynchronizationManager.getSynchronizations()) {
sync.afterCommit();
}
verify(flowClient, timeout(3000)).apply(anyLong(), anyString(), anyString(), anyString(),
any(), any(), any(), anyInt(), anyString(), any());
verify(applyRepository, timeout(3000)).markApproving(eq(42), eq(9011L), any(LocalDateTime.class));
}
@Test
void submitKeepsPendingWhenFlowApplyFails() {
when(flowClient.apply(anyLong(), anyString(), anyString(), anyString(), anyString(),
any(), any(), anyInt(), anyString(), any()))
.thenThrow(new RuntimeException("connection refused"));
ApplyVo vo = service.submit(user(9, 1), request());
assertEquals(ApplyFlowStatusEnum.PENDING.getCode(), vo.getFlowStatus(), "失败静默保持待提交(可重试)");
verify(applyRepository, never()).markApproving(anyInt(), anyLong(), any());
}
@Test
void submitStaysPendingWhenFlowClientDisabled() {
when(flowClient.enabled()).thenReturn(false);
ApplyVo vo = service.submit(user(9, 1), request());
assertEquals(ApplyFlowStatusEnum.PENDING.getCode(), vo.getFlowStatus());
verify(flowClient, never()).apply(anyLong(), anyString(), anyString(), anyString(),
any(), any(), any(), anyInt(), anyString(), any());
}
@Test
void duplicatedApplyStillMarksApproving() {
when(flowClient.apply(anyLong(), anyString(), anyString(), anyString(), anyString(),
any(), any(), anyInt(), anyString(), any()))
.thenReturn(new FlowClient.ApplyResult(9011L, true));
service.submit(user(9, 1), request());
verify(applyRepository).markApproving(eq(42), eq(9011L), any(LocalDateTime.class));
}
// ===== 重试:既有规则回归 + 同步代发起 =====
@Test
void retryRejectsNonOwner() {
when(applyRepository.findByIdAndSchId(42, 1L)).thenReturn(Optional.of(apply));
BizException e = assertThrows(BizException.class, () -> service.retry(user(8, 1), 42));
assertEquals("仅可重试自己的办件", e.getMessage());
}
@Test
void retryRejectsNonPendingApply() {
apply.setFlowStatus(ApplyFlowStatusEnum.APPROVING.getCode());
when(applyRepository.findByIdAndSchId(42, 1L)).thenReturn(Optional.of(apply));
assertThrows(BizException.class, () -> service.retry(user(9, 1), 42));
}
@Test
void retryAppliesSynchronously() {
apply.setFlowStatus(ApplyFlowStatusEnum.PENDING.getCode());
when(applyRepository.findByIdAndSchId(42, 1L)).thenReturn(Optional.of(apply));
service.retry(user(9, 1), 42);
verify(flowClient).apply(anyLong(), anyString(), anyString(), anyString(), anyString(),
any(), any(), anyInt(), anyString(), any());
verify(applyRepository).markApproving(eq(42), eq(9011L), any(LocalDateTime.class));
}
// ===== 详情放行收紧(BP-R2-3):同校普通用户不再放行 =====
@Test
void detail_ownerSeesOwnApply() {
when(applyRepository.findByIdAndSchId(42, 1L)).thenReturn(Optional.of(apply));
assertEquals(Integer.valueOf(42), service.detail(user(9, 1), "S1", 42).getId());
}
@Test
void detail_plainSameSchoolUserDenied() {
when(applyRepository.findByIdAndSchId(42, 1L)).thenReturn(Optional.of(apply));
BoaUser plain = user(8, 1);
when(plain.getType()).thenReturn(null);
when(plain.getRoleIds()).thenReturn(null);
BizException e = assertThrows(BizException.class, () -> service.detail(plain, "S1", 42));
assertEquals("办件不存在或无权查看", e.getMessage());
}
// ===== 一键重提(P2-5/BP-R2-2:30/40 复用同 bizId,R4 引擎重启推进) =====
@Test
void resubmit_rejectedApplyReappliesSameBizIdAndMarksApproving() {
apply.setFlowStatus(ApplyFlowStatusEnum.REJECTED.getCode());
apply.setFlowInstanceId(9000L);
when(applyRepository.findByIdAndSchId(42, 1L)).thenReturn(Optional.of(apply));
service.resubmit(user(9, 1), 42);
// 同 bizId=42(flow-center R4 命中 30/40 → 复用同一办件行重启推进)
verify(flowClient).apply(eq(1L), eq("fg-1"), eq("svc_item"), eq("42"),
anyString(), anyString(), any(), eq(9), eq("张三"), any());
verify(applyRepository).markResubmitted(eq(42), eq(9011L), any(LocalDateTime.class));
}
@Test
void resubmit_withdrawnApplyAlsoAllowed() {
apply.setFlowStatus(ApplyFlowStatusEnum.WITHDRAWN.getCode());
when(applyRepository.findByIdAndSchId(42, 1L)).thenReturn(Optional.of(apply));
service.resubmit(user(9, 1), 42);
verify(applyRepository).markResubmitted(eq(42), eq(9011L), any(LocalDateTime.class));
}
@Test
void resubmit_rejectsNonOwner() {
apply.setFlowStatus(ApplyFlowStatusEnum.REJECTED.getCode());
when(applyRepository.findByIdAndSchId(42, 1L)).thenReturn(Optional.of(apply));
BizException e = assertThrows(BizException.class, () -> service.resubmit(user(8, 1), 42));
assertEquals("仅可重提自己的办件", e.getMessage());
verify(flowClient, never()).apply(anyLong(), anyString(), anyString(), anyString(),
anyString(), anyString(), any(), anyInt(), anyString(), any());
}
@Test
void resubmit_rejectsNonTerminalStatus() {
for (ApplyFlowStatusEnum st : ApplyFlowStatusEnum.values()) {
if (st == ApplyFlowStatusEnum.REJECTED || st == ApplyFlowStatusEnum.WITHDRAWN) {
continue;
}
apply.setFlowStatus(st.getCode());
when(applyRepository.findByIdAndSchId(42, 1L)).thenReturn(Optional.of(apply));
assertThrows(BizException.class, () -> service.resubmit(user(9, 1), 42),
"flowStatus=" + st.getCode() + " 不可重提");
}
verify(flowClient, never()).apply(anyLong(), anyString(), anyString(), anyString(),
anyString(), anyString(), any(), anyInt(), anyString(), any());
}
@Test
void resubmit_flowUnreachable_keepsStateReenterable() {
apply.setFlowStatus(ApplyFlowStatusEnum.REJECTED.getCode());
when(applyRepository.findByIdAndSchId(42, 1L)).thenReturn(Optional.of(apply));
when(flowClient.apply(anyLong(), anyString(), anyString(), anyString(), anyString(),
anyString(), any(), anyInt(), anyString(), any()))
.thenThrow(new RuntimeException("connection refused"));
assertThrows(BizException.class, () -> service.resubmit(user(9, 1), 42));
verify(applyRepository, never()).markResubmitted(anyInt(), anyLong(), any());
assertEquals(ApplyFlowStatusEnum.REJECTED.getCode(), apply.getFlowStatus().intValue(),
"出站失败保持原态,可再次重提");
}
}
@@ -0,0 +1,126 @@
package com.yunhai.svchall.service.impl;
import com.yunhai.svchall.entity.ServiceFlowReg;
import com.yunhai.svchall.repository.ServiceFlowRegRepository;
import com.yunhai.svchall.repository.ServiceFormRepository;
import com.yunhai.svchall.repository.ServiceItemRepository;
import com.yunhai.svchall.service.DeptSource;
import com.yunhai.svchall.service.FlowClient;
import com.yunhai.svchall.service.SiteDirectory;
import com.yunhai.svchall.service.SiteGuard;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 回调注册防轮换单测(契约冻结:flow-center 重复注册会轮换 secret,导致已存密钥验签失配):
* 仅 reg 缺失/失效(secret 空或 status≠0)时才(重)注册;有效注册绝不重复调用;
* 注册失败 best-effort 不阻断事项保存。
*/
class ServiceItemFlowRegTest {
private ServiceFlowRegRepository flowRegRepository;
private FlowClient flowClient;
private ServiceItemService service;
private SiteDirectory.SiteInfo site = new SiteDirectory.SiteInfo("S1", "站点一", 1L);
@BeforeEach
void setUp() {
flowRegRepository = mock(ServiceFlowRegRepository.class);
flowClient = mock(FlowClient.class);
service = new ServiceItemService(
mock(ServiceItemRepository.class), mock(ServiceFormRepository.class), flowRegRepository,
mock(SiteDirectory.class), mock(ServiceDictService.class), mock(ServiceFormService.class),
mock(AttachmentService.class), mock(SiteGuard.class), mock(DeptSource.class),
flowClient, mock(TransactionTemplate.class));
ReflectionTestUtils.setField(service, "callbackPublicBase", "http://localhost:8086");
when(flowClient.enabled()).thenReturn(true);
}
private ServiceFlowReg reg(Integer status, String secret) {
ServiceFlowReg reg = new ServiceFlowReg();
reg.setSchId(1L);
reg.setFlowGuid("fg-1");
reg.setStatus(status);
reg.setSecret(secret);
return reg;
}
@Test
void validRegistrationIsNeverRepeated() {
when(flowRegRepository.findBySchIdAndFlowGuid(1L, "fg-1"))
.thenReturn(Optional.of(reg(0, "stored-secret")));
service.ensureFlowReg(site, "fg-1");
verify(flowClient, never()).registerCallback(any(), anyString(), anyString(), any());
}
@Test
void missingRegistrationIsCreated() {
when(flowRegRepository.findBySchIdAndFlowGuid(1L, "fg-1")).thenReturn(Optional.empty());
when(flowClient.registerCallback(eq(1L), eq("fg-1"),
eq("http://localhost:8086/callback/flow"), isNull())).thenReturn("issued-secret");
service.ensureFlowReg(site, "fg-1");
assertEquals("issued-secret", firstSavedReg().getSecret());
assertEquals(Integer.valueOf(0), firstSavedReg().getStatus());
assertEquals("http://localhost:8086/callback/flow", firstSavedReg().getCallbackUrl());
}
@Test
void registrationWithEmptySecretIsReissued() {
// 失效注册(secret 空)→ 重新注册并原地更新同一行(拿回新 secret)
ServiceFlowReg existing = reg(0, null);
when(flowRegRepository.findBySchIdAndFlowGuid(1L, "fg-1")).thenReturn(Optional.of(existing));
when(flowClient.registerCallback(eq(1L), eq("fg-1"), anyString(), isNull()))
.thenReturn("rotated-secret");
service.ensureFlowReg(site, "fg-1");
assertSame(existing, firstSavedReg(), "失效注册应原地更新同一行");
assertEquals("rotated-secret", firstSavedReg().getSecret());
}
@Test
void registrationWithDisabledStatusIsReissued() {
ServiceFlowReg existing = reg(1, "old-secret");
when(flowRegRepository.findBySchIdAndFlowGuid(1L, "fg-1")).thenReturn(Optional.of(existing));
when(flowClient.registerCallback(eq(1L), eq("fg-1"), anyString(), isNull()))
.thenReturn("rotated-secret");
service.ensureFlowReg(site, "fg-1");
verify(flowRegRepository).save(existing);
}
@Test
void registrationFailureIsBestEffort() {
when(flowRegRepository.findBySchIdAndFlowGuid(1L, "fg-1")).thenReturn(Optional.empty());
when(flowClient.registerCallback(any(), anyString(), anyString(), any()))
.thenThrow(new RuntimeException("flow-center down"));
service.ensureFlowReg(site, "fg-1"); // 不抛——事项保存不被注册失败阻断
verify(flowRegRepository, never()).save(any());
}
@Test
void flowClientDisabledSkipsEverything() {
when(flowClient.enabled()).thenReturn(false);
service.ensureFlowReg(site, "fg-1");
verify(flowRegRepository, never()).findBySchIdAndFlowGuid(any(), anyString());
}
private ServiceFlowReg firstSavedReg() {
org.mockito.ArgumentCaptor<ServiceFlowReg> captor =
org.mockito.ArgumentCaptor.forClass(ServiceFlowReg.class);
verify(flowRegRepository).save(captor.capture());
return captor.getValue();
}
}
@@ -0,0 +1,107 @@
package com.yunhai.svchall.utils;
import com.yunhai.platform.apistd.BizException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* 表单 schema 契约单测(V2.0 Part B.3:14 类命名冻结/一期 7 类/声明式校验白名单/提交强校验)。
*/
@DisplayName("FormValidator - 表单 schema 契约")
class FormValidatorTest {
private static final String OK_SCHEMA = "{\"groups\":[{"
+ "\"title\":\"基本信息\",\"fields\":["
+ "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true,\"maxLength\":20},"
+ "{\"key\":\"idCard\",\"label\":\"身份证\",\"type\":\"text\",\"required\":true,"
+ "\"pattern\":\"^\\\\d{17}[\\\\dXx]$\"},"
+ "{\"key\":\"dept\",\"label\":\"部门\",\"type\":\"select\",\"options\":[\"计财处\",\"教务处\"]},"
+ "{\"key\":\"joinDate\",\"label\":\"日期\",\"type\":\"date\"}"
+ "]}]}";
@Test
@DisplayName("合法 schema 通过校验并返回字段数")
void validSchemaPasses() {
assertThat(FormValidator.validateSchema(OK_SCHEMA)).isEqualTo(4);
}
@Test
@DisplayName("一期只放行 7 类控件:user 为二期占位 → 拒绝")
void phase2TypeRejected() {
String schema = "{\"groups\":[{\"fields\":["
+ "{\"key\":\"u\",\"label\":\"用户\",\"type\":\"user\"}]}]}";
assertThatThrownBy(() -> FormValidator.validateSchema(schema))
.isInstanceOf(BizException.class)
.hasMessageContaining("二期开放");
}
@Test
@DisplayName("未冻结类型拒绝(14 类之外的字符串 key)")
void unfrozenTypeRejected() {
String schema = "{\"groups\":[{\"fields\":["
+ "{\"key\":\"x\",\"label\":\"X\",\"type\":\"richtext\"}]}]}";
assertThatThrownBy(() -> FormValidator.validateSchema(schema))
.isInstanceOf(BizException.class)
.hasMessageContaining("未冻结");
}
@Test
@DisplayName("契约白名单外的属性拒绝(防存库 JS/eval 类契约外语义渗入)")
void nonContractPropRejected() {
String schema = "{\"groups\":[{\"fields\":["
+ "{\"key\":\"x\",\"label\":\"X\",\"type\":\"text\",\"dataValidator\":\"return true\"}]}]}";
assertThatThrownBy(() -> FormValidator.validateSchema(schema))
.isInstanceOf(BizException.class)
.hasMessageContaining("dataValidator");
}
@Test
@DisplayName("key 重复拒绝;pattern 不可编译拒绝")
void duplicateKeyAndBadPatternRejected() {
String dup = "{\"groups\":[{\"fields\":["
+ "{\"key\":\"a\",\"label\":\"A\",\"type\":\"text\"},"
+ "{\"key\":\"a\",\"label\":\"A2\",\"type\":\"text\"}]}]}";
assertThatThrownBy(() -> FormValidator.validateSchema(dup))
.isInstanceOf(BizException.class)
.hasMessageContaining("重复");
String badPattern = "{\"groups\":[{\"fields\":["
+ "{\"key\":\"p\",\"label\":\"P\",\"type\":\"text\",\"pattern\":\"[a-\"}]}]}";
assertThatThrownBy(() -> FormValidator.validateSchema(badPattern))
.isInstanceOf(BizException.class)
.hasMessageContaining("pattern");
}
@Test
@DisplayName("提交校验:必填拦截、pattern 拦截、select 候选成员、未知字段拒绝")
void submitValidation() {
FormValidator.validateSchema(OK_SCHEMA);
// 缺必填
assertThatThrownBy(() -> FormValidator.validateSubmit("{\"name\":\"张三\"}", OK_SCHEMA))
.hasMessageContaining("必填");
// pattern 不匹配
assertThatThrownBy(() -> FormValidator.validateSubmit(
"{\"name\":\"张三\",\"idCard\":\"123\",\"dept\":\"计财处\"}", OK_SCHEMA))
.hasMessageContaining("格式不正确");
// select 候选外
assertThatThrownBy(() -> FormValidator.validateSubmit(
"{\"name\":\"张三\",\"idCard\":\"110101199001011234\",\"dept\":\"食堂\"}", OK_SCHEMA))
.hasMessageContaining("候选列表");
// 未知字段
assertThatThrownBy(() -> FormValidator.validateSubmit(
"{\"name\":\"张三\",\"idCard\":\"110101199001011234\",\"dept\":\"教务处\",\"hack\":1}", OK_SCHEMA))
.hasMessageContaining("未知字段");
// 合法提交通过
assertThatCode(() -> FormValidator.validateSubmit(
"{\"name\":\"张三\",\"idCard\":\"110101199001011234\",\"dept\":\"教务处\"}", OK_SCHEMA))
.doesNotThrowAnyException();
// 无表单数据(null)→ 只按必填口径
assertThatThrownBy(() -> FormValidator.validateSubmit(null, OK_SCHEMA))
.hasMessageContaining("必填");
}
}