|
|
@@ -0,0 +1,142 @@
|
|
|
+package com.payment.platform.module.payment.leshouying.config;
|
|
|
+
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
|
|
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
|
|
+import org.springframework.context.annotation.Configuration;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+
|
|
|
+/**
|
|
|
+ * `validate()` 的**启动拒绝路径** —— 在**真容器**里跑,而不是直接调方法。
|
|
|
+ *
|
|
|
+ * <p>★ <b>为什么需要独立一条</b>(§11.2 第 7 项):{@link LeshouyingPropertiesTest} 用
|
|
|
+ * `p::validate` 直接调,覆盖的是**决策逻辑**;而本闸的承诺是**「启动中止」/「拒绝启动」**——
|
|
|
+ * 从「`validate()` 会抛」到「应用起不来」之间还隔着 Spring 的 `@PostConstruct` 调用链。
|
|
|
+ * 那段此前**从未被观测过**:全仓唯一的容器入口 `PaymentApplicationContextTest`
|
|
|
+ * (`@SpringBootTest`)吃 `src/main/resources/application.yml`,而那份 yml 第 13 行钉着
|
|
|
+ * `spring.profiles.active: dev` ⇒ 它走的是 `log.warn` 那条**放行**分支。
|
|
|
+ * 也就是说:拒绝分支在执行过的容器里一次都没响过。
|
|
|
+ *
|
|
|
+ * <p>★ <b>这道闸在生产的可达性(2026-09-23 实测)</b>:生产不是靠 yml 那个 `dev`
|
|
|
+ * 生效的 —— `docker-compose.yml:10`、`deploy.sh:16`、`docs/ops.md:21/134` 与
|
|
|
+ * `.claude/plan/java-deployment.md:121` 全部注入 `SPRING_PROFILES_ACTIVE=prod`,
|
|
|
+ * 覆盖掉 yml 的默认值。⇒ 「忘注入 `LESHOUYING_KEY` 就拿文档公开密钥收单」这条路是真的会被挡,
|
|
|
+ * 不是纸面承诺。
|
|
|
+ *
|
|
|
+ * <p>★ <b>量具的选择与代价</b>:`ApplicationContextRunner` 只注册本 bean 与绑定后处理器,
|
|
|
+ * **不扫描、不自动配置** ⇒ 不起 DataSource、不需要库隧道、不需要 Redis
|
|
|
+ * (区别于 `PaymentApplicationContextTest` 的已知硬依赖)。代价是它**不覆盖**
|
|
|
+ * 「本 bean 真的在 `@ComponentScan` 范围内」这件事 —— 那一条仍由
|
|
|
+ * `PaymentApplicationContextTest` 负责,两者不互相替代。
|
|
|
+ *
|
|
|
+ * <p>★ <b>三条臂是一次单变量对照</b>:臂一与臂二**只差** `spring.profiles.active` 一格,
|
|
|
+ * 其余属性逐字相同 ⇒ 臂一的失败只能是 profile 闸造成的,而不是「属性没绑上」。
|
|
|
+ * 臂二的断言就是为这个「只差一格」作证的。
|
|
|
+ */
|
|
|
+class LeshouyingPropertiesStartupTest {
|
|
|
+
|
|
|
+ /** 只注册这一个 bean + `@ConfigurationProperties` 绑定后处理器。 */
|
|
|
+ @Configuration(proxyBeanMethods = false)
|
|
|
+ @EnableConfigurationProperties(LeshouyingProperties.class)
|
|
|
+ static class Cfg {
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 与 {@code LeshouyingPropertiesTest.docTestProps()} 逐字同值,免得两处各自漂。 */
|
|
|
+ private static final String NOTIFY_URL =
|
|
|
+ "https://example.com/api/v1/payment/collection/notify/pay";
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 造出「`application.yml` 的三项默认值原样生效」的那个场景 ——
|
|
|
+ * 即**忘设环境变量**时的真实取值。三个常量本身就是 yml 里给的默认值。
|
|
|
+ */
|
|
|
+ private static ApplicationContextRunner docTestConfig() {
|
|
|
+ return new ApplicationContextRunner()
|
|
|
+ .withUserConfiguration(Cfg.class)
|
|
|
+ .withPropertyValues(
|
|
|
+ "leshouying.base-url=" + LeshouyingProperties.DOC_TEST_BASE_URL,
|
|
|
+ "leshouying.inst-no=" + LeshouyingProperties.DOC_TEST_INST_NO,
|
|
|
+ "leshouying.key=" + LeshouyingProperties.DOC_TEST_KEY,
|
|
|
+ "leshouying.notify-url=" + NOTIFY_URL);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 把异常链上的消息拼起来 —— 根因常常埋在 `BeanCreationException` 的 cause 里。 */
|
|
|
+ private static String chain(Throwable t) {
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
+ for (Throwable c = t; c != null; c = c.getCause()) {
|
|
|
+ sb.append(c.getClass().getSimpleName()).append(": ").append(c.getMessage()).append(" | ");
|
|
|
+ if (sb.length() > 4000) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return sb.toString();
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void prodProfile_withDocTestConfig_failsContextStartup() {
|
|
|
+ docTestConfig().withPropertyValues("spring.profiles.active=prod")
|
|
|
+ .run(context -> {
|
|
|
+ Throwable failure = context.getStartupFailure();
|
|
|
+ assertNotNull(failure,
|
|
|
+ "非 dev 环境拿文档测试配置时必须**起不来**(本闸的承诺就是「启动中止」)");
|
|
|
+ String all = chain(failure);
|
|
|
+ // 消息里带上整条链:万一断言不成立,读输出就能看见真正抛的是什么。
|
|
|
+ assertTrue(all.contains("文档测试"),
|
|
|
+ "起不来的根因必须是第二道闸(文档测试配置),实际链: " + all);
|
|
|
+ assertTrue(all.contains("拒绝启动"),
|
|
|
+ "必须是「拒绝启动」那条出口,实际链: " + all);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ <b>正对照</b>:同一份属性、只把 profile 换成 `dev` ⇒ 必须起得来。
|
|
|
+ *
|
|
|
+ * <p>两条断言各有分工,缺一条这组对照就不成立:
|
|
|
+ * <ul>
|
|
|
+ * <li>{@code getKey()} 等于文档测试密钥 ⇒ **属性绑定真的发生了**。否则臂一的「拒绝」
|
|
|
+ * 可能只是「`key` 绑成了 null,撞上第一道闸」,而根因断言恰好也会红 —— 但那时
|
|
|
+ * 臂一证明的不是 profile 闸。</li>
|
|
|
+ * <li>{@code getActiveProfiles()} 等于 `dev` ⇒ **`@Value` 真的解析到了**。否则两臂读到的
|
|
|
+ * 都是空串,而空串在 `isDevProfile()` 下按 fail-closed 算非 dev ⇒ 臂二也会抛,
|
|
|
+ * 「只差一格」的对照就退化成「两臂都以同一方式失败」。</li>
|
|
|
+ * </ul>
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void devProfile_withDocTestConfig_startsAndBinds() {
|
|
|
+ docTestConfig().withPropertyValues("spring.profiles.active=dev")
|
|
|
+ .run(context -> {
|
|
|
+ assertNull(context.getStartupFailure(),
|
|
|
+ "dev 下文档测试配置必须放行(开发期开箱即用),实际: "
|
|
|
+ + chain(context.getStartupFailure()));
|
|
|
+ LeshouyingProperties props = context.getBean(LeshouyingProperties.class);
|
|
|
+ assertEquals(LeshouyingProperties.DOC_TEST_KEY, props.getKey(),
|
|
|
+ "正对照:属性没有被绑定,臂一与臂二的差异不成立");
|
|
|
+ assertEquals("dev", props.getActiveProfiles(),
|
|
|
+ "正对照:profile 没有被解析到,两臂都在读空串");
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 第一道闸(缺配置)同样要在容器里生效,且**先于**第二道闸。
|
|
|
+ *
|
|
|
+ * <p>判据是消息里必须是「配置缺失」而**不是**「文档测试」:两道闸的顺序反了的话,
|
|
|
+ * 一个 `key` 留空的非 dev 环境会被报成「你在用文档测试配置」—— 把人指向错误的方向
|
|
|
+ * (真正的问题是没注入,不是注入了测试值)。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void prodProfile_withBlankKey_failsOnTheMissingConfigGateFirst() {
|
|
|
+ docTestConfig()
|
|
|
+ .withPropertyValues("spring.profiles.active=prod", "leshouying.key=")
|
|
|
+ .run(context -> {
|
|
|
+ Throwable failure = context.getStartupFailure();
|
|
|
+ assertNotNull(failure, "缺 key 必须起不来");
|
|
|
+ String all = chain(failure);
|
|
|
+ assertTrue(all.contains("配置缺失"), "实际链: " + all);
|
|
|
+ assertTrue(!all.contains("文档测试"),
|
|
|
+ "第一道闸必须先炸,不该报成「文档测试配置」把人指向错误方向。实际链: " + all);
|
|
|
+ });
|
|
|
+ }
|
|
|
+}
|