Files
svc-hall-service/src/test/java/com/yunhai/svchall/service/impl/ServiceApplyFlowSubmitTest.java
T
chenxing 05d2143ce1
build / build-push (push) Successful in 48s
feat(p3.2): bizType 方案B——常量换终值 svc-hall + 双值双查 + P1-02 行级绑定校验 + P1-03 R4 时序修复
- SvcFlowCallbackHandler.BIZ_TYPE → SvcBizTypes.CURRENT(svc-hall,H-1 实核 appKey=100008
  注册行 app_code);yml 回调白名单双值 [svc-hall, svc_item](收敛条件=另一类型不再可能
  产生事件,本轮不收敛;回滚也保双值)
- 双值双查补丁(先终值后存量,S0-H2 实测中心 svc_item 存量=0,次查为防御性兼容):
  ApplyViewGuard 办理人分支 / FlowReconcileJob 对账(命中判据=内容非空,兼容 miss 空载体)/
  progress 空态出参 / S2 审批代理 byBiz·todo(S2 已落)
- P1-02 行级事件绑定校验:事件 schId 与行一致、行有镜像实例时事件 instanceId 必须一致;
  不匹配 200 消费但不推进+告警日志(不抛 5xx 防重推风暴);行无值(首发回调早于回填)放行,
  doFlowApply 回填侧对实例不一致告警留痕(方案B 书面接受第二实例降级)
- P1-03 R4 时序修复:markResubmitted 清旧 flowResultJson(保留 lastInstanceVersion 跨轮
  乱序防护);Handler 终态守卫改版本感知(事件版本>已消费版本=新轮竞态窗口放行,不再以
  旧结果非空一刀切拒绝);非终态推进清残留 finishTime;三场景测试静态化注入
  (新轮回调先于 apply 响应/新轮快速终态/旧轮迟到重复)
- 测试断言按新语义更新:新件出站断言 svc-hall×3;全量 163/163 绿(Stage S3/P3.2)
2026-10-19 23:49:02 +08:00

303 lines
15 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)));
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-hall"), 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 是裸相对路径(2026-09-26 起不拼本应用前端基址,绝对化由接收方按页面归属承担),
// 形态须是 hash 路由 + query 传参(routes.ts path="/apply-detail"、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-hall"), eq("42"),
anyString(), bizUrl.capture(), any(), eq(9), eq("张三"), any());
assertEquals("/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-hall"), 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(),
"出站失败保持原态,可再次重提");
}
}