feat: add self-operated analytics and enforce Lastleg module boundaries

This commit is contained in:
2026-10-12 20:27:35 +08:00
parent c06e788b0f
commit a1f1d93902
174 changed files with 13514 additions and 490 deletions
@@ -9,6 +9,8 @@ import com.baiyue.integration.provider.lingxing.LingxingWmsException;
import com.baiyue.integration.security.InternalServiceTokenGuard;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.springframework.http.MediaType;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
@@ -35,6 +37,7 @@ class LingxingInternalControllerTest {
private static final String BODY = """
{"startTime":"2026-09-12T08:00:00","endTime":"2026-10-12T08:00:00","current":1,"size":1}
""";
private static final String CONTEXT = "{\"sourceAccount\":\"demo\",\"executionPurpose\":\"VALIDATION\",\"rootTaskId\":\"root-1\",\"sessionId\":\"session-1\",\"attemptId\":\"attempt-1\",\"exclusiveGrantId\":\"grant-1\"}";
private final String token = UUID.randomUUID().toString();
private LingxingWmsClient client;
private MockMvc mvc;
@@ -49,7 +52,7 @@ class LingxingInternalControllerTest {
try (var context = new AnnotationConfigApplicationContext(LongIdJacksonConfiguration.class)) {
context.getBean(Jackson2ObjectMapperBuilderCustomizer.class).customize(builder);
}
mvc = MockMvcBuilders.standaloneSetup(new LingxingInternalController(guard, client))
mvc = MockMvcBuilders.standaloneSetup(new LingxingInternalController(guard, client, builder.build()))
.setMessageConverters(new MappingJackson2HttpMessageConverter(builder.build()))
.setControllerAdvice(new GlobalExceptionHandler()).build();
}
@@ -64,13 +67,73 @@ class LingxingInternalControllerTest {
verifyNoInteractions(client);
}
@ParameterizedTest
@CsvSource(value = {
"/warehouses/query|{\"current\":1,\"size\":1000}",
"/outbound-orders/locate|{\"deliveryNo\":\"DO-DEMO\",\"current\":1,\"size\":10}",
"/outbound-orders/detail|{\"warehouseCode\":\"W-DEMO\",\"customerCode\":\"C-DEMO\",\"deliveryNo\":\"DO-DEMO\"}",
"/product-inventory/query|{\"warehouseCode\":\"W-DEMO\",\"current\":1,\"size\":1000}"
}, delimiter = '|')
void everyNewEndpointRequiresInternalIdentity(String suffix, String request) throws Exception {
String path = "/internal/integrations/lingxing/wms" + suffix;
for (String invalidToken : new String[] {"", "wrong", "Bearer user-jwt"}) {
mvc.perform(post(path).header("X-Internal-Service-Token", invalidToken)
.contentType(MediaType.APPLICATION_JSON).content(request))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0401"));
}
verifyNoInteractions(client);
}
@ParameterizedTest
@CsvSource(value = {
"/outbound-orders/query|{\"startTime\":\"2026-09-12T08:00:00\",\"endTime\":\"2026-10-12T08:00:00\",\"current\":1,\"size\":1}",
"/warehouses/query|{\"current\":1,\"size\":10}",
"/outbound-orders/locate|{\"deliveryNo\":\"DO-DEMO\",\"current\":1,\"size\":10}",
"/outbound-orders/detail|{\"warehouseCode\":\"W-DEMO\",\"customerCode\":\"C-DEMO\",\"deliveryNo\":\"DO-DEMO\"}",
"/product-inventory/query|{\"warehouseCode\":\"W-DEMO\",\"current\":1,\"size\":10}"
}, delimiter = '|')
void everyEndpointRejectsMissingControlContextBeforeProvider(String suffix, String request) throws Exception {
mvc.perform(post("/internal/integrations/lingxing/wms" + suffix).header("X-Internal-Service-Token", token)
.contentType(MediaType.APPLICATION_JSON).content(request))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("B0001"))
.andExpect(jsonPath("$.data.errorCode").value("LINGXING_WMS_EGRESS_CONTEXT_INVALID"))
.andExpect(jsonPath("$.data.egress.actualHttpAttempts").value(0));
verifyNoInteractions(client);
}
@Test
void controlHeaderRejectsUnknownDuplicateAndTrailingFields() throws Exception {
for (String header : new String[] {
CONTEXT.replace("\"sourceAccount\":", "\"extra\":1,\"sourceAccount\":"),
CONTEXT.replace("\"sourceAccount\":", "\"sourceAccount\":\"other\",\"sourceAccount\":"),
CONTEXT + " {}", " ", "x".repeat(2049), "{}", "null",
CONTEXT.replace("VALIDATION", "OFF"), CONTEXT.replace("root-1", "root with spaces")
}) {
mvc.perform(post(PATH).header("X-Internal-Service-Token", token).header("X-Lingxing-Egress-Context", header)
.contentType(MediaType.APPLICATION_JSON).content(BODY))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.errorCode").value("LINGXING_WMS_EGRESS_CONTEXT_INVALID"));
}
verifyNoInteractions(client);
}
@Test
void inventoryRequiresWarehouseAndDetailRequiresAllThreeIdentifiers() throws Exception {
for (String suffix : new String[] {"/product-inventory/query", "/outbound-orders/detail"}) {
mvc.perform(post("/internal/integrations/lingxing/wms" + suffix)
.header("X-Internal-Service-Token", token).header("X-Lingxing-Egress-Context", CONTEXT).contentType(MediaType.APPLICATION_JSON)
.content("{\"current\":1,\"size\":1000,\"deliveryNo\":\"DO-DEMO\"}"))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0422"));
}
verifyNoInteractions(client);
}
@Test
void authenticatedReadReturnsOnlyOutboundSummary() throws Exception {
when(client.queryPage(any())).thenReturn(new PageResult(
when(client.queryPage(any(), any())).thenReturn(new PageResult(
List.of(new OrderSummary("TEST-OUT-001", "TEST-SRC-001", "TEST-CUSTOMER", "TEST-WH", 100,
"2026-10-01 08:00:00", "2026-10-02 08:00:00")),
1L, 1, 1, 200, Instant.parse("2026-10-12T00:00:00Z")));
mvc.perform(post(PATH).header("X-Internal-Service-Token", token)
1L, 1, 1, 1, 1, 1, 200, Instant.parse("2026-10-12T00:00:00Z"), null));
mvc.perform(post(PATH).header("X-Internal-Service-Token", token).header("X-Lingxing-Egress-Context", CONTEXT)
.contentType(MediaType.APPLICATION_JSON).content(BODY))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("200"))
.andExpect(jsonPath("$.data.total").isString()).andExpect(jsonPath("$.data.total").value("1"))
@@ -80,7 +143,7 @@ class LingxingInternalControllerTest {
@Test
void invalidPageCannotReachProvider() throws Exception {
mvc.perform(post(PATH).header("X-Internal-Service-Token", token)
mvc.perform(post(PATH).header("X-Internal-Service-Token", token).header("X-Lingxing-Egress-Context", CONTEXT)
.contentType(MediaType.APPLICATION_JSON).content(BODY.replace("\"current\":1", "\"current\":0")))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0422"));
verifyNoInteractions(client);
@@ -88,9 +151,9 @@ class LingxingInternalControllerTest {
@Test
void providerFailureRemainsAnExplicitBusinessFailure() throws Exception {
when(client.queryPage(any())).thenThrow(new LingxingWmsException(
when(client.queryPage(any(), any())).thenThrow(new LingxingWmsException(
"LINGXING_WMS_HTTP_RATE_LIMITED", "领星 WMS 请求受到限流", true, 429, null));
mvc.perform(post(PATH).header("X-Internal-Service-Token", token)
mvc.perform(post(PATH).header("X-Internal-Service-Token", token).header("X-Lingxing-Egress-Context", CONTEXT)
.contentType(MediaType.APPLICATION_JSON).content(BODY))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("S0503"))
.andExpect(jsonPath("$.data.errorCode").value("LINGXING_WMS_HTTP_RATE_LIMITED"))
@@ -0,0 +1,226 @@
package com.baiyue.integration.provider.lingxing;
import com.baiyue.integration.config.LingxingEgressProperties;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
import org.junit.jupiter.api.Test;
import java.net.URI;
import java.time.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.UnaryOperator;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertThrows;
class LingxingEgressGateTest {
static final Instant NOW = Instant.parse("2026-10-12T00:00:00Z");
static final String HASH = "a".repeat(64);
private final MutableClock clock = new MutableClock(NOW);
private final LedgerAuthority ledger = new LedgerAuthority(clock);
private LingxingEgressGate gate() { return new LingxingEgressGate(props(CollectionMode.VALIDATION, "test"), ledger, clock, clock::advanceMillis); }
static EgressContext context(String attempt) { return new EgressContext("demo-account", CollectionMode.VALIDATION, "root", "session", attempt, "demo-grant"); }
static LingxingEgressProperties props(CollectionMode mode, String environment) {
return new LingxingEgressProperties(mode, environment, "demo-account", "demo-grant", NOW.plusSeconds(3600), URI.create("http://127.0.0.1"), null, null);
}
@Test void failureCompletionPreservesNullableTransportAndSourceCodes() {
for (Integer status : new Integer[] {null, 503}) {
var gate = gate();
var error = assertThrows(LingxingWmsException.class, () -> gate.execute(context("nullable-" + status),
Operation.WAREHOUSES, HASH, handle -> {
handle.startHttp();
throw new LingxingWmsException("LINGXING_WMS_IO_FAILED", "固定失败摘要", true, status, null);
}));
assertThat(error.egress().actualHttpAttempts()).isEqualTo(1);
var completion = ledger.completions.get(ledger.completions.size() - 1);
assertThat(completion.actualHttpAttempts()).isEqualTo(1);
assertThat(completion.upstreamStatus()).isEqualTo(status);
assertThat(completion.sourceCode()).isNull();
}
}
@Test void defaultOffAndNonProductionBusinessNeverAuthorize() {
var defaults = new LingxingEgressProperties(null, null, null, null, null, null, null, null);
assertThat(defaults.collectionMode()).isEqualTo(CollectionMode.OFF);
assertThat(defaults.minStartInterval()).isEqualTo(Duration.ofSeconds(5));
var off = new LingxingEgressGate(defaults, ledger, clock, clock::advanceMillis);
assertThatThrownBy(() -> off.execute(context("a"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return true; })).hasMessageContaining("EGRESS_OFF");
var business = new LingxingEgressGate(props(CollectionMode.BUSINESS, "test"), ledger, clock, clock::advanceMillis);
assertThatThrownBy(() -> business.execute(context("a"), Operation.WAREHOUSES, HASH, h -> true)).hasMessageContaining("BUSINESS_ENVIRONMENT_REJECTED");
assertThat(ledger.authorizations).isZero();
}
@Test void proofAndContextAreServerBoundBeforeAuthorization() {
var contexts = Arrays.asList(null, new EgressContext("other", CollectionMode.VALIDATION, "root", "session", "a", "demo-grant"),
new EgressContext("demo-account", CollectionMode.BUSINESS, "root", "session", "a", "demo-grant"),
new EgressContext("demo-account", CollectionMode.VALIDATION, "root", "session", "a", "client-claimed-grant"),
new EgressContext("demo-account", CollectionMode.VALIDATION, "", "session", "a", "demo-grant"),
new EgressContext("demo-account", CollectionMode.VALIDATION, "root", "bad session", "a", "demo-grant"));
for (var c : contexts) assertThrows(LingxingWmsException.class, () -> gate().execute(c, Operation.WAREHOUSES, HASH, h -> true));
clock.advanceMillis(3600000);
assertThrows(LingxingWmsException.class, () -> gate().execute(context("a"), Operation.WAREHOUSES, HASH, h -> true));
assertThat(ledger.authorizations).isZero();
}
@Test void budgetPermitMustBeOneConsumedExclusiveUnexpiredAndExactlyBound() {
List<UnaryOperator<AttemptPermit>> mutations = List.of(
p -> null,
p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 2, p.expiresAt(), true, true),
p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 1, clock.instant(), true, true),
p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 1, clock.instant().plusSeconds(31), true, true),
p -> new AttemptPermit(context("different"), p.operation(), p.requestHash(), 1, p.expiresAt(), true, true),
p -> new AttemptPermit(p.context(), Operation.OUTBOUND_DETAIL, p.requestHash(), 1, p.expiresAt(), true, true),
p -> new AttemptPermit(p.context(), p.operation(), "b".repeat(64), 1, p.expiresAt(), true, true),
p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 1, p.expiresAt(), false, true),
p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 1, p.expiresAt(), true, false));
var calls = new AtomicInteger(); int n = 0;
for (var mutation : mutations) {
ledger.mutation = mutation; var gate = gate(); var c = context("attempt-" + ++n);
assertThatThrownBy(() -> gate.execute(c, Operation.WAREHOUSES, HASH, h -> calls.incrementAndGet())).hasMessageContaining("BUDGET_PERMIT_INVALID");
assertThatThrownBy(() -> gate.execute(context("next-" + c.attemptId()), Operation.WAREHOUSES, HASH, h -> true)).hasMessageContaining("ACCOUNT_PAUSED");
}
assertThat(calls).hasValue(0); assertThat(ledger.completions).isEmpty();
}
@Test void everyAttemptIsPersistentlyConsumedAndCannotReplayEvenAcrossGateInstances() {
var c = context("single-use");
assertThat(gate().execute(c, Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return h.metadata(); }).actualHttpAttempts()).isEqualTo(1);
assertThat(ledger.consumed).containsExactly("single-use");
var second = gate(); var sends = new AtomicInteger();
assertThatThrownBy(() -> second.execute(c, Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return sends.incrementAndGet(); })).hasMessageContaining("BUDGET_AUTHORITY_UNAVAILABLE");
assertThat(sends).hasValue(0); assertThat(ledger.completions).hasSize(1);
}
@Test void handleCanStartOnlyOnceAndExpiredHandleCannotSend() {
var error = assertThrows(LingxingWmsException.class, () -> gate().execute(context("once"), Operation.WAREHOUSES, HASH,
h -> { h.startHttp(); h.startHttp(); return true; }));
assertThat(error.code()).endsWith("ATTEMPT_NOT_SENDABLE");
assertThat(error.egress().actualHttpAttempts()).isEqualTo(1);
error = assertThrows(LingxingWmsException.class, () -> gate().execute(context("expires"), Operation.WAREHOUSES, HASH,
h -> { clock.advanceMillis(30000); h.startHttp(); return true; }));
assertThat(error.egress().actualHttpAttempts()).isZero();
assertThat(ledger.completions).extracting(CompletionRequest::actualHttpAttempts).containsExactly(1, 0);
}
@Test void authorizationAndCompletionFailuresPauseAllFurtherCalls() {
ledger.failAuthorize = true; var gate = gate();
var error = assertThrows(LingxingWmsException.class, () -> gate.execute(context("unavailable"), Operation.WAREHOUSES, HASH, h -> true));
assertThat(error.egress().actualHttpAttempts()).isZero(); assertThat(error.egress().accountPaused()).isTrue();
assertThatThrownBy(() -> gate.execute(context("next"), Operation.OUTBOUND_DETAIL, HASH, h -> true)).hasMessageContaining("ACCOUNT_PAUSED");
ledger.failAuthorize = false; ledger.failComplete = true; var fresh = gate();
error = assertThrows(LingxingWmsException.class, () -> fresh.execute(context("callback"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return true; }));
assertThat(error.code()).endsWith("BUDGET_COMPLETION_UNCONFIRMED");
assertThat(error.egress().actualHttpAttempts()).isEqualTo(1); assertThat(error.egress().accountPaused()).isTrue();
assertThatThrownBy(() -> fresh.execute(context("after-callback"), Operation.OUTBOUND_DETAIL, HASH, h -> true)).hasMessageContaining("ACCOUNT_PAUSED");
}
@Test void startsAreSerializedAndSpacedByAtLeastFiveSeconds() throws Exception {
var gate = gate(); var executor = Executors.newFixedThreadPool(2);
var entered = new CountDownLatch(1); var release = new CountDownLatch(1);
var starts = new CopyOnWriteArrayList<Instant>();
try {
var first = executor.submit(() -> gate.execute(context("first"), Operation.WAREHOUSES, HASH, h -> {
h.startHttp(); starts.add(clock.instant()); entered.countDown();
try { if (!release.await(2, TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); }
catch (InterruptedException e) { throw new IllegalStateException(e); }
return true;
}));
assertThat(entered.await(2, TimeUnit.SECONDS)).isTrue();
var secondReady = new CountDownLatch(1);
var second = executor.submit(() -> { secondReady.countDown(); return gate.execute(context("second"), Operation.OUTBOUND_DETAIL, HASH,
h -> { h.startHttp(); starts.add(clock.instant()); return true; }); });
assertThat(secondReady.await(2, TimeUnit.SECONDS)).isTrue();
assertThat(ledger.authorizations).isEqualTo(1); assertThat(second.isDone()).isFalse();
release.countDown(); assertThat(first.get(2, TimeUnit.SECONDS)).isTrue(); assertThat(second.get(2, TimeUnit.SECONDS)).isTrue();
assertThat(starts).hasSize(2); assertThat(Duration.between(starts.get(0), starts.get(1))).isGreaterThanOrEqualTo(Duration.ofSeconds(5));
assertThat(ledger.completions).hasSize(2);
} finally { release.countDown(); executor.shutdownNow(); }
}
@Test void intervalCannotBeConfiguredBelowFiveSecondsAndPropertiesHideGrant() {
assertThatThrownBy(() -> new LingxingEgressProperties(null, null, null, null, null, null, Duration.ofMillis(4999), null)).isInstanceOf(IllegalArgumentException.class);
assertThat(props(CollectionMode.VALIDATION, "test").toString()).doesNotContain("demo-grant", "demo-account", "127.0.0.1");
}
@Test void knownBudgetDenialDoesNotPermanentlyPauseOrConsumeHttp() {
var gate = gate(); ledger.denial = new AuthorizationDenial("VALIDATION_TOTAL_LIMIT", true, null);
var error = assertThrows(LingxingWmsException.class, () -> gate.execute(context("denied"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return true; }));
assertThat(error.denial()).isEqualTo(ledger.denial);
assertThat(error.egress().actualHttpAttempts()).isZero(); assertThat(error.egress().accountPaused()).isFalse();
assertThat(ledger.consumed).isEmpty(); assertThat(ledger.completions).isEmpty();
ledger.denial = null;
assertThat(gate.execute(context("fresh-session-attempt"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return h.metadata(); }).actualHttpAttempts()).isEqualTo(1);
}
private static EgressContext businessContext(String attempt) {
return new EgressContext("demo-account", CollectionMode.BUSINESS, "root", "session", attempt, "demo-grant");
}
private LingxingWmsException rateLimit() { return new LingxingWmsException("RATE_LIMIT", "synthetic rate limit", false, 429, null); }
@Test void businessCooldownRequiresFreshDurablePermitBeforeRecovery() {
var gate = new LingxingEgressGate(props(CollectionMode.BUSINESS, "production"), ledger, clock, clock::advanceMillis);
assertThrows(LingxingWmsException.class, () -> gate.execute(businessContext("limited"), Operation.WAREHOUSES, HASH, h -> {
h.startHttp(); h.rateLimited(clock.instant().plusSeconds(600)); throw rateLimit();
}));
int used = ledger.authorizations;
assertThatThrownBy(() -> gate.execute(businessContext("too-early"), Operation.OUTBOUND_DETAIL, HASH, h -> true)).hasMessageContaining("ACCOUNT_PAUSED");
assertThat(ledger.authorizations).isEqualTo(used);
clock.advanceMillis(600000); ledger.denial = new AuthorizationDenial("ACCOUNT_COOLDOWN", false, clock.instant().plusSeconds(60));
var denied = assertThrows(LingxingWmsException.class, () -> gate.execute(businessContext("still-denied"), Operation.OUTBOUND_DETAIL, HASH, h -> { h.startHttp(); return true; }));
assertThat(denied.egress().actualHttpAttempts()).isZero(); assertThat(denied.egress().accountPaused()).isTrue();
ledger.denial = null;
var accepted = gate.execute(businessContext("accepted"), Operation.OUTBOUND_DETAIL, HASH, h -> { h.startHttp(); return h.metadata(); });
assertThat(accepted.actualHttpAttempts()).isEqualTo(1); assertThat(accepted.accountPaused()).isFalse(); assertThat(accepted.cooldownUntil()).isNull();
assertThat(ledger.completions).hasSize(2);
}
@Test void committedRateCompletionWithLostAckReturnsUncertaintyInsteadOfRecoverableRateError() {
var gate=new LingxingEgressGate(props(CollectionMode.BUSINESS,"production"),ledger,clock,clock::advanceMillis);
ledger.failComplete=true;
var error=assertThrows(LingxingWmsException.class,()->gate.execute(businessContext("rate-lost-ack"),Operation.WAREHOUSES,HASH,handle->{
handle.startHttp();handle.rateLimited(clock.instant().plusSeconds(600));throw rateLimit();
}));
assertThat(error.code()).isEqualTo("LINGXING_WMS_BUDGET_COMPLETION_UNCONFIRMED");
assertThat(error.egress().actualHttpAttempts()).isEqualTo(1);assertThat(error.egress().accountPaused()).isTrue();
assertThat(error.egress().cooldownUntil()).isEqualTo(NOW.plusSeconds(600));
assertThat(ledger.completions).singleElement().satisfies(result->{
assertThat(result.upstreamStatus()).isEqualTo(429);assertThat(result.actualHttpAttempts()).isEqualTo(1);
assertThat(result.accountPaused()).isTrue();
});
clock.advanceMillis(601000);ledger.failComplete=false;
assertThatThrownBy(()->gate.execute(businessContext("after-uncertain-ack"),Operation.WAREHOUSES,HASH,handle->true)).hasMessageContaining("ACCOUNT_PAUSED");
assertThat(ledger.authorizations).isEqualTo(1);
}
@Test void validationRatePauseAndUnconfirmedCallbackNeverRecoverByTime() {
var gate = gate();
assertThrows(LingxingWmsException.class, () -> gate.execute(context("limited"), Operation.WAREHOUSES, HASH, h -> {
h.startHttp(); h.rateLimited(null); throw rateLimit();
}));
clock.advanceMillis(600000);
assertThatThrownBy(() -> gate.execute(context("next"), Operation.WAREHOUSES, HASH, h -> true)).hasMessageContaining("ACCOUNT_PAUSED");
assertThat(ledger.authorizations).isEqualTo(1);
var business = new LingxingEgressGate(props(CollectionMode.BUSINESS, "production"), ledger, clock, clock::advanceMillis);
ledger.failComplete = true;
assertThrows(LingxingWmsException.class, () -> business.execute(businessContext("lost-complete"), Operation.WAREHOUSES, HASH, h -> {
h.startHttp(); h.rateLimited(null); throw rateLimit();
}));
clock.advanceMillis(600000); ledger.failComplete = false;
assertThatThrownBy(() -> business.execute(businessContext("no-recover"), Operation.OUTBOUND_DETAIL, HASH, h -> true)).hasMessageContaining("ACCOUNT_PAUSED");
assertThat(ledger.authorizations).isEqualTo(2);
}
static final class MutableClock extends Clock {
private Instant now;
MutableClock(Instant now) { this.now = now; }
synchronized void advanceMillis(long millis) { now = now.plusMillis(millis); }
@Override public ZoneId getZone() { return ZoneOffset.UTC; }
@Override public Clock withZone(ZoneId zone) { return this; }
@Override public synchronized Instant instant() { return now; }
}
/** Synthetic durable single-use ledger, shared between independently constructed gates. */
static class LedgerAuthority implements LingxingBudgetAuthority {
final Clock clock;
final Set<String> consumed = new LinkedHashSet<>();
final List<CompletionRequest> completions = new ArrayList<>();
volatile int authorizations;
boolean failAuthorize, failComplete;
AuthorizationDenial denial;
UnaryOperator<AttemptPermit> mutation = UnaryOperator.identity();
LedgerAuthority(Clock clock) { this.clock = clock; }
@Override public synchronized AttemptPermit authorize(AuthorizationRequest request) {
authorizations++;
if (denial != null) throw new LingxingBudgetDeniedException(denial);
if (failAuthorize || !consumed.add(request.context().attemptId())) throw new IllegalStateException("budget refused");
return mutation.apply(new AttemptPermit(request.context(), request.operation(), request.requestHash(), 1, clock.instant().plusSeconds(30), true, true));
}
@Override public synchronized void complete(CompletionRequest request) {
completions.add(request); if (failComplete) throw new IllegalStateException("completion not durable");
}
}
}
@@ -0,0 +1,143 @@
package com.baiyue.integration.provider.lingxing;
import com.baiyue.integration.config.LingxingEgressProperties;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.io.IOException;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.*;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertThrows;
/** Loopback authority only; the persisted collector ledger is tested by its own service. */
class LingxingHttpBudgetAuthorityTest {
private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
private final AtomicReference<String> response = new AtomicReference<>();
private final AtomicInteger status = new AtomicInteger(200);
private final AtomicInteger calls = new AtomicInteger();
private final AtomicReference<String> path = new AtomicReference<>(), token = new AtomicReference<>(), body = new AtomicReference<>();
private HttpServer server;
private static final String TOKEN = "synthetic-internal-token-0123456789";
private final AuthorizationRequest request = new AuthorizationRequest(LingxingEgressGateTest.context("attempt"), Operation.WAREHOUSES, LingxingEgressGateTest.HASH);
@BeforeEach void start() throws IOException {
response.set("{\"code\":\"200\",\"data\":true}");
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/", exchange -> {
calls.incrementAndGet(); path.set(exchange.getRequestURI().getPath());
token.set(exchange.getRequestHeaders().getFirst("X-Internal-Service-Token"));
body.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
exchange.getResponseHeaders().set("Location", "/redirect-must-not-follow");
byte[] bytes = response.get().getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status.get(), bytes.length); exchange.getResponseBody().write(bytes); exchange.close();
});
server.start();
}
@AfterEach void stop() { server.stop(0); }
private LingxingHttpBudgetAuthority authority(String serviceToken) {
return new LingxingHttpBudgetAuthority(new LingxingEgressProperties(CollectionMode.VALIDATION, "test", "demo-account", "demo-grant",
LingxingEgressGateTest.NOW.plusSeconds(3600), URI.create("http://127.0.0.1:" + server.getAddress().getPort()), null, null), mapper, serviceToken);
}
private CompletionRequest completion() { return new CompletionRequest(request.context(), request.operation(), request.requestHash(), 1, null, 200, 200, false, null); }
private String permit() throws Exception { return mapper.writeValueAsString(new AttemptPermit(request.context(), request.operation(), request.requestHash(), 1,
LingxingEgressGateTest.NOW.plusSeconds(30), true, true)); }
@Test void fixedInternalEndpointsAndTokenCarryExactAuthorizationAndCompletionContext() throws Exception {
response.set("{\"code\":\"200\",\"data\":" + permit() + "}");
var permit = authority(TOKEN).authorize(request);
assertThat(permit.context()).isEqualTo(request.context()); assertThat(permit.allowedAttempts()).isEqualTo(1);
assertThat(path.get()).isEqualTo("/internal/lastleg/self-operated-analytics/egress/authorize");
assertThat(token.get()).isEqualTo(TOKEN);
assertThat(mapper.readTree(body.get())).isEqualTo(mapper.valueToTree(request));
response.set("{\"code\":\"200\",\"data\":true}");
authority(TOKEN).complete(completion());
assertThat(path.get()).endsWith("/egress/complete"); assertThat(mapper.readTree(body.get())).isEqualTo(mapper.valueToTree(completion()));
assertThat(calls.get()).isEqualTo(2);
}
@ParameterizedTest @ValueSource(strings = {"false", "\"true\"", "1", "{}", "null"})
void completionRequiresLiteralBooleanAcknowledgment(String data) {
response.set("{\"code\":\"200\",\"data\":" + data + "}");
assertThrows(LingxingWmsException.class, () -> authority(TOKEN).complete(completion()));
assertThat(calls.get()).isEqualTo(1);
}
@ParameterizedTest @ValueSource(strings = {"200", "\"201\"", "null"})
void envelopeRequiresExactStringSuccessCode(String code) {
response.set("{\"code\":" + code + ",\"data\":true}");
assertThrows(LingxingWmsException.class, () -> authority(TOKEN).complete(completion()));
assertThat(calls.get()).isEqualTo(1);
}
@ParameterizedTest @ValueSource(ints = {302, 401, 429, 503})
void statusErrorsNeverRetryOrFollowRedirectAndNeverEchoAuthorityBody(int code) {
status.set(code); response.set("private-fixture@example.test");
var error = assertThrows(LingxingWmsException.class, () -> authority(TOKEN).authorize(request));
assertThat(error.getCause()).isNull(); assertThat(error.getMessage()).doesNotContain("private-fixture", "synthetic-token");
assertThat(calls.get()).isEqualTo(1);
}
@Test void missingTokenPreventsAllAuthorityHttp() {
assertThrows(LingxingWmsException.class, () -> authority("").authorize(request));
assertThrows(LingxingWmsException.class, () -> authority(null).complete(completion()));
assertThat(calls.get()).isZero();
}
@Test void missingServerConfiguredAuthorityPreventsHttp() {
var authority = new LingxingHttpBudgetAuthority(new LingxingEgressProperties(CollectionMode.VALIDATION, "test", "demo-account", "demo-grant",
LingxingEgressGateTest.NOW.plusSeconds(3600), null, null, null), mapper, "synthetic-token");
assertThrows(LingxingWmsException.class, () -> authority.authorize(request)); assertThat(calls.get()).isZero();
}
@Test void permitRejectsCoercedBooleansExtraFieldsAndInvalidAttemptCount() throws Exception {
String valid = permit();
String[] invalid = {valid.replace("\"allowedAttempts\":1", "\"allowedAttempts\":2"),
valid.replace("\"allowedAttempts\":1", "\"allowedAttempts\":\"1\""),
valid.replace("\"exclusiveConfirmed\":true", "\"exclusiveConfirmed\":\"true\""),
valid.replace("\"budgetConsumed\":true", "\"budgetConsumed\":false"),
valid.substring(0, valid.length() - 1) + ",\"untrusted\":true}",
valid.replace("\"operation\":\"WAREHOUSES\"", "\"operation\":\"UNKNOWN\"")};
for (var data : invalid) {
response.set("{\"code\":\"200\",\"data\":" + data + "}");
assertThrows(LingxingWmsException.class, () -> authority(TOKEN).authorize(request));
}
assertThat(calls.get()).isEqualTo(invalid.length);
}
@Test void trailingTokensAndMalformedResponseFailClosed() {
for (String value : new String[] {"{\"code\":\"200\",\"data\":true} {}", "[]", "{"}) {
response.set(value); assertThrows(LingxingWmsException.class, () -> authority(TOKEN).complete(completion()));
}
assertThat(calls.get()).isEqualTo(3);
}
@Test void knownAuthorityDenialIsTypedAndDoesNotBecomeUnavailability() {
response.set("{\"code\":\"B0001\",\"data\":{\"errorCode\":\"SESSION_EXPIRED\",\"sampleDone\":true,\"waitUntil\":null}}");
var error = assertThrows(LingxingBudgetDeniedException.class, () -> authority(TOKEN).authorize(request));
assertThat(error.denial()).isEqualTo(new AuthorizationDenial("SESSION_EXPIRED", true, null));
assertThat(calls.get()).isEqualTo(1);
response.set("{\"code\":\"B0001\",\"data\":{\"errorCode\":\"ACCOUNT_COOLDOWN\",\"sampleDone\":false,\"waitUntil\":\"2026-10-12T00:10:00Z\"}}");
error = assertThrows(LingxingBudgetDeniedException.class, () -> authority(TOKEN).authorize(request));
assertThat(error.denial().waitUntil()).isEqualTo(LingxingEgressGateTest.NOW.plusSeconds(600));
assertThrows(LingxingWmsException.class, () -> authority(TOKEN).complete(completion()));
}
@ParameterizedTest @ValueSource(strings = {
"{\"errorCode\":\"private@example.test\",\"sampleDone\":true,\"waitUntil\":null}",
"{\"errorCode\":\"ACCOUNT_COOLDOWN\",\"sampleDone\":\"true\",\"waitUntil\":null}",
"{\"errorCode\":\"ACCOUNT_COOLDOWN\",\"sampleDone\":true}",
"{\"errorCode\":\"ACCOUNT_COOLDOWN\",\"sampleDone\":true,\"waitUntil\":\"invalid\"}",
"{\"errorCode\":\"ACCOUNT_COOLDOWN\",\"sampleDone\":true,\"waitUntil\":null,\"extra\":true}"
})
void malformedDenialFailsAsUnavailability(String data) {
response.set("{\"code\":\"B0001\",\"data\":" + data + "}");
var error = assertThrows(LingxingWmsException.class, () -> authority(TOKEN).authorize(request));
assertThat(error.code()).endsWith("BUDGET_AUTHORITY_UNAVAILABLE");
assertThat(error.getMessage()).doesNotContain("private@example.test"); assertThat(calls.get()).isEqualTo(1);
}
@Test void allControlDtosRoundTripWithProductionTimeSerialization() throws Exception {
var permit = new AttemptPermit(request.context(), request.operation(), request.requestHash(), 1, LingxingEgressGateTest.NOW.plusSeconds(30), true, true);
var metadata = new AttemptMetadata("attempt", 1, true, LingxingEgressGateTest.NOW.plusSeconds(600));
var denial = new AuthorizationDenial("ACCOUNT_COOLDOWN", false, metadata.cooldownUntil());
for (var dto : new Object[] {request.context(), request, permit, completion(), metadata, denial}) {
assertThat(mapper.readValue(mapper.writeValueAsString(dto), dto.getClass())).isEqualTo(dto);
}
}
}
@@ -1,261 +1,281 @@
package com.baiyue.integration.provider.lingxing;
import com.baiyue.integration.config.LingxingWmsProperties;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.QueryRequest;
import com.baiyue.integration.config.LingxingEgressProperties;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.HexFormat;
import java.time.*;
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;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertThrows;
/** Synthetic loopback transport only; no supplier or application services. */
class LingxingWmsClientTest {
private static final String APP_KEY = "local-test-app-key";
private static final String APP_SECRET = "local-test-app-secret";
private static final String PRIVATE_DATA = "customer-private@example.test";
private static final String PATH = "/openapi/v2/order/delivery/page";
private static final Instant NOW = Instant.parse("2026-10-12T00:00:00.987Z");
private static final String EMPTY = "{\"code\":200,\"success\":true,\"data\":{\"total\":0,\"current\":1,\"size\":1,\"records\":[]}}";
private final ObjectMapper mapper = new ObjectMapper();
private static final Instant NOW = Instant.parse("2026-10-12T00:00:00Z");
private static final String PRIVATE = "private-secret@example.test";
private static final String ORDER = """
{"deliveryNo":"DO-DEMO","sourceNo":"SRC-DEMO","customerCode":"C-DEMO","whCode":"W-DEMO",
"status":100,"outboundTime":"2026-10-01 01:00:00"}
""";
private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
private final AtomicInteger calls = new AtomicInteger();
private final AtomicInteger responseStatus = new AtomicInteger(200);
private final AtomicReference<String> responseBody = new AtomicReference<>(EMPTY);
private final AtomicReference<String> receivedBody = new AtomicReference<>();
private final AtomicReference<URI> receivedUri = new AtomicReference<>();
private final AtomicReference<String> receivedMethod = new AtomicReference<>();
private final AtomicInteger status = new AtomicInteger(200);
private final AtomicReference<String> response = new AtomicReference<>();
private final AtomicReference<String> body = new AtomicReference<>();
private final AtomicReference<String> path = new AtomicReference<>();
private HttpServer server;
@BeforeEach
void startServer() throws IOException {
private final AtomicInteger attempts = new AtomicInteger();
private final AtomicReference<String> retryAfter = new AtomicReference<>();
private final AtomicReference<CompletionRequest> completion = new AtomicReference<>();
@BeforeEach void start() throws IOException {
response.set(page("current", "size", 1, 1, 1, 1, ORDER));
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext(PATH, exchange -> {
calls.incrementAndGet();
receivedMethod.set(exchange.getRequestMethod());
receivedUri.set(exchange.getRequestURI());
receivedBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
byte[] bytes = responseBody.get().getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json; charset=UTF-8");
exchange.sendResponseHeaders(responseStatus.get(), bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
server.createContext("/openapi/v2/", exchange -> {
calls.incrementAndGet(); path.set(exchange.getRequestURI().getPath());
body.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
byte[] bytes = response.get().getBytes(StandardCharsets.UTF_8);
if (retryAfter.get() != null) exchange.getResponseHeaders().set("Retry-After", retryAfter.get());
exchange.sendResponseHeaders(status.get(), bytes.length);
exchange.getResponseBody().write(bytes); exchange.close();
});
server.start();
}
@AfterEach
void stopServer() { server.stop(0); }
@Test
void sendsOmpWmsSignatureAndOutboundTimeFilterAndOnlyReturnsWhitelist() throws Exception {
responseBody.set("""
{"code":200,"success":true,"data":{"total":12,"current":1,"size":1,"records":[{
"deliveryNo":"DO-LOCAL-001","sourceNo":"OB-LOCAL-001","customerCode":"C-LOCAL",
"whCode":"WH-LOCAL","status":100,"createTime":"2026-09-21 10:11:12",
"outboundTime":"2026-09-22 20:21:22","email":"customer-private@example.test",
"remark":"local-test-app-secret","receiver":"PRIVATE_RECEIVER"
}]}}
""");
var result = client(true, APP_KEY, APP_SECRET, Duration.ZERO).queryPage(query());
var body = mapper.readTree(receivedBody.get());
String timestamp = Long.toString(NOW.getEpochSecond());
String dataValue = "{current=1, size=1, timeType=outboundTime, startTime=2026-09-12 08:00:00, endTime=2026-10-12 08:00:00, status=100}";
String input = APP_SECRET + PATH + "appKey" + APP_KEY + "data" + dataValue + "timestamp" + timestamp + APP_SECRET;
assertThat(body.path("sign").textValue()).isEqualTo(hmac(input));
assertThat(body.path("timestamp").textValue()).isEqualTo(timestamp).matches("\\d{10}");
assertThat(body.path("appKey").textValue()).isEqualTo(APP_KEY);
assertThat(body.path("data").toString()).isEqualTo("{\"current\":1,\"size\":1,\"timeType\":\"outboundTime\",\"startTime\":\"2026-09-12 08:00:00\",\"endTime\":\"2026-10-12 08:00:00\",\"status\":100}");
assertThat(receivedUri.get().getPath()).isEqualTo(PATH);
assertThat(receivedUri.get().getRawQuery()).isNull();
assertThat(receivedMethod.get()).isEqualTo("POST");
assertThat(receivedBody.get()).doesNotContain(APP_SECRET, "authcode", "reqTime", "access_token", "pageSize");
assertThat(result.total()).isEqualTo(12L);
@AfterEach void stop() { server.stop(0); }
@Test void discoveryFiltersOutboundTimeAndUsesWhitelist() throws Exception {
var result = client().queryPage(query(1, 1), context());
var data = mapper.readTree(body.get()).path("data");
assertThat(data.size()).isEqualTo(6);
assertThat(data.path("timeType").asText()).isEqualTo("outboundTime");
assertThat(data.path("status").intValue()).isEqualTo(100);
assertThat(data.has("whCode")).isFalse();
assertThat(path.get()).isEqualTo("/openapi/v2/order/delivery/page");
assertThat(result.observedAt()).isEqualTo(NOW);
assertThat(result.sourceCode()).isEqualTo(200);
assertThat(result.records()).singleElement().satisfies(order -> {
assertThat(order.deliveryNo()).isEqualTo("DO-LOCAL-001");
assertThat(order.sourceNo()).isEqualTo("OB-LOCAL-001");
assertThat(order.customerCode()).isEqualTo("C-LOCAL");
assertThat(order.whCode()).isEqualTo("WH-LOCAL");
assertThat(order.status()).isEqualTo(100);
assertThat(order.createTime()).isEqualTo("2026-09-21 10:11:12");
assertThat(order.outboundTime()).isEqualTo("2026-09-22 20:21:22");
});
String summary = mapper.writeValueAsString(result.records().get(0));
assertThat(mapper.readTree(summary).size()).isEqualTo(7);
assertThat(summary).doesNotContain(PRIVATE_DATA, APP_SECRET, "PRIVATE_RECEIVER");
assertThat(result.pages()).isEqualTo(1);
}
@Test
void emptySuccessfulPageHasNoInventedOrder() {
var result = client(true, APP_KEY, APP_SECRET, Duration.ZERO).queryPage(query());
assertThat(result.records()).isEmpty();
assertThat(result.total()).isZero();
assertThat(calls).hasValue(1);
}
@Test
void verifiedWmsEnvelopeWithoutSuccessRequiresValidPage() {
responseBody.set("""
{"code":200,"data":{"total":1,"current":1,"size":1,"records":[{
"deliveryNo":"DO-LOCAL-001","status":100,"outboundTime":"2026-09-22 20:21:22"
}]}}
""");
var result = client(true, APP_KEY, APP_SECRET, Duration.ZERO).queryPage(query());
assertThat(result.sourceCode()).isEqualTo(200);
assertThat(result.total()).isEqualTo(1L);
assertThat(result.records()).singleElement().satisfies(order -> {
assertThat(order.deliveryNo()).isEqualTo("DO-LOCAL-001");
assertThat(order.status()).isEqualTo(100);
assertThat(order.outboundTime()).isEqualTo("2026-09-22 20:21:22");
});
assertThat(calls).hasValue(1);
}
@Test
void documentedEmptyNumericValuesRemainUnknown() {
responseBody.set("{\"code\":200,\"success\":true,\"data\":{\"total\":\"\",\"records\":[{\"deliveryNo\":\"DO-LOCAL\",\"status\":\"\",\"outboundTime\":\"\"}]}}");
var result = client(true, APP_KEY, APP_SECRET, Duration.ZERO).queryPage(query());
assertThat(result.total()).isNull();
assertThat(result.records().get(0).status()).isNull();
@Test void cancelledLocateOmitsTimeAndStatusAndRejectsFuzzyMatch() throws Exception {
response.set(page("current", "size", 1, 1, 1, 1, ORDER.replace("100", "99").replace("2026-10-01 01:00:00", "")));
var result = client().locate(new LocateRequest("DO-DEMO", 1, 1), context());
var data = mapper.readTree(body.get()).path("data");
assertThat(data.size()).isEqualTo(3);
assertThat(data.has("status")).isFalse(); assertThat(data.has("startTime")).isFalse();
assertThat(result.records().get(0).status()).isEqualTo(99);
assertThat(result.records().get(0).outboundTime()).isEmpty();
response.set(page("current", "size", 1, 1, 1, 1, ORDER.replace("DO-DEMO", "DO-DEMO-X")));
contract(() -> client().locate(new LocateRequest("DO-DEMO", 1, 1), context()), "LOCATE_DELIVERY_NO_MISMATCH");
}
@Test
void disabledAndMissingCredentialsNeverSendRequest() {
assertFailure(client(false, APP_KEY, APP_SECRET, Duration.ZERO), "DISABLED", false);
assertFailure(client(true, "", APP_SECRET, Duration.ZERO), "MISSING_CREDENTIALS", false);
assertFailure(client(true, APP_KEY, " ", Duration.ZERO), "MISSING_CREDENTIALS", false);
assertThat(calls).hasValue(0);
@Test void exactEmptyExceptionKeepsSourceSizeAndNeverAppliesToInventory() {
response.set(page("current", "size", 1, 10, 0, 0, ""));
var result = client().queryPage(query(1, 100), context());
assertThat(result.records()).isEmpty(); assertThat(result.total()).isZero();
assertThat(result.size()).isEqualTo(100); assertThat(result.sourceSize()).isEqualTo(10);
contract(() -> client().queryPage(query(1, 99), context()), "SIZE_MISMATCH");
contract(() -> client().queryPage(query(2, 100), context()), "CURRENT_MISMATCH");
contract(() -> client().queryInventory(new InventoryQueryRequest("W-DEMO", 1, 100), context()), "SIZE_MISMATCH");
response.set(page("current", "size", 1, 10, 1, 1, ORDER));
contract(() -> client().queryPage(query(1, 100), context()), "SIZE_MISMATCH");
}
@ParameterizedTest
@ValueSource(ints = {11002, 11003, 11004, 11005, 11006, 11007, 11008, 11010, 31001})
void documentedStringAuthErrorsNeedNoDataAndDoNotEchoPayloadOrRetry(int sourceCode) {
responseBody.set("{\"code\":\"" + sourceCode + "\",\"msg\":\"" + APP_KEY + APP_SECRET + PRIVATE_DATA + "\"}");
String expected = sourceCode == 11008 ? "AUTHORIZATION_REJECTED"
: sourceCode == 11006 ? "SIGNATURE_REJECTED" : "SOURCE_REJECTED";
var error = assertFailure(client(true, APP_KEY, APP_SECRET, Duration.ZERO), expected, sourceCode == 31001);
assertThat(error.sourceCode()).isEqualTo(sourceCode);
assertThat(error.upstreamStatus()).isEqualTo(200);
assertThat(calls).hasValue(1);
@Test void warehousesMapPageAndPageSizeWithoutTreatingTimezoneAsSource() throws Exception {
response.set(page("page", "pageSize", 1, 1000, 1, 1, """
{"whCode":"W-DEMO","whNameCn":"Demo","warehouseType":1,"status":0,"timeZone":"America/New_York","address":"private"}
"""));
var result = client().queryWarehouses(new WarehouseQueryRequest(1, 1000), context());
assertThat(result.sourceSize()).isEqualTo(1000);
assertThat(result.records().get(0).timeZone()).isEqualTo("America/New_York");
assertThat(mapper.readTree(body.get()).path("data").size()).isEqualTo(2);
assertThat(path.get()).isEqualTo("/openapi/v2/warehouse/page");
assertThat(mapper.writeValueAsString(result)).doesNotContain("address");
}
@Test
void falseBusinessSuccessIsNotAnEmptySuccess() {
responseBody.set("{\"code\":200,\"success\":false,\"msg\":\"" + PRIVATE_DATA + "\"}");
assertFailure(client(true, APP_KEY, APP_SECRET, Duration.ZERO), "SOURCE_REJECTED", false);
@Test void detailPreservesDecimalLinesAndIgnoresPackageDuplicates() throws Exception {
response.set("""
{"code":200,"data":{"deliveryNo":"DO-DEMO","customerCode":"C-DEMO","whCode":"W-DEMO","status":100,
"orderCreateTime":"2026-09-01 01:00:00","outboundTime":"2026-10-01 01:00:00",
"skuVOList":[{"productSku":"SKU","actualDeliveryQty":2.123456,"deliveryQty":6},
{"productSku":"SKU","actualDeliveryQty":3,"deliveryQty":6}],
"packageList":[{"skuList":[{"actualDeliveryQty":"5"}]}],"email":"private"}}
""");
var result = client().queryDetail(detail(), context());
assertThat(result.skuVOList()).hasSize(2);
assertThat(result.skuVOList().get(0).actualDeliveryQty()).isEqualByComparingTo("2.123456");
assertThat(result.orderCreateTime()).isEqualTo("2026-09-01 01:00:00");
assertThat(mapper.writeValueAsString(result.skuVOList())).doesNotContain("email", "packageList");
assertThat(mapper.readTree(body.get()).path("data").toString())
.isEqualTo("{\"whCode\":\"W-DEMO\",\"customerCode\":\"C-DEMO\",\"deliveryNo\":\"DO-DEMO\"}");
response.set(response.get().replace("W-DEMO", "OTHER"));
contract(() -> client().queryDetail(detail(), context()), "DETAIL_IDENTITY_MISMATCH");
}
@ParameterizedTest
@ValueSource(ints = {401, 403, 429, 500, 302})
void httpFailuresDoNotEchoBodyOrRetry(int status) {
responseStatus.set(status);
responseBody.set(APP_KEY + APP_SECRET + PRIVATE_DATA);
String code = status == 429 ? "HTTP_RATE_LIMITED" : status == 401 || status == 403 ? "AUTHORIZATION_REJECTED" : "HTTP_FAILED";
var error = assertFailure(client(true, APP_KEY, APP_SECRET, Duration.ZERO), code, status == 429 || status >= 500);
assertThat(error.upstreamStatus()).isEqualTo(status);
assertThat(calls).hasValue(1);
@Test void cancelledDetailRetainsUnknownQuantityAndEmptyTime() {
response.set("""
{"code":200,"data":{"deliveryNo":"DO-DEMO","customerCode":"C-DEMO","whCode":"W-DEMO","status":99,
"outboundTime":"","skuVOList":[{"productSku":"SKU","actualDeliveryQty":"","deliveryQty":1}]}}
""");
var result = client().queryDetail(detail(), context());
assertThat(result.status()).isEqualTo(99); assertThat(result.outboundTime()).isEmpty();
assertThat(result.skuVOList().get(0).actualDeliveryQty()).isNull();
}
@Test
void malformedResponsesExposeFixedContractStageOnly() {
String[][] scenarios = {
{"<html>" + PRIVATE_DATA + "</html>", "JSON"},
{"[]", "ROOT_TYPE_ARRAY"},
{"{}", "CODE_TYPE_MISSING"},
{"{\"code\":\"200\"}", "CODE_TYPE_STRING"},
{"{\"code\":\"99999\"}", "CODE_TYPE_STRING"},
{"{\"code\":200}", "DATA_TYPE_MISSING"},
{"{\"code\":200,\"data\":{}}", "RECORDS_TYPE_MISSING"},
{"{\"code\":200,\"success\":null}", "SUCCESS_TYPE_NULL"},
{"{\"code\":200,\"success\":\"true\"}", "SUCCESS_TYPE_STRING"},
{"{\"code\":200,\"success\":true,\"data\":[]}", "DATA_TYPE_ARRAY"},
{"{\"code\":200,\"success\":true,\"data\":{\"records\":{}}}", "RECORDS_TYPE_OBJECT"},
{"{\"code\":200,\"success\":true,\"data\":{\"records\":[{},{}]}}", "RECORDS_OVER_PAGE_SIZE"},
{"{\"code\":200,\"success\":true,\"data\":{\"total\":\"0\",\"records\":[]}}", "TOTAL_TYPE_STRING"},
{"{\"code\":200,\"success\":true,\"data\":{\"current\":2,\"records\":[]}}", "CURRENT_MISMATCH"},
{"{\"code\":200,\"success\":true,\"data\":{\"records\":[{\"deliveryNo\":1}]}}", "DELIVERY_NO_TYPE_NUMBER"},
{"{\"code\":200,\"success\":true,\"data\":{\"records\":[{\"deliveryNo\":\"A\",\"status\":\"100\"}]}}", "STATUS_TYPE_STRING"}
};
var client = client(true, APP_KEY, APP_SECRET, Duration.ZERO);
for (String[] scenario : scenarios) {
responseBody.set(scenario[0]);
var error = assertFailure(client, "INVALID_RESPONSE", false);
assertThat(error.getMessage()).contains("(" + scenario[1] + ")");
assertThat(error.upstreamStatus()).isEqualTo(200);
@Test void inventoryPreservesIndependentNumbersAndUnknownsAndRejectsDuplicates() throws Exception {
String stock = """
{"customerCode":"C-DEMO","whCode":"W-DEMO","productSku":"SKU","productQuality":0,
"stkQty":120.123456,"freezeQty":20,"useStkQty":100,"transitQty":30,"innerBatchNo":"",
"stockCompare":{"stkQty":999},"phone":"private"}
""";
response.set(page("current", "size", 1, 1000, 1, 1, stock));
var result = client().queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1000), context());
assertThat(mapper.readTree(body.get()).path("data").toString())
.isEqualTo("{\"current\":1,\"size\":1000,\"whCode\":\"W-DEMO\",\"isHideInventory\":0}");
var row = result.records().get(0);
assertThat(row.stkQty()).isEqualByComparingTo("120.123456");
assertThat(row.useStkQty()).isEqualByComparingTo("100"); assertThat(row.transitQty()).isEqualByComparingTo("30");
assertThat(row.innerBatchNo()).isEmpty();
assertThat(mapper.writeValueAsString(result.records())).doesNotContain("phone", "stockCompare");
response.set(page("current", "size", 1, 1000, 1, 1, stock.replace("120.123456", "0")
.replace("\"freezeQty\":20", "\"freezeQty\":\"\"").replace("\"useStkQty\":100", "\"useStkQty\":null")
.replace("\"transitQty\":30", "\"transitQty\":-1")));
row = client().queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1000), context()).records().get(0);
assertThat(row.stkQty()).isZero(); assertThat(row.freezeQty()).isNull(); assertThat(row.useStkQty()).isNull();
assertThat(row.transitQty()).isEqualByComparingTo("-1");
response.set(page("current", "size", 1, 1000, 2, 1, stock + "," + stock));
contract(() -> client().queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1000), context()), "DUPLICATE_INVENTORY_KEY");
response.set(page("current", "size", 1, 1000, 1, 1, stock.replace("W-DEMO", "OTHER")));
contract(() -> client().queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1000), context()), "INVENTORY_WAREHOUSE_MISMATCH");
}
@ParameterizedTest @ValueSource(strings = {"total", "pages", "current", "size"})
void missingPaginationIsRejected(String field) {
response.set(page("current", "size", 1, 1, 1, 1, ORDER).replace("\"" + field + "\":1,", ""));
contract(() -> client().queryPage(query(1, 1), context()), "");
}
@Test void shortPageAndWrongPageCountAreRejected() {
response.set(page("current", "size", 1, 2, 2, 1, ORDER));
contract(() -> client().queryPage(query(1, 2), context()), "PAGE_ROW_COUNT_MISMATCH");
response.set(page("current", "size", 1, 1, 1, 2, ORDER));
contract(() -> client().queryPage(query(1, 1), context()), "PAGES_MISMATCH");
}
@ParameterizedTest @ValueSource(ints = {401, 403, 302})
void nonretryableHttpNeverEchoesOrRetries(int code) {
status.set(code); response.set(PRIVATE);
var error = assertThrows(LingxingWmsException.class, () -> client().queryPage(query(1, 1), context()));
assertThat(error.retryable()).isFalse(); assertThat(error.getCause()).isNull();
assertThat(error.getMessage()).doesNotContain(PRIVATE); assertThat(calls.get()).isEqualTo(1);
}
@ParameterizedTest @ValueSource(ints = {429, 500, 503})
void everyFailureHasOneHttpAttemptAndNeverImplicitRetry(int code) {
status.set(code); response.set(PRIVATE);
var error = assertThrows(LingxingWmsException.class, () -> client().queryPage(query(1, 1), context()));
assertThat(calls.get()).isEqualTo(1);
assertThat(error.getMessage()).doesNotContain(PRIVATE);
assertThat(error.egress().actualHttpAttempts()).isEqualTo(1);
assertThat(completion.get().actualHttpAttempts()).isEqualTo(1);
assertThat(completion.get().upstreamStatus()).isEqualTo(code);
}
@ParameterizedTest @ValueSource(strings = {"600", "Mon, 12 Oct 2026 00:10:00 GMT", "invalid"})
void rateLimitPausesAccountAcrossAllEntrypointsAndReportsCooldown(String header) {
status.set(429); response.set(PRIVATE); retryAfter.set(header);
var client = client();
var error = assertThrows(LingxingWmsException.class, () -> client.queryPage(query(1, 1), context()));
assertThat(error.egress().accountPaused()).isTrue();
assertThat(error.egress().cooldownUntil()).isEqualTo(NOW.plusSeconds(header.equals("invalid") ? 300 : 600));
assertThat(completion.get().accountPaused()).isTrue();
assertThat(completion.get().cooldownUntil()).isEqualTo(error.egress().cooldownUntil());
assertThatThrownBy(() -> client.locate(new LocateRequest("DO-DEMO", 1, 1), context())).hasMessageContaining("ACCOUNT_PAUSED");
assertThatThrownBy(() -> client.queryWarehouses(new WarehouseQueryRequest(1, 1), context())).hasMessageContaining("ACCOUNT_PAUSED");
assertThatThrownBy(() -> client.queryDetail(detail(), context())).hasMessageContaining("ACCOUNT_PAUSED");
assertThatThrownBy(() -> client.queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1), context())).hasMessageContaining("ACCOUNT_PAUSED");
assertThat(calls.get()).isEqualTo(1);
}
@Test void validationRejectsLargePageBeforeHttpOrBudget() {
var c = client(CollectionMode.VALIDATION, true, true);
assertThatThrownBy(() -> c.queryPage(query(1, 11), context(CollectionMode.VALIDATION))).hasMessageContaining("参数");
assertThatThrownBy(() -> c.locate(new LocateRequest("DO-DEMO", 1, 11), context(CollectionMode.VALIDATION))).hasMessageContaining("参数");
assertThatThrownBy(() -> c.queryWarehouses(new WarehouseQueryRequest(1, 11), context(CollectionMode.VALIDATION))).hasMessageContaining("参数");
assertThatThrownBy(() -> c.queryInventory(new InventoryQueryRequest("W-DEMO", 1, 11), context(CollectionMode.VALIDATION))).hasMessageContaining("参数");
assertThat(calls.get()).isZero(); assertThat(completion.get()).isNull();
}
@Test void validationNeverRetriesEvenServerError() {
status.set(503); response.set(PRIVATE);
assertThrows(LingxingWmsException.class, () -> client(CollectionMode.VALIDATION, true, true)
.queryPage(query(1, 1), context(CollectionMode.VALIDATION)));
assertThat(calls.get()).isEqualTo(1);
}
@ParameterizedTest @ValueSource(strings = {"31001", "\"429\""})
void businessRateLimitCodesPauseWithoutHttpRetry(String sourceCode) {
response.set("{\"code\":" + sourceCode + ",\"msg\":\"" + PRIVATE + "\"}");
var c = client();
var error = assertThrows(LingxingWmsException.class, () -> c.queryPage(query(1, 1), context()));
assertThat(error.egress().actualHttpAttempts()).isEqualTo(1); assertThat(error.egress().accountPaused()).isTrue();
assertThat(completion.get().sourceCode()).isIn(31001, 429);
assertThatThrownBy(() -> c.queryDetail(detail(), context())).hasMessageContaining("ACCOUNT_PAUSED");
assertThat(calls.get()).isEqualTo(1); assertThat(error.getMessage()).doesNotContain(PRIVATE);
}
@Test void allFiveEntrypointsRequireControlContext() {
var c = client();
assertThrows(LingxingWmsException.class, () -> c.queryPage(query(1, 1), null));
assertThrows(LingxingWmsException.class, () -> c.locate(new LocateRequest("DO-DEMO", 1, 1), null));
assertThrows(LingxingWmsException.class, () -> c.queryDetail(detail(), null));
assertThrows(LingxingWmsException.class, () -> c.queryWarehouses(new WarehouseQueryRequest(1, 1), null));
assertThrows(LingxingWmsException.class, () -> c.queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1), null));
assertThat(calls.get()).isZero(); assertThat(completion.get()).isNull();
}
@Test void allFiveEntrypointsFailClosedForOffMissingGrantOrBudget() {
for (var c : new LingxingWmsClient[] {client(CollectionMode.OFF, true, true),
client(CollectionMode.BUSINESS, false, true), client(CollectionMode.BUSINESS, true, false)}) {
assertThrows(LingxingWmsException.class, () -> c.queryPage(query(1, 1), context()));
assertThrows(LingxingWmsException.class, () -> c.locate(new LocateRequest("DO-DEMO", 1, 1), context()));
assertThrows(LingxingWmsException.class, () -> c.queryWarehouses(new WarehouseQueryRequest(1, 1), context()));
assertThrows(LingxingWmsException.class, () -> c.queryDetail(detail(), context()));
assertThrows(LingxingWmsException.class, () -> c.queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1), context()));
}
assertThat(calls.get()).isZero(); assertThat(completion.get()).isNull();
}
@Test
void localRateLimitStopsSecondRequest() {
var client = client(true, APP_KEY, APP_SECRET, Duration.ofMinutes(1));
client.queryPage(query());
assertFailure(client, "LOCAL_RATE_LIMITED", true);
assertThat(calls).hasValue(1);
@ParameterizedTest @ValueSource(strings = {"\"11006\"", "11008", "\"200\"", "null"})
void rejectedEnvelopesNeverEchoRawMessage(String code) {
response.set("{\"code\":" + code + ",\"msg\":\"" + PRIVATE + "\"}");
var error = assertThrows(LingxingWmsException.class, () -> client().queryPage(query(1, 1), context()));
assertThat(error.getMessage()).doesNotContain(PRIVATE); assertThat(error.getCause()).isNull();
assertThat(calls.get()).isEqualTo(1);
}
@Test
void validatesLocalPaginationAndMonthBeforeSending() {
var client = client(true, APP_KEY, APP_SECRET, Duration.ZERO);
var query = query();
for (var invalid : new QueryRequest[] {
new QueryRequest(query.startTime(), query.endTime(), 0, 1),
new QueryRequest(query.startTime(), query.endTime(), 1, 101),
new QueryRequest(query.endTime(), query.startTime(), 1, 1),
new QueryRequest(query.startTime().minusSeconds(1), query.endTime(), 1, 1),
new QueryRequest(null, query.endTime(), 1, 1)
}) assertThatThrownBy(() -> client.queryPage(invalid)).isInstanceOf(LingxingWmsException.class);
assertThat(calls).hasValue(0);
@ParameterizedTest @ValueSource(strings = {"false", "null", "\"true\""})
void presentSuccessMustBeBooleanTrue(String success) {
response.set(page("current", "size", 1, 1, 1, 1, ORDER).replace("\"code\":200", "\"code\":200,\"success\":" + success));
assertThrows(LingxingWmsException.class, () -> client().queryPage(query(1, 1), context()));
}
private LingxingWmsException assertFailure(LingxingWmsClient client, String code, boolean retryable) {
var error = assertThrows(LingxingWmsException.class, () -> client.queryPage(query()));
assertThat(error.code()).isEqualTo("LINGXING_WMS_" + code);
assertThat(error.retryable()).isEqualTo(retryable);
assertThat(error.getCause()).isNull();
StringWriter trace = new StringWriter();
error.printStackTrace(new PrintWriter(trace));
assertThat(trace.toString()).doesNotContain(APP_KEY, APP_SECRET, PRIVATE_DATA, "sign=", "http://");
return error;
private LingxingWmsClient client() { return client(CollectionMode.BUSINESS, true, true); }
private LingxingWmsClient client(CollectionMode mode, boolean grant, boolean budget) {
var clock = new LingxingEgressGateTest.MutableClock(NOW);
var p = new LingxingWmsProperties(true, URI.create("http://127.0.0.1:" + server.getAddress().getPort()),
"synthetic-key", "synthetic-secret", Duration.ofSeconds(2), Duration.ofSeconds(2));
var props = new LingxingEgressProperties(mode, "production", "demo-account", grant ? "demo-grant" : null,
NOW.plusSeconds(3600), budget ? URI.create("http://127.0.0.1") : null, null, null);
var ledger = new LingxingEgressGateTest.LedgerAuthority(clock) {
@Override public void complete(CompletionRequest request) { super.complete(request); completion.set(request); }
};
var gate = new LingxingEgressGate(props, ledger, clock, clock::advanceMillis);
return new LingxingWmsClient(p, mapper, clock, gate);
}
private LingxingWmsClient client(boolean enabled, String key, String secret, Duration interval) {
var properties = new LingxingWmsProperties(enabled, URI.create("http://127.0.0.1:" + server.getAddress().getPort()),
key, secret, Duration.ofSeconds(2), Duration.ofSeconds(2), interval);
return new LingxingWmsClient(properties, mapper, Clock.fixed(NOW, ZoneOffset.UTC));
private EgressContext context() { return context(CollectionMode.BUSINESS); }
private EgressContext context(CollectionMode mode) {
return new EgressContext("demo-account", mode, "demo-root", "demo-session", "attempt-" + attempts.incrementAndGet(), "demo-grant");
}
private static QueryRequest query() {
LocalDateTime end = LocalDateTime.of(2026, 10, 12, 8, 0);
return new QueryRequest(end.minusMonths(1), end, 1, 1);
private static QueryRequest query(int current, int size) {
var end = LocalDateTime.of(2026, 10, 12, 8, 0); return new QueryRequest(end.minusMonths(1), end, current, size);
}
private static String hmac(String input) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(APP_SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return HexFormat.of().formatHex(mac.doFinal(input.getBytes(StandardCharsets.UTF_8)));
private static DetailRequest detail() { return new DetailRequest("W-DEMO", "C-DEMO", "DO-DEMO"); }
private static String page(String c, String s, int current, int size, long total, int pages, String rows) {
return "{\"code\":200,\"data\":{\"" + c + "\":" + current + ",\"" + s + "\":" + size
+ ",\"total\":" + total + ",\"pages\":" + pages + ",\"records\":[" + rows + "]}}";
}
private static void contract(Runnable action, String stage) {
var error = assertThrows(LingxingWmsException.class, action::run);
assertThat(error.code()).isEqualTo("LINGXING_WMS_INVALID_RESPONSE");
assertThat(error.getMessage()).contains(stage).doesNotContain(PRIVATE); assertThat(error.retryable()).isFalse();
}
}
@@ -70,16 +70,15 @@ class LingxingWmsSignerTest {
assertThat(properties.baseUri()).isEqualTo(URI.create("https://api.xlwms.com"));
assertThat(properties.connectTimeout()).isEqualTo(Duration.ofSeconds(5));
assertThat(properties.requestTimeout()).isEqualTo(Duration.ofSeconds(15));
assertThat(properties.minRequestInterval()).isEqualTo(Duration.ofSeconds(1));
assertThat(properties.toString()).doesNotContain("example-key", "example-secret");
}
@Test
void disabledIsDefaultAndPlainHttpOutsideLoopbackIsRejected() {
var properties = new LingxingWmsProperties(false, null, null, null, null, null, null);
var properties = new LingxingWmsProperties(false, null, null, null, null, null);
assertThat(properties.enabled()).isFalse();
assertThat(properties.hasCredentials()).isFalse();
assertThatThrownBy(() -> new LingxingWmsProperties(true, URI.create("http://example.test"), "key", "secret", null, null, null))
assertThatThrownBy(() -> new LingxingWmsProperties(true, URI.create("http://example.test"), "key", "secret", null, null))
.isInstanceOf(IllegalArgumentException.class);
}