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 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 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(), "出站失败保持原态,可再次重提"); } }