发布当前测试版本至临时灰度环境 #19
+96
@@ -0,0 +1,96 @@
|
||||
package com.baiyue.auth.client;
|
||||
|
||||
import feign.Client;
|
||||
import feign.FeignException;
|
||||
import feign.Request;
|
||||
import feign.Response;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
|
||||
import org.springframework.boot.env.YamlPropertySourceLoader;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||
import org.springframework.cloud.openfeign.FeignAutoConfiguration;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.cloud.openfeign.FeignClientProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class AuthorizationFeignConfigurationContractTest {
|
||||
@Test
|
||||
void actualNamedNacosConfigMatchesContextIdAndOverridesGlobalFeignBudget() {
|
||||
new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(FeignAutoConfiguration.class,
|
||||
JacksonAutoConfiguration.class, HttpMessageConvertersAutoConfiguration.class))
|
||||
.withUserConfiguration(OfflineFeignConfiguration.class)
|
||||
.withPropertyValues("baiyue.clients.iam.url=http://127.0.0.1:1")
|
||||
.withInitializer(context -> {
|
||||
for (String file : new String[]{"baiyue-common.yml", "auth-service.yml"}) {
|
||||
Path path = Path.of("config", "nacos", "test", file);
|
||||
if (!Files.isRegularFile(path)) path = Path.of("..").resolve(path);
|
||||
try {
|
||||
// Load real configuration as properties only, without config-data imports.
|
||||
for (var source : new YamlPropertySourceLoader().load(file, new FileSystemResource(path))) {
|
||||
context.getEnvironment().getPropertySources().addLast(source);
|
||||
}
|
||||
} catch (java.io.IOException failure) {
|
||||
throw new IllegalStateException("Cannot read actual Auth Feign configuration", failure);
|
||||
}
|
||||
}
|
||||
})
|
||||
.run(context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
String contextId = IamAuthorizationClient.class.getAnnotation(FeignClient.class).contextId();
|
||||
var properties = context.getBean(FeignClientProperties.class);
|
||||
var named = properties.getConfig().get(contextId);
|
||||
assertThat(named).as("Nacos key must match actual @FeignClient contextId").isNotNull();
|
||||
assertThat(named.getConnectTimeout()).isEqualTo(1000);
|
||||
assertThat(named.getReadTimeout()).isEqualTo(2000);
|
||||
assertThat(named.isFollowRedirects()).isFalse();
|
||||
assertThat(properties.getConfig().get("default").getConnectTimeout()).isEqualTo(3000);
|
||||
assertThat(properties.getConfig().get("default").getReadTimeout()).isEqualTo(5000);
|
||||
|
||||
var transport = context.getBean(CapturingClient.class);
|
||||
var client = context.getBean(IamAuthorizationClient.class);
|
||||
var request = new AuthorizationSnapshotRequest(1L, 7L, 9L, "MERCHANT");
|
||||
client.load("offline-service-token", request);
|
||||
assertThat(transport.options.get().connectTimeoutMillis()).isEqualTo(1000);
|
||||
assertThat(transport.options.get().readTimeoutMillis()).isEqualTo(2000);
|
||||
assertThat(transport.options.get().isFollowRedirects()).isFalse();
|
||||
|
||||
transport.status.set(503);
|
||||
assertThatThrownBy(() -> client.load("offline-service-token", request)).isInstanceOf(FeignException.class);
|
||||
assertThat(transport.calls.get()).as("One success plus one failure, without automatic retry").isEqualTo(2);
|
||||
});
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableFeignClients(clients = IamAuthorizationClient.class)
|
||||
static class OfflineFeignConfiguration {
|
||||
@Bean CapturingClient capturingClient() { return new CapturingClient(); }
|
||||
}
|
||||
|
||||
/** The real Feign factory builds the client; only network I/O is replaced. */
|
||||
static class CapturingClient implements Client {
|
||||
final AtomicReference<Request.Options> options = new AtomicReference<>();
|
||||
final AtomicInteger calls = new AtomicInteger();
|
||||
final AtomicInteger status = new AtomicInteger(204);
|
||||
|
||||
@Override public Response execute(Request request, Request.Options effectiveOptions) {
|
||||
options.set(effectiveOptions);
|
||||
calls.incrementAndGet();
|
||||
return Response.builder().request(request).status(status.get()).reason("offline")
|
||||
.headers(Map.of()).build();
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -10,14 +10,14 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class AuthTokenTtlConfigurationContractTest {
|
||||
@Test
|
||||
void cnNacosConfigurationKeepsShortAccessAndSevenDayRefreshTokens() throws Exception {
|
||||
void testNacosConfigurationKeepsUnifiedShortAccessAndSevenDayRefreshTokens() throws Exception {
|
||||
String configuration = Files.readString(
|
||||
Path.of("..", "config", "nacos", "cn", "auth-service.yml"), StandardCharsets.UTF_8);
|
||||
Path.of("..", "config", "nacos", "test", "auth-service.yml"), StandardCharsets.UTF_8);
|
||||
|
||||
assertThat(configuration)
|
||||
.contains("access-token-ttl: ${ACCESS_TOKEN_TTL:24m}")
|
||||
.contains("refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}")
|
||||
.contains("access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m}")
|
||||
.doesNotContain("OVERSEAS_ACCESS_TOKEN_TTL")
|
||||
.doesNotContain("access-token-ttl: ${ACCESS_TOKEN_TTL:7d}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
| MyBatis-Plus 与审计人 | `src/main/java/com/baiyue/common/core/mybatis/` |
|
||||
| 有限重试与退避 | `src/main/java/com/baiyue/common/core/retry/BoundedRetryPolicy.java`;供多个服务的 Outbox 复用,不拥有持久化或投递逻辑 |
|
||||
| 人工重试事务与凭据 | `src/main/java/com/baiyue/common/core/outbox/OutboxOperations.java`、`EventOperationsTokenGuard.java`;三个生产服务显式选择本库表及事件白名单,锁定 DEAD 原记录,状态恢复与审计同事务;不访问其他服务数据库或直接投递事件 |
|
||||
| 逐次投递审计事务 | `src/main/java/com/baiyue/common/core/outbox/EventDeliveryJournal.java`;领取与STARTED、持有者CAS完成与审计各自同库REQUIRES_NEW事务;HTTP在两个事务之间执行,迟到结果不得写入;JDBC为optional依赖,Print等无数据库服务不被引入JDBC |
|
||||
| 响应契约测试 | `src/test/java/com/baiyue/common/core/ApiResponseTest.java` |
|
||||
|
||||
## 修改与验证
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency><groupId>org.springframework</groupId><artifactId>spring-jdbc</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework</groupId><artifactId>spring-jdbc</artifactId><optional>true</optional></dependency>
|
||||
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>test</scope></dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
|
||||
@@ -40,7 +40,7 @@ public final class EventDeliveryJournal {
|
||||
|
||||
public boolean complete(String attempt, Outcome outcome, LocalDateTime now,
|
||||
String errorCode, LocalDateTime nextRetry, IntSupplier updateOwnedOutbox) {
|
||||
if (errorCode != null && !Set.of("RETRY_EXHAUSTED", "LEASE_EXPIRED", "INVALID_EVENT", "PERMANENT_FAILURE", "DELIVERY_FAILURE").contains(errorCode)
|
||||
if (errorCode != null && !Set.of("RETRY_EXHAUSTED", "LEASE_EXPIRED", "INVALID_EVENT", "INVALID_RESPONSE", "PERMANENT_FAILURE", "DELIVERY_FAILURE").contains(errorCode)
|
||||
&& !errorCode.matches("HTTP_[1-5][0-9]{2}")) throw new IllegalArgumentException("Unbounded audit error code");
|
||||
return Boolean.TRUE.equals(transaction.execute(status -> {
|
||||
int changed = updateOwnedOutbox.getAsInt();
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
| W3C Trace Context | `src/main/java/com/baiyue/common/web/TraceParent.java`;严格接收 version 00,响应 `traceparent`、`X-Trace-Id` 与 MDC `traceId` 使用同一 trace ID |
|
||||
| Feign Trace 传播 | `FeignTraceRequestInterceptor.java`、`FeignTracePropagationAutoConfiguration.java`;仅 Feign 存在时装配,optional 依赖不向无 Feign 服务传递 |
|
||||
| 可靠事件 HTTP 客户端配置 | `EventDeliveryFeignConfiguration.java`;由业务事件 Feign 客户端显式选择,在最终构建阶段固定连接/读取超时、禁止重定向和客户端内部重试,不作为全局自动配置 |
|
||||
| 投递审计和指标装配 | `EventDeliveryObservationAutoConfiguration.java`、`EventDeliveryMetrics.java`、`EventDeliveryObservation.java`;仅EventDeliveryProducer标记的业务Relay装配必需Journal/Metrics,其他JDBC服务不装配;定时采样固定事件白名单,Prometheus仅读内存快照,采样失败保留旧值并暴露失败/时间,错误标签不含载荷或令牌 |
|
||||
| RestClient Trace 传播 | `RestClientTraceInterceptor.java`、`RestClientTracePropagationAutoConfiguration.java`;Spring 管理的 Builder 自动装配,原始 Builder 可显式复用拦截器 |
|
||||
| 自动配置入口 | `src/main/java/com/baiyue/common/web/CommonWebAutoConfiguration.java`;为只扫描自身包的服务注册异常处理器和 Correlation 过滤器 |
|
||||
| 全局异常映射与业务失败指标 | `src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java`;指标 `baiyue.api.business.failures` 按有限 `code` 标记 |
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<name>baiyue-common-web</name>
|
||||
|
||||
<dependencies>
|
||||
<dependency><groupId>org.springframework</groupId><artifactId>spring-jdbc</artifactId><optional>true</optional></dependency>
|
||||
<dependency><groupId>com.baiyue.shared</groupId><artifactId>common-event-contract</artifactId><version>${project.version}</version></dependency>
|
||||
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>test</scope></dependency>
|
||||
<dependency>
|
||||
|
||||
@@ -42,7 +42,8 @@ public class EventDeliveryFailure extends IllegalStateException {
|
||||
&& ("NoFallbackAvailableException".equals(current.getClass().getSimpleName())
|
||||
|| "No fallback available.".equals(current.getMessage()))) current = current.getCause();
|
||||
String message;
|
||||
if (current instanceof FeignException http) message = "HTTP " + http.status();
|
||||
if (current instanceof feign.codec.DecodeException) message = "Invalid consumer response contract";
|
||||
else if (current instanceof FeignException http) message = "HTTP " + http.status();
|
||||
else if (current instanceof JsonProcessingException) message = "Invalid event JSON";
|
||||
else message = current.getMessage() == null ? "unknown" : current.getMessage();
|
||||
String text = current.getClass().getSimpleName() + ": " + message;
|
||||
|
||||
@@ -19,6 +19,7 @@ public final class EventDeliveryObservation {
|
||||
public static String errorCode(Throwable error, boolean permanent, boolean requestStarted) {
|
||||
Set<Throwable> seen = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
for (Throwable cause = error; cause != null && seen.add(cause); cause = cause.getCause()) {
|
||||
if (cause instanceof feign.codec.DecodeException) return "INVALID_RESPONSE";
|
||||
if (cause instanceof FeignException http && http.status() >= 100 && http.status() <= 599) {
|
||||
return "HTTP_" + http.status();
|
||||
}
|
||||
|
||||
+2
@@ -5,6 +5,7 @@ import com.baiyue.common.core.outbox.EventDeliveryProducer;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||
import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
@@ -13,6 +14,7 @@ import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
@AutoConfiguration(after = {JdbcTemplateAutoConfiguration.class, DataSourceTransactionManagerAutoConfiguration.class})
|
||||
@ConditionalOnBean(EventDeliveryProducer.class)
|
||||
@ConditionalOnClass(name = "org.springframework.jdbc.core.JdbcTemplate")
|
||||
public class EventDeliveryObservationAutoConfiguration {
|
||||
@Bean
|
||||
EventDeliveryJournal eventDeliveryJournal(JdbcTemplate jdbc, PlatformTransactionManager manager) {
|
||||
|
||||
@@ -5,6 +5,15 @@ import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class EventDeliveryObservationTest {
|
||||
@Test void malformedSuccessResponseIsNotReportedAsAnHttpSuccess() {
|
||||
var request = feign.Request.create(feign.Request.HttpMethod.POST, "http://consumer.invalid/events",
|
||||
java.util.Map.of(), new byte[0], java.nio.charset.StandardCharsets.UTF_8, new feign.RequestTemplate());
|
||||
var failure = new feign.codec.DecodeException(200, "secret remote payload", request);
|
||||
assertThat(EventDeliveryObservation.errorCode(failure, false, true)).isEqualTo("INVALID_RESPONSE");
|
||||
assertThat(EventDeliveryFailure.diagnostic(failure, "token"))
|
||||
.contains("Invalid consumer response contract").doesNotContain("secret", "HTTP 200");
|
||||
assertThat(EventDeliveryFailure.isPermanent(failure)).isFalse();
|
||||
}
|
||||
@Test void auditIdentitySupportsTheFormalUuidAndUlidContractWithoutSavingMalformedPayload() {
|
||||
var mapper = new ObjectMapper();
|
||||
for (String id : new String[]{"00000000-0000-4000-8000-000000000019", "01ARZ3NDEKTSV4RRFFQ69G5FAV"}) {
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
spring:
|
||||
cloud:
|
||||
openfeign:
|
||||
client:
|
||||
config:
|
||||
iamAuthorizationClient:
|
||||
connect-timeout: 1000
|
||||
read-timeout: 2000
|
||||
follow-redirects: false
|
||||
datasource:
|
||||
url: ${AUTH_DB_URL}
|
||||
username: ${AUTH_DB_USERNAME}
|
||||
|
||||
@@ -11,10 +11,9 @@ class CustomsFileExportContractTest {
|
||||
private static final String TYPE = "LOADING_LIST_BUY_DECLARATION_ITEMS";
|
||||
|
||||
@Test void applicationAndNacosRegisterTheSameFirstlegProviderContract() throws Exception {
|
||||
for (String file : new String[]{
|
||||
"file/src/main/resources/application.yml",
|
||||
"config/nacos/local/file-service.yml",
|
||||
"config/nacos/cn/file-service.yml"}) {
|
||||
assertThat(Files.readString(repositoryRoot().resolve("file/src/main/resources/application.yml")))
|
||||
.contains("nacos:${spring.application.name}.yml").doesNotContain("optional:nacos:");
|
||||
for (String file : new String[]{"config/nacos/test/file-service.yml"}) {
|
||||
String source = Files.readString(repositoryRoot().resolve(file));
|
||||
assertThat(source).contains(TYPE + ":")
|
||||
.contains("provider-service: firstleg-service")
|
||||
|
||||
+3
-2
@@ -14,8 +14,9 @@ class DomesticWarehouseOrderExportContractTest {
|
||||
private static final String TYPE = "DOMESTIC_WAREHOUSE_ORDER_LIST";
|
||||
|
||||
@Test void applicationAndNacosRegisterTheSameOrderProvider() throws Exception {
|
||||
for (String file : List.of("file/src/main/resources/application.yml",
|
||||
"config/nacos/local/file-service.yml", "config/nacos/cn/file-service.yml")) {
|
||||
assertThat(Files.readString(repositoryRoot().resolve("file/src/main/resources/application.yml")))
|
||||
.contains("nacos:${spring.application.name}.yml").doesNotContain("optional:nacos:");
|
||||
for (String file : List.of("config/nacos/test/file-service.yml")) {
|
||||
String source = Files.readString(repositoryRoot().resolve(file));
|
||||
assertThat(source).contains(TYPE + ":")
|
||||
.contains("provider-service: order-service")
|
||||
|
||||
@@ -16,12 +16,14 @@ class ExportInfrastructureContractTest {
|
||||
assertTrue(compose.contains("FILE_EXPORT_PUBLISHER_ENABLED: ${FILE_EXPORT_PUBLISHER_ENABLED:-false}"));
|
||||
assertTrue(compose.contains("FILE_EXPORT_RECONCILER_ENABLED: ${FILE_EXPORT_RECONCILER_ENABLED:-false}"));
|
||||
assertTrue(compose.contains("-Xms1g -Xmx2g"));
|
||||
assertTrue(compose.contains("MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-}"));
|
||||
assertTrue(compose.contains("ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER-}"));
|
||||
String deployScript=read("..","deploy","scripts","deploy-services.sh");
|
||||
assertTrue(deployScript.contains("部署 file 服务时,环境文件必须显式包含 MQ_TOPIC_PREFIX"));
|
||||
assertTrue(deployScript.contains("测试环境部署 file 服务时 MQ_TOPIC_PREFIX 必须为 test-"));
|
||||
assertTrue(deployScript.contains("环境文件必须包含非空 ROCKETMQ_NAME_SERVER"));
|
||||
assertTrue(compose.contains("MQ_TOPIC_PREFIX: test-"));
|
||||
assertTrue(compose.contains("ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER}"));
|
||||
String entry=read("..","deploy","scripts","deploy-services.sh");
|
||||
assertTrue(entry.contains("sh deploy/scripts/deploy-built-service.sh"));
|
||||
String deployScript=read("..","deploy","scripts","deploy-built-service.sh");
|
||||
assertTrue(deployScript.contains("MQ_TOPIC_PREFIX must be test-"));
|
||||
assertTrue(deployScript.contains("ROCKETMQ_NAME_SERVER"));
|
||||
assertTrue(deployScript.contains("env_file_value"));
|
||||
}
|
||||
|
||||
@Test void rocketMqBootstrapCreatesBothGroupsAndAReadableWorkerDlq() throws Exception {
|
||||
|
||||
@@ -22,18 +22,15 @@ class FinanceExportContractTest {
|
||||
void keepsAllFileDefinitionsOnTheSameNineTypes() throws Exception {
|
||||
Path backendRoot = backendRoot();
|
||||
String application = Files.readString(backendRoot.resolve("file/src/main/resources/application.yml"), StandardCharsets.UTF_8);
|
||||
String local = Files.readString(backendRoot.resolve("config/nacos/local/file-service.yml"), StandardCharsets.UTF_8);
|
||||
String cn = Files.readString(backendRoot.resolve("config/nacos/cn/file-service.yml"), StandardCharsets.UTF_8);
|
||||
String test = Files.readString(backendRoot.resolve("config/nacos/test/file-service.yml"), StandardCharsets.UTF_8);
|
||||
|
||||
Yaml yaml = new Yaml();
|
||||
assertThat((Object) yaml.load(application)).isNotNull();
|
||||
assertThat((Object) yaml.load(local)).isNotNull();
|
||||
assertThat((Object) yaml.load(cn)).isNotNull();
|
||||
assertThat((Object) yaml.load(test)).isNotNull();
|
||||
assertThat(application).contains("nacos:${spring.application.name}.yml").doesNotContain("optional:nacos:");
|
||||
|
||||
for (String type : TYPES) {
|
||||
assertThat(application).contains(type + ":").contains("provider-path: /internal/exports/v1/" + type + "/batches");
|
||||
assertThat(local).contains(type + ":").contains("provider-path: /internal/exports/v1/" + type + "/batches");
|
||||
assertThat(cn).contains(type + ":").contains("provider-path: /internal/exports/v1/" + type + "/batches");
|
||||
assertThat(test).contains(type + ":").contains("provider-path: /internal/exports/v1/" + type + "/batches");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<parent><groupId>com.baiyue.firstleg</groupId><artifactId>baiyue-firstleg-platform</artifactId><version>1.0.0-SNAPSHOT</version><relativePath>../pom.xml</relativePath></parent>
|
||||
<artifactId>firstleg-service</artifactId>
|
||||
<dependencies>
|
||||
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>test</scope></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-mail</artifactId></dependency>
|
||||
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-core</artifactId><version>${project.version}</version></dependency>
|
||||
<dependency><groupId>com.baiyue.shared</groupId><artifactId>common-event-contract</artifactId><version>${project.version}</version></dependency>
|
||||
|
||||
+11
-10
@@ -12,21 +12,22 @@ class EventBridgeClientDeploymentContractTest {
|
||||
private static final Path REPOSITORY_ROOT = Path.of("..").toAbsolutePath().normalize();
|
||||
|
||||
@Test
|
||||
void firstlegBridgeTargetComesFromEnvironmentWithoutLoopbackFallback() throws Exception {
|
||||
void firstlegDirectTargetUsesServiceDiscoveryOrExplicitServiceUrl() throws Exception {
|
||||
String application = Files.readString(REPOSITORY_ROOT.resolve("firstleg/src/main/resources/application.yml"));
|
||||
String nacos = Files.readString(REPOSITORY_ROOT.resolve("config/nacos/cn/firstleg-service.yml"));
|
||||
|
||||
assertTrue(application.contains("event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL:}"));
|
||||
assertFalse(application.contains("LASTLEG_EVENT_BRIDGE_URL:http://127.0.0.1"));
|
||||
assertTrue(nacos.contains("event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL}"));
|
||||
String nacos = Files.readString(REPOSITORY_ROOT.resolve("config/nacos/test/firstleg-service.yml"));
|
||||
var feign = LastlegEventClient.class.getAnnotation(org.springframework.cloud.openfeign.FeignClient.class);
|
||||
assertTrue(feign.name().equals("lastleg-service"));
|
||||
assertTrue(feign.url().equals("${LASTLEG_SERVICE_URL:${baiyue.clients.lastleg.url:}}"));
|
||||
assertFalse((application + nacos).contains("EVENT_BRIDGE_URL"));
|
||||
assertFalse(feign.url().contains("127.0.0.1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cnComposeForwardsTargetAndPublishesStableNetworkAlias() throws Exception {
|
||||
void testComposeUsesDirectBusinessServiceDns() throws Exception {
|
||||
String compose = Files.readString(REPOSITORY_ROOT.resolve("deploy/docker-compose.yml"));
|
||||
|
||||
assertTrue(compose.contains("LASTLEG_EVENT_BRIDGE_URL: ${LASTLEG_EVENT_BRIDGE_URL}"));
|
||||
assertTrue(compose.contains("SERVER_PORT: 9312"));
|
||||
assertTrue(compose.contains("aliases: [event-bridge-cn]"));
|
||||
assertTrue(compose.contains("LASTLEG_SERVICE_URL: http://lastleg-service:9303"));
|
||||
assertTrue(compose.contains("firstleg-test-network"));
|
||||
assertFalse(compose.contains("event-bridge"));
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -14,8 +14,7 @@ class ContainerCustomsMultipartConfigurationContractTest {
|
||||
|
||||
@Test void applicationAndEnvironmentConfigsPermitFiftyMegabyteCustomsFiles() throws Exception {
|
||||
assertLimits(read("firstleg/src/main/resources/application.yml"));
|
||||
assertLimits(read("config/nacos/cn/firstleg-service.yml"));
|
||||
assertLimits(read("config/nacos/local/firstleg-service.yml"));
|
||||
assertLimits(read("config/nacos/test/firstleg-service.yml"));
|
||||
}
|
||||
|
||||
@Test void customsOpenApiPublishesFileAndMultipartRequestLimits() throws Exception {
|
||||
|
||||
@@ -29,7 +29,7 @@ class OutboxLeaseDatabaseTest {
|
||||
var repository = new LoadingCompletedOutboxRepository();
|
||||
ReflectionTestUtils.setField(repository, "mapper", mapper);
|
||||
var journal = new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(dataSource));
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime now = LocalDateTime.of(2026, 9, 29, 12, 0);
|
||||
jdbc.update("INSERT INTO fl_inbound_outbox(id,event_type,publish_status,retry_count,next_retry_at,created_at) VALUES(1,?,'PENDING',0,?,?)", "FirstLegLoadingPlanCompleted", now.minusSeconds(1), now.minusMinutes(3));
|
||||
assertThat(journal.begin("old", "firstleg", "FirstLegLoadingPlanCompleted", "1", null, "consumer", now.minusMinutes(2), () -> repository.claim(1L, "old", now.minusMinutes(2)))).isFalse();
|
||||
assertThat(journal.begin("old", "firstleg", "FirstLegLoadingPlanCompleted", "1", null, "consumer", now, () -> repository.claim(1L, "old", now))).isTrue();
|
||||
|
||||
+11
@@ -3,11 +3,22 @@ package com.baiyue.gateway.config;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ContextConfiguration;
|
||||
import org.springframework.cloud.gateway.config.GatewayProperties;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@ContextConfiguration(initializers = OfflineGatewayConfigurationInitializer.class)
|
||||
@SpringBootTest(properties = {
|
||||
"spring.config.location=classpath:/gateway-offline-test.properties",
|
||||
"spring.data.redis.host=127.0.0.1",
|
||||
"spring.data.redis.port=1",
|
||||
"spring.cloud.nacos.server-addr=127.0.0.1:1",
|
||||
"spring.cloud.nacos.username=offline-test",
|
||||
"spring.cloud.nacos.password=offline-test",
|
||||
"spring.cloud.nacos.discovery.namespace=offline-test",
|
||||
"spring.cloud.nacos.config.namespace=offline-test",
|
||||
"baiyue.security.internal-service-token=offline-test-service-token-32-bytes",
|
||||
"spring.cloud.nacos.discovery.enabled=false",
|
||||
"spring.cloud.nacos.discovery.register-enabled=false",
|
||||
"spring.cloud.nacos.config.enabled=false",
|
||||
|
||||
+2
-3
@@ -15,10 +15,9 @@ class DeploymentHeaderSizeContractTest {
|
||||
"SERVER_MAX_HTTP_REQUEST_HEADER_SIZE: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:-32KB}";
|
||||
|
||||
@Test
|
||||
void allowsAuthorizationTokensAndTrustedIdentityHeadersAcrossAllServices() throws Exception {
|
||||
void allowsUnifiedAuthorizationHeadersAcrossAllServices() throws Exception {
|
||||
assertThat(readRepositoryFile("deploy", "docker-compose.yml")).contains(ENVIRONMENT);
|
||||
assertThat(readRepositoryFile("config", "nacos", "cn", "baiyue-common.yml")).contains(PROPERTY);
|
||||
assertThat(readRepositoryFile("config", "nacos", "local", "baiyue-common.yml")).contains(PROPERTY);
|
||||
assertThat(readRepositoryFile("config", "nacos", "test", "baiyue-common.yml")).contains(PROPERTY);
|
||||
}
|
||||
|
||||
private static String readRepositoryFile(String first, String... more) throws Exception {
|
||||
|
||||
@@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ContextConfiguration;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||
@@ -13,7 +14,17 @@ import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
@ContextConfiguration(initializers = OfflineGatewayConfigurationInitializer.class)
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = {
|
||||
"spring.config.location=classpath:/gateway-offline-test.properties",
|
||||
"spring.data.redis.host=127.0.0.1",
|
||||
"spring.data.redis.port=1",
|
||||
"spring.cloud.nacos.server-addr=127.0.0.1:1",
|
||||
"spring.cloud.nacos.username=offline-test",
|
||||
"spring.cloud.nacos.password=offline-test",
|
||||
"spring.cloud.nacos.discovery.namespace=offline-test",
|
||||
"spring.cloud.nacos.config.namespace=offline-test",
|
||||
"baiyue.security.internal-service-token=offline-test-service-token-32-bytes",
|
||||
"spring.cloud.nacos.discovery.enabled=false",
|
||||
"spring.cloud.nacos.discovery.register-enabled=false",
|
||||
"spring.cloud.nacos.config.enabled=false",
|
||||
|
||||
+11
@@ -3,13 +3,24 @@ package com.baiyue.gateway.config;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ContextConfiguration;
|
||||
import org.springframework.cloud.gateway.config.GatewayProperties;
|
||||
import org.springframework.http.server.PathContainer;
|
||||
import org.springframework.web.util.pattern.PathPatternParser;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@ContextConfiguration(initializers = OfflineGatewayConfigurationInitializer.class)
|
||||
@SpringBootTest(properties = {
|
||||
"spring.config.location=classpath:/gateway-offline-test.properties",
|
||||
"spring.data.redis.host=127.0.0.1",
|
||||
"spring.data.redis.port=1",
|
||||
"spring.cloud.nacos.server-addr=127.0.0.1:1",
|
||||
"spring.cloud.nacos.username=offline-test",
|
||||
"spring.cloud.nacos.password=offline-test",
|
||||
"spring.cloud.nacos.discovery.namespace=offline-test",
|
||||
"spring.cloud.nacos.config.namespace=offline-test",
|
||||
"baiyue.security.internal-service-token=offline-test-service-token-32-bytes",
|
||||
"spring.cloud.nacos.discovery.enabled=false",
|
||||
"spring.cloud.nacos.discovery.register-enabled=false",
|
||||
"spring.cloud.nacos.config.enabled=false",
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.baiyue.gateway.config;
|
||||
|
||||
import org.springframework.boot.env.YamlPropertySourceLoader;
|
||||
import org.springframework.context.ApplicationContextInitializer;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
import org.springframework.core.env.EnumerablePropertySource;
|
||||
import org.springframework.core.env.MapPropertySource;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
|
||||
/** Uses actual production routes without invoking production config-data imports. */
|
||||
public final class OfflineGatewayConfigurationInitializer
|
||||
implements ApplicationContextInitializer<ConfigurableApplicationContext> {
|
||||
@Override
|
||||
public void initialize(ConfigurableApplicationContext context) {
|
||||
Path production = Path.of("src", "main", "resources", "application.yml");
|
||||
if (!Files.isRegularFile(production)) {
|
||||
production = Path.of("gateway", "src", "main", "resources", "application.yml");
|
||||
}
|
||||
try {
|
||||
var sources = new YamlPropertySourceLoader().load("gateway-production",
|
||||
new FileSystemResource(production));
|
||||
// Later YAML documents take precedence; test properties remain above all of them.
|
||||
for (int i = sources.size() - 1; i >= 0; i--) {
|
||||
var source = (EnumerablePropertySource<?>) sources.get(i);
|
||||
var properties = new LinkedHashMap<String, Object>();
|
||||
for (String key : source.getPropertyNames()) {
|
||||
if (!key.equals("spring.config.import") && !key.startsWith("spring.config.import[")) {
|
||||
properties.put(key, source.getProperty(key));
|
||||
}
|
||||
}
|
||||
context.getEnvironment().getPropertySources().addLast(
|
||||
new MapPropertySource("offline-" + source.getName(), properties));
|
||||
}
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Cannot load production Gateway configuration for offline tests", failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# Offline bootstrap only. OfflineGatewayConfigurationInitializer loads the production
|
||||
# application.yml after config-data processing, excluding only Nacos import keys.
|
||||
+2
-4
@@ -12,10 +12,8 @@ class NotificationConfigurationContractTest {
|
||||
@Test void applicationAndNacosKeepConsumersDisabledAndExposeRecoveryTimeout() throws Exception {
|
||||
assertSafeDefaults(read(Path.of("iam", "src", "main", "resources", "application.yml"),
|
||||
Path.of("src", "main", "resources", "application.yml")));
|
||||
assertSafeDefaults(read(Path.of("config", "nacos", "local", "iam-service.yml"),
|
||||
Path.of("..", "config", "nacos", "local", "iam-service.yml")));
|
||||
assertSafeDefaults(read(Path.of("config", "nacos", "cn", "iam-service.yml"),
|
||||
Path.of("..", "config", "nacos", "cn", "iam-service.yml")));
|
||||
assertSafeDefaults(read(Path.of("config", "nacos", "test", "iam-service.yml"),
|
||||
Path.of("..", "config", "nacos", "test", "iam-service.yml")));
|
||||
}
|
||||
|
||||
private void assertSafeDefaults(String yaml) {
|
||||
|
||||
+5
-7
@@ -34,15 +34,13 @@ class CustomerDimensionInitializationSqlContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void schedulerRetryBudgetHasSafeOverridableDefaultsInAllDomesticProfiles() throws Exception {
|
||||
void schedulerRetryBudgetHasSafeOverridableDefaultsInTestConfiguration() throws Exception {
|
||||
String application = new ClassPathResource("application.yml").getContentAsString(StandardCharsets.UTF_8);
|
||||
assertSchedulerConfig(application);
|
||||
for (String profile : new String[] {"local", "cn"}) {
|
||||
Path direct = Path.of("config", "nacos", profile, "iam-service.yml");
|
||||
Path config = Files.exists(direct) ? direct
|
||||
: Path.of("..", "config", "nacos", profile, "iam-service.yml");
|
||||
assertSchedulerConfig(Files.readString(config, StandardCharsets.UTF_8));
|
||||
}
|
||||
Path direct = Path.of("config", "nacos", "test", "iam-service.yml");
|
||||
Path config = Files.exists(direct) ? direct
|
||||
: Path.of("..", "config", "nacos", "test", "iam-service.yml");
|
||||
assertSchedulerConfig(Files.readString(config, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private static void assertContract(String sql) {
|
||||
|
||||
+10
-3
@@ -73,11 +73,18 @@ class Phase4BrowserApiBoundaryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void overseasLastlegReachesDomesticPrintThroughSharedInfrastructureNetwork() throws Exception {
|
||||
Path composePath = Path.of("deploy/docker-compose.overseas.yml");
|
||||
void lastlegReachesPrintThroughTheUnifiedTestNetwork() throws Exception {
|
||||
Path composePath = Path.of("deploy/docker-compose.yml");
|
||||
if (!Files.exists(composePath)) composePath = Path.of("..").resolve(composePath);
|
||||
String compose = Files.readString(composePath);
|
||||
assertTrue(compose.contains("PRINT_SERVICE_URL: ${PRINT_SERVICE_URL:-http://print:9207}"));
|
||||
String lastleg = compose.substring(compose.indexOf(" lastleg-service:"), compose.indexOf(" warehouse-overseas-service:"));
|
||||
assertTrue(lastleg.contains("<<: *common"));
|
||||
assertTrue(lastleg.contains("<<: *common-environment"));
|
||||
assertTrue(lastleg.contains("PRINT_SERVICE_URL: ${PRINT_SERVICE_URL:-http://print:9207}"));
|
||||
assertTrue(compose.contains("networks: [firstleg-network, infra-network]"));
|
||||
assertTrue(compose.contains("name: firstleg-test-network"));
|
||||
assertFalse(lastleg.contains("ports:"));
|
||||
assertFalse(compose.contains("gateway-overseas"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+2
-2
@@ -12,9 +12,9 @@ class ReceivingPhotoUploadConfigurationContractTest {
|
||||
@Test
|
||||
void lastlegAcceptsTheSameTenMegabyteLimitAsFileService() throws Exception {
|
||||
String application = read("lastleg-service/src/main/resources/application.yml");
|
||||
String overseas = read("config/nacos/overseas/lastleg-service.yml");
|
||||
String testConfiguration = read("config/nacos/test/lastleg-service.yml");
|
||||
|
||||
for (String configuration : new String[]{application, overseas}) {
|
||||
for (String configuration : new String[]{application, testConfiguration}) {
|
||||
assertThat(configuration).contains(
|
||||
"max-file-size: ${LASTLEG_MULTIPART_MAX_FILE_SIZE:10MB}",
|
||||
"max-request-size: ${LASTLEG_MULTIPART_MAX_REQUEST_SIZE:11MB}");
|
||||
|
||||
+1
@@ -170,6 +170,7 @@ class InboundUnloadCompletedOutboxRelayTest {
|
||||
private LastlegEventOutbox row(EventEnvelope event) throws Exception {
|
||||
LastlegEventOutbox row = new LastlegEventOutbox();
|
||||
row.setId(9001L);
|
||||
row.setEventType(event.eventType());
|
||||
row.setEventKey("OverseasContainerUnloaded:7001");
|
||||
row.setPayloadJson(objectMapper.writeValueAsString(event));
|
||||
return row;
|
||||
|
||||
+21
-10
@@ -14,7 +14,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class DomesticPartnerWarehouseClientContractTest {
|
||||
@Test
|
||||
void freezesCnInternalUrlTokenHeaderAndFourFieldDto() throws Exception {
|
||||
void freezesWarehouseInternalUrlTokenHeaderAndFourFieldDto() throws Exception {
|
||||
FeignClient feign = DomesticPartnerWarehouseClient.class.getAnnotation(FeignClient.class);
|
||||
assertThat(feign.name()).isEqualTo("warehouse-service");
|
||||
assertThat(feign.url()).isEqualTo("${baiyue.clients.domestic-warehouse.url:}");
|
||||
@@ -30,22 +30,33 @@ class DomesticPartnerWarehouseClientContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void developmentDeploymentRequiresAndPassesTheCnUrlAndSharedInternalToken() throws Exception {
|
||||
void testDeploymentPassesInternalWarehouseUrlAndSharedAuthentication() throws Exception {
|
||||
String application = Files.readString(Path.of("src/main/resources/application.yml"));
|
||||
String nacos = Files.readString(Path.of("../config/nacos/overseas/lastleg-service.yml"));
|
||||
String common = Files.readString(Path.of("../config/nacos/overseas/baiyue-common.yml"));
|
||||
String compose = Files.readString(Path.of("../deploy/docker-compose.overseas.yml"));
|
||||
String deploy = Files.readString(Path.of("../deploy/scripts/deploy-services.sh"));
|
||||
String nacos = Files.readString(Path.of("../config/nacos/test/lastleg-service.yml"));
|
||||
String common = Files.readString(Path.of("../config/nacos/test/baiyue-common.yml"));
|
||||
String compose = Files.readString(Path.of("../deploy/docker-compose.yml"));
|
||||
String deploy = Files.readString(Path.of("../deploy/scripts/deploy-built-service.sh"));
|
||||
|
||||
assertThat(application).contains("url: ${DOMESTIC_WAREHOUSE_URL:}")
|
||||
.contains("cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}");
|
||||
assertThat(nacos).contains("url: ${DOMESTIC_WAREHOUSE_URL}")
|
||||
.contains("cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}");
|
||||
assertThat(common).contains("internal-service-token: ${INTERNAL_SERVICE_TOKEN}");
|
||||
String lastleg = compose.substring(compose.indexOf(" lastleg-service:"), compose.indexOf(" warehouse-overseas-service:"));
|
||||
assertThat(lastleg).contains("<<: *common-environment")
|
||||
.contains("DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL:-http://warehouse:9205}")
|
||||
.contains("expose: [\"9303\"]")
|
||||
.doesNotContain("ports:");
|
||||
assertThat(compose).contains("INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN}")
|
||||
.contains("DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL}");
|
||||
assertThat(deploy).contains("FIRSTLEG_EVENT_BRIDGE_URL DOMESTIC_WAREHOUSE_URL")
|
||||
.contains("NACOS_SERVER_ADDR OVERSEAS_NACOS_USERNAME OVERSEAS_NACOS_PASSWORD "
|
||||
+ "OVERSEAS_NACOS_NAMESPACE INTERNAL_SERVICE_TOKEN");
|
||||
.contains("NACOS_USERNAME: ${NACOS_USERNAME}")
|
||||
.contains("NACOS_NAMESPACE: ${NACOS_NAMESPACE:?actual TEST namespace ID required}")
|
||||
.contains("JWT_JWKS_URI: http://auth:9100/api/auth/.well-known/jwks.json")
|
||||
.doesNotContain("OVERSEAS_NACOS_NAMESPACE", "OVERSEAS_NACOS_USERNAME", "FIRSTLEG_EVENT_BRIDGE_URL");
|
||||
assertThat(common).contains("session-mode: redis")
|
||||
.contains("jwk-set-uri: ${JWT_JWKS_URI:http://auth:9100/api/auth/.well-known/jwks.json}");
|
||||
assertThat(deploy).contains("Only test is supported")
|
||||
.contains("NACOS_SERVER_ADDR NACOS_NAMESPACE NACOS_USERNAME NACOS_PASSWORD")
|
||||
.contains("REDIS_TOKEN_KEY_PREFIX INTERNAL_SERVICE_TOKEN")
|
||||
.contains("lastleg-service) db_prefix=LASTLEG");
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -75,8 +75,19 @@ class PdaWarehouseContractTest {
|
||||
"/api/pda/v1/last-leg/locations/resolve:", "/api/pda/v1/last-leg/pallets/resolve:",
|
||||
"/api/pda/v1/last-leg/operations:")) assertThat(openapi).contains(path);
|
||||
assertThat(openapi).doesNotContain("/api/pda/v1/last-leg/inbound-plans/{planNo}");
|
||||
String gateway = Files.readString(Path.of("../gateway-overseas/src/main/resources/application-overseas.yml"));
|
||||
assertThat(gateway).contains("/api/pda/v1/last-leg/**").contains("lb://lastleg-service");
|
||||
String gateway = Files.readString(Path.of("../gateway/src/main/resources/application.yml"));
|
||||
String lastlegRoute = gateway.substring(gateway.indexOf("- id: lastleg-service"),
|
||||
gateway.indexOf("- id: warehouse-overseas-service"));
|
||||
assertThat(lastlegRoute).contains("uri: lb://lastleg-service")
|
||||
.contains("/api/pda/v1/last-leg/**")
|
||||
.contains("/api/platform/v1/last-leg/**")
|
||||
.contains("/api/merchant/v1/last-leg/**");
|
||||
assertThat(gateway.lines().filter(line -> line.strip().startsWith("- Path=")))
|
||||
.noneMatch(line -> line.contains("/internal/"));
|
||||
assertThat(gateway).contains("allowed-headers: Authorization,")
|
||||
.doesNotContain("gateway-overseas");
|
||||
String nacos = Files.readString(Path.of("../config/nacos/test/gateway.yml"));
|
||||
assertThat(nacos).doesNotContain("routes:"); // Shared source routes must not be replaced by a partial regional list.
|
||||
}
|
||||
|
||||
private static AuthenticatedUser user(Set<String> authorities, Long warehouseId) {
|
||||
|
||||
+2
-2
@@ -29,7 +29,7 @@ class OutboxLeaseDatabaseTest {
|
||||
var repository = new UnloadCompletedOutboxRepository();
|
||||
ReflectionTestUtils.setField(repository, "mapper", mapper);
|
||||
var journal = new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(dataSource));
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime now = LocalDateTime.of(2026, 9, 29, 12, 0);
|
||||
jdbc.update("INSERT INTO ll_event_outbox(id,event_type,status,retry_count,next_retry_at,created_at) VALUES(1,?,'PENDING',0,?,?)", "OverseasContainerUnloaded", now.minusSeconds(1), now.minusMinutes(3));
|
||||
assertThat(journal.begin("old", "lastleg-service", "OverseasContainerUnloaded", "1", null, "consumer", now.minusMinutes(2), () -> repository.claim(1L, "old", now.minusMinutes(2)))).isFalse();
|
||||
assertThat(journal.begin("old", "lastleg-service", "OverseasContainerUnloaded", "1", null, "consumer", now, () -> repository.claim(1L, "old", now))).isTrue();
|
||||
@@ -66,7 +66,7 @@ class OutboxLeaseDatabaseTest {
|
||||
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(LastlegEventOutboxMapper.class);
|
||||
var repository = new com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository(mapper);
|
||||
var journal = new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(dataSource));
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime now = LocalDateTime.of(2026, 9, 29, 12, 0);
|
||||
jdbc.update("INSERT INTO ll_event_outbox(id,event_type,status,retry_count,next_retry_at,created_at) VALUES(1,?,'PENDING',0,?,?)", "BusinessFeeSourceChanged", now.minusSeconds(1), now.minusMinutes(3));
|
||||
assertThat(journal.begin("old", "lastleg-service", "BusinessFeeSourceChanged", "1", null, "consumer", now.minusMinutes(2), () -> repository.claim(1L, "old", now.minusMinutes(2)))).isFalse();
|
||||
assertThat(journal.begin("old", "lastleg-service", "BusinessFeeSourceChanged", "1", null, "consumer", now, () -> repository.claim(1L, "old", now))).isTrue();
|
||||
|
||||
+5
-9
@@ -69,16 +69,12 @@ class Phase5OverseasUpgradeSqlContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void overseasFixedAdminProjectionAssertionMatchesItsAuthorityArray() throws Exception {
|
||||
void unifiedSessionDoesNotInitializeASecondAuthorizationProjection() throws Exception {
|
||||
String overseas = repositoryFile("deploy/sql/海外区域统一增量-MySQL8.sql");
|
||||
int start = overseas.indexOf("VALUES ('AUTHORIZATION','1000001'");
|
||||
int end = overseas.indexOf(";", start);
|
||||
String authorityRow = overseas.substring(start, end);
|
||||
int authorityCount = count(authorityRow, "platform:");
|
||||
|
||||
assertThat(authorityCount).isPositive();
|
||||
assertThat(authorityRow).contains("baseline-platform-admin-authorization-v0");
|
||||
assertThat(overseas).doesNotContain("@platform_admin_overseas_authorities");
|
||||
assertThat(overseas).doesNotContain("CREATE TABLE ll_security_projection", "CREATE TABLE ll_security_event_inbox",
|
||||
"baseline-platform-admin-authorization-v0", "@platform_admin_overseas_authorities");
|
||||
String primary = repositoryFile("deploy/sql/头程数据库统一增量-MySQL8.sql");
|
||||
assertThat(primary).contains("CREATE TABLE iam_security_subject_version", "CREATE TABLE iam_security_tenant_version");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+3
-1
@@ -39,7 +39,9 @@ class SchemaChineseCommentContractTest {
|
||||
|
||||
List<TableDefinition> baselineTables = assertAllChineseComments(baseline, "基线 SQL", 56);
|
||||
assertAllChineseComments(incremental, "增量 SQL", 50);
|
||||
assertAllChineseComments(overseasUnified, "海外统一初始化 SQL", 127);
|
||||
assertAllChineseComments(overseasUnified, "统一测试初始化 SQL", 126);
|
||||
assertFalse(overseasUnified.contains("CREATE TABLE ll_security_projection"));
|
||||
assertFalse(overseasUnified.contains("CREATE TABLE ll_security_event_inbox"));
|
||||
assertCommentMigrationCovers(incremental, overseasUnified, baselineTables);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ class LoadingCompletedConsumerTest {
|
||||
String table=schema.substring(schema.indexOf("CREATE TABLE IF NOT EXISTS ll_inbound_order ("),schema.indexOf("CREATE TABLE IF NOT EXISTS ll_inbound_order_item ("));
|
||||
assertTrue(table.contains("customer_id")); assertFalse(table.contains("tenant_id"));
|
||||
}
|
||||
@Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); }
|
||||
@Test void rejectsUnsupportedEventType(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),"UnsupportedEvent",source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); }
|
||||
@Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","MSCU6639870","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); addManifest(node.path("orders").get(0).path("pallets").get(0),2); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome()); ArgumentCaptor<DropshipRequests.Receive> captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals("123e4567-e89b-12d3-a456-426614174010:DROPSHIP:in-d",captor.getValue().idempotencyKey()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); }
|
||||
@Test void freezesReceivingWarehouseInInitialInTransitInventory(){
|
||||
service.consume(event());
|
||||
|
||||
@@ -7,8 +7,14 @@ import org.springframework.boot.test.context.SpringBootTest;
|
||||
classes = PrintServiceApplication.class,
|
||||
webEnvironment = SpringBootTest.WebEnvironment.NONE,
|
||||
properties = {
|
||||
"spring.config.location=optional:classpath:offline-print-test.yml",
|
||||
"spring.cloud.nacos.config.enabled=false",
|
||||
"spring.cloud.nacos.discovery.enabled=false",
|
||||
"spring.cloud.nacos.server-addr=127.0.0.1:1",
|
||||
"spring.cloud.nacos.username=offline",
|
||||
"spring.cloud.nacos.password=offline",
|
||||
"spring.cloud.nacos.discovery.namespace=offline-test",
|
||||
"spring.cloud.nacos.config.namespace=offline-test",
|
||||
"baiyue.security.internal-service-token=test-internal-token-at-least-32-bytes"
|
||||
})
|
||||
class PrintServiceApplicationTest {
|
||||
|
||||
+2
-2
@@ -11,8 +11,8 @@
|
||||
| 类别 | 正式入口 | 管理边界 |
|
||||
|---|---|---|
|
||||
| 生产单票发布验收 | `prod-release-acceptance.mjs`、`prod-release-delivery-stage.mjs`、内部 `prod-release-order-stage.mjs` | 同一票义乌到洛杉矶、前置同时核对东莞;订单创建/提交/审核与尾程签收/国内回传分阶段续跑,检查点独立;不调用测试造数模块,不创建账号/主档或删除数据;配置和边界见 README |
|
||||
| Admin 全量 HTTP 验证 | `admin-test-env-acceptance.mjs` | 通过显式环境文件运行开发或测试环境;默认模块覆盖平台全链路,定向验证使用 `--modules`,模块注册和依赖以总入口及 `admin-test-env-modules/README.md` 为准 |
|
||||
| Client 全量 HTTP 验证 | `client-test-env-acceptance.mjs` | 通过显式环境文件运行开发或测试环境;覆盖客户工作台完整链路,`client-phase2-test-env-acceptance.mjs` 只是入库测量与计费专项,不替代全量入口 |
|
||||
| Admin 全量 HTTP 验证 | `admin-test-env-acceptance.mjs` | 通过显式环境文件运行统一 TEST Gateway/会话,拒绝旧海外网关参数;默认模块覆盖平台全链路,定向验证使用 `--modules`,模块注册和依赖以总入口及 `admin-test-env-modules/README.md` 为准 |
|
||||
| Client 全量 HTTP 验证 | `client-test-env-acceptance.mjs` | 通过显式环境文件运行统一 TEST Gateway/会话,拒绝旧海外网关参数;覆盖客户工作台完整链路,`client-phase2-test-env-acceptance.mjs` 只是入库测量与计费专项,不替代全量入口 |
|
||||
| Client 快速下单定向验证 | `client-quick-order-test-env-acceptance.mjs` | 使用既有测试客户通过批量草稿接口验证 FBA、海外仓、私人地址、逐行失败和幂等重放;只创建草稿,不提交或审核,不替代 Client 全量入口 |
|
||||
| 待审核订单造数 | `create-pending-review-orders.mjs` | 使用已配置客户创建并提交订单,停在待审核;不审核、不推进后续履约 |
|
||||
| 批量待分柜造数 | `it-test-wait-split-bulk.mjs` | 固定“IT测试”和“义乌仓”,随机客户可选真实线路并尽力推进到 `WAIT_SPLIT`;失败记录后继续,不自动删除成功数据;只复用 `TP-001`/`YW-TP-002`,不新增托盘或库位,普通位不足落 `A0` |
|
||||
|
||||
+7
-16
@@ -1,5 +1,7 @@
|
||||
# 后端脚本管理与全量 HTTP 验收索引
|
||||
|
||||
`admin-test-env-acceptance.mjs` 与 `client-test-env-acceptance.mjs` 仅接受统一 TEST Gateway 和 Auth 会话;移除环境文件中的 `ADMIN_OVERSEAS_GATEWAY`,不再传 `--overseas-gateway` 或非 test 环境。平台、客户、PDA 身份仍分别登录并验证权限及客户隔离。海外资源接口使用 `/api/platform/v1/overseas-warehouse-resources/**`,仓库主档仍归 Warehouse。跨服务模块的重复 GET 不证明事件重放;真实重试/幂等证据缺失会报告部署阻断,详见模块 README 和下方事件人工重试入口。离线合同不会执行真实 HTTP;环境执行仍需本轮写入授权。
|
||||
|
||||
`db/local-return-relabel-multiple-applications.sql`:本地 `baiyue_lastleg.ll_relabel_application` 单次结构修复,将尾程订单关联的唯一索引替换为普通索引,允许同一退货单剩余货物分批换标;不修改业务行,单号与幂等键唯一索引保留。凭据及目标连接由操作者显式提供,只在已授权本地库执行;DDL失败停止,已执行后不要重复运行,回查SHOW INDEX。新库结构同步在Lastleg基线和海外区域SQL中。
|
||||
|
||||
`db/recent-schema-incremental-20260923.sql`:汇总 2026-09-21 至 2026-09-22 的近期结构变更,依次显式指向 `baiyue_iam`、`baiyue_firstleg`、`baiyue_lastleg`、`baiyue_lastlag_warehouse`;补齐国内 PDA 作业仓字段及索引、人工 FBA 报价可空字段、墨家 SKU 测量字段、换标申请普通索引和 A0 共享溢出位字段。所有字段和索引变更均经 `information_schema` 判断,可重复执行;不创建数据库、不含凭据、权限种子或业务状态修正,末尾输出逐库 `READY/CHECK_REQUIRED`。由操作者选择并确认目标连接后执行,任一 DDL 报错立即停止。
|
||||
@@ -66,18 +68,10 @@ node --env-file="<外部生产配置.env>" scripts/prod-release-acceptance.mjs o
|
||||
node --env-file="<外部生产配置.env>" scripts/prod-release-acceptance.mjs order-stage "<外部订单输入.json>" "<本批次检查点.json>" --confirm-production-order --resume
|
||||
```
|
||||
|
||||
开发与测试环境的账号、密码和 Gateway 只维护在用户目录的两份外部文件中,不写入仓库:
|
||||
|
||||
| 环境 | 文件 |
|
||||
|---|---|
|
||||
| 开发环境 | `$env:USERPROFILE\.codex\chongchong-global-dev-acceptance.env` |
|
||||
| 测试环境 | `$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env` |
|
||||
|
||||
两份文件统一提供以下变量:
|
||||
统一 TEST 验收账号、密码和 Gateway 由仓库外配置文件显式提供,例如 `$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env`,不写入仓库。文件提供以下变量:
|
||||
|
||||
```text
|
||||
ADMIN_TEST_GATEWAY
|
||||
ADMIN_OVERSEAS_GATEWAY
|
||||
ADMIN_TEST_USERNAME
|
||||
ADMIN_TEST_PASSWORD
|
||||
MERCHANT_TEST_USERNAME
|
||||
@@ -91,9 +85,6 @@ Admin 默认全量及依赖国内装柜链的定向验收必须提供 `ADMIN_PDA
|
||||
脚本只读取当前 Node 进程的环境变量,不会自行查找或加载上述文件。运行时必须把 `--env-file` 放在脚本路径之前,明确选择环境:
|
||||
|
||||
```powershell
|
||||
# 开发环境
|
||||
node --env-file="$env:USERPROFILE\.codex\chongchong-global-dev-acceptance.env" scripts/admin-test-env-acceptance.mjs
|
||||
|
||||
# 测试环境
|
||||
node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/admin-test-env-acceptance.mjs
|
||||
|
||||
@@ -144,7 +135,7 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜
|
||||
| 订单与客户本地验收 | `phase1-acceptance.ps1` | 订单与客户本地服务/API 综合验收,会创建本地业务数据 |
|
||||
| 订单与客户本地验收 | `phase1-business-maturity-regression.ps1` | 订单与客户业务成熟度回归,调用本地服务并生成报告 |
|
||||
| 订单与客户本地验收 | `phase1-onboarding-order-e2e.ps1` | 注册、资质、审核到下单的本地 E2E,会创建客户、仓库、往来单位和订单 |
|
||||
| 本地质量 | `backend-full-regression.ps1` | Mapper 门禁后执行受影响后端 Maven 测试;`-DryRun` 只打印与校验 |
|
||||
| 本地质量 | `backend-full-regression.ps1` | Gateway 路由与 Mapper 门禁后执行根 POM 全部有效模块 Maven 测试;`-DryRun` 只打印与校验 |
|
||||
| 本地质量 | `frontend-full-regression.ps1` | 编排 Admin/Client 本地测试、类型检查及国内/海外 PDA Node 全量测试,不访问 Gateway |
|
||||
| 本地质量 | `database-comment-audit.ps1` | 离线扫描数据库中文注释,不连接数据库 |
|
||||
| Jenkins 只读数据库结构 | `database-schema-contract.mjs` | 只查询 `information_schema` 核对区域初始化 SQL 的表和字段;不执行 SQL、不造业务数据、不执行迁移 |
|
||||
@@ -157,7 +148,7 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜
|
||||
| SQL 合同辅助 | `sql-baseline-contract-helpers.mjs` | 只供离线 Node 合同读取统一增量 SQL、拆分表定义和种子数据;不连接数据库,也不是 CLI |
|
||||
| OpenAPI | `openapi-validation/validate-openapi.mjs` | 发现并解析各服务 OpenAPI,检查跨文件引用;只读源码 |
|
||||
| 本地环境 | `local-env.ps1` | 本地开发环境公共参数/辅助函数,由其他 PowerShell 脚本加载 |
|
||||
| 本地环境 | `start-gateway-overseas-local.ps1` | 使用本地 Maven 启动海外 Gateway,并将海外安全投影地址指向本机;会启动本地 Java 进程,不访问或修改共享环境 |
|
||||
| 本地环境 | `start-gateway-overseas-local.ps1` | 退役海外 Gateway 的遗留启动脚本,不属于当前统一 TEST 启动或回归入口;不得据此恢复海外 Gateway 部署 |
|
||||
| Nacos 环境选择 | `use-nacos-environment.ps1` | 仅设置当前进程 TEST 配置;显式提供地址、实际Namespace ID与凭据,不联网、不写运行环境 |
|
||||
| Nacos 配置发布 | `publish-nacos-config.ps1` | 仅发布 `config/nacos/test/*.yml`;必须指定实际Namespace ID,凭据从环境取得。`-DryRun`离线列清单;正式运行会写Nacos,需授权;失败停止,重跑完整匹配配置,不自动清理 |
|
||||
| 密钥配置 | `generate-overseas-jwk.mjs` | 生成 3072 位海外 JWT RSA JWK 并写入指定 `deploy/env/.env.*`;已有 JWK 时拒绝覆盖,密钥文件不得提交 |
|
||||
@@ -282,10 +273,10 @@ Admin/Client HTTP 脚本的离线合同只验证脚本结构、参数和模块
|
||||
后端脚本固定运行:
|
||||
|
||||
```text
|
||||
mvn -f <仓库根>/pom.xml -pl order,firstleg,iam,gateway,file,warehouse -am test
|
||||
mvn -f <仓库根>/pom.xml test
|
||||
```
|
||||
|
||||
这会先构建公共依赖,再运行 `order`、`firstleg`、`iam`、`gateway`、`file` 与 `warehouse` 的全部 Maven 测试。覆盖包括:
|
||||
这会按根 POM 的 reactor 拓扑执行全部有效模块测试(当前 20 个模块,包括 Auth、Finance、Lastleg、海外仓资源、Print 及公共模块),不维护服务子集白名单;根 POM 后续新增模块自动纳入。既有 Gateway 路由与 Mapper Node 门禁仍在 Maven 前执行。关键覆盖包括:
|
||||
|
||||
- 国内货柜履约 Ocean Booking Controller/Service/Mapper/SQL 契约;
|
||||
- 国内货柜履约 Truck Appointment Controller/Service/Mapper 契约、状态机、乐观锁和幂等操作;
|
||||
|
||||
@@ -286,7 +286,6 @@ function usage() {
|
||||
|
||||
选项:
|
||||
--gateway <url> 必填,国内 Gateway 地址
|
||||
--overseas-gateway <url> 四期海外模块必填,海外 Gateway 地址
|
||||
--username <name> 必填,管理员账号
|
||||
--password <value> 必填,管理员密码
|
||||
--pda-username <name> 国内装柜链必填,实际领取任务的 PDA 作业员账号
|
||||
@@ -315,7 +314,7 @@ function usage() {
|
||||
--output <path> JSON 报告路径,默认 reports/
|
||||
--help 显示帮助
|
||||
|
||||
也可使用环境变量: ADMIN_TEST_GATEWAY, ADMIN_OVERSEAS_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD,
|
||||
也可使用环境变量: ADMIN_TEST_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD,
|
||||
ADMIN_TEST_WEIGHT_IMAGE_PATH, ADMIN_TEST_CUSTOMER_NAME, MERCHANT_TEST_USERNAME,
|
||||
MERCHANT_TEST_PASSWORD, ADMIN_PDA_USERNAME, ADMIN_PDA_PASSWORD, ADMIN_WAIT_SPLIT_COUNT`)
|
||||
}
|
||||
@@ -336,6 +335,8 @@ export function parseArguments(argv) {
|
||||
values.set(name, next); index += 1
|
||||
}
|
||||
}
|
||||
if (values.has('overseas-gateway') || process.env.ADMIN_OVERSEAS_GATEWAY) throw new Error('Remove overseas-gateway / ADMIN_OVERSEAS_GATEWAY; use one TEST gateway')
|
||||
if (values.has('environment') && values.get('environment') !== 'test') throw new Error('Only test acceptance is supported')
|
||||
const requestedModules = (values.get('modules') || DEFAULT_MODULES.join(','))
|
||||
.split(',').map((v) => v.trim()).filter(Boolean)
|
||||
const modules = normalizeModuleOrder(requestedModules)
|
||||
@@ -354,8 +355,8 @@ export function parseArguments(argv) {
|
||||
if (fixtureIndex >= 0) modules.splice(fixtureIndex, 1)
|
||||
}
|
||||
return {
|
||||
environment: 'test',
|
||||
gateway: (values.get('gateway') || process.env.ADMIN_TEST_GATEWAY || '').replace(/\/$/, ''),
|
||||
overseasGateway: (values.get('overseas-gateway') || process.env.ADMIN_OVERSEAS_GATEWAY || '').replace(/\/$/, ''),
|
||||
username: values.get('username') || process.env.ADMIN_TEST_USERNAME || '',
|
||||
password: values.get('password') || process.env.ADMIN_TEST_PASSWORD || '',
|
||||
pdaUsername: values.get('pda-username') || process.env.ADMIN_PDA_USERNAME || '',
|
||||
@@ -396,10 +397,8 @@ export function validateExecutionOptions(options) {
|
||||
for (const [name, value] of [['gateway', options.gateway], ['username', options.username], ['password', options.password]]) {
|
||||
if (typeof value !== 'string' || value.trim() === '') throw new Error(`缺少必填执行参数: ${name}`)
|
||||
}
|
||||
if (options.modules.some((module) => module.startsWith('phase4-overseas-'))
|
||||
&& (!options.overseasGateway || options.overseasGateway.trim() === '')) {
|
||||
throw new Error('四期海外模块缺少必填执行参数: overseas-gateway')
|
||||
}
|
||||
if (options.environment && options.environment !== 'test') throw new Error('Only test acceptance is supported')
|
||||
if ('overseasGateway' in options) throw new Error('Use the single gateway option')
|
||||
if (options.modules.some((module) => ['phase3-loading', 'phase3-container-loading'].includes(module))
|
||||
&& (!options.pdaUsername?.trim() || !options.pdaPassword?.trim())) {
|
||||
throw new Error('国内装柜链缺少 PDA 作业员凭据: pda-username / pda-password(ADMIN_PDA_USERNAME / ADMIN_PDA_PASSWORD)')
|
||||
@@ -561,8 +560,7 @@ export class AcceptanceRun {
|
||||
this.token = ''
|
||||
this.pdaToken = ''
|
||||
this.refreshToken = ''
|
||||
this.overseasToken = ''
|
||||
this.overseasExpiresAt = 0
|
||||
this.expiresAt = 0
|
||||
this.fatal = null
|
||||
}
|
||||
|
||||
@@ -597,29 +595,6 @@ export class AcceptanceRun {
|
||||
}
|
||||
throw new Error('unreachable request attempt state')
|
||||
}
|
||||
async requestOverseas(method, path, body = undefined, extraHeaders = {}, token = this.overseasToken) {
|
||||
const headers = { Accept: 'application/json', ...extraHeaders }
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json; charset=utf-8'
|
||||
try {
|
||||
const response = await fetch(`${this.options.overseasGateway}${path}`, {
|
||||
method, headers, body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
})
|
||||
const content = await response.text()
|
||||
let payload = null
|
||||
try { payload = content ? JSON.parse(content) : null } catch { /* Raw response is recorded below. */ }
|
||||
return {
|
||||
method, path, http: response.status, body: payload,
|
||||
raw: payload === null ? content.slice(0, 500) : null,
|
||||
requestId: payload?.requestId || response.headers.get('x-request-id'),
|
||||
traceId: payload?.traceId || response.headers.get('x-trace-id'),
|
||||
}
|
||||
} catch (error) {
|
||||
return { method, path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null }
|
||||
}
|
||||
}
|
||||
|
||||
async requestMultipart(method, path, file, token = this.token, extraHeaders = {}) {
|
||||
if (this.pdaToken && /\/pda-loading\/tasks\/\d+/.test(String(path))) token = this.pdaToken
|
||||
const form = file instanceof FormData ? file : new FormData()
|
||||
@@ -631,31 +606,6 @@ export class AcceptanceRun {
|
||||
return this.requestWithBody(method, path, form, token, extraHeaders)
|
||||
}
|
||||
|
||||
async requestOverseasMultipart(method, path, file, token = this.overseasToken, extraHeaders = {}) {
|
||||
const form = file instanceof FormData ? file : new FormData()
|
||||
if (!(file instanceof FormData)) {
|
||||
if (!file || file.content === undefined) throw new TypeError('multipart file requires content')
|
||||
const content = file.content instanceof Uint8Array ? file.content : new Uint8Array(file.content)
|
||||
form.append('file', new Blob([content], { type: file.type || 'application/octet-stream' }), file.name || 'fixture.bin')
|
||||
}
|
||||
const headers = { Accept: 'application/json', ...extraHeaders }
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
try {
|
||||
const response = await fetch(`${this.options.overseasGateway}${path}`, {
|
||||
method, headers, body: form, signal: AbortSignal.timeout(60_000),
|
||||
})
|
||||
const content = await response.text(); let payload = null
|
||||
try { payload = content ? JSON.parse(content) : null } catch { /* bounded raw response */ }
|
||||
return { method, path, http: response.status, body: payload,
|
||||
raw: payload === null ? content.slice(0, 500) : null,
|
||||
requestId: payload?.requestId || response.headers.get('x-request-id'),
|
||||
traceId: payload?.traceId || response.headers.get('x-trace-id') }
|
||||
} catch (error) {
|
||||
return { method, path, http: 0, body: null,
|
||||
raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null }
|
||||
}
|
||||
}
|
||||
|
||||
async requestWithBody(method, path, body, token = this.token, extraHeaders = {}) {
|
||||
const headers = { Accept: 'application/json', ...extraHeaders }
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
@@ -706,31 +656,6 @@ export class AcceptanceRun {
|
||||
throw new Error('unreachable binary request attempt state')
|
||||
}
|
||||
|
||||
async requestOverseasBinary(method, path) {
|
||||
const headers = { Accept: '*/*' }
|
||||
if (this.overseasToken) headers.Authorization = `Bearer ${this.overseasToken}`
|
||||
try {
|
||||
const response = await fetch(`${this.options.overseasGateway}${path}`, {
|
||||
method, headers, signal: AbortSignal.timeout(120_000),
|
||||
})
|
||||
const bytes = new Uint8Array(await response.arrayBuffer())
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
let payload = null
|
||||
if (contentType.toLowerCase().includes('json')) {
|
||||
try { payload = JSON.parse(new TextDecoder().decode(bytes)) } catch { /* Invalid envelope remains binary evidence. */ }
|
||||
}
|
||||
return {
|
||||
method, path, http: response.status, body: payload, bytes, contentType,
|
||||
raw: payload ? null : `${bytes.length} bytes (${contentType || 'unknown'})`,
|
||||
requestId: payload?.requestId || response.headers.get('x-request-id'),
|
||||
traceId: payload?.traceId || response.headers.get('x-trace-id'),
|
||||
}
|
||||
} catch (error) {
|
||||
return { method, path, http: 0, body: null, bytes: new Uint8Array(), contentType: '',
|
||||
raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null }
|
||||
}
|
||||
}
|
||||
|
||||
addStep(module, name, response, passed, evidence = '', options = {}) {
|
||||
const step = {
|
||||
module, scenario: options.scenario || null, name, passed: Boolean(passed),
|
||||
@@ -947,24 +872,16 @@ export class AcceptanceRun {
|
||||
async refreshSession(reason) {
|
||||
const response = await this.request('POST', '/api/auth/refresh',
|
||||
{ refreshToken: this.refreshToken }, '')
|
||||
const next = this.apiSuccess('authentication', '国内 refresh 签发新双区域会话', response,
|
||||
`reason=${reason}`, { scenario: 'dual-token-refresh', deploymentBlocker: true })
|
||||
const valid = Boolean(next?.accessToken) && Boolean(next?.refreshToken)
|
||||
&& Boolean(next?.overseasAccessToken) && Number(next?.overseasExpiresIn) > 0
|
||||
this.assert('authentication', '刷新响应完整后原子替换国内与海外Token', response, valid,
|
||||
`accessTokenPresent=${Boolean(next?.accessToken)}, refreshTokenPresent=${Boolean(next?.refreshToken)}, overseasTokenPresent=${Boolean(next?.overseasAccessToken)}, overseasExpiresIn=${next?.overseasExpiresIn ?? 'missing'}`,
|
||||
{ scenario: 'dual-token-refresh', deploymentBlocker: true })
|
||||
const next = this.apiSuccess('authentication', '刷新统一会话', response,
|
||||
`reason=${reason}`, { scenario: 'session-refresh', deploymentBlocker: true })
|
||||
const valid = Boolean(next?.accessToken) && Boolean(next?.refreshToken) && Number(next?.expiresIn) > 0
|
||||
this.assert('authentication', '刷新响应完整后原子替换会话', response, valid,
|
||||
`accessTokenPresent=${Boolean(next?.accessToken)}, refreshTokenPresent=${Boolean(next?.refreshToken)}`,
|
||||
{ scenario: 'session-refresh', deploymentBlocker: true })
|
||||
if (!valid) return false
|
||||
const replacement = {
|
||||
token: next.accessToken,
|
||||
refreshToken: next.refreshToken,
|
||||
overseasToken: next.overseasAccessToken,
|
||||
overseasExpiresAt: Date.now() + Number(next.overseasExpiresIn) * 1000,
|
||||
}
|
||||
this.token = replacement.token
|
||||
this.refreshToken = replacement.refreshToken
|
||||
this.overseasToken = replacement.overseasToken
|
||||
this.overseasExpiresAt = replacement.overseasExpiresAt
|
||||
this.token = next.accessToken
|
||||
this.refreshToken = next.refreshToken
|
||||
this.expiresAt = Date.now() + Number(next.expiresIn) * 1000
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1809,23 +1726,12 @@ export class AcceptanceRun {
|
||||
`tokenPresent=${Boolean(this.pdaToken)}`, { deploymentBlocker: true, scenario: 'phase3-pda' })
|
||||
}
|
||||
this.refreshToken = login.refreshToken || ''
|
||||
this.overseasToken = login.overseasAccessToken || ''
|
||||
this.overseasExpiresAt = Number(login.overseasExpiresIn) > 0
|
||||
? Date.now() + Number(login.overseasExpiresIn) * 1000 : 0
|
||||
const requiresOverseas = this.options.modules.some((module) =>
|
||||
module.startsWith('phase4-overseas-') || module.startsWith('phase5-overseas-'))
|
||||
if (requiresOverseas) {
|
||||
this.assert('authentication', '登录响应包含有效海外访问令牌', loginResponse,
|
||||
Boolean(this.overseasToken) && Number(login.overseasExpiresIn) > 0,
|
||||
`tokenPresent=${Boolean(this.overseasToken)}, expiresIn=${login.overseasExpiresIn ?? 'missing'}`,
|
||||
{ deploymentBlocker: true, scenario: 'phase4-dual-token' })
|
||||
}
|
||||
this.expiresAt = Number(login.expiresIn) > 0 ? Date.now() + Number(login.expiresIn) * 1000 : 0
|
||||
const handlers = { customers: () => this.runCustomers() }
|
||||
for (let moduleIndex = 0; moduleIndex < this.options.modules.length; moduleIndex += 1) {
|
||||
const module = this.options.modules[moduleIndex]
|
||||
if ((module.startsWith('phase4-overseas-') || module.startsWith('phase5-overseas-'))
|
||||
&& this.overseasExpiresAt > 0 && Date.now() + 120_000 >= this.overseasExpiresAt) {
|
||||
await this.refreshSession(`海外模块 ${module} 执行前Token临近到期`)
|
||||
if (this.expiresAt > 0 && Date.now() + 120_000 >= this.expiresAt) {
|
||||
await this.refreshSession(`模块 ${module} 执行前Token临近到期`)
|
||||
}
|
||||
const fullFinanceChain = ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full'].every((name) => this.options.modules.includes(name))
|
||||
if (module === 'finance' && fullFinanceChain) {
|
||||
@@ -1913,12 +1819,11 @@ export class AcceptanceRun {
|
||||
if (module === 'system' && laterOverseasModule) {
|
||||
const refreshed = await this.refreshSession('system模块权限与角色变更完成')
|
||||
if (refreshed) {
|
||||
const probe = await this.requestOverseas('GET',
|
||||
'/api/platform/v1/last-leg/orders?page=1&size=1')
|
||||
this.assert('authentication', 'system后新admin海外Token只读探针可用', probe,
|
||||
const probe = await this.request('GET', '/api/platform/v1/last-leg/orders?page=1&size=1')
|
||||
this.assert('authentication', 'system后新admin统一Token只读探针可用', probe,
|
||||
probe.http === 200 && String(probe.body?.code) === '200',
|
||||
`HTTP=${probe.http}, code=${probe.body?.code ?? 'missing'}, requestId=${probe.requestId || 'missing'}, traceId=${probe.traceId || 'missing'}`,
|
||||
{ scenario: 'dual-token-refresh', deploymentBlocker: true })
|
||||
{ scenario: 'session-refresh', deploymentBlocker: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1929,7 +1834,7 @@ export class AcceptanceRun {
|
||||
createdResources: this.createdResources,
|
||||
})
|
||||
const forbiddenValues = [this.options.password, this.options.merchantPassword,
|
||||
this.token, this.refreshToken, this.overseasToken]
|
||||
this.token, this.refreshToken]
|
||||
.filter((value) => typeof value === 'string' && value.length >= 8)
|
||||
const sensitivePatterns = [
|
||||
/"(?:password|newPassword|temporaryPassword|accessToken|refreshToken|authorization)"\s*:/i,
|
||||
@@ -1946,7 +1851,7 @@ export class AcceptanceRun {
|
||||
const summary = buildAcceptanceSummary(this.steps, this.modules, this.coverageGaps)
|
||||
return {
|
||||
reportName: '后台管理测试环境 HTTP API 验收', marker: this.marker,
|
||||
gateway: this.options.gateway, overseasGateway: this.options.overseasGateway,
|
||||
gateway: this.options.gateway, gateway: this.options.gateway,
|
||||
generatedAt: new Date().toISOString(), startedAt: this.startedAt,
|
||||
success: this.fatal === null && summary.failed === 0, fatal: this.fatal,
|
||||
summary,
|
||||
|
||||
@@ -190,7 +190,7 @@ export async function run(context) {
|
||||
| 海外 PDA 退货入库、计费批次与退货合箱 | `phase4-overseas-return-receipt.mjs`(自包含创建本轮客户退货预报和绑定仓临时 PDA 操作员;覆盖退货单号/SKU 查询、正常空结果、分批签收、核平、超收、幂等、退货箱/SKU 库存、每次成功签收唯一仓储计费批次及合箱;finally 停用操作员) | `phase4-overseas-return-receipt-contract.test.mjs`;`ReturnReceiptServiceTest` 精确核对计费批次客户、仓库、签收单、起算时间和 `PENDING_PRICING` 初态 |
|
||||
| 尾程单公网控制与人工计划 | `phase4-tail-order-controls.mjs`(PDA 收货前对本轮柜执行整柜接柜仓变更/恢复与幂等冲突、单/批 FBA 目的仓变更、单/批合托标记并取消、CSV 导出、订单费用回查、单票暂扣/解除、八个人工节点和逐票人工计划) | `phase4-tail-order-controls-contract.test.mjs`;只复用现有公共接柜仓/FBA 主档,不新增仓库、托盘或库位;为目标柜全部入仓码创建 `MANUAL` 计划后交给 PDA 继续消费,不借历史订单 |
|
||||
| 海外入仓扩展、客户服务与排车 | `phase4-overseas-inbound-operations.mjs`、`phase4-overseas-client-services.mjs`、`phase4-overseas-dispatch.mjs` | 对应同名 `*-contract.test.mjs`;只消费本轮前置资源,缺失时 fail-closed |
|
||||
| 海外仓业务跨区事件与安全门禁 | `phase4-cross-region-events.mjs`(双 Token Audience 互斥、canonical 权限 403、国内装柜完成到 Lastleg 建单、海外柜完成到 Firstleg 回写、轮询重试/幂等/诊断证据;缺本轮 marker 前置时阻断) | `phase4-cross-region-events-contract.test.mjs` |
|
||||
| 海外仓业务跨区事件与安全门禁 | `phase4-cross-region-events.mjs`(统一平台会话、匿名拒绝、canonical 权限 403、国内装柜完成到 Lastleg 建单、海外柜完成到 Firstleg 回写、轮询与诊断证据;重复 GET 仅验证读取稳定,缺真实投递重放证据仍阻断;缺本轮 marker 前置时阻断) | `phase4-cross-region-events-contract.test.mjs` |
|
||||
| 国内离仓至海外 PDA 可见人工验收节点 | `phase3-customs.mjs`(国内报关资料与整柜照片)、`phase3-outbound-dispatch.mjs`(柜级离仓)、`phase3-container-documents.mjs`(离仓后目的港清关、DO;PU 使用柜级数字码)、`phase3-carrier-schedule-queries.mjs`、`phase4-carrier-pickup-ready.mjs`、`phase4-overseas-pda.mjs`(同柜候选) | `phase3-phase4-departure-pickup-chain-contract.test.mjs` |
|
||||
| 国内出仓聚合、门禁、待装柜与仓库权限 | `phase3-outbound-aggregate.mjs`、`phase3-dispatch-gate.mjs`、`phase3-waiting-for-loading.mjs`、`phase3-warehouse-rbac.mjs` | 由容器回归、生产者链和出仓相关合同覆盖;只消费本轮主链资源 |
|
||||
| 人工状态矩阵 | `manual-state-matrix.mjs` | `manual-state-matrix-contract.test.mjs`;验证装柜完成、报关和订舱相关状态组合 |
|
||||
@@ -202,7 +202,7 @@ export async function run(context) {
|
||||
|
||||
表中的 `*-fixture.mjs` 是被业务模块复用的测试数据前置,不是新增的独立全量入口。执行 HTTP 时仍从 `../admin-test-env-acceptance.mjs` 进入;`*contract.test.mjs` 只做离线脚本结构和数据归属回归。
|
||||
|
||||
海外仓业务 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;PDA 收货模块使用本轮临时 OVERSEAS_WAREHOUSE_PDA_OPERATOR 海外 Token,只调用 `/api/pda/v1/last-leg/receiving-containers/**` 柜号收货入口,不接受 `customerId`、`planNo` 或分配 ID。收货成功后由相邻的 `phase4-overseas-pda-operations` 消费仅存在于进程内存、不会进入 JSON 报告的 Token 与库存事实,执行盘点、公共位核对、托盘操作和换标;库内操作只复用收货计划产生的已有空托盘及当前仓 `VIRTUAL + ACTIVE` 公共位,不调用托盘或库位新增接口。新模块无论成功失败都在 finally 停用两个 picker 并清空临时凭据。两模块均不调用出库、退货出库、派送或内部接口;任何真实柜、箱唛、托盘、库存或换标前置缺失都必须形成 deployment blocker,不能按空场景通过;船节点禁止手工创建任务。
|
||||
海外仓业务 HTTP 验收使用 Auth 统一会话,仅通过 `--gateway` 访问 TEST Gateway;拒绝旧 `--overseas-gateway`、`ADMIN_OVERSEAS_GATEWAY` 和非 test 环境参数。平台、客户与 PDA 各用自己的账号访问令牌,权限、客户隔离及仓绑定仍须验证。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;PDA 收货模块使用本轮临时 OVERSEAS_WAREHOUSE_PDA_OPERATOR 访问令牌,只调用 `/api/pda/v1/last-leg/receiving-containers/**` 柜号收货入口,不接受 `customerId`、`planNo` 或分配 ID。收货成功后由相邻的 `phase4-overseas-pda-operations` 消费仅存在于进程内存、不会进入 JSON 报告的 Token 与库存事实,执行盘点、公共位核对、托盘操作和换标;库内操作只复用收货计划产生的已有空托盘及当前仓 `VIRTUAL + ACTIVE` 公共位,不调用托盘或库位新增接口。新模块无论成功失败都在 finally 停用两个 picker 并清空临时凭据。两模块均不调用出库、退货出库、派送或内部接口;任何真实柜、箱唛、托盘、库存或换标前置缺失都必须形成 deployment blocker,不能按空场景通过;船节点禁止手工创建任务。
|
||||
|
||||
节点全链定向入口为 `--modules phase4-overseas-pda`。依赖展开固定先为本轮 `LOADED` 柜次完成国内 `CUSTOMS_DECLARATION` 与 PDA 装柜证据,再确认国内出仓;离仓后才维护 `DESTINATION_CLEARANCE`、`DELIVERY_ORDER` 两类目的港文件,PU 使用柜级数字码而不是文件,随后核对自动创建的 `OUTBOUND_CONFIRM` 船节点任务、人工补录至实际 `PICKUP_READY`,最后验证海外 PDA 候选可见。资料模块只写本轮 marker 柜次,覆盖上传/完成重放、同键异载荷 `A0409`、冲突后零漂移和聚合回读;任一 fixture 或 Trace 缺失均为 deployment blocker。
|
||||
|
||||
@@ -217,7 +217,7 @@ node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env"
|
||||
FBA 无 `ll_inbound_plan_carton` manifest 时,服务端只取完整扫描值的第一个 `#` 前缀,并在当前锁定柜中精确匹配唯一 `inboundCode`;不校验 SKU、`#` 后缀或箱序号,也不能要求前端拆分或自行决定归属。无 `#`、空前缀、未命中、跨柜或同柜多匹配必须拒绝。OVERSEAS 仍按完整箱码精确匹配真实 manifest 行。HTTP 合同使用任意 FBA 后缀验证前缀解析,覆盖上述负向与 OVERSEAS 缺 manifest,并在正负向 resolve 后回查 current 的状态、版本与收货数量零漂移。2026-08-26 最新已部署实机尚返回 `A0404 Carton mark was not found`,requestId `a716e2c2-dc10-4d07-8014-7438afb63090`、traceId `6ab8fcfcd812056ad6570d3b1652157b`;该证据保留为后端 FBA 入仓码前缀解析落盘及重新部署后的回归反证,不据此放宽成功合同。
|
||||
|
||||
```powershell
|
||||
node scripts/admin-test-env-acceptance.mjs --gateway <development-gateway> --overseas-gateway <development-overseas-gateway> --modules phase4-overseas-warehouse,phase4-overseas-pda,phase4-overseas-pda-operations,phase4-cross-region-events,phase4-overseas-business,phase4-overseas-master-data,phase4-carrier-nodes,phase4-overseas-wallet-and-pallet-cost
|
||||
node scripts/admin-test-env-acceptance.mjs --gateway <test-gateway> --modules phase4-overseas-warehouse,phase4-overseas-pda,phase4-overseas-pda-operations,phase4-cross-region-events,phase4-overseas-business,phase4-overseas-master-data,phase4-carrier-nodes,phase4-overseas-wallet-and-pallet-cost
|
||||
```
|
||||
|
||||
只有已登记在 `../admin-test-env-acceptance.mjs` 的 `CONFIGURED_MODULES` 中的名称才能用于 `--modules`;目录中存在同名 `.mjs` 不代表总入口已注册。新增或改名 HTTP 模块时必须同步总入口、依赖展开、离线入口合同和本表;尚未注册的实验脚本不得写成可执行验收入口。
|
||||
@@ -340,3 +340,5 @@ node --test scripts/admin-test-env-modules/merchant-qualification-fixture-contra
|
||||
|
||||
|
||||
国内拖车自动完成与应付回归由 `phase3-outbound-dispatch.mjs` 在真实离仓后只读核验:订舱全部柜数、逐柜离仓、唯一 `AUTO_COMPLETE`、按拖车编号追溯柜次应付、金额守恒及 Finance 来源入池(有界等待 Outbox)。该模块的原有确认/撤销边界操作仍会写目标环境,执行须获得对应环境授权。`manual-state-ocean-trucking.mjs` 只构造可人工操作的状态,不伪造已完成拖车;自动终态由上述离仓链路覆盖。
|
||||
|
||||
`event-bridge-pending-recovery.mjs` 为纯离线证据校验器,文件名保留为索引入口;无事件桥调用或历史事件常量。合同覆盖 Compose DNS、三服务内部凭据版本一致、有限重试/DEAD、原事件摘要、ACK 丢失后 DUPLICATE 与单次业务效果。合成合同通过不代表环境故障演练通过;`phase4-cross-region-events` 保留真实重放证据阻断项。环境验收需本轮 marker 事件逐次投递记录、Inbox 与业务计数;仅使用受保护 `event-outbox-operations.mjs` 对 DEAD 原事件执行另行授权重试,不重放 SENT,不改表或伪造新事件。
|
||||
|
||||
@@ -30,11 +30,11 @@ test('系统验收覆盖后端四种权限资源类型并清理测试资源', ()
|
||||
assert.match(system, /DELETE.*permissions\/\$\{additionalPermissionId\}/s)
|
||||
})
|
||||
|
||||
test('system后刷新双Token且受限海外权限只接受403语义', () => {
|
||||
test('system后刷新统一Token且受限海外权限只接受403语义', () => {
|
||||
assert.match(acceptance, /module === 'system' && laterOverseasModule/)
|
||||
assert.match(acceptance, /refreshSession\('system模块权限与角色变更完成'\)/)
|
||||
assert.match(acceptance, /system后新admin海外Token只读探针可用/)
|
||||
assert.match(acceptance, /Date\.now\(\) \+ 120_000 >= this\.overseasExpiresAt/)
|
||||
assert.match(acceptance, /system后新admin统一Token只读探针可用/)
|
||||
assert.match(acceptance, /Date\.now\(\) \+ 120_000 >= this\.expiresAt/)
|
||||
assert.match(system, /缺少canonical海外订单权限必须403/)
|
||||
assert.match(system, /denied\.http === 403 \|\| \['403', 'A0403'\]\.includes/)
|
||||
assert.doesNotMatch(system, /A0401[^\r\n]{0,120}(必须403|includes)/)
|
||||
|
||||
@@ -34,7 +34,7 @@ function record(edge, name, response, passed, evidence = '') {
|
||||
}
|
||||
|
||||
async function request(edge, method, path, body, pda = false) {
|
||||
return edge.context.requestOverseas(method, path, body, {}, pda ? edge.token : edge.context.overseasToken)
|
||||
return edge.context.request(method, path, body, pda ? edge.token : edge.context.token, {})
|
||||
}
|
||||
|
||||
async function candidates(edge) {
|
||||
|
||||
@@ -93,7 +93,7 @@ test('partial-pick rejection stays failed; a changed snapshot aborts further pic
|
||||
label: 'SINGLE-SINGLE', task: { outboundNo: 'OD', taskNo: 'PDA' },
|
||||
pick: { packages: 4, cartonMarks: ['A', 'B', 'C', 'D'], idempotencyKey: 'pick' },
|
||||
context: { assert: (_module, name, _response, passed) => { if (!passed) failures.push(name) },
|
||||
requestOverseas: async () => ({ body: { code: 'A0409' } }) },
|
||||
request: async () => ({ body: { code: 'A0409' } }) },
|
||||
check: (_name, _response, passed) => assert.ok(passed),
|
||||
call: async () => structuredClone(snapshot),
|
||||
}
|
||||
|
||||
@@ -402,7 +402,7 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
|
||||
const bookingId = String(loading.cabinet.id)
|
||||
const base = '/api/platform/v1/last-leg'
|
||||
async function call(name, method, path, body, headers) {
|
||||
const response = await context.requestOverseas(method, path, body, headers)
|
||||
const response = await context.request(method, path, body, undefined, headers)
|
||||
const data = context.apiSuccess(MODULE, `${label}${name}`, response, '', OPTIONS)
|
||||
check(`${label}${name}成功`, response, response?.body?.code === '200' && data != null)
|
||||
return data
|
||||
@@ -410,7 +410,7 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
|
||||
let summary
|
||||
let summaryResponse
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
const response = await context.requestOverseas('GET', `${base}/orders/container-summaries/${bookingId}`)
|
||||
const response = await context.request('GET', `${base}/orders/container-summaries/${bookingId}`)
|
||||
summaryResponse = response
|
||||
summary = response?.body?.code === '200' ? response.body.data : null
|
||||
if (summary?.orders?.length === group.length) break
|
||||
@@ -463,14 +463,14 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
|
||||
try {
|
||||
check(`${label}海外PDA账号和安全投影可用`, null, Boolean(picker?.token))
|
||||
async function call(name, method, path, body) {
|
||||
const response = await context.requestOverseas(method, path, body, {}, picker.token)
|
||||
const response = await context.request(method, path, body, picker.token, {})
|
||||
const data = context.apiSuccess(MODULE, `${label}${name}`, response, '', OPTIONS)
|
||||
check(`${label}${name}成功`, response, response?.body?.code === '200' && data != null)
|
||||
return data
|
||||
}
|
||||
let candidate
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
const response = await context.requestOverseas('GET', `${base}/candidates`, undefined, {}, picker.token)
|
||||
const response = await context.request('GET', `${base}/candidates`, undefined, picker.token, {})
|
||||
candidate = response?.body?.data?.find?.((item) => item.containerNo === record.loading.containerNo)
|
||||
if (candidate?.operable && candidate?.allPlansReady) break
|
||||
if (attempt < 59) await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
@@ -486,7 +486,7 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
|
||||
check(`${label}签收成功`, null, signed.status === 'SIGNED')
|
||||
if (mixedContainer) {
|
||||
const completed = await receiveMixedContainer({ group, call, check, marker: context.marker,
|
||||
request: (method, path, body) => context.requestOverseas(method, path, body, {}, picker.token) })
|
||||
request: (method, path, body) => context.request(method, path, body, picker.token, {}) })
|
||||
for (const item of group) {
|
||||
const stock = await call(`${item.label}混柜完成后按入仓码查询库存`, 'POST',
|
||||
'/api/pda/v1/last-leg/inventory/resolve', { code: item.source.inboundCode })
|
||||
@@ -507,8 +507,7 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
|
||||
const before = await call('未齐收货前回查柜级事实', 'GET', `${base}/current`)
|
||||
check(`${label}剩余托盘待收箱数准确`, null,
|
||||
Number(before.pendingPackages) === group.reduce((sum, item) => sum + item.expectedCartonMarks.length, 0) - accepted)
|
||||
const premature = await context.requestOverseas('POST', `${base}/current/complete`,
|
||||
{ idempotencyKey: `${context.marker}-premature-${index}`, allowDifference: false }, {}, picker.token)
|
||||
const premature = await context.request('POST', `${base}/current/complete`, { idempotencyKey: `${context.marker}-premature-${index}`, allowDifference: false }, picker.token, {})
|
||||
check(`${label}未收齐不得按正常完成提交`, premature, premature.body?.code === 'A0409')
|
||||
const unchanged = await call('未齐完成被拒后回查柜级事实', 'GET', `${base}/current`)
|
||||
check(`${label}未齐完成拒绝后事实不变且可继续收货`, null,
|
||||
@@ -555,7 +554,7 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
|
||||
const customerId = String(group[0].task.tenantId)
|
||||
const warehouseId = group[0].overseas.warehouseId
|
||||
async function call(name, method, path, body, pda = false) {
|
||||
const response = await context.requestOverseas(method, path, body, {}, pda ? token : context.overseasToken)
|
||||
const response = await context.request(method, path, body, pda ? token : context.token, {})
|
||||
const value = context.apiSuccess(MODULE, `${label}${name}`, response, '', OPTIONS)
|
||||
check(`${label}${name}成功`, response, response?.body?.code === '200' && value != null)
|
||||
return value
|
||||
|
||||
@@ -1,49 +1,34 @@
|
||||
export const EVENT_BRIDGE_RECOVERY_FIXTURE = Object.freeze({
|
||||
customerCode: 'C10000000',
|
||||
eventId: 'b569c7f6-3493-4a0f-a944-dd42698f183b',
|
||||
initialOutboxStatus: 'PENDING',
|
||||
initialRetryCount: 62,
|
||||
failedTarget: '172.31.198.8:9313',
|
||||
currentOutboxStatus: 'PENDING',
|
||||
currentRetryCount: 81,
|
||||
currentFailureCode: 'A0401',
|
||||
currentFailureMessage: '内部服务令牌无效',
|
||||
currentRequestId: '200edca3-428e-446d-977a-ca98a84dd704',
|
||||
currentTraceId: 'e8e6d620c1cff40caa765e6968f89024',
|
||||
dockerDnsProven: true,
|
||||
// Pure evidence checks; no HTTP, SQL or historical incident data.
|
||||
export const TEST_DIRECT_DELIVERY_URLS = Object.freeze({
|
||||
LASTLEG_SERVICE_URL: 'http://lastleg-service:9303',
|
||||
FIRSTLEG_SERVICE_URL: 'http://firstleg:9202',
|
||||
FINANCE_SERVICE_URL: 'http://finance:9203',
|
||||
})
|
||||
|
||||
export const TEST_EVENT_BRIDGE_URLS = Object.freeze({
|
||||
LASTLEG_EVENT_BRIDGE_URL: 'http://event-bridge-overseas:9312',
|
||||
FIRSTLEG_EVENT_BRIDGE_URL: 'http://event-bridge-cn:9312',
|
||||
})
|
||||
|
||||
export const INTERNAL_TOKEN_COHORT_SERVICES = Object.freeze([
|
||||
'firstleg',
|
||||
'event-bridge-cn',
|
||||
'event-bridge-overseas',
|
||||
'lastleg-service',
|
||||
])
|
||||
|
||||
export function validTestEventBridgeEnvironment(values = {}) {
|
||||
return Object.entries(TEST_EVENT_BRIDGE_URLS)
|
||||
.every(([key, expected]) => values[key] === expected)
|
||||
&& Object.values(values).every((value) => !/^https?:\/\/(?:\d{1,3}\.){3}\d{1,3}(?::|\/|$)/.test(String(value)))
|
||||
export const INTERNAL_TOKEN_COHORT_SERVICES = Object.freeze(['firstleg', 'lastleg-service', 'finance'])
|
||||
export function validDirectDeliveryEnvironment(values = {}) {
|
||||
return Object.entries(TEST_DIRECT_DELIVERY_URLS).every(([key, url]) => values[key] === url)
|
||||
}
|
||||
|
||||
// Callers must provide only a non-secret fingerprint/revision marker, never the token value.
|
||||
// Only provide non-secret credential revision/fingerprints, never tokens.
|
||||
export function validInternalTokenCohort(fingerprints = {}) {
|
||||
const values = INTERNAL_TOKEN_COHORT_SERVICES.map((service) => fingerprints[service])
|
||||
return values.every((value) => typeof value === 'string' && value.length > 0)
|
||||
&& new Set(values).size === 1
|
||||
const values = INTERNAL_TOKEN_COHORT_SERVICES.map(service => fingerprints[service])
|
||||
return values.every(value => typeof value === 'string' && value.trim().length > 0) && new Set(values).size === 1
|
||||
}
|
||||
|
||||
export function recoveredEventBridgeFacts(facts = {}) {
|
||||
return facts.firstlegOutboxStatus === 'SENT'
|
||||
&& Number(facts.overseasBridgeEventCount) === 1
|
||||
&& Number(facts.lastlegInboxEventCount) === 1
|
||||
&& Number(facts.lastlegOrderCount) === 1
|
||||
&& Number(facts.cnBridgeDuplicateCount) === 0
|
||||
&& Number(facts.manualReplayCount) === 0
|
||||
&& Number(facts.sqlMutationCount) === 0
|
||||
export function validDirectDeliveryEvidence(facts = {}) {
|
||||
const attempts = facts.attempts
|
||||
if (!facts.marker || !facts.eventId || !facts.payloadHash || !Array.isArray(attempts) || attempts.length < 1) return false
|
||||
if (!Number.isInteger(facts.maxAttempts) || facts.maxAttempts < 1 || facts.sqlMutationCount !== 0) return false
|
||||
if (!attempts.every(a => a.eventId === facts.eventId && a.payloadHash === facts.payloadHash
|
||||
&& Number.isFinite(Date.parse(a.at)) && a.requestId && a.traceId)) return false
|
||||
const failures = attempts.filter(a => ['DNS_UNAVAILABLE','TOKEN_REJECTED','TIMEOUT'].includes(a.failure))
|
||||
if (failures.length === 0 || attempts.some(a => a.failure && a.outcome)) return false
|
||||
if (failures.some(a => a.failure === 'TOKEN_REJECTED') && facts.status !== 'DEAD'
|
||||
&& facts.authorizedDeadRetry !== true) return false
|
||||
const successful = attempts.filter(a => ['APPLIED','DUPLICATE','STALE'].includes(a.outcome))
|
||||
if (facts.status === 'DEAD') return successful.length === 0 && (failures.length === facts.maxAttempts
|
||||
|| (failures.length === 1 && failures[0].failure === 'TOKEN_REJECTED'))
|
||||
&& facts.automaticRetryScheduled === false
|
||||
// A read-only snapshot alone can never prove a duplicate delivery happened.
|
||||
return facts.status === 'SENT' && failures.length < facts.maxAttempts
|
||||
&& successful.some(a => a.outcome === 'APPLIED' && a.ackLost === true) && successful.some(a => a.outcome === 'DUPLICATE')
|
||||
&& facts.inboxEventCount === 1 && facts.businessEffectCount === 1
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ function settlementKey(context, operation) {
|
||||
function write(context, method, path, body, operation, token = context.token, overseas = false) {
|
||||
const headers = { 'Idempotency-Key': key(context, operation) }
|
||||
return overseas
|
||||
? context.requestOverseas(method, path, body, headers, token)
|
||||
? context.request(method, path, body, token, headers)
|
||||
: context.request(method, path, body, token, headers)
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ async function loadActiveSupplierAndWarehouse(context, fixture) {
|
||||
|
||||
async function publishDirectPaid(context, fixture, supplier, warehouse) {
|
||||
const body = buildDirectPaidBody(context.marker, fixture, supplier, warehouse)
|
||||
const createdResponse = await write(context, 'POST', OVERSEAS_FEES, body, 'overseas-create', context.overseasToken, true)
|
||||
const createdResponse = await write(context, 'POST', OVERSEAS_FEES, body, 'overseas-create', context.token, true)
|
||||
const created = context.apiSuccess(MODULE, '创建无入仓码海外仓 DIRECT_PAID 来源', createdResponse,
|
||||
diagnostic(createdResponse), OPTIONS)
|
||||
const targetId = sourceId(created?.sourceFeeId ? created : { feeSourceId: created?.sourceFeeId })
|
||||
@@ -116,21 +116,19 @@ async function publishDirectPaid(context, fixture, supplier, warehouse) {
|
||||
`sourceFeeId=${targetId || 'missing'}, settlementMode=${created?.settlementMode || 'missing'}`, OPTIONS)
|
||||
if (!targetId) return null
|
||||
|
||||
const replayResponse = await write(context, 'POST', OVERSEAS_FEES, body, 'overseas-create', context.overseasToken, true)
|
||||
const replayResponse = await write(context, 'POST', OVERSEAS_FEES, body, 'overseas-create', context.token, true)
|
||||
const replay = data(replayResponse)
|
||||
context.assert(MODULE, '海外费用创建同键同参重放同一来源', replayResponse,
|
||||
ok(replayResponse) && sourceId(replay?.sourceFeeId ? replay : { feeSourceId: replay?.sourceFeeId }) === targetId,
|
||||
`first=${targetId}, replay=${id(replay?.sourceFeeId) || 'missing'}`, OPTIONS)
|
||||
const conflictResponse = await write(context, 'POST', OVERSEAS_FEES,
|
||||
{ ...body, amount: body.amount + 1 }, 'overseas-create', context.overseasToken, true)
|
||||
{ ...body, amount: body.amount + 1 }, 'overseas-create', context.token, true)
|
||||
context.assert(MODULE, '海外费用创建同键异参冲突', conflictResponse, rejected(conflictResponse),
|
||||
diagnostic(conflictResponse), OPTIONS)
|
||||
|
||||
const submittedResponse = await context.requestOverseas('POST', `${OVERSEAS_FEES}/${targetId}/submit`,
|
||||
{ idempotencyKey: key(context, 'overseas-submit') })
|
||||
const submittedResponse = await context.request('POST', `${OVERSEAS_FEES}/${targetId}/submit`, { idempotencyKey: key(context, 'overseas-submit') })
|
||||
context.apiSuccess(MODULE, '提交 DIRECT_PAID 来源审核', submittedResponse, diagnostic(submittedResponse), OPTIONS)
|
||||
const reviewedResponse = await context.requestOverseas('POST', `${OVERSEAS_FEES}/${targetId}/review`,
|
||||
{ decision: 'APPROVE', reason: context.marker, idempotencyKey: key(context, 'overseas-review') })
|
||||
const reviewedResponse = await context.request('POST', `${OVERSEAS_FEES}/${targetId}/review`, { decision: 'APPROVE', reason: context.marker, idempotencyKey: key(context, 'overseas-review') })
|
||||
context.apiSuccess(MODULE, '审核发布 DIRECT_PAID 来源', reviewedResponse, diagnostic(reviewedResponse), OPTIONS)
|
||||
return { sourceFeeId: targetId, body }
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ test('merged scenarios remain in default and focused dependency chains', () => {
|
||||
test('review helper refuses stale fixture before any request', async () => {
|
||||
const steps = []
|
||||
await verifyWarehouseTransferReview({ marker: 'current', context: { merchantQualificationFixture: { marker: 'old' } },
|
||||
addStep: (...args) => steps.push(args), requestOverseas: () => { throw Error('must not request') } }, 'token', '123', {}, 'T1')
|
||||
addStep: (...args) => steps.push(args), request: () => { throw Error('must not request') } }, 'token', '123', {}, 'T1')
|
||||
assert.equal(steps.length, 1)
|
||||
assert.equal(steps[0][3], false)
|
||||
assert.equal(steps[0][5].deploymentBlocker, true)
|
||||
@@ -46,7 +46,7 @@ test('draft and removal checks use actual IDs plus readback rather than snapshot
|
||||
|
||||
test('fulfillment checks PDF signature, platform replay and immutable pallets after transfer', async () => {
|
||||
const source = await read('phase5-overseas-fulfillment.mjs')
|
||||
for (const value of ['requestOverseasBinary', '/prints/bol', "=== '%PDF-'", '/transfer-delivery',
|
||||
for (const value of ['requestBinary', '/prints/bol', "=== '%PDF-'", '/transfer-delivery',
|
||||
'adminReplay.data?.replayed', 'conflict(deniedRemove)', 'stable.data?.items?.map'])
|
||||
assert.ok(source.includes(value), value)
|
||||
})
|
||||
|
||||
@@ -158,7 +158,7 @@ test('only PDA-operated variants traverse real outbound and carrier nodes before
|
||||
assert.match(entry, /carrierLatestActualAt/)
|
||||
assert.match(pickupChain, /pickup\.latestActualAt/)
|
||||
assert.match(pickupChain, /pickup\?\.marker === variantMarker/)
|
||||
assert.doesNotMatch(pickupChain, /internal\/event-bridge|ll_container_shipping_gate|requestOverseas/)
|
||||
assert.doesNotMatch(pickupChain, /internal\/event-bridge|ll_container_shipping_gate|request/)
|
||||
assert.match(entry, /dropshipReady\?\.shippingReady === true/)
|
||||
assert.match(entry, /overseasReady\?\.shippingReady === true/)
|
||||
assert.match(entry, /awaitingVariants\.length >= 1/)
|
||||
|
||||
@@ -36,8 +36,7 @@ function fixture(context) {
|
||||
}
|
||||
|
||||
async function listOrders(context, containerNo) {
|
||||
const response = await context.requestOverseas('GET',
|
||||
`/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(containerNo)}`)
|
||||
const response = await context.request('GET', `/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(containerNo)}`)
|
||||
return { response, rows: records(response?.body?.data).filter((row) => row?.containerNo === containerNo) }
|
||||
}
|
||||
|
||||
@@ -46,8 +45,7 @@ async function snapshot(context, source) {
|
||||
const plans = []
|
||||
for (const order of listed.rows) {
|
||||
if (!order?.inboundCode) continue
|
||||
const response = await context.requestOverseas('GET',
|
||||
`/api/platform/v1/last-leg/inbound-plans?page=1&size=200&inboundCode=${encodeURIComponent(order.inboundCode)}`)
|
||||
const response = await context.request('GET', `/api/platform/v1/last-leg/inbound-plans?page=1&size=200&inboundCode=${encodeURIComponent(order.inboundCode)}`)
|
||||
plans.push(...records(response?.body?.data).map((row) => ({
|
||||
planId: id(row.planId), planNo: row.planNo, orderId: id(row.orderId), customerId: id(row.customerId), status: row.status,
|
||||
})))
|
||||
@@ -66,16 +64,14 @@ function convertBody(source) {
|
||||
}
|
||||
|
||||
async function convert(context, name, source, key) {
|
||||
const response = await context.requestOverseas('POST', CONTAINER_CONVERT_ENDPOINT,
|
||||
convertBody(source), { 'Idempotency-Key': key }, context.overseasToken)
|
||||
const response = await context.request('POST', CONTAINER_CONVERT_ENDPOINT, convertBody(source), context.token, { 'Idempotency-Key': key })
|
||||
context.addStep(MODULE, name, response, success(response), trace(response), OPTIONS)
|
||||
return response
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const data = fixture(context)
|
||||
const summaryResponse = await context.requestOverseas('GET',
|
||||
`${CONTAINER_SUMMARY_ENDPOINT}?page=1&size=10&containerNo=${encodeURIComponent(data.normal.containerNo)}`)
|
||||
const summaryResponse = await context.request('GET', `${CONTAINER_SUMMARY_ENDPOINT}?page=1&size=10&containerNo=${encodeURIComponent(data.normal.containerNo)}`)
|
||||
context.assert(MODULE, 'P1服务端柜级摘要跨订单分页仍返回15票', summaryResponse,
|
||||
success(summaryResponse) && validContainerSummaryPage(summaryResponse.body?.data, {
|
||||
sourceContainerBookingId: data.normal.sourceContainerBookingId,
|
||||
@@ -132,8 +128,7 @@ export async function run(context) {
|
||||
&& after.rows.every((row) => row.currentStatus === 'INBOUND_PLANNED' && row.inboundOrderNo),
|
||||
`planned=${after.rows.filter((row) => row.currentStatus === 'INBOUND_PLANNED').length}; ${trace(after.response)}`, OPTIONS)
|
||||
|
||||
const readySummary = await context.requestOverseas('GET',
|
||||
`${CONTAINER_SUMMARY_ENDPOINT}?page=1&size=10&containerNo=${encodeURIComponent(data.normal.containerNo)}`)
|
||||
const readySummary = await context.request('GET', `${CONTAINER_SUMMARY_ENDPOINT}?page=1&size=10&containerNo=${encodeURIComponent(data.normal.containerNo)}`)
|
||||
context.assert(MODULE, 'P0全柜每票独立计划完成后allPlansReady', readySummary,
|
||||
success(readySummary) && validContainerSummaryPage(readySummary.body?.data, {
|
||||
sourceContainerBookingId: data.normal.sourceContainerBookingId,
|
||||
|
||||
@@ -22,7 +22,7 @@ function context(resources = {}) {
|
||||
const gaps = []
|
||||
const calls = []
|
||||
return {
|
||||
marker: 'MARKER-1', token: 'domestic-token', overseasToken: 'overseas-token',
|
||||
marker: 'MARKER-1', token: 'platform-token',
|
||||
createdResources: resources, steps, gaps, calls,
|
||||
addCoverageGap(module, scenario, name, reason, category, metadata) {
|
||||
gaps.push({ module, scenario, name, reason, category, ...metadata })
|
||||
@@ -34,24 +34,28 @@ function context(resources = {}) {
|
||||
assert(module, name, value, passed, evidence, options) {
|
||||
return this.addStep(module, name, value, passed, evidence, options)
|
||||
},
|
||||
async requestOverseas(method, path, body, headers, token) {
|
||||
calls.push({ region: 'OVERSEAS', method, path, token: token || this.overseasToken })
|
||||
if (token === this.token) return response(path, null, 403, 'A0403')
|
||||
|
||||
async request(...args) {
|
||||
const route = /\/(?:last-leg|overseas-warehouse-resources)(?:\/|\?|$)/.test(String(args[1]));
|
||||
const handler = route ? async function(method,path,body,token,headers) {
|
||||
calls.push({ region: 'OVERSEAS', method, path, token: token || this.token })
|
||||
if (token === '') return response(path, null, 401, 'A0401')
|
||||
if (path.includes('/last-leg/orders/9001')) {
|
||||
return response(path, { order: { id: '9001', containerNo: 'MSCU1234567' } })
|
||||
}
|
||||
return response(path, { records: [{ id: '7001', containerNo: 'MSCU1234567' }] })
|
||||
},
|
||||
async request(method, path, body, token) {
|
||||
} : async function(method,path,body,token) {
|
||||
calls.push({ region: 'CN', method, path, token: token || this.token })
|
||||
if (token === this.overseasToken) return response(path, null, 403, 'A0403')
|
||||
if (token === '') return response(path, null, 401, 'A0401')
|
||||
if (path.includes('/overview')) return response(path, { container: {
|
||||
id: '3001', containerNo: 'MSCU1234567', unloadingCompletedAt: '2026-08-24T10:00:00Z',
|
||||
unloadingPhotoUploadedAt: '2026-08-24T09:00:00Z', estimatedEmptyReportedAt: '2026-08-27T10:00:00Z',
|
||||
actualEmptyReportedAt: null, emptyReportSourceVersion: 7,
|
||||
} })
|
||||
return response(path, { records: [{ container: { id: '3001', containerNo: 'MSCU1234567' } }] })
|
||||
},
|
||||
};
|
||||
return handler.apply(this,args);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,11 +114,11 @@ test('tail controls receive their own completed producer matrix before they cons
|
||||
assert.match(entry, /await this\.ensurePhase4PdaProducerMatrix\(\)/)
|
||||
})
|
||||
|
||||
test('contract covers dual audience, canonical 403, both event directions and diagnostics', () => {
|
||||
test('contract covers unified session, canonical 403, both event directions and diagnostics', () => {
|
||||
for (const token of [
|
||||
'context.token)', 'context.overseasToken)', 'cn-overseas-event-not-delivered',
|
||||
'无会话不得访问Lastleg', '同一平台会话可访问国内授权详情', 'cn-overseas-event-not-delivered',
|
||||
'overseas-cn-event-not-delivered', 'requestId=', 'traceId=', 'pollRequests=',
|
||||
'装柜完成事件重试保持建单幂等', '海外柜完成事件重试保持回写幂等',
|
||||
'非事件重放证据', 'missing-direct-delivery-replay-evidence',
|
||||
]) assert.ok(source.includes(token), token)
|
||||
assert.match(system, /缺少canonical海外订单权限必须403/)
|
||||
assert.match(system, /restrictedOverseasToken/)
|
||||
@@ -127,13 +131,14 @@ test('full observable cross-region flow performs requests and passes', async ()
|
||||
phase4OverseasPda: { marker: 'MARKER-1', finalStatus: 'COMPLETED', customerId: '100', orderId: '9001' },
|
||||
})
|
||||
|
||||
await run(ctx)
|
||||
await assert.rejects(() => run(ctx), /missing|真实投递重试与同事件幂等证据/)
|
||||
|
||||
assert.ok(ctx.calls.length >= 8)
|
||||
assert.equal(ctx.steps.every((step) => step.passed), true)
|
||||
assert.equal(ctx.gaps.length, 0)
|
||||
assert.ok(ctx.calls.some((call) => call.region === 'CN' && call.token === 'overseas-token'))
|
||||
assert.ok(ctx.calls.some((call) => call.region === 'OVERSEAS' && call.token === 'domestic-token'))
|
||||
assert.equal(ctx.steps.filter((step) => !step.name.includes('真实投递')).every((step) => step.passed), true)
|
||||
assert.equal(ctx.gaps.length, 1)
|
||||
assert.equal(ctx.gaps[0].category, 'missing-direct-delivery-replay-evidence')
|
||||
assert.ok(ctx.calls.some((call) => call.region === 'CN' && call.token === 'platform-token'))
|
||||
assert.ok(ctx.calls.some((call) => call.region === 'OVERSEAS' && call.token === 'platform-token'))
|
||||
})
|
||||
|
||||
test('missing marker fixture is a blocking coverage gap after real security request', async () => {
|
||||
|
||||
@@ -32,16 +32,12 @@ function sameIds(left, right) {
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
if (!context.token || !context.overseasToken) {
|
||||
blockingGap(context, '双Token前置', '国内或海外访问令牌缺失,不能执行跨区安全门禁', 'missing-dual-token')
|
||||
}
|
||||
|
||||
const domesticTokenAgainstOverseas = await context.requestOverseas('GET',
|
||||
'/api/platform/v1/last-leg/orders?page=1&size=1', undefined, {}, context.token)
|
||||
context.assert(MODULE, '国内Token不能访问海外Audience', domesticTokenAgainstOverseas,
|
||||
denied(domesticTokenAgainstOverseas) && traced(domesticTokenAgainstOverseas),
|
||||
evidence(domesticTokenAgainstOverseas), OPTIONS)
|
||||
|
||||
if (!context.token) blockingGap(context, '统一会话前置', '平台访问令牌缺失', 'missing-session')
|
||||
const authorized = await context.request('GET', '/api/platform/v1/last-leg/orders?page=1&size=1')
|
||||
context.assert(MODULE, '统一平台会话可访问Lastleg授权列表', authorized,
|
||||
success(authorized) && traced(authorized), evidence(authorized), OPTIONS)
|
||||
const anonymous = await context.request('GET', '/api/platform/v1/last-leg/orders?page=1&size=1', undefined, '')
|
||||
context.assert(MODULE, '无会话不得访问Lastleg', anonymous, denied(anonymous) && traced(anonymous), evidence(anonymous), OPTIONS)
|
||||
const phase3 = context.createdResources?.phase3Loading
|
||||
if (phase3?.marker !== context.marker || !phase3?.finalizeSucceeded
|
||||
|| !phase3?.containerBookingId || !phase3?.containerNo || !phase3?.loadingPlanId) {
|
||||
@@ -50,31 +46,29 @@ export async function run(context) {
|
||||
'missing-marker-loading-completed')
|
||||
}
|
||||
|
||||
const overseasTokenAgainstDomestic = await context.request('GET',
|
||||
`/api/platform/v1/containers/domestic/${encodeURIComponent(id(phase3.containerBookingId))}`,
|
||||
undefined, context.overseasToken)
|
||||
context.assert(MODULE, '海外Token不能访问国内Audience', overseasTokenAgainstDomestic,
|
||||
denied(overseasTokenAgainstDomestic) && traced(overseasTokenAgainstDomestic),
|
||||
evidence(overseasTokenAgainstDomestic), OPTIONS)
|
||||
const domestic = await context.request('GET',
|
||||
`/api/platform/v1/containers/domestic/${encodeURIComponent(id(phase3.containerBookingId))}`)
|
||||
context.assert(MODULE, '同一平台会话可访问国内授权详情', domestic,
|
||||
success(domestic) && traced(domestic), evidence(domestic), OPTIONS)
|
||||
|
||||
const taillegPath = `/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(phase3.containerNo)}`
|
||||
const delivered = await poll(
|
||||
() => context.requestOverseas('GET', taillegPath),
|
||||
() => context.request('GET', taillegPath),
|
||||
(response) => success(response) && records(response.body?.data)
|
||||
.some((item) => item?.containerNo === phase3.containerNo))
|
||||
const deliveredRows = records(delivered.response?.body?.data)
|
||||
.filter((item) => item?.containerNo === phase3.containerNo)
|
||||
context.assert(MODULE, '国内装柜完成经Bridge生成Lastleg订单', delivered.response,
|
||||
context.assert(MODULE, '国内装柜完成经Outbox直接投递生成Lastleg订单', delivered.response,
|
||||
deliveredRows.length > 0 && traced(delivered.response),
|
||||
evidence(delivered.response, `pollRequests=${delivered.attempts}, containerNo=${phase3.containerNo}, orders=${deliveredRows.length}`), OPTIONS)
|
||||
if (deliveredRows.length === 0) {
|
||||
blockingGap(context, '国内到海外事件闭环',
|
||||
`本轮柜号${phase3.containerNo}在轮询后仍无Lastleg订单;需结合requestId/traceId检查Bridge重试与下游诊断`,
|
||||
`本轮柜号${phase3.containerNo}在轮询后仍无Lastleg订单;需结合requestId/traceId检查生产服务Outbox重试与下游诊断`,
|
||||
'cn-overseas-event-not-delivered', delivered.response)
|
||||
}
|
||||
|
||||
const deliveredAgain = await context.requestOverseas('GET', taillegPath)
|
||||
context.assert(MODULE, '装柜完成事件重试保持建单幂等', deliveredAgain,
|
||||
const deliveredAgain = await context.request('GET', taillegPath)
|
||||
context.assert(MODULE, '装柜结果重复读取稳定(非事件重放证据)', deliveredAgain,
|
||||
success(deliveredAgain) && sameIds(delivered.response.body?.data, deliveredAgain.body?.data)
|
||||
&& traced(deliveredAgain),
|
||||
evidence(deliveredAgain, `firstOrders=${deliveredRows.map((item) => id(item.id)).join(',')}`), OPTIONS)
|
||||
@@ -88,7 +82,7 @@ export async function run(context) {
|
||||
}
|
||||
|
||||
const orderDetailPath = `/api/platform/v1/last-leg/orders/${encodeURIComponent(overseasPda.orderId)}?customerId=${encodeURIComponent(overseasPda.customerId)}`
|
||||
const orderDetail = await context.requestOverseas('GET', orderDetailPath)
|
||||
const orderDetail = await context.request('GET', orderDetailPath)
|
||||
const order = orderDetail.body?.data?.order || orderDetail.body?.data
|
||||
const completedContainerNo = order?.containerNo || ''
|
||||
context.assert(MODULE, '读取海外完成柜来源尾程单', orderDetail,
|
||||
@@ -127,13 +121,13 @@ export async function run(context) {
|
||||
const validTimeline = Number.isFinite(completedAt) && Number.isFinite(uploadedAt)
|
||||
&& Number.isFinite(estimatedAt) && completedAt >= uploadedAt
|
||||
&& estimatedAt - completedAt === 72 * 60 * 60 * 1_000
|
||||
context.assert(MODULE, '海外柜完成经Bridge回写Firstleg时间事实', writtenBack.response,
|
||||
context.assert(MODULE, '海外柜完成经Outbox直接投递回写Firstleg时间事实', writtenBack.response,
|
||||
validTimeline && traced(writtenBack.response),
|
||||
evidence(writtenBack.response,
|
||||
`pollRequests=${writtenBack.attempts}, completed=${snapshot.unloadingCompletedAt || 'missing'}, photo=${snapshot.unloadingPhotoUploadedAt || 'missing'}, estimatedEmpty=${snapshot.estimatedEmptyReportedAt || 'missing'}`), OPTIONS)
|
||||
if (!validTimeline) {
|
||||
blockingGap(context, '海外到国内事件闭环',
|
||||
`柜号${completedContainerNo}轮询后仍无有效卸柜完成/拍照/预计报空回写;需检查Bridge重试与诊断证据`,
|
||||
`柜号${completedContainerNo}轮询后仍无有效卸柜完成/拍照/预计报空回写;需检查生产服务Outbox重试与诊断证据`,
|
||||
'overseas-cn-event-not-delivered', writtenBack.response)
|
||||
}
|
||||
|
||||
@@ -141,8 +135,11 @@ export async function run(context) {
|
||||
const replaySnapshot = writtenBackAgain.body?.data?.container || {}
|
||||
const frozen = ['unloadingCompletedAt', 'unloadingPhotoUploadedAt', 'estimatedEmptyReportedAt',
|
||||
'actualEmptyReportedAt', 'emptyReportSourceVersion']
|
||||
context.assert(MODULE, '海外柜完成事件重试保持回写幂等', writtenBackAgain,
|
||||
context.assert(MODULE, '卸柜时间事实重复读取稳定(非事件重放证据)', writtenBackAgain,
|
||||
success(writtenBackAgain) && frozen.every((field) => (replaySnapshot[field] ?? null) === (snapshot[field] ?? null))
|
||||
&& traced(writtenBackAgain),
|
||||
evidence(writtenBackAgain, `containerId=${id(domesticMatches[0].id)}, sourceVersion=${snapshot.emptyReportSourceVersion ?? 'missing'}`), OPTIONS)
|
||||
blockingGap(context, '真实投递重试与同事件幂等证据',
|
||||
'两次GET仅证明读取稳定;需按event-outbox-operations.mjs的受保护内部运维流程,在本轮事件上验证故障后有限重试/DEAD及同eventId+payloadHash重复消费,保存逐次尝试与业务计数证据。不得重放SENT、改表或伪造新事件。',
|
||||
'missing-direct-delivery-replay-evidence')
|
||||
}
|
||||
|
||||
@@ -41,17 +41,9 @@ function fixture(options = {}) {
|
||||
marker, calls, steps, gaps, blockers,
|
||||
context: { merchantQualificationFixture: credentials },
|
||||
createdResources: { merchantQualificationFixture: { tenantId: '10001', marker } },
|
||||
async request(method, requestPath, body) {
|
||||
calls.push({ channel: 'domestic', method, path: requestPath, body })
|
||||
if (requestPath.includes('/iam/tenants?')) return response(page(activeCustomers))
|
||||
if (requestPath === '/api/auth/login') return response({ overseasAccessToken: 'merchant-overseas-token' })
|
||||
if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001')
|
||||
if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) {
|
||||
state.warehouseStatus = 'INACTIVE'; return response(null)
|
||||
}
|
||||
throw new Error(`unexpected domestic ${method} ${requestPath}`)
|
||||
},
|
||||
async requestOverseas(method, requestPath, body, headers, token) {
|
||||
async request(...args) {
|
||||
const route = /\/(?:last-leg|overseas-warehouse-resources)(?:\/|\?|$)/.test(String(args[1]));
|
||||
const handler = route ? async function(method,requestPath,body,token,headers) {
|
||||
calls.push({ channel: 'overseas', method, path: requestPath, body, tokenPresent: Boolean(token) })
|
||||
|
||||
if (requestPath === '/api/merchant/v1/last-leg/overseas-procurements' && method === 'POST') {
|
||||
@@ -185,7 +177,19 @@ function fixture(options = {}) {
|
||||
return response(looseboardDetail())
|
||||
}
|
||||
throw new Error(`unexpected ${method} ${requestPath}`)
|
||||
},
|
||||
} : async function(method,requestPath,body) {
|
||||
calls.push({ channel: 'domestic', method, path: requestPath, body })
|
||||
if (requestPath.includes('/iam/tenants?')) return response(page(activeCustomers))
|
||||
if (requestPath === '/api/auth/login') return response({ accessToken: 'merchant-overseas-token' })
|
||||
if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001')
|
||||
if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) {
|
||||
state.warehouseStatus = 'INACTIVE'; return response(null)
|
||||
}
|
||||
throw new Error(`unexpected domestic ${method} ${requestPath}`)
|
||||
};
|
||||
return handler.apply(this,args);
|
||||
},
|
||||
|
||||
apiSuccess(module, name, result, evidence, stepOptions) {
|
||||
const passed = result.http === 200 && String(result.body?.code) === '200'
|
||||
this.addStep(module, name, result, passed, evidence, stepOptions)
|
||||
@@ -215,7 +219,7 @@ test('module is context injected, blocks missing prerequisites and excludes forb
|
||||
for (const forbidden of ['/outbounds', '/receive-pallets', '/outbound', '/delivery', '/dispatch', '/export']) {
|
||||
assert.equal(source.includes(forbidden), false, forbidden)
|
||||
}
|
||||
assert.doesNotMatch(source, /requestOverseas\('DELETE'/)
|
||||
assert.doesNotMatch(source, /request\('DELETE'/)
|
||||
})
|
||||
|
||||
test('complete procurement truck and looseboard lifecycles pass with legal marker terminals', async () => {
|
||||
|
||||
@@ -41,7 +41,7 @@ async function domestic(context, method, name, path, body) {
|
||||
}
|
||||
|
||||
async function overseas(context, method, name, path, body) {
|
||||
const response = await context.requestOverseas(method, path, body)
|
||||
const response = await context.request(method, path, body)
|
||||
const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS)
|
||||
return { response, data }
|
||||
}
|
||||
@@ -96,17 +96,17 @@ async function loginMerchant(context, activeCustomer) {
|
||||
}
|
||||
const login = await context.request('POST', '/api/auth/login', { loginId: fixture.username, password: fixture.password }, '')
|
||||
const auth = context.apiSuccess(MODULE, '采购客户账号登录', login, trace(login), OPTIONS)
|
||||
const overseasToken = auth?.overseasAccessToken
|
||||
context.assert(MODULE, '采购客户登录返回海外Token', login, nonEmpty(overseasToken),
|
||||
`customerId=${id(activeCustomer?.id) || 'missing'}, tokenPresent=${Boolean(overseasToken)}; ${trace(login)}`, OPTIONS)
|
||||
return nonEmpty(overseasToken) ? overseasToken : null
|
||||
const token = auth?.accessToken
|
||||
context.assert(MODULE, '采购客户登录返回统一Token', login, nonEmpty(token),
|
||||
`customerId=${id(activeCustomer?.id) || 'missing'}, tokenPresent=${Boolean(token)}; ${trace(login)}`, OPTIONS)
|
||||
return nonEmpty(token) ? token : null
|
||||
}
|
||||
|
||||
async function createMerchantProcurement(context, activeCustomer, overseasToken) {
|
||||
async function createMerchantProcurement(context, activeCustomer, token) {
|
||||
const path = '/api/merchant/v1/last-leg/overseas-procurements'
|
||||
const payload = procurementPayload(context, activeCustomer)
|
||||
let activeToken = overseasToken
|
||||
let response = await context.requestOverseas('POST', path, payload, {}, activeToken)
|
||||
let activeToken = token
|
||||
let response = await context.request('POST', path, payload, activeToken, {})
|
||||
// A newly approved customer is projected to the overseas region asynchronously. The
|
||||
// domestic login can therefore succeed a few seconds before the overseas gateway has
|
||||
// received the matching USER/TENANT/AUTHORIZATION versions. Retry only that exact
|
||||
@@ -117,7 +117,7 @@ async function createMerchantProcurement(context, activeCustomer, overseasToken)
|
||||
const refreshedToken = await loginMerchant(context, activeCustomer)
|
||||
if (!refreshedToken) break
|
||||
activeToken = refreshedToken
|
||||
response = await context.requestOverseas('POST', path, payload, {}, activeToken)
|
||||
response = await context.request('POST', path, payload, activeToken, {})
|
||||
}
|
||||
const detail = context.apiSuccess(MODULE, '创建本轮客户采购订单', response, trace(response), OPTIONS)
|
||||
const value = summary(detail)
|
||||
@@ -132,15 +132,14 @@ async function createMerchantProcurement(context, activeCustomer, overseasToken)
|
||||
`orderNo=${id(value?.orderNo) || 'missing'}, customerId=${id(value?.customerId) || 'missing'}, status=${value?.status || 'missing'}, version=${value?.version ?? 'missing'}; ${trace(response)}`, OPTIONS)
|
||||
if (!valid) return null
|
||||
|
||||
const replayResponse = await context.requestOverseas('POST', path, payload, {}, activeToken)
|
||||
const replayResponse = await context.request('POST', path, payload, activeToken, {})
|
||||
const replayDetail = context.apiSuccess(MODULE, '采购创建同幂等键同载荷精确重放', replayResponse, trace(replayResponse), OPTIONS)
|
||||
const replay = summary(replayDetail)
|
||||
context.assert(MODULE, '采购幂等重放复用订单且版本不漂移', replayResponse,
|
||||
replay?.orderNo === value.orderNo && Number(replay?.version) === Number(value.version),
|
||||
`orderNo=${id(replay?.orderNo) || 'missing'}, version=${replay?.version ?? 'missing'}; ${trace(replayResponse)}`, OPTIONS)
|
||||
|
||||
const mismatch = await context.requestOverseas('POST', path,
|
||||
{ ...payload, productName: `${payload.productName}-DIFFERENT` }, {}, activeToken)
|
||||
const mismatch = await context.request('POST', path, { ...payload, productName: `${payload.productName}-DIFFERENT` }, activeToken, {})
|
||||
expectedConflict(context, '采购创建同幂等键异载荷返回冲突', mismatch)
|
||||
|
||||
return { resource, payload }
|
||||
@@ -156,11 +155,11 @@ async function prerequisites(context) {
|
||||
const activeCustomer = activeCustomers.find((item) => id(item?.id) === fixtureCustomerId)
|
||||
if (!longId(fixtureCustomerId) || !activeCustomer) return blocker(context, '本轮资格客户属于 ACTIVE 候选', tenants.response,
|
||||
`marker fixture 客户 ${fixtureCustomerId || 'missing'} 不在 ACTIVE 候选中,禁止借用其他客户身份`)
|
||||
const overseasToken = await loginMerchant(context, activeCustomer)
|
||||
if (!overseasToken) return null
|
||||
const token = await loginMerchant(context, activeCustomer)
|
||||
if (!token) return null
|
||||
const partnerWarehouse = await createPartnerWarehouse(context)
|
||||
if (!partnerWarehouse) return null
|
||||
const purchase = await createMerchantProcurement(context, activeCustomer, overseasToken)
|
||||
const purchase = await createMerchantProcurement(context, activeCustomer, token)
|
||||
if (!purchase) return null
|
||||
|
||||
const customerOptions = await overseas(context, 'GET', '查询采购客户候选', `${BASE}/overseas-procurements/customer-options?limit=200`)
|
||||
@@ -181,7 +180,7 @@ async function prerequisites(context) {
|
||||
if (!warehouse) return blocker(context, '采购仓候选存在 ACTIVE 仓', warehouseOptions.response,
|
||||
'采购仓候选为空,禁止跳过采购三态清空')
|
||||
return { activeCustomer, procurementCustomer, warehouse, customerOptions, warehouseOptions,
|
||||
partnerWarehouse, purchase, overseasToken }
|
||||
partnerWarehouse, purchase, token }
|
||||
}
|
||||
|
||||
function assertProcurementHistory(context, result) {
|
||||
@@ -249,8 +248,7 @@ async function procurementFlow(context, prerequisite) {
|
||||
&& Number(clearedSummary?.version) === Number(omittedSummary?.version) + 1,
|
||||
`version=${clearedSummary?.version ?? 'missing'}, specification=${clearedSummary?.productSpecification ?? 'null'}, warehouseId=${clearedSummary?.purchaseWarehouseId ?? 'null'}; ${trace(clearedResult.response)}`, OPTIONS)
|
||||
|
||||
const stale = await context.requestOverseas('PATCH', path,
|
||||
{ version: Number(setSummary?.version), productSpecification: 'STALE-DO-NOT-APPLY' })
|
||||
const stale = await context.request('PATCH', path, { version: Number(setSummary?.version), productSpecification: 'STALE-DO-NOT-APPLY' })
|
||||
expectedConflict(context, '采购旧 version 返回 A0409', stale, true)
|
||||
const afterConflict = await overseas(context, 'GET', '采购冲突后零漂移回查', path)
|
||||
const afterConflictSummary = summary(afterConflict.data)
|
||||
@@ -278,13 +276,9 @@ async function procurementFlow(context, prerequisite) {
|
||||
summary(rejectedReplay.data)?.status === 'REJECTED'
|
||||
&& Number(summary(rejectedReplay.data)?.version) === Number(rejectedSummary?.version),
|
||||
trace(rejectedReplay.response), OPTIONS)
|
||||
const reviewHashConflict = await context.requestOverseas('POST',
|
||||
query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}/review`, { customerId }),
|
||||
{ ...rejectedReplayBody, remark: 'DIFFERENT-DO-NOT-APPLY' })
|
||||
const reviewHashConflict = await context.request('POST', query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}/review`, { customerId }), { ...rejectedReplayBody, remark: 'DIFFERENT-DO-NOT-APPLY' })
|
||||
expectedConflict(context, '采购审核同键异载荷返回冲突', reviewHashConflict)
|
||||
const reverseReview = await context.requestOverseas('POST',
|
||||
query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}/review`, { customerId }),
|
||||
{ version: Number(rejectedSummary?.version), decision: 'APPROVE',
|
||||
const reverseReview = await context.request('POST', query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}/review`, { customerId }), { version: Number(rejectedSummary?.version), decision: 'APPROVE',
|
||||
idempotencyKey: `${context.marker}-procurement-review-terminal-reverse`.slice(0, 255),
|
||||
remark: 'REVERSE-DO-NOT-APPLY' })
|
||||
expectedConflict(context, '采购审核终态逆向返回冲突', reverseReview)
|
||||
@@ -333,8 +327,7 @@ async function assertCreateIdempotency(context, base, payload, customerId, label
|
||||
context.assert(MODULE, `${label}幂等重放复用订单且版本不漂移`, replay.response,
|
||||
replayValue?.orderNo === value.orderNo && Number(replayValue?.version) === Number(value.version),
|
||||
`orderNo=${id(replayValue?.orderNo) || 'missing'}, version=${replayValue?.version ?? 'missing'}; ${trace(replay.response)}`, OPTIONS)
|
||||
const mismatch = await context.requestOverseas('POST', path,
|
||||
{ ...payload, destination: `${payload.destination}-DIFFERENT` })
|
||||
const mismatch = await context.request('POST', path, { ...payload, destination: `${payload.destination}-DIFFERENT` })
|
||||
expectedConflict(context, `${label}创建同幂等键异载荷返回冲突`, mismatch)
|
||||
return { created, value, path }
|
||||
}
|
||||
@@ -406,8 +399,7 @@ async function truckFlow(context, customerId) {
|
||||
after?.destination === `${payload.destination}-EDIT` && Number(after?.version) === Number(before?.version) + 1
|
||||
&& after?.status === 'PENDING_REVIEW',
|
||||
`version=${after?.version ?? 'missing'}, destination=${after?.destination || 'missing'}; ${trace(edited.response)}`, OPTIONS)
|
||||
const stale = await context.requestOverseas('PATCH', path,
|
||||
{ version: Number(before?.version), destination: 'STALE-DO-NOT-APPLY' })
|
||||
const stale = await context.request('PATCH', path, { version: Number(before?.version), destination: 'STALE-DO-NOT-APPLY' })
|
||||
expectedConflict(context, '卡车旧 version 编辑返回 A0409', stale, true)
|
||||
const afterStale = await overseas(context, 'GET', '卡车旧 version 冲突后零漂移回查', path)
|
||||
const afterStaleSummary = summary(afterStale.data)
|
||||
@@ -430,11 +422,9 @@ async function truckFlow(context, customerId) {
|
||||
summary(reviewReplay.data)?.status === 'WAITING_INBOUND'
|
||||
&& Number(summary(reviewReplay.data)?.version) === Number(forwardedSummary?.version),
|
||||
trace(reviewReplay.response), OPTIONS)
|
||||
const reviewMismatch = await context.requestOverseas('POST', reviewPath,
|
||||
{ ...reviewBody, remark: 'DIFFERENT-DO-NOT-APPLY' })
|
||||
const reviewMismatch = await context.request('POST', reviewPath, { ...reviewBody, remark: 'DIFFERENT-DO-NOT-APPLY' })
|
||||
expectedConflict(context, '卡车审核同键异载荷返回冲突', reviewMismatch)
|
||||
const reversed = await context.requestOverseas('POST', reviewPath,
|
||||
{ version: Number(forwardedSummary?.version), decision: 'REJECT',
|
||||
const reversed = await context.request('POST', reviewPath, { version: Number(forwardedSummary?.version), decision: 'REJECT',
|
||||
idempotencyKey: `${context.marker}-truck-review-terminal-reverse`.slice(0, 255),
|
||||
remark: 'REVERSE-DO-NOT-APPLY' })
|
||||
expectedConflict(context, '卡车审核终态逆向返回冲突', reversed)
|
||||
@@ -483,8 +473,7 @@ async function looseboardFlow(context, customerId) {
|
||||
after?.destination === `${payload.destination}-EDIT` && Number(after?.version) === Number(before?.version) + 1
|
||||
&& after?.status === 'PENDING_ACCEPTANCE',
|
||||
`version=${after?.version ?? 'missing'}, destination=${after?.destination || 'missing'}; ${trace(edited.response)}`, OPTIONS)
|
||||
const stale = await context.requestOverseas('PUT', path,
|
||||
{ ...editable, version: Number(before?.version), destination: 'STALE-DO-NOT-APPLY' })
|
||||
const stale = await context.request('PUT', path, { ...editable, version: Number(before?.version), destination: 'STALE-DO-NOT-APPLY' })
|
||||
expectedConflict(context, '散板旧 version 编辑返回 A0409', stale, true)
|
||||
const afterStale = await overseas(context, 'GET', '散板旧 version 冲突后零漂移回查', path)
|
||||
const afterStaleSummary = summary(afterStale.data)
|
||||
@@ -511,11 +500,9 @@ async function looseboardFlow(context, customerId) {
|
||||
summary(forwardReplay.data)?.status === 'DISPATCHED'
|
||||
&& Number(summary(forwardReplay.data)?.version) === Number(forwardedSummary?.version),
|
||||
trace(forwardReplay.response), OPTIONS)
|
||||
const forwardMismatch = await context.requestOverseas('PATCH', statusPath,
|
||||
{ ...forwardBody, remark: 'DIFFERENT-DO-NOT-APPLY' })
|
||||
const forwardMismatch = await context.request('PATCH', statusPath, { ...forwardBody, remark: 'DIFFERENT-DO-NOT-APPLY' })
|
||||
expectedConflict(context, '散板状态同键异载荷返回冲突', forwardMismatch)
|
||||
const reversed = await context.requestOverseas('PATCH', statusPath,
|
||||
{ version: Number(forwardedSummary?.version), status: 'PENDING_ACCEPTANCE',
|
||||
const reversed = await context.request('PATCH', statusPath, { version: Number(forwardedSummary?.version), status: 'PENDING_ACCEPTANCE',
|
||||
idempotencyKey: `${context.marker}-looseboard-status-terminal-reverse`.slice(0, 255),
|
||||
remark: 'REVERSE-DO-NOT-APPLY' })
|
||||
expectedConflict(context, '散板状态逆向回退返回冲突', reversed)
|
||||
|
||||
@@ -51,9 +51,9 @@ test('relabel transfer and discard cover create replay conflict list detail and
|
||||
|
||||
test('customer isolation is exercised with merchant token, forged customerId and platform denial', () => {
|
||||
assert.match(source, /merchantQualificationFixture/)
|
||||
assert.match(source, /overseasAccessToken/)
|
||||
assert.match(source, /accessToken/)
|
||||
assert.match(source, /customerId: 0/)
|
||||
assert.match(source, /平台海外Token不能冒充客户访问/)
|
||||
assert.match(source, /平台统一Token不能冒充客户访问/)
|
||||
assert.match(source, /response\?\.http === 403.*codeOf\(response\) === 'A0403'/s)
|
||||
})
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ function query(path, values) {
|
||||
function future(days) { return new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 19) }
|
||||
|
||||
async function merchant(context, token, method, name, path, body) {
|
||||
const response = await context.requestOverseas(method, path, body, {}, token)
|
||||
const response = await context.request(method, path, body, token, {})
|
||||
const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS)
|
||||
return { response, data }
|
||||
}
|
||||
@@ -51,9 +51,9 @@ async function loginMerchant(context) {
|
||||
loginId: fixture.username, password: fixture.password,
|
||||
}, '')
|
||||
const auth = context.apiSuccess(MODULE, '客户工作台账号登录', response, trace(response), OPTIONS)
|
||||
context.assert(MODULE, '客户登录返回海外访问Token', response, nonBlank(auth?.overseasAccessToken),
|
||||
`tokenPresent=${Boolean(auth?.overseasAccessToken)}; ${trace(response)}`, OPTIONS)
|
||||
return nonBlank(auth?.overseasAccessToken) ? auth.overseasAccessToken : null
|
||||
context.assert(MODULE, '客户登录返回海外访问Token', response, nonBlank(auth?.accessToken),
|
||||
`tokenPresent=${Boolean(auth?.accessToken)}; ${trace(response)}`, OPTIONS)
|
||||
return nonBlank(auth?.accessToken) ? auth.accessToken : null
|
||||
}
|
||||
|
||||
async function currentOrders(context, token) {
|
||||
@@ -136,7 +136,7 @@ async function verifyRelabel(context, token, order) {
|
||||
const replay = await merchant(context, token, 'POST', '客户贴标申请幂等重放', RELABEL, body)
|
||||
context.assert(MODULE, '贴标重放复用同一申请', replay.response,
|
||||
id(replay.data?.application?.relabelNo) === relabelNo, trace(replay.response), OPTIONS)
|
||||
const hashConflict = await context.requestOverseas('POST', RELABEL, { ...body, remark: `${context.marker}-DIFFERENT` }, {}, token)
|
||||
const hashConflict = await context.request('POST', RELABEL, { ...body, remark: `${context.marker}-DIFFERENT` }, token, {})
|
||||
context.addStep(MODULE, '贴标同幂等键异载荷返回A0409', hashConflict, conflict(hashConflict), trace(hashConflict), OPTIONS)
|
||||
const page = await merchant(context, token, 'GET', '客户查询自身贴标申请列表',
|
||||
query(RELABEL, { page: 1, size: 20, relabelNo, customerId: 0 }))
|
||||
@@ -189,8 +189,7 @@ async function verifyTransfer(context, token, orders) {
|
||||
// reconcile from the authoritative detail before asserting the state.
|
||||
let submitted = null
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
const response = await context.requestOverseas('POST',
|
||||
`${TRANSFER}/${encodeURIComponent(transferNo)}/submit`, undefined, {}, token)
|
||||
const response = await context.request('POST', `${TRANSFER}/${encodeURIComponent(transferNo)}/submit`, undefined, token, {})
|
||||
if (response?.http === 0 && attempt < 3) {
|
||||
await new Promise(resolve => setTimeout(resolve, 800 * attempt))
|
||||
continue
|
||||
@@ -214,7 +213,7 @@ async function verifyTransfer(context, token, orders) {
|
||||
const replay = await merchant(context, token, 'POST', '客户仓储转运申请幂等重放', TRANSFER, body)
|
||||
context.assert(MODULE, '转运重放复用同一申请', replay.response,
|
||||
id(replay.data?.transferNo) === transferNo, trace(replay.response), OPTIONS)
|
||||
const hashConflict = await context.requestOverseas('POST', TRANSFER, { ...body, remark: `${context.marker}-DIFFERENT` }, {}, token)
|
||||
const hashConflict = await context.request('POST', TRANSFER, { ...body, remark: `${context.marker}-DIFFERENT` }, token, {})
|
||||
context.addStep(MODULE, '转运同幂等键异载荷返回A0409', hashConflict, conflict(hashConflict), trace(hashConflict), OPTIONS)
|
||||
const page = await merchant(context, token, 'GET', '客户查询自身仓储转运列表',
|
||||
query(TRANSFER, { page: 1, size: 20, transferNo, customerId: 0 }))
|
||||
@@ -231,7 +230,7 @@ async function verifyTransfer(context, token, orders) {
|
||||
}
|
||||
|
||||
async function verifyDiscard(context, token, markerOrders) {
|
||||
const response = await context.requestOverseas('GET', `${DISCARD}/inventory-candidates`, undefined, {}, token)
|
||||
const response = await context.request('GET', `${DISCARD}/inventory-candidates`, undefined, token, {})
|
||||
const candidates = context.apiSuccess(MODULE, '客户查询货物废弃库存候选', response, trace(response), OPTIONS)
|
||||
const markerOrderIds = new Set(markerOrders.map((value) => id(value.row?.id)))
|
||||
const candidate = Array.isArray(candidates) ? candidates.find((value) => markerOrderIds.has(id(value?.orderId))
|
||||
@@ -281,8 +280,8 @@ export async function run(context) {
|
||||
if (!token) return
|
||||
|
||||
for (const [name, path] of [['贴标', RELABEL], ['仓储转运', TRANSFER], ['货物废弃', DISCARD]]) {
|
||||
const denied = await context.requestOverseas('GET', query(path, { page: 1, size: 1 }))
|
||||
context.addStep(MODULE, `平台海外Token不能冒充客户访问${name}`, denied, forbidden(denied), trace(denied), OPTIONS)
|
||||
const denied = await context.request('GET', query(path, { page: 1, size: 1 }))
|
||||
context.addStep(MODULE, `平台统一Token不能冒充客户访问${name}`, denied, forbidden(denied), trace(denied), OPTIONS)
|
||||
}
|
||||
|
||||
const orders = await currentOrders(context, token)
|
||||
|
||||
@@ -32,7 +32,7 @@ function query(path, values) {
|
||||
}
|
||||
|
||||
async function api(context, method, name, path, body) {
|
||||
const response = await context.requestOverseas(method, path, body)
|
||||
const response = await context.request(method, path, body)
|
||||
const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS)
|
||||
return { response, data }
|
||||
}
|
||||
@@ -159,7 +159,7 @@ export async function run(context) {
|
||||
success(removed.response) && success(removedDetail.response)
|
||||
&& Number(removedDetail.data?.packageCount) === 0 && Number(removedDetail.data?.palletCount) === 0
|
||||
&& removedDetail.data?.items?.length === 0, trace(removedDetail.response), OPTIONS)
|
||||
const removedAgain = await context.requestOverseas('DELETE', removePath)
|
||||
const removedAgain = await context.request('DELETE', removePath)
|
||||
context.addStep(MODULE, '重复删除已不存在排车明细返回A0404', removedAgain,
|
||||
removedAgain?.http === 404 || (removedAgain?.http === 200 && codeOf(removedAgain) === 'A0404'), trace(removedAgain), OPTIONS)
|
||||
const freed = await api(context, 'GET', '移除后本轮托盘重新可预排', query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' }))
|
||||
@@ -235,7 +235,7 @@ export async function run(context) {
|
||||
`planPalletIds=${planPalletIds.join(',')}; ${trace(hiddenResult.response)}`, OPTIONS)
|
||||
|
||||
const foreignToken = `${context.marker}-DISPATCH-FOREIGN`.slice(0, 64)
|
||||
const foreignLock = await context.requestOverseas('POST', `${BASE}/draft-locks/lock`, {
|
||||
const foreignLock = await context.request('POST', `${BASE}/draft-locks/lock`, {
|
||||
planPalletId, draftToken: foreignToken,
|
||||
})
|
||||
context.addStep(MODULE, '其他草稿锁定同一托盘返回A0409', foreignLock, conflict(foreignLock), trace(foreignLock), OPTIONS)
|
||||
@@ -257,7 +257,7 @@ export async function run(context) {
|
||||
context.createdResources.phase4OverseasDispatch.dispatchId = id(arranged.data?.id)
|
||||
context.createdResources.phase4OverseasDispatch.finalStatus = arranged.data?.status || null
|
||||
|
||||
const duplicateArrange = await context.requestOverseas('POST', `${BASE}/pre-arrange`, preArrangeBody)
|
||||
const duplicateArrange = await context.request('POST', `${BASE}/pre-arrange`, preArrangeBody)
|
||||
context.addStep(MODULE, '出库前同草稿重复预排复用原排车且业务快照不漂移', duplicateArrange,
|
||||
success(duplicateArrange)
|
||||
&& dispatchBusinessSnapshot(duplicateArrange.body?.data) === dispatchBusinessSnapshot(arranged.data),
|
||||
|
||||
+30
-27
@@ -42,28 +42,11 @@ function fixture() {
|
||||
},
|
||||
phase4OverseasPda: { customerId: '2001', overseasWarehouseId: '9001' },
|
||||
},
|
||||
async request(method, path, body) {
|
||||
calls.push({ region: 'CN', method, path, body })
|
||||
if (path === '/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE') {
|
||||
return response({ records: [{ id: '1001', tenantName: '客户一' }, { id: '1002', tenantName: '客户二' }] })
|
||||
}
|
||||
if (path === '/internal/event-bridge/status') return response(null, '200', 200, 'CN')
|
||||
if (path.startsWith('/api/platform/finance/v1/fee-sources?')) {
|
||||
const query = new URLSearchParams(path.split('?')[1])
|
||||
const customerId = query.get('customerId')
|
||||
const keyword = query.get('keyword')
|
||||
const rows = [...fees.values()].filter((fee) => fee.customerId === customerId
|
||||
&& (fee.inboundCode || fee.businessNo) === keyword)
|
||||
.filter((fee) => fee.reviewStatus === 'APPROVED')
|
||||
.map((fee) => ({ feeSourceId: String(Number(fee.sourceFeeId) + 10000), sourceSystem: 'LASTLEG',
|
||||
sourceType: fee.sourceType, sourceFeeId: fee.sourceFeeId, customerId: fee.customerId }))
|
||||
return response(rows)
|
||||
}
|
||||
throw new Error(`unexpected CN request ${method} ${path}`)
|
||||
},
|
||||
async requestOverseas(method, path, body, headers = {}) {
|
||||
async request(...args) {
|
||||
const route = /\/(?:last-leg|overseas-warehouse-resources)(?:\/|\?|$)/.test(String(args[1]));
|
||||
const handler = route ? async function(method,path,body,_token,headers = {}) {
|
||||
calls.push({ region: 'OVERSEAS', method, path, body, headers })
|
||||
if (path === '/internal/event-bridge/status') return response(null, '200', 200, 'OVERSEAS')
|
||||
if (path === '/internal/finance/event-operations/outbox') return response(null, '404', 404, 'OVERSEAS')
|
||||
for (const [base, row] of Object.entries(businessRows)) {
|
||||
if (method === 'GET' && path.startsWith(`${base}?`)) return response({ records: [row] })
|
||||
}
|
||||
@@ -101,7 +84,28 @@ function fixture() {
|
||||
return response(fee)
|
||||
}
|
||||
throw new Error(`unexpected OVERSEAS request ${method} ${path}`)
|
||||
},
|
||||
} : async function(method,path,body) {
|
||||
calls.push({ region: 'CN', method, path, body })
|
||||
if (path === '/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE') {
|
||||
return response({ records: [{ id: '1001', tenantName: '客户一' }, { id: '1002', tenantName: '客户二' }] })
|
||||
}
|
||||
if (path === '/internal/finance/event-operations/outbox') return response(null, '404', 404, 'CN')
|
||||
if (path.startsWith('/api/platform/finance/v1/fee-sources?')) {
|
||||
const query = new URLSearchParams(path.split('?')[1])
|
||||
const customerId = query.get('customerId')
|
||||
const keyword = query.get('keyword')
|
||||
const rows = [...fees.values()].filter((fee) => fee.customerId === customerId
|
||||
&& (fee.inboundCode || fee.businessNo) === keyword)
|
||||
.filter((fee) => fee.reviewStatus === 'APPROVED')
|
||||
.map((fee) => ({ feeSourceId: String(Number(fee.sourceFeeId) + 10000), sourceSystem: 'LASTLEG',
|
||||
sourceType: fee.sourceType, sourceFeeId: fee.sourceFeeId, customerId: fee.customerId }))
|
||||
return response(rows)
|
||||
}
|
||||
throw new Error(`unexpected CN request ${method} ${path}`)
|
||||
};
|
||||
return handler.apply(this,args);
|
||||
},
|
||||
|
||||
apiSuccess(module, name, result, evidence, options) {
|
||||
const passed = result.http === 200 && String(result.body?.code) === '200'
|
||||
this.addStep(module, name, result, passed, evidence, options)
|
||||
@@ -126,7 +130,7 @@ test('module uses only injected real HTTP clients and never fabricates database/
|
||||
'OVERSEAS_INBOUND_FEE', 'OVERSEAS_PROCUREMENT_FEE', 'OVERSEAS_TRUCK_FEE',
|
||||
'OVERSEAS_LOOSEBOARD_FEE', 'OVERSEAS_WAREHOUSE_FEE',
|
||||
'/api/platform/v1/last-leg/finance-fee-sources', '/api/platform/finance/v1/fee-sources',
|
||||
'/internal/event-bridge/status',
|
||||
'/internal/finance/event-operations/outbox',
|
||||
]) assert.ok(source.includes(token), token)
|
||||
})
|
||||
|
||||
@@ -163,10 +167,9 @@ test('five fee types traverse Lastleg lifecycle and reach Finance through real H
|
||||
assert.ok(context.steps.find((step) => step.name === '费用创建同键异载荷冲突')?.passed)
|
||||
assert.ok(context.steps.find((step) => step.name === '海外费用列表不会泄露其他客户来源')?.passed)
|
||||
assert.ok(context.steps.find((step) => step.name === '其他客户不能关联本客户业务创建费用')?.passed)
|
||||
assert.equal(context.steps.filter((step) => step.name.endsWith('费用经跨区链进入 Finance')).length, 5)
|
||||
assert.ok(context.steps.filter((step) => step.name.endsWith('费用经跨区链进入 Finance')).every((step) => step.passed))
|
||||
assert.ok(context.steps.find((step) => step.name === '国内 Event Bridge HTTP 能力状态可观察')?.passed)
|
||||
assert.ok(context.steps.find((step) => step.name === '海外 Event Bridge HTTP 能力状态可观察')?.passed)
|
||||
assert.equal(context.steps.filter((step) => step.name.endsWith('费用经业务Outbox直接投递进入 Finance')).length, 5)
|
||||
assert.ok(context.steps.filter((step) => step.name.endsWith('费用经业务Outbox直接投递进入 Finance')).every((step) => step.passed))
|
||||
assert.ok(context.steps.find((step) => step.name === '统一Gateway不公开内部事件运维接口')?.passed)
|
||||
|
||||
assert.deepEqual(context.steps.filter((step) => !step.passed), [])
|
||||
assert.equal(context.scenarios[0].deploymentBlocked, false)
|
||||
|
||||
@@ -20,7 +20,7 @@ function blocker(context, name, reason, response = null) {
|
||||
}
|
||||
|
||||
async function overseas(context, method, name, path, body, headers = {}) {
|
||||
const response = await context.requestOverseas(method, path, body, headers)
|
||||
const response = await context.request(method, path, body, undefined, headers)
|
||||
const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS)
|
||||
return { response, data }
|
||||
}
|
||||
@@ -147,8 +147,7 @@ async function createApprove(context, definition, fixture, index) {
|
||||
id(replay.data?.sourceFeeId) === sourceFeeId && Number(replay.data?.sourceVersion) === 1,
|
||||
trace(replay.response), OPTIONS)
|
||||
if (index === 0) {
|
||||
const mismatch = await context.requestOverseas('POST', LASTLEG_BASE,
|
||||
{ ...payload, amount: payload.amount + 1 }, { 'Idempotency-Key': key })
|
||||
const mismatch = await context.request('POST', LASTLEG_BASE, { ...payload, amount: payload.amount + 1 }, undefined, { 'Idempotency-Key': key })
|
||||
context.addStep(MODULE, '费用创建同键异载荷冲突', mismatch, conflict(mismatch), trace(mismatch), OPTIONS)
|
||||
}
|
||||
|
||||
@@ -189,8 +188,7 @@ async function verifyCustomerIsolation(context, fixtures, source) {
|
||||
`otherCustomerId=${id(other.id)}, leaked=${rows(wrongList.data).some((item) => id(item?.sourceFeeId) === source.sourceFeeId)}; ${trace(wrongList.response)}`, OPTIONS)
|
||||
if (source.payload.businessId) {
|
||||
const wrongPayload = { ...source.payload, customerId: id(other.id), customerName: other.tenantName || other.name || 'other' }
|
||||
const wrong = await context.requestOverseas('POST', LASTLEG_BASE, wrongPayload,
|
||||
{ 'Idempotency-Key': `${context.marker}-wrong-customer-fee`.slice(0, 128) })
|
||||
const wrong = await context.request('POST', LASTLEG_BASE, wrongPayload, undefined, { 'Idempotency-Key': `${context.marker}-wrong-customer-fee`.slice(0, 128) })
|
||||
context.addStep(MODULE, '其他客户不能关联本客户业务创建费用', wrong, rejected(wrong), trace(wrong), OPTIONS)
|
||||
}
|
||||
}
|
||||
@@ -205,7 +203,7 @@ async function verifyFinanceDelivery(context, sources) {
|
||||
&& id(item?.sourceFeeId) === source.sourceFeeId)
|
||||
return { response, found }
|
||||
}, (value) => success(value?.response) && Boolean(value?.found))
|
||||
context.assert(MODULE, `${source.definition.label}费用经跨区链进入 Finance`, delivered.result?.response,
|
||||
context.assert(MODULE, `${source.definition.label}费用经业务Outbox直接投递进入 Finance`, delivered.result?.response,
|
||||
Boolean(delivered.result?.found)
|
||||
&& id(delivered.result.found.customerId) === id(source.customerId)
|
||||
&& delivered.result.found.sourceType === source.definition.sourceType,
|
||||
@@ -213,15 +211,10 @@ async function verifyFinanceDelivery(context, sources) {
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyBridgeCapabilities(context) {
|
||||
const domestic = await context.request('GET', '/internal/event-bridge/status')
|
||||
context.addStep(MODULE, '国内 Event Bridge HTTP 能力状态可观察', domestic,
|
||||
(success(domestic) && String(domestic.body?.data?.region).toUpperCase() === 'CN') || domestic.http === 404,
|
||||
`${domestic.http === 404 ? 'internal endpoint is intentionally not exposed by public gateway; ' : ''}${trace(domestic)}`, OPTIONS)
|
||||
const overseas = await context.requestOverseas('GET', '/internal/event-bridge/status')
|
||||
context.addStep(MODULE, '海外 Event Bridge HTTP 能力状态可观察', overseas,
|
||||
(success(overseas) && String(overseas.body?.data?.region).toUpperCase() === 'OVERSEAS') || overseas.http === 404,
|
||||
`${overseas.http === 404 ? 'internal endpoint is intentionally not exposed by public gateway; ' : ''}${trace(overseas)}`, OPTIONS)
|
||||
async function verifyInternalBoundary(context) {
|
||||
const response = await context.request('GET', '/internal/finance/event-operations/outbox')
|
||||
context.assert(MODULE, '统一Gateway不公开内部事件运维接口', response,
|
||||
[401, 403, 404].includes(response.http), trace(response), OPTIONS)
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
@@ -251,7 +244,7 @@ export async function run(context) {
|
||||
`sourceFeeId=${warehouseSource?.sourceFeeId || 'missing'}, warehouseId=${warehouseSource?.payload?.warehouseId || 'missing'}`, OPTIONS)
|
||||
await verifyCustomerIsolation(context, fixtures, sources.find((item) => item.payload.businessId))
|
||||
await verifyFinanceDelivery(context, sources)
|
||||
await verifyBridgeCapabilities(context)
|
||||
await verifyInternalBoundary(context)
|
||||
context.createdResources.phase4OverseasFinanceCrossRegion = {
|
||||
marker: context.marker,
|
||||
sources: sources.map((item) => ({ sourceFeeId: item.sourceFeeId, customerId: item.customerId,
|
||||
|
||||
@@ -16,7 +16,7 @@ function query(path, values) {
|
||||
function key(context, operation) { return `${context.marker}:P4-INBOUND:${operation}`.slice(0, 255) }
|
||||
|
||||
async function request(context, method, name, path, body, headers = {}) {
|
||||
const response = await context.requestOverseas(method, path, body, headers)
|
||||
const response = await context.request(method, path, body, undefined, headers)
|
||||
context.addStep(MODULE, name, response, ok(response), trace(response), OPTIONS)
|
||||
return ok(response) ? response.body?.data : null
|
||||
}
|
||||
@@ -44,17 +44,12 @@ async function inboundAdminLifecycle(context) {
|
||||
let preview = await request(context, 'GET', '预览第二柜待生成入仓计划', query(`${PLAN_BASE}/container-preview`, {
|
||||
sourceContainerBookingId: bookingId,
|
||||
}))
|
||||
const arrivalQuery = await context.requestOverseas('POST',
|
||||
'/api/platform/v1/last-leg/orders/container-arrival-times/query',
|
||||
{ sourceContainerBookingIds: [bookingId] })
|
||||
const arrivalQuery = await context.request('POST', '/api/platform/v1/last-leg/orders/container-arrival-times/query', { sourceContainerBookingIds: [bookingId] })
|
||||
const arrival = ok(arrivalQuery) && Array.isArray(arrivalQuery.body?.data)
|
||||
? arrivalQuery.body.data.find((item) => id(item?.sourceContainerBookingId) === bookingId) : null
|
||||
if (arrival && !arrival.estimatedWarehouseArrivalAt) {
|
||||
await context.requestOverseas('PUT',
|
||||
`/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(bookingId)}/expected-arrival`,
|
||||
{ estimatedWarehouseArrivalAt: new Date(Date.now() + 2 * 86400_000).toISOString().slice(0, 19),
|
||||
expectedVersion: Number(arrival.version), remark: `${context.marker}-inbound-plan-prerequisite` },
|
||||
{ 'Idempotency-Key': `${context.marker}-inbound-plan-arrival-${bookingId}`.slice(0, 128) })
|
||||
await context.request('PUT', `/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(bookingId)}/expected-arrival`, { estimatedWarehouseArrivalAt: new Date(Date.now() + 2 * 86400_000).toISOString().slice(0, 19),
|
||||
expectedVersion: Number(arrival.version), remark: `${context.marker}-inbound-plan-prerequisite` }, undefined, { 'Idempotency-Key': `${context.marker}-inbound-plan-arrival-${bookingId}`.slice(0, 128) })
|
||||
preview = await request(context, 'GET', '刷新补齐时间后的入仓计划预览', query(`${PLAN_BASE}/container-preview`, {
|
||||
sourceContainerBookingId: bookingId,
|
||||
}))
|
||||
@@ -108,7 +103,7 @@ async function inboundAdminLifecycle(context) {
|
||||
id(appointmentReplay?.id) === id(appointment?.id)
|
||||
&& appointmentReplay?.items?.length === fbaLines.length,
|
||||
`appointmentId=${id(appointment?.id) || 'missing'}, items=${appointmentReplay?.items?.length ?? 'missing'}`, OPTIONS)
|
||||
const appointmentConflict = await context.requestOverseas('POST', `${PLAN_BASE}/fba-appointments/batch`, {
|
||||
const appointmentConflict = await context.request('POST', `${PLAN_BASE}/fba-appointments/batch`, {
|
||||
...appointmentBody, appointmentRemark: `${context.marker}-different`,
|
||||
})
|
||||
context.addStep(MODULE, 'FBA预约同单号异载荷返回A0409', appointmentConflict,
|
||||
@@ -171,7 +166,7 @@ async function inventoryCommandLifecycle(context) {
|
||||
context.assert(MODULE, '库存冻结重放不重复扣减', null,
|
||||
held?.operationType === 'HOLD' && heldReplay?.operationId === held?.operationId && heldReplay?.replayed === true,
|
||||
`operationId=${id(held?.operationId) || 'missing'}, replayed=${heldReplay?.replayed}`, OPTIONS)
|
||||
const holdConflict = await context.requestOverseas('POST', `${INVENTORY_BASE}/hold`, {
|
||||
const holdConflict = await context.request('POST', `${INVENTORY_BASE}/hold`, {
|
||||
...holdBody, remark: `${context.marker}-different`,
|
||||
})
|
||||
context.addStep(MODULE, '库存冻结同键异载荷返回A0409', holdConflict,
|
||||
@@ -194,7 +189,7 @@ async function inventoryCommandLifecycle(context) {
|
||||
increased?.operationType === 'ADJUST' && increaseReplay?.operationId === increased?.operationId
|
||||
&& increaseReplay?.replayed === true,
|
||||
`operationId=${id(increased?.operationId) || 'missing'}, replayed=${increaseReplay?.replayed}`, OPTIONS)
|
||||
const adjustConflict = await context.requestOverseas('POST', `${INVENTORY_BASE}/adjust`, {
|
||||
const adjustConflict = await context.request('POST', `${INVENTORY_BASE}/adjust`, {
|
||||
...increaseBody, reason: 'DIFFERENT_COUNT_REASON',
|
||||
})
|
||||
context.addStep(MODULE, '盘点调整同键异载荷返回A0409', adjustConflict,
|
||||
|
||||
@@ -56,46 +56,9 @@ function fakeContext({ softAssertions = false, refreshEmpty = false } = {}) {
|
||||
addCoverageGap(module, scenario, name, reason, category, metadata = {}) {
|
||||
coverageGaps.push({ module, scenario, name, reason, category, ...metadata })
|
||||
},
|
||||
async request(method, path, body) {
|
||||
calls.push({ gateway: 'domestic', method, path, body })
|
||||
assert.notEqual(method, 'DELETE')
|
||||
if (method === 'POST' && path === DOMESTIC_BASE) {
|
||||
state.warehouse = { ...body, id: '910000000000000001' }
|
||||
return ok(state.warehouse.id)
|
||||
}
|
||||
if (method === 'GET' && path === `${DOMESTIC_BASE}/910000000000000001?tenantId=0`) {
|
||||
return ok({ warehouse: { ...state.warehouse }, addresses: [state.warehouse.address], capabilities: state.warehouse.capabilities })
|
||||
}
|
||||
if (method === 'PUT' && path === `${DOMESTIC_BASE}/910000000000000001?tenantId=0`) {
|
||||
state.warehouse = { ...body, id: state.warehouse.id }
|
||||
return ok(null)
|
||||
}
|
||||
if (method === 'PATCH' && path === `${DOMESTIC_BASE}/910000000000000001/status?tenantId=0&status=INACTIVE`) {
|
||||
state.warehouse.status = 'INACTIVE'
|
||||
return ok(null)
|
||||
}
|
||||
if (method === 'POST' && path === '/api/platform/v1/iam/roles') {
|
||||
state.role = { id: '940000000000000001', ...body, status: 'ACTIVE' }
|
||||
return ok({ ...state.role })
|
||||
}
|
||||
if (method === 'POST' && path === '/api/platform/v1/iam/users') {
|
||||
state.user = { membershipId: '950000000000000001', ...body, status: 'ACTIVE' }
|
||||
return ok({ ...state.user })
|
||||
}
|
||||
if (method === 'POST' && path === '/api/auth/login') {
|
||||
return ok({ accessToken: 'deny-domestic-token', overseasAccessToken: 'deny-overseas-token', overseasExpiresIn: 900 })
|
||||
}
|
||||
if (method === 'PUT' && path === '/api/platform/v1/iam/users/950000000000000001/status') {
|
||||
state.user.status = body.status
|
||||
return ok(null)
|
||||
}
|
||||
if (method === 'PUT' && path === '/api/platform/v1/iam/roles/940000000000000001/status') {
|
||||
state.role.status = body.status
|
||||
return ok(null)
|
||||
}
|
||||
assert.fail(`unexpected domestic call ${method} ${path}`)
|
||||
},
|
||||
async requestOverseas(method, path, body, extraHeaders = {}, token = 'admin-overseas-token') {
|
||||
async request(...args) {
|
||||
const route = /\/(?:last-leg|overseas-warehouse-resources)(?:\/|\?|$)/.test(String(args[1]));
|
||||
const handler = route ? async function(method,path,body,token = 'admin-overseas-token',extraHeaders = {}) {
|
||||
calls.push({ gateway: 'overseas', method, path, body, extraHeaders, tokenKind: token === 'deny-overseas-token' ? 'denied' : 'admin' })
|
||||
assert.notEqual(method, 'DELETE')
|
||||
if (token === 'deny-overseas-token') return forbidden()
|
||||
@@ -189,8 +152,49 @@ function fakeContext({ softAssertions = false, refreshEmpty = false } = {}) {
|
||||
total: state.vehicle ? 1 : 0, pages: state.vehicle ? 1 : 0 })
|
||||
}
|
||||
assert.fail(`unexpected overseas call ${method} ${path}`)
|
||||
},
|
||||
async requestOverseasBinary(method, path) {
|
||||
} : async function(method,path,body) {
|
||||
calls.push({ gateway: 'domestic', method, path, body })
|
||||
assert.notEqual(method, 'DELETE')
|
||||
if (method === 'POST' && path === DOMESTIC_BASE) {
|
||||
state.warehouse = { ...body, id: '910000000000000001' }
|
||||
return ok(state.warehouse.id)
|
||||
}
|
||||
if (method === 'GET' && path === `${DOMESTIC_BASE}/910000000000000001?tenantId=0`) {
|
||||
return ok({ warehouse: { ...state.warehouse }, addresses: [state.warehouse.address], capabilities: state.warehouse.capabilities })
|
||||
}
|
||||
if (method === 'PUT' && path === `${DOMESTIC_BASE}/910000000000000001?tenantId=0`) {
|
||||
state.warehouse = { ...body, id: state.warehouse.id }
|
||||
return ok(null)
|
||||
}
|
||||
if (method === 'PATCH' && path === `${DOMESTIC_BASE}/910000000000000001/status?tenantId=0&status=INACTIVE`) {
|
||||
state.warehouse.status = 'INACTIVE'
|
||||
return ok(null)
|
||||
}
|
||||
if (method === 'POST' && path === '/api/platform/v1/iam/roles') {
|
||||
state.role = { id: '940000000000000001', ...body, status: 'ACTIVE' }
|
||||
return ok({ ...state.role })
|
||||
}
|
||||
if (method === 'POST' && path === '/api/platform/v1/iam/users') {
|
||||
state.user = { membershipId: '950000000000000001', ...body, status: 'ACTIVE' }
|
||||
return ok({ ...state.user })
|
||||
}
|
||||
if (method === 'POST' && path === '/api/auth/login') {
|
||||
return ok({ accessToken: 'deny-overseas-token', expiresIn: 900 })
|
||||
}
|
||||
if (method === 'PUT' && path === '/api/platform/v1/iam/users/950000000000000001/status') {
|
||||
state.user.status = body.status
|
||||
return ok(null)
|
||||
}
|
||||
if (method === 'PUT' && path === '/api/platform/v1/iam/roles/940000000000000001/status') {
|
||||
state.role.status = body.status
|
||||
return ok(null)
|
||||
}
|
||||
assert.fail(`unexpected domestic call ${method} ${path}`)
|
||||
};
|
||||
return handler.apply(this,args);
|
||||
},
|
||||
|
||||
async requestBinary(method, path) {
|
||||
calls.push({ gateway: 'overseas-binary', method, path })
|
||||
assert.equal(method, 'GET')
|
||||
assert.match(path, /\/(?:overseas-quotes|exchange-rates|vehicles)\/export\?/)
|
||||
@@ -208,10 +212,10 @@ function fakeContext({ softAssertions = false, refreshEmpty = false } = {}) {
|
||||
test('静态锁定真实双Gateway生命周期、CSV与安全边界', () => {
|
||||
assert.match(source, /context\.request\('POST', DOMESTIC_BASE/)
|
||||
assert.match(source, /warehouseType: 'OVERSEAS_PARTNER'/)
|
||||
assert.match(source, /context\.requestOverseas\('POST'/)
|
||||
assert.match(source, /context\.requestOverseas\('PUT'/)
|
||||
assert.match(source, /context\.requestOverseas\('PATCH'/)
|
||||
assert.match(source, /context\.requestOverseasBinary\('GET'/)
|
||||
assert.match(source, /context\.request\('POST'/)
|
||||
assert.match(source, /context\.request\('PUT'/)
|
||||
assert.match(source, /context\.request\('PATCH'/)
|
||||
assert.match(source, /context\.requestBinary\('GET'/)
|
||||
assert.match(source, /context\.expectConflict/)
|
||||
assert.match(source, /warehouse-options\?refresh=true/)
|
||||
assert.match(source, /空候选不负缓存30分钟/)
|
||||
|
||||
@@ -111,7 +111,7 @@ async function createAndVerifyPartnerWarehouse(context, resource) {
|
||||
}
|
||||
|
||||
async function captureWarehouseOptionsBeforeCreate(context, resource) {
|
||||
const response = await context.requestOverseas('GET', `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`)
|
||||
const response = await context.request('GET', `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`)
|
||||
const options = requireData(context, '创建合作仓前查询报价候选缓存基线', response)
|
||||
context.assert(MODULE, '合作仓候选基线为规范数组', response,
|
||||
Array.isArray(options) && options.every((item) => asId(item?.warehouseId)
|
||||
@@ -144,7 +144,7 @@ function quoteUpdatePayload(created, context, version) {
|
||||
}
|
||||
|
||||
async function quoteLifecycle(context, resource) {
|
||||
const optionsResponse = await context.requestOverseas('GET', `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`)
|
||||
const optionsResponse = await context.request('GET', `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`)
|
||||
const options = requireData(context, '查询海外报价合作仓候选', optionsResponse)
|
||||
context.assert(MODULE, '报价候选只来自海外正式接口', optionsResponse,
|
||||
Array.isArray(options) && options.every((item) => asId(item.warehouseId)
|
||||
@@ -156,37 +156,35 @@ async function quoteLifecycle(context, resource) {
|
||||
optionIds(options).includes(createdId), `createdId=${createdId}, ids=${optionIds(options).join(',') || 'empty'}`, BLOCKER)
|
||||
}
|
||||
|
||||
const refreshResponse = await context.requestOverseas('GET',
|
||||
`${OVERSEAS_BASE}/overseas-quotes/warehouse-options?refresh=true`)
|
||||
const refreshResponse = await context.request('GET', `${OVERSEAS_BASE}/overseas-quotes/warehouse-options?refresh=true`)
|
||||
const refreshed = requireData(context, '强制刷新海外报价合作仓候选', refreshResponse)
|
||||
const option = Array.isArray(refreshed) ? refreshed.find((item) => asId(item.warehouseId) === createdId) : null
|
||||
context.assert(MODULE, '刷新后新建合作仓立即可见', refreshResponse, Boolean(option),
|
||||
`createdId=${createdId}, ids=${optionIds(refreshed).join(',') || 'empty'}`, BLOCKER)
|
||||
if (!option) throw new Error(`warehouse-options refresh=true 未返回本轮合作仓 ${createdId}`)
|
||||
|
||||
const cachedResponse = await context.requestOverseas('GET', `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`)
|
||||
const cachedResponse = await context.request('GET', `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`)
|
||||
const cached = requireData(context, '刷新后读取30分钟正缓存', cachedResponse)
|
||||
context.assert(MODULE, '30分钟正缓存与刷新快照一致', cachedResponse,
|
||||
JSON.stringify(cached) === JSON.stringify(refreshed),
|
||||
`refreshed=${optionIds(refreshed).join(',')}, cached=${optionIds(cached).join(',')}`, BLOCKER)
|
||||
|
||||
const payload = quoteCreatePayload(context, asId(option.warehouseId))
|
||||
const createResponse = await context.requestOverseas('POST', `${OVERSEAS_BASE}/overseas-quotes`, payload)
|
||||
const createResponse = await context.request('POST', `${OVERSEAS_BASE}/overseas-quotes`, payload)
|
||||
let detail = requireData(context, '新增海外报价', createResponse)
|
||||
const id = asId(detail?.summary?.id)
|
||||
if (!id || !recordVersion(detail?.summary?.recordVersion)) throw new Error('报价创建响应缺少字符串ID或recordVersion')
|
||||
resource.quote = { id, warehouseId: payload.warehouseId, recordVersion: Number(detail.summary.recordVersion), finalStatus: detail.summary.status }
|
||||
const path = `${OVERSEAS_BASE}/overseas-quotes/${encodeURIComponent(id)}`
|
||||
|
||||
const detailResponse = await context.requestOverseas('GET', path)
|
||||
const detailResponse = await context.request('GET', path)
|
||||
detail = requireData(context, '查询海外报价详情', detailResponse)
|
||||
context.assert(MODULE, '报价详情长ID、费用和版本', detailResponse,
|
||||
asId(detail?.summary?.id) === id && recordVersion(detail?.summary?.recordVersion)
|
||||
&& Array.isArray(detail?.warehouseFees) && detail.warehouseFees.length > 0,
|
||||
`id=${asId(detail?.summary?.id) || 'missing'}, version=${detail?.summary?.recordVersion ?? 'missing'}`, BLOCKER)
|
||||
|
||||
const listResponse = await context.requestOverseas('GET',
|
||||
`${OVERSEAS_BASE}/overseas-quotes?page=1&size=20&keyword=${encodeURIComponent(context.marker)}`)
|
||||
const listResponse = await context.request('GET', `${OVERSEAS_BASE}/overseas-quotes?page=1&size=20&keyword=${encodeURIComponent(context.marker)}`)
|
||||
const page = requireData(context, '分页查询marker海外报价', listResponse)
|
||||
context.assert(MODULE, '报价列表包含本轮长ID与recordVersion', listResponse,
|
||||
Array.isArray(page?.records) && page.records.some((item) => asId(item.id) === id && recordVersion(item.recordVersion)),
|
||||
@@ -194,22 +192,22 @@ async function quoteLifecycle(context, resource) {
|
||||
|
||||
const staleVersion = Number(detail.summary.recordVersion)
|
||||
const updatePayload = quoteUpdatePayload({ ...payload, transitPrices: payload.transitPrices }, context, staleVersion)
|
||||
const updateResponse = await context.requestOverseas('PUT', path, updatePayload)
|
||||
const updateResponse = await context.request('PUT', path, updatePayload)
|
||||
detail = requireData(context, '编辑海外报价', updateResponse)
|
||||
resource.quote.recordVersion = Number(detail.summary.recordVersion)
|
||||
context.assert(MODULE, '报价编辑版本递增', updateResponse,
|
||||
detail.summary.name === updatePayload.name && resource.quote.recordVersion === staleVersion + 1,
|
||||
`before=${staleVersion}, after=${resource.quote.recordVersion}`, BLOCKER)
|
||||
|
||||
const conflictResponse = await context.requestOverseas('PUT', path, { ...updatePayload, name: `${context.marker} stale` })
|
||||
const conflictResponse = await context.request('PUT', path, { ...updatePayload, name: `${context.marker} stale` })
|
||||
context.expectConflict(MODULE, '报价旧recordVersion编辑冲突', conflictResponse, BLOCKER.scenario)
|
||||
const afterConflictResponse = await context.requestOverseas('GET', path)
|
||||
const afterConflictResponse = await context.request('GET', path)
|
||||
detail = requireData(context, '报价冲突后重新查询', afterConflictResponse)
|
||||
context.assert(MODULE, '报价冲突未覆盖新值', afterConflictResponse,
|
||||
detail.summary.name === updatePayload.name && Number(detail.summary.recordVersion) === resource.quote.recordVersion,
|
||||
`name=${detail.summary.name}, version=${detail.summary.recordVersion}`, BLOCKER)
|
||||
|
||||
const activateResponse = await context.requestOverseas('PATCH', `${path}/status`, {
|
||||
const activateResponse = await context.request('PATCH', `${path}/status`, {
|
||||
status: 'ACTIVE', recordVersion: resource.quote.recordVersion,
|
||||
})
|
||||
detail = requireData(context, '启用海外报价', activateResponse)
|
||||
@@ -218,7 +216,7 @@ async function quoteLifecycle(context, resource) {
|
||||
context.assert(MODULE, '报价状态切换为ACTIVE', activateResponse,
|
||||
detail.summary.status === 'ACTIVE', `status=${detail.summary.status}`, BLOCKER)
|
||||
|
||||
const csv = await context.requestOverseasBinary('GET', `${OVERSEAS_BASE}/overseas-quotes/export?keyword=${encodeURIComponent(context.marker)}`)
|
||||
const csv = await context.requestBinary('GET', `${OVERSEAS_BASE}/overseas-quotes/export?keyword=${encodeURIComponent(context.marker)}`)
|
||||
assertBinaryCsv(context, '导出海外报价CSV', csv,
|
||||
'报价ID,报价名称,合作仓编码,合作仓名称,报价方,币种,生效开始,生效结束,版本号,状态,更新时间',
|
||||
[id, context.marker])
|
||||
@@ -232,7 +230,7 @@ async function chooseSafeExchangeMonth(context) {
|
||||
const serial = ((seed + offset) % 120)
|
||||
const month = `${2090 + Math.floor(serial / 12)}-${String((serial % 12) + 1).padStart(2, '0')}`
|
||||
const path = `${OVERSEAS_BASE}/exchange-rates?page=1&size=20&monthFrom=${month}&monthTo=${month}`
|
||||
const response = await context.requestOverseas('GET', path)
|
||||
const response = await context.request('GET', path)
|
||||
const page = requireData(context, `探测安全汇率月份${month}`, response)
|
||||
if (Array.isArray(page?.records) && page.records.length === 0 && Number(page.total) === 0) return month
|
||||
}
|
||||
@@ -242,12 +240,12 @@ async function chooseSafeExchangeMonth(context) {
|
||||
async function exchangeLifecycle(context, resource) {
|
||||
const month = await chooseSafeExchangeMonth(context)
|
||||
const createPayload = { month, usdToCnyAverage: 7.123456, remark: `${context.marker} safe marker month` }
|
||||
const createResponse = await context.requestOverseas('POST', `${OVERSEAS_BASE}/exchange-rates`, createPayload)
|
||||
const createResponse = await context.request('POST', `${OVERSEAS_BASE}/exchange-rates`, createPayload)
|
||||
let rate = requireData(context, '新增安全marker月汇率', createResponse)
|
||||
resource.exchangeRate = { month, recordVersion: Number(rate.recordVersion), finalStatus: rate.status }
|
||||
const path = `${OVERSEAS_BASE}/exchange-rates/${encodeURIComponent(month)}`
|
||||
|
||||
const detailResponse = await context.requestOverseas('GET', path)
|
||||
const detailResponse = await context.request('GET', path)
|
||||
rate = requireData(context, '查询marker月汇率详情', detailResponse)
|
||||
context.assert(MODULE, '汇率详情月份、decimal与版本', detailResponse,
|
||||
rate.month === month && Number(rate.usdToCnyAverage) > 0 && recordVersion(rate.recordVersion),
|
||||
@@ -255,7 +253,7 @@ async function exchangeLifecycle(context, resource) {
|
||||
|
||||
const staleVersion = Number(rate.recordVersion)
|
||||
const updatePayload = { recordVersion: staleVersion, usdToCnyAverage: 7.234567, remark: `${context.marker} overwritten once` }
|
||||
const updateResponse = await context.requestOverseas('PUT', path, updatePayload)
|
||||
const updateResponse = await context.request('PUT', path, updatePayload)
|
||||
rate = requireData(context, '覆盖维护marker月汇率', updateResponse)
|
||||
resource.exchangeRate.recordVersion = Number(rate.recordVersion)
|
||||
context.assert(MODULE, '汇率覆盖版本递增', updateResponse,
|
||||
@@ -263,16 +261,16 @@ async function exchangeLifecycle(context, resource) {
|
||||
&& resource.exchangeRate.recordVersion === staleVersion + 1,
|
||||
`before=${staleVersion}, after=${resource.exchangeRate.recordVersion}`, BLOCKER)
|
||||
|
||||
const conflictResponse = await context.requestOverseas('PUT', path, { ...updatePayload, usdToCnyAverage: 7.345678 })
|
||||
const conflictResponse = await context.request('PUT', path, { ...updatePayload, usdToCnyAverage: 7.345678 })
|
||||
context.expectConflict(MODULE, '汇率旧recordVersion覆盖冲突', conflictResponse, BLOCKER.scenario)
|
||||
const afterConflictResponse = await context.requestOverseas('GET', path)
|
||||
const afterConflictResponse = await context.request('GET', path)
|
||||
rate = requireData(context, '汇率冲突后重新查询', afterConflictResponse)
|
||||
context.assert(MODULE, '汇率冲突未覆盖新值', afterConflictResponse,
|
||||
Number(rate.usdToCnyAverage) === updatePayload.usdToCnyAverage
|
||||
&& Number(rate.recordVersion) === resource.exchangeRate.recordVersion,
|
||||
`rate=${rate.usdToCnyAverage}, version=${rate.recordVersion}`, BLOCKER)
|
||||
|
||||
const statusResponse = await context.requestOverseas('PATCH', `${path}/status`, {
|
||||
const statusResponse = await context.request('PATCH', `${path}/status`, {
|
||||
status: 'INACTIVE', recordVersion: resource.exchangeRate.recordVersion,
|
||||
remark: `${context.marker} lifecycle complete`,
|
||||
})
|
||||
@@ -282,14 +280,13 @@ async function exchangeLifecycle(context, resource) {
|
||||
context.assert(MODULE, '汇率状态切换为INACTIVE', statusResponse,
|
||||
rate.status === 'INACTIVE', `status=${rate.status}`, BLOCKER)
|
||||
|
||||
const listResponse = await context.requestOverseas('GET',
|
||||
`${OVERSEAS_BASE}/exchange-rates?page=1&size=20&monthFrom=${month}&monthTo=${month}&status=INACTIVE`)
|
||||
const listResponse = await context.request('GET', `${OVERSEAS_BASE}/exchange-rates?page=1&size=20&monthFrom=${month}&monthTo=${month}&status=INACTIVE`)
|
||||
const page = requireData(context, '分页查询marker月汇率', listResponse)
|
||||
context.assert(MODULE, '汇率列表包含本轮月份与recordVersion', listResponse,
|
||||
Array.isArray(page?.records) && page.records.some((item) => item.month === month && recordVersion(item.recordVersion)),
|
||||
`month=${month}, total=${page?.total ?? 'missing'}`, BLOCKER)
|
||||
|
||||
const csv = await context.requestOverseasBinary('GET',
|
||||
const csv = await context.requestBinary('GET',
|
||||
`${OVERSEAS_BASE}/exchange-rates/export?monthFrom=${month}&monthTo=${month}`)
|
||||
assertBinaryCsv(context, '导出marker月汇率CSV', csv,
|
||||
'月份,基准币种,报价币种,月平均汇率,状态,更新人,更新时间', [month, 'INACTIVE'])
|
||||
@@ -311,14 +308,14 @@ function vehiclePayload(context, edited = false) {
|
||||
|
||||
async function vehicleLifecycle(context, resource) {
|
||||
const createPayload = vehiclePayload(context)
|
||||
const createResponse = await context.requestOverseas('POST', `${OVERSEAS_BASE}/vehicles`, createPayload)
|
||||
const createResponse = await context.request('POST', `${OVERSEAS_BASE}/vehicles`, createPayload)
|
||||
let vehicle = requireData(context, '新增marker车辆', createResponse)
|
||||
const id = asId(vehicle.id)
|
||||
if (!id || !recordVersion(vehicle.recordVersion)) throw new Error('车辆创建响应缺少字符串ID或recordVersion')
|
||||
resource.vehicle = { id, plateNumber: createPayload.plateNumber, recordVersion: Number(vehicle.recordVersion), finalStatus: vehicle.status }
|
||||
const path = `${OVERSEAS_BASE}/vehicles/${encodeURIComponent(id)}`
|
||||
|
||||
const detailResponse = await context.requestOverseas('GET', path)
|
||||
const detailResponse = await context.request('GET', path)
|
||||
vehicle = requireData(context, '查询marker车辆详情', detailResponse)
|
||||
context.assert(MODULE, '车辆详情长ID、五态字段与版本', detailResponse,
|
||||
asId(vehicle.id) === id && ['IN_SERVICE', 'DISPATCHED', 'MAINTENANCE', 'INACTIVE', 'RETURNED'].includes(vehicle.status)
|
||||
@@ -327,7 +324,7 @@ async function vehicleLifecycle(context, resource) {
|
||||
|
||||
const staleVersion = Number(vehicle.recordVersion)
|
||||
const updatePayload = { recordVersion: staleVersion, ...vehiclePayload(context, true) }
|
||||
const updateResponse = await context.requestOverseas('PUT', path, updatePayload)
|
||||
const updateResponse = await context.request('PUT', path, updatePayload)
|
||||
vehicle = requireData(context, '编辑marker车辆', updateResponse)
|
||||
const updatedVersion = Number(vehicle.recordVersion)
|
||||
const updatedMileage = Number(vehicle.mileageKm)
|
||||
@@ -337,15 +334,15 @@ async function vehicleLifecycle(context, resource) {
|
||||
&& updatedVersion === Number(staleVersion) + 1,
|
||||
`before=${staleVersion}, after=${updatedVersion}, mileage=${updatedMileage}`, BLOCKER)
|
||||
|
||||
const conflictResponse = await context.requestOverseas('PUT', path, { ...updatePayload, model: 'stale overwrite' })
|
||||
const conflictResponse = await context.request('PUT', path, { ...updatePayload, model: 'stale overwrite' })
|
||||
context.expectConflict(MODULE, '车辆旧recordVersion编辑冲突', conflictResponse, BLOCKER.scenario)
|
||||
const afterConflictResponse = await context.requestOverseas('GET', path)
|
||||
const afterConflictResponse = await context.request('GET', path)
|
||||
vehicle = requireData(context, '车辆冲突后重新查询', afterConflictResponse)
|
||||
context.assert(MODULE, '车辆冲突未覆盖新值', afterConflictResponse,
|
||||
vehicle.model === updatePayload.model && Number(vehicle.recordVersion) === resource.vehicle.recordVersion,
|
||||
`model=${vehicle.model}, version=${vehicle.recordVersion}`, BLOCKER)
|
||||
|
||||
const statusResponse = await context.requestOverseas('PATCH', `${path}/status`, {
|
||||
const statusResponse = await context.request('PATCH', `${path}/status`, {
|
||||
status: 'MAINTENANCE', recordVersion: resource.vehicle.recordVersion,
|
||||
})
|
||||
vehicle = requireData(context, '车辆切换安全维护状态', statusResponse)
|
||||
@@ -354,14 +351,13 @@ async function vehicleLifecycle(context, resource) {
|
||||
context.assert(MODULE, '车辆状态切换为MAINTENANCE', statusResponse,
|
||||
vehicle.status === 'MAINTENANCE', `status=${vehicle.status}`, BLOCKER)
|
||||
|
||||
const listResponse = await context.requestOverseas('GET',
|
||||
`${OVERSEAS_BASE}/vehicles?page=1&size=20&keyword=${encodeURIComponent(createPayload.plateNumber)}`)
|
||||
const listResponse = await context.request('GET', `${OVERSEAS_BASE}/vehicles?page=1&size=20&keyword=${encodeURIComponent(createPayload.plateNumber)}`)
|
||||
const page = requireData(context, '分页查询marker车辆', listResponse)
|
||||
context.assert(MODULE, '车辆列表包含本轮长ID与recordVersion', listResponse,
|
||||
Array.isArray(page?.records) && page.records.some((item) => asId(item.id) === id && recordVersion(item.recordVersion)),
|
||||
`id=${id}, total=${page?.total ?? 'missing'}`, BLOCKER)
|
||||
|
||||
const csv = await context.requestOverseasBinary('GET', `${OVERSEAS_BASE}/vehicles/export?keyword=${encodeURIComponent(createPayload.plateNumber)}`)
|
||||
const csv = await context.requestBinary('GET', `${OVERSEAS_BASE}/vehicles/export?keyword=${encodeURIComponent(createPayload.plateNumber)}`)
|
||||
assertBinaryCsv(context, '导出marker车辆CSV', csv,
|
||||
'车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间',
|
||||
[id, createPayload.plateNumber, 'MAINTENANCE'])
|
||||
@@ -392,9 +388,8 @@ async function verifyCanonicalPermissionDenial(context, resource) {
|
||||
|
||||
const loginResponse = await context.request('POST', '/api/auth/login', { loginId: username, password }, '')
|
||||
const login = requireData(context, '无主数据权限用户登录', loginResponse)
|
||||
if (!login?.overseasAccessToken) throw new Error('无主数据权限用户未签发海外Token')
|
||||
const denied = await context.requestOverseas('GET',
|
||||
`${OVERSEAS_BASE}/overseas-quotes?page=1&size=1`, undefined, {}, login.overseasAccessToken)
|
||||
if (!login?.accessToken) throw new Error('无主数据权限用户未签发统一Token')
|
||||
const denied = await context.request('GET', `${OVERSEAS_BASE}/overseas-quotes?page=1&size=1`, undefined, login.accessToken, {})
|
||||
assertForbidden(context, '无quote:list权限访问海外报价被拒绝', denied)
|
||||
}
|
||||
|
||||
@@ -409,7 +404,7 @@ async function cleanupPartnerWarehouse(context, resource) {
|
||||
async function cleanupVersioned(context, resource, key, path, extract) {
|
||||
const current = resource[key]
|
||||
if (!current) return
|
||||
const detailResponse = await context.requestOverseas('GET', path)
|
||||
const detailResponse = await context.request('GET', path)
|
||||
const detail = data(detailResponse)
|
||||
const value = extract(detail)
|
||||
if (!success(detailResponse) || !value || !recordVersion(value.recordVersion)) {
|
||||
@@ -420,7 +415,7 @@ async function cleanupVersioned(context, resource, key, path, extract) {
|
||||
current.recordVersion = Number(value.recordVersion)
|
||||
current.finalStatus = value.status
|
||||
if (value.status === 'INACTIVE') return
|
||||
const response = await context.requestOverseas('PATCH', `${path}/status`, {
|
||||
const response = await context.request('PATCH', `${path}/status`, {
|
||||
status: 'INACTIVE', recordVersion: current.recordVersion,
|
||||
...(key === 'exchangeRate' ? { remark: `${context.marker} finally cleanup` } : {}),
|
||||
})
|
||||
|
||||
@@ -31,7 +31,7 @@ test('dropship original counted cartons must resolve exactly before any receivin
|
||||
for (const fault of [null, 'missing-carton', 'wrong-carton', 'wrong-inbound', 'wrong-destination']) {
|
||||
const calls = []; const checks = []
|
||||
const context = {
|
||||
async requestOverseas(method, requestPath, body) {
|
||||
async request(method,requestPath,body) {
|
||||
calls.push({ method, requestPath, body })
|
||||
const data = { cartonMark: body.cartonMark, inboundCode: 'REAL-INBOUND', destinationType: 'OVERSEAS' }
|
||||
if (fault === 'wrong-carton') data.cartonMark = 'ANOTHER-CARTON'
|
||||
@@ -60,7 +60,7 @@ test('dropship original counted cartons must resolve exactly before any receivin
|
||||
for (const sealedCartonMarks of [undefined, [], ['REAL-INBOUND#2-1'], ['REAL-INBOUND#2-1', 'REAL-INBOUND#2-1']]) {
|
||||
const checks = []
|
||||
const result = await resolveCartonFixtures({ assert(m,n,r,passed) { checks.push(passed) },
|
||||
async requestOverseas() { assert.fail('incomplete physical fixture must not issue HTTP') } },
|
||||
async request() { assert.fail('incomplete physical fixture must not issue HTTP') } },
|
||||
'token', inbounds, producer.sealedCartonMarks, { ...producer, sealedCartonMarks })
|
||||
assert.equal(result, null)
|
||||
assert.deepEqual(checks, [false])
|
||||
@@ -91,8 +91,8 @@ test('current loading event binds each pallet carton group to an explicit source
|
||||
|
||||
test('PDA module is injected, token-scoped, traceable, and contains no direct network or secret source', () => {
|
||||
assert.match(source, /export async function run\(context\)/)
|
||||
assert.match(source, /requestOverseas\(/)
|
||||
assert.match(source, /requestOverseasMultipart\(/)
|
||||
assert.match(source, /request\(/)
|
||||
assert.match(source, /requestMultipart\(/)
|
||||
assert.match(source, /requestId=/)
|
||||
assert.match(source, /traceId=/)
|
||||
assert.doesNotMatch(source, /https?:\/\//)
|
||||
@@ -140,8 +140,8 @@ test('PDA module covers the container-number receiving contract and excludes out
|
||||
assert.match(source, /安全扫描返回A0400/)
|
||||
assert.match(source, /warehouse-admin\?tenantId=0&.*capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE/)
|
||||
assert.match(source, /193000000000200003.*193000000000200004/)
|
||||
assert.doesNotMatch(source, /requestOverseas\('POST', '\/api\/platform\/v1\/warehouse-admin'/)
|
||||
assert.doesNotMatch(source, /requestOverseas\('GET', '\/api\/platform\/v1\/warehouse-admin'/)
|
||||
assert.doesNotMatch(source, /request\('POST', '\/api\/platform\/v1\/warehouse-admin'/)
|
||||
assert.doesNotMatch(source, /request\('GET', '\/api\/platform\/v1\/warehouse-admin'/)
|
||||
assert.match(source, /receiving-containers\/current\/pallets\/resolve/)
|
||||
assert.match(source, /overseasWarehouseId, password/)
|
||||
assert.match(source, /inbound\?\.overseasWarehouseId/)
|
||||
@@ -280,13 +280,17 @@ test('missing OVERSEAS_WAREHOUSE_PDA_OPERATOR overseas authority is a deployment
|
||||
const calls = []; const gaps = []; const steps = []
|
||||
const context = {
|
||||
marker: 'PDA-CONTRACT', context: {}, createdResources: {},
|
||||
async request(method, requestPath) {
|
||||
async request(...args) {
|
||||
const route = /\/(?:last-leg|overseas-warehouse-resources)(?:\/|\?|$)/.test(String(args[1]));
|
||||
const handler = route ? async function(...args) { calls.push(args); throw new Error('must not request without credentials') } : async function(method,requestPath) {
|
||||
calls.push({ method, requestPath })
|
||||
return { method, path: requestPath, http: 200, body: { code: '200', data: [] },
|
||||
requestId: 'request-contract-1', traceId: 'trace-contract-0001' }
|
||||
},
|
||||
async requestOverseas(...args) { calls.push(args); throw new Error('must not request without credentials') },
|
||||
async requestOverseasMultipart(...args) { calls.push(args); throw new Error('must not request without credentials') },
|
||||
};
|
||||
return handler.apply(this,args);
|
||||
},
|
||||
|
||||
async requestMultipart(...args) { calls.push(args); throw new Error('must not request without credentials') },
|
||||
apiSuccess(module, name, response) { return response.body.data },
|
||||
addStep(...args) { steps.push(args) },
|
||||
assert(module, name, response, passed, evidence, options) { steps.push({ module, name, passed, evidence, options }) },
|
||||
@@ -306,9 +310,16 @@ test('empty candidate response cannot become a zero-business-request pass', asyn
|
||||
let userSequence = 0
|
||||
const roleId = '4000005'; const permissionId = '5107015'
|
||||
const context = {
|
||||
marker: 'PDA-CONTRACT', overseasToken: '', createdResources: {}, context: {},
|
||||
marker: 'PDA-CONTRACT', token: '', createdResources: {}, context: {},
|
||||
async findAvailablePhone() { userSequence += 1; return `1750000000${userSequence}` },
|
||||
async request(method, requestPath, body) {
|
||||
async request(...args) {
|
||||
const route = /\/(?:last-leg|overseas-warehouse-resources)(?:\/|\?|$)/.test(String(args[1]));
|
||||
const handler = route ? async function(method,requestPath,body,token,headers) {
|
||||
calls.push({ channel: 'overseas', method, requestPath })
|
||||
if (requestPath.endsWith('/current')) return { method, path: requestPath, http: 200,
|
||||
body: { code: 'A0404', message: 'not found' }, requestId: 'req-current', traceId: 'trace-current' }
|
||||
return response([], requestPath, method)
|
||||
} : async function(method,requestPath,body) {
|
||||
calls.push({ channel: 'domestic', method, requestPath })
|
||||
if (method === 'GET' && requestPath.endsWith('/roles')) return response([
|
||||
{ id: roleId, roleCode: 'OVERSEAS_WAREHOUSE_PDA_OPERATOR', status: 'ACTIVE', permissionIds: [permissionId] },
|
||||
@@ -324,16 +335,13 @@ test('empty candidate response cannot become a zero-business-request pass', asyn
|
||||
membershipId: String(9000 + userSequence), username: body.username, tenantId: '0', roleIds: [roleId],
|
||||
overseasWarehouseId: String(body.overseasWarehouseId), status: 'ACTIVE',
|
||||
}, requestPath, method)
|
||||
if (method === 'POST' && requestPath === '/api/auth/login') return response({ overseasAccessToken: `pda-token-${userSequence}` }, requestPath, method)
|
||||
if (method === 'POST' && requestPath === '/api/auth/login') return response({ accessToken: `pda-token-${userSequence}` }, requestPath, method)
|
||||
if (method === 'PUT' && /\/users\/\d+\/status$/.test(requestPath)) return response({ status: 'DISABLED' }, requestPath, method)
|
||||
throw new Error(`unexpected domestic request ${method} ${requestPath}`)
|
||||
},
|
||||
async requestOverseas(method, requestPath, body, headers, token) {
|
||||
calls.push({ channel: 'overseas', method, requestPath })
|
||||
if (requestPath.endsWith('/current')) return { method, path: requestPath, http: 200,
|
||||
body: { code: 'A0404', message: 'not found' }, requestId: 'req-current', traceId: 'trace-current' }
|
||||
return response([], requestPath, method)
|
||||
},
|
||||
};
|
||||
return handler.apply(this,args);
|
||||
},
|
||||
|
||||
apiSuccess(module, name, result) { return result.body.data },
|
||||
addStep(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed, evidence, options }) },
|
||||
assert(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed, evidence, options }) },
|
||||
|
||||
@@ -103,7 +103,7 @@ test('PDA operations reuse received plan pallets and the public location without
|
||||
})
|
||||
|
||||
test('P5 follows warehouse review, customer confirmation and fulfillment completion in order', () => {
|
||||
assert.match(source, /request\(context, context.overseasToken, 'POST', 'P5平台复核退回REWORK'/)
|
||||
assert.match(source, /request\(context, context.token, 'POST', 'P5平台复核退回REWORK'/)
|
||||
assert.doesNotMatch(source, /decision: 'APPROVE'/)
|
||||
assert.match(source, /manualCompletion: true/)
|
||||
assert.match(source, /P5返工重新确认箱标阶段/)
|
||||
|
||||
@@ -47,7 +47,7 @@ function blocker(context, name, reason, response = null) {
|
||||
}
|
||||
|
||||
async function request(context, token, method, name, path, body = undefined) {
|
||||
const response = await context.requestOverseas(method, path, body, {}, token)
|
||||
const response = await context.request(method, path, body, token, {})
|
||||
context.addStep(MODULE, name, response, success(response), trace(response), OPTIONS)
|
||||
return { response, data: success(response) ? response.body?.data : null }
|
||||
}
|
||||
@@ -85,7 +85,7 @@ async function fundRelabelWallet(context, customerId) {
|
||||
async function expectConflict(context, token, method, name, path, body) {
|
||||
let response = null
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
response = await context.requestOverseas(method, path, body, {}, token)
|
||||
response = await context.request(method, path, body, token, {})
|
||||
if (response?.http !== 0 || attempt === 3) break
|
||||
await new Promise((resolve) => setTimeout(resolve, 400 * attempt))
|
||||
}
|
||||
@@ -99,8 +99,7 @@ async function resolvePallet(context, token, palletCode, name = '扫码解析托
|
||||
}
|
||||
|
||||
async function resolvePublicLocation(context, fixture, sourceLocationCode) {
|
||||
const response = await context.requestOverseas('GET',
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(fixture.overseasWarehouseId)}/locations?page=1&size=200`)
|
||||
const response = await context.request('GET', `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(fixture.overseasWarehouseId)}/locations?page=1&size=200`)
|
||||
const locations = records(response?.body?.data)
|
||||
const location = locations.find((item) => item?.locationType === 'SHARED_OVERFLOW'
|
||||
&& item?.locationCode === 'A0' && item?.status === 'ACTIVE' && item.locationCode !== sourceLocationCode)
|
||||
@@ -389,10 +388,10 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
const inventoryCandidates = []
|
||||
let inventoryCandidatesResponse = null
|
||||
for (const inboundCode of inboundCodes) {
|
||||
inventoryCandidatesResponse = await context.requestOverseas('GET', query(
|
||||
inventoryCandidatesResponse = await context.request('GET', query(
|
||||
'/api/merchant/v1/last-leg/relabel-applications/inventory-candidates', {
|
||||
operationWarehouseId: fixture.overseasWarehouseId, inboundCode,
|
||||
}), undefined, {}, merchantToken)
|
||||
}), undefined, merchantToken, {})
|
||||
context.addStep(MODULE, 'P5读取本轮同柜同目的仓换标候选', inventoryCandidatesResponse,
|
||||
success(inventoryCandidatesResponse), trace(inventoryCandidatesResponse), OPTIONS)
|
||||
if (!success(inventoryCandidatesResponse)) return false
|
||||
@@ -437,8 +436,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
}
|
||||
}),
|
||||
}
|
||||
const createdResponse = await context.requestOverseas('POST',
|
||||
query(relabelBase, { customerId }), createBody)
|
||||
const createdResponse = await context.request('POST', query(relabelBase, { customerId }), createBody)
|
||||
const created = success(createdResponse) ? createdResponse.body?.data : null
|
||||
const relabelNo = id(created?.application?.relabelNo)
|
||||
context.addStep(MODULE, 'P5基于本轮完成库存创建真实换标申请', createdResponse,
|
||||
@@ -449,11 +447,11 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
blocker(context, 'P5真实换标申请', '平台正式换标创建接口未返回申请单号', createdResponse)
|
||||
return false
|
||||
}
|
||||
const reviewed = await request(context, context.overseasToken, 'PUT', 'P5尾程订单审核直接贴标申请',
|
||||
const reviewed = await request(context, context.token, 'PUT', 'P5尾程订单审核直接贴标申请',
|
||||
query(`/api/platform/v1/last-leg/orders/${encodeURIComponent(id(created.application.lastlegOrderId))}/relabel-review`, { customerId }),
|
||||
{ status: 'PENDING_PROCESSING', remark: context.marker })
|
||||
if (!success(reviewed.response) || reviewed.data?.application?.status !== 'PENDING_PROCESSING') return false
|
||||
const funded = await poll(async () => context.requestOverseas('GET', `${relabelBase}/${encodeURIComponent(relabelNo)}`),
|
||||
const funded = await poll(async () => context.request('GET', `${relabelBase}/${encodeURIComponent(relabelNo)}`),
|
||||
(response) => success(response) && response.body?.data?.application?.walletStatus === 'FROZEN')
|
||||
context.addStep(MODULE, 'P5换标费用冻结后开放PDA作业', funded.result,
|
||||
funded.result?.body?.data?.application?.walletStatus === 'FROZEN',
|
||||
@@ -537,7 +535,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
detail = exception.data
|
||||
if (!detail) return false
|
||||
|
||||
const photo = await context.requestOverseasMultipart('POST',
|
||||
const photo = await context.requestMultipart('POST',
|
||||
`${pdaBase}/${encodeURIComponent(relabelNo)}/photos/files`, {
|
||||
name: `${context.marker}-relabel.png`, type: 'image/png', content: PNG,
|
||||
}, token)
|
||||
@@ -557,9 +555,9 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
const reviewPath = `${relabelBase}/${encodeURIComponent(relabelNo)}/rework`
|
||||
const reworkBody = { decision: 'REWORK', expectedVersion: Number(completed.data.version),
|
||||
idempotencyKey: key(context, 'RELABEL-REWORK'), remark: `${context.marker}-rework` }
|
||||
const reworked = await request(context, context.overseasToken, 'POST', 'P5平台复核退回REWORK', reviewPath, reworkBody)
|
||||
const reworked = await request(context, context.token, 'POST', 'P5平台复核退回REWORK', reviewPath, reworkBody)
|
||||
if (!success(reworked.response)) return false
|
||||
const reworkReplay = await request(context, context.overseasToken, 'POST', 'P5 REWORK同键重放', reviewPath, reworkBody)
|
||||
const reworkReplay = await request(context, context.token, 'POST', 'P5 REWORK同键重放', reviewPath, reworkBody)
|
||||
context.assert(MODULE, 'P5 REWORK重放保持AVAILABLE等待重新认领', reworkReplay.response,
|
||||
reworkReplay.data?.taskStatus === 'AVAILABLE' && Number(reworkReplay.data.relabelledPackages) === 0,
|
||||
trace(reworkReplay.response), OPTIONS)
|
||||
@@ -588,7 +586,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
if (!success(redo.response)) return false
|
||||
detail = redo.data
|
||||
}
|
||||
const bindingResponse = await context.requestOverseas('GET', `${relabelBase}/${encodeURIComponent(relabelNo)}`)
|
||||
const bindingResponse = await context.request('GET', `${relabelBase}/${encodeURIComponent(relabelNo)}`)
|
||||
const bindingDetail = success(bindingResponse) ? bindingResponse.body.data : null
|
||||
if (!Array.isArray(bindingDetail?.items) || bindingDetail.items.length !== 3) return false
|
||||
|
||||
@@ -681,7 +679,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
`targets=${new Set(movedFacts.map((fact) => fact.targetPalletId)).size}, targetBMoves=${movedFacts.filter((fact) => fact.targetPalletId === id(printedB.data.palletId)).length}`, OPTIONS)
|
||||
|
||||
for (const fact of movedFacts) {
|
||||
const positionsResponse = await context.requestOverseas('GET', query(
|
||||
const positionsResponse = await context.request('GET', query(
|
||||
`/api/platform/v1/last-leg/inventory/balances/${encodeURIComponent(fact.balanceId)}/positions`,
|
||||
{ customerId }), undefined)
|
||||
const positions = success(positionsResponse) && Array.isArray(positionsResponse.body?.data)
|
||||
@@ -694,7 +692,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
&& targetPosition?.inboundCode === fact.inboundCode,
|
||||
`targetPallet=${fact.targetPalletCode}, inbound=${targetPosition?.inboundOrderNo || 'missing'}; ${trace(positionsResponse)}`, OPTIONS)
|
||||
}
|
||||
const ledgerResponse = await context.requestOverseas('GET', query(
|
||||
const ledgerResponse = await context.request('GET', query(
|
||||
'/api/platform/v1/last-leg/inventory/ledger', {
|
||||
customerId, page: 1, size: 100, businessType: 'RELABEL_PALLET_DISASSEMBLE',
|
||||
occurredFrom: new Date(Date.now() - 10 * 60_000).toISOString().slice(0, 19),
|
||||
@@ -721,8 +719,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
trace(finalReplay.response), OPTIONS)
|
||||
const confirmBody = { idempotencyKey: key(context, 'RELABEL-CUSTOMER-CONFIRM'),
|
||||
remark: `${context.marker}-customer-confirmed` }
|
||||
const confirmedResponse = await context.requestOverseas('POST',
|
||||
`${merchantRelabelBase}/${encodeURIComponent(relabelNo)}/confirm-completion`, confirmBody, {}, merchantToken)
|
||||
const confirmedResponse = await context.request('POST', `${merchantRelabelBase}/${encodeURIComponent(relabelNo)}/confirm-completion`, confirmBody, merchantToken, {})
|
||||
const confirmed = success(confirmedResponse) ? confirmedResponse.body?.data : null
|
||||
context.addStep(MODULE, 'P5客户确认换标完成进入待排车', confirmedResponse,
|
||||
confirmed?.application?.status === 'READY_FOR_OUTBOUND'
|
||||
@@ -741,13 +738,12 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
export async function settleRelabelFulfillment(context, relabel) {
|
||||
const relabelNo = relabel.relabelNo
|
||||
const relabelBase = '/api/platform/v1/last-leg/relabel-applications'
|
||||
const terminal = await context.requestOverseas('PUT',
|
||||
`${relabelBase}/${encodeURIComponent(relabelNo)}/status`, {
|
||||
const terminal = await context.request('PUT', `${relabelBase}/${encodeURIComponent(relabelNo)}/status`, {
|
||||
status: 'COMPLETED', remark: `${context.marker}-fulfillment-complete`,
|
||||
})
|
||||
context.addStep(MODULE, 'P5待出库换标单完成履约', terminal,
|
||||
success(terminal) && terminal.body?.data?.application?.status === 'COMPLETED', trace(terminal), OPTIONS)
|
||||
const settled = await poll(async () => context.requestOverseas('GET', `${relabelBase}/${encodeURIComponent(relabelNo)}`),
|
||||
const settled = await poll(async () => context.request('GET', `${relabelBase}/${encodeURIComponent(relabelNo)}`),
|
||||
(response) => success(response) && response.body?.data?.application?.walletStatus === 'SETTLED')
|
||||
context.addStep(MODULE, 'P5换标完成后钱包结算收敛', settled.result,
|
||||
settled.result?.body?.data?.application?.walletStatus === 'SETTLED',
|
||||
|
||||
@@ -59,20 +59,19 @@ function blockerGap(context, name, reason, category = 'fixture-unavailable', res
|
||||
}
|
||||
|
||||
async function pda(context, token, method, name, path, body = undefined) {
|
||||
const response = await context.requestOverseas(method, path, body, {}, token)
|
||||
const response = await context.request(method, path, body, token, {})
|
||||
context.addStep(MODULE, name, response, success(response), trace(response), OPTIONS)
|
||||
return { response, data: success(response) ? response.body?.data : null }
|
||||
}
|
||||
|
||||
async function expected(context, token, method, name, path, body, predicate) {
|
||||
const response = await context.requestOverseas(method, path, body, {}, token)
|
||||
const response = await context.request(method, path, body, token, {})
|
||||
context.addStep(MODULE, name, response, predicate(response), trace(response), OPTIONS)
|
||||
return response
|
||||
}
|
||||
|
||||
async function captureCompletedOrder(context, containerNo, expectedCustomerId) {
|
||||
const response = await context.requestOverseas('GET',
|
||||
`/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(containerNo)}`)
|
||||
const response = await context.request('GET', `/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(containerNo)}`)
|
||||
const candidates = records(response?.body?.data)
|
||||
.filter((item) => id(item?.containerNo) === id(containerNo))
|
||||
const exactCustomer = expectedCustomerId == null ? candidates
|
||||
@@ -85,7 +84,7 @@ async function captureCompletedOrder(context, containerNo, expectedCustomerId) {
|
||||
context.createdResources.phase4OverseasPda.orderId = id(order.id)
|
||||
context.createdResources.phase4OverseasPda.customerId = id(order.customerId)
|
||||
const detailPath = `/api/platform/v1/last-leg/orders/${encodeURIComponent(id(order.id))}?customerId=${encodeURIComponent(id(order.customerId))}`
|
||||
const detail = await context.requestOverseas('GET', detailPath)
|
||||
const detail = await context.request('GET', detailPath)
|
||||
context.assert(MODULE, '完成柜回查尾程订单货物明细供后续PDA作业', detail,
|
||||
success(detail) && id(detail.body?.data?.order?.id) === id(order.id)
|
||||
&& Array.isArray(detail.body?.data?.cargo) && detail.body.data.cargo.length > 0,
|
||||
@@ -102,10 +101,10 @@ async function loginMerchantForNegativeProbe(context) {
|
||||
if (!username || !password) return null
|
||||
const response = await context.request('POST', '/api/auth/login', { loginId: username, password }, '')
|
||||
const data = success(response) ? response.body?.data : null
|
||||
context.addStep(MODULE, '本轮客户登录取得海外令牌用于PDA越权反证', response,
|
||||
success(response) && nonBlank(data?.overseasAccessToken),
|
||||
`overseasTokenPresent=${nonBlank(data?.overseasAccessToken)}; ${trace(response)}`, OPTIONS)
|
||||
return nonBlank(data?.overseasAccessToken) ? data.overseasAccessToken : null
|
||||
context.addStep(MODULE, '本轮客户登录取得访问令牌用于PDA越权反证', response,
|
||||
success(response) && nonBlank(data?.accessToken),
|
||||
`tokenPresent=${nonBlank(data?.accessToken)}; ${trace(response)}`, OPTIONS)
|
||||
return nonBlank(data?.accessToken) ? data.accessToken : null
|
||||
}
|
||||
|
||||
export async function resolvePickerAccess(context) {
|
||||
@@ -144,17 +143,16 @@ export async function createPicker(context, index, overseasWarehouseId, access)
|
||||
`membershipId=${membershipId || 'missing'}, tenantId=${id(created?.tenantId) || 'missing'}, overseasWarehouseId=${id(created?.overseasWarehouseId) || 'missing'}`, OPTIONS)
|
||||
if (!longId(membershipId)) return null
|
||||
const login = await context.request('POST', '/api/auth/login', { loginId: username, password }, '')
|
||||
const token = success(login) ? login.body?.data?.overseasAccessToken : null
|
||||
context.addStep(MODULE, `海外PDA操作员${index}登录取得双区域海外令牌`, login, nonBlank(token),
|
||||
`overseasTokenPresent=${nonBlank(token)}; ${trace(login)}`, OPTIONS)
|
||||
const token = success(login) ? login.body?.data?.accessToken : null
|
||||
context.addStep(MODULE, `海外PDA操作员${index}登录取得统一访问令牌`, login, nonBlank(token),
|
||||
`tokenPresent=${nonBlank(token)}; ${trace(login)}`, OPTIONS)
|
||||
if (!nonBlank(token)) return { membershipId, username, token: null }
|
||||
|
||||
let projectionResponse = null
|
||||
let projectionReady = false
|
||||
let projectionAttempt = 0
|
||||
for (; projectionAttempt < 30; projectionAttempt += 1) {
|
||||
projectionResponse = await context.requestOverseas('GET',
|
||||
'/api/pda/v1/last-leg/receiving-containers/current', undefined, {}, token)
|
||||
projectionResponse = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, token, {})
|
||||
projectionReady = success(projectionResponse) || notFound(projectionResponse)
|
||||
if (projectionReady || !forbidden(projectionResponse)) break
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
@@ -193,12 +191,10 @@ async function resolveGeneratedPallet(context, inbound) {
|
||||
const pallet = candidates[0]
|
||||
if (!pallet || !longId(pallet.palletId) || !nonBlank(pallet.palletCode)) return null
|
||||
const warehouseId = id(inbound?.overseasWarehouseId || pallet.warehouseId)
|
||||
const listed = await context.requestOverseas('GET',
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets?page=1&size=10&keyword=${encodeURIComponent(pallet.palletCode)}`)
|
||||
const listed = await context.request('GET', `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets?page=1&size=10&keyword=${encodeURIComponent(pallet.palletCode)}`)
|
||||
const stored = records(listed?.body?.data).find((row) => id(row?.id) === pallet.palletId && row?.palletCode === pallet.palletCode)
|
||||
const locations = stored && longId(stored?.locationId)
|
||||
? await context.requestOverseas('GET',
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations?page=1&size=200`)
|
||||
? await context.request('GET', `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/locations?page=1&size=200`)
|
||||
: null
|
||||
const location = records(locations?.body?.data).find((row) => id(row?.id) === id(stored?.locationId))
|
||||
const resolved = success(listed) && success(locations) && stored && longId(stored?.locationId) && nonBlank(location?.locationCode)
|
||||
@@ -263,14 +259,14 @@ async function verifyWarehouseQueries(context, token, inboundCode, pallet, desti
|
||||
}
|
||||
|
||||
export async function uploadPhotos(context, token) {
|
||||
const malicious = await context.requestOverseasMultipart('POST', '/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files', {
|
||||
const malicious = await context.requestMultipart('POST', '/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files', {
|
||||
name: `${context.marker}-forged.png`, type: 'image/png', content: Uint8Array.from(Buffer.from('<script>alert(1)</script>')),
|
||||
}, token)
|
||||
context.addStep(MODULE, '伪造图片内容安全扫描返回A0400', malicious,
|
||||
malicious?.http === 200 && code(malicious) === 'A0400' && traced(malicious), trace(malicious), OPTIONS)
|
||||
const photos = []
|
||||
for (const role of PHOTO_ROLES) {
|
||||
const response = await context.requestOverseasMultipart('POST', '/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files', {
|
||||
const response = await context.requestMultipart('POST', '/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files', {
|
||||
name: `${context.marker}-${role.toLowerCase()}.png`, type: 'image/png', content: PNG,
|
||||
}, token)
|
||||
const fileId = id(response?.body?.data?.fileId)
|
||||
@@ -290,8 +286,7 @@ async function awaitProducerConsumption(context, producers) {
|
||||
for (let attempt = 1; attempt <= 90; attempt += 1) {
|
||||
const missing = expected.filter((containerNo) => !consumed.has(containerNo))
|
||||
const results = await Promise.all(missing.map(async (containerNo) => {
|
||||
const response = await context.requestOverseas('GET',
|
||||
`/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(containerNo)}`)
|
||||
const response = await context.request('GET', `/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(containerNo)}`)
|
||||
return { containerNo, response }
|
||||
}))
|
||||
for (const { containerNo, response } of results) {
|
||||
@@ -316,9 +311,7 @@ async function awaitProducerConsumption(context, producers) {
|
||||
async function convertReceivingContainers(context, producers) {
|
||||
for (const producer of producers) {
|
||||
const containerNo = id(producer?.containerNo)
|
||||
const summaryResponse = await context.requestOverseas('GET',
|
||||
`/api/platform/v1/last-leg/orders/container-summaries?page=1&size=20&containerNo=${encodeURIComponent(containerNo)}`,
|
||||
undefined, {}, context.overseasToken)
|
||||
const summaryResponse = await context.request('GET', `/api/platform/v1/last-leg/orders/container-summaries?page=1&size=20&containerNo=${encodeURIComponent(containerNo)}`, undefined, context.token, {})
|
||||
const summaries = success(summaryResponse) && Array.isArray(summaryResponse.body?.data?.records)
|
||||
? summaryResponse.body.data.records : []
|
||||
const summary = summaries.find((item) => id(item?.containerNo) === containerNo)
|
||||
@@ -336,9 +329,7 @@ async function convertReceivingContainers(context, producers) {
|
||||
`sourceContainerBookingId=${id(summary?.sourceContainerBookingId) || 'missing'}, allPlansReady=${summary?.allPlansReady === true}, orders=${totalOrders}, inboundCodes=${inboundCodes}, plans=${existingPlans}, pending=${pendingPlans}; ${trace(summaryResponse)}`, OPTIONS)
|
||||
if (!convertible) return false
|
||||
const bookingId = id(summary.sourceContainerBookingId)
|
||||
const arrivalQueryResponse = await context.requestOverseas('POST',
|
||||
'/api/platform/v1/last-leg/orders/container-arrival-times/query',
|
||||
{ sourceContainerBookingIds: [bookingId] }, {}, context.overseasToken)
|
||||
const arrivalQueryResponse = await context.request('POST', '/api/platform/v1/last-leg/orders/container-arrival-times/query', { sourceContainerBookingIds: [bookingId] }, context.token, {})
|
||||
const arrivalTimes = success(arrivalQueryResponse) && Array.isArray(arrivalQueryResponse.body?.data)
|
||||
? arrivalQueryResponse.body.data : []
|
||||
let arrivalTime = arrivalTimes.find((item) => id(item?.sourceContainerBookingId) === bookingId)
|
||||
@@ -349,12 +340,8 @@ async function convertReceivingContainers(context, producers) {
|
||||
if (!arrivalTime) return false
|
||||
if (!nonBlank(arrivalTime.estimatedWarehouseArrivalAt)) {
|
||||
const estimatedWarehouseArrivalAt = new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 19)
|
||||
const arrivalUpdateResponse = await context.requestOverseas('PUT',
|
||||
`/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(bookingId)}/expected-arrival`,
|
||||
{ estimatedWarehouseArrivalAt, expectedVersion: Number(arrivalTime.version),
|
||||
remark: `${context.marker}-overseas-inbound-plan-prerequisite` },
|
||||
{ 'Idempotency-Key': `${context.marker}-expected-arrival-${containerNo}`.slice(0, 128) },
|
||||
context.overseasToken)
|
||||
const arrivalUpdateResponse = await context.request('PUT', `/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(bookingId)}/expected-arrival`, { estimatedWarehouseArrivalAt, expectedVersion: Number(arrivalTime.version),
|
||||
remark: `${context.marker}-overseas-inbound-plan-prerequisite` }, context.token, { 'Idempotency-Key': `${context.marker}-expected-arrival-${containerNo}`.slice(0, 128) })
|
||||
arrivalTime = success(arrivalUpdateResponse) ? arrivalUpdateResponse.body?.data : null
|
||||
context.addStep(MODULE, `维护整柜预计到仓时间 ${containerNo}`, arrivalUpdateResponse,
|
||||
id(arrivalTime?.sourceContainerBookingId) === bookingId
|
||||
@@ -364,8 +351,7 @@ async function convertReceivingContainers(context, producers) {
|
||||
if (!arrivalTime || !nonBlank(arrivalTime.estimatedWarehouseArrivalAt)) return false
|
||||
}
|
||||
const previewPath = `/api/platform/v1/last-leg/inbound-plans/container-preview?sourceContainerBookingId=${encodeURIComponent(bookingId)}`
|
||||
const previewResponse = await context.requestOverseas('GET', previewPath,
|
||||
undefined, {}, context.overseasToken)
|
||||
const previewResponse = await context.request('GET', previewPath, undefined, context.token, {})
|
||||
const preview = success(previewResponse) ? previewResponse.body?.data : null
|
||||
context.addStep(MODULE, `读取PDA前柜级计划与自动托盘预览 ${containerNo}`, previewResponse,
|
||||
preview?.ready === true && Number(preview?.totalOrderCount) === totalOrders
|
||||
@@ -377,9 +363,7 @@ async function convertReceivingContainers(context, producers) {
|
||||
const body = { sourceContainerBookingId: bookingId,
|
||||
snapshotVersion: Number(preview.snapshotVersion), snapshotHash: String(preview.snapshotHash) }
|
||||
const key = `${context.marker}-pda-convert-${containerNo}`.slice(0, 128)
|
||||
const convertedResponse = await context.requestOverseas('POST',
|
||||
'/api/platform/v1/last-leg/inbound-plans/convert-container', body,
|
||||
{ 'Idempotency-Key': key }, context.overseasToken)
|
||||
const convertedResponse = await context.request('POST', '/api/platform/v1/last-leg/inbound-plans/convert-container', body, context.token, { 'Idempotency-Key': key })
|
||||
const plans = success(convertedResponse) && Array.isArray(convertedResponse.body?.data)
|
||||
? convertedResponse.body.data : []
|
||||
const convertedInboundCodes = new Set(plans
|
||||
@@ -412,9 +396,7 @@ async function convertReceivingContainers(context, producers) {
|
||||
OPTIONS)
|
||||
}
|
||||
rememberGeneratedPlans(context, plans)
|
||||
const replayResponse = await context.requestOverseas('POST',
|
||||
'/api/platform/v1/last-leg/inbound-plans/convert-container', body,
|
||||
{ 'Idempotency-Key': key }, context.overseasToken)
|
||||
const replayResponse = await context.request('POST', '/api/platform/v1/last-leg/inbound-plans/convert-container', body, context.token, { 'Idempotency-Key': key })
|
||||
context.addStep(MODULE, `整柜转换幂等重放零新增 ${containerNo}`, replayResponse,
|
||||
success(replayResponse) && JSON.stringify(replayResponse.body?.data) === JSON.stringify(plans),
|
||||
trace(replayResponse), OPTIONS)
|
||||
@@ -428,9 +410,7 @@ async function convertReceivingContainers(context, producers) {
|
||||
`plans=${existingPlans}, plannedPallets=${preview?.existingPlannedPalletCount ?? 'missing'}; ${trace(previewResponse)}`,
|
||||
OPTIONS)
|
||||
}
|
||||
const progressResponse = await context.requestOverseas('GET',
|
||||
`/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(summary.sourceContainerBookingId)}`,
|
||||
undefined, {}, context.overseasToken)
|
||||
const progressResponse = await context.request('GET', `/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(summary.sourceContainerBookingId)}`, undefined, context.token, {})
|
||||
const progress = success(progressResponse) ? progressResponse.body?.data?.summary : null
|
||||
context.addStep(MODULE, `PDA前整柜计划完整度闭环 ${containerNo}`, progressResponse,
|
||||
progress?.allPlansReady === true && Number(progress?.planCount) === totalOrders
|
||||
@@ -452,8 +432,7 @@ async function awaitPickupReadyCandidates(context, token, producers, allProducer
|
||||
let response = null
|
||||
let candidatesByContainer = new Map()
|
||||
for (let attempt = 1; attempt <= 30; attempt += 1) {
|
||||
response = await context.requestOverseas('GET',
|
||||
'/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, token)
|
||||
response = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, token, {})
|
||||
const candidateRows = success(response) && Array.isArray(response.body?.data) ? response.body.data : []
|
||||
candidatesByContainer = new Map(candidateRows
|
||||
.map((item) => [id(item?.containerNo), item]).filter(([containerNo]) => nonBlank(containerNo)))
|
||||
@@ -499,8 +478,7 @@ export async function resolveCartonFixtures(context, token, inbounds, producerCa
|
||||
}
|
||||
const cartonFixtures = []
|
||||
for (const [index, cartonMark] of cartonCandidates.entries()) {
|
||||
const response = await context.requestOverseas('POST',
|
||||
'/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', { cartonMark }, {}, token)
|
||||
const response = await context.request('POST', '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', { cartonMark }, token, {})
|
||||
if (!strictDropship && overseasCartons.includes(cartonMark)
|
||||
&& a0404Message(response, /^Carton mark was not found$/)) {
|
||||
context.addStep(MODULE, `排除未实装箱码候选${index + 1}`, response, true,
|
||||
@@ -669,15 +647,15 @@ async function receiveDropship(context, token, producer, inbounds) {
|
||||
}
|
||||
|
||||
async function runReceiving(context, token, secondaryToken, merchantToken, preferredProducer = null, runAuthorizationProbes = true) {
|
||||
if (runAuthorizationProbes && context.overseasToken) {
|
||||
const denied = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, context.overseasToken)
|
||||
context.addStep(MODULE, '平台管理员海外令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS)
|
||||
if (runAuthorizationProbes && context.token) {
|
||||
const denied = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, context.token, {})
|
||||
context.addStep(MODULE, '平台管理员访问令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS)
|
||||
}
|
||||
if (runAuthorizationProbes && merchantToken) {
|
||||
const denied = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, merchantToken)
|
||||
context.addStep(MODULE, '客户海外令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS)
|
||||
const denied = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, merchantToken, {})
|
||||
context.addStep(MODULE, '客户访问令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS)
|
||||
}
|
||||
const before = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, {}, token)
|
||||
const before = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, token, {})
|
||||
const noCurrent = notFound(before)
|
||||
context.addStep(MODULE, '无当前柜返回404属于正常空态', before, noCurrent || success(before),
|
||||
`${noCurrent ? 'empty=true' : `resumeStatus=${before?.body?.data?.status || 'missing'}`}; ${trace(before)}`, OPTIONS)
|
||||
@@ -848,7 +826,7 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe
|
||||
const completeReplay = await pda(context, token, 'POST', '完成当前柜同键同载荷重放', '/api/pda/v1/last-leg/receiving-containers/current/complete', completeBody)
|
||||
context.assert(MODULE, '完成幂等重放仍返回COMPLETED', completeReplay.response,
|
||||
success(completeReplay.response) && completeReplay.data?.status === 'COMPLETED', trace(completeReplay.response), OPTIONS)
|
||||
const after = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, {}, token)
|
||||
const after = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, token, {})
|
||||
context.addStep(MODULE, '完成后current返回404正常空态', after, notFound(after), trace(after), OPTIONS)
|
||||
context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null
|
||||
if (context.createdResources.phase4OverseasPda.finalStatus === 'COMPLETED') {
|
||||
|
||||
@@ -42,7 +42,7 @@ function add(context, name, response, passed, evidence = '') {
|
||||
}
|
||||
|
||||
async function overseas(context, token, method, name, path, body = undefined) {
|
||||
const response = await context.requestOverseas(method, path, body, {}, token)
|
||||
const response = await context.request(method, path, body, token, {})
|
||||
add(context, name, response, success(response))
|
||||
return { response, data: success(response) ? response.body?.data : null }
|
||||
}
|
||||
@@ -70,7 +70,7 @@ async function loginMerchant(context) {
|
||||
const response = await context.request('POST', '/api/auth/login', {
|
||||
loginId: fixture.username, password: fixture.password,
|
||||
}, '')
|
||||
const token = success(response) ? response.body?.data?.overseasAccessToken : null
|
||||
const token = success(response) ? response.body?.data?.accessToken : null
|
||||
add(context, '客户登录并取得海外访问令牌', response, nonBlank(token), `tokenPresent=${nonBlank(token)}`)
|
||||
return nonBlank(token) ? token : null
|
||||
}
|
||||
@@ -136,7 +136,7 @@ export async function run(context) {
|
||||
const form = new FormData()
|
||||
form.append('file', new Blob([new TextEncoder().encode(`%PDF-1.4\n${context.marker}\n%%EOF`)],
|
||||
{ type: 'application/pdf' }), `${suffix}-return-relabel.pdf`)
|
||||
const uploaded = await context.requestOverseasMultipart('POST',
|
||||
const uploaded = await context.requestMultipart('POST',
|
||||
`/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/files?scope=CARTON_TOTAL`,
|
||||
form, merchantToken)
|
||||
add(context, '客户编辑退货换标总资料', uploaded, success(uploaded))
|
||||
@@ -150,7 +150,7 @@ export async function run(context) {
|
||||
}
|
||||
}
|
||||
|
||||
const outboundCandidates = await overseas(context, context.overseasToken, 'GET', '平台查询本轮退货出库候选',
|
||||
const outboundCandidates = await overseas(context, context.token, 'GET', '平台查询本轮退货出库候选',
|
||||
query('/api/platform/v1/last-leg/outbound-orders/return/candidates', {
|
||||
customerId, warehouseId: warehouse.id,
|
||||
}))
|
||||
@@ -165,7 +165,7 @@ export async function run(context) {
|
||||
returnNo, cartonMarks: [outboundCandidate.cartonMark], remark: context.marker,
|
||||
idempotencyKey: key(context, 'OUTBOUND'),
|
||||
}
|
||||
const outbound = await overseas(context, context.overseasToken, 'POST', '平台创建独立退货出库单',
|
||||
const outbound = await overseas(context, context.token, 'POST', '平台创建独立退货出库单',
|
||||
query('/api/platform/v1/last-leg/outbound-orders/return', { customerId }), outboundBody)
|
||||
const taskNo = id(outbound.data?.taskNo)
|
||||
const outboundNo = id(outbound.data?.outboundNo)
|
||||
@@ -187,7 +187,7 @@ export async function run(context) {
|
||||
const completed = await overseas(context, picker.token, 'POST', 'PDA完成退货出库',
|
||||
`/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(taskNo)}/complete`,
|
||||
{ idempotencyKey: key(context, 'OUTBOUND-COMPLETE') })
|
||||
const outboundDetail = await overseas(context, context.overseasToken, 'GET', '平台回查退货出库最终状态',
|
||||
const outboundDetail = await overseas(context, context.token, 'GET', '平台回查退货出库最终状态',
|
||||
query(`/api/platform/v1/last-leg/outbound-orders/${encodeURIComponent(outboundNo)}`, { customerId }))
|
||||
context.assert(MODULE, 'RETURN_OUTBOUND真实完成且箱唛只出库一次', outboundDetail.response,
|
||||
completed.data?.status === 'COMPLETED'
|
||||
@@ -220,7 +220,7 @@ export async function outboundRelabelledReturn(context, merchantToken, pdaToken,
|
||||
context.assert(MODULE, name, null, Boolean(value), `relabelNo=${relabelNo}`, OPTIONS)
|
||||
if (!value) throw new Error(name)
|
||||
}
|
||||
const candidates = await command(context.overseasToken, 'GET', '查询换标完成后的退货出库候选',
|
||||
const candidates = await command(context.token, 'GET', '查询换标完成后的退货出库候选',
|
||||
query(`${root}/return/candidates`, { customerId, warehouseId }))
|
||||
const selected = []
|
||||
for (const row of (Array.isArray(candidates) ? candidates : []).filter(row => row.returnNo === returnNo)) {
|
||||
@@ -236,13 +236,13 @@ export async function outboundRelabelledReturn(context, merchantToken, pdaToken,
|
||||
const mark = selected[0].cartonMark
|
||||
const body = { recipient: `${context.marker} RELABELLED RETURN`, overseasWarehouseId: warehouseId,
|
||||
returnNo, cartonMarks: [mark], remark: context.marker, idempotencyKey: key(context, 'RELABELLED-OUTBOUND') }
|
||||
const task = await command(context.overseasToken, 'POST', '创建本轮已换标库存出库', query(`${root}/return`, { customerId }), body)
|
||||
const replay = await command(context.overseasToken, 'POST', '已换标库存出库创建原键重放', query(`${root}/return`, { customerId }), body)
|
||||
const task = await command(context.token, 'POST', '创建本轮已换标库存出库', query(`${root}/return`, { customerId }), body)
|
||||
const replay = await command(context.token, 'POST', '已换标库存出库创建原键重放', query(`${root}/return`, { customerId }), body)
|
||||
require('出库重放保持同一任务和业务单', nonBlank(task.taskNo) && nonBlank(task.outboundNo)
|
||||
&& replay.taskNo === task.taskNo && replay.outboundNo === task.outboundNo)
|
||||
const path = `/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(task.taskNo)}`
|
||||
const detail = await command(pdaToken, 'GET', 'PDA核对已换标库存出库任务', path)
|
||||
const frozen = await command(context.overseasToken, 'GET', '平台核对本轮冻结出库箱号',
|
||||
const frozen = await command(context.token, 'GET', '平台核对本轮冻结出库箱号',
|
||||
query(`${root}/${encodeURIComponent(task.outboundNo)}`, { customerId }))
|
||||
const items = detail.items || []
|
||||
require('出库任务只包含本轮已换标箱', detail.task?.outboundNo === task.outboundNo
|
||||
@@ -255,7 +255,7 @@ export async function outboundRelabelledReturn(context, merchantToken, pdaToken,
|
||||
cartonMarks: [mark], idempotencyKey: key(context, 'RELABELLED-PICK'),
|
||||
})
|
||||
const completed = await command(pdaToken, 'POST', 'PDA完成已换标库存出库', `${path}/complete`, { idempotencyKey: key(context, 'RELABELLED-COMPLETE') })
|
||||
const outbound = await command(context.overseasToken, 'GET', '平台回查已换标出库终态', query(`${root}/${encodeURIComponent(task.outboundNo)}`, { customerId }))
|
||||
const outbound = await command(context.token, 'GET', '平台回查已换标出库终态', query(`${root}/${encodeURIComponent(task.outboundNo)}`, { customerId }))
|
||||
const after = await command(merchantToken, 'GET', '客户回查已换标库存出库终态', `${inventory}/${encodeURIComponent(mark)}`)
|
||||
require('已换标库存出库完成且来源和换标关联不漂移', completed.status === 'COMPLETED'
|
||||
&& outbound.outbound?.status === 'OUTBOUNDED' && after.status === 'OUTBOUNDED'
|
||||
@@ -277,7 +277,7 @@ export async function completeReturnRelabel(context, merchantToken, pdaToken, re
|
||||
context.assert(MODULE, name, null, Boolean(passed), `relabelNo=${relabelNo}`, OPTIONS)
|
||||
if (!passed) throw new Error(name)
|
||||
}
|
||||
const reviewed = await command(context.overseasToken, 'PUT', '审核本轮退货换标申请', `${platform}/status`,
|
||||
const reviewed = await command(context.token, 'PUT', '审核本轮退货换标申请', `${platform}/status`,
|
||||
{ status: 'PENDING_PROCESSING', remark: context.marker })
|
||||
require('换标审核后等待PDA作业', reviewed.application?.relabelNo === relabelNo && reviewed.application.status === 'PENDING_PROCESSING')
|
||||
let task = await command(pdaToken, 'POST', 'PDA认领本轮退货换标', `${work}/claim`, { idempotencyKey: key(context, 'RETURN-CLAIM') })
|
||||
@@ -311,7 +311,7 @@ export async function completeReturnRelabel(context, merchantToken, pdaToken, re
|
||||
task = await command(pdaToken, 'GET', '组托后回查换标任务版本', work)
|
||||
const form = new FormData()
|
||||
form.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), 'synthetic-return-relabel.png')
|
||||
const photo = await context.requestOverseasMultipart('POST', `${work}/photos/files`, form, pdaToken)
|
||||
const photo = await context.requestMultipart('POST', `${work}/photos/files`, form, pdaToken)
|
||||
add(context, '上传合成换标完工照片', photo, success(photo))
|
||||
if (!success(photo) || !photo.body.data?.fileId) throw new Error('换标完工照片失败')
|
||||
task = await command(pdaToken, 'POST', 'PDA提交退货换标完工', `${work}/complete`, {
|
||||
|
||||
@@ -10,9 +10,9 @@ const acceptanceUrl = new URL('../admin-test-env-acceptance.mjs', import.meta.ur
|
||||
|
||||
test('relabelled outbound refuses another relabel application before creating an outbound', async () => {
|
||||
const calls = []
|
||||
const context = { marker: 'fixture', overseasToken: 'admin', context: { merchantQualificationFixture: { tenantId: '123' } },
|
||||
const context = { marker: 'fixture', token: 'admin', context: { merchantQualificationFixture: { tenantId: '123' } },
|
||||
addStep() {}, assert() {},
|
||||
async requestOverseas(method, path) {
|
||||
async request(method,path) {
|
||||
calls.push({ method, path })
|
||||
return { http: 200, body: { code: '200', data: path.includes('/candidates')
|
||||
? [{ returnNo: 'RT-1', cartonMark: 'BOX' }] : { relabelNo: 'RL-OTHER' } } }
|
||||
@@ -22,10 +22,10 @@ test('relabelled outbound refuses another relabel application before creating an
|
||||
})
|
||||
|
||||
test('return outbound binds platform frozen carton to PDA item without requiring ordinary inventory positions', async () => {
|
||||
const context = { marker: 'fixture', overseasToken: 'admin', context: { merchantQualificationFixture: { tenantId: '123' } },
|
||||
const context = { marker: 'fixture', token: 'admin', context: { merchantQualificationFixture: { tenantId: '123' } },
|
||||
createdResources: { phase4OverseasReturnReceipt: {} }, addStep() {},
|
||||
assert(_m, name, _r, passed) { assert.ok(passed, name) },
|
||||
async requestOverseas(method, path, body) {
|
||||
async request(method,path,body) {
|
||||
let data
|
||||
if (path.includes('/candidates')) data = [{ returnNo: 'RT-1', cartonMark: 'BOX' }]
|
||||
else if (path.includes('/return-inventory/cartons/')) data = { cartonMark: 'BOX', customerId: '123',
|
||||
@@ -47,9 +47,9 @@ test('return outbound binds platform frozen carton to PDA item without requiring
|
||||
test('return relabel lifecycle requires review, quantities, binding, photo and customer confirmation', async () => {
|
||||
const calls = []
|
||||
const task = { relabelNo: 'RL-1', version: 1, items: [{ itemId: '1', cartonMark: 'BOX', operationType: 'RETURN_BULK_SKU_LABEL', expectedPackages: 1 }] }
|
||||
const context = { marker: 'fixture', overseasToken: 'admin', createdResources: { phase4OverseasReturnReceipt: {} },
|
||||
const context = { marker: 'fixture', token: 'admin', createdResources: { phase4OverseasReturnReceipt: {} },
|
||||
addStep() {}, assert(_m, name, _r, passed) { assert.ok(passed, name) },
|
||||
async requestOverseas(method, path, body, _headers, token) {
|
||||
async request(method,path,body,token,_headers) {
|
||||
calls.push({ method, path, body, token })
|
||||
let data = task
|
||||
if (path.endsWith('/status')) data = { application: { relabelNo: 'RL-1', status: 'PENDING_PROCESSING' } }
|
||||
@@ -68,7 +68,7 @@ test('return relabel lifecycle requires review, quantities, binding, photo and c
|
||||
if (path.endsWith('/return-orders/RT-1')) data = { order: { status: 'COMPLETED', relabelNo: 'RL-1', relabelStatus: 'COMPLETED' } }
|
||||
return { http: 200, body: { code: '200', data } }
|
||||
},
|
||||
async requestOverseasMultipart(method, path, form, token) {
|
||||
async requestMultipart(method, path, form, token) {
|
||||
assert.equal(token, 'pda'); assert.ok(form instanceof FormData)
|
||||
calls.push({ method, path })
|
||||
return { http: 200, body: { code: '200', data: { fileId: '7' } } }
|
||||
@@ -101,7 +101,7 @@ test('return receipt acceptance covers both lookup paths, partial/final receipt
|
||||
assert.match(source, /palletCode: cartonA/)
|
||||
assert.match(source, /最后一批缺少打托照片拒绝且库存不变/)
|
||||
assert.match(source, /photos\/files/)
|
||||
assert.match(source, /requestOverseasMultipart\('POST',[^\n]+photoForm, picker\.token\)/)
|
||||
assert.match(source, /requestMultipart\('POST',[^\n]+photoForm, picker\.token\)/)
|
||||
assert.doesNotMatch(source, /photoForm, \{\}, picker\.token/)
|
||||
assert.match(source, /不存在SKU不是业务错误/)
|
||||
assert.match(source, /已完成退货单不再进入待签收流程/)
|
||||
|
||||
@@ -55,10 +55,10 @@ function add(context, name, response, passed, evidence = '') {
|
||||
}
|
||||
|
||||
async function overseas(context, token, method, name, path, body = undefined) {
|
||||
let response = await context.requestOverseas(method, path, body, {}, token)
|
||||
let response = await context.request(method, path, body, token, {})
|
||||
for (let attempt = 1; attempt < 3 && response?.http === 0; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250 * attempt))
|
||||
response = await context.requestOverseas(method, path, body, {}, token)
|
||||
response = await context.request(method, path, body, token, {})
|
||||
}
|
||||
add(context, name, response, success(response))
|
||||
return { response, data: success(response) ? response.body?.data : null }
|
||||
@@ -87,7 +87,7 @@ async function loginMerchant(context) {
|
||||
const response = await context.request('POST', '/api/auth/login', {
|
||||
loginId: fixture.username, password: fixture.password,
|
||||
}, '')
|
||||
const token = success(response) ? response.body?.data?.overseasAccessToken : null
|
||||
const token = success(response) ? response.body?.data?.accessToken : null
|
||||
add(context, '客户登录并取得海外访问令牌', response, nonBlank(token), `tokenPresent=${nonBlank(token)}`)
|
||||
return nonBlank(token) ? token : null
|
||||
}
|
||||
@@ -107,8 +107,7 @@ export async function approveReturnFixture(context, merchantToken, created) {
|
||||
if (!success(owned.response) || owned.data?.order?.returnNo !== order.returnNo
|
||||
|| owned.data?.order?.status !== 'PENDING_REVIEW') throw new Error('客户不可读取当前待审核退货单')
|
||||
const path = `/api/platform/v1/last-leg/return-orders/${encodeURIComponent(order.returnNo)}/review`
|
||||
const response = await context.requestOverseas('POST', path,
|
||||
{ decision: 'APPROVE', remark: `${context.marker} 退货验收审核` }, { 'X-Customer-Id': expectedCustomer })
|
||||
const response = await context.request('POST', path, { decision: 'APPROVE', remark: `${context.marker} 退货验收审核` }, undefined, { 'X-Customer-Id': expectedCustomer })
|
||||
add(context, '平台审核本轮退货预报', response, success(response))
|
||||
if (!success(response)) throw new Error(`退货审核失败:${trace(response)}`)
|
||||
const detail = await overseas(context, merchantToken, 'GET', '客户回查退货审核状态',
|
||||
@@ -193,8 +192,7 @@ export async function run(context) {
|
||||
const createReplay = await overseas(context, merchantToken, 'POST', '退货预报同键同载荷重放', RETURN_ORDERS, createBody)
|
||||
context.assert(MODULE, '预报幂等重放复用同一退货单', createReplay.response,
|
||||
id(createReplay.data?.order?.returnNo) === returnNo, trace(createReplay.response), OPTIONS)
|
||||
await reject('退货预报同键异载荷返回冲突', conflict, () => context.requestOverseas('POST', RETURN_ORDERS,
|
||||
{ ...createBody, remark: `${context.marker}-CHANGED` }, {}, merchantToken))
|
||||
await reject('退货预报同键异载荷返回冲突', conflict, () => context.request('POST', RETURN_ORDERS, { ...createBody, remark: `${context.marker}-CHANGED` }, merchantToken, {}))
|
||||
|
||||
await approveReturnFixture(context, merchantToken, created.data)
|
||||
|
||||
@@ -205,8 +203,7 @@ export async function run(context) {
|
||||
['小数预报数量', [{ skuCode: skuA, expectedQuantity: 1.5 }]],
|
||||
['重复预报SKU', [createBody.items[0], createBody.items[0]]],
|
||||
]) {
|
||||
await reject(`拒绝${name}`, validationFailure, () => context.requestOverseas('POST', RETURN_ORDERS,
|
||||
{ ...createBody, items, idempotencyKey: key(context, `INVALID-CREATE-${encodeURIComponent(name)}`) }, {}, merchantToken))
|
||||
await reject(`拒绝${name}`, validationFailure, () => context.request('POST', RETURN_ORDERS, { ...createBody, items, idempotencyKey: key(context, `INVALID-CREATE-${encodeURIComponent(name)}`) }, merchantToken, {}))
|
||||
}
|
||||
const onlyOrder = await overseas(context, merchantToken, 'GET', '非法预报后回查本轮订单数量',
|
||||
query(RETURN_ORDERS, { page: 1, size: 20, skuCode: skuA }))
|
||||
@@ -214,8 +211,7 @@ export async function run(context) {
|
||||
Number(onlyOrder.data?.total) === 1 && records(onlyOrder.data)[0]?.returnNo === returnNo
|
||||
&& records(onlyOrder.data)[0]?.status === 'PENDING_RECEIPT', trace(onlyOrder.response), OPTIONS)
|
||||
|
||||
await reject('不存在退货单返回可诊断未找到', notFound, () => context.requestOverseas('POST', `${PDA_RECEIPTS}/start`,
|
||||
{ returnNo: `MISSING-${suffix}` }, {}, picker.token))
|
||||
await reject('不存在退货单返回可诊断未找到', notFound, () => context.request('POST', `${PDA_RECEIPTS}/start`, { returnNo: `MISSING-${suffix}` }, picker.token, {}))
|
||||
|
||||
const emptySku = await overseas(context, picker.token, 'POST', 'PDA查询不存在SKU返回正常空候选',
|
||||
`${PDA_RECEIPTS}/sku-candidates`, { skuCode: `MISSING-${suffix}` })
|
||||
@@ -260,9 +256,9 @@ export async function run(context) {
|
||||
&& id(firstReplay.data?.receiptBatchNo) === firstBatchNo
|
||||
&& id(firstReplay.data?.palletCode) === cartonA,
|
||||
trace(firstReplay.response), OPTIONS)
|
||||
await reject('签收同键异载荷返回冲突', conflict, () => context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, {
|
||||
await reject('签收同键异载荷返回冲突', conflict, () => context.request('POST', `${PDA_RECEIPTS}/confirm`, {
|
||||
...firstBody, trackingNo: `TRACK-${suffix}-CHANGED`,
|
||||
}, {}, picker.token))
|
||||
}, picker.token, {}))
|
||||
|
||||
await reject('超收被拒绝且不得生成费用批次', conflict, async () => {
|
||||
const body = {
|
||||
@@ -271,7 +267,7 @@ export async function run(context) {
|
||||
}
|
||||
let response = null
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
response = await context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, body, {}, picker.token)
|
||||
response = await context.request('POST', `${PDA_RECEIPTS}/confirm`, body, picker.token, {})
|
||||
if (response?.http !== 0 || attempt === 3) break
|
||||
await new Promise((resolve) => setTimeout(resolve, 400 * attempt))
|
||||
}
|
||||
@@ -285,13 +281,12 @@ export async function run(context) {
|
||||
['非法签收方式', { startedBy: 'INVALID' }, validationFailure],
|
||||
['未预报SKU', { items: [{ skuCode: `UNKNOWN-${suffix}`, receivedQuantity: 1 }] }, conflict],
|
||||
]) {
|
||||
await reject(`拒绝${name}`, expected, () => context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`,
|
||||
{ ...firstBody, ...changes, idempotencyKey: key(context, `INVALID-RECEIPT-${encodeURIComponent(name)}`) }, {}, picker.token))
|
||||
await reject(`拒绝${name}`, expected, () => context.request('POST', `${PDA_RECEIPTS}/confirm`, { ...firstBody, ...changes, idempotencyKey: key(context, `INVALID-RECEIPT-${encodeURIComponent(name)}`) }, picker.token, {}))
|
||||
}
|
||||
await reject('部分签收退货单禁止合箱', conflict, () => context.requestOverseas('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge', {
|
||||
await reject('部分签收退货单禁止合箱', conflict, () => context.request('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge', {
|
||||
returnNos: [returnNo], items: [{ skuCode: skuA, quantity: 1 }], length: 30, width: 20, height: 10,
|
||||
weight: 1, idempotencyKey: key(context, 'EARLY-MERGE'),
|
||||
}, {}, picker.token))
|
||||
}, picker.token, {}))
|
||||
|
||||
const afterPartial = await overseas(context, merchantToken, 'GET', '客户回查部分签收状态',
|
||||
`${RETURN_ORDERS}/${encodeURIComponent(returnNo)}`)
|
||||
@@ -310,7 +305,7 @@ export async function run(context) {
|
||||
await reject('最后一批缺少打托照片拒绝且库存不变', conflict, async () => {
|
||||
let response = null
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
response = await context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, secondBody, {}, picker.token)
|
||||
response = await context.request('POST', `${PDA_RECEIPTS}/confirm`, secondBody, picker.token, {})
|
||||
if (response?.http !== 0 || attempt === 3) break
|
||||
await new Promise((resolve) => setTimeout(resolve, 400 * attempt))
|
||||
}
|
||||
@@ -319,7 +314,7 @@ export async function run(context) {
|
||||
const photoForm = new FormData()
|
||||
// Synthetic HTTP fixture, not a photograph of real warehouse activity.
|
||||
photoForm.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), 'return-receipt-test.png')
|
||||
const photo = await context.requestOverseasMultipart('POST', `${PDA_RECEIPTS}/${encodeURIComponent(returnNo)}/photos/files`, photoForm, picker.token)
|
||||
const photo = await context.requestMultipart('POST', `${PDA_RECEIPTS}/${encodeURIComponent(returnNo)}/photos/files`, photoForm, picker.token)
|
||||
add(context, '上传本轮合成打托测试照片', photo, success(photo))
|
||||
if (!success(photo) || !photo.body?.data?.fileId) throw new Error('打托照片上传失败')
|
||||
const second = await overseas(context, picker.token, 'POST', 'PDA按单SKU完成乙2件签收', `${PDA_RECEIPTS}/confirm`, secondBody)
|
||||
@@ -338,7 +333,7 @@ export async function run(context) {
|
||||
context.createdResources.phase4OverseasReturnReceipt.cartonMarks.push(cartonA, cartonB)
|
||||
|
||||
await reject('已完成退货单不再进入待签收流程', notFound,
|
||||
() => context.requestOverseas('POST', `${PDA_RECEIPTS}/start`, { returnNo }, {}, picker.token))
|
||||
() => context.request('POST', `${PDA_RECEIPTS}/start`, { returnNo }, picker.token, {}))
|
||||
const completedCandidates = await overseas(context, picker.token, 'POST', '完成后SKU候选自动清空',
|
||||
`${PDA_RECEIPTS}/sku-candidates`, { skuCode: skuB })
|
||||
context.assert(MODULE, '完成单不再出现在SKU候选', completedCandidates.response,
|
||||
@@ -358,13 +353,11 @@ export async function run(context) {
|
||||
&& detailBatches.has(firstBatchNo) && detailBatches.has(secondBatchNo),
|
||||
`receipts=${detailReceipts.length}, billingBatches=${detailBatches.size}`, OPTIONS)
|
||||
|
||||
const merchantInventory = await context.requestOverseas('GET',
|
||||
query(RETURN_INVENTORY, { page: 1, size: 20, overseasWarehouseId: warehouse.id, returnNo }),
|
||||
undefined, {}, merchantToken)
|
||||
const merchantInventory = await context.request('GET', query(RETURN_INVENTORY, { page: 1, size: 20, overseasWarehouseId: warehouse.id, returnNo }), undefined, merchantToken, {})
|
||||
add(context, '客户权限可读取自身退货库存', merchantInventory, success(merchantInventory))
|
||||
|
||||
const customerId = id(context.context?.merchantQualificationFixture?.tenantId)
|
||||
const inventory = await overseas(context, context.overseasToken, 'GET', '平台按客户和退货单回查全部退货箱库存',
|
||||
const inventory = await overseas(context, context.token, 'GET', '平台按客户和退货单回查全部退货箱库存',
|
||||
query('/api/platform/v1/last-leg/return-inventory/cartons', {
|
||||
page: 1, size: 20, customerId, overseasWarehouseId: warehouse.id, returnNo,
|
||||
}))
|
||||
@@ -376,7 +369,7 @@ export async function run(context) {
|
||||
&& sourceBatches.size === 2 && sourceBatches.has(firstBatchNo) && sourceBatches.has(secondBatchNo),
|
||||
`cartons=${inventoryRows.length}, quantity=${inventoryRows.reduce((sum, row) => sum + Number(row.totalQuantity || 0), 0)}`, OPTIONS)
|
||||
|
||||
const summary = await overseas(context, context.overseasToken, 'GET', '平台回查退货SKU汇总库存',
|
||||
const summary = await overseas(context, context.token, 'GET', '平台回查退货SKU汇总库存',
|
||||
query('/api/platform/v1/last-leg/return-inventory', {
|
||||
page: 1, size: 20, customerId, overseasWarehouseId: warehouse.id,
|
||||
}))
|
||||
@@ -389,14 +382,14 @@ export async function run(context) {
|
||||
|
||||
for (const [index, expected] of [{ cartonMark: cartonA, batchNo: firstBatchNo, quantity: 3 },
|
||||
{ cartonMark: cartonB, batchNo: secondBatchNo, quantity: 2 }].entries()) {
|
||||
const carton = await overseas(context, context.overseasToken, 'GET', `平台回查第${index + 1}个退货箱明细`,
|
||||
const carton = await overseas(context, context.token, 'GET', `平台回查第${index + 1}个退货箱明细`,
|
||||
`/api/platform/v1/last-leg/return-inventory/cartons/${encodeURIComponent(expected.cartonMark)}`)
|
||||
context.assert(MODULE, `第${index + 1}个退货箱来源费用批次和数量正确`, carton.response,
|
||||
carton.data?.sourceBatchNo === expected.batchNo
|
||||
&& Number(carton.data?.totalQuantity) === expected.quantity
|
||||
&& Array.isArray(carton.data?.items) && carton.data.items.length > 0,
|
||||
`batch=${carton.data?.sourceBatchNo || 'missing'}, quantity=${carton.data?.totalQuantity ?? 'missing'}`, OPTIONS)
|
||||
const label = await overseas(context, context.overseasToken, 'GET', `平台生成第${index + 1}个退货箱标签`,
|
||||
const label = await overseas(context, context.token, 'GET', `平台生成第${index + 1}个退货箱标签`,
|
||||
`/api/platform/v1/last-leg/return-inventory/cartons/${encodeURIComponent(expected.cartonMark)}/label`)
|
||||
context.assert(MODULE, `第${index + 1}个箱标签保留来源批次`, label.response,
|
||||
label.data?.cartonMark === expected.cartonMark && label.data?.sourceBatchNo === expected.batchNo
|
||||
@@ -454,9 +447,7 @@ export async function run(context) {
|
||||
context.assert(MODULE, '合箱重放复用同一新箱且不重复扣减', mergeReplay.response,
|
||||
success(mergeReplay.response) && nonBlank(merged.data?.cartonMark)
|
||||
&& id(mergeReplay.data?.cartonMark) === id(merged.data?.cartonMark), trace(mergeReplay.response), OPTIONS)
|
||||
await reject('合箱同键异载荷返回冲突', conflict, () => context.requestOverseas('POST',
|
||||
'/api/pda/v1/last-leg/return-inventory/cartons/merge',
|
||||
{ ...mergeBody, weight: 3 }, {}, picker.token))
|
||||
await reject('合箱同键异载荷返回冲突', conflict, () => context.request('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge', { ...mergeBody, weight: 3 }, picker.token, {}))
|
||||
|
||||
const beforeInvalid = await overseas(context, merchantToken, 'GET', '冻结合箱异常检查前库存快照',
|
||||
query(`${RETURN_INVENTORY}/cartons`, { page: 1, size: 200, returnNo }))
|
||||
@@ -468,8 +459,7 @@ export async function run(context) {
|
||||
['超可用库存', { items: [{ skuCode: skuA, quantity: 999 }] }, conflict],
|
||||
['不存在来源单', { returnNos: [`MISSING-${suffix}`] }, notFound],
|
||||
]) {
|
||||
await reject(`合箱拒绝${name}`, expected, () => context.requestOverseas('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge',
|
||||
{ ...mergeBody, ...changes, idempotencyKey: key(context, `INVALID-MERGE-${encodeURIComponent(name)}`) }, {}, picker.token))
|
||||
await reject(`合箱拒绝${name}`, expected, () => context.request('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge', { ...mergeBody, ...changes, idempotencyKey: key(context, `INVALID-MERGE-${encodeURIComponent(name)}`) }, picker.token, {}))
|
||||
}
|
||||
await overseas(context, picker.token, 'POST', '异常检查后再重放原合箱请求',
|
||||
'/api/pda/v1/last-leg/return-inventory/cartons/merge', mergeBody)
|
||||
|
||||
@@ -48,8 +48,8 @@ async function merchantLogin(context, fixture) {
|
||||
const response = await context.request('POST', '/api/auth/login', { loginId: fixture.username, password: fixture.password }, '')
|
||||
const auth = success(response) ? response.body.data : null
|
||||
assert(context, '客户账号同时取得国内和海外短时Token', response,
|
||||
Boolean(auth?.accessToken) && Boolean(auth?.overseasAccessToken),
|
||||
`customerId=${fixture.tenantId}, domestic=${Boolean(auth?.accessToken)}, overseas=${Boolean(auth?.overseasAccessToken)}`)
|
||||
Boolean(auth?.accessToken) && Boolean(auth?.accessToken),
|
||||
`customerId=${fixture.tenantId}, domestic=${Boolean(auth?.accessToken)}, overseas=${Boolean(auth?.accessToken)}`)
|
||||
return auth
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ async function prepareWallet(context, customerId) {
|
||||
return success(recharge) ? { before: before.account, funded: recharge.body.data, rechargeAmount, creditLimit } : null
|
||||
}
|
||||
|
||||
async function procurementFlow(context, fixture, overseasToken) {
|
||||
async function procurementFlow(context, fixture, token) {
|
||||
const merchantPath = '/api/merchant/v1/last-leg/overseas-procurements'
|
||||
const payload = {
|
||||
customerName: fixture.tenantName, purchaseCategory: 'OFFICE_SUPPLIES', productName: `${context.marker} 钱包采购`,
|
||||
@@ -105,7 +105,7 @@ async function procurementFlow(context, fixture, overseasToken) {
|
||||
contactName: '全量验收联系人', contactPhone: '13800000002', remark: context.marker,
|
||||
idempotencyKey: `${context.marker}-wallet-procurement`.slice(0, 255),
|
||||
}
|
||||
const created = await context.requestOverseas('POST', merchantPath, payload, {}, overseasToken)
|
||||
const created = await context.request('POST', merchantPath, payload, token, {})
|
||||
const detail = created?.body?.data
|
||||
const orderNo = detail?.summary?.orderNo
|
||||
assert(context, '客户创建钱包采购单', created,
|
||||
@@ -117,7 +117,7 @@ async function procurementFlow(context, fixture, overseasToken) {
|
||||
`orderNo=${orderNo}, expectedPrefix=${fixture.expectedProcurementPrefix || 'custom-customer'}`)
|
||||
|
||||
const customerId = id(fixture.tenantId)
|
||||
const approved = await context.requestOverseas('POST', query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/review`, { customerId }), {
|
||||
const approved = await context.request('POST', query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/review`, { customerId }), {
|
||||
version: Number(detail.summary.version), decision: 'APPROVE',
|
||||
idempotencyKey: `${context.marker}-wallet-procurement-review`.slice(0, 255), remark: context.marker,
|
||||
})
|
||||
@@ -128,7 +128,7 @@ async function procurementFlow(context, fixture, overseasToken) {
|
||||
|
||||
const files = []
|
||||
for (const category of ['VOUCHER', 'RECEIPT']) {
|
||||
const uploaded = await context.requestOverseasMultipart('POST',
|
||||
const uploaded = await context.requestMultipart('POST',
|
||||
query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/files/${category}`, { customerId }),
|
||||
{ content: PNG, type: 'image/png', name: `${category.toLowerCase()}-${orderNo}.png` })
|
||||
assert(context, `采购单上传${category}文件`, uploaded,
|
||||
@@ -138,7 +138,7 @@ async function procurementFlow(context, fixture, overseasToken) {
|
||||
}
|
||||
if (files.length !== 2) return null
|
||||
|
||||
const fee = await context.requestOverseas('POST', query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/fees`, { customerId }), {
|
||||
const fee = await context.request('POST', query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/fees`, { customerId }), {
|
||||
feeItemCode: 'PROCUREMENT_SERVICE', feeItemName: '采购服务费', amount: 0.01,
|
||||
voucherFileId: id(files.find((item) => item.category === 'VOUCHER')?.fileId), remark: context.marker,
|
||||
})
|
||||
@@ -149,7 +149,7 @@ async function procurementFlow(context, fixture, overseasToken) {
|
||||
if (!success(fee) || !feeNo) return null
|
||||
|
||||
const detailPath = query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}`, { customerId })
|
||||
const frozen = await poll(async () => context.requestOverseas('GET', detailPath),
|
||||
const frozen = await poll(async () => context.request('GET', detailPath),
|
||||
(response) => success(response) && response.body.data?.fees?.find((item) => item.feeNo === feeNo)?.walletStatus === 'FROZEN')
|
||||
const frozenFee = frozen.result?.body?.data?.fees?.find((item) => item.feeNo === feeNo)
|
||||
assert(context, '采购费用跨区冻结到账', frozen.result,
|
||||
@@ -157,13 +157,13 @@ async function procurementFlow(context, fixture, overseasToken) {
|
||||
if (frozenFee?.walletStatus !== 'FROZEN') return null
|
||||
|
||||
const current = frozen.result.body.data.summary
|
||||
const settledOrder = await context.requestOverseas('PATCH', detailPath, {
|
||||
const settledOrder = await context.request('PATCH', detailPath, {
|
||||
version: Number(current.version), inboundQuantity: Number(current.purchaseQuantity),
|
||||
})
|
||||
assert(context, '采购全量入库推进待出库并触发结算', settledOrder,
|
||||
success(settledOrder) && settledOrder.body.data?.summary?.status === 'PENDING_OUTBOUND',
|
||||
`status=${settledOrder.body?.data?.summary?.status || 'missing'}`)
|
||||
const settled = await poll(async () => context.requestOverseas('GET', detailPath),
|
||||
const settled = await poll(async () => context.request('GET', detailPath),
|
||||
(response) => success(response) && response.body.data?.fees?.find((item) => item.feeNo === feeNo)?.walletStatus === 'SETTLED')
|
||||
const settledFee = settled.result?.body?.data?.fees?.find((item) => item.feeNo === feeNo)
|
||||
assert(context, '采购费用跨区结算到账', settled.result,
|
||||
@@ -191,10 +191,9 @@ function relabelPayload(context, warehouseId, source, suffix, chargeAmount) {
|
||||
}
|
||||
}
|
||||
|
||||
async function relabelFlow(context, overseasToken, warehouseId, suffix, terminalStatus) {
|
||||
const candidatesResponse = await context.requestOverseas('GET', query(
|
||||
'/api/merchant/v1/last-leg/relabel-applications/inventory-candidates', { operationWarehouseId: warehouseId }),
|
||||
undefined, {}, overseasToken)
|
||||
async function relabelFlow(context, token, warehouseId, suffix, terminalStatus) {
|
||||
const candidatesResponse = await context.request('GET', query(
|
||||
'/api/merchant/v1/last-leg/relabel-applications/inventory-candidates', { operationWarehouseId: warehouseId }), undefined, token, {})
|
||||
const candidates = success(candidatesResponse) && Array.isArray(candidatesResponse.body?.data)
|
||||
? candidatesResponse.body.data : []
|
||||
const currentPda = context.createdResources?.phase4OverseasPda
|
||||
@@ -229,7 +228,7 @@ async function relabelFlow(context, overseasToken, warehouseId, suffix, terminal
|
||||
Boolean(source?.position), `warehouseId=${warehouseId}, candidates=${candidates.length}`)
|
||||
if (!source?.position) return null
|
||||
const payload = relabelPayload(context, warehouseId, source, suffix, 0.01)
|
||||
const created = await context.requestOverseas('POST', '/api/merchant/v1/last-leg/relabel-applications', payload, {}, overseasToken)
|
||||
const created = await context.request('POST', '/api/merchant/v1/last-leg/relabel-applications', payload, token, {})
|
||||
const relabelNo = created?.body?.data?.application?.relabelNo
|
||||
assert(context, `${suffix}换标单创建并携带收费金额`, created,
|
||||
success(created) && Boolean(relabelNo) && money(created.body.data?.application?.chargeAmount) === 0.01,
|
||||
@@ -237,7 +236,7 @@ async function relabelFlow(context, overseasToken, warehouseId, suffix, terminal
|
||||
if (!success(created) || !relabelNo) return null
|
||||
|
||||
const detailPath = `${RELABEL}/${encodeURIComponent(relabelNo)}`
|
||||
const frozen = await poll(async () => context.requestOverseas('GET', detailPath),
|
||||
const frozen = await poll(async () => context.request('GET', detailPath),
|
||||
(response) => success(response) && response.body.data?.application?.walletStatus === 'FROZEN')
|
||||
assert(context, `${suffix}换标费用跨区冻结到账`, frozen.result,
|
||||
frozen.result?.body?.data?.application?.walletStatus === 'FROZEN',
|
||||
@@ -245,32 +244,29 @@ async function relabelFlow(context, overseasToken, warehouseId, suffix, terminal
|
||||
if (frozen.result?.body?.data?.application?.walletStatus !== 'FROZEN') return null
|
||||
|
||||
const reviewStatus = terminalStatus === 'COMPLETED' ? 'PENDING_PROCESSING' : 'CANCELLED'
|
||||
const review = await context.requestOverseas('PUT', query(
|
||||
const review = await context.request('PUT', query(
|
||||
`/api/platform/v1/last-leg/orders/${encodeURIComponent(id(created.body.data.application.lastlegOrderId))}/relabel-review`,
|
||||
{ customerId: id(context.context.merchantQualificationFixture.tenantId) }),
|
||||
{ status: reviewStatus, remark: context.marker })
|
||||
{ customerId: id(context.context.merchantQualificationFixture.tenantId) }), { status: reviewStatus, remark: context.marker })
|
||||
assert(context, `${suffix}尾程订单审核直接贴标申请`, review,
|
||||
success(review) && review.body.data?.application?.status === reviewStatus)
|
||||
if (!success(review)) return null
|
||||
|
||||
if (terminalStatus === 'COMPLETED') {
|
||||
const processing = await context.requestOverseas('PUT', `${detailPath}/status`, { status: 'PROCESSING', remark: context.marker })
|
||||
const processing = await context.request('PUT', `${detailPath}/status`, { status: 'PROCESSING', remark: context.marker })
|
||||
assert(context, '换标单冻结后才允许进入处理中', processing,
|
||||
success(processing) && processing.body.data?.application?.status === 'PROCESSING')
|
||||
const confirmPending = await context.requestOverseas('PUT', `${detailPath}/status`,
|
||||
{ status: 'CUSTOMER_CONFIRM_PENDING', remark: context.marker })
|
||||
const confirmPending = await context.request('PUT', `${detailPath}/status`, { status: 'CUSTOMER_CONFIRM_PENDING', remark: context.marker })
|
||||
assert(context, '换标处理完成后进入客户确认', confirmPending,
|
||||
success(confirmPending) && confirmPending.body.data?.application?.status === 'CUSTOMER_CONFIRM_PENDING')
|
||||
const readyForOutbound = await context.requestOverseas('PUT', `${detailPath}/status`,
|
||||
{ status: 'READY_FOR_OUTBOUND', remark: context.marker })
|
||||
const readyForOutbound = await context.request('PUT', `${detailPath}/status`, { status: 'READY_FOR_OUTBOUND', remark: context.marker })
|
||||
assert(context, '换标客户确认后进入待出库', readyForOutbound,
|
||||
success(readyForOutbound) && readyForOutbound.body.data?.application?.status === 'READY_FOR_OUTBOUND')
|
||||
}
|
||||
const terminal = terminalStatus === 'CANCELLED' ? review : await context.requestOverseas('PUT', `${detailPath}/status`, { status: terminalStatus, remark: context.marker })
|
||||
const terminal = terminalStatus === 'CANCELLED' ? review : await context.request('PUT', `${detailPath}/status`, { status: terminalStatus, remark: context.marker })
|
||||
assert(context, `${suffix}换标单进入${terminalStatus}`, terminal,
|
||||
success(terminal) && terminal.body.data?.application?.status === terminalStatus)
|
||||
const walletTerminal = terminalStatus === 'COMPLETED' ? 'SETTLED' : 'RELEASED'
|
||||
const wallet = await poll(async () => context.requestOverseas('GET', detailPath),
|
||||
const wallet = await poll(async () => context.request('GET', detailPath),
|
||||
(response) => success(response) && response.body.data?.application?.walletStatus === walletTerminal)
|
||||
assert(context, `${suffix}换标费用跨区${walletTerminal}`, wallet.result,
|
||||
wallet.result?.body?.data?.application?.walletStatus === walletTerminal,
|
||||
@@ -308,14 +304,13 @@ async function verifyPalletSummaries(context, warehouseId) {
|
||||
success(domestic) && summaryValid(d, ['totalCount', 'idleCount', 'occupiedCount'])
|
||||
&& Number(d.totalCount) === Number(d.idleCount) + Number(d.occupiedCount),
|
||||
`total=${d?.totalCount}, idle=${d?.idleCount}, occupied=${d?.occupiedCount}`)
|
||||
const overseas = await context.requestOverseas('GET', '/api/platform/v1/warehouse-admin/pallets/summary?page=1&size=20')
|
||||
const overseas = await context.request('GET', '/api/platform/v1/warehouse-admin/pallets/summary?page=1&size=20')
|
||||
const o = overseas?.body?.data
|
||||
assert(context, '海外托盘汇总含闲置、占用和报废数量', overseas,
|
||||
success(overseas) && summaryValid(o, ['totalCount', 'idleCount', 'occupiedCount', 'scrappedCount'])
|
||||
&& Number(o.totalCount) === Number(o.idleCount) + Number(o.occupiedCount) + Number(o.scrappedCount),
|
||||
`total=${o?.totalCount}, idle=${o?.idleCount}, occupied=${o?.occupiedCount}, scrapped=${o?.scrappedCount}`)
|
||||
const warehouse = await context.requestOverseas('GET',
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/summary?page=1&size=20`)
|
||||
const warehouse = await context.request('GET', `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets/summary?page=1&size=20`)
|
||||
assert(context, '海外仓级托盘汇总可用', warehouse,
|
||||
success(warehouse) && summaryValid(warehouse.body.data, ['totalCount', 'idleCount', 'occupiedCount', 'scrappedCount']))
|
||||
}
|
||||
@@ -324,7 +319,7 @@ async function verifyDestuffCosts(context, completedFacts) {
|
||||
const evidence = []
|
||||
for (const fact of completedFacts) {
|
||||
const businessNo = `DESTUFF-${fact.containerBookingId}`
|
||||
const lastleg = await poll(async () => context.requestOverseas('GET', query('/api/platform/v1/last-leg/finance-fee-sources', {
|
||||
const lastleg = await poll(async () => context.request('GET', query('/api/platform/v1/last-leg/finance-fee-sources', {
|
||||
businessType: 'OVERSEAS_CONTAINER', businessNo,
|
||||
})), (response) => success(response) && rows(response.body.data).length > 0, 75)
|
||||
const sources = success(lastleg.result) ? rows(lastleg.result.body.data) : []
|
||||
@@ -363,9 +358,9 @@ export async function run(context) {
|
||||
|
||||
const auth = await merchantLogin(context, fixture)
|
||||
const wallet = auth ? await prepareWallet(context, id(fixture.tenantId)) : null
|
||||
const procurement = wallet ? await procurementFlow(context, fixture, auth.overseasAccessToken) : null
|
||||
const settledRelabel = procurement ? await relabelFlow(context, auth.overseasAccessToken, warehouseId, 'SETTLE', 'COMPLETED') : null
|
||||
const releasedRelabel = settledRelabel ? await relabelFlow(context, auth.overseasAccessToken, warehouseId, 'RELEASE', 'CANCELLED') : null
|
||||
const procurement = wallet ? await procurementFlow(context, fixture, auth.accessToken) : null
|
||||
const settledRelabel = procurement ? await relabelFlow(context, auth.accessToken, warehouseId, 'SETTLE', 'COMPLETED') : null
|
||||
const releasedRelabel = settledRelabel ? await relabelFlow(context, auth.accessToken, warehouseId, 'RELEASE', 'CANCELLED') : null
|
||||
const relabels = [settledRelabel, releasedRelabel].filter(Boolean)
|
||||
const finalAccount = procurement && relabels.length === 2
|
||||
? await verifyClientWallet(context, auth.accessToken, fixture, wallet, procurement, relabels) : null
|
||||
|
||||
@@ -16,22 +16,15 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou
|
||||
const calls = []; const steps = []; const gaps = []; let createdPallet = null; let createdLocation = null
|
||||
const context = {
|
||||
context: {}, createdResources: {}, marker: 'ACC-PHASE4-TEST', calls, steps, gaps,
|
||||
async request(method, requestPath, body) {
|
||||
calls.push({ channel: 'domestic', method, path: requestPath, body })
|
||||
if (requestPath.startsWith('/api/platform/v1/iam/tenants?')) return response(page(tenants))
|
||||
if (method === 'GET' && requestPath.startsWith('/api/platform/v1/warehouse-admin?')) return response(page(warehouseRecords))
|
||||
if (method === 'GET' && requestPath.includes('/warehouse-admin/20001?')) return response({ warehouse: { id: '20001', customerId: '0', warehouseType: 'OVERSEAS', status: 'ACTIVE' } })
|
||||
if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001')
|
||||
if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) return response(null)
|
||||
throw new Error(`Unexpected domestic path: ${requestPath}`)
|
||||
},
|
||||
async requestOverseas(method, requestPath, body) {
|
||||
async request(...args) {
|
||||
const route = /\/(?:last-leg|overseas-warehouse-resources)(?:\/|\?|$)/.test(String(args[1]));
|
||||
const handler = route ? async function(method,requestPath,body) {
|
||||
calls.push({ channel: 'overseas', method, path: requestPath, body })
|
||||
if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/locations')) { createdLocation = { id: '72002', customerId: '0', warehouseId: '20001', ...body }; return response('72002') }
|
||||
if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = { ...createdLocation, ...body }; return voidResponse() }
|
||||
if (method === 'DELETE' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = null; return voidResponse() }
|
||||
if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/pallets')) { createdPallet = { id: '71001', customerId: '0', warehouseId: '20001', locationId: '72001', version: 0, ...body }; return response('71001') }
|
||||
if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) {
|
||||
if (method === 'POST' && requestPath.endsWith('/overseas-warehouse-resources/20001/locations')) { createdLocation = { id: '72002', customerId: '0', warehouseId: '20001', ...body }; return response('72002') }
|
||||
if (method === 'PUT' && requestPath.endsWith('/overseas-warehouse-resources/20001/locations/72002')) { createdLocation = { ...createdLocation, ...body }; return voidResponse() }
|
||||
if (method === 'DELETE' && requestPath.endsWith('/overseas-warehouse-resources/20001/locations/72002')) { createdLocation = null; return voidResponse() }
|
||||
if (method === 'POST' && requestPath.endsWith('/overseas-warehouse-resources/20001/pallets')) { createdPallet = { id: '71001', customerId: '0', warehouseId: '20001', locationId: '72001', version: 0, ...body }; return response('71001') }
|
||||
if (method === 'PUT' && requestPath.endsWith('/overseas-warehouse-resources/20001/pallets/71001')) {
|
||||
if (Number(body.version) !== Number(createdPallet.version)) return response(null, 'A0409')
|
||||
createdPallet = { ...createdPallet, ...body, version: createdPallet.version + 1 }; return voidResponse()
|
||||
}
|
||||
@@ -45,16 +38,27 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou
|
||||
if (requestPath.includes('/inventory/balances?')) return response(page([{ id: '60001', customerId: '10001', skuCode: 'SKU-1', totalPackages: 10, availablePackages: 10, updatedAt: '2026-08-21T10:00:00' }]))
|
||||
if (requestPath.includes('/inventory/balances/60001/positions?')) return response([{ id: '61001', balanceId: '60001', totalPackages: 10 }])
|
||||
if (requestPath.includes('/inventory/ledger?')) return response(page([{ id: '62001', customerId: '10001', businessType: 'STOCK_IN', beforePackages: 0, deltaPackages: 10, afterPackages: 10, occurredAt: '2026-08-21T10:00:00' }]))
|
||||
if (requestPath.endsWith('/warehouse-admin/20001/locations?page=1&size=20')) return response(page([{ id: '72001', customerId: '0', warehouseId: '20001', locationCode: 'A-01', locationType: 'STORAGE', maxLoadKg: 1000, status: 'ACTIVE' }]))
|
||||
if (requestPath.endsWith('/warehouse-admin/20001/locations/72001')) return response({ id: '72001', customerId: '0', warehouseId: '20001', locationCode: 'A-01', status: 'ACTIVE' })
|
||||
if (requestPath.endsWith('/warehouse-admin/20001/locations/72002')) return createdLocation == null
|
||||
if (requestPath.endsWith('/overseas-warehouse-resources/20001/locations?page=1&size=20')) return response(page([{ id: '72001', customerId: '0', warehouseId: '20001', locationCode: 'A-01', locationType: 'STORAGE', maxLoadKg: 1000, status: 'ACTIVE' }]))
|
||||
if (requestPath.endsWith('/overseas-warehouse-resources/20001/locations/72001')) return response({ id: '72001', customerId: '0', warehouseId: '20001', locationCode: 'A-01', status: 'ACTIVE' })
|
||||
if (requestPath.endsWith('/overseas-warehouse-resources/20001/locations/72002')) return createdLocation == null
|
||||
? response(undefined, 'A0404') : response(createdLocation)
|
||||
if (requestPath.endsWith('/warehouse-admin/20001/pallets?page=1&size=20')) return response(page([{ id: '70001', customerId: '0', warehouseId: '20001', palletCode: 'PLT-1', status: 'IDLE' }]))
|
||||
if (requestPath.endsWith('/warehouse-admin/20001/pallets/70001')) return response({ id: '70001', customerId: '0', warehouseId: '20001' })
|
||||
if (requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) return response(createdPallet)
|
||||
if (requestPath.endsWith('/overseas-warehouse-resources/20001/pallets?page=1&size=20')) return response(page([{ id: '70001', customerId: '0', warehouseId: '20001', palletCode: 'PLT-1', status: 'IDLE' }]))
|
||||
if (requestPath.endsWith('/overseas-warehouse-resources/20001/pallets/70001')) return response({ id: '70001', customerId: '0', warehouseId: '20001' })
|
||||
if (requestPath.endsWith('/overseas-warehouse-resources/20001/pallets/71001')) return response(createdPallet)
|
||||
throw new Error(`Unexpected path: ${requestPath}`)
|
||||
},
|
||||
async requestOverseasBinary(method, requestPath) { calls.push({ channel: 'overseas-binary', method, path: requestPath }); return { http: 200, bytes: Buffer.from('id,status\n'), contentType: 'text/csv', requestId: 'request-binary', traceId: 'trace-binary' } },
|
||||
} : async function(method,requestPath,body) {
|
||||
calls.push({ channel: 'domestic', method, path: requestPath, body })
|
||||
if (requestPath.startsWith('/api/platform/v1/iam/tenants?')) return response(page(tenants))
|
||||
if (method === 'GET' && requestPath.startsWith('/api/platform/v1/warehouse-admin?')) return response(page(warehouseRecords))
|
||||
if (method === 'GET' && requestPath.includes('/warehouse-admin/20001?')) return response({ warehouse: { id: '20001', customerId: '0', warehouseType: 'OVERSEAS', status: 'ACTIVE' } })
|
||||
if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001')
|
||||
if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) return response(null)
|
||||
throw new Error(`Unexpected domestic path: ${requestPath}`)
|
||||
};
|
||||
return handler.apply(this,args);
|
||||
},
|
||||
|
||||
async requestBinary(method, requestPath) { calls.push({ channel: 'overseas-binary', method, path: requestPath }); return { http: 200, bytes: Buffer.from('id,status\n'), contentType: 'text/csv', requestId: 'request-binary', traceId: 'trace-binary' } },
|
||||
apiSuccess(_module, _name, result) { return result?.http === 200 && String(result?.body?.code) === '200' ? result.body.data : null },
|
||||
assert(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed: Boolean(passed), evidence, options }) },
|
||||
addStep(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed: Boolean(passed), evidence, options }) },
|
||||
@@ -65,10 +69,10 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou
|
||||
|
||||
test('模块只依赖注入请求,不读取环境或自行联网', () => {
|
||||
assert.match(source, /export async function run\(context\)/)
|
||||
assert.match(source, /context\.requestOverseas\('GET'/)
|
||||
assert.match(source, /context\.requestOverseasBinary\('GET'/)
|
||||
assert.match(source, /context\.request\('GET'/)
|
||||
assert.match(source, /context\.requestBinary\('GET'/)
|
||||
assert.doesNotMatch(source, /https?:\/\//)
|
||||
assert.doesNotMatch(source, /process\.env|fetch\(|requestBinary/)
|
||||
assert.doesNotMatch(source, /process\.env|fetch\(/)
|
||||
})
|
||||
|
||||
test('既有仓库场景只写本轮库位与托盘生命周期', () => {
|
||||
@@ -83,9 +87,9 @@ test('既有仓库场景只写本轮库位与托盘生命周期', () => {
|
||||
assert.ok(context.calls.filter((call) => call.channel === 'overseas').every((call) => call.path.startsWith('/api/platform/v1/')))
|
||||
const writes = context.calls.filter((call) => call.method !== 'GET')
|
||||
assert.ok(writes.some((call) => call.method === 'DELETE' && call.path.includes('/locations/72002')))
|
||||
assert.ok(writes.some((call) => call.path.includes('/warehouse-admin/20001/pallets')))
|
||||
assert.ok(writes.some((call) => call.path.includes('/overseas-warehouse-resources/20001/pallets')))
|
||||
assert.equal(writes.some((call) => call.path.includes('/relabel-applications')), false)
|
||||
assert.ok(writes.every((call) => call.path.includes('/warehouse-admin/20001/')))
|
||||
assert.ok(writes.every((call) => call.path.includes('/overseas-warehouse-resources/20001/')))
|
||||
for (const name of ['编辑本轮公共库位', '删除本轮公共库位', '报废本轮公共托盘']) {
|
||||
const step = context.steps.find((item) => item.name === name)
|
||||
assert.ok(step?.passed, name)
|
||||
|
||||
@@ -31,12 +31,12 @@ async function domesticGet(context, name, path) {
|
||||
return { response, data: context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) }
|
||||
}
|
||||
async function overseasGet(context, name, path) {
|
||||
const response = await context.requestOverseas('GET', path)
|
||||
const response = await context.request('GET', path)
|
||||
assertEnvelope(context, name, response)
|
||||
return { response, data: context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) }
|
||||
}
|
||||
async function overseasMutation(context, method, name, path, body) {
|
||||
const response = await context.requestOverseas(method, path, body)
|
||||
const response = await context.request(method, path, body)
|
||||
const passed = response?.http === 200 && String(response?.body?.code) === '200'
|
||||
context.addStep(MODULE, name, response, passed, trace(response), OPTIONS)
|
||||
return { response, data: passed ? response.body?.data : null }
|
||||
@@ -48,7 +48,7 @@ async function domesticMutation(context, method, name, path, body) {
|
||||
return { response, data: passed ? response.body?.data : null }
|
||||
}
|
||||
async function overseasCsv(context, name, path) {
|
||||
const response = await context.requestOverseasBinary('GET', path)
|
||||
const response = await context.requestBinary('GET', path)
|
||||
context.addStep(MODULE, name, response,
|
||||
response?.http === 200 && Number(response?.bytes?.length) > 0 && String(response?.contentType || '').toLowerCase().includes('csv'),
|
||||
`bytes=${response?.bytes?.length ?? 0}, contentType=${response?.contentType || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`, OPTIONS)
|
||||
@@ -164,7 +164,7 @@ export async function run(context) {
|
||||
const warehouseId = id(warehouse.id)
|
||||
context.createdResources.phase4OverseasWarehouse.customerId = customerId
|
||||
context.createdResources.phase4OverseasWarehouse.warehouseId = warehouseId
|
||||
const locations = await overseasGet(context, '查询公共库位列表', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, { page: 1, size: 20 }))
|
||||
const locations = await overseasGet(context, '查询公共库位列表', query(`/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/locations`, { page: 1, size: 20 }))
|
||||
assertPage(context, '公共库位列表', locations)
|
||||
const location = records(locations.data)[0]
|
||||
if (location) {
|
||||
@@ -172,12 +172,12 @@ export async function run(context) {
|
||||
longId(location.id) && id(location.warehouseId) === warehouseId && id(location.customerId) === '0'
|
||||
&& nonBlank(location.locationCode) && nonBlank(location.status),
|
||||
`id=${id(location.id) || 'missing'}, customerId=${id(location.customerId) || 'missing'}, status=${location.status || 'missing'}; ${trace(locations.response)}`, OPTIONS)
|
||||
const detail = await overseasGet(context, '查询公共库位详情', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(id(location.id))}`)
|
||||
const detail = await overseasGet(context, '查询公共库位详情', `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(id(location.id))}`)
|
||||
context.assert(MODULE, '公共库位详情对应列表记录', detail.response,
|
||||
id(detail.data?.id) === id(location.id) && id(detail.data?.warehouseId) === warehouseId && id(detail.data?.customerId) === '0',
|
||||
`listId=${id(location.id)}, detailId=${id(detail.data?.id) || 'missing'}; ${trace(detail.response)}`, OPTIONS)
|
||||
}
|
||||
const pallets = await overseasGet(context, '查询公共托盘列表', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, { page: 1, size: 20 }))
|
||||
const pallets = await overseasGet(context, '查询公共托盘列表', query(`/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets`, { page: 1, size: 20 }))
|
||||
assertPage(context, '公共托盘列表', pallets)
|
||||
const pallet = records(pallets.data)[0]
|
||||
if (pallet) {
|
||||
@@ -185,7 +185,7 @@ export async function run(context) {
|
||||
longId(pallet.id) && id(pallet.warehouseId) === warehouseId && id(pallet.customerId) === '0'
|
||||
&& nonBlank(pallet.palletCode) && ['IDLE', 'OCCUPIED', 'SCRAPPED'].includes(pallet.status),
|
||||
`id=${id(pallet.id) || 'missing'}, customerId=${id(pallet.customerId) || 'missing'}, status=${pallet.status || 'missing'}; ${trace(pallets.response)}`, OPTIONS)
|
||||
const detail = await overseasGet(context, '查询公共托盘详情', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(id(pallet.id))}`)
|
||||
const detail = await overseasGet(context, '查询公共托盘详情', `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(id(pallet.id))}`)
|
||||
context.assert(MODULE, '公共托盘详情对应列表记录', detail.response,
|
||||
id(detail.data?.id) === id(pallet.id) && id(detail.data?.warehouseId) === warehouseId && id(detail.data?.customerId) === '0',
|
||||
`listId=${id(pallet.id)}, detailId=${id(detail.data?.id) || 'missing'}; ${trace(detail.response)}`, OPTIONS)
|
||||
@@ -209,7 +209,7 @@ async function verifyLocationLifecycle(context, warehouseId) {
|
||||
let locationId = ''
|
||||
try {
|
||||
const created = await overseasMutation(context, 'POST', '创建本轮公共库位',
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, {
|
||||
`/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/locations`, {
|
||||
locationCode, locationType: 'STORAGE', maxLoadKg: 1000,
|
||||
status: 'ACTIVE', remark: context.marker,
|
||||
})
|
||||
@@ -218,7 +218,7 @@ async function verifyLocationLifecycle(context, warehouseId) {
|
||||
context.assert(MODULE, '公共库位创建返回长ID', created.response, longId(locationId),
|
||||
`locationId=${locationId || 'missing'}, marker=${context.marker}; ${trace(created.response)}`, OPTIONS)
|
||||
if (!locationId) return
|
||||
const detailPath = `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}`
|
||||
const detailPath = `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}`
|
||||
const detail = await overseasGet(context, '查询本轮公共库位详情', detailPath)
|
||||
context.assert(MODULE, '本轮公共库位创建事实正确', detail.response,
|
||||
id(detail.data?.id) === locationId && id(detail.data?.customerId) === '0'
|
||||
@@ -237,9 +237,8 @@ async function verifyLocationLifecycle(context, warehouseId) {
|
||||
} finally {
|
||||
if (locationId) {
|
||||
const removed = await overseasMutation(context, 'DELETE', '删除本轮公共库位',
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}`)
|
||||
const deletedDetail = await context.requestOverseas('GET',
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}`)
|
||||
`/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}`)
|
||||
const deletedDetail = await context.request('GET', `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}`)
|
||||
const deleted = voidMutationSucceeded(removed) && businessNotFound(deletedDetail)
|
||||
context.createdResources.phase4OverseasWarehouse.locationTerminalStatus = deleted ? 'DELETED' : null
|
||||
context.assert(MODULE, '本轮公共库位已删除且回查不存在', deletedDetail, deleted,
|
||||
@@ -287,7 +286,7 @@ async function verifyPalletLifecycle(context, warehouseId) {
|
||||
let palletId = ''
|
||||
try {
|
||||
const created = await overseasMutation(context, 'POST', '创建本轮公共托盘',
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, {
|
||||
`/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets`, {
|
||||
palletCode, palletType: '木托盘', capacityCbm: 2, unitPrice: 0, status: 'IDLE', remark: context.marker,
|
||||
})
|
||||
palletId = id(created.data)
|
||||
@@ -296,7 +295,7 @@ async function verifyPalletLifecycle(context, warehouseId) {
|
||||
`palletId=${palletId || 'missing'}, marker=${context.marker}; ${trace(created.response)}`, OPTIONS)
|
||||
if (!palletId) return
|
||||
|
||||
const detailPath = `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`
|
||||
const detailPath = `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`
|
||||
const detail = await overseasGet(context, '查询本轮公共托盘详情', detailPath)
|
||||
context.assert(MODULE, '本轮公共托盘创建事实正确', detail.response,
|
||||
id(detail.data?.id) === palletId && id(detail.data?.warehouseId) === warehouseId
|
||||
@@ -314,7 +313,7 @@ async function verifyPalletLifecycle(context, warehouseId) {
|
||||
id(after.data?.id) === palletId && Number(after.data?.capacityCbm) === 2.5
|
||||
&& Number(after.data?.unitPrice) === 1 && after.data?.remark === `${context.marker}-edited`,
|
||||
`capacity=${after.data?.capacityCbm ?? 'missing'}, unitPrice=${after.data?.unitPrice ?? 'missing'}, remark=${after.data?.remark || 'missing'}; ${trace(after.response)}`, OPTIONS)
|
||||
const stale = await context.requestOverseas('PUT', detailPath, {
|
||||
const stale = await context.request('PUT', detailPath, {
|
||||
palletCode, palletType: '木托盘', capacityCbm: 9, unitPrice: 9, status: 'IDLE',
|
||||
remark: 'STALE-DO-NOT-APPLY', version: Number(detail.data?.version),
|
||||
})
|
||||
@@ -323,17 +322,17 @@ async function verifyPalletLifecycle(context, warehouseId) {
|
||||
} finally {
|
||||
if (palletId) {
|
||||
const retired = await overseasMutation(context, 'PUT', '报废本轮公共托盘',
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`, {
|
||||
`/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`, {
|
||||
palletCode, palletType: '木托盘', capacityCbm: 2.5, unitPrice: 1, status: 'SCRAPPED',
|
||||
remark: `${context.marker}-acceptance-retired`, version: Number((await overseasGet(context,
|
||||
'读取公共托盘报废前最新版本',
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`)).data?.version),
|
||||
`/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`)).data?.version),
|
||||
})
|
||||
context.createdResources.phase4OverseasWarehouse.terminalStatus = voidMutationSucceeded(retired) ? 'SCRAPPED' : null
|
||||
context.assert(MODULE, '本轮公共托盘已报废收口', retired.response, voidMutationSucceeded(retired),
|
||||
`palletId=${palletId}, terminalStatus=${context.createdResources.phase4OverseasWarehouse.terminalStatus || 'missing'}; ${trace(retired.response)}`, OPTIONS)
|
||||
const retiredDetail = await overseasGet(context, '回查本轮公共托盘报废状态',
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`)
|
||||
`/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`)
|
||||
context.assert(MODULE, '本轮公共托盘终态为已报废', retiredDetail.response,
|
||||
id(retiredDetail.data?.id) === palletId && retiredDetail.data?.status === 'SCRAPPED',
|
||||
`palletId=${id(retiredDetail.data?.id) || 'missing'}, status=${retiredDetail.data?.status || 'missing'}; ${trace(retiredDetail.response)}`, OPTIONS)
|
||||
|
||||
@@ -42,7 +42,7 @@ test('module covers the public tail-order write surface before PDA receiving', (
|
||||
]) assert.ok(source.includes(evidence), `missing ${evidence}`)
|
||||
assert.match(source, /nodes\.data\?\.nodes\?\.length === 8/)
|
||||
assert.match(source, /creationMode === 'AUTO'/)
|
||||
assert.match(source, /requestOverseasBinary\('GET'/)
|
||||
assert.match(source, /requestBinary\('GET'/)
|
||||
assert.match(source, /palletMergeBatchId/)
|
||||
assert.match(source, /feeItemCode/)
|
||||
assert.match(source, /feeItemName/)
|
||||
|
||||
@@ -26,7 +26,7 @@ function query(path, values) {
|
||||
return params.size ? `${path}?${params}` : path
|
||||
}
|
||||
async function api(context, method, name, path, body, headers = {}) {
|
||||
const response = await context.requestOverseas(method, path, body, headers, context.overseasToken)
|
||||
const response = await context.request(method, path, body, context.token, headers)
|
||||
const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS)
|
||||
return { response, data }
|
||||
}
|
||||
@@ -123,12 +123,9 @@ async function verifyReceivingWarehouseChange(context, producer, details) {
|
||||
context.assert(MODULE, '接柜仓变更重放版本零漂移', replay.response,
|
||||
Number(replay.data?.assignmentVersion) === Number(changed.data?.assignmentVersion)
|
||||
&& id(replay.data?.overseasWarehouseId) === id(alternate.id), trace(replay.response), OPTIONS)
|
||||
const hashConflict = await context.requestOverseas('PUT', changePath, { ...changeBody, remark: `${context.marker}-DIFFERENT` },
|
||||
{ 'Idempotency-Key': changeKey }, context.overseasToken)
|
||||
const hashConflict = await context.request('PUT', changePath, { ...changeBody, remark: `${context.marker}-DIFFERENT` }, context.token, { 'Idempotency-Key': changeKey })
|
||||
context.addStep(MODULE, '接柜仓变更同键异载荷返回A0409', hashConflict, conflict(hashConflict), trace(hashConflict), OPTIONS)
|
||||
const stale = await context.requestOverseas('PUT', changePath,
|
||||
{ overseasWarehouseId: originalWarehouseId, expectedVersion: Number(summary.assignmentVersion), remark: context.marker },
|
||||
{ 'Idempotency-Key': `${context.marker}-receiving-warehouse-stale`.slice(0, 128) }, context.overseasToken)
|
||||
const stale = await context.request('PUT', changePath, { overseasWarehouseId: originalWarehouseId, expectedVersion: Number(summary.assignmentVersion), remark: context.marker }, context.token, { 'Idempotency-Key': `${context.marker}-receiving-warehouse-stale`.slice(0, 128) })
|
||||
context.addStep(MODULE, '接柜仓变更旧版本返回A0409', stale, conflict(stale), trace(stale), OPTIONS)
|
||||
const restored = await api(context, 'PUT', '整柜恢复原公共接柜仓', changePath,
|
||||
{ overseasWarehouseId: originalWarehouseId, expectedVersion: Number(changed.data.assignmentVersion), remark: `${context.marker}-restore` },
|
||||
@@ -210,7 +207,7 @@ async function verifyDestinationMergeFeeAndExport(context, details, producer) {
|
||||
&& row?.originalCurrency === 'USD' && Number(row?.originalAmount) === 0.01
|
||||
&& row?.currency === 'CNY' && Number(row?.amount) > 0), trace(feeRead.response), OPTIONS)
|
||||
|
||||
const exported = await context.requestOverseasBinary('GET', query(`${ORDERS}/export`, {
|
||||
const exported = await context.requestBinary('GET', query(`${ORDERS}/export`, {
|
||||
customerId: id(representative.customerId), containerNo: producer.containerNo,
|
||||
}))
|
||||
const csv = new TextDecoder().decode(exported.bytes || new Uint8Array())
|
||||
@@ -309,7 +306,7 @@ async function convertContainerPlans(context, producer, details) {
|
||||
|
||||
for (const { plan, pallet } of pallets) {
|
||||
const line = plan.lines[0]
|
||||
const palletPath = query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(id(preview.data.overseasWarehouseId))}/pallets`, {
|
||||
const palletPath = query(`/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(id(preview.data.overseasWarehouseId))}/pallets`, {
|
||||
page: 1, size: 10, keyword: pallet.palletNo,
|
||||
})
|
||||
const listed = await api(context, 'GET', `回查自动托盘${pallet.palletNo}`, palletPath)
|
||||
@@ -357,13 +354,11 @@ export async function verifyReceivedOrderHold(context) {
|
||||
const heldReplay = await api(context, 'PUT', '暂扣命令同键同载荷重放', orderPath, holdBody, { 'Idempotency-Key': holdKey })
|
||||
context.assert(MODULE, '暂扣重放版本零漂移', heldReplay.response,
|
||||
JSON.stringify(heldReplay.data) === JSON.stringify(held.data), trace(heldReplay.response), OPTIONS)
|
||||
const heldConflict = await context.requestOverseas('PUT', orderPath,
|
||||
{ ...holdBody, reason: `${holdBody.reason}-DIFFERENT` }, { 'Idempotency-Key': holdKey }, context.overseasToken)
|
||||
const heldConflict = await context.request('PUT', orderPath, { ...holdBody, reason: `${holdBody.reason}-DIFFERENT` }, context.token, { 'Idempotency-Key': holdKey })
|
||||
context.addStep(MODULE, '暂扣同键异载荷返回A0409', heldConflict, conflict(heldConflict), trace(heldConflict), OPTIONS)
|
||||
|
||||
const blockedPlanBody = manualPlanBody(representative, held.data.version, context.marker)
|
||||
const blockedPlan = await context.requestOverseas('POST', `${PLANS}/manual`, blockedPlanBody,
|
||||
{ 'Idempotency-Key': `${context.marker}-held-plan`.slice(0, 128) }, context.overseasToken)
|
||||
const blockedPlan = await context.request('POST', `${PLANS}/manual`, blockedPlanBody, context.token, { 'Idempotency-Key': `${context.marker}-held-plan`.slice(0, 128) })
|
||||
context.addStep(MODULE, '暂扣尾程订单不能生成人工计划', blockedPlan, conflict(blockedPlan), trace(blockedPlan), OPTIONS)
|
||||
|
||||
const releaseKey = `${context.marker}-tail-release`.slice(0, 128)
|
||||
@@ -412,10 +407,8 @@ export async function run(context) {
|
||||
refreshedDetails.push(refreshed.data.order)
|
||||
}
|
||||
const representative = refreshedDetails[0]
|
||||
const rejectedHold = await context.requestOverseas('PUT', query(
|
||||
`${ORDERS}/${encodeURIComponent(id(representative.id))}/hold`, { customerId: id(representative.customerId) }),
|
||||
{ held: true, chargeable: true, reason: `${context.marker} 入库前禁止暂扣`, expectedVersion: Number(representative.version) },
|
||||
{ 'Idempotency-Key': `${context.marker}-pre-receipt-hold` })
|
||||
const rejectedHold = await context.request('PUT', query(
|
||||
`${ORDERS}/${encodeURIComponent(id(representative.id))}/hold`, { customerId: id(representative.customerId) }), { held: true, chargeable: true, reason: `${context.marker} 入库前禁止暂扣`, expectedVersion: Number(representative.version) }, undefined, { 'Idempotency-Key': `${context.marker}-pre-receipt-hold` })
|
||||
context.addStep(MODULE, '入库前暂扣必须拒绝', rejectedHold, conflict(rejectedHold), trace(rejectedHold), OPTIONS)
|
||||
const afterHold = await detail(context, representative, '入库前暂扣拒绝后回查订单')
|
||||
const unchanged = success(afterHold.response) && JSON.stringify(afterHold.data?.order) === JSON.stringify(representative)
|
||||
@@ -440,9 +433,7 @@ export async function run(context) {
|
||||
{ 'Idempotency-Key': nodesKey })
|
||||
context.assert(MODULE, '人工节点重放版本与八行快照零漂移', nodesReplay.response,
|
||||
JSON.stringify(nodesReplay.data) === JSON.stringify(nodes.data), trace(nodesReplay.response), OPTIONS)
|
||||
const nodesConflict = await context.requestOverseas('PUT', nodesPath,
|
||||
{ ...nodesBody, nodes: nodesBody.nodes.map((node, index) => index === 0 ? { ...node, nodeAt: localIso(new Date()) } : node) },
|
||||
{ 'Idempotency-Key': nodesKey }, context.overseasToken)
|
||||
const nodesConflict = await context.request('PUT', nodesPath, { ...nodesBody, nodes: nodesBody.nodes.map((node, index) => index === 0 ? { ...node, nodeAt: localIso(new Date()) } : node) }, context.token, { 'Idempotency-Key': nodesKey })
|
||||
context.addStep(MODULE, '人工节点同键异载荷返回A0409', nodesConflict, conflict(nodesConflict), trace(nodesConflict), OPTIONS)
|
||||
const nodesRead = await api(context, 'GET', '回查尾程人工节点来源', nodesPath)
|
||||
context.assert(MODULE, '人工节点读取固定为MANUAL且不回退柜级节点', nodesRead.response,
|
||||
|
||||
@@ -57,7 +57,7 @@ test('phase-five module proves the client delivery create, list and nested detai
|
||||
|
||||
test('phase-five module proves receipt-save completion, cross-region convergence, customer visit, exception and return', () => {
|
||||
for (const token of [
|
||||
'/documents/RECEIPT/files', 'requestOverseasMultipart', "category: 'RECEIPT'",
|
||||
'/documents/RECEIPT/files', 'requestMultipart', "category: 'RECEIPT'",
|
||||
'SAVE-RECEIPT', 'RECEIPT_AUTO_COMPLETE',
|
||||
'/customer-visits/${encodeURIComponent(visitId)}',
|
||||
"visitResult: 'SATISFIED'", '/exception-orders',
|
||||
|
||||
@@ -33,17 +33,17 @@ function blocker(context, name, reason, response = null) {
|
||||
context.assert(MODULE, `${name}覆盖前置`, response, false, `${reason}; ${trace(response)}`, OPTIONS)
|
||||
}
|
||||
async function platform(context, method, name, path, body = undefined) {
|
||||
const response = await context.requestOverseas(method, path, body)
|
||||
const response = await context.request(method, path, body)
|
||||
const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS)
|
||||
return { response, data }
|
||||
}
|
||||
async function pda(context, token, method, name, path, body = undefined) {
|
||||
const response = await context.requestOverseas(method, path, body, {}, token)
|
||||
const response = await context.request(method, path, body, token, {})
|
||||
context.addStep(MODULE, name, response, success(response), trace(response), OPTIONS)
|
||||
return { response, data: success(response) ? response.body?.data : null }
|
||||
}
|
||||
async function merchant(context, token, method, name, path, body = undefined) {
|
||||
const response = await context.requestOverseas(method, path, body, {}, token)
|
||||
const response = await context.request(method, path, body, token, {})
|
||||
const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS)
|
||||
return { response, data }
|
||||
}
|
||||
@@ -75,7 +75,7 @@ export async function saveReceiptAndComplete(context, delivery, customerId, inde
|
||||
Number.isInteger(version) && version >= 0, `version=${before.data?.version ?? 'missing'}; ${trace(before.response)}`, OPTIONS)
|
||||
if (!success(before.response) || !Number.isInteger(version) || version < 0) return null
|
||||
|
||||
const uploadResponse = await context.requestOverseasMultipart('POST',
|
||||
const uploadResponse = await context.requestMultipart('POST',
|
||||
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/documents/RECEIPT/files`, { customerId }), {
|
||||
name: `${context.marker}-${deliveryNo}-receipt.txt`, type: 'text/plain',
|
||||
content: new TextEncoder().encode(`${context.marker} receipt acceptance for ${deliveryNo}`),
|
||||
@@ -158,16 +158,16 @@ async function loginMerchant(context, customerId) {
|
||||
password: credentials.password,
|
||||
}, '')
|
||||
const auth = context.apiSuccess(MODULE, '本轮客户工作台账号登录', response, trace(response), OPTIONS)
|
||||
context.assert(MODULE, '客户登录返回海外访问Token', response, nonBlank(auth?.overseasAccessToken),
|
||||
`tokenPresent=${Boolean(auth?.overseasAccessToken)}; ${trace(response)}`, OPTIONS)
|
||||
if (!nonBlank(auth?.accessToken) || !nonBlank(auth?.overseasAccessToken)) return null
|
||||
context.assert(MODULE, '客户登录返回海外访问Token', response, nonBlank(auth?.accessToken),
|
||||
`tokenPresent=${Boolean(auth?.accessToken)}; ${trace(response)}`, OPTIONS)
|
||||
if (!nonBlank(auth?.accessToken) || !nonBlank(auth?.accessToken)) return null
|
||||
const profileResponse = await context.request('GET', '/api/merchant/v1/iam/tenant/profile', undefined, auth.accessToken)
|
||||
const profile = context.apiSuccess(MODULE, '回查客户工作台企业身份', profileResponse,
|
||||
trace(profileResponse), OPTIONS)
|
||||
context.assert(MODULE, '客户工作台企业与五期履约客户严格一致', profileResponse,
|
||||
id(profile?.id) === customerId,
|
||||
`merchantCustomer=${id(profile?.id) || 'missing'}, fulfillmentCustomer=${customerId}; ${trace(profileResponse)}`, OPTIONS)
|
||||
return id(profile?.id) === customerId ? auth.overseasAccessToken : null
|
||||
return id(profile?.id) === customerId ? auth.accessToken : null
|
||||
}
|
||||
|
||||
function fixture(context) {
|
||||
@@ -270,7 +270,7 @@ export async function run(context) {
|
||||
trace(updated.response), OPTIONS)
|
||||
if (!success(updated.response)) return
|
||||
|
||||
const bol = await context.requestOverseasBinary('GET', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/prints/bol`)
|
||||
const bol = await context.requestBinary('GET', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/prints/bol`)
|
||||
context.addStep(MODULE, '排车BOL下载为真实PDF而非JSON错误', bol,
|
||||
bol.http === 200 && /application\/pdf/i.test(bol.contentType || '')
|
||||
&& new TextDecoder().decode(bol.bytes?.slice(0, 5)) === '%PDF-', trace(bol), OPTIONS)
|
||||
@@ -310,8 +310,7 @@ export async function run(context) {
|
||||
&& JSON.stringify(deliveryNumbers(adminReplay.data)) === JSON.stringify(deliveryNos), trace(adminReplay.response), OPTIONS)
|
||||
const lockedItem = updated.data?.items?.[0]
|
||||
if (lockedItem?.id) {
|
||||
const deniedRemove = await context.requestOverseas('DELETE',
|
||||
`${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(lockedItem.id))}`)
|
||||
const deniedRemove = await context.request('DELETE', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(lockedItem.id))}`)
|
||||
context.addStep(MODULE, '已生成出库单禁止移除排车托盘', deniedRemove, conflict(deniedRemove), trace(deniedRemove), OPTIONS)
|
||||
const stable = await platform(context, 'GET', '移除拒绝后回查排车托盘零漂移', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
|
||||
context.assert(MODULE, '移除失败不改变箱数和托盘身份', stable.response,
|
||||
@@ -323,8 +322,7 @@ export async function run(context) {
|
||||
context.assert(MODULE, '转派送重放复用同一业务单据', transferReplay.response,
|
||||
transferReplay.data?.replayed === true && id(transferReplay.data?.outboundNo) === outboundNo
|
||||
&& JSON.stringify(deliveryNumbers(transferReplay.data)) === JSON.stringify(deliveryNos), trace(transferReplay.response), OPTIONS)
|
||||
const transferConflict = await context.requestOverseas('POST', query(MERCHANT_DELIVERY_BASE, { dispatchNo }),
|
||||
{ ...transferBody, idempotencyKey: key(context, 'TRANSFER-DIFFERENT') }, {}, merchantToken)
|
||||
const transferConflict = await context.request('POST', query(MERCHANT_DELIVERY_BASE, { dispatchNo }), { ...transferBody, idempotencyKey: key(context, 'TRANSFER-DIFFERENT') }, merchantToken, {})
|
||||
context.addStep(MODULE, '同一排车使用另一幂等键仍返回既有单据', transferConflict,
|
||||
success(transferConflict) && id(transferConflict.body?.data?.outboundNo) === outboundNo
|
||||
&& transferConflict.body?.data?.replayed === true
|
||||
@@ -409,9 +407,7 @@ export async function run(context) {
|
||||
&& completedDeliveries.every((value) => value?.order?.status === 'DELIVERED'),
|
||||
`deliveries=${deliveryNos.join(',')}, completed=${completedDeliveries.length}`, OPTIONS)
|
||||
|
||||
const removedComplete = await context.requestOverseas('POST',
|
||||
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/complete`, { customerId }),
|
||||
{ status: 'DELIVERED', expectedVersion: 0, remark: `${context.marker}-removed-complete-probe` })
|
||||
const removedComplete = await context.request('POST', query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/complete`, { customerId }), { status: 'DELIVERED', expectedVersion: 0, remark: `${context.marker}-removed-complete-probe` })
|
||||
context.addStep(MODULE, '人工确认派送完成接口已移除', removedComplete,
|
||||
removedComplete?.http === 404 || removedComplete?.http === 405
|
||||
|| (removedComplete?.http === 200 && ['A0404', 'A0405'].includes(code(removedComplete))),
|
||||
|
||||
@@ -138,7 +138,7 @@ test('边界fixture遵守审核、单SKU托盘和完工照片新契约', async (
|
||||
assert.match(source, /'X-Customer-Id': customerId/)
|
||||
assert.match(source, /status !== 'PENDING_REVIEW'/)
|
||||
assert.match(source, /palletCode: order\.palletCode/)
|
||||
assert.match(source, /requestOverseasMultipart\('POST',[^\n]+form, operator\.token\)/)
|
||||
assert.match(source, /requestMultipart\('POST',[^\n]+form, operator\.token\)/)
|
||||
assert.match(source, /两个合法SKU同批签收仍整笔拒绝/)
|
||||
assert.match(source, /palletCode: nextPallet\.palletCode/)
|
||||
assert.doesNotMatch(source, /\['receiptNo', 'receiptBatchNo', 'cartonMark'\]/)
|
||||
|
||||
@@ -61,10 +61,10 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p
|
||||
context.assert(MODULE, name, response, Boolean(passed), evidence, OPTIONS)
|
||||
}
|
||||
async function request(token, method, path, body) {
|
||||
let response = await context.requestOverseas(method, path, body, {}, token)
|
||||
let response = await context.request(method, path, body, token, {})
|
||||
for (let attempt = 1; attempt < 3 && response?.http === 0; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250 * attempt))
|
||||
response = await context.requestOverseas(method, path, body, {}, token)
|
||||
response = await context.request(method, path, body, token, {})
|
||||
}
|
||||
return response
|
||||
}
|
||||
@@ -87,8 +87,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p
|
||||
/^RT-[0-9A-Z]{25}$/.test(data.order.returnNo) && /^RETU-[0-9A-Z]{25}$/.test(data.order.lastlegOrderNo))
|
||||
const customerId = customerByToken.get(token)
|
||||
if (!customerId || data.order.status !== 'PENDING_REVIEW') throw new Error('边界退货客户或审核初态不符')
|
||||
const reviewed = await context.requestOverseas('POST', `/api/platform/v1/last-leg/return-orders/${data.order.returnNo}/review`,
|
||||
{ decision: 'APPROVE', remark: context.marker }, { 'X-Customer-Id': customerId })
|
||||
const reviewed = await context.request('POST', `/api/platform/v1/last-leg/return-orders/${data.order.returnNo}/review`, { decision: 'APPROVE', remark: context.marker }, undefined, { 'X-Customer-Id': customerId })
|
||||
check('边界退货单审核通过进入待签收', reviewed, ok(reviewed) && reviewed.body.data?.order?.status === 'PENDING_RECEIPT')
|
||||
if (!ok(reviewed) || reviewed.body.data?.order?.status !== 'PENDING_RECEIPT') throw new Error('边界退货审核失败')
|
||||
const operator = String(warehouseId) === String(warehouse.id) ? picker : foreignPicker
|
||||
@@ -97,7 +96,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p
|
||||
if (!pallet.palletCode) throw new Error('退货托盘码缺失')
|
||||
const form = new FormData()
|
||||
form.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), 'synthetic-return-boundary.png')
|
||||
const photo = await context.requestOverseasMultipart('POST', `${RECEIPTS}/${data.order.returnNo}/photos/files`, form, operator.token)
|
||||
const photo = await context.requestMultipart('POST', `${RECEIPTS}/${data.order.returnNo}/photos/files`, form, operator.token)
|
||||
check('边界签收准备合成打托照片', photo, ok(photo) && Boolean(photo.body.data?.fileId))
|
||||
if (!ok(photo) || !photo.body.data?.fileId) throw new Error('边界照片上传失败')
|
||||
return { returnNo: data.order.returnNo, sku, skus: items.map(item => item.skuCode), quantity, token, warehouseId, palletCode: pallet.palletCode }
|
||||
@@ -160,9 +159,9 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p
|
||||
const fixtureB = secondary.context.merchantQualificationFixture
|
||||
resources.secondaryCustomer = secondary.createdResources.merchantQualificationFixture
|
||||
const loginB = await context.request('POST', '/api/auth/login', { loginId: fixtureB.username, password: fixtureB.password }, '')
|
||||
check('第二隔离客户登录取得海外令牌', loginB, ok(loginB) && Boolean(loginB.body?.data?.overseasAccessToken))
|
||||
if (!ok(loginB) || !loginB.body?.data?.overseasAccessToken) return
|
||||
const tokenB = loginB.body.data.overseasAccessToken
|
||||
check('第二隔离客户登录取得访问令牌', loginB, ok(loginB) && Boolean(loginB.body?.data?.accessToken))
|
||||
if (!ok(loginB) || !loginB.body?.data?.accessToken) return
|
||||
const tokenB = loginB.body.data.accessToken
|
||||
customerByToken.set(tokenB, String(fixtureB.tenantId))
|
||||
const warehouseResponse = await context.request('GET', '/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE')
|
||||
const otherWarehouse = rows(warehouseResponse?.body?.data).find(row => String(row.id) !== String(warehouse.id))
|
||||
@@ -364,7 +363,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p
|
||||
|
||||
await scenario('身份入口权限', async () => {
|
||||
const order = await newOrder('权限边界', `${prefix}-AUTH`, 1)
|
||||
for (const [name, token, statuses] of [['客户', merchantToken, [403]], ['无登录', '', [401]], ['平台管理员', context.overseasToken, [403]]]) {
|
||||
for (const [name, token, statuses] of [['客户', merchantToken, [403]], ['无登录', '', [401]], ['平台管理员', context.token, [403]]]) {
|
||||
await unchanged(`${name}不能调用PDA签收`, [order], () => request(token, 'POST', `${RECEIPTS}/confirm`, receiptBody(order)), statuses)
|
||||
}
|
||||
await receive(order)
|
||||
@@ -383,7 +382,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p
|
||||
await scenario('签收流水与余额核对', async () => {
|
||||
const order = await newOrder('流水核对', `${prefix}-LEDGER`, 3)
|
||||
const customerId = context.context.merchantQualificationFixture.tenantId
|
||||
const ledger = () => good(context.overseasToken, 'GET', '读取本轮签收流水', url(`${PLATFORM_INVENTORY}/movements`, {
|
||||
const ledger = () => good(context.token, 'GET', '读取本轮签收流水', url(`${PLATFORM_INVENTORY}/movements`, {
|
||||
customerId, overseasWarehouseId: warehouse.id, skuCode: order.sku, page: 1, size: 200 }))
|
||||
check('未签收前本轮SKU无流水', null, exactReceiptLedger(await ledger(), []))
|
||||
const body = receiptBody(order, 2)
|
||||
|
||||
@@ -7,7 +7,7 @@ function fixture({ failedReview = false, wrongOwner = false, stale = false } = {
|
||||
const calls = []
|
||||
const context = { marker: 'FIXTURE', context: { merchantQualificationFixture: { tenantId: '123' } },
|
||||
addStep() {}, assert() {},
|
||||
async requestOverseas(method, path, body, headers, token) {
|
||||
async request(method,path,body,token,headers) {
|
||||
calls.push({ method, path, body, headers, token })
|
||||
if (method === 'POST') {
|
||||
assert.equal(headers['X-Customer-Id'], '123')
|
||||
|
||||
@@ -147,13 +147,12 @@ export async function run(context) {
|
||||
const resetLoginPassed = resetLoginResponse.http === 200 && String(resetLoginResponse.body?.code) === '200' && Boolean(resetLoginResponse.body?.data?.accessToken)
|
||||
context.addStep(module, '内部用户使用重置密码登录', resetLoginResponse, resetLoginPassed, `username=${username}`)
|
||||
if (context.options.modules.includes('phase4-cross-region-events')) {
|
||||
const restrictedOverseasToken = resetLoginResponse.body?.data?.overseasAccessToken || ''
|
||||
context.assert(module, '受限角色登录同时签发海外Token', resetLoginResponse,
|
||||
const restrictedOverseasToken = resetLoginResponse.body?.data?.accessToken || ''
|
||||
context.assert(module, '受限角色登录同时签发统一Token', resetLoginResponse,
|
||||
Boolean(restrictedOverseasToken), `tokenPresent=${Boolean(restrictedOverseasToken)}`,
|
||||
{ scenario: 'canonical-overseas-permission-denial', deploymentBlocker: true })
|
||||
if (restrictedOverseasToken) {
|
||||
const denied = await context.requestOverseas('GET', '/api/platform/v1/last-leg/orders?page=1&size=1',
|
||||
undefined, {}, restrictedOverseasToken)
|
||||
const denied = await context.request('GET', '/api/platform/v1/last-leg/orders?page=1&size=1', undefined, restrictedOverseasToken, {})
|
||||
context.assert(module, '缺少canonical海外订单权限必须403', denied,
|
||||
(denied.http === 403 || ['403', 'A0403'].includes(String(denied.body?.code)))
|
||||
&& Boolean(denied.requestId) && Boolean(denied.traceId),
|
||||
|
||||
+49
-104
@@ -1,110 +1,55 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
EVENT_BRIDGE_RECOVERY_FIXTURE,
|
||||
INTERNAL_TOKEN_COHORT_SERVICES,
|
||||
recoveredEventBridgeFacts,
|
||||
TEST_EVENT_BRIDGE_URLS,
|
||||
validInternalTokenCohort,
|
||||
validTestEventBridgeEnvironment,
|
||||
} from './event-bridge-pending-recovery.mjs'
|
||||
|
||||
const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8')
|
||||
const cnCompose = read('../../deploy/docker-compose.yml')
|
||||
const overseasCompose = read('../../deploy/docker-compose.overseas.yml')
|
||||
const deployBuilt = read('../../deploy/scripts/deploy-built-service.sh')
|
||||
const deployServices = read('../../deploy/scripts/deploy-services.sh')
|
||||
const crossRegionHttp = read('phase4-cross-region-events.mjs')
|
||||
|
||||
test('test bridge URLs use stable Compose DNS and internal port 9312 only', () => {
|
||||
assert.deepEqual(TEST_EVENT_BRIDGE_URLS, {
|
||||
LASTLEG_EVENT_BRIDGE_URL: 'http://event-bridge-overseas:9312',
|
||||
FIRSTLEG_EVENT_BRIDGE_URL: 'http://event-bridge-cn:9312',
|
||||
})
|
||||
assert.equal(validTestEventBridgeEnvironment(TEST_EVENT_BRIDGE_URLS), true)
|
||||
assert.equal(validTestEventBridgeEnvironment({
|
||||
...TEST_EVENT_BRIDGE_URLS, LASTLEG_EVENT_BRIDGE_URL: 'http://172.31.198.8:9313',
|
||||
}), false)
|
||||
import { TEST_DIRECT_DELIVERY_URLS, INTERNAL_TOKEN_COHORT_SERVICES, validDirectDeliveryEnvironment,
|
||||
validInternalTokenCohort, validDirectDeliveryEvidence } from './event-bridge-pending-recovery.mjs'
|
||||
const read=p=>readFileSync(new URL(p,import.meta.url),'utf8')
|
||||
const compose=read('../../deploy/docker-compose.yml')
|
||||
test('all three direct targets use Compose DNS and current internal ports',()=>{
|
||||
assert.equal(validDirectDeliveryEnvironment(TEST_DIRECT_DELIVERY_URLS),true)
|
||||
for(const [key,url] of Object.entries(TEST_DIRECT_DELIVERY_URLS)){
|
||||
assert.ok(compose.includes(`${key}: ${url}`))
|
||||
assert.equal(validDirectDeliveryEnvironment({...TEST_DIRECT_DELIVERY_URLS,[key]:'http://localhost:9312'}),false)
|
||||
}
|
||||
assert.doesNotMatch(compose,/EVENT_BRIDGE_URL|event-bridge-service:/)
|
||||
})
|
||||
test('the three producers and consumers share internal token revision without exposing secrets',()=>{
|
||||
assert.deepEqual(INTERNAL_TOKEN_COHORT_SERVICES,['firstleg','lastleg-service','finance'])
|
||||
assert.match(compose,/INTERNAL_SERVICE_TOKEN: \$\{INTERNAL_SERVICE_TOKEN\}/)
|
||||
assert.equal(validInternalTokenCohort({firstleg:'r1','lastleg-service':'r1',finance:'r1'}),true)
|
||||
assert.equal(validInternalTokenCohort({firstleg:'r1','lastleg-service':'r1',finance:'r2'}),false)
|
||||
assert.equal(validInternalTokenCohort({}),false)
|
||||
})
|
||||
const attempt=(extra={})=>({eventId:'event-fixture',payloadHash:'hash-fixture',at:'2026-09-29T00:00:00Z',requestId:'request-fixture',traceId:'trace-fixture',...extra})
|
||||
const facts=(attempts,extra={})=>({marker:'run-fixture',eventId:'event-fixture',payloadHash:'hash-fixture',maxAttempts:12,sqlMutationCount:0,status:'SENT',inboxEventCount:1,businessEffectCount:1,attempts,...extra})
|
||||
test('DNS, invalid token and timeout recovery require original event identity and actual duplicate ACK',()=>{
|
||||
for(const failure of ['DNS_UNAVAILABLE','TOKEN_REJECTED','TIMEOUT']){
|
||||
const evidence=facts([attempt({failure}),attempt({outcome:'APPLIED',ackLost:true}),attempt({outcome:'DUPLICATE'})],{authorizedDeadRetry:failure==='TOKEN_REJECTED'})
|
||||
assert.equal(validDirectDeliveryEvidence(evidence),true)
|
||||
assert.equal(validDirectDeliveryEvidence({...evidence,businessEffectCount:2}),false)
|
||||
assert.equal(validDirectDeliveryEvidence({...evidence,sqlMutationCount:1}),false)
|
||||
assert.equal(validDirectDeliveryEvidence({...evidence,attempts:[...evidence.attempts,attempt({eventId:'new-id',outcome:'DUPLICATE'})]}),false)
|
||||
}
|
||||
assert.equal(validDirectDeliveryEvidence(facts([attempt({outcome:'APPLIED',ackLost:true}),attempt({outcome:'APPLIED',ackLost:true})])),false)
|
||||
})
|
||||
test('retry budget exhaustion stays DEAD and never schedules an automatic retry',()=>{
|
||||
const evidence=facts(Array.from({length:12},()=>attempt({failure:'TIMEOUT'})),{status:'DEAD',automaticRetryScheduled:false})
|
||||
assert.equal(validDirectDeliveryEvidence(evidence),true)
|
||||
assert.equal(validDirectDeliveryEvidence({...evidence,automaticRetryScheduled:true}),false)
|
||||
assert.equal(validDirectDeliveryEvidence({...evidence,attempts:[...evidence.attempts,attempt({failure:'TIMEOUT'})]}),false)
|
||||
})
|
||||
test('GET stability does not claim real event replay coverage',()=>{
|
||||
const source=read('./phase4-cross-region-events.mjs')
|
||||
assert.match(source,/非事件重放证据/)
|
||||
assert.match(source,/missing-direct-delivery-replay-evidence/)
|
||||
assert.match(source,/event-outbox-operations\.mjs/)
|
||||
assert.doesNotMatch(source,/经Bridge|不能访问.*Audience/)
|
||||
})
|
||||
|
||||
test('Compose wires both producers to the stable service aliases used by test env', () => {
|
||||
assert.match(cnCompose, /LASTLEG_EVENT_BRIDGE_URL:\s*\$\{LASTLEG_EVENT_BRIDGE_URL\}/)
|
||||
assert.match(overseasCompose,
|
||||
/FIRSTLEG_EVENT_BRIDGE_URL:\s*\$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/)
|
||||
assert.match(cnCompose, /aliases:\s*\[event-bridge-cn\]/)
|
||||
assert.match(cnCompose,
|
||||
/FINANCE_FEE_EVENT_CONSUMER_URL:\s*\$\{FINANCE_FEE_EVENT_CONSUMER_URL:-http:\/\/finance:9203\/internal\/finance\/v1\/business-fee-source-events\}/)
|
||||
assert.match(overseasCompose, /aliases:\s*\[event-bridge-overseas\]/)
|
||||
for (const source of [cnCompose, overseasCompose]) {
|
||||
assert.doesNotMatch(source, /(?:LASTLEG|FIRSTLEG|CN)_EVENT_BRIDGE_URL:[^\n]*(?:\d{1,3}\.){3}\d{1,3}/)
|
||||
assert.doesNotMatch(source, /(?:LASTLEG|FIRSTLEG|CN)_EVENT_BRIDGE_URL:[^\n]*:9313/)
|
||||
}
|
||||
})
|
||||
|
||||
test('all four recovery services inherit INTERNAL_SERVICE_TOKEN and deploy from one env file', () => {
|
||||
assert.deepEqual(INTERNAL_TOKEN_COHORT_SERVICES, [
|
||||
'firstleg', 'event-bridge-cn', 'event-bridge-overseas', 'lastleg-service',
|
||||
])
|
||||
assert.match(cnCompose, /x-common:\s*&common[\s\S]*INTERNAL_SERVICE_TOKEN:\s*\$\{INTERNAL_SERVICE_TOKEN\}/)
|
||||
assert.match(overseasCompose,
|
||||
/x-overseas-common:\s*&overseas-common[\s\S]*INTERNAL_SERVICE_TOKEN:\s*\$\{INTERNAL_SERVICE_TOKEN\}/)
|
||||
assert.match(cnCompose, /firstleg:[\s\S]*<<:\s*\*common/)
|
||||
assert.match(cnCompose, /event-bridge-service:[\s\S]*<<:\s*\*common/)
|
||||
assert.match(overseasCompose, /lastleg-service:[\s\S]*<<:\s*\*overseas-common/)
|
||||
assert.match(overseasCompose, /event-bridge-service:[\s\S]*<<:\s*\*overseas-common/)
|
||||
for (const source of [deployBuilt, deployServices]) {
|
||||
assert.match(source, /--env-file\s+"\$ENV_FILE"/)
|
||||
}
|
||||
assert.equal(validInternalTokenCohort({
|
||||
firstleg: 'revision-20260827',
|
||||
'event-bridge-cn': 'revision-20260827',
|
||||
'event-bridge-overseas': 'revision-20260827',
|
||||
'lastleg-service': 'revision-20260827',
|
||||
}), true)
|
||||
assert.equal(validInternalTokenCohort({
|
||||
firstleg: 'revision-20260827',
|
||||
'event-bridge-cn': 'revision-20260827',
|
||||
'event-bridge-overseas': 'stale-revision',
|
||||
'lastleg-service': 'revision-20260827',
|
||||
}), false)
|
||||
})
|
||||
|
||||
test('incident fixture records DNS recovery followed by retry81 token rejection', () => {
|
||||
assert.deepEqual(EVENT_BRIDGE_RECOVERY_FIXTURE, {
|
||||
customerCode: 'C10000000',
|
||||
eventId: 'b569c7f6-3493-4a0f-a944-dd42698f183b',
|
||||
initialOutboxStatus: 'PENDING',
|
||||
initialRetryCount: 62,
|
||||
failedTarget: '172.31.198.8:9313',
|
||||
currentOutboxStatus: 'PENDING',
|
||||
currentRetryCount: 81,
|
||||
currentFailureCode: 'A0401',
|
||||
currentFailureMessage: '内部服务令牌无效',
|
||||
currentRequestId: '200edca3-428e-446d-977a-ca98a84dd704',
|
||||
currentTraceId: 'e8e6d620c1cff40caa765e6968f89024',
|
||||
dockerDnsProven: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('post-rebuild convergence uses the original pending event with no replay or SQL repair', () => {
|
||||
assert.equal(recoveredEventBridgeFacts({
|
||||
firstlegOutboxStatus: 'SENT', overseasBridgeEventCount: 1,
|
||||
lastlegInboxEventCount: 1, lastlegOrderCount: 1, cnBridgeDuplicateCount: 0,
|
||||
manualReplayCount: 0, sqlMutationCount: 0,
|
||||
}), true)
|
||||
assert.equal(recoveredEventBridgeFacts({
|
||||
firstlegOutboxStatus: 'SENT', overseasBridgeEventCount: 1,
|
||||
lastlegInboxEventCount: 1, lastlegOrderCount: 2, cnBridgeDuplicateCount: 0,
|
||||
manualReplayCount: 0, sqlMutationCount: 0,
|
||||
}), false)
|
||||
assert.equal(recoveredEventBridgeFacts({
|
||||
firstlegOutboxStatus: 'SENT', overseasBridgeEventCount: 1,
|
||||
lastlegInboxEventCount: 1, lastlegOrderCount: 1, cnBridgeDuplicateCount: 0,
|
||||
manualReplayCount: 1, sqlMutationCount: 0,
|
||||
}), false)
|
||||
assert.match(crossRegionHttp, /const delivered = await poll/)
|
||||
assert.match(crossRegionHttp, /deliveredRows\.length > 0/)
|
||||
assert.match(crossRegionHttp, /sameIds\(delivered\.response\.body\?\.data, deliveredAgain\.body\?\.data\)/)
|
||||
test('permanent token failure stops immediately and recovery needs authorized DEAD retry', () => {
|
||||
const stopped=facts([attempt({failure:'TOKEN_REJECTED'})],{status:'DEAD',automaticRetryScheduled:false})
|
||||
assert.equal(validDirectDeliveryEvidence(stopped),true)
|
||||
const recovered=facts([attempt({failure:'TOKEN_REJECTED'}),attempt({outcome:'APPLIED',ackLost:true}),attempt({outcome:'DUPLICATE'})])
|
||||
assert.equal(validDirectDeliveryEvidence(recovered),false)
|
||||
assert.equal(validDirectDeliveryEvidence({...recovered,authorizedDeadRetry:true}),true)
|
||||
})
|
||||
|
||||
@@ -92,7 +92,16 @@ test('same event replay adds nothing and preserves the allocated number', () =>
|
||||
const before = { orderCount: 15, orderNo: 'LMO-260827-0042', detailCount: 30, historyCount: 15 }
|
||||
assert.equal(validEventReplay(before, { ...before }), true)
|
||||
assert.equal(validEventReplay(before, { ...before, orderCount: 30 }), false)
|
||||
assert.match(consumer, /if\s*\(existing\s*!=\s*null\)[\s\S]{0,400}return countOrders/)
|
||||
const duplicateBranch = consumer.slice(consumer.indexOf('if(existing!=null)'), consumer.indexOf('LastlegEventInbox inbox='))
|
||||
assert.match(duplicateBranch, /existing\.getPayloadHash\(\)\.equalsIgnoreCase\(envelope\.payloadHash\(\)\)/)
|
||||
assert.match(duplicateBranch, /!"PROCESSED"\.equals\(existing\.getStatus\(\)\).*throw new IllegalStateException/)
|
||||
assert.match(duplicateBranch, /return DeliveryAck\.of\(envelope, DeliveryAck\.Outcome\.DUPLICATE\)/)
|
||||
assert.doesNotMatch(duplicateBranch, /createOrder|forContainerNo|nextValue/)
|
||||
const racedBranch = consumer.slice(consumer.indexOf('catch(DuplicateKeyException exception)'), consumer.indexOf('projectLoadingEvidence(envelope'))
|
||||
assert.match(racedBranch, /raced\.getPayloadHash\(\)\.equalsIgnoreCase\(envelope\.payloadHash\(\)\)/)
|
||||
assert.match(racedBranch, /!"PROCESSED"\.equals\(raced\.getStatus\(\)\).*throw new IllegalStateException/)
|
||||
assert.match(racedBranch, /DeliveryAck\.Outcome\.DUPLICATE/)
|
||||
|
||||
assert.match(consumerTests, /replayPreservesExistingLmoNumberAndAddsNothing/)
|
||||
assert.match(consumerTests, /verify\(sequences,never\(\)\)\.nextValue/)
|
||||
})
|
||||
|
||||
@@ -16,7 +16,7 @@ export async function verifyWarehouseTransferReview(context, token, customerId,
|
||||
const scoped = no => `${PLATFORM}/${encodeURIComponent(no)}?customerId=${encodeURIComponent(customerId)}`
|
||||
const reviewPath = no => `${PLATFORM}/${encodeURIComponent(no)}/review?customerId=${encodeURIComponent(customerId)}`
|
||||
async function call(method, path, body, merchantToken) {
|
||||
return context.requestOverseas(method, path, body, {}, merchantToken)
|
||||
return context.request(method, path, body, merchantToken, {})
|
||||
}
|
||||
const initial = await call('GET', scoped(transferNo))
|
||||
context.addStep(MODULE, '平台转运详情按客户读取待审核单', initial,
|
||||
|
||||
@@ -38,10 +38,10 @@ $requiredTests = @(
|
||||
"warehouse/src/test/java/com/baiyue/warehouse/repository/OverseasStorageFeeSqlContractTest.java",
|
||||
"warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformOverseasStorageFeeControllerContractTest.java"
|
||||
)
|
||||
$mavenArgs = @("-f", $pom, "-pl", "order,firstleg,iam,gateway,file,warehouse", "-am", "test")
|
||||
$mavenArgs = @("-f", $pom, "test")
|
||||
|
||||
Write-Host "Backend regression root: $repoRoot"
|
||||
Write-Host "Scope: order + firstleg + iam + gateway + file + warehouse Maven reactor (includes all module tests)"
|
||||
Write-Host "Scope: complete root POM reactor (all active modules and their tests)"
|
||||
Write-Host "Database/API access: none"
|
||||
|
||||
if (-not (Test-Path -LiteralPath $pom)) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { resolve } from 'node:path'
|
||||
import { createInterface } from 'node:readline/promises'
|
||||
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
if ((args.environment && args.environment !== 'test') || args['overseas-gateway'] || process.env.ADMIN_OVERSEAS_GATEWAY) throw new Error('Only one TEST gateway is supported; remove regional options')
|
||||
const gateway = trimSlash(args.gateway ?? process.env.ADMIN_TEST_GATEWAY)
|
||||
const platformUsername = args['platform-username'] ?? process.env.ADMIN_TEST_USERNAME
|
||||
?? await promptText('平台审核账号', '')
|
||||
|
||||
@@ -20,8 +20,12 @@ test('frontend full regression includes domestic and overseas PDA node tests by
|
||||
assert.match(frontendScript, /tests\/\*\.test\.mjs/)
|
||||
})
|
||||
|
||||
test('backend full regression includes file reactor and critical file/container coverage', () => {
|
||||
assert.match(backendScript, /-pl["',\s\S]*order,firstleg,iam,gateway,file/)
|
||||
test('backend full regression uses the entire root reactor and preserves critical file/container coverage', () => {
|
||||
const args = backendScript.match(/\$mavenArgs\s*=\s*@\(([^\r\n]+)\)/)?.[1]
|
||||
assert.equal(args, '"-f", $pom, "test"')
|
||||
assert.match(backendScript, /\$pom = Join-Path \$repoRoot "pom.xml"/)
|
||||
assert.doesNotMatch(args, /-pl|-rf|-N|skipTests|maven.test.skip/)
|
||||
assert.match(backendScript, /complete root POM reactor/)
|
||||
for (const required of [
|
||||
'file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java',
|
||||
'file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java',
|
||||
@@ -36,6 +40,7 @@ test('backend full regression includes file reactor and critical file/container
|
||||
|
||||
test('backend full regression rejects Mapper registration drift before Maven', () => {
|
||||
assert.match(backendScript, /scripts\/mapper-scan-contract\.mjs/)
|
||||
assert.match(backendScript, /& node --test[^\r\n]*gateway-route-contract\.test\.mjs[\s\S]*& node \$mapperScanContract/)
|
||||
assert.match(backendScript, /& node \$mapperScanContract[\s\S]*& \$MavenCommand @mavenArgs/)
|
||||
})
|
||||
|
||||
|
||||
@@ -45,28 +45,15 @@ test('overseas inbound commands, client services and dispatch follow completed P
|
||||
}
|
||||
})
|
||||
|
||||
test('entry accepts a separate overseas Gateway without exposing token state', () => {
|
||||
const options = parseArguments([
|
||||
'--gateway', 'http://localhost:9000/',
|
||||
'--overseas-gateway', 'http://localhost:9300/',
|
||||
'--modules', 'phase4-overseas-warehouse',
|
||||
])
|
||||
test('entry uses one TEST gateway and rejects retired regional options', () => {
|
||||
const options = parseArguments(['--gateway', 'http://localhost:9000/', '--modules', 'phase4-overseas-warehouse'])
|
||||
assert.equal(options.gateway, 'http://localhost:9000')
|
||||
assert.equal(options.overseasGateway, 'http://localhost:9300')
|
||||
assert.match(source, /this\.overseasToken = login\.overseasAccessToken \|\| ''/)
|
||||
assert.match(source, /this\.refreshToken = login\.refreshToken \|\| ''/)
|
||||
assert.match(source, /this\.overseasExpiresAt = Number\(login\.overseasExpiresIn\)/)
|
||||
assert.match(source, /async refreshSession\(reason\)/)
|
||||
assert.match(source, /refreshToken: this\.refreshToken/)
|
||||
assert.match(source, /const replacement = \{[\s\S]*token: next\.accessToken[\s\S]*refreshToken: next\.refreshToken[\s\S]*overseasToken: next\.overseasAccessToken/)
|
||||
assert.match(source, /module === 'system' && laterOverseasModule/)
|
||||
assert.match(source, /system后新admin海外Token只读探针可用/)
|
||||
assert.match(source, /module\.startsWith\('phase4-overseas-'\)[\s\S]*Date\.now\(\) \+ 120_000 >= this\.overseasExpiresAt/)
|
||||
assert.match(source, /海外模块 \$\{module\} 执行前Token临近到期/)
|
||||
assert.match(source, /async requestOverseas\(/)
|
||||
assert.match(source, /async requestOverseasBinary\(/)
|
||||
assert.match(source, /async requestOverseasMultipart\(/)
|
||||
assert.match(source, /forbiddenValues = \[this\.options\.password, this\.options\.merchantPassword,[\s\S]*this\.token, this\.refreshToken, this\.overseasToken\]/)
|
||||
assert.equal(options.environment, 'test')
|
||||
assert.throws(() => parseArguments(['--overseas-gateway', 'http://localhost:9300']), /one TEST gateway/)
|
||||
assert.throws(() => parseArguments(['--environment', 'cn']), /TEST|test/)
|
||||
assert.doesNotMatch(source, /async requestOverseas|this\.overseasToken|overseasAccessToken/)
|
||||
assert.match(source, /this\.refreshToken = login\.refreshToken/)
|
||||
assert.match(source, /system后新admin统一Token只读探针可用/)
|
||||
})
|
||||
|
||||
test('overseas business keeps its marker-scoped customer fixture with reusable credentials configured', () => {
|
||||
@@ -96,32 +83,31 @@ test('a fixed origin route does not require randomized order fixtures', () => {
|
||||
assert.match(source, /this\.context\.allowAnyExistingPallet = true/)
|
||||
})
|
||||
|
||||
test('dual-token refresh replaces the whole session only after a complete response', async () => {
|
||||
const run = new AcceptanceRun({ gateway: 'http://localhost:9000', overseasGateway: 'http://localhost:9300',
|
||||
test('unified-session refresh replaces the whole session only after a complete response', async () => {
|
||||
const run = new AcceptanceRun({ gateway: 'http://localhost:9000',
|
||||
username: 'contract-user', password: 'contract-password', modules: [], output: '' })
|
||||
Object.assign(run, { token: 'old-access', refreshToken: 'old-refresh', overseasToken: 'old-overseas', overseasExpiresAt: 1 })
|
||||
Object.assign(run, { token: 'old-access', refreshToken: 'old-refresh', expiresAt: 1 })
|
||||
run.request = async (method, path, body, token) => {
|
||||
assert.equal(method, 'POST'); assert.equal(path, '/api/auth/refresh')
|
||||
assert.deepEqual(body, { refreshToken: 'old-refresh' }); assert.equal(token, '')
|
||||
return { method, path, http: 200, body: { code: '200', data: {
|
||||
accessToken: 'new-access', refreshToken: 'new-refresh', overseasAccessToken: 'new-overseas',
|
||||
overseasExpiresIn: 900,
|
||||
accessToken: 'new-access', refreshToken: 'new-refresh',
|
||||
expiresIn: 900,
|
||||
} }, requestId: 'refresh-request', traceId: 'refresh-trace' }
|
||||
}
|
||||
assert.equal(await run.refreshSession('contract'), true)
|
||||
assert.equal(run.token, 'new-access')
|
||||
assert.equal(run.refreshToken, 'new-refresh')
|
||||
assert.equal(run.overseasToken, 'new-overseas')
|
||||
assert.ok(run.overseasExpiresAt > Date.now())
|
||||
assert.ok(run.expiresAt > Date.now())
|
||||
|
||||
const frozen = { token: run.token, refreshToken: run.refreshToken,
|
||||
overseasToken: run.overseasToken, overseasExpiresAt: run.overseasExpiresAt }
|
||||
expiresAt: run.expiresAt }
|
||||
run.request = async (method, path) => ({ method, path, http: 200,
|
||||
body: { code: '200', data: { accessToken: 'partial-only' } },
|
||||
requestId: 'partial-request', traceId: 'partial-trace' })
|
||||
assert.equal(await run.refreshSession('incomplete-contract'), false)
|
||||
assert.deepEqual({ token: run.token, refreshToken: run.refreshToken,
|
||||
overseasToken: run.overseasToken, overseasExpiresAt: run.overseasExpiresAt }, frozen)
|
||||
expiresAt: run.expiresAt }, frozen)
|
||||
})
|
||||
|
||||
test('entry has no built-in environment address or password and requires explicit execution options', () => {
|
||||
@@ -129,7 +115,7 @@ test('entry has no built-in environment address or password and requires explici
|
||||
assert.throws(() => validateExecutionOptions(parseArguments(['--modules', 'phase4-overseas-pda'])),
|
||||
/缺少必填执行参数/)
|
||||
const options = parseArguments([
|
||||
'--gateway', 'http://localhost:9000', '--overseas-gateway', 'http://localhost:9300',
|
||||
'--gateway', 'http://localhost:9000',
|
||||
'--username', 'contract-user', '--password', 'contract-password', '--modules', 'phase4-overseas-pda',
|
||||
'--pda-username', 'contract-picker', '--pda-password', 'contract-picker-password',
|
||||
])
|
||||
@@ -139,7 +125,7 @@ test('entry has no built-in environment address or password and requires explici
|
||||
test('loading dependency chains require PDA credentials before making any HTTP requests', () => {
|
||||
for (const module of ['phase3-loading', 'phase3-container-loading', 'phase5-overseas-fulfillment']) {
|
||||
const options = parseArguments(['--gateway', 'http://localhost:9000',
|
||||
'--overseas-gateway', 'http://localhost:9300', '--username', 'admin', '--password', 'fixture',
|
||||
'--username', 'admin', '--password', 'fixture',
|
||||
'--modules', module])
|
||||
options.pdaUsername = ''
|
||||
options.pdaPassword = ''
|
||||
@@ -152,7 +138,7 @@ test('loading dependency chains require PDA credentials before making any HTTP r
|
||||
})
|
||||
|
||||
test('core coverage gaps fail closed while device and external-provider gaps remain report-only', () => {
|
||||
const run = new AcceptanceRun({ gateway: 'http://localhost:9000', overseasGateway: 'http://localhost:9300',
|
||||
const run = new AcceptanceRun({ gateway: 'http://localhost:9000',
|
||||
username: 'contract-user', password: 'contract-password', modules: [], output: '' })
|
||||
run.addCoverageGap('phase4', 'core', 'fixture', 'missing fixture', 'fixture')
|
||||
assert.equal(run.steps.at(-1)?.passed, false)
|
||||
|
||||
+8
-8
@@ -11,14 +11,14 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class WarehouseDatabaseGuardConfigurationTest {
|
||||
@Test
|
||||
void applicationDefaultsTargetDedicatedOverseasWarehouseDatabase() throws Exception {
|
||||
var properties = new YamlPropertySourceLoader()
|
||||
.load("application", new ClassPathResource("application.yml"))
|
||||
.get(0);
|
||||
|
||||
assertThat(properties.getProperty("spring.datasource.url"))
|
||||
.asString()
|
||||
.contains("baiyue_lastlag_warehouse")
|
||||
void testConfigurationTargetsDedicatedWarehouseDatabaseViaRequiredExternalSettings() throws Exception {
|
||||
var root = java.nio.file.Path.of("..");
|
||||
String nacos = java.nio.file.Files.readString(root.resolve("config/nacos/test/warehouse-overseas-service.yml"));
|
||||
String compose = java.nio.file.Files.readString(root.resolve("deploy/docker-compose.yml"));
|
||||
String example = java.nio.file.Files.readString(root.resolve("deploy/env/.env.test.example"));
|
||||
assertThat(nacos).contains("url: ${WAREHOUSE_DB_URL}");
|
||||
assertThat(compose).contains("WAREHOUSE_DB_URL: ${WAREHOUSE_OVERSEAS_DB_URL}");
|
||||
assertThat(example).contains("WAREHOUSE_OVERSEAS_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_lastlag_warehouse")
|
||||
.doesNotContain("baiyue_warehouse_overseas");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user