diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json index e07efb192..d6e8a153e 100644 --- a/descriptors/ModuleDescriptor-template.json +++ b/descriptors/ModuleDescriptor-template.json @@ -153,6 +153,10 @@ { "id": "settings", "version": "1.2" + }, + { + "id": "locale", + "version": "1.0" } ], "optional": [ @@ -249,7 +253,8 @@ "users.collection.get", "transfers.collection.get", "inventory-storage.service-points.collection.get", - "instance-authority-links.authority-statistics.collection.get" + "instance-authority-links.authority-statistics.collection.get", + "locale.item.get" ] }, { diff --git a/src/main/java/org/folio/dew/batch/circulationlog/CirculationLogItemProcessor.java b/src/main/java/org/folio/dew/batch/circulationlog/CirculationLogItemProcessor.java index e69e3d530..e00b2d2a7 100644 --- a/src/main/java/org/folio/dew/batch/circulationlog/CirculationLogItemProcessor.java +++ b/src/main/java/org/folio/dew/batch/circulationlog/CirculationLogItemProcessor.java @@ -1,12 +1,10 @@ package org.folio.dew.batch.circulationlog; -import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; -import lombok.SneakyThrows; import lombok.extern.log4j.Log4j2; import org.apache.commons.lang3.StringUtils; +import org.folio.dew.client.LocaleClient; import org.folio.dew.client.ServicePointClient; -import org.folio.dew.client.SettingsClient; import org.folio.dew.domain.dto.CirculationLogExportFormat; import org.folio.dew.domain.dto.LogRecord; import org.folio.dew.domain.dto.LogRecordItemsInner; @@ -31,8 +29,7 @@ public class CirculationLogItemProcessor implements ItemProcessor { private final ServicePointClient servicePointClient; - private final SettingsClient settingsClient; - private final ObjectMapper objectMapper; + private final LocaleClient localeClient; private Map servicePointMap; private SimpleDateFormat format; @@ -67,45 +64,22 @@ public void initStep(StepExecution stepExecution) { private void initTenantSpecificDateFormat() { if (format != null) return; - String timezoneId = fetchTimezone(); + var localeSettings = localeClient.getLocaleSettings(); + var zoneId = localeSettings.getZoneId(); + + log.info("Initializing circulation log date format with locale settings: locale='{}', timezone='{}', currency='{}', numberingSystem='{}', resolvedZoneId='{}'.", + localeSettings.locale(), + localeSettings.timezone(), + localeSettings.currency(), + localeSettings.numberingSystem(), + zoneId.getId()); var dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm"); - dateFormat.setTimeZone(TimeZone.getTimeZone(timezoneId)); + dateFormat.setTimeZone(TimeZone.getTimeZone(zoneId)); format = dateFormat; } - @SneakyThrows - @SuppressWarnings("unchecked") - private String fetchTimezone() { - try { - final Map tenantLocaleSettings = - settingsClient.getSettings("scope==stripes-core.prefs.manage and key==tenantLocaleSettings"); - - var resultInfo = (Map) tenantLocaleSettings.get("resultInfo"); - var totalRecords = (Integer) resultInfo.get("totalRecords"); - - if (totalRecords > 0) { - var items = (List>) tenantLocaleSettings.get("items"); - var settingsEntry = items.get(0); - var value = settingsEntry.get("value"); - - if (value instanceof String valueStr) { - return valueStr; - } else { - var jsonObject = objectMapper.valueToTree(value); - if (jsonObject.has("timezone")) { - return jsonObject.get("timezone").asText(); - } - } - } - } catch (Exception e) { - log.warn("Failed to fetch timezone from mod-settings: {}", e.getMessage()); - } - - return "UTC"; - } - private void fetchServicePoints() { if (servicePointMap != null) return; diff --git a/src/main/java/org/folio/dew/client/LocaleClient.java b/src/main/java/org/folio/dew/client/LocaleClient.java new file mode 100644 index 000000000..ae55ca79a --- /dev/null +++ b/src/main/java/org/folio/dew/client/LocaleClient.java @@ -0,0 +1,77 @@ +package org.folio.dew.client; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import feign.FeignException; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.folio.dew.config.feign.FeignClientConfiguration; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.annotation.GetMapping; + +import java.time.DateTimeException; +import java.time.ZoneId; + +/** + * Client for the /locale API. + * + * @implNote This is a separate class from the internal LocaleClientRaw interface because Feign clients must be interfaces, disallowing any injection. + */ +@Log4j2 +@Component +@RequiredArgsConstructor(onConstructor_ = @Autowired) +public class LocaleClient { + + private final ObjectMapper objectMapper; + private final LocaleClientRaw underlyingClient; + + /** + * Provides raw access to the /locale API. + */ + @FeignClient(name = "locale", configuration = FeignClientConfiguration.class) + interface LocaleClientRaw { + @GetMapping + String getLocaleSettings(); + } + + public record LocaleSettings( + String locale, + String currency, + String timezone, + String numberingSystem + ) { + public ZoneId getZoneId() { + try { + return ZoneId.of(timezone); + } catch (DateTimeException | NullPointerException e) { + log.error("Invalid or missing timezone '{}', defaulting to UTC.", timezone, e); + return ZoneId.of("UTC"); + } + } + } + + public LocaleSettings getLocaleSettings() { + try { + String response = underlyingClient.getLocaleSettings(); + LocaleSettings settings = objectMapper.readValue(response, LocaleSettings.class); + log.info("Retrieved tenant locale settings from /locale: locale='{}', timezone='{}', currency='{}', numberingSystem='{}'.", + settings.locale(), settings.timezone(), settings.currency(), settings.numberingSystem()); + return settings; + } catch (FeignException e) { + log.warn("Failed to call /locale (status={}): {}. Falling back to defaults.", e.status(), e.getMessage()); + return defaultLocaleSettings(); + } catch (JsonProcessingException e) { + log.warn("Failed to parse /locale response. Falling back to defaults.", e); + return defaultLocaleSettings(); + } catch (NullPointerException e) { + log.warn("Missing locale settings payload from /locale. Falling back to defaults.", e); + return defaultLocaleSettings(); + } + } + + private LocaleSettings defaultLocaleSettings() { + return new LocaleSettings("en-US", "USD", "UTC", "latn"); + } +} diff --git a/src/test/java/org/folio/dew/CirculationLogTest.java b/src/test/java/org/folio/dew/CirculationLogTest.java index faa021ea9..bc38bb60c 100644 --- a/src/test/java/org/folio/dew/CirculationLogTest.java +++ b/src/test/java/org/folio/dew/CirculationLogTest.java @@ -1,11 +1,13 @@ package org.folio.dew; +import com.github.tomakehurst.wiremock.stubbing.StubMapping; import org.apache.commons.io.FileUtils; import org.folio.dew.domain.dto.JobParameterNames; import org.folio.dew.domain.dto.ExportType; import org.folio.dew.domain.dto.CirculationLogExportFormat; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.batch.core.ExitStatus; @@ -24,8 +26,11 @@ import java.util.Date; import java.util.UUID; +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.urlMatching; import static org.assertj.core.api.Assertions.assertThat; import static org.folio.dew.domain.dto.JobParameterNames.CIRCULATION_LOG_FILE_NAME; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -43,6 +48,11 @@ static void beforeAll() { setUpTenant(NON_CONSORTIUM_TENANT); } + @BeforeEach + void beforeEach() { + wireMockServer.resetRequests(); + } + @Test @DisplayName("Run CirculationLogJob successfully") void circulationLogJobTest() throws Exception { @@ -59,6 +69,8 @@ void circulationLogJobTest() throws Exception { getRequestedFor( urlEqualTo( "/audit-data/circulation/logs?query&offset=0&limit=0"))); + wireMockServer.verify(getRequestedFor(urlEqualTo("/locale"))); + wireMockServer.verify(0, getRequestedFor(urlMatching("/settings/entries\\?query=.*tenantLocaleSettings.*"))); } @Test @@ -128,4 +140,71 @@ void circulationLogJobFileMismatchTest() throws Exception { ); assertThat(thrown.getMessage()).contains("Files are not identical!"); } + + @Test + @DisplayName("Run CirculationLogJob successfully when /locale returns unauthorized") + void circulationLogJobUsesDefaultLocaleWhenLocaleApiUnauthorized() throws Exception { + StubMapping localeStub = wireMockServer.stubFor(get(urlEqualTo("/locale")) + .atPriority(3) + .willReturn(aResponse().withStatus(401))); + try { + JobLauncherTestUtils testLauncher = createTestLauncher(getCirculationLogJob); + JobExecution jobExecution = testLauncher.launchJob(prepareJobParameters()); + + assertThat(jobExecution.getExitStatus()).isEqualTo(ExitStatus.COMPLETED); + wireMockServer.verify(getRequestedFor(urlEqualTo("/locale"))); + wireMockServer.verify(0, getRequestedFor(urlMatching("/settings/entries\\?query=.*tenantLocaleSettings.*"))); + } finally { + wireMockServer.removeStubMapping(localeStub); + } + } + + @Test + @DisplayName("Run CirculationLogJob successfully when /locale returns invalid json") + void circulationLogJobUsesDefaultLocaleWhenLocaleApiReturnsInvalidJson() throws Exception { + StubMapping localeStub = wireMockServer.stubFor(get(urlEqualTo("/locale")) + .atPriority(2) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("invalid json"))); + try { + JobLauncherTestUtils testLauncher = createTestLauncher(getCirculationLogJob); + JobExecution jobExecution = testLauncher.launchJob(prepareJobParameters()); + + assertThat(jobExecution.getExitStatus()).isEqualTo(ExitStatus.COMPLETED); + wireMockServer.verify(getRequestedFor(urlEqualTo("/locale"))); + wireMockServer.verify(0, getRequestedFor(urlMatching("/settings/entries\\?query=.*tenantLocaleSettings.*"))); + } finally { + wireMockServer.removeStubMapping(localeStub); + } + } + + @Test + @DisplayName("Run CirculationLogJob successfully when /locale returns invalid timezone") + void circulationLogJobUsesUtcWhenLocaleApiReturnsInvalidTimezone() throws Exception { + StubMapping localeStub = wireMockServer.stubFor(get(urlEqualTo("/locale")) + .atPriority(1) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(""" + { + "locale": "en-US", + "currency": "USD", + "timezone": "invalid-timezone", + "numberingSystem": "latn" + } + """))); + try { + JobLauncherTestUtils testLauncher = createTestLauncher(getCirculationLogJob); + JobExecution jobExecution = testLauncher.launchJob(prepareJobParameters()); + + assertThat(jobExecution.getExitStatus()).isEqualTo(ExitStatus.COMPLETED); + wireMockServer.verify(getRequestedFor(urlEqualTo("/locale"))); + wireMockServer.verify(0, getRequestedFor(urlMatching("/settings/entries\\?query=.*tenantLocaleSettings.*"))); + } finally { + wireMockServer.removeStubMapping(localeStub); + } + } } diff --git a/src/test/resources/mappings/locale.json b/src/test/resources/mappings/locale.json new file mode 100644 index 000000000..3685c6a7d --- /dev/null +++ b/src/test/resources/mappings/locale.json @@ -0,0 +1,17 @@ +{ + "mappings": [ + { + "request": { + "method": "GET", + "urlPattern": "/locale" + }, + "response": { + "status": 200, + "body": "{\n \"locale\": \"en-US\",\n \"currency\": \"USD\",\n \"timezone\": \"Pacific/Yap\",\n \"numberingSystem\": \"latn\"\n}", + "headers": { + "Content-Type": "application/json" + } + } + } + ] +} diff --git a/src/test/resources/output/invalid_file.csv b/src/test/resources/output/invalid_file.csv new file mode 100644 index 000000000..e51a188a4 --- /dev/null +++ b/src/test/resources/output/invalid_file.csv @@ -0,0 +1,2 @@ +this,is,not,the,expected,export +1,2,3