|
|
@@ -1,5 +1,6 @@
|
|
|
package com.payment.platform.module.payment.leshouying.service;
|
|
|
|
|
|
+import com.payment.platform.module.payment.leshouying.client.LeshouyingApiException;
|
|
|
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.entity.LsyOrderEntity;
|
|
|
@@ -163,27 +164,107 @@ class LsyRefundServiceTest {
|
|
|
}
|
|
|
|
|
|
@Test
|
|
|
- void refund_insertsAsRefundingFirst_neverDirectlyTerminal() {
|
|
|
- // 必须先进 REFUNDING 再收敛:insert 成 SUCCESS 会让本行落在
|
|
|
- // uk_lsy_refund_one_pending 的谓词之外,并发第二笔退款撞不上约束。
|
|
|
- // 这个断言就是在钉住"互斥窗口必须真的存在"。
|
|
|
- //
|
|
|
- // ★ 必须用 doAnswer 在**调用那一刻**断言,不能用 ArgumentCaptor。
|
|
|
- // captor 存的是**引用**,而 createRefund 返回前会在同一个实例上把状态翻成 SUCCESS
|
|
|
- // (applyNotifyResult 是退款行唯一的状态写入口,同步路径也照样走它),
|
|
|
- // 于是 captor.getValue().getStatus() 在返回之后求值永远得到 SUCCESS ——
|
|
|
- // 那条断言恒假,与实现有没有先落 REFUNDING 无关。
|
|
|
- // 这里观测的是 INSERT 那一刻的实参状态,才是"先落非终态"的可验证含义。
|
|
|
+ void refund_预插入CREATED_且早于上游调用() {
|
|
|
+ // ★ 用 InOrder 钉住**顺序**,不只钉「发生过 insert」——
|
|
|
+ // 本次改动的全部意义就在这个顺序上(insert 早于 post)。
|
|
|
+ // ★ insert 那一刻断言状态,不能用 ArgumentCaptor:
|
|
|
+ // captor 存的是**引用**,而 createRefund 返回前会在同一个实例上把状态推到终态,
|
|
|
+ // 于是 captor.getValue().getStatus() 在返回之后求值永远得到终态 —— 那条断言恒假,
|
|
|
+ // 与实现有没有先插 CREATED 无关。(2026-09-21 那条用例的教训,本次沿用。)
|
|
|
+ // ★ 必须仍是**非终态**:直接插 SUCCESS 的行不在 uk_lsy_refund_one_pending 的谓词里,
|
|
|
+ // 并发第二笔退款撞不上约束 —— 互斥窗口必须真的存在,这是钱的那一侧。
|
|
|
+ when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(resp("PAY_SUCCESS"));
|
|
|
+ doAnswer(inv -> {
|
|
|
+ LsyRefundEntity e = inv.getArgument(0);
|
|
|
+ assertEquals("CREATED", e.getStatus(),
|
|
|
+ "INSERT 那一刻必须是 CREATED(「上游可能还没收到」),不能是终态");
|
|
|
+ e.setId(77L); // 模拟 MyBatis-Plus 回填雪花 id:后面两次 update 都按它定位
|
|
|
+ return 1;
|
|
|
+ }).when(refundMapper).insert(any());
|
|
|
+ when(refundMapper.update(any(), any())).thenReturn(1);
|
|
|
+ when(orderService.applyRefundResult(any(), anyLong(), any())).thenReturn(true);
|
|
|
+
|
|
|
+ service.createRefund(order(), "RF001", 100L);
|
|
|
+
|
|
|
+ org.mockito.InOrder io = inOrder(refundMapper, client);
|
|
|
+ io.verify(refundMapper).insert(any());
|
|
|
+ io.verify(client).post(eq("/v3/refundorder"), anyMap());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void refund_上游调用抛异常_本地已留CREATED行且异常继续外抛() {
|
|
|
+ // ★ 台账 §2.1 失效链的第一步:上游超时,旧实现一行都没写,而上游**可能已受理**。
|
|
|
+ // 本用例钉住「行已经落库」——那是下一条重试用例能挡住重复出金的前提。
|
|
|
+ doAnswer(inv -> {
|
|
|
+ ((LsyRefundEntity) inv.getArgument(0)).setId(77L);
|
|
|
+ return 1;
|
|
|
+ }).when(refundMapper).insert(any());
|
|
|
+ when(client.post(eq("/v3/refundorder"), anyMap()))
|
|
|
+ .thenThrow(new LeshouyingApiException("NETWORK_ERROR",
|
|
|
+ "乐收赢接口调用失败: read timeout", null));
|
|
|
+
|
|
|
+ assertThrows(LeshouyingApiException.class,
|
|
|
+ () -> service.createRefund(order(), "RF001", 100L));
|
|
|
+
|
|
|
+ var captor = org.mockito.ArgumentCaptor.forClass(LsyRefundEntity.class);
|
|
|
+ verify(refundMapper).insert(captor.capture());
|
|
|
+ assertEquals("CREATED", captor.getValue().getStatus(),
|
|
|
+ "上游可能已受理 ⇒ 行必须先落库;落终态是错的(我们并不知道结果)");
|
|
|
+ // 没收到响应 ⇒ 不该有任何推进:CREATED 表达的就是「不知道上游收到没有」。
|
|
|
+ verify(refundMapper, never()).update(any(), any());
|
|
|
+ // 订单侧一个字都不许动 —— 钱有没有退出去,我们还不知道。
|
|
|
+ verify(orderService, never()).applyRefundResult(any(), anyLong(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void refund_上游超时后重试_被在途闸门挡住不再出金() {
|
|
|
+ // ★★ 这条是 §2.1 预插入**存在的理由**。
|
|
|
+ // 旧实现的失效链(不依赖任何竞态):post 抛异常 ⇒ 本地零行 ⇒
|
|
|
+ // 租户拿同一个 out_refund_no 重试时在途校验读空表 ⇒ 放行;
|
|
|
+ // 而 refund_trace_no 每次重新 fastSimpleUUID()、请求体里也不含 out_refund_no
|
|
|
+ // ⇒ 上游收到的是一笔**全新**退款,它连去重的键都没有 ⇒ 两次真实出金。
|
|
|
+ // 预插入之后,第一次失败留下的 CREATED 行让第二步撞上在途闸门。
|
|
|
+ LsyRefundEntity stuck = new LsyRefundEntity();
|
|
|
+ stuck.setId(77L);
|
|
|
+ stuck.setStatus("CREATED"); // 第一次 post 抛异常留下的那一行
|
|
|
+ when(refundMapper.selectList(any())).thenReturn(List.of(stuck));
|
|
|
+
|
|
|
+ IllegalStateException ex = assertThrows(IllegalStateException.class,
|
|
|
+ () -> service.createRefund(order(), "RF001", 100L));
|
|
|
+ assertTrue(ex.getMessage().contains("退款正在处理中"), "实得: " + ex.getMessage());
|
|
|
+ // ★ 非空转的关键:第二笔必须**根本没发出去**。
|
|
|
+ // 只断言抛异常的话,一个「抛完异常仍然发请求」的实现也会绿。
|
|
|
+ verify(client, never()).post(anyString(), anyMap());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void refund_上游响应里的字段_补写到预插入的那一行() {
|
|
|
+ // refund_no 是**对外叫「退款单号」的那个值**(LsyQueryDetailAssembler 的白名单
|
|
|
+ // detail.refund_no),是租户可见字段。预插入时它还不可知,只在响应这一刻可得 ——
|
|
|
+ // 不在这里落库就永久丢失,因为退款回调报文**不带**这些字段(台账 R41 的实测结论)。
|
|
|
when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(resp("PAY_SUCCESS"));
|
|
|
doAnswer(inv -> {
|
|
|
- assertEquals("REFUNDING", ((LsyRefundEntity) inv.getArgument(0)).getStatus(),
|
|
|
- "INSERT 那一刻必须是非终态,不能直接落终态");
|
|
|
+ ((LsyRefundEntity) inv.getArgument(0)).setId(77L);
|
|
|
return 1;
|
|
|
}).when(refundMapper).insert(any());
|
|
|
when(refundMapper.update(any(), any())).thenReturn(1);
|
|
|
when(orderService.applyRefundResult(any(), anyLong(), any())).thenReturn(true);
|
|
|
|
|
|
service.createRefund(order(), "RF001", 100L);
|
|
|
+
|
|
|
+ var captor = org.mockito.ArgumentCaptor.forClass(LsyRefundEntity.class);
|
|
|
+ // 用 atLeast 而不是精确次数:这条用例关心的是「写了什么」,不是「写了几次」。
|
|
|
+ // 同步成功这条路径上本来就是 3 次(补字段 → REFUNDING → SUCCESS),
|
|
|
+ // 写死次数只会让将来多加一次写就红,与它要钉的事实无关。
|
|
|
+ verify(refundMapper, atLeast(2)).update(captor.capture(), any());
|
|
|
+ // 不按下标取(那会把用例绑死在实现的调用顺序上),按内容找。
|
|
|
+ assertTrue(captor.getAllValues().stream().anyMatch(x -> "R001".equals(x.getRefundNo())),
|
|
|
+ "上游回传的 refund_no 必须落库 —— 它是对外 detail.refund_no 的唯一来源");
|
|
|
+ assertTrue(captor.getAllValues().stream().anyMatch(x -> Long.valueOf(100L).equals(x.getRefundSettle())),
|
|
|
+ "refund_settle 同样只在响应这一刻可得");
|
|
|
+ // 状态推进也必须真的发生(CREATED → REFUNDING)。
|
|
|
+ assertTrue(captor.getAllValues().stream().anyMatch(x -> "REFUNDING".equals(x.getStatus())),
|
|
|
+ "收到响应 = 上游已受理 ⇒ 必须离开 CREATED");
|
|
|
}
|
|
|
|
|
|
@Test
|
|
|
@@ -226,6 +307,12 @@ class LsyRefundServiceTest {
|
|
|
when(refundMapper.selectList(any())).thenReturn(List.of(settled));
|
|
|
when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(resp("PAY_IN_PROCESS"));
|
|
|
when(refundMapper.insert(any())).thenReturn(1);
|
|
|
+ // ★ 必须 stub update 命中:§2.1 之后状态不再是 insert 那一刻写死的,
|
|
|
+ // 而是走 applyNotifyResult 推进(CREATED → REFUNDING),
|
|
|
+ // 而它**只在条件更新真的命中 1 行时**才回写内存里的实体。
|
|
|
+ // 不 stub 的话 Mockito 返回 0 ⇒ 推进失败 ⇒ 返回的实体停在 CREATED,
|
|
|
+ // 红的是夹具、不是实现。
|
|
|
+ when(refundMapper.update(any(), any())).thenReturn(1);
|
|
|
|
|
|
LsyRefundEntity r = service.createRefund(order(), "RF002", 30L);
|
|
|
|
|
|
@@ -236,6 +323,7 @@ class LsyRefundServiceTest {
|
|
|
void refund_payInProcess_statusIsRefunding() {
|
|
|
when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(resp("PAY_IN_PROCESS"));
|
|
|
when(refundMapper.insert(any())).thenReturn(1);
|
|
|
+ when(refundMapper.update(any(), any())).thenReturn(1); // 同上:推进要真命中才回写实体
|
|
|
|
|
|
LsyRefundEntity r = service.createRefund(order(), "RF001", 100L);
|
|
|
|
|
|
@@ -254,6 +342,7 @@ class LsyRefundServiceTest {
|
|
|
fail.put("return_msg", "退款失败");
|
|
|
when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(fail);
|
|
|
when(refundMapper.insert(any())).thenReturn(1);
|
|
|
+ when(refundMapper.update(any(), any())).thenReturn(1); // 推进 CREATED → REFUNDING 要真命中
|
|
|
|
|
|
LsyRefundEntity r = service.createRefund(order(), "RF001", 100L);
|
|
|
|
|
|
@@ -262,7 +351,19 @@ class LsyRefundServiceTest {
|
|
|
// 下面两条是让本用例**非空转**的关键:只断言 status 的话,
|
|
|
// 一个「把 FAIL 写成 REFUNDING」的实现也会绿。
|
|
|
verify(orderService, never()).applyRefundResult(any(), anyLong(), any());
|
|
|
- verify(refundMapper, never()).update(any(), any());
|
|
|
+ // ★ 断言「没有落终态」,而不是「没有 update」:§2.1 预插入之后,收到响应必然产生
|
|
|
+ // 两次写 —— 补上游身份字段 + 推进 CREATED → REFUNDING,两次都不是终态。
|
|
|
+ // 旧的 verify(never()).update() 会被这两次误伤(它当初要表达的就是「一个状态都不许写」,
|
|
|
+ // 而现在的正确含义是「不许写**终态**」——释放互斥窗口的是终态那个动作)。
|
|
|
+ var captor = org.mockito.ArgumentCaptor.forClass(LsyRefundEntity.class);
|
|
|
+ verify(refundMapper, times(2)).update(captor.capture(), any());
|
|
|
+ // 用 equals 而不是 Set.of(...).contains(...):补上游字段那一次的 patch 里 status 是 null,
|
|
|
+ // 而 Set.of 的不可变集合对 contains(null) 直接抛 NPE(不是返回 false)。
|
|
|
+ assertTrue(captor.getAllValues().stream().noneMatch(
|
|
|
+ x -> "SUCCESS".equals(x.getStatus()) || "FAIL".equals(x.getStatus())),
|
|
|
+ "PAY_FAIL 不得被落成终态 —— 落 FAIL 会释放 uk_lsy_refund_one_pending 的互斥窗口");
|
|
|
+ assertTrue(captor.getAllValues().stream().anyMatch(x -> "REFUNDING".equals(x.getStatus())),
|
|
|
+ "必须推进到 REFUNDING:上游回过话就说明它收到了这笔请求");
|
|
|
}
|
|
|
|
|
|
@Test
|
|
|
@@ -285,13 +386,23 @@ class LsyRefundServiceTest {
|
|
|
unknown.put("return_msg", "成功");
|
|
|
when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(unknown);
|
|
|
when(refundMapper.insert(any())).thenReturn(1);
|
|
|
+ when(refundMapper.update(any(), any())).thenReturn(1); // 推进 CREATED → REFUNDING 要真命中
|
|
|
|
|
|
LsyRefundEntity r = service.createRefund(order(), "RF001", 100L);
|
|
|
|
|
|
assertEquals("REFUNDING", r.getStatus());
|
|
|
// 与 R11 那条同形:只断言 status 的话,一个「把未知码落成终态」的实现也会绿。
|
|
|
verify(orderService, never()).applyRefundResult(any(), anyLong(), any());
|
|
|
- verify(refundMapper, never()).update(any(), any());
|
|
|
+ // 同 R11 那条:断言「没有落终态」,不是「没有 update」(§2.1 之后必然有两次非终态写)。
|
|
|
+ var captor = org.mockito.ArgumentCaptor.forClass(LsyRefundEntity.class);
|
|
|
+ verify(refundMapper, times(2)).update(captor.capture(), any());
|
|
|
+ // 用 equals 而不是 Set.of(...).contains(...):补上游字段那一次的 patch 里 status 是 null,
|
|
|
+ // 而 Set.of 的不可变集合对 contains(null) 直接抛 NPE(不是返回 false)。
|
|
|
+ assertTrue(captor.getAllValues().stream().noneMatch(
|
|
|
+ x -> "SUCCESS".equals(x.getStatus()) || "FAIL".equals(x.getStatus())),
|
|
|
+ "未知码不得被落成终态 —— 落任何一侧都是替上游猜方向");
|
|
|
+ assertTrue(captor.getAllValues().stream().anyMatch(x -> "REFUNDING".equals(x.getStatus())),
|
|
|
+ "必须推进到 REFUNDING:上游回过话就说明它收到了这笔请求");
|
|
|
}
|
|
|
|
|
|
@Test
|