|
|
@@ -0,0 +1,136 @@
|
|
|
+package com.payment.platform.module.payment.leshouying.client;
|
|
|
+
|
|
|
+import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
|
|
|
+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.Mockito;
|
|
|
+import org.mockito.junit.jupiter.MockitoExtension;
|
|
|
+import org.springframework.http.HttpEntity;
|
|
|
+import org.springframework.http.HttpStatus;
|
|
|
+import org.springframework.http.ResponseEntity;
|
|
|
+import org.springframework.web.client.RestTemplate;
|
|
|
+
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.*;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.ArgumentMatchers.eq;
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 钉住 `LeshouyingClient.postApi` —— 进件类 `/api/*` 出站腿用的是**另一套信封**。
|
|
|
+ *
|
|
|
+ * 【为什么值得单开一个测试类】两条腿共用同一段「补 inst_no → 签名 → POST → 异常包装」,
|
|
|
+ * 唯一的差别只有最后一行的解析器(`parseV3` vs `parseApi`)。正因差别只有一个词,
|
|
|
+ * 它恰恰是最容易被一次重构抹平的地方 —— 而抹平之后的行为是:
|
|
|
+ * 一次**成功的进件**(`/api/*` 回 `{"result_code":"SUCCESS"}`,**没有** `return_code`)
|
|
|
+ * 会走进 parseV3 的「缺少 return_code」分支,抛 `PARSE_ERROR`。
|
|
|
+ * 调用方看到的是「解析错误」,真实原因却是「信封选错了」—— 排查方向直接跑偏。
|
|
|
+ *
|
|
|
+ * 三个用例各自钉一件事,见每个方法上的注释;第 4 个用例钉的是全局约束
|
|
|
+ * 「验签绝不跳过」在**新增的这条腿**上也成立(共用 helper 不等于共用签名,
|
|
|
+ * 将来有人给 postApi 另写一份 payload 拼装就会漏签)。
|
|
|
+ */
|
|
|
+@ExtendWith(MockitoExtension.class)
|
|
|
+class LeshouyingClientApiEnvelopeTest {
|
|
|
+
|
|
|
+ @Mock private RestTemplate restTemplate;
|
|
|
+ private LeshouyingProperties props;
|
|
|
+ private LeshouyingClient client;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ props = new LeshouyingProperties();
|
|
|
+ props.setBaseUrl("https://test.gdyfsk.com/yfpay");
|
|
|
+ props.setInstNo("900112");
|
|
|
+ // ★ 占位密钥,**故意不是**任何真实凭据。签名器对 key 只做拼接(不校验形状),
|
|
|
+ // 所以本类能钉住「确实签了、且签的是 props 里那个 key」而不必把真实机构密钥
|
|
|
+ // 再抄一份进测试文件(全局约束:凭据只打印长度、不落值)。
|
|
|
+ props.setKey("task-1-placeholder-key-not-a-credential");
|
|
|
+ client = new LeshouyingClient(restTemplate, new LeshouyingSigner(),
|
|
|
+ new LeshouyingResponseParser(), props);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 这条钉的是**信封选择**本身。
|
|
|
+ * 桩回的报文里**只有** `result_code`(进件类信封的形状),没有 `return_code`。
|
|
|
+ * 于是:postApi 走 parseApi ⇒ 正常返回;postApi 误走 parseV3 ⇒ 抛 PARSE_ERROR
|
|
|
+ * (parseV3 的第一条分支就是「缺 return_code ⇒ 非 v3 响应」)。
|
|
|
+ * 两种实现的差别在返回值上直接可见,用例不会两边都绿。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void postApi_parsesApiEnvelope_notV3() {
|
|
|
+ String body = "{\"result_code\":\"SUCCESS\",\"result_msg\":\"操作成功\",\"area_list\":[]}";
|
|
|
+ when(restTemplate.postForEntity(any(String.class), any(HttpEntity.class), eq(String.class)))
|
|
|
+ .thenReturn(new ResponseEntity<>(body, HttpStatus.OK));
|
|
|
+
|
|
|
+ Map<String, Object> resp = client.postApi("/api/queryArea", Map.of("foo", "bar"));
|
|
|
+
|
|
|
+ assertEquals("SUCCESS", resp.get("result_code"));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 进件类的失败是**失败码**,不是业务结果:非 SUCCESS 必须抛异常,
|
|
|
+ * 且 `result_msg` 要能到达调用方(进件失败的原因全在这一句里,丢了就只能让上游查单)。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void postApi_failure_throwsWithResultMsg() {
|
|
|
+ when(restTemplate.postForEntity(any(String.class), any(HttpEntity.class), eq(String.class)))
|
|
|
+ .thenReturn(new ResponseEntity<>(
|
|
|
+ "{\"result_code\":\"FAIL\",\"result_msg\":\"参数不合法\"}", HttpStatus.OK));
|
|
|
+
|
|
|
+ LeshouyingApiException ex = assertThrows(LeshouyingApiException.class,
|
|
|
+ () -> client.postApi("/api/queryArea", Map.of("foo", "bar")));
|
|
|
+
|
|
|
+ assertTrue(String.valueOf(ex.getMessage()).contains("参数不合法"),
|
|
|
+ "getMessage() 里必须带上 result_msg,实际: " + ex.getMessage());
|
|
|
+ // 同时钉住错误码来源:必须是上游给的 FAIL,而不是 PARSE_ERROR
|
|
|
+ // (后者意味着报文根本没被当成 api 信封读,那样 message 里那句 result_msg 只是碰巧)
|
|
|
+ assertEquals("FAIL", ex.getRawReturnCode());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ **正对照**:证明两条腿没有共用同一个解析器。
|
|
|
+ * 同一份「只有 result_code」的报文交给 `post()`(/v3/* 腿)必须**被拒**。
|
|
|
+ * 没有这条,M-1 式的「两条腿都接 parseApi」会被上面两条用例放过 ——
|
|
|
+ * 那正是「把 v3 的 PAY_SUCCESS 判成进件失败」的反向事故。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void post_stillUsesV3Envelope_apiEnvelopeIsRejected() {
|
|
|
+ when(restTemplate.postForEntity(any(String.class), any(HttpEntity.class), eq(String.class)))
|
|
|
+ .thenReturn(new ResponseEntity<>("{\"result_code\":\"SUCCESS\"}", HttpStatus.OK));
|
|
|
+
|
|
|
+ LeshouyingApiException ex = assertThrows(LeshouyingApiException.class,
|
|
|
+ () -> client.post("/v3/pay", Map.of("mch_no", "Y52211090004")));
|
|
|
+
|
|
|
+ assertEquals("PARSE_ERROR", ex.getRawReturnCode());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 全局约束「验签绝不跳过」要**逐条腿**成立。
|
|
|
+ * 三条信封用例都对「出站报文长什么样」零断言:一个把 postApi 写成
|
|
|
+ * 「直接发原样 bizParams、不补 inst_no 也不签名」的实现照样能全绿。
|
|
|
+ * 而漏签的后果不是报错而是**静默**——上游只会回一句笼统的失败。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void postApi_injectsInstNoAndSign_automatically() {
|
|
|
+ when(restTemplate.postForEntity(any(String.class), any(HttpEntity.class), eq(String.class)))
|
|
|
+ .thenReturn(new ResponseEntity<>(
|
|
|
+ "{\"result_code\":\"SUCCESS\",\"result_msg\":\"操作成功\"}", HttpStatus.OK));
|
|
|
+
|
|
|
+ client.postApi("/api/queryArea", Map.of("foo", "bar"));
|
|
|
+
|
|
|
+ ArgumentCaptor<HttpEntity> captor = ArgumentCaptor.forClass(HttpEntity.class);
|
|
|
+ verify(restTemplate).postForEntity(
|
|
|
+ eq("https://test.gdyfsk.com/yfpay/api/queryArea"), captor.capture(), eq(String.class));
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ Map<String, Object> sent = (Map<String, Object>) captor.getValue().getBody();
|
|
|
+ assertEquals("900112", sent.get("inst_no"));
|
|
|
+ assertNotNull(sent.get("sign"));
|
|
|
+ assertTrue(new LeshouyingSigner().verify(sent, props.getKey()));
|
|
|
+ }
|
|
|
+}
|