Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion descriptors/ModuleDescriptor-template.json
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,10 @@
{
"id": "settings",
"version": "1.2"
},
{
"id": "locale",
"version": "1.0"
}
],
"optional": [
Expand Down Expand Up @@ -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"
]
},
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -31,8 +29,7 @@
public class CirculationLogItemProcessor implements ItemProcessor<LogRecord, CirculationLogExportFormat> {

private final ServicePointClient servicePointClient;
private final SettingsClient settingsClient;
private final ObjectMapper objectMapper;
private final LocaleClient localeClient;

private Map<String, String> servicePointMap;
private SimpleDateFormat format;
Expand Down Expand Up @@ -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<String, Object> tenantLocaleSettings =
settingsClient.getSettings("scope==stripes-core.prefs.manage and key==tenantLocaleSettings");

var resultInfo = (Map<String, Object>) tenantLocaleSettings.get("resultInfo");
var totalRecords = (Integer) resultInfo.get("totalRecords");

if (totalRecords > 0) {
var items = (List<Map<String, Object>>) 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;

Expand Down
77 changes: 77 additions & 0 deletions src/main/java/org/folio/dew/client/LocaleClient.java
Original file line number Diff line number Diff line change
@@ -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");
}
}
79 changes: 79 additions & 0 deletions src/test/java/org/folio/dew/CirculationLogTest.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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);
}
}
}
17 changes: 17 additions & 0 deletions src/test/resources/mappings/locale.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
]
}
2 changes: 2 additions & 0 deletions src/test/resources/output/invalid_file.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
this,is,not,the,expected,export
1,2,3
Loading