This commit is contained in:
2026-08-08 13:32:20 +08:00
parent 96788e10de
commit 3203adb5a7
73 changed files with 44331 additions and 26 deletions
@@ -0,0 +1,6 @@
package com.baiyue.firstleg.controller;
import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; import org.springframework.security.access.prepost.PreAuthorize; import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; import static org.assertj.core.api.Assertions.assertThat;
class TruckAppointmentControllerContractTest {
@Test void exposesDedicatedEndpointAndPermissions() {String p=PlatformTruckAppointmentController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0];assertThat(p).isEqualTo("/api/platform/v1/truck-appointments"); for(String n:new String[]{"page","detail","create","edit","action","reschedule"}) assertThat(java.util.Arrays.stream(PlatformTruckAppointmentController.class.getDeclaredMethods()).anyMatch(m->m.getName().equals(n))).isTrue();}
@Test void schemaHasAggregateTablesAndStates() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","fl_truck_appointment_history","fl_truck_appointment_attachment","fl_truck_appointment_operation","PENDING_DISPATCH","PICKUP_IN_PROGRESS","ARRIVED_WAREHOUSE","RESCHEDULED");}
}
@@ -0,0 +1,56 @@
package com.baiyue.firstleg.oceanbooking.controller;
import com.baiyue.common.audit.BusinessAudit;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestMapping;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class PlatformOceanBookingControllerContractTest {
@Test
void usesDedicatedResourceAndPermissions() {
assertThat(PlatformOceanBookingController.class.getAnnotation(RequestMapping.class).value())
.containsExactly("/api/platform/v1/ocean-bookings");
Map<String, String> permissions = Map.ofEntries(
Map.entry("page", "platform:firstleg:ocean-booking:list"),
Map.entry("detail", "platform:firstleg:ocean-booking:view"),
Map.entry("create", "platform:firstleg:ocean-booking:create"),
Map.entry("updateDraft", "platform:firstleg:ocean-booking:edit"),
Map.entry("submit", "platform:firstleg:ocean-booking:submit"),
Map.entry("confirm", "platform:firstleg:ocean-booking:confirm"),
Map.entry("fail", "platform:firstleg:ocean-booking:confirm"),
Map.entry("release", "platform:firstleg:ocean-booking:release"),
Map.entry("cancel", "platform:firstleg:ocean-booking:cancel"),
Map.entry("requestChange", "platform:firstleg:ocean-booking:change"),
Map.entry("confirmChange", "platform:firstleg:ocean-booking:change"),
Map.entry("failChange", "platform:firstleg:ocean-booking:change"),
Map.entry("allocate", "platform:firstleg:ocean-booking:allocate"));
permissions.forEach((name, permission) -> {
Method method = method(name);
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + permission + "')");
if (!name.equals("page") && !name.equals("detail")) {
assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull();
}
});
}
@Test
void pathVariablesUseExplicitAggregateNames() {
assertThat(Arrays.stream(PlatformOceanBookingController.class.getDeclaredMethods())
.filter(method -> !method.getName().equals("page") && !method.getName().equals("create"))
.flatMap(method -> Arrays.stream(method.getParameters()))
.filter(parameter -> parameter.isAnnotationPresent(org.springframework.web.bind.annotation.PathVariable.class))
.allMatch(parameter -> parameter.getName().equals("oceanBookingId"))).isTrue();
}
private static Method method(String name) {
return Arrays.stream(PlatformOceanBookingController.class.getDeclaredMethods())
.filter(method -> method.getName().equals(name)).findFirst().orElseThrow();
}
}
@@ -0,0 +1,39 @@
package com.baiyue.firstleg.oceanbooking.repository;
import com.baiyue.firstleg.oceanbooking.mapper.OceanBookingMapper;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
class OceanBookingMapperContractTest {
@Test
void listUsesPlatformAggregateFilters() throws Exception {
String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("selectPage",
com.baomidou.mybatisplus.extension.plugins.pagination.Page.class,
com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Query.class)
.getAnnotation(Select.class).value());
assertThat(sql).contains("customer_id=#{query.customerId}")
.contains("status=#{query.status}")
.contains("booking_no LIKE")
.contains("ORDER BY updated_at DESC,id DESC");
}
@Test
void everyMutationUsesExpectedVersionAndStateGuard() {
String sql = Arrays.stream(OceanBookingMapper.class.getDeclaredMethods())
.map(method -> method.getAnnotation(Update.class))
.filter(java.util.Objects::nonNull)
.flatMap(annotation -> Arrays.stream(annotation.value()))
.reduce("", (left, right) -> left + '\n' + right);
assertThat(sql)
.contains("status='DRAFT' AND version=#{expectedVersion}")
.contains("status=#{fromStatus} AND version=#{expectedVersion}")
.contains("status='CONFIRMED' AND version=#{expectedVersion}")
.contains("status='CHANGE_PENDING' AND version=#{expectedVersion}")
.contains("status IN ('SUBMITTED','CONFIRMED','CHANGE_PENDING')");
}
}
@@ -0,0 +1,58 @@
package com.baiyue.firstleg.oceanbooking.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class OceanBookingSqlContractTest {
@Test
void definesDedicatedAggregateWithoutChangingContainerSemantics() throws Exception {
String sql = sql();
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking (")
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_party")
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_requirement")
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_allocation")
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_status_history")
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_operation")
.contains("container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '明确引用现有柜次ID'")
.contains("REFERENCES fl_container_booking(id)")
.doesNotContain("ALTER TABLE fl_container_booking");
}
@Test
void enforcesStateIdempotencyAndAuditContracts() throws Exception {
assertThat(sql())
.contains("OLD.status='DRAFT' AND NEW.status IN ('SUBMITTED','CANCELLED')")
.contains("OLD.status='CONFIRMED' AND NEW.status IN ('RELEASED','CHANGE_PENDING','CANCELLED')")
.contains("OLD.status='CHANGE_PENDING' AND NEW.status IN ('CONFIRMED','FAILED','CANCELLED')")
.contains("OLD.status IN ('RELEASED','CANCELLED')")
.contains("UNIQUE KEY uk_ocean_operation (operator_id,operation,idempotency_key)")
.contains("request_hash CHAR(64) NOT NULL")
.contains("订舱状态历史不可修改")
.contains("UNIQUE KEY uk_ocean_allocation_container (container_booking_id)");
}
@Test
void declaresDedicatedPlatformPermissions() throws Exception {
assertThat(sql())
.contains("platform:firstleg:ocean-booking:list")
.contains("platform:firstleg:ocean-booking:view")
.contains("platform:firstleg:ocean-booking:create")
.contains("platform:firstleg:ocean-booking:edit")
.contains("platform:firstleg:ocean-booking:submit")
.contains("platform:firstleg:ocean-booking:confirm")
.contains("platform:firstleg:ocean-booking:release")
.contains("platform:firstleg:ocean-booking:cancel")
.contains("platform:firstleg:ocean-booking:change")
.contains("platform:firstleg:ocean-booking:allocate");
}
private static String sql() throws Exception {
return new ClassPathResource("db/phase3-ocean-booking-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,121 @@
package com.baiyue.firstleg.oceanbooking.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ActionRequest;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ChangeRequest;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Detail;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingOperationEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.ArgumentCaptor;
import org.mockito.MockitoAnnotations;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class OceanBookingServiceTest {
@Mock private OceanBookingRepository repository;
@InjectMocks private OceanBookingService service;
@BeforeEach
void setUp() throws Exception {
MockitoAnnotations.openMocks(this);
var field = OceanBookingService.class.getDeclaredField("objectMapper");
field.setAccessible(true); field.set(service, new ObjectMapper().findAndRegisterModules());
when(repository.parties(anyLong())).thenReturn(List.of());
when(repository.requirements(anyLong())).thenReturn(List.of());
when(repository.allocations(anyLong())).thenReturn(List.of());
when(repository.histories(anyLong())).thenReturn(List.of());
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.completeOperation(anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(1);
}
@Test
void advancesDraftToSubmittedWithVersionAndHistory() {
when(repository.find(1L)).thenReturn(booking("DRAFT", 2), booking("SUBMITTED", 3));
var shipper = new com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity(); shipper.setPartyType("SHIPPER");
var consignee = new com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity(); consignee.setPartyType("CONSIGNEE");
var notify = new com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity(); notify.setPartyType("NOTIFY_PARTY");
when(repository.parties(1L)).thenReturn(List.of(shipper, consignee, notify));
when(repository.requirements(1L)).thenReturn(List.of(new com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity()));
when(repository.transition(1L, "DRAFT", "SUBMITTED", null, null, 2, 9L, "admin")).thenReturn(1);
Detail result = service.submit(1L, 9L, "admin", "submit-1", new ActionRequest(2, "提交"));
assertThat(result.oceanBooking().getStatus()).isEqualTo("SUBMITTED");
ArgumentCaptor<OceanBookingStatusHistoryEntity> history =
ArgumentCaptor.forClass(OceanBookingStatusHistoryEntity.class);
verify(repository).insert(history.capture());
assertThat(history.getValue().getFromStatus()).isEqualTo("DRAFT");
assertThat(history.getValue().getToStatus()).isEqualTo("SUBMITTED");
assertThat(history.getValue().getAction()).isEqualTo("SUBMIT");
}
@Test
void rejectsCancellationAfterRelease() {
when(repository.find(1L)).thenReturn(booking("RELEASED", 4));
assertThatThrownBy(() -> service.cancel(1L, 9L, "admin", "cancel-1",
new ActionRequest(4, "错误"))).isInstanceOf(BizException.class)
.hasMessageContaining("不可逆");
verify(repository, never()).transition(anyLong(), anyString(), anyString(), any(), any(), anyInt(), anyLong(), anyString());
}
@Test
void keepsCurrentVoyagePendingUntilChangeConfirmation() {
OceanBookingEntity current = booking("CONFIRMED", 5);
current.setVesselName("OLD"); current.setVoyageNo("V1");
OceanBookingEntity pending = booking("CHANGE_PENDING", 6);
pending.setVesselName("OLD"); pending.setVoyageNo("V1"); pending.setPendingVesselName("NEW"); pending.setPendingVoyageNo("V2");
when(repository.find(1L)).thenReturn(current, pending);
when(repository.requestChange(eq(1L), any(), eq("改船"), eq(5), eq(9L), eq("admin"))).thenReturn(1);
Detail result = service.requestChange(1L, 9L, "admin", "change-1",
new ChangeRequest(5, "NEW", "V2", LocalDateTime.of(2026, 9, 1, 0, 0),
LocalDateTime.of(2026, 9, 20, 0, 0), "改船"));
assertThat(result.oceanBooking().getStatus()).isEqualTo("CHANGE_PENDING");
assertThat(result.oceanBooking().getVesselName()).isEqualTo("OLD");
assertThat(result.oceanBooking().getPendingVesselName()).isEqualTo("NEW");
}
@Test
void replaysCompletedRequestBeforeVersionValidation() throws Exception {
OceanBookingEntity submitted = booking("SUBMITTED", 3);
Detail replay = new Detail(submitted, List.of(), List.of(), List.of(), List.of());
OceanBookingOperationEntity operation = new OceanBookingOperationEntity();
operation.setRequestHash(hashForSubmit()); operation.setStatus("SUCCESS");
operation.setResultJson(new ObjectMapper().findAndRegisterModules().writeValueAsString(replay));
when(repository.claimOperation(eq(1L), eq(9L), eq("SUBMIT"), eq("submit-replay"), anyString())).thenReturn(false);
when(repository.operation(9L, "SUBMIT", "submit-replay")).thenReturn(operation);
Detail result = service.submit(1L, 9L, "admin", "submit-replay", new ActionRequest(2, "提交"));
assertThat(result.oceanBooking().getStatus()).isEqualTo("SUBMITTED");
verify(repository, never()).find(anyLong());
}
private String hashForSubmit() throws Exception {
var method = OceanBookingService.class.getDeclaredMethod("hash", String.class, Long.class, Object.class);
method.setAccessible(true);
return (String) method.invoke(service, "SUBMIT", 1L, new ActionRequest(2, "提交"));
}
private static OceanBookingEntity booking(String status, int version) {
OceanBookingEntity value = new OceanBookingEntity(); value.setId(1L); value.setBookingNo("OB1");
value.setStatus(status); value.setCustomerId(100L); value.setCustomerName("客户"); value.setVersion(version);
return value;
}
}