This commit is contained in:
2026-08-20 15:58:25 +08:00
parent cd34cd426e
commit 2847ad1b2d
45 changed files with 921 additions and 68 deletions
@@ -0,0 +1,57 @@
package com.baiyue.lastleg.controller;
import com.baiyue.lastleg.dispatch.controller.DispatchController;
import com.baiyue.lastleg.inboundplan.controller.InboundPlanController;
import com.baiyue.lastleg.inventory.query.controller.InventoryQueryController;
import com.baiyue.lastleg.inventory.command.controller.InventoryCommandController;
import com.baiyue.lastleg.procurement.controller.PlatformOverseasProcurementController;
import com.baiyue.lastleg.relabel.controller.RelabelApplicationController;
import com.baiyue.lastleg.trucking.controller.PlatformUsTruckOrderController;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import java.lang.reflect.Method;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
class LastlegPlatformPermissionContractTest {
@Test
void readEndpointsReuseListPermissionsAndActionsStayIndependent() {
assertPermission(LastlegOrderController.class, "list", "platform:lastleg:order:list");
assertPermission(LastlegOrderController.class, "detail", "platform:lastleg:order:list");
assertPermission(LastlegOrderController.class, "export", "platform:lastleg:order:export");
assertPermission(LastlegOrderController.class, "transferOne", "platform:lastleg:order:transfer");
assertPermission(InboundPlanController.class, "detail", "platform:lastleg:inbound-plan:list");
assertPermission(InboundPlanController.class, "fbaAppointmentDetail", "platform:lastleg:inbound-plan:list");
assertPermission(InboundPlanController.class, "convert", "platform:lastleg:inbound-plan:create");
assertPermission(InboundPlanController.class, "batchFbaAppointment", "platform:lastleg:inbound-plan:appointment");
assertPermission(RelabelApplicationController.class, "page", "platform:lastleg:relabel:list");
assertPermission(RelabelApplicationController.class, "detail", "platform:lastleg:relabel:list");
assertPermission(InventoryQueryController.class, "balances", "platform:lastleg:inventory:list");
assertPermission(InventoryQueryController.class, "positions", "platform:lastleg:inventory:list");
assertPermission(InventoryQueryController.class, "exportBalances", "platform:lastleg:inventory:export");
assertPermission(InventoryCommandController.class, "hold", "platform:lastleg:inventory:manage");
assertPermission(InventoryCommandController.class, "adjust", "platform:lastleg:inventory:adjust");
assertPermission(DispatchController.class, "page", "platform:lastleg:dispatch:list");
assertPermission(DispatchController.class, "detail", "platform:lastleg:dispatch:list");
assertPermission(DispatchController.class, "preArrange", "platform:lastleg:dispatch:create");
assertPermission(PlatformOverseasProcurementController.class, "page", "platform:lastleg:overseas-procurement:list");
assertPermission(PlatformOverseasProcurementController.class, "detail", "platform:lastleg:overseas-procurement:list");
assertPermission(PlatformUsTruckOrderController.class, "page", "platform:lastleg:us-trucking:list");
assertPermission(PlatformUsTruckOrderController.class, "detail", "platform:lastleg:us-trucking:list");
}
private static void assertPermission(Class<?> type, String methodName, String permission) {
Method method = Arrays.stream(type.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals(methodName))
.findFirst().orElseThrow();
assertThat(method.getAnnotation(PreAuthorize.class)).isNotNull();
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + permission + "')");
}
}
@@ -10,6 +10,7 @@ import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway;
import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
@@ -20,6 +21,7 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.mock.web.MockMultipartFile;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@@ -33,6 +35,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@@ -44,6 +47,7 @@ class InboundPdaWorkflowServiceTest {
@Mock WarehouseResourceGateway warehouse;
@Mock InboundStockReceiptService stockReceipts;
@Mock InboundUnloadCompletedOutboxService unloadCompletedOutbox;
@Mock UnloadPhotoFileGateway unloadPhotoFiles;
private InboundPdaWorkflowService service;
private InboundPlan plan;
@@ -64,6 +68,7 @@ class InboundPdaWorkflowServiceTest {
ReflectionTestUtils.setField(service, "warehouse", warehouse);
ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts);
ReflectionTestUtils.setField(service, "unloadCompletedOutbox", unloadCompletedOutbox);
ReflectionTestUtils.setField(service, "unloadPhotoFiles", unloadPhotoFiles);
ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules());
service.initializeObjectMapper();
plan = plan();
@@ -76,7 +81,7 @@ class InboundPdaWorkflowServiceTest {
target = allocation(31L, targetLine.getId(), targetPallet.getId(), 3, 3, 0);
source = allocation(32L, sourceLine.getId(), sourcePallet.getId(), 2, 2, 2);
otherWarehouse = allocation(33L, sourceLine.getId(), otherWarehousePallet.getId(), 2, 2, 2);
when(repository.lockPlan("100", "LIP1")).thenReturn(plan);
lenient().when(repository.lockPlan("100", "LIP1")).thenReturn(plan);
}
@Test
@@ -326,6 +331,8 @@ class InboundPdaWorkflowServiceTest {
assertEquals(List.of(101L, 103L), result.unloadPhotoFileIds());
assertEquals(result.unloadPhotoUploadedAt().plusDays(2), result.estimatedEmptyReturnAt());
verify(unloadPhotoFiles).validate("100", task.getId(), 101L);
verify(unloadPhotoFiles).validate("100", task.getId(), 103L);
verify(repository).update(task);
ArgumentCaptor<InboundPdaOperation> operation = ArgumentCaptor.forClass(InboundPdaOperation.class);
verify(repository).insert(operation.capture());
@@ -344,6 +351,54 @@ class InboundPdaWorkflowServiceTest {
verify(repository, never()).update(task);
}
@Test
void uploadUnloadPhotosRejectsAFileOwnedByAnotherBusinessTask() {
activeTask();
org.mockito.Mockito.doThrow(new IllegalArgumentException("ownership mismatch"))
.when(unloadPhotoFiles).validate("100", task.getId(), 101L);
assertThrows(ResponseStatusException.class, () -> service.uploadUnloadPhotos("100", "LIP1",
new PdaRequests.UnloadPhotos(List.of(101L), "unload-photo-wrong-owner", "7", "worker")));
verify(repository, never()).update(task);
}
@Test
void uploadUnloadPhotoFileBindsTheFileToTheActivePdaTask() {
activeTask();
var file = new MockMultipartFile("file", "unload.jpg", "image/jpeg", new byte[] {1, 2, 3});
when(unloadPhotoFiles.upload("100", task.getId(), 7L, file))
.thenReturn(new UnloadPhotoFileGateway.UploadedFile(101L, "unload.jpg", "image/jpeg", 3L));
var result = service.uploadUnloadPhotoFile("100", "LIP1", 7L, file);
assertEquals(101L, result.fileId());
assertEquals("unload.jpg", result.fileName());
assertEquals("image/jpeg", result.contentType());
assertEquals(3L, result.sizeBytes());
verify(unloadPhotoFiles).upload("100", task.getId(), 7L, file);
}
@Test
void uploadUnloadPhotoFileRejectsEmptyFileBeforeCallingFileService() {
var file = new MockMultipartFile("file", "unload.jpg", "image/jpeg", new byte[0]);
assertThrows(ResponseStatusException.class,
() -> service.uploadUnloadPhotoFile("100", "LIP1", 7L, file));
verify(unloadPhotoFiles, never()).upload(any(), anyLong(), anyLong(), any());
}
@Test
void uploadUnloadPhotoFileRejectsNonImageBeforeCallingFileService() {
var file = new MockMultipartFile("file", "unload.pdf", "application/pdf", new byte[] {1});
assertThrows(ResponseStatusException.class,
() -> service.uploadUnloadPhotoFile("100", "LIP1", 7L, file));
verify(unloadPhotoFiles, never()).upload(any(), anyLong(), anyLong(), any());
}
@Test
void completesWhenMergedAllocationsPreserveExactLineTotals() {
activeTask();
@@ -2,13 +2,12 @@ package com.baiyue.lastleg.inboundplan.pda.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.OverseasContainerUnloadedV1;
import com.baiyue.common.event.RegionCode;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -29,7 +28,6 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InboundUnloadCompletedOutboxServiceTest {
@Mock LastlegEventOutboxMapper outbox;
@Mock LastlegOrderMapper orders;
private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
private InboundUnloadCompletedOutboxService service;
@@ -37,17 +35,15 @@ class InboundUnloadCompletedOutboxServiceTest {
void setUp() {
service = new InboundUnloadCompletedOutboxService();
ReflectionTestUtils.setField(service, "mapper", outbox);
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "objectMapper", objectMapper);
}
@Test
void writesAValidOverseasToCnEnvelopeUsingTheSourceBookingId() throws Exception {
InboundPlan plan = new InboundPlan(); plan.setSourceOrderId(11L); plan.setContainerNo("MSCU1");
void writesAValidOverseasToCnEnvelopeUsingTheContainerNo() throws Exception {
InboundPlan plan = new InboundPlan(); plan.setContainerNo("MSCU1");
InboundPdaTask task = new InboundPdaTask(); task.setId(22L); task.setCustomerId("100");
task.setUnloadPhotoUploadedAt(LocalDateTime.of(2026, 8, 20, 10, 0)); task.setVersion(3);
LastlegOrder order = new LastlegOrder(); order.setSourceContainerBookingId("9001");
when(orders.selectById(11L)).thenReturn(order);
task.setUnloadPhotoUploadedAt(LocalDateTime.of(2026, 8, 20, 10, 0));
task.setCompletedAt(LocalDateTime.of(2026, 8, 20, 10, 5)); task.setVersion(3);
service.publish(plan, task, List.of(101L));
@@ -57,15 +53,10 @@ class InboundUnloadCompletedOutboxServiceTest {
assertEquals(EventTypes.OVERSEAS_CONTAINER_UNLOADED, envelope.eventType());
assertEquals(RegionCode.OVERSEAS, envelope.sourceRegion());
assertEquals(RegionCode.CN, envelope.targetRegion());
assertEquals("9001", envelope.aggregateId());
assertEquals("MSCU1", envelope.aggregateId());
assertEquals("MSCU1", envelope.payload().path("containerNo").asText());
OverseasContainerUnloadedV1 payload = objectMapper.treeToValue(envelope.payload(), OverseasContainerUnloadedV1.class);
assertEquals("2026-08-20T10:05:00Z", payload.unloadingCompletedAt().toString());
}
@Test
void rejectsPlansWhoseSourceOrderLostTheContainerBookingId() {
InboundPlan plan = new InboundPlan(); plan.setSourceOrderId(11L);
InboundPdaTask task = new InboundPdaTask(); task.setId(22L); task.setCustomerId("100");
when(orders.selectById(11L)).thenReturn(new LastlegOrder());
assertThrows(IllegalStateException.class, () -> service.publish(plan, task, List.of(101L)));
}
}
@@ -0,0 +1,57 @@
package com.baiyue.lastleg.service;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class ContainerUnloadOutboxRelayTest {
private final LastlegEventOutboxMapper outbox = mock(LastlegEventOutboxMapper.class);
private final EventBridgePublisher publisher = mock(EventBridgePublisher.class);
private ContainerUnloadOutboxRelay relay;
@BeforeEach
void setUp() {
relay = new ContainerUnloadOutboxRelay();
ReflectionTestUtils.setField(relay, "outbox", outbox);
ReflectionTestUtils.setField(relay, "publisher", publisher);
}
@Test
void publishesClaimedEnvelopeAndMarksItDone() throws Exception {
LastlegEventOutbox row = row(0);
when(outbox.claim(eq(11L), anyString(), any(), any())).thenReturn(1);
when(outbox.claimed(eq(11L), anyString())).thenReturn(row);
relay.relayOne(11L, LocalDateTime.now(), LocalDateTime.now().minusMinutes(2));
verify(publisher).publish("{\"eventId\":\"event-1\"}");
verify(outbox).markPublished(eq(11L), anyString(), any());
verify(outbox, never()).markFailed(anyLong(), anyString(), anyString(), any(), anyString());
}
@Test
void schedulesRetryWhenBridgeIsUnavailable() throws Exception {
LastlegEventOutbox row = row(2);
when(outbox.claim(eq(11L), anyString(), any(), any())).thenReturn(1);
when(outbox.claimed(eq(11L), anyString())).thenReturn(row);
doThrow(new IllegalStateException("bridge down")).when(publisher).publish(anyString());
relay.relayOne(11L, LocalDateTime.now(), LocalDateTime.now().minusMinutes(2));
verify(outbox).markFailed(eq(11L), anyString(), eq("PENDING"), any(), eq("bridge down"));
verify(outbox, never()).markPublished(anyLong(), anyString(), any());
}
private LastlegEventOutbox row(int retries) {
LastlegEventOutbox value = new LastlegEventOutbox();
value.setId(11L); value.setPayloadJson("{\"eventId\":\"event-1\"}"); value.setRetryCount(retries);
return value;
}
}
@@ -4,7 +4,7 @@ import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com
class LoadingCompletedConsumerTest {
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock DropshipOrderService dropshipOrders; @Mock InboundPlanService inboundPlans; 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,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inboundPlans",inboundPlans); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); 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 createsOneOrderAndOneInboundPlanPerInbound(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(inboundPlans,times(2)).convert(eq("100"),anyLong()); 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.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"book-1".equals(o.getSourceContainerBookingId()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); }
@Test void createsOneOrderAndOneInboundPlanPerInbound(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(inboundPlans,times(2)).convert(eq("100"),anyLong()); 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.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); }
@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)); }
@Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); }