|
|
@@ -0,0 +1,249 @@
|
|
|
+package com.payment.platform.module.payment.unified.controller;
|
|
|
+
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
+import com.payment.platform.common.config.JacksonConfig;
|
|
|
+import com.payment.platform.common.exception.GlobalExceptionHandler;
|
|
|
+import com.payment.platform.module.payment.unified.controller.open.UnifiedOpenController;
|
|
|
+import com.payment.platform.module.payment.unified.dto.req.UnifiedMerchantSaveDTO;
|
|
|
+import com.payment.platform.module.payment.unified.dto.req.UnifiedOrderCreateCommand;
|
|
|
+import com.payment.platform.module.payment.unified.dto.req.UnifiedRefundCreateCommand;
|
|
|
+import com.payment.platform.module.payment.unified.service.UnifiedChannelUserService;
|
|
|
+import com.payment.platform.module.payment.unified.service.UnifiedMerchantService;
|
|
|
+import com.payment.platform.module.payment.unified.service.UnifiedOrderService;
|
|
|
+import com.payment.platform.module.payment.unified.service.UnifiedRefundService;
|
|
|
+import com.payment.platform.module.payment.unified.util.UnifiedTenantGuard;
|
|
|
+import jakarta.validation.Validation;
|
|
|
+import jakarta.validation.Validator;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.springframework.http.MediaType;
|
|
|
+import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
|
|
|
+import org.springframework.mock.web.MockHttpServletRequest;
|
|
|
+import org.springframework.test.web.servlet.MockMvc;
|
|
|
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
|
|
+
|
|
|
+import java.util.Set;
|
|
|
+import java.util.stream.Collectors;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+import static org.mockito.Mockito.mock;
|
|
|
+import static org.mockito.Mockito.verifyNoInteractions;
|
|
|
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
|
|
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
|
|
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 请求字段上限的 Bean Validation 覆盖(评审 M6)。
|
|
|
+ *
|
|
|
+ * <p>两类证据各一份:
|
|
|
+ * <ol>
|
|
|
+ * <li><b>Validator 单测</b>:逐字段钉住 DDL 列宽对应的 {@code @Size} 与
|
|
|
+ * {@code expiredTime} 的 {@code @Positive} —— 包括合法边界值的正对照;</li>
|
|
|
+ * <li><b>HTTP 层</b>:超长值必须被 400 拒绝,而不是穿过校验后在入库时炸成 500。
|
|
|
+ * 用 standalone MockMvc + 生产 {@link GlobalExceptionHandler},
|
|
|
+ * 断言的是线上同一个异常映射。</li>
|
|
|
+ * </ol>
|
|
|
+ */
|
|
|
+class UnifiedValidationHttpTest {
|
|
|
+
|
|
|
+ private static final Validator VALIDATOR = Validation.buildDefaultValidatorFactory().getValidator();
|
|
|
+
|
|
|
+ /** 生产 mapper:SNAKE_CASE 请求体 + 未知字段报错,和线上 Web 层同一支量具。 */
|
|
|
+ private static final ObjectMapper GLOBAL_MAPPER = new JacksonConfig().objectMapper();
|
|
|
+
|
|
|
+ // ────────────────────────────── Validator 单测 ──────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void orderCreateCommand_documentedLimits() {
|
|
|
+ // 正对照:合法值零违规(否则下面的「有违规」可能只是量具恒红)
|
|
|
+ assertTrue(violations(validOrder()).isEmpty(),
|
|
|
+ "合法订单请求不应有违规,读数: " + violations(validOrder()));
|
|
|
+
|
|
|
+ UnifiedOrderCreateCommand expiredZero = validOrder();
|
|
|
+ expiredZero.setExpiredTime(0);
|
|
|
+ assertTrue(violations(expiredZero).contains("expiredTime"),
|
|
|
+ "expiredTime=0 必须被 @Positive 拒绝,读数: " + violations(expiredZero));
|
|
|
+
|
|
|
+ UnifiedOrderCreateCommand expiredOne = validOrder();
|
|
|
+ expiredOne.setExpiredTime(1);
|
|
|
+ assertFalse(violations(expiredOne).contains("expiredTime"),
|
|
|
+ "expiredTime=1 是合法下界,读数: " + violations(expiredOne));
|
|
|
+
|
|
|
+ UnifiedOrderCreateCommand longWayCode = validOrder();
|
|
|
+ longWayCode.setWayCode("X".repeat(17));
|
|
|
+ assertTrue(violations(longWayCode).contains("wayCode"),
|
|
|
+ "wayCode 17 字符必须被拒绝,读数: " + violations(longWayCode));
|
|
|
+
|
|
|
+ UnifiedOrderCreateCommand longMchNo = validOrder();
|
|
|
+ longMchNo.setMchNo("M".repeat(65));
|
|
|
+ assertTrue(violations(longMchNo).contains("mchNo"),
|
|
|
+ "mchNo 65 字符必须被拒绝,读数: " + violations(longMchNo));
|
|
|
+
|
|
|
+ UnifiedOrderCreateCommand longClientIp = validOrder();
|
|
|
+ longClientIp.setClientIp("1".repeat(65));
|
|
|
+ assertTrue(violations(longClientIp).contains("clientIp"),
|
|
|
+ "clientIp 65 字符必须被拒绝,读数: " + violations(longClientIp));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void refundCreateCommand_documentedLimits() {
|
|
|
+ assertTrue(violations(validRefund()).isEmpty(),
|
|
|
+ "合法退款请求不应有违规,读数: " + violations(validRefund()));
|
|
|
+
|
|
|
+ UnifiedRefundCreateCommand longNotifyUrl = validRefund();
|
|
|
+ longNotifyUrl.setNotifyUrl("h".repeat(513));
|
|
|
+ assertTrue(violations(longNotifyUrl).contains("notifyUrl"),
|
|
|
+ "notifyUrl 513 字符必须被拒绝,读数: " + violations(longNotifyUrl));
|
|
|
+
|
|
|
+ UnifiedRefundCreateCommand longExtParam = validRefund();
|
|
|
+ longExtParam.setExtParam("e".repeat(513));
|
|
|
+ assertTrue(violations(longExtParam).contains("extParam"),
|
|
|
+ "extParam 513 字符必须被拒绝,读数: " + violations(longExtParam));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void merchantSaveDTO_documentedLimits() {
|
|
|
+ assertTrue(violations(validMerchant()).isEmpty(),
|
|
|
+ "合法商户配置不应有违规,读数: " + violations(validMerchant()));
|
|
|
+
|
|
|
+ assertTrue(violations(merchantWithMchNo("M".repeat(65))).contains("mchNo"));
|
|
|
+ assertTrue(violations(merchantWithAppId("a".repeat(65))).contains("appId"));
|
|
|
+
|
|
|
+ UnifiedMerchantSaveDTO longSecret = validMerchant();
|
|
|
+ longSecret.setAppSecret("s".repeat(129));
|
|
|
+ assertTrue(violations(longSecret).contains("appSecret"));
|
|
|
+
|
|
|
+ UnifiedMerchantSaveDTO longName = validMerchant();
|
|
|
+ longName.setMerchantName("名".repeat(129));
|
|
|
+ assertTrue(violations(longName).contains("merchantName"));
|
|
|
+
|
|
|
+ UnifiedMerchantSaveDTO longIfCode = validMerchant();
|
|
|
+ longIfCode.setChannelUserIfCode("i".repeat(17));
|
|
|
+ assertTrue(violations(longIfCode).contains("channelUserIfCode"));
|
|
|
+
|
|
|
+ UnifiedMerchantSaveDTO longStatus = validMerchant();
|
|
|
+ longStatus.setStatus("0".repeat(11));
|
|
|
+ assertTrue(violations(longStatus).contains("status"));
|
|
|
+
|
|
|
+ UnifiedMerchantSaveDTO longDescription = validMerchant();
|
|
|
+ longDescription.setDescription("d".repeat(513));
|
|
|
+ assertTrue(violations(longDescription).contains("description"));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── HTTP 层 ──────────────────────────────
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 超长 {@code notify_url} 以前会穿过校验,在入库时撞列宽变成 500;
|
|
|
+ * 现在必须在 MVC 校验阶段以 400 返回,且服务层一次都不被调用。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void openRefund_tooLongNotifyUrl_is400Not500() throws Exception {
|
|
|
+ UnifiedOrderService orderService = mock(UnifiedOrderService.class);
|
|
|
+ UnifiedRefundService refundService = mock(UnifiedRefundService.class);
|
|
|
+ UnifiedChannelUserService channelUserService = mock(UnifiedChannelUserService.class);
|
|
|
+ MockHttpServletRequest servletRequest =
|
|
|
+ new MockHttpServletRequest("POST", "/payment/openapi/unified/refund");
|
|
|
+ servletRequest.setAttribute("openapi.tenantId", 2L);
|
|
|
+ servletRequest.setAttribute("openapi.apiKey", "key-2");
|
|
|
+
|
|
|
+ MockMvc mvc = MockMvcBuilders
|
|
|
+ .standaloneSetup(new UnifiedOpenController(
|
|
|
+ orderService, refundService, channelUserService, servletRequest))
|
|
|
+ .setControllerAdvice(new GlobalExceptionHandler())
|
|
|
+ .setMessageConverters(new MappingJackson2HttpMessageConverter(GLOBAL_MAPPER))
|
|
|
+ .build();
|
|
|
+
|
|
|
+ String body = "{\"order_no\":\"UO1\",\"refund_amount\":100,\"refund_reason\":\"测试退款\","
|
|
|
+ + "\"out_refund_no\":\"RF1\",\"notify_url\":\"" + "h".repeat(600) + "\"}";
|
|
|
+ mvc.perform(post("/payment/openapi/unified/refund")
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
+ .content(body))
|
|
|
+ .andExpect(status().isBadRequest())
|
|
|
+ .andExpect(jsonPath("$.code").value(400))
|
|
|
+ .andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("notifyUrl")));
|
|
|
+
|
|
|
+ verifyNoInteractions(orderService, refundService, channelUserService);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 超长 {@code mchNo} 同样必须在校验层 400,而不是入库时 500。 */
|
|
|
+ @Test
|
|
|
+ void merchantSave_tooLongMchNo_is400Not500() throws Exception {
|
|
|
+ UnifiedMerchantService service = mock(UnifiedMerchantService.class);
|
|
|
+ MockMvc mvc = MockMvcBuilders
|
|
|
+ .standaloneSetup(new UnifiedMerchantController(service, mock(UnifiedTenantGuard.class)))
|
|
|
+ .setControllerAdvice(new GlobalExceptionHandler())
|
|
|
+ .setMessageConverters(new MappingJackson2HttpMessageConverter(GLOBAL_MAPPER))
|
|
|
+ .build();
|
|
|
+
|
|
|
+ String body = "{\"tenant_id\":7,\"mch_no\":\"" + "M".repeat(600) + "\","
|
|
|
+ + "\"app_id\":\"app-1\",\"app_secret\":\"secret\"}";
|
|
|
+ mvc.perform(post("/payment/unified/merchant")
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
+ .content(body))
|
|
|
+ .andExpect(status().isBadRequest())
|
|
|
+ .andExpect(jsonPath("$.code").value(400))
|
|
|
+ .andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("mchNo")));
|
|
|
+
|
|
|
+ verifyNoInteractions(service);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 工具 ──────────────────────────────
|
|
|
+
|
|
|
+ private static Set<String> violations(Object bean) {
|
|
|
+ return VALIDATOR.validate(bean).stream()
|
|
|
+ .map(v -> v.getPropertyPath().toString())
|
|
|
+ .collect(Collectors.toSet());
|
|
|
+ }
|
|
|
+
|
|
|
+ private static UnifiedOrderCreateCommand validOrder() {
|
|
|
+ UnifiedOrderCreateCommand cmd = new UnifiedOrderCreateCommand();
|
|
|
+ cmd.setMchNo("M001");
|
|
|
+ cmd.setAmount(100L);
|
|
|
+ cmd.setWayCode("QR_CASHIER");
|
|
|
+ cmd.setSubject("测试订单");
|
|
|
+ cmd.setOutTradeNo("T001");
|
|
|
+ cmd.setClientIp("203.0.113.9");
|
|
|
+ cmd.setNotifyUrl("https://merchant.example.test/notify");
|
|
|
+ cmd.setReturnUrl("https://merchant.example.test/return");
|
|
|
+ cmd.setExpiredTime(7200);
|
|
|
+ cmd.setExtParam("ext");
|
|
|
+ return cmd;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static UnifiedRefundCreateCommand validRefund() {
|
|
|
+ UnifiedRefundCreateCommand cmd = new UnifiedRefundCreateCommand();
|
|
|
+ cmd.setMchNo("M001");
|
|
|
+ cmd.setOrderNo("UO1");
|
|
|
+ cmd.setRefundAmount(100L);
|
|
|
+ cmd.setRefundReason("测试退款");
|
|
|
+ cmd.setOutRefundNo("RF1");
|
|
|
+ cmd.setNotifyUrl("https://merchant.example.test/refund-notify");
|
|
|
+ cmd.setExtParam("ext");
|
|
|
+ return cmd;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static UnifiedMerchantSaveDTO validMerchant() {
|
|
|
+ UnifiedMerchantSaveDTO dto = new UnifiedMerchantSaveDTO();
|
|
|
+ dto.setTenantId(7L);
|
|
|
+ dto.setMchNo("M001");
|
|
|
+ dto.setAppId("app-1");
|
|
|
+ dto.setAppSecret("secret");
|
|
|
+ dto.setMerchantName("测试商户");
|
|
|
+ dto.setChannelUserIfCode("fuioupay");
|
|
|
+ dto.setStatus("0");
|
|
|
+ dto.setDescription("备注");
|
|
|
+ return dto;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static UnifiedMerchantSaveDTO merchantWithMchNo(String mchNo) {
|
|
|
+ UnifiedMerchantSaveDTO dto = validMerchant();
|
|
|
+ dto.setMchNo(mchNo);
|
|
|
+ return dto;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static UnifiedMerchantSaveDTO merchantWithAppId(String appId) {
|
|
|
+ UnifiedMerchantSaveDTO dto = validMerchant();
|
|
|
+ dto.setAppId(appId);
|
|
|
+ return dto;
|
|
|
+ }
|
|
|
+}
|