fix: complete single-order acceptance and add Lingxing WMS integration

This commit is contained in:
2026-10-12 16:09:21 +08:00
parent 6c4ee2bfde
commit c06e788b0f
27 changed files with 2305 additions and 46 deletions
@@ -0,0 +1,100 @@
package com.baiyue.integration.controller;
import com.baiyue.common.web.GlobalExceptionHandler;
import com.baiyue.common.web.LongIdJacksonConfiguration;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.OrderSummary;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.PageResult;
import com.baiyue.integration.provider.lingxing.LingxingWmsClient;
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.springframework.http.MediaType;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class LingxingInternalControllerTest {
private static final String PATH = "/internal/integrations/lingxing/wms/outbound-orders/query";
private static final String BODY = """
{"startTime":"2026-09-12T08:00:00","endTime":"2026-10-12T08:00:00","current":1,"size":1}
""";
private final String token = UUID.randomUUID().toString();
private LingxingWmsClient client;
private MockMvc mvc;
@BeforeEach
void setUp() {
var guard = new InternalServiceTokenGuard();
ReflectionTestUtils.setField(guard, "token", token);
ReflectionTestUtils.invokeMethod(guard, "initializeToken");
client = mock(LingxingWmsClient.class);
var builder = Jackson2ObjectMapperBuilder.json();
try (var context = new AnnotationConfigApplicationContext(LongIdJacksonConfiguration.class)) {
context.getBean(Jackson2ObjectMapperBuilderCustomizer.class).customize(builder);
}
mvc = MockMvcBuilders.standaloneSetup(new LingxingInternalController(guard, client))
.setMessageConverters(new MappingJackson2HttpMessageConverter(builder.build()))
.setControllerAdvice(new GlobalExceptionHandler()).build();
}
@Test
void missingOrWrongInternalTokenCannotCallProvider() throws Exception {
mvc.perform(post(PATH).contentType(MediaType.APPLICATION_JSON).content(BODY))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0401"));
mvc.perform(post(PATH).header("X-Internal-Service-Token", "wrong")
.contentType(MediaType.APPLICATION_JSON).content(BODY))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0401"));
verifyNoInteractions(client);
}
@Test
void authenticatedReadReturnsOnlyOutboundSummary() throws Exception {
when(client.queryPage(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)
.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"))
.andExpect(jsonPath("$.data.records[0].deliveryNo").value("TEST-OUT-001"))
.andExpect(jsonPath("$.data.records[0].recipient").doesNotExist());
}
@Test
void invalidPageCannotReachProvider() throws Exception {
mvc.perform(post(PATH).header("X-Internal-Service-Token", token)
.contentType(MediaType.APPLICATION_JSON).content(BODY.replace("\"current\":1", "\"current\":0")))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0422"));
verifyNoInteractions(client);
}
@Test
void providerFailureRemainsAnExplicitBusinessFailure() throws Exception {
when(client.queryPage(any())).thenThrow(new LingxingWmsException(
"LINGXING_WMS_HTTP_RATE_LIMITED", "领星 WMS 请求受到限流", true, 429, null));
mvc.perform(post(PATH).header("X-Internal-Service-Token", token)
.contentType(MediaType.APPLICATION_JSON).content(BODY))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("S0503"))
.andExpect(jsonPath("$.data.errorCode").value("LINGXING_WMS_HTTP_RATE_LIMITED"))
.andExpect(jsonPath("$.data.upstreamStatus").value(429))
.andExpect(jsonPath("$.data.records").doesNotExist());
}
}
@@ -0,0 +1,261 @@
package com.baiyue.integration.provider.lingxing;
import com.baiyue.integration.config.LingxingWmsProperties;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.QueryRequest;
import com.fasterxml.jackson.databind.ObjectMapper;
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.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.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.junit.jupiter.api.Assertions.assertThrows;
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 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 HttpServer server;
@BeforeEach
void startServer() throws IOException {
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.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);
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");
}
@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();
assertThat(result.records().get(0).outboundTime()).isEmpty();
}
@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);
}
@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 falseBusinessSuccessIsNotAnEmptySuccess() {
responseBody.set("{\"code\":200,\"success\":false,\"msg\":\"" + PRIVATE_DATA + "\"}");
assertFailure(client(true, APP_KEY, APP_SECRET, Duration.ZERO), "SOURCE_REJECTED", false);
}
@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 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 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);
}
@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);
}
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(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 static QueryRequest query() {
LocalDateTime end = LocalDateTime.of(2026, 10, 12, 8, 0);
return new QueryRequest(end.minusMonths(1), end, 1, 1);
}
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)));
}
}
@@ -0,0 +1,91 @@
package com.baiyue.integration.provider.lingxing;
import com.baiyue.integration.config.LingxingWmsProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.bind.Bindable;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.context.properties.source.MapConfigurationPropertySource;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class LingxingWmsSignerTest {
@Test
void matchesOfficialOmpParameterRenderingIncludingNamesPathAndBothSecrets() throws Exception {
var mapper = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT);
var signer = new LingxingWmsSigner(mapper);
var data = new LinkedHashMap<String, Object>();
data.put("status", 0);
data.put("current", 1);
data.put("size", 10);
var signed = signer.sign("YOUR_APP_KEY", "YOUR_APP_SECRET", "/openapi/v2/customer/list", data,
Instant.ofEpochSecond(1790056664));
var body = mapper.readTree(signed.body());
String officialInput = "YOUR_APP_SECRET/openapi/v2/customer/listappKeyYOUR_APP_KEYdata{status=0, current=1, size=10}timestamp1790056664YOUR_APP_SECRET";
assertThat(body.path("sign").textValue()).isEqualTo(hmac("YOUR_APP_SECRET", officialInput));
var fieldNames = new ArrayList<String>();
body.path("data").fieldNames().forEachRemaining(fieldNames::add);
assertThat(fieldNames).containsExactly("status", "current", "size");
assertThat(signed.body()).doesNotContain("\n", "YOUR_APP_SECRET", "authcode", "reqTime");
assertThat(signed.toString()).doesNotContain("YOUR_APP_KEY", "YOUR_APP_SECRET", body.path("sign").textValue());
}
@Test
void nestedDataUsesTransmittedOrderAndMapListValueSyntax() throws Exception {
var mapper = new ObjectMapper();
var signer = new LingxingWmsSigner(mapper);
var child = new LinkedHashMap<String, Object>();
child.put("z", "中文");
child.put("A", 2);
var data = new LinkedHashMap<String, Object>();
data.put("items", List.of(child));
var signed = signer.sign("key", "secret", "/openapi/v2/customer/list", data, Instant.ofEpochSecond(1770000000));
String input = "secret/openapi/v2/customer/listappKeykeydata{items=[{z=中文, A=2}]}timestamp1770000000secret";
assertThat(mapper.readTree(signed.body()).path("sign").textValue()).isEqualTo(hmac("secret", input));
}
@Test
void propertiesBindWmsOnlyAndRedactBothCredentials() {
var source = new MapConfigurationPropertySource(Map.of(
"baiyue.integration.lingxing-wms.enabled", "true",
"baiyue.integration.lingxing-wms.app-key", "example-key",
"baiyue.integration.lingxing-wms.app-secret", "example-secret"));
var properties = new Binder(source).bind("baiyue.integration.lingxing-wms", Bindable.of(LingxingWmsProperties.class)).get();
assertThat(properties.enabled()).isTrue();
assertThat(properties.hasCredentials()).isTrue();
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);
assertThat(properties.enabled()).isFalse();
assertThat(properties.hasCredentials()).isFalse();
assertThatThrownBy(() -> new LingxingWmsProperties(true, URI.create("http://example.test"), "key", "secret", null, null, null))
.isInstanceOf(IllegalArgumentException.class);
}
private static String hmac(String key, String input) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return HexFormat.of().formatHex(mac.doFinal(input.getBytes(StandardCharsets.UTF_8)));
}
}