From 94881d23fdcb4a7f3500de0bf2104ce257ff21df Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 16:35:05 +0800 Subject: [PATCH] test(firstleg): verify ship exception persistence and rollback --- ...rrierScheduleExceptionPersistenceTest.java | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleExceptionPersistenceTest.java diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleExceptionPersistenceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleExceptionPersistenceTest.java new file mode 100644 index 000000000..55c97e44e --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleExceptionPersistenceTest.java @@ -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 + ")"); + } +}