feat(firstleg): add compliant carrier schedule queries
This commit is contained in:
+27
@@ -0,0 +1,27 @@
|
||||
package com.baiyue.firstleg.portschedule.controller;
|
||||
|
||||
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 CarrierScheduleQueryOpenApiContractTest {
|
||||
@Test
|
||||
void publishesFrequencyFreshnessAndManualFallbackContract() throws Exception {
|
||||
String yaml = new ClassPathResource("openapi/carrier-schedule-query-v1.yaml")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(yaml)
|
||||
.contains("/api/platform/v1/carrier-schedule-queries:")
|
||||
.contains("/api/platform/v1/carrier-schedule-queries/{queryId}/attempts:")
|
||||
.contains("/api/platform/v1/carrier-schedule-queries/{queryId}/manual-results:")
|
||||
.contains("enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]")
|
||||
.contains("MANUAL_REQUIRED")
|
||||
.contains("coverageGap")
|
||||
.contains("lastAttemptAt")
|
||||
.contains("lastSuccessAt")
|
||||
.contains("sourceUpdatedAt")
|
||||
.contains("nextAllowedAt");
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.firstleg.portschedule.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 PlatformCarrierScheduleQueryControllerContractTest {
|
||||
@Test
|
||||
void exposesDedicatedTaskResourceAndLeastPrivilegeActions() {
|
||||
assertThat(PlatformCarrierScheduleQueryController.class.getAnnotation(RequestMapping.class).value())
|
||||
.containsExactly("/api/platform/v1/carrier-schedule-queries");
|
||||
Map<String, String> permissions = Map.of(
|
||||
"carriers", "platform:firstleg:port-schedule-query:view",
|
||||
"page", "platform:firstleg:port-schedule-query:list",
|
||||
"detail", "platform:firstleg:port-schedule-query:view",
|
||||
"history", "platform:firstleg:port-schedule-query:view",
|
||||
"create", "platform:firstleg:port-schedule-query:create",
|
||||
"attempt", "platform:firstleg:port-schedule-query:attempt",
|
||||
"manualResult", "platform:firstleg:port-schedule-query:manual");
|
||||
permissions.forEach((name, permission) -> {
|
||||
Method method = Arrays.stream(PlatformCarrierScheduleQueryController.class.getDeclaredMethods())
|
||||
.filter(candidate -> candidate.getName().equals(name)).findFirst().orElseThrow();
|
||||
assertThat(method.getAnnotation(PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('" + permission + "')");
|
||||
if (name.equals("create") || name.equals("attempt") || name.equals("manualResult")) {
|
||||
assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package com.baiyue.firstleg.portschedule.repository;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class CarrierScheduleQuerySqlContractTest {
|
||||
@Test
|
||||
void definesTaskAttemptsHistoryIdempotencyAndHardLimit() throws Exception {
|
||||
assertThat(moduleSql())
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query (")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation")
|
||||
.contains("attempt_count BETWEEN 0 AND 3")
|
||||
.contains("window_attempt_no BETWEEN 1 AND 3")
|
||||
.contains("UNIQUE KEY uk_carrier_schedule_operation (operator_id,idempotency_key)")
|
||||
.contains("船司查询尝试不可修改")
|
||||
.contains("船司查询历史不可删除");
|
||||
}
|
||||
|
||||
@Test
|
||||
void enforcesRealBookingContainerAndPermissions() throws Exception {
|
||||
assertThat(moduleSql())
|
||||
.contains("booking.id=NEW.ocean_booking_id AND booking.customer_id=NEW.customer_id")
|
||||
.contains("allocation.container_booking_id=NEW.container_booking_id")
|
||||
.contains("5102184 AND 5102188")
|
||||
.contains("platform:firstleg:port-schedule-query:attempt")
|
||||
.contains("platform:firstleg:port-schedule-query:manual")
|
||||
.contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')")
|
||||
.contains("船司查询任务角色授权未完整创建");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unifiedDeploymentEntryContainsTheSameContract() throws Exception {
|
||||
String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
|
||||
StandardCharsets.UTF_8);
|
||||
assertThat(unified)
|
||||
.contains("fl_carrier_schedule_query")
|
||||
.contains("uk_carrier_schedule_query_scope")
|
||||
.contains("5102184 AND 5102188")
|
||||
.contains("assert_carrier_schedule_query_schema")
|
||||
.contains("船司查询任务角色授权未完整创建");
|
||||
}
|
||||
|
||||
private static String moduleSql() throws Exception {
|
||||
return new ClassPathResource("db/phase3-carrier-schedule-query-schema.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package com.baiyue.firstleg.portschedule.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class CarrierScheduleAdapterRegistryTest {
|
||||
private final CarrierScheduleAdapterRegistry registry = new CarrierScheduleAdapterRegistry();
|
||||
|
||||
@Test
|
||||
void exposesExactlySixOfficialSourcesWithoutPretendingAutomation() {
|
||||
assertThat(registry.descriptors()).extracting("carrierCode")
|
||||
.containsExactly("MATSON", "SML", "ZIM", "COSCO", "EMC", "HEDE");
|
||||
assertThat(registry.descriptors()).allSatisfy(value -> {
|
||||
assertThat(value.automated()).isFalse();
|
||||
assertThat(value.coverageGap()).isTrue();
|
||||
assertThat(value.officialUrl()).startsWith("http");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsStructuredManualRequirementsForCaptchaAndDynamicPage() {
|
||||
var zim = registry.prepare("zim", "BILL_OF_LADING", "GAOU6898053");
|
||||
var cosco = registry.prepare("COSCO", "CONTAINER_NO", "C123");
|
||||
|
||||
assertThat(zim.status()).isEqualTo("MANUAL_REQUIRED");
|
||||
assertThat(zim.accessMode()).isEqualTo("HCAPTCHA");
|
||||
assertThat(zim.coverageGapCode()).isEqualTo("HCAPTCHA_REQUIRED");
|
||||
assertThat(zim.officialUrl()).contains("consnumber=GAOU6898053");
|
||||
assertThat(cosco.accessMode()).isEqualTo("DYNAMIC_IFRAME");
|
||||
assertThat(cosco.coverageGapCode()).isEqualTo("DYNAMIC_IFRAME_REQUIRED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnknownCarrier() {
|
||||
assertThatThrownBy(() -> registry.prepare("UNKNOWN", "BOOKING_NO", "X"))
|
||||
.hasMessageContaining("仅支持MATSON");
|
||||
}
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
package com.baiyue.firstleg.portschedule.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.AttemptRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest;
|
||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity;
|
||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
|
||||
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
|
||||
import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository;
|
||||
import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
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.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class CarrierScheduleQueryServiceTest {
|
||||
private static final LocalDateTime NOW = LocalDateTime.of(2026, 8, 10, 12, 0);
|
||||
@Mock private CarrierScheduleQueryRepository repository;
|
||||
@Mock private PortScheduleRepository portSchedules;
|
||||
@InjectMocks private CarrierScheduleQueryService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
set("objectMapper", new ObjectMapper().findAndRegisterModules());
|
||||
set("adapters", new CarrierScheduleAdapterRegistry());
|
||||
set("clock", Clock.fixed(Instant.parse("2026-08-10T04:00:00Z"), ZoneOffset.UTC));
|
||||
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
|
||||
when(repository.completeOperation(anyLong(), anyLong(), anyString(), anyString())).thenReturn(1);
|
||||
when(repository.attempts(anyLong())).thenReturn(List.of());
|
||||
when(repository.histories(anyLong())).thenReturn(List.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsTaskOnlyForBookingCustomerAndAllocatedContainer() {
|
||||
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
|
||||
when(portSchedules.oceanBooking(10L)).thenReturn(booking);
|
||||
when(portSchedules.containerBooking(20L)).thenReturn(new com.baiyue.firstleg.entity.ContainerBookingEntity());
|
||||
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
|
||||
|
||||
var result = service.create(7L, "admin", "carrier-create",
|
||||
new CreateRequest(0, 10001L, 10L, 20L, "ZIM", "BILL_OF_LADING", "GAOU6898053", null));
|
||||
|
||||
assertThat(result.query().query().getCarrierCode()).isEqualTo("ZIM");
|
||||
assertThat(result.query().query().getStatus()).isEqualTo("READY");
|
||||
assertThat(result.query().expired()).isTrue();
|
||||
verify(repository).insert(any(CarrierScheduleQueryEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordsManualRequiredAttemptWithoutBypassingCaptcha() {
|
||||
CarrierScheduleQueryEntity task = task(0, 0);
|
||||
when(repository.lock(1L)).thenReturn(task);
|
||||
when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1);
|
||||
when(repository.find(1L)).thenAnswer(invocation -> task);
|
||||
|
||||
var result = service.attempt(1L, 7L, "admin", "carrier-attempt",
|
||||
new AttemptRequest(0, "官网查询"));
|
||||
|
||||
assertThat(result.query().query().getStatus()).isEqualTo("MANUAL_REQUIRED");
|
||||
assertThat(result.query().query().getAttemptCount()).isEqualTo(1);
|
||||
assertThat(result.query().query().getManualReason()).contains("hCaptcha");
|
||||
ArgumentCaptor<CarrierScheduleQueryAttemptEntity> attempt =
|
||||
ArgumentCaptor.forClass(CarrierScheduleQueryAttemptEntity.class);
|
||||
verify(repository).insert(attempt.capture());
|
||||
assertThat(attempt.getValue().getCoverageGap()).isTrue();
|
||||
assertThat(attempt.getValue().getCoverageGapCode()).isEqualTo("HCAPTCHA_REQUIRED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void blocksFourthAttemptInsideSameThreeDayWindow() {
|
||||
CarrierScheduleQueryEntity task = task(3, 4);
|
||||
when(repository.lock(1L)).thenReturn(task);
|
||||
|
||||
assertThatThrownBy(() -> service.attempt(1L, 7L, "admin", "carrier-fourth",
|
||||
new AttemptRequest(4, null)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("最多查询3次");
|
||||
|
||||
verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void manualResultMustBelongToSameCustomerBookingAndContainer() {
|
||||
CarrierScheduleQueryEntity task = task(1, 1);
|
||||
when(repository.lock(1L)).thenReturn(task);
|
||||
PortScheduleEntity schedule = new PortScheduleEntity();
|
||||
schedule.setId(99L); schedule.setCustomerId(20002L); schedule.setOceanBookingId(10L);
|
||||
schedule.setContainerBookingId(20L); schedule.setObservedAt(NOW); schedule.setExpiresAt(NOW.plusDays(3));
|
||||
when(portSchedules.find(99L)).thenReturn(schedule);
|
||||
|
||||
assertThatThrownBy(() -> service.attachManualResult(1L, 7L, "admin", "carrier-manual",
|
||||
new ManualResultRequest(1, 99L, "人工核验")))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不一致");
|
||||
|
||||
verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString());
|
||||
}
|
||||
|
||||
private static CarrierScheduleQueryEntity task(int attempts, int version) {
|
||||
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity();
|
||||
value.setId(1L); value.setQueryNo("CSQ1"); value.setCustomerId(10001L);
|
||||
value.setOceanBookingId(10L); value.setContainerBookingId(20L); value.setCarrierCode("ZIM");
|
||||
value.setTrackingType("BILL_OF_LADING"); value.setTrackingNo("GAOU6898053");
|
||||
value.setOfficialUrl("https://www.zimchina.com/tools/track-a-shipment"); value.setAccessMode("HCAPTCHA");
|
||||
value.setStatus("MANUAL_REQUIRED"); value.setWindowStartedAt(NOW.minusDays(1));
|
||||
value.setAttemptCount(attempts); value.setNextAllowedAt(NOW); value.setVersion(version);
|
||||
return value;
|
||||
}
|
||||
|
||||
private void set(String fieldName, Object value) throws Exception {
|
||||
var field = CarrierScheduleQueryService.class.getDeclaredField(fieldName);
|
||||
field.setAccessible(true); field.set(service, value);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user