feat(selfop): add bounded independent quantity diagnostic capture
This commit is contained in:
+146
@@ -0,0 +1,146 @@
|
||||
package com.baiyue.integration.provider.lingxing;
|
||||
|
||||
import com.baiyue.integration.config.LingxingQuantityCaptureProperties;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import java.time.*;
|
||||
import java.util.concurrent.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class LingxingQuantityCaptureTest {
|
||||
private static final Instant NOW = Instant.parse("2026-10-13T01:00:00Z");
|
||||
private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
|
||||
private final BlockingQueue<LingxingQuantityEvidence> saved = new LinkedBlockingQueue<>();
|
||||
private EgressContext context(String session) { return new EgressContext("account", CollectionMode.BUSINESS,
|
||||
"root", session, "attempt", "grant"); }
|
||||
private LingxingQuantityCapture capture(Instant expiry, LingxingQuantityEvidence.Sink sink) {
|
||||
return new LingxingQuantityCapture(new LingxingQuantityCaptureProperties(true, "capture", "account",
|
||||
"root", "session", "*", "*", NOW.minusSeconds(1), expiry), mapper, sink, Clock.fixed(NOW, ZoneOffset.UTC));
|
||||
}
|
||||
private void observe(LingxingQuantityCapture c, boolean detail, String items, String session, int status) throws Exception {
|
||||
String order = "{\"whCode\":\"W\",\"customerCode\":\"C\",\"deliveryNo\":\"D\",\"status\":" + status
|
||||
+ ",\"" + (detail ? "skuVOList" : "productList") + "\":[" + items + "]}";
|
||||
c.observe(mapper.readTree(detail ? order : "{\"records\":[" + order + "]}"), detail, context(session), NOW);
|
||||
}
|
||||
@ParameterizedTest @ValueSource(ints = {10, 8})
|
||||
void preservesBothSemanticOutcomesAndStopsAtDifference(int listQty) throws Exception {
|
||||
try (var c = new CloseableCapture(capture(NOW.plusSeconds(60), saved::add))) {
|
||||
observe(c.value, false, "{\"productSku\":\"S\",\"qty\":" + listQty + "}", "session", 100);
|
||||
observe(c.value, true, "{\"productSku\":\"S\",\"deliveryQty\":10,\"actualDeliveryQty\":8}", "session", 100);
|
||||
var e = saved.poll(3, TimeUnit.SECONDS);
|
||||
assertThat(e).isNotNull(); assertThat(e.forecastActualDifference()).isTrue();
|
||||
assertThat(e.skus().get(0).listQty()).isEqualByComparingTo(Integer.toString(listQty));
|
||||
assertThat(e.skuSetEqual()).isTrue();
|
||||
awaitStop(c.value, "FORECAST_ACTUAL_DIFFERENCE");
|
||||
}
|
||||
}
|
||||
@Test void duplicateDecimalsUnknownsAndSkuMismatchRemainVisibleForCancelledOrder() throws Exception {
|
||||
try (var c = new CloseableCapture(capture(NOW.plusSeconds(60), saved::add))) {
|
||||
observe(c.value, false, "{\"productSku\":\"S\",\"qty\":0.1},{\"productSku\":\"S\",\"qty\":0.2},"
|
||||
+ "{\"productSku\":\"LIST_ONLY\",\"qty\":1}", "session", 99);
|
||||
observe(c.value, true, "{\"productSku\":\"S\",\"deliveryQty\":1,\"actualDeliveryQty\":null},"
|
||||
+ "{\"productSku\":\"S\",\"deliveryQty\":2,\"actualDeliveryQty\":1}", "session", 99);
|
||||
var e = saved.poll(3, TimeUnit.SECONDS);
|
||||
assertThat(e).isNotNull(); assertThat(e.skuSetEqual()).isFalse();
|
||||
var line = e.skus().stream().filter(v -> v.sku().equals("S")).findFirst().orElseThrow();
|
||||
assertThat(line.listQty()).isEqualByComparingTo("0.3");
|
||||
assertThat(line.forecastQty()).isEqualByComparingTo("3"); assertThat(line.actualQty()).isNull();
|
||||
assertThat(e.detailObservation().status()).isEqualTo("99"); assertThat(e.detailObservation().outboundTime()).isNull();
|
||||
assertThat(e.forecastActualDifference()).isFalse();
|
||||
}
|
||||
}
|
||||
@Test void crossSessionCannotPairAndExpiredCaptureDoesNotWrite() throws Exception {
|
||||
try (var c = new CloseableCapture(capture(NOW.plusSeconds(60), saved::add))) {
|
||||
observe(c.value, false, "{\"productSku\":\"S\",\"qty\":1}", "other", 100);
|
||||
observe(c.value, true, "{\"productSku\":\"S\",\"deliveryQty\":1,\"actualDeliveryQty\":1}", "session", 100);
|
||||
assertThat(saved.poll(100, TimeUnit.MILLISECONDS)).isNull();
|
||||
}
|
||||
try (var c = new CloseableCapture(capture(NOW, saved::add))) {
|
||||
observe(c.value, false, "{\"productSku\":\"S\",\"qty\":1}", "session", 100);
|
||||
assertThat(c.value.stopReason()).isEqualTo("EXPIRED"); assertThat(saved).isEmpty();
|
||||
}
|
||||
}
|
||||
@Test void sinkFailureStopsOnlyDiagnosticAndNeverEscapesCaller() throws Exception {
|
||||
try (var c = new CloseableCapture(capture(NOW.plusSeconds(60), evidence -> { throw new IllegalStateException("private"); }))) {
|
||||
observe(c.value, false, "{\"productSku\":\"S\",\"qty\":1}", "session", 100);
|
||||
observe(c.value, true, "{\"productSku\":\"S\",\"deliveryQty\":1,\"actualDeliveryQty\":1}", "session", 100);
|
||||
awaitStop(c.value, "DIAGNOSTIC_FAILED");
|
||||
}
|
||||
}
|
||||
@Test void hundredCompleteOrdersStopsFurtherCapture() throws Exception {
|
||||
try (var c = new CloseableCapture(capture(NOW.plusSeconds(60), saved::add))) {
|
||||
for (int i = 0; i < 100; i++) {
|
||||
String head = "\"whCode\":\"W\",\"customerCode\":\"C\",\"deliveryNo\":\"D" + i + "\",\"status\":100,";
|
||||
c.value.observe(mapper.readTree("{\"records\":[{" + head + "\"productList\":[{\"productSku\":\"S\",\"qty\":1}]}]}"), false, context("session"), NOW);
|
||||
c.value.observe(mapper.readTree("{" + head + "\"skuVOList\":[{\"productSku\":\"S\",\"deliveryQty\":1,\"actualDeliveryQty\":1}]}"), true, context("session"), NOW);
|
||||
assertThat(saved.poll(3, TimeUnit.SECONDS)).isNotNull();
|
||||
}
|
||||
awaitStop(c.value, "ORDER_LIMIT");
|
||||
observe(c.value, false, "{\"productSku\":\"S\",\"qty\":1}", "session", 100);
|
||||
assertThat(saved).isEmpty();
|
||||
}
|
||||
}
|
||||
@Test void thousandRowPageSelectsHundredAndSlowSinkDoesNotBlockDetails() throws Exception {
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
try (var c = new CloseableCapture(capture(NOW.plusSeconds(60), evidence -> {
|
||||
entered.countDown();
|
||||
try { release.await(3, TimeUnit.SECONDS); } catch (InterruptedException error) { Thread.currentThread().interrupt(); }
|
||||
saved.add(evidence);
|
||||
}))) {
|
||||
var page = mapper.createObjectNode(); var records = page.putArray("records");
|
||||
for (int i = 0; i < 1000; i++) {
|
||||
var order = records.addObject().put("whCode", "W").put("customerCode", "C").put("deliveryNo", "D" + i).put("status", 100);
|
||||
order.putArray("productList").addObject().put("productSku", "S").put("qty", 1);
|
||||
}
|
||||
c.value.observe(page, false, context("session"), NOW);
|
||||
assertThat(c.value.stopReason()).isNull();
|
||||
// Unselected details arrive first: they must not consume queue or evidence capacity.
|
||||
for (int i = 999; i >= 0; i--) {
|
||||
var detail = mapper.createObjectNode().put("whCode", "W").put("customerCode", "C").put("deliveryNo", "D" + i).put("status", 100);
|
||||
detail.putArray("skuVOList").addObject().put("productSku", "S").put("deliveryQty", 1).put("actualDeliveryQty", 1);
|
||||
c.value.observe(detail, true, context("session"), NOW);
|
||||
}
|
||||
assertThat(entered.await(3, TimeUnit.SECONDS)).isTrue();
|
||||
assertThat(c.value.stopReason()).isEqualTo("ORDER_LIMIT");
|
||||
release.countDown();
|
||||
for (int i = 0; i < 100; i++) assertThat(saved.poll(3, TimeUnit.SECONDS)).isNotNull();
|
||||
assertThat(saved).isEmpty();
|
||||
} finally { release.countDown(); }
|
||||
}
|
||||
private static void awaitStop(LingxingQuantityCapture capture, String reason) throws Exception {
|
||||
long end = System.nanoTime() + TimeUnit.SECONDS.toNanos(3);
|
||||
while (capture.stopReason() == null && System.nanoTime() < end) Thread.sleep(5);
|
||||
assertThat(capture.stopReason()).isEqualTo(reason);
|
||||
}
|
||||
@ParameterizedTest @ValueSource(strings = {"FORECAST_ACTUAL_DIFFERENCE", "LIMIT_REACHED"})
|
||||
void savedFinalOrderRetainsNormalReceiverReasonAndDiscardsQueuedOrders(String reason) throws Exception {
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
try (var c = new CloseableCapture(capture(NOW.plusSeconds(60), evidence -> {
|
||||
entered.countDown();
|
||||
try { release.await(3, TimeUnit.SECONDS); } catch (InterruptedException error) { Thread.currentThread().interrupt(); }
|
||||
saved.add(evidence); // Receiver persisted the last accepted order before returning its stop decision.
|
||||
throw new LingxingQuantityEvidence.ReceiverStopped(reason, true);
|
||||
}))) {
|
||||
for (int i = 0; i < 3; i++) {
|
||||
var order = mapper.createObjectNode().put("whCode", "W").put("customerCode", "C").put("deliveryNo", "D" + i).put("status", 100);
|
||||
order.putArray("productList").addObject().put("productSku", "S").put("qty", 1);
|
||||
var page = mapper.createObjectNode(); page.putArray("records").add(order);
|
||||
c.value.observe(page, false, context("session"), NOW);
|
||||
order.putArray("skuVOList").addObject().put("productSku", "S").put("deliveryQty", 1).put("actualDeliveryQty", 1);
|
||||
c.value.observe(order, true, context("session"), NOW);
|
||||
}
|
||||
assertThat(entered.await(3, TimeUnit.SECONDS)).isTrue();
|
||||
release.countDown(); awaitStop(c.value, reason);
|
||||
assertThat(saved).hasSize(1);
|
||||
assertThat(saved.remove().orderNo()).isEqualTo("D0");
|
||||
assertThat(saved.poll(100, TimeUnit.MILLISECONDS)).isNull();
|
||||
} finally { release.countDown(); }
|
||||
}
|
||||
private record CloseableCapture(LingxingQuantityCapture value) implements AutoCloseable {
|
||||
@Override public void close() { value.close(); }
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package com.baiyue.integration.provider.lingxing;
|
||||
|
||||
import com.baiyue.integration.config.LingxingEgressProperties;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.CollectionMode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.net.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class LingxingQuantityHttpSinkTest {
|
||||
@Test void exactContractAuthenticationAndNoRetryWhenReceiverStops() throws Exception {
|
||||
var calls = new AtomicInteger(); var body = new AtomicReference<String>(); var header = new AtomicReference<String>();
|
||||
var outcome = new AtomicReference<>("ACCEPTED");
|
||||
var reason = new AtomicReference<>("null");
|
||||
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/internal/lastleg/self-operated-analytics/quantity-diagnostics/orders", exchange -> {
|
||||
calls.incrementAndGet(); body.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
|
||||
header.set(exchange.getRequestHeaders().getFirst("X-Internal-Service-Token"));
|
||||
byte[] response = ("{\"code\":\"200\",\"data\":{\"outcome\":\"" + outcome.get() + "\",\"stopReason\":" + reason.get() + "}}")
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(200, response.length); exchange.getResponseBody().write(response); exchange.close();
|
||||
}); server.start();
|
||||
try {
|
||||
var props = new LingxingEgressProperties(CollectionMode.OFF, "test", null, null, null,
|
||||
URI.create("http://127.0.0.1:" + server.getAddress().getPort()), null, null, false);
|
||||
var mapper = new ObjectMapper().registerModule(new JavaTimeModule())
|
||||
.disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
String token = "synthetic-internal-token-000000000000";
|
||||
var sink = new LingxingQuantityHttpSink(props, mapper, token);
|
||||
var obs = new LingxingQuantityEvidence.Observation("99", null, Instant.parse("2026-10-13T00:00:00Z"), "attempt", true);
|
||||
var evidence = new LingxingQuantityEvidence("diagnostic", "account", "root", "collection", "W", "C", "D", obs, obs,
|
||||
List.of(new LingxingQuantityEvidence.Line("S", true, true, null, null, null)));
|
||||
sink.accept(evidence);
|
||||
var json = mapper.readTree(body.get());
|
||||
assertThat(header.get()).isEqualTo(token); assertThat(json.path("diagnosticSessionId").asText()).isEqualTo("diagnostic");
|
||||
assertThat(json.path("listObservation").path("observedAt").asText()).isEqualTo("2026-10-13T00:00:00Z");
|
||||
assertThat(json.path("skus").get(0).path("forecastQty").isNull()).isTrue();
|
||||
assertThat(json.has("skuSetEqual")).isFalse(); assertThat(json.has("forecastActualDifference")).isFalse();
|
||||
outcome.set("STOPPED"); assertThatThrownBy(() -> sink.accept(evidence)).isInstanceOf(IllegalStateException.class);
|
||||
assertThat(calls.get()).isEqualTo(2);
|
||||
for (String stopReason : List.of("FORECAST_ACTUAL_DIFFERENCE", "LIMIT_REACHED")) {
|
||||
outcome.set("ACCEPTED"); reason.set("\"" + stopReason + "\"");
|
||||
assertThatThrownBy(() -> sink.accept(evidence)).isInstanceOfSatisfying(
|
||||
LingxingQuantityEvidence.ReceiverStopped.class, stopped -> {
|
||||
assertThat(stopped.reason()).isEqualTo(stopReason);
|
||||
assertThat(stopped.orderStored()).isTrue();
|
||||
});
|
||||
}
|
||||
outcome.set("STOPPED"); reason.set("\"DISABLED\"");
|
||||
assertThatThrownBy(() -> sink.accept(evidence)).isInstanceOfSatisfying(
|
||||
LingxingQuantityEvidence.ReceiverStopped.class, stopped -> {
|
||||
assertThat(stopped.reason()).isEqualTo("DISABLED");
|
||||
assertThat(stopped.orderStored()).isFalse();
|
||||
});
|
||||
assertThat(calls.get()).isEqualTo(5);
|
||||
} finally { server.stop(0); }
|
||||
}
|
||||
}
|
||||
+18
@@ -52,6 +52,24 @@ class LingxingWmsClientTest {
|
||||
server.start();
|
||||
}
|
||||
@AfterEach void stop() { server.stop(0); }
|
||||
@Test void observerFailureCannotChangeBusinessResponseOrIssueExtraRequests() {
|
||||
var observer = org.mockito.Mockito.mock(LingxingQuantityCapture.class);
|
||||
org.mockito.Mockito.doThrow(new IllegalStateException("diagnostic failure")).when(observer)
|
||||
.observe(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyBoolean(),
|
||||
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
|
||||
var c = client(); c.setQuantityCapture(observer);
|
||||
assertThat(c.queryPage(query(1, 1), context()).records()).hasSize(1);
|
||||
assertThat(calls.get()).isEqualTo(1);
|
||||
}
|
||||
@Test void deniedOrRateLimitedRequestsNeverReachObserver() {
|
||||
var observer = org.mockito.Mockito.mock(LingxingQuantityCapture.class);
|
||||
var disabled = client(CollectionMode.OFF, true, true); disabled.setQuantityCapture(observer);
|
||||
assertThrows(LingxingWmsException.class, () -> disabled.queryPage(query(1, 1), context()));
|
||||
assertThat(calls.get()).isZero();
|
||||
var enabled = client(); enabled.setQuantityCapture(observer); status.set(429);
|
||||
assertThrows(LingxingWmsException.class, () -> enabled.queryPage(query(1, 1), context()));
|
||||
org.mockito.Mockito.verifyNoInteractions(observer); assertThat(calls.get()).isEqualTo(1);
|
||||
}
|
||||
@Test void discoveryFiltersOutboundTimeAndUsesWhitelist() throws Exception {
|
||||
var result = client().queryPage(query(1, 1), context());
|
||||
var data = mapper.readTree(body.get()).path("data");
|
||||
|
||||
Reference in New Issue
Block a user