feat(fleet): query vehicle delivery and reassignment history

This commit is contained in:
2026-10-03 10:10:55 +08:00
parent d93185e750
commit 30559fd4eb
8 changed files with 420 additions and 0 deletions
@@ -0,0 +1,65 @@
package com.baiyue.lastleg.fleet.trip;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.*;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.security.access.prepost.PreAuthorize;
import org.yaml.snakeyaml.Yaml;
import java.nio.file.*;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class VehicleFulfillmentContractTest {
private static final String VEHICLE="platform:overseas-master-data:vehicle:list", DELIVERY="platform:overseas-warehouse:delivery:list", TRIP="platform:fleet:trip:list";
private void login(String... authorities) {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("test", "unused",
Arrays.stream(authorities).map(SimpleGrantedAuthority::new).toList()));
}
@Test void vehicleViewingDoesNotGrantDeliveryOrTripDataAndEachConsumerNeedsItsOwnReadPermission() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var controller=context.getBean(PlatformVehicleFulfillmentController.class);
var service=context.getBean(VehicleFulfillmentService.class);
var query=new VehicleFulfillmentModels.Query(1,10);
for(String[] granted:List.of(new String[]{VEHICLE},new String[]{DELIVERY,TRIP},new String[]{})) {
login(granted);
assertThatThrownBy(()->controller.deliveries(8,query)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->controller.changes(8,query)).isInstanceOf(AccessDeniedException.class);
}
verifyNoInteractions(service);
login(VEHICLE,DELIVERY); controller.deliveries(8,query);
assertThatThrownBy(()->controller.changes(8,query)).isInstanceOf(AccessDeniedException.class);
login(VEHICLE,TRIP); controller.changes(8,query);
assertThatThrownBy(()->controller.deliveries(8,query)).isInstanceOf(AccessDeniedException.class);
verify(service).deliveries(8,query); verify(service).changes(8,query); verifyNoMoreInteractions(service);
} finally { SecurityContextHolder.clearContext(); }
}
@Test void publicGetOperationsAndResponseFieldsAreDocumentedAndQueriesAreReadOnly() throws Exception {
Map<?,?> document=new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/fleet-vehicle-fulfillment-v1.yaml")));
Map<?,?> paths=(Map<?,?>)document.get("paths");
String base=PlatformVehicleFulfillmentController.class.getAnnotation(RequestMapping.class).value()[0];
for(var method:PlatformVehicleFulfillmentController.class.getDeclaredMethods()) if(method.isAnnotationPresent(GetMapping.class)) {
String path=base+method.getAnnotation(GetMapping.class).value()[0];
Map<?,?> get=(Map<?,?>)((Map<?,?>)paths.get(path)).get("get");
String expression=method.getAnnotation(PreAuthorize.class).value();
for(String permission:List.of(VEHICLE,DELIVERY,TRIP)) if(expression.contains(permission)) assertThat(get.get("description").toString()).contains(permission);
}
Map<?,?> schemas=(Map<?,?>)((Map<?,?>)document.get("components")).get("schemas");
for(var type:List.of(VehicleFulfillmentModels.Delivery.class,VehicleFulfillmentModels.Change.class)) {
Map<?,?> properties=(Map<?,?>)((Map<?,?>)schemas.get(type.getSimpleName())).get("properties");
assertThat(properties.keySet().stream().map(Object::toString).toList()).containsExactlyInAnyOrder(Arrays.stream(type.getRecordComponents()).map(java.lang.reflect.RecordComponent::getName).toArray(String[]::new));
}
assertThat(VehicleFulfillmentService.class.getAnnotation(Transactional.class).readOnly()).isTrue();
}
@Configuration @EnableMethodSecurity static class Config {
@Bean VehicleFulfillmentService service(){return mock(VehicleFulfillmentService.class);}
@Bean PlatformVehicleFulfillmentController controller(VehicleFulfillmentService service){return new PlatformVehicleFulfillmentController(service);}
}
}
@@ -0,0 +1,105 @@
package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
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.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
/** Exercises the production SQL with H2 adapters for MySQL's two JSON scalar functions. */
public class VehicleFulfillmentQueryTest {
private JdbcTemplate jdbc;
private VehicleFulfillmentMapper mapper;
private VehicleFulfillmentService service;
private VehicleRepository vehicles;
private static final ObjectMapper JSON = new ObjectMapper();
public static String jsonExtract(String source, String path) throws Exception {
if (source == null) return null;
var value = JSON.readTree(source);
for (String field : path.substring(2).split("\\.")) value = value.path(field);
return value.isMissingNode() ? null : value.toString();
}
public static String jsonUnquote(String value) throws Exception { return value == null ? null : JSON.readTree(value).asText(); }
@BeforeEach void setup() throws Exception {
var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
jdbc = new JdbcTemplate(dataSource);
jdbc.execute("CREATE ALIAS JSON_EXTRACT FOR 'com.baiyue.lastleg.fleet.trip.VehicleFulfillmentQueryTest.jsonExtract'");
jdbc.execute("CREATE ALIAS JSON_UNQUOTE FOR 'com.baiyue.lastleg.fleet.trip.VehicleFulfillmentQueryTest.jsonUnquote'");
jdbc.execute("CREATE TABLE ll_fleet_trip(id BIGINT PRIMARY KEY,trip_no VARCHAR(64),vehicle_id BIGINT,status VARCHAR(32))");
jdbc.execute("CREATE TABLE ll_fleet_trip_dispatch(dispatch_id BIGINT PRIMARY KEY,trip_id BIGINT,customer_id VARCHAR(32))");
jdbc.execute("CREATE TABLE ll_fleet_trip_history(id BIGINT PRIMARY KEY,trip_id BIGINT,record_version BIGINT,occurred_at TIMESTAMP,action VARCHAR(64),reason VARCHAR(255),snapshot_json CLOB,UNIQUE(trip_id,record_version))");
jdbc.execute("CREATE TABLE ll_delivery_order(id BIGINT PRIMARY KEY,delivery_no VARCHAR(64),dispatch_no VARCHAR(64),dispatch_order_id BIGINT,customer_id VARCHAR(32),customer_name VARCHAR(100),bol_no VARCHAR(64),destination_type VARCHAR(32),destination_warehouse_code VARCHAR(64),destination_warehouse_name VARCHAR(100),delivery_address VARCHAR(255),planned_delivery_at TIMESTAMP,status VARCHAR(32),created_at TIMESTAMP)");
var configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true); configuration.addMapper(VehicleFulfillmentMapper.class);
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(dataSource); factory.setConfiguration(configuration);
mapper = new SqlSessionTemplate(Objects.requireNonNull(factory.getObject())).getMapper(VehicleFulfillmentMapper.class);
vehicles = mock(VehicleRepository.class);
when(vehicles.findById(anyLong())).thenReturn(Optional.of(mock(VehicleRepository.VehicleSnapshot.class)));
service = new VehicleFulfillmentService(vehicles, mapper, JSON);
jdbc.update("INSERT INTO ll_fleet_trip VALUES(1,'TR-1',8,'PLANNED')");
jdbc.update("INSERT INTO ll_fleet_trip_dispatch VALUES(11,1,'CUSTOMER-A'),(12,1,'CUSTOMER-B')");
delivery(21,11,"CUSTOMER-A"); delivery(22,12,"CUSTOMER-B");
}
private void delivery(long deliveryId, long dispatchId, String customer) {
jdbc.update("INSERT INTO ll_delivery_order(id,delivery_no,dispatch_no,dispatch_order_id,customer_id,customer_name,destination_type,destination_warehouse_code,status,created_at) VALUES(?,?,?,?,?,?,'FBA','ONT8','WAIT_DELIVERY',TIMESTAMP '2026-10-03 00:00:00')",
deliveryId,"DL-"+deliveryId,"DP-"+dispatchId,dispatchId,customer,customer);
}
private void history(long version, String action, String vehicle, String driver) throws Exception {
var trip = new HashMap<String,Object>();
if (vehicle != null) { trip.put("vehicleId",vehicle); trip.put("vehiclePlate","PLATE-"+vehicle); }
if (driver != null) { trip.put("driverId",driver); trip.put("driverName","司机"+driver); }
String snapshot = JSON.writeValueAsString(Map.of("trip",trip,"dispatches",List.of(Map.of("dispatchNo","DP-11"),Map.of("dispatchNo","DP-12"))));
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(?,1,?,TIMESTAMP '2026-10-03 00:00:00',?,'更换车辆',?)",version+1,version,action,snapshot);
}
@Test void currentPlanUsesStableVehicleIdentityAndCustomerScopedDispatchJoin() {
delivery(23,11,"WRONG-CUSTOMER");
var result = service.deliveries(8,new VehicleFulfillmentModels.Query(1,10));
assertThat(result.total()).isEqualTo(2);
assertThat(result.records()).extracting(VehicleFulfillmentModels.Delivery::customerName).containsExactly("CUSTOMER-B","CUSTOMER-A");
assertThat(result.records()).allMatch(row -> row.relationship().equals("PLANNED"));
assertThat(service.deliveries(7,new VehicleFulfillmentModels.Query(1,10)).records()).isEmpty();
}
@Test void departedVehicleIsReadFromImmutableSnapshotAfterCurrentAssignmentChanges() throws Exception {
history(0,"DEPART","8","18"); history(1,"COMPLETE","8","18");
jdbc.update("UPDATE ll_fleet_trip SET vehicle_id=9,status='COMPLETED'");
assertThat(service.deliveries(8,new VehicleFulfillmentModels.Query(1,1)).records()).singleElement().satisfies(row -> {
assertThat(row.deliveryNo()).isEqualTo("DL-22"); assertThat(row.relationship()).isEqualTo("DEPARTED");
});
assertThat(service.deliveries(8,new VehicleFulfillmentModels.Query(2,1)).records()).extracting(VehicleFulfillmentModels.Delivery::deliveryNo).containsExactly("DL-21");
assertThat(service.deliveries(9,new VehicleFulfillmentModels.Query(1,10)).total()).isZero();
assertThat(service.deliveries(18,new VehicleFulfillmentModels.Query(1,10)).total()).isZero();
}
@Test void cancelledUnexecutedTripIsNotPresentedAsActualTransport() {
jdbc.update("UPDATE ll_fleet_trip SET status='CANCELLED'");
assertThat(service.deliveries(8,new VehicleFulfillmentModels.Query(null,null)).total()).isZero();
}
@Test void initialAssignmentIsNotAReplacementAndBothOldAndNewVehiclesRetainReplacement() throws Exception {
history(0,"CREATE",null,null); history(1,"ASSIGN","7","17"); history(2,"ASSIGN","8","18");
var out = service.changes(7,new VehicleFulfillmentModels.Query(1,10));
var in = service.changes(8,new VehicleFulfillmentModels.Query(1,10));
assertThat(out.total()).isEqualTo(1); assertThat(in.total()).isEqualTo(1);
assertThat(out.records().get(0).relationship()).isEqualTo("REPLACED_OUT");
assertThat(in.records().get(0).relationship()).isEqualTo("REPLACED_IN");
assertThat(out.records().get(0).previousVehiclePlate()).isEqualTo("PLATE-7");
assertThat(out.records().get(0).vehiclePlate()).isEqualTo("PLATE-8");
assertThat(out.records().get(0).dispatchNos()).containsExactly("DP-11","DP-12");
assertThat(out.records().get(0).occurredAt().toString()).isEqualTo("2026-10-03T00:00:00Z");
assertThat(service.changes(77,new VehicleFulfillmentModels.Query(1,10)).total()).isZero();
}
@Test void driverOnlyReplacementIsVisibleForSameVehicleAndNoOpIsExcluded() throws Exception {
history(0,"ASSIGN","8","17"); history(1,"ASSIGN","8","18"); history(2,"ASSIGN","8","18");
var result=service.changes(8,new VehicleFulfillmentModels.Query(1,10));
assertThat(result.total()).isEqualTo(1); assertThat(result.records().get(0).relationship()).isEqualTo("DRIVER_CHANGED");
}
@Test void nonexistentVehicleCannotBeUsedToQueryOrLeakHistory() {
when(vehicles.findById(404L)).thenReturn(Optional.empty());
assertThatThrownBy(()->service.deliveries(404,new VehicleFulfillmentModels.Query(1,10))).hasMessageContaining("车辆不存在");
assertThatThrownBy(()->service.changes(404,new VehicleFulfillmentModels.Query(1,10))).hasMessageContaining("车辆不存在");
}
}