test(firstleg): verify ship exception persistence and rollback
This commit is contained in:
+132
@@ -0,0 +1,132 @@
|
||||
package com.baiyue.firstleg.portschedule.service;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderExceptionRequest;
|
||||
import com.baiyue.firstleg.portschedule.entity.*;
|
||||
import com.baiyue.firstleg.portschedule.mapper.*;
|
||||
import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.aop.framework.ProxyFactory;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
|
||||
import org.springframework.transaction.interceptor.TransactionInterceptor;
|
||||
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
/** Real production service/mapper writes against H2; provider inputs are synthetic,
|
||||
* so this does not claim deployed third-party/runtime acceptance. */
|
||||
class CarrierScheduleExceptionPersistenceTest {
|
||||
private static final LocalDateTime TIME = LocalDateTime.of(2026, 9, 30, 12, 0);
|
||||
private JdbcTemplate jdbc;
|
||||
private CarrierScheduleQueryService service;
|
||||
|
||||
@BeforeEach
|
||||
void setup() throws Exception {
|
||||
JdbcDataSource dataSource = new JdbcDataSource();
|
||||
dataSource.setURL("jdbc:h2:mem:carrier-exception-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
jdbc = new JdbcTemplate(dataSource);
|
||||
for (Class<?> entity : List.of(CarrierScheduleQueryEntity.class, CarrierScheduleQueryHistoryEntity.class,
|
||||
CarrierScheduleExceptionFactEntity.class, CarrierScheduleNodeFactEntity.class)) createTable(entity);
|
||||
jdbc.execute("ALTER TABLE fl_carrier_schedule_exception_fact ADD CONSTRAINT uq_exception UNIQUE(query_id,event_key)");
|
||||
jdbc.update("INSERT INTO fl_carrier_schedule_query(id,query_no,customer_id,ocean_booking_id,container_booking_id,"
|
||||
+ "carrier_code,tracking_type,tracking_no,status,trigger_source,latest_standard_node,latest_node_observed_at,version)"
|
||||
+ " VALUES(1,'CQ-TEST',0,2,3,'MATSON','CONTAINER_NO','TSTU0000010','READY','OUTBOUND_CONFIRM','IN_TRANSIT',?,7)", TIME);
|
||||
jdbc.update("INSERT INTO fl_carrier_schedule_node_fact(id,query_id,container_booking_id,event_key,provider_code,"
|
||||
+ "provider_event_id,provider_node_code,standard_node,node_sequence,observed_at,actual_at,raw_payload_hash)"
|
||||
+ " VALUES(4,1,3,'MATSON:normal','MATSON','normal','TRANSIT','IN_TRANSIT',50,?,?,?)", TIME, TIME, "c".repeat(64));
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
configuration.setMapUnderscoreToCamelCase(true);
|
||||
for (Class<?> mapper : List.of(CarrierScheduleQueryMapper.class, CarrierScheduleQueryHistoryMapper.class,
|
||||
CarrierScheduleExceptionFactMapper.class, CarrierScheduleNodeFactMapper.class)) configuration.addMapper(mapper);
|
||||
MybatisSqlSessionFactoryBean factory = new MybatisSqlSessionFactoryBean();
|
||||
factory.setDataSource(dataSource); factory.setConfiguration(configuration);
|
||||
SqlSessionTemplate session = new SqlSessionTemplate(factory.getObject());
|
||||
CarrierScheduleQueryRepository repository = new CarrierScheduleQueryRepository();
|
||||
ReflectionTestUtils.setField(repository, "queries", session.getMapper(CarrierScheduleQueryMapper.class));
|
||||
ReflectionTestUtils.setField(repository, "histories", session.getMapper(CarrierScheduleQueryHistoryMapper.class));
|
||||
ReflectionTestUtils.setField(repository, "exceptions", session.getMapper(CarrierScheduleExceptionFactMapper.class));
|
||||
ReflectionTestUtils.setField(repository, "nodes", session.getMapper(CarrierScheduleNodeFactMapper.class));
|
||||
CarrierScheduleQueryService target = new CarrierScheduleQueryService();
|
||||
ReflectionTestUtils.setField(target, "repository", repository);
|
||||
ReflectionTestUtils.setField(target, "objectMapper", new ObjectMapper().findAndRegisterModules());
|
||||
ProxyFactory proxy = new ProxyFactory(target);
|
||||
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(dataSource),
|
||||
new AnnotationTransactionAttributeSource()));
|
||||
service = (CarrierScheduleQueryService) proxy.getProxy();
|
||||
}
|
||||
|
||||
@Test
|
||||
void fourHoldAndReleaseFactsPersistIndependentlyAlongsideUnchangedNormalProgress() {
|
||||
for (String code : List.of("CUSTOMS_HOLD", "TERMINAL_HOLD", "DOCUMENT_HOLD", "UNKNOWN_HOLD")) {
|
||||
service.applyProviderException(1, request(code, code + "-ON", true, "a"));
|
||||
service.applyProviderException(1, request(code, code + "-OFF", false, "b"));
|
||||
}
|
||||
var detail = service.detail(1);
|
||||
assertThat(detail.exceptions()).hasSize(8);
|
||||
assertThat(detail.exceptions().stream().filter(fact -> fact.active()).count()).isEqualTo(4);
|
||||
assertThat(detail.histories()).hasSize(8).allMatch(history -> history.action().equals("EXCEPTION_OBSERVED"));
|
||||
assertThat(detail.nodes()).hasSize(1);
|
||||
assertThat(detail.query().query().latestActualNode()).isEqualTo("IN_TRANSIT");
|
||||
assertThat(detail.query().query().version()).isEqualTo(7);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fl_carrier_schedule_exception_fact WHERE source_reference IS NOT NULL AND raw_payload_hash IS NOT NULL", Integer.class)).isEqualTo(8);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameEventHashReplayAddsNoFactOrHistoryAndDifferentHashCannotOverwrite() {
|
||||
var request = request("CUSTOMS_HOLD", "hold-original", true, "a");
|
||||
service.applyProviderException(1, request);
|
||||
service.applyProviderException(1, request);
|
||||
assertThat(service.detail(1).exceptions()).hasSize(1);
|
||||
assertThat(service.detail(1).histories()).hasSize(1);
|
||||
assertThatThrownBy(() -> service.applyProviderException(1,
|
||||
request("CUSTOMS_HOLD", "hold-original", true, "b")))
|
||||
.hasMessageContaining("rawPayloadHash");
|
||||
assertThat(service.detail(1).exceptions()).hasSize(1);
|
||||
assertThat(service.detail(1).exceptions().get(0).rawPayloadHash()).isEqualTo("a".repeat(64));
|
||||
assertThat(service.detail(1).histories()).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedHistoryWriteRollsBackExceptionAndOriginalRequestCanCommitAfterRepair() {
|
||||
jdbc.execute("ALTER TABLE fl_carrier_schedule_query_history ADD CONSTRAINT reject_observation CHECK(action <> 'EXCEPTION_OBSERVED')");
|
||||
var request = request("DOCUMENT_HOLD", "rollback-original", true, "a");
|
||||
assertThatThrownBy(() -> service.applyProviderException(1, request)).isInstanceOf(RuntimeException.class);
|
||||
assertThat(service.detail(1).exceptions()).isEmpty();
|
||||
assertThat(service.detail(1).histories()).isEmpty();
|
||||
jdbc.execute("ALTER TABLE fl_carrier_schedule_query_history DROP CONSTRAINT reject_observation");
|
||||
service.applyProviderException(1, request);
|
||||
assertThat(service.detail(1).exceptions()).hasSize(1);
|
||||
assertThat(service.detail(1).histories()).hasSize(1);
|
||||
assertThat(service.detail(1).query().query().latestActualNode()).isEqualTo("IN_TRANSIT");
|
||||
}
|
||||
|
||||
private ProviderExceptionRequest request(String code, String eventId, boolean active, String hash) {
|
||||
return new ProviderExceptionRequest("MATSON", eventId, active ? code : code + " RELEASED", code,
|
||||
active, active ? TIME : TIME.plusMinutes(1), "USLAX", "Los Angeles", "synthetic-test:" + eventId, hash.repeat(64));
|
||||
}
|
||||
|
||||
private void createTable(Class<?> entity) {
|
||||
String columns = Arrays.stream(entity.getDeclaredFields()).filter(field -> !Modifier.isStatic(field.getModifiers()))
|
||||
.map(field -> field.getName().replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase()
|
||||
+ " " + (field.getType() == Long.class ? "BIGINT" : field.getType() == Integer.class ? "INT"
|
||||
: field.getType() == Boolean.class ? "BOOLEAN" : field.getType() == LocalDateTime.class ? "TIMESTAMP(3)" : "VARCHAR(16000)")
|
||||
+ (field.getName().equals("id") ? " PRIMARY KEY" : ""))
|
||||
.collect(Collectors.joining(","));
|
||||
jdbc.execute("CREATE TABLE " + entity.getAnnotation(TableName.class).value() + "(" + columns + ")");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user