This commit is contained in:
2026-08-21 16:15:02 +08:00
parent 8e07342fa1
commit cd477fe40a
16 changed files with 278 additions and 72 deletions
@@ -19,8 +19,10 @@ class CarrierScheduleQuerySqlContractTest {
.contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation")
.contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact")
.contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_exception_fact")
.contains("attempt_count BETWEEN 0 AND 3")
.contains("window_attempt_no BETWEEN 1 AND 3")
.contains("累计自动查询调用次数")
.contains("下一个09:00、12:30或16:00固定查询槽位")
.doesNotContain("attempt_count BETWEEN 0 AND 3")
.doesNotContain("window_attempt_no BETWEEN 1 AND 3")
.contains("UNIQUE KEY uk_carrier_schedule_operation (operator_id,idempotency_key)")
.contains("船司查询尝试不可修改")
.contains("船司查询历史不可删除")
@@ -61,6 +63,10 @@ class CarrierScheduleQuerySqlContractTest {
.contains("uk_carrier_schedule_query_scope")
.contains("id IN (5102184,5102186,5102188)")
.contains("assert_carrier_schedule_query_schema")
.contains("migrate_carrier_schedule_fixed_slots")
.contains("DROP CHECK chk_carrier_schedule_query_attempt")
.contains("DROP CHECK chk_carrier_schedule_attempt_no")
.contains("SET @carrier_schedule_now = CONVERT_TZ(UTC_TIMESTAMP(3),'+00:00','+08:00')")
.contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'")
.contains("船司查询任务角色授权未完整创建")
.doesNotContain("'platform:firstleg:port-schedule-query:attempt','执行船司查询'");
@@ -4,18 +4,81 @@ import com.baiyue.firstleg.client.CarrierScheduleIntegrationClient;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository;
import org.junit.jupiter.api.Test;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.test.util.ReflectionTestUtils;
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.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class CarrierScheduleQuerySchedulerTest {
@Test
void declaresExactlyThreeConfigurableShanghaiCronTriggers() throws Exception {
Scheduled[] schedules = CarrierScheduleQueryScheduler.class.getMethod("poll").getAnnotationsByType(Scheduled.class);
assertThat(schedules).hasSize(3);
assertThat(schedules).extracting(Scheduled::cron).containsExactlyInAnyOrder(
"${baiyue.carrier-schedule.scheduler.cron-morning:0 0 9 * * *}",
"${baiyue.carrier-schedule.scheduler.cron-noon:0 30 12 * * *}",
"${baiyue.carrier-schedule.scheduler.cron-afternoon:0 0 16 * * *}");
assertThat(schedules).extracting(Scheduled::zone)
.containsOnly("${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}");
}
@Test
void drainsControlledBatchesAndStopsAtThePerTriggerSafetyLimit() {
CarrierScheduleQueryRepository repository = mock(CarrierScheduleQueryRepository.class);
CarrierScheduleQueryScheduler scheduler = spy(new CarrierScheduleQueryScheduler(repository,
mock(CarrierScheduleQueryService.class), mock(CarrierScheduleIntegrationClient.class)));
CarrierScheduleQueryEntity first = task(1L); CarrierScheduleQueryEntity second = task(2L);
CarrierScheduleQueryEntity third = task(3L);
when(repository.due(any(), anyInt())).thenReturn(List.of(first, second), List.of(third));
doNothing().when(scheduler).execute(any(), any());
ReflectionTestUtils.setField(scheduler, "batchSize", 2);
ReflectionTestUtils.setField(scheduler, "maxTasksPerTrigger", 3);
ReflectionTestUtils.setField(scheduler, "scheduleZone", "Asia/Shanghai");
ReflectionTestUtils.setField(scheduler, "clock",
Clock.fixed(Instant.parse("2026-08-21T01:00:00Z"), ZoneOffset.UTC));
scheduler.poll();
verify(repository).due(LocalDateTime.of(2026, 8, 21, 9, 0), 2);
verify(repository).due(LocalDateTime.of(2026, 8, 21, 9, 0), 1);
verify(scheduler, times(3)).execute(any(), any());
}
@Test
void keepsClaimingControlledBatchesUntilNoDueTasksRemain() {
CarrierScheduleQueryRepository repository = mock(CarrierScheduleQueryRepository.class);
CarrierScheduleQueryScheduler scheduler = spy(new CarrierScheduleQueryScheduler(repository,
mock(CarrierScheduleQueryService.class), mock(CarrierScheduleIntegrationClient.class)));
when(repository.due(any(), anyInt())).thenReturn(List.of(task(1L), task(2L)), List.of());
doNothing().when(scheduler).execute(any(), any());
ReflectionTestUtils.setField(scheduler, "batchSize", 2);
ReflectionTestUtils.setField(scheduler, "maxTasksPerTrigger", 10);
ReflectionTestUtils.setField(scheduler, "scheduleZone", "Asia/Shanghai");
ReflectionTestUtils.setField(scheduler, "clock",
Clock.fixed(Instant.parse("2026-08-21T04:30:00Z"), ZoneOffset.UTC));
scheduler.poll();
verify(repository, times(2)).due(LocalDateTime.of(2026, 8, 21, 12, 30), 2);
verify(scheduler, times(2)).execute(any(), any());
}
@Test
void leaseWinnerPersistsStandardNodesAndCompletesAttempt() {
CarrierScheduleQueryRepository repository = mock(CarrierScheduleQueryRepository.class);
@@ -42,4 +105,8 @@ class CarrierScheduleQuerySchedulerTest {
verify(service).applyProviderException(anyLong(), any());
verify(service).completeScheduledAttempt(1L, "SUCCESS", null, null);
}
private static CarrierScheduleQueryEntity task(long id) {
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity(); value.setId(id); return value;
}
}
@@ -71,7 +71,9 @@ class CarrierScheduleQueryServiceTest {
assertThat(result.query().query().carrierCode()).isEqualTo("ZIM");
assertThat(result.query().query().status()).isEqualTo("READY");
assertThat(result.query().expired()).isTrue();
verify(repository).insert(any(CarrierScheduleQueryEntity.class));
ArgumentCaptor<CarrierScheduleQueryEntity> created = ArgumentCaptor.forClass(CarrierScheduleQueryEntity.class);
verify(repository).insert(created.capture());
assertThat(created.getValue().getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30));
}
@Test
@@ -90,6 +92,7 @@ class CarrierScheduleQueryServiceTest {
assertThat(result.getCarrierCode()).isEqualTo("COSCO");
assertThat(result.getTrackingType()).isEqualTo("CONTAINER_NO");
assertThat(result.getLatestStandardNode()).isEqualTo("DEPARTED_WAREHOUSE");
assertThat(result.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30));
verify(repository).insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class));
}
@@ -212,7 +215,31 @@ class CarrierScheduleQueryServiceTest {
verify(repository).insert(attempt.capture());
assertThat(attempt.getValue().getAttemptStatus()).isEqualTo("NO_CHANGE");
assertThat(attempt.getValue().getOperatorName()).isEqualTo("carrier-scheduler");
assertThat(attempt.getValue().getWindowAttemptNo()).isEqualTo(1);
assertThat(task.getStatus()).isEqualTo("READY");
assertThat(task.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30));
}
@Test
void everySchedulerOutcomeUsesTheNextFixedSlotWithoutExtraRetryTimes() {
List<String> outcomes = List.of("SUCCESS", "NO_CHANGE", "MANUAL_REQUIRED", "RETRYABLE_FAILURE",
"PERMANENT_FAILURE");
List<CarrierScheduleQueryEntity> tasks = outcomes.stream().map(value -> task(0, 0)).toList();
when(repository.lock(1L)).thenReturn(tasks.get(0), tasks.get(1), tasks.get(2), tasks.get(3), tasks.get(4));
when(repository.find(1L)).thenReturn(tasks.get(0), tasks.get(1), tasks.get(2), tasks.get(3), tasks.get(4));
when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1);
outcomes.forEach(outcome -> service.completeScheduledAttempt(1L, outcome, null, null));
assertThat(tasks).allSatisfy(value -> {
assertThat(value.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30));
assertThat(value.getAttemptCount()).isEqualTo(1);
});
assertThat(tasks.get(0).getStatus()).isEqualTo("READY");
assertThat(tasks.get(1).getStatus()).isEqualTo("READY");
assertThat(tasks.get(2).getStatus()).isEqualTo("MANUAL_REQUIRED");
assertThat(tasks.get(3).getStatus()).isEqualTo("READY");
assertThat(tasks.get(4).getStatus()).isEqualTo("MANUAL_REQUIRED");
}
@Test
@@ -291,7 +318,7 @@ class CarrierScheduleQueryServiceTest {
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.setOfficialUrl("https://api.zim.com/"); value.setAccessMode("OFFICIAL_API");
value.setStatus("MANUAL_REQUIRED"); value.setWindowStartedAt(NOW.minusDays(1));
value.setAttemptCount(attempts); value.setNextAllowedAt(NOW); value.setVersion(version);
return value;
@@ -0,0 +1,24 @@
package com.baiyue.firstleg.portschedule.service;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
class CarrierScheduleQuerySlotsTest {
@Test
void returnsTheFirstStrictlyLaterShanghaiSlotAtExactBoundaries() {
assertThat(CarrierScheduleQuerySlots.nextSlot(at(8, 59, 59))).isEqualTo(at(9, 0, 0));
assertThat(CarrierScheduleQuerySlots.nextSlot(at(9, 0, 0))).isEqualTo(at(12, 30, 0));
assertThat(CarrierScheduleQuerySlots.nextSlot(at(12, 29, 59))).isEqualTo(at(12, 30, 0));
assertThat(CarrierScheduleQuerySlots.nextSlot(at(12, 30, 0))).isEqualTo(at(16, 0, 0));
assertThat(CarrierScheduleQuerySlots.nextSlot(at(15, 59, 59))).isEqualTo(at(16, 0, 0));
assertThat(CarrierScheduleQuerySlots.nextSlot(at(16, 0, 0)))
.isEqualTo(LocalDateTime.of(2026, 8, 22, 9, 0));
}
private static LocalDateTime at(int hour, int minute, int second) {
return LocalDateTime.of(2026, 8, 21, hour, minute, second);
}
}