feat(firstleg): confirm domestic departure by container
This commit is contained in:
+28
-7
@@ -10,9 +10,25 @@ class OutboundDispatchReadMapperContractTest {
|
||||
@Test
|
||||
void bindingProjectsStableCabinetAndRealContainerNumbersSeparately() throws Exception {
|
||||
String sql = String.join("\n", OutboundDispatchReadMapper.class
|
||||
.getMethod("bindings", Long.class, Long.class, Long.class)
|
||||
.getMethod("bindings", long.class)
|
||||
.getAnnotation(Select.class).value());
|
||||
assertThat(sql).contains("booking.cabinet_no cabinetNo", "booking.container_no containerNo");
|
||||
assertThat(sql)
|
||||
.contains("booking.cabinet_no cabinetNo", "booking.container_no containerNo")
|
||||
.contains("booking.id=#{containerBookingId}")
|
||||
.doesNotContain("#{orderId}", "#{inboundId}");
|
||||
}
|
||||
|
||||
@Test
|
||||
void actionQueryLocksEveryRealInboundBindingForTheContainer() throws Exception {
|
||||
String sql = String.join("\n", OutboundDispatchReadMapper.class
|
||||
.getMethod("bindingsForUpdate", long.class)
|
||||
.getAnnotation(Select.class).value());
|
||||
|
||||
assertThat(sql)
|
||||
.contains("booking.id=#{containerBookingId}")
|
||||
.contains("item.deleted=0", "inbound.deleted=0")
|
||||
.contains("FOR UPDATE")
|
||||
.doesNotContain("LIMIT 1");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -36,14 +52,19 @@ class OutboundDispatchReadMapperContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void oceanAndTruckQueriesUseTenantIsolationKey() throws Exception {
|
||||
String oceanSql = OutboundDispatchReadMapper.class.getMethod("ocean", long.class, long.class)
|
||||
void oceanAndTruckQueriesAreResolvedOnceFromTheContainerRoot() throws Exception {
|
||||
String oceanSql = OutboundDispatchReadMapper.class.getMethod("ocean", long.class)
|
||||
.getAnnotation(Select.class).value()[0];
|
||||
String truckSql = OutboundDispatchReadMapper.class.getMethod("truck", long.class, long.class, long.class)
|
||||
String truckSql = OutboundDispatchReadMapper.class.getMethod("truck", long.class, long.class)
|
||||
.getAnnotation(Select.class).value()[0];
|
||||
|
||||
assertThat(oceanSql).contains("booking.customer_id=#{tenantId}").doesNotContain("#{customerId}");
|
||||
assertThat(truckSql).contains("customer_id=#{tenantId}").doesNotContain("#{customerId}");
|
||||
assertThat(oceanSql)
|
||||
.contains("allocation.container_booking_id=#{containerBookingId}")
|
||||
.contains("booking.customer_id customerId")
|
||||
.doesNotContain("#{tenantId}");
|
||||
assertThat(truckSql)
|
||||
.contains("container_booking_id=#{containerBookingId}", "loading_plan_id=#{loadingPlanId}")
|
||||
.doesNotContain("#{tenantId}");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+138
-103
@@ -9,9 +9,11 @@ import com.baiyue.firstleg.outbound.dispatch.mapper.OutboundDispatchReadMapper;
|
||||
import com.baiyue.firstleg.outbound.dispatch.repository.OutboundDispatchRepository;
|
||||
import com.baiyue.firstleg.outbound.dispatch.service.OutboundDispatchService;
|
||||
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
@@ -19,159 +21,191 @@ import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class OutboundDispatchServiceTest {
|
||||
private final OutboundDispatchRepository repository = mock(OutboundDispatchRepository.class);
|
||||
private OutboundDispatchRepository repository;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
repository = mock(OutboundDispatchRepository.class);
|
||||
when(repository.timeline(any(), any(), any())).thenReturn(List.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
void gateFailsClosedWhenCustomsAndScheduleModulesAreUnavailable() {
|
||||
OutboundDispatchService service = new OutboundDispatchService(repository, List.of());
|
||||
stubReadyChain();
|
||||
void gateAggregatesEveryRealInboundAndFailsClosedWhenOneBindingFails() {
|
||||
OutboundDispatchService service = serviceWithoutCarrier();
|
||||
stubContainer();
|
||||
when(repository.evidence(7L, 12L)).thenReturn(new OutboundDispatchReadMapper.Evidence(4, 1));
|
||||
|
||||
GateResult result = service.checkGate(new Query(null, 11L, 22L));
|
||||
ContainerGateResult result = service.checkGate(22L);
|
||||
|
||||
assertThat(result.passed()).isFalse();
|
||||
assertThat(result.items()).filteredOn(item -> !item.passed())
|
||||
.extracting(GateItem::code).containsExactlyInAnyOrder("CUSTOMS_CLEARANCE", "SAILING_SCHEDULE");
|
||||
assertThat(result.inbounds()).hasSize(2);
|
||||
assertThat(result.inbounds().get(1).gate().items())
|
||||
.filteredOn(item -> "LOADING_EVIDENCE_COMPLETE".equals(item.code()))
|
||||
.extracting(GateItem::passed).containsExactly(false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aggregateUsesTenantKeyWhenCustomerEntityIdDiffers() {
|
||||
OutboundDispatchService service = new OutboundDispatchService(repository, List.of());
|
||||
stubReadyChain();
|
||||
void detailReturnsOneVersionAndAllInboundBindings() {
|
||||
OutboundDispatchService service = serviceWithoutCarrier();
|
||||
stubContainer();
|
||||
OutboundDispatchEntity first = state(31L, 11L, "WAITING_FOR_LOADING", 3);
|
||||
OutboundDispatchEntity second = state(32L, 12L, "WAITING_FOR_LOADING", 3);
|
||||
when(repository.dispatch(7L, 11L, 22L)).thenReturn(first);
|
||||
when(repository.dispatch(7L, 12L, 22L)).thenReturn(second);
|
||||
|
||||
service.checkGate(new Query(null, 11L, 22L));
|
||||
Detail detail = service.detail(22L);
|
||||
|
||||
verify(repository).ocean(22L, 7L);
|
||||
verify(repository).truck(22L, 44L, 7L);
|
||||
verify(repository, never()).ocean(22L, 8L);
|
||||
verify(repository, never()).truck(22L, 44L, 8L);
|
||||
assertThat(detail.aggregate().inbounds()).extracting(InboundBinding::inboundId).containsExactly(11L, 12L);
|
||||
assertThat(detail.aggregate().dispatchStatus()).isEqualTo("WAITING_FOR_LOADING");
|
||||
assertThat(detail.aggregate().version()).isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmAdvancesDispatchWithoutMutatingLoadedInbound() {
|
||||
OutboundDispatchService service = new OutboundDispatchService(repository,
|
||||
List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE")));
|
||||
stubReadyChain();
|
||||
OutboundDispatchEntity state = new OutboundDispatchEntity();
|
||||
state.setId(33L); state.setTenantId(7L); state.setOrderId(10L); state.setInboundId(11L);
|
||||
state.setContainerBookingId(22L); state.setStatus("READY_FOR_DISPATCH"); state.setVersion(0);
|
||||
when(repository.dispatch(7L, 11L, 22L)).thenReturn(state);
|
||||
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-1"), anyString())).thenReturn(true);
|
||||
when(repository.confirm(33L, 0, 9L, "admin", "ok")).thenReturn(1);
|
||||
when(repository.completeOperation(33L, 9L, "CONFIRM_DISPATCH", "confirm-1")).thenReturn(1);
|
||||
when(repository.timeline(any(), any(), any())).thenReturn(List.of());
|
||||
|
||||
service.confirm(9L, "admin", "confirm-1", new ActionRequest(11L, 22L, 0, "ok"));
|
||||
|
||||
verify(repository).confirm(33L, 0, 9L, "admin", "ok");
|
||||
verify(repository).history(argThat(history -> "CONFIRM_DISPATCH".equals(history.getAction())
|
||||
&& "WAITING_FOR_LOADING".equals(history.getToStatus())));
|
||||
verify(repository).completeOperation(33L, 9L, "CONFIRM_DISPATCH", "confirm-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmCreatesCanonicalCarrierScheduleTaskInTheSameTransaction() {
|
||||
void confirmLocksAndAdvancesAllBindingsButWritesOneHistoryAndOneShippingFact() {
|
||||
CarrierScheduleQueryService carrierSchedules = mock(CarrierScheduleQueryService.class);
|
||||
OutboundDispatchService service = new OutboundDispatchService(repository,
|
||||
List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE")), carrierSchedules);
|
||||
stubReadyChain();
|
||||
OutboundDispatchEntity state = new OutboundDispatchEntity();
|
||||
state.setId(33L); state.setTenantId(7L); state.setInboundId(11L);
|
||||
state.setContainerBookingId(22L); state.setStatus("READY_FOR_DISPATCH"); state.setVersion(0);
|
||||
when(repository.dispatch(7L, 11L, 22L)).thenReturn(state);
|
||||
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-carrier"), anyString())).thenReturn(true);
|
||||
when(repository.confirm(33L, 0, 9L, "admin", null)).thenReturn(1);
|
||||
when(repository.completeOperation(33L, 9L, "CONFIRM_DISPATCH", "confirm-carrier")).thenReturn(1);
|
||||
when(repository.timeline(any(), any(), any())).thenReturn(List.of());
|
||||
OutboundDispatchService service = service(carrierSchedules);
|
||||
stubContainer();
|
||||
when(repository.lockBindings(22L)).thenReturn(bindings());
|
||||
OutboundDispatchEntity first = state(31L, 11L, "READY_FOR_DISPATCH", 0);
|
||||
OutboundDispatchEntity second = state(32L, 12L, "READY_FOR_DISPATCH", 0);
|
||||
when(repository.dispatchLocked(7L, 11L, 22L)).thenReturn(first);
|
||||
when(repository.dispatchLocked(7L, 12L, 22L)).thenReturn(second);
|
||||
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-container"), anyString())).thenReturn(true);
|
||||
when(repository.confirm(anyLong(), eq(0), eq(9L), eq("admin"), eq("ok"))).thenReturn(1);
|
||||
when(repository.completeOperation(31L, 9L, "CONFIRM_DISPATCH", "confirm-container")).thenReturn(1);
|
||||
|
||||
service.confirm(9L, "admin", "confirm-carrier", new ActionRequest(11L, 22L, 0, null));
|
||||
service.confirm(22L, 9L, "admin", "confirm-container", new ActionRequest(0, "ok"));
|
||||
|
||||
verify(carrierSchedules).ensureFromOutboundConfirm(7L, 66L, 22L, 9L, "admin");
|
||||
verify(repository).lockBindings(22L);
|
||||
verify(repository).confirm(31L, 0, 9L, "admin", "ok");
|
||||
verify(repository).confirm(32L, 0, 9L, "admin", "ok");
|
||||
verify(repository, times(1)).history(argThat(value -> "CONFIRM_DISPATCH".equals(value.getAction())
|
||||
&& value.getContainerBookingId().equals(22L)));
|
||||
verify(carrierSchedules, times(1)).ensureFromOutboundConfirm(7L, 66L, 22L, 9L, "admin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmNeverWritesStateWhenAnyRealGateFails() {
|
||||
OutboundDispatchService service = new OutboundDispatchService(repository,
|
||||
List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE")));
|
||||
stubReadyChain();
|
||||
when(repository.pick(44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(55L, "LOADING"));
|
||||
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-2"), anyString())).thenReturn(true);
|
||||
void confirmDoesNotWriteAnyRowWhenOneInboundGateFails() {
|
||||
OutboundDispatchService service = serviceWithoutCarrier();
|
||||
stubContainer();
|
||||
when(repository.lockBindings(22L)).thenReturn(bindings());
|
||||
when(repository.evidence(7L, 12L)).thenReturn(new OutboundDispatchReadMapper.Evidence(4, 1));
|
||||
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("blocked"), anyString())).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.confirm(9L, "admin", "confirm-2",
|
||||
new ActionRequest(11L, 22L, 0, null))).isInstanceOf(BizException.class).hasMessageContaining("门禁");
|
||||
assertThatThrownBy(() -> service.confirm(22L, 9L, "admin", "blocked", new ActionRequest(0, null)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("全部入库绑定");
|
||||
|
||||
verify(repository, never()).confirm(anyLong(), anyInt(), anyLong(), anyString(), any());
|
||||
verify(repository, never()).history(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void differingTechnicalRowVersionsAreRejectedAsContainerConflict() {
|
||||
OutboundDispatchService service = serviceWithoutCarrier();
|
||||
stubContainer();
|
||||
when(repository.lockBindings(22L)).thenReturn(bindings());
|
||||
when(repository.dispatchLocked(7L, 11L, 22L)).thenReturn(state(31L, 11L, "READY_FOR_DISPATCH", 0));
|
||||
when(repository.dispatchLocked(7L, 12L, 22L)).thenReturn(state(32L, 12L, "READY_FOR_DISPATCH", 1));
|
||||
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("mixed"), anyString())).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.confirm(22L, 9L, "admin", "mixed", new ActionRequest(0, null)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("版本不一致");
|
||||
|
||||
verify(repository, never()).confirm(anyLong(), anyInt(), anyLong(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reverseIsRejectedAfterActualDepartedWarehouseFactExists() {
|
||||
void actualDepartedWarehouseFactBlocksContainerReverseBeforeAnyRowChanges() {
|
||||
CarrierScheduleQueryService carrierSchedules = mock(CarrierScheduleQueryService.class);
|
||||
OutboundDispatchService service = new OutboundDispatchService(repository, List.of(), carrierSchedules);
|
||||
stubReadyChain();
|
||||
OutboundDispatchEntity state = new OutboundDispatchEntity();
|
||||
state.setId(33L); state.setTenantId(7L); state.setInboundId(11L); state.setContainerBookingId(22L);
|
||||
state.setStatus("WAITING_FOR_LOADING"); state.setVersion(1);
|
||||
when(repository.dispatch(7L, 11L, 22L)).thenReturn(state);
|
||||
OutboundDispatchService service = service(carrierSchedules);
|
||||
stubContainer();
|
||||
when(repository.lockBindings(22L)).thenReturn(bindings());
|
||||
when(repository.dispatchLocked(7L, 11L, 22L)).thenReturn(state(31L, 11L, "WAITING_FOR_LOADING", 1));
|
||||
when(repository.dispatchLocked(7L, 12L, 22L)).thenReturn(state(32L, 12L, "WAITING_FOR_LOADING", 1));
|
||||
when(repository.claim(eq(9L), eq("REVERSE_DISPATCH"), eq("reverse-blocked"), anyString())).thenReturn(true);
|
||||
when(carrierSchedules.hasOutboundDepartureFact(7L, 66L, 22L)).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.reverse(9L, "admin", "reverse-blocked",
|
||||
new ActionRequest(11L, 22L, 1, "误操作")))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("DEPARTED_WAREHOUSE");
|
||||
assertThatThrownBy(() -> service.reverse(22L, 9L, "admin", "reverse-blocked",
|
||||
new ActionRequest(1, "误操作"))).isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("DEPARTED_WAREHOUSE");
|
||||
|
||||
verify(repository, never()).reverse(anyLong(), anyInt(), anyLong(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectorsThatMatchMultipleContainerBindingsRequireDisambiguation() {
|
||||
OutboundDispatchService service = new OutboundDispatchService(repository, List.of());
|
||||
when(repository.bindings(any())).thenReturn(List.of(binding(), binding()));
|
||||
assertThatThrownBy(() -> service.detail(new Query(10L, null, null)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("多个出仓聚合");
|
||||
void sameIdempotencyKeyAndPayloadReplaysContainerResultWithoutMutation() {
|
||||
OutboundDispatchService service = serviceWithoutCarrier();
|
||||
stubContainer();
|
||||
AtomicReference<String> requestHash = new AtomicReference<>();
|
||||
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("replay"), anyString())).thenAnswer(invocation -> {
|
||||
requestHash.set(invocation.getArgument(3));
|
||||
return false;
|
||||
});
|
||||
OutboundDispatchOperationEntity existing = new OutboundDispatchOperationEntity();
|
||||
existing.setStatus("SUCCESS");
|
||||
when(repository.operation(9L, "CONFIRM_DISPATCH", "replay")).thenAnswer(invocation -> {
|
||||
existing.setRequestHash(requestHash.get());
|
||||
return existing;
|
||||
});
|
||||
|
||||
Detail replay = service.confirm(22L, 9L, "admin", "replay", new ActionRequest(0, null));
|
||||
|
||||
assertThat(replay.aggregate().containerBookingId()).isEqualTo(22L);
|
||||
verify(repository, never()).confirm(anyLong(), anyInt(), anyLong(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleDispatchVersionIsRejectedWithoutPartialInboundAdvance() {
|
||||
OutboundDispatchService service = new OutboundDispatchService(repository,
|
||||
List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE")));
|
||||
stubReadyChain();
|
||||
OutboundDispatchEntity state = new OutboundDispatchEntity();
|
||||
state.setId(33L); state.setTenantId(7L); state.setInboundId(11L); state.setContainerBookingId(22L);
|
||||
state.setStatus("READY_FOR_DISPATCH"); state.setVersion(2);
|
||||
when(repository.dispatch(7L, 11L, 22L)).thenReturn(state);
|
||||
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("stale"), anyString())).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.confirm(9L, "admin", "stale",
|
||||
new ActionRequest(11L, 22L, 1, null))).isInstanceOf(BizException.class).hasMessageContaining("刷新");
|
||||
}
|
||||
|
||||
@Test
|
||||
void reusedIdempotencyKeyWithDifferentHashIsRejected() {
|
||||
OutboundDispatchService service = new OutboundDispatchService(repository,
|
||||
List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE")));
|
||||
stubReadyChain();
|
||||
void reusedIdempotencyKeyWithDifferentPayloadIsRejected() {
|
||||
OutboundDispatchService service = serviceWithoutCarrier();
|
||||
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("reused"), anyString())).thenReturn(false);
|
||||
OutboundDispatchOperationEntity existing = new OutboundDispatchOperationEntity();
|
||||
existing.setRequestHash("different"); existing.setStatus("SUCCESS");
|
||||
existing.setRequestHash("different");
|
||||
existing.setStatus("SUCCESS");
|
||||
when(repository.operation(9L, "CONFIRM_DISPATCH", "reused")).thenReturn(existing);
|
||||
|
||||
assertThatThrownBy(() -> service.confirm(9L, "admin", "reused",
|
||||
new ActionRequest(11L, 22L, 0, null))).isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("用于不同请求");
|
||||
assertThatThrownBy(() -> service.confirm(22L, 9L, "admin", "reused", new ActionRequest(0, null)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("用于不同请求");
|
||||
}
|
||||
|
||||
private void stubReadyChain() {
|
||||
when(repository.bindings(any())).thenReturn(List.of(binding()));
|
||||
private OutboundDispatchService serviceWithoutCarrier() {
|
||||
return service(null);
|
||||
}
|
||||
|
||||
private OutboundDispatchService service(CarrierScheduleQueryService carrierSchedules) {
|
||||
return new OutboundDispatchService(repository,
|
||||
List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE")), carrierSchedules);
|
||||
}
|
||||
|
||||
private void stubContainer() {
|
||||
when(repository.bindings(22L)).thenReturn(bindings());
|
||||
when(repository.pick(44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(55L, "COMPLETED"));
|
||||
when(repository.ocean(22L, 7L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(66L, "RELEASED"));
|
||||
when(repository.truck(22L, 44L, 7L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "DEPARTED"));
|
||||
when(repository.ocean(22L)).thenReturn(new OutboundDispatchReadMapper.OceanBinding(66L, "RELEASED", 7L));
|
||||
when(repository.truck(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "DEPARTED"));
|
||||
when(repository.evidence(7L, 11L)).thenReturn(new OutboundDispatchReadMapper.Evidence(5, 1));
|
||||
when(repository.evidence(7L, 12L)).thenReturn(new OutboundDispatchReadMapper.Evidence(5, 1));
|
||||
}
|
||||
|
||||
private static OutboundDispatchReadMapper.Binding binding() {
|
||||
return new OutboundDispatchReadMapper.Binding(10L, "O-10", 11L, "I-11", 7L, 8L, "客户A",
|
||||
private static List<OutboundDispatchReadMapper.Binding> bindings() {
|
||||
return List.of(binding(11L, "I-11"), binding(12L, "I-12"));
|
||||
}
|
||||
|
||||
private static OutboundDispatchReadMapper.Binding binding(long inboundId, String inboundCode) {
|
||||
return new OutboundDispatchReadMapper.Binding(10L, "O-10", inboundId, inboundCode, 7L, 8L, "客户A",
|
||||
"LOADED", 22L, "CAB-22", "C-22", 44L, "COMPLETED");
|
||||
}
|
||||
|
||||
private static OutboundDispatchEntity state(long id, long inboundId, String status, int version) {
|
||||
OutboundDispatchEntity value = new OutboundDispatchEntity();
|
||||
value.setId(id);
|
||||
value.setTenantId(7L);
|
||||
value.setOrderId(10L);
|
||||
value.setInboundId(inboundId);
|
||||
value.setContainerBookingId(22L);
|
||||
value.setStatus(status);
|
||||
value.setVersion(version);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static OutboundDispatchGateContributor passing(String code) {
|
||||
return new OutboundDispatchGateContributor() {
|
||||
@Override public String code() { return code; }
|
||||
@@ -180,4 +214,5 @@ class OutboundDispatchServiceTest {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+8
-6
@@ -38,17 +38,19 @@ class OutboundDispatchSqlContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void openApiPublishesSelectorsGateAndBothVersionedActions() throws Exception {
|
||||
void openApiPublishesOnlyContainerLevelGateDetailAndVersionedActions() throws Exception {
|
||||
String openApi = resource("openapi/phase3-outbound-dispatch-v1.yaml");
|
||||
assertThat(openApi)
|
||||
.contains("outbound-dispatches:")
|
||||
.contains("outbound-dispatches/detail:")
|
||||
.contains("outbound-dispatches/check-gate:")
|
||||
.contains("outbound-dispatches/confirm-dispatch:")
|
||||
.contains("outbound-dispatches/reverse-dispatch:")
|
||||
.contains("outbound-dispatches/{containerBookingId}:")
|
||||
.contains("outbound-dispatches/{containerBookingId}/gate:")
|
||||
.contains("outbound-dispatches/{containerBookingId}/confirm:")
|
||||
.contains("outbound-dispatches/{containerBookingId}/reverse:")
|
||||
.contains("every real inbound binding", "No partial success is possible")
|
||||
.contains("Once an actual DEPARTED_WAREHOUSE node exists")
|
||||
.contains("expectedVersion")
|
||||
.contains("Idempotency-Key");
|
||||
.contains("Idempotency-Key")
|
||||
.doesNotContain("confirm-dispatch", "reverse-dispatch", "check-gate", "inboundId, containerBookingId");
|
||||
}
|
||||
|
||||
private static String migration() throws Exception { return resource("db/phase3-outbound-dispatch-schema.sql"); }
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package com.baiyue.firstleg.outbound.dispatch;
|
||||
|
||||
import com.baiyue.common.audit.BusinessAudit;
|
||||
import com.baiyue.firstleg.outbound.dispatch.controller.PlatformOutboundDispatchController;
|
||||
import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.ActionRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PlatformOutboundDispatchControllerContractTest {
|
||||
@Test
|
||||
void exposesOnlyContainerLevelResourcePathsAndExistingPermissions() {
|
||||
assertThat(PlatformOutboundDispatchController.class.getAnnotation(RequestMapping.class).value())
|
||||
.containsExactly("/api/platform/v1/first-leg/outbound-dispatches");
|
||||
Map<String, String> permissions = Map.of(
|
||||
"list", "platform:firstleg:outbound-dispatch:list",
|
||||
"detail", "platform:firstleg:outbound-dispatch:view",
|
||||
"checkGate", "platform:firstleg:outbound-dispatch:check",
|
||||
"confirm", "platform:firstleg:outbound-dispatch:confirm",
|
||||
"reverse", "platform:firstleg:outbound-dispatch:reverse");
|
||||
permissions.forEach((name, permission) -> assertThat(method(name).getAnnotation(PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('" + permission + "')"));
|
||||
|
||||
assertThat(method("detail").getAnnotation(GetMapping.class).value()).containsExactly("/{containerBookingId}");
|
||||
assertThat(method("checkGate").getAnnotation(GetMapping.class).value())
|
||||
.containsExactly("/{containerBookingId}/gate");
|
||||
assertThat(method("confirm").getAnnotation(PostMapping.class).value())
|
||||
.containsExactly("/{containerBookingId}/confirm");
|
||||
assertThat(method("reverse").getAnnotation(PostMapping.class).value())
|
||||
.containsExactly("/{containerBookingId}/reverse");
|
||||
}
|
||||
|
||||
@Test
|
||||
void actionBodyContainsOnlyContainerVersionAndRemarkAndAuditsContainerRoot() {
|
||||
assertThat(Arrays.stream(ActionRequest.class.getRecordComponents()).map(value -> value.getName()))
|
||||
.containsExactly("expectedVersion", "remark")
|
||||
.doesNotContain("inboundId", "containerBookingId");
|
||||
for (String name : List.of("confirm", "reverse")) {
|
||||
BusinessAudit audit = method(name).getAnnotation(BusinessAudit.class);
|
||||
assertThat(audit.resourceType()).isEqualTo("CONTAINER_BOOKING");
|
||||
assertThat(audit.resourceId()).isEqualTo("#containerBookingId");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyNonListOperationUsesOnlyContainerBookingIdPathTarget() {
|
||||
for (String name : List.of("detail", "checkGate", "confirm", "reverse")) {
|
||||
assertThat(Arrays.stream(method(name).getParameters())
|
||||
.filter(value -> value.isAnnotationPresent(PathVariable.class))
|
||||
.map(value -> value.getName())).containsExactly("containerBookingId");
|
||||
}
|
||||
}
|
||||
|
||||
private static Method method(String name) {
|
||||
return Arrays.stream(PlatformOutboundDispatchController.class.getDeclaredMethods())
|
||||
.filter(value -> value.getName().equals(name)).findFirst().orElseThrow();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user