优化
This commit is contained in:
+74
@@ -0,0 +1,74 @@
|
||||
package com.baiyue.integration.provider;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
class EvergreenCarrierScheduleProviderAdapterTest {
|
||||
private static final String CONTAINER_HTML = """
|
||||
<html><nav>irrelevant navigation</nav><table><tr><td>other</td></tr></table>
|
||||
<table class="tracking"><tr>
|
||||
<th>Method</th><th>地点</th><th>箱号</th><th>货柜动态</th><th>日期</th><th>柜型</th><th>VGM</th><th>船名航次</th>
|
||||
</tr><tr>
|
||||
<td>CY</td><td>NINGBO, CHINA (CN)</td><td> EGSU9319340 </td><td>Loaded (FCL) on vessel</td>
|
||||
<td>AUG-07-2026</td><td>40HQ</td><td></td><td>EVER LOVELY 1232-067E</td>
|
||||
</tr><tr>
|
||||
<td>CY</td><td>NINGBO, CHINA (CN)</td><td>EITU1262610</td><td>Loaded (FCL) on vessel</td>
|
||||
<td>AUG-06-2026</td><td>40HQ</td><td></td><td>EVER LOVELY 1232-067E</td>
|
||||
</tr></table></html>
|
||||
""";
|
||||
private static final String BILL_HTML = """
|
||||
<html><table><tr><td>预计装船日</td><td>AUG-07-2026</td></tr></table></html>
|
||||
""";
|
||||
|
||||
@Test
|
||||
void mapsTheExactContainerRowAndPreservesDayPrecisionWithoutUsingObservedAtAsActual() {
|
||||
var response = new EvergreenTrackingClient.TrackingResponse(CONTAINER_HTML, BILL_HTML, "0000",
|
||||
CONTAINER_HTML + BILL_HTML);
|
||||
LocalDateTime queriedAt = LocalDateTime.of(2026, 8, 21, 15, 30);
|
||||
|
||||
var values = new EvergreenCarrierScheduleProviderAdapter(mock(EvergreenTrackingClient.class))
|
||||
.observations("egsu9319340", response, queriedAt);
|
||||
|
||||
assertThat(values).hasSize(2);
|
||||
assertThat(values).extracting(value -> value.observedAt()).containsOnly(queriedAt);
|
||||
assertThat(values).filteredOn(value -> value.actualAt() != null).singleElement().satisfies(value -> {
|
||||
assertThat(value.standardNode()).isEqualTo("LOADED_ON_VESSEL");
|
||||
assertThat(value.actualAt()).isEqualTo(LocalDateTime.of(2026, 8, 7, 0, 0));
|
||||
assertThat(value.actualAt()).isNotEqualTo(queriedAt);
|
||||
assertThat(value.locationCode()).isEqualTo("CN");
|
||||
assertThat(value.locationName()).isEqualTo("NINGBO, CHINA (CN)");
|
||||
assertThat(value.sourceReference()).contains("DATE:AUG-07-2026", "PRECISION:DAY", "ZONE:Asia/Shanghai")
|
||||
.doesNotContain("000000000000");
|
||||
});
|
||||
assertThat(values).filteredOn(value -> value.estimatedAt() != null).singleElement()
|
||||
.satisfies(value -> assertThat(value.estimatedAt()).isEqualTo(LocalDateTime.of(2026, 8, 7, 0, 0)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void secondConfirmedFixtureRowUsesItsOwnDateAndUnknownStatusesStayUnmapped() {
|
||||
var response = new EvergreenTrackingClient.TrackingResponse(CONTAINER_HTML, "<html><table></table></html>",
|
||||
"0000", CONTAINER_HTML);
|
||||
var values = new EvergreenCarrierScheduleProviderAdapter(mock(EvergreenTrackingClient.class))
|
||||
.observations("EITU1262610", response, LocalDateTime.of(2026, 8, 21, 15, 30));
|
||||
|
||||
assertThat(values).singleElement().satisfies(value -> {
|
||||
assertThat(value.actualAt()).isEqualTo(LocalDateTime.of(2026, 8, 6, 0, 0));
|
||||
assertThat(value.standardNode()).isEqualTo("LOADED_ON_VESSEL");
|
||||
});
|
||||
assertThat(EvergreenCarrierScheduleProviderAdapter.standardNode("Loaded (FCL) on vessel"))
|
||||
.isEqualTo("LOADED_ON_VESSEL");
|
||||
assertThat(EvergreenCarrierScheduleProviderAdapter.standardNode("Discharged from vessel")).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingStableHeaderOrExactContainerRowIsNotParsed() {
|
||||
assertThat(EvergreenCarrierScheduleProviderAdapter.trackingRow(
|
||||
"<table><tr><th>箱号</th><th>日期</th></tr><tr><td>EGSU9319340</td><td>AUG-07-2026</td></tr></table>",
|
||||
"EGSU9319340")).isNull();
|
||||
assertThat(EvergreenCarrierScheduleProviderAdapter.trackingRow(CONTAINER_HTML, "OTHER0000000")).isNull();
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package com.baiyue.integration.provider;
|
||||
|
||||
import com.baiyue.integration.config.IntegrationProperties;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class EvergreenTrackingClientTest {
|
||||
private HttpServer server;
|
||||
|
||||
@AfterEach
|
||||
void stopServer() { if (server != null) server.stop(0); }
|
||||
|
||||
@Test
|
||||
void executesContainerThenRelatedBillPostsWithoutExposingTheFullBill() throws Exception {
|
||||
List<String> forms = new ArrayList<>();
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/track", exchange -> {
|
||||
String form = body(exchange); forms.add(form);
|
||||
String html = form.startsWith("TYPE=CNTR")
|
||||
? "<html><input value='000000000000' name='bl_no'><table></table></html>"
|
||||
: "<html><table><tr><td>预计装船日</td><td>AUG-07-2026</td></tr></table></html>";
|
||||
respond(exchange, 200, html);
|
||||
});
|
||||
server.start();
|
||||
|
||||
var result = client().query(new QueryRequest("EMC", "CONTAINER_NO", "EGSU9319340"));
|
||||
|
||||
assertThat(forms).containsExactly("TYPE=CNTR&CNTR=EGSU9319340", "TYPE=BL&BL=000000000000");
|
||||
assertThat(result.billSuffix()).isEqualTo("0000");
|
||||
assertThat(result.toString()).doesNotContain("000000000000");
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractsTheRelatedBillFromHiddenInputOrDispInfoLink() {
|
||||
assertThat(EvergreenTrackingClient.relatedBill(
|
||||
"<input VALUE=\"000000000000\" NAME=\"bl_no\">" )).isEqualTo("000000000000");
|
||||
assertThat(EvergreenTrackingClient.relatedBill(
|
||||
"<a href=\"javascript:GetDispInfo('000000000000')\">detail</a>" )).isEqualTo("000000000000");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonHtmlAndMissingRelatedBillBecomeTraceableManualFailures() throws Exception {
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/track", exchange -> respond(exchange, 200, "{\"unexpected\":true}"));
|
||||
server.start();
|
||||
assertThatThrownBy(() -> client().query(new QueryRequest("EMC", "CONTAINER_NO", "EGSU9319340")))
|
||||
.isInstanceOfSatisfying(EvergreenTrackingClient.EvergreenTrackingException.class,
|
||||
value -> assertThat(value.code()).isEqualTo("EMC_CONTAINER_NON_HTML"));
|
||||
|
||||
server.stop(0);
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/track", exchange -> respond(exchange, 200, "<html><table></table></html>"));
|
||||
server.start();
|
||||
assertThatThrownBy(() -> client().query(new QueryRequest("EMC", "CONTAINER_NO", "EGSU9319340")))
|
||||
.isInstanceOfSatisfying(EvergreenTrackingClient.EvergreenTrackingException.class,
|
||||
value -> assertThat(value.code()).isEqualTo("EMC_RELATED_BL_MISSING"));
|
||||
}
|
||||
|
||||
private EvergreenTrackingClient client() {
|
||||
var evergreen = new IntegrationProperties.CarrierSchedule.Evergreen(true,
|
||||
URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/track"),
|
||||
Duration.ofSeconds(2), Duration.ofSeconds(2), Duration.ZERO);
|
||||
EvergreenTrackingClient client = new EvergreenTrackingClient();
|
||||
ReflectionTestUtils.setField(client, "properties", new IntegrationProperties(null, null,
|
||||
new IntegrationProperties.CarrierSchedule(null, null, null, null, evergreen)));
|
||||
client.initialize(); return client;
|
||||
}
|
||||
private static String body(HttpExchange exchange) throws IOException {
|
||||
return new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
private static void respond(HttpExchange exchange, int status, String body) throws IOException {
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().add("Content-Type", "text/html; charset=utf-8");
|
||||
exchange.sendResponseHeaders(status, bytes.length); exchange.getResponseBody().write(bytes); exchange.close();
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package com.baiyue.integration.provider;
|
||||
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
|
||||
import com.baiyue.integration.provider.HedeTrackingClient.TrackingResponse;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class HedeCarrierScheduleProviderAdapterTest {
|
||||
@Test
|
||||
void parsesConfirmedMainTableColumnsAndDoesNotTurnEtaIntoAnActualNode() throws Exception {
|
||||
String html = row("XYLU8251457", "2026-08-20 00:00", "2026-09-09 00:00",
|
||||
"", "", "", "", "", "");
|
||||
|
||||
var rows = HedeCarrierScheduleProviderAdapter.rows(html);
|
||||
HedeTrackingClient client = mock(HedeTrackingClient.class);
|
||||
HedeCarrierScheduleProviderAdapter adapter = new HedeCarrierScheduleProviderAdapter(client);
|
||||
|
||||
assertThat(rows).singleElement().satisfies(value -> {
|
||||
assertThat(value.containerNo()).isEqualTo("XYLU8251457");
|
||||
assertThat(value.vessel()).isEqualTo("POSEN");
|
||||
assertThat(value.voyage()).isEqualTo("2607W");
|
||||
assertThat(value.estimatedDeparture()).isEqualTo("2026-08-20 00:00");
|
||||
assertThat(value.estimatedArrival()).isEqualTo("2026-09-09 00:00");
|
||||
});
|
||||
assertThat(adapter.observations("XYLU8251457", html)).extracting(value -> value.standardNode())
|
||||
.containsExactlyInAnyOrder("DEPARTED_ORIGIN_PORT", "ARRIVED_DESTINATION_PORT");
|
||||
assertThat(adapter.observations("XYLU8251457", html)).allSatisfy(value -> {
|
||||
assertThat(value.estimatedAt()).isNotNull();
|
||||
assertThat(value.actualAt()).isNull();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsOnlyActualHedeMilestonesForTheRequestedContainer() {
|
||||
String html = row("FFAU4879673", "2026-08-05 04:24", "2026-08-22 00:00",
|
||||
"2026-07-30 21:31", "2026-08-01 15:55", "2026-08-04 19:45", "", "", "");
|
||||
HedeTrackingClient client = mock(HedeTrackingClient.class);
|
||||
when(client.configured()).thenReturn(true);
|
||||
when(client.query(new QueryRequest("HEDE", "CONTAINER_NO", "FFAU4879673")))
|
||||
.thenReturn(new TrackingResponse(html));
|
||||
HedeCarrierScheduleProviderAdapter adapter = new HedeCarrierScheduleProviderAdapter(client);
|
||||
|
||||
var result = adapter.query(new QueryRequest("HEDE", "CONTAINER_NO", "FFAU4879673"));
|
||||
|
||||
assertThat(result.outcome()).isEqualTo("SUCCESS");
|
||||
assertThat(result.observations()).extracting(value -> value.standardNode())
|
||||
.containsExactlyInAnyOrder("DEPARTED_ORIGIN_PORT", "ARRIVED_DESTINATION_PORT", "AT_ORIGIN_PORT",
|
||||
"LOADED_ON_VESSEL");
|
||||
assertThat(result.observations()).allSatisfy(value -> {
|
||||
assertThat(value.providerEventId()).hasSize(64);
|
||||
assertThat(value.rawPayloadHash()).hasSize(64);
|
||||
assertThat(value.sourceReference()).contains("FFAU4879673").contains("POSEN/2607W");
|
||||
});
|
||||
assertThat(result.observations()).extracting(value -> value.observedAt())
|
||||
.containsOnly(result.observations().get(0).observedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void actualEmptyReturnIsMappedPrecisely() throws Exception {
|
||||
String html = row("FFAU4879673", "2026-08-05 04:24", "2026-08-22 00:00",
|
||||
"2026-07-30 21:31", "2026-08-01 15:55", "2026-08-04 19:45", "", "",
|
||||
"2026-08-28 08:00");
|
||||
HedeTrackingClient client = mock(HedeTrackingClient.class);
|
||||
var result = new HedeCarrierScheduleProviderAdapter(client).observations("FFAU4879673", html);
|
||||
assertThat(result).filteredOn(value -> "EMPTY_RETURNED".equals(value.standardNode()))
|
||||
.singleElement().satisfies(value -> {
|
||||
assertThat(value.actualAt()).isNotNull();
|
||||
assertThat(value.estimatedAt()).isNull();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesAmbiguousBillLevelAutomation() {
|
||||
HedeTrackingClient client = mock(HedeTrackingClient.class);
|
||||
HedeCarrierScheduleProviderAdapter adapter = new HedeCarrierScheduleProviderAdapter(client);
|
||||
|
||||
var result = adapter.query(new QueryRequest("HEDE", "BILL_OF_LADING", "HDE123"));
|
||||
|
||||
assertThat(result.outcome()).isEqualTo("PERMANENT_FAILURE");
|
||||
assertThat(result.errorCode()).isEqualTo("HEDE_CONTAINER_REQUIRED");
|
||||
}
|
||||
|
||||
private static String row(String container, String etd, String eta, String emptyOut, String fullIn,
|
||||
String loaded, String discharged, String fullOut, String emptyReturned) {
|
||||
return "<table><tbody><tr class=\"read-tr\">"
|
||||
+ td("***") + td(container) + td("POSEN") + td("2607W") + td(etd) + td(eta)
|
||||
+ td(emptyOut) + td(fullIn) + td(loaded) + td(discharged) + td(fullOut) + td(emptyReturned)
|
||||
+ td("查看") + "</tr></tbody></table>";
|
||||
}
|
||||
|
||||
private static String td(String value) { return "<td>" + value + "</td>"; }
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.baiyue.integration.provider;
|
||||
|
||||
import com.baiyue.integration.config.IntegrationProperties;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class HedeTrackingClientTest {
|
||||
private HttpServer server;
|
||||
|
||||
@AfterEach
|
||||
void stopServer() { if (server != null) server.stop(0); }
|
||||
|
||||
@Test
|
||||
void postsTheConfirmedHedeFormAsUtf8() throws Exception {
|
||||
AtomicReference<String> postedBody = new AtomicReference<>();
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/getVBilldynamic", exchange -> {
|
||||
postedBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
|
||||
respond(exchange, 200, "<html><table><tr class=\"read-tr\"><td>***</td>"
|
||||
+ "<td>XYLU8251457</td></tr></table></html>");
|
||||
});
|
||||
server.start();
|
||||
|
||||
var hede = new IntegrationProperties.CarrierSchedule.Hede(true,
|
||||
URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/getVBilldynamic"),
|
||||
Duration.ofSeconds(2), Duration.ofSeconds(2), Duration.ZERO);
|
||||
HedeTrackingClient client = new HedeTrackingClient();
|
||||
ReflectionTestUtils.setField(client, "properties", new IntegrationProperties(null, null,
|
||||
new IntegrationProperties.CarrierSchedule(null, hede, null, null, null)));
|
||||
client.initialize();
|
||||
|
||||
var response = client.query(new QueryRequest("HEDE", "CONTAINER_NO", "XYLU8251457"));
|
||||
|
||||
assertThat(response.html()).contains("XYLU8251457");
|
||||
assertThat(postedBody.get()).isEqualTo(
|
||||
"billno=&cntr=XYLU8251457&selecttype=1&resultlist=1¶mtype=0");
|
||||
}
|
||||
|
||||
private static void respond(HttpExchange exchange, int status, String body) throws IOException {
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().add("Content-Type", "text/html; charset=UTF-8");
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package com.baiyue.integration.provider;
|
||||
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
|
||||
import com.baiyue.integration.provider.MatsonTrackingClient.TrackingResponse;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.Instant;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class MatsonCarrierScheduleProviderAdapterTest {
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void realMatu4646247FixtureProducesTwoActualsAndEtaOnly() throws Exception {
|
||||
String raw = """
|
||||
{"bookingNumber":"MATSON-REAL","containerNumber":"MATU464624","checkDigit":"7",
|
||||
"originPort":"LONG BEACH","destPort":"HONOLULU","originCode":"LAX","destCode":"HIL",
|
||||
"loadPort":"LONG BEACH","dischargePort":"HONOLULU","vesselETA":"08/25/26","events":[
|
||||
{"date":%d,"location":"LONG BEACH","status":"INGATE"},
|
||||
{"date":%d,"location":"LONG BEACH","status":"LOAD TO VESSEL"}]}
|
||||
""".formatted(Instant.parse("2026-08-18T19:15:00Z").toEpochMilli(),
|
||||
Instant.parse("2026-08-18T22:12:00Z").toEpochMilli());
|
||||
MatsonTrackingClient client = mock(MatsonTrackingClient.class);
|
||||
when(client.configured()).thenReturn(true);
|
||||
when(client.query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU4646247")))
|
||||
.thenReturn(new TrackingResponse(objectMapper.readTree(raw), raw));
|
||||
|
||||
var result = new MatsonCarrierScheduleProviderAdapter(client)
|
||||
.query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU4646247"));
|
||||
|
||||
assertThat(result.observations()).extracting(value -> value.standardNode())
|
||||
.containsExactlyInAnyOrder("AT_ORIGIN_PORT", "LOADED_ON_VESSEL", "ARRIVED_DESTINATION_PORT");
|
||||
assertThat(result.observations()).filteredOn(value -> value.actualAt() != null).hasSize(2);
|
||||
assertThat(result.observations()).filteredOn(value -> value.estimatedAt() != null).singleElement();
|
||||
assertThat(result.observations()).extracting(value -> value.observedAt())
|
||||
.containsOnly(result.observations().get(0).observedAt());
|
||||
assertThat(result.exceptions()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void conservativelyMapsKnownMatsonEventsWithoutTreatingLoadAsDeparture() throws Exception {
|
||||
String raw = """
|
||||
{"bookingNumber":"9974749","containerNumber":"MATU459895","checkDigit":"6",
|
||||
"originPort":"NINGBO","destPort":"LONG BEACH","originCode":"NGB","destCode":"LAX",
|
||||
"vesselETA":"08/24/26",
|
||||
"loadPort":"NINGBO","dischargePort":"LONG BEACH","events":[
|
||||
{"date":1787160480000,"location":"","status":"CONTAINER ON HOLD - Customs Clearance"},
|
||||
{"date":1787033640000,"location":"NINGBO","status":"LOAD TO VESSEL"},
|
||||
{"date":1786940100000,"location":"NINGBO","status":"INGATE"}]}
|
||||
""";
|
||||
MatsonTrackingClient client = mock(MatsonTrackingClient.class);
|
||||
when(client.configured()).thenReturn(true);
|
||||
when(client.query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU4598956")))
|
||||
.thenReturn(new TrackingResponse(objectMapper.readTree(raw), raw));
|
||||
MatsonCarrierScheduleProviderAdapter adapter = new MatsonCarrierScheduleProviderAdapter(client);
|
||||
|
||||
var result = adapter.query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU4598956"));
|
||||
|
||||
assertThat(result.outcome()).isEqualTo("SUCCESS");
|
||||
assertThat(result.observations()).extracting(value -> value.standardNode())
|
||||
.containsExactlyInAnyOrder("AT_ORIGIN_PORT", "LOADED_ON_VESSEL", "ARRIVED_DESTINATION_PORT");
|
||||
assertThat(result.observations()).filteredOn(value -> "ARRIVED_DESTINATION_PORT".equals(value.standardNode()))
|
||||
.singleElement().satisfies(value -> {
|
||||
assertThat(value.estimatedAt()).isNotNull();
|
||||
assertThat(value.actualAt()).isNull();
|
||||
});
|
||||
assertThat(result.exceptions()).singleElement().satisfies(value -> {
|
||||
assertThat(value.exceptionCode()).isEqualTo("CUSTOMS_HOLD");
|
||||
assertThat(value.active()).isTrue();
|
||||
assertThat(value.providerStatus()).isEqualTo("CONTAINER ON HOLD - Customs Clearance");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsDestinationDischargeAndAvailabilityWithoutUsingEtaAsActualArrival() {
|
||||
assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("LOAD TO VESSEL"))
|
||||
.isEqualTo("LOADED_ON_VESSEL");
|
||||
assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("DISCHARGE FROM VESSEL"))
|
||||
.isEqualTo("DISCHARGED_FROM_VESSEL");
|
||||
assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("AVAILABLE FOR PICKUP"))
|
||||
.isEqualTo("PICKUP_READY");
|
||||
assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("OUTGATE")).isEqualTo("PICKED_UP");
|
||||
assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("EMPTY RETURNED"))
|
||||
.isEqualTo("EMPTY_RETURNED");
|
||||
assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("PICKUP APPOINTMENT"))
|
||||
.isNull();
|
||||
assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("CONTAINER ON HOLD - Customs Clearance"))
|
||||
.isNull();
|
||||
assertThat(MatsonCarrierScheduleProviderAdapter.exceptionCode(
|
||||
"CONTAINER ON HOLD - Customs Clearance")).isEqualTo("CUSTOMS_HOLD");
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyBusinessObjectReturnsNoFacts() throws Exception {
|
||||
String raw = "{}";
|
||||
MatsonTrackingClient client = mock(MatsonTrackingClient.class);
|
||||
when(client.configured()).thenReturn(true);
|
||||
when(client.query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU5164086")))
|
||||
.thenReturn(new TrackingResponse(objectMapper.readTree(raw), raw));
|
||||
var result = new MatsonCarrierScheduleProviderAdapter(client)
|
||||
.query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU5164086"));
|
||||
assertThat(result.outcome()).isEqualTo("NO_CHANGE");
|
||||
assertThat(result.observations()).isEmpty();
|
||||
assertThat(result.exceptions()).isEmpty();
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package com.baiyue.integration.provider;
|
||||
|
||||
import com.baiyue.integration.config.IntegrationProperties;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class MatsonTrackingClientTest {
|
||||
private HttpServer server;
|
||||
|
||||
@AfterEach
|
||||
void stopServer() {
|
||||
if (server != null) server.stop(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void obtainsDynamicTtrAndCookieBeforePostingAjaxForm() throws Exception {
|
||||
AtomicReference<String> postedBody = new AtomicReference<>();
|
||||
AtomicReference<String> postedCookie = new AtomicReference<>();
|
||||
AtomicReference<String> requestedWith = new AtomicReference<>();
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/page", exchange -> {
|
||||
exchange.getResponseHeaders().add("Set-Cookie", "matson-session=abc123; Path=/");
|
||||
respond(exchange, 200, "<input type=\"hidden\" id=\"ttr\" name=\"ttr\" value=\"dynamic123\" />");
|
||||
});
|
||||
server.createContext("/query", exchange -> {
|
||||
postedBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
|
||||
postedCookie.set(exchange.getRequestHeaders().getFirst("Cookie"));
|
||||
requestedWith.set(exchange.getRequestHeaders().getFirst("X-Requested-With"));
|
||||
respond(exchange, 200, "{\"containerNumber\":\"MATU459895\",\"checkDigit\":\"6\",\"events\":[]}");
|
||||
});
|
||||
server.start();
|
||||
|
||||
int port = server.getAddress().getPort();
|
||||
var matson = new IntegrationProperties.CarrierSchedule.Matson(true,
|
||||
URI.create("http://127.0.0.1:" + port + "/page"),
|
||||
URI.create("http://127.0.0.1:" + port + "/query"),
|
||||
Duration.ofSeconds(2), Duration.ofSeconds(2), Duration.ZERO);
|
||||
MatsonTrackingClient client = new MatsonTrackingClient();
|
||||
ReflectionTestUtils.setField(client, "properties", new IntegrationProperties(null, null,
|
||||
new IntegrationProperties.CarrierSchedule(matson, null, null, null, null)));
|
||||
ReflectionTestUtils.setField(client, "objectMapper", new ObjectMapper());
|
||||
client.initialize();
|
||||
|
||||
var result = client.query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU4598956"));
|
||||
|
||||
assertThat(result.payload().path("containerNumber").asText()).isEqualTo("MATU459895");
|
||||
assertThat(postedBody.get()).contains("container_trackingnumber=MATU4598956")
|
||||
.contains("trackingVal=tckconta").contains("ttr=dynamic123");
|
||||
assertThat(postedCookie.get()).contains("matson-session=abc123");
|
||||
assertThat(requestedWith.get()).isEqualTo("XMLHttpRequest");
|
||||
}
|
||||
|
||||
private static void respond(HttpExchange exchange, int status, String body) throws IOException {
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package com.baiyue.integration.provider;
|
||||
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class SmlCarrierScheduleProviderAdapterTest {
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void sevenSampleBoundaryUsesActTypeAndLeavesFitmdoUnmapped() throws Exception {
|
||||
String timeline = """
|
||||
{"TRANS_RESULT_KEY":"S","count":9,"events":[
|
||||
{"actCd":"FOTMAD","actTpCd":"A","eventDt":"2026-08-01 15:55"},
|
||||
{"actCd":"FLVMLO","actTpCd":"A","eventDt":"2026-08-04 19:45"},
|
||||
{"actCd":"FLVMDO","actTpCd":"A","eventDt":"2026-08-07 17:00"},
|
||||
{"actCd":"FUVMAD","actTpCd":"E","eventDt":"2026-08-23 14:00"},
|
||||
{"actCd":"FUVMBD","actTpCd":"E","eventDt":"2026-08-23 16:00"},
|
||||
{"actCd":"FUVMUD","actTpCd":"E","eventDt":"2026-08-24 08:00"},
|
||||
{"actCd":"FITMDO","actTpCd":"E","eventDt":"2026-08-25 08:00"},
|
||||
{"actCd":"MITYAD","actTpCd":"E","eventDt":"2026-09-01 08:00"},
|
||||
{"actCd":"MOTYDO","actTpCd":"A","eventDt":"2026-07-28 08:00"}]}
|
||||
""";
|
||||
SmlTrackingClient client = mock(SmlTrackingClient.class);
|
||||
when(client.configured()).thenReturn(true);
|
||||
var response = new SmlTrackingClient.TrackingResponse("SMLBKG", "COP1",
|
||||
objectMapper.readTree(timeline), objectMapper.createObjectNode(), objectMapper.createObjectNode(), timeline);
|
||||
when(client.query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890"))).thenReturn(response);
|
||||
|
||||
var result = new SmlCarrierScheduleProviderAdapter(client)
|
||||
.query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890"));
|
||||
|
||||
assertThat(result.observations()).hasSize(7);
|
||||
assertThat(result.observations()).filteredOn(value -> value.actualAt() != null)
|
||||
.extracting(value -> value.standardNode())
|
||||
.containsExactlyInAnyOrder("AT_ORIGIN_PORT", "LOADED_ON_VESSEL", "DEPARTED_ORIGIN_PORT");
|
||||
assertThat(result.observations()).filteredOn(value -> value.estimatedAt() != null).hasSize(4);
|
||||
assertThat(result.observations()).extracting(value -> value.observedAt())
|
||||
.containsOnly(result.observations().get(0).observedAt());
|
||||
assertThat(result.observations()).extracting(value -> value.providerNodeCode())
|
||||
.doesNotContain("FITMDO", "MOTYDO");
|
||||
}
|
||||
|
||||
@Test
|
||||
void onlyActualMityadCanBecomeActualEmptyReturn() throws Exception {
|
||||
String timeline = """
|
||||
{"events":[
|
||||
{"actCd":"MITYAD","actTpCd":"E","eventDt":"2026-09-01 08:00"},
|
||||
{"actCd":"MITYAD","actTpCd":"A","eventDt":"2026-09-02 09:00"}]}
|
||||
""";
|
||||
var response = new SmlTrackingClient.TrackingResponse("BKG", "COP", objectMapper.readTree(timeline),
|
||||
objectMapper.createObjectNode(), objectMapper.createObjectNode(), timeline);
|
||||
var values = new SmlCarrierScheduleProviderAdapter(mock(SmlTrackingClient.class))
|
||||
.observations("SMCU7019890", response);
|
||||
assertThat(values).filteredOn(value -> value.actualAt() != null).singleElement()
|
||||
.satisfies(value -> assertThat(value.standardNode()).isEqualTo("EMPTY_RETURNED"));
|
||||
assertThat(values).filteredOn(value -> value.estimatedAt() != null).singleElement();
|
||||
}
|
||||
|
||||
@Test
|
||||
void ambiguousFitmdoAndCargoEmptyReleaseStayUnmapped() {
|
||||
assertThat(SmlCarrierScheduleProviderAdapter.standardNode("FITMDO")).isNull();
|
||||
assertThat(SmlCarrierScheduleProviderAdapter.standardNode("MOTYDO")).isNull();
|
||||
assertThat(SmlCarrierScheduleProviderAdapter.standardNode("FUVMUD"))
|
||||
.isEqualTo("DISCHARGED_FROM_VESSEL");
|
||||
}
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
package com.baiyue.integration.provider;
|
||||
|
||||
import com.baiyue.integration.config.IntegrationProperties;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class SmlTrackingClientTest {
|
||||
private HttpServer server;
|
||||
|
||||
@AfterEach
|
||||
void stopServer() { if (server != null) server.stop(0); }
|
||||
|
||||
@Test
|
||||
void executesTheConfirmed122125124126Chain() throws Exception {
|
||||
List<String> forms = new ArrayList<>();
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/track", exchange -> {
|
||||
String form = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
|
||||
forms.add(form);
|
||||
String body = form.contains("f_cmd=122")
|
||||
? "{\"TRANS_RESULT_KEY\":\"S\",\"count\":1,\"data\":[{\"bkgNo\":\"BKG1\",\"copNo\":\"COP1\"}]}"
|
||||
: form.contains("f_cmd=125")
|
||||
? "{\"TRANS_RESULT_KEY\":\"S\",\"count\":1,\"events\":[{\"actCd\":\"FOTMAD\",\"actTpCd\":\"A\",\"eventDt\":\"2026-08-01 15:55\"}]}"
|
||||
: "{\"TRANS_RESULT_KEY\":\"S\",\"count\":0}";
|
||||
respond(exchange, 200, body);
|
||||
});
|
||||
server.start();
|
||||
|
||||
var result = new SmlCarrierScheduleProviderAdapter(client())
|
||||
.query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890"));
|
||||
|
||||
assertThat(result.outcome()).isEqualTo("SUCCESS");
|
||||
assertThat(result.observations()).singleElement()
|
||||
.satisfies(value -> assertThat(value.sourceReference()).isEqualTo("SML:BKG1:COP1"));
|
||||
assertThat(forms).hasSize(4);
|
||||
assertThat(forms.get(0)).contains("f_cmd=122").contains("cntr_no=SMCU7019890");
|
||||
assertThat(forms.get(1)).contains("f_cmd=125").contains("bkg_no=BKG1").contains("cop_no=COP1");
|
||||
assertThat(forms.get(2)).contains("f_cmd=124").contains("bkg_no=BKG1");
|
||||
assertThat(forms.get(3)).contains("f_cmd=126").contains("cntr_no=SMCU7019890");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonJsonAndMissingBusinessKeysFailClosed() throws Exception {
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/track", exchange -> respond(exchange, 200, "<html>schema changed</html>"));
|
||||
server.start();
|
||||
assertThatThrownBy(() -> client().query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890")))
|
||||
.isInstanceOfSatisfying(SmlTrackingClient.SmlTrackingException.class,
|
||||
value -> assertThat(value.code()).contains("NON_JSON"));
|
||||
|
||||
server.stop(0);
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/track", exchange -> respond(exchange, 200,
|
||||
"{\"TRANS_RESULT_KEY\":\"S\",\"count\":1}"));
|
||||
server.start();
|
||||
assertThatThrownBy(() -> client().query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890")))
|
||||
.isInstanceOfSatisfying(SmlTrackingClient.SmlTrackingException.class,
|
||||
value -> assertThat(value.code()).isEqualTo("SML_BUSINESS_KEYS_MISSING"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void providerFailureAndZeroCountAreTraceableNotFoundResults() throws Exception {
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/track", exchange -> respond(exchange, 200,
|
||||
"{\"TRANS_RESULT_KEY\":\"F\",\"count\":1}"));
|
||||
server.start();
|
||||
assertThatThrownBy(() -> client().query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890")))
|
||||
.isInstanceOfSatisfying(SmlTrackingClient.SmlTrackingException.class,
|
||||
value -> assertThat(value.code()).isEqualTo("SML_BUSINESS_FAILED"));
|
||||
|
||||
server.stop(0);
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/track", exchange -> respond(exchange, 200,
|
||||
"{\"TRANS_RESULT_KEY\":\"S\",\"count\":0}"));
|
||||
server.start();
|
||||
assertThatThrownBy(() -> client().query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890")))
|
||||
.isInstanceOfSatisfying(SmlTrackingClient.SmlTrackingException.class,
|
||||
value -> assertThat(value.code()).isEqualTo("SML_NOT_FOUND"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonSuccessHttpAndTimeoutRemainTraceableRetryableFailures() throws Exception {
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/track", exchange -> respond(exchange, 503, "{}"));
|
||||
server.start();
|
||||
assertThatThrownBy(() -> client().query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890")))
|
||||
.isInstanceOfSatisfying(SmlTrackingClient.SmlTrackingException.class, value -> {
|
||||
assertThat(value.code()).isEqualTo("SML_LOOKUP_HTTP_503");
|
||||
assertThat(value.retryable()).isTrue();
|
||||
});
|
||||
|
||||
server.stop(0);
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/track", exchange -> {
|
||||
try { Thread.sleep(200); }
|
||||
catch (InterruptedException exception) { Thread.currentThread().interrupt(); }
|
||||
respond(exchange, 200, "{}");
|
||||
});
|
||||
server.start();
|
||||
assertThatThrownBy(() -> client(Duration.ofMillis(50))
|
||||
.query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890")))
|
||||
.isInstanceOfSatisfying(SmlTrackingClient.SmlTrackingException.class, value -> {
|
||||
assertThat(value.code()).isEqualTo("SML_LOOKUP_TIMEOUT");
|
||||
assertThat(value.retryable()).isTrue();
|
||||
});
|
||||
}
|
||||
|
||||
private SmlTrackingClient client() {
|
||||
return client(Duration.ofSeconds(2));
|
||||
}
|
||||
|
||||
private SmlTrackingClient client(Duration requestTimeout) {
|
||||
var sml = new IntegrationProperties.CarrierSchedule.Sml(true,
|
||||
URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/track"),
|
||||
Duration.ofSeconds(2), requestTimeout, Duration.ZERO);
|
||||
SmlTrackingClient client = new SmlTrackingClient();
|
||||
ReflectionTestUtils.setField(client, "properties", new IntegrationProperties(null, null,
|
||||
new IntegrationProperties.CarrierSchedule(null, null, sml, null, null)));
|
||||
ReflectionTestUtils.setField(client, "objectMapper", new ObjectMapper());
|
||||
client.initialize();
|
||||
return client;
|
||||
}
|
||||
|
||||
private static void respond(HttpExchange exchange, int status, String body) throws IOException {
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().add("Content-Type", "application/json; charset=UTF-8");
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package com.baiyue.integration.provider;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
class ZimCarrierScheduleProviderAdapterTest {
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void v2FixtureSelectsOnlyTheExactContainerAndSeparatesActualFromPlans() throws Exception {
|
||||
String fixture = """
|
||||
{"bookingReference":" BK-STRING-001 ","blReference":" BL-STRING-001 ",
|
||||
"finalETA":{"etaValue":"2023-09-03T08:00:00","etaValueDate":"2023-09-03","podInd":"ETA"},
|
||||
"routeDetails":[
|
||||
{"sailingDateTz":"2023-08-10T12:00:00","arrivalDateTz":"2023-08-17T06:00:00","portCode":"CNNGB","port":"Ningbo","country":"CN","vesselName":"V1","voyage":"001E","leg":"1"},
|
||||
{"sailingDateDT":"2023-08-18T10:00:00","arrivalDateDT":"2023-09-02T04:00:00","portCode":"USNYC","port":"New York","country":"US","vesselName":"V2","voyage":"002E","leg":"2"},
|
||||
{"portCode":"USNYC","port":"New York","country":"US"}],
|
||||
"bkBlDetails":{"consContainerList":[
|
||||
{"containerNumber":"SIBLING000001 ","containerEventsList":[
|
||||
{"activityDateTz":"2023-08-01T01:00:00","eventName":"Export Gate-In to Port of Loading","portCode":"CNNGB"}]},
|
||||
{"containerNumber":" zcsu7881970 ","containerEventsList":[
|
||||
{"activityDateTz":"2023-08-01T01:00:00","eventName":"Empty container dispatched from Port of Loading to Customer","portCode":"CNNGB"},
|
||||
{"activityDateTz":"2023-08-10T02:00:00","eventName":"Export gate-in from Customer to Port of Loading","portCode":"CNNGB","port":"Ningbo","country":"CN"},
|
||||
{"activityDateTz":"2023-08-18T11:00:00","eventName":"Vessel departure from first Transshipment Port to next Transshipment Port","portCode":"SGSIN","port":"Singapore","country":"SG","vesselName":"V2","voyage":"002E","leg":"2"},
|
||||
{"activityDateTz":"2023-08-19T12:00:00","eventName":"Container was loaded at Transshipment Port to Transshipment Port","portCode":"SGSIN","port":"Singapore","country":"SG","vesselName":"V2","voyage":"002E","leg":"2"},
|
||||
{"activityDate":"2023-08-20T12:00:00","eventName":"Container was loaded at Transshipment Port to Transshipment Port","portCode":"SGSIN","port":"Singapore","country":"SG","vesselName":"V2","voyage":"002E","leg":"2"},
|
||||
{"activityDateTz":"2023-08-21T12:00:00","eventName":"Unknown Future Event","portCode":"SGSIN"}]}]}}
|
||||
""";
|
||||
var payload = objectMapper.readValue(fixture, ZimTrackingClient.TracingPayload.class);
|
||||
var response = new ZimTrackingClient.TrackingResponse(payload, fixture);
|
||||
LocalDateTime queriedAt = LocalDateTime.of(2026, 8, 21, 12, 0);
|
||||
|
||||
var values = new ZimCarrierScheduleProviderAdapter(mock(ZimTrackingClient.class))
|
||||
.observations("ZCSU7881970", response, queriedAt);
|
||||
|
||||
assertThat(values).hasSize(7);
|
||||
assertThat(values).extracting(value -> value.observedAt()).containsOnly(queriedAt);
|
||||
assertThat(values).filteredOn(value -> value.actualAt() != null)
|
||||
.extracting(value -> value.standardNode())
|
||||
.containsExactlyInAnyOrder("AT_ORIGIN_PORT", "IN_TRANSIT", "LOADED_ON_VESSEL", "LOADED_ON_VESSEL");
|
||||
assertThat(values).filteredOn(value -> value.estimatedAt() != null)
|
||||
.extracting(value -> value.standardNode())
|
||||
.containsExactlyInAnyOrder("DEPARTED_ORIGIN_PORT", "ARRIVED_DESTINATION_PORT",
|
||||
"ARRIVED_DESTINATION_PORT");
|
||||
assertThat(values).noneMatch(value -> LocalDateTime.of(2023, 8, 1, 1, 0).equals(value.actualAt()));
|
||||
assertThat(values).extracting(value -> value.providerNodeCode())
|
||||
.doesNotContain("EMPTY CONTAINER DISPATCHED FROM PORT OF LOADING TO CUSTOMER", "UNKNOWN FUTURE EVENT");
|
||||
assertThat(values).allSatisfy(value -> assertThat(value.sourceReference())
|
||||
.isEqualTo("ZIM:CONTAINER:ZCSU7881970|BOOKING:BK-STRING-001|BL:BL-STRING-001"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void eventNameMappingIsAnExactExpandableWhitelist() {
|
||||
assertThat(ZimCarrierScheduleProviderAdapter.standardNode(
|
||||
"Export Gate-In to Port of Loading")).isEqualTo("AT_ORIGIN_PORT");
|
||||
assertThat(ZimCarrierScheduleProviderAdapter.standardNode(
|
||||
"Container was loaded at Transshipment Port to Transshipment Port")).isEqualTo("LOADED_ON_VESSEL");
|
||||
assertThat(ZimCarrierScheduleProviderAdapter.standardNode(
|
||||
"Vessel departure from first Transshipment Port to next Transshipment Port")).isEqualTo("IN_TRANSIT");
|
||||
assertThat(ZimCarrierScheduleProviderAdapter.standardNode(
|
||||
"Container loaded at Port of Loading")).isEqualTo("LOADED_ON_VESSEL");
|
||||
assertThat(ZimCarrierScheduleProviderAdapter.standardNode(
|
||||
"Vessel departure from Port of Loading")).isEqualTo("DEPARTED_ORIGIN_PORT");
|
||||
assertThat(ZimCarrierScheduleProviderAdapter.standardNode(
|
||||
"Container was loaded at Port of Loading to Port of Discharge")).isEqualTo("LOADED_ON_VESSEL");
|
||||
assertThat(ZimCarrierScheduleProviderAdapter.standardNode(
|
||||
"Vessel departure from Port of Loading to Port of Discharge")).isEqualTo("DEPARTED_ORIGIN_PORT");
|
||||
assertThat(ZimCarrierScheduleProviderAdapter.standardNode(
|
||||
"Empty container dispatched from Port of Loading to Customer")).isNull();
|
||||
assertThat(ZimCarrierScheduleProviderAdapter.standardNode("Unknown Future Event")).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void liveBkBlOnlyFixtureToleratesMissingPlansAndDoesNotAdvanceCarrierRelease() throws Exception {
|
||||
String fixture = """
|
||||
{"bkBlDetails":{"consContainerList":[
|
||||
{"containerNumber":" ZCSU7205614 ","containerEventsList":[
|
||||
{"activityDateTz":"2026-08-01T01:00:00Z","eventName":"Empty container dispatched from inland point to Customer","portCode":"CNNGB"},
|
||||
{"activityDateTz":"2026-08-03T02:00:00Z","eventName":"Export gate-in from Customer to Port of Loading","portCode":"CNNGB","port":"Ningbo","country":"CN"},
|
||||
{"activityDateTz":"2026-08-05T03:00:00Z","eventName":"Container was loaded at Port of Loading to Port of Discharge","portCode":"CNNGB","port":"Ningbo","country":"CN","vesselName":"LIVE VESSEL","voyage":"001E","leg":"1"},
|
||||
{"activityDateTz":"2026-08-06T04:00:00Z","eventName":"Vessel departure from Port of Loading to Port of Discharge","portCode":"CNNGB","port":"Ningbo","country":"CN","vesselName":"LIVE VESSEL","voyage":"001E","leg":"1"},
|
||||
{"activityDateTz":"2026-08-20T05:00:00Z","eventName":"Carrier Release","portCode":"USLAX","port":"Los Angeles","country":"US"}]},
|
||||
{"containerNumber":"ZCSU7960370","containerEventsList":[
|
||||
{"activityDateTz":"2026-08-07T00:00:00Z","eventName":"Export gate-in from Customer to Port of Loading"}]}]}}
|
||||
""";
|
||||
var payload = objectMapper.readValue(fixture, ZimTrackingClient.TracingPayload.class);
|
||||
var response = new ZimTrackingClient.TrackingResponse(payload, fixture);
|
||||
|
||||
var values = new ZimCarrierScheduleProviderAdapter(mock(ZimTrackingClient.class))
|
||||
.observations("zcsu7205614", response, LocalDateTime.of(2026, 8, 21, 12, 0));
|
||||
|
||||
assertThat(payload.routeDetails()).isNull();
|
||||
assertThat(payload.finalETA()).isNull();
|
||||
assertThat(values).hasSize(3).allSatisfy(value -> assertThat(value.actualAt()).isNotNull());
|
||||
assertThat(values).extracting(value -> value.standardNode())
|
||||
.containsExactlyInAnyOrder("AT_ORIGIN_PORT", "LOADED_ON_VESSEL", "DEPARTED_ORIGIN_PORT");
|
||||
assertThat(values).extracting(value -> value.standardNode()).doesNotContain("PICKUP_READY");
|
||||
assertThat(values).extracting(value -> value.providerNodeCode())
|
||||
.doesNotContain("CARRIER RELEASE", "EMPTY CONTAINER DISPATCHED FROM INLAND POINT TO CUSTOMER");
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
package com.baiyue.integration.provider;
|
||||
|
||||
import com.baiyue.integration.config.IntegrationProperties;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class ZimTrackingClientTest {
|
||||
private HttpServer server;
|
||||
|
||||
@AfterEach
|
||||
void stopServer() { if (server != null) server.stop(0); }
|
||||
|
||||
@Test
|
||||
void usesOAuthTokenCacheAndContainerOnlyTracingPath() throws Exception {
|
||||
Credentials credentials = Credentials.random();
|
||||
String accessToken = UUID.randomUUID().toString();
|
||||
AtomicInteger authCalls = new AtomicInteger();
|
||||
AtomicReference<String> authForm = new AtomicReference<>();
|
||||
AtomicReference<String> tracingPath = new AtomicReference<>();
|
||||
server = server();
|
||||
server.createContext("/authorize/v1", exchange -> {
|
||||
authCalls.incrementAndGet(); authForm.set(body(exchange));
|
||||
respond(exchange, 200, tokenBody(accessToken));
|
||||
});
|
||||
server.createContext("/tracing/v2", exchange -> {
|
||||
tracingPath.set(exchange.getRequestURI().getPath());
|
||||
assertThat(exchange.getRequestHeaders().getFirst("Authorization")).isEqualTo("bearer " + accessToken);
|
||||
assertThat(exchange.getRequestHeaders().getFirst("Ocp-Apim-Subscription-Key"))
|
||||
.isEqualTo(credentials.subscriptionKey());
|
||||
respond(exchange, 200, successBody());
|
||||
});
|
||||
server.start();
|
||||
ZimTrackingClient client = client(credentials);
|
||||
|
||||
client.query(query()); client.query(query());
|
||||
|
||||
assertThat(authCalls).hasValue(1);
|
||||
assertThat(authForm.get()).contains("grant_type=client_credentials", "scope=tracing")
|
||||
.contains("client_id=", "client_secret=");
|
||||
assertThat(tracingPath.get()).isEqualTo("/tracing/v2/ZCSU7881970");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unauthorizedRefreshesTokenAndRetriesTracingExactlyOnce() throws Exception {
|
||||
Credentials credentials = Credentials.random();
|
||||
String firstToken = UUID.randomUUID().toString();
|
||||
String secondToken = UUID.randomUUID().toString();
|
||||
AtomicInteger authCalls = new AtomicInteger();
|
||||
AtomicInteger tracingCalls = new AtomicInteger();
|
||||
server = server();
|
||||
server.createContext("/authorize/v1", exchange -> {
|
||||
int call = authCalls.incrementAndGet(); respond(exchange, 200, tokenBody(call == 1 ? firstToken : secondToken));
|
||||
});
|
||||
server.createContext("/tracing/v2", exchange -> {
|
||||
int call = tracingCalls.incrementAndGet();
|
||||
respond(exchange, call == 1 ? 401 : 200, call == 1 ? "{}" : successBody());
|
||||
});
|
||||
server.start();
|
||||
|
||||
client(credentials).query(query());
|
||||
|
||||
assertThat(authCalls).hasValue(2);
|
||||
assertThat(tracingCalls).hasValue(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void forbiddenIsManualAuthorizationFailureAndDoesNotEchoSecrets() throws Exception {
|
||||
Credentials credentials = Credentials.random();
|
||||
server = server();
|
||||
server.createContext("/authorize/v1", exchange -> respond(exchange, 200, tokenBody(UUID.randomUUID().toString())));
|
||||
server.createContext("/tracing/v2", exchange -> respond(exchange, 403, "<html>forbidden</html>"));
|
||||
server.start();
|
||||
|
||||
assertThatThrownBy(() -> client(credentials).query(query()))
|
||||
.isInstanceOfSatisfying(ZimTrackingClient.ZimTrackingException.class, value -> {
|
||||
assertThat(value.outcome()).isEqualTo("MANUAL_REQUIRED");
|
||||
assertThat(value.code()).isEqualTo("ZIM_HTTP_403");
|
||||
assertThat(value.getMessage()).doesNotContain(credentials.clientSecret())
|
||||
.doesNotContain(credentials.subscriptionKey()).doesNotContain("ZIM_NOT_FOUND");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyOrMissingContainerListRequiresManualHandling() throws Exception {
|
||||
Credentials credentials = Credentials.random();
|
||||
AtomicReference<String> businessBody = new AtomicReference<>(
|
||||
"{\"bkBlDetails\":{\"consContainerList\":[]}}");
|
||||
server = server();
|
||||
server.createContext("/authorize/v1", exchange -> respond(exchange, 200, tokenBody(UUID.randomUUID().toString())));
|
||||
server.createContext("/tracing/v2", exchange -> respond(exchange, 200, businessBody.get()));
|
||||
server.start();
|
||||
ZimTrackingClient client = client(credentials);
|
||||
assertThatThrownBy(() -> client.query(query())).isInstanceOfSatisfying(
|
||||
ZimTrackingClient.ZimTrackingException.class, value -> assertThat(value.code()).isEqualTo("ZIM_NOT_FOUND"));
|
||||
|
||||
businessBody.set("{}");
|
||||
assertThatThrownBy(() -> client.query(query())).isInstanceOfSatisfying(
|
||||
ZimTrackingClient.ZimTrackingException.class,
|
||||
value -> assertThat(value.code()).isEqualTo("ZIM_EMPTY_RESULT"));
|
||||
}
|
||||
|
||||
private HttpServer server() throws IOException { return HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); }
|
||||
private QueryRequest query() { return new QueryRequest("ZIM", "CONTAINER_NO", "ZCSU7881970"); }
|
||||
|
||||
private ZimTrackingClient client(Credentials credentials) {
|
||||
String root = "http://127.0.0.1:" + server.getAddress().getPort();
|
||||
var zim = new IntegrationProperties.CarrierSchedule.Zim(true, URI.create(root + "/authorize/v1"),
|
||||
URI.create(root + "/tracing/v2"), credentials.clientId(), credentials.clientSecret(),
|
||||
credentials.subscriptionKey(), Duration.ofSeconds(2), Duration.ofSeconds(2), 10);
|
||||
ZimTrackingClient client = new ZimTrackingClient();
|
||||
ReflectionTestUtils.setField(client, "properties", new IntegrationProperties(null, null,
|
||||
new IntegrationProperties.CarrierSchedule(null, null, null, zim, null)));
|
||||
ReflectionTestUtils.setField(client, "objectMapper", new ObjectMapper()); client.initialize(); return client;
|
||||
}
|
||||
|
||||
private static String body(HttpExchange exchange) throws IOException {
|
||||
return new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
private static String tokenBody(String token) {
|
||||
return "{\"access_token\":\"" + token + "\",\"expires_in\":3600}";
|
||||
}
|
||||
private static String successBody() {
|
||||
return "{\"bkBlDetails\":{\"consContainerList\":[{\"containerNumber\":\" ZCSU7881970 \"}]}}";
|
||||
}
|
||||
private static void respond(HttpExchange exchange, int status, String body) throws IOException {
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().add("Content-Type", "application/json; charset=UTF-8");
|
||||
exchange.sendResponseHeaders(status, bytes.length); exchange.getResponseBody().write(bytes); exchange.close();
|
||||
}
|
||||
private record Credentials(String clientId, String clientSecret, String subscriptionKey) {
|
||||
static Credentials random() {
|
||||
return new Credentials(UUID.randomUUID().toString(), UUID.randomUUID().toString(), UUID.randomUUID().toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-1
@@ -3,6 +3,7 @@ package com.baiyue.integration.service;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.Observation;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.ExceptionObservation;
|
||||
import com.baiyue.integration.provider.CarrierScheduleProviderAdapter;
|
||||
import com.baiyue.integration.provider.CompliantCarrierScheduleProviderAdapter;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -21,6 +22,7 @@ class CarrierScheduleProviderServiceTest {
|
||||
assertThat(result.outcome()).isEqualTo("MANUAL_REQUIRED");
|
||||
assertThat(result.errorCode()).isEqualTo("PROVIDER_API_NOT_CONFIGURED");
|
||||
assertThat(result.observations()).isEmpty();
|
||||
assertThat(result.exceptions()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -30,7 +32,7 @@ class CarrierScheduleProviderServiceTest {
|
||||
@Override public QueryResult query(QueryRequest request) {
|
||||
return new QueryResult("TEST", "SUCCESS", List.of(new Observation("evt", "RAW", "UNKNOWN",
|
||||
LocalDateTime.now(), null, null, null, null, null,
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")), null, null,
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")), List.of(), null, null,
|
||||
LocalDateTime.now());
|
||||
}
|
||||
};
|
||||
@@ -38,4 +40,20 @@ class CarrierScheduleProviderServiceTest {
|
||||
assertThatThrownBy(() -> service.query(new QueryRequest("COSCO", "CONTAINER_NO", "CSNU1234567")))
|
||||
.hasMessageContaining("无效标准节点");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsProviderHoldsOutsideTheFourFrozenCodes() {
|
||||
CarrierScheduleProviderAdapter invalid = new CarrierScheduleProviderAdapter() {
|
||||
@Override public boolean supports(String carrierCode) { return true; }
|
||||
@Override public QueryResult query(QueryRequest request) {
|
||||
return new QueryResult("TEST", "SUCCESS", List.of(), List.of(new ExceptionObservation(
|
||||
"hold-1", "RAW HOLD", "OTHER_HOLD", true, LocalDateTime.now(), null, null, null,
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")), null, null,
|
||||
LocalDateTime.now());
|
||||
}
|
||||
};
|
||||
var service = new CarrierScheduleProviderService(List.of(invalid));
|
||||
assertThatThrownBy(() -> service.query(new QueryRequest("COSCO", "CONTAINER_NO", "CSNU1234567")))
|
||||
.hasMessageContaining("无效异常事实");
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ class WeightOcrBatchServiceTest {
|
||||
URI.create("https://example.com/meter"), Duration.ofSeconds(1), Duration.ofSeconds(1),
|
||||
inputRoot, outputRoot, DataSize.ofMegabytes(3), 40_000_000L,
|
||||
50, Duration.ZERO, 0);
|
||||
return new IntegrationProperties(baidu, null);
|
||||
return new IntegrationProperties(baidu, null, null);
|
||||
}
|
||||
|
||||
@Configuration
|
||||
|
||||
Reference in New Issue
Block a user