|
|
@@ -0,0 +1,318 @@
|
|
|
+package com.payment.platform.module.payment.har.controller;
|
|
|
+
|
|
|
+import com.payment.platform.common.exception.BusinessException;
|
|
|
+import com.payment.platform.common.response.Result;
|
|
|
+import com.payment.platform.core.security.LoginUser;
|
|
|
+import com.payment.platform.module.payment.har.client.HarApiException;
|
|
|
+import com.payment.platform.module.payment.har.dto.HarCheckoutInfoVO;
|
|
|
+import com.payment.platform.module.payment.har.dto.HarCheckoutOrderVO;
|
|
|
+import com.payment.platform.module.payment.har.dto.HarCheckoutPayDTO;
|
|
|
+import com.payment.platform.module.payment.har.dto.HarOrderCreateDTO;
|
|
|
+import com.payment.platform.module.payment.har.dto.HarOrderVO;
|
|
|
+import com.payment.platform.module.payment.har.entity.HarCheckoutLinkEntity;
|
|
|
+import com.payment.platform.module.payment.har.service.HarCheckoutLinkService;
|
|
|
+import com.payment.platform.module.payment.har.service.HarMerchantService;
|
|
|
+import com.payment.platform.module.payment.har.service.HarOrderService;
|
|
|
+import org.junit.jupiter.api.AfterEach;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.junit.jupiter.api.extension.ExtendWith;
|
|
|
+import org.mockito.ArgumentCaptor;
|
|
|
+import org.mockito.Mock;
|
|
|
+import org.mockito.junit.jupiter.MockitoExtension;
|
|
|
+import org.springframework.security.core.Authentication;
|
|
|
+import org.springframework.security.core.context.SecurityContextHolder;
|
|
|
+
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.*;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.ArgumentMatchers.isNull;
|
|
|
+import static org.mockito.Mockito.*;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 顾客页(收款链接)两个端点。
|
|
|
+ *
|
|
|
+ * <p>本类钉住的都是**这条腿独有**的东西 —— 同形的落库装配在收银台那条腿上有自己的用例表:
|
|
|
+ * <ol>
|
|
|
+ * <li><b>租户身份只能来自链接那一行</b>,且必须在**任何一次库操作之前**绑进
|
|
|
+ * SecurityContext。这条路径上的三张表({@code pay_har_checkout_link} /
|
|
|
+ * {@code pay_har_merchant} / {@code pay_har_order})全在
|
|
|
+ * {@code TenantInnerInterceptor.CONDITIONAL_TABLES} 里,而拦截器对它们
|
|
|
+ * <b>无认证时是跳过过滤</b>的 ⇒ 不绑租户时整条路径跑在**没有任何租户谓词**的状态下;
|
|
|
+ * 绑上之后拦截器会独立地再写一遍同一个边界。
|
|
|
+ * ★ 这是**纵深防御**,不是「不绑就查不到」—— 别把这条读成原先有 bug;</li>
|
|
|
+ * <li><b>请求体里只有价格一个字段</b>:商户编号 / 标题 / 交易方式 / 支付时限全部来自
|
|
|
+ * 链接那一行与服务端常数。企业号与 apiKey 传 null —— 顾客页不是下游系统;</li>
|
|
|
+ * <li><b>金额下限在控制器里再判一次</b>:本仓所有控制器用例都是普通方法直调,
|
|
|
+ * Spring 的参数校验一步都不参与 ⇒ 只靠 DTO 注解的话,这一层没有任何守卫。
|
|
|
+ * 且下限是<b>含等号</b>的(1 元整放行);</li>
|
|
|
+ * <li><b>上游失败只回一句顾客能照做的话</b>:上游原话(「下单失败: …」「调用上游失败: …」)
|
|
|
+ * 一个字都不许出给顾客。上游失败在 HAR 会分成**两类**抛出({@code BusinessException}
|
|
|
+ * = 上游明确拒绝,{@code HarApiException} = 网络/解析/通道未启用且 {@code create}
|
|
|
+ * 不捕它),两类都要收住。</li>
|
|
|
+ * </ol>
|
|
|
+ *
|
|
|
+ * <p>★ <b>本类覆盖不到、由别的用例守着的那半边</b>:{@code pay_har_checkout_link} 必须登记在
|
|
|
+ * {@code TenantInnerInterceptor.CONDITIONAL_TABLES}、{@code /payment/har/checkout/**} 必须在
|
|
|
+ * {@code SecurityConfig.WHITE_LIST}(漏登记 ⇒ 未认证请求被 JWT 过滤器拦下)。
|
|
|
+ * 那是装配事实,钉它的是 {@code HarCheckoutWiringTest},不是这里。
|
|
|
+ */
|
|
|
+@ExtendWith(MockitoExtension.class)
|
|
|
+class HarCheckoutControllerTest {
|
|
|
+
|
|
|
+ private static final Long TENANT = 42L;
|
|
|
+ private static final String MCH = "M001";
|
|
|
+
|
|
|
+ @Mock private HarCheckoutLinkService linkService;
|
|
|
+ @Mock private HarMerchantService merchantService;
|
|
|
+ @Mock private HarOrderService orderService;
|
|
|
+
|
|
|
+ private HarCheckoutController controller;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ controller = new HarCheckoutController(linkService, merchantService, orderService);
|
|
|
+ SecurityContextHolder.clearContext();
|
|
|
+ }
|
|
|
+
|
|
|
+ @AfterEach
|
|
|
+ void tearDown() {
|
|
|
+ SecurityContextHolder.clearContext();
|
|
|
+ }
|
|
|
+
|
|
|
+ private static HarCheckoutLinkEntity link() {
|
|
|
+ HarCheckoutLinkEntity e = new HarCheckoutLinkEntity();
|
|
|
+ e.setId(9L);
|
|
|
+ e.setTenantId(TENANT);
|
|
|
+ e.setMerchantCode(MCH);
|
|
|
+ e.setToken("tok");
|
|
|
+ e.setMerchantName("小店");
|
|
|
+ e.setStatus("ENABLED");
|
|
|
+ return e;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static HarCheckoutPayDTO pay(String yuan) {
|
|
|
+ HarCheckoutPayDTO d = new HarCheckoutPayDTO();
|
|
|
+ d.setPrice(yuan == null ? null : new BigDecimal(yuan));
|
|
|
+ return d;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static HarOrderVO paidVo(String payUrl) {
|
|
|
+ HarOrderVO vo = new HarOrderVO();
|
|
|
+ vo.setOrderNo("OR123");
|
|
|
+ vo.setPayUrl(payUrl);
|
|
|
+ vo.setPrice(new BigDecimal("12.34"));
|
|
|
+ return vo;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ─────────────────────────── ① 读商户显示名 ───────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void info_returnsMerchantNameSnapshot() {
|
|
|
+ when(linkService.requireByToken("tok")).thenReturn(link());
|
|
|
+
|
|
|
+ Result<?> r = controller.info("tok");
|
|
|
+
|
|
|
+ assertEquals(0, r.getCode(), "0 = 成功(Result.ok 的口径,不是 HTTP 200)");
|
|
|
+ assertNotNull(r.getData());
|
|
|
+ assertEquals("小店", ((HarCheckoutInfoVO) r.getData()).getMerchantName());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void info_unknownLink_404_andNothingElseIsTouched() {
|
|
|
+ when(linkService.requireByToken("nope"))
|
|
|
+ .thenThrow(new BusinessException(404, "收款链接无效或已停用"));
|
|
|
+
|
|
|
+ BusinessException ex = assertThrows(BusinessException.class, () -> controller.info("nope"));
|
|
|
+
|
|
|
+ assertEquals(404, ex.getCode());
|
|
|
+ verifyNoInteractions(merchantService, orderService);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ─────────────────────────── ② 下单 ───────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void create_belowFloor_400_withTheSameHintAsEveryOtherEntry() {
|
|
|
+ when(linkService.requireByToken("tok")).thenReturn(link());
|
|
|
+
|
|
|
+ BusinessException ex = assertThrows(BusinessException.class,
|
|
|
+ () -> controller.create("tok", pay("0.99")));
|
|
|
+
|
|
|
+ assertEquals(400, ex.getCode());
|
|
|
+ assertTrue(ex.getMessage().contains("不能低于 1 元"), "实际: " + ex.getMessage());
|
|
|
+ // ★ 一次商户查询、一次下单都不许发生 —— 顾客只会看到一句能照做的话。
|
|
|
+ verifyNoInteractions(merchantService, orderService);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void create_nullPrice_400() {
|
|
|
+ when(linkService.requireByToken("tok")).thenReturn(link());
|
|
|
+
|
|
|
+ assertEquals(400, assertThrows(BusinessException.class,
|
|
|
+ () -> controller.create("tok", pay(null))).getCode());
|
|
|
+ verifyNoInteractions(merchantService, orderService);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 空报文(没有 body)也是 400,不是 NPE —— 免登录入口不该把一场异常泄漏成 500 */
|
|
|
+ @Test
|
|
|
+ void create_nullBody_400_notNpe() {
|
|
|
+ when(linkService.requireByToken("tok")).thenReturn(link());
|
|
|
+
|
|
|
+ assertEquals(400, assertThrows(BusinessException.class,
|
|
|
+ () -> controller.create("tok", null)).getCode());
|
|
|
+ verifyNoInteractions(merchantService, orderService);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 下限**含等号**:1 元整是一笔合法的单(挡的是 0.99 这一类,不是 1 元)。 */
|
|
|
+ @Test
|
|
|
+ void create_floorExactlyOneYuan_isAccepted() {
|
|
|
+ when(linkService.requireByToken("tok")).thenReturn(link());
|
|
|
+ when(orderService.create(any(), any(), any())).thenReturn(paidVo("https://pay/x"));
|
|
|
+
|
|
|
+ Result<?> r = controller.create("tok", pay("1"));
|
|
|
+
|
|
|
+ assertEquals(0, r.getCode(), "1 元整必须放行");
|
|
|
+ ArgumentCaptor<HarOrderCreateDTO> captor = ArgumentCaptor.forClass(HarOrderCreateDTO.class);
|
|
|
+ verify(orderService).create(captor.capture(), isNull(), isNull());
|
|
|
+ assertEquals(0, new BigDecimal("1").compareTo(captor.getValue().getPrice()));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void create_assemblesTheOrderFromTheLinkRow_andDelegatesWithNullEnterpriseAndApiKey() {
|
|
|
+ when(linkService.requireByToken("tok")).thenReturn(link());
|
|
|
+ when(orderService.create(any(), any(), any())).thenReturn(paidVo("https://pay/x"));
|
|
|
+
|
|
|
+ controller.create("tok", pay("12.34"));
|
|
|
+
|
|
|
+ ArgumentCaptor<HarOrderCreateDTO> captor = ArgumentCaptor.forClass(HarOrderCreateDTO.class);
|
|
|
+ verify(orderService).create(captor.capture(), isNull(), isNull());
|
|
|
+ HarOrderCreateDTO create = captor.getValue();
|
|
|
+
|
|
|
+ // 寻址:商户编号来自链接那一行,请求体里没有它
|
|
|
+ assertEquals(MCH, create.getMerchantCode());
|
|
|
+
|
|
|
+ // 金额原样带下去(元)
|
|
|
+ assertEquals(0, new BigDecimal("12.34").compareTo(create.getPrice()));
|
|
|
+
|
|
|
+ // 主扫腿的形状(与收银台同值;两条腿各持一份常数,见控制器 javadoc 的差异说明)
|
|
|
+ assertEquals("扫码收款", create.getTitle(),
|
|
|
+ "标题是固定文案:merchant_name 是 VARCHAR(128) 而 title 是 @Size(64),"
|
|
|
+ + "拿商户名当标题会让长名商户的每一笔支付在本地校验处 400");
|
|
|
+ assertEquals("alipay_un_stage", create.getTradeType());
|
|
|
+ assertEquals("har_ali_un_stage", create.getBiz());
|
|
|
+ assertEquals(15, create.getPayTimeout().intValue());
|
|
|
+
|
|
|
+ // 有意留空的两格
|
|
|
+ assertNull(create.getEnterpriseId(), "顾客页没有企业维度:显式商户编号即租户级寻址");
|
|
|
+ assertNull(create.getAuthCode(), "没有付款码 ⇒ 主扫(precreate),不是被扫(pay)");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void create_setsTenantContextForEveryDbOperationAndRestoresItAfterwards() {
|
|
|
+ when(linkService.requireByToken("tok")).thenReturn(link());
|
|
|
+
|
|
|
+ List<Long> seen = new ArrayList<>();
|
|
|
+ when(merchantService.requireByTenantAndCode(TENANT, MCH)).thenAnswer(inv -> {
|
|
|
+ seen.add(currentTenantId());
|
|
|
+ return null;
|
|
|
+ });
|
|
|
+ when(orderService.create(any(), any(), any())).thenAnswer(inv -> {
|
|
|
+ seen.add(currentTenantId());
|
|
|
+ return paidVo("https://pay/x");
|
|
|
+ });
|
|
|
+
|
|
|
+ controller.create("tok", pay("5"));
|
|
|
+
|
|
|
+ assertEquals(List.of(TENANT, TENANT), seen,
|
|
|
+ "本地闸与下单腿都必须跑在「该链接所属租户」的身份下 —— 得到的是拦截器那一条"
|
|
|
+ + "独立写出的租户谓词(纵深防御:手写 WHERE 写错时另一条还在)");
|
|
|
+ assertNull(SecurityContextHolder.getContext().getAuthentication(),
|
|
|
+ "请求结束后必须把上下文还原(这是容器的线程,不是我们自己的线程)");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 链接指向的商户行没了(删配置/改号)⇒ 404 且**不发单**。
|
|
|
+ *
|
|
|
+ * <p>本地闸排在订单侧任何动作之前;它也让这条出口保持 404,而不是被下面
|
|
|
+ * 「上游失败」的 catch 折叠成 500 —— 两种处境的操作员改法完全不同。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void create_missingMerchantRow_404_andNoOrderLeg_andContextRestored() {
|
|
|
+ when(linkService.requireByToken("tok")).thenReturn(link());
|
|
|
+ when(merchantService.requireByTenantAndCode(TENANT, MCH))
|
|
|
+ .thenThrow(new BusinessException(404, "商户编号不存在或不属于当前租户: " + MCH));
|
|
|
+
|
|
|
+ assertEquals(404, assertThrows(BusinessException.class,
|
|
|
+ () -> controller.create("tok", pay("5"))).getCode());
|
|
|
+ verifyNoInteractions(orderService);
|
|
|
+ assertNull(SecurityContextHolder.getContext().getAuthentication(),
|
|
|
+ "抛出的那条路径也要还原上下文(finally)");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void create_upstreamRejects_returnsBusinessFailure_withoutUpstreamWording() {
|
|
|
+ when(linkService.requireByToken("tok")).thenReturn(link());
|
|
|
+ when(orderService.create(any(), any(), any()))
|
|
|
+ .thenThrow(new BusinessException("下单失败: 上游返回 40004: 参数错误"));
|
|
|
+
|
|
|
+ Result<?> r = controller.create("tok", pay("5"));
|
|
|
+
|
|
|
+ assertEquals(500, r.getCode());
|
|
|
+ assertEquals("支付发起失败,请稍后重试", r.getMsg());
|
|
|
+ assertFalse(r.getMsg().contains("下单失败"), "上游的原话一个字都不许出给顾客");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 通道层失败(网络/解析/未启用)走的是 {@code HarApiException} —— {@code create}
|
|
|
+ * **不捕它**,它会一路穿出服务层。对顾客与「上游明确拒绝」是同一件事:这次没发起成功,
|
|
|
+ * 再按一次(再按会生成新的单号,重试完全安全)。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void create_upstreamTransportFailure_returnsBusinessFailure_too() {
|
|
|
+ when(linkService.requireByToken("tok")).thenReturn(link());
|
|
|
+ when(orderService.create(any(), any(), any()))
|
|
|
+ .thenThrow(new HarApiException("调用上游失败: connection reset"));
|
|
|
+
|
|
|
+ Result<?> r = controller.create("tok", pay("5"));
|
|
|
+
|
|
|
+ assertEquals(500, r.getCode());
|
|
|
+ assertEquals("支付发起失败,请稍后重试", r.getMsg());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void create_blankPayUrl_returns500() {
|
|
|
+ when(linkService.requireByToken("tok")).thenReturn(link());
|
|
|
+ when(orderService.create(any(), any(), any())).thenReturn(paidVo(null));
|
|
|
+
|
|
|
+ Result<?> r = controller.create("tok", pay("5"));
|
|
|
+
|
|
|
+ assertEquals(500, r.getCode(), "主扫的全部意义就是那条链接,没有它这笔单在页面上画不出来");
|
|
|
+ assertEquals("支付发起失败,请稍后重试", r.getMsg());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void create_success_returnsOrderNoPayUrlAndPrice() {
|
|
|
+ when(linkService.requireByToken("tok")).thenReturn(link());
|
|
|
+ when(orderService.create(any(), any(), any())).thenReturn(paidVo("https://pay/x"));
|
|
|
+
|
|
|
+ Result<?> r = controller.create("tok", pay("12.34"));
|
|
|
+
|
|
|
+ assertEquals(0, r.getCode());
|
|
|
+ HarCheckoutOrderVO vo = (HarCheckoutOrderVO) r.getData();
|
|
|
+ assertEquals("OR123", vo.getOrderNo());
|
|
|
+ assertEquals("https://pay/x", vo.getPayUrl(),
|
|
|
+ "给顾客的是**上游**的支付链接本身,不要包装成我们自己的地址");
|
|
|
+ assertEquals(0, new BigDecimal("12.34").compareTo(vo.getPrice()));
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Long currentTenantId() {
|
|
|
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
|
|
+ if (auth != null && auth.getPrincipal() instanceof LoginUser u) {
|
|
|
+ return u.getTenantId();
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+}
|