| New file |
| | |
| | | package com.talentcard.server.business.merchant; |
| | | |
| | | import static org.assertj.core.api.Assertions.assertThat; |
| | | import static org.assertj.core.api.Assertions.assertThatThrownBy; |
| | | import static org.mockito.ArgumentMatchers.any; |
| | | import static org.mockito.Mockito.mockingDetails; |
| | | import static org.mockito.Mockito.never; |
| | | import static org.mockito.Mockito.times; |
| | | import static org.mockito.Mockito.verify; |
| | | import static org.mockito.Mockito.when; |
| | | |
| | | import java.util.Map; |
| | | |
| | | import com.talentcard.server.business.payment.huifu.HuifuMerchantCapabilityGateway; |
| | | import com.talentcard.server.business.payment.huifu.HuifuMerchantCapabilityQueryCommand; |
| | | import com.talentcard.server.common.BusinessException; |
| | | import org.junit.jupiter.api.BeforeEach; |
| | | import org.junit.jupiter.api.Test; |
| | | import org.springframework.beans.factory.annotation.Autowired; |
| | | import org.springframework.boot.test.context.SpringBootTest; |
| | | import org.springframework.jdbc.core.JdbcTemplate; |
| | | import org.springframework.test.context.ActiveProfiles; |
| | | import org.springframework.test.context.TestPropertySource; |
| | | import org.springframework.test.context.bean.override.mockito.MockitoBean; |
| | | |
| | | /** |
| | | * 商户端的收款能力自查({@code GET /api/merchant/payment-readiness})与手动核实 |
| | | * ({@code POST /api/merchant/payment-readiness/verify})。 |
| | | * |
| | | * <p>锁定的行为:判定与下单闸门同源同文案、只读的判定绝不查通道、手动核实有冷却(成功与失败都 |
| | | * 节流),以及没有已通过的进件时按既有规则拒绝。 |
| | | */ |
| | | @SpringBootTest |
| | | @ActiveProfiles("test") |
| | | @TestPropertySource(properties = { |
| | | "app.payment.huifu.enabled=true", |
| | | "app.payment.huifu.merchant-capability-required=true" |
| | | }) |
| | | class MerchantPaymentReadinessMerchantViewTests { |
| | | |
| | | @Autowired |
| | | private JdbcTemplate jdbc; |
| | | |
| | | @Autowired |
| | | private MerchantPaymentReadinessService readinessService; |
| | | |
| | | @MockitoBean |
| | | private HuifuMerchantCapabilityGateway capabilityGateway; |
| | | |
| | | private Long merchantId; |
| | | private Long storeId; |
| | | private Long applicationId; |
| | | |
| | | @BeforeEach |
| | | void prepareMerchant() { |
| | | String suffix = String.valueOf(System.nanoTime()); |
| | | jdbc.update("insert into merchant(merchant_name, status, deleted) values (?, 1, 0)", "Readiness view merchant " + suffix); |
| | | merchantId = jdbc.queryForObject("select max(id) from merchant", Long.class); |
| | | jdbc.update("insert into merchant_store(merchant_id, store_name, status, deleted) values (?, ?, 1, 0)", merchantId, "Readiness view store " + suffix); |
| | | storeId = jdbc.queryForObject("select max(id) from merchant_store", Long.class); |
| | | jdbc.update(""" |
| | | insert into merchant_onboarding_application(merchant_id, store_id, application_no, subject_type, subject_name, |
| | | contact_name, contact_mobile, idempotency_key, status, provider_huifu_id) |
| | | values (?, ?, ?, 'individual', 'Readiness view owner', 'Readiness view owner', '13800000000', ?, 'approved', ?) |
| | | """, merchantId, storeId, "MO-RDV-" + suffix, "readiness-view-" + suffix, shortReference("H", suffix)); |
| | | applicationId = jdbc.queryForObject("select max(id) from merchant_onboarding_application", Long.class); |
| | | jdbc.update(""" |
| | | insert into merchant_bank_card(merchant_id, store_id, bank_name, branch_name, city_code, card_tail, card_masked, |
| | | card_hash, status, idempotency_key, provider_token_no) |
| | | values (?, ?, '中国建设银行', '攀枝花东区支行', '510400', '6666', '**** **** **** 6666', ?, 'approved', ?, ?) |
| | | """, merchantId, storeId, "hash-readiness-view-" + suffix, "readiness-view-card-" + suffix, shortReference("T", suffix)); |
| | | Long bankCardId = jdbc.queryForObject("select max(id) from merchant_bank_card", Long.class); |
| | | jdbc.update("update merchant_onboarding_application set initial_bank_card_id=? where id=?", bankCardId, applicationId); |
| | | jdbc.update("insert into merchant_onboarding_progress(application_id, stages_json) values (?, '{\"wechat\":\"confirmed\",\"realname\":\"confirmed\"}')", applicationId); |
| | | } |
| | | |
| | | /** 只读的判定不查通道:快照的新鲜度由定时核实与手动核实负责。 */ |
| | | @Test |
| | | void merchantViewNeverQueriesTheProvider() { |
| | | stubProvider("NORMAL", "0", "1"); |
| | | readinessService.refresh(applicationId); |
| | | |
| | | int before = mockingDetails(capabilityGateway).getInvocations().size(); |
| | | Map<String, Object> view = readinessService.merchantView(merchantId, storeId); |
| | | |
| | | assertThat(view).containsEntry("ready", true).containsEntry("verifyAvailable", true); |
| | | assertThat(String.valueOf(view.get("verifiedAt"))).isNotBlank(); |
| | | assertThat(mockingDetails(capabilityGateway).getInvocations().size()).isEqualTo(before); |
| | | } |
| | | |
| | | /** |
| | | * 页面上的失败原因与下单被拒的文案必须逐字一致——同一个事实不能有两套口径。 |
| | | * 这里直接拿闸门抛出的那句话来比对。 |
| | | */ |
| | | @Test |
| | | void merchantViewReusesTheGateMessage() { |
| | | stubProvider("FREEZEWITHDRAWAL", "0", "1"); |
| | | readinessService.refresh(applicationId); |
| | | |
| | | String viewReason = String.valueOf(readinessService.merchantView(merchantId, storeId).get("reason")); |
| | | |
| | | assertThat(viewReason).contains("FREEZEWITHDRAWAL"); |
| | | assertThatThrownBy(() -> readinessService.requireCollectingMerchant(merchantId, storeId)) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessage(viewReason); |
| | | } |
| | | |
| | | /** 进件还没通过时也打开这个页面:给结论,不查通道。 */ |
| | | @Test |
| | | void merchantViewBlocksWithoutAnApprovedApplication() { |
| | | jdbc.update("update merchant_onboarding_application set status='pending' where id=?", applicationId); |
| | | |
| | | Map<String, Object> view = readinessService.merchantView(merchantId, storeId); |
| | | |
| | | assertThat(view).containsEntry("ready", false).containsEntry("verifyAvailable", false); |
| | | assertThat(String.valueOf(view.get("reason"))).contains("进件"); |
| | | assertThat(String.valueOf(view.get("verifiedAt"))).isEmpty(); |
| | | verify(capabilityGateway, never()).query(any(HuifuMerchantCapabilityQueryCommand.class)); |
| | | } |
| | | |
| | | @Test |
| | | void merchantVerifyQueriesTheProviderOnceAndThenKeepsTheCooldown() { |
| | | stubProvider("NORMAL", "0", "1"); |
| | | |
| | | Map<String, Object> verified = readinessService.merchantVerify(merchantId, storeId); |
| | | |
| | | assertThat(verified).containsEntry("refreshed", true).containsEntry("ready", true).containsEntry("verifyAvailable", false); |
| | | assertThat(String.valueOf(verified.get("verifiedAt"))).isNotBlank(); |
| | | verify(capabilityGateway, times(1)).query(any(HuifuMerchantCapabilityQueryCommand.class)); |
| | | |
| | | Map<String, Object> throttled = readinessService.merchantVerify(merchantId, storeId); |
| | | |
| | | assertThat(throttled).containsEntry("refreshed", false).containsEntry("ready", true); |
| | | verify(capabilityGateway, times(1)).query(any(HuifuMerchantCapabilityQueryCommand.class)); |
| | | } |
| | | |
| | | /** 通道查不通时恰恰最需要节流:失败不推进 capability_verified_at,冷却必须照样生效。 */ |
| | | @Test |
| | | void merchantVerifyThrottlesEvenWhenTheProviderQueryFails() { |
| | | when(capabilityGateway.query(any(HuifuMerchantCapabilityQueryCommand.class))) |
| | | .thenReturn(HuifuMerchantCapabilityGateway.CapabilityResult.failed("99999999", "商户不存在")); |
| | | |
| | | Map<String, Object> failed = readinessService.merchantVerify(merchantId, storeId); |
| | | |
| | | assertThat(failed).containsEntry("refreshed", true).containsEntry("ready", false); |
| | | assertThat(String.valueOf(failed.get("reason"))).contains("待确认"); |
| | | assertThat(readinessService.merchantVerify(merchantId, storeId)).containsEntry("refreshed", false); |
| | | verify(capabilityGateway, times(1)).query(any(HuifuMerchantCapabilityQueryCommand.class)); |
| | | } |
| | | |
| | | @Test |
| | | void merchantVerifyQueriesAgainAfterTheCooldownExpires() { |
| | | stubProvider("NORMAL", "0", "1"); |
| | | readinessService.merchantVerify(merchantId, storeId); |
| | | jdbc.update("update merchant_onboarding_application set capability_sync_until=date_sub(now(), interval 1 minute) where id=?", applicationId); |
| | | |
| | | Map<String, Object> again = readinessService.merchantVerify(merchantId, storeId); |
| | | |
| | | assertThat(again).containsEntry("refreshed", true).containsEntry("verifyAvailable", false); |
| | | verify(capabilityGateway, times(2)).query(any(HuifuMerchantCapabilityQueryCommand.class)); |
| | | } |
| | | |
| | | /** 没有已通过的进件(或没有商户号)时不查通道:与闸门的拒绝规则一致。 */ |
| | | @Test |
| | | void merchantVerifyRefusesWithoutAnApprovedApplication() { |
| | | jdbc.update("update merchant_onboarding_application set status='pending' where id=?", applicationId); |
| | | |
| | | assertThatThrownBy(() -> readinessService.merchantVerify(merchantId, storeId)) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessageContaining("进件"); |
| | | verify(capabilityGateway, never()).query(any(HuifuMerchantCapabilityQueryCommand.class)); |
| | | } |
| | | |
| | | /** Provider identifiers are limited to 32 characters and huifu ids to 18, so tests keep them short. */ |
| | | private String shortReference(String prefix, String suffix) { |
| | | return prefix + suffix.substring(Math.max(0, suffix.length() - 8)); |
| | | } |
| | | |
| | | private void stubProvider(String merchantStatus, String settleStatus, String cashSwitch) { |
| | | when(capabilityGateway.query(any(HuifuMerchantCapabilityQueryCommand.class))) |
| | | .thenReturn(new HuifuMerchantCapabilityGateway.CapabilityResult(true, "00000000", "成功", merchantStatus, settleStatus, "D1", cashSwitch, "DM")); |
| | | } |
| | | } |