|
@@ -305,6 +305,200 @@ class LsyMerchantServiceTest {
|
|
|
verify(merchantMapper, never()).insert(any());
|
|
verify(merchantMapper, never()).insert(any());
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // ────────────────── markSubmitted():进件「已提交」记号(2026-09-23 傻瓜化) ──────────────────
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 定向写 {@code review_status} + {@code updated_time},**恰好这两列**,值就是 {@code "SUBMITTED"}。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>★ 为什么这个记号必须有:{@code createOnboardingRow} 写 {@code PENDING},而审核通知也写
|
|
|
|
|
+ * {@code review_status} ⇒「建了号还没提交入网」与「提交了在等审核」在库里**长得一模一样**,
|
|
|
|
|
+ * 管理端因此答不出「下一步该干嘛」。本方法就是那个空档的填法。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>★ 为什么是集合相等而不是 contains:多一列意味着覆盖了别的列({@code save} 那条 D-1
|
|
|
|
|
+ * 静默数据缺陷正是这一形态),少一列意味着记号没写上。{@code status}(启用/停用)与
|
|
|
|
|
+ * {@code review_status} 是两件事,前者不得被本方法碰。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void markSubmitted_writesExactlyTheTwoColumns_withSubmittedWord() {
|
|
|
|
|
+ when(merchantMapper.selectList(any()))
|
|
|
|
|
+ .thenReturn(List.of(rowWithReviewStatus(EDIT_ID, "PENDING")));
|
|
|
|
|
+ when(merchantMapper.update(any(), any())).thenReturn(1);
|
|
|
|
|
+
|
|
|
|
|
+ service.markSubmitted("Y52211090004", 7L);
|
|
|
|
|
+
|
|
|
|
|
+ LambdaUpdateWrapper<?> w = capturedUpdate();
|
|
|
|
|
+ Set<String> cols = setColumns(w);
|
|
|
|
|
+ assertEquals(Set.of("review_status", "updated_time"), cols,
|
|
|
|
|
+ "SET 必须恰好是这两列。实测: " + w.getSqlSet());
|
|
|
|
|
+ assertFalse(cols.contains("status"),
|
|
|
|
|
+ "status 是启用/停用,不在本方法的职责里(同一个 contains 子串陷阱:review_status 含 status)");
|
|
|
|
|
+ assertFalse(cols.contains("mch_no"), "mch_no 只是定位条件,不得进 SET");
|
|
|
|
|
+ assertFalse(cols.contains("tenant_id"), "tenant_id 不得进 SET");
|
|
|
|
|
+ assertFalse(cols.contains("id"), "id 只是 WHERE 条件,不得进 SET");
|
|
|
|
|
+
|
|
|
|
|
+ // 写进去的是 getValue() 那个词,不是枚举常量名恰好相同就放行 —— 字面值由枚举自己的用例钉住
|
|
|
|
|
+ assertEquals("SUBMITTED", setValue(w, "review_status"),
|
|
|
|
|
+ "实测 SET 参数表: " + w.getParamNameValuePairs());
|
|
|
|
|
+
|
|
|
|
|
+ // updated_time 必须显式写:update(null, wrapper) 不经过实体,拿不到 AutoFill 的 strictUpdateFill
|
|
|
|
|
+ List<OffsetDateTime> stamps = w.getParamNameValuePairs().values().stream()
|
|
|
|
|
+ .filter(OffsetDateTime.class::isInstance).map(OffsetDateTime.class::cast)
|
|
|
|
|
+ .collect(Collectors.toList());
|
|
|
|
|
+ assertEquals(1, stamps.size(),
|
|
|
|
|
+ "SET 里应恰好一个时间戳参数(updated_time),实测: " + w.getParamNameValuePairs());
|
|
|
|
|
+ assertTrue(Duration.between(stamps.get(0), OffsetDateTime.now()).abs().toSeconds() < 60,
|
|
|
|
|
+ "updated_time 必须是本次写入的时刻,实测: " + stamps.get(0));
|
|
|
|
|
+
|
|
|
|
|
+ // 旧写法就是 updateById:NOT_NULL 策略会把实体上所有非 null 列都塞进 SET
|
|
|
|
|
+ verify(merchantMapper, never()).updateById(any());
|
|
|
|
|
+ verify(merchantMapper, never()).insert(any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * ★★ 本方法最要紧的一条:**定位查询必须带租户谓词**。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>同文件里有一个形状几乎一样、名字也像的 {@code updateReviewStatusByMchNo} ——
|
|
|
|
|
+ * 它是**免登录**入口,按 {@code mch_no} 跨租户 {@code selectList}。顺手复用它(或复用它调用的
|
|
|
|
|
+ * {@code requireByMchNo})会让本方法变成:任何登录用户拿别人的商户号点一下「提交入网」,
|
|
|
|
|
+ * 就能把**别的租户**那一行的状态改掉。这条用例就是拦这个的 —— 条件集必须恰好是
|
|
|
|
|
+ * ({@code tenant_id} + {@code mch_no})两条,且租户谓词绑的是入参租户。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void markSubmitted_lookupCarriesTenantPredicate_boundToTheInputTenant() {
|
|
|
|
|
+ when(merchantMapper.selectList(any()))
|
|
|
|
|
+ .thenReturn(List.of(rowWithReviewStatus(EDIT_ID, "PENDING")));
|
|
|
|
|
+ when(merchantMapper.update(any(), any())).thenReturn(1);
|
|
|
|
|
+
|
|
|
|
|
+ service.markSubmitted("Y52211090004", 7L);
|
|
|
|
|
+
|
|
|
|
|
+ AbstractWrapper<?, ?, ?> q = capturedSelectList();
|
|
|
|
|
+ String seg = q.getSqlSegment();
|
|
|
|
|
+ Set<String> conds = Arrays.stream(seg.replaceAll("[()]", "").split(" AND "))
|
|
|
|
|
+ .map(String::trim).collect(Collectors.toSet());
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(2, conds.size(), "定位条件应恰好两条(租户 + 商户号)。实测: [" + seg + "] ⇒ " + conds);
|
|
|
|
|
+ assertEquals(1, conds.stream().filter(c -> c.startsWith("tenant_id = ")).count(),
|
|
|
|
|
+ "必须恰有一条租户条件(少了就跨租户改别人的行)。实测条件: " + conds);
|
|
|
|
|
+ assertTrue(conds.stream().anyMatch(c -> c.startsWith("mch_no = ")),
|
|
|
|
|
+ "必须按 mch_no 定位。实测条件: " + conds);
|
|
|
|
|
+ assertEquals(7L, boundPredicateValue(q, "tenant_id"),
|
|
|
|
|
+ "租户谓词应绑定入参 tenantId。实测: " + q.getParamNameValuePairs());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 写入的 WHERE 指向**定位到那一行的 id**,不是 {@code mch_no}。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>同 {@code updateReviewStatusByMchNo_whereTargetsTheLocatedRowId} 的理由:定位与写入之间
|
|
|
|
|
+ * 若有人插进一行同 {@code mch_no} 的行(唯一索引 2026-09-23 时还没建),
|
|
|
|
|
+ * {@code WHERE mch_no = ?} 会一次改两行。主键定位没有这个窗口。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void markSubmitted_whereTargetsTheLocatedRowId() {
|
|
|
|
|
+ when(merchantMapper.selectList(any()))
|
|
|
|
|
+ .thenReturn(List.of(rowWithReviewStatus(EDIT_ID, "PENDING")));
|
|
|
|
|
+ when(merchantMapper.update(any(), any())).thenReturn(1);
|
|
|
|
|
+
|
|
|
|
|
+ service.markSubmitted("Y52211090004", 7L);
|
|
|
|
|
+
|
|
|
|
|
+ LambdaUpdateWrapper<?> w = capturedUpdate();
|
|
|
|
|
+ String where = w.getSqlSegment();
|
|
|
|
|
+ assertTrue(where.startsWith("(") && where.endsWith(")"),
|
|
|
|
|
+ "WHERE 应是一个带括号的等值条件(删掉 .eq 那行时实测为空串)。实测: [" + where + "]");
|
|
|
|
|
+ String inner = where.substring(1, where.length() - 1);
|
|
|
|
|
+ assertTrue(inner.contains(" = "), "WHERE 里应出现 ` = ` 分隔符。实测: [" + where + "]");
|
|
|
|
|
+ assertEquals("id", inner.substring(0, inner.indexOf(" = ")),
|
|
|
|
|
+ "定位列应是 id(换成 mch_no 时这里会报出那个列名)。实测: [" + where + "]");
|
|
|
|
|
+ assertEquals(inner.indexOf(" = "), inner.lastIndexOf(" = "),
|
|
|
|
|
+ "WHERE 里应恰有一处等值条件(两处以上 ⇒ 多了别的定位条件)。实测: [" + where + "]");
|
|
|
|
|
+
|
|
|
|
|
+ Matcher ph = Pattern.compile("MPGENVAL\\d+").matcher(inner);
|
|
|
|
|
+ assertTrue(ph.find(), "WHERE 里应有一个占位符。实测: [" + where + "]");
|
|
|
|
|
+ // ★ 先把组名取出来再找第二次:第二次 find() 成功之后 matcher 已越过末尾,
|
|
|
|
|
+ // 此时再调 group() 抛 IllegalStateException("No match found") —— 已实测。
|
|
|
|
|
+ // 形状照 save_updateBranch_whereTargetsExactlyTheInputId(那里就是这个顺序)。
|
|
|
|
|
+ String name = ph.group();
|
|
|
|
|
+ assertFalse(ph.find(), "WHERE 里应恰有一个占位符。实测: [" + where + "]");
|
|
|
|
|
+ assertEquals(Long.valueOf(EDIT_ID), w.getParamNameValuePairs().get(name),
|
|
|
|
|
+ "WHERE 绑的应是定位到那一行的 id,实测: " + w.getParamNameValuePairs());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 租户闸先于一切:{@code tenantId == null} ⇒ 403,且**一次库都不碰**。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>用 {@code verifyNoInteractions} 而不是只查异常码 —— 后者在「先查了库再抛 403」的写法下
|
|
|
|
|
+ * 照样绿,而那种写法已经把别租户的行读进内存了(同 Task 7 那条顺序断言的教训)。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void markSubmitted_nullTenant_throws403_andTouchesNothing() {
|
|
|
|
|
+ BusinessException ex = assertThrows(BusinessException.class,
|
|
|
|
|
+ () -> service.markSubmitted("Y52211090004", null));
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(403, ex.getCode(), "实测: " + ex.getCode());
|
|
|
|
|
+ verifyNoInteractions(merchantMapper);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 本租户内查不到 ⇒ 404,且**一行都不写**(不是「静默成功」—— 记号没落上就不能算提交成功)。 */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void markSubmitted_zeroRows_throws404_andWritesNothing() {
|
|
|
|
|
+ when(merchantMapper.selectList(any())).thenReturn(List.of());
|
|
|
|
|
+
|
|
|
|
|
+ BusinessException ex = assertThrows(BusinessException.class,
|
|
|
|
|
+ () -> service.markSubmitted("Y52211090004", 7L));
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(404, ex.getCode(), "实测: " + ex.getCode());
|
|
|
|
|
+ verify(merchantMapper, never()).update(any(), any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 本租户内命中多行 ⇒ 409,且**一行都不写**。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>这不是理论情形:真库里 {@code mch_no='Y52211090004'} 就有 2 行(见
|
|
|
|
|
+ * {@link #requireByMchNoForTenant_duplicateMchNoSameTenant_failsLoudly})。
|
|
|
|
|
+ * 挑一行改等于赌哪一行才对,而这个记号是给操作员看的 —— 改错行等于告诉他「提交成功了」,
|
|
|
|
|
+ * 而真正那一行还停在「待提交」。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void markSubmitted_multipleRows_throws409_andWritesNothing() {
|
|
|
|
|
+ when(merchantMapper.selectList(any())).thenReturn(List.of(
|
|
|
|
|
+ rowWithReviewStatus(1L, "PENDING"),
|
|
|
|
|
+ rowWithReviewStatus(2102313081860202497L, "PENDING")));
|
|
|
|
|
+
|
|
|
|
|
+ BusinessException ex = assertThrows(BusinessException.class,
|
|
|
|
|
+ () -> service.markSubmitted("Y52211090004", 7L));
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(409, ex.getCode(), "实测: " + ex.getCode());
|
|
|
|
|
+ verify(merchantMapper, never()).update(any(), any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 定位到了行、但 UPDATE 影响 0 行(定位与写入之间那一行被删)⇒ 404,**不是静默成功**。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>★ 为什么不能静默:记号没落上却回「成功」,操作员看到进度仍是「待提交入网」⇒ 再点一次
|
|
|
|
|
+ * 「提交入网」⇒ **上游又收到一次提交**。而这条路径的上游调用无法回滚。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>这条与上面 {@code zeroRows} 那条(闸里 0 行)是**两个不同的点**,mock 形状也不同:
|
|
|
|
|
+ * 那条是 {@code selectList} 返回空,这条是 {@code selectList} 有行而 {@code update} 返回 0。
|
|
|
|
|
+ * 形状照 {@code updateReviewStatusByMchNo_updateAffectsZeroRows_returnsNotFound_withoutThrowing}。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void markSubmitted_updateAffectsZeroRows_throws404_insteadOfSilentSuccess() {
|
|
|
|
|
+ when(merchantMapper.selectList(any()))
|
|
|
|
|
+ .thenReturn(List.of(rowWithReviewStatus(EDIT_ID, "PENDING")));
|
|
|
|
|
+ when(merchantMapper.update(any(), any())).thenReturn(0);
|
|
|
|
|
+
|
|
|
|
|
+ List<String> logs = logsOf(() -> {
|
|
|
|
|
+ BusinessException ex = assertThrows(BusinessException.class,
|
|
|
|
|
+ () -> service.markSubmitted("Y52211090004", 7L));
|
|
|
|
|
+ assertEquals(404, ex.getCode(), "实测: " + ex.getCode());
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ String all = String.join("\n", logs);
|
|
|
|
|
+ assertTrue(all.contains("Y52211090004"),
|
|
|
|
|
+ "0 行时 ERROR 日志必须带商户号(人工处置唯一线索)。实测日志: " + all);
|
|
|
|
|
+ assertTrue(all.contains(String.valueOf(EDIT_ID)),
|
|
|
|
|
+ "日志还须带定位到的 id(那一行就是被删掉的那一行)。实测日志: " + all);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// ─────────────────────── save():编辑分支(D-1 / D-2 / 0 行判据) ───────────────────────
|
|
// ─────────────────────── save():编辑分支(D-1 / D-2 / 0 行判据) ───────────────────────
|
|
|
|
|
|
|
|
@Test
|
|
@Test
|