diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json index c079f031..d52c1e5d 100644 --- a/descriptors/ModuleDescriptor-template.json +++ b/descriptors/ModuleDescriptor-template.json @@ -10,6 +10,10 @@ "id": "authtoken", "version": "2.0" }, + { + "id": "configuration", + "version": "2.0" + }, { "id": "permissions", "version": "5.3" @@ -274,7 +278,8 @@ "perms.users.get", "perms.users.assign.immutable", "data-export.job.collection.get", - "data-export.config.collection.get" + "data-export.config.collection.get", + "configuration.entries.collection.get" ] }, { diff --git a/src/main/java/org/folio/client/ConfigurationClient.java b/src/main/java/org/folio/client/ConfigurationClient.java new file mode 100644 index 00000000..c3e9331e --- /dev/null +++ b/src/main/java/org/folio/client/ConfigurationClient.java @@ -0,0 +1,15 @@ +package org.folio.client; + +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.service.annotation.GetExchange; +import org.springframework.web.service.annotation.HttpExchange; + +import tools.jackson.databind.JsonNode; + +@HttpExchange("configurations") +public interface ConfigurationClient { + + @GetExchange("/entries") + JsonNode getConfigurations(@RequestParam("query") String query, @RequestParam("limit") int limit); + +} diff --git a/src/main/java/org/folio/des/config/HttpClientConfiguration.java b/src/main/java/org/folio/des/config/HttpClientConfiguration.java index e8e8f41c..37e71f24 100644 --- a/src/main/java/org/folio/des/config/HttpClientConfiguration.java +++ b/src/main/java/org/folio/des/config/HttpClientConfiguration.java @@ -2,6 +2,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; +import org.folio.client.ConfigurationClient; import org.folio.des.client.DataExportSpringClient; import org.folio.des.client.ExportWorkerClient; import org.folio.des.exceptions.RestClientErrorHandler; @@ -20,6 +21,11 @@ public class HttpClientConfiguration { private final RestClientErrorHandler errorHandler; + @Bean + public ConfigurationClient configurationClient(HttpServiceProxyFactory factory) { + return factory.createClient(ConfigurationClient.class); + } + @Bean public DataExportSpringClient dataExportSpringClient(HttpServiceProxyFactory factory) { return factory.createClient(DataExportSpringClient.class); diff --git a/src/main/java/org/folio/des/scheduling/util/ScheduleUtil.java b/src/main/java/org/folio/des/scheduling/util/ScheduleUtil.java index 1dd34c04..604f2a4c 100644 --- a/src/main/java/org/folio/des/scheduling/util/ScheduleUtil.java +++ b/src/main/java/org/folio/des/scheduling/util/ScheduleUtil.java @@ -68,7 +68,7 @@ private static boolean isMigrationNeededForVersions(String moduleFrom, String mo (moduleFromSemVer == null || moduleFromSemVer.compareTo(minQuartzSupportVersion) < 0); } - private static SemVer moduleVersionToSemVer(String version) { + public static SemVer moduleVersionToSemVer(String version) { try { return new SemVer(version); } catch (IllegalArgumentException ex) { diff --git a/src/main/java/org/folio/des/service/FolioTenantService.java b/src/main/java/org/folio/des/service/FolioTenantService.java index e991b91b..8cd73ab8 100644 --- a/src/main/java/org/folio/des/service/FolioTenantService.java +++ b/src/main/java/org/folio/des/service/FolioTenantService.java @@ -9,9 +9,9 @@ import org.folio.des.scheduling.quartz.ScheduledJobsRemover; import org.folio.des.service.bursarlegacy.BursarExportLegacyJobService; import org.folio.des.service.bursarlegacy.BursarMigrationService; +import org.folio.des.service.config.ConfigurationMigrationService; import org.folio.des.service.config.impl.BursarFeesFinesExportConfigService; import org.folio.spring.FolioExecutionContext; -import org.folio.spring.exception.TenantUpgradeException; import org.folio.spring.liquibase.FolioSpringLiquibase; import org.folio.spring.service.PrepareSystemUserService; import org.folio.spring.service.TenantService; @@ -20,8 +20,6 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; -import liquibase.exception.LiquibaseException; -import liquibase.exception.UnexpectedLiquibaseException; import lombok.extern.log4j.Log4j2; @Log4j2 @@ -42,6 +40,7 @@ public class FolioTenantService extends TenantService { private final BursarMigrationService bursarMigrationService; private final BursarFeesFinesExportConfigService bursarFeesFinesExportConfigService; private final FolioExecutionContext folioExecutionContext; + private final ConfigurationMigrationService configurationMigrationService; public FolioTenantService(JdbcTemplate jdbcTemplate, FolioExecutionContext context, FolioSpringLiquibase folioSpringLiquibase, PrepareSystemUserService prepareSystemUserService, KafkaService kafka, @@ -49,7 +48,7 @@ public FolioTenantService(JdbcTemplate jdbcTemplate, FolioExecutionContext conte BursarScheduledJobInitializer bursarScheduledJobInitializer, OldJobDeleteScheduler oldJobDeleteScheduler, BursarExportLegacyJobService bursarExportLegacyJobService, JobService jobService, BursarMigrationService bursarMigrationService, BursarFeesFinesExportConfigService bursarFeesFinesExportConfigService, - FolioExecutionContext folioExecutionContext) { + FolioExecutionContext folioExecutionContext, ConfigurationMigrationService configurationMigrationService) { super(jdbcTemplate, context, folioSpringLiquibase); this.prepareSystemUserService = prepareSystemUserService; this.kafka = kafka; @@ -62,6 +61,7 @@ public FolioTenantService(JdbcTemplate jdbcTemplate, FolioExecutionContext conte this.bursarMigrationService = bursarMigrationService; this.bursarFeesFinesExportConfigService = bursarFeesFinesExportConfigService; this.folioExecutionContext = folioExecutionContext; + this.configurationMigrationService = configurationMigrationService; } @Override @@ -75,6 +75,7 @@ protected void beforeLiquibaseUpdate(TenantAttributes tenantAttributes) { protected void afterTenantUpdate(TenantAttributes tenantAttributes) { try { prepareSystemUserService.setupSystemUser(); + configurationMigrationService.migrateConfigurationData(tenantAttributes, context.getTenantId()); bursarMigrationService.updateLegacyBursarIfNeeded(tenantAttributes, bursarFeesFinesExportConfigService, bursarExportLegacyJobService, jobService); bursarScheduledJobInitializer.initAllScheduledJob(tenantAttributes); diff --git a/src/main/java/org/folio/des/service/config/ConfigurationMigrationService.java b/src/main/java/org/folio/des/service/config/ConfigurationMigrationService.java new file mode 100644 index 00000000..da938e73 --- /dev/null +++ b/src/main/java/org/folio/des/service/config/ConfigurationMigrationService.java @@ -0,0 +1,130 @@ +package org.folio.des.service.config; + +import static org.folio.des.scheduling.util.ScheduleUtil.moduleVersionToSemVer; + +import org.apache.commons.lang3.StringUtils; +import org.folio.client.ConfigurationClient; +import org.folio.des.domain.dto.ExportTypeSpecificParameters; +import org.folio.spring.FolioModuleMetadata; +import org.folio.tenant.domain.dto.TenantAttributes; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +@Service +@RequiredArgsConstructor +@Log4j2 +public class ConfigurationMigrationService { + + private static final String MIGRATION_TARGET_VERSION = "3.6.0"; + private static final String CONFIGURATION_QUERY = "module==mod-data-export-spring"; + private static final String EXPORT_CONFIG_TABLE = "export_config"; + private static final String EXPORT_CONFIG_SQL = """ + INSERT INTO %s.%s ( + id, config_name, type, tenant, export_type_specific_parameters, + schedule_frequency, schedule_period, schedule_time, week_days, + created_date, created_by, updated_date, updated_by + ) VALUES ( + ?::uuid, ?, ?, ?, ?::jsonb, + ?, ?, ?, ?::jsonb, + ?::timestamp, ?::uuid, ?::timestamp, ?::uuid + ) ON CONFLICT (id) DO NOTHING + """; + + private final JdbcTemplate jdbcTemplate; + private final ObjectMapper objectMapper; + private final ConfigurationClient configurationClient; + private final FolioModuleMetadata folioModuleMetadata; + + public void migrateConfigurationData(TenantAttributes attributes, String tenantId) { + log.info("migrateConfigurationData:: Attempting to migrate configuration data from mod-configuration for tenant: {}", tenantId); + if (!isMigrationNeeded(attributes)) { + log.info("migrateConfigurationData:: Migration not needed for tenant: {}. Skipping configuration data migration.", tenantId); + return; + } + + try { + var configsResponse = fetchConfigurationEntries(); + if (configsResponse == null || !configsResponse.has("configs") || configsResponse.get("configs").isEmpty()) { + log.info("migrateConfigurationData:: No configuration entries found to migrate"); + return; + } + configsResponse.get("configs").forEach(config -> migrateConfigEntry(tenantId, config)); + } catch (Exception e) { + log.warn("migrateConfigurationData:: Failed to migrate configuration data from mod-configuration. ", e); + } + } + + private static boolean isMigrationNeeded(TenantAttributes tenantAttributes) { + var moduleFrom = tenantAttributes.getModuleFrom(); + if (StringUtils.isBlank(moduleFrom)) { + return true; + } + return moduleVersionToSemVer(MIGRATION_TARGET_VERSION).compareTo(moduleVersionToSemVer(moduleFrom)) > 0; + } + + private JsonNode fetchConfigurationEntries() { + return configurationClient.getConfigurations(CONFIGURATION_QUERY, Integer.MAX_VALUE); + } + + private void migrateConfigEntry(String tenantId, JsonNode config) { + var id = config.path("id").asString(); + + try { + JsonNode valueObj = objectMapper.readTree(config.path("value").asString("{}")); + + var type = valueObj.path("type").asString(null); + var exportTypeSpecificParameters = valueObj.path("exportTypeSpecificParameters"); + + if (StringUtils.isBlank(type) || type.equals("null") || !validateExportTypeSpecificParams(exportTypeSpecificParameters)) { + log.warn("migrateConfigEntry:: Skipping config {} due to missing type or exportTypeSpecificParameters", id); + return; + } + + var metadata = config.path("metadata"); + var weekDays = valueObj.path("weekDays"); + + jdbcTemplate.update(EXPORT_CONFIG_SQL.formatted(folioModuleMetadata.getDBSchemaName(tenantId), EXPORT_CONFIG_TABLE), + id, + config.path("configName").asString(), + type, + valueObj.path("tenant").asString(null), + exportTypeSpecificParameters.toString(), + valueObj.path("scheduleFrequency").asIntOpt().stream().boxed().findFirst().orElse(null), + valueObj.path("schedulePeriod").asString(null), + valueObj.path("scheduleTime").asString(null), + weekDays.isMissingNode() ? null : weekDays.toString(), + metadata.path("createdDate").asString(null), + metadata.path("createdByUserId").asString(null), + metadata.path("updatedDate").asString(null), + metadata.path("updatedByUserId").asString(null) + ); + log.info("migrateConfigEntry:: Successfully migrated export config with id: {}", id); + } catch (Exception e) { + log.error("migrateConfigEntry:: Failed to insert export config with id: {}", id, e); + } + } + + + /** + * Validates the exportTypeSpecificParameters by attempting to convert it to the ExportTypeSpecificParameters class. + + * @param exportTypeSpecificParameters the JsonNode containing the export type specific parameters to validate + */ + private boolean validateExportTypeSpecificParams(JsonNode exportTypeSpecificParameters) { + if (exportTypeSpecificParameters.isMissingNode()) { + return false; + } + try { + objectMapper.treeToValue(exportTypeSpecificParameters, ExportTypeSpecificParameters.class); + return true; + } catch (Exception e) { + return false; + } + } + +} diff --git a/src/main/resources/db/changelog/changelog-master.xml b/src/main/resources/db/changelog/changelog-master.xml index b63cb3bd..4dd243b8 100644 --- a/src/main/resources/db/changelog/changelog-master.xml +++ b/src/main/resources/db/changelog/changelog-master.xml @@ -16,6 +16,5 @@ - diff --git a/src/main/resources/db/changelog/changes/07_11_2025_migrate_configuration_data.xml b/src/main/resources/db/changelog/changes/07_11_2025_migrate_configuration_data.xml deleted file mode 100644 index 6329e239..00000000 --- a/src/main/resources/db/changelog/changes/07_11_2025_migrate_configuration_data.xml +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - DO ' - DECLARE - BEGIN - IF EXISTS (SELECT 1 FROM information_schema.tables - WHERE table_schema = ''${tenantname}_mod_configuration'' AND table_name = ''config_data'') - THEN - INSERT INTO ${tenantname}_mod_data_export_spring.export_config ( - id, config_name, - type, tenant, export_type_specific_parameters, schedule_frequency, schedule_period, schedule_time, week_days, - created_date, created_by, updated_date, updated_by - ) - SELECT - C.id::uuid, - C.jsonb->>''configName'', - (C.jsonb->>''value'')::jsonb->>''type'', - (C.jsonb->>''value'')::jsonb->>''tenant'', - (C.jsonb->>''value'')::jsonb->''exportTypeSpecificParameters'', - ((C.jsonb->>''value'')::jsonb->>''scheduleFrequency'')::integer, - (C.jsonb->>''value'')::jsonb->>''schedulePeriod'', - (C.jsonb->>''value'')::jsonb->>''scheduleTime'', - (C.jsonb->>''value'')::jsonb->''weekDays'', - (C.jsonb->''metadata''->>''createdDate'')::timestamp, - (C.jsonb->''metadata''->>''createdByUserId'')::uuid, - (C.jsonb->''metadata''->>''updatedDate'')::timestamp, - (C.jsonb->''metadata''->>''updatedByUserId'')::uuid - FROM ${tenantname}_mod_configuration.config_data C - WHERE C.jsonb->>''module'' = ''mod-data-export-spring'' - AND (C.jsonb->>''value'')::jsonb->>''type'' IS NOT NULL - AND (C.jsonb->>''value'')::jsonb->''exportTypeSpecificParameters'' IS NOT NULL - AND NOT EXISTS ( - SELECT 1 FROM ${tenantname}_mod_data_export_spring.export_config EC - WHERE EC.config_name = C.jsonb->>''configName'' - ) - ON CONFLICT (id) DO NOTHING; - END IF; - END; - ' LANGUAGE plpgsql; - - - - diff --git a/src/test/java/org/folio/des/builder/job/JobCommandBuilderResolverTest.java b/src/test/java/org/folio/des/builder/job/JobCommandBuilderResolverTest.java index 3c9c86d8..66486085 100644 --- a/src/test/java/org/folio/des/builder/job/JobCommandBuilderResolverTest.java +++ b/src/test/java/org/folio/des/builder/job/JobCommandBuilderResolverTest.java @@ -10,10 +10,10 @@ import java.util.Optional; import java.util.UUID; +import org.folio.client.ConfigurationClient; import org.folio.de.entity.Job; import org.folio.des.client.DataExportSpringClient; import org.folio.des.client.ExportWorkerClient; -import org.folio.des.config.HttpClientConfiguration; import org.folio.des.config.JacksonConfiguration; import org.folio.des.config.ServiceConfiguration; import org.folio.des.config.scheduling.QuartzSchemaInitializer; @@ -29,6 +29,7 @@ import org.folio.des.domain.dto.ExportType; import org.folio.des.domain.dto.ExportTypeSpecificParameters; import org.folio.des.domain.dto.VendorEdiOrdersExportConfig; +import org.folio.des.service.config.ConfigurationMigrationService; import org.folio.spring.client.AuthnClient; import org.folio.spring.client.PermissionsClient; import org.folio.spring.client.UsersClient; @@ -66,6 +67,10 @@ class JobCommandBuilderResolverTest { private UsersClient usersClient; @MockitoBean private PermissionsClient permissionsClient; + @MockitoBean + private ConfigurationClient configurationClient; + @MockitoBean + private ConfigurationMigrationService configurationMigrationService; @ParameterizedTest @DisplayName("Should retrieve builder for specific export type if builder is registered in the resolver") diff --git a/src/test/java/org/folio/des/service/FolioTenantServiceTest.java b/src/test/java/org/folio/des/service/FolioTenantServiceTest.java index 26a1861d..465e69c7 100644 --- a/src/test/java/org/folio/des/service/FolioTenantServiceTest.java +++ b/src/test/java/org/folio/des/service/FolioTenantServiceTest.java @@ -13,6 +13,7 @@ import org.folio.des.scheduling.quartz.OldJobDeleteScheduler; import org.folio.des.scheduling.quartz.ScheduledJobsRemover; import org.folio.des.service.bursarlegacy.BursarMigrationService; +import org.folio.des.service.config.ConfigurationMigrationService; import org.folio.spring.FolioExecutionContext; import org.folio.spring.service.PrepareSystemUserService; import org.folio.tenant.domain.dto.TenantAttributes; @@ -52,6 +53,9 @@ class FolioTenantServiceTest { @Mock BursarMigrationService bursarMigrationService; + @Mock + ConfigurationMigrationService configurationMigrationService; + @Test void shouldDoProcessAfterTenantUpdating() { TenantAttributes tenantAttributes = createTenantAttributes(); @@ -70,6 +74,8 @@ void shouldDoProcessAfterTenantUpdating() { .scheduleOldJobDeletion(any()); doNothing().when(bursarMigrationService) .updateLegacyBursarIfNeeded(eq(tenantAttributes), any(), any(), any()); + doNothing().when(configurationMigrationService) + .migrateConfigurationData(any(), any()); folioTenantService.afterTenantUpdate(tenantAttributes); @@ -78,6 +84,7 @@ void shouldDoProcessAfterTenantUpdating() { verify(bursarScheduledJobInitializer, times(1)).initAllScheduledJob(tenantAttributes); verify(oldJobDeleteScheduler, times(1)).scheduleOldJobDeletion(any()); verify(bursarMigrationService, times(1)).updateLegacyBursarIfNeeded(eq(tenantAttributes), any(), any(), any()); + verify(configurationMigrationService, times(1)).migrateConfigurationData(any(), any()); verify(kafka, times(1)).createKafkaTopics(); verify(kafka, times(1)).restartEventListeners(); } diff --git a/src/test/java/org/folio/des/service/config/ConfigurationMigrationServiceTest.java b/src/test/java/org/folio/des/service/config/ConfigurationMigrationServiceTest.java new file mode 100644 index 00000000..7049ab74 --- /dev/null +++ b/src/test/java/org/folio/des/service/config/ConfigurationMigrationServiceTest.java @@ -0,0 +1,188 @@ +package org.folio.des.service.config; + +import static org.folio.des.support.TestUtils.loadData; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.UUID; + +import org.folio.client.ConfigurationClient; +import org.folio.des.CopilotGenerated; +import org.folio.des.repository.ExportConfigRepository; +import org.folio.des.support.BaseTest; +import org.folio.tenant.domain.dto.TenantAttributes; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +@TestPropertySource(properties = "spring.jpa.properties.hibernate.default_schema=diku_mod_data_export_spring") +@CopilotGenerated(partiallyGenerated = true, model = "Claude Opus 4.6") +class ConfigurationMigrationServiceTest extends BaseTest { + + @Autowired + private ConfigurationMigrationService migrationService; + + @Autowired + private ExportConfigRepository exportConfigRepository; + + @MockitoBean + private ConfigurationClient configurationClient; + + @ParameterizedTest + @DisplayName("Should migrate when upgrading from old version or fresh install (null)") + @CsvSource(value = {"mod-data-export-spring-3.6.0-SNAPSHOT", "mod-data-export-spring-3.6.0-SNAPSHOT.123", "3.0.0", "''", "null"}, nullValues = "null") + void shouldMigrateWhenVersionRequiresMigration(String moduleFrom) { + var attributes = tenantAttributes(moduleFrom); + when(configurationClient.getConfigurations(anyString(), anyInt())) + .thenReturn(loadData("data/configuration-entries/empty-response.json")); + + assertDoesNotThrow(() -> migrationService.migrateConfigurationData(attributes, TENANT)); + verify(configurationClient).getConfigurations(anyString(), anyInt()); + } + + @ParameterizedTest + @DisplayName("Should skip migration when version is at or above target") + @CsvSource({"mod-data-export-spring-3.6.1-SNAPSHOT", "mod-data-export-spring-3.6.0", "mod-data-export-spring-4.0.0"}) + void shouldSkipMigrationWhenVersionDoesNotRequireMigration(String moduleFrom) { + var attributes = tenantAttributes(moduleFrom); + + assertDoesNotThrow(() -> migrationService.migrateConfigurationData(attributes, TENANT)); + verify(configurationClient, never()).getConfigurations(anyString(), anyInt()); + } + + @Test + @DisplayName("Should handle configuration client exception gracefully") + void shouldHandleConfigurationClientException() { + var attributes = tenantAttributes(null); + when(configurationClient.getConfigurations(anyString(), anyInt())) + .thenThrow(new RuntimeException("Connection refused")); + + assertDoesNotThrow(() -> migrationService.migrateConfigurationData(attributes, TENANT)); + } + + @Test + @DisplayName("Should handle null response from configuration client") + void shouldHandleNullConfigurationResponse() { + var attributes = tenantAttributes(null); + when(configurationClient.getConfigurations(anyString(), anyInt())) + .thenReturn(null); + + assertDoesNotThrow(() -> migrationService.migrateConfigurationData(attributes, TENANT)); + } + + @Test + @DisplayName("Should handle empty configs array from configuration client") + void shouldHandleEmptyConfigsArray() { + var attributes = tenantAttributes(null); + when(configurationClient.getConfigurations(anyString(), anyInt())) + .thenReturn(loadData("data/configuration-entries/empty-response.json")); + + assertDoesNotThrow(() -> migrationService.migrateConfigurationData(attributes, TENANT)); + assertEquals(0, exportConfigRepository.count()); + } + + @Test + @DisplayName("Should insert valid configuration entry") + void shouldInsertValidConfigurationEntry() { + var attributes = tenantAttributes(null); + when(configurationClient.getConfigurations(anyString(), anyInt())) + .thenReturn(loadData("data/configuration-entries/valid-entry-response.json")); + + migrationService.migrateConfigurationData(attributes, TENANT); + + assertEquals(1, exportConfigRepository.count()); + } + + @Test + @DisplayName("Should handle duplicate records in response by inserting only once") + void shouldHandleDuplicateRecordsInResponse() { + var attributes = tenantAttributes(null); + when(configurationClient.getConfigurations(anyString(), anyInt())) + .thenReturn(loadData("data/configuration-entries/duplicate-entries-response.json")); + + assertDoesNotThrow(() -> migrationService.migrateConfigurationData(attributes, TENANT)); + assertEquals(1, exportConfigRepository.count()); + } + + @Test + @Sql(executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD, + scripts = "classpath:data/configuration-entries/existing-export-config.sql") + @DisplayName("Should not overwrite existing DB record when config with same id is received") + void shouldNotOverwriteExistingDbRecord() { + var attributes = tenantAttributes(null); + when(configurationClient.getConfigurations(anyString(), anyInt())) + .thenReturn(loadData("data/configuration-entries/conflicting-entry-response.json")); + + assertDoesNotThrow(() -> migrationService.migrateConfigurationData(attributes, TENANT)); + + // Verify the original record is unchanged + assertEquals(1, exportConfigRepository.count()); + var entity = exportConfigRepository.findById(UUID.fromString("a1111111-1111-1111-1111-111111111111")); + assertTrue(entity.isPresent()); + assertEquals("BURSAR_FEES_FINES", entity.get().getType()); + } + + @Test + @DisplayName("Should skip config entries with missing type") + void shouldSkipEntriesWithMissingType() { + var attributes = tenantAttributes(null); + when(configurationClient.getConfigurations(anyString(), anyInt())) + .thenReturn(loadData("data/configuration-entries/missing-type-response.json")); + + assertDoesNotThrow(() -> migrationService.migrateConfigurationData(attributes, TENANT)); + assertEquals(0, exportConfigRepository.count()); + } + + @ParameterizedTest + @DisplayName("Should skip config entries with invalid exportTypeSpecificParameters") + @ValueSource(strings = { + "data/configuration-entries/missing-params-response.json", + "data/configuration-entries/invalid-params-response.json" + }) + void shouldSkipEntriesWithInvalidExportTypeSpecificParameters(String responsePath) { + var attributes = tenantAttributes(null); + when(configurationClient.getConfigurations(anyString(), anyInt())).thenReturn(loadData(responsePath)); + + assertDoesNotThrow(() -> migrationService.migrateConfigurationData(attributes, TENANT)); + assertEquals(0, exportConfigRepository.count()); + } + + @Test + @DisplayName("Should be idempotent when migration is run twice with the same data") + void shouldBeIdempotentWhenMigrationRunTwice() { + var attributes = tenantAttributes(null); + var response = loadData("data/configuration-entries/valid-entry-response.json"); + when(configurationClient.getConfigurations(anyString(), anyInt())) + .thenReturn(response); + + migrationService.migrateConfigurationData(attributes, TENANT); + assertEquals(1, exportConfigRepository.count()); + + assertDoesNotThrow(() -> migrationService.migrateConfigurationData(attributes, TENANT)); + assertEquals(1, exportConfigRepository.count()); + } + + private TenantAttributes tenantAttributes(String moduleFrom) { + var attrs = new TenantAttributes(); + attrs.setModuleFrom(moduleFrom); + attrs.setModuleTo("mod-data-export-spring-3.0.0"); + return attrs; + } +} + + + diff --git a/src/test/java/org/folio/des/support/TestUtils.java b/src/test/java/org/folio/des/support/TestUtils.java index 865376b2..024601ec 100644 --- a/src/test/java/org/folio/des/support/TestUtils.java +++ b/src/test/java/org/folio/des/support/TestUtils.java @@ -2,10 +2,13 @@ import static org.folio.des.service.config.ExportConfigConstants.DEFAULT_CONFIG_NAME; +import java.io.BufferedReader; +import java.io.InputStreamReader; import java.lang.reflect.Field; import java.util.ArrayList; import java.util.List; import java.util.UUID; +import java.util.stream.Collectors; import org.folio.des.domain.dto.BursarExportFilterAge; import org.folio.des.domain.dto.BursarExportFilterCondition; @@ -13,12 +16,21 @@ import org.folio.des.domain.dto.BursarExportJob; import org.folio.des.domain.dto.ExportConfig; import org.folio.des.domain.dto.ExportTypeSpecificParameters; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import lombok.SneakyThrows; import lombok.experimental.UtilityClass; +import lombok.val; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; @UtilityClass public class TestUtils { + private static final String CLASSPATH_PREFIX = "classpath:"; + private static final ObjectMapper MAPPER = JsonMapper.builder().build(); + public static ExportConfig getBursarExportConfig() { return new ExportConfig() .id(UUID.randomUUID().toString()) @@ -55,4 +67,22 @@ private static Field getDeclaredFieldRecursive(String field, Class cls) { } } + @SneakyThrows + public static T loadData(String path, Class cls) { + return MAPPER.readValue(readContent(path), cls); + } + + @SneakyThrows + public static JsonNode loadData(String path) { + return MAPPER.readTree(readContent(path)); + } + + @SneakyThrows + private static String readContent(String path) { + val fullPath = path.startsWith(CLASSPATH_PREFIX) ? path : CLASSPATH_PREFIX + path; + val resource = new PathMatchingResourcePatternResolver().getResource(fullPath); + return new BufferedReader(new InputStreamReader(resource.getInputStream())) + .lines().collect(Collectors.joining(System.lineSeparator())); + } + } diff --git a/src/test/resources/config/application.yml b/src/test/resources/config/application.yml deleted file mode 100644 index c4c4208d..00000000 --- a/src/test/resources/config/application.yml +++ /dev/null @@ -1,5 +0,0 @@ -folio: - system: - password: testpassword - exchange: - enabled: true \ No newline at end of file diff --git a/src/test/resources/data/configuration-entries/conflicting-entry-response.json b/src/test/resources/data/configuration-entries/conflicting-entry-response.json new file mode 100644 index 00000000..6639aad9 --- /dev/null +++ b/src/test/resources/data/configuration-entries/conflicting-entry-response.json @@ -0,0 +1,17 @@ +{ + "configs": [ + { + "id": "a1111111-1111-1111-1111-111111111111", + "configName": "different_name", + "module": "mod-data-export-spring", + "value": "{\"type\":\"EDIFACT_ORDERS_EXPORT\",\"tenant\":\"other_tenant\",\"exportTypeSpecificParameters\":{\"vendorEdiOrdersExportConfig\":{}},\"schedulePeriod\":\"DAY\",\"scheduleFrequency\":1,\"scheduleTime\":\"12:00:00.000Z\"}", + "metadata": { + "createdDate": "2025-01-15T10:00:00.000+00:00", + "createdByUserId": "625dd2b6-b6f2-4f77-90fe-68954b26ee3c", + "updatedDate": "2025-01-15T10:00:00.000+00:00", + "updatedByUserId": "625dd2b6-b6f2-4f77-90fe-68954b26ee3c" + } + } + ] +} + diff --git a/src/test/resources/data/configuration-entries/duplicate-entries-response.json b/src/test/resources/data/configuration-entries/duplicate-entries-response.json new file mode 100644 index 00000000..23d7e8a1 --- /dev/null +++ b/src/test/resources/data/configuration-entries/duplicate-entries-response.json @@ -0,0 +1,29 @@ +{ + "configs": [ + { + "id": "c3333333-3333-3333-3333-333333333333", + "configName": "dup_config", + "module": "mod-data-export-spring", + "value": "{\"type\":\"BURSAR_FEES_FINES\",\"tenant\":\"diku\",\"exportTypeSpecificParameters\":{\"bursarFeeFines\":{\"daysOutstanding\":10}},\"schedulePeriod\":\"DAY\",\"scheduleFrequency\":1,\"scheduleTime\":\"12:00:00.000Z\"}", + "metadata": { + "createdDate": "2025-01-15T10:00:00.000+00:00", + "createdByUserId": "625dd2b6-b6f2-4f77-90fe-68954b26ee3c", + "updatedDate": "2025-01-15T10:00:00.000+00:00", + "updatedByUserId": "625dd2b6-b6f2-4f77-90fe-68954b26ee3c" + } + }, + { + "id": "c3333333-3333-3333-3333-333333333333", + "configName": "dup_config", + "module": "mod-data-export-spring", + "value": "{\"type\":\"BURSAR_FEES_FINES\",\"tenant\":\"diku\",\"exportTypeSpecificParameters\":{\"bursarFeeFines\":{\"daysOutstanding\":10}},\"schedulePeriod\":\"DAY\",\"scheduleFrequency\":1,\"scheduleTime\":\"12:00:00.000Z\"}", + "metadata": { + "createdDate": "2025-01-15T10:00:00.000+00:00", + "createdByUserId": "625dd2b6-b6f2-4f77-90fe-68954b26ee3c", + "updatedDate": "2025-01-15T10:00:00.000+00:00", + "updatedByUserId": "625dd2b6-b6f2-4f77-90fe-68954b26ee3c" + } + } + ] +} + diff --git a/src/test/resources/data/configuration-entries/empty-response.json b/src/test/resources/data/configuration-entries/empty-response.json new file mode 100644 index 00000000..0adafada --- /dev/null +++ b/src/test/resources/data/configuration-entries/empty-response.json @@ -0,0 +1,4 @@ +{ + "configs": [] +} + diff --git a/src/test/resources/data/configuration-entries/existing-export-config.sql b/src/test/resources/data/configuration-entries/existing-export-config.sql new file mode 100644 index 00000000..91052e80 --- /dev/null +++ b/src/test/resources/data/configuration-entries/existing-export-config.sql @@ -0,0 +1,11 @@ +INSERT INTO diku_mod_data_export_spring.export_config + (id, config_name, type, tenant, export_type_specific_parameters, + schedule_frequency, schedule_period, schedule_time, week_days, + created_date, created_by, updated_date, updated_by) +VALUES + ('a1111111-1111-1111-1111-111111111111', 'export_config_parameters', 'BURSAR_FEES_FINES', 'diku', + '{"bursarFeeFines": {"daysOutstanding": 10}}', + 1, 'DAY', '12:00:00.000Z', null, + '2025-01-01 00:00:00', '625dd2b6-b6f2-4f77-90fe-68954b26ee3c', + '2025-01-01 00:00:00', '625dd2b6-b6f2-4f77-90fe-68954b26ee3c'); + diff --git a/src/test/resources/data/configuration-entries/invalid-params-response.json b/src/test/resources/data/configuration-entries/invalid-params-response.json new file mode 100644 index 00000000..06b7da67 --- /dev/null +++ b/src/test/resources/data/configuration-entries/invalid-params-response.json @@ -0,0 +1,17 @@ +{ + "configs": [ + { + "id": "e5555555-5555-5555-5555-555555555555", + "configName": "some_config", + "module": "mod-data-export-spring", + "value": "{\"id\":\"550e8400-e29b-41d4-a716-446655440000\",\"tenant\":\"testTenant\",\"schedulePeriod\":\"NONE\",\"type\":\"CLAIMS\",\"exportTypeSpecificParameters\":{\"bursarFeeFines\":null,\"vendorEdiOrdersExportConfig\":{\"exportConfigId\":\"550e8400-e29b-41d4-a716-446655440000\",\"vendorId\":\"11111111-1111-1111-1111-111111111111\",\"integrationType\":\"Claiming\",\"transmissionMethod\":\"File Download\",\"fileFormat\":\"EDI\",\"configName\":\"testConfig\",\"configDescription\":\"Test integration configuration for claims export\",\"isDefaultConfig\":false,\"ediConfig\":{\"vendorEdiType\":\"31B/US-SAN\",\"vendorEdiCode\":\"VENDOR-EDI-CODE\",\"libEdiCode\":\"LIB-EDI-CODE\",\"libEdiType\":\"31B/US-SAN\",\"ediNamingConvention\":\"{organizationCode}-{integrationName}-{exportJobEndDate}\",\"sendAccountNumber\":false,\"supportOrder\":true,\"supportInvoice\":false,\"accountNoList\":[\"1234567890\"],\"defaultAcquisitionMethods\":[\"df26d81b-9d63-4ff8-bf41-49bf75cfa70e\"],\"notes\":\"EDI configuration notes\"},\"ediFtp\":{\"ftpConnMode\":\"Active\",\"ftpFormat\":\"FTP\",\"ftpMode\":\"ASCII\",\"ftpPort\":22,\"orderDirectory\":\"/files\",\"invoiceDirectory\":\"/files\",\"password\":\"Ffx29%pu\",\"serverAddress\":\"ftp://ftp.ci.folio.org\",\"username\":\"folio\",\"isPrimaryTransmissionMethod\":null,\"notes\":\"FTP configuration notes\"},\"ediSchedule\":{\"enableScheduledExport\":false,\"scheduleParameters\":{\"id\":\"550e8400-e29b-41d4-a716-446655440000\",\"scheduleFrequency\":1,\"schedulePeriod\":\"DAY\",\"schedulingDate\":null,\"scheduleTime\":\"12:00:00\",\"weekDays\":null,\"timeZone\":\"UTC\"},\"schedulingNotes\":null}},\"query\":null,\"eHoldingsExportConfig\":null,\"authorityControlExportConfig\":null},\"scheduleFrequency\":null,\"scheduleTime\":null,\"weekDays\":null}", + "metadata": { + "createdDate": "2025-01-15T10:00:00.000+00:00", + "createdByUserId": "625dd2b6-b6f2-4f77-90fe-68954b26ee3c", + "updatedDate": "2025-01-15T10:00:00.000+00:00", + "updatedByUserId": "625dd2b6-b6f2-4f77-90fe-68954b26ee3c" + } + } + ] +} + diff --git a/src/test/resources/data/configuration-entries/missing-params-response.json b/src/test/resources/data/configuration-entries/missing-params-response.json new file mode 100644 index 00000000..d333a1ba --- /dev/null +++ b/src/test/resources/data/configuration-entries/missing-params-response.json @@ -0,0 +1,17 @@ +{ + "configs": [ + { + "id": "e5555555-5555-5555-5555-555555555555", + "configName": "some_config", + "module": "mod-data-export-spring", + "value": "{\"type\":\"BURSAR_FEES_FINES\"}", + "metadata": { + "createdDate": "2025-01-15T10:00:00.000+00:00", + "createdByUserId": "625dd2b6-b6f2-4f77-90fe-68954b26ee3c", + "updatedDate": "2025-01-15T10:00:00.000+00:00", + "updatedByUserId": "625dd2b6-b6f2-4f77-90fe-68954b26ee3c" + } + } + ] +} + diff --git a/src/test/resources/data/configuration-entries/missing-type-response.json b/src/test/resources/data/configuration-entries/missing-type-response.json new file mode 100644 index 00000000..485de293 --- /dev/null +++ b/src/test/resources/data/configuration-entries/missing-type-response.json @@ -0,0 +1,17 @@ +{ + "configs": [ + { + "id": "d4444444-4444-4444-4444-444444444444", + "configName": "some_config", + "module": "mod-data-export-spring", + "value": "{\"exportTypeSpecificParameters\":{\"bursarFeeFines\":{}}}", + "metadata": { + "createdDate": "2025-01-15T10:00:00.000+00:00", + "createdByUserId": "625dd2b6-b6f2-4f77-90fe-68954b26ee3c", + "updatedDate": "2025-01-15T10:00:00.000+00:00", + "updatedByUserId": "625dd2b6-b6f2-4f77-90fe-68954b26ee3c" + } + } + ] +} + diff --git a/src/test/resources/data/configuration-entries/valid-entry-response.json b/src/test/resources/data/configuration-entries/valid-entry-response.json new file mode 100644 index 00000000..2b1d69a9 --- /dev/null +++ b/src/test/resources/data/configuration-entries/valid-entry-response.json @@ -0,0 +1,17 @@ +{ + "configs": [ + { + "id": "b2222222-2222-2222-2222-222222222222", + "configName": "bursar_config", + "module": "mod-data-export-spring", + "value": "{\"type\":\"BURSAR_FEES_FINES\",\"tenant\":\"diku\",\"exportTypeSpecificParameters\":{\"bursarFeeFines\":{\"daysOutstanding\":10}},\"schedulePeriod\":\"DAY\",\"scheduleFrequency\":1,\"scheduleTime\":\"12:00:00.000Z\"}", + "metadata": { + "createdDate": "2025-01-15T10:00:00.000+00:00", + "createdByUserId": "625dd2b6-b6f2-4f77-90fe-68954b26ee3c", + "updatedDate": "2025-01-15T10:00:00.000+00:00", + "updatedByUserId": "625dd2b6-b6f2-4f77-90fe-68954b26ee3c" + } + } + ] +} +