|
|
@@ -5,6 +5,8 @@ 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 com.payment.platform.module.payment.leshouying.dto.LsyReviewStatusDTO;
|
|
|
+import com.payment.platform.module.payment.leshouying.dto.LsyShopCreateDTO;
|
|
|
+import com.payment.platform.module.payment.leshouying.dto.LsyShopItemDTO;
|
|
|
import org.junit.jupiter.api.BeforeEach;
|
|
|
import org.junit.jupiter.api.Test;
|
|
|
import org.junit.jupiter.api.extension.ExtendWith;
|
|
|
@@ -16,6 +18,7 @@ import java.util.LinkedHashMap;
|
|
|
import java.util.List;
|
|
|
import java.util.Map;
|
|
|
import java.util.Set;
|
|
|
+import java.util.function.Consumer;
|
|
|
|
|
|
import static org.junit.jupiter.api.Assertions.*;
|
|
|
import static org.mockito.ArgumentMatchers.*;
|
|
|
@@ -225,8 +228,10 @@ class LsyOnboardingServiceTest {
|
|
|
*
|
|
|
* <p>{@link LeshouyingResponseParser} 的归一化**只走顶层**(不递归),这一点由它自己的
|
|
|
* javadoc 与 {@code parseV3_normalization_isTopLevelOnly_nestedJsonNullSurvives} 钉住,
|
|
|
- * 那里还写着「谁要开始读嵌套字段,必须先把这里改成递归**或在这里补归一化**」。
|
|
|
- * 本类是全模块第一个读嵌套字段的消费方,所以归一化补在读取处:
|
|
|
+ * 那里还写着「谁要开始读嵌套字段,先回来把这里改成递归」—— 它只给了**改解析器**这一条路,
|
|
|
+ * 而本 task 的边界不允许改(会动到 Task 1-4 与批次一全部路径)。本类是全模块第一个读嵌套
|
|
|
+ * 字段的消费方,所以归一化补在读取处,重新评估的条件见
|
|
|
+ * {@code LsyOnboardingService#toShopItems} 的 javadoc:
|
|
|
* {@code area_list[i].area_code} 的 JSON null 若按 {@code String.valueOf} 走,
|
|
|
* 得到的是**字符串** {@code "null"}(实测,见解析器 javadoc),
|
|
|
* 那会被当成一个真实的区划编码继续传给上游(三级联动的下一跳 parent_code)。
|
|
|
@@ -507,4 +512,302 @@ class LsyOnboardingServiceTest {
|
|
|
}
|
|
|
verifyNoInteractions(client);
|
|
|
}
|
|
|
+
|
|
|
+ // ================= 门店创建 + 门店列表(上游 page 196236559 / 196236560) =================
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 一份**能通过全部必填校验**的创建门店入参 —— 各条用例只改它关心的那一项。
|
|
|
+ *
|
|
|
+ * <p>值取自 page 196236559 请求参数表的「参数示例」列(门店名称「小店」、门店电话
|
|
|
+ * {@code 13566668888}、门店地址「湖北武汉」、门店管理员姓名「店小二」);商户号用本类的
|
|
|
+ * {@link #MCH_NO},与其它用例同一个口径。
|
|
|
+ */
|
|
|
+ private static LsyShopCreateDTO validShopDto() {
|
|
|
+ LsyShopCreateDTO dto = new LsyShopCreateDTO();
|
|
|
+ dto.setMchNo(MCH_NO);
|
|
|
+ dto.setShopName("小店");
|
|
|
+ dto.setShopPhone("13566668888");
|
|
|
+ dto.setShopAddress("湖北武汉");
|
|
|
+ dto.setManagerName("店小二");
|
|
|
+ dto.setManagerPhone("13566668888");
|
|
|
+ return dto;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 在 {@link #validShopDto()} 上改一项。 */
|
|
|
+ private static LsyShopCreateDTO shopDto(Consumer<LsyShopCreateDTO> mutation) {
|
|
|
+ LsyShopCreateDTO dto = validShopDto();
|
|
|
+ mutation.accept(dto);
|
|
|
+ return dto;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 创建门店成功的应答:page 196236559 的返回示例里操作成功时的形状,只留本类要读的键。 */
|
|
|
+ private static final String SHOP_CREATE_OK =
|
|
|
+ "{\"result_code\":\"SUCCESS\",\"shop_no\":\"861907240003\"}";
|
|
|
+
|
|
|
+ /**
|
|
|
+ * page 196236560 的返回示例原文(2026-09-22 从上游文档站重取复核过),
|
|
|
+ * **两处非法 JSON 已修好**,其余逐字照抄:
|
|
|
+ * <ol>
|
|
|
+ * <li>{@code "trace_no": "123456"} 那一行末尾**缺一个逗号**(spec §6.2(e) 已记录);</li>
|
|
|
+ * <li>末尾 {@code "merchant_name": "糖糖西饼屋",} 后面那个**多余的尾逗号**。</li>
|
|
|
+ * </ol>
|
|
|
+ * 修它们是因为**夹具本身必须是合法 JSON** —— 照抄的话「上游报文坏了」与「我们读错了」
|
|
|
+ * 两种失败在用例里长得一样,而前者会把这个用例变成一条永远查不出根因的假红。
|
|
|
+ * 两处都在本类要断言的四个字段之外,改动不触及任何断言。
|
|
|
+ *
|
|
|
+ * <p>★ 第二项({@code 糖糖西饼屋默认门店})**没有 {@code shop_tel} 键** —— 那是上游原文的
|
|
|
+ * 真实形状,不是这里漏抄。
|
|
|
+ */
|
|
|
+ private static final String SHOP_LIST_EXAMPLE =
|
|
|
+ "{\"result_code\":\"SUCCESS\",\"result_msg\":\"操作成功\",\"inst_no\":\"900024\","
|
|
|
+ + "\"trace_no\":\"123456\",\"merchant_no\":\"851811130002\",\"shop_list\":["
|
|
|
+ + "{\"shop_tel\":\"13277581225\",\"shop_name\":\"一分店\","
|
|
|
+ + "\"shop_address\":\"司门口大智路111号\",\"shop_no\":\"861811140412\"},"
|
|
|
+ + "{\"shop_name\":\"糖糖西饼屋默认门店\",\"shop_address\":\"地址\","
|
|
|
+ + "\"shop_no\":\"86201812290001\"},"
|
|
|
+ + "{\"shop_tel\":\"13470123132131\",\"shop_name\":\"西饼一店\","
|
|
|
+ + "\"shop_address\":\"湖北武汉\",\"shop_no\":\"86201812290002\"}],"
|
|
|
+ + "\"merchant_name\":\"糖糖西饼屋\"}";
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 钉的是**R-2 的裁定**:发的是参数表的拼写 {@code manager_name} / {@code manager_phone},
|
|
|
+ * **不是**同页请求示例里的 {@code maneger_name} / {@code maneger_phone}("maneger" 是错拼)。
|
|
|
+ *
|
|
|
+ * <p>依据:page 196236559 的**参数表**写 {@code manager_name}/{@code manager_phone},
|
|
|
+ * 同一页的**请求示例**写 {@code maneger_name}/{@code maneger_phone} —— 参数表是规范,
|
|
|
+ * 示例是手打的。★ 这是**可实测项**:Task 7 真机一轮拿这条去取证,若上游回
|
|
|
+ * 「参数不合法 / 缺少 manager_name」,说明它认的是错拼版,那时**只改
|
|
|
+ * {@code LsyOnboardingService} 里的常量 + 反转本条断言**。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void createShop_sendsManagerNameSpelledAsTable() {
|
|
|
+ stubUpstream(SHOP_CREATE_OK);
|
|
|
+
|
|
|
+ service.createShop(validShopDto());
|
|
|
+
|
|
|
+ Map<String, Object> sent = sentParams("/api/shop/create");
|
|
|
+ assertEquals("店小二", sent.get("manager_name"));
|
|
|
+ assertEquals("13566668888", sent.get("manager_phone"));
|
|
|
+ assertFalse(sent.containsKey("maneger_name"),
|
|
|
+ "不得发请求示例里的错拼版 maneger_name(R-2:参数表为准):" + sent);
|
|
|
+ assertFalse(sent.containsKey("maneger_phone"),
|
|
|
+ "不得发请求示例里的错拼版 maneger_phone(R-2:参数表为准):" + sent);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 出站参数**恰好**是服务自己拥有的 7 个键 —— {@code merchant_no} + {@code trace_no}
|
|
|
+ * + DTO 的五个。**集合相等**,不是 {@code contains}:多发的字段会进签名,上游可能因此判
|
|
|
+ * 「参数不合法」,而失败形态是一句笼统的话,根因不可见。
|
|
|
+ *
|
|
|
+ * <p>★ 【为什么不是上游入参表的 9 个键】{@code inst_no} 与 {@code sign} 由
|
|
|
+ * {@link LeshouyingClient} 在**客户端内部**补({@code signedPayload}),本类 mock 的正是
|
|
|
+ * 那个客户端 ⇒ 服务层交出去的 map 里**永远不会**有这两个键。同文件里
|
|
|
+ * {@code merchantInfo_sendsMerchantNoAndTraceNo} 断言的也正是
|
|
|
+ * {@code Set.of("merchant_no","trace_no")},同一个口径。
|
|
|
+ * 服务层若自己补这两个键,{@code inst_no} 会被客户端无条件覆盖、{@code sign} 会被重签
|
|
|
+ * —— 那是把 Task 1 已落地的契约又实现一遍(变体 M-3 的反面)。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void createShop_sendsExactlyTheSevenServiceOwnedParams() {
|
|
|
+ stubUpstream(SHOP_CREATE_OK);
|
|
|
+
|
|
|
+ service.createShop(validShopDto());
|
|
|
+
|
|
|
+ Map<String, Object> sent = sentParams("/api/shop/create");
|
|
|
+ assertEquals(Set.of("merchant_no", "trace_no", "shop_name", "shop_phone", "shop_address",
|
|
|
+ "manager_name", "manager_phone"), sent.keySet(),
|
|
|
+ "出站参数必须恰好是服务拥有的 7 个键(inst_no / sign 归客户端补):" + sent);
|
|
|
+ assertEquals(MCH_NO, sent.get("merchant_no"));
|
|
|
+ assertEquals("小店", sent.get("shop_name"));
|
|
|
+ assertEquals("13566668888", sent.get("shop_phone"));
|
|
|
+ assertEquals("湖北武汉", sent.get("shop_address"));
|
|
|
+ assertEquals("店小二", sent.get("manager_name"));
|
|
|
+ assertEquals("13566668888", sent.get("manager_phone"));
|
|
|
+ Object traceNo = sent.get("trace_no");
|
|
|
+ assertInstanceOf(String.class, traceNo, "trace_no 必须是字符串,实际: " + traceNo);
|
|
|
+ assertEquals(32, ((String) traceNo).length(),
|
|
|
+ "trace_no 必须是 IdUtil.fastSimpleUUID() 的形状(32 位),实际: " + traceNo);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 六个必填项**逐个**置空白 ⇒ 全部 400,且**一次上游调用都没发出** ——
|
|
|
+ * 这条钉的是「本地校验先于出站」。
|
|
|
+ *
|
|
|
+ * <p>六个 = {@code merchant_no}(走 Task 4 的 {@code mchNoParams},不另写一份空白检查)
|
|
|
+ * + DTO 的五个。空白的三种形态({@code null} / 空串 / 全空格)逐个试,因为
|
|
|
+ * 「漏了其中一个」正是这条要防的形态。
|
|
|
+ *
|
|
|
+ * <p>★ 本用例**一次都不打桩**(整个方法里不出现 {@code stubUpstream}):
|
|
|
+ * {@code verifyNoInteractions} 的前提就是「这条用例没打过桩」。
|
|
|
+ * (Mockito 的严格桩检查会把「打了桩却没人用」也判成失败 —— 但那**不是**本断言,
|
|
|
+ * 两者不要混为一谈。)
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void createShop_blankRequiredField_throws400() {
|
|
|
+ for (String blank : new String[]{null, "", " "}) {
|
|
|
+ assertRejected400("merchant_no", "商户号不能为空", shopDto(d -> d.setMchNo(blank)));
|
|
|
+ assertRejected400("shop_name", "门店名称不能为空", shopDto(d -> d.setShopName(blank)));
|
|
|
+ assertRejected400("shop_phone", "门店电话不能为空", shopDto(d -> d.setShopPhone(blank)));
|
|
|
+ assertRejected400("shop_address", "门店地址不能为空", shopDto(d -> d.setShopAddress(blank)));
|
|
|
+ assertRejected400("manager_name", "门店管理员姓名不能为空",
|
|
|
+ shopDto(d -> d.setManagerName(blank)));
|
|
|
+ assertRejected400("manager_phone", "门店管理员手机号不能为空",
|
|
|
+ shopDto(d -> d.setManagerPhone(blank)));
|
|
|
+ }
|
|
|
+ verifyNoInteractions(client);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 断言某一次 {@code createShop} 被 400 拒掉,且文案点名的是**中文字段名**。 */
|
|
|
+ private void assertRejected400(String field, String expectedMessage, LsyShopCreateDTO dto) {
|
|
|
+ BusinessException ex = assertThrows(BusinessException.class,
|
|
|
+ () -> service.createShop(dto), field + " 置空白必须被拒");
|
|
|
+ assertEquals(400, ex.getCode(), field);
|
|
|
+ assertEquals(expectedMessage, ex.getMessage(), field);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 操作成功时把上游给的 {@code shop_no} 原样带回。 */
|
|
|
+ @Test
|
|
|
+ void createShop_returnsShopNo() {
|
|
|
+ stubUpstream(SHOP_CREATE_OK);
|
|
|
+
|
|
|
+ assertEquals("861907240003", service.createShop(validShopDto()));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 上游说成功却**没给门店号** ⇒ 抛异常,**绝不让 null 一路往外走**:调用方拿 null 存库
|
|
|
+ * 就是一条查不回来的记录(门店在上游**确实建了**,本地却没有号)。
|
|
|
+ *
|
|
|
+ * <p>两个输入:brief 的字面夹具(整段没有 {@code shop_no} 键)与**空串**。
|
|
|
+ * 分开不是因为形状不同,而是因为它们**钉的不是同一个判据**:只测「缺键」的话,
|
|
|
+ * 「只判 null」与「判空白」两种实现分不开,而后者才是对的 —— 上游回
|
|
|
+ * {@code "shop_no":""} 与没回一样不可用,空串照样是一条查不回来的记录。
|
|
|
+ *
|
|
|
+ * <p>异常类型是**实现者裁定的**(brief 允许二选一),这里是
|
|
|
+ * {@link BusinessException}(502):见 {@code LsyOnboardingService#createShop} 的 javadoc
|
|
|
+ * 里那段「为什么不是 {@code LeshouyingApiException}」。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void createShop_missingShopNo_throws() {
|
|
|
+ for (String json : new String[]{
|
|
|
+ "{\"result_code\":\"SUCCESS\"}",
|
|
|
+ "{\"result_code\":\"SUCCESS\",\"shop_no\":\"\"}"}) {
|
|
|
+ stubUpstream(json);
|
|
|
+
|
|
|
+ BusinessException ex = assertThrows(BusinessException.class,
|
|
|
+ () -> service.createShop(validShopDto()), json + " ⇒ 必须抛,不得返回 null/空串");
|
|
|
+ assertEquals(502, ex.getCode(), json);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 门店列表的出站参数与另外两个 {@code /api/*} 接口同一组:{@code merchant_no} + {@code trace_no}
|
|
|
+ * (键集合相等;{@code inst_no} / {@code sign} 归客户端补,理由见
|
|
|
+ * {@link #createShop_sendsExactlyTheSevenServiceOwnedParams})。路径按文档字面写死。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void listShops_sendsMerchantNoAndTraceNo() {
|
|
|
+ stubUpstream(SHOP_LIST_EXAMPLE);
|
|
|
+
|
|
|
+ service.listShops(MCH_NO);
|
|
|
+
|
|
|
+ Map<String, Object> sent = sentParams("/api/shop/list");
|
|
|
+ assertEquals(MCH_NO, sent.get("merchant_no"));
|
|
|
+ Object traceNo = sent.get("trace_no");
|
|
|
+ assertInstanceOf(String.class, traceNo, "trace_no 必须是字符串,实际: " + traceNo);
|
|
|
+ assertEquals(32, ((String) traceNo).length(),
|
|
|
+ "trace_no 必须是 IdUtil.fastSimpleUUID() 的形状(32 位),实际: " + traceNo);
|
|
|
+ assertEquals(Set.of("merchant_no", "trace_no"), sent.keySet(),
|
|
|
+ "出站参数必须恰好是 merchant_no + trace_no:" + sent);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * page 196236560 返回示例里三项、四个字段全部映射上(四项 =
|
|
|
+ * {@code shop_no} / {@code shop_name} / {@code shop_address} / {@code shop_tel})。
|
|
|
+ *
|
|
|
+ * <p>三项都断言,是因为示例里三项的**键集并不相同**(第二项没有 {@code shop_tel})——
|
|
|
+ * 只断言第一项的话,「按第一项的形状硬编码」这种实现照样绿。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void listShops_mapsFourFields() {
|
|
|
+ stubUpstream(SHOP_LIST_EXAMPLE);
|
|
|
+
|
|
|
+ List<LsyShopItemDTO> shops = service.listShops(MCH_NO);
|
|
|
+
|
|
|
+ assertEquals(3, shops.size(), "上游示例里就是三项:" + shops);
|
|
|
+ assertEquals("861811140412", shops.get(0).getShopNo());
|
|
|
+ assertEquals("一分店", shops.get(0).getShopName());
|
|
|
+ assertEquals("司门口大智路111号", shops.get(0).getShopAddress());
|
|
|
+ assertEquals("13277581225", shops.get(0).getShopTel());
|
|
|
+ assertEquals("86201812290001", shops.get(1).getShopNo());
|
|
|
+ assertEquals("糖糖西饼屋默认门店", shops.get(1).getShopName());
|
|
|
+ assertEquals("地址", shops.get(1).getShopAddress());
|
|
|
+ assertEquals("86201812290002", shops.get(2).getShopNo());
|
|
|
+ assertEquals("西饼一店", shops.get(2).getShopName());
|
|
|
+ assertEquals("湖北武汉", shops.get(2).getShopAddress());
|
|
|
+ assertEquals("13470123132131", shops.get(2).getShopTel());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 这条钉的是 **Hutool 的 {@code JSONNull}**:没有电话的门店,{@code shopTel} 必须是
|
|
|
+ * Java {@code null},**不是** 4 个字符的字符串 {@code "null"}。
|
|
|
+ *
|
|
|
+ * <p>{@link LeshouyingResponseParser} 对 JSON null 的归一化**只走顶层、不递归**
|
|
|
+ * (由 {@code parseV3_normalization_isTopLevelOnly_nestedJsonNullSurvives} 钉住),
|
|
|
+ * 而 {@code shop_list} 是**嵌套**数组 ⇒ 里面取出来的 null 仍是 {@code JSONNull} 单例,
|
|
|
+ * 裸 {@code String.valueOf} 会得到字符串 {@code "null"}。那会被页面当成一个真实的
|
|
|
+ * 门店电话显示出来,并原样回传给上游。
|
|
|
+ *
|
|
|
+ * <p>两种输入**都要测,因为它们走的是两条不同的路径**,谁也不覆盖谁:
|
|
|
+ * <ol>
|
|
|
+ * <li><b>键不存在</b>(上游示例第二项就是)⇒ {@code Map.get} 给 Java {@code null},
|
|
|
+ * 一路到 {@code String.valueOf(Object)} 同样会变成字符串 {@code "null"};</li>
|
|
|
+ * <li><b>键在、值是 JSON null</b> ⇒ 取到的是 {@code JSONNull} <b>单例</b>,
|
|
|
+ * 与上一条**完全不是同一个对象**。只测第 1 种的话,「只判 Java null」的实现照样绿,
|
|
|
+ * 而它在真机上遇到显式 null 就漏 —— 变体 M-4 打的就是这一条。</li>
|
|
|
+ * </ol>
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void listShops_blankShopTel_doesNotBecomeStringNull() {
|
|
|
+ stubUpstream(SHOP_LIST_EXAMPLE);
|
|
|
+ assertNull(service.listShops(MCH_NO).get(1).getShopTel(),
|
|
|
+ "第二项没有 shop_tel 键 ⇒ 必须是 Java null,不是字符串 \"null\"");
|
|
|
+
|
|
|
+ stubUpstream(ok(",\"shop_list\":[{\"shop_no\":\"861811140412\",\"shop_tel\":null}]"));
|
|
|
+ assertNull(service.listShops(MCH_NO).get(0).getShopTel(),
|
|
|
+ "JSON null ⇒ 必须是 Java null,不是字符串 \"null\"");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * {@code shop_list} 缺失 / 为 null / 不是数组 ⇒ **空列表**(不是 null,也不抛)。
|
|
|
+ *
|
|
|
+ * <p>口径与同类的 {@code areaList_blankList_returnsEmpty} 一致:上游返回参数表里
|
|
|
+ * {@code shop_list} 的「必含」列是**空的**(page 196236560,2026-09-22 取原文复核),
|
|
|
+ * 即上游可以不给 ⇒ 「这个商户没有门店」是一个合法的成功结果,
|
|
|
+ * 不该让调用方拿到 null 再去判空。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void listShops_blankList_returnsEmpty() {
|
|
|
+ for (String json : new String[]{
|
|
|
+ ok(""),
|
|
|
+ ok(",\"shop_list\":null"),
|
|
|
+ ok(",\"shop_list\":\"不是数组\"")}) {
|
|
|
+ stubUpstream(json);
|
|
|
+
|
|
|
+ List<LsyShopItemDTO> shops = service.listShops(MCH_NO);
|
|
|
+
|
|
|
+ assertNotNull(shops, json);
|
|
|
+ assertTrue(shops.isEmpty(), json + " ⇒ 必须返回空列表,实际: " + shops);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 数组元素不是对象时跳过 —— 与 {@code areaList_nonMapItem_isSkipped_notBlankRow} 同一口径。 */
|
|
|
+ @Test
|
|
|
+ void listShops_nonMapItem_isSkipped() {
|
|
|
+ stubUpstream(ok(",\"shop_list\":[\"oops\",{\"shop_no\":\"861811140412\",\"shop_name\":\"一分店\"}]"));
|
|
|
+
|
|
|
+ List<LsyShopItemDTO> shops = service.listShops(MCH_NO);
|
|
|
+
|
|
|
+ assertEquals(1, shops.size(), "非对象元素不得被映射成一行空门店,实际: " + shops);
|
|
|
+ assertEquals("861811140412", shops.get(0).getShopNo());
|
|
|
+ }
|
|
|
}
|