feat: add self-operated analytics and enforce Lastleg module boundaries
This commit is contained in:
+70
-7
@@ -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"))
|
||||
|
||||
+226
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+143
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+242
-222
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+2
-3
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user