This commit is contained in:
2026-08-25 14:46:06 +08:00
parent 5abc7543ae
commit f325d57fc1
106 changed files with 3682 additions and 450 deletions
@@ -0,0 +1,84 @@
package com.baiyue.lastleg.config;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.bind.Bindable;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.context.properties.source.MapConfigurationPropertySource;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClient;
import java.net.InetSocketAddress;
import java.time.Duration;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class LastlegRestClientFactoryTest {
private final LastlegRestClientFactory factory = new LastlegRestClientFactory();
@Test
void defaultsArePositiveAndSeparateFileUploadsFromWarehouseCalls() {
LastlegRestClientProperties properties = new LastlegRestClientProperties();
assertThat(properties.getUnloadPhotoFile().getConnectTimeout()).isEqualTo(Duration.ofSeconds(3));
assertThat(properties.getUnloadPhotoFile().getReadTimeout()).isEqualTo(Duration.ofSeconds(30));
assertThat(properties.getRelabelPhotoFile().getReadTimeout()).isEqualTo(Duration.ofSeconds(30));
assertThat(properties.getWarehouseResource().getReadTimeout()).isEqualTo(Duration.ofSeconds(5));
assertThat(properties.getPalletPhysical().getReadTimeout()).isEqualTo(Duration.ofSeconds(5));
}
@Test
void rejectsUnboundedOrNonPositiveTimeouts() {
assertThatThrownBy(() -> new LastlegRestClientProperties.ClientTimeouts(null, Duration.ofSeconds(1)))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new LastlegRestClientProperties.ClientTimeouts(Duration.ZERO, Duration.ofSeconds(1)))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new LastlegRestClientProperties.ClientTimeouts(Duration.ofSeconds(1), Duration.ZERO))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void bindsPerClientTimeoutOverridesFromConfiguration() {
var source = new MapConfigurationPropertySource(Map.of(
"baiyue.http.clients.unload-photo-file.connect-timeout", "125ms",
"baiyue.http.clients.unload-photo-file.read-timeout", "9s",
"baiyue.http.clients.pallet-physical.connect-timeout", "250ms",
"baiyue.http.clients.pallet-physical.read-timeout", "2s"));
LastlegRestClientProperties properties = new Binder(source)
.bind("baiyue.http.clients", Bindable.of(LastlegRestClientProperties.class))
.orElseThrow(() -> new AssertionError("Lastleg HTTP client properties were not bound"));
assertThat(properties.getUnloadPhotoFile().getConnectTimeout()).isEqualTo(Duration.ofMillis(125));
assertThat(properties.getUnloadPhotoFile().getReadTimeout()).isEqualTo(Duration.ofSeconds(9));
assertThat(properties.getPalletPhysical().getConnectTimeout()).isEqualTo(Duration.ofMillis(250));
assertThat(properties.getPalletPhysical().getReadTimeout()).isEqualTo(Duration.ofSeconds(2));
assertThat(properties.getWarehouseResource().getReadTimeout()).isEqualTo(Duration.ofSeconds(5));
}
@Test
void appliesConfiguredConnectAndReadTimeoutsWithoutRetryOrFallback() throws Exception {
var timeouts = new LastlegRestClientProperties.ClientTimeouts(
Duration.ofMillis(250), Duration.ofMillis(75));
assertThat(factory.httpClient(timeouts).connectTimeout()).contains(Duration.ofMillis(250));
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/slow", exchange -> {
try { Thread.sleep(500); }
catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
exchange.sendResponseHeaders(200, 0);
exchange.close();
});
server.start();
try {
RestClient client = factory.build(RestClient.builder(),
"http://127.0.0.1:" + server.getAddress().getPort(), timeouts);
assertThatThrownBy(() -> client.get().uri("/slow").retrieve().toBodilessEntity())
.isInstanceOf(ResourceAccessException.class);
} finally {
server.stop(0);
}
}
}
@@ -3,6 +3,8 @@ package com.baiyue.lastleg.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventTypes;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.dto.LastlegOrderSummary;
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
@@ -21,6 +23,8 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.when;
class LastlegOrderControllerTest {
private final LastlegOrderService service = mock(LastlegOrderService.class);
@@ -46,6 +50,17 @@ class LastlegOrderControllerTest {
verify(service).page(eq("200"), eq(query));
}
@Test
void iamSecurityEventsAreRejectedBeforeLoadingCompletedConsumer() {
EventEnvelope event = mock(EventEnvelope.class);
when(event.eventType()).thenReturn(EventTypes.IAM_USER_STATUS_CHANGED);
LoadingCompletedConsumer loading = (LoadingCompletedConsumer) ReflectionTestUtils.getField(controller, "consumer");
assertThrows(ResponseStatusException.class, () -> controller.receive("internal-token", event));
verify(loading, never()).consume(event);
}
private static AuthenticatedUser user(long customerId, String appCode) {
return new AuthenticatedUser(1L, customerId, customerId, 1L, "tester", appCode, "WEB", Set.of());
}
@@ -1,4 +1,4 @@
package com.baiyue.lastleg.masterdata.vehicle.controller;
package com.baiyue.lastleg.masterdata.vehicle.adapter.in.web;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -0,0 +1,64 @@
package com.baiyue.lastleg.masterdata.vehicle.application;
import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService.FailureKind;
import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService.VehicleFailure;
import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.VehicleSnapshot;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class VehicleApplicationServiceTest {
private VehicleRepository repository;
private VehicleApplicationService service;
@BeforeEach
void setUp() {
repository = mock(VehicleRepository.class);
service = new VehicleApplicationService(repository);
}
@Test
void rejectsStaleVersionBeforeUpdating() {
when(repository.lockById(1L)).thenReturn(Optional.of(vehicle(3L, 1000L)));
VehicleModels.Update request = update(2L, 1000L);
assertThatThrownBy(() -> service.update(1L, "9", request))
.isInstanceOfSatisfying(VehicleFailure.class,
failure -> assertThat(failure.kind()).isEqualTo(FailureKind.CONFLICT));
}
@Test
void rejectsDecreasingOdometer() {
when(repository.lockById(1L)).thenReturn(Optional.of(vehicle(3L, 1000L)));
VehicleModels.Update request = update(3L, 999L);
assertThatThrownBy(() -> service.update(1L, "9", request))
.isInstanceOfSatisfying(VehicleFailure.class,
failure -> assertThat(failure.kind()).isEqualTo(FailureKind.BAD_REQUEST));
}
@Test
void rejectsUnknownStatus() {
when(repository.lockById(1L)).thenReturn(Optional.of(vehicle(3L, 1000L)));
assertThatThrownBy(() -> service.changeStatus(1L, "9", new VehicleModels.ChangeStatus("BUSY", 3L)))
.isInstanceOfSatisfying(VehicleFailure.class,
failure -> assertThat(failure.kind()).isEqualTo(FailureKind.BAD_REQUEST));
}
private static VehicleModels.Update update(long version, long mileage) {
return new VehicleModels.Update(version, "ABC-001", "Box Truck", new BigDecimal("12.50"), mileage,
"Driver", "123", "OVERSEAS", "OWNED", null);
}
private static VehicleSnapshot vehicle(long version, long mileage) {
return new VehicleSnapshot(1L, "ABC-001", "Box Truck", new BigDecimal("12.50"), mileage,
"Driver", "123", "OVERSEAS", "OWNED", "IN_SERVICE", null, version, "9", null);
}
}
@@ -1,63 +0,0 @@
package com.baiyue.lastleg.masterdata.vehicle.service;
import com.baiyue.lastleg.masterdata.vehicle.dto.VehicleDtos;
import com.baiyue.lastleg.masterdata.vehicle.entity.Vehicle;
import com.baiyue.lastleg.masterdata.vehicle.mapper.VehicleMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class VehicleServiceTest {
private VehicleMapper mapper;
private VehicleService service;
@BeforeEach
void setUp() {
mapper = mock(VehicleMapper.class);
service = new VehicleService();
ReflectionTestUtils.setField(service, "vehicles", mapper);
}
@Test
void rejectsStaleVersionBeforeUpdating() {
when(mapper.lockById(1L)).thenReturn(vehicle(3L, 1000L));
VehicleDtos.Update request = update(2L, 1000L);
assertThatThrownBy(() -> service.update(1L, "9", request))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("409 CONFLICT");
}
@Test
void rejectsDecreasingOdometer() {
when(mapper.lockById(1L)).thenReturn(vehicle(3L, 1000L));
VehicleDtos.Update request = update(3L, 999L);
assertThatThrownBy(() -> service.update(1L, "9", request))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("400 BAD_REQUEST");
}
@Test
void rejectsUnknownStatus() {
when(mapper.lockById(1L)).thenReturn(vehicle(3L, 1000L));
assertThatThrownBy(() -> service.changeStatus(1L, "9", new VehicleDtos.ChangeStatus("BUSY", 3L)))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("400 BAD_REQUEST");
}
private static VehicleDtos.Update update(long version, long mileage) {
return new VehicleDtos.Update(version, "ABC-001", "Box Truck", new BigDecimal("12.50"), mileage,
"Driver", "123", "OVERSEAS", "OWNED", null);
}
private static Vehicle vehicle(long version, long mileage) {
Vehicle value = new Vehicle(); value.setId(1L); value.setRecordVersion(version); value.setMileageKm(mileage);
value.setStatus("IN_SERVICE"); return value;
}
}
@@ -0,0 +1,38 @@
package com.baiyue.lastleg.repository;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class UnloadCompletedOutboxRepositoryTest {
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void claimRechecksRetryWindowAfterCandidateSelection() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"),
LastlegEventOutbox.class);
LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class);
UnloadCompletedOutboxRepository repository = new UnloadCompletedOutboxRepository();
ReflectionTestUtils.setField(repository, "mapper", mapper);
ArgumentCaptor<LambdaUpdateWrapper> wrapper = ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
when(mapper.update(isNull(LastlegEventOutbox.class), wrapper.capture())).thenReturn(1);
LocalDateTime now = LocalDateTime.of(2026, 8, 25, 12, 0);
assertThat(repository.claim(1L, "worker", now, now.minusMinutes(1))).isTrue();
assertThat(wrapper.getValue().getSqlSegment())
.contains("next_retry_at", "<=", "status", "locked_at");
}
}
@@ -8,6 +8,8 @@ class LoadingCompletedConsumerTest {
@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)); }
@Test void tamperedPayloadHashIsRejectedBeforeInboxMutation(){ EventEnvelope source=event(); EventEnvelope tampered=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(),source.payload(),"sha256:"+"0".repeat(64)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(tampered)); assertTrue(error.getMessage().contains("payloadHash")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); }
@Test void concurrentDuplicateKeyWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox raced=new LastlegEventInbox(); raced.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(null,raced); doThrow(new org.springframework.dao.DuplicateKeyException("race")).when(inbox).insert(any(LastlegEventInbox.class)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(event)); assertTrue(error.getMessage().contains("摘要冲突")); verify(orders,never()).countContainer(anyString(),anyString()); }
@Test void inboundSnapshotUsesCustomerIdAndHasNoPhantomTenantColumn() throws Exception {
assertThrows(NoSuchFieldException.class,()->LastlegInboundOrder.class.getDeclaredField("tenantId"));
String schema=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/db/lastleg.sql"));
@@ -96,6 +96,21 @@ class ContainerShippingNodeConsumerTest {
.hasMessageContaining("摘要冲突");
}
@Test
void tamperedPayloadHashIsRejectedBeforeInboxMutation() {
EventEnvelope source = event("123e4567-e89b-12d3-a456-426614174105", "PICKUP_READY", 80, 5,
Instant.parse("2026-08-21T10:00:00Z"));
EventEnvelope tampered = 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(), source.payload(),
"sha256:" + "0".repeat(64));
assertThatThrownBy(() -> consumer.consume(tampered)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("payloadHash");
verify(inboxes, never()).insert(any(LastlegEventInbox.class));
}
private ContainerShippingGate gate(String status, int sequence, long version, Instant actualAt) {
ContainerShippingGate gate = new ContainerShippingGate(); gate.setContainerBookingId("CB-1");
gate.setCustomerId("100"); gate.setOceanBookingId("OB-1"); gate.setContainerNo("CONT-1");