package com.talentcard.server.business.merchant; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; import java.util.Map; import com.talentcard.server.business.merchant.auth.MerchantPrincipal; import com.talentcard.server.business.payment.huifu.HuifuOnboardingProgressGateway; 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.bean.override.mockito.MockitoBean; @SpringBootTest @ActiveProfiles("test") class MerchantExistingOnboardingTests { @Autowired JdbcTemplate jdbc; @Autowired MerchantSettlementService service; @MockitoBean HuifuOnboardingProgressGateway gateway; @Autowired org.springframework.web.context.WebApplicationContext context; @Autowired com.talentcard.server.business.admin.auth.AdminAuthService adminAuth; @Test void successfulDelayCallbackIgnoresLateFailureForTheSameAttempt() { MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal"); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","N","forced_delay_flag","N")); when(gateway.configureDelay(anyString(),anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_no","delay-apply")); service.refreshOnboarding(principal,false); var number=org.mockito.ArgumentCaptor.forClass(String.class); var date=org.mockito.ArgumentCaptor.forClass(String.class); verify(gateway).configureDelay(anyString(),number.capture(),date.capture()); assertThat(service.handleBusinessOpenCallback(Map.of("huifu_id","6666000241335656","req_seq_id",number.getValue(),"req_date",date.getValue(),"audit_status","Y","sub_resp_code","00000000"))).isTrue(); assertThat(service.handleBusinessOpenCallback(Map.of("huifu_id","6666000241335656","req_seq_id",number.getValue(),"req_date",date.getValue(),"audit_status","N","sub_resp_code","99999999"))).isTrue(); assertThat(service.refreshOnboarding(principal,true)).containsEntry("retryable",false); verify(gateway,times(1)).configureDelay(anyString(),anyString(),anyString()); } @Test void completesOnlyAfterEveryCapabilityAndTheOriginalCardAreConfirmed() { MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal"); jdbc.update("update merchant_bank_card set card_hash=? where merchant_id=?",sha256("622200001234"),principal.merchantId()); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S","bind_card_status","S")); var scenes=java.util.List.of("4","5","12").stream().map(scene -> Map.of("pay_scene",scene,"fee_rate","0.70","fee_min_amt","0.00","fee_charge_type","1","out_fee_flag","2", "wx_mer_infos",java.util.List.of(Map.of("bank_mer_code","wx-merchant","pay_channel_id","00016904","is_default","1")))).toList(); when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N","qry_wx_conf_list",scenes, "qry_settle_config_list",java.util.List.of(Map.of("settle_status","1","settle_cycle","D1","settle_pattern","P0","min_amt","0","remained_amt","0","token_no","original-card-token")), "qry_cash_card_info_list",java.util.List.of(Map.of("card_no","622200001234","token_no","original-card-token")))); when(gateway.queryWechatConfig(anyString())).thenReturn(Map.of("resp_code","00000000","atu_config_bo_list",java.util.List.of(Map.of("fee_type","01","wx_config_list",java.util.List.of(Map.of("config_type","1","config_value","wxcfee477acb54d179")))))); when(gateway.queryRealname(anyString())).thenReturn(Map.of("resp_code","00000000","applyment_stat","APPLYMENT_STATE_PASSED","authorize_stat","1")); assertThat(service.refreshOnboarding(principal,false)).containsEntry("status","approved"); assertThat(service.onboardingStatus(principal)).containsEntry("status","approved"); assertThat(service.bankCards(principal)).hasSize(1); verify(gateway,never()).configureDelay(anyString(),anyString(),anyString()); } private static String sha256(String value) { try { return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8))); } catch (Exception exception) { throw new IllegalStateException(exception); } } @Test void concurrentRefreshCannotCreateASecondDelayAttempt() throws Exception { MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal"); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","N","forced_delay_flag","N")); var entered=new java.util.concurrent.CountDownLatch(1); var release=new java.util.concurrent.CountDownLatch(1); when(gateway.configureDelay(anyString(),anyString(),anyString())).thenAnswer(call -> { entered.countDown(); if (!release.await(10,java.util.concurrent.TimeUnit.SECONDS)) throw new IllegalStateException("Test release timed out"); return Map.of("resp_code","00000000","apply_no","concurrent-apply"); }); var executor=java.util.concurrent.Executors.newSingleThreadExecutor(); try { var first=executor.submit(() -> service.refreshOnboarding(principal,false)); assertThat(entered.await(10,java.util.concurrent.TimeUnit.SECONDS)).isTrue(); service.refreshOnboarding(principal,true); release.countDown(); first.get(10,java.util.concurrent.TimeUnit.SECONDS); verify(gateway,times(1)).configureDelay(anyString(),anyString(),anyString()); } finally { release.countDown(); executor.shutdownNow(); } } @Test void retriesOnlyAnExplicitlyFailedDelayAttemptWithANewRequestNumber() { MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal"); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","N","forced_delay_flag","N")); when(gateway.configureDelay(anyString(),anyString(),anyString())).thenReturn(Map.of("resp_code","20002"),Map.of("resp_code","00000000","apply_no","retry-apply")); assertThat(service.refreshOnboarding(principal,false)).containsEntry("retryable",true); service.refreshOnboarding(principal,false); service.refreshOnboarding(principal,true); var requests=org.mockito.ArgumentCaptor.forClass(String.class); verify(gateway,times(2)).configureDelay(eq("6666000241335656"),requests.capture(),anyString()); assertThat(requests.getAllValues()).doesNotHaveDuplicates(); verify(gateway,never()).openWechat(anyString(),anyString(),anyMap()); } @Test void oldBasicFailureCallbackCannotRejectAnExistingVerifiedMerchantOrCard() { Map fixture=fixture(); MerchantPrincipal principal=(MerchantPrincipal)fixture.get("principal"); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","delay_flag","Y","forced_delay_flag","N")); service.refreshOnboarding(principal,false); service.handleOnboardingCallback(Map.of("req_seq_id",fixture.get("applicationNo"),"huifu_id","6666000241335656","audit_status","N","sub_resp_code","99999999")); assertThat(service.bankCards(principal)).allSatisfy(card -> assertThat(card.get("status")).isEqualTo("approved")); assertThat(service.onboardingStatus(principal)).containsEntry("status","pending"); } @Test void unknownDelayUpdateIsQueriedBeforeRetryAndNeverSubmittedTwice() { MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal"); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","N","forced_delay_flag","N")); when(gateway.configureDelay(anyString(),anyString(),anyString())).thenThrow(new com.talentcard.server.business.payment.huifu.HuifuPaymentGatewayException("timeout",true,null)); service.refreshOnboarding(principal,false); Map result=service.refreshOnboarding(principal,true); verify(gateway,times(1)).configureDelay(eq("6666000241335656"),matches("[a-f0-9]{32}"),matches("[0-9]{8}")); assertThat((java.util.List>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","delay").containsEntry("status","pending")); assertThat(result).containsEntry("retryable",false).containsEntry("status","pending"); } @Test void failedRealnameQueryPreservesConfirmedStateAndReportsQueryFailure() { MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal"); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","delay_flag","Y","forced_delay_flag","N")); when(gateway.queryRealname(anyString())).thenReturn(Map.of("resp_code","00000000","applyment_stat","APPLYMENT_STATE_PASSED","authorize_stat","1"),Map.of("resp_code","20002")); service.refreshOnboarding(principal,false); Map result=service.refreshOnboarding(principal,false); assertThat((java.util.List>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","realname").containsEntry("status","confirmed")); assertThat(result.get("syncError")).isEqualTo("汇付查询权限不足(20002),请联系平台运营核实接口权限"); } @Test void realnamePassedStillNeedsAuthorizationAndControlledAppIdIsNotReady() { MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal"); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","delay_flag","Y","forced_delay_flag","N")); when(gateway.queryWechatConfig(anyString())).thenReturn(Map.of("resp_code","00000000","atu_config_bo_list",java.util.List.of(Map.of("fee_type","01","wx_applet_app_id","wxcfee477acb54d179","wx_config_list",java.util.List.of(Map.of("config_type","1","config_value","wxcfee477acb54d179","control_flag","Y")))))); when(gateway.queryRealname(anyString())).thenReturn(Map.of("resp_code","00000000","applyment_stat","APPLYMENT_STATE_PASSED","authorize_stat","0")); Map result=service.refreshOnboarding(principal,false); assertThat((java.util.List>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","appId").containsEntry("status","action_required")); assertThat((java.util.List>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","realname").containsEntry("status","action_required")); assertThat(result).containsEntry("status","pending"); } @Test void ordinaryReadsExposeSameProgressWithoutCallingHuifu() { MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal"); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","delay_flag","N","forced_delay_flag","N")); Map refreshed=service.refreshOnboarding(principal,false); clearInvocations(gateway); assertThat(service.onboardingStatus(principal).get("stages")).isEqualTo(refreshed.get("stages")); verifyNoInteractions(gateway); } @Test void refreshRequiresSettlementEditPermissionEvenForAnAuthenticatedAdmin() throws Exception { String account="t02admin"+System.nanoTime(); jdbc.update("insert into admin_operator(account,password_hash,display_name,role_code,status,deleted) values (?,?,?,'t02-no-rights',1,0)",account, com.talentcard.server.business.merchant.auth.MerchantPasswordHasher.encode("TestPass123"),account); String token=String.valueOf(adminAuth.login(new com.talentcard.server.business.admin.auth.AdminLoginRequest(account,"TestPass123"),account).get("accessToken")); org.springframework.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup(context).build().perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/admin/management/merchant-settlement/onboardings/449/progress/refresh") .header("Authorization","Bearer "+token)).andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isForbidden()); verifyNoInteractions(gateway); } @Test void configuredWechatScenesStillRequireTalentAppAndAuthorization() { MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal"); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); var scenes=java.util.List.of("4","5","12").stream().map(scene -> Map.of("pay_scene",scene,"fee_rate","0.70","fee_min_amt","0.00","fee_charge_type","1","out_fee_flag","2", "wx_mer_infos",java.util.List.of(Map.of("bank_mer_code","wx-merchant","pay_channel_id","00016904","is_default","1")))).toList(); when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N","qry_wx_conf_list",scenes)); Map result=service.refreshOnboarding(principal,false); assertThat((java.util.List>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","wechat").containsEntry("status","confirmed")); assertThat(result).containsEntry("status","pending"); } @Test void confirmsD1OnlyFromEffectiveZeroThresholdBatchSettlement() { MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal"); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N", "qry_settle_config_list","[{\"settle_status\":\"1\",\"settle_cycle\":\"D1\",\"settle_pattern\":\"P0\",\"min_amt\":0,\"remained_amt\":0}]")); Map result=service.refreshOnboarding(principal,false); assertThat((java.util.List>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","settlement").containsEntry("status","confirmed")); assertThat(result).containsEntry("status","pending"); } @Test void submittingAgainCannotReplaceDesignatedExistingMerchantAfterConfigFailure() { Map application=fixture(); jdbc.update("update merchant_onboarding_application set wechat_config_status='failed' where application_no=?",application.get("applicationNo")); Map result=service.submitOnboarding((MerchantPrincipal)application.get("principal"),null,"T02-retry-"+System.nanoTime()); assertThat(result.get("applicationNo")).isEqualTo(application.get("applicationNo")); } @Test void missingDelayFieldsKeepLastConfirmedEvidenceAndReportIncompleteQuery() { MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal"); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","delay_flag","Y","forced_delay_flag","N"),Map.of("resp_code","00000000")); service.refreshOnboarding(principal,false); Map result=service.refreshOnboarding(principal,false); assertThat((java.util.List>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","delay").containsEntry("status","confirmed")); assertThat(result.get("syncError")).isEqualTo("汇付返回缺少延迟配置字段,已保留最后有效状态"); } @Test void capabilityQueryErrorKeepsConfirmedDelayAndReportsSyncError() { Map application=fixture(); MerchantPrincipal principal=(MerchantPrincipal)application.get("principal"); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","delay_flag","Y","forced_delay_flag","N"),Map.of("resp_code","20002","resp_desc","数据权限认证失败")); service.refreshOnboarding(principal,false); Map result=service.refreshOnboarding(principal,false); assertThat(result.get("syncError")).isEqualTo("汇付查询权限不足(20002),请联系平台运营核实接口权限"); assertThat((java.util.List>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","delay").containsEntry("status","confirmed")); } @Test void approvedBasicApplicationIsNotCompleteWithoutWechatAndFundsEvidence() { Map application=fixture(); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S","bind_card_status","S")); when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL")); Map result=service.refreshOnboarding((MerchantPrincipal)application.get("principal"),false); assertThat(result).containsEntry("status","pending"); assertThat((java.util.List>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","basic").containsEntry("status","confirmed")); assertThat(result.get("lastSyncedAt")).isNotEqualTo(""); } private Map fixture() { String key="T02-"+System.nanoTime(); jdbc.update("insert into merchant(merchant_name,status,deleted) values (?,1,0)",key); Long merchant=jdbc.queryForObject("select id from merchant where merchant_name=?",Long.class,key); jdbc.update("insert into merchant_store(merchant_id,store_name,status,deleted) values (?,?,1,0)",merchant,key); Long store=jdbc.queryForObject("select id from merchant_store where merchant_id=?",Long.class,merchant); 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) values (?,?,'Bank','Branch','510100','1234','****1234',?,'approved',?)",merchant,store,key,key); Long card=jdbc.queryForObject("select id from merchant_bank_card where idempotency_key=?",Long.class,key); 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,provider_apply_no,initial_bank_card_id) values (?, ?, ?, 'enterprise','Owner','Owner','13700000000',?,'pending','6666000241335656','original-apply',?)",merchant,store,key,key,card); return Map.of("principal",new MerchantPrincipal(1L,merchant,store,"owner"),"applicationNo",key); } @Test void queryPermissionErrorPreservesOriginalApplicationAndCard() { String key = "T02-" + System.nanoTime(); jdbc.update("insert into merchant(merchant_name,status,deleted) values (?,1,0)", key); Long merchant = jdbc.queryForObject("select id from merchant where merchant_name=?", Long.class,key); jdbc.update("insert into merchant_store(merchant_id,store_name,status,deleted) values (?,?,1,0)",merchant,key); Long store = jdbc.queryForObject("select id from merchant_store where merchant_id=?",Long.class,merchant); 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) values (?,?,'Bank','Branch','510100','1234','****1234',?,'approved',?)",merchant,store,key,key); Long card = jdbc.queryForObject("select id from merchant_bank_card where idempotency_key=?",Long.class,key); 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,provider_apply_no,initial_bank_card_id) values (?, ?, ?, 'enterprise','Owner','Owner','13700000000',?,'pending','6666000241335656','original-apply',?)",merchant,store,key,key,card); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","20002","resp_desc","数据权限认证失败")); Map result=service.refreshOnboarding(new MerchantPrincipal(1L,merchant,store,"owner"),false); assertThat(result).containsEntry("applicationNo",key).containsEntry("status","pending").containsEntry("syncError","汇付查询权限不足(20002),请联系平台运营核实接口权限"); assertThat(service.bankCards(new MerchantPrincipal(1L,merchant,store,"owner"))).isNotNull(); verify(gateway,never()).openWechat(any(),any(),any()); assertThat(jdbc.queryForObject("select initial_bank_card_id from merchant_onboarding_application where application_no=?",Long.class,key)).isEqualTo(card); } }