feat(fleet): query vehicle delivery and reassignment history
This commit is contained in:
+65
@@ -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);}
|
||||
}
|
||||
}
|
||||
+105
@@ -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("车辆不存在");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user