feat(fleet): enforce reviewed trips and original fee coverage for dispatch

This commit is contained in:
2026-10-03 04:18:52 +08:00
parent c50e9f97d1
commit 5e683da3b9
162 changed files with 9642 additions and 218 deletions
@@ -0,0 +1,71 @@
package com.baiyue.lastleg.fleet.driver;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.yaml.snakeyaml.Yaml;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
class DriverApiContractTest {
@Test void everyControllerOperationHasOpenApiAndRegisteredPermission() throws Exception {
String yaml = Files.readString(Path.of("src/main/resources/openapi/fleet-drivers-v1.yaml"));
Map<String, Object> document = new Yaml().load(yaml);
Map<?, ?> paths = (Map<?, ?>) document.get("paths");
String seeds = Files.readString(Path.of("../iam/src/main/resources/db/lastleg-permissions.sql"));
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
String base = PlatformDriverController.class.getAnnotation(RequestMapping.class).value()[0];
int operationCount = 0;
for (var method : PlatformDriverController.class.getDeclaredMethods()) {
String verb = null;
String[] relative = null;
if (method.isAnnotationPresent(GetMapping.class)) { verb = "get"; relative = method.getAnnotation(GetMapping.class).value(); }
if (method.isAnnotationPresent(PostMapping.class)) { verb = "post"; relative = method.getAnnotation(PostMapping.class).value(); }
if (method.isAnnotationPresent(PutMapping.class)) { verb = "put"; relative = method.getAnnotation(PutMapping.class).value(); }
if (method.isAnnotationPresent(PatchMapping.class)) { verb = "patch"; relative = method.getAnnotation(PatchMapping.class).value(); }
if (verb == null) continue;
operationCount++;
String path = base + (relative.length == 0 ? "" : relative[0]);
assertThat(paths.containsKey(path)).as(path).isTrue();
Map<?, ?> operation = (Map<?, ?>) ((Map<?, ?>) paths.get(path)).get(verb);
assertThat(operation).as(verb + " " + path).isNotNull();
String expression = method.getAnnotation(PreAuthorize.class).value();
String permission = expression.substring(expression.indexOf('\'') + 1, expression.lastIndexOf('\''));
assertThat(operation.get("description").toString()).contains(permission);
assertThat(seeds).contains("'" + permission + "'");
assertThat(unified).contains("'" + permission + "'");
}
assertThat(operationCount).isEqualTo(12);
verifyRefs(document, document);
}
@Test void driverTablesMatchDeploymentBaselineAndKeepUniqueHistoryVersions() throws Exception {
String module = Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
String deployed = Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"));
for (String table : new String[]{"ll_fleet_driver", "ll_fleet_driver_history"}) {
var pattern = Pattern.compile("(?s)CREATE TABLE " + table + " \\(.*?;");
var a = pattern.matcher(module); var b = pattern.matcher(deployed);
assertThat(a.find()).isTrue(); assertThat(b.find()).isTrue();
assertThat(a.group().replace("\r\n", "\n")).isEqualTo(b.group().replace("\r\n", "\n"));
}
assertThat(deployed).contains("UNIQUE KEY uk_fleet_driver_history_version (driver_id,record_version)")
.contains("FOREIGN KEY (driver_id) REFERENCES ll_fleet_driver(id)");
}
private static void verifyRefs(Object node, Map<String, Object> document) {
if (node instanceof Map<?, ?> map) {
if (map.get("$ref") instanceof String ref) {
assertThat(ref).startsWith("#/");
Object value = document;
for (String key : ref.substring(2).split("/")) value = ((Map<?, ?>) value).get(key);
assertThat(value).as(ref).isNotNull();
}
map.values().forEach(value -> verifyRefs(value, document));
} else if (node instanceof Iterable<?> values) values.forEach(value -> verifyRefs(value, document));
}
}
@@ -0,0 +1,61 @@
package com.baiyue.lastleg.fleet.driver;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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 java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class DriverAuthorizationTest {
@AfterEach void clearIdentity() { SecurityContextHolder.clearContext(); }
@Test void listPermissionReadsDetailsButCannotModifyOrApprove() {
try (var context = new AnnotationConfigApplicationContext(Config.class)) {
var controller = context.getBean(PlatformDriverController.class);
var service = context.getBean(DriverService.class);
authenticate("platform:overseas-master-data:driver:list");
controller.detail(71, null);
verify(service).detail(71, null);
assertThatThrownBy(() -> controller.create(null, null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.update(null, 71, null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.review(null, 71, null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.status(null, 71, null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.submit(null, 71, null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.upload(null, 71, 0, null)).isInstanceOf(AccessDeniedException.class);
verifyNoMoreInteractions(service);
}
}
@Test void unrelatedPermissionCannotReadSensitiveDriverDetailsOrAttachments() {
try (var context = new AnnotationConfigApplicationContext(Config.class)) {
var controller = context.getBean(PlatformDriverController.class);
authenticate("platform:overseas-master-data:vehicle:list");
assertThatThrownBy(() -> controller.detail(71, null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.history(71, 1, 20)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.download(71, 81)).isInstanceOf(AccessDeniedException.class);
verifyNoInteractions(context.getBean(DriverService.class));
}
}
private static void authenticate(String permission) {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("operator", "unused",
List.of(new SimpleGrantedAuthority(permission))));
}
@Configuration
@EnableMethodSecurity
static class Config {
@Bean DriverService service() { return mock(DriverService.class); }
@Bean PlatformDriverController controller(DriverService service) { return new PlatformDriverController(service); }
}
}
@@ -0,0 +1,130 @@
package com.baiyue.lastleg.fleet.driver;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.UUID;
import java.util.concurrent.*;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.mock;
class DriverPersistenceTest {
private JdbcTemplate jdbc;
private DriverService service;
@BeforeEach void createDatabaseAndTransactionalService() throws Exception {
var ds = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
jdbc = new JdbcTemplate(ds);
String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for (String table : new String[]{"ll_fleet_driver", "ll_fleet_driver_history"}) {
var match = Pattern.compile("(?s)CREATE TABLE " + table + " \\(.*?;", Pattern.MULTILINE).matcher(schema);
assertThat(match.find()).isTrue();
// H2 JSON encodes bound strings differently; production schema is checked separately against MySQL baseline.
jdbc.execute(match.group().replace("snapshot_json JSON", "snapshot_json LONGTEXT"));
}
var configuration = new MybatisConfiguration();
var pagination = new com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor();
pagination.addInnerInterceptor(new com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor(com.baomidou.mybatisplus.annotation.DbType.H2));
configuration.addInterceptor(pagination);
configuration.addMapper(DriverMapper.class);
configuration.addMapper(DriverHistoryMapper.class);
var factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(ds);
factory.setConfiguration(configuration);
var session = new SqlSessionTemplate(factory.getObject());
var target = new DriverService(session.getMapper(DriverMapper.class), session.getMapper(DriverHistoryMapper.class),
mock(DriverFileGateway.class), new ObjectMapper().registerModule(new JavaTimeModule()),
Clock.fixed(Instant.parse("2026-10-02T12:00:00Z"), ZoneOffset.UTC), mock(FleetWarehouseDirectory.class));
var proxy = new ProxyFactory(target);
proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource()));
service = (DriverService) proxy.getProxy();
}
@Test void historyFailureRollsBackDriverStatusAndVersion() {
var driver = service.create("operator", draft("初始备注"));
jdbc.execute("ALTER TABLE ll_fleet_driver_history ADD CONSTRAINT reject_deactivate CHECK(action <> 'DEACTIVATE')");
assertThatThrownBy(() -> service.changeStatus(Long.parseLong(driver.id()), "e", new DriverModels.ChangeStatus(0L, false, "暂停")))
.isInstanceOf(RuntimeException.class);
var stored = service.detail(Long.parseLong(driver.id()), null);
assertThat(stored.active()).isTrue();
assertThat(stored.recordVersion()).isZero();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_driver_history", Integer.class)).isEqualTo(1);
}
@Test void clearingOptionalFieldsPersistsAndHistoryRemainsImmutable() {
var driver = service.create("operator", draft("原始备注"));
long id = Long.parseLong(driver.id());
service.update(id, "e", new DriverModels.Update(0L, draft(null)));
assertThat(service.detail(id, null).details().remark()).isNull();
assertThat(service.history(id, 1, 20).records()).hasSize(2);
assertThat(service.history(id, 1, 20).records().get(1).snapshot().details().remark()).isEqualTo("原始备注");
}
@Test void concurrentSameVersionWritesProduceOneWinnerAndOneConflict() throws Exception {
var driver = service.create("operator", draft(null));
long id = Long.parseLong(driver.id());
var executor = Executors.newFixedThreadPool(2);
var start = new CountDownLatch(1);
Callable<Boolean> operation = () -> {
start.await(5, TimeUnit.SECONDS);
try { service.changeStatus(id, "e", new DriverModels.ChangeStatus(0L, false, "暂停")); return true; }
catch (org.springframework.web.server.ResponseStatusException e) {
assertThat(e.getStatusCode().value()).isEqualTo(409);
return false;
}
};
try {
var first = executor.submit(operation);
var second = executor.submit(operation);
start.countDown();
assertThat(java.util.List.of(first.get(10, TimeUnit.SECONDS), second.get(10, TimeUnit.SECONDS))).containsExactlyInAnyOrder(true, false);
assertThat(service.detail(id, null).recordVersion()).isEqualTo(1);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_driver_history", Integer.class)).isEqualTo(2);
} finally { executor.shutdownNow(); }
}
@Test void summaryAndExpiryFilteringUseLicenseZoneAcrossPages() {
var eastern = new DriverModels.Draft("东侧日期", "123", "OWNED", "OVERSEAS", null, null, null,
java.time.LocalDate.of(2026, 10, 2), "Pacific/Kiritimati", null, null);
var western = new DriverModels.Draft("西侧日期", "123", "DISPATCHED", "OVERSEAS", null, null, null,
java.time.LocalDate.of(2026, 10, 2), "America/Los_Angeles", null, null);
service.create("operator", eastern);
service.create("operator", western);
service.create("operator", draft(null));
var all = service.page(new DriverModels.Query(1, 1, null, null, null, null, null, null, null));
assertThat(all.total()).isEqualTo(3);
assertThat(all.records()).hasSize(1);
assertThat(all.summary()).isEqualTo(new DriverModels.Summary(3, 2, 1, 1, 1));
var expired = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "EXPIRED", null));
assertThat(expired.records()).extracting(row -> row.details().name()).containsExactly("东侧日期");
assertThat(expired.summary().expired()).isEqualTo(1);
var expiring = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "EXPIRING", null));
assertThat(expiring.records()).extracting(DriverModels.Response::licenseState).containsExactly("EXPIRING");
var missing = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "MISSING", null));
assertThat(missing.total()).isEqualTo(1);
assertThat(missing.summary().expiringSoon()).isZero();
}
private static DriverModels.Draft draft(String remark) {
return new DriverModels.Draft("测试司机", "123", "OWNED", "OVERSEAS", null, null, null, null, "UTC", remark, null);
}
}
@@ -0,0 +1,196 @@
package com.baiyue.lastleg.fleet.driver;
import com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy.Reason;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.time.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class DriverServiceTest {
private final DriverMapper drivers = mock(DriverMapper.class);
private final DriverHistoryMapper history = mock(DriverHistoryMapper.class);
private final DriverFileGateway files = mock(DriverFileGateway.class);
private final Clock clock = Clock.fixed(Instant.parse("2026-10-02T12:00:00Z"), ZoneOffset.UTC);
private final ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule());
private final FleetWarehouseDirectory warehouses = mock(FleetWarehouseDirectory.class);
private final DriverService service = new DriverService(drivers, history, files, json, clock, warehouses);
@BeforeEach void writesSucceed() {
when(drivers.insert(any(DriverEntity.class))).thenReturn(1);
when(drivers.update(any(DriverEntity.class), any())).thenReturn(1);
when(history.insert(any(DriverHistoryEntity.class))).thenReturn(1);
}
@Test void draftCanBeSavedWithoutLicenseButCannotDispatchOrBeApproved() throws Exception {
var response = service.create("operator", draft(null));
assertThat(response.driverNo()).startsWith("DR-").isNotEqualTo(response.id());
assertThat(response.qualification().reason()).isEqualTo(Reason.MISSING_LICENSE);
assertThat(response.reviewStatus()).isEqualTo("PENDING");
var captured = ArgumentCaptor.forClass(DriverEntity.class);
verify(drivers).insert(captured.capture());
when(drivers.lockById(captured.getValue().getId())).thenReturn(captured.getValue());
assertThatThrownBy(() -> service.review(captured.getValue().getId(), "reviewer", new DriverModels.Review(0L, "APPROVED", null)))
.hasMessageContaining("补齐驾驶证");
var event = ArgumentCaptor.forClass(DriverHistoryEntity.class);
verify(history).insert(event.capture());
assertThat(json.readValue(event.getValue().getSnapshotJson(), DriverModels.Response.class).driverNo()).isEqualTo(response.driverNo());
verifyNoInteractions(files);
}
@Test void approvedDriverMayDepartWithReminderOnlyWhenTaskEndsBeforeExpiry() {
var row = row("APPROVED");
when(drivers.lockById(71)).thenReturn(row);
var response = service.requireDispatchable(71, Instant.parse("2026-10-20T12:00:00Z"));
assertThat(response.qualification().canDispatch()).isTrue();
assertThat(response.qualification().expiringSoon()).isTrue();
verify(files).metadata(71, 81);
assertThatThrownBy(() -> service.requireDispatchable(71, Instant.parse("2026-10-23T00:00:00Z")))
.hasMessageContaining("预计任务结束前到期");
assertThatThrownBy(() -> service.requireDispatchable(71, null)).hasMessageContaining("预计任务结束时间");
}
@Test void approvalVerifiesAttachmentAndPersistsReviewerAndSnapshot() throws Exception {
when(drivers.lockById(71)).thenReturn(row("PENDING"));
var response = service.review(71, "reviewer", new DriverModels.Review(2L, "APPROVED", "已核对"));
assertThat(response.reviewStatus()).isEqualTo("APPROVED");
assertThat(response.recordVersion()).isEqualTo(3);
verify(files).metadata(71, 81);
var event = ArgumentCaptor.forClass(DriverHistoryEntity.class);
verify(history).insert(event.capture());
assertThat(event.getValue().getOperatorId()).isEqualTo("reviewer");
assertThat(event.getValue().getReason()).isEqualTo("已核对");
assertThat(json.readValue(event.getValue().getSnapshotJson(), DriverModels.Response.class).reviewStatus()).isEqualTo("APPROVED");
}
@Test void failedFileVerificationCannotWriteApproval() {
when(drivers.lockById(71)).thenReturn(row("PENDING"));
when(files.metadata(71, 81)).thenThrow(new ResponseStatusException(HttpStatus.BAD_REQUEST, "错误文件归属"));
assertThatThrownBy(() -> service.review(71, "r", new DriverModels.Review(2L, "APPROVED", null))).hasMessageContaining("错误文件归属");
verify(drivers, never()).update(any(DriverEntity.class), any());
verify(history, never()).insert(any(DriverHistoryEntity.class));
}
@Test void rejectsExpiredAndNotYetIssuedLicenseApproval() {
var row = row("PENDING");
row.setLicenseExpiresOn(LocalDate.of(2026, 10, 1));
when(drivers.lockById(71)).thenReturn(row);
assertThatThrownBy(() -> service.review(71, "r", new DriverModels.Review(2L, "APPROVED", null))).hasMessageContaining("已过期");
row.setLicenseExpiresOn(LocalDate.of(2027, 10, 1));
row.setLicenseIssuedOn(LocalDate.of(2026, 10, 3));
assertThatThrownBy(() -> service.review(71, "r", new DriverModels.Review(2L, "APPROVED", null))).hasMessageContaining("尚未生效");
verifyNoInteractions(files);
}
@Test void rejectRequiresReasonAndCanBeExplicitlyResubmitted() {
var row = row("PENDING");
when(drivers.lockById(71)).thenReturn(row);
assertThatThrownBy(() -> service.review(71, "r", new DriverModels.Review(2L, "REJECTED", " "))).hasMessageContaining("打回原因");
assertThat(service.review(71, "r", new DriverModels.Review(2L, "REJECTED", "附件不清楚")).reviewStatus()).isEqualTo("REJECTED");
assertThat(service.submit(71, "editor", new DriverModels.Submit(3L)).reviewStatus()).isEqualTo("PENDING");
verify(files).metadata(71, 81);
}
@Test void credentialEditResetsApprovalButPhoneCorrectionDoesNot() {
var row = row("APPROVED");
when(drivers.lockById(71)).thenReturn(row);
var phoneOnly = new DriverModels.Draft("司机", "new-phone", "OWNED", "OVERSEAS", "LIC-1", "C",
null, row.getLicenseExpiresOn(), "UTC", null, null);
assertThat(service.update(71, "e", new DriverModels.Update(2L, phoneOnly)).reviewStatus()).isEqualTo("APPROVED");
var changedExpiry = new DriverModels.Draft("司机", "new-phone", "OWNED", "OVERSEAS", "LIC-1", "C",
null, LocalDate.of(2027, 10, 22), "UTC", null, null);
assertThat(service.update(71, "e", new DriverModels.Update(3L, changedExpiry)).reviewStatus()).isEqualTo("PENDING");
}
@Test void replacingLicenseBindsNewFileAndResetsApproval() {
when(drivers.lockById(71)).thenReturn(row("APPROVED"));
var file = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-test".getBytes());
when(files.upload(71, "e", file)).thenReturn(new DriverModels.File("82", "license.pdf", "application/pdf", 9));
var response = service.uploadLicense(71, 2, "e", file);
assertThat(response.licenseFileId()).isEqualTo("82");
assertThat(response.reviewStatus()).isEqualTo("PENDING");
var event = ArgumentCaptor.forClass(DriverHistoryEntity.class);
verify(history).insert(event.capture());
assertThat(event.getValue().getLicenseFileId()).isEqualTo(82);
}
@Test void staleVersionFailsBeforeUploadOrReviewAndCasFailureHasNoHistory() {
when(drivers.lockById(71)).thenReturn(row("PENDING"));
assertThatThrownBy(() -> service.uploadLicense(71, 1, "e", null)).hasMessageContaining("已被修改");
assertThatThrownBy(() -> service.review(71, "r", new DriverModels.Review(1L, "APPROVED", null))).hasMessageContaining("已被修改");
verifyNoInteractions(files);
when(drivers.update(any(DriverEntity.class), any())).thenReturn(0);
assertThatThrownBy(() -> service.changeStatus(71, "e", new DriverModels.ChangeStatus(2L, false, "暂停驾驶"))).hasMessageContaining("已被修改");
verify(history, never()).insert(any(DriverHistoryEntity.class));
}
@Test void unlinkedFileCannotBeReadEvenIfItExistsInFileService() {
when(drivers.selectById(71L)).thenReturn(row("APPROVED"));
when(history.selectCount(any())).thenReturn(0L);
assertThatThrownBy(() -> service.download(71, 99)).hasMessageContaining("未关联");
verifyNoInteractions(files);
}
@Test void historicalLinkedFileIsReadableWithExactDriverScope() {
when(drivers.selectById(71L)).thenReturn(row("APPROVED"));
when(history.selectCount(any())).thenReturn(1L);
when(files.metadata(71, 80)).thenReturn(new DriverModels.File("80", "old.pdf", "application/pdf", 8));
when(files.download(71, 80)).thenReturn(new byte[]{1, 2});
assertThat(service.download(71, 80).content()).containsExactly(1, 2);
}
@Test void invalidTimeZoneAndReversedDatesCannotBeSaved() {
var badZone = new DriverModels.Draft("司机", "123", "OWNED", "OVERSEAS", null, null, null, null, "wrong-zone", null, null);
assertThatThrownBy(() -> service.create("e", badZone)).hasMessageContaining("时区");
var badDates = new DriverModels.Draft("司机", "123", "OWNED", "OVERSEAS", null, null,
LocalDate.of(2027, 1, 1), LocalDate.of(2026, 1, 1), "UTC", null, null);
assertThatThrownBy(() -> service.create("e", badDates)).hasMessageContaining("不能早于");
verify(drivers, never()).insert(any(DriverEntity.class));
}
@Test void optionalProfileUsesAuthoritativeWarehouseSnapshotAndDoesNotInventQualificationRequirements() {
when(warehouses.required(92, "OVERSEAS")).thenReturn(new DriverModels.WarehouseOption("92", "WH-US", "公共海外仓", "OVERSEAS"));
var profile = new DriverModels.Profile(92L, "53尺拖车", "中文 / 洛杉矶", LocalDate.of(2020, 1, 1), null, null);
var details = new DriverModels.Draft("司机", "123", "OWNED", "OVERSEAS", "LIC-1", "C", null,
LocalDate.of(2026, 10, 22), "UTC", null, profile);
var response = service.create("e", details);
assertThat(response.warehouseCode()).isEqualTo("WH-US");
assertThat(response.warehouseName()).isEqualTo("公共海外仓");
assertThat(response.details().profile().medicalExpiresOn()).isEqualTo(LocalDate.of(2020, 1, 1));
var captured = ArgumentCaptor.forClass(DriverEntity.class);
verify(drivers).insert(captured.capture());
var row = captured.getValue(); row.setLicenseFileId(81L);
when(drivers.lockById(row.getId())).thenReturn(row);
assertThat(service.review(row.getId(), "r", new DriverModels.Review(0L, "APPROVED", null)).reviewStatus()).isEqualTo("APPROVED");
}
@Test void invalidWarehouseCannotBePersistedAsFreeText() {
when(warehouses.required(92, "OVERSEAS")).thenThrow(new ResponseStatusException(HttpStatus.BAD_REQUEST, "仓库已停用"));
var details = new DriverModels.Draft("司机", "123", "OWNED", "OVERSEAS", null, null, null, null, "UTC", null,
new DriverModels.Profile(92L, null, null, null, null, null));
assertThatThrownBy(() -> service.create("e", details)).hasMessageContaining("仓库已停用");
verify(drivers, never()).insert(any(DriverEntity.class));
}
private static DriverModels.Draft draft(LocalDate expiry) {
return new DriverModels.Draft("司机", "123", "OWNED", "OVERSEAS", null, null, null, expiry, "UTC", null, null);
}
private static DriverEntity row(String review) {
var row = new DriverEntity();
row.setId(71L); row.setDriverNo("DR-TEST"); row.setName("司机"); row.setPhone("123");
row.setOwnership("OWNED"); row.setRegion("OVERSEAS"); row.setActive(true); row.setReviewStatus(review);
row.setLicenseNumber("LIC-1"); row.setLicenseType("C"); row.setLicenseExpiresOn(LocalDate.of(2026, 10, 22));
row.setLicenseZone("UTC"); row.setLicenseFileId(81L); row.setRecordVersion(2L);
row.setUpdatedAt(LocalDateTime.of(2026, 10, 1, 0, 0)); row.setUpdatedBy("editor");
return row;
}
}
@@ -0,0 +1,32 @@
package com.baiyue.lastleg.fleet.driver;
import com.baiyue.common.core.api.ApiResponse;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class FleetWarehouseDirectoryTest {
private final FleetWarehouseClient client = mock(FleetWarehouseClient.class);
private final FleetWarehouseDirectory directory = new FleetWarehouseDirectory(client, "test-token");
@Test void onlyActiveMatchingOperatingWarehousesCanBeSelected() {
when(client.activeWarehouses("test-token")).thenReturn(ApiResponse.success(List.of(
new FleetWarehouseClient.Warehouse(1L, "CN", "国内仓", "DOMESTIC", "CN"),
new FleetWarehouseClient.Warehouse(2L, "US", "海外仓", "OVERSEAS", "US"),
new FleetWarehouseClient.Warehouse(3L, "FBA", "目的仓", "FBA", "US"),
new FleetWarehouseClient.Warehouse(4L, "PARTNER", "合作仓", "OVERSEAS_PARTNER", "US"))));
assertThat(directory.options("OVERSEAS")).extracting(DriverModels.WarehouseOption::id).containsExactly("2", "4");
assertThat(directory.required(1, "DOMESTIC").name()).isEqualTo("国内仓");
assertThatThrownBy(() -> directory.required(1, "OVERSEAS")).hasMessageContaining("区域不符");
assertThatThrownBy(() -> directory.required(3, "OVERSEAS")).hasMessageContaining("不存在");
assertThatThrownBy(() -> directory.required(99, "OVERSEAS")).hasMessageContaining("不存在");
}
@Test void failureOrInvalidRegionNeverProducesInventedWarehouseOptions() {
when(client.activeWarehouses("test-token")).thenThrow(new IllegalStateException("unavailable"));
assertThatThrownBy(() -> directory.options("OVERSEAS")).hasMessageContaining("暂不可用");
assertThatThrownBy(() -> directory.options("unknown")).hasMessageContaining("区域");
verify(client, times(1)).activeWarehouses("test-token");
}
}
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.fleet.driver;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
class HttpDriverFileGatewayTest {
@Test void acceptsOnlyMatchingPlatformDriverLicenseMetadata() {
assertThat(HttpDriverFileGateway.verified(envelope(0L, "FLEET_DRIVER_LICENSE", "71", 81L, "application/pdf", 12L), 71, 81L).fileId())
.isEqualTo("81");
assertThatThrownBy(() -> HttpDriverFileGateway.verified(envelope(5L, "FLEET_DRIVER_LICENSE", "71", 81L, "application/pdf", 12L), 71, 81L)).hasMessageContaining("不属于");
assertThatThrownBy(() -> HttpDriverFileGateway.verified(envelope(0L, "DELIVERY_BOL", "71", 81L, "application/pdf", 12L), 71, 81L)).hasMessageContaining("不属于");
assertThatThrownBy(() -> HttpDriverFileGateway.verified(envelope(0L, "FLEET_DRIVER_LICENSE", "72", 81L, "application/pdf", 12L), 71, 81L)).hasMessageContaining("不属于");
assertThatThrownBy(() -> HttpDriverFileGateway.verified(envelope(0L, "FLEET_DRIVER_LICENSE", "71", 82L, "application/pdf", 12L), 71, 81L)).hasMessageContaining("不属于");
}
@Test void rejectsMissingOversizeOrUnsafeMetadataAndFailedEnvelope() {
assertThatThrownBy(() -> HttpDriverFileGateway.verified(envelope(0L, "FLEET_DRIVER_LICENSE", "71", 81L, "text/html", 12L), 71, 81L)).hasMessageContaining("不是有效");
assertThatThrownBy(() -> HttpDriverFileGateway.verified(envelope(0L, "FLEET_DRIVER_LICENSE", "71", 81L, "application/pdf", 0L), 71, 81L)).hasMessageContaining("不是有效");
assertThatThrownBy(() -> HttpDriverFileGateway.verified(envelope(0L, "FLEET_DRIVER_LICENSE", "71", 81L, "application/pdf", 10485761L), 71, 81L)).hasMessageContaining("不是有效");
assertThatThrownBy(() -> HttpDriverFileGateway.verified(null, 71, 81L)).hasMessageContaining("不可用");
assertThatThrownBy(() -> HttpDriverFileGateway.verified(new HttpDriverFileGateway.Envelope("A0400", null), 71, 81L)).hasMessageContaining("不可用");
}
private static HttpDriverFileGateway.Envelope envelope(Long owner, String type, String driver, Long id, String contentType, Long size) {
return new HttpDriverFileGateway.Envelope("200", new HttpDriverFileGateway.Metadata(id, owner, type, driver, "license.pdf", contentType, size));
}
}