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.
+ *
New parking — {@code handleCompletelyNewParking} saves the incoming entity as-is
+ *
Existing parking — {@code handleAlreadyExistingParking} merges {@code paymentMethods}
+ * from the incoming parking into the version copy via {@link MergingParkingImporter#mergeExtendedFields}
+ *
+ */
+@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