This commit is contained in:
2026-08-21 16:04:16 +08:00
parent dceec6cbed
commit 0e7df9052a
68 changed files with 3985 additions and 267 deletions
@@ -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&nbsp;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();
}
}
@@ -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();
}
}
@@ -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>"; }
}
@@ -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&paramtype=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();
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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");
}
}
@@ -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();
}
}
@@ -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");
}
}
@@ -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());
}
}
}
@@ -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("无效异常事实");
}
}
@@ -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