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 返修增补)规则单测:
*
*
* - 事实与状态同事务落库(同步监听器语义)并为事件赋稳定身份;
* - eventId 全链复用(事实行=投递行=重试);同事实重复发布不重复落行;
* - 同事务事实写失败=状态事务一并失败(不吞);
* - URGE 站内信不入事件流;
* - R2:事实载荷=完整冻结信封(含 eventId/eventSeq/flowGuid),并随身份冻结到事件对象——
* 正常分发与崩溃恢复共用同一冻结体。
*
*/
@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());
}
}