Files
svc-hall-service/src/test/java/com/yunhai/svchall/service/impl/ServiceApplyFlowSubmitTest.java
T
chenxing a4d220362e Preserve active leases and align flow recovery rounds
Local review candidate only. Explicitly selected inherited prerequisites and prior repair patches; unrelated WIP and independent MQ fixes are excluded. No push or deployment authorized.
2026-10-23 10:51:40 +08:00

325 lines
16 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(),
"出站失败保持原态,可再次重提");
}
@Test
void productionResubmitUsesCommandContractWithoutBlindApprovingWrite() {
apply.setFlowStatus(30); apply.setFcIntentId(1);
when(applyRepository.findByIdAndSchId(42, 1L)).thenReturn(Optional.of(apply));
FlowApplyRecoveryService commands = mock(FlowApplyRecoveryService.class);
service.setFlowCommandsForTest(commands);
service.resubmit(user(9, 1), 42);
verify(commands).requestExecution(42, true);
verify(flowClient, never()).apply(anyLong(), anyString(), anyString(), anyString(), anyString(), any(), any(), anyInt(), anyString(), any());
verify(applyRepository, never()).markResubmitted(anyInt(), anyLong(), any());
assertEquals(30, apply.getFlowStatus().intValue());
}
@Test
void guardedApplyCannotFallBackToLegacyWhenCommandServiceMissing() {
apply.setFlowStatus(30); apply.setFcIntentId(1);
when(applyRepository.findByIdAndSchId(42, 1L)).thenReturn(Optional.of(apply));
assertThrows(BizException.class, () -> service.resubmit(user(9, 1), 42));
verify(flowClient, never()).apply(anyLong(), anyString(), anyString(), anyString(), anyString(), any(), any(), anyInt(), anyString(), any());
assertEquals(30, apply.getFlowStatus().intValue());
}
}