This commit is contained in:
2026-08-28 13:54:32 +08:00
parent fe68551124
commit 6b5f2f0565
29 changed files with 959 additions and 178 deletions
@@ -0,0 +1,38 @@
package com.baiyue.lastleg.client;
import org.junit.jupiter.api.Test;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import java.lang.reflect.Method;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
class DomesticWarehouseDirectoryClientContractTest {
@Test
void freezesActivePlatformWarehouseLookupAndDto() throws Exception {
FeignClient feign = DomesticWarehouseDirectoryClient.class.getAnnotation(FeignClient.class);
assertThat(feign.name()).isEqualTo("warehouse-service");
assertThat(feign.contextId()).isEqualTo("domesticWarehouseDirectoryClient");
assertThat(feign.url()).isEqualTo("${baiyue.clients.domestic-warehouse.url:}");
Method method = DomesticWarehouseDirectoryClient.class.getDeclaredMethod(
"activeWarehouse", long.class, String.class);
assertThat(method.getAnnotation(GetMapping.class).value())
.containsExactly("/internal/warehouses/0/{warehouseId}/active");
assertThat(method.getParameters()[0].getAnnotation(PathVariable.class).value())
.isEqualTo("warehouseId");
assertThat(method.getParameters()[1].getAnnotation(RequestHeader.class).value())
.isEqualTo("X-Internal-Service-Token");
assertThat(Arrays.stream(DomesticWarehouseDirectoryClient.Warehouse.class.getRecordComponents())
.map(value -> value.getName() + ":" + value.getType().getSimpleName()))
.containsExactly("id:Long", "tenantId:Long", "warehouseCode:String", "warehouseName:String",
"warehouseType:String", "countryCode:String", "status:String");
assertThat(Arrays.stream(DomesticWarehouseDirectoryClient.Address.class.getRecordComponents())
.map(value -> value.getName() + ":" + value.getType().getSimpleName()))
.containsExactly("stateProvince:String");
}
}
@@ -27,6 +27,7 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.when;
class LastlegOrderControllerTest {
// Controller contract tests intentionally instantiate the controller without a Spring context here.
private final LastlegOrderService service = mock(LastlegOrderService.class);
private final LastlegOrderController controller = controller();
private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query(
@@ -52,20 +53,27 @@ class LastlegOrderControllerTest {
@Test
void platformOperatorCanQueryCrossCustomerContainerSummaries() {
var summaryQuery = new LastlegOrderRequests.PendingContainerQuery(1, 20, "CONT-", null, null);
var summaryQuery = new LastlegOrderRequests.ContainerProgressQuery(1, 20, "CONT-", null, null);
controller.containerSummaries(user(0L, "PLATFORM"), summaryQuery);
verify(service).pendingContainerPage(summaryQuery);
verify(service).containerProgressPage(summaryQuery);
}
@Test
void containerSummariesRejectMerchantIdentity() {
var summaryQuery = new LastlegOrderRequests.PendingContainerQuery(1, 20, null, null, null);
var summaryQuery = new LastlegOrderRequests.ContainerProgressQuery(1, 20, null, null, null);
assertThrows(ResponseStatusException.class,
() -> controller.containerSummaries(user(100L, "MERCHANT"), summaryQuery));
verify(service, never()).pendingContainerPage(summaryQuery);
verify(service, never()).containerProgressPage(summaryQuery);
}
@Test
void platformOperatorCanQueryUnpagedContainerDetailByStableId() {
controller.containerSummaryDetail(user(0L, "PLATFORM"), "BOOKING-1");
verify(service).containerProgressDetail("BOOKING-1");
}
@Test
@@ -39,7 +39,8 @@ class ReceivingContainerControllerTest {
@Test
void candidatesDerivesOperatorFromPlatformWarehousePickerIdentity() {
when(service.candidates("42", 900L)).thenReturn(List.of(new ReceivingContainerResponses.Candidate(
"CONT-1", "WAIT_SIGN", 2, 2, List.of("ONT8"), 4, 20, 0, false)));
"BOOKING-1", "CONT-1", "WAIT_SIGN", 2, 2, 0, 2, 2, 0,
List.of("ONT8"), 4, 20, 0, true, true, null, null, false)));
controller.candidates(picker());
@@ -87,11 +87,18 @@ class ReceivingContainerServiceBehaviorTest {
}
@Test
void containerIsHiddenAndPrepareRejectedBeforePickupReady() {
void containerRemainsVisibleButPrepareIsRejectedBeforePickupReady() {
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A")));
when(lines.selectList(any())).thenReturn(List.of(line("FBA")));
LastlegOrder order = containerOrder(1L, "100", "CONT-A", "BOOKING-A", "IN-1");
when(orders.selectList(any())).thenReturn(List.of(order));
when(shippingGates.receivingAvailable("100", "CONT-A")).thenReturn(0);
assertThat(service.candidates("7", 100L)).isEmpty();
assertThat(service.candidates("7", 100L)).singleElement().satisfies(candidate -> {
assertThat(candidate.operable()).isFalse();
assertThat(candidate.allPlansReady()).isTrue();
assertThat(candidate.blockReason()).contains("尚未达到");
});
assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-A",
new ReceivingContainerRequests.Action("not-ready")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
@@ -101,7 +108,10 @@ class ReceivingContainerServiceBehaviorTest {
@Test
void prepareIdempotencyHashIncludesContainerPath() {
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A")));
when(lines.selectList(any())).thenReturn(List.of());
when(lines.selectList(any())).thenReturn(List.of(line("FBA")));
LastlegOrder order = containerOrder(1L, "100", "CONT-A", "BOOKING-A", "IN-1");
when(orders.selectList(any())).thenReturn(List.of(order));
when(orders.selectById(1L)).thenReturn(order);
when(sessions.insert(any(ReceivingContainerSession.class))).thenReturn(1);
when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1);
when(warehouse.requireActiveSharedWarehouse(100L)).thenReturn(warehouseReference());
@@ -132,14 +142,23 @@ class ReceivingContainerServiceBehaviorTest {
InboundPlan second = plan("CONT-B"); second.setId(3L); second.setCustomerId("200");
when(plans.selectList(any())).thenReturn(List.of(first, second), List.of(first), List.of(second));
when(lines.selectList(any())).thenReturn(List.of());
when(orders.selectList(any())).thenReturn(List.of(
containerOrder(1L, "100", "CONT-A", "BOOKING-A", "IN-1"),
containerOrder(2L, "200", "CONT-B", "BOOKING-B", "IN-2")));
assertThat(service.candidates("7", 100L)).extracting("containerNo").containsExactly("CONT-A", "CONT-B");
InboundPlan ambiguous = plan("CONT-A"); ambiguous.setId(4L); ambiguous.setCustomerId("200");
org.mockito.Mockito.reset(plans, sessions);
when(plans.selectList(any())).thenReturn(List.of(first, ambiguous));
assertThatThrownBy(() -> service.candidates("7", 100L)).isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("exactly one customer");
when(lines.selectList(any())).thenReturn(List.of());
when(orders.selectList(any())).thenReturn(List.of(
containerOrder(1L, "100", "CONT-A", "BOOKING-A", "IN-1"),
containerOrder(2L, "200", "CONT-A", "BOOKING-A", "IN-2")));
assertThat(service.candidates("7", 100L)).singleElement().satisfies(candidate -> {
assertThat(candidate.operable()).isFalse();
assertThat(candidate.blockReason()).contains("未操作/缺入仓码");
});
}
@Test
@@ -150,6 +169,12 @@ class ReceivingContainerServiceBehaviorTest {
when(plans.selectList(any())).thenReturn(List.of(unassigned, same, other),
List.of(unassigned), List.of(same));
when(lines.selectList(any())).thenReturn(List.of());
LastlegOrder orderA = containerOrder(1L, "100", "CONT-A", "BOOKING-A", "IN-1");
LastlegOrder orderB = containerOrder(2L, "100", "CONT-B", "BOOKING-B", "IN-2");
orderB.setOverseasWarehouseId("100");
LastlegOrder orderC = containerOrder(3L, "100", "CONT-C", "BOOKING-C", "IN-3");
orderC.setOverseasWarehouseId("200");
when(orders.selectList(any())).thenReturn(List.of(orderA, orderB, orderC));
assertThat(service.candidates("7", 100L)).extracting("containerNo")
.containsExactly("CONT-A", "CONT-B");
@@ -164,11 +189,36 @@ class ReceivingContainerServiceBehaviorTest {
other.setActiveContainerKey("CONTAINER:CONT-B");
when(plans.selectList(any())).thenReturn(List.of(available, reserved), List.of(available));
when(lines.selectList(any())).thenReturn(List.of());
when(orders.selectList(any())).thenReturn(List.of(
containerOrder(1L, "100", "CONT-A", "BOOKING-A", "IN-1"),
containerOrder(2L, "100", "CONT-B", "BOOKING-B", "IN-2")));
when(sessions.findActiveContainers()).thenReturn(List.of(other));
assertThat(service.candidates("7", 100L)).extracting("containerNo").containsExactly("CONT-A");
}
@Test
void partialContainerPlansRemainVisibleAndPrepareIsHardRejected() {
InboundPlan firstPlan = plan("CONT-A");
when(plans.selectList(any())).thenReturn(List.of(firstPlan));
when(lines.selectList(any())).thenReturn(List.of(line("FBA")));
when(orders.selectList(any())).thenReturn(List.of(
containerOrder(1L, "100", "CONT-A", "BOOKING-A", "IN-1"),
containerOrder(2L, "100", "CONT-A", "BOOKING-A", "IN-2")));
var candidate = service.candidates("7", 100L).get(0);
assertThat(candidate.totalOrderCount()).isEqualTo(2);
assertThat(candidate.plannedInboundCodeCount()).isEqualTo(1);
assertThat(candidate.pendingPlanCount()).isEqualTo(1);
assertThat(candidate.operable()).isFalse();
assertThat(candidate.blockReason()).isEqualTo("1个入仓码已生成计划,1个订单未操作/缺入仓码");
assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-A",
new ReceivingContainerRequests.Action("partial")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verify(sessions, never()).insert(any(ReceivingContainerSession.class));
}
@Test
void detailRejectsContainerReservedByAnotherOperator() {
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B")));
@@ -193,6 +243,9 @@ class ReceivingContainerServiceBehaviorTest {
@Test
void prepareEnforcesBothActiveAccountAndActiveContainerUniqueness() {
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B")));
when(lines.selectList(any())).thenReturn(List.of(line("FBA")));
LastlegOrder order = containerOrder(1L, "100", "CONT-B", "BOOKING-B", "IN-1");
when(orders.selectList(any())).thenReturn(List.of(order));
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B",
new ReceivingContainerRequests.Action("account-lock"))).isInstanceOf(ResponseStatusException.class);
@@ -575,6 +628,14 @@ class ReceivingContainerServiceBehaviorTest {
return value;
}
private static LastlegOrder containerOrder(long id, String customerId, String containerNo,
String bookingId, String inboundCode) {
LastlegOrder value = order("FBA");
value.setId(id); value.setCustomerId(customerId); value.setContainerNo(containerNo);
value.setSourceContainerBookingId(bookingId); value.setInboundCode(inboundCode);
return value;
}
private static LastlegOrderDetail detail(String skuCode, int packages) {
LastlegOrderDetail value = new LastlegOrderDetail();
value.setId(11L); value.setCustomerId("100"); value.setOrderId(1L);
@@ -0,0 +1,77 @@
package com.baiyue.lastleg.service;
import com.baiyue.lastleg.dto.ContainerPlanProgressDetail;
import com.baiyue.lastleg.dto.ContainerPlanProgressSummary;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.mapping.SqlSource;
import org.apache.ibatis.scripting.xmltags.XMLLanguageDriver;
import org.apache.ibatis.session.Configuration;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ContainerPlanProgressContractTest {
@Test
void responseFieldsAndServerAggregateStayFrozen() throws Exception {
assertEquals(List.of("sourceContainerBookingId", "containerNo", "totalOrderCount", "inboundCodeCount",
"missingInboundCodeCount", "planCount", "plannedInboundCodeCount", "pendingPlanCount",
"plannedArrivalAt", "allPlansReady", "blockingReason"),
Arrays.stream(ContainerPlanProgressSummary.class.getRecordComponents())
.map(value -> value.getName()).toList());
assertEquals(List.of("orderId", "customerId", "orderNo", "inboundCode", "orderStatus", "planId",
"planNo", "planStatus", "planValid", "plannedArrivalAt"),
Arrays.stream(ContainerPlanProgressDetail.Item.class.getRecordComponents())
.map(value -> value.getName()).toList());
Select rows = LastlegOrderMapper.class.getMethod("pageContainerProgress",
LastlegOrderRequests.ContainerProgressQuery.class, long.class, long.class).getAnnotation(Select.class);
String sql = String.join("", rows.value()).toLowerCase();
assertTrue(sql.contains("left join ll_inbound_plan"));
assertTrue(sql.contains("o.estimated_warehouse_at"));
assertTrue(sql.contains("progress.plannedarrivalat asc"));
assertTrue(sql.contains("progress.sourcecontainerbookingid asc"));
assertTrue(sql.contains("limit #{limit} offset #{offset}"));
}
@Test
void openApiAndSchemaExposeReadinessAndUnpagedDetail() throws Exception {
String openApi = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"),
StandardCharsets.UTF_8);
String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8);
String deployment = Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"),
StandardCharsets.UTF_8);
assertTrue(openApi.contains("/api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}:"));
assertTrue(openApi.contains("ContainerPlanProgressSummary:"));
assertTrue(openApi.contains("allPlansReady:"));
assertTrue(openApi.contains("ll_order.estimated_warehouse_at"));
assertTrue(schema.contains("idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id)"));
assertTrue(deployment.contains("idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id)"));
assertTrue(deployment.contains("assert_container_progress_index_ready"));
}
@Test
void mybatisRendersExecutableProgressSqlWithoutXmlEntities() throws Exception {
Select select = LastlegOrderMapper.class.getMethod("pageContainerProgress",
LastlegOrderRequests.ContainerProgressQuery.class, long.class, long.class).getAnnotation(Select.class);
SqlSource source = new XMLLanguageDriver().createSqlSource(new Configuration(),
String.join("", select.value()), Map.class);
String sql = source.getBoundSql(Map.of(
"q", new LastlegOrderRequests.ContainerProgressQuery(1, 20, null, null, null),
"limit", 20L, "offset", 0L)).getSql().replaceAll("\\s+", " ").trim();
assertTrue(sql.contains("source_container_booking_id<>''"));
assertTrue(sql.contains("LEFT JOIN ll_inbound_plan"));
assertTrue(!sql.contains("&lt;"));
}
}
@@ -0,0 +1,101 @@
package com.baiyue.lastleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
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.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class FbaWarehouseValidatorTest {
@Mock private DomesticWarehouseDirectoryClient client;
private FbaWarehouseValidator validator;
@BeforeEach
void setUp() {
validator = new FbaWarehouseValidator();
ReflectionTestUtils.setField(validator, "client", client);
ReflectionTestUtils.setField(validator, "internalToken", "internal-token");
}
@Test
void acceptsOnlyMatchingActivePlatformFbaAndReturnsRegionSnapshot() {
when(client.activeWarehouse(193000000000000001L, "internal-token"))
.thenReturn(ApiResponse.success(detail("FBA", "ACTIVE", "ONT8", "安大略 ONT8")));
var result = validator.required("193000000000000001", "ONT8", "安大略 ONT8");
assertThat(result.id()).isEqualTo(193000000000000001L);
assertThat(result.code()).isEqualTo("ONT8");
assertThat(result.name()).isEqualTo("安大略 ONT8");
assertThat(result.countryCode()).isEqualTo("US");
assertThat(result.region()).isEqualTo("CA");
}
@Test
void rejectsAnActualOverseasReceivingWarehouseAsFbaDestination() {
when(client.activeWarehouse(193000000000000001L, "internal-token"))
.thenReturn(ApiResponse.success(detail("OVERSEAS", "ACTIVE", "US-LAX", "洛杉矶海外收货仓")));
assertInvalid(() -> validator.required("193000000000000001", "US-LAX", "洛杉矶海外收货仓"));
}
@Test
void rejectsForgedCodeOrNameEvenWhenIdPointsToAnActiveFba() {
when(client.activeWarehouse(193000000000000001L, "internal-token"))
.thenReturn(ApiResponse.success(detail("FBA", "ACTIVE", "ONT8", "安大略 ONT8")));
assertInvalid(() -> validator.required("193000000000000001", "FAKE", "安大略 ONT8"));
assertInvalid(() -> validator.required("193000000000000001", "ONT8", "伪造名称"));
}
@Test
void rejectsInactiveOrMissingAuthorityFactWithFrozenMessage() {
when(client.activeWarehouse(193000000000000001L, "internal-token"))
.thenReturn(ApiResponse.failure(ResultCode.NOT_FOUND, "仓库已停用", null, null));
assertInvalid(() -> validator.required("193000000000000001", "ONT8", "安大略 ONT8"));
assertInvalid(() -> validator.required("not-an-id", "ONT8", "安大略 ONT8"));
}
@Test
void openApiFreezesFbaAuthorityValidationAndExistingAdminCandidateContract() throws Exception {
String lastlegOpenApi = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"),
StandardCharsets.UTF_8);
String warehouseController = Files.readString(Path.of(
"../warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java"),
StandardCharsets.UTF_8);
assertThat(lastlegOpenApi).contains("/api/platform/v1/last-leg/orders/{id}/destination:")
.contains("FbaTransferDestination:")
.contains("所选FBA仓不存在或已停用");
assertThat(warehouseController).contains("@RequestMapping(\"/api/platform/v1/warehouse-admin\")")
.contains("@PreAuthorize(\"hasAuthority('platform:warehouse:list')\")");
}
private DomesticWarehouseDirectoryClient.Detail detail(String type, String status, String code, String name) {
return new DomesticWarehouseDirectoryClient.Detail(
new DomesticWarehouseDirectoryClient.Warehouse(193000000000000001L, 0L, code, name,
type, "US", status),
List.of(new DomesticWarehouseDirectoryClient.Address("CA")));
}
private void assertInvalid(org.assertj.core.api.ThrowableAssert.ThrowingCallable action) {
assertThatThrownBy(action).isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("所选FBA仓不存在或已停用");
}
}
@@ -1,7 +1,8 @@
package com.baiyue.lastleg.service;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.dto.PendingContainerSummary;
import com.baiyue.lastleg.dto.ContainerPlanProgressDetail;
import com.baiyue.lastleg.dto.ContainerPlanProgressSummary;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
@@ -20,7 +21,6 @@ import org.springframework.web.server.ResponseStatusException;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -30,13 +30,16 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.lenient;
@ExtendWith(MockitoExtension.class)
class LastlegOrderServiceTest {
// Service tests keep remote master-data validation and persistence independently observable here.
@Mock LastlegOrderMapper orders;
@Mock LastlegOrderDetailMapper details;
@Mock LastlegOrderStatusHistoryMapper histories;
@Mock InboundPlanCartonMapper cartons;
@Mock FbaWarehouseValidator fbaWarehouses;
LastlegOrderService service;
@BeforeEach
@@ -46,6 +49,11 @@ class LastlegOrderServiceTest {
ReflectionTestUtils.setField(service, "detailMapper", details);
ReflectionTestUtils.setField(service, "historyMapper", histories);
ReflectionTestUtils.setField(service, "cartonMapper", cartons);
ReflectionTestUtils.setField(service, "fbaWarehouseValidator", fbaWarehouses);
lenient().when(fbaWarehouses.required(any(), any(), any())).thenAnswer(invocation ->
new FbaWarehouseValidator.VerifiedFbaWarehouse(
Long.parseLong(invocation.getArgument(0)), invocation.getArgument(1), invocation.getArgument(2),
"US", "CA"));
}
@Test
@@ -73,11 +81,11 @@ class LastlegOrderServiceTest {
LastlegOrder order = order(11L, "100", "PENDING_ASSIGNMENT", null);
when(orders.selectList(any())).thenReturn(List.of(order));
var request = new LastlegOrderRequests.TransferDestination(
List.of(11L), "wh-2", "LAX-02", "Los Angeles 02", null);
List.of(11L), "193000000000000002", "LAX-02", "Los Angeles 02", null);
assertEquals(1, service.transferDestination("100", request));
assertEquals("wh-2", order.getTransferWarehouseId());
assertEquals("193000000000000002", order.getTransferWarehouseId());
assertEquals("LAX-02", order.getTransferWarehouseCode());
assertEquals("DESTINATION_ASSIGNED", order.getCurrentStatus());
ArgumentCaptor<LastlegOrderDetail> cargo = ArgumentCaptor.forClass(LastlegOrderDetail.class);
@@ -85,7 +93,7 @@ class LastlegOrderServiceTest {
assertEquals("LAX-02", cargo.getValue().getDestinationWarehouseCode());
ArgumentCaptor<InboundPlanCarton> carton = ArgumentCaptor.forClass(InboundPlanCarton.class);
verify(cartons).update(carton.capture(), any());
assertEquals("wh-2", carton.getValue().getDestinationWarehouseId());
assertEquals("193000000000000002", carton.getValue().getDestinationWarehouseId());
assertEquals("LAX-02", carton.getValue().getDestinationWarehouseCode());
ArgumentCaptor<LastlegOrderStatusHistory> history = ArgumentCaptor.forClass(LastlegOrderStatusHistory.class);
verify(histories).insert(history.capture());
@@ -99,7 +107,7 @@ class LastlegOrderServiceTest {
LastlegOrder order = order(11L, "100", "DESTINATION_ASSIGNED", "IB-1");
when(orders.selectList(any())).thenReturn(List.of(order));
var request = new LastlegOrderRequests.TransferDestination(
List.of(11L), "wh-2", "LAX-02", "Los Angeles 02", null);
List.of(11L), "193000000000000002", "LAX-02", "Los Angeles 02", null);
assertThrows(ResponseStatusException.class, () -> service.transferDestination("100", request));
verify(orders, never()).updateById(any(LastlegOrder.class));
@@ -111,7 +119,7 @@ class LastlegOrderServiceTest {
void batchRejectsMissingOrCrossCustomerIdsBeforeWriting() {
when(orders.selectList(any())).thenReturn(List.of(order(11L, "100", "PENDING_ASSIGNMENT", null)));
var request = new LastlegOrderRequests.TransferDestination(
List.of(11L, 22L), "wh-2", "LAX-02", "Los Angeles 02", null);
List.of(11L, 22L), "193000000000000002", "LAX-02", "Los Angeles 02", null);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.transferDestination("100", request));
@@ -119,6 +127,22 @@ class LastlegOrderServiceTest {
verify(orders, never()).updateById(any(LastlegOrder.class));
}
@Test
void forgedFbaSelectionIsRejectedBeforeLockingOrWritingOrders() {
var request = new LastlegOrderRequests.TransferDestination(
List.of(11L), "193000000000000002", "FAKE", "伪造仓库", null);
when(fbaWarehouses.required("193000000000000002", "FAKE", "伪造仓库"))
.thenThrow(new ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST,
"所选FBA仓不存在或已停用"));
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.transferDestination("100", request));
assertTrue(error.getMessage().contains("所选FBA仓不存在或已停用"));
verify(orders, never()).selectList(any());
verify(orders, never()).updateById(any(LastlegOrder.class));
}
@Test
void pageUsesPaginationAndPlatformFiltersWithoutCustomerScope() {
var query = new LastlegOrderRequests.Query(3, 25, "LL-", "FIRST_LEG", "IN-", null,
@@ -137,14 +161,13 @@ class LastlegOrderServiceTest {
}
@Test
void pendingContainerPageUsesServerSideAggregatePagination() {
var query = new LastlegOrderRequests.PendingContainerQuery(2, 10, "CONT-", null, null);
var row = new PendingContainerSummary("BOOKING-1", "CONT-1", 15, 15, 3,
150, 0, new BigDecimal("12.5000"), true, null);
when(orders.countPendingContainers(query)).thenReturn(11L);
when(orders.pagePendingContainers(query, 10, 10)).thenReturn(List.of(row));
void containerProgressPageUsesServerSideAggregatePagination() {
var query = new LastlegOrderRequests.ContainerProgressQuery(2, 10, "CONT-", null, null);
var row = summary(false, 3, "存在3票尚未生成有效入库计划");
when(orders.countContainerProgress(query)).thenReturn(11L);
when(orders.pageContainerProgress(query, 10, 10)).thenReturn(List.of(row));
var result = service.pendingContainerPage(query);
var result = service.containerProgressPage(query);
assertEquals(2, result.page());
assertEquals(10, result.size());
@@ -154,13 +177,36 @@ class LastlegOrderServiceTest {
}
@Test
void pendingContainerPageRejectsReversedDateRangeBeforeQuerying() {
var query = new LastlegOrderRequests.PendingContainerQuery(1, 20, null,
void containerProgressPageRejectsReversedDateRangeBeforeQuerying() {
var query = new LastlegOrderRequests.ContainerProgressQuery(1, 20, null,
LocalDateTime.of(2026, 8, 20, 0, 0), LocalDateTime.of(2026, 8, 19, 0, 0));
assertThrows(ResponseStatusException.class, () -> service.pendingContainerPage(query));
assertThrows(ResponseStatusException.class, () -> service.containerProgressPage(query));
verify(orders, never()).countPendingContainers(any());
verify(orders, never()).countContainerProgress(any());
}
@Test
void containerProgressDetailReturnsEveryOrderWithoutPagination() {
var summary = summary(true, 0, null);
var items = List.of(
new ContainerPlanProgressDetail.Item(1L, "100", "LMO-1", "RK-1", "INBOUND_PLANNED",
101L, "LIP-1", "PENDING_ASSIGNMENT", true, LocalDateTime.of(2026, 10, 3, 9, 0)),
new ContainerPlanProgressDetail.Item(2L, "200", "LMO-1", "RK-2", "INBOUND_PLANNED",
102L, "LIP-2", "PDA_PENDING", true, LocalDateTime.of(2026, 10, 3, 9, 0)));
when(orders.findContainerProgress("BOOKING-1")).thenReturn(summary);
when(orders.findContainerProgressItems("BOOKING-1")).thenReturn(items);
var result = service.containerProgressDetail(" BOOKING-1 ");
assertEquals(summary, result.summary());
assertEquals(items, result.orders());
verify(orders).findContainerProgressItems("BOOKING-1");
}
private static ContainerPlanProgressSummary summary(boolean ready, long pending, String reason) {
return new ContainerPlanProgressSummary("BOOKING-1", "CONT-1", 15, 15, 0,
15 - pending, 15 - pending, pending, LocalDateTime.of(2026, 10, 3, 9, 0), ready, reason);
}
private static LastlegOrder order(long id, String customerId, String status, String inboundOrderNo) {
@@ -5,6 +5,7 @@ class LoadingCompletedConsumerTest {
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock LastlegOrderNoDailySequenceMapper sequences; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service;
@BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(); ReflectionTestUtils.setField(generator,"sequenceMapper",sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().when(sequences.currentValue()).thenReturn(1L); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); }
@Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor<InventoryCommandRequests.InTransit> inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&o.getOverseasWarehouseId()==null)); ArgumentCaptor<LastlegInboundOrder> snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("LMO-[0-9]{6}-0001")); }
@Test void freezesFormalPlannedWarehouseArrivalFromLoadingEvent(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("estimatedWarehouseArrivalAt","2026-10-03T09:00"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); service.consume(changed); ArgumentCaptor<LastlegOrder> captor=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captor.capture()); assertTrue(captor.getAllValues().stream().allMatch(order->java.time.LocalDateTime.of(2026,10,3,9,0).equals(order.getEstimatedWarehouseAt()))); }
@Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","100","","MSCU1",0L,1L); }
@Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); }
@Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); }
@@ -1,46 +0,0 @@
package com.baiyue.lastleg.service;
import com.baiyue.lastleg.dto.PendingContainerSummary;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PendingContainerSummaryContractTest {
@Test
void responseFieldsAndServerAggregateSqlStayFrozen() throws Exception {
assertEquals(List.of("sourceContainerBookingId", "containerNo", "orderCount", "inboundCodeCount",
"customerCount", "totalPackageCount", "totalPalletCount", "totalVolumeCbm", "eligible",
"ineligibleReason"),
Arrays.stream(PendingContainerSummary.class.getRecordComponents()).map(value -> value.getName()).toList());
Select rows = LastlegOrderMapper.class.getMethod("pagePendingContainers",
com.baiyue.lastleg.dto.LastlegOrderRequests.PendingContainerQuery.class, long.class, long.class)
.getAnnotation(Select.class);
String sql = String.join("", rows.value()).toLowerCase();
assertTrue(sql.contains("group by o.source_container_booking_id"));
assertTrue(sql.contains("pending_assignment"));
assertTrue(sql.contains("destination_assigned"));
assertTrue(sql.contains("limit #{limit} offset #{offset}"));
}
@Test
void openApiAndSchemaExposeStableContainerIdentity() throws Exception {
String openApi = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"),
StandardCharsets.UTF_8);
String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8);
assertTrue(openApi.contains("/api/platform/v1/last-leg/orders/container-summaries:"));
assertTrue(openApi.contains("PendingContainerSummary:"));
assertTrue(openApi.contains("sourceContainerBookingId:"));
assertTrue(schema.contains("idx_ll_order_pending_container(source_container_booking_id,current_status,created_at)"));
}
}