test(return): cover isolation and concurrency; fix receipt and packing races

This commit is contained in:
2026-09-15 17:05:07 +08:00
parent 28fa2a001d
commit 6b0de57a4b
18 changed files with 635 additions and 40 deletions
@@ -64,7 +64,8 @@ class RelabelDocumentServiceTest {
service.downloadFile("101","RL-1","TOTAL",null,99L);
verify(files).download("101","OVERSEAS_RELABEL_TOTAL_DOCUMENT","11",99L);
assertThatThrownBy(()->service.downloadFile("101","RL-1","TOTAL",null,98L)).hasMessageContaining("未绑定");
assertThatThrownBy(()->service.downloadFile("101","RL-1","other",null,99L)).hasMessageContaining("资料范围");
assertThatThrownBy(()->service.downloadFile("101","RL-1","other",null,99L))
.isInstanceOfSatisfying(ResponseStatusException.class, error -> assertThat(error.getStatusCode().value()).isEqualTo(400));
assertThatThrownBy(()->service.downloadFile("101","RL-1","ITEM",null,99L)).hasMessageContaining("指定明细");
verifyNoMoreInteractions(files);
}
@@ -0,0 +1,29 @@
package com.baiyue.lastleg.returnreceipt.dto;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class ReturnQuantityDeserializerTest {
private final ObjectMapper mapper = new ObjectMapper();
@Test
void allThreeReturnQuantityInputsRejectFractionalOrCoercedValues() {
for (String value : new String[] { "1.5", "0.5", "1.0", "1e1", "null", "\"1\"", "true", "9223372036854775808" }) {
assertThrows(JsonProcessingException.class, () -> mapper.readValue(
"{\"skuCode\":\"A\",\"expectedQuantity\":" + value + "}", ReturnOrderRequests.Item.class), value);
assertThrows(JsonProcessingException.class, () -> mapper.readValue(
"{\"skuCode\":\"A\",\"receivedQuantity\":" + value + "}", PdaReturnReceiptRequests.Item.class), value);
assertThrows(JsonProcessingException.class, () -> mapper.readValue(
"{\"skuCode\":\"A\",\"quantity\":" + value + "}", ReturnInventoryRequests.ValidItem.class), value);
}
}
@Test
void integerQuantitiesRemainExact() throws Exception {
assertEquals(2L, mapper.readValue("{\"skuCode\":\"A\",\"expectedQuantity\":2}", ReturnOrderRequests.Item.class).expectedQuantity());
assertEquals(2L, mapper.readValue("{\"skuCode\":\"A\",\"receivedQuantity\":2}", PdaReturnReceiptRequests.Item.class).receivedQuantity());
assertEquals(Long.MAX_VALUE, mapper.readValue("{\"skuCode\":\"A\",\"quantity\":9223372036854775807}", ReturnInventoryRequests.ValidItem.class).quantity());
}
}
@@ -0,0 +1,18 @@
package com.baiyue.lastleg.returnreceipt.service;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ReturnCurrentReadContractTest {
@Test
void concurrentReplayAndInventoryReadBypassRepeatableReadSnapshots() throws Exception {
String receiptSql = String.join(" ", ReturnReceiptMapper.class.getMethod("lockKey", String.class, String.class).getAnnotation(Select.class).value());
String itemSql = String.join(" ", ReturnInventoryCartonItemMapper.class.getMethod("lockCarton", long.class).getAnnotation(Select.class).value());
assertTrue(receiptSql.endsWith("FOR UPDATE"));
assertTrue(itemSql.endsWith("FOR UPDATE"));
assertTrue(itemSql.contains("ORDER BY sku_code"));
}
}
@@ -31,6 +31,10 @@ import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.never;
import static org.mockito.ArgumentMatchers.any;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.springframework.web.server.ResponseStatusException;
@ExtendWith(MockitoExtension.class)
class ReturnInventoryServiceTest {
@@ -132,7 +136,7 @@ class ReturnInventoryServiceTest {
when(receipts.listOrder(11L)).thenReturn(List.of(receipt));
when(cartons.findReceipt(21L)).thenReturn(source);
when(cartons.lockMark("RC-SOURCE")).thenReturn(source);
when(cartonItems.listCarton(31L)).thenReturn(List.of(skuA, skuB));
when(cartonItems.lockCarton(31L)).thenReturn(List.of(skuA, skuB));
when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-MERGED");
when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1);
@@ -152,6 +156,64 @@ class ReturnInventoryServiceTest {
assertThat(target.getValue().getSourceReturnNo()).isEqualTo("RT-1");
}
@Test
void sourceEmptiedByAnotherMergeCannotBeConsumedAgainAfterLockWait() {
ReturnInventoryCarton locked = prepareConcurrentSource();
locked.setStatus(ReturnInventoryService.MERGED); locked.setTotalQuantity(0L);
var error = assertThrows(ResponseStatusException.class, () -> service.merge(
new PdaWarehouseIdentity("operator-2", "操作员2", 42L), mergeRequest(1)));
assertThat(error.getStatusCode().value()).isEqualTo(409);
verify(cartonItems, never()).lockCarton(anyLong());
verify(cartons, never()).insert(any(ReturnInventoryCarton.class));
verify(balances, never()).adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong());
}
@Test
void partiallyConsumedSourceUsesCurrentLockedSkuQuantityNotEarlierSnapshot() {
ReturnInventoryCarton locked = prepareConcurrentSource();
locked.setTotalQuantity(1L);
when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 1L)));
var error = assertThrows(ResponseStatusException.class, () -> service.merge(
new PdaWarehouseIdentity("operator-2", "操作员2", 42L), mergeRequest(2)));
assertThat(error.getStatusCode().value()).isEqualTo(409);
verify(cartonItems).lockCarton(31L);
verify(cartonItems, never()).listCarton(31L);
verify(cartons, never()).insert(any(ReturnInventoryCarton.class));
}
@Test
void fullyConsumedSourceIsMergedAndSkuCartonCountRemainsOne() {
ReturnInventoryCarton locked = prepareConcurrentSource();
when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 2L)));
when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-TARGET");
when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1);
service.merge(new PdaWarehouseIdentity("operator-2", "操作员2", 42L), mergeRequest(2));
assertThat(locked.getTotalQuantity()).isZero();
assertThat(locked.getSkuTypes()).isZero();
assertThat(locked.getStatus()).isEqualTo(ReturnInventoryService.MERGED);
verify(cartonItems).deleteById(41L);
verify(balances).adjustCartonQuantity("10001", "42", "SKU-A", 0L);
}
private ReturnInventoryCarton prepareConcurrentSource() {
ReturnReceipt receipt = new ReturnReceipt(); receipt.setId(21L);
ReturnInventoryCarton snapshot = new ReturnInventoryCarton();
snapshot.setId(31L); snapshot.setCartonMark("RC-SOURCE"); snapshot.setStatus(ReturnInventoryService.AVAILABLE);
ReturnInventoryCarton locked = new ReturnInventoryCarton();
locked.setId(31L); locked.setCartonMark("RC-SOURCE"); locked.setStatus(ReturnInventoryService.AVAILABLE);
locked.setCustomerId("10001"); locked.setOverseasWarehouseId("42"); locked.setSourceBatchNo("RB-1"); locked.setSourceReturnNo("RT-1");
when(orders.findByNos("42", List.of("RT-1"))).thenReturn(List.of(completedOrder()));
when(receipts.listOrder(11L)).thenReturn(List.of(receipt));
when(cartons.findReceipt(21L)).thenReturn(snapshot);
when(cartons.lockMark("RC-SOURCE")).thenReturn(locked);
return locked;
}
private ReturnInventoryRequests.Merge mergeRequest(long quantity) {
return new ReturnInventoryRequests.Merge(List.of("RT-1"), List.of(new ReturnInventoryRequests.ValidItem("SKU-A", quantity)),
BigDecimal.ONE, BigDecimal.ONE, BigDecimal.ONE, BigDecimal.ONE, "concurrent-key");
}
private ReturnOrder completedOrder() {
ReturnOrder order = new ReturnOrder();
order.setId(11L);
@@ -244,6 +244,38 @@ class ReturnReceiptServiceTest {
when(inventory.createReceiptCarton(eq(order), any(ReturnReceipt.class), any(), any())).thenReturn(carton);
}
@Test
void sameKeyCommittedWhileWaitingForOrderLockReplaysCompletedReceipt() throws Exception {
var request = confirm("RETURN_NO", null, null, "concurrent-key", List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)));
ReturnOrder completed = order("RT-1", "10", ReturnReceiptService.COMPLETED, 1, 1);
ReturnReceipt committed = new ReturnReceipt();
committed.setReceiptNo("RR-COMMITTED"); committed.setReceiptBatchNo("RB-COMMITTED"); committed.setCartonMark("RC-COMMITTED");
committed.setRequestHash(java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256")
.digest(new ObjectMapper().writeValueAsBytes(request))));
when(orders.lockNo("RT-1")).thenReturn(completed);
when(receipts.lockKey("operator-1", "concurrent-key")).thenReturn(committed);
var result = service.confirm(identity, request);
assertEquals("RR-COMMITTED", result.receiptNo());
assertEquals("RB-COMMITTED", result.receiptBatchNo());
assertEquals(1, result.receivedQuantity());
assertTrue(result.fullyMatched());
verify(receipts, never()).insert(any(ReturnReceipt.class));
verifyNoInteractions(inventory, batches, orderItems);
}
@Test
void concurrentSameKeyWithDifferentPayloadStillConflicts() {
when(orders.lockNo("RT-1")).thenReturn(order("RT-1", "10", ReturnReceiptService.COMPLETED, 1, 1));
ReturnReceipt committed = new ReturnReceipt(); committed.setRequestHash("different-content");
when(receipts.lockKey("operator-1", "concurrent-key")).thenReturn(committed);
var error = assertThrows(ResponseStatusException.class, () -> service.confirm(identity,
confirm("RETURN_NO", null, null, "concurrent-key", List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)))));
assertEquals(409, error.getStatusCode().value());
verifyNoInteractions(inventory, batches, orderItems);
}
private static ReturnOrder order(String returnNo, String warehouseId, String status, long expected, long received) {
ReturnOrder order = new ReturnOrder();
order.setId(1L);