package com.yunhai.flowcenter.callback; import com.fasterxml.jackson.databind.ObjectMapper; import com.yunhai.flowcenter.engine.FlowEvent; import com.yunhai.flowcenter.entity.FlwDef; import com.yunhai.flowcenter.entity.FlwInstance; import com.yunhai.flowcenter.entity.FlwStateEvent; import com.yunhai.flowcenter.repository.FlwDefRepository; import com.yunhai.flowcenter.repository.FlwInstanceRepository; import com.yunhai.flowcenter.repository.FlwStateEventRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.Mockito; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** * 状态事件事实(T05;2026-10-25 R2 返修增补)规则单测: * * */ @DisplayName("状态事件事实 FlwStateEventRecorder(T05/R2)") class FlStateEventRecoveryTest { private FlwStateEventRepository eventRepository; private FlwInstanceRepository instanceRepository; private FlwDefRepository defRepository; private FlwStateEventRecorder recorder; @BeforeEach void setUp() { eventRepository = mock(FlwStateEventRepository.class); instanceRepository = mock(FlwInstanceRepository.class); defRepository = mock(FlwDefRepository.class); recorder = new FlwStateEventRecorder(eventRepository, instanceRepository, defRepository, new ObjectMapper()); when(eventRepository.save(any())).thenAnswer(c -> { FlwStateEvent e = c.getArgument(0); e.setId(1L); return e; }); FlwInstance instance = new FlwInstance(); instance.setId(900L); instance.setSchId(27L); instance.setDefId(55L); instance.setApplyRound(2); instance.setBizType("svc-hall"); instance.setBizId("42"); when(instanceRepository.findById(900L)).thenReturn(Optional.of(instance)); when(instanceRepository.nextEventSeqForUpdate(900L)).thenReturn(5); when(instanceRepository.setEventSeqCounter(Mockito.eq(900L), Mockito.anyInt())).thenReturn(1); FlwDef def = new FlwDef(); def.setId(55L); def.setFlowGuid("fg-r2-envelope"); when(defRepository.findById(55L)).thenReturn(Optional.of(def)); } @Test @DisplayName("同步监听:事实落库并赋稳定身份(eventId+实例内 seq=max+1)") void recordAssignsStableIdentity() { FlowEvent event = FlowEvent.of(FlowEvent.Type.SUBMITTED, 900L); recorder.record(event); Mockito.verify(eventRepository).save(any(FlwStateEvent.class)); assertThat(event.getStableEventId()).isNotBlank(); assertThat(event.getStableEventSeq()).isEqualTo(5); } @Test @DisplayName("同事件对象重复发布:不重复落事实、身份不变") void duplicatePublishKeepsIdentity() { FlowEvent event = FlowEvent.of(FlowEvent.Type.COMPLETED, 900L); recorder.record(event); String first = event.getStableEventId(); recorder.record(event); Mockito.verify(eventRepository, Mockito.times(1)).save(any(FlwStateEvent.class)); assertThat(event.getStableEventId()).isEqualTo(first); } @Test @DisplayName("URGE 站内信:不入事件流(既有口径不变)") void urgeSkipped() { recorder.record(FlowEvent.of(FlowEvent.Type.URGE, 900L)); Mockito.verify(eventRepository, never()).save(any(FlwStateEvent.class)); } @Test @DisplayName("事实载荷快照含轮次身份(旧事件不被 AFTER_COMMIT 重读贴新轮标签)") void payloadCarriesRoundIdentity() throws Exception { FlowEvent event = FlowEvent.of(FlowEvent.Type.REJECTED, 900L); event.captureRoundIdentity(2, "tok-1"); recorder.record(event); Mockito.verify(eventRepository).save(Mockito.argThat(f -> { try { com.fasterxml.jackson.databind.JsonNode payload = new ObjectMapper().readTree(f.getPayloadJson()); return payload.path("applyRound").asInt() == 2 && "tok-1".equals(payload.path("data").path("requestToken").asText()); } catch (Exception e) { return false; } })); } @Test @DisplayName("R2:事实载荷=完整冻结信封——eventId/eventSeq/flowGuid/接收端必填四件套全部在体") void payloadIsFullFrozenEnvelope() throws Exception { FlowEvent event = FlowEvent.of(FlowEvent.Type.SUBMITTED, 900L); recorder.record(event); ArgumentCaptor captor = ArgumentCaptor.forClass(FlwStateEvent.class); verify(eventRepository).save(captor.capture()); com.fasterxml.jackson.databind.JsonNode payload = new ObjectMapper().readTree(captor.getValue().getPayloadJson()); // 接收端 FlowCallbackReceiver 必填:eventId/event/bizType/bizId(缺一按脏消息 200 丢弃) assertThat(payload.path("eventId").asText()).isEqualTo(event.getStableEventId()); assertThat(payload.path("event").asText()).isEqualTo("flow.submitted"); assertThat(payload.path("bizType").asText()).isEqualTo("svc-hall"); assertThat(payload.path("bizId").asText()).isEqualTo("42"); // R2 新冻结字段:稳定序号 + flowGuid(原实现只在 HTTP 头/列里,消息体缺失) assertThat(payload.path("eventSeq").asInt()).isEqualTo(5); assertThat(payload.path("flowGuid").asText()).isEqualTo("fg-r2-envelope"); // 既有信封字段不回退 assertThat(payload.has("instanceId")).isTrue(); assertThat(payload.has("instanceVersion")).isTrue(); assertThat(payload.has("occurredTime")).isTrue(); } @Test @DisplayName("R2:冻结信封随身份一并赋给事件对象——分发器正常链直接复用冻结体(与恢复链同构)") void frozenEnvelopeAssignedToEvent() throws Exception { FlowEvent event = FlowEvent.of(FlowEvent.Type.COMPLETED, 900L); recorder.record(event); assertThat(event.getStablePayloadJson()).isNotBlank(); com.fasterxml.jackson.databind.JsonNode payload = new ObjectMapper().readTree(event.getStablePayloadJson()); assertThat(payload.path("eventId").asText()).isEqualTo(event.getStableEventId()); assertThat(payload.path("flowGuid").asText()).isEqualTo("fg-r2-envelope"); // 重复发布不换冻结体(幂等) String first = event.getStablePayloadJson(); recorder.record(event); assertThat(event.getStablePayloadJson()).isEqualTo(first); } @Test @DisplayName("R2:定义已删(def 缺席)——信封不带 flowGuid,不炸记录器(该场合分发器同样不投递)") void missingDefStillRecordsWithoutFlowGuid() throws Exception { FlwInstance instance = new FlwInstance(); instance.setId(901L); instance.setSchId(27L); instance.setDefId(404L); instance.setBizType("svc-hall"); instance.setBizId("43"); when(instanceRepository.findById(901L)).thenReturn(Optional.of(instance)); when(defRepository.findById(404L)).thenReturn(Optional.empty()); when(instanceRepository.nextEventSeqForUpdate(901L)).thenReturn(1); when(instanceRepository.setEventSeqCounter(Mockito.eq(901L), Mockito.anyInt())).thenReturn(1); FlowEvent event = FlowEvent.of(FlowEvent.Type.SUBMITTED, 901L); recorder.record(event); ArgumentCaptor captor = ArgumentCaptor.forClass(FlwStateEvent.class); verify(eventRepository).save(captor.capture()); com.fasterxml.jackson.databind.JsonNode payload = new ObjectMapper().readTree(captor.getValue().getPayloadJson()); assertThat(payload.has("flowGuid")).isFalse(); assertThat(payload.path("eventId").asText()).isEqualTo(event.getStableEventId()); } }