diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java index fe432ba49..a18b494b5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java @@ -575,8 +575,8 @@ public class CarrierScheduleQueryService { fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash); if (repository.insertIfAbsent(fact)) return fact; CarrierScheduleNodeFactEntity existing = repository.nodeByContainer(containerBookingId, fact.getEventKey()); - if (existing != null && !Objects.equals(existing.getRawPayloadHash(), rawPayloadHash)) { - throw conflict("相同providerEventId对应不同rawPayloadHash,拒绝覆盖既有节点事实"); + if (existing != null && !nodeFactHash(existing).equals(nodeFactHash(fact))) { + throw conflict("相同providerEventId对应不同事件事实摘要,拒绝覆盖既有节点事实"); } return null; } @@ -595,12 +595,32 @@ public class CarrierScheduleQueryService { fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash); if (repository.insertIfAbsent(fact)) return true; CarrierScheduleExceptionFactEntity existing = repository.exception(queryId, fact.getEventKey()); - if (existing != null && !Objects.equals(existing.getRawPayloadHash(), rawPayloadHash)) { - throw conflict("相同providerEventId对应不同rawPayloadHash,拒绝覆盖既有异常事实"); + if (existing != null && !exceptionFactHash(existing).equals(exceptionFactHash(fact))) { + throw conflict("相同providerEventId对应不同事件事实摘要,拒绝覆盖既有异常事实"); } return false; } + // rawPayloadHash audits the whole provider response. Sibling events, ETA and query time + // may change between polls; compare the immutable event projection instead of that audit hash. + private String nodeFactHash(CarrierScheduleNodeFactEntity fact) { + return hash("PROVIDER_NODE_FACT_V1", fact.getContainerBookingId(), java.util.Arrays.asList( + fact.getProviderCode(), fact.getProviderEventId(), fact.getProviderNodeCode(), fact.getStandardNode(), + factTimestamp(fact.getEstimatedAt()), factTimestamp(fact.getActualAt()), + fact.getLocationCode(), fact.getLocationName(), fact.getSourceReference())); + } + + private String exceptionFactHash(CarrierScheduleExceptionFactEntity fact) { + return hash("PROVIDER_EXCEPTION_FACT_V1", fact.getQueryId(), java.util.Arrays.asList( + fact.getProviderCode(), fact.getProviderEventId(), fact.getProviderStatus(), fact.getExceptionCode(), + fact.getActive(), fact.getLocationCode(), fact.getLocationName(), fact.getSourceReference())); + } + + private static LocalDateTime factTimestamp(LocalDateTime value) { + // Match DATETIME(3) storage rounding, including a carry into the next second. + return value == null ? null : value.plusNanos(500_000).truncatedTo(java.time.temporal.ChronoUnit.MILLIS); + } + private String requireContainerNo(long containerBookingId) { ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId); if (container == null) { 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 index 55c97e44e..6ec940065 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleExceptionPersistenceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleExceptionPersistenceTest.java @@ -11,6 +11,8 @@ 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.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mybatis.spring.SqlSessionTemplate; import org.springframework.aop.framework.ProxyFactory; import org.springframework.jdbc.core.JdbcTemplate; @@ -23,6 +25,7 @@ import java.lang.reflect.Modifier; import java.time.LocalDateTime; import java.util.Arrays; import java.util.List; +import java.util.Map; import java.util.UUID; import java.util.stream.Collectors; @@ -43,12 +46,21 @@ class CarrierScheduleExceptionPersistenceTest { 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.execute("ALTER TABLE fl_carrier_schedule_node_fact ADD CONSTRAINT uq_node UNIQUE(container_booking_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)); + 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,estimated_at,raw_payload_hash)" + + " VALUES(5,1,3,'MATSON:estimated','MATSON','estimated','ETA','ARRIVED_DESTINATION_PORT',60,?,?,?)", + TIME, TIME.plusDays(14), "d".repeat(64)); + jdbc.update("INSERT INTO fl_carrier_schedule_query_history(id,query_id,action,from_version,to_version,reason," + + "operator_id,operator_name,created_at) VALUES(6,1,'NODE_OBSERVED',6,7,'IN_TRANSIT',0,'carrier-scheduler',?)", TIME); + jdbc.update("INSERT INTO fl_carrier_schedule_query_history(id,query_id,action,from_version,to_version,reason," + + "operator_id,operator_name,created_at) VALUES(7,1,'NODE_OBSERVED',7,7,'ARRIVED_DESTINATION_PORT',0,'carrier-scheduler',?)", TIME); MybatisConfiguration configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true); for (Class mapper : List.of(CarrierScheduleQueryMapper.class, CarrierScheduleQueryHistoryMapper.class, @@ -62,6 +74,11 @@ class CarrierScheduleExceptionPersistenceTest { ReflectionTestUtils.setField(repository, "exceptions", session.getMapper(CarrierScheduleExceptionFactMapper.class)); ReflectionTestUtils.setField(repository, "nodes", session.getMapper(CarrierScheduleNodeFactMapper.class)); CarrierScheduleQueryService target = new CarrierScheduleQueryService(); + var ports = org.mockito.Mockito.mock(com.baiyue.firstleg.portschedule.repository.PortScheduleRepository.class); + var container = new com.baiyue.firstleg.entity.ContainerBookingEntity(); container.setContainerNo("ZCSU7881970"); + org.mockito.Mockito.when(ports.containerBooking(3L)).thenReturn(container); + ReflectionTestUtils.setField(target, "portSchedules", ports); + ReflectionTestUtils.setField(target, "nodeOutbox", org.mockito.Mockito.mock(CarrierScheduleNodeOutboxService.class)); ReflectionTestUtils.setField(target, "repository", repository); ReflectionTestUtils.setField(target, "objectMapper", new ObjectMapper().findAndRegisterModules()); ProxyFactory proxy = new ProxyFactory(target); @@ -72,47 +89,140 @@ class CarrierScheduleExceptionPersistenceTest { @Test void fourHoldAndReleaseFactsPersistIndependentlyAlongsideUnchangedNormalProgress() { + var normalBefore = normalSnapshot(); for (String code : List.of("CUSTOMS_HOLD", "TERMINAL_HOLD", "DOCUMENT_HOLD", "UNKNOWN_HOLD")) { + var existingExceptions = exceptionRows(); service.applyProviderException(1, request(code, code + "-ON", true, "a")); + var activeSnapshot = exceptionRows(); + assertThat(activeSnapshot).containsAll(existingExceptions).hasSize(existingExceptions.size() + 1); service.applyProviderException(1, request(code, code + "-OFF", false, "b")); + assertThat(exceptionRows()).containsAll(activeSnapshot).hasSize(activeSnapshot.size() + 1); + assertThat(normalSnapshot()).isEqualTo(normalBefore); + assertThat(service.detail(1).exceptions().stream().filter(fact -> code.equals(fact.exceptionCode())).toList()) + .hasSize(2).anyMatch(fact -> fact.active()).anyMatch(fact -> !fact.active()) + .allSatisfy(fact -> { + assertThat(fact.providerCode()).isEqualTo("MATSON"); + assertThat(fact.providerStatus()).isEqualTo(fact.active() ? code : code + " RELEASED"); + assertThat(fact.observedAt()).isEqualTo(fact.active() ? TIME : TIME.plusMinutes(1)); + assertThat(fact.locationCode()).isEqualTo("USLAX"); + assertThat(fact.locationName()).isEqualTo("Los Angeles"); + assertThat(fact.sourceReference()).isEqualTo("synthetic-test:" + fact.providerEventId()); + assertThat(fact.rawPayloadHash()).isEqualTo((fact.active() ? "a" : "b").repeat(64)); + }); } 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.histories()).hasSize(10); + assertThat(detail.histories().stream().filter(history -> history.action().equals("EXCEPTION_OBSERVED")).toList()) + .hasSize(8).allSatisfy(history -> { + assertThat(history.fromVersion()).isEqualTo(7); + assertThat(history.toVersion()).isEqualTo(7); + }); + assertThat(detail.nodes()).hasSize(2) + .anyMatch(node -> node.actualAt() != null && node.estimatedAt() == null) + .anyMatch(node -> node.estimatedAt() != null && node.actualAt() == null); 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); } + @ParameterizedTest + @ValueSource(strings = {"CUSTOMS_HOLD", "TERMINAL_HOLD", "DOCUMENT_HOLD", "UNKNOWN_HOLD"}) + void sameFactsReplayPreservesAuditAndChangedFactsCannotOverwrite(String code) { + var normalBefore = normalSnapshot(); + for (boolean active : List.of(true, false)) { + var original = request(code, code + (active ? "-ON" : "-OFF"), active, active ? "a" : "b"); + service.applyProviderException(1, original); + var persisted = allSnapshot(); + service.applyProviderException(1, original); + assertThat(allSnapshot()).as("same event/hash must not change any persisted row").isEqualTo(persisted); + service.applyProviderException(1, new ProviderExceptionRequest(original.providerCode(), original.providerEventId(), + original.providerStatus(), original.exceptionCode(), active, TIME.plusHours(3), original.locationCode(), + original.locationName(), original.sourceReference(), "e".repeat(64))); + assertThat(allSnapshot()).as("response audit changes do not mutate an existing event").isEqualTo(persisted); + assertThatThrownBy(() -> service.applyProviderException(1, + new ProviderExceptionRequest(original.providerCode(), original.providerEventId(), original.providerStatus(), + original.exceptionCode(), !active, original.observedAt(), original.locationCode(), original.locationName(), + original.sourceReference(), original.rawPayloadHash()))) + .isInstanceOf(com.baiyue.common.core.exception.BizException.class) + .hasMessageContaining("事件事实摘要"); + assertThat(allSnapshot()).as("conflicting hash must roll back without changing any persisted row").isEqualTo(persisted); + assertThat(normalSnapshot()).isEqualTo(normalBefore); + } + assertThat(service.detail(1).exceptions()).hasSize(2); + assertThat(service.detail(1).histories()).hasSize(4); + } + @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); + void nextPollReplaysOldNodesThenAppendsIndependentEventAndRevisedEta() { + var oldNodes = normalSnapshot().get("nodes"); + service.applyProviderObservation(1, new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest( + "MATSON", "normal", "TRANSIT", "IN_TRANSIT", TIME.plusHours(3), null, TIME, null, null, null, "e".repeat(64))); + service.applyProviderObservation(1, new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest( + "MATSON", "estimated", "ETA", "ARRIVED_DESTINATION_PORT", TIME.plusHours(3), TIME.plusDays(14), null, null, null, null, "e".repeat(64))); + assertThat(normalSnapshot().get("nodes")).isEqualTo(oldNodes); + service.applyProviderObservation(1, new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest( + "MATSON", "estimated-next", "ETA", "ARRIVED_DESTINATION_PORT", TIME.plusHours(3), TIME.plusDays(15), null, null, null, null, "e".repeat(64))); + service.applyProviderObservation(1, new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest( + "MATSON", "late-gate", "INGATE", "AT_ORIGIN_PORT", TIME.plusHours(3), null, TIME.minusDays(2), null, null, null, "e".repeat(64))); + assertThat(normalSnapshot().get("nodes")).containsAll(oldNodes).hasSize(4); + assertThat(service.detail(1).query().query().latestActualNode()).isEqualTo("IN_TRANSIT"); + assertThat(service.detail(1).query().query().version()).isEqualTo(7); + var before = allSnapshot(); + assertThatThrownBy(() -> service.applyProviderObservation(1, + new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest( + "MATSON", "normal", "TRANSIT", "IN_TRANSIT", TIME, null, TIME.plusDays(1), null, null, null, "c".repeat(64)))) + .isInstanceOf(com.baiyue.common.core.exception.BizException.class).hasMessageContaining("事件事实摘要"); + assertThat(allSnapshot()).isEqualTo(before); } @Test void failedHistoryWriteRollsBackExceptionAndOriginalRequestCanCommitAfterRepair() { + var before = allSnapshot(); 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(); + assertThat(allSnapshot()).isEqualTo(before); 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).histories()).hasSize(3); assertThat(service.detail(1).query().query().latestActualNode()).isEqualTo("IN_TRANSIT"); + assertThat(normalSnapshot()).isEqualTo(before.get("normal")); + } + + private List> exceptionRows() { + return jdbc.queryForList("SELECT * FROM fl_carrier_schedule_exception_fact ORDER BY id"); + } + + @Test + void identicalSubMillisecondProviderTimeReplaysAfterDatabasePrecisionRounding() { + var request = new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest( + "MATSON", "fractional", "INGATE", "AT_ORIGIN_PORT", TIME, null, + TIME.minusDays(1).withNano(999_700_000), null, null, null, "a".repeat(64)); + service.applyProviderObservation(1, request); + var before = allSnapshot(); + service.applyProviderObservation(1, request); + assertThat(allSnapshot()).isEqualTo(before); + assertThatThrownBy(() -> service.applyProviderObservation(1, + new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest( + request.providerCode(), request.providerEventId(), request.providerNodeCode(), request.standardNode(), TIME, + null, request.actualAt().plusNanos(1_000_000), null, null, null, request.rawPayloadHash()))) + .isInstanceOf(com.baiyue.common.core.exception.BizException.class).hasMessageContaining("事件事实摘要"); + assertThat(allSnapshot()).isEqualTo(before); + } + + private Map>> normalSnapshot() { + return Map.of("query", jdbc.queryForList("SELECT * FROM fl_carrier_schedule_query ORDER BY id"), + "nodes", jdbc.queryForList("SELECT * FROM fl_carrier_schedule_node_fact ORDER BY id"), + "normalHistory", jdbc.queryForList("SELECT * FROM fl_carrier_schedule_query_history WHERE action <> 'EXCEPTION_OBSERVED' ORDER BY id")); + } + + private Map allSnapshot() { + return Map.of("normal", normalSnapshot(), "exceptions", exceptionRows(), + "histories", jdbc.queryForList("SELECT * FROM fl_carrier_schedule_query_history ORDER BY id")); } private ProviderExceptionRequest request(String code, String eventId, boolean active, String hash) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java index 7449dfedd..1351048a4 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java @@ -566,11 +566,15 @@ class CarrierScheduleQueryServiceTest { } @Test - void providerEventIdReplayRequiresTheSameRawPayloadHash() { + void providerReplayAllowsChangedResponseAuditButRejectsChangedEventFacts() { CarrierScheduleQueryEntity task = task(0, 1); task.setLatestStandardNode("IN_TRANSIT"); CarrierScheduleNodeFactEntity existing = new CarrierScheduleNodeFactEntity(); existing.setEventKey("ZIM:evt-duplicate"); existing.setRawPayloadHash("a".repeat(64)); + existing.setContainerBookingId(task.getContainerBookingId()); + existing.setProviderCode("ZIM"); existing.setProviderEventId("evt-duplicate"); + existing.setProviderNodeCode("GATE_IN"); existing.setStandardNode("AT_ORIGIN_PORT"); + existing.setActualAt(NOW.minusDays(1)); existing.setObservedAt(NOW); existing.setSourceReference("ZIM:evt"); when(repository.lock(1L)).thenReturn(task); when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(false); when(repository.nodeByContainer(task.getContainerBookingId(), "ZIM:evt-duplicate")).thenReturn(existing); @@ -578,12 +582,15 @@ class CarrierScheduleQueryServiceTest { "AT_ORIGIN_PORT", NOW, null, NOW.minusDays(1), null, null, "ZIM:evt", "a".repeat(64)); service.applyProviderObservation(1L, replay); + service.applyProviderObservation(1L, new ProviderObservationRequest("ZIM", "evt-duplicate", "GATE_IN", + "AT_ORIGIN_PORT", NOW.plusHours(3), null, NOW.minusDays(1), null, null, "ZIM:evt", "b".repeat(64))); + assertThat(existing.getRawPayloadHash()).isEqualTo("a".repeat(64)); verify(nodeOutbox, never()).publish(any(), any(), anyString()); ProviderObservationRequest conflict = new ProviderObservationRequest("ZIM", "evt-duplicate", "GATE_IN", - "AT_ORIGIN_PORT", NOW, null, NOW.minusDays(1), null, null, "ZIM:evt", "b".repeat(64)); + "AT_ORIGIN_PORT", NOW, null, NOW.minusDays(2), null, null, "ZIM:evt", "a".repeat(64)); assertThatThrownBy(() -> service.applyProviderObservation(1L, conflict)) - .isInstanceOf(BizException.class).hasMessageContaining("不同rawPayloadHash"); + .isInstanceOf(BizException.class).hasMessageContaining("事件事实摘要"); } @Test diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/CarrierResponseAuditAssertions.java b/integration-service/src/test/java/com/baiyue/integration/provider/CarrierResponseAuditAssertions.java new file mode 100644 index 000000000..255125a99 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/CarrierResponseAuditAssertions.java @@ -0,0 +1,30 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.dto.CarrierScheduleDtos.Observation; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; + +/** The response audit must change; the stable per-event projection must not. */ +final class CarrierResponseAuditAssertions { + static void stableSharedFacts(List before, List after, String original, String changed) throws Exception { + assertThat(before).isNotEmpty(); assertThat(after).isNotEmpty(); + assertThat(before).allSatisfy(row -> assertThat(row.rawPayloadHash()).isEqualTo(hash(original))); + assertThat(after).allSatisfy(row -> assertThat(row.rawPayloadHash()).isEqualTo(hash(changed))); + int matches = 0; + for (Observation old : before) { + var matching = after.stream().filter(row -> row.providerEventId().equals(old.providerEventId())).toList(); + if (matching.isEmpty()) continue; // An updated estimate is a distinct fact under existing IDs. + assertThat(matching).hasSize(1); + assertThat(matching.get(0)).usingRecursiveComparison().ignoringFields("observedAt", "rawPayloadHash").isEqualTo(old); + assertThat(matching.get(0).rawPayloadHash()).isNotEqualTo(old.rawPayloadHash()); + matches++; + } + assertThat(matches).isPositive(); + } + private static String hash(String raw) throws Exception { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(raw.getBytes(StandardCharsets.UTF_8))); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/EvergreenCarrierScheduleProviderAdapterTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/EvergreenCarrierScheduleProviderAdapterTest.java index 2a09fb252..c07fb3918 100644 --- a/integration-service/src/test/java/com/baiyue/integration/provider/EvergreenCarrierScheduleProviderAdapterTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/provider/EvergreenCarrierScheduleProviderAdapterTest.java @@ -8,6 +8,19 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; class EvergreenCarrierScheduleProviderAdapterTest { + @Test + void addingThenRevisingEstimateLeavesActualEventStableAndAuditsWholeHtml() throws Exception { + var adapter = new EvergreenCarrierScheduleProviderAdapter(mock(EvergreenTrackingClient.class)); + var time = LocalDateTime.of(2026, 8, 21, 12, 0); + var first = adapter.observations("EGSU9319340", new EvergreenTrackingClient.TrackingResponse(CONTAINER_HTML, "", "0000", CONTAINER_HTML), time); + String raw = CONTAINER_HTML + BILL_HTML; + var second = adapter.observations("EGSU9319340", new EvergreenTrackingClient.TrackingResponse(CONTAINER_HTML, BILL_HTML, "0000", raw), time.plusHours(3)); + CarrierResponseAuditAssertions.stableSharedFacts(first, second, CONTAINER_HTML, raw); + String revised = BILL_HTML.replace("AUG-07", "AUG-08"); + var third = adapter.observations("EGSU9319340", new EvergreenTrackingClient.TrackingResponse(CONTAINER_HTML, revised, "0000", CONTAINER_HTML + revised), time.plusHours(6)); + CarrierResponseAuditAssertions.stableSharedFacts(second, third, raw, CONTAINER_HTML + revised); + assertThat(first).hasSize(1); assertThat(second).hasSize(2); + } private static final String CONTAINER_HTML = """
other
diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/HedeCarrierScheduleProviderAdapterTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/HedeCarrierScheduleProviderAdapterTest.java index f5cdfd456..3259e6a27 100644 --- a/integration-service/src/test/java/com/baiyue/integration/provider/HedeCarrierScheduleProviderAdapterTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/provider/HedeCarrierScheduleProviderAdapterTest.java @@ -9,6 +9,16 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; class HedeCarrierScheduleProviderAdapterTest { + @Test + void appendedLoadingAndChangedEtaPreserveGateEventProjection() throws Exception { + String raw = row("FFAU4879673", "2026-08-05 04:24", "2026-08-22 00:00", "2026-07-30 21:31", "2026-08-01 15:55", "", "", "", ""); + String next = row("FFAU4879673", "2026-08-05 04:24", "2026-08-23 00:00", "2026-07-30 21:31", "2026-08-01 15:55", "2026-08-04 19:45", "", "", ""); + var adapter = new HedeCarrierScheduleProviderAdapter(mock(HedeTrackingClient.class)); + var before = adapter.observations("FFAU4879673", raw); + var after = adapter.observations("FFAU4879673", next); + CarrierResponseAuditAssertions.stableSharedFacts(before, after, raw, next); + assertThat(after).hasSize(before.size() + 1); + } @Test void parsesConfirmedMainTableColumnsAndDoesNotTurnEtaIntoAnActualNode() throws Exception { String html = row("XYLU8251457", "2026-08-20 00:00", "2026-09-09 00:00", diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/MatsonCarrierScheduleProviderAdapterTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/MatsonCarrierScheduleProviderAdapterTest.java index c352c07d2..881bd6d00 100644 --- a/integration-service/src/test/java/com/baiyue/integration/provider/MatsonCarrierScheduleProviderAdapterTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/provider/MatsonCarrierScheduleProviderAdapterTest.java @@ -13,6 +13,28 @@ import static org.mockito.Mockito.when; class MatsonCarrierScheduleProviderAdapterTest { private final ObjectMapper objectMapper = new ObjectMapper(); + @Test + void appendedEventAndChangedEtaKeepOldNodeAndHoldFactsButChangeResponseAudit() throws Exception { + String raw = """ + {"bookingNumber":"MATSON-REAL","containerNumber":"MATU459895","checkDigit":"6", + "originPort":"NINGBO","destPort":"LONG BEACH","originCode":"NGB","destCode":"LAX","vesselETA":"08/24/26", + "events":[{"date":1786940100000,"location":"NINGBO","status":"INGATE"}, + {"date":1787160480000,"location":"","status":"CONTAINER ON HOLD - Customs Clearance"}]} + """; + String next = raw.replace("08/24/26", "08/25/26").replace("]}", ",{\"date\":1787033640000,\"location\":\"NINGBO\",\"status\":\"LOAD TO VESSEL\"}]}"); + var adapter = new MatsonCarrierScheduleProviderAdapter(mock(MatsonTrackingClient.class)); + var query = new QueryRequest("MATSON", "CONTAINER_NO", "MATU4598956"); + var time = java.time.LocalDateTime.of(2026, 8, 21, 12, 0); + var before = adapter.observations(query, objectMapper.readTree(raw), raw, time); + var after = adapter.observations(query, objectMapper.readTree(next), next, time.plusHours(3)); + CarrierResponseAuditAssertions.stableSharedFacts(before, after, raw, next); + assertThat(after).hasSize(before.size() + 1); + var hold = adapter.exceptions(query, objectMapper.readTree(raw), raw, time).get(0); + var replay = adapter.exceptions(query, objectMapper.readTree(next), next, time.plusHours(3)).get(0); + assertThat(replay).usingRecursiveComparison().ignoringFields("observedAt", "rawPayloadHash").isEqualTo(hold); + assertThat(replay.rawPayloadHash()).isNotEqualTo(hold.rawPayloadHash()); + } + @Test void realMatu4646247FixtureProducesTwoActualsAndEtaOnly() throws Exception { String raw = """ diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/SmlCarrierScheduleProviderAdapterTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/SmlCarrierScheduleProviderAdapterTest.java index 517556227..3bd9ac178 100644 --- a/integration-service/src/test/java/com/baiyue/integration/provider/SmlCarrierScheduleProviderAdapterTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/provider/SmlCarrierScheduleProviderAdapterTest.java @@ -11,6 +11,20 @@ import static org.mockito.Mockito.when; class SmlCarrierScheduleProviderAdapterTest { private final ObjectMapper objectMapper = new ObjectMapper(); + @Test + void appendedMilestoneAndRevisedEstimateDoNotChangeExistingEventProjection() throws Exception { + String raw = """ + {"events":[{"actCd":"FOTMAD","actTpCd":"A","eventDt":"2026-08-01 15:55"}, + {"actCd":"FUVMAD","actTpCd":"E","eventDt":"2026-08-23 14:00"}]} + """; + String next = raw.replace("08-23", "08-24").replace("]}", ",{\"actCd\":\"FLVMLO\",\"actTpCd\":\"A\",\"eventDt\":\"2026-08-04 19:45\"}]}"); + var adapter = new SmlCarrierScheduleProviderAdapter(mock(SmlTrackingClient.class)); + var before = adapter.observations("SMCU7019890", new SmlTrackingClient.TrackingResponse("BKG", "COP", objectMapper.readTree(raw), objectMapper.createObjectNode(), objectMapper.createObjectNode(), raw)); + var after = adapter.observations("SMCU7019890", new SmlTrackingClient.TrackingResponse("BKG", "COP", objectMapper.readTree(next), objectMapper.createObjectNode(), objectMapper.createObjectNode(), next)); + CarrierResponseAuditAssertions.stableSharedFacts(before, after, raw, next); + assertThat(after).hasSize(before.size() + 1); + } + @Test void sevenSampleBoundaryUsesActTypeAndLeavesFitmdoUnmapped() throws Exception { String timeline = """ diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/ZimCarrierScheduleProviderAdapterTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/ZimCarrierScheduleProviderAdapterTest.java index 9bb1064a6..254682bcf 100644 --- a/integration-service/src/test/java/com/baiyue/integration/provider/ZimCarrierScheduleProviderAdapterTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/provider/ZimCarrierScheduleProviderAdapterTest.java @@ -11,6 +11,23 @@ import static org.mockito.Mockito.mock; class ZimCarrierScheduleProviderAdapterTest { private final ObjectMapper objectMapper = new ObjectMapper(); + @Test + void additionalContainerActivityDoesNotChangePreviousEventProjection() throws Exception { + String raw = """ + {"bookingReference":"BK-STRING-001","blReference":"BL-STRING-001", + "finalETA":{"etaValue":"2026-08-23T08:00:00","etaValueDate":"2026-08-23","podInd":"ETA"}, + "bkBlDetails":{"consContainerList":[{"containerNumber":"ZCSU7205614","containerEventsList":[ + {"activityDateTz":"2026-08-03T02:00:00Z","eventName":"Export gate-in from Customer to Port of Loading","portCode":"CNNGB","port":"Ningbo","country":"CN"}]}]}} + """; + String next = raw.replace("2026-08-23", "2026-08-24").replace("}]}]}}", "},{\"activityDateTz\":\"2026-08-05T03:00:00Z\",\"eventName\":\"Container was loaded at Port of Loading to Port of Discharge\",\"portCode\":\"CNNGB\",\"port\":\"Ningbo\",\"country\":\"CN\"}]}]}}"); + var adapter = new ZimCarrierScheduleProviderAdapter(mock(ZimTrackingClient.class)); + var time = LocalDateTime.of(2026, 8, 21, 12, 0); + var before = adapter.observations("ZCSU7205614", new ZimTrackingClient.TrackingResponse(objectMapper.readValue(raw, ZimTrackingClient.TracingPayload.class), raw), time); + var after = adapter.observations("ZCSU7205614", new ZimTrackingClient.TrackingResponse(objectMapper.readValue(next, ZimTrackingClient.TracingPayload.class), next), time.plusHours(3)); + CarrierResponseAuditAssertions.stableSharedFacts(before, after, raw, next); + assertThat(after).hasSize(before.size() + 1); + } + @Test void v2FixtureSelectsOnlyTheExactContainerAndSeparatesActualFromPlans() throws Exception { String fixture = """