fix(firstleg): compare stable carrier facts separately from response audit hashes

This commit is contained in:
2026-10-03 00:39:57 +08:00
parent 9803dcfd2c
commit 4cd51a4955
9 changed files with 266 additions and 23 deletions
@@ -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)));
}
}
@@ -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>
@@ -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",
@@ -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 = """
@@ -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 = """
@@ -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 = """