|
|
@@ -1,8 +1,10 @@
|
|
|
package com.payment.platform.module.payment.leshouying.service;
|
|
|
|
|
|
+import com.payment.platform.common.exception.BusinessException;
|
|
|
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 org.junit.jupiter.api.BeforeEach;
|
|
|
import org.junit.jupiter.api.Test;
|
|
|
import org.junit.jupiter.api.extension.ExtendWith;
|
|
|
@@ -10,6 +12,7 @@ import org.mockito.ArgumentCaptor;
|
|
|
import org.mockito.Mock;
|
|
|
import org.mockito.junit.jupiter.MockitoExtension;
|
|
|
|
|
|
+import java.util.LinkedHashMap;
|
|
|
import java.util.List;
|
|
|
import java.util.Map;
|
|
|
import java.util.Set;
|
|
|
@@ -19,7 +22,8 @@ import static org.mockito.ArgumentMatchers.*;
|
|
|
import static org.mockito.Mockito.*;
|
|
|
|
|
|
/**
|
|
|
- * 进件服务「获取省市区」的出站契约与响应映射(上游 page 196236568)。
|
|
|
+ * 进件服务的出站契约与响应映射:省市区(page 196236568)、获取商户信息(page 196236558)、
|
|
|
+ * 商户审核主动查询(page 196236567)。
|
|
|
*
|
|
|
* <p>【mock 的是谁】这里 mock 的是 {@link LeshouyingClient},不是 {@code RestTemplate}
|
|
|
* —— 后者在客户端内部,服务层看不见。所以本类里「没有出海」的断言一律写作
|
|
|
@@ -42,6 +46,9 @@ import static org.mockito.Mockito.*;
|
|
|
@ExtendWith(MockitoExtension.class)
|
|
|
class LsyOnboardingServiceTest {
|
|
|
|
|
|
+ /** 测试商户号。A 阶段手工维护的就是这一个(见 {@code LsyMerchantService} 的类 javadoc)。 */
|
|
|
+ private static final String MCH_NO = "Y52211090004";
|
|
|
+
|
|
|
@Mock private LeshouyingClient client;
|
|
|
|
|
|
private LsyOnboardingService service;
|
|
|
@@ -61,15 +68,29 @@ class LsyOnboardingServiceTest {
|
|
|
return "{\"result_code\":\"SUCCESS\",\"result_msg\":\"操作成功\"" + extraFields + "}";
|
|
|
}
|
|
|
|
|
|
- /** 捕获唯一一次出站调用的业务参数,并钉住路径 */
|
|
|
- @SuppressWarnings("unchecked")
|
|
|
+ /** 捕获唯一一次出站调用的业务参数,并钉住路径(默认是省市区那条腿) */
|
|
|
private Map<String, Object> sentParams() {
|
|
|
+ return sentParams("/api/info/area");
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 同上,路径由调用方指定 —— 本类从 Task 4 起覆盖不止一个接口 */
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ private Map<String, Object> sentParams(String path) {
|
|
|
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());
|
|
|
+ verify(client, times(1)).postApi(eq(path), captor.capture());
|
|
|
return captor.getValue();
|
|
|
}
|
|
|
|
|
|
+ /** 捕获**全部**出站调用的业务参数(同一路径上调了多次时用) */
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ private List<Map<String, Object>> allSentParams(String path, int times) {
|
|
|
+ ArgumentCaptor<Map<String, Object>> captor =
|
|
|
+ ArgumentCaptor.forClass((Class<Map<String, Object>>) (Class<?>) Map.class);
|
|
|
+ verify(client, times(times)).postApi(eq(path), captor.capture());
|
|
|
+ return captor.getAllValues();
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* ★ 「1 级不填 parent_code」钉的是 level,**不是** parentCode 是否为 null。
|
|
|
*
|
|
|
@@ -235,4 +256,242 @@ class LsyOnboardingServiceTest {
|
|
|
assertEquals("某区", areas.get(0).getAreaName());
|
|
|
assertEquals("100G", areas.get(0).getAreaCode());
|
|
|
}
|
|
|
+
|
|
|
+ // ================= 获取商户信息(上游 page 196236558) =================
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 出站参数**恰好**是 {@code merchant_no} + {@code trace_no}(键集合相等,多一个少一个都红),
|
|
|
+ * 路径按文档字面写死。
|
|
|
+ *
|
|
|
+ * <p>trace_no 断言长度 32:{@code IdUtil.fastSimpleUUID()} 的形状就是这个 ——
|
|
|
+ * 光断言「非空白」的话,一个写死的短串也能过。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void merchantInfo_sendsMerchantNoAndTraceNo() {
|
|
|
+ stubUpstream(ok(""));
|
|
|
+
|
|
|
+ service.merchantInfo(MCH_NO);
|
|
|
+
|
|
|
+ Map<String, Object> sent = sentParams("/api/merchant/info");
|
|
|
+ assertEquals(MCH_NO, sent.get("merchant_no"));
|
|
|
+ Object traceNo = sent.get("trace_no");
|
|
|
+ assertInstanceOf(String.class, traceNo, "trace_no 必须是字符串,实际: " + traceNo);
|
|
|
+ assertFalse(((String) traceNo).isBlank(), "trace_no 不得为空白");
|
|
|
+ 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);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 钉的是**流水号唯一**:连调两次,两次的 {@code trace_no} 必须不相等。
|
|
|
+ *
|
|
|
+ * <p>写死常量、或把 trace_no 缓存在字段里只生成一次,都会在**上游那边撞流水** ——
|
|
|
+ * 而上游的失败形态是笼统的「重复请求」,本侧看不出根因。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void merchantInfo_twoCalls_produceDifferentTraceNo() {
|
|
|
+ stubUpstream(ok(""));
|
|
|
+
|
|
|
+ service.merchantInfo(MCH_NO);
|
|
|
+ service.merchantInfo(MCH_NO);
|
|
|
+
|
|
|
+ List<Map<String, Object>> sent = allSentParams("/api/merchant/info", 2);
|
|
|
+ assertNotEquals(sent.get(0).get("trace_no"), sent.get(1).get("trace_no"),
|
|
|
+ "两次调用必须各生成一个 trace_no,实际两次都是: " + sent.get(0).get("trace_no"));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 本方法**不做字段白名单**:上游给了什么键就带回什么键。
|
|
|
+ *
|
|
|
+ * <p>依据:spec §6.2 记着四处字段名/类型不一致,其中「获取商户信息」这一页自己就占两条
|
|
|
+ * (§6.2(e):{@code wx_channel_rate} / {@code ali_channel_rate} 在返回参数表里各出现两次;
|
|
|
+ * 返回示例里 {@code merchant_type} / {@code account_type} / {@code license_type} /
|
|
|
+ * {@code wx_channel_id} 是**数字**而表里写 {@code String}),§6.2(c)(d) 又各有一条跨页的。
|
|
|
+ * 现在立白名单 = 把还没定下来的字段集固化成代码,被剔掉的键看起来会像「上游没有」。
|
|
|
+ * 读具体字段的调用方按 spec §6.2(e) 的口径自行 {@code String.valueOf} 归一。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void merchantInfo_returnsRawUpstreamFields_untouched() {
|
|
|
+ stubUpstream(ok(",\"merchant_type\":3,\"wx_channel_rate\":\"0.006\""));
|
|
|
+
|
|
|
+ Map<String, Object> info = service.merchantInfo(MCH_NO);
|
|
|
+
|
|
|
+ assertEquals("0.006", info.get("wx_channel_rate"), "字符串型费率必须原样带回");
|
|
|
+ assertEquals("3", String.valueOf(info.get("merchant_type")),
|
|
|
+ "数字型字段必须原样带回,被剔掉的话这里会是字符串 \"null\"");
|
|
|
+ assertEquals(Set.of("result_code", "result_msg", "merchant_type", "wx_channel_rate"),
|
|
|
+ info.keySet(), "不得立白名单:上游给了什么键就带什么键,实际: " + info.keySet());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 空白 mchNo ⇒ 400,且**一个字节都不出海**。
|
|
|
+ *
|
|
|
+ * <p>文案逐字取自 brief。brief 同一句里又说「照 {@code LsyMerchantService.requireByMchNo}
|
|
|
+ * 的口径」,而那个先例的文案其实是 {@code "mch_no 不能为空"} —— 两者不一致;
|
|
|
+ * 「照口径」指的是**空白检查 + 400 这个形状**,文案按 brief 的字面值走。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void merchantInfo_blankMchNo_throws400WithoutCallingUpstream() {
|
|
|
+ for (String blank : new String[]{null, "", " "}) {
|
|
|
+ BusinessException ex = assertThrows(BusinessException.class,
|
|
|
+ () -> service.merchantInfo(blank), "mchNo=[" + blank + "] 必须被拒");
|
|
|
+ assertEquals(400, ex.getCode(), "mchNo=[" + blank + "]");
|
|
|
+ assertEquals("商户号不能为空", ex.getMessage(), "mchNo=[" + blank + "]");
|
|
|
+ }
|
|
|
+ verifyNoInteractions(client);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ================= 商户审核主动查询(上游 page 196236567) =================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void queryReview_sendsMerchantNoAndTraceNo() {
|
|
|
+ stubUpstream(ok(",\"reviewed_status\":\"0\""));
|
|
|
+
|
|
|
+ service.queryReview(MCH_NO);
|
|
|
+
|
|
|
+ Map<String, Object> sent = sentParams("/api/merchant/query");
|
|
|
+ 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 196236567 的六码映射逐条对上。
|
|
|
+ *
|
|
|
+ * <p>{@code "10"} 这一条同时钉住**精确字符串匹配**:任何「前缀/包含」式的匹配
|
|
|
+ * 都会把 {@code "10"} 读成 {@code "1"}(审核中),而这是两个不同的审核态。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void queryReview_mapsKnownStatusCodes() {
|
|
|
+ Map<String, String> expected = new LinkedHashMap<>();
|
|
|
+ expected.put("0", "审核通过");
|
|
|
+ expected.put("1", "审核中");
|
|
|
+ expected.put("9", "审核驳回");
|
|
|
+ expected.put("10", "通道报备中");
|
|
|
+ expected.put("98", "停用");
|
|
|
+ expected.put("99", "关闭");
|
|
|
+
|
|
|
+ for (Map.Entry<String, String> e : expected.entrySet()) {
|
|
|
+ stubUpstream(ok(",\"reviewed_status\":\"" + e.getKey() + "\""));
|
|
|
+
|
|
|
+ LsyReviewStatusDTO dto = service.queryReview(MCH_NO);
|
|
|
+
|
|
|
+ assertEquals(e.getKey(), dto.getReviewedStatus(), "原值必须原样带回");
|
|
|
+ assertEquals(e.getValue(), dto.getStatusText(), "码 " + e.getKey() + " 的文案");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 钉的是**不猜值域**:将来上游加一个码,正确行为是「页面显示原始码」,
|
|
|
+ * 而不是「整条链路 500」。
|
|
|
+ *
|
|
|
+ * <p>实现选的是「{@code statusText} 置 **null**」而不是「回填原值」——两个字段的职责必须可区分:
|
|
|
+ * {@code statusText} 是**文案**,回填原值会让调用方分不清「码已知」与「码未知」,
|
|
|
+ * 而「码未知」正是页面要显示原始码的那个状态(原始码本来就在 {@code reviewedStatus} 里,不会丢)。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void queryReview_unknownStatus_keepsRawAndDoesNotThrow() {
|
|
|
+ stubUpstream(ok(",\"reviewed_status\":\"7\""));
|
|
|
+
|
|
|
+ LsyReviewStatusDTO dto = assertDoesNotThrow(() -> service.queryReview(MCH_NO));
|
|
|
+
|
|
|
+ assertEquals("7", dto.getReviewedStatus());
|
|
|
+ assertNull(dto.getStatusText(),
|
|
|
+ "未知码的 statusText 必须是 null(原始码在 reviewedStatus 里,不在这里回填)");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * **非数字**的未知码同样「原样透传、不抛」。
|
|
|
+ *
|
|
|
+ * <p>与上一条是同一族(未知码)的两个形态,必须**分开**钉:上一条的 {@code "7"} 是
|
|
|
+ * 可被 {@code Integer.parseInt} 解析的,把一个 `parseInt` 式实现放进去**它也照样绿**
|
|
|
+ * ——那种实现只是「这次碰巧没炸」(spec §6.2(b) 里 page 196236556 的示例就是
|
|
|
+ * 中文字符串 {@code "审核通过"} 落在同名 {@code reviewed_status} 上:**数字不是唯一形态**)。
|
|
|
+ * 本条的 {@code "0A"} 会让任何 `parseInt` / `(String)` 式读法直接抛。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void queryReview_nonNumericStatus_keepsRawAndDoesNotThrow() {
|
|
|
+ stubUpstream(ok(",\"reviewed_status\":\"0A\""));
|
|
|
+
|
|
|
+ LsyReviewStatusDTO dto = assertDoesNotThrow(() -> service.queryReview(MCH_NO));
|
|
|
+
|
|
|
+ assertEquals("0A", dto.getReviewedStatus());
|
|
|
+ assertNull(dto.getStatusText(), "未知码的 statusText 必须是 null");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 上游两处示例一个给字符串一个给数字,本条钉住**数字形态**:
|
|
|
+ * page 196236567 的返回示例就是 {@code "reviewed_status": 0}(数字)。
|
|
|
+ *
|
|
|
+ * <p>读法必须过 {@code String.valueOf}(服务层复用既有的 {@code asText})——
|
|
|
+ * 直接 {@code (String)} 强转会在活服务上 ClassCastException。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void queryReview_numericStatusFromUpstream_isReadable() {
|
|
|
+ stubUpstream(ok(",\"reviewed_status\":0"));
|
|
|
+
|
|
|
+ LsyReviewStatusDTO dto = service.queryReview(MCH_NO);
|
|
|
+
|
|
|
+ assertEquals("0", dto.getReviewedStatus(), "数字形态必须被取成文本 \"0\"");
|
|
|
+ assertEquals("审核通过", dto.getStatusText());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ {@code reviewed_reason} 的语义边界:page 196236567 的返回示例里
|
|
|
+ * {@code reviewed_status: 0}(审核通过)配的 reason 就是 {@code "审核通过"} ——
|
|
|
+ * **驳回原因字段里填的不是驳回原因**。所以它只在 {@code reviewedStatus} 为 {@code "9"}
|
|
|
+ * 时才当作驳回原因,其余情况语义未知。
|
|
|
+ *
|
|
|
+ * <p>实现**不做归一化**:不清空、不替换,原样返回。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void queryReview_reviewedReason_isNotTrustedAsReason() {
|
|
|
+ stubUpstream(ok(",\"reviewed_status\":0,\"reviewed_reason\":\"审核通过\""));
|
|
|
+
|
|
|
+ LsyReviewStatusDTO dto = service.queryReview(MCH_NO);
|
|
|
+
|
|
|
+ assertEquals("审核通过", dto.getReviewedReason(),
|
|
|
+ "不得因为 status=0(通过)就把 reason 清空或改写 —— 那是归一化");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 字段缺失 / 为 JSON {@code null} ⇒ 三个字段都是 Java {@code null},
|
|
|
+ * **不得变成 4 个字符的字符串 {@code "null"}**。
|
|
|
+ *
|
|
|
+ * <p>{@code reviewed_reason} 这一支与 {@code areaList_itemJsonNull_isJavaNull_notStringNull}
|
|
|
+ * 是同一条判据:裸 {@code String.valueOf(null)} 得到的是字符串 {@code "null"},
|
|
|
+ * 那会被页面当成一句真实的驳回原因显示出来。两种输入都测:键**缺失**、
|
|
|
+ * 键在但值为 {@code null}(解析器对顶层 JSON null 已归一成 Java null,两条路径都要成立)。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void queryReview_missingOrNullFields_areJavaNull_notStringNull() {
|
|
|
+ for (String json : new String[]{
|
|
|
+ ok(""),
|
|
|
+ ok(",\"reviewed_status\":null,\"reviewed_reason\":null")}) {
|
|
|
+ stubUpstream(json);
|
|
|
+
|
|
|
+ LsyReviewStatusDTO dto = service.queryReview(MCH_NO);
|
|
|
+
|
|
|
+ assertNull(dto.getReviewedStatus(), json);
|
|
|
+ assertNull(dto.getReviewedReason(), json);
|
|
|
+ assertNull(dto.getStatusText(), json);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 空白 mchNo 与 {@code merchantInfo} 同一条守卫(同一个 400、同一句文案),同样不出海。 */
|
|
|
+ @Test
|
|
|
+ void queryReview_blankMchNo_throws400WithoutCallingUpstream() {
|
|
|
+ for (String blank : new String[]{null, "", " "}) {
|
|
|
+ BusinessException ex = assertThrows(BusinessException.class,
|
|
|
+ () -> service.queryReview(blank), "mchNo=[" + blank + "] 必须被拒");
|
|
|
+ assertEquals(400, ex.getCode(), "mchNo=[" + blank + "]");
|
|
|
+ assertEquals("商户号不能为空", ex.getMessage(), "mchNo=[" + blank + "]");
|
|
|
+ }
|
|
|
+ verifyNoInteractions(client);
|
|
|
+ }
|
|
|
}
|