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.
325 lines
16 KiB
Java
325 lines
16 KiB
Java
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());
|
||
}
|
||
|
||
}
|