From 6c0a026cc489cf2568d7f1ea9aad0cd17ca513bb Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 29 Sep 2026 19:48:34 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=E5=8D=95=E6=9C=BATEST?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=85=A5=E5=8F=A3=E4=B8=8E=E5=8F=AF=E9=9D=A0?= =?UTF-8?q?=E6=8A=95=E9=80=92=E5=9B=9E=E5=BD=92=E6=94=B6=E5=B0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...izationFeignConfigurationContractTest.java | 96 +++++++++++ ...AuthTokenTtlConfigurationContractTest.java | 6 +- common-core/AGENTS.md | 1 + common-core/pom.xml | 2 +- .../core/outbox/EventDeliveryJournal.java | 2 +- common-web/AGENTS.md | 1 + common-web/pom.xml | 1 + .../common/web/EventDeliveryFailure.java | 3 +- .../common/web/EventDeliveryObservation.java | 1 + ...tDeliveryObservationAutoConfiguration.java | 2 + .../web/EventDeliveryObservationTest.java | 9 ++ config/nacos/test/auth-service.yml | 8 + .../config/CustomsFileExportContractTest.java | 7 +- ...esticWarehouseOrderExportContractTest.java | 5 +- .../ExportInfrastructureContractTest.java | 14 +- .../finance/FinanceExportContractTest.java | 11 +- firstleg/pom.xml | 1 + ...entBridgeClientDeploymentContractTest.java | 21 +-- ...omsMultipartConfigurationContractTest.java | 3 +- .../repository/OutboxLeaseDatabaseTest.java | 2 +- ...merTransitStatisticsRouteContractTest.java | 11 ++ .../DeploymentHeaderSizeContractTest.java | 5 +- .../config/GatewayCorsConfigurationTest.java | 11 ++ .../GatewayFileRouteConfigurationTest.java | 11 ++ ...fflineGatewayConfigurationInitializer.java | 43 +++++ .../resources/gateway-offline-test.properties | 2 + ...NotificationConfigurationContractTest.java | 6 +- ...imensionInitializationSqlContractTest.java | 12 +- .../Phase4BrowserApiBoundaryTest.java | 13 +- ...gPhotoUploadConfigurationContractTest.java | 4 +- ...InboundUnloadCompletedOutboxRelayTest.java | 1 + ...ticPartnerWarehouseClientContractTest.java | 31 ++-- .../warehouse/PdaWarehouseContractTest.java | 15 +- .../repository/OutboxLeaseDatabaseTest.java | 4 +- .../Phase5OverseasUpgradeSqlContractTest.java | 14 +- .../SchemaChineseCommentContractTest.java | 4 +- .../service/LoadingCompletedConsumerTest.java | 2 +- .../print/PrintServiceApplicationTest.java | 6 + scripts/AGENTS.md | 4 +- scripts/README.md | 23 +-- scripts/admin-test-env-acceptance.mjs | 143 +++------------- scripts/admin-test-env-modules/README.md | 8 +- .../customer-system-contract.test.mjs | 6 +- .../dropship-outbound-edge-cases.mjs | 2 +- .../dropship-pallet-matrix-contract.test.mjs | 2 +- .../dropship-pallet-matrix.mjs | 15 +- .../event-bridge-pending-recovery.mjs | 73 ++++----- .../finance-direct-paid-isolation.mjs | 14 +- ...merged-overseas-coverage-contract.test.mjs | 4 +- ...-phase4-producer-fixture-contract.test.mjs | 2 +- .../phase4-container-inbound-plan-batch.mjs | 15 +- ...ase4-cross-region-events-contract.test.mjs | 37 +++-- .../phase4-cross-region-events.mjs | 47 +++--- ...phase4-overseas-business-contract.test.mjs | 30 ++-- .../phase4-overseas-business.mjs | 63 +++----- ...overseas-client-services-contract.test.mjs | 4 +- .../phase4-overseas-client-services.mjs | 21 ++- .../phase4-overseas-dispatch.mjs | 8 +- ...eas-finance-cross-region-contract.test.mjs | 57 +++---- .../phase4-overseas-finance-cross-region.mjs | 25 ++- .../phase4-overseas-inbound-operations.mjs | 19 +-- ...se4-overseas-master-data-contract.test.mjs | 96 +++++------ .../phase4-overseas-master-data.mjs | 71 ++++---- .../phase4-overseas-pda-contract.test.mjs | 48 +++--- ...-overseas-pda-operations-contract.test.mjs | 2 +- .../phase4-overseas-pda-operations.mjs | 38 ++--- .../phase4-overseas-pda.mjs | 90 ++++------- .../phase4-overseas-return-followups.mjs | 26 +-- ...-overseas-return-receipt-contract.test.mjs | 16 +- .../phase4-overseas-return-receipt.mjs | 56 +++---- ...phase4-overseas-wallet-and-pallet-cost.mjs | 61 ++++--- ...hase4-overseas-warehouse-contract.test.mjs | 60 +++---- .../phase4-overseas-warehouse.mjs | 35 ++-- ...ase4-tail-order-controls-contract.test.mjs | 2 +- .../phase4-tail-order-controls.mjs | 29 ++-- ...se5-overseas-fulfillment-contract.test.mjs | 2 +- .../phase5-overseas-fulfillment.mjs | 28 ++-- ...eturn-boundary-scenarios-contract.test.mjs | 2 +- .../return-boundary-scenarios.mjs | 19 ++- .../return-review-contract.test.mjs | 2 +- scripts/admin-test-env-modules/system.mjs | 7 +- ...nt-bridge-dns-deployment-contract.test.mjs | 153 ++++++------------ ...illeg-owner-order-number-contract.test.mjs | 11 +- .../warehouse-transfer-review.mjs | 2 +- scripts/backend-full-regression.ps1 | 4 +- scripts/client-test-env-acceptance.mjs | 1 + .../full-regression-entry-contract.test.mjs | 9 +- ...verseas-acceptance-entry-contract.test.mjs | 52 +++--- ...rehouseDatabaseGuardConfigurationTest.java | 16 +- 89 files changed, 968 insertions(+), 983 deletions(-) create mode 100644 auth/src/test/java/com/baiyue/auth/client/AuthorizationFeignConfigurationContractTest.java create mode 100644 gateway/src/test/java/com/baiyue/gateway/config/OfflineGatewayConfigurationInitializer.java create mode 100644 gateway/src/test/resources/gateway-offline-test.properties diff --git a/auth/src/test/java/com/baiyue/auth/client/AuthorizationFeignConfigurationContractTest.java b/auth/src/test/java/com/baiyue/auth/client/AuthorizationFeignConfigurationContractTest.java new file mode 100644 index 000000000..fe67e14bb --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/client/AuthorizationFeignConfigurationContractTest.java @@ -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 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(); + } + } +} diff --git a/auth/src/test/java/com/baiyue/auth/config/AuthTokenTtlConfigurationContractTest.java b/auth/src/test/java/com/baiyue/auth/config/AuthTokenTtlConfigurationContractTest.java index 0b7440f31..d597d7097 100644 --- a/auth/src/test/java/com/baiyue/auth/config/AuthTokenTtlConfigurationContractTest.java +++ b/auth/src/test/java/com/baiyue/auth/config/AuthTokenTtlConfigurationContractTest.java @@ -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}"); } } diff --git a/common-core/AGENTS.md b/common-core/AGENTS.md index 564b68cfd..e725c4e21 100644 --- a/common-core/AGENTS.md +++ b/common-core/AGENTS.md @@ -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` | ## 修改与验证 diff --git a/common-core/pom.xml b/common-core/pom.xml index 9aef71cb6..956d0ca79 100644 --- a/common-core/pom.xml +++ b/common-core/pom.xml @@ -24,7 +24,7 @@ spring-boot-starter-test test - org.springframeworkspring-jdbc + org.springframeworkspring-jdbctrue com.h2databaseh2test diff --git a/common-core/src/main/java/com/baiyue/common/core/outbox/EventDeliveryJournal.java b/common-core/src/main/java/com/baiyue/common/core/outbox/EventDeliveryJournal.java index c5b00f76b..4783d1b52 100644 --- a/common-core/src/main/java/com/baiyue/common/core/outbox/EventDeliveryJournal.java +++ b/common-core/src/main/java/com/baiyue/common/core/outbox/EventDeliveryJournal.java @@ -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(); diff --git a/common-web/AGENTS.md b/common-web/AGENTS.md index 6e566731f..edc326106 100644 --- a/common-web/AGENTS.md +++ b/common-web/AGENTS.md @@ -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` 标记 | diff --git a/common-web/pom.xml b/common-web/pom.xml index 47be8e5ac..084dd86b8 100644 --- a/common-web/pom.xml +++ b/common-web/pom.xml @@ -14,6 +14,7 @@ baiyue-common-web + org.springframeworkspring-jdbctrue com.baiyue.sharedcommon-event-contract${project.version} com.h2databaseh2test diff --git a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryFailure.java b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryFailure.java index 341bd0e36..3210dbd2e 100644 --- a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryFailure.java +++ b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryFailure.java @@ -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; diff --git a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservation.java b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservation.java index dc184aef3..8d38e26b0 100644 --- a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservation.java +++ b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservation.java @@ -19,6 +19,7 @@ public final class EventDeliveryObservation { public static String errorCode(Throwable error, boolean permanent, boolean requestStarted) { Set 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(); } diff --git a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservationAutoConfiguration.java b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservationAutoConfiguration.java index efa4c05f8..35c6225f8 100644 --- a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservationAutoConfiguration.java +++ b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservationAutoConfiguration.java @@ -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) { diff --git a/common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationTest.java b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationTest.java index 83f6a9ef8..036c54ca4 100644 --- a/common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationTest.java +++ b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationTest.java @@ -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"}) { diff --git a/config/nacos/test/auth-service.yml b/config/nacos/test/auth-service.yml index 10ae3fae1..9e4613181 100644 --- a/config/nacos/test/auth-service.yml +++ b/config/nacos/test/auth-service.yml @@ -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} diff --git a/file/src/test/java/com/baiyue/file/config/CustomsFileExportContractTest.java b/file/src/test/java/com/baiyue/file/config/CustomsFileExportContractTest.java index 29fef5e9e..72945f2c6 100644 --- a/file/src/test/java/com/baiyue/file/config/CustomsFileExportContractTest.java +++ b/file/src/test/java/com/baiyue/file/config/CustomsFileExportContractTest.java @@ -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") diff --git a/file/src/test/java/com/baiyue/file/config/DomesticWarehouseOrderExportContractTest.java b/file/src/test/java/com/baiyue/file/config/DomesticWarehouseOrderExportContractTest.java index 97bcb5f18..f676b361d 100644 --- a/file/src/test/java/com/baiyue/file/config/DomesticWarehouseOrderExportContractTest.java +++ b/file/src/test/java/com/baiyue/file/config/DomesticWarehouseOrderExportContractTest.java @@ -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") diff --git a/file/src/test/java/com/baiyue/file/config/ExportInfrastructureContractTest.java b/file/src/test/java/com/baiyue/file/config/ExportInfrastructureContractTest.java index 41bcb25fa..4cd57f188 100644 --- a/file/src/test/java/com/baiyue/file/config/ExportInfrastructureContractTest.java +++ b/file/src/test/java/com/baiyue/file/config/ExportInfrastructureContractTest.java @@ -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 { diff --git a/finance/src/test/java/com/baiyue/finance/FinanceExportContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceExportContractTest.java index eed2eb18e..a56e35abc 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceExportContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceExportContractTest.java @@ -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"); } } diff --git a/firstleg/pom.xml b/firstleg/pom.xml index 98dc43916..a9e9e245a 100644 --- a/firstleg/pom.xml +++ b/firstleg/pom.xml @@ -5,6 +5,7 @@ com.baiyue.firstlegbaiyue-firstleg-platform1.0.0-SNAPSHOT../pom.xml firstleg-service + com.h2databaseh2test org.springframework.bootspring-boot-starter-mail com.baiyue.sharedbaiyue-common-core${project.version} com.baiyue.sharedcommon-event-contract${project.version} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/client/EventBridgeClientDeploymentContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/client/EventBridgeClientDeploymentContractTest.java index b22423e5f..e3e80589e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/client/EventBridgeClientDeploymentContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/client/EventBridgeClientDeploymentContractTest.java @@ -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")); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMultipartConfigurationContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMultipartConfigurationContractTest.java index ccbc57e4b..7a5a60e89 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMultipartConfigurationContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMultipartConfigurationContractTest.java @@ -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 { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/OutboxLeaseDatabaseTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/OutboxLeaseDatabaseTest.java index 63cf7e5df..edfd11d77 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/OutboxLeaseDatabaseTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/OutboxLeaseDatabaseTest.java @@ -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(); diff --git a/gateway/src/test/java/com/baiyue/gateway/config/CustomerTransitStatisticsRouteContractTest.java b/gateway/src/test/java/com/baiyue/gateway/config/CustomerTransitStatisticsRouteContractTest.java index bd212811f..bab8c2557 100644 --- a/gateway/src/test/java/com/baiyue/gateway/config/CustomerTransitStatisticsRouteContractTest.java +++ b/gateway/src/test/java/com/baiyue/gateway/config/CustomerTransitStatisticsRouteContractTest.java @@ -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", diff --git a/gateway/src/test/java/com/baiyue/gateway/config/DeploymentHeaderSizeContractTest.java b/gateway/src/test/java/com/baiyue/gateway/config/DeploymentHeaderSizeContractTest.java index 38549c6f4..a3206b626 100644 --- a/gateway/src/test/java/com/baiyue/gateway/config/DeploymentHeaderSizeContractTest.java +++ b/gateway/src/test/java/com/baiyue/gateway/config/DeploymentHeaderSizeContractTest.java @@ -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 { diff --git a/gateway/src/test/java/com/baiyue/gateway/config/GatewayCorsConfigurationTest.java b/gateway/src/test/java/com/baiyue/gateway/config/GatewayCorsConfigurationTest.java index 09755984a..b5cb63453 100644 --- a/gateway/src/test/java/com/baiyue/gateway/config/GatewayCorsConfigurationTest.java +++ b/gateway/src/test/java/com/baiyue/gateway/config/GatewayCorsConfigurationTest.java @@ -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", diff --git a/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java b/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java index e792921e3..e5f7900c9 100644 --- a/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java +++ b/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java @@ -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", diff --git a/gateway/src/test/java/com/baiyue/gateway/config/OfflineGatewayConfigurationInitializer.java b/gateway/src/test/java/com/baiyue/gateway/config/OfflineGatewayConfigurationInitializer.java new file mode 100644 index 000000000..2529d85dd --- /dev/null +++ b/gateway/src/test/java/com/baiyue/gateway/config/OfflineGatewayConfigurationInitializer.java @@ -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 { + @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(); + 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); + } + } +} diff --git a/gateway/src/test/resources/gateway-offline-test.properties b/gateway/src/test/resources/gateway-offline-test.properties new file mode 100644 index 000000000..5b0687cbb --- /dev/null +++ b/gateway/src/test/resources/gateway-offline-test.properties @@ -0,0 +1,2 @@ +# Offline bootstrap only. OfflineGatewayConfigurationInitializer loads the production +# application.yml after config-data processing, excluding only Nacos import keys. diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationConfigurationContractTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationConfigurationContractTest.java index a21f4e66b..197357173 100644 --- a/iam/src/test/java/com/baiyue/iam/notification/NotificationConfigurationContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationConfigurationContractTest.java @@ -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) { diff --git a/iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionInitializationSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionInitializationSqlContractTest.java index 0ddd9a741..048bebeaa 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionInitializationSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionInitializationSqlContractTest.java @@ -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) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java index 890f61a38..85e786ae1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java @@ -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 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingPhotoUploadConfigurationContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingPhotoUploadConfigurationContractTest.java index 137aaf146..253251f31 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingPhotoUploadConfigurationContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingPhotoUploadConfigurationContractTest.java @@ -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}"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java index 28ea51299..b88483d96 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java @@ -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; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/client/DomesticPartnerWarehouseClientContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/client/DomesticPartnerWarehouseClientContractTest.java index 0d0c6e580..9751fd95c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/client/DomesticPartnerWarehouseClientContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/client/DomesticPartnerWarehouseClientContractTest.java @@ -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"); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/warehouse/PdaWarehouseContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/warehouse/PdaWarehouseContractTest.java index a31c6267b..928c94501 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/warehouse/PdaWarehouseContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/warehouse/PdaWarehouseContractTest.java @@ -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 authorities, Long warehouseId) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java index 3cddb32a8..e7de9f255 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java @@ -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(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5OverseasUpgradeSqlContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5OverseasUpgradeSqlContractTest.java index e31035a1e..f27e39a69 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5OverseasUpgradeSqlContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5OverseasUpgradeSqlContractTest.java @@ -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 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java index 8e0607d16..bc57114ec 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java @@ -39,7 +39,9 @@ class SchemaChineseCommentContractTest { List 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); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index c6c9c3a00..6b714df95 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -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 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()); diff --git a/print/src/test/java/com/baiyue/print/PrintServiceApplicationTest.java b/print/src/test/java/com/baiyue/print/PrintServiceApplicationTest.java index 13ac78693..d390c9b63 100644 --- a/print/src/test/java/com/baiyue/print/PrintServiceApplicationTest.java +++ b/print/src/test/java/com/baiyue/print/PrintServiceApplicationTest.java @@ -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 { diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 0fb99aaa6..afb1f0ca0 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -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` | diff --git a/scripts/README.md b/scripts/README.md index 203657136..21641aa81 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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 契约、状态机、乐观锁和幂等操作; diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index f76b6f140..252af883c 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -286,7 +286,6 @@ function usage() { 选项: --gateway 必填,国内 Gateway 地址 - --overseas-gateway 四期海外模块必填,海外 Gateway 地址 --username 必填,管理员账号 --password 必填,管理员密码 --pda-username 国内装柜链必填,实际领取任务的 PDA 作业员账号 @@ -315,7 +314,7 @@ function usage() { --output 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, diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 3178c1d80..4b97b0a51 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -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 --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 --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,不改表或伪造新事件。 diff --git a/scripts/admin-test-env-modules/customer-system-contract.test.mjs b/scripts/admin-test-env-modules/customer-system-contract.test.mjs index 80f236ad0..939156479 100644 --- a/scripts/admin-test-env-modules/customer-system-contract.test.mjs +++ b/scripts/admin-test-env-modules/customer-system-contract.test.mjs @@ -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)/) diff --git a/scripts/admin-test-env-modules/dropship-outbound-edge-cases.mjs b/scripts/admin-test-env-modules/dropship-outbound-edge-cases.mjs index 9e36a4786..c8db998b8 100644 --- a/scripts/admin-test-env-modules/dropship-outbound-edge-cases.mjs +++ b/scripts/admin-test-env-modules/dropship-outbound-edge-cases.mjs @@ -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) { diff --git a/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs b/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs index 157ce23ef..d82528c10 100644 --- a/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs +++ b/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs @@ -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), } diff --git a/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs b/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs index cdde8ec20..f5bbb2025 100644 --- a/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs +++ b/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs @@ -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 diff --git a/scripts/admin-test-env-modules/event-bridge-pending-recovery.mjs b/scripts/admin-test-env-modules/event-bridge-pending-recovery.mjs index d7257fb53..3595ac9ee 100644 --- a/scripts/admin-test-env-modules/event-bridge-pending-recovery.mjs +++ b/scripts/admin-test-env-modules/event-bridge-pending-recovery.mjs @@ -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 } diff --git a/scripts/admin-test-env-modules/finance-direct-paid-isolation.mjs b/scripts/admin-test-env-modules/finance-direct-paid-isolation.mjs index e4fa6a647..df2ea3c86 100644 --- a/scripts/admin-test-env-modules/finance-direct-paid-isolation.mjs +++ b/scripts/admin-test-env-modules/finance-direct-paid-isolation.mjs @@ -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 } } diff --git a/scripts/admin-test-env-modules/merged-overseas-coverage-contract.test.mjs b/scripts/admin-test-env-modules/merged-overseas-coverage-contract.test.mjs index ccbe7dbb7..d4c47053d 100644 --- a/scripts/admin-test-env-modules/merged-overseas-coverage-contract.test.mjs +++ b/scripts/admin-test-env-modules/merged-overseas-coverage-contract.test.mjs @@ -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) }) diff --git a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs index c3a878e98..246ce07a0 100644 --- a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs @@ -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/) diff --git a/scripts/admin-test-env-modules/phase4-container-inbound-plan-batch.mjs b/scripts/admin-test-env-modules/phase4-container-inbound-plan-batch.mjs index 1abc83093..b68039627 100644 --- a/scripts/admin-test-env-modules/phase4-container-inbound-plan-batch.mjs +++ b/scripts/admin-test-env-modules/phase4-container-inbound-plan-batch.mjs @@ -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, diff --git a/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs b/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs index e5a7988ad..978042d97 100644 --- a/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs @@ -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 () => { diff --git a/scripts/admin-test-env-modules/phase4-cross-region-events.mjs b/scripts/admin-test-env-modules/phase4-cross-region-events.mjs index 6df4b4c2a..c02993209 100644 --- a/scripts/admin-test-env-modules/phase4-cross-region-events.mjs +++ b/scripts/admin-test-env-modules/phase4-cross-region-events.mjs @@ -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') } diff --git a/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs index 63a70ecf9..a345815dc 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs @@ -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 () => { diff --git a/scripts/admin-test-env-modules/phase4-overseas-business.mjs b/scripts/admin-test-env-modules/phase4-overseas-business.mjs index d0a361cdf..352fb78f0 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-business.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-business.mjs @@ -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) diff --git a/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs index d5d99517b..ae66774fe 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs @@ -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) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs index 29231889c..6a8572cce 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs @@ -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) diff --git a/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs b/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs index 5f5337eb9..d60708ea8 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs @@ -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), diff --git a/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region-contract.test.mjs index 619c25d38..15abb7e4c 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region-contract.test.mjs @@ -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) diff --git a/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region.mjs b/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region.mjs index 96b565e71..f33e8fda0 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region.mjs @@ -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, diff --git a/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs b/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs index da39febde..a660ab7e5 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs @@ -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, diff --git a/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs index 641fadaad..5d1c559ef 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs @@ -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分钟/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs b/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs index d2e5c4e48..8230c10db 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs @@ -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` } : {}), }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index f873cb56c..89392a80b 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -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 }) }, diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs index 052e5d152..4753aff30 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs @@ -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返工重新确认箱标阶段/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs index 3f3bd98be..7afe31a09 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs @@ -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', diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 3702cf39e..146c65592 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -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('')), }, 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') { diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs index 36eecc1db..776c34df1 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs @@ -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`, { diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs index e6a42f051..ba64512e8 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs @@ -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, /已完成退货单不再进入待签收流程/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs index 3fbdfa1a1..016f68832 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs @@ -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) diff --git a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs index efb735e5e..ac32a2cbc 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs @@ -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 diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs index 8b889591a..05c8e17db 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs @@ -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) diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs index 7328e5768..58430d4c6 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs @@ -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) diff --git a/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs b/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs index 6225b8682..407f4d12c 100644 --- a/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs @@ -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/) diff --git a/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs b/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs index cb53399f0..fb81380b1 100644 --- a/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs +++ b/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs @@ -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, diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs index 4d1b7b095..513e079e5 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs @@ -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', diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs index 7e0d38940..7005eaab7 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs @@ -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))), diff --git a/scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs b/scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs index 20dd73f1e..da1505fc2 100644 --- a/scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs +++ b/scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs @@ -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'\]/) diff --git a/scripts/admin-test-env-modules/return-boundary-scenarios.mjs b/scripts/admin-test-env-modules/return-boundary-scenarios.mjs index 1654dfa06..514596acd 100644 --- a/scripts/admin-test-env-modules/return-boundary-scenarios.mjs +++ b/scripts/admin-test-env-modules/return-boundary-scenarios.mjs @@ -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) diff --git a/scripts/admin-test-env-modules/return-review-contract.test.mjs b/scripts/admin-test-env-modules/return-review-contract.test.mjs index 8076f8576..595b87a04 100644 --- a/scripts/admin-test-env-modules/return-review-contract.test.mjs +++ b/scripts/admin-test-env-modules/return-review-contract.test.mjs @@ -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') diff --git a/scripts/admin-test-env-modules/system.mjs b/scripts/admin-test-env-modules/system.mjs index 9b2ad4273..45add7123 100644 --- a/scripts/admin-test-env-modules/system.mjs +++ b/scripts/admin-test-env-modules/system.mjs @@ -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), diff --git a/scripts/admin-test-env-modules/test-event-bridge-dns-deployment-contract.test.mjs b/scripts/admin-test-env-modules/test-event-bridge-dns-deployment-contract.test.mjs index 56515796a..ecd20ccfd 100644 --- a/scripts/admin-test-env-modules/test-event-bridge-dns-deployment-contract.test.mjs +++ b/scripts/admin-test-env-modules/test-event-bridge-dns-deployment-contract.test.mjs @@ -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) }) diff --git a/scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs b/scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs index a78f190b8..0757cdc17 100644 --- a/scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs +++ b/scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs @@ -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/) }) diff --git a/scripts/admin-test-env-modules/warehouse-transfer-review.mjs b/scripts/admin-test-env-modules/warehouse-transfer-review.mjs index f86c2fa1c..bfa891e43 100644 --- a/scripts/admin-test-env-modules/warehouse-transfer-review.mjs +++ b/scripts/admin-test-env-modules/warehouse-transfer-review.mjs @@ -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, diff --git a/scripts/backend-full-regression.ps1 b/scripts/backend-full-regression.ps1 index 0fa1ee893..ff1d2e60a 100644 --- a/scripts/backend-full-regression.ps1 +++ b/scripts/backend-full-regression.ps1 @@ -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)) { diff --git a/scripts/client-test-env-acceptance.mjs b/scripts/client-test-env-acceptance.mjs index ad6943a31..b028d889c 100644 --- a/scripts/client-test-env-acceptance.mjs +++ b/scripts/client-test-env-acceptance.mjs @@ -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('平台审核账号', '') diff --git a/scripts/full-regression-entry-contract.test.mjs b/scripts/full-regression-entry-contract.test.mjs index 74939ae8a..7f7ffb1f0 100644 --- a/scripts/full-regression-entry-contract.test.mjs +++ b/scripts/full-regression-entry-contract.test.mjs @@ -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/) }) diff --git a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs index c21e5a98f..fdb4551bc 100644 --- a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs +++ b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs @@ -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) diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfigurationTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfigurationTest.java index 2f2ef9751..87d7baf34 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfigurationTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfigurationTest.java @@ -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"); }