fix(firstleg): compare stable carrier facts separately from response audit hashes
This commit is contained in:
+24
-4
@@ -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) {
|
||||
|
||||
+126
-16
@@ -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<Map<String, Object>> 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<String, List<Map<String, Object>>> 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<String, Object> 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) {
|
||||
|
||||
+10
-3
@@ -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
|
||||
|
||||
+30
@@ -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<Observation> before, List<Observation> 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)));
|
||||
}
|
||||
}
|
||||
+13
@@ -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 = """
|
||||
<html><nav>irrelevant navigation</nav><table><tr><td>other</td></tr></table>
|
||||
<table class="tracking"><tr>
|
||||
|
||||
+10
@@ -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",
|
||||
|
||||
+22
@@ -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 = """
|
||||
|
||||
+14
@@ -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 = """
|
||||
|
||||
+17
@@ -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 = """
|
||||
|
||||
Reference in New Issue
Block a user