fix(transfer): reserve receipt resources with durable recovery intents
This commit is contained in:
+47
@@ -21,6 +21,53 @@ class HttpWarehouseResourceGatewayTest {
|
||||
private HttpWarehouseResourceGateway gateway;
|
||||
private MockRestServiceServer server;
|
||||
|
||||
@Test
|
||||
void reserveAcceptsOnlyCompleteMatchingActiveLockWithStringEncodedLongs() {
|
||||
server.expect(requestTo("http://warehouse/internal/v1/warehouse-resource-locks"))
|
||||
.andRespond(withSuccess("""
|
||||
{"code":"200","data":{"lockId":"51","customerId":"1","warehouseId":"2",
|
||||
"palletId":"3","locationId":"4","businessType":"WAREHOUSE_TRANSFER_RECEIPT",
|
||||
"businessId":"target-hash","reservationKey":"resource-1","status":"ACTIVE"}}
|
||||
""", MediaType.APPLICATION_JSON));
|
||||
assertThat(gateway.reserve("1", 2, 3, 4, "WAREHOUSE_TRANSFER_RECEIPT", "target-hash", "resource-1")).isEqualTo(51);
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void reserveRejectsReleasedHistoricalLockRatherThanTreatingOldKeyAsNewOccupancy() {
|
||||
server.expect(requestTo("http://warehouse/internal/v1/warehouse-resource-locks"))
|
||||
.andRespond(withSuccess("""
|
||||
{"code":"200","data":{"lockId":51,"customerId":1,"warehouseId":2,"palletId":3,
|
||||
"locationId":4,"businessType":"WAREHOUSE_TRANSFER_RECEIPT","businessId":"target-hash",
|
||||
"reservationKey":"resource-1","status":"RELEASED"}}
|
||||
""", MediaType.APPLICATION_JSON));
|
||||
assertThatThrownBy(() -> gateway.reserve("1", 2, 3, 4, "WAREHOUSE_TRANSFER_RECEIPT", "target-hash", "resource-1"))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("requested ACTIVE lock");
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void reserveRejectsWrongOwnershipEvenWhenReturnedLockIsActive() {
|
||||
server.expect(requestTo("http://warehouse/internal/v1/warehouse-resource-locks"))
|
||||
.andRespond(withSuccess("""
|
||||
{"code":"200","data":{"lockId":51,"customerId":99,"warehouseId":2,"palletId":3,
|
||||
"locationId":4,"businessType":"WAREHOUSE_TRANSFER_RECEIPT","businessId":"target-hash",
|
||||
"reservationKey":"resource-1","status":"ACTIVE"}}
|
||||
""", MediaType.APPLICATION_JSON));
|
||||
assertThatThrownBy(() -> gateway.reserve("1", 2, 3, 4, "WAREHOUSE_TRANSFER_RECEIPT", "target-hash", "resource-1"))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("requested ACTIVE lock");
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void reserveRejectsMissingActiveStateInsteadOfAcceptingBareLockId() {
|
||||
server.expect(requestTo("http://warehouse/internal/v1/warehouse-resource-locks"))
|
||||
.andRespond(withSuccess("{\"code\":\"200\",\"data\":{\"lockId\":51}}", MediaType.APPLICATION_JSON));
|
||||
assertThatThrownBy(() -> gateway.reserve("1", 2, 3, 4, "WAREHOUSE_TRANSFER_RECEIPT", "target-hash", "resource-1"))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("requested ACTIVE lock");
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
RestClient.Builder builder = RestClient.builder();
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.baiyue.lastleg.transfer;
|
||||
|
||||
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class WarehouseTransferReceiptOpenApiContractTest {
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void actualReceiptEndpointDocumentsExactDtoAndDurableResourceBoundary() {
|
||||
Map<String,Object> document = new Yaml().load(getClass().getResourceAsStream("/openapi/overseas-business-v1.yaml"));
|
||||
var paths=(Map<String,Object>)document.get("paths");
|
||||
var path=(Map<String,Object>)paths.get("/api/platform/v1/last-leg/warehouse-transfers/{transferNo}/receipts");
|
||||
assertThat(path).isNotNull();
|
||||
var post=(Map<String,Object>)path.get("post");
|
||||
assertThat((String)post.get("description")).contains("lastleg:warehouse-transfer:manage", "原键原载荷", "共享一次ACTIVE资源锁");
|
||||
var body=(Map<String,Object>)post.get("requestBody");
|
||||
var content=(Map<String,Object>)body.get("content");
|
||||
var media=(Map<String,Object>)content.get("application/json");
|
||||
var schema=(Map<String,Object>)media.get("schema");
|
||||
var properties=(Map<String,Object>)schema.get("properties");
|
||||
Set<String> fields=Arrays.stream(WarehouseTransferRequests.Receipt.class.getRecordComponents())
|
||||
.map(java.lang.reflect.RecordComponent::getName).collect(Collectors.toSet());
|
||||
assertThat(properties.keySet()).isEqualTo(fields);
|
||||
assertThat((List<String>)schema.get("required")).containsExactlyInAnyOrder(
|
||||
"sourceInventoryPositionId","destinationOverseasWarehouseId","destinationWarehouseCode",
|
||||
"destinationPalletId","destinationPalletNo","idempotencyKey");
|
||||
assertThat((Map<String,Object>)post.get("responses")).containsKeys("200","409","503");
|
||||
}
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
package com.baiyue.lastleg.transfer.service;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient;
|
||||
import com.baiyue.lastleg.fleet.driver.FleetWarehouseClient;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
||||
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
|
||||
import com.baiyue.lastleg.transfer.entity.*;
|
||||
import com.baiyue.lastleg.transfer.mapper.*;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class WarehouseTransferReceiptCoordinatorTest {
|
||||
JdbcTemplate jdbc; SqlSessionTemplate session; WarehouseTransferReceiptCoordinator coordinator;
|
||||
WarehouseResourceGateway warehouse; DomesticWarehouseDirectoryClient directory; FleetWarehouseClient catalog;
|
||||
ObjectMapper json = new ObjectMapper().findAndRegisterModules();
|
||||
AtomicInteger occupancies = new AtomicInteger(), reserveCalls = new AtomicInteger(), stockCalls = new AtomicInteger();
|
||||
AtomicBoolean loseReserveResponse = new AtomicBoolean(), failLocal = new AtomicBoolean();
|
||||
Map<Long, RemoteLock> remote = new HashMap<>();
|
||||
record RemoteLock(String customer, String key, String hash, long warehouse, long location, long lockId) { }
|
||||
|
||||
@BeforeEach void setup() throws Exception {
|
||||
var ds = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MYSQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
jdbc = new JdbcTemplate(ds);
|
||||
for (Class<?> type : List.of(WarehouseTransferOrder.class, WarehouseTransferInventoryAllocation.class,
|
||||
WarehouseTransferReceipt.class, WarehouseTransferReceiptCommand.class)) {
|
||||
List<String> fields = new ArrayList<>();
|
||||
for (var f : type.getDeclaredFields()) {
|
||||
if (Modifier.isStatic(f.getModifiers())) continue;
|
||||
String name = f.getName().replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase(Locale.ROOT);
|
||||
String sqlType = f.getType() == String.class ? "VARCHAR(16000)" : f.getType() == LocalDateTime.class
|
||||
? "TIMESTAMP" : f.getType() == BigDecimal.class ? "DECIMAL(24,6)" : f.getType() == Boolean.class ? "BOOLEAN" : "BIGINT";
|
||||
fields.add(name + " " + sqlType + (name.equals("id") ? " PRIMARY KEY" : ""));
|
||||
}
|
||||
jdbc.execute("CREATE TABLE " + type.getAnnotation(TableName.class).value() + " (" + String.join(",", fields) + ")");
|
||||
}
|
||||
jdbc.execute("CREATE UNIQUE INDEX receipt_command_source ON ll_warehouse_transfer_receipt_command(transfer_order_id,source_inventory_position_id)");
|
||||
jdbc.execute("CREATE UNIQUE INDEX receipt_command_key ON ll_warehouse_transfer_receipt_command(customer_id,idempotency_key)");
|
||||
jdbc.execute("CREATE UNIQUE INDEX receipt_source ON ll_warehouse_transfer_receipt(transfer_order_id,source_inventory_position_id)");
|
||||
jdbc.execute("CREATE TABLE local_effect(type VARCHAR(30), source_id BIGINT)");
|
||||
var configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true);
|
||||
for (Class<?> mapper : List.of(WarehouseTransferOrderMapper.class, WarehouseTransferInventoryAllocationMapper.class,
|
||||
WarehouseTransferReceiptMapper.class, WarehouseTransferReceiptCommandMapper.class)) configuration.addMapper(mapper);
|
||||
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(configuration);
|
||||
session = new SqlSessionTemplate(factory.getObject());
|
||||
coordinator = new WarehouseTransferReceiptCoordinator();
|
||||
ReflectionTestUtils.setField(coordinator, "transfers", session.getMapper(WarehouseTransferOrderMapper.class));
|
||||
ReflectionTestUtils.setField(coordinator, "allocations", session.getMapper(WarehouseTransferInventoryAllocationMapper.class));
|
||||
ReflectionTestUtils.setField(coordinator, "receipts", session.getMapper(WarehouseTransferReceiptMapper.class));
|
||||
ReflectionTestUtils.setField(coordinator, "commands", session.getMapper(WarehouseTransferReceiptCommandMapper.class));
|
||||
ReflectionTestUtils.setField(coordinator, "transactionManager", new DataSourceTransactionManager(ds));
|
||||
ReflectionTestUtils.setField(coordinator, "objectMapper", json);
|
||||
warehouse = mock(WarehouseResourceGateway.class); directory = mock(DomesticWarehouseDirectoryClient.class); catalog = mock(FleetWarehouseClient.class);
|
||||
ReflectionTestUtils.setField(coordinator, "warehouse", warehouse); ReflectionTestUtils.setField(coordinator, "directory", directory);
|
||||
ReflectionTestUtils.setField(coordinator, "catalog", catalog); ReflectionTestUtils.setField(coordinator, "internalToken", "test-token");
|
||||
when(warehouse.requireActiveSharedWarehouse(9L)).thenReturn(new WarehouseResourceGateway.WarehouseReference(9, "NY", "NY warehouse", "ACTIVE"));
|
||||
when(warehouse.resolvePallet(anyLong())).thenAnswer(call -> pallet(call.getArgument(0), remote.containsKey(call.getArgument(0)) ? "OCCUPIED" : "IDLE"));
|
||||
when(warehouse.resolveLocation(eq(9L), anyLong())).thenAnswer(call -> new WarehouseResourceGateway.WarehouseLocationReference(
|
||||
call.getArgument(1), "LOC-" + call.getArgument(1), "STORAGE", new BigDecimal("1000"), "ACTIVE"));
|
||||
when(catalog.activeWarehouses("test-token")).thenReturn(ApiResponse.success(List.of(
|
||||
new FleetWarehouseClient.Warehouse(30L, "ABE4", "ABE4 warehouse", "FBA", "US"))));
|
||||
when(directory.activeWarehouse(30L, "test-token")).thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(
|
||||
new DomesticWarehouseDirectoryClient.Warehouse(30L, 0L, "ABE4", "ABE4 warehouse", "FBA", "US", "ACTIVE"), List.of())));
|
||||
when(warehouse.reserve(anyString(), anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenAnswer(call -> {
|
||||
assertFalse(TransactionSynchronizationManager.isActualTransactionActive(), "Remote reserve must not hold local transaction locks");
|
||||
reserveCalls.incrementAndGet(); long pallet = call.getArgument(2), wh = call.getArgument(1), location = call.getArgument(3);
|
||||
String customer = call.getArgument(0), key = call.getArgument(6), hash = call.getArgument(5);
|
||||
RemoteLock existing = remote.get(pallet);
|
||||
if (existing != null && (!existing.customer.equals(customer) || !existing.key.equals(key) || !existing.hash.equals(hash)
|
||||
|| existing.warehouse != wh || existing.location != location)) throw conflict("Other reservation owns pallet");
|
||||
if (existing == null) { existing = new RemoteLock(customer, key, hash, wh, location, 500L + pallet); remote.put(pallet, existing); occupancies.incrementAndGet(); }
|
||||
if (loseReserveResponse.getAndSet(false)) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "response lost after reserve commit");
|
||||
return existing.lockId;
|
||||
});
|
||||
addTransfer(1, "WT-1", 101);
|
||||
}
|
||||
|
||||
private WarehouseResourceGateway.WarehousePalletReference pallet(long id, String state) {
|
||||
return new WarehouseResourceGateway.WarehousePalletReference(id, "PAL-" + id, 9, id + 20, state, "ABE4", remote.containsKey(id) ? 1 : 0);
|
||||
}
|
||||
private void addTransfer(long id, String no, long sourceId) {
|
||||
var head = new WarehouseTransferOrder(); head.setId(id); head.setCustomerId("7"); head.setTransferNo(no);
|
||||
head.setStatus("OUTBOUNDED"); head.setFbaWarehouseCode("ABE4"); head.setDestinationWarehouseId("30"); head.setDestinationWarehouseName("ABE4 warehouse");
|
||||
session.getMapper(WarehouseTransferOrderMapper.class).insert(head); addSource(id, sourceId);
|
||||
}
|
||||
private void addSource(long transfer, long source) {
|
||||
var allocation = new WarehouseTransferInventoryAllocation(); allocation.setId(source + 1000); allocation.setCustomerId("7");
|
||||
allocation.setTransferOrderId(transfer); allocation.setInventoryPositionId(source); allocation.setPackageCount(1); allocation.setStatus("SHIPPED");
|
||||
session.getMapper(WarehouseTransferInventoryAllocationMapper.class).insert(allocation);
|
||||
}
|
||||
private WarehouseTransferRequests.Receipt request(long source, long pallet, String key) {
|
||||
return new WarehouseTransferRequests.Receipt(source, "9", "NY", "NY warehouse", "30", "ABE4", "ABE4 warehouse",
|
||||
pallet, "PAL-" + pallet, "BATCH", pallet + 20, "LOC-" + (pallet + 20), key, "TEST");
|
||||
}
|
||||
private void receive(String no, WarehouseTransferRequests.Receipt request) {
|
||||
coordinator.receive("7", no, "operator", request, () -> null, context -> {
|
||||
assertTrue(TransactionSynchronizationManager.isActualTransactionActive()); stockCalls.incrementAndGet();
|
||||
assertEquals(30L, Long.parseLong(context.target().fbaId())); assertTrue(context.resourceLockId() > 0);
|
||||
jdbc.update("INSERT INTO local_effect(type,source_id) VALUES('STOCK_IN',?)", request.sourceInventoryPositionId());
|
||||
jdbc.update("UPDATE ll_warehouse_transfer_inventory_allocation SET status='RECEIVED' WHERE customer_id='7' AND inventory_position_id=?", request.sourceInventoryPositionId());
|
||||
var head = session.getMapper(WarehouseTransferOrderMapper.class).findNo("7", no);
|
||||
var receipt = new WarehouseTransferReceipt(); receipt.setId(request.sourceInventoryPositionId() + 2000);
|
||||
receipt.setCustomerId("7"); receipt.setTransferOrderId(head.getId()); receipt.setSourceInventoryPositionId(request.sourceInventoryPositionId());
|
||||
receipt.setRequestHash(hash(request)); receipt.setIdempotencyKey(request.idempotencyKey()); receipt.setPackageCount(1);
|
||||
session.getMapper(WarehouseTransferReceiptMapper.class).insert(receipt);
|
||||
long left = jdbc.queryForObject("SELECT COUNT(*) FROM ll_warehouse_transfer_inventory_allocation WHERE transfer_order_id=? AND status='SHIPPED'", Long.class, head.getId());
|
||||
jdbc.update("UPDATE ll_warehouse_transfer_order SET status=? WHERE id=?", left == 0 ? "RECEIVED" : "PARTIALLY_RECEIVED", head.getId());
|
||||
if (left == 0) { jdbc.update("INSERT INTO local_effect(type,source_id) VALUES('FEE_APPROVED',?)", request.sourceInventoryPositionId()); jdbc.update("INSERT INTO local_effect(type,source_id) VALUES('SETTLE',?)", request.sourceInventoryPositionId()); }
|
||||
if (failLocal.getAndSet(false)) throw new IllegalStateException("local stock/fee transaction failed");
|
||||
return null;
|
||||
});
|
||||
}
|
||||
private String hash(Object value) { try { return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(json.writeValueAsBytes(value))); } catch (Exception e) { throw new AssertionError(e); } }
|
||||
private long count(String table) { return jdbc.queryForObject("SELECT COUNT(*) FROM " + table, Long.class); }
|
||||
private String state(long source) { return jdbc.queryForObject("SELECT status FROM ll_warehouse_transfer_receipt_command WHERE source_inventory_position_id=?", String.class, source); }
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
|
||||
@Test void originalReceiptReplayDoesNotReserveOrStockAgainEvenAfterPhysicalResourceChanges() {
|
||||
var request = request(101, 20, "KEY-1"); receive("WT-1", request); remote.clear(); receive("WT-1", request);
|
||||
assertEquals(1, reserveCalls.get()); assertEquals(1, occupancies.get()); assertEquals(1, stockCalls.get());
|
||||
assertEquals(1, count("ll_warehouse_transfer_receipt")); assertEquals("COMPLETED", state(101));
|
||||
}
|
||||
@Test void multipleSourcesShareOnePhysicalReservationAndCompleteDistinctReceipts() {
|
||||
addSource(1, 102); receive("WT-1", request(101, 20, "KEY-1")); receive("WT-1", request(102, 20, "KEY-2"));
|
||||
assertEquals(1, occupancies.get()); assertEquals(2, stockCalls.get()); assertEquals(2, count("ll_warehouse_transfer_receipt"));
|
||||
assertEquals(1L, jdbc.queryForObject("SELECT COUNT(DISTINCT resource_lock_id) FROM ll_warehouse_transfer_receipt_command", Long.class));
|
||||
assertEquals(1L, jdbc.queryForObject("SELECT COUNT(DISTINCT target_hash) FROM ll_warehouse_transfer_receipt_command", Long.class));
|
||||
assertEquals(2L, jdbc.queryForObject("SELECT COUNT(DISTINCT request_hash) FROM ll_warehouse_transfer_receipt_command", Long.class));
|
||||
assertEquals(1L, jdbc.queryForObject("SELECT COUNT(*) FROM local_effect WHERE type='SETTLE'", Long.class));
|
||||
}
|
||||
@Test void remoteCommittedButLocalStockAndFeeFailureRollsBackThenSameRequestRecovers() {
|
||||
var request = request(101, 20, "KEY-1"); failLocal.set(true);
|
||||
assertThrows(IllegalStateException.class, () -> receive("WT-1", request));
|
||||
assertEquals("RESERVED", state(101)); assertEquals(0, count("local_effect")); assertEquals(0, count("ll_warehouse_transfer_receipt"));
|
||||
assertEquals("SHIPPED", jdbc.queryForObject("SELECT status FROM ll_warehouse_transfer_inventory_allocation WHERE inventory_position_id=101", String.class));
|
||||
receive("WT-1", request); assertEquals(1, occupancies.get()); assertEquals("COMPLETED", state(101));
|
||||
assertEquals(1L, jdbc.queryForObject("SELECT COUNT(*) FROM local_effect WHERE type='STOCK_IN'", Long.class));
|
||||
verify(warehouse, never()).release(anyString(), anyLong());
|
||||
}
|
||||
@Test void responseLostAfterRemoteCommitLeavesPreparedIntentRecoverableWithoutNewOccupancy() {
|
||||
loseReserveResponse.set(true); var request = request(101, 20, "KEY-1");
|
||||
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request));
|
||||
assertEquals("PREPARED", state(101)); assertEquals(0, stockCalls.get()); assertEquals(1, occupancies.get());
|
||||
receive("WT-1", request); assertEquals("COMPLETED", state(101)); assertEquals(1, occupancies.get());
|
||||
}
|
||||
@Test void failedFirstSourceDoesNotPreventSecondSourceThenOriginalRecoveryOnSamePallet() {
|
||||
addSource(1, 102); failLocal.set(true); var first = request(101, 20, "KEY-1");
|
||||
assertThrows(IllegalStateException.class, () -> receive("WT-1", first));
|
||||
receive("WT-1", request(102, 20, "KEY-2")); receive("WT-1", first);
|
||||
assertEquals(1, occupancies.get()); assertEquals(2, count("ll_warehouse_transfer_receipt"));
|
||||
assertEquals(1L, jdbc.queryForObject("SELECT COUNT(*) FROM local_effect WHERE type='SETTLE'", Long.class));
|
||||
}
|
||||
@Test void changedOriginalKeyPayloadOrTargetCannotBypassPreparedIntent() {
|
||||
failLocal.set(true); assertThrows(IllegalStateException.class, () -> receive("WT-1", request(101, 20, "KEY-1")));
|
||||
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(101, 20, "NEW-KEY")));
|
||||
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(101, 21, "KEY-1")));
|
||||
var original = request(101, 20, "KEY-1"); var changed = new WarehouseTransferRequests.Receipt(101L, "9", "NY", "NY warehouse", "30", "ABE4", "ABE4 warehouse", 20L, "PAL-20", "CHANGED-BATCH", 40L, "LOC-40", "KEY-1", "TEST");
|
||||
assertThrows(ResponseStatusException.class, () -> receive("WT-1", changed));
|
||||
assertEquals(1, reserveCalls.get()); assertEquals(1, count("ll_warehouse_transfer_receipt_command")); assertEquals(0, count("ll_warehouse_transfer_receipt"));
|
||||
}
|
||||
@Test void sameKeyCannotBeReusedForAnotherSource() {
|
||||
addSource(1, 102); receive("WT-1", request(101, 20, "KEY-1"));
|
||||
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(102, 20, "KEY-1")));
|
||||
assertEquals(1, occupancies.get()); assertEquals(1, count("ll_warehouse_transfer_receipt_command"));
|
||||
}
|
||||
@Test void occupiedPalletOfAnotherTransferIsNotReused() {
|
||||
receive("WT-1", request(101, 20, "KEY-1")); addTransfer(2, "WT-2", 201);
|
||||
assertThrows(ResponseStatusException.class, () -> receive("WT-2", request(201, 20, "KEY-3")));
|
||||
assertEquals(1, occupancies.get()); assertEquals(1, count("ll_warehouse_transfer_receipt"));
|
||||
assertEquals(1, count("ll_warehouse_transfer_receipt_command"));
|
||||
receive("WT-2", request(201, 21, "KEY-3"));
|
||||
assertEquals(2, occupancies.get()); assertEquals(2, count("ll_warehouse_transfer_receipt"));
|
||||
}
|
||||
@Test void twoRealResourcesInSameWarehouseStillRequireExactPalletLocationAssociation() {
|
||||
var wrong = new WarehouseTransferRequests.Receipt(101L, "9", "NY", "NY warehouse", "30", "ABE4", "ABE4 warehouse", 20L, "PAL-20", "BATCH", 41L, "LOC-41", "KEY-1", "TEST");
|
||||
assertThrows(ResponseStatusException.class, () -> receive("WT-1", wrong)); assertEquals(0, reserveCalls.get()); assertEquals(0, count("ll_warehouse_transfer_receipt_command"));
|
||||
}
|
||||
@Test void omittedFbaIdsStillResolveAuthoritativeCatalogAndActivePublicMaster() {
|
||||
jdbc.update("UPDATE ll_warehouse_transfer_order SET destination_warehouse_id=NULL WHERE id=1");
|
||||
var request = new WarehouseTransferRequests.Receipt(101L, "9", null, null, null, "ABE4", null, 20L, "PAL-20", "BATCH", null, null, "KEY-1", "TEST");
|
||||
receive("WT-1", request); verify(catalog).activeWarehouses("test-token"); verify(directory).activeWarehouse(30L, "test-token");
|
||||
assertEquals(1, occupancies.get());
|
||||
}
|
||||
@Test void omittedIdsDoNotAllowStringOnlyFbaWhenAuthorityHasNoMatchingWarehouse() {
|
||||
jdbc.update("UPDATE ll_warehouse_transfer_order SET destination_warehouse_id=NULL WHERE id=1");
|
||||
when(catalog.activeWarehouses("test-token")).thenReturn(ApiResponse.success(List.of()));
|
||||
var request = new WarehouseTransferRequests.Receipt(101L, "9", null, null, null, "ABE4", null, 20L, "PAL-20", "BATCH", null, null, "KEY-1", "TEST");
|
||||
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request)); assertEquals(0, count("ll_warehouse_transfer_receipt_command")); assertEquals(0, occupancies.get());
|
||||
}
|
||||
@Test void inactiveOrForeignTypeFbaMasterStopsBeforeResourceOccupation() {
|
||||
when(directory.activeWarehouse(30L, "test-token")).thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(
|
||||
new DomesticWarehouseDirectoryClient.Warehouse(30L, 0L, "ABE4", "ABE4 warehouse", "OVERSEAS", "US", "ACTIVE"), List.of())));
|
||||
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(101, 20, "KEY-1"))); assertEquals(0, occupancies.get());
|
||||
when(directory.activeWarehouse(30L, "test-token")).thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(
|
||||
new DomesticWarehouseDirectoryClient.Warehouse(30L, 0L, "ABE4", "ABE4 warehouse", "FBA", "US", "DISABLED"), List.of())));
|
||||
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(101, 20, "KEY-1"))); assertEquals(0, count("ll_warehouse_transfer_receipt_command"));
|
||||
}
|
||||
@Test void inactiveReceivingWarehouseOrForeignPalletCannotCreateReceiptCommand() {
|
||||
when(warehouse.requireActiveSharedWarehouse(9L)).thenReturn(new WarehouseResourceGateway.WarehouseReference(9, "NY", "NY warehouse", "DISABLED"));
|
||||
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(101, 20, "KEY-1")));
|
||||
when(warehouse.requireActiveSharedWarehouse(9L)).thenReturn(new WarehouseResourceGateway.WarehouseReference(9, "NY", "NY warehouse", "ACTIVE"));
|
||||
when(warehouse.resolvePallet(20L)).thenReturn(new WarehouseResourceGateway.WarehousePalletReference(20, "PAL-20", 99, 40, "IDLE", "ABE4", 0));
|
||||
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(101, 20, "KEY-1"))); assertEquals(0, count("ll_warehouse_transfer_receipt_command"));
|
||||
}
|
||||
@Test void wrongFrozenFbaAndWrongMasterIdAreRejected() {
|
||||
when(warehouse.resolvePallet(20L)).thenReturn(new WarehouseResourceGateway.WarehousePalletReference(20, "PAL-20", 9, 40, "IDLE", "ABE3", 0));
|
||||
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(101, 20, "KEY-1"))); assertEquals(0, occupancies.get());
|
||||
when(warehouse.resolvePallet(20L)).thenReturn(pallet(20, "IDLE"));
|
||||
when(directory.activeWarehouse(30L, "test-token")).thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(
|
||||
new DomesticWarehouseDirectoryClient.Warehouse(31L, 0L, "ABE4", "ABE4 warehouse", "FBA", "US", "ACTIVE"), List.of())));
|
||||
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(101, 20, "KEY-1"))); assertEquals(0, count("ll_warehouse_transfer_receipt_command"));
|
||||
}
|
||||
@Test void sourceWithoutShippedOwnershipIsRejectedBeforeAnyRemoteCall() {
|
||||
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(999, 20, "KEY-1")));
|
||||
assertEquals(0, reserveCalls.get()); verify(warehouse, never()).requireActiveSharedWarehouse(anyLong());
|
||||
}
|
||||
@Test void legitimateDifferentTargetPalletsReceiveIndependentSourcesWithoutReducingCapability() {
|
||||
addSource(1, 102); receive("WT-1", request(101, 20, "KEY-1")); receive("WT-1", request(102, 21, "KEY-2"));
|
||||
assertEquals(2, occupancies.get()); assertEquals(2, count("ll_warehouse_transfer_receipt"));
|
||||
assertEquals(2L, jdbc.queryForObject("SELECT COUNT(DISTINCT resource_lock_id) FROM ll_warehouse_transfer_receipt_command", Long.class));
|
||||
}
|
||||
}
|
||||
+13
@@ -173,10 +173,23 @@ class WarehouseTransferServiceTest {
|
||||
@Mock InventoryCommandMapper inventoryCommands;
|
||||
@Mock com.baiyue.lastleg.relabel.service.RelabelApplicationService relabelService;
|
||||
private WarehouseTransferService service;
|
||||
@Mock WarehouseTransferReceiptCoordinator receiptCoordinator;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new WarehouseTransferService();
|
||||
ReflectionTestUtils.setField(service, "receiptCoordinator", receiptCoordinator);
|
||||
lenient().when(receiptCoordinator.receive(anyString(), anyString(), anyString(), any(), any(), any()))
|
||||
.thenAnswer(call -> {
|
||||
WarehouseTransferRequests.Receipt request = call.getArgument(3);
|
||||
var target = new WarehouseTransferReceiptCoordinator.Target(request.destinationOverseasWarehouseId(),
|
||||
request.destinationOverseasWarehouseCode(), request.destinationOverseasWarehouseName(),
|
||||
request.destinationPalletId(), request.destinationPalletNo(), request.warehouseLocationId(),
|
||||
request.warehouseLocationCode(), request.destinationWarehouseId(), request.destinationWarehouseCode(),
|
||||
request.destinationWarehouseName());
|
||||
java.util.function.Function<WarehouseTransferReceiptCoordinator.Context, com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses.Detail> work = call.getArgument(5);
|
||||
return work.apply(new WarehouseTransferReceiptCoordinator.Context(target, 51L, 61L));
|
||||
});
|
||||
ReflectionTestUtils.setField(service, "transfers", transfers);
|
||||
ReflectionTestUtils.setField(service, "transferItems", transferItems);
|
||||
ReflectionTestUtils.setField(service, "transferCartons", transferCartons);
|
||||
|
||||
Reference in New Issue
Block a user