feat(appointments): expose authoritative arrival forecast observations

This commit is contained in:
2026-10-10 14:10:17 +08:00
parent 71111bf21b
commit 4b5b639adc
6 changed files with 199 additions and 0 deletions
@@ -0,0 +1,31 @@
package com.baiyue.firstleg.portschedule.mapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
class AppointmentForecastMapperTest {
@Test void keepsNewestTwoObservationsIncludingWithdrawnEtaAndExcludesOtherNodes() throws Exception {
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
try(var c=ds.getConnection();var s=c.createStatement()) {
s.execute("CREATE TABLE fl_container_booking(id BIGINT PRIMARY KEY,record_status VARCHAR(32))");
s.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT PRIMARY KEY,container_booking_id BIGINT,standard_node VARCHAR(64),provider_code VARCHAR(64),source_reference VARCHAR(128),observed_at TIMESTAMP,estimated_at TIMESTAMP,actual_at TIMESTAMP)");
s.execute("INSERT INTO fl_container_booking VALUES(1,'ACTIVE'),(2,'CANCELLED'),(3,'ACTIVE')");
for(int id=1;id<=3;id++) s.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES("+id+",1,'ARRIVED_DESTINATION_PORT','carrier','REF','2026-10-10 12:00:00',"+(id==3?"NULL":"'2026-10-15 09:00:00'")+",NULL)");
s.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(4,1,'DEPARTED_ORIGIN_PORT','carrier','OTHER','2026-10-11',NULL,NULL),(5,2,'ARRIVED_DESTINATION_PORT','carrier','REF','2026-10-11','2026-10-15',NULL),(6,3,'ARRIVED_DESTINATION_PORT','carrier','REF','2026-10-11',NULL,'2026-10-11')");
}
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);
config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));config.addMapper(AppointmentForecastMapper.class);
try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
var rows=session.getMapper(AppointmentForecastMapper.class).latest(List.of(1L,2L,3L));
assertThat(rows).extracting(AppointmentForecastMapper.Fact::factId).containsExactly(3L,2L,6L);
assertThat(rows.get(0).estimatedAt()).isNull();assertThat(rows.get(1).estimatedAt()).isNotNull();
assertThat(rows.get(2).actualAt()).isNotNull();
}
}
}
@@ -0,0 +1,34 @@
package com.baiyue.firstleg.portschedule.service;
import com.baiyue.firstleg.portschedule.mapper.AppointmentForecastMapper;
import com.baiyue.firstleg.portschedule.controller.InternalAppointmentForecastController;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class AppointmentForecastServiceTest {
@Test void preservesMissingForecastAndSourceLocalTimesWithoutSynthesizingEta() {
var mapper=mock(AppointmentForecastMapper.class);var service=new AppointmentForecastService(mapper);
var observed=LocalDateTime.of(2026,10,10,12,0);
var latest=new AppointmentForecastMapper.Fact(1L,20L,"carrier","REF",observed,null,null);
var previous=new AppointmentForecastMapper.Fact(1L,10L,"carrier","REF-OLD",observed.minusDays(1),observed.plusDays(5),null);
when(mapper.latest(List.of(1L,2L))).thenReturn(List.of(latest,previous));
var result=service.batch(List.of(1L,2L));
assertThat(result.get(0).latest()).isSameAs(latest);assertThat(result.get(0).previous()).isSameAs(previous);
assertThat(result.get(1).latest()).isNull();assertThat(result.get(1).timeBasis()).isEqualTo("SOURCE_LOCAL_UNSPECIFIED");
verify(mapper).latest(List.of(1L,2L));verifyNoMoreInteractions(mapper);
}
@Test void rejectsInvalidBatchesAndMissingOrBlankInternalCredentials() {
var mapper=mock(AppointmentForecastMapper.class);var service=new AppointmentForecastService(mapper);
for(var ids:List.of(List.<Long>of(),List.of(1L,1L),List.of(-1L),Collections.nCopies(201,1L)))
assertThatThrownBy(()->service.batch(ids)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
assertThatThrownBy(()->service.batch(null)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
var controller=new InternalAppointmentForecastController(service,"expected");
assertThatThrownBy(()->controller.batch(null,new InternalAppointmentForecastController.Request(List.of(1L)))).isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
assertThatThrownBy(()->controller.batch("wrong",new InternalAppointmentForecastController.Request(List.of(1L)))).isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
assertThatThrownBy(()->new InternalAppointmentForecastController(service,"").batch("",new InternalAppointmentForecastController.Request(List.of(1L)))).isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
verifyNoInteractions(mapper);
}
}