|
|
@@ -8,7 +8,9 @@ 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.config.LeshouyingProperties;
|
|
|
import com.payment.platform.module.payment.leshouying.dto.LsyAreaItemDTO;
|
|
|
+import com.payment.platform.module.payment.leshouying.dto.LsyCommitMerchantInfoDTO;
|
|
|
import com.payment.platform.module.payment.leshouying.dto.LsyMerchantCreateDTO;
|
|
|
+import com.payment.platform.module.payment.leshouying.dto.LsyMerchantUpdateDTO;
|
|
|
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;
|
|
|
@@ -1903,6 +1905,542 @@ class LsyOnboardingServiceTest {
|
|
|
assertNotEquals(first, second, "两笔请求必须是两个流水号(trace_no 定义就是唯一标识一次请求)");
|
|
|
}
|
|
|
|
|
|
+ // ==================== Task 4:更新商户 / 提交入网信息 / 查询入网状态 ====================
|
|
|
+ //
|
|
|
+ // 三个薄腿(上游 page 196236557 / 196236563 / 196236564),形状照 createMerchant 的四步:
|
|
|
+ // ① 凭据闸 → ② 必填 → ③ 组装 → ④ 上送。三条都复用 mchNoParams(merchant_no + 新 trace_no)。
|
|
|
+ // ★ 键名大小写就是签名原文:store_Loca / mcht_Landuse 不得「顺手规整」。
|
|
|
+ // ★ reportQuery **不映射**任何码表(同名 reviewed_status 在本仓已有第三套值域),原样透传。
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 一份**逐字段填满**的更新商户入参(51 个字段:{@code mchNo} + 该页参数表的 50 个业务字段)。
|
|
|
+ *
|
|
|
+ * <p>★ 每个字段的值就是它自己的上游键名({@code merchant_name → "merchant_name"}),
|
|
|
+ * 理由与 {@link #merchantDto()} 逐字同源(那里写得细):一处接错 getter 就会让**值**对不上。
|
|
|
+ *
|
|
|
+ * <p>★ 本页与创建页**没有**的字段一个都不设:{@code merchant_name_business} / {@code merchant_type}
|
|
|
+ * (该页接口描述逐字写着这两个「创建后即固定,无法修改」)、{@code bd_id} / {@code notify_url}、
|
|
|
+ * {@code lease_contract} / {@code share_scene} / {@code share_certificate} /
|
|
|
+ * {@code tripartite_flow_certificate_url} —— 它们**不在**更新页的参数表里。
|
|
|
+ */
|
|
|
+ private static LsyMerchantUpdateDTO updateDto() {
|
|
|
+ LsyMerchantUpdateDTO dto = new LsyMerchantUpdateDTO();
|
|
|
+ dto.setMchNo(MCH_NO);
|
|
|
+ dto.setMerchantName("merchant_name");
|
|
|
+ dto.setMerchantNameShort("merchant_name_short");
|
|
|
+ dto.setMerchantMccCode("merchant_mcc_code");
|
|
|
+ dto.setContactName("contact_name");
|
|
|
+ dto.setContactPhone("contact_phone");
|
|
|
+ dto.setContactEmail("contact_email");
|
|
|
+ dto.setContactService("contact_service");
|
|
|
+ dto.setAddressProvince("address_province");
|
|
|
+ dto.setAddressCity("address_city");
|
|
|
+ dto.setAddressCounty("address_county");
|
|
|
+ dto.setAddressStreet("address_street");
|
|
|
+ dto.setLicenseType("license_type");
|
|
|
+ dto.setLicenseNo("license_no");
|
|
|
+ dto.setLicenseStt("license_stt");
|
|
|
+ dto.setLicenseExpire("license_expire");
|
|
|
+ dto.setAccountType("account_type");
|
|
|
+ dto.setLegalName("legal_name");
|
|
|
+ dto.setLegalCertNo("legal_cert_no");
|
|
|
+ dto.setLegalCertStt("legal_cert_stt");
|
|
|
+ dto.setLegalCertExpire("legal_cert_expire");
|
|
|
+ dto.setAccountIdcardNum("account_idcard_num");
|
|
|
+ dto.setAccountIdcardStt("account_idcard_stt");
|
|
|
+ dto.setAccountIdcardExpire("account_idcard_expire");
|
|
|
+ dto.setAccountCardName("account_card_name");
|
|
|
+ dto.setAccountCardNum("account_card_num");
|
|
|
+ dto.setAccountCardPhone("account_card_phone");
|
|
|
+ dto.setAccountBankCode("account_bank_code");
|
|
|
+ dto.setRegCapital("reg_capital");
|
|
|
+ dto.setFSettleAccountType("fSettle_account_type");
|
|
|
+ dto.setActualAddr("actual_addr");
|
|
|
+ dto.setBusinessScope("business_scope");
|
|
|
+ dto.setCheckCode("check_code");
|
|
|
+ dto.setRateCode("rate_code");
|
|
|
+ dto.setPictureLicense("picture_license");
|
|
|
+ dto.setPictureIdcardA("picture_idcard_a");
|
|
|
+ dto.setPictureIdcardB("picture_idcard_b");
|
|
|
+ dto.setPictureBankcardA("picture_bankcard_a");
|
|
|
+ dto.setPictureBankcardB("picture_bankcard_b");
|
|
|
+ dto.setPictureOutside("picture_outside");
|
|
|
+ dto.setPictureInside("picture_inside");
|
|
|
+ dto.setPictureTripartite("picture_tripartite");
|
|
|
+ dto.setPictureIdcardHander("picture_idcard_hander");
|
|
|
+ dto.setPictureOpenAccount("picture_open_account");
|
|
|
+ dto.setPictureOrganization("picture_organization");
|
|
|
+ dto.setPictureTaxRegistration("picture_tax_registration");
|
|
|
+ dto.setPictureUnincorporated("picture_unincorporated");
|
|
|
+ dto.setPictureBankIdcardA("picture_bank_idcard_a");
|
|
|
+ dto.setPictureBankIdcardB("picture_bank_idcard_b");
|
|
|
+ dto.setPCashier("p_cashier");
|
|
|
+ dto.setPictureOther("picture_other");
|
|
|
+ return dto;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 在 {@link #updateDto()}(全填)上改一项。 */
|
|
|
+ private static LsyMerchantUpdateDTO updateDto(Consumer<LsyMerchantUpdateDTO> mutation) {
|
|
|
+ LsyMerchantUpdateDTO dto = updateDto();
|
|
|
+ mutation.accept(dto);
|
|
|
+ return dto;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 更新腿出站参数的**字面期望表**:51 个键({@code trace_no} 另算,它每次新生成)。
|
|
|
+ *
|
|
|
+ * <p>★ 手写字面表,不从 {@code LsyMerchantUpdateDTO} 反射生成 —— 理由与
|
|
|
+ * {@link #expectedMerchantParams()} 同源(裁定 h1):反射表是自洽式断言,实现改错它跟着错。
|
|
|
+ *
|
|
|
+ * <p>★ {@code channel_id} 是 **{@code Integer}**:page 196236557 把它声明成 {@code Integer(11)},
|
|
|
+ * 与创建页同形(**不是**提交入网页 / 入网状态页那种光秃秃的 {@code String})⇒ 走既有的
|
|
|
+ * {@code upstreamInteger}(与创建腿同一处转换、同一道「规范十进制」守卫)。请求体是 JSON,
|
|
|
+ * {@code 1001} 与 {@code "1001"} 在报文上不同。
|
|
|
+ */
|
|
|
+ private static Map<String, Object> expectedUpdateParams() {
|
|
|
+ Map<String, Object> p = new LinkedHashMap<>();
|
|
|
+ // ---- 服务端拥有的一项(不是 DTO 字段)----
|
|
|
+ p.put("channel_id", 1001); // ★ Integer:page 196236557 声明 Integer(11)
|
|
|
+ // ---- DTO 的 50 个:值就是它自己的上游键名 ----
|
|
|
+ p.put("merchant_name", "merchant_name");
|
|
|
+ p.put("merchant_name_short", "merchant_name_short");
|
|
|
+ p.put("merchant_mcc_code", "merchant_mcc_code");
|
|
|
+ p.put("contact_name", "contact_name");
|
|
|
+ p.put("contact_phone", "contact_phone");
|
|
|
+ p.put("contact_email", "contact_email");
|
|
|
+ p.put("contact_service", "contact_service");
|
|
|
+ p.put("address_province", "address_province");
|
|
|
+ p.put("address_city", "address_city");
|
|
|
+ p.put("address_county", "address_county");
|
|
|
+ p.put("address_street", "address_street");
|
|
|
+ p.put("license_type", "license_type");
|
|
|
+ p.put("license_no", "license_no");
|
|
|
+ p.put("license_stt", "license_stt");
|
|
|
+ p.put("license_expire", "license_expire");
|
|
|
+ p.put("account_type", "account_type");
|
|
|
+ p.put("legal_name", "legal_name");
|
|
|
+ p.put("legal_cert_no", "legal_cert_no");
|
|
|
+ p.put("legal_cert_stt", "legal_cert_stt");
|
|
|
+ p.put("legal_cert_expire", "legal_cert_expire");
|
|
|
+ p.put("account_idcard_num", "account_idcard_num");
|
|
|
+ p.put("account_idcard_stt", "account_idcard_stt");
|
|
|
+ p.put("account_idcard_expire", "account_idcard_expire");
|
|
|
+ p.put("account_card_name", "account_card_name");
|
|
|
+ p.put("account_card_num", "account_card_num");
|
|
|
+ p.put("account_card_phone", "account_card_phone");
|
|
|
+ p.put("account_bank_code", "account_bank_code");
|
|
|
+ p.put("reg_capital", "reg_capital");
|
|
|
+ p.put("fSettle_account_type", "fSettle_account_type");
|
|
|
+ p.put("actual_addr", "actual_addr");
|
|
|
+ p.put("business_scope", "business_scope");
|
|
|
+ p.put("check_code", "check_code");
|
|
|
+ p.put("rate_code", "rate_code");
|
|
|
+ p.put("picture_license", "picture_license");
|
|
|
+ p.put("picture_idcard_a", "picture_idcard_a");
|
|
|
+ p.put("picture_idcard_b", "picture_idcard_b");
|
|
|
+ p.put("picture_bankcard_a", "picture_bankcard_a");
|
|
|
+ p.put("picture_bankcard_b", "picture_bankcard_b");
|
|
|
+ p.put("picture_outside", "picture_outside");
|
|
|
+ p.put("picture_inside", "picture_inside");
|
|
|
+ p.put("picture_tripartite", "picture_tripartite");
|
|
|
+ p.put("picture_idcard_hander", "picture_idcard_hander");
|
|
|
+ p.put("picture_open_account", "picture_open_account");
|
|
|
+ p.put("picture_organization", "picture_organization");
|
|
|
+ p.put("picture_tax_registration", "picture_tax_registration");
|
|
|
+ p.put("picture_unincorporated", "picture_unincorporated");
|
|
|
+ p.put("picture_bank_idcard_a", "picture_bank_idcard_a");
|
|
|
+ p.put("picture_bank_idcard_b", "picture_bank_idcard_b");
|
|
|
+ p.put("p_cashier", "p_cashier");
|
|
|
+ p.put("picture_other", "picture_other");
|
|
|
+ return p;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ <b>增量语义</b>:page 196236557 页末逐字写着「此接口更新模式为<u>增量更新</u>,
|
|
|
+ * 请求参数有值则更新,请求参数无值则保持原数据」⇒ <b>空白字段不得出现在参数 Map 里</b>。
|
|
|
+ *
|
|
|
+ * <p>两种输入各钉一件事,都在同一次断言里:
|
|
|
+ * <ol>
|
|
|
+ * <li><b>全填</b> ⇒ 键集恰好 51 个字面键 + {@code trace_no},且除 {@code trace_no} 外每个键的
|
|
|
+ * 值(含类型)都对得上(挡住「A 字段取了 B 的 getter」);</li>
|
|
|
+ * <li><b>四个字段置空白</b> ⇒ 那四个键**一个都不出现**({@code merchant_name} 空串、
|
|
|
+ * {@code contact_email} 全空格、{@code picture_other} {@code null}、
|
|
|
+ * {@code account_type} 空串 —— 三种「没填」的形态都覆盖到)。</li>
|
|
|
+ * </ol>
|
|
|
+ *
|
|
|
+ * <p>★ 为什么是**键集**断言而不是「值不为空」:本仓签名规则是 <b>null 不参与、空串参与</b>
|
|
|
+ * ⇒ 一个 {@code ""} 会让签名原文凭空多一项 {@code xxx=},报文与签名一起变,而失败只在上游
|
|
|
+ * 表现为一句笼统的话。客户端 {@code signedPayload} 只剔 {@code null},**不剔空串** ——
|
|
|
+ * 所以「不得送空串」这条只能由组装层保证(Task 3 的 U3 契约,本任务同样受它约束)。
|
|
|
+ *
|
|
|
+ * <p>★ {@code account_type} 在本页标「必填=是」,但它照样走增量(有值才发):该页「必填=是」
|
|
|
+ * 与页末「增量更新」两句在增量语义下互相矛盾,裁定 R39/R40 取增量语义 —— 理由见
|
|
|
+ * {@link com.payment.platform.module.payment.leshouying.dto.LsyMerchantUpdateDTO} 的类 javadoc。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void updateMerchant_sendsOnlyNonBlankFields() {
|
|
|
+ withOnboardingCredentials();
|
|
|
+ stubUpstream(ok(""));
|
|
|
+
|
|
|
+ service.updateMerchant(updateDto());
|
|
|
+ service.updateMerchant(updateDto(d -> {
|
|
|
+ d.setMerchantName("");
|
|
|
+ d.setContactEmail(" ");
|
|
|
+ d.setPictureOther(null);
|
|
|
+ d.setAccountType("");
|
|
|
+ }));
|
|
|
+
|
|
|
+ List<Map<String, Object>> sentList = allSentParams("/api/merchant/update", 2);
|
|
|
+ Map<String, Object> full = sentList.get(0);
|
|
|
+ Map<String, Object> partial = sentList.get(1);
|
|
|
+
|
|
|
+ // ① 全填:除 trace_no 外每个键的值(含类型)都要对得上
|
|
|
+ Map<String, Object> expectedFullValues = new LinkedHashMap<>();
|
|
|
+ expectedFullValues.put("merchant_no", MCH_NO); // 来自 mchNoParams
|
|
|
+ expectedFullValues.putAll(expectedUpdateParams());
|
|
|
+ Set<String> expectedFull = new LinkedHashSet<>(expectedFullValues.keySet());
|
|
|
+ expectedFull.add("trace_no");
|
|
|
+ assertEquals(expectedFull, full.keySet(),
|
|
|
+ "出站参数键集必须恰好是 merchant_no + channel_id + DTO 的 50 个字段 + trace_no(共 53 个):"
|
|
|
+ + full.keySet());
|
|
|
+ Object traceNo = full.get("trace_no");
|
|
|
+ assertInstanceOf(String.class, traceNo, "trace_no 必须是字符串,实际: " + traceNo);
|
|
|
+ assertEquals(32, ((String) traceNo).length(),
|
|
|
+ "trace_no 必须是 IdUtil.fastSimpleUUID() 的形状(32 位),实际: " + traceNo);
|
|
|
+ Map<String, Object> actualFull = new LinkedHashMap<>(full);
|
|
|
+ actualFull.remove("trace_no");
|
|
|
+ assertEquals(expectedFullValues, actualFull,
|
|
|
+ "每个键的值(含类型)都要对得上 —— 夹具里每个字段的值就是它自己的上游键名,"
|
|
|
+ + "所以「A 字段取了 B 的 getter」也会在这里红");
|
|
|
+
|
|
|
+ // ② 四个空白字段 ⇒ 四个键都不在(增量语义)
|
|
|
+ Set<String> expectedPartial = new LinkedHashSet<>(expectedFull);
|
|
|
+ expectedPartial.removeAll(Set.of("merchant_name", "contact_email", "picture_other", "account_type"));
|
|
|
+ assertEquals(expectedPartial, partial.keySet(),
|
|
|
+ "空白字段**不得**出现在参数 Map 里(增量语义;空串还会参与签名):" + partial.keySet());
|
|
|
+ Map<String, Object> actualPartial = new LinkedHashMap<>(partial);
|
|
|
+ actualPartial.remove("trace_no");
|
|
|
+ Map<String, Object> expectedPartialValues = new LinkedHashMap<>(expectedFullValues);
|
|
|
+ for (String blank : new String[]{"merchant_name", "contact_email", "picture_other", "account_type"}) {
|
|
|
+ expectedPartialValues.remove(blank);
|
|
|
+ }
|
|
|
+ assertEquals(expectedPartialValues, actualPartial,
|
|
|
+ "被剔掉的只能是那四个空白字段,其余字段的值一个都不许变:" + actualPartial);
|
|
|
+
|
|
|
+ // ③ 不变式:两次出站里**任何**值都不得是空白串(空串参与签名 ⇒ 报文与签名一起变)
|
|
|
+ for (Map<String, Object> sent : sentList) {
|
|
|
+ sent.forEach((key, value) -> assertFalse(value instanceof String s && s.isBlank(),
|
|
|
+ "参数 " + key + " 的值是空白串(U3 契约:没填就不发这个键):" + sent));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ <b>这一页的两个必填项是「商户号」与「通道 id」,一个由 DTO 给、一个由配置给</b> ——
|
|
|
+ * 「即使 DTO 里为空也要有校验(400)」。
|
|
|
+ *
|
|
|
+ * <p>三个子场景:
|
|
|
+ * <ol>
|
|
|
+ * <li><b>DTO 的其余 50 个字段全空</b> ⇒ 出站仍恰好是 {@code merchant_no} + {@code channel_id}
|
|
|
+ * (+ 服务自己生成的 {@code trace_no})—— 增量语义的另一个方向:可选的都不发,
|
|
|
+ * 必填的两个照发;</li>
|
|
|
+ * <li>{@code mchNo} 空白 ⇒ 400,文案「商户号不能为空」(走 {@code mchNoParams},
|
|
|
+ * 与创建/查询腿同一处校验);</li>
|
|
|
+ * <li>{@code channel-id} 没配 ⇒ 凭据闸 400(与创建腿**同一道闸**:这三条腿的
|
|
|
+ * {@code channel_id} 同样取自配置,只在创建腿设闸会造出「配置缺失时创建腿拒、
|
|
|
+ * 更新腿静默用空值上送」的分叉)。</li>
|
|
|
+ * </ol>
|
|
|
+ * 全程**只出海一次**(末行那条 {@code times(1)} 就是 ②③ 没发上游的证据)。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void updateMerchant_alwaysSendsMchNoAndChannelId() {
|
|
|
+ withOnboardingCredentials();
|
|
|
+ stubUpstream(ok(""));
|
|
|
+
|
|
|
+ // ① 只给 mchNo
|
|
|
+ LsyMerchantUpdateDTO onlyMchNo = new LsyMerchantUpdateDTO();
|
|
|
+ onlyMchNo.setMchNo(MCH_NO);
|
|
|
+ service.updateMerchant(onlyMchNo);
|
|
|
+
|
|
|
+ Map<String, Object> sent = sentParams("/api/merchant/update");
|
|
|
+ assertEquals(Set.of("merchant_no", "trace_no", "channel_id"), sent.keySet(),
|
|
|
+ "DTO 其余字段全空时,出站只该有这三个键:" + sent.keySet());
|
|
|
+ assertEquals(MCH_NO, sent.get("merchant_no"));
|
|
|
+ Object channelId = sent.get("channel_id");
|
|
|
+ assertInstanceOf(Integer.class, channelId,
|
|
|
+ "page 196236557 把 channel_id 声明成 Integer(11),实际: " + channelId);
|
|
|
+ assertEquals(1001, ((Integer) channelId).intValue());
|
|
|
+
|
|
|
+ // ② mchNo 空白 ⇒ 400(文案证明它过了凭据闸、是被 mchNoParams 拒的)
|
|
|
+ BusinessException blankMchNo = assertThrows(BusinessException.class,
|
|
|
+ () -> service.updateMerchant(updateDto(d -> d.setMchNo(" "))),
|
|
|
+ "商户号空白必须被拒");
|
|
|
+ assertEquals(400, blankMchNo.getCode());
|
|
|
+ assertEquals("商户号不能为空", blankMchNo.getMessage());
|
|
|
+
|
|
|
+ // ③ channel-id 没配 ⇒ 凭据闸 400
|
|
|
+ props.setChannelId(" ");
|
|
|
+ BusinessException noCredential = assertThrows(BusinessException.class,
|
|
|
+ () -> service.updateMerchant(updateDto()), "通道 id 没配时必须被拒");
|
|
|
+ assertEquals(400, noCredential.getCode());
|
|
|
+ assertCredentialsMessage(noCredential.getMessage(), 1);
|
|
|
+
|
|
|
+ // 全程恰好一次出站 —— ②③ 一次都没发
|
|
|
+ verify(client, times(1)).postApi(anyString(), anyMap());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---- 提交商户入网信息(上游 page 196236563,路径 /api/merchant/commitMerchantInfo)----
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 一份**逐字段填满**的提交入网信息入参:{@code mchNo} + 该页参数表的六个业务字段
|
|
|
+ * (六个在该页**全是「必填=是」**)。值就是它自己的上游键名。
|
|
|
+ */
|
|
|
+ private static LsyCommitMerchantInfoDTO commitDto() {
|
|
|
+ LsyCommitMerchantInfoDTO dto = new LsyCommitMerchantInfoDTO();
|
|
|
+ dto.setMchNo(MCH_NO);
|
|
|
+ dto.setMchtAptitude("mcht_aptitude");
|
|
|
+ dto.setMchtAptitudeNote("mcht_aptitude_note");
|
|
|
+ dto.setStoreLoca("store_Loca");
|
|
|
+ dto.setMchtLanduse("mcht_Landuse");
|
|
|
+ dto.setLongitude("longitude");
|
|
|
+ dto.setLatitude("latitude");
|
|
|
+ return dto;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 在 {@link #commitDto()}(全填)上改一项。 */
|
|
|
+ private static LsyCommitMerchantInfoDTO commitDto(Consumer<LsyCommitMerchantInfoDTO> mutation) {
|
|
|
+ LsyCommitMerchantInfoDTO dto = commitDto();
|
|
|
+ mutation.accept(dto);
|
|
|
+ return dto;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 出站参数**恰好 9 个**:{@code merchant_no} + {@code trace_no}({@code mchNoParams} 一并产出的
|
|
|
+ * 两个)+ 配置的 {@code channel_id} + 该页参数表那六个业务字段。
|
|
|
+ *
|
|
|
+ * <p>★ 为什么是 9 而不是 7:参数表里扣掉客户端自动补的 {@code inst_no} / {@code sign} 之后,
|
|
|
+ * 服务层要组装的就是 {@code merchant_no} + {@code channel_id} + 六个。把
|
|
|
+ * {@code merchant_no}/{@code trace_no} 漏掉是**最可能**的错法,而 {@code trace_no} 每次调用
|
|
|
+ * 都要重新生成,缺了会撞流水 —— 失败形态是笼统的「重复请求」,看不出是哪个参数。
|
|
|
+ *
|
|
|
+ * <p>★ {@code channel_id} 在**本页**声明成光秃秃的 {@code String}(不是创建页/更新页的
|
|
|
+ * {@code Integer(11)})⇒ 原样发配置字符串,不过 {@code upstreamInteger}。同一物理值在四个页面上
|
|
|
+ * 的声明类型不同,本类按**各页自己的声明**发(计划 §0.5 对同批字段的口径:「按各自页的类型发」)。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void commitMerchantInfo_sendsAllNineParams() {
|
|
|
+ withOnboardingCredentials();
|
|
|
+ stubUpstream(ok(""));
|
|
|
+
|
|
|
+ service.commitMerchantInfo(commitDto());
|
|
|
+
|
|
|
+ Map<String, Object> sent = sentParams("/api/merchant/commitMerchantInfo");
|
|
|
+ Set<String> expected = new LinkedHashSet<>(Arrays.asList(
|
|
|
+ "merchant_no", "trace_no", "channel_id",
|
|
|
+ "mcht_aptitude", "mcht_aptitude_note", "store_Loca", "mcht_Landuse",
|
|
|
+ "longitude", "latitude"));
|
|
|
+ assertEquals(expected, sent.keySet(),
|
|
|
+ "出站参数必须恰好这 9 个键(含 merchant_no 与 trace_no):" + sent.keySet());
|
|
|
+ assertEquals(MCH_NO, sent.get("merchant_no"));
|
|
|
+ assertEquals(CFG_CHANNEL_ID, sent.get("channel_id"),
|
|
|
+ "本页把 channel_id 声明成 String ⇒ 原样发配置字符串,实际: " + sent.get("channel_id"));
|
|
|
+ assertEquals("mcht_aptitude", sent.get("mcht_aptitude"));
|
|
|
+ assertEquals("mcht_aptitude_note", sent.get("mcht_aptitude_note"));
|
|
|
+ assertEquals("store_Loca", sent.get("store_Loca"));
|
|
|
+ assertEquals("mcht_Landuse", sent.get("mcht_Landuse"));
|
|
|
+ assertEquals("longitude", sent.get("longitude"));
|
|
|
+ assertEquals("latitude", sent.get("latitude"));
|
|
|
+ Object traceNo = sent.get("trace_no");
|
|
|
+ assertInstanceOf(String.class, traceNo, "trace_no 必须是字符串,实际: " + traceNo);
|
|
|
+ assertEquals(32, ((String) traceNo).length(),
|
|
|
+ "trace_no 必须是 IdUtil.fastSimpleUUID() 的形状(32 位),实际: " + traceNo);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ <b>{@code store_Loca} / {@code mcht_Landuse} 的大小写是参数表原文,不得「顺手规整」</b>
|
|
|
+ * —— 键名进**签名原文**,改一个字母就换签名,而失败只会表现为上游一句笼统的「签名错误」。
|
|
|
+ * 同族的既有先例:{@code fSettle_account_type}、上游自己拼错的 {@code picture_idcard_hander}。
|
|
|
+ *
|
|
|
+ * <p>两条断言各钉一半:① 「忽略大小写后恰好只有一个键、且拼写就是原文」(挡住改名,也挡住
|
|
|
+ * 同时发出规整版与原版两个键);② 逐字 {@code containsKey}(失败信息直接点出实际键集,
|
|
|
+ * 比集合相等那条更好读)。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void commitMerchantInfo_preservesUpstreamKeyCasing() {
|
|
|
+ withOnboardingCredentials();
|
|
|
+ stubUpstream(ok(""));
|
|
|
+
|
|
|
+ service.commitMerchantInfo(commitDto());
|
|
|
+
|
|
|
+ Map<String, Object> sent = sentParams("/api/merchant/commitMerchantInfo");
|
|
|
+ for (String exact : new String[]{"store_Loca", "mcht_Landuse"}) {
|
|
|
+ assertTrue(sent.containsKey(exact),
|
|
|
+ "键名必须是参数表原文 " + exact + "(签名对键名敏感),实际键集: " + sent.keySet());
|
|
|
+ List<String> sameIgnoringCase = sent.keySet().stream()
|
|
|
+ .filter(k -> k.equalsIgnoreCase(exact))
|
|
|
+ .toList();
|
|
|
+ assertEquals(List.of(exact), sameIgnoringCase,
|
|
|
+ exact + " 一族(忽略大小写)必须恰好一个键、且拼写是 " + exact
|
|
|
+ + ",实际: " + sameIgnoringCase);
|
|
|
+ }
|
|
|
+ assertEquals("store_Loca", sent.get("store_Loca"), "值必须落在原文键上");
|
|
|
+ assertEquals("mcht_Landuse", sent.get("mcht_Landuse"), "值必须落在原文键上");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 该页六个业务字段**全是「必填=是」** ⇒ 逐个置空白都要在**组报文之前**被拒(400),
|
|
|
+ * 一次上游调用都不发;文案点名**中文字段名**(不是键名 —— 报键名的话操作者得在文档里
|
|
|
+ * 做一次心算才能对上是界面上的哪个输入框)。
|
|
|
+ *
|
|
|
+ * <p>空白的三种形态({@code null} / 空串 / 全空格)逐个试,因为「漏了其中一个」正是这条要防的。
|
|
|
+ * 形状照 {@link #createShop_blankRequiredField_throws400}(那里的上游必填列同样是「是」)。
|
|
|
+ *
|
|
|
+ * <p>★ <b>为什么这条腿要校验、而更新腿只校验两个</b>:本页**没有**「增量更新」那句话,
|
|
|
+ * 它的必填标记没有歧义;更新页有那句话,两句在增量语义下互相矛盾,裁定取增量语义(R39/R40)。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void commitMerchantInfo_blankRequiredField_throws400() {
|
|
|
+ withOnboardingCredentials();
|
|
|
+ for (String blank : new String[]{null, "", " "}) {
|
|
|
+ assertCommitRejected400("商户号不能为空", d -> d.setMchNo(blank));
|
|
|
+ assertCommitRejected400("商户资质不能为空", d -> d.setMchtAptitude(blank));
|
|
|
+ assertCommitRejected400("商户资质备注不能为空", d -> d.setMchtAptitudeNote(blank));
|
|
|
+ assertCommitRejected400("门店所在位置不能为空", d -> d.setStoreLoca(blank));
|
|
|
+ assertCommitRejected400("营业用地不能为空", d -> d.setMchtLanduse(blank));
|
|
|
+ assertCommitRejected400("营业地址经度不能为空", d -> d.setLongitude(blank));
|
|
|
+ // 上游说明列原文是「营业地址**维度**」("维度" 疑为 "纬度" 的错字):标签按说明列
|
|
|
+ // **逐字**取(与 LsyMerchantRequiredFields.LABELS 同一条规则),不顺手改字。
|
|
|
+ assertCommitRejected400("营业地址维度不能为空", d -> d.setLatitude(blank));
|
|
|
+ }
|
|
|
+ verifyNoInteractions(client);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 断言某一次 {@code commitMerchantInfo} 被 400 拒掉,且文案点名的是**中文字段名**。 */
|
|
|
+ private void assertCommitRejected400(String expectedMessage,
|
|
|
+ Consumer<LsyCommitMerchantInfoDTO> mutation) {
|
|
|
+ BusinessException ex = assertThrows(BusinessException.class,
|
|
|
+ () -> service.commitMerchantInfo(commitDto(mutation)), expectedMessage + " 必须被拒");
|
|
|
+ assertEquals(400, ex.getCode(), expectedMessage);
|
|
|
+ assertEquals(expectedMessage, ex.getMessage(), expectedMessage);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---- 查询商户入网状态(上游 page 196236564,路径 /api/merchant/reportQuery)----
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ <b>0..6 逐个原样回,不映射、不建枚举。</b>该页**同页内**就有两套互相冲突的 0–6 映射
|
|
|
+ * (返回参数表一套、接口描述块另一套,逐字抄在
|
|
|
+ * {@code LsyOnboardingService#reportQuery} 的 javadoc 里)⇒ 任何映射都是在挑一套猜。
|
|
|
+ *
|
|
|
+ * <p>顺带把出站参数也钉住:{@code reportQuery} 只收一个参数({@code mchNo}),
|
|
|
+ * {@code channel_id} 与另外两条腿一样取自配置 ⇒ 七次调用每次都恰好三个键,
|
|
|
+ * 且七个 {@code trace_no} 两两不同({@code mchNoParams} 的「每次新生成」在这条腿上同样成立)。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void reportQuery_returnsRawStatus_forAllSevenKnownCodes() {
|
|
|
+ withOnboardingCredentials();
|
|
|
+
|
|
|
+ List<String> codes = List.of("0", "1", "2", "3", "4", "5", "6");
|
|
|
+ for (String code : codes) {
|
|
|
+ stubUpstream(ok(",\"reviewed_status\":\"" + code + "\""));
|
|
|
+ assertEquals(code, service.reportQuery(MCH_NO),
|
|
|
+ "码 " + code + " 必须**原样**返回 —— 本页两套映射自己就冲突,映射它就是挑一套猜");
|
|
|
+ }
|
|
|
+
|
|
|
+ List<Map<String, Object>> sentList = allSentParams("/api/merchant/reportQuery", codes.size());
|
|
|
+ Set<String> traceNos = new LinkedHashSet<>();
|
|
|
+ for (Map<String, Object> sent : sentList) {
|
|
|
+ assertEquals(Set.of("merchant_no", "trace_no", "channel_id"), sent.keySet(),
|
|
|
+ "出站参数恰好三个键:" + sent.keySet());
|
|
|
+ assertEquals(MCH_NO, sent.get("merchant_no"));
|
|
|
+ assertEquals(CFG_CHANNEL_ID, sent.get("channel_id"),
|
|
|
+ "channel_id 取自配置(R37:本页说明逐字写「同创建商户接口」)");
|
|
|
+ traceNos.add((String) sent.get("trace_no"));
|
|
|
+ }
|
|
|
+ assertEquals(codes.size(), traceNos.size(), "七次调用的 trace_no 必须两两不同:" + traceNos);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 未知码原样回、不抛 —— 这里选的几个码**不是**「没人用过的数字」:
|
|
|
+ * {@code 9 / 10 / 98 / 99} 正是**同名字段的另一套值域**(page 196236567,
|
|
|
+ * {@code LsyReviewStatus})里的码,{@code "审核通过"} 是 page 196236556 示例里的中文值。
|
|
|
+ *
|
|
|
+ * <p>换句话说本用例就是那条陷阱的探测器:谁把 {@code LsyReviewStatus} 接进来做映射
|
|
|
+ * (「长得非常像的现成枚举」,顺手复用是最可能发生的错),{@code 9} 这一格立刻红
|
|
|
+ * —— 码 {@code 2} 在**本页**是「审核驳回」,而在那套值域里根本没有对应项,复用会**静默**
|
|
|
+ * 给出错误文案(不抛、不红)。所以本方法返回的是**原始字符串**,不是任何文案。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void reportQuery_returnsRawStatus_forUnknownCode() {
|
|
|
+ withOnboardingCredentials();
|
|
|
+
|
|
|
+ for (String code : new String[]{"9", "10", "98", "99", "7", "审核通过"}) {
|
|
|
+ stubUpstream(ok(",\"reviewed_status\":\"" + code + "\""));
|
|
|
+ assertEquals(code, service.reportQuery(MCH_NO),
|
|
|
+ "未知码 " + code + " 必须原样回,不抛也不映射");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 缺键 / JSON {@code null} ⇒ 回 Java {@code null},**不抛**(§0.5:该页的返回示例是空代码块,
|
|
|
+ * 应答结构只能等实测 ⇒ 键可能真的不在)。
|
|
|
+ *
|
|
|
+ * <p>两种输入都走 {@code asText}(Java null 与 Hutool {@code JSONNull} 都归成 null);
|
|
|
+ * 后者是**真实形态**:解析器只把**顶层**的 JSON null 归一成 Java null(嵌套的不归一,
|
|
|
+ * 见 {@code toAreaItems}),而这里是顶层键。
|
|
|
+ *
|
|
|
+ * <p>★ 本用例**不**覆盖「键在、值是空串」这一格:那会返回 {@code ""} 而不是 {@code null}
|
|
|
+ * (实现就是 {@code asText(resp.get(...))} 一件事,不做空串归一化)。本页没有依据说上游会给空串,
|
|
|
+ * 记在报告的待确认项里,不在断言里替它定行为。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void reportQuery_missingStatus_returnsNull() {
|
|
|
+ withOnboardingCredentials();
|
|
|
+
|
|
|
+ for (String json : new String[]{ok(""), ok(",\"reviewed_status\":null")}) {
|
|
|
+ stubUpstream(json);
|
|
|
+ assertNull(service.reportQuery(MCH_NO),
|
|
|
+ "缺键 / JSON null ⇒ 必须是 Java null(不抛、不编):" + json);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---- 三条薄腿共用的凭据闸(裁定 R38)----
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 三条**薄腿**(更新 / 提交入网 / 入网状态查询)都走同一道凭据闸 —— 裁定 R38。
|
|
|
+ *
|
|
|
+ * <p>为什么单独一条:上面每条腿的用例都先调 {@link #withOnboardingCredentials()}(把配置配齐),
|
|
|
+ * 于是「闸在不在」这件事**对它们全都不可见** —— 实测把 {@code requireOnboardingCredentials()}
|
|
|
+ * 从 {@code commitMerchantInfo} 与 {@code reportQuery} 里**各删一行**,本类 65 条**全绿**
|
|
|
+ * (只有 {@code updateMerchant} 那条在 ③ 段里钉了闸)。
|
|
|
+ * 那种形态的后果不是本侧报错,而是**配置缺失时拿空 {@code channel_id} 去签名上送** ——
|
|
|
+ * 出错只在上游,本侧静默。所以这条用例把三条腿的闸一次性钉住。
|
|
|
+ *
|
|
|
+ * <p>断言三件事,缺一不可:① 三条腿各自 400;② 文案是「凭据缺失」那套(不是
|
|
|
+ * {@code mchNoParams} 的「商户号不能为空」——否则 400 可能来自另一处校验,闸照样能是缺的);
|
|
|
+ * ③ 一次上游都没发。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void newLegs_missingCredentials_allThreeLegsThrow400WithoutCallingUpstream() {
|
|
|
+ // 一个键都不配(通道 id 等五项全缺,走 props 的默认空值)
|
|
|
+ BusinessException update = assertThrows(BusinessException.class,
|
|
|
+ () -> service.updateMerchant(updateDto()), "更新腿缺凭据时必须被拒");
|
|
|
+ BusinessException commit = assertThrows(BusinessException.class,
|
|
|
+ () -> service.commitMerchantInfo(commitDto()), "提交入网腿缺凭据时必须被拒");
|
|
|
+ BusinessException report = assertThrows(BusinessException.class,
|
|
|
+ () -> service.reportQuery(MCH_NO), "入网状态腿缺凭据时必须被拒");
|
|
|
+
|
|
|
+ for (BusinessException e : new BusinessException[]{update, commit, report}) {
|
|
|
+ assertEquals(400, e.getCode(), "凭据缺失一律 400,实际: " + e.getMessage());
|
|
|
+ // 文案必须是凭据闸那套:若这里是「商户号不能为空」,说明 400 来自 mchNoParams,
|
|
|
+ // 凭据闸有没有设这条用例都没咬住(② 就是为此存在的)
|
|
|
+ assertFalse("商户号不能为空".equals(e.getMessage()),
|
|
|
+ "400 来自 mchNoParams 而不是凭据闸,本用例对闸是空的: " + e.getMessage());
|
|
|
+ assertCredentialsMessage(e.getMessage(), 5);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ③ 三条腿一次都没发上游(闸在组装/上送之前)
|
|
|
+ verifyNoInteractions(client);
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* 把 {@link ListAppender} 挂到指定 logger 上、跑完 body、**无论成败都摘掉**
|
|
|
* —— 与 {@code LsyNotifyServiceTest} / {@code LsyQrPayTest} 同款(本仓库按测试文件各自持有一份,
|