fix(lastleg): harden container plan saga recovery
This commit is contained in:
+142
-1
@@ -1,9 +1,11 @@
|
||||
package com.baiyue.lastleg.inboundplan.service;
|
||||
|
||||
import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest;
|
||||
import com.baiyue.lastleg.inboundplan.dto.ContainerInboundPlanPreviewResponse;
|
||||
import com.baiyue.lastleg.inboundplan.entity.ContainerPlanConversionCommand;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.ContainerPlanConversionCommandMapper;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
||||
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -11,8 +13,12 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.support.SimpleTransactionStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
@@ -20,6 +26,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.any;
|
||||
import static org.mockito.Mockito.eq;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ContainerInboundPlanConversionCoordinatorTest {
|
||||
@@ -29,6 +37,8 @@ class ContainerInboundPlanConversionCoordinatorTest {
|
||||
@Mock InboundPlanService plans;
|
||||
@Mock WarehouseResourceGateway warehouse;
|
||||
@Mock ContainerPlanConversionCommandMapper commands;
|
||||
@Mock LastlegOrderNoGenerator numbers;
|
||||
@Mock PlatformTransactionManager transactionManager;
|
||||
|
||||
private ContainerInboundPlanConversionCoordinator coordinator;
|
||||
private JsonMapper objectMapper;
|
||||
@@ -40,7 +50,11 @@ class ContainerInboundPlanConversionCoordinatorTest {
|
||||
ReflectionTestUtils.setField(coordinator, "plans", plans);
|
||||
ReflectionTestUtils.setField(coordinator, "warehouse", warehouse);
|
||||
ReflectionTestUtils.setField(coordinator, "commands", commands);
|
||||
ReflectionTestUtils.setField(coordinator, "numbers", numbers);
|
||||
ReflectionTestUtils.setField(coordinator, "objectMapper", objectMapper);
|
||||
ReflectionTestUtils.setField(coordinator, "transactionManager", transactionManager);
|
||||
org.mockito.Mockito.lenient().when(transactionManager.getTransaction(any()))
|
||||
.thenReturn(new SimpleTransactionStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -57,6 +71,19 @@ class ContainerInboundPlanConversionCoordinatorTest {
|
||||
verify(commands, never()).insert(org.mockito.ArgumentMatchers.any(ContainerPlanConversionCommand.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameIdempotencyKeyStillRejectsAlteredCompletedRequest() throws Exception {
|
||||
ContainerPlanConversionCommand completed = command("FIRST-KEY", "COMPLETED", request());
|
||||
completed.setResultJson("[]");
|
||||
when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(completed);
|
||||
|
||||
ConvertContainerInboundPlanRequest changed =
|
||||
new ConvertContainerInboundPlanRequest(BOOKING_ID, 8L, "b".repeat(64));
|
||||
|
||||
assertEquals(409, assertThrows(ResponseStatusException.class,
|
||||
() -> coordinator.confirm(changed, "FIRST-KEY")).getStatusCode().value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentCabinetClaimRejectsFollowerBeforeWarehouseReservation() throws Exception {
|
||||
ConvertContainerInboundPlanRequest request = request();
|
||||
@@ -78,7 +105,7 @@ class ContainerInboundPlanConversionCoordinatorTest {
|
||||
when(commands.find(BOOKING_ID, "SECOND-KEY")).thenReturn(null);
|
||||
when(commands.findByBooking(BOOKING_ID)).thenReturn(prepared);
|
||||
when(commands.claim(org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq("PREPARED"),
|
||||
org.mockito.ArgumentMatchers.eq("APPLYING"), org.mockito.ArgumentMatchers.any())).thenReturn(0);
|
||||
org.mockito.ArgumentMatchers.eq("APPLYING"), any(String.class), org.mockito.ArgumentMatchers.any())).thenReturn(0);
|
||||
|
||||
ResponseStatusException error = assertThrows(ResponseStatusException.class,
|
||||
() -> coordinator.confirm(request, "SECOND-KEY"));
|
||||
@@ -102,6 +129,102 @@ class ContainerInboundPlanConversionCoordinatorTest {
|
||||
assertEquals(409, error.getStatusCode().value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleApplyingLeaseIsReclaimedWithoutRepeatingPreparedClaim() throws Exception {
|
||||
ConvertContainerInboundPlanRequest request = request();
|
||||
ContainerPlanConversionCommand stale = command("FIRST-KEY", "APPLYING", request);
|
||||
stale.setUpdatedAt(LocalDateTime.now().minusMinutes(3));
|
||||
when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(stale);
|
||||
when(commands.reclaimStale(eq(1L), eq(stale.getUpdatedAt()), any(String.class), any())).thenReturn(1);
|
||||
when(plans.convertContainerReserved(BOOKING_ID, 7L, SNAPSHOT_HASH, List.of())).thenReturn(List.of());
|
||||
when(commands.updateState(eq(1L), eq("APPLYING"), any(String.class), eq("COMPLETED"), eq("[]"),
|
||||
org.mockito.ArgumentMatchers.isNull(), any())).thenReturn(1);
|
||||
|
||||
assertEquals(List.of(), coordinator.confirm(request, "FIRST-KEY"));
|
||||
|
||||
verify(commands, never()).claim(any(Long.class), any(String.class), any(String.class),
|
||||
any(String.class), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void compensatedFailureCanBeReplacedByFreshSnapshotAndComplete() throws Exception {
|
||||
ContainerPlanConversionCommand failed = command("OLD-KEY", "FAILED", request());
|
||||
when(commands.find(BOOKING_ID, "NEW-KEY")).thenReturn(null);
|
||||
when(commands.findByBooking(BOOKING_ID)).thenReturn(failed);
|
||||
when(plans.previewContainer(BOOKING_ID)).thenReturn(preview(8L, "b".repeat(64)));
|
||||
when(commands.replaceFailed(eq(1L), eq("NEW-KEY"), any(), eq(8L), eq("b".repeat(64)),
|
||||
eq(31L), any(), any(), any())).thenReturn(1);
|
||||
when(commands.claim(eq(1L), eq("PREPARED"), eq("APPLYING"), any(String.class), any())).thenReturn(1);
|
||||
when(plans.convertContainerReserved(BOOKING_ID, 8L, "b".repeat(64), List.of())).thenReturn(List.of());
|
||||
when(commands.updateState(eq(1L), eq("APPLYING"), any(String.class), eq("COMPLETED"), eq("[]"),
|
||||
org.mockito.ArgumentMatchers.isNull(), any())).thenReturn(1);
|
||||
|
||||
assertEquals(List.of(), coordinator.confirm(
|
||||
new ConvertContainerInboundPlanRequest(BOOKING_ID, 8L, "b".repeat(64)), "NEW-KEY"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidEnsureResponseIsCompensatedBeforeCommandBecomesFailed() throws Exception {
|
||||
ConvertContainerInboundPlanRequest request = request();
|
||||
ContainerPlanConversionCommand prepared = command("FIRST-KEY", "PREPARED", request);
|
||||
setOnePreparedItem(prepared);
|
||||
when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(prepared);
|
||||
when(commands.claim(eq(1L), eq("PREPARED"), eq("APPLYING"), any(String.class), any())).thenReturn(1);
|
||||
when(warehouse.ensurePalletBatch(any())).thenReturn(new WarehouseResourceGateway.PalletBatchResponse(
|
||||
0L, "BATCH-1", "RESERVED", 1, false, List.of(reserved("RESERVED"))));
|
||||
when(warehouse.compensatePalletBatch(any())).thenReturn(new WarehouseResourceGateway.PalletBatchResponse(
|
||||
41L, "BATCH-1", "COMPENSATED", 1, false, List.of(reserved("RELEASED"))));
|
||||
when(commands.updateState(eq(1L), eq("APPLYING"), any(String.class), eq("FAILED"),
|
||||
org.mockito.ArgumentMatchers.isNull(), any(), any())).thenReturn(1);
|
||||
|
||||
assertEquals(409, assertThrows(ResponseStatusException.class,
|
||||
() -> coordinator.confirm(request, "FIRST-KEY")).getStatusCode().value());
|
||||
|
||||
verify(warehouse).compensatePalletBatch(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void uncertainLocalCommitIsRereadBeforeDecidingWhetherToCompensate() throws Exception {
|
||||
ConvertContainerInboundPlanRequest request = request();
|
||||
ContainerPlanConversionCommand prepared = command("FIRST-KEY", "PREPARED", request);
|
||||
setOnePreparedItem(prepared);
|
||||
ContainerPlanConversionCommand committed = command("FIRST-KEY", "LOCAL_COMMITTED", request);
|
||||
committed.setResultJson("[]");
|
||||
when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(prepared);
|
||||
when(commands.claim(eq(1L), eq("PREPARED"), eq("APPLYING"), any(String.class), any())).thenReturn(1);
|
||||
when(warehouse.ensurePalletBatch(any())).thenReturn(new WarehouseResourceGateway.PalletBatchResponse(
|
||||
41L, "BATCH-1", "RESERVED", 1, false, List.of(reserved("RESERVED"))));
|
||||
when(plans.convertContainerReserved(eq(BOOKING_ID), eq(7L), eq(SNAPSHOT_HASH), any()))
|
||||
.thenThrow(new IllegalStateException("commit outcome unknown"));
|
||||
when(commands.findByBooking(BOOKING_ID)).thenReturn(committed);
|
||||
when(warehouse.confirmPalletBatch(any())).thenReturn(new WarehouseResourceGateway.PalletBatchResponse(
|
||||
41L, "BATCH-1", "CONFIRMED", 1, false, List.of(reserved("CONFIRMED"))));
|
||||
when(commands.updateState(eq(1L), eq("LOCAL_COMMITTED"), any(String.class), eq("COMPLETED"), eq("[]"),
|
||||
org.mockito.ArgumentMatchers.isNull(), any())).thenReturn(1);
|
||||
|
||||
assertEquals(List.of(), coordinator.confirm(request, "FIRST-KEY"));
|
||||
|
||||
verify(warehouse, never()).compensatePalletBatch(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void localCommittedReplayOnlyRetriesRemoteConfirmation() throws Exception {
|
||||
ConvertContainerInboundPlanRequest request = request();
|
||||
ContainerPlanConversionCommand committed = command("FIRST-KEY", "LOCAL_COMMITTED", request);
|
||||
committed.setResultJson("[]");
|
||||
setOnePreparedItem(committed);
|
||||
when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(committed);
|
||||
when(warehouse.confirmPalletBatch(any())).thenReturn(new WarehouseResourceGateway.PalletBatchResponse(
|
||||
41L, "BATCH-1", "CONFIRMED", 1, false, List.of(reserved("CONFIRMED"))));
|
||||
when(commands.updateState(eq(1L), eq("LOCAL_COMMITTED"), any(String.class), eq("COMPLETED"), eq("[]"),
|
||||
org.mockito.ArgumentMatchers.isNull(), any())).thenReturn(1);
|
||||
|
||||
assertEquals(List.of(), coordinator.confirm(request, "FIRST-KEY"));
|
||||
|
||||
verify(warehouse, never()).ensurePalletBatch(any());
|
||||
verify(warehouse).confirmPalletBatch(any());
|
||||
}
|
||||
|
||||
private ConvertContainerInboundPlanRequest request() {
|
||||
return new ConvertContainerInboundPlanRequest(BOOKING_ID, 7L, SNAPSHOT_HASH);
|
||||
}
|
||||
@@ -119,8 +242,26 @@ class ContainerInboundPlanConversionCoordinatorTest {
|
||||
command.setWarehouseId(31L);
|
||||
command.setBatchKey("BATCH-1");
|
||||
command.setStatus(status);
|
||||
if ("APPLYING".equals(status) || "LOCAL_COMMITTED".equals(status)) command.setLeaseToken("LEASE-1");
|
||||
command.setPreparationJson(objectMapper.writeValueAsString(
|
||||
new ContainerInboundPlanConversionCoordinator.Preparation(31L, List.of())));
|
||||
return command;
|
||||
}
|
||||
|
||||
private void setOnePreparedItem(ContainerPlanConversionCommand command) throws Exception {
|
||||
var item = new ContainerInboundPlanConversionCoordinator.PreparedItem(
|
||||
"100", 11L, 21L, "IP-21", 1, 1, "CIP:1:1", "ONT8");
|
||||
command.setPreparationJson(objectMapper.writeValueAsString(
|
||||
new ContainerInboundPlanConversionCoordinator.Preparation(31L, List.of(item))));
|
||||
}
|
||||
|
||||
private WarehouseResourceGateway.PalletBatchItem reserved(String status) {
|
||||
return new WarehouseResourceGateway.PalletBatchItem(
|
||||
100L, "CIP:1:1", "IP-21", 1, 51L, "PLT-51", 61L, 71L, status);
|
||||
}
|
||||
|
||||
private ContainerInboundPlanPreviewResponse preview(long version, String hash) {
|
||||
return new ContainerInboundPlanPreviewResponse(BOOKING_ID, "MSCU6639870", "31", "US-LAX-01",
|
||||
"Los Angeles", version, hash, 1, 1, 0, 0, 0, 1, 1, 1, true, List.of());
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -67,6 +67,21 @@ class ContainerInboundPlanPreviewTest {
|
||||
assertEquals(List.of("DESTINATION_NOT_READY"), result.groups().get(4).orders().get(0).blockers());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hashesBlockedPreviewEvenWhenContainerAndWarehouseFactsAreNull() {
|
||||
when(orderMapper.containerPreviewOrders("BOOKING-NULL")).thenReturn(List.of(
|
||||
new InboundPlanOrderMapper.ContainerPreviewOrderRow("100", 1L, "LM-1", "FO-1", "测试客户",
|
||||
"FIRST_LEG", "OVERSEAS", "IN-1", "PENDING_ASSIGNMENT", null, null, 0, 0,
|
||||
BigDecimal.ZERO, null, null, null, null, null, null, null, 0, false, 1,
|
||||
null, null, null)));
|
||||
|
||||
var result = service.previewContainer("BOOKING-NULL");
|
||||
|
||||
assertNotNull(result.snapshotHash());
|
||||
assertEquals(64, result.snapshotHash().length());
|
||||
assertFalse(result.ready());
|
||||
}
|
||||
|
||||
private InboundPlanOrderMapper.ContainerPreviewOrderRow row(
|
||||
long id, String orderNo, String orderType, String destinationType, String destinationCode, String destinationName,
|
||||
int recommendedPallets, String planNo, Integer plannedPallets, int version) {
|
||||
|
||||
Reference in New Issue
Block a user