This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user