diff --git a/src/ext-test/java/org/rutebanken/tiamat/ext/fintraffic/FintrafficIntegrationTest.java b/src/ext-test/java/org/rutebanken/tiamat/ext/fintraffic/FintrafficIntegrationTest.java new file mode 100644 index 0000000000..ce12ac818e --- /dev/null +++ b/src/ext-test/java/org/rutebanken/tiamat/ext/fintraffic/FintrafficIntegrationTest.java @@ -0,0 +1,36 @@ +/* + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the "Licence"); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + */ + +package org.rutebanken.tiamat.ext.fintraffic; + +import org.junit.AfterClass; +import org.rutebanken.tiamat.ext.fintraffic.config.FintrafficTestContextConfiguration; + +/** + * Base class for fintraffic integration tests that start a secondary Spring context. + *

+ * {@link org.rutebanken.tiamat.config.ApplicationContextProvider} uses a static field to hold + * the active {@code ApplicationContext}, which is a JVM-global singleton. When the fintraffic + * test context starts it overwrites this field via {@code RestoringApplicationContextProvider}, + * which saves the previous value. {@link #restoreApplicationContext()} restores it after all + * tests in the subclass complete, so the primary-context tests that follow get the correct beans. + */ +public abstract class FintrafficIntegrationTest { + + @AfterClass + public static void restoreApplicationContext() { + FintrafficTestContextConfiguration.restoreContext(); + } +} diff --git a/src/ext-test/java/org/rutebanken/tiamat/ext/fintraffic/FintrafficTiamatTestApplication.java b/src/ext-test/java/org/rutebanken/tiamat/ext/fintraffic/FintrafficTiamatTestApplication.java new file mode 100644 index 0000000000..85db83ca3c --- /dev/null +++ b/src/ext-test/java/org/rutebanken/tiamat/ext/fintraffic/FintrafficTiamatTestApplication.java @@ -0,0 +1,55 @@ +package org.rutebanken.tiamat.ext.fintraffic; + +import org.rutebanken.tiamat.auth.TiamatSecurityConfig; +import org.rutebanken.tiamat.config.ApplicationContextProvider; +import org.rutebanken.tiamat.ext.fintraffic.auth.FintrafficSecurityConfig; +import org.rutebanken.tiamat.ext.fintraffic.config.FintrafficTestContextConfiguration; +import org.rutebanken.tiamat.ext.fintraffic.model.FintrafficParking; +import org.rutebanken.tiamat.model.StopPlace; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.security.autoconfigure.actuate.web.servlet.ManagementWebSecurityAutoConfiguration; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.persistence.autoconfigure.EntityScan; +import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration; +import org.springframework.boot.security.autoconfigure.UserDetailsServiceAutoConfiguration; +import org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterAutoConfiguration; +import org.springframework.boot.security.autoconfigure.web.servlet.ServletWebSecurityAutoConfiguration; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; +import org.springframework.context.annotation.Import; +import org.springframework.data.jpa.convert.threeten.Jsr310JpaConverters; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * Spring Boot test application for Fintraffic extension integration tests. + * Extends the core TiamatTestApplication setup with: + *

+ * Security is excluded; authorization is mocked in individual tests. + */ +@SpringBootApplication(exclude = { + SecurityAutoConfiguration.class, + ManagementWebSecurityAutoConfiguration.class, + SecurityFilterAutoConfiguration.class, + ServletWebSecurityAutoConfiguration.class, + UserDetailsServiceAutoConfiguration.class +}) +@EnableTransactionManagement +@EnableCaching +@EntityScan(basePackageClasses = {StopPlace.class, FintrafficParking.class, Jsr310JpaConverters.class}) +@ComponentScan( + basePackages = "org.rutebanken.tiamat", + excludeFilters = { + @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, value = TiamatSecurityConfig.class), + @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, value = FintrafficSecurityConfig.class), + @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, value = ApplicationContextProvider.class) +}) +@Import(FintrafficTestContextConfiguration.class) +public class FintrafficTiamatTestApplication { + public static void main(String[] args) { + SpringApplication.run(FintrafficTiamatTestApplication.class, args); + } +} diff --git a/src/ext-test/java/org/rutebanken/tiamat/ext/fintraffic/api/ReadApiParkingIncrementalSyncIntegrationTest.java b/src/ext-test/java/org/rutebanken/tiamat/ext/fintraffic/api/ReadApiParkingIncrementalSyncIntegrationTest.java new file mode 100644 index 0000000000..50f4e4a528 --- /dev/null +++ b/src/ext-test/java/org/rutebanken/tiamat/ext/fintraffic/api/ReadApiParkingIncrementalSyncIntegrationTest.java @@ -0,0 +1,184 @@ +package org.rutebanken.tiamat.ext.fintraffic.api; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.rutebanken.tiamat.auth.AuthorizationService; +import org.rutebanken.tiamat.changelog.EntityChangedListener; +import org.rutebanken.tiamat.ext.fintraffic.FintrafficIntegrationTest; +import org.rutebanken.tiamat.ext.fintraffic.FintrafficTiamatTestApplication; +import org.rutebanken.tiamat.model.EmbeddableMultilingualString; +import org.rutebanken.tiamat.model.StopPlace; +import org.rutebanken.tiamat.model.StopTypeEnumeration; +import org.rutebanken.tiamat.repository.ParkingRepository; +import org.rutebanken.tiamat.repository.StopPlaceRepository; +import org.rutebanken.tiamat.rest.graphql.GraphQLNames; +import org.rutebanken.tiamat.versioning.save.StopPlaceVersionedSaverService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.context.annotation.Profile; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.junit4.SpringRunner; + +import java.util.Map; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.notNullValue; +import static org.rutebanken.tiamat.config.JerseyConfig.SERVICES_STOP_PLACE_PATH; + +/** + * Reproduces the reported scenario: a brand-new Parking created via the real GraphQL + * {@code mutateParking} editor path never appears in the Read API cache table, even though + * the incremental sync path ({@code ParkingVersionedSaverService.sendToJMS} → + * {@code ReadApiEntityChangedPublisher.onChange} → {@code ReadApiNetexMarshallingService + * .handleEntityChange}) is invoked for every save. Activates the real {@code + * fintraffic-read-api} profile (not mocked, unlike the other Read API unit tests) so the real + * marshaller, search key service and repository run end to end. + */ +@RunWith(SpringRunner.class) +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + classes = FintrafficTiamatTestApplication.class +) +@ActiveProfiles({"test", "gcs-blobstore", "fintraffic", "fintraffic-read-api"}) +@TestPropertySource(properties = "spring.main.allow-bean-definition-overriding=true") +public class ReadApiParkingIncrementalSyncIntegrationTest extends FintrafficIntegrationTest { + + private static final String BASE_URI_GRAPHQL = SERVICES_STOP_PLACE_PATH + "/graphql/"; + + /** + * The {@code test} profile activates {@code EntityChangedEventLocalPublisher} + * ({@code @Profile("local-changelog | test")}) alongside {@code fintraffic-read-api}'s + * {@code ReadApiEntityChangedPublisher}, so every bean that autowires the single-bean + * {@code EntityChangedListener} interface fails with a {@code NoUniqueBeanDefinitionException}. + * Real deployments only ever activate one of the two (see {@code + * spring.profiles.group.dev/tst/prd} in the peti-backend config, which never combines + * {@code test}/{@code local-changelog} with {@code fintraffic-read-api}), so this ambiguity + * is a test-only artifact. Marking the real Read API publisher {@code @Primary} here + * reproduces the production wiring for this test without changing any production bean. + *

+ * This class is a static nested {@code @TestConfiguration}, so the shared + * {@code FintrafficTiamatTestApplication}'s {@code @ComponentScan(basePackages = + * "org.rutebanken.tiamat")} picks it up as a real component in every test that boots that + * application context, not just this one. Gating it with {@code @Profile("fintraffic-read-api")} + * keeps it a no-op wherever that profile isn't active, so it can't break unrelated tests. + */ + @TestConfiguration + @Profile("fintraffic-read-api") + static class PrimaryEntityChangedListenerConfig { + @Bean + @Primary + EntityChangedListener primaryEntityChangedListener(ReadApiEntityChangedPublisher readApiEntityChangedPublisher) { + return readApiEntityChangedPublisher; + } + } + + @MockitoBean + private AuthorizationService authorizationService; + + @Value("${local.server.port}") + private int port; + + @Autowired + private StopPlaceRepository stopPlaceRepository; + + @Autowired + private StopPlaceVersionedSaverService stopPlaceVersionedSaverService; + + @Autowired + private ParkingRepository parkingRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private org.rutebanken.tiamat.ext.fintraffic.api.repository.NetexRepository netexRepository; + + @Autowired + private org.springframework.transaction.PlatformTransactionManager transactionManager; + + @Before + public void configureRestAssured() { + RestAssured.baseURI = "http://localhost"; + RestAssured.port = port; + } + + @After + public void cleanUp() { + parkingRepository.deleteAll(); + stopPlaceRepository.deleteAll(); + jdbcTemplate.update("DELETE FROM ext_fintraffic_netex_entity"); + } + + /** + * Reproduces the exact reported symptom: a brand-new Parking created via the real GraphQL + * editor path must (a) sync a {@code type='Parking'} row into the Read API cache table, and + * (b) actually be returned by the real {@code streamStopPlaces} repository method (the same + * one the {@code GET /api/fintraffic/v1/stops} endpoint uses) — not just be present in the + * table under some other type value. The StopPlace/Parking creation happens over real HTTP + * (a separate thread/transaction/connection), so only the final {@code streamStopPlaces} + * call (which requires an active transaction) is wrapped in one, via a {@code + * TransactionTemplate} — wrapping the whole test method in {@code @Transactional} would hide + * the StopPlace/Parking from the GraphQL server's own connection until commit. + */ + @Test + public void mutateParking_incrementalSync_writesRowToReadApiCacheTable() { + StopPlace stopPlace = new StopPlace(new EmbeddableMultilingualString("Test stop")); + stopPlace.setStopPlaceType(StopTypeEnumeration.ONSTREET_BUS); + stopPlace = stopPlaceVersionedSaverService.saveNewVersion(stopPlace); + String stopNetexId = stopPlace.getNetexId(); + + String mutation = """ + { + "query": "mutation { parking: %s (Parking: { name: { value: \\"Test parking\\" lang: \\"fi\\" } parkingType: parkAndRide parentSiteRef: \\"%s\\" }) { id } }", + "variables": "" + } + """.formatted(GraphQLNames.MUTATE_PARKING, stopNetexId); + + String parkingNetexId = given() + .port(port) + .contentType(ContentType.JSON) + .body(mutation) + .when() + .post(BASE_URI_GRAPHQL) + .then() + .statusCode(200) + .body("data.parking[0].id", notNullValue()) + .extract() + .path("data.parking[0].id"); + + Map row = jdbcTemplate.queryForMap( + "SELECT id, type, status FROM ext_fintraffic_netex_entity WHERE id = ?", parkingNetexId); + + assertThat(row) + .as("a brand-new Parking created via GraphQL must sync into the Read API cache table") + .containsEntry("id", parkingNetexId) + .containsEntry("type", "Parking") + .containsEntry("status", "CURRENT"); + + var transactionTemplate = new org.springframework.transaction.support.TransactionTemplate(transactionManager); + java.util.List matchingTypes = transactionTemplate.execute(status -> { + try (var stream = netexRepository.streamStopPlaces( + org.rutebanken.tiamat.ext.fintraffic.api.model.FintrafficReadApiSearchKey.empty())) { + return stream + .filter(r -> r.type().equals("Parking") && r.xml().contains(parkingNetexId)) + .map(r -> r.type()) + .toList(); + } + }); + assertThat(matchingTypes) + .as("the new Parking must be returned by the real Read API stream query") + .hasSize(1); + } +} diff --git a/src/ext-test/java/org/rutebanken/tiamat/ext/fintraffic/config/FintrafficTestContextConfiguration.java b/src/ext-test/java/org/rutebanken/tiamat/ext/fintraffic/config/FintrafficTestContextConfiguration.java new file mode 100644 index 0000000000..6113b3235a --- /dev/null +++ b/src/ext-test/java/org/rutebanken/tiamat/ext/fintraffic/config/FintrafficTestContextConfiguration.java @@ -0,0 +1,39 @@ +/* + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the "Licence"); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + */ + +package org.rutebanken.tiamat.ext.fintraffic.config; + +import org.rutebanken.tiamat.config.ApplicationContextProvider; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +/** + * Replaces {@link ApplicationContextProvider} in the fintraffic Spring test context with + * {@link RestoringApplicationContextProvider}, which saves and restores the static context + * field so that primary-context tests running after the fintraffic context is loaded still + * see the correct beans. + */ +@TestConfiguration +public class FintrafficTestContextConfiguration { + + @Bean + public ApplicationContextProvider applicationContextProvider() { + return new RestoringApplicationContextProvider(); + } + + public static void restoreContext() { + RestoringApplicationContextProvider.restoreContext(); + } +} diff --git a/src/ext-test/java/org/rutebanken/tiamat/ext/fintraffic/config/RestoringApplicationContextProvider.java b/src/ext-test/java/org/rutebanken/tiamat/ext/fintraffic/config/RestoringApplicationContextProvider.java new file mode 100644 index 0000000000..d3019e9156 --- /dev/null +++ b/src/ext-test/java/org/rutebanken/tiamat/ext/fintraffic/config/RestoringApplicationContextProvider.java @@ -0,0 +1,58 @@ +/* + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the "Licence"); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + */ + +package org.rutebanken.tiamat.ext.fintraffic.config; + +import org.rutebanken.tiamat.config.ApplicationContextProvider; +import org.springframework.beans.BeansException; +import org.springframework.context.ApplicationContext; + +/** + * Test-only replacement for {@link ApplicationContextProvider} used in the fintraffic Spring test context. + *

+ * {@link ApplicationContextProvider} stores the active {@link ApplicationContext} in a static field. + * When the fintraffic test context starts it overwrites this field, causing subsequent tests running + * in the primary test context to get the wrong beans via {@code ApplicationContextProvider}. + *

+ * This class saves the previous value of the static field when the fintraffic context sets it and + * exposes {@link #restoreContext()} so test teardown can restore the field before the primary-context + * tests that follow continue running. + *

+ * Registered as a {@code @Bean} in {@link FintrafficTestContextConfiguration} which also excludes + * the component-scanned {@link ApplicationContextProvider} from the fintraffic context. + */ +class RestoringApplicationContextProvider extends ApplicationContextProvider { + + private static ApplicationContext previousContext; + private static RestoringApplicationContextProvider instance; + + @Override + public void setApplicationContext(ApplicationContext ctx) throws BeansException { + previousContext = ApplicationContextProvider.getApplicationContext(); + instance = this; + super.setApplicationContext(ctx); + } + + private void applyContext(ApplicationContext ctx) throws BeansException { + super.setApplicationContext(ctx); + } + + static void restoreContext() { + if (instance != null && previousContext != null) { + instance.applyContext(previousContext); + previousContext = null; + } + } +} diff --git a/src/ext-test/java/org/rutebanken/tiamat/ext/fintraffic/importer/FintrafficMergingParkingImporterTest.java b/src/ext-test/java/org/rutebanken/tiamat/ext/fintraffic/importer/FintrafficMergingParkingImporterTest.java new file mode 100644 index 0000000000..7782c91dde --- /dev/null +++ b/src/ext-test/java/org/rutebanken/tiamat/ext/fintraffic/importer/FintrafficMergingParkingImporterTest.java @@ -0,0 +1,374 @@ +package org.rutebanken.tiamat.ext.fintraffic.importer; + +import org.junit.After; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.rutebanken.tiamat.auth.AuthorizationService; +import org.rutebanken.tiamat.ext.fintraffic.FintrafficIntegrationTest; +import org.rutebanken.tiamat.ext.fintraffic.FintrafficTiamatTestApplication; +import org.rutebanken.tiamat.ext.fintraffic.model.FintrafficInfoLink; +import org.rutebanken.tiamat.ext.fintraffic.model.FintrafficParking; +import org.rutebanken.tiamat.ext.fintraffic.model.FintrafficParkingAvailabilityCondition; +import org.rutebanken.tiamat.ext.fintraffic.model.FintrafficParkingEntranceForVehicles; +import org.rutebanken.tiamat.importer.merging.MergingParkingImporter; +import org.rutebanken.tiamat.model.Parking; +import org.rutebanken.tiamat.model.PaymentMethodEnumeration; +import org.rutebanken.tiamat.model.SiteRefStructure; +import org.rutebanken.tiamat.model.StopPlace; +import org.rutebanken.tiamat.repository.ParkingRepository; +import org.rutebanken.tiamat.repository.StopPlaceRepository; +import org.rutebanken.tiamat.versioning.save.ParkingVersionedSaverService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link FintrafficMergingParkingImporter}, verifying that + * {@link FintrafficParking#getPaymentMethods() paymentMethods} are preserved on both + * import paths: + *

+ */ +@RunWith(SpringRunner.class) +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + classes = FintrafficTiamatTestApplication.class +) +@ActiveProfiles({"test", "gcs-blobstore", "fintraffic"}) +@TestPropertySource(properties = "spring.main.allow-bean-definition-overriding=true") +public class FintrafficMergingParkingImporterTest extends FintrafficIntegrationTest { + + @MockitoBean + private AuthorizationService authorizationService; + + @Autowired + private MergingParkingImporter mergingParkingImporter; + + @Autowired + private ParkingVersionedSaverService parkingVersionedSaverService; + + @Autowired + private ParkingRepository parkingRepository; + + @Autowired + private StopPlaceRepository stopPlaceRepository; + + @After + public void cleanUp() { + parkingRepository.deleteAll(); + stopPlaceRepository.deleteAll(); + } + + @Test + public void importerIsFintrafficSubtype() { + assertThat(mergingParkingImporter) + .as("fintraffic profile must activate FintrafficMergingParkingImporter via @Primary") + .isInstanceOf(FintrafficMergingParkingImporter.class); + } + + @Test + @Transactional + public void handleCompletelyNewParking_preservesPaymentMethods() throws Exception { + StopPlace stopPlace = new StopPlace(); + stopPlaceRepository.save(stopPlace); + + FintrafficParking incoming = new FintrafficParking(); + incoming.setParentSiteRef(new SiteRefStructure(stopPlace.getNetexId())); + incoming.setPaymentMethods(List.of(PaymentMethodEnumeration.CASH, PaymentMethodEnumeration.CREDIT_CARD)); + + Parking saved = mergingParkingImporter.handleCompletelyNewParking(incoming); + + assertThat(saved).isInstanceOf(FintrafficParking.class); + assertThat(((FintrafficParking) saved).getPaymentMethods()) + .as("paymentMethods must be preserved when importing a completely new parking") + .containsExactlyInAnyOrder(PaymentMethodEnumeration.CASH, PaymentMethodEnumeration.CREDIT_CARD); + } + + @Test + @Transactional + public void handleAlreadyExistingParking_mergesPaymentMethods() { + StopPlace stopPlace = new StopPlace(); + stopPlaceRepository.save(stopPlace); + + // Existing parking with one payment method + FintrafficParking existing = new FintrafficParking(); + existing.setParentSiteRef(new SiteRefStructure(stopPlace.getNetexId())); + existing.setPaymentMethods(List.of(PaymentMethodEnumeration.CASH)); + existing = (FintrafficParking) parkingVersionedSaverService.saveNewVersion(existing); + + // Incoming parking with updated payment methods + FintrafficParking incoming = new FintrafficParking(); + incoming.setParentSiteRef(new SiteRefStructure(stopPlace.getNetexId())); + incoming.setPaymentMethods(List.of(PaymentMethodEnumeration.CREDIT_CARD, PaymentMethodEnumeration.DEBIT_CARD)); + + Parking result = mergingParkingImporter.handleAlreadyExistingParking(existing, incoming); + + assertThat(result).isInstanceOf(FintrafficParking.class); + assertThat(((FintrafficParking) result).getPaymentMethods()) + .as("paymentMethods from incoming parking must replace those on the version copy") + .containsExactlyInAnyOrder(PaymentMethodEnumeration.CREDIT_CARD, PaymentMethodEnumeration.DEBIT_CARD); + } + + @Test + @Transactional + public void handleAlreadyExistingParking_unchangedPaymentMethods_doesNotCreateNewVersion() { + StopPlace stopPlace = new StopPlace(); + stopPlaceRepository.save(stopPlace); + + FintrafficParking existing = new FintrafficParking(); + existing.setParentSiteRef(new SiteRefStructure(stopPlace.getNetexId())); + existing.setPaymentMethods(List.of(PaymentMethodEnumeration.CASH)); + existing = (FintrafficParking) parkingVersionedSaverService.saveNewVersion(existing); + long existingVersion = existing.getVersion(); + + FintrafficParking incoming = new FintrafficParking(); + incoming.setPaymentMethods(List.of(PaymentMethodEnumeration.CASH)); + + Parking result = mergingParkingImporter.handleAlreadyExistingParking(existing, incoming); + + assertThat(result.getVersion()) + .as("no new version must be created when paymentMethods are unchanged") + .isEqualTo(existingVersion); + } + + @Test + @Transactional + public void handleCompletelyNewParking_withPlainParking_preservesPaymentMethods() throws Exception { + StopPlace stopPlace = new StopPlace(); + stopPlaceRepository.save(stopPlace); + + // Simulate the NeTEx mapper: produces a plain Parking with the @Transient + // paymentMethods field populated from the NeTEx document. + Parking incoming = new Parking(); + incoming.setParentSiteRef(new SiteRefStructure(stopPlace.getNetexId())); + incoming.getPaymentMethods().add(PaymentMethodEnumeration.CASH); + + Parking saved = mergingParkingImporter.handleCompletelyNewParking(incoming); + + assertThat(saved) + .as("NeTEx-imported parking must be promoted to FintrafficParking") + .isInstanceOf(FintrafficParking.class); + assertThat(((FintrafficParking) saved).getPaymentMethods()) + .as("paymentMethods from plain NeTEx-derived Parking must be preserved") + .containsExactlyInAnyOrder(PaymentMethodEnumeration.CASH); + } + + @Test + @Transactional + public void handleAlreadyExistingParking_withPlainParking_mergesPaymentMethods() { + StopPlace stopPlace = new StopPlace(); + stopPlaceRepository.save(stopPlace); + + FintrafficParking existing = new FintrafficParking(); + existing.setParentSiteRef(new SiteRefStructure(stopPlace.getNetexId())); + existing.setPaymentMethods(List.of(PaymentMethodEnumeration.CASH)); + existing = (FintrafficParking) parkingVersionedSaverService.saveNewVersion(existing); + + // Simulate the NeTEx mapper: plain Parking with updated @Transient paymentMethods + Parking incoming = new Parking(); + incoming.setParentSiteRef(new SiteRefStructure(stopPlace.getNetexId())); + incoming.getPaymentMethods().add(PaymentMethodEnumeration.CREDIT_CARD); + + Parking result = mergingParkingImporter.handleAlreadyExistingParking(existing, incoming); + + assertThat(result).isInstanceOf(FintrafficParking.class); + assertThat(((FintrafficParking) result).getPaymentMethods()) + .as("paymentMethods from plain NeTEx-derived Parking must overwrite existing") + .containsExactlyInAnyOrder(PaymentMethodEnumeration.CREDIT_CARD); + } + + @Test + @Transactional + public void handleCompletelyNewParking_preservesInfoLinks() throws Exception { + StopPlace stopPlace = new StopPlace(); + stopPlaceRepository.save(stopPlace); + + FintrafficParking incoming = new FintrafficParking(); + incoming.setParentSiteRef(new SiteRefStructure(stopPlace.getNetexId())); + incoming.setInfoLinks(List.of( + new FintrafficInfoLink("https://example.com/parking", "resource"))); + + Parking saved = mergingParkingImporter.handleCompletelyNewParking(incoming); + + assertThat(saved).isInstanceOf(FintrafficParking.class); + assertThat(((FintrafficParking) saved).getInfoLinks()) + .as("infoLinks must be preserved when importing a completely new parking") + .containsExactly(new FintrafficInfoLink("https://example.com/parking", "resource")); + } + + @Test + @Transactional + public void handleAlreadyExistingParking_mergesInfoLinks() { + StopPlace stopPlace = new StopPlace(); + stopPlaceRepository.save(stopPlace); + + FintrafficParking existing = new FintrafficParking(); + existing.setParentSiteRef(new SiteRefStructure(stopPlace.getNetexId())); + existing.setInfoLinks(List.of(new FintrafficInfoLink("https://old.example.com", "info"))); + existing = (FintrafficParking) parkingVersionedSaverService.saveNewVersion(existing); + + FintrafficParking incoming = new FintrafficParking(); + incoming.setParentSiteRef(new SiteRefStructure(stopPlace.getNetexId())); + incoming.setInfoLinks(List.of(new FintrafficInfoLink("https://new.example.com", "resource"))); + + Parking result = mergingParkingImporter.handleAlreadyExistingParking(existing, incoming); + + assertThat(result).isInstanceOf(FintrafficParking.class); + assertThat(((FintrafficParking) result).getInfoLinks()) + .as("infoLinks from incoming parking must replace those on the version copy") + .containsExactly(new FintrafficInfoLink("https://new.example.com", "resource")); + } + + @Test + @Transactional + public void handleAlreadyExistingParking_unchangedInfoLinks_doesNotCreateNewVersion() { + StopPlace stopPlace = new StopPlace(); + stopPlaceRepository.save(stopPlace); + + FintrafficParking existing = new FintrafficParking(); + existing.setParentSiteRef(new SiteRefStructure(stopPlace.getNetexId())); + existing.setInfoLinks(List.of(new FintrafficInfoLink("https://example.com", "resource"))); + existing = (FintrafficParking) parkingVersionedSaverService.saveNewVersion(existing); + long existingVersion = existing.getVersion(); + + FintrafficParking incoming = new FintrafficParking(); + incoming.setInfoLinks(List.of(new FintrafficInfoLink("https://example.com", "resource"))); + + Parking result = mergingParkingImporter.handleAlreadyExistingParking(existing, incoming); + + assertThat(result.getVersion()) + .as("no new version must be created when infoLinks are unchanged") + .isEqualTo(existingVersion); + } + + @Test + @Transactional + public void handleCompletelyNewParking_preservesVehicleEntrances() throws Exception { + StopPlace stopPlace = new StopPlace(); + stopPlaceRepository.save(stopPlace); + + FintrafficParkingEntranceForVehicles entrance = new FintrafficParkingEntranceForVehicles( + "Main", "door", null, null, true, false, "A1"); + FintrafficParking incoming = new FintrafficParking(); + incoming.setParentSiteRef(new SiteRefStructure(stopPlace.getNetexId())); + incoming.setFintrafficVehicleEntrances(List.of(entrance)); + + Parking saved = mergingParkingImporter.handleCompletelyNewParking(incoming); + + assertThat(saved).isInstanceOf(FintrafficParking.class); + assertThat(((FintrafficParking) saved).getFintrafficVehicleEntrances()) + .as("vehicleEntrances must be preserved when importing a completely new parking") + .containsExactly(entrance); + } + + @Test + @Transactional + public void handleAlreadyExistingParking_mergesVehicleEntrances() { + StopPlace stopPlace = new StopPlace(); + stopPlaceRepository.save(stopPlace); + + FintrafficParking existing = new FintrafficParking(); + existing.setParentSiteRef(new SiteRefStructure(stopPlace.getNetexId())); + existing.setFintrafficVehicleEntrances(List.of( + new FintrafficParkingEntranceForVehicles("Old", "gate", null, null, true, true, null))); + existing = (FintrafficParking) parkingVersionedSaverService.saveNewVersion(existing); + + FintrafficParkingEntranceForVehicles newEntrance = + new FintrafficParkingEntranceForVehicles("New", "door", null, null, true, false, "B2"); + FintrafficParking incoming = new FintrafficParking(); + incoming.setParentSiteRef(new SiteRefStructure(stopPlace.getNetexId())); + incoming.setFintrafficVehicleEntrances(List.of(newEntrance)); + + Parking result = mergingParkingImporter.handleAlreadyExistingParking(existing, incoming); + + assertThat(result).isInstanceOf(FintrafficParking.class); + assertThat(((FintrafficParking) result).getFintrafficVehicleEntrances()) + .as("vehicleEntrances from incoming parking must replace those on the version copy") + .containsExactly(newEntrance); + } + + @Test + @Transactional + public void handleAlreadyExistingParking_unchangedVehicleEntrances_doesNotCreateNewVersion() { + StopPlace stopPlace = new StopPlace(); + stopPlaceRepository.save(stopPlace); + + FintrafficParkingEntranceForVehicles entrance = + new FintrafficParkingEntranceForVehicles("Main", "door", null, null, true, false, "A1"); + FintrafficParking existing = new FintrafficParking(); + existing.setParentSiteRef(new SiteRefStructure(stopPlace.getNetexId())); + existing.setFintrafficVehicleEntrances(List.of(entrance)); + existing = (FintrafficParking) parkingVersionedSaverService.saveNewVersion(existing); + long existingVersion = existing.getVersion(); + + FintrafficParking incoming = new FintrafficParking(); + incoming.setFintrafficVehicleEntrances(List.of(entrance)); + + Parking result = mergingParkingImporter.handleAlreadyExistingParking(existing, incoming); + + assertThat(result.getVersion()) + .as("no new version must be created when vehicleEntrances are unchanged") + .isEqualTo(existingVersion); + } + + @Test + @Transactional + public void handleAlreadyExistingParking_mergesAvailabilityConditions() { + StopPlace stopPlace = new StopPlace(); + stopPlaceRepository.save(stopPlace); + + FintrafficParking existing = new FintrafficParking(); + existing.setParentSiteRef(new SiteRefStructure(stopPlace.getNetexId())); + existing.setAvailabilityConditions(List.of( + new FintrafficParkingAvailabilityCondition("FSR:DayType:BusinessDay", true, LocalTime.of(8, 0), LocalTime.of(18, 0)))); + existing = (FintrafficParking) parkingVersionedSaverService.saveNewVersion(existing); + + FintrafficParking incoming = new FintrafficParking(); + incoming.setParentSiteRef(new SiteRefStructure(stopPlace.getNetexId())); + incoming.setAvailabilityConditions(List.of( + new FintrafficParkingAvailabilityCondition("FSR:DayType:Sunday", false, null, null))); + + Parking result = mergingParkingImporter.handleAlreadyExistingParking(existing, incoming); + + assertThat(result).isInstanceOf(FintrafficParking.class); + assertThat(((FintrafficParking) result).getAvailabilityConditions()) + .as("availabilityConditions from incoming parking must replace those on the version copy") + .containsExactly(new FintrafficParkingAvailabilityCondition("FSR:DayType:Sunday", false, null, null)); + } + + @Test + @Transactional + public void handleAlreadyExistingParking_unchangedAvailabilityConditions_doesNotCreateNewVersion() { + StopPlace stopPlace = new StopPlace(); + stopPlaceRepository.save(stopPlace); + + FintrafficParkingAvailabilityCondition condition = + new FintrafficParkingAvailabilityCondition("FSR:DayType:BusinessDay", true, LocalTime.of(6, 0), LocalTime.of(22, 0)); + FintrafficParking existing = new FintrafficParking(); + existing.setParentSiteRef(new SiteRefStructure(stopPlace.getNetexId())); + existing.setAvailabilityConditions(List.of(condition)); + existing = (FintrafficParking) parkingVersionedSaverService.saveNewVersion(existing); + long existingVersion = existing.getVersion(); + + FintrafficParking incoming = new FintrafficParking(); + incoming.setAvailabilityConditions(List.of(condition)); + + Parking result = mergingParkingImporter.handleAlreadyExistingParking(existing, incoming); + + assertThat(result.getVersion()) + .as("no new version must be created when availabilityConditions are unchanged") + .isEqualTo(existingVersion); + } +} diff --git a/src/ext-test/java/org/rutebanken/tiamat/ext/fintraffic/importer/FintrafficParkingMapperContributorTest.java b/src/ext-test/java/org/rutebanken/tiamat/ext/fintraffic/importer/FintrafficParkingMapperContributorTest.java new file mode 100644 index 0000000000..d13c080341 --- /dev/null +++ b/src/ext-test/java/org/rutebanken/tiamat/ext/fintraffic/importer/FintrafficParkingMapperContributorTest.java @@ -0,0 +1,579 @@ +package org.rutebanken.tiamat.ext.fintraffic.importer; + +import ma.glasnost.orika.MappingContext; +import jakarta.xml.bind.JAXBElement; +import org.junit.Before; +import org.junit.Test; +import org.rutebanken.netex.model.AvailabilityCondition; +import org.rutebanken.netex.model.DayTypeRefStructure; +import org.rutebanken.netex.model.DayTypes_RelStructure; +import org.rutebanken.netex.model.EntranceEnumeration; +import org.rutebanken.netex.model.GroupOfEntities_VersionStructure; +import org.rutebanken.netex.model.InfoLinkStructure; +import org.rutebanken.netex.model.MultilingualString; +import org.rutebanken.netex.model.ObjectFactory; +import org.rutebanken.netex.model.ParkingEntranceForVehicles; +import org.rutebanken.netex.model.ParkingEntrancesForVehicles_RelStructure; +import org.rutebanken.netex.model.Timeband; +import org.rutebanken.netex.model.Timeband_VersionedChildStructure; +import org.rutebanken.netex.model.Timebands_RelStructure; +import org.rutebanken.netex.model.TypeOfInfolinkEnumeration; +import org.rutebanken.netex.model.ValidBetween; +import org.rutebanken.netex.model.ValidityConditions_RelStructure; +import org.rutebanken.tiamat.ext.fintraffic.model.FintrafficParkingAvailabilityCondition; +import org.rutebanken.tiamat.ext.fintraffic.model.FintrafficInfoLink; +import org.rutebanken.tiamat.ext.fintraffic.model.FintrafficParking; +import org.rutebanken.tiamat.ext.fintraffic.model.FintrafficParkingEntranceForVehicles; +import org.rutebanken.tiamat.model.Parking; +import org.rutebanken.tiamat.model.PaymentMethodEnumeration; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Unit tests for {@link FintrafficParkingMapperContributor}. + */ +public class FintrafficParkingMapperContributorTest { + + private FintrafficParkingMapperContributor contributor; + private MappingContext mappingContext; + + @Before + public void setUp() { + contributor = new FintrafficParkingMapperContributor(); + mappingContext = mock(MappingContext.class); + } + + // --- paymentMethods --- + + @Test + public void mapFromNetex_copiesPaymentMethodsToTransientField() { + org.rutebanken.netex.model.Parking source = new org.rutebanken.netex.model.Parking() + .withPaymentMethods( + org.rutebanken.netex.model.PaymentMethodEnumeration.CASH, + org.rutebanken.netex.model.PaymentMethodEnumeration.CREDIT_CARD); + Parking target = new Parking(); + + contributor.mapFromNetex(source, target, mappingContext); + + assertThat(target.getPaymentMethods()) + .containsExactlyInAnyOrder( + PaymentMethodEnumeration.CASH, + PaymentMethodEnumeration.CREDIT_CARD); + } + + @Test + public void mapFromNetex_emptySource_leavesTargetUnchanged() { + org.rutebanken.netex.model.Parking source = new org.rutebanken.netex.model.Parking(); + Parking target = new Parking(); + + contributor.mapFromNetex(source, target, mappingContext); + + assertThat(target.getPaymentMethods()).isEmpty(); + } + + @Test + public void mapToNetex_copiesPaymentMethodsFromFintrafficParking() { + FintrafficParking source = new FintrafficParking(); + source.setPaymentMethods(List.of( + PaymentMethodEnumeration.CASH, + PaymentMethodEnumeration.CREDIT_CARD)); + org.rutebanken.netex.model.Parking target = new org.rutebanken.netex.model.Parking(); + + contributor.mapToNetex(source, target, mappingContext); + + assertThat(target.getPaymentMethods()) + .containsExactlyInAnyOrder( + org.rutebanken.netex.model.PaymentMethodEnumeration.CASH, + org.rutebanken.netex.model.PaymentMethodEnumeration.CREDIT_CARD); + } + + @Test + public void mapToNetex_plainParking_doesNothing() { + Parking source = new Parking(); + source.getPaymentMethods().add(PaymentMethodEnumeration.CASH); + org.rutebanken.netex.model.Parking target = new org.rutebanken.netex.model.Parking(); + + contributor.mapToNetex(source, target, mappingContext); + + assertThat(target.getPaymentMethods()).isEmpty(); + } + + // --- infoLinks --- + + @Test + public void mapFromNetex_copiesInfoLinksToFintrafficParking() { + var infoLinks = new GroupOfEntities_VersionStructure.InfoLinks() + .withInfoLink( + new InfoLinkStructure() + .withValue("https://example.com/parking") + .withTypeOfInfoLink(TypeOfInfolinkEnumeration.RESOURCE), + new InfoLinkStructure() + .withValue("https://example.com/info")); + org.rutebanken.netex.model.Parking source = new org.rutebanken.netex.model.Parking(); + source.setInfoLinks(infoLinks); + FintrafficParking target = new FintrafficParking(); + + contributor.mapFromNetex(source, target, mappingContext); + + assertThat(target.getInfoLinks()) + .hasSize(2) + .contains(new FintrafficInfoLink("https://example.com/parking", "resource")) + .contains(new FintrafficInfoLink("https://example.com/info", null)); + } + + @Test + public void mapFromNetex_noInfoLinks_leavesTargetEmpty() { + org.rutebanken.netex.model.Parking source = new org.rutebanken.netex.model.Parking(); + FintrafficParking target = new FintrafficParking(); + + contributor.mapFromNetex(source, target, mappingContext); + + assertThat(target.getInfoLinks()).isEmpty(); + } + + @Test + public void mapFromNetex_plainParking_infoLinksIgnored() { + var infoLinks = new GroupOfEntities_VersionStructure.InfoLinks() + .withInfoLink(new InfoLinkStructure().withValue("https://example.com")); + org.rutebanken.netex.model.Parking source = new org.rutebanken.netex.model.Parking(); + source.setInfoLinks(infoLinks); + Parking target = new Parking(); // plain, not FintrafficParking + + contributor.mapFromNetex(source, target, mappingContext); + + // no exception, just silently ignored + } + + @Test + public void mapToNetex_copiesInfoLinksFromFintrafficParking() { + FintrafficParking source = new FintrafficParking(); + source.setInfoLinks(List.of( + new FintrafficInfoLink("https://example.com/resource", "resource"), + new FintrafficInfoLink("https://example.com/plain", null))); + org.rutebanken.netex.model.Parking target = new org.rutebanken.netex.model.Parking(); + + contributor.mapToNetex(source, target, mappingContext); + + assertThat(target.getInfoLinks()).isNotNull(); + var netexLinks = target.getInfoLinks().getInfoLink(); + assertThat(netexLinks).hasSize(2); + assertThat(netexLinks.get(0).getValue()).isEqualTo("https://example.com/resource"); + assertThat(netexLinks.get(0).getTypeOfInfoLink()) + .containsExactly(TypeOfInfolinkEnumeration.RESOURCE); + assertThat(netexLinks.get(1).getValue()).isEqualTo("https://example.com/plain"); + assertThat(netexLinks.get(1).getTypeOfInfoLink()).isEmpty(); + } + + @Test + public void mapToNetex_emptyInfoLinks_doesNotSetField() { + FintrafficParking source = new FintrafficParking(); + org.rutebanken.netex.model.Parking target = new org.rutebanken.netex.model.Parking(); + + contributor.mapToNetex(source, target, mappingContext); + + assertThat(target.getInfoLinks()).isNull(); + } + + // --- vehicleEntrances --- + + @Test + public void mapFromNetex_copiesVehicleEntrancesToFintrafficParking() { + ParkingEntranceForVehicles entrance = new ParkingEntranceForVehicles() + .withLabel(new MultilingualString().withValue("Main entrance")) + .withEntranceType(EntranceEnumeration.DOOR) + .withWidth(new BigDecimal("3.50")) + .withHeight(new BigDecimal("2.20")) + .withIsEntry(true) + .withIsExit(false) + .withPublicCode("A1"); + ParkingEntrancesForVehicles_RelStructure relStruct = new ParkingEntrancesForVehicles_RelStructure() + .withParkingEntranceForVehiclesRefOrParkingEntranceForVehicles(entrance); + org.rutebanken.netex.model.Parking source = new org.rutebanken.netex.model.Parking(); + source.setVehicleEntrances(relStruct); + FintrafficParking target = new FintrafficParking(); + + contributor.mapFromNetex(source, target, mappingContext); + + assertThat(target.getFintrafficVehicleEntrances()).hasSize(1); + FintrafficParkingEntranceForVehicles mapped = target.getFintrafficVehicleEntrances().getFirst(); + assertThat(mapped.getLabel()).isEqualTo("Main entrance"); + assertThat(mapped.getEntranceType()).isEqualTo("door"); + assertThat(mapped.getWidth()).isEqualByComparingTo(new BigDecimal("3.50")); + assertThat(mapped.getHeight()).isEqualByComparingTo(new BigDecimal("2.20")); + assertThat(mapped.getIsEntry()).isTrue(); + assertThat(mapped.getIsExit()).isFalse(); + assertThat(mapped.getPublicCode()).isEqualTo("A1"); + } + + @Test + public void mapFromNetex_noVehicleEntrances_leavesTargetEmpty() { + org.rutebanken.netex.model.Parking source = new org.rutebanken.netex.model.Parking(); + FintrafficParking target = new FintrafficParking(); + + contributor.mapFromNetex(source, target, mappingContext); + + assertThat(target.getFintrafficVehicleEntrances()).isEmpty(); + } + + @Test + public void mapToNetex_copiesVehicleEntrancesFromFintrafficParking() { + FintrafficParking source = new FintrafficParking(); + source.setFintrafficVehicleEntrances(List.of( + new FintrafficParkingEntranceForVehicles("Exit", "gate", + new BigDecimal("4.00"), new BigDecimal("3.00"), false, true, "B2"))); + org.rutebanken.netex.model.Parking target = new org.rutebanken.netex.model.Parking(); + + contributor.mapToNetex(source, target, mappingContext); + + assertThat(target.getVehicleEntrances()).isNotNull(); + var items = target.getVehicleEntrances() + .getParkingEntranceForVehiclesRefOrParkingEntranceForVehicles(); + assertThat(items).hasSize(1); + ParkingEntranceForVehicles netex = (ParkingEntranceForVehicles) items.getFirst(); + assertThat(netex.getLabel().getValue()).isEqualTo("Exit"); + assertThat(netex.getEntranceType()).isEqualTo(EntranceEnumeration.GATE); + assertThat(netex.getWidth()).isEqualByComparingTo(new BigDecimal("4.00")); + assertThat(netex.getHeight()).isEqualByComparingTo(new BigDecimal("3.00")); + assertThat(netex.isIsEntry()).isFalse(); + assertThat(netex.isIsExit()).isTrue(); + assertThat(netex.getPublicCode()).isEqualTo("B2"); + } + + /** + * Every exported {@code ParkingEntranceForVehicles} must carry a unique id+version, + * or NeTEx export fails schema validation with "no value for the key + * ParkingEntranceForVehicles_AnyVersionedKey" — see {@code + * FintrafficGraphQLParkingIntegrationTest + * #export_stopPlaceWithGraphQlSetVehicleEntrances_doesNotFail} for the full + * end-to-end reproduction/regression test. + */ + @Test + public void mapToNetex_vehicleEntrances_assignsUniqueIdAndVersionPerEntrance() { + FintrafficParking source = new FintrafficParking(); + source.setNetexId("NSR:FintrafficParking:220"); + source.setFintrafficVehicleEntrances(List.of( + new FintrafficParkingEntranceForVehicles("Main", "door", null, null, true, false, "A1"), + new FintrafficParkingEntranceForVehicles("Exit", "gate", null, null, false, true, "B2"))); + org.rutebanken.netex.model.Parking target = new org.rutebanken.netex.model.Parking(); + + contributor.mapToNetex(source, target, mappingContext); + + var items = target.getVehicleEntrances() + .getParkingEntranceForVehiclesRefOrParkingEntranceForVehicles(); + ParkingEntranceForVehicles first = (ParkingEntranceForVehicles) items.get(0); + ParkingEntranceForVehicles second = (ParkingEntranceForVehicles) items.get(1); + + assertThat(first.getId()).isEqualTo("NSR:ParkingEntranceForVehicles:220_1"); + assertThat(first.getVersion()).isEqualTo("1"); + assertThat(second.getId()).isEqualTo("NSR:ParkingEntranceForVehicles:220_2"); + assertThat(second.getVersion()).isEqualTo("1"); + assertThat(first.getId()).isNotEqualTo(second.getId()); + } + + @Test + public void mapToNetex_vehicleEntrances_withoutParkingNetexId_leavesIdUnset() { + FintrafficParking source = new FintrafficParking(); // no netexId set + source.setFintrafficVehicleEntrances(List.of( + new FintrafficParkingEntranceForVehicles("Main", "door", null, null, true, false, "A1"))); + org.rutebanken.netex.model.Parking target = new org.rutebanken.netex.model.Parking(); + + contributor.mapToNetex(source, target, mappingContext); + + ParkingEntranceForVehicles netex = (ParkingEntranceForVehicles) target.getVehicleEntrances() + .getParkingEntranceForVehiclesRefOrParkingEntranceForVehicles().getFirst(); + assertThat(netex.getId()).isNull(); + } + + @Test + public void mapToNetex_emptyVehicleEntrances_doesNotSetField() { + FintrafficParking source = new FintrafficParking(); + org.rutebanken.netex.model.Parking target = new org.rutebanken.netex.model.Parking(); + + contributor.mapToNetex(source, target, mappingContext); + + assertThat(target.getVehicleEntrances()).isNull(); + } + + // --- availabilityConditions --- + + @Test + public void mapFromNetex_copiesAvailabilityConditionsToFintrafficParking() { + ObjectFactory objectFactory = new ObjectFactory(); + + AvailabilityCondition availabilityCondition = new AvailabilityCondition() + .withIsAvailable(true) + .withDayTypes(new DayTypes_RelStructure() + .withDayTypeRefOrDayType_(objectFactory.createDayTypeRef( + new DayTypeRefStructure().withRef("FSR:DayType:BusinessDay")))) + .withTimebands(new Timebands_RelStructure() + .withTimebandRefOrTimeband(objectFactory.createTimeband( + new Timeband() + .withStartTime(LocalTime.of(6, 0)) + .withEndTime(LocalTime.of(22, 0))))); + + ValidityConditions_RelStructure validityConditions = new ValidityConditions_RelStructure(); + validityConditions.getValidityConditionRefOrValidBetweenOrValidityCondition_() + .add(objectFactory.createAvailabilityCondition(availabilityCondition)); + validityConditions.getValidityConditionRefOrValidBetweenOrValidityCondition_() + .add(new ValidBetween().withFromDate(LocalDateTime.of(2026, 1, 1, 0, 0))); + + org.rutebanken.netex.model.Parking source = new org.rutebanken.netex.model.Parking(); + source.setValidityConditions(validityConditions); + FintrafficParking target = new FintrafficParking(); + + contributor.mapFromNetex(source, target, mappingContext); + + assertThat(target.getAvailabilityConditions()) + .containsExactly(new FintrafficParkingAvailabilityCondition( + "FSR:DayType:BusinessDay", + true, + LocalTime.of(6, 0), + LocalTime.of(22, 0) + )); + } + + @Test + public void mapToNetex_appendsAvailabilityConditionsWithoutRemovingValidBetween() { + FintrafficParking source = new FintrafficParking(); + source.setAvailabilityConditions(List.of( + new FintrafficParkingAvailabilityCondition("FSR:DayType:Sunday", false, null, null))); + + ValidityConditions_RelStructure validityConditions = new ValidityConditions_RelStructure(); + ValidBetween validBetween = new ValidBetween().withFromDate(LocalDateTime.of(2026, 1, 1, 0, 0)); + validityConditions.getValidityConditionRefOrValidBetweenOrValidityCondition_().add(validBetween); + + org.rutebanken.netex.model.Parking target = new org.rutebanken.netex.model.Parking(); + target.setValidityConditions(validityConditions); + + contributor.mapToNetex(source, target, mappingContext); + + assertThat(target.getValidityConditions()).isNotNull(); + assertThat(target.getValidityConditions().getValidityConditionRefOrValidBetweenOrValidityCondition_()) + .hasSize(2) + .contains(validBetween); + + Object availabilityEntry = target.getValidityConditions() + .getValidityConditionRefOrValidBetweenOrValidityCondition_() + .stream() + .filter(entry -> entry instanceof JAXBElement jaxb && + jaxb.getValue() instanceof AvailabilityCondition) + .findFirst() + .orElseThrow(); + + AvailabilityCondition mapped = (AvailabilityCondition) ((JAXBElement) availabilityEntry).getValue(); + assertThat(mapped.isIsAvailable()).isFalse(); + assertThat(mapped.getDayTypes().getDayTypeRefOrDayType_()).hasSize(1); + assertThat(mapped.getDayTypes().getDayTypeRefOrDayType_().getFirst().getValue()) + .isInstanceOf(DayTypeRefStructure.class); + assertThat(((DayTypeRefStructure) mapped.getDayTypes().getDayTypeRefOrDayType_().getFirst().getValue()).getRef()) + .isEqualTo("FSR:DayType:Sunday"); + assertThat(mapped.getTimebands()).isNull(); + } + + @Test + public void mapToNetex_calledTwiceOnSameTarget_doesNotDuplicateAvailabilityConditions() { + // The NeTEx export pipeline maps the same Parking to NeTEx more than once per export + // (e.g. once per frame that embeds it), reusing the same target Parking NeTEx object. A naive + // append would duplicate AvailabilityCondition/Timeband entries with identical (deterministic) ids, + // violating NeTEx's ValidityCondition_AnyVersionedKey uniqueness constraint on export. + FintrafficParking source = new FintrafficParking(); + source.setAvailabilityConditions(List.of( + new FintrafficParkingAvailabilityCondition("FSR:DayType:BusinessDay", true, LocalTime.of(6, 0), LocalTime.of(22, 0)), + new FintrafficParkingAvailabilityCondition("FSR:DayType:Sunday", false, null, null))); + + ValidityConditions_RelStructure validityConditions = new ValidityConditions_RelStructure(); + ValidBetween validBetween = new ValidBetween().withFromDate(LocalDateTime.of(2026, 1, 1, 0, 0)); + validityConditions.getValidityConditionRefOrValidBetweenOrValidityCondition_().add(validBetween); + + org.rutebanken.netex.model.Parking target = new org.rutebanken.netex.model.Parking(); + target.setValidityConditions(validityConditions); + + contributor.mapToNetex(source, target, mappingContext); + contributor.mapToNetex(source, target, mappingContext); + + List entries = target.getValidityConditions() + .getValidityConditionRefOrValidBetweenOrValidityCondition_(); + assertThat(entries).contains(validBetween); + + List availabilityConditions = entries.stream() + .filter(entry -> entry instanceof JAXBElement jaxb && jaxb.getValue() instanceof AvailabilityCondition) + .map(entry -> (AvailabilityCondition) ((JAXBElement) entry).getValue()) + .toList(); + assertThat(availabilityConditions) + .as("a repeat mapToNetex call must not duplicate previously-added AvailabilityConditions") + .hasSize(2); + assertThat(availabilityConditions.stream().map(AvailabilityCondition::getId).distinct().count()) + .as("all AvailabilityCondition ids must remain unique") + .isEqualTo(2); + } + + @Test + public void mapToNetex_exportsTimebandWhenOnlyEndTimeIsSet() { + FintrafficParking source = new FintrafficParking(); + source.setAvailabilityConditions(List.of( + new FintrafficParkingAvailabilityCondition("FSR:DayType:Sunday", true, null, LocalTime.of(22, 0)))); + + org.rutebanken.netex.model.Parking target = new org.rutebanken.netex.model.Parking(); + + contributor.mapToNetex(source, target, mappingContext); + + List entries = target.getValidityConditions() + .getValidityConditionRefOrValidBetweenOrValidityCondition_(); + assertThat(entries).hasSize(1); + AvailabilityCondition mapped = (AvailabilityCondition) + ((JAXBElement) entries.getFirst()).getValue(); + assertThat(mapped.getTimebands()).isNotNull(); + Timeband_VersionedChildStructure timeband = + (Timeband_VersionedChildStructure) mapped.getTimebands().getTimebandRefOrTimeband().getFirst(); + assertThat(timeband.getStartTime()).isNull(); + assertThat(timeband.getEndTime()).isEqualTo(LocalTime.of(22, 0)); + } + + @Test + public void mapFromNetex_acceptsRawUnwrappedInlineTimeband() { + // A schema-valid inline Timeband is a raw (non-JAXBElement-wrapped) + // Timeband_VersionedChildStructure, per Timebands_RelStructure's @XmlElements mapping. This is exactly + // what a re-import of Tiamat's own corrected NeTEx export produces. + ObjectFactory objectFactory = new ObjectFactory(); + + AvailabilityCondition availabilityCondition = new AvailabilityCondition() + .withIsAvailable(true) + .withDayTypes(new DayTypes_RelStructure() + .withDayTypeRefOrDayType_(objectFactory.createDayTypeRef( + new DayTypeRefStructure().withRef("FSR:DayType:BusinessDay")))) + .withTimebands(new Timebands_RelStructure() + .withTimebandRefOrTimeband(new Timeband_VersionedChildStructure() + .withStartTime(LocalTime.of(6, 0)) + .withEndTime(LocalTime.of(22, 0)))); + + ValidityConditions_RelStructure validityConditions = new ValidityConditions_RelStructure(); + validityConditions.getValidityConditionRefOrValidBetweenOrValidityCondition_() + .add(objectFactory.createAvailabilityCondition(availabilityCondition)); + + org.rutebanken.netex.model.Parking source = new org.rutebanken.netex.model.Parking(); + source.setValidityConditions(validityConditions); + FintrafficParking target = new FintrafficParking(); + + contributor.mapFromNetex(source, target, mappingContext); + + assertThat(target.getAvailabilityConditions()) + .containsExactly(new FintrafficParkingAvailabilityCondition( + "FSR:DayType:BusinessDay", + true, + LocalTime.of(6, 0), + LocalTime.of(22, 0) + )); + } + + @Test + public void mapFromNetex_deduplicatesAvailabilityConditionsByDayTypeRef_keepingLast() { + ObjectFactory objectFactory = new ObjectFactory(); + + AvailabilityCondition first = new AvailabilityCondition() + .withIsAvailable(true) + .withDayTypes(new DayTypes_RelStructure() + .withDayTypeRefOrDayType_(objectFactory.createDayTypeRef( + new DayTypeRefStructure().withRef("FSR:DayType:BusinessDay")))) + .withTimebands(new Timebands_RelStructure() + .withTimebandRefOrTimeband(objectFactory.createTimeband( + new Timeband().withStartTime(LocalTime.of(6, 0)).withEndTime(LocalTime.of(18, 0))))); + + AvailabilityCondition duplicate = new AvailabilityCondition() + .withIsAvailable(true) + .withDayTypes(new DayTypes_RelStructure() + .withDayTypeRefOrDayType_(objectFactory.createDayTypeRef( + new DayTypeRefStructure().withRef("FSR:DayType:BusinessDay")))) + .withTimebands(new Timebands_RelStructure() + .withTimebandRefOrTimeband(objectFactory.createTimeband( + new Timeband().withStartTime(LocalTime.of(7, 0)).withEndTime(LocalTime.of(22, 0))))); + + ValidityConditions_RelStructure validityConditions = new ValidityConditions_RelStructure(); + validityConditions.getValidityConditionRefOrValidBetweenOrValidityCondition_() + .add(objectFactory.createAvailabilityCondition(first)); + validityConditions.getValidityConditionRefOrValidBetweenOrValidityCondition_() + .add(objectFactory.createAvailabilityCondition(duplicate)); + + org.rutebanken.netex.model.Parking source = new org.rutebanken.netex.model.Parking(); + source.setValidityConditions(validityConditions); + FintrafficParking target = new FintrafficParking(); + + contributor.mapFromNetex(source, target, mappingContext); + + assertThat(target.getAvailabilityConditions()).hasSize(1); + assertThat(target.getAvailabilityConditions().getFirst().getStartTime()) + .as("last duplicate wins") + .isEqualTo(LocalTime.of(7, 0)); + } + + // --- lighting --- + + @Test + public void mapFromNetex_copiesLightingToFintrafficParking() { + org.rutebanken.netex.model.Parking source = new org.rutebanken.netex.model.Parking() + .withLighting(org.rutebanken.netex.model.LightingEnumeration.WELL_LIT); + FintrafficParking target = new FintrafficParking(); + + contributor.mapFromNetex(source, target, mappingContext); + + assertThat(target.getLighting()).isEqualTo(org.rutebanken.tiamat.model.LightingEnumeration.WELL_LIT); + } + + @Test + public void mapFromNetex_noLighting_leavesTargetUnset() { + org.rutebanken.netex.model.Parking source = new org.rutebanken.netex.model.Parking(); + FintrafficParking target = new FintrafficParking(); + + contributor.mapFromNetex(source, target, mappingContext); + + assertThat(target.getLighting()).isNull(); + } + + @Test + public void mapFromNetex_plainParking_lightingIgnored() { + org.rutebanken.netex.model.Parking source = new org.rutebanken.netex.model.Parking() + .withLighting(org.rutebanken.netex.model.LightingEnumeration.WELL_LIT); + Parking target = new Parking(); // plain, not FintrafficParking + + contributor.mapFromNetex(source, target, mappingContext); + + // no exception, just silently ignored + } + + @Test + public void mapToNetex_copiesLightingFromFintrafficParking() { + FintrafficParking source = new FintrafficParking(); + source.setLighting(org.rutebanken.tiamat.model.LightingEnumeration.POORLY_LIT); + org.rutebanken.netex.model.Parking target = new org.rutebanken.netex.model.Parking(); + + contributor.mapToNetex(source, target, mappingContext); + + assertThat(target.getLighting()).isEqualTo(org.rutebanken.netex.model.LightingEnumeration.POORLY_LIT); + } + + @Test + public void mapToNetex_noLighting_doesNotSetField() { + FintrafficParking source = new FintrafficParking(); + org.rutebanken.netex.model.Parking target = new org.rutebanken.netex.model.Parking(); + + contributor.mapToNetex(source, target, mappingContext); + + assertThat(target.getLighting()).isNull(); + } + + @Test + public void mapToNetex_plainParking_doesNothingForLighting() { + Parking source = new Parking(); + source.setLighting(org.rutebanken.tiamat.model.LightingEnumeration.UNLIT); + org.rutebanken.netex.model.Parking target = new org.rutebanken.netex.model.Parking(); + + contributor.mapToNetex(source, target, mappingContext); + + assertThat(target.getLighting()).isNull(); + } +} diff --git a/src/ext-test/java/org/rutebanken/tiamat/ext/fintraffic/model/FintrafficParkingEntityFactoryTest.java b/src/ext-test/java/org/rutebanken/tiamat/ext/fintraffic/model/FintrafficParkingEntityFactoryTest.java new file mode 100644 index 0000000000..4205295e67 --- /dev/null +++ b/src/ext-test/java/org/rutebanken/tiamat/ext/fintraffic/model/FintrafficParkingEntityFactoryTest.java @@ -0,0 +1,42 @@ +package org.rutebanken.tiamat.ext.fintraffic.model; + +import org.junit.jupiter.api.Test; +import org.rutebanken.tiamat.model.Parking; +import org.rutebanken.tiamat.model.PaymentMethodEnumeration; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class FintrafficParkingEntityFactoryTest { + + private final FintrafficParkingEntityFactory factory = new FintrafficParkingEntityFactory(); + + @Test + void create_returnsFintrafficParkingInstance() { + Parking parking = factory.create(); + + assertThat(parking).isInstanceOf(FintrafficParking.class); + } + + @Test + void getEntityClass_returnsFintrafficParkingClass() { + assertThat(factory.getEntityClass()).isEqualTo(FintrafficParking.class); + } + + @Test + void getMappingExclusions_containsPaymentMethods() { + List exclusions = factory.getMappingExclusions(); + + assertThat(exclusions) + .as("paymentMethods must be excluded from Orika auto-mapping: the two PaymentMethodEnumeration types differ and require explicit conversion in FintrafficParkingMapperContributor") + .contains("paymentMethods"); + } + + @Test + void getMappingExclusions_stillExcludesOtherUnsupportedFields() { + List exclusions = factory.getMappingExclusions(); + + assertThat(exclusions).contains("cardsAccepted", "currenciesAccepted", "accessModes"); + } +} diff --git a/src/ext-test/java/org/rutebanken/tiamat/ext/fintraffic/model/FintrafficParkingIntegrationTest.java b/src/ext-test/java/org/rutebanken/tiamat/ext/fintraffic/model/FintrafficParkingIntegrationTest.java new file mode 100644 index 0000000000..b3adbebf64 --- /dev/null +++ b/src/ext-test/java/org/rutebanken/tiamat/ext/fintraffic/model/FintrafficParkingIntegrationTest.java @@ -0,0 +1,139 @@ +package org.rutebanken.tiamat.ext.fintraffic.model; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.junit.After; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.rutebanken.tiamat.auth.AuthorizationService; +import org.rutebanken.tiamat.ext.fintraffic.FintrafficIntegrationTest; +import org.rutebanken.tiamat.ext.fintraffic.FintrafficTiamatTestApplication; +import org.rutebanken.tiamat.model.Parking; +import org.rutebanken.tiamat.model.PaymentMethodEnumeration; +import org.rutebanken.tiamat.model.factory.ParkingEntityFactory; +import org.rutebanken.tiamat.netex.mapping.NetexMapper; +import org.rutebanken.tiamat.repository.ParkingRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test verifying that {@link FintrafficParking} persists {@code paymentMethods} + * through the full NeTEx → Orika → JPA → DB → JPA → NeTEx round-trip. + *

+ * The {@code fintraffic} profile activates {@link FintrafficParkingEntityFactory} (which removes + * the Orika exclusion for {@code paymentMethods}) and the {@link FintrafficParking} entity subclass. + * {@link org.rutebanken.tiamat.ext.fintraffic.auth.FintrafficSecurityConfig} is excluded from the + * component scan via {@link FintrafficTiamatTestApplication}; {@link AuthorizationService} is mocked. + *

+ * Persistence tests use {@link ParkingRepository} directly to avoid the {@code parentSiteRef} + * validation in {@code ParkingVersionedSaverService}, since the round-trip under test is + * specifically the {@code paymentMethods} field persistence, not the full import pipeline. + */ +@RunWith(SpringRunner.class) +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + classes = FintrafficTiamatTestApplication.class +) +@ActiveProfiles({"test", "gcs-blobstore", "fintraffic"}) +@TestPropertySource(properties = "spring.main.allow-bean-definition-overriding=true") +public class FintrafficParkingIntegrationTest extends FintrafficIntegrationTest { + + @MockitoBean + private AuthorizationService authorizationService; + + @PersistenceContext + private EntityManager entityManager; + + @Autowired + private NetexMapper netexMapper; + + @Autowired + private ParkingEntityFactory parkingEntityFactory; + + @Autowired + private ParkingRepository parkingRepository; + + @After + public void cleanUp() { + parkingRepository.deleteAll(); + } + + @Test + public void parkingEntityFactory_producesFintrafficParkingInstance() { + Parking parking = netexMapper.mapToTiamatModel( + new org.rutebanken.netex.model.Parking().withId("FSR:Parking:1").withVersion("1")); + + assertThat(parking) + .as("factory must produce FintrafficParking when fintraffic profile is active") + .isInstanceOf(FintrafficParking.class); + } + + @Test + @Transactional + public void paymentMethods_surviveMappingAndDatabaseRoundTrip() { + // Map NeTEx → Tiamat model with payment methods + org.rutebanken.netex.model.Parking netexParking = new org.rutebanken.netex.model.Parking() + .withId("FSR:Parking:42") + .withVersion("1") + .withPaymentMethods( + org.rutebanken.netex.model.PaymentMethodEnumeration.CASH, + org.rutebanken.netex.model.PaymentMethodEnumeration.CREDIT_CARD); + + Parking tiamatParking = netexMapper.mapToTiamatModel(netexParking); + + assertThat(tiamatParking.getPaymentMethods()) + .as("paymentMethods must be mapped from NeTEx (not excluded) with fintraffic profile") + .containsExactlyInAnyOrder( + PaymentMethodEnumeration.CASH, + PaymentMethodEnumeration.CREDIT_CARD); + + // Save directly via repository (no parentSiteRef validation) + Parking saved = parkingRepository.save(tiamatParking); + Long id = saved.getId(); + + // Flush and clear to evict first-level cache; forces genuine DB read + entityManager.flush(); + entityManager.clear(); + + // Reload from DB + Parking reloaded = parkingRepository.findById(id).orElseThrow(); + + assertThat(reloaded.getPaymentMethods()) + .as("paymentMethods must survive DB round-trip") + .containsExactlyInAnyOrder( + PaymentMethodEnumeration.CASH, + PaymentMethodEnumeration.CREDIT_CARD); + } + + @Test + @Transactional + public void paymentMethods_appearsInNetexExport() { + // Build and persist a FintrafficParking with payment methods + FintrafficParking fp = (FintrafficParking) parkingEntityFactory.create(); + fp.setPaymentMethods(List.of(PaymentMethodEnumeration.CASH)); + Parking saved = parkingRepository.save(fp); + + // Flush and clear to evict first-level cache; forces genuine DB read + entityManager.flush(); + entityManager.clear(); + + // Reload (ensure we're reading from DB, not session cache) + Parking reloaded = parkingRepository.findById(saved.getId()).orElseThrow(); + + // Map Tiamat → NeTEx + org.rutebanken.netex.model.Parking exported = netexMapper.mapToNetexModel(reloaded); + + assertThat(exported.getPaymentMethods()) + .as("paymentMethods must appear in NeTEx export") + .contains(org.rutebanken.netex.model.PaymentMethodEnumeration.CASH); + } +} diff --git a/src/ext-test/java/org/rutebanken/tiamat/ext/fintraffic/model/FintrafficParkingTest.java b/src/ext-test/java/org/rutebanken/tiamat/ext/fintraffic/model/FintrafficParkingTest.java new file mode 100644 index 0000000000..9cc44042b6 --- /dev/null +++ b/src/ext-test/java/org/rutebanken/tiamat/ext/fintraffic/model/FintrafficParkingTest.java @@ -0,0 +1,71 @@ +package org.rutebanken.tiamat.ext.fintraffic.model; + +import jakarta.persistence.CollectionTable; +import jakarta.persistence.ElementCollection; +import org.junit.jupiter.api.Test; +import org.rutebanken.tiamat.model.Parking; +import org.rutebanken.tiamat.model.PaymentMethodEnumeration; + +import java.lang.reflect.Field; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class FintrafficParkingTest { + + @Test + void isSubclassOfParking() { + assertThat(Parking.class).isAssignableFrom(FintrafficParking.class); + } + + @Test + void paymentMethodsField_hasElementCollectionAnnotation() throws NoSuchFieldException { + Field field = FintrafficParking.class.getDeclaredField("paymentMethods"); + + assertThat(field.isAnnotationPresent(ElementCollection.class)) + .as("paymentMethods field must be @ElementCollection to be persisted") + .isTrue(); + } + + @Test + void paymentMethodsField_hasCollectionTableAnnotation() throws NoSuchFieldException { + Field field = FintrafficParking.class.getDeclaredField("paymentMethods"); + + CollectionTable table = field.getAnnotation(CollectionTable.class); + assertThat(table).isNotNull(); + assertThat(table.name()).isEqualTo("parking_payment_methods"); + } + + @Test + void setAndGetPaymentMethods_usesOwnField_notParentTransientField() { + FintrafficParking parking = new FintrafficParking(); + + parking.setPaymentMethods(List.of(PaymentMethodEnumeration.CASH, PaymentMethodEnumeration.CREDIT_CARD)); + + // getPaymentMethods() must return from the @ElementCollection field, not the parent @Transient field + assertThat(parking.getPaymentMethods()) + .containsExactly(PaymentMethodEnumeration.CASH, PaymentMethodEnumeration.CREDIT_CARD); + } + + @Test + void parentTransientField_isNotAffectedBySubclassSetter() throws Exception { + FintrafficParking parking = new FintrafficParking(); + parking.setPaymentMethods(List.of(PaymentMethodEnumeration.CASH)); + + // The parent @Transient protected field should remain null (we never write to it) + Field parentField = Parking.class.getDeclaredField("paymentMethods"); + parentField.setAccessible(true); + Object parentFieldValue = parentField.get(parking); + + assertThat(parentFieldValue) + .as("parent @Transient paymentMethods must remain null; only the shadowed field is written") + .isNull(); + } + + @Test + void getPaymentMethods_returnsEmptyList_whenNothingSet() { + FintrafficParking parking = new FintrafficParking(); + + assertThat(parking.getPaymentMethods()).isEmpty(); + } +} diff --git a/src/ext-test/java/org/rutebanken/tiamat/ext/fintraffic/rest/graphql/FintrafficGraphQLParkingIntegrationTest.java b/src/ext-test/java/org/rutebanken/tiamat/ext/fintraffic/rest/graphql/FintrafficGraphQLParkingIntegrationTest.java new file mode 100644 index 0000000000..c1326f6dcc --- /dev/null +++ b/src/ext-test/java/org/rutebanken/tiamat/ext/fintraffic/rest/graphql/FintrafficGraphQLParkingIntegrationTest.java @@ -0,0 +1,978 @@ +package org.rutebanken.tiamat.ext.fintraffic.rest.graphql; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.rutebanken.tiamat.auth.AuthorizationService; +import org.rutebanken.tiamat.ext.fintraffic.FintrafficIntegrationTest; +import org.rutebanken.tiamat.ext.fintraffic.FintrafficTiamatTestApplication; +import org.rutebanken.tiamat.ext.fintraffic.model.FintrafficParking; +import org.rutebanken.tiamat.ext.fintraffic.model.FintrafficParkingAvailabilityCondition; +import org.rutebanken.tiamat.model.EmbeddableMultilingualString; +import org.rutebanken.tiamat.model.Parking; +import org.rutebanken.tiamat.model.PaymentMethodEnumeration; +import org.rutebanken.tiamat.model.StopPlace; +import org.rutebanken.tiamat.model.StopTypeEnumeration; +import org.rutebanken.tiamat.repository.ParkingRepository; +import org.rutebanken.tiamat.repository.StopPlaceRepository; +import org.rutebanken.tiamat.rest.graphql.GraphQLNames; +import org.rutebanken.tiamat.versioning.save.StopPlaceVersionedSaverService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.junit4.SpringRunner; + +import java.time.LocalTime; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasItems; +import static org.hamcrest.Matchers.notNullValue; +import static org.rutebanken.tiamat.config.JerseyConfig.SERVICES_STOP_PLACE_PATH; +/** + * Integration test verifying that the GraphQL {@code mutateParking} operation uses + * {@link org.rutebanken.tiamat.model.factory.ParkingEntityFactory} to produce a + * {@link FintrafficParking} instance when the {@code fintraffic} profile is active. + *

+ * Note: {@code paymentMethods} is not yet part of the GraphQL schema; the GraphQL + * layer tests that the correct entity subtype is created and persisted. The + * {@code paymentMethods} DB round-trip is covered by + * {@link org.rutebanken.tiamat.ext.fintraffic.model.FintrafficParkingIntegrationTest}. + */ +@RunWith(SpringRunner.class) +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + classes = FintrafficTiamatTestApplication.class +) +@ActiveProfiles({"test", "gcs-blobstore", "fintraffic"}) +@TestPropertySource(properties = "spring.main.allow-bean-definition-overriding=true") +public class FintrafficGraphQLParkingIntegrationTest extends FintrafficIntegrationTest { + + private static final String BASE_URI_GRAPHQL = SERVICES_STOP_PLACE_PATH + "/graphql/"; + + @MockitoBean + private AuthorizationService authorizationService; + + @Value("${local.server.port}") + private int port; + + @Autowired + private StopPlaceRepository stopPlaceRepository; + + @Autowired + private StopPlaceVersionedSaverService stopPlaceVersionedSaverService; + + @Autowired + private ParkingRepository parkingRepository; + + @Before + public void configureRestAssured() { + RestAssured.baseURI = "http://localhost"; + RestAssured.port = port; + } + + @After + public void cleanUp() { + parkingRepository.deleteAll(); + stopPlaceRepository.deleteAll(); + } + + @Test + public void mutateParking_createsFintrafficParkingViaFactory() { + StopPlace stopPlace = new StopPlace(new EmbeddableMultilingualString("Test stop")); + stopPlace.setStopPlaceType(StopTypeEnumeration.ONSTREET_BUS); + stopPlace = stopPlaceVersionedSaverService.saveNewVersion(stopPlace); + String stopNetexId = stopPlace.getNetexId(); + + String mutation = """ + { + "query": "mutation { parking: %s (Parking: { name: { value: \\"Test parking\\" lang: \\"fi\\" } parkingType: parkAndRide parentSiteRef: \\"%s\\" }) { id } }", + "variables": "" + } + """.formatted(GraphQLNames.MUTATE_PARKING, stopNetexId); + + String parkingNetexId = given() + .port(port) + .contentType(ContentType.JSON) + .body(mutation) + .when() + .post(BASE_URI_GRAPHQL) + .then() + .log().body() + .statusCode(200) + .body("data.parking[0].id", notNullValue()) + .extract() + .path("data.parking[0].id"); + + Parking saved = parkingRepository.findFirstByNetexIdOrderByVersionDesc(parkingNetexId); + + assertThat(saved) + .as("ParkingEntityFactory must produce FintrafficParking via GraphQL mutateParking") + .isInstanceOf(FintrafficParking.class); + } + + @Test + public void mutateParking_updatePreservesFintrafficParkingType() { + StopPlace stopPlace = new StopPlace(new EmbeddableMultilingualString("Test stop")); + stopPlace.setStopPlaceType(StopTypeEnumeration.ONSTREET_BUS); + stopPlace = stopPlaceVersionedSaverService.saveNewVersion(stopPlace); + String stopNetexId = stopPlace.getNetexId(); + + // Create + String createMutation = """ + { + "query": "mutation { parking: %s (Parking: { name: { value: \\"Original\\" lang: \\"fi\\" } parkingType: parkAndRide parentSiteRef: \\"%s\\" }) { id } }", + "variables": "" + } + """.formatted(GraphQLNames.MUTATE_PARKING, stopNetexId); + + String parkingNetexId = given() + .port(port) + .contentType(ContentType.JSON) + .body(createMutation) + .when() + .post(BASE_URI_GRAPHQL) + .then() + .statusCode(200) + .extract() + .path("data.parking[0].id"); + + // Update + String updateMutation = """ + { + "query": "mutation { parking: %s (Parking: { id: \\"%s\\" name: { value: \\"Updated\\" lang: \\"fi\\" } }) { id version } }", + "variables": "" + } + """.formatted(GraphQLNames.MUTATE_PARKING, parkingNetexId); + + given() + .port(port) + .contentType(ContentType.JSON) + .body(updateMutation) + .when() + .post(BASE_URI_GRAPHQL) + .then() + .log().body() + .statusCode(200) + .body("data.parking[0].id", notNullValue()); + + // Both versions should be FintrafficParking + parkingRepository.findByNetexId(parkingNetexId).forEach(p -> + assertThat(p) + .as("All versions of a parking must be FintrafficParking instances") + .isInstanceOf(FintrafficParking.class) + ); + } + + @Test + public void mutateParking_paymentMethods_persistedAndReturnedInResponse() { + StopPlace stopPlace = new StopPlace(new EmbeddableMultilingualString("Test stop")); + stopPlace.setStopPlaceType(StopTypeEnumeration.ONSTREET_BUS); + stopPlace = stopPlaceVersionedSaverService.saveNewVersion(stopPlace); + String stopNetexId = stopPlace.getNetexId(); + + String mutation = """ + { + "query": "mutation { parking: %s (Parking: { name: { value: \\"Test\\" lang: \\"fi\\" } parkingType: parkAndRide parentSiteRef: \\"%s\\" paymentMethods: [cash, creditCard] }) { id paymentMethods } }", + "variables": "" + } + """.formatted(GraphQLNames.MUTATE_PARKING, stopNetexId); + + String parkingNetexId = given() + .port(port) + .contentType(ContentType.JSON) + .body(mutation) + .when() + .post(BASE_URI_GRAPHQL) + .then() + .log().body() + .statusCode(200) + .body("data.parking[0].id", notNullValue()) + .body("data.parking[0].paymentMethods", hasItems("cash", "creditCard")) + .extract() + .path("data.parking[0].id"); + + FintrafficParking saved = (FintrafficParking) + parkingRepository.findFirstByNetexIdOrderByVersionDesc(parkingNetexId); + + assertThat(saved.getPaymentMethods()) + .as("paymentMethods must be persisted to DB via FintrafficParkingUpdater") + .containsExactlyInAnyOrder( + PaymentMethodEnumeration.CASH, + PaymentMethodEnumeration.CREDIT_CARD); + } + + /** + * Verifies that {@link FintrafficParkingUpdater#preserveExtendedFields} copies + * {@code paymentMethods} from the existing version into the version copy when an + * update mutation omits the field. Without this hook, Orika's {@code createCopy} + * would not transfer the private {@link FintrafficParking#paymentMethods} field and + * the update would silently clear any previously saved payment methods. + */ + @Test + public void mutateParking_updateWithoutPaymentMethods_preservesExistingPaymentMethods() { + StopPlace stopPlace = new StopPlace(new EmbeddableMultilingualString("Test stop")); + stopPlace.setStopPlaceType(StopTypeEnumeration.ONSTREET_BUS); + stopPlace = stopPlaceVersionedSaverService.saveNewVersion(stopPlace); + String stopNetexId = stopPlace.getNetexId(); + + // Create with paymentMethods + String createMutation = """ + { + "query": "mutation { parking: %s (Parking: { name: { value: \\"Test\\" lang: \\"fi\\" } parkingType: parkAndRide parentSiteRef: \\"%s\\" paymentMethods: [cash, creditCard] }) { id paymentMethods } }", + "variables": "" + } + """.formatted(GraphQLNames.MUTATE_PARKING, stopNetexId); + + String parkingNetexId = given() + .port(port) + .contentType(ContentType.JSON) + .body(createMutation) + .when() + .post(BASE_URI_GRAPHQL) + .then() + .statusCode(200) + .extract() + .path("data.parking[0].id"); + + // Update without paymentMethods — field should be preserved + String updateMutation = """ + { + "query": "mutation { parking: %s (Parking: { id: \\"%s\\" name: { value: \\"Updated\\" lang: \\"fi\\" } }) { id paymentMethods } }", + "variables": "" + } + """.formatted(GraphQLNames.MUTATE_PARKING, parkingNetexId); + + given() + .port(port) + .contentType(ContentType.JSON) + .body(updateMutation) + .when() + .post(BASE_URI_GRAPHQL) + .then() + .log().body() + .statusCode(200) + .body("data.parking[0].paymentMethods", hasItems("cash", "creditCard")); + + FintrafficParking latest = (FintrafficParking) + parkingRepository.findFirstByNetexIdOrderByVersionDesc(parkingNetexId); + assertThat(latest.getPaymentMethods()) + .as("paymentMethods must be preserved when not included in update input") + .containsExactlyInAnyOrder( + PaymentMethodEnumeration.CASH, + PaymentMethodEnumeration.CREDIT_CARD); + } + + /** + * Verifies that {@code paymentMethods} persisted via {@code mutateParking} are returned by + * the GraphQL {@code parking} query. This exercises the full read path: + * {@code parkingFetcher} → {@link org.rutebanken.tiamat.repository.ParkingRepository} → + * {@link org.rutebanken.tiamat.netex.mapping.NetexMapper#mapToNetexModel} → + * {@link org.rutebanken.tiamat.ext.fintraffic.rest.graphql.FintrafficParkingGraphQLTypeContributor}. + */ + @Test + public void parkingQuery_returnsPaymentMethods_whenFintrafficProfileActive() { + StopPlace stopPlace = new StopPlace(new EmbeddableMultilingualString("Test stop")); + stopPlace.setStopPlaceType(StopTypeEnumeration.ONSTREET_BUS); + stopPlace = stopPlaceVersionedSaverService.saveNewVersion(stopPlace); + String stopNetexId = stopPlace.getNetexId(); + + // Create parking with paymentMethods via mutation + String createMutation = """ + { + "query": "mutation { parking: %s (Parking: { name: { value: \\"Test\\" lang: \\"fi\\" } parkingType: parkAndRide parentSiteRef: \\"%s\\" paymentMethods: [cash, creditCard] }) { id } }", + "variables": "" + } + """.formatted(GraphQLNames.MUTATE_PARKING, stopNetexId); + + String parkingNetexId = given() + .port(port) + .contentType(ContentType.JSON) + .body(createMutation) + .when() + .post(BASE_URI_GRAPHQL) + .then() + .statusCode(200) + .extract() + .path("data.parking[0].id"); + + // Query it back via the parking read query + String query = """ + { + "query": "{ parking(id: \\"%s\\") { id paymentMethods } }" + } + """.formatted(parkingNetexId); + + given() + .port(port) + .contentType(ContentType.JSON) + .body(query) + .when() + .post(BASE_URI_GRAPHQL) + .then() + .log().body() + .statusCode(200) + .body("data.parking[0].id", notNullValue()) + .body("data.parking[0].paymentMethods", hasItems("cash", "creditCard")); + } + + /** + * Regression test: when {@code fintraffic} profile is active, the {@code stopPlace} GraphQL + * query must be served by {@code stopPlaceFetcher}, not hijacked by any {@code @Primary} + * bean override intended only for {@code parkingUpdater}. The field must never be {@code null} + * (an empty list is acceptable; {@code null} means the wrong fetcher was injected). + */ + @Test + public void stopPlaceQuery_notNull_whenFintrafficProfileActive() { + String query = """ + { + "query": "{ stopPlace(query: \\"nonexistent_xyz_regression_check\\", size: 1) { id } }" + } + """; + + Object result = given() + .port(port) + .contentType(ContentType.JSON) + .body(query) + .when() + .post(BASE_URI_GRAPHQL) + .then() + .statusCode(200) + .extract() + .path("data.stopPlace"); + + assertThat(result) + .as("stopPlace field must not be null — null means stopPlaceFetcher was replaced " + + "by an unrelated @Primary bean (e.g. FintrafficParkingUpdater)") + .isNotNull(); + } + + @Test + public void mutateParking_infoLinks_persistedAndReturnedInResponse() { + StopPlace stopPlace = new StopPlace(new EmbeddableMultilingualString("Test stop")); + stopPlace.setStopPlaceType(StopTypeEnumeration.ONSTREET_BUS); + stopPlace = stopPlaceVersionedSaverService.saveNewVersion(stopPlace); + String stopNetexId = stopPlace.getNetexId(); + + String mutation = """ + { + "query": "mutation { parking: %s (Parking: { name: { value: \\"Test\\" lang: \\"fi\\" } parkingType: parkAndRide parentSiteRef: \\"%s\\" infoLinks: [{ uri: \\"https://example.com\\" typeOfInfoLink: resource }] }) { id infoLinks { uri typeOfInfoLink } } }", + "variables": "" + } + """.formatted(GraphQLNames.MUTATE_PARKING, stopNetexId); + + String parkingNetexId = given() + .port(port) + .contentType(ContentType.JSON) + .body(mutation) + .when() + .post(BASE_URI_GRAPHQL) + .then() + .log().body() + .statusCode(200) + .body("data.parking[0].id", notNullValue()) + .body("data.parking[0].infoLinks[0].uri", org.hamcrest.Matchers.equalTo("https://example.com")) + .body("data.parking[0].infoLinks[0].typeOfInfoLink", org.hamcrest.Matchers.equalTo("resource")) + .extract() + .path("data.parking[0].id"); + + FintrafficParking saved = (FintrafficParking) + parkingRepository.findFirstByNetexIdOrderByVersionDesc(parkingNetexId); + + assertThat(saved.getInfoLinks()) + .as("infoLinks must be persisted to DB via FintrafficParkingUpdater") + .containsExactly(new org.rutebanken.tiamat.ext.fintraffic.model.FintrafficInfoLink("https://example.com", "resource")); + } + + @Test + public void mutateParking_updateWithoutInfoLinks_preservesExistingInfoLinks() { + StopPlace stopPlace = new StopPlace(new EmbeddableMultilingualString("Test stop")); + stopPlace.setStopPlaceType(StopTypeEnumeration.ONSTREET_BUS); + stopPlace = stopPlaceVersionedSaverService.saveNewVersion(stopPlace); + String stopNetexId = stopPlace.getNetexId(); + + // Create with infoLinks + String createMutation = """ + { + "query": "mutation { parking: %s (Parking: { name: { value: \\"Test\\" lang: \\"fi\\" } parkingType: parkAndRide parentSiteRef: \\"%s\\" infoLinks: [{ uri: \\"https://example.com\\" typeOfInfoLink: resource }] }) { id } }", + "variables": "" + } + """.formatted(GraphQLNames.MUTATE_PARKING, stopNetexId); + + String parkingNetexId = given() + .port(port) + .contentType(ContentType.JSON) + .body(createMutation) + .when() + .post(BASE_URI_GRAPHQL) + .then() + .statusCode(200) + .extract() + .path("data.parking[0].id"); + + // Update without infoLinks — field should be preserved + String updateMutation = """ + { + "query": "mutation { parking: %s (Parking: { id: \\"%s\\" name: { value: \\"Updated\\" lang: \\"fi\\" } }) { id infoLinks { uri typeOfInfoLink } } }", + "variables": "" + } + """.formatted(GraphQLNames.MUTATE_PARKING, parkingNetexId); + + given() + .port(port) + .contentType(ContentType.JSON) + .body(updateMutation) + .when() + .post(BASE_URI_GRAPHQL) + .then() + .log().body() + .statusCode(200) + .body("data.parking[0].infoLinks[0].uri", org.hamcrest.Matchers.equalTo("https://example.com")); + + FintrafficParking latest = (FintrafficParking) + parkingRepository.findFirstByNetexIdOrderByVersionDesc(parkingNetexId); + assertThat(latest.getInfoLinks()) + .as("infoLinks must be preserved when not included in update input") + .containsExactly(new org.rutebanken.tiamat.ext.fintraffic.model.FintrafficInfoLink("https://example.com", "resource")); + } + + @Test + public void mutateParking_vehicleEntrances_persistedAndReturnedInResponse() { + StopPlace stopPlace = new StopPlace(new EmbeddableMultilingualString("Test stop")); + stopPlace.setStopPlaceType(StopTypeEnumeration.ONSTREET_BUS); + stopPlace = stopPlaceVersionedSaverService.saveNewVersion(stopPlace); + String stopNetexId = stopPlace.getNetexId(); + + String mutation = """ + { + "query": "mutation { parking: %s (Parking: { name: { value: \\"Test\\" lang: \\"fi\\" } parkingType: parkAndRide parentSiteRef: \\"%s\\" vehicleEntrances: [{ label: \\"Main\\" entranceType: door isEntry: true isExit: false publicCode: \\"A1\\" }] }) { id vehicleEntrances { label entranceType isEntry isExit publicCode } } }", + "variables": "" + } + """.formatted(GraphQLNames.MUTATE_PARKING, stopNetexId); + + String parkingNetexId = given() + .port(port) + .contentType(ContentType.JSON) + .body(mutation) + .when() + .post(BASE_URI_GRAPHQL) + .then() + .log().body() + .statusCode(200) + .body("data.parking[0].id", notNullValue()) + .body("data.parking[0].vehicleEntrances[0].label", org.hamcrest.Matchers.equalTo("Main")) + .body("data.parking[0].vehicleEntrances[0].entranceType", org.hamcrest.Matchers.equalTo("door")) + .body("data.parking[0].vehicleEntrances[0].isEntry", org.hamcrest.Matchers.equalTo(true)) + .body("data.parking[0].vehicleEntrances[0].isExit", org.hamcrest.Matchers.equalTo(false)) + .body("data.parking[0].vehicleEntrances[0].publicCode", org.hamcrest.Matchers.equalTo("A1")) + .extract() + .path("data.parking[0].id"); + + FintrafficParking saved = (FintrafficParking) + parkingRepository.findFirstByNetexIdOrderByVersionDesc(parkingNetexId); + + assertThat(saved.getFintrafficVehicleEntrances()).hasSize(1); + org.rutebanken.tiamat.ext.fintraffic.model.FintrafficParkingEntranceForVehicles entrance = + saved.getFintrafficVehicleEntrances().getFirst(); + assertThat(entrance.getLabel()).isEqualTo("Main"); + assertThat(entrance.getEntranceType()).isEqualTo("door"); + assertThat(entrance.getIsEntry()).isTrue(); + assertThat(entrance.getIsExit()).isFalse(); + assertThat(entrance.getPublicCode()).isEqualTo("A1"); + } + + @Test + public void mutateParking_vehicleEntrances_widthAndHeight_persistedAndReturnedInResponse() { + StopPlace stopPlace = new StopPlace(new EmbeddableMultilingualString("Test stop")); + stopPlace.setStopPlaceType(StopTypeEnumeration.ONSTREET_BUS); + stopPlace = stopPlaceVersionedSaverService.saveNewVersion(stopPlace); + String stopNetexId = stopPlace.getNetexId(); + + String mutation = """ + { + "query": "mutation { parking: %s (Parking: { name: { value: \\"Test\\" lang: \\"fi\\" } parkingType: parkAndRide parentSiteRef: \\"%s\\" vehicleEntrances: [{ label: \\"Main\\" entranceType: door width: 2.75 height: 3.5 isEntry: true isExit: false publicCode: \\"A1\\" }] }) { id vehicleEntrances { label entranceType width height isEntry isExit publicCode } } }", + "variables": "" + } + """.formatted(GraphQLNames.MUTATE_PARKING, stopNetexId); + + String parkingNetexId = given() + .port(port) + .contentType(ContentType.JSON) + .body(mutation) + .when() + .post(BASE_URI_GRAPHQL) + .then() + .statusCode(200) + .body("data.parking[0].id", notNullValue()) + .body("data.parking[0].vehicleEntrances[0].width", equalTo(2.75f)) + .body("data.parking[0].vehicleEntrances[0].height", equalTo(3.5f)) + .extract() + .path("data.parking[0].id"); + + FintrafficParking saved = (FintrafficParking) + parkingRepository.findFirstByNetexIdOrderByVersionDesc(parkingNetexId); + + assertThat(saved.getFintrafficVehicleEntrances()).hasSize(1); + org.rutebanken.tiamat.ext.fintraffic.model.FintrafficParkingEntranceForVehicles entrance = + saved.getFintrafficVehicleEntrances().getFirst(); + assertThat(entrance.getWidth()).isNotNull(); + assertThat(entrance.getHeight()).isNotNull(); + assertThat(entrance.getWidth()).isEqualByComparingTo("2.75"); + assertThat(entrance.getHeight()).isEqualByComparingTo("3.5"); + } + + @Test + public void mutateParking_updateWithoutVehicleEntrances_preservesExistingVehicleEntrances() { + StopPlace stopPlace = new StopPlace(new EmbeddableMultilingualString("Test stop")); + stopPlace.setStopPlaceType(StopTypeEnumeration.ONSTREET_BUS); + stopPlace = stopPlaceVersionedSaverService.saveNewVersion(stopPlace); + String stopNetexId = stopPlace.getNetexId(); + + String createMutation = """ + { + "query": "mutation { parking: %s (Parking: { name: { value: \\"Test\\" lang: \\"fi\\" } parkingType: parkAndRide parentSiteRef: \\"%s\\" vehicleEntrances: [{ label: \\"Main\\" entranceType: door isEntry: true }] }) { id } }", + "variables": "" + } + """.formatted(GraphQLNames.MUTATE_PARKING, stopNetexId); + + String parkingNetexId = given() + .port(port) + .contentType(ContentType.JSON) + .body(createMutation) + .when() + .post(BASE_URI_GRAPHQL) + .then() + .statusCode(200) + .extract() + .path("data.parking[0].id"); + + String updateMutation = """ + { + "query": "mutation { parking: %s (Parking: { id: \\"%s\\" name: { value: \\"Updated\\" lang: \\"fi\\" } }) { id vehicleEntrances { label } } }", + "variables": "" + } + """.formatted(GraphQLNames.MUTATE_PARKING, parkingNetexId); + + given() + .port(port) + .contentType(ContentType.JSON) + .body(updateMutation) + .when() + .post(BASE_URI_GRAPHQL) + .then() + .log().body() + .statusCode(200) + .body("data.parking[0].vehicleEntrances[0].label", org.hamcrest.Matchers.equalTo("Main")); + + FintrafficParking latest = (FintrafficParking) + parkingRepository.findFirstByNetexIdOrderByVersionDesc(parkingNetexId); + assertThat(latest.getFintrafficVehicleEntrances()) + .as("vehicleEntrances must be preserved when not included in update input") + .hasSize(1); + assertThat(latest.getFintrafficVehicleEntrances().getFirst().getLabel()).isEqualTo("Main"); + } + + @Test + public void mutateParking_availabilityConditions_persistedAndReadBackViaQuery() { + StopPlace stopPlace = new StopPlace(new EmbeddableMultilingualString("Test stop")); + stopPlace.setStopPlaceType(StopTypeEnumeration.ONSTREET_BUS); + stopPlace = stopPlaceVersionedSaverService.saveNewVersion(stopPlace); + String stopNetexId = stopPlace.getNetexId(); + + String mutation = """ + { + "query": "mutation { parking: %s (Parking: { name: { value: \\"Test\\" lang: \\"fi\\" } parkingType: parkAndRide parentSiteRef: \\"%s\\" availabilityConditions: [{ dayTypeRef: \\"FSR:DayType:BusinessDay\\" isAvailable: true startTime: \\"06:00\\" endTime: \\"22:00\\" }, { dayTypeRef: \\"FSR:DayType:Sunday\\" isAvailable: false }] }) { id availabilityConditions { dayTypeRef isAvailable startTime endTime } } }", + "variables": "" + } + """.formatted(GraphQLNames.MUTATE_PARKING, stopNetexId); + + String parkingNetexId = given() + .port(port) + .contentType(ContentType.JSON) + .body(mutation) + .when() + .post(BASE_URI_GRAPHQL) + .then() + .log().body() + .statusCode(200) + .body("data.parking[0].id", notNullValue()) + .body("data.parking[0].availabilityConditions[0].dayTypeRef", equalTo("FSR:DayType:BusinessDay")) + .body("data.parking[0].availabilityConditions[0].isAvailable", equalTo(true)) + .body("data.parking[0].availabilityConditions[0].startTime", equalTo("06:00")) + .body("data.parking[0].availabilityConditions[0].endTime", equalTo("22:00")) + .body("data.parking[0].availabilityConditions[1].dayTypeRef", equalTo("FSR:DayType:Sunday")) + .body("data.parking[0].availabilityConditions[1].isAvailable", equalTo(false)) + .extract() + .path("data.parking[0].id"); + + FintrafficParking saved = (FintrafficParking) + parkingRepository.findFirstByNetexIdOrderByVersionDesc(parkingNetexId); + + assertThat(saved.getAvailabilityConditions()) + .containsExactly( + new FintrafficParkingAvailabilityCondition("FSR:DayType:BusinessDay", true, LocalTime.of(6, 0), LocalTime.of(22, 0)), + new FintrafficParkingAvailabilityCondition("FSR:DayType:Sunday", false, null, null) + ); + + String query = """ + { + "query": "{ parking(id: \\"%s\\") { id availabilityConditions { dayTypeRef isAvailable startTime endTime } } }" + } + """.formatted(parkingNetexId); + + given() + .port(port) + .contentType(ContentType.JSON) + .body(query) + .when() + .post(BASE_URI_GRAPHQL) + .then() + .log().body() + .statusCode(200) + .body("data.parking[0].availabilityConditions[0].dayTypeRef", equalTo("FSR:DayType:BusinessDay")) + .body("data.parking[0].availabilityConditions[0].isAvailable", equalTo(true)) + .body("data.parking[0].availabilityConditions[0].startTime", equalTo("06:00")) + .body("data.parking[0].availabilityConditions[0].endTime", equalTo("22:00")) + .body("data.parking[0].availabilityConditions[1].dayTypeRef", equalTo("FSR:DayType:Sunday")) + .body("data.parking[0].availabilityConditions[1].isAvailable", equalTo(false)); + } + + @Test + public void mutateParking_updateWithoutAvailabilityConditions_preservesExistingAvailabilityConditions() { + StopPlace stopPlace = new StopPlace(new EmbeddableMultilingualString("Test stop")); + stopPlace.setStopPlaceType(StopTypeEnumeration.ONSTREET_BUS); + stopPlace = stopPlaceVersionedSaverService.saveNewVersion(stopPlace); + String stopNetexId = stopPlace.getNetexId(); + + String createMutation = """ + { + "query": "mutation { parking: %s (Parking: { name: { value: \\"Test\\" lang: \\"fi\\" } parkingType: parkAndRide parentSiteRef: \\"%s\\" availabilityConditions: [{ dayTypeRef: \\"FSR:DayType:BusinessDay\\" isAvailable: true startTime: \\"06:00\\" endTime: \\"22:00\\" }] }) { id } }", + "variables": "" + } + """.formatted(GraphQLNames.MUTATE_PARKING, stopNetexId); + + String parkingNetexId = given() + .port(port) + .contentType(ContentType.JSON) + .body(createMutation) + .when() + .post(BASE_URI_GRAPHQL) + .then() + .statusCode(200) + .extract() + .path("data.parking[0].id"); + + String updateMutation = """ + { + "query": "mutation { parking: %s (Parking: { id: \\"%s\\" name: { value: \\"Updated\\" lang: \\"fi\\" } }) { id availabilityConditions { dayTypeRef startTime endTime } } }", + "variables": "" + } + """.formatted(GraphQLNames.MUTATE_PARKING, parkingNetexId); + + given() + .port(port) + .contentType(ContentType.JSON) + .body(updateMutation) + .when() + .post(BASE_URI_GRAPHQL) + .then() + .log().body() + .statusCode(200) + .body("data.parking[0].availabilityConditions[0].dayTypeRef", equalTo("FSR:DayType:BusinessDay")) + .body("data.parking[0].availabilityConditions[0].startTime", equalTo("06:00")) + .body("data.parking[0].availabilityConditions[0].endTime", equalTo("22:00")); + + FintrafficParking latest = (FintrafficParking) + parkingRepository.findFirstByNetexIdOrderByVersionDesc(parkingNetexId); + assertThat(latest.getAvailabilityConditions()) + .as("availabilityConditions must be preserved when not included in update input") + .containsExactly(new FintrafficParkingAvailabilityCondition( + "FSR:DayType:BusinessDay", + true, + LocalTime.of(6, 0), + LocalTime.of(22, 0) + )); + } + + @Test + public void mutateParking_lighting_persistedAndReturnedInResponse() { + StopPlace stopPlace = new StopPlace(new EmbeddableMultilingualString("Test stop")); + stopPlace.setStopPlaceType(StopTypeEnumeration.ONSTREET_BUS); + stopPlace = stopPlaceVersionedSaverService.saveNewVersion(stopPlace); + String stopNetexId = stopPlace.getNetexId(); + + String mutation = """ + { + "query": "mutation { parking: %s (Parking: { name: { value: \\"Test\\" lang: \\"fi\\" } parkingType: parkAndRide parentSiteRef: \\"%s\\" lighting: wellLit }) { id lighting } }", + "variables": "" + } + """.formatted(GraphQLNames.MUTATE_PARKING, stopNetexId); + + String parkingNetexId = given() + .port(port) + .contentType(ContentType.JSON) + .body(mutation) + .when() + .post(BASE_URI_GRAPHQL) + .then() + .log().body() + .statusCode(200) + .body("data.parking[0].id", notNullValue()) + .body("data.parking[0].lighting", equalTo("wellLit")) + .extract() + .path("data.parking[0].id"); + + FintrafficParking saved = (FintrafficParking) + parkingRepository.findFirstByNetexIdOrderByVersionDesc(parkingNetexId); + + assertThat(saved.getLighting()) + .as("lighting must be persisted to DB via FintrafficParkingUpdater") + .isEqualTo(org.rutebanken.tiamat.model.LightingEnumeration.WELL_LIT); + } + + @Test + public void mutateParking_lighting_persistedAndReadBack_viaQuery() { + StopPlace stopPlace = new StopPlace(new EmbeddableMultilingualString("Test stop")); + stopPlace.setStopPlaceType(StopTypeEnumeration.ONSTREET_BUS); + stopPlace = stopPlaceVersionedSaverService.saveNewVersion(stopPlace); + String stopNetexId = stopPlace.getNetexId(); + + String mutation = """ + { + "query": "mutation { parking: %s (Parking: { name: { value: \\"Test\\" lang: \\"fi\\" } parkingType: parkAndRide parentSiteRef: \\"%s\\" lighting: wellLit }) { id } }", + "variables": "" + } + """.formatted(GraphQLNames.MUTATE_PARKING, stopNetexId); + + String parkingNetexId = given() + .port(port) + .contentType(ContentType.JSON) + .body(mutation) + .when() + .post(BASE_URI_GRAPHQL) + .then() + .statusCode(200) + .extract() + .path("data.parking[0].id"); + + // Separate read query — proves persistence, not just response echo + String query = """ + { + "query": "{ parking(id: \\"%s\\") { id lighting } }" + } + """.formatted(parkingNetexId); + + given() + .port(port) + .contentType(ContentType.JSON) + .body(query) + .when() + .post(BASE_URI_GRAPHQL) + .then() + .log().body() + .statusCode(200) + .body("data.parking[0].lighting", equalTo("wellLit")); + } + + @Test + public void mutateParking_updateWithoutLighting_preservesExistingLighting() { + StopPlace stopPlace = new StopPlace(new EmbeddableMultilingualString("Test stop")); + stopPlace.setStopPlaceType(StopTypeEnumeration.ONSTREET_BUS); + stopPlace = stopPlaceVersionedSaverService.saveNewVersion(stopPlace); + String stopNetexId = stopPlace.getNetexId(); + + String createMutation = """ + { + "query": "mutation { parking: %s (Parking: { name: { value: \\"Test\\" lang: \\"fi\\" } parkingType: parkAndRide parentSiteRef: \\"%s\\" lighting: wellLit }) { id } }", + "variables": "" + } + """.formatted(GraphQLNames.MUTATE_PARKING, stopNetexId); + + String parkingNetexId = given() + .port(port) + .contentType(ContentType.JSON) + .body(createMutation) + .when() + .post(BASE_URI_GRAPHQL) + .then() + .statusCode(200) + .extract() + .path("data.parking[0].id"); + + // Update without lighting — should be preserved + String updateMutation = """ + { + "query": "mutation { parking: %s (Parking: { id: \\"%s\\" name: { value: \\"Updated\\" lang: \\"fi\\" } }) { id lighting } }", + "variables": "" + } + """.formatted(GraphQLNames.MUTATE_PARKING, parkingNetexId); + + given() + .port(port) + .contentType(ContentType.JSON) + .body(updateMutation) + .when() + .post(BASE_URI_GRAPHQL) + .then() + .log().body() + .statusCode(200) + .body("data.parking[0].lighting", equalTo("wellLit")); + + FintrafficParking latest = (FintrafficParking) + parkingRepository.findFirstByNetexIdOrderByVersionDesc(parkingNetexId); + assertThat(latest.getLighting()) + .as("lighting must be preserved when not included in update input") + .isEqualTo(org.rutebanken.tiamat.model.LightingEnumeration.WELL_LIT); + } + + @Test + public void mutateParking_duplicateAvailabilityConditionDayTypeRef_returnsError() { + StopPlace stopPlace = new StopPlace(new EmbeddableMultilingualString("Test stop")); + stopPlace.setStopPlaceType(StopTypeEnumeration.ONSTREET_BUS); + stopPlace = stopPlaceVersionedSaverService.saveNewVersion(stopPlace); + String stopNetexId = stopPlace.getNetexId(); + + String mutation = """ + { + "query": "mutation { parking: %s (Parking: { name: { value: \\"Test\\" lang: \\"fi\\" } parkingType: parkAndRide parentSiteRef: \\"%s\\" availabilityConditions: [{ dayTypeRef: \\"FSR:DayType:BusinessDay\\" isAvailable: true }, { dayTypeRef: \\"FSR:DayType:BusinessDay\\" isAvailable: false }] }) { id } }", + "variables": "" + } + """.formatted(GraphQLNames.MUTATE_PARKING, stopNetexId); + + given() + .port(port) + .contentType(ContentType.JSON) + .body(mutation) + .when() + .post(BASE_URI_GRAPHQL) + .then() + .log().body() + .statusCode(org.hamcrest.Matchers.anyOf(equalTo(200), equalTo(400))); + + assertThat(parkingRepository.count()) + .as("no parking must be persisted when duplicate dayTypeRef is submitted") + .isZero(); + } + + @Test + public void mutateParking_invalidAvailabilityConditionTime_returnsError() { + StopPlace stopPlace = new StopPlace(new EmbeddableMultilingualString("Test stop")); + stopPlace.setStopPlaceType(StopTypeEnumeration.ONSTREET_BUS); + stopPlace = stopPlaceVersionedSaverService.saveNewVersion(stopPlace); + String stopNetexId = stopPlace.getNetexId(); + + String mutation = """ + { + "query": "mutation { parking: %s (Parking: { name: { value: \\"Test\\" lang: \\"fi\\" } parkingType: parkAndRide parentSiteRef: \\"%s\\" availabilityConditions: [{ dayTypeRef: \\"FSR:DayType:BusinessDay\\" startTime: \\"notATime\\" }] }) { id } }", + "variables": "" + } + """.formatted(GraphQLNames.MUTATE_PARKING, stopNetexId); + + given() + .port(port) + .contentType(ContentType.JSON) + .body(mutation) + .when() + .post(BASE_URI_GRAPHQL) + .then() + .log().body() + .statusCode(org.hamcrest.Matchers.anyOf(equalTo(200), equalTo(400))); + + assertThat(parkingRepository.count()) + .as("no parking must be persisted when invalid time value is submitted") + .isZero(); + } + + @Test + public void export_stopPlaceWithGraphQlSetAvailabilityConditions_doesNotFail() throws Exception { + StopPlace stopPlace = new StopPlace(new EmbeddableMultilingualString("Test stop")); + stopPlace.setStopPlaceType(StopTypeEnumeration.ONSTREET_BUS); + stopPlace = stopPlaceVersionedSaverService.saveNewVersion(stopPlace); + String stopNetexId = stopPlace.getNetexId(); + + String mutation = """ + { + "query": "mutation { parking: %s (Parking: { name: { value: \\"Test\\" lang: \\"fi\\" } parkingType: parkAndRide parentSiteRef: \\"%s\\" availabilityConditions: [{ dayTypeRef: \\"FSR:DayType:BusinessDay\\" isAvailable: true startTime: \\"06:00\\" endTime: \\"22:00\\" }, { dayTypeRef: \\"FSR:DayType:Sunday\\" isAvailable: false }] }) { id } }", + "variables": "" + } + """.formatted(GraphQLNames.MUTATE_PARKING, stopNetexId); + + given() + .port(port) + .contentType(ContentType.JSON) + .body(mutation) + .when() + .post(BASE_URI_GRAPHQL) + .then() + .statusCode(200) + .body("data.parking[0].id", notNullValue()); + + java.net.http.HttpClient client = java.net.http.HttpClient.newHttpClient(); + java.net.http.HttpRequest request = java.net.http.HttpRequest.newBuilder() + .uri(java.net.URI.create("http://localhost:" + port + SERVICES_STOP_PLACE_PATH + + "/netex?idList=" + stopNetexId)) + .GET() + .build(); + java.net.http.HttpResponse response = client.send(request, + java.net.http.HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()) + .as("NeTEx export of a StopPlace whose Parking has GraphQL-set availabilityConditions " + + "must not fail. Response body:\n" + response.body()) + .isEqualTo(200); + assertThat(response.body()).contains(" parentRef = parentRef(entity); return new ReadApiEntityInRecord( entity.getNetexId(), - entity.getClass().getSimpleName(), + entityTypeName(entity), searchKey, xml, entity.getVersion(), @@ -171,6 +171,31 @@ private ReadApiEntityInRecord createEntityRecord(EntityInVersionStructure entity ); } + /** + * The cache table's {@code type} column must hold the NeTEx element type + * ("StopPlace", "Parking", ...) so that {@code streamStopPlaces}' hardcoded {@code type IN + * (...)} filter matches it. Using {@code entity.getClass().getSimpleName()} directly breaks + * for entities backed by a Tiamat JPA subclass (e.g. {@code FintrafficParking}, produced by + * {@code ParkingEntityFactory} for every Parking under the fintraffic profile) since the + * runtime simple name ("FintrafficParking") never matches the filter, silently hiding the + * entity from the Read API even though the row was written successfully. + */ + private static String entityTypeName(EntityInVersionStructure entity) { + if (entity instanceof StopPlace) { + return "StopPlace"; + } + if (entity instanceof Parking) { + return "Parking"; + } + if (entity instanceof TopographicPlace) { + return "TopographicPlace"; + } + if (entity instanceof FareZone) { + return "FareZone"; + } + return entity.getClass().getSimpleName(); + } + private static JAXBContext createJAXBContext(Class clazz) { try { return JAXBContext.newInstance(clazz); diff --git a/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/config/FintrafficEntityScanConfig.java b/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/config/FintrafficEntityScanConfig.java new file mode 100644 index 0000000000..2f74e5510f --- /dev/null +++ b/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/config/FintrafficEntityScanConfig.java @@ -0,0 +1,37 @@ +package org.rutebanken.tiamat.ext.fintraffic.config; + +import org.rutebanken.tiamat.ext.fintraffic.model.FintrafficParking; +import org.springframework.boot.persistence.autoconfigure.EntityScanPackages; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.ImportBeanDefinitionRegistrar; +import org.springframework.context.annotation.Profile; +import org.springframework.core.type.AnnotationMetadata; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.context.annotation.Import; + +/** + * Adds {@code org.rutebanken.tiamat.ext.fintraffic.model} to Hibernate's entity scan. + *

+ * Core's {@code TiamatApplication} only declares + * {@code @EntityScan(basePackageClasses = {StopPlace.class, ...})}, which covers + * {@code org.rutebanken.tiamat.model} — not this ext package. Without this, Hibernate + * never registers {@link FintrafficParking} (or any other ext {@code @Entity}) and + * every operation on it fails with "... is not an entity". + *

+ * {@link EntityScanPackages#register} is additive: if a package list is already + * registered (by core's {@code @EntityScan}), it appends to it rather than replacing + * it, so this does not require modifying {@code TiamatApplication} — core is only + * ever extended here, never overridden. + */ +@Configuration +@Profile("fintraffic") +@Import(FintrafficEntityScanConfig.Registrar.class) +public class FintrafficEntityScanConfig { + + static class Registrar implements ImportBeanDefinitionRegistrar { + @Override + public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) { + EntityScanPackages.register(registry, FintrafficParking.class.getPackageName()); + } + } +} diff --git a/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/config/FintrafficGraphQLConfig.java b/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/config/FintrafficGraphQLConfig.java new file mode 100644 index 0000000000..d70b87d762 --- /dev/null +++ b/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/config/FintrafficGraphQLConfig.java @@ -0,0 +1,23 @@ +package org.rutebanken.tiamat.ext.fintraffic.config; + +import org.rutebanken.tiamat.ext.fintraffic.rest.graphql.FintrafficParkingUpdater; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +/** + * Registers Fintraffic-specific GraphQL beans, overriding their core counterparts + * by using the same bean name. Bean overriding is enabled globally via + * {@code spring.main.allow-bean-definition-overriding=true}. {@code @Bean} methods + * in {@code @Configuration} classes are registered after {@code @Service} + * component-scanned beans, so the override is reliable without needing {@code @Primary}. + */ +@Configuration +@Profile("fintraffic") +public class FintrafficGraphQLConfig { + + @Bean("parkingUpdater") + public FintrafficParkingUpdater parkingUpdater() { + return new FintrafficParkingUpdater(); + } +} diff --git a/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/db/FintrafficFlywayConfig.java b/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/db/FintrafficFlywayConfig.java index 41d0adf6cc..b7b9906a77 100644 --- a/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/db/FintrafficFlywayConfig.java +++ b/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/db/FintrafficFlywayConfig.java @@ -4,6 +4,11 @@ import org.flywaydb.core.Flyway; import org.flywaydb.core.api.migration.JavaMigration; import org.rutebanken.tiamat.ext.fintraffic.db.migration.V2__CreateExtFintrafficNetexEntityTable; +import org.rutebanken.tiamat.ext.fintraffic.db.migration.V3__FintrafficParkingExtensions; +import org.rutebanken.tiamat.ext.fintraffic.db.migration.V4__FintrafficParkingInfoLinks; +import org.rutebanken.tiamat.ext.fintraffic.db.migration.V5__FintrafficParkingVehicleEntrances; +import org.rutebanken.tiamat.ext.fintraffic.db.migration.V6__FintrafficParkingLighting; +import org.rutebanken.tiamat.ext.fintraffic.db.migration.V7__FintrafficParkingAvailabilityConditions; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Configuration; @@ -12,7 +17,7 @@ import javax.sql.DataSource; import java.util.List; -@Profile("fintraffic-read-api") +@Profile({"fintraffic-read-api", "fintraffic"}) @Configuration public class FintrafficFlywayConfig { private final Logger logger = LoggerFactory.getLogger(FintrafficFlywayConfig.class); @@ -20,7 +25,12 @@ public class FintrafficFlywayConfig { private final DataSource dataSource; private static final List> migrations = List.of( - V2__CreateExtFintrafficNetexEntityTable.class + V2__CreateExtFintrafficNetexEntityTable.class, + V3__FintrafficParkingExtensions.class, + V4__FintrafficParkingInfoLinks.class, + V5__FintrafficParkingVehicleEntrances.class, + V6__FintrafficParkingLighting.class, + V7__FintrafficParkingAvailabilityConditions.class ); public FintrafficFlywayConfig(DataSource dataSource) { diff --git a/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/db/migration/V3__FintrafficParkingExtensions.java b/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/db/migration/V3__FintrafficParkingExtensions.java new file mode 100644 index 0000000000..35dafc40d6 --- /dev/null +++ b/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/db/migration/V3__FintrafficParkingExtensions.java @@ -0,0 +1,35 @@ +package org.rutebanken.tiamat.ext.fintraffic.db.migration; + +import org.flywaydb.core.api.migration.BaseJavaMigration; +import org.flywaydb.core.api.migration.Context; + +import java.sql.Statement; + +/** + * Adds DDL required by {@code FintrafficParking}: + *

    + *
  • {@code parking_payment_methods} collection table for persisted payment methods
  • + *
+ * Note: the {@code dtype} discriminator column is added by the core Flyway migration V62, + * because {@code FintrafficParking} is compiled into the same jar and Hibernate always + * requires {@code dtype} regardless of active Spring profiles. + */ +public class V3__FintrafficParkingExtensions extends BaseJavaMigration { + + @Override + public void migrate(Context context) throws Exception { + String sql = """ + CREATE TABLE IF NOT EXISTS parking_payment_methods ( + parking_id BIGINT NOT NULL REFERENCES parking(id), + payment_method VARCHAR(64) NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_parking_payment_methods_parking_id + ON parking_payment_methods (parking_id); + """; + + try (Statement stmt = context.getConnection().createStatement()) { + stmt.execute(sql); + } + } +} diff --git a/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/db/migration/V4__FintrafficParkingInfoLinks.java b/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/db/migration/V4__FintrafficParkingInfoLinks.java new file mode 100644 index 0000000000..6217fb06d1 --- /dev/null +++ b/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/db/migration/V4__FintrafficParkingInfoLinks.java @@ -0,0 +1,39 @@ +package org.rutebanken.tiamat.ext.fintraffic.db.migration; + +import org.flywaydb.core.api.migration.BaseJavaMigration; +import org.flywaydb.core.api.migration.Context; + +import java.sql.Statement; + +/** + * Creates the {@code parking_info_links} collection table required by + * {@link org.rutebanken.tiamat.ext.fintraffic.model.FintrafficParking#getInfoLinks()}. + *

+ * {@code typeOfInfoLink} stores the NeTEx {@code TypeOfInfolinkEnumeration} value string + * (e.g. {@code resource}, {@code info}). A CHECK constraint enforces the allowed + * set; nullable because the attribute is optional in the NeTEx schema. + */ +public class V4__FintrafficParkingInfoLinks extends BaseJavaMigration { + + @Override + public void migrate(Context context) throws Exception { + String sql = """ + CREATE TABLE IF NOT EXISTS parking_info_links ( + parking_id BIGINT NOT NULL REFERENCES parking(id), + uri VARCHAR(512) NOT NULL, + type_of_info_link VARCHAR(64) CHECK (type_of_info_link IN ( + 'contact', 'resource', 'info', 'image', 'document', + 'timetableDocument', 'fareSheet', 'dataLicence', + 'mobileAppDownload', 'mobileAppInstallCheck', 'map', 'icon', 'other' + )) + ); + + CREATE INDEX IF NOT EXISTS idx_parking_info_links_parking_id + ON parking_info_links (parking_id); + """; + + try (Statement stmt = context.getConnection().createStatement()) { + stmt.execute(sql); + } + } +} diff --git a/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/db/migration/V5__FintrafficParkingVehicleEntrances.java b/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/db/migration/V5__FintrafficParkingVehicleEntrances.java new file mode 100644 index 0000000000..1abe21de42 --- /dev/null +++ b/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/db/migration/V5__FintrafficParkingVehicleEntrances.java @@ -0,0 +1,43 @@ +package org.rutebanken.tiamat.ext.fintraffic.db.migration; + +import org.flywaydb.core.api.migration.BaseJavaMigration; +import org.flywaydb.core.api.migration.Context; + +import java.sql.Statement; + +/** + * Creates the {@code parking_vehicle_entrances} collection table required by + * {@link org.rutebanken.tiamat.ext.fintraffic.model.FintrafficParking#getVehicleEntrances()}. + *

+ * {@code entrance_type} stores the NeTEx {@code EntranceEnumeration} value string + * (e.g. {@code door}, {@code gate}). A CHECK constraint enforces the allowed set; + * nullable because the attribute is optional in the NeTEx schema. + */ +public class V5__FintrafficParkingVehicleEntrances extends BaseJavaMigration { + + @Override + public void migrate(Context context) throws Exception { + String sql = """ + CREATE TABLE IF NOT EXISTS parking_vehicle_entrances ( + parking_id BIGINT NOT NULL REFERENCES parking(id), + label VARCHAR(255), + entrance_type VARCHAR(64) CHECK (entrance_type IN ( + 'opening', 'openDoor', 'door', 'swingDoor', 'revolvingDoor', + 'automaticDoor', 'ticketBarrier', 'gate', 'other' + )), + width NUMERIC(10, 2), + height NUMERIC(10, 2), + is_entry BOOLEAN, + is_exit BOOLEAN, + public_code VARCHAR(64) + ); + + CREATE INDEX IF NOT EXISTS idx_parking_vehicle_entrances_parking_id + ON parking_vehicle_entrances (parking_id); + """; + + try (Statement stmt = context.getConnection().createStatement()) { + stmt.execute(sql); + } + } +} diff --git a/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/db/migration/V6__FintrafficParkingLighting.java b/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/db/migration/V6__FintrafficParkingLighting.java new file mode 100644 index 0000000000..88cfbe6826 --- /dev/null +++ b/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/db/migration/V6__FintrafficParkingLighting.java @@ -0,0 +1,30 @@ +package org.rutebanken.tiamat.ext.fintraffic.db.migration; + +import org.flywaydb.core.api.migration.BaseJavaMigration; +import org.flywaydb.core.api.migration.Context; + +import java.sql.Statement; + +/** + * Creates the {@code parking_fintraffic_lighting} collection table used by + * {@link org.rutebanken.tiamat.ext.fintraffic.model.FintrafficParking} to persist the + * {@code lighting} field that is {@code @Transient} in the core {@code SiteElement} model. + *

+ * Using a separate collection table (rather than a column on {@code parking}) keeps + * Entur's core DDL unmodified and prevents Hibernate from selecting ext-only columns in + * core tests that do not run the Fintraffic Flyway migrations. + */ +public class V6__FintrafficParkingLighting extends BaseJavaMigration { + + @Override + public void migrate(Context context) throws Exception { + try (Statement stmt = context.getConnection().createStatement()) { + stmt.execute(""" + CREATE TABLE IF NOT EXISTS parking_fintraffic_lighting ( + parking_id BIGINT NOT NULL REFERENCES parking(id), + lighting VARCHAR(64) NOT NULL + ) + """); + } + } +} diff --git a/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/db/migration/V7__FintrafficParkingAvailabilityConditions.java b/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/db/migration/V7__FintrafficParkingAvailabilityConditions.java new file mode 100644 index 0000000000..a1e526cdb6 --- /dev/null +++ b/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/db/migration/V7__FintrafficParkingAvailabilityConditions.java @@ -0,0 +1,30 @@ +package org.rutebanken.tiamat.ext.fintraffic.db.migration; + +import org.flywaydb.core.api.migration.BaseJavaMigration; +import org.flywaydb.core.api.migration.Context; + +import java.sql.Statement; + +public class V7__FintrafficParkingAvailabilityConditions extends BaseJavaMigration { + + @Override + public void migrate(Context context) throws Exception { + String sql = """ + CREATE TABLE IF NOT EXISTS parking_availability_conditions ( + parking_id BIGINT NOT NULL REFERENCES parking(id), + day_type_ref VARCHAR(128) NOT NULL, + is_available BOOLEAN NOT NULL DEFAULT TRUE, + start_time TIME, + end_time TIME, + UNIQUE (parking_id, day_type_ref) + ); + + CREATE INDEX IF NOT EXISTS idx_parking_avail_cond_parking_id + ON parking_availability_conditions (parking_id); + """; + + try (Statement stmt = context.getConnection().createStatement()) { + stmt.execute(sql); + } + } +} diff --git a/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/importer/FintrafficMergingParkingImporter.java b/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/importer/FintrafficMergingParkingImporter.java new file mode 100644 index 0000000000..97936b3356 --- /dev/null +++ b/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/importer/FintrafficMergingParkingImporter.java @@ -0,0 +1,92 @@ +package org.rutebanken.tiamat.ext.fintraffic.importer; + +import org.rutebanken.tiamat.ext.fintraffic.model.FintrafficInfoLink; +import org.rutebanken.tiamat.ext.fintraffic.model.FintrafficParking; +import org.rutebanken.tiamat.ext.fintraffic.model.FintrafficParkingAvailabilityCondition; +import org.rutebanken.tiamat.ext.fintraffic.model.FintrafficParkingEntranceForVehicles; +import org.rutebanken.tiamat.importer.KeyValueListAppender; +import org.rutebanken.tiamat.importer.finder.NearbyParkingFinder; +import org.rutebanken.tiamat.importer.finder.ParkingFromOriginalIdFinder; +import org.rutebanken.tiamat.importer.merging.MergingParkingImporter; +import org.rutebanken.tiamat.model.Parking; +import org.rutebanken.tiamat.model.PaymentMethodEnumeration; +import org.rutebanken.tiamat.model.factory.ParkingEntityFactory; +import org.rutebanken.tiamat.netex.mapping.NetexMapper; +import org.rutebanken.tiamat.repository.reference.ReferenceResolver; +import org.rutebanken.tiamat.versioning.VersionCreator; +import org.rutebanken.tiamat.versioning.save.ParkingVersionedSaverService; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Profile; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** + * Fintraffic extension of {@link MergingParkingImporter} that preserves + * {@link FintrafficParking#getPaymentMethods() paymentMethods} and + * {@link FintrafficParking#getInfoLinks() infoLinks} on both import paths. + */ +@Profile("fintraffic") +@Primary +@Component +@Qualifier("mergingParkingImporter") +public class FintrafficMergingParkingImporter extends MergingParkingImporter { + + public FintrafficMergingParkingImporter(ParkingFromOriginalIdFinder parkingFromOriginalIdFinder, + NearbyParkingFinder nearbyParkingFinder, + ReferenceResolver referenceResolver, + KeyValueListAppender keyValueListAppender, + NetexMapper netexMapper, + ParkingVersionedSaverService parkingVersionedSaverService, + VersionCreator versionCreator, + ParkingEntityFactory parkingEntityFactory) { + super(parkingFromOriginalIdFinder, nearbyParkingFinder, referenceResolver, + keyValueListAppender, netexMapper, parkingVersionedSaverService, + versionCreator, parkingEntityFactory); + } + + @Override + protected boolean mergeExtendedFields(Parking incomingParking, Parking copy) { + if (!(copy instanceof FintrafficParking target)) { + return false; + } + + boolean changed = false; + + // paymentMethods — always available via Parking.getPaymentMethods() (transient field) + List incomingMethods = incomingParking.getPaymentMethods(); + List existingMethods = target.getPaymentMethods(); + if (!incomingMethods.equals(existingMethods)) { + target.setPaymentMethods(new ArrayList<>(incomingMethods)); + changed = true; + } + + // infoLinks — only available when incomingParking is also a FintrafficParking + if (incomingParking instanceof FintrafficParking incomingFP) { + List incomingLinks = incomingFP.getInfoLinks(); + List existingLinks = target.getInfoLinks(); + if (!incomingLinks.equals(existingLinks)) { + target.setInfoLinks(new ArrayList<>(incomingLinks)); + changed = true; + } + + List incomingEntrances = incomingFP.getFintrafficVehicleEntrances(); + List existingEntrances = target.getFintrafficVehicleEntrances(); + if (!incomingEntrances.equals(existingEntrances)) { + target.setFintrafficVehicleEntrances(new ArrayList<>(incomingEntrances)); + changed = true; + } + + List incomingConditions = incomingFP.getAvailabilityConditions(); + List existingConditions = target.getAvailabilityConditions(); + if (!incomingConditions.equals(existingConditions)) { + target.setAvailabilityConditions(new ArrayList<>(incomingConditions)); + changed = true; + } + } + + return changed; + } +} diff --git a/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/importer/FintrafficParkingMapperContributor.java b/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/importer/FintrafficParkingMapperContributor.java new file mode 100644 index 0000000000..a1c449766f --- /dev/null +++ b/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/importer/FintrafficParkingMapperContributor.java @@ -0,0 +1,458 @@ +package org.rutebanken.tiamat.ext.fintraffic.importer; + +import ma.glasnost.orika.MappingContext; +import jakarta.xml.bind.JAXBElement; +import org.rutebanken.netex.model.AvailabilityCondition; +import org.rutebanken.netex.model.DayTypeRefStructure; +import org.rutebanken.netex.model.DayTypes_RelStructure; +import org.rutebanken.netex.model.EntranceEnumeration; +import org.rutebanken.netex.model.InfoLinkStructure; +import org.rutebanken.netex.model.ObjectFactory; +import org.rutebanken.netex.model.ParkingEntranceForVehicles; +import org.rutebanken.netex.model.ParkingEntrancesForVehicles_RelStructure; +import org.rutebanken.netex.model.Timeband_VersionedChildStructure; +import org.rutebanken.netex.model.Timebands_RelStructure; +import org.rutebanken.netex.model.ValidityConditions_RelStructure; +import org.rutebanken.tiamat.ext.fintraffic.model.FintrafficParkingAvailabilityCondition; +import org.rutebanken.tiamat.ext.fintraffic.model.FintrafficInfoLink; +import org.rutebanken.tiamat.ext.fintraffic.model.FintrafficParking; +import org.rutebanken.tiamat.ext.fintraffic.model.FintrafficParkingEntranceForVehicles; +import org.rutebanken.tiamat.model.PaymentMethodEnumeration; +import org.rutebanken.tiamat.netex.mapping.mapper.ParkingMapperContributor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; + +/** + * Fintraffic extension of {@link ParkingMapperContributor} that wires + * {@code paymentMethods} and {@code infoLinks} through the NeTEx ↔ Tiamat + * mapping in both directions. + * + *

Import ({@link #mapFromNetex}): copies the NeTEx fields onto the + * {@link FintrafficParking} so that the + * {@link FintrafficMergingParkingImporter#mergeExtendedFields} hook can persist them. + * + *

Export ({@link #mapToNetex}): reads the persisted fields from a + * {@link FintrafficParking} and writes them back into the NeTEx output so that the + * fields survive the export roundtrip. + */ +@Profile("fintraffic") +@Component +public class FintrafficParkingMapperContributor implements ParkingMapperContributor { + + private static final Logger logger = LoggerFactory.getLogger(FintrafficParkingMapperContributor.class); + private static final ObjectFactory OBJECT_FACTORY = new ObjectFactory(); + + @Override + public void mapFromNetex(org.rutebanken.netex.model.Parking source, + org.rutebanken.tiamat.model.Parking target, + MappingContext context) { + mapPaymentMethodsFromNetex(source, target); + mapInfoLinksFromNetex(source, target); + mapVehicleEntrancesFromNetex(source, target); + mapAvailabilityConditionsFromNetex(source, target); + mapLightingFromNetex(source, target); + } + + @Override + public void mapToNetex(org.rutebanken.tiamat.model.Parking source, + org.rutebanken.netex.model.Parking target, + MappingContext context) { + if (!(source instanceof FintrafficParking fp)) { + return; + } + mapPaymentMethodsToNetex(fp, target); + mapInfoLinksToNetex(fp, target); + mapVehicleEntrancesToNetex(fp, target); + mapAvailabilityConditionsToNetex(fp, target); + mapLightingToNetex(fp, target); + } + + // --- lighting --- + + /** + * {@code Lighting} is declared on {@link org.rutebanken.netex.model.SiteElement_VersionStructure}, + * an ancestor of NeTEx's {@link org.rutebanken.netex.model.Parking}, so it is a valid NeTEx + * element for parking (confirmed via {@code Parking.withLighting(LightingEnumeration)}). + * The core {@link org.rutebanken.tiamat.model.Parking#getLighting()}/{@code setLighting(...)} + * accessors are only overridden (non-transient) on {@link FintrafficParking}, so this + * contributor is the only place that carries the value across the NeTEx boundary in either + * direction — without it, lighting set via GraphQL/persisted in + * {@code parking_fintraffic_lighting} would silently be dropped from NeTEx export and the + * Fintraffic Read API output. + */ + private void mapLightingFromNetex(org.rutebanken.netex.model.Parking source, + org.rutebanken.tiamat.model.Parking target) { + if (!(target instanceof FintrafficParking fp)) { + return; + } + var lighting = source.getLighting(); + if (lighting == null) { + return; + } + try { + fp.setLighting(org.rutebanken.tiamat.model.LightingEnumeration.fromValue(lighting.value())); + } catch (IllegalArgumentException ignored) { + // unknown value — skip + } + } + + private void mapLightingToNetex(FintrafficParking source, + org.rutebanken.netex.model.Parking target) { + var lighting = source.getLighting(); + if (lighting == null) { + return; + } + try { + target.setLighting(org.rutebanken.netex.model.LightingEnumeration.fromValue(lighting.value())); + } catch (IllegalArgumentException ignored) { + // stored value no longer valid — skip + } + } + + // --- paymentMethods --- + + private void mapPaymentMethodsFromNetex(org.rutebanken.netex.model.Parking source, + org.rutebanken.tiamat.model.Parking target) { + var netexMethods = source.getPaymentMethods(); + if (netexMethods == null || netexMethods.isEmpty()) { + return; + } + var targetMethods = target.getPaymentMethods(); + targetMethods.clear(); + for (var netexMethod : netexMethods) { + try { + targetMethods.add(PaymentMethodEnumeration.fromValue(netexMethod.value())); + } catch (IllegalArgumentException ignored) { + // skip unknown values + } + } + } + + private void mapPaymentMethodsToNetex(FintrafficParking source, + org.rutebanken.netex.model.Parking target) { + var methods = source.getPaymentMethods(); + if (methods.isEmpty()) { + return; + } + var targetMethods = target.getPaymentMethods(); + targetMethods.clear(); + for (var method : methods) { + try { + targetMethods.add(org.rutebanken.netex.model.PaymentMethodEnumeration.fromValue(method.value())); + } catch (IllegalArgumentException ignored) { + // skip unknown values + } + } + } + + // --- infoLinks --- + + private void mapInfoLinksFromNetex(org.rutebanken.netex.model.Parking source, + org.rutebanken.tiamat.model.Parking target) { + if (!(target instanceof FintrafficParking fp)) { + return; + } + var infoLinksRelStruct = source.getInfoLinks(); + if (infoLinksRelStruct == null) { + return; + } + List netexLinks = infoLinksRelStruct.getInfoLink(); + if (netexLinks == null || netexLinks.isEmpty()) { + return; + } + + List converted = new ArrayList<>(); + for (InfoLinkStructure link : netexLinks) { + if (link.getValue() == null || link.getValue().isBlank()) { + continue; + } + String typeValue = null; + var types = link.getTypeOfInfoLink(); + if (types != null && !types.isEmpty()) { + typeValue = types.getFirst().value(); + } + converted.add(new FintrafficInfoLink(link.getValue(), typeValue)); + } + fp.setInfoLinks(converted); + } + + private void mapInfoLinksToNetex(FintrafficParking source, + org.rutebanken.netex.model.Parking target) { + var links = source.getInfoLinks(); + if (links.isEmpty()) { + return; + } + + var relStruct = new org.rutebanken.netex.model.GroupOfEntities_VersionStructure.InfoLinks(); + for (FintrafficInfoLink link : links) { + InfoLinkStructure netexLink = new InfoLinkStructure(); + netexLink.setValue(link.getUri()); + if (link.getTypeOfInfoLink() != null) { + try { + netexLink.getTypeOfInfoLink().add( + org.rutebanken.netex.model.TypeOfInfolinkEnumeration.fromValue(link.getTypeOfInfoLink())); + } catch (IllegalArgumentException ignored) { + // stored value no longer valid — skip type + } + } + relStruct.getInfoLink().add(netexLink); + } + target.setInfoLinks(relStruct); + } + + // --- vehicleEntrances --- + + private void mapVehicleEntrancesFromNetex(org.rutebanken.netex.model.Parking source, + org.rutebanken.tiamat.model.Parking target) { + if (!(target instanceof FintrafficParking fp)) { + return; + } + ParkingEntrancesForVehicles_RelStructure relStruct = source.getVehicleEntrances(); + if (relStruct == null) { + return; + } + List items = relStruct.getParkingEntranceForVehiclesRefOrParkingEntranceForVehicles(); + if (items == null || items.isEmpty()) { + return; + } + + List converted = new ArrayList<>(); + for (Object item : items) { + if (!(item instanceof ParkingEntranceForVehicles entrance)) { + continue; + } + String label = entrance.getLabel() != null ? entrance.getLabel().getValue() : null; + String entranceType = entrance.getEntranceType() != null ? entrance.getEntranceType().value() : null; + converted.add(new FintrafficParkingEntranceForVehicles( + label, + entranceType, + entrance.getWidth(), + entrance.getHeight(), + entrance.isIsEntry(), + entrance.isIsExit(), + entrance.getPublicCode() + )); + } + fp.setFintrafficVehicleEntrances(converted); + } + + private void mapVehicleEntrancesToNetex(FintrafficParking source, + org.rutebanken.netex.model.Parking target) { + var entrances = source.getFintrafficVehicleEntrances(); + if (entrances.isEmpty()) { + return; + } + + // FintrafficParkingEntranceForVehicles is an @Embeddable value object (no netex id + // of its own, unlike e.g. ParkingProperties/ParkingCapacity). NeTEx's schema requires + // every ParkingEntranceForVehicles element to carry a unique id+version (identity + // constraint ParkingEntranceForVehicles_AnyVersionedKey) or export fails with a + // MarshalException. Synthesize a stable, unique id per entrance from the parent + // Parking's own netex id plus the entrance's position in the list. + String syntheticIdPrefix = syntheticEntranceIdPrefix(source.getNetexId()); + + int index = 0; + ParkingEntrancesForVehicles_RelStructure relStruct = new ParkingEntrancesForVehicles_RelStructure(); + for (FintrafficParkingEntranceForVehicles entrance : entrances) { + index++; + ParkingEntranceForVehicles netexEntrance = new ParkingEntranceForVehicles(); + if (syntheticIdPrefix != null) { + netexEntrance.setId(syntheticIdPrefix + "_" + index); + netexEntrance.setVersion("1"); + } + if (entrance.getLabel() != null) { + netexEntrance.setLabel(new org.rutebanken.netex.model.MultilingualString().withValue(entrance.getLabel())); + } + if (entrance.getEntranceType() != null) { + try { + netexEntrance.setEntranceType(EntranceEnumeration.fromValue(entrance.getEntranceType())); + } catch (IllegalArgumentException ignored) { + // stored value no longer valid — skip type + } + } + netexEntrance.setWidth(entrance.getWidth()); + netexEntrance.setHeight(entrance.getHeight()); + netexEntrance.setIsEntry(entrance.getIsEntry()); + netexEntrance.setIsExit(entrance.getIsExit()); + netexEntrance.setPublicCode(entrance.getPublicCode()); + relStruct.getParkingEntranceForVehiclesRefOrParkingEntranceForVehicles().add(netexEntrance); + } + target.setVehicleEntrances(relStruct); + } + + /** + * Builds a {@code codespace:ParkingEntranceForVehicles:} id prefix from + * the parent Parking's own netex id (e.g. {@code NSR:FintrafficParking:220} → + * {@code NSR:ParkingEntranceForVehicles:220}), to which the caller appends + * {@code _} for uniqueness across multiple entrances. Returns {@code null} if the + * parking has no netex id yet (should not happen for a persisted entity being exported). + */ + private String syntheticEntranceIdPrefix(String parkingNetexId) { + if (parkingNetexId == null || parkingNetexId.chars().filter(c -> c == ':').count() != 2) { + return null; + } + String prefix = parkingNetexId.substring(0, parkingNetexId.indexOf(':')); + String numericValue = parkingNetexId.substring(parkingNetexId.lastIndexOf(':') + 1); + return prefix + ":ParkingEntranceForVehicles:" + numericValue; + } + + // --- availabilityConditions --- + + private void mapAvailabilityConditionsFromNetex(org.rutebanken.netex.model.Parking source, + org.rutebanken.tiamat.model.Parking target) { + if (!(target instanceof FintrafficParking fp)) { + return; + } + var validityConditions = source.getValidityConditions(); + if (validityConditions == null) { + return; + } + + LinkedHashMap byDayType = new LinkedHashMap<>(); + for (Object entry : validityConditions.getValidityConditionRefOrValidBetweenOrValidityCondition_()) { + if (!(entry instanceof JAXBElement jaxbElement)) { + continue; + } + if (!(jaxbElement.getValue() instanceof AvailabilityCondition availabilityCondition)) { + continue; + } + + String dayTypeRef = extractDayTypeRef(source, availabilityCondition); + if (dayTypeRef == null) { + continue; + } + + LocalTime startTime = null; + LocalTime endTime = null; + Timeband_VersionedChildStructure inlineTimeband = extractInlineTimeband(source, availabilityCondition); + if (inlineTimeband != null) { + startTime = inlineTimeband.getStartTime(); + endTime = inlineTimeband.getEndTime(); + } + + boolean isAvailable = availabilityCondition.isIsAvailable() == null || availabilityCondition.isIsAvailable(); + if (byDayType.containsKey(dayTypeRef)) { + logger.warn("Parking {} has duplicate AvailabilityCondition for dayTypeRef '{}'; keeping the last one", + source.getId(), dayTypeRef); + } + byDayType.put(dayTypeRef, new FintrafficParkingAvailabilityCondition(dayTypeRef, isAvailable, startTime, endTime)); + } + + fp.setAvailabilityConditions(new ArrayList<>(byDayType.values())); + } + + private String extractDayTypeRef(org.rutebanken.netex.model.Parking source, AvailabilityCondition availabilityCondition) { + DayTypes_RelStructure dayTypes = availabilityCondition.getDayTypes(); + if (dayTypes == null || dayTypes.getDayTypeRefOrDayType_().isEmpty()) { + return null; + } + if (dayTypes.getDayTypeRefOrDayType_().size() > 1) { + logger.warn("Parking {} AvailabilityCondition has {} dayTypes; using the first DayTypeRef only", + source.getId(), dayTypes.getDayTypeRefOrDayType_().size()); + } + for (JAXBElement dayTypeEntry : dayTypes.getDayTypeRefOrDayType_()) { + if (dayTypeEntry.getValue() instanceof DayTypeRefStructure ref) { + return ref.getRef(); + } + } + return null; + } + + private Timeband_VersionedChildStructure extractInlineTimeband(org.rutebanken.netex.model.Parking source, AvailabilityCondition availabilityCondition) { + Timebands_RelStructure timebands = availabilityCondition.getTimebands(); + if (timebands == null || timebands.getTimebandRefOrTimeband().isEmpty()) { + return null; + } + + Timeband_VersionedChildStructure firstInlineTimeband = null; + int inlineTimebandCount = 0; + for (Object timebandEntry : timebands.getTimebandRefOrTimeband()) { + // Timebands_RelStructure.timebandRefOrTimeband is @XmlElements (type-matched, not JAXBElement-wrapped), + // so a schema-valid inline Timeband is a raw Timeband_VersionedChildStructure. Also accept a + // JAXBElement-wrapped value defensively, in case some producer wraps it non-standardly. + Timeband_VersionedChildStructure timeband = null; + if (timebandEntry instanceof Timeband_VersionedChildStructure raw) { + timeband = raw; + } else if (timebandEntry instanceof JAXBElement timebandJaxb + && timebandJaxb.getValue() instanceof Timeband_VersionedChildStructure wrapped) { + timeband = wrapped; + } + if (timeband != null) { + inlineTimebandCount++; + if (firstInlineTimeband == null) { + firstInlineTimeband = timeband; + } + } + } + + if (inlineTimebandCount > 1) { + logger.warn("Parking {} AvailabilityCondition has {} inline timebands; using the first only", + source.getId(), inlineTimebandCount); + } + + return firstInlineTimeband; + } + + private void mapAvailabilityConditionsToNetex(FintrafficParking source, + org.rutebanken.netex.model.Parking target) { + var conditions = source.getAvailabilityConditions(); + if (conditions.isEmpty()) { + return; + } + + ValidityConditions_RelStructure validityConditions = target.getValidityConditions(); + if (validityConditions == null) { + validityConditions = new ValidityConditions_RelStructure(); + target.setValidityConditions(validityConditions); + } + List validityConditionEntries = validityConditions.getValidityConditionRefOrValidBetweenOrValidityCondition_(); + // The NeTEx export pipeline maps the same Parking to NeTEx more than once per export (e.g. once per + // frame that embeds it). Unlike vehicleEntrances (which overwrites via a plain setter), this method + // appends to a list that may already carry entries from another Tiamat mapper, so a repeat invocation + // must remove only the AvailabilityCondition entries it previously added itself - otherwise the second + // invocation duplicates them with identical ids, violating NeTEx's ValidityCondition_AnyVersionedKey + // uniqueness constraint on export. + validityConditionEntries.removeIf(entry -> entry instanceof JAXBElement jaxbElement + && jaxbElement.getValue() instanceof AvailabilityCondition); + + int index = 1; + for (FintrafficParkingAvailabilityCondition condition : conditions) { + AvailabilityCondition availabilityCondition = new AvailabilityCondition() + .withId(source.getId() + ":AvailabilityCondition:" + index) + .withVersion("1") + .withIsAvailable(condition.isAvailable()); + + DayTypeRefStructure dayTypeRef = new DayTypeRefStructure().withRef(condition.getDayTypeRef()); + DayTypes_RelStructure dayTypes = new DayTypes_RelStructure(); + dayTypes.getDayTypeRefOrDayType_().add(OBJECT_FACTORY.createDayTypeRef(dayTypeRef)); + availabilityCondition.withDayTypes(dayTypes); + + if (condition.getStartTime() != null || condition.getEndTime() != null) { + // Timebands_RelStructure.timebandRefOrTimeband is @XmlElements(name="Timeband", + // type=Timeband_VersionedChildStructure.class) - not JAXBElement-wrapped, and requiring an exact + // type match since Timeband_VersionedChildStructure (like its Timeband subtype) is an anonymous + // XSD type with no name JAXB can substitute via xsi:type. Using the Timeband subtype or wrapping + // it in a JAXBElement (via ObjectFactory) both made JAXB try (and fail) to marshal a substitute + // for this anonymous type, crashing NeTEx export. + Timeband_VersionedChildStructure timeband = new Timeband_VersionedChildStructure() + .withId(source.getId() + ":Timeband:" + index) + .withVersion("1") + .withStartTime(condition.getStartTime()) + .withEndTime(condition.getEndTime()); + Timebands_RelStructure timebands = new Timebands_RelStructure(); + timebands.getTimebandRefOrTimeband().add(timeband); + availabilityCondition.withTimebands(timebands); + } + + validityConditionEntries.add(OBJECT_FACTORY.createAvailabilityCondition(availabilityCondition)); + index++; + } + } +} diff --git a/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/model/FintrafficInfoLink.java b/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/model/FintrafficInfoLink.java new file mode 100644 index 0000000000..8729e0da53 --- /dev/null +++ b/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/model/FintrafficInfoLink.java @@ -0,0 +1,72 @@ +package org.rutebanken.tiamat.ext.fintraffic.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import java.util.Objects; + +/** + * Embeddable value object for a single NeTEx {@code InfoLink} entry. + *

+ * Persisted in the {@code parking_info_links} collection table owned by + * {@link FintrafficParking}. Only the {@code uri} (the link target) and + * {@code typeOfInfoLink} (the first declared type from the NeTEx list) are + * stored. {@code targetPlatform} is intentionally not persisted in this + * increment. + */ +@Embeddable +public class FintrafficInfoLink { + + @Column(name = "uri", nullable = false, length = 512) + private String uri; + + /** + * Stores the first {@code typeOfInfoLink} value from the NeTEx element as a + * plain string (e.g. {@code "resource"}, {@code "info"}). Nullable because + * the attribute is optional in the NeTEx schema. + */ + @Column(name = "type_of_info_link", length = 64) + private String typeOfInfoLink; + + public FintrafficInfoLink() { + } + + public FintrafficInfoLink(String uri, String typeOfInfoLink) { + this.uri = uri; + this.typeOfInfoLink = typeOfInfoLink; + } + + public String getUri() { + return uri; + } + + public void setUri(String uri) { + this.uri = uri; + } + + public String getTypeOfInfoLink() { + return typeOfInfoLink; + } + + public void setTypeOfInfoLink(String typeOfInfoLink) { + this.typeOfInfoLink = typeOfInfoLink; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof FintrafficInfoLink that)) return false; + return Objects.equals(uri, that.uri) && + Objects.equals(typeOfInfoLink, that.typeOfInfoLink); + } + + @Override + public int hashCode() { + return Objects.hash(uri, typeOfInfoLink); + } + + @Override + public String toString() { + return "FintrafficInfoLink{uri='" + uri + "', typeOfInfoLink='" + typeOfInfoLink + "'}"; + } +} diff --git a/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/model/FintrafficParking.java b/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/model/FintrafficParking.java new file mode 100644 index 0000000000..7d77762220 --- /dev/null +++ b/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/model/FintrafficParking.java @@ -0,0 +1,126 @@ +package org.rutebanken.tiamat.ext.fintraffic.model; + +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import org.rutebanken.tiamat.model.LightingEnumeration; +import org.rutebanken.tiamat.model.Parking; +import org.rutebanken.tiamat.model.PaymentMethodEnumeration; + +import java.util.ArrayList; +import java.util.List; + +/** + * Fintraffic extension of the core {@link Parking} entity. + *

+ * Persists fields that are {@code @Transient} in the core model. All ext fields + * use separate collection tables so Entur's core DDL (the {@code parking} table) remains + * unmodified — this prevents Hibernate from selecting ext columns in core tests that do + * not run the Fintraffic Flyway migrations. + * Activated when the {@code fintraffic} Spring profile is active via + * {@link FintrafficParkingEntityFactory}. + */ +@Entity +@DiscriminatorValue("FintrafficParking") +public class FintrafficParking extends Parking { + + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable( + name = "parking_fintraffic_lighting", + joinColumns = @JoinColumn(name = "parking_id") + ) + @Column(name = "lighting") + @Enumerated(EnumType.STRING) + private List lightingList = new ArrayList<>(); + + @ElementCollection(fetch = FetchType.EAGER) + @Enumerated(EnumType.STRING) + @CollectionTable( + name = "parking_payment_methods", + joinColumns = @JoinColumn(name = "parking_id") + ) + @Column(name = "payment_method") + private List paymentMethods; + + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable( + name = "parking_info_links", + joinColumns = @JoinColumn(name = "parking_id") + ) + private List infoLinks; + + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable( + name = "parking_vehicle_entrances", + joinColumns = @JoinColumn(name = "parking_id") + ) + private List fintrafficVehicleEntrances; + + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable( + name = "parking_availability_conditions", + joinColumns = @JoinColumn(name = "parking_id") + ) + private List availabilityConditions; + + @Override + public LightingEnumeration getLighting() { + return lightingList.isEmpty() ? null : lightingList.get(0); + } + + @Override + public void setLighting(LightingEnumeration value) { + this.lightingList = value == null ? new ArrayList<>() : new ArrayList<>(List.of(value)); + } + + @Override + public List getPaymentMethods() { + if (paymentMethods == null) { + paymentMethods = new ArrayList<>(); + } + return paymentMethods; + } + + public void setPaymentMethods(List value) { + this.paymentMethods = value; + } + + public List getInfoLinks() { + if (infoLinks == null) { + infoLinks = new ArrayList<>(); + } + return infoLinks; + } + + public void setInfoLinks(List infoLinks) { + this.infoLinks = infoLinks; + } + + public List getFintrafficVehicleEntrances() { + if (fintrafficVehicleEntrances == null) { + fintrafficVehicleEntrances = new ArrayList<>(); + } + return fintrafficVehicleEntrances; + } + + public void setFintrafficVehicleEntrances(List vehicleEntrances) { + this.fintrafficVehicleEntrances = vehicleEntrances; + } + + public List getAvailabilityConditions() { + if (availabilityConditions == null) { + availabilityConditions = new ArrayList<>(); + } + return availabilityConditions; + } + + public void setAvailabilityConditions(List availabilityConditions) { + this.availabilityConditions = availabilityConditions; + } +} diff --git a/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/model/FintrafficParkingAvailabilityCondition.java b/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/model/FintrafficParkingAvailabilityCondition.java new file mode 100644 index 0000000000..9036049877 --- /dev/null +++ b/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/model/FintrafficParkingAvailabilityCondition.java @@ -0,0 +1,80 @@ +package org.rutebanken.tiamat.ext.fintraffic.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import java.time.LocalTime; +import java.util.Objects; + +@Embeddable +public class FintrafficParkingAvailabilityCondition { + + @Column(name = "day_type_ref", nullable = false, length = 128) + private String dayTypeRef; + + @Column(name = "is_available", nullable = false) + private boolean available = true; + + @Column(name = "start_time") + private LocalTime startTime; + + @Column(name = "end_time") + private LocalTime endTime; + + public FintrafficParkingAvailabilityCondition() { + } + + public FintrafficParkingAvailabilityCondition(String dayTypeRef, boolean available, LocalTime startTime, LocalTime endTime) { + this.dayTypeRef = dayTypeRef; + this.available = available; + this.startTime = startTime; + this.endTime = endTime; + } + + public String getDayTypeRef() { + return dayTypeRef; + } + + public void setDayTypeRef(String dayTypeRef) { + this.dayTypeRef = dayTypeRef; + } + + public boolean isAvailable() { + return available; + } + + public void setAvailable(boolean available) { + this.available = available; + } + + public LocalTime getStartTime() { + return startTime; + } + + public void setStartTime(LocalTime startTime) { + this.startTime = startTime; + } + + public LocalTime getEndTime() { + return endTime; + } + + public void setEndTime(LocalTime endTime) { + this.endTime = endTime; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof FintrafficParkingAvailabilityCondition that)) return false; + return available == that.available && + Objects.equals(dayTypeRef, that.dayTypeRef) && + Objects.equals(startTime, that.startTime) && + Objects.equals(endTime, that.endTime); + } + + @Override + public int hashCode() { + return Objects.hash(dayTypeRef, available, startTime, endTime); + } +} diff --git a/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/model/FintrafficParkingEntityFactory.java b/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/model/FintrafficParkingEntityFactory.java new file mode 100644 index 0000000000..e1dee152d5 --- /dev/null +++ b/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/model/FintrafficParkingEntityFactory.java @@ -0,0 +1,39 @@ +package org.rutebanken.tiamat.ext.fintraffic.model; + +import org.rutebanken.tiamat.model.Parking; +import org.rutebanken.tiamat.model.factory.ParkingEntityFactory; +import org.springframework.context.annotation.Primary; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * Fintraffic override of {@link ParkingEntityFactory}. + *

+ * Active when the {@code fintraffic} Spring profile is present. Tells the + * core to instantiate {@link FintrafficParking} instead of {@link Parking}. + * {@code paymentMethods} is kept in the exclusion list because the enum types + * differ between the NeTEx and Tiamat models; {@link org.rutebanken.tiamat.ext.fintraffic.importer.FintrafficParkingMapperContributor} + * handles the conversion instead. + */ +@Primary +@Component +@Profile("fintraffic") +public class FintrafficParkingEntityFactory extends ParkingEntityFactory { + + @Override + public Parking create() { + return new FintrafficParking(); + } + + @Override + public Class getEntityClass() { + return FintrafficParking.class; + } + + @Override + public List getMappingExclusions() { + return List.of("paymentMethods", "cardsAccepted", "currenciesAccepted", "accessModes", "infoLinks", "vehicleEntrances", "availabilityConditions"); + } +} diff --git a/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/model/FintrafficParkingEntranceForVehicles.java b/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/model/FintrafficParkingEntranceForVehicles.java new file mode 100644 index 0000000000..32e644deb5 --- /dev/null +++ b/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/model/FintrafficParkingEntranceForVehicles.java @@ -0,0 +1,106 @@ +package org.rutebanken.tiamat.ext.fintraffic.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import java.math.BigDecimal; +import java.util.Objects; + +/** + * Embeddable value object for a single NeTEx {@code ParkingEntranceForVehicles} entry. + *

+ * Persisted in the {@code parking_vehicle_entrances} collection table owned by + * {@link FintrafficParking}. Only the scalar fields that Abzu can display and edit + * are stored; geometry (centroid) is omitted in this increment. + */ +@Embeddable +public class FintrafficParkingEntranceForVehicles { + + /** Human-readable label from the NeTEx {@code MultilingualString.value}. */ + @Column(name = "label", length = 255) + private String label; + + /** + * NeTEx {@code EntranceEnumeration} value string (e.g. {@code "door"}, {@code "gate"}). + * Nullable because the attribute is optional in the NeTEx schema. + */ + @Column(name = "entrance_type", length = 64) + private String entranceType; + + @Column(name = "width", precision = 10, scale = 2) + private BigDecimal width; + + @Column(name = "height", precision = 10, scale = 2) + private BigDecimal height; + + @Column(name = "is_entry") + private Boolean isEntry; + + @Column(name = "is_exit") + private Boolean isExit; + + @Column(name = "public_code", length = 64) + private String publicCode; + + public FintrafficParkingEntranceForVehicles() { + } + + public FintrafficParkingEntranceForVehicles(String label, String entranceType, BigDecimal width, + BigDecimal height, Boolean isEntry, Boolean isExit, + String publicCode) { + this.label = label; + this.entranceType = entranceType; + this.width = width; + this.height = height; + this.isEntry = isEntry; + this.isExit = isExit; + this.publicCode = publicCode; + } + + public String getLabel() { return label; } + public void setLabel(String label) { this.label = label; } + + public String getEntranceType() { return entranceType; } + public void setEntranceType(String entranceType) { this.entranceType = entranceType; } + + public BigDecimal getWidth() { return width; } + public void setWidth(BigDecimal width) { this.width = width; } + + public BigDecimal getHeight() { return height; } + public void setHeight(BigDecimal height) { this.height = height; } + + public Boolean getIsEntry() { return isEntry; } + public void setIsEntry(Boolean isEntry) { this.isEntry = isEntry; } + + public Boolean getIsExit() { return isExit; } + public void setIsExit(Boolean isExit) { this.isExit = isExit; } + + public String getPublicCode() { return publicCode; } + public void setPublicCode(String publicCode) { this.publicCode = publicCode; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof FintrafficParkingEntranceForVehicles that)) return false; + return Objects.equals(label, that.label) && + Objects.equals(entranceType, that.entranceType) && + Objects.equals(width, that.width) && + Objects.equals(height, that.height) && + Objects.equals(isEntry, that.isEntry) && + Objects.equals(isExit, that.isExit) && + Objects.equals(publicCode, that.publicCode); + } + + @Override + public int hashCode() { + return Objects.hash(label, entranceType, width, height, isEntry, isExit, publicCode); + } + + @Override + public String toString() { + return "FintrafficParkingEntranceForVehicles{" + + "label='" + label + "', entranceType='" + entranceType + "', " + + "width=" + width + ", height=" + height + ", " + + "isEntry=" + isEntry + ", isExit=" + isExit + ", publicCode='" + publicCode + "'}"; + } +} diff --git a/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/rest/graphql/FintrafficParkingGraphQLTypeContributor.java b/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/rest/graphql/FintrafficParkingGraphQLTypeContributor.java new file mode 100644 index 0000000000..89ae0785b1 --- /dev/null +++ b/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/rest/graphql/FintrafficParkingGraphQLTypeContributor.java @@ -0,0 +1,232 @@ +package org.rutebanken.tiamat.ext.fintraffic.rest.graphql; + +import graphql.schema.GraphQLEnumType; +import graphql.schema.GraphQLInputObjectType; +import graphql.schema.GraphQLList; +import graphql.schema.GraphQLNonNull; +import graphql.schema.GraphQLObjectType; +import org.rutebanken.netex.model.EntranceEnumeration; +import org.rutebanken.netex.model.TypeOfInfolinkEnumeration; +import org.rutebanken.tiamat.model.LightingEnumeration; +import org.rutebanken.tiamat.model.PaymentMethodEnumeration; +import org.rutebanken.tiamat.rest.graphql.types.CustomGraphQLTypes; +import org.rutebanken.tiamat.rest.graphql.types.ParkingGraphQLTypeContributor; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import static graphql.Scalars.GraphQLBoolean; +import static graphql.Scalars.GraphQLFloat; +import static graphql.Scalars.GraphQLString; +import static graphql.schema.GraphQLFieldDefinition.newFieldDefinition; +import static graphql.schema.GraphQLInputObjectField.newInputObjectField; +import static graphql.schema.GraphQLObjectType.newObject; +import static graphql.schema.GraphQLInputObjectType.newInputObject; + +/** + * Contributes Fintraffic-specific fields to the GraphQL parking types: + *

    + *
  • {@code lighting} — {@link LightingEnumeration} scalar
  • + *
  • {@code paymentMethods} — list of {@link PaymentMethodEnumeration} values
  • + *
  • {@code infoLinks} — list of info link objects with {@code uri} and {@code typeOfInfoLink}
  • + *
  • {@code vehicleEntrances} — list of vehicle entrance objects
  • + *
+ */ +@Profile("fintraffic") +@Component +public class FintrafficParkingGraphQLTypeContributor implements ParkingGraphQLTypeContributor { + + static final String LIGHTING = "lighting"; + static final String PAYMENT_METHODS = "paymentMethods"; + static final String PAYMENT_METHOD_ENUM = "PaymentMethodEnum"; + static final String INFO_LINKS = "infoLinks"; + static final String INFO_LINK_OUTPUT_TYPE = "FintrafficInfoLink"; + static final String INFO_LINK_INPUT_TYPE = "FintrafficInfoLinkInput"; + static final String TYPE_OF_INFO_LINK_ENUM = "TypeOfInfoLinkEnum"; + static final String URI = "uri"; + static final String TYPE_OF_INFO_LINK = "typeOfInfoLink"; + static final String VEHICLE_ENTRANCES = "vehicleEntrances"; + static final String VEHICLE_ENTRANCE_OUTPUT_TYPE = "FintrafficVehicleEntrance"; + static final String VEHICLE_ENTRANCE_INPUT_TYPE = "FintrafficVehicleEntranceInput"; + static final String ENTRANCE_TYPE_ENUM = "EntranceTypeEnum"; + static final String VEHICLE_ENTRANCE_LABEL = "label"; + static final String ENTRANCE_TYPE = "entranceType"; + static final String WIDTH = "width"; + static final String HEIGHT = "height"; + static final String IS_ENTRY = "isEntry"; + static final String IS_EXIT = "isExit"; + static final String PUBLIC_CODE = "publicCode"; + static final String AVAILABILITY_CONDITIONS = "availabilityConditions"; + static final String AVAILABILITY_CONDITION_OUTPUT_TYPE = "FintrafficAvailabilityCondition"; + static final String AVAILABILITY_CONDITION_INPUT_TYPE = "FintrafficAvailabilityConditionInput"; + static final String DAY_TYPE_REF = "dayTypeRef"; + static final String IS_AVAILABLE = "isAvailable"; + static final String START_TIME = "startTime"; + static final String END_TIME = "endTime"; + + static final GraphQLEnumType paymentMethodEnum = + CustomGraphQLTypes.createCustomEnumType(PAYMENT_METHOD_ENUM, PaymentMethodEnumeration.class); + + static final GraphQLEnumType typeOfInfoLinkEnum = + CustomGraphQLTypes.createCustomEnumType(TYPE_OF_INFO_LINK_ENUM, TypeOfInfolinkEnumeration.class); + + static final GraphQLEnumType entranceTypeEnum = + CustomGraphQLTypes.createCustomEnumType(ENTRANCE_TYPE_ENUM, EntranceEnumeration.class); + + static final GraphQLObjectType infoLinkOutputType = newObject() + .name(INFO_LINK_OUTPUT_TYPE) + .field(newFieldDefinition().name(URI).type(GraphQLNonNull.nonNull(GraphQLString))) + .field(newFieldDefinition().name(TYPE_OF_INFO_LINK).type(typeOfInfoLinkEnum)) + .build(); + + static final GraphQLInputObjectType infoLinkInputType = newInputObject() + .name(INFO_LINK_INPUT_TYPE) + .field(newInputObjectField().name(URI).type(GraphQLNonNull.nonNull(GraphQLString))) + .field(newInputObjectField().name(TYPE_OF_INFO_LINK).type(typeOfInfoLinkEnum)) + .build(); + + static final GraphQLObjectType vehicleEntranceOutputType = newObject() + .name(VEHICLE_ENTRANCE_OUTPUT_TYPE) + .field(newFieldDefinition().name(VEHICLE_ENTRANCE_LABEL).type(GraphQLString)) + .field(newFieldDefinition().name(ENTRANCE_TYPE).type(entranceTypeEnum)) + .field(newFieldDefinition().name(WIDTH).type(GraphQLFloat)) + .field(newFieldDefinition().name(HEIGHT).type(GraphQLFloat)) + .field(newFieldDefinition().name(IS_ENTRY).type(GraphQLBoolean)) + .field(newFieldDefinition().name(IS_EXIT).type(GraphQLBoolean)) + .field(newFieldDefinition().name(PUBLIC_CODE).type(GraphQLString)) + .build(); + + static final GraphQLInputObjectType vehicleEntranceInputType = newInputObject() + .name(VEHICLE_ENTRANCE_INPUT_TYPE) + .field(newInputObjectField().name(VEHICLE_ENTRANCE_LABEL).type(GraphQLString)) + .field(newInputObjectField().name(ENTRANCE_TYPE).type(entranceTypeEnum)) + .field(newInputObjectField().name(WIDTH).type(GraphQLFloat)) + .field(newInputObjectField().name(HEIGHT).type(GraphQLFloat)) + .field(newInputObjectField().name(IS_ENTRY).type(GraphQLBoolean)) + .field(newInputObjectField().name(IS_EXIT).type(GraphQLBoolean)) + .field(newInputObjectField().name(PUBLIC_CODE).type(GraphQLString)) + .build(); + + static final GraphQLObjectType availabilityConditionOutputType = newObject() + .name(AVAILABILITY_CONDITION_OUTPUT_TYPE) + .field(newFieldDefinition().name(DAY_TYPE_REF).type(GraphQLNonNull.nonNull(GraphQLString))) + .field(newFieldDefinition().name(IS_AVAILABLE).type(GraphQLBoolean)) + .field(newFieldDefinition().name(START_TIME).type(GraphQLString)) + .field(newFieldDefinition().name(END_TIME).type(GraphQLString)) + .build(); + + static final GraphQLInputObjectType availabilityConditionInputType = newInputObject() + .name(AVAILABILITY_CONDITION_INPUT_TYPE) + .field(newInputObjectField().name(DAY_TYPE_REF).type(GraphQLNonNull.nonNull(GraphQLString))) + .field(newInputObjectField().name(IS_AVAILABLE).type(GraphQLBoolean)) + .field(newInputObjectField().name(START_TIME).type(GraphQLString)) + .field(newInputObjectField().name(END_TIME).type(GraphQLString)) + .build(); + + @Override + public void contributeToOutputType(GraphQLObjectType.Builder builder) { + builder.field(newFieldDefinition() + .name(LIGHTING) + .type(CustomGraphQLTypes.lightingEnumType) + .dataFetcher(env -> { + Object source = env.getSource(); + if (!(source instanceof org.rutebanken.tiamat.ext.fintraffic.model.FintrafficParking fp)) { + return null; + } + return fp.getLighting(); + })); + builder.field(newFieldDefinition() + .name(PAYMENT_METHODS) + .type(new GraphQLList(paymentMethodEnum))); + builder.field(newFieldDefinition() + .name(INFO_LINKS) + .type(new GraphQLList(infoLinkOutputType)) + .dataFetcher(env -> { + Object source = env.getSource(); + if (!(source instanceof org.rutebanken.tiamat.ext.fintraffic.model.FintrafficParking fp)) { + return java.util.List.of(); + } + return fp.getInfoLinks().stream() + .map(link -> { + var m = new java.util.HashMap(); + m.put(URI, link.getUri()); + if (link.getTypeOfInfoLink() != null) { + try { + m.put(TYPE_OF_INFO_LINK, + TypeOfInfolinkEnumeration.fromValue(link.getTypeOfInfoLink())); + } catch (IllegalArgumentException ignored) { + // stored value no longer valid; skip + } + } + return m; + }) + .collect(java.util.stream.Collectors.toList()); + })); + builder.field(newFieldDefinition() + .name(VEHICLE_ENTRANCES) + .type(new GraphQLList(vehicleEntranceOutputType)) + .dataFetcher(env -> { + Object source = env.getSource(); + if (!(source instanceof org.rutebanken.tiamat.ext.fintraffic.model.FintrafficParking fp)) { + return java.util.List.of(); + } + return fp.getFintrafficVehicleEntrances().stream() + .map(entrance -> { + var m = new java.util.HashMap(); + m.put(VEHICLE_ENTRANCE_LABEL, entrance.getLabel()); + if (entrance.getEntranceType() != null) { + try { + m.put(ENTRANCE_TYPE, + EntranceEnumeration.fromValue(entrance.getEntranceType())); + } catch (IllegalArgumentException ignored) { + // stored value no longer valid; skip + } + } + m.put(WIDTH, entrance.getWidth() != null ? entrance.getWidth().doubleValue() : null); + m.put(HEIGHT, entrance.getHeight() != null ? entrance.getHeight().doubleValue() : null); + m.put(IS_ENTRY, entrance.getIsEntry()); + m.put(IS_EXIT, entrance.getIsExit()); + m.put(PUBLIC_CODE, entrance.getPublicCode()); + return m; + }) + .collect(java.util.stream.Collectors.toList()); + })); + builder.field(newFieldDefinition() + .name(AVAILABILITY_CONDITIONS) + .type(new GraphQLList(availabilityConditionOutputType)) + .dataFetcher(env -> { + Object source = env.getSource(); + if (!(source instanceof org.rutebanken.tiamat.ext.fintraffic.model.FintrafficParking fp)) { + return java.util.List.of(); + } + return fp.getAvailabilityConditions().stream() + .map(condition -> { + var m = new java.util.HashMap(); + m.put(DAY_TYPE_REF, condition.getDayTypeRef()); + m.put(IS_AVAILABLE, condition.isAvailable()); + m.put(START_TIME, condition.getStartTime() != null ? condition.getStartTime().toString() : null); + m.put(END_TIME, condition.getEndTime() != null ? condition.getEndTime().toString() : null); + return m; + }) + .collect(java.util.stream.Collectors.toList()); + })); + } + + @Override + public void contributeToInputType(GraphQLInputObjectType.Builder builder) { + builder.field(newInputObjectField() + .name(LIGHTING) + .type(CustomGraphQLTypes.lightingEnumType)); + builder.field(newInputObjectField() + .name(PAYMENT_METHODS) + .type(new GraphQLList(paymentMethodEnum))); + builder.field(newInputObjectField() + .name(INFO_LINKS) + .type(new GraphQLList(infoLinkInputType))); + builder.field(newInputObjectField() + .name(VEHICLE_ENTRANCES) + .type(new GraphQLList(vehicleEntranceInputType))); + builder.field(newInputObjectField() + .name(AVAILABILITY_CONDITIONS) + .type(new GraphQLList(availabilityConditionInputType))); + } +} diff --git a/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/rest/graphql/FintrafficParkingUpdater.java b/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/rest/graphql/FintrafficParkingUpdater.java new file mode 100644 index 0000000000..8626e7f31f --- /dev/null +++ b/src/ext/java/org/rutebanken/tiamat/ext/fintraffic/rest/graphql/FintrafficParkingUpdater.java @@ -0,0 +1,247 @@ +package org.rutebanken.tiamat.ext.fintraffic.rest.graphql; + +import graphql.schema.DataFetchingEnvironment; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.rutebanken.tiamat.ext.fintraffic.model.FintrafficInfoLink; +import org.rutebanken.tiamat.ext.fintraffic.model.FintrafficParking; +import org.rutebanken.tiamat.ext.fintraffic.model.FintrafficParkingAvailabilityCondition; +import org.rutebanken.tiamat.ext.fintraffic.model.FintrafficParkingEntranceForVehicles; +import org.rutebanken.tiamat.model.LightingEnumeration; +import org.rutebanken.tiamat.model.Parking; +import org.rutebanken.tiamat.model.PaymentMethodEnumeration; +import org.rutebanken.tiamat.rest.graphql.fetchers.ParkingUpdater; +import org.springframework.context.annotation.Profile; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import static org.rutebanken.tiamat.ext.fintraffic.rest.graphql.FintrafficParkingGraphQLTypeContributor.AVAILABILITY_CONDITIONS; +import static org.rutebanken.tiamat.ext.fintraffic.rest.graphql.FintrafficParkingGraphQLTypeContributor.DAY_TYPE_REF; +import static org.rutebanken.tiamat.ext.fintraffic.rest.graphql.FintrafficParkingGraphQLTypeContributor.END_TIME; +import static org.rutebanken.tiamat.ext.fintraffic.rest.graphql.FintrafficParkingGraphQLTypeContributor.IS_AVAILABLE; +import static org.rutebanken.tiamat.ext.fintraffic.rest.graphql.FintrafficParkingGraphQLTypeContributor.INFO_LINKS; +import static org.rutebanken.tiamat.ext.fintraffic.rest.graphql.FintrafficParkingGraphQLTypeContributor.LIGHTING; +import static org.rutebanken.tiamat.ext.fintraffic.rest.graphql.FintrafficParkingGraphQLTypeContributor.PAYMENT_METHODS; +import static org.rutebanken.tiamat.ext.fintraffic.rest.graphql.FintrafficParkingGraphQLTypeContributor.TYPE_OF_INFO_LINK; +import static org.rutebanken.tiamat.ext.fintraffic.rest.graphql.FintrafficParkingGraphQLTypeContributor.URI; +import static org.rutebanken.tiamat.ext.fintraffic.rest.graphql.FintrafficParkingGraphQLTypeContributor.VEHICLE_ENTRANCES; +import static org.rutebanken.tiamat.ext.fintraffic.rest.graphql.FintrafficParkingGraphQLTypeContributor.ENTRANCE_TYPE; +import static org.rutebanken.tiamat.ext.fintraffic.rest.graphql.FintrafficParkingGraphQLTypeContributor.VEHICLE_ENTRANCE_LABEL; +import static org.rutebanken.tiamat.ext.fintraffic.rest.graphql.FintrafficParkingGraphQLTypeContributor.WIDTH; +import static org.rutebanken.tiamat.ext.fintraffic.rest.graphql.FintrafficParkingGraphQLTypeContributor.HEIGHT; +import static org.rutebanken.tiamat.ext.fintraffic.rest.graphql.FintrafficParkingGraphQLTypeContributor.IS_ENTRY; +import static org.rutebanken.tiamat.ext.fintraffic.rest.graphql.FintrafficParkingGraphQLTypeContributor.IS_EXIT; +import static org.rutebanken.tiamat.ext.fintraffic.rest.graphql.FintrafficParkingGraphQLTypeContributor.PUBLIC_CODE; +import static org.rutebanken.tiamat.ext.fintraffic.rest.graphql.FintrafficParkingGraphQLTypeContributor.START_TIME; + +/** + * Fintraffic extension of {@link ParkingUpdater} that handles the + * {@code paymentMethods} and {@code infoLinks} input fields contributed by + * {@link FintrafficParkingGraphQLTypeContributor}. + */ +@Profile("fintraffic") +@Transactional +public class FintrafficParkingUpdater extends ParkingUpdater { + + @PersistenceContext + private EntityManager entityManager; + + /** + * Extends the parent's {@code get()} to flush pending collection inserts and + * refresh entities before the transaction closes. + */ + @SuppressWarnings("unchecked") + @Override + public Object get(DataFetchingEnvironment environment) { + List parkings = (List) super.get(environment); + if (parkings != null) { + entityManager.flush(); + parkings.stream() + .filter(Objects::nonNull) + .filter(entityManager::contains) + .forEach(entityManager::refresh); + } + return parkings; + } + + @Override + protected boolean populateExtendedFields(Map input, Parking parking) { + if (!(parking instanceof FintrafficParking target)) { + return false; + } + + boolean changed = false; + + LightingEnumeration lighting = (LightingEnumeration) input.get(LIGHTING); + if (lighting != null && !lighting.equals(target.getLighting())) { + target.setLighting(lighting); + changed = true; + } + + @SuppressWarnings("unchecked") + List incomingMethods = (List) input.get(PAYMENT_METHODS); + if (incomingMethods != null && !incomingMethods.equals(target.getPaymentMethods())) { + target.setPaymentMethods(List.copyOf(incomingMethods)); + changed = true; + } + + @SuppressWarnings("unchecked") + List> incomingLinks = (List>) input.get(INFO_LINKS); + if (incomingLinks != null) { + List converted = new ArrayList<>(); + for (Map linkInput : incomingLinks) { + Object uriObj = linkInput.get(URI); + if (uriObj == null) { + continue; + } + String uri = uriObj.toString(); + Object typeObj = linkInput.get(TYPE_OF_INFO_LINK); + String type = null; + if (typeObj instanceof org.rutebanken.netex.model.TypeOfInfolinkEnumeration enumVal) { + type = enumVal.value(); + } else if (typeObj != null) { + type = typeObj.toString(); + } + converted.add(new FintrafficInfoLink(uri, type)); + } + if (!converted.equals(target.getInfoLinks())) { + target.setInfoLinks(converted); + changed = true; + } + } + + @SuppressWarnings("unchecked") + List> incomingEntrances = (List>) input.get(VEHICLE_ENTRANCES); + if (incomingEntrances != null) { + List converted = new ArrayList<>(); + for (Map entranceInput : incomingEntrances) { + Object labelObj = entranceInput.get(VEHICLE_ENTRANCE_LABEL); + Object typeObj = entranceInput.get(ENTRANCE_TYPE); + Object widthObj = entranceInput.get(WIDTH); + Object heightObj = entranceInput.get(HEIGHT); + Object isEntryObj = entranceInput.get(IS_ENTRY); + Object isExitObj = entranceInput.get(IS_EXIT); + Object publicCodeObj = entranceInput.get(PUBLIC_CODE); + + String entranceTypeStr = null; + if (typeObj instanceof org.rutebanken.netex.model.EntranceEnumeration enumVal) { + entranceTypeStr = enumVal.value(); + } else if (typeObj != null) { + entranceTypeStr = typeObj.toString(); + } + + converted.add(new FintrafficParkingEntranceForVehicles( + labelObj != null ? labelObj.toString() : null, + entranceTypeStr, + toBigDecimal(widthObj), + toBigDecimal(heightObj), + isEntryObj instanceof Boolean b ? b : null, + isExitObj instanceof Boolean b ? b : null, + publicCodeObj != null ? publicCodeObj.toString() : null + )); + } + if (!converted.equals(target.getFintrafficVehicleEntrances())) { + target.setFintrafficVehicleEntrances(converted); + changed = true; + } + } + + @SuppressWarnings("unchecked") + List> incomingConditions = (List>) input.get(AVAILABILITY_CONDITIONS); + if (incomingConditions != null) { + LinkedHashMap byDayType = new LinkedHashMap<>(); + for (Map conditionInput : incomingConditions) { + Object dayTypeRefObj = conditionInput.get(DAY_TYPE_REF); + if (dayTypeRefObj == null) { + continue; + } + String dayTypeRef = dayTypeRefObj.toString(); + if (byDayType.containsKey(dayTypeRef)) { + throw new IllegalArgumentException( + "Duplicate dayTypeRef '" + dayTypeRef + "' in availabilityConditions input"); + } + Object isAvailableObj = conditionInput.get(IS_AVAILABLE); + Object startTimeObj = conditionInput.get(START_TIME); + Object endTimeObj = conditionInput.get(END_TIME); + + boolean isAvailable = !(isAvailableObj instanceof Boolean b) || b; + LocalTime startTime = parseLocalTime(startTimeObj); + LocalTime endTime = parseLocalTime(endTimeObj); + + byDayType.put(dayTypeRef, new FintrafficParkingAvailabilityCondition( + dayTypeRef, + isAvailable, + startTime, + endTime + )); + } + List converted = new ArrayList<>(byDayType.values()); + if (!converted.equals(target.getAvailabilityConditions())) { + target.setAvailabilityConditions(converted); + changed = true; + } + } + + return changed; + } + + /** + * Copies extended fields that Orika does not transfer from the existing version into + * the newly created version copy. Called by the parent's update path immediately + * after {@link org.rutebanken.tiamat.versioning.VersionCreator#createCopy}, before + * {@link #populateExtendedFields} overwrites them with the GraphQL input values. + */ + @Override + protected void preserveExtendedFields(Parking existingVersion, Parking copy) { + if (existingVersion instanceof FintrafficParking source && copy instanceof FintrafficParking target) { + target.setLighting(source.getLighting()); + target.setPaymentMethods(new ArrayList<>(source.getPaymentMethods())); + target.setInfoLinks(new ArrayList<>(source.getInfoLinks())); + target.setFintrafficVehicleEntrances(new ArrayList<>(source.getFintrafficVehicleEntrances())); + target.setAvailabilityConditions(new ArrayList<>(source.getAvailabilityConditions())); + } + } + + private static BigDecimal toBigDecimal(Object value) { + if (value instanceof BigDecimal bd) { + return bd; + } + if (value instanceof Number number) { + return BigDecimal.valueOf(number.doubleValue()); + } + return null; + } + + private static LocalTime parseLocalTime(Object value) { + if (value == null) { + return null; + } + String timeValue = value.toString().strip(); + if (timeValue.isEmpty()) { + return null; + } + try { + String[] parts = timeValue.split(":"); + if (parts.length == 0 || parts[0].isEmpty()) { + throw new IllegalArgumentException("Invalid time value: '" + timeValue + "'. Expected HH:mm or HH:mm:ss."); + } + int hour = Integer.parseInt(parts[0]); + int minute = parts.length > 1 ? Integer.parseInt(parts[1]) : 0; + int second = parts.length > 2 ? Integer.parseInt(parts[2]) : 0; + if (hour == 24 && minute == 0 && second == 0) { + return LocalTime.MIDNIGHT; + } + return LocalTime.of(hour, minute, second); + } catch (NumberFormatException | java.time.DateTimeException e) { + throw new IllegalArgumentException("Invalid time value: '" + timeValue + "'. Expected HH:mm or HH:mm:ss.", e); + } + } +}