package com.talentcard.server.business.payment; import static org.assertj.core.api.Assertions.assertThat; import java.lang.reflect.Field; import java.nio.charset.StandardCharsets; import java.util.regex.Matcher; import java.util.regex.Pattern; import com.talentcard.server.business.payment.huifu.HuifuPaymentProperties; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.test.context.ActiveProfiles; /** * 定时任务的开关与间隔必须能从配置读、且默认值一致。 * *

为什么单独测这个:定时对账是支付与退款唯一的后台兜底,而它原先「注解默认关、配置里打开」—— * 一次配置编辑删掉那一行,兜底就**静默消失**;交易确认补偿的开关更是只写在注释里、配置键从未被读取。 * 这些都不会被任何业务用例发现,所以直接对着代码与配置的形状断言。 */ @SpringBootTest @ActiveProfiles("test") class PaymentSchedulingConfigurationTests { @Test void everyPaymentBackgroundTaskCanBeReadFromTheConfigurationFile() throws Exception { String yml = configurationFile(); assertThat(yml) .as("对账间隔要写进配置,不能只靠注解默认值") .contains("fixed-delay-ms: ${HUIFU_RECONCILIATION_FIXED_DELAY_MS:") .contains("enabled: ${HUIFU_RECONCILIATION_ENABLED:true}"); assertThat(yml) .as("交易确认补偿的开关要真的存在且可读") .contains("enabled: ${FUND_CONFIRMATION_RETRY_ENABLED:true}"); assertThat(yml) .as("悬账自动退款的开关要真的存在且可读") .contains("enabled: ${STRANDED_REFUND_ENABLED:true}"); } @Test void theReconciliationSwitchDefaultsToOnInTheCodeAsWell() throws Exception { assertThat(defaultExpression(PaymentReconciliationScheduler.class, "enabled")) .as("注解上的默认值必须与配置一致:默认关 + 配置打开,就等于一行配置编辑能关掉兜底") .isEqualTo("${app.payment.huifu.reconciliation.enabled:true}"); assertThat(PaymentReconciliationScheduler.class.getMethod("reconcilePendingTransactions") .getAnnotation(Scheduled.class).fixedDelayString()) .as("注解默认值必须与配置里的默认值一致:两边不一致时,删掉配置行就会悄悄改变行为") .isEqualTo("${app.payment.huifu.reconciliation.fixed-delay-ms:" + configuredReconciliationDelay() + "}"); assertThat(configuredReconciliationDelay()) .as("扫描间隔同时是关单补偿的重试间隔(快车道的资格窗口是 1 分钟,见 CLOSE_RETRY_MINUTES):" + "比它长,设计里那句「关单每分钟重试一次、最多三次」就不成立,用户看到的是点了取消一直卡着") .isEqualTo("60000"); } @Test void theFundConfirmationRetrySwitchIsImplementedAndDefaultsToOn() throws Exception { assertThat(defaultExpression(PaymentFundConfirmationRetry.class, "enabled")) .as("注释里宣称有这个开关,代码就必须真的读它") .isEqualTo("${app.payment.fund-confirmation-retry.enabled:true}"); } @Test void everyHuifuCallbackUrlIsReportedAndBlankOnesAreCalledOut() { HuifuPaymentProperties properties = new HuifuPaymentProperties(); properties.setNotifyUrl("https://example.com/api/payments/huifu/notify"); HuifuCallbackUrlAudit audit = new HuifuCallbackUrlAudit(properties); assertThat(audit.report()) .as("五个回调端点都要有结论(地址或「未启用」),而不是静默地什么都不说") .containsOnlyKeys("notify", "merchant-business-notify", "merchant-onboarding-notify", "merchant-wechat-config-notify", "merchant-business-open-notify") .allSatisfy((endpoint, url) -> assertThat(url).isNotBlank()); properties.setNotifyUrl(""); assertThat(audit.report()) .as("任何一个回调地址为空时都必须报「回调未启用」,而不是留一个不可达的假地址") .allSatisfy((endpoint, url) -> assertThat(url).isEmpty()); } private String defaultExpression(Class type, String fieldName) throws Exception { Field field = type.getDeclaredField(fieldName); return field.getAnnotation(Value.class).value(); } /** 配置里写的对账间隔默认值(毫秒),供「两处默认值必须一致」的断言使用。 */ private String configuredReconciliationDelay() throws Exception { Matcher matcher = Pattern.compile("HUIFU_RECONCILIATION_FIXED_DELAY_MS:(\\d+)").matcher(configurationFile()); assertThat(matcher.find()).as("配置里要给出对账间隔的默认值").isTrue(); return matcher.group(1); } private String configurationFile() throws Exception { try (var stream = getClass().getResourceAsStream("/application.yml")) { assertThat(stream).as("application.yml 必须在类路径上").isNotNull(); return new String(stream.readAllBytes(), StandardCharsets.UTF_8); } } }