|
|
@@ -0,0 +1,211 @@
|
|
|
+package com.payment.platform.module.payment.leshouying.service;
|
|
|
+
|
|
|
+import com.payment.platform.module.payment.leshouying.client.LeshouyingClient;
|
|
|
+import com.payment.platform.module.payment.leshouying.client.LeshouyingResponseParser;
|
|
|
+import com.payment.platform.module.payment.leshouying.dto.LsyAreaItemDTO;
|
|
|
+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 java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.Set;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.*;
|
|
|
+import static org.mockito.ArgumentMatchers.*;
|
|
|
+import static org.mockito.Mockito.*;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 进件服务「获取省市区」的出站契约与响应映射(上游 page 196236568)。
|
|
|
+ *
|
|
|
+ * <p>【mock 的是谁】这里 mock 的是 {@link LeshouyingClient},不是 {@code RestTemplate}
|
|
|
+ * —— 后者在客户端内部,服务层看不见。所以本类里「没有出海」的断言一律写作
|
|
|
+ * {@code verifyNoInteractions(client)},而「发了什么」一律抓 {@code client} 收到的 map
|
|
|
+ * (那已经是服务层能负责的最远端:补 {@code inst_no} / 签名是客户端的事,由
|
|
|
+ * {@code LeshouyingClientApiEnvelopeTest} 钉)。
|
|
|
+ *
|
|
|
+ * <p>【为什么断言「键集合恰好相等」而不只是 {@code containsKey}】本类的核心断言之一
|
|
|
+ * 是「1 级**不填** parent_code」。`containsKey` 只挡得住「多填了非空值」,
|
|
|
+ * 而签名规则是**null 不参与、空串参与**(见 {@link com.payment.platform.module.payment.leshouying.client.LeshouyingSigner})——
|
|
|
+ * 填 {@code ""} 与不填是两份不同的签名原文。所以取值一律 {@code assertEquals},
|
|
|
+ * 并额外钉住 {@code keySet()} 恰好等于期望集合:多一个键、少一个键都会红。
|
|
|
+ *
|
|
|
+ * <p>【桩为什么经真实解析器产生】服务层拿到的是 {@code LeshouyingClient.postApi} 的返回值,
|
|
|
+ * 也就是 {@link LeshouyingResponseParser#parseApi} 的产物:数组是 Hutool 的 {@code JSONArray}、
|
|
|
+ * 元素是 {@code JSONObject}、JSON null 是 {@code JSONNull} 单例。测试里手搓
|
|
|
+ * {@code Map.of("area_list", List.of(...))} 会把这三种真实形状全部换掉,
|
|
|
+ * 于是「服务层的类型判断对不对」这件事在本类里就没有证据了。
|
|
|
+ */
|
|
|
+@ExtendWith(MockitoExtension.class)
|
|
|
+class LsyOnboardingServiceTest {
|
|
|
+
|
|
|
+ @Mock private LeshouyingClient client;
|
|
|
+
|
|
|
+ private LsyOnboardingService service;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ service = new LsyOnboardingService(client);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 把上游报文原文喂给**真实的**解析器,取它真正会交给服务层的那个 Map 当桩的返回值 */
|
|
|
+ private void stubUpstream(String rawJson) {
|
|
|
+ when(client.postApi(anyString(), anyMap()))
|
|
|
+ .thenReturn(new LeshouyingResponseParser().parseApi(rawJson));
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String ok(String extraFields) {
|
|
|
+ return "{\"result_code\":\"SUCCESS\",\"result_msg\":\"操作成功\"" + extraFields + "}";
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 捕获唯一一次出站调用的业务参数,并钉住路径 */
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ private Map<String, Object> sentParams() {
|
|
|
+ ArgumentCaptor<Map<String, Object>> captor =
|
|
|
+ ArgumentCaptor.forClass((Class<Map<String, Object>>) (Class<?>) Map.class);
|
|
|
+ verify(client, times(1)).postApi(eq("/api/info/area"), captor.capture());
|
|
|
+ return captor.getValue();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 「1 级不填 parent_code」钉的是 level,**不是** parentCode 是否为 null。
|
|
|
+ *
|
|
|
+ * <p>两种输入都要不填:① 三联动第一步的现实调用(什么都没选,传 null);
|
|
|
+ * ② 调用方误传了上级 code —— 文档的参数表写着 parent_code 是「当前等级的上级 code」,
|
|
|
+ * 1 级不存在上级,所以判据只能是 level。
|
|
|
+ *
|
|
|
+ * <p>若实现统一填 {@code ""}:签名原文会多出一个 {@code parent_code=} 项
|
|
|
+ * (本仓签名规则:null 不参与、**空串参与**),与文档示例对不上,而失败只会表现为
|
|
|
+ * 上游一句笼统的「签名错误」。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void areaList_level1_omitsParentCode() {
|
|
|
+ stubUpstream(ok(""));
|
|
|
+
|
|
|
+ service.areaList("1", null);
|
|
|
+ service.areaList("1", "110000");
|
|
|
+
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ ArgumentCaptor<Map<String, Object>> captor =
|
|
|
+ ArgumentCaptor.forClass((Class<Map<String, Object>>) (Class<?>) Map.class);
|
|
|
+ verify(client, times(2)).postApi(eq("/api/info/area"), captor.capture());
|
|
|
+
|
|
|
+ for (Map<String, Object> sent : captor.getAllValues()) {
|
|
|
+ assertEquals("1", sent.get("level"));
|
|
|
+ assertFalse(sent.containsKey("parent_code"),
|
|
|
+ "1 级不得上送 parent_code(连键都不该出现):" + sent);
|
|
|
+ assertEquals(Set.of("level"), sent.keySet(),
|
|
|
+ "1 级出站参数必须恰好只有 level:" + sent);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void areaList_level3_sendsParentCode() {
|
|
|
+ stubUpstream(ok(""));
|
|
|
+
|
|
|
+ service.areaList("3", "110100");
|
|
|
+
|
|
|
+ Map<String, Object> sent = sentParams();
|
|
|
+
|
|
|
+ assertEquals("3", sent.get("level"));
|
|
|
+ assertEquals("110100", sent.get("parent_code"));
|
|
|
+ assertEquals(Set.of("level", "parent_code"), sent.keySet(),
|
|
|
+ "3 级出站参数必须恰好是 level + parent_code:" + sent);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void areaList_mapsSnakeCaseToCamel() {
|
|
|
+ stubUpstream(ok(",\"area_list\":[{\"area_name\":\"东城区\",\"area_code\":\"1000\"}]"));
|
|
|
+
|
|
|
+ List<LsyAreaItemDTO> areas = service.areaList("3", "110100");
|
|
|
+
|
|
|
+ assertEquals(1, areas.size());
|
|
|
+ assertEquals("东城区", areas.get(0).getAreaName());
|
|
|
+ assertEquals("1000", areas.get(0).getAreaCode());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 这条钉上游数据的**真实形状**:page 196236568 的返回示例里 {@code area_code}
|
|
|
+ * 出现 {@code 100A}…{@code 100H},其中 {@code 100G} / {@code 100H} **不是合法十六进制**
|
|
|
+ * (连十六进制都不是,更不是十进制)。任何 {@code Integer.parseInt} / {@code Long.parseLong} /
|
|
|
+ * 数字型 DTO 字段都会在这里炸 —— 而炸的是三级联动的第一屏。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void areaList_areaCode_isString_notNumber() {
|
|
|
+ stubUpstream(ok(",\"area_list\":[{\"area_name\":\"某区\",\"area_code\":\"100G\"}]"));
|
|
|
+
|
|
|
+ List<LsyAreaItemDTO> areas = service.areaList("3", "110100");
|
|
|
+
|
|
|
+ assertEquals(1, areas.size());
|
|
|
+ Object code = areas.get(0).getAreaCode();
|
|
|
+ assertInstanceOf(String.class, code,
|
|
|
+ "area_code 必须原样当字符串带回,不得解析成数字");
|
|
|
+ assertEquals("100G", code);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 契约明写的三种「防御性读取」输入之一:上游没返回 area_list 这个键。 */
|
|
|
+ @Test
|
|
|
+ void areaList_blankList_returnsEmpty() {
|
|
|
+ stubUpstream(ok(""));
|
|
|
+
|
|
|
+ List<LsyAreaItemDTO> areas = service.areaList("1", null);
|
|
|
+
|
|
|
+ assertNotNull(areas, "上游没给 area_list 时必须返回空列表,不是 null");
|
|
|
+ assertTrue(areas.isEmpty(), "必须返回空列表,实际: " + areas);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 契约明写的另外两种输入:键存在但为 null、键存在但不是数组。同样归成空列表。 */
|
|
|
+ @Test
|
|
|
+ void areaList_nullOrNonListAreaList_returnsEmpty() {
|
|
|
+ for (String json : new String[]{
|
|
|
+ ok(",\"area_list\":null"),
|
|
|
+ ok(",\"area_list\":\"不是数组\"")}) {
|
|
|
+ stubUpstream(json);
|
|
|
+
|
|
|
+ List<LsyAreaItemDTO> areas = service.areaList("1", null);
|
|
|
+
|
|
|
+ assertNotNull(areas, json);
|
|
|
+ assertTrue(areas.isEmpty(), json + " ⇒ 必须返回空列表,实际: " + areas);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 嵌套字段的 JSON null 必须变成 Java null。
|
|
|
+ *
|
|
|
+ * <p>{@link LeshouyingResponseParser} 的归一化**只走顶层**(不递归),这一点由它自己的
|
|
|
+ * javadoc 与 {@code parseV3_normalization_isTopLevelOnly_nestedJsonNullSurvives} 钉住,
|
|
|
+ * 那里还写着「谁要开始读嵌套字段,必须先把这里改成递归**或在这里补归一化**」。
|
|
|
+ * 本类是全模块第一个读嵌套字段的消费方,所以归一化补在读取处:
|
|
|
+ * {@code area_list[i].area_code} 的 JSON null 若按 {@code String.valueOf} 走,
|
|
|
+ * 得到的是**字符串** {@code "null"}(实测,见解析器 javadoc),
|
|
|
+ * 那会被当成一个真实的区划编码继续传给上游(三级联动的下一跳 parent_code)。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void areaList_itemJsonNull_isJavaNull_notStringNull() {
|
|
|
+ stubUpstream(ok(",\"area_list\":[{\"area_name\":\"某区\",\"area_code\":null}]"));
|
|
|
+
|
|
|
+ List<LsyAreaItemDTO> areas = service.areaList("3", "110100");
|
|
|
+
|
|
|
+ assertEquals(1, areas.size());
|
|
|
+ assertEquals("某区", areas.get(0).getAreaName());
|
|
|
+ assertNull(areas.get(0).getAreaCode(), "JSON null 不得变成字符串 \"null\"");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 数组元素不是对象时跳过,而不是映射成一行「两个字段都空」的区划:
|
|
|
+ * 空行会直接出现在省市区下拉里被用户选中。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void areaList_nonMapItem_isSkipped_notBlankRow() {
|
|
|
+ stubUpstream(ok(",\"area_list\":[\"oops\",{\"area_name\":\"某区\",\"area_code\":\"100G\"}]"));
|
|
|
+
|
|
|
+ List<LsyAreaItemDTO> areas = service.areaList("3", "110100");
|
|
|
+
|
|
|
+ assertEquals(1, areas.size(), "非对象元素不得被映射成一行空区划,实际: " + areas);
|
|
|
+ assertEquals("某区", areas.get(0).getAreaName());
|
|
|
+ assertEquals("100G", areas.get(0).getAreaCode());
|
|
|
+ }
|
|
|
+}
|