From b71c69a8257b87ec111299fd202faaec99592617 Mon Sep 17 00:00:00 2001 From: Mark Patton Date: Mon, 16 Mar 2026 10:42:24 -0400 Subject: [PATCH 1/5] Normalize error handling of external DOI services. Remove checks for multiple requests about the same DOI. Better handle character encoding of responses. --- pass-core-doi-service/pom.xml | 5 + .../pass/doi/service/ExternalDoiService.java | 72 +---- .../service/ExternalDoiServiceConnector.java | 59 ++-- .../doi/service/PassDoiServiceController.java | 134 ++++----- .../pass/doi/service/UnpaywallDoiService.java | 5 +- ...nalDoiServiceConnectorIntegrationTest.java | 96 +++++++ .../ExternalDoiServiceConnectorTest.java | 147 +++++----- .../service/PassDoiServiceControllerTest.java | 262 ++++++++++++++++++ 8 files changed, 536 insertions(+), 244 deletions(-) create mode 100644 pass-core-doi-service/src/test/java/org/eclipse/pass/doi/service/ExternalDoiServiceConnectorIntegrationTest.java create mode 100644 pass-core-doi-service/src/test/java/org/eclipse/pass/doi/service/PassDoiServiceControllerTest.java diff --git a/pass-core-doi-service/pom.xml b/pass-core-doi-service/pom.xml index 49071e25..e27ace80 100644 --- a/pass-core-doi-service/pom.xml +++ b/pass-core-doi-service/pom.xml @@ -49,6 +49,11 @@ commons-io test + + com.squareup.okhttp3 + mockwebserver + test + diff --git a/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/ExternalDoiService.java b/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/ExternalDoiService.java index f453beea..3fcd1027 100644 --- a/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/ExternalDoiService.java +++ b/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/ExternalDoiService.java @@ -16,12 +16,7 @@ */ package org.eclipse.pass.doi.service; -import static java.lang.Thread.sleep; - -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Set; +import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -35,9 +30,6 @@ * @author jrm */ public abstract class ExternalDoiService { - private final Set activeJobSet = new HashSet<>(); - private final Set syncActiveJobSet = Collections.synchronizedSet(activeJobSet); - final static String MAILTO = "pass@jhu.edu"; /** @@ -56,13 +48,13 @@ public abstract class ExternalDoiService { * A key, value map of query parameters used by the external service; null if there aren't any. * @return the map */ - public abstract HashMap parameterMap(); + public abstract Map parameterMap(); /** * A key, value map of headers used by the external service; null if there aren't any. * @return the map */ - public abstract HashMap headerMap(); + public abstract Map headerMap(); /** * A method to transform the raw external service's JSON response to suit the UI requirements @@ -89,62 +81,4 @@ String verify(String doi) { Matcher matcher = pattern.matcher(suffix); return matcher.matches() ? suffix : null; } - - /** - * this simply protects the external service from a person hammering on a request thinking - * it wasn't processed, when it really is just slow coming back - * - * @param doi the doi to check active - * @return whether the doi lookup is still active - */ - boolean isAlreadyActive(String doi) { - //check cache map for existence of doi - //put doi on map if absent - - if (syncActiveJobSet.contains(doi)) { - return true; - } else { - // this DOI is not actively being processed - // let's temporarily prohibit new requests for this DOI - syncActiveJobSet.add(doi); - //longest time we expect it should take to create a Journal object, in - //milliseconds - int cachePeriod = 30000; - Thread t = new Thread(new ExternalDoiService.ExpiringLock(doi, cachePeriod)); - t.start(); - } - return false; - } - - /** - * Once a doi has been processed, this method removes it from the locked list. - * @param doi the doi - */ - void unlockDoi(String doi) { - syncActiveJobSet.remove(doi); - } - - /** - * A class to manage locking so that an active process for a DOI will finish executing before - * another one begins - */ - public class ExpiringLock implements Runnable { - private final String key; - private final int duration; - - ExpiringLock(String key, int duration) { - this.key = key; - this.duration = duration; - } - - @Override - public void run() { - try { - sleep(duration); - syncActiveJobSet.remove(key); - } catch (InterruptedException e) { - syncActiveJobSet.remove(key); - } - } - } } diff --git a/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/ExternalDoiServiceConnector.java b/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/ExternalDoiServiceConnector.java index 5fad6647..990bb1e9 100644 --- a/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/ExternalDoiServiceConnector.java +++ b/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/ExternalDoiServiceConnector.java @@ -19,7 +19,7 @@ import static java.util.concurrent.TimeUnit.SECONDS; import java.io.IOException; -import java.io.StringReader; +import java.io.Reader; import java.util.Objects; import jakarta.json.Json; @@ -42,9 +42,14 @@ */ public class ExternalDoiServiceConnector { private static final Logger LOG = LoggerFactory.getLogger(ExternalDoiServiceConnector.class); + static final String HTTP_STATUS_CODE = "HTTP_STATUS_CODE"; private final OkHttpClient client; + ExternalDoiServiceConnector(OkHttpClient client) { + this.client = client; + } + ExternalDoiServiceConnector() { OkHttpClient.Builder builder = new OkHttpClient.Builder(); builder.connectTimeout(30, SECONDS); @@ -54,54 +59,50 @@ public class ExternalDoiServiceConnector { } /** - * consult external service to get a json object for a supplied doi + * Consult external service to get a json object for a supplied doi. * * @param doi - the supplied doi string, prefix trimmed if necessary - * @return a string representing the works object if successful; an empty string if not found; null if IO exception + * @return a JSON object if successful, + * null if an error occurs interacting with the external service, + * {error: "error message", HTTP_STATUS_CODE: http status code} if + * the external service returns an error status code */ JsonObject retrieveMetadata(String doi, ExternalDoiService service) { HttpUrl.Builder urlBuilder = Objects.requireNonNull(HttpUrl.parse(service.baseUrl() + doi)).newBuilder(); - if ( service.parameterMap() != null ) { - for ( String key : service.parameterMap().keySet() ) { + if (service.parameterMap() != null) { + for (String key : service.parameterMap().keySet()) { urlBuilder.addQueryParameter(key, service.parameterMap().get(key)); } } - String url = urlBuilder.build().toString(); + Request.Builder requestBuilder = new Request.Builder().url(urlBuilder.build()); - Request.Builder requestBuilder = new Request.Builder() - .url(url); - if ( service.headerMap() != null ) { + if (service.headerMap() != null) { requestBuilder.headers(Headers.of(service.headerMap())); } - Request okHttpRequest = requestBuilder.build(); + Request okHttpRequest = requestBuilder.build(); Call call = client.newCall(okHttpRequest); - JsonReader reader; - JsonObject metadataJsonObject; - String responseString = null; try (Response okHttpResponse = call.execute()) { - responseString = Objects.requireNonNull(okHttpResponse.body()).string(); - reader = Json.createReader(new StringReader(responseString)); - metadataJsonObject = reader.readObject(); - reader.close(); - - service.unlockDoi(doi); - - return metadataJsonObject; - } catch (JsonParsingException e) { - if (responseString != null) { - return Json.createObjectBuilder() - .add("error", responseString) - .build(); + if (okHttpResponse.isSuccessful()) { + try (Reader reader = okHttpResponse.body().charStream(); + JsonReader jsonReader = Json.createReader(reader)) { + return jsonReader.readObject(); + } catch (JsonParsingException e) { + LOG.error("Error parsing JSON of external service: " + okHttpRequest.url(), e); + return null; + } } + + // Set response as the error field and save the status code. + return Json.createObjectBuilder().add("error", okHttpResponse.body().string()). + add(HTTP_STATUS_CODE, Json.createValue(okHttpResponse.code())).build(); } catch (IOException e) { - LOG.error(e.getMessage(), e); + LOG.error("Error accessing external service: " + okHttpRequest.url(), e); + return null; } - return null; } - } diff --git a/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/PassDoiServiceController.java b/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/PassDoiServiceController.java index 98cf13fc..4e22c902 100644 --- a/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/PassDoiServiceController.java +++ b/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/PassDoiServiceController.java @@ -17,7 +17,7 @@ package org.eclipse.pass.doi.service; import java.io.IOException; -import java.io.OutputStream; +import java.io.Writer; import com.yahoo.elide.RefreshableElide; import jakarta.json.Json; @@ -26,6 +26,7 @@ import jakarta.servlet.http.HttpServletResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @@ -46,6 +47,7 @@ public class PassDoiServiceController { /** * @param refreshableElide the RefreshableElide */ + @Autowired public PassDoiServiceController(RefreshableElide refreshableElide) { this.elideConnector = new ElideConnector(refreshableElide); this.externalDoiServiceConnector = new ExternalDoiServiceConnector(); @@ -53,6 +55,24 @@ public PassDoiServiceController(RefreshableElide refreshableElide) { this.unpaywallDoiService = new UnpaywallDoiService(); } + /** + * Constructor for testing, allows injection of mock connectors and services. + * + * @param elideConnector + * @param externalDoiServiceConnector + * @param xrefDoiService + * @param unpaywallDoiService + */ + PassDoiServiceController(ElideConnector elideConnector, + ExternalDoiServiceConnector externalDoiServiceConnector, + ExternalDoiService xrefDoiService, + ExternalDoiService unpaywallDoiService) { + this.elideConnector = elideConnector; + this.externalDoiServiceConnector = externalDoiServiceConnector; + this.xrefDoiService = xrefDoiService; + this.unpaywallDoiService = unpaywallDoiService; + } + /** * This method handles GET requests to resolve a given DOI to a journal metadata * @@ -71,64 +91,50 @@ protected void getXrefMetadata(HttpServletRequest request, HttpServletResponse r //we will call out to crossref and collect the work JSON object //the value of this parameter is expected to be already URIencoded - String doi = request.getParameter("doi"); + String doi = externalService.verify(request.getParameter("doi")); //stage 1: verify doi is valid - if (externalService.verify(doi) == null) { + if (doi == null) { // do not have have a valid xref doi - try (OutputStream out = response.getOutputStream()) { + try (Writer out = response.getWriter()) { JsonObject jsonObject = Json.createObjectBuilder() .add("error", "Supplied DOI is not in valid DOI format.") .build(); - out.write(jsonObject.toString().getBytes()); + out.write(jsonObject.toString()); response.setStatus(400); return; } } - //Stage 2: make sure we don't already have a request being processed for this doi - if (externalService.isAlreadyActive(doi)) { - // return already processing error (429>) - try (OutputStream out = response.getOutputStream()) { - String message = "There is already an active request for " + doi; - JsonObject jsonObject = Json.createObjectBuilder() - .add("error", message + "; try again later.") - .build(); - out.write(jsonObject.toString().getBytes()); - response.setStatus(429); - LOG.warn(message); - } - } - - //stage 3: try to get crossref record, catch errors first, and halt processing + //stage 2: try to get crossref record, catch errors first, and halt processing JsonObject xrefJsonObject = externalDoiServiceConnector.retrieveMetadata(doi, externalService); + if (xrefJsonObject == null) { - try (OutputStream out = response.getOutputStream()) { + try (Writer out = response.getWriter()) { String message = "There was an error getting the metadata from " + externalService.name() + " for " + doi; JsonObject jsonObject = Json.createObjectBuilder() .add("error", message) .build(); - out.write(jsonObject.toString().getBytes()); + out.write(jsonObject.toString()); response.setStatus(500); - LOG.warn(message); } - } else if (xrefJsonObject.getJsonString("error") != null) { - int responseCode; + } else if (xrefJsonObject.containsKey("error")) { + int responseCode = xrefJsonObject.getInt(ExternalDoiServiceConnector.HTTP_STATUS_CODE); String message; - if (xrefJsonObject.getString("error").equals("Resource not found.")) { - responseCode = 404; + + if (responseCode == 404) { message = "The resource for DOI " + doi + " could not be found on " + externalService.name() + "."; } else { - responseCode = 500; message = "A record for this resource could not be returned from " + externalService.name() + ": " + - xrefJsonObject.getJsonString("error"); + xrefJsonObject.getJsonString("error"); } - try (OutputStream out = response.getOutputStream()) { + + try (Writer out = response.getWriter()) { JsonObject jsonObject = Json.createObjectBuilder() .add("error", message) .build(); - out.write(jsonObject.toString().getBytes()); + out.write(jsonObject.toString()); response.setStatus(responseCode); LOG.warn(message); } @@ -137,25 +143,25 @@ protected void getXrefMetadata(HttpServletRequest request, HttpServletResponse r String journalId = elideConnector.resolveJournal(xrefJsonObject); if (journalId != null) { - try (OutputStream out = response.getOutputStream()) { + try (Writer out = response.getWriter()) { JsonObject jsonObject = Json.createObjectBuilder() .add("journal-id", journalId) .add("crossref", externalService.processObject(xrefJsonObject)) .build(); - out.write(jsonObject.toString().getBytes()); + out.write(jsonObject.toString()); response.setStatus(200); } } else { // journal id is null - this should never happen unless Crosssref journal is insufficient // for example, if a book doi ws supplied which has no issns - try (OutputStream out = response.getOutputStream()) { + try (Writer out = response.getWriter()) { String message = "Insufficient information to locate or specify a journal entry."; JsonObject jsonObject = Json.createObjectBuilder() .add("error", message) .build(); - out.write(jsonObject.toString().getBytes()); + out.write(jsonObject.toString()); response.setStatus(422); LOG.warn(message); } @@ -182,79 +188,51 @@ protected void getUnpaywallMetadata(HttpServletRequest request, HttpServletRespo //we will call out to unpaywall and collect the JSON object //the value of this parameter is expected to be already URIencoded - String doi = request.getParameter("doi"); + String doi = externalService.verify(request.getParameter("doi")); //stage 1: verify doi is valid - if (externalService.verify(doi) == null) { + if (doi == null) { // do not have have a valid doi - try (OutputStream out = response.getOutputStream()) { + try (Writer out = response.getWriter()) { JsonObject jsonObject = Json.createObjectBuilder() .add("error", "Supplied DOI is not in valid DOI format.") .build(); - out.write(jsonObject.toString().getBytes()); + out.write(jsonObject.toString()); response.setStatus(400); return; } } - //Stage 2: make sure we don't already have a request being processed for this doi - if (externalService.isAlreadyActive(doi)) { - // return already processing error (429) - try (OutputStream out = response.getOutputStream()) { - String message = "There is already an active request for " + doi; - JsonObject jsonObject = Json.createObjectBuilder() - .add("error", message + "; try again later.") - .build(); - out.write(jsonObject.toString().getBytes()); - response.setStatus(429); - LOG.warn(message); - } - } - - //stage 3: try to get unpaywall record, catch errors first, and halt processing + //stage 2: try to get unpaywall record, catch errors first, and halt processing JsonObject unpaywallJsonObject = externalDoiServiceConnector.retrieveMetadata(doi, externalService); if (unpaywallJsonObject == null) { - try (OutputStream out = response.getOutputStream()) { + try (Writer out = response.getWriter()) { String message = "There was an error getting the metadata from " + externalService.name() + " for " + doi; JsonObject jsonObject = Json.createObjectBuilder() .add("error", message) .build(); - out.write(jsonObject.toString().getBytes()); + out.write(jsonObject.toString()); response.setStatus(500); - LOG.warn(message); - } - } else if ( - unpaywallJsonObject.containsKey("error") && - unpaywallJsonObject.getValue("/error").toString().equals("true") - ) { - int responseCode; - String message; - - if (unpaywallJsonObject.getValue("/HTTP_status_code") != null && - unpaywallJsonObject.getValue("/message") != null) { - - responseCode = Integer.parseInt(unpaywallJsonObject.getValue("/HTTP_status_code").toString()); - message = unpaywallJsonObject.getValue("/message").toString(); - } else { - responseCode = 500; - message = "A record for this resource could not be returned from Unpaywall: " + - unpaywallJsonObject.getJsonString("error"); } + } else if (unpaywallJsonObject.containsKey("error")) { + int responseCode = unpaywallJsonObject.getInt(ExternalDoiServiceConnector.HTTP_STATUS_CODE); + String message = "A record for this resource could not be returned from Unpaywall: " + + unpaywallJsonObject.getJsonString("error"); - try (OutputStream out = response.getOutputStream()) { + try (Writer out = response.getWriter()) { JsonObject jsonObject = Json.createObjectBuilder() .add("error", message) .build(); - out.write(jsonObject.toString().getBytes()); + out.write(jsonObject.toString()); response.setStatus(responseCode); LOG.warn(message); } } else { // have a non-empty JSON string to process - try (OutputStream out = response.getOutputStream()) { + try (Writer out = response.getWriter()) { JsonObject jsonObject = externalService.processObject(unpaywallJsonObject); - out.write(jsonObject.toString().getBytes()); + out.write(jsonObject.toString()); response.setStatus(200); } } diff --git a/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/UnpaywallDoiService.java b/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/UnpaywallDoiService.java index 92759223..23d4c663 100644 --- a/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/UnpaywallDoiService.java +++ b/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/UnpaywallDoiService.java @@ -20,6 +20,7 @@ import java.net.URI; import java.net.URISyntaxException; import java.util.HashMap; +import java.util.Map; import jakarta.json.Json; import jakarta.json.JsonArray; @@ -48,7 +49,7 @@ public String baseUrl() { } @Override - public HashMap parameterMap() { + public Map parameterMap() { HashMap parameterMap = new HashMap<>(); String agent = System.getenv("PASS_DOI_SERVICE_MAILTO") != null ? System.getenv( "PASS_DOI_SERVICE_MAILTO") : MAILTO; @@ -57,7 +58,7 @@ public HashMap parameterMap() { } @Override - public HashMap headerMap() { + public Map headerMap() { return null; } diff --git a/pass-core-doi-service/src/test/java/org/eclipse/pass/doi/service/ExternalDoiServiceConnectorIntegrationTest.java b/pass-core-doi-service/src/test/java/org/eclipse/pass/doi/service/ExternalDoiServiceConnectorIntegrationTest.java new file mode 100644 index 00000000..14446649 --- /dev/null +++ b/pass-core-doi-service/src/test/java/org/eclipse/pass/doi/service/ExternalDoiServiceConnectorIntegrationTest.java @@ -0,0 +1,96 @@ +/* + * + * Copyright 2019 Johns Hopkins University + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.eclipse.pass.doi.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import jakarta.json.JsonObject; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +/** + * Unit tests for the connector. This test class is @Disableed in production, and is only used to + * verify behavior against live services when either the connector class or the external APIs + * are changed + * + * @author jrm + */ +@Disabled +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ExternalDoiServiceConnectorIntegrationTest { + + private final ExternalDoiServiceConnector underTest = new ExternalDoiServiceConnector(); + private final ExternalDoiService xrefService = new XrefDoiService(); + private final ExternalDoiService unpaywallService = new UnpaywallDoiService(); + + /** + * test that hitting the Crossref API with a doi returns the expected JSON object + */ + @Test + void testXrefLookup() { + String realDoi = "10.4137/cmc.s38446"; + JsonObject blob = underTest.retrieveMetadata(realDoi, xrefService); + //these results will differ by a timestamp - but a good check is that they return the same journal objects + JsonObject object = JsonTestObjectsUtil.xrefTestJsonObject(); + + assertNotNull(blob.getJsonObject("message").getJsonArray("ISSN")); + assertEquals(blob.getJsonObject("message").getJsonArray("ISSN"), + object.getJsonObject("message").getJsonArray("ISSN")); + } + + /** + * test that hitting the Unpaywall API with a doi returns the expected JSON object + */ + @Test + void testUnpaywallLookup() { + String realDoi = "10.4137/cmc.s38446"; + JsonObject blob = underTest.retrieveMetadata(realDoi, unpaywallService); + + JsonObject object = JsonTestObjectsUtil.unpaywallTestJsonObject(); + + assertNotNull(blob.getJsonString("doi")); + assertEquals(blob.getJsonString("doi"), object.getJsonString("doi")); + + } + + /** + * Test that sending bad doi to Xref results in an error. + */ + @Test + void testBadXrefDoiLookup() { + String badDoi = "10.1212/abc.DEF"; + JsonObject blob = underTest.retrieveMetadata(badDoi, xrefService); + assertTrue(blob.containsKey("error")); + assertEquals(404, blob.getInt(ExternalDoiServiceConnector.HTTP_STATUS_CODE)); + } + + /** + * Test that sending a bad doi to Unpaywall results in an error. + */ + @Test + void testBadUnpaywallDoiLookup() { + String badDoi = "10.1212/abc.DEF"; + JsonObject blob = underTest.retrieveMetadata(badDoi, unpaywallService); + assertTrue(blob.containsKey("error")); + assertEquals(404, blob.getInt(ExternalDoiServiceConnector.HTTP_STATUS_CODE)); + } + +} + diff --git a/pass-core-doi-service/src/test/java/org/eclipse/pass/doi/service/ExternalDoiServiceConnectorTest.java b/pass-core-doi-service/src/test/java/org/eclipse/pass/doi/service/ExternalDoiServiceConnectorTest.java index 8334be1a..45e131c8 100644 --- a/pass-core-doi-service/src/test/java/org/eclipse/pass/doi/service/ExternalDoiServiceConnectorTest.java +++ b/pass-core-doi-service/src/test/java/org/eclipse/pass/doi/service/ExternalDoiServiceConnectorTest.java @@ -1,94 +1,109 @@ -/* - * - * Copyright 2019 Johns Hopkins University - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ package org.eclipse.pass.doi.service; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.io.IOException; +import java.util.Map; import jakarta.json.JsonObject; -import org.junit.jupiter.api.Disabled; +import okhttp3.OkHttpClient; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.SocketPolicy; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInstance; /** - * Unit tests for the connector. This test class is @Disableed in production, and is only used to - * verify behavior against live services when either the connector class or the external APIs - * are changed - * - * @author jrm + * Unit tests that check the behavior of retrieveMetadata by mocking the external service. */ -@Disabled -@TestInstance(TestInstance.Lifecycle.PER_CLASS) public class ExternalDoiServiceConnectorTest { - private final ExternalDoiServiceConnector underTest = new ExternalDoiServiceConnector(); - private final ExternalDoiService xrefService = new XrefDoiService(); - private final ExternalDoiService unpaywallService = new UnpaywallDoiService(); + private ExternalDoiService mockService(String baseUrl) { + return new ExternalDoiService() { + @Override + public JsonObject processObject(JsonObject object) { + return object; + } - /** - * test that hitting the Crossref API with a doi returns the expected JSON object - */ - @Test - public void testXrefLookup() { - String realDoi = "10.4137/cmc.s38446"; - JsonObject blob = underTest.retrieveMetadata(realDoi, xrefService); - //these results will differ by a timestamp - but a good check is that they return the same journal objects - JsonObject object = JsonTestObjectsUtil.xrefTestJsonObject(); - - assertNotNull(blob.getJsonObject("message").getJsonArray("ISSN")); - assertEquals(blob.getJsonObject("message").getJsonArray("ISSN"), - object.getJsonObject("message").getJsonArray("ISSN")); + @Override + public Map parameterMap() { + return Map.of(); + } + + @Override + public String name() { + return "test"; + } + + @Override + public Map headerMap() { + return Map.of(); + } + + @Override + public String baseUrl() { + return baseUrl; + } + }; } - /** - * test that hitting the Unpaywall API with a doi returns the expected JSON object - */ @Test - public void testUnpaywallLookup() { - String realDoi = "10.4137/cmc.s38446"; //"10.1038/nature12373"; - JsonObject blob = underTest.retrieveMetadata(realDoi, unpaywallService); + void testRetrieveMetadataNotFound() throws IOException { + try (MockWebServer server = new MockWebServer()) { + server.enqueue(new MockResponse().setResponseCode(404)); - JsonObject object = JsonTestObjectsUtil.unpaywallTestJsonObject(); + ExternalDoiServiceConnector underTest = new ExternalDoiServiceConnector(new OkHttpClient()); + ExternalDoiService service = mockService(server.url("/").toString()); - assertNotNull(blob.getJsonString("doi")); - assertEquals(blob.getJsonString("doi"), object.getJsonString("doi")); + JsonObject result = underTest.retrieveMetadata("10.123/abc", service); + assertNotNull(result); + assertEquals(404, result.getInt(ExternalDoiServiceConnector.HTTP_STATUS_CODE)); + } } - /** - * test that a bad doi gives the required error message - */ @Test - public void testBadXrefDoiLookup() { - String badDoi = "10.1212/abc.DEF"; - JsonObject blob = underTest.retrieveMetadata(badDoi, xrefService); - assertEquals("Resource not found.", blob.getString("error")); + void testRetrieveMetadataBadIO() throws IOException { + try (MockWebServer server = new MockWebServer()) { + server.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)); + + ExternalDoiServiceConnector underTest = new ExternalDoiServiceConnector(new OkHttpClient()); + ExternalDoiService service = mockService(server.url("/").toString()); + + JsonObject result = underTest.retrieveMetadata("10.123/abc", service); + + assertNull(result); + } } - /** - * test that a bad doi gives the required error message - */ @Test - public void testBadUnpaywallDoiLookup() { - String badDoi = "10.1212/abc.DEF"; - JsonObject blob = underTest.retrieveMetadata(badDoi, unpaywallService); - assertEquals("true",blob.getValue("/error").toString()); - assertEquals("404", blob.getValue("/HTTP_status_code").toString()); + void testRetrieveMetadataBadJson() throws IOException { + try (MockWebServer server = new MockWebServer()) { + server.enqueue(new MockResponse().setBody("This is not JSON")); + + ExternalDoiServiceConnector underTest = new ExternalDoiServiceConnector(new OkHttpClient()); + ExternalDoiService service = mockService(server.url("/").toString()); + + JsonObject result = underTest.retrieveMetadata("10.123/abc", service); + + assertNull(result); + } } -} + @Test + void testRetrieveMetadataSuccess() throws IOException { + try (MockWebServer server = new MockWebServer()) { + String json = "{\"foo\":\"bar\"}"; + server.enqueue(new MockResponse().setBody(json)); + ExternalDoiServiceConnector underTest = new ExternalDoiServiceConnector(new OkHttpClient()); + ExternalDoiService service = mockService(server.url("/").toString()); + + JsonObject result = underTest.retrieveMetadata("10.4137/cmc.s38446", service); + + assertNotNull(result); + assertEquals("bar", result.getString("foo")); + } + } +} diff --git a/pass-core-doi-service/src/test/java/org/eclipse/pass/doi/service/PassDoiServiceControllerTest.java b/pass-core-doi-service/src/test/java/org/eclipse/pass/doi/service/PassDoiServiceControllerTest.java new file mode 100644 index 00000000..3c6c28eb --- /dev/null +++ b/pass-core-doi-service/src/test/java/org/eclipse/pass/doi/service/PassDoiServiceControllerTest.java @@ -0,0 +1,262 @@ +package org.eclipse.pass.doi.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringReader; +import java.io.StringWriter; + +import jakarta.json.Json; +import jakarta.json.JsonObject; +import jakarta.json.JsonReader; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class PassDoiServiceControllerTest { + private PassDoiServiceController controller; + private ElideConnector elideConnector; + private ExternalDoiServiceConnector externalDoiServiceConnector; + private ExternalDoiService xrefDoiService; + private ExternalDoiService unpaywallDoiService; + + @BeforeEach + public void setUp() { + elideConnector = mock(ElideConnector.class); + externalDoiServiceConnector = mock(ExternalDoiServiceConnector.class); + xrefDoiService = mock(ExternalDoiService.class); + unpaywallDoiService = mock(ExternalDoiService.class); + + controller = new PassDoiServiceController( + elideConnector, + externalDoiServiceConnector, + xrefDoiService, + unpaywallDoiService + ); + } + + @Test + public void testGetXrefMetadata_Success() throws IOException { + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + StringWriter stringWriter = new StringWriter(); + PrintWriter writer = new PrintWriter(stringWriter); + + String doi = "10.1234/5678"; + when(request.getParameter("doi")).thenReturn(doi); + when(response.getWriter()).thenReturn(writer); + when(xrefDoiService.verify(doi)).thenReturn(doi); + + JsonObject xrefJson = Json.createObjectBuilder().add("a", "b").build(); + JsonObject processedJson = Json.createObjectBuilder().add("processed", "true").build(); + + when(externalDoiServiceConnector.retrieveMetadata(doi, xrefDoiService)).thenReturn(xrefJson); + when(elideConnector.resolveJournal(xrefJson)).thenReturn("journal-1"); + when(xrefDoiService.processObject(xrefJson)).thenReturn(processedJson); + + controller.getXrefMetadata(request, response); + + verify(response).setStatus(200); + verify(response).setContentType("application/json"); + + JsonObject result = parseJson(stringWriter.toString()); + assertEquals("journal-1", result.getString("journal-id")); + assertEquals(processedJson, result.getJsonObject("crossref")); + } + + @Test + public void testGetXrefMetadata_InvalidDoi() throws IOException { + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + StringWriter stringWriter = new StringWriter(); + PrintWriter writer = new PrintWriter(stringWriter); + + String doi = "invalid-doi"; + when(request.getParameter("doi")).thenReturn(doi); + when(response.getWriter()).thenReturn(writer); + when(xrefDoiService.verify(doi)).thenReturn(null); + + controller.getXrefMetadata(request, response); + + verify(response).setStatus(400); + JsonObject result = parseJson(stringWriter.toString()); + assertEquals("Supplied DOI is not in valid DOI format.", result.getString("error")); + } + + @Test + public void testGetXrefMetadata_ServiceError_NullCheck() throws IOException { + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + StringWriter stringWriter = new StringWriter(); + PrintWriter writer = new PrintWriter(stringWriter); + + String doi = "10.1234/5678"; + when(request.getParameter("doi")).thenReturn(doi); + when(response.getWriter()).thenReturn(writer); + when(xrefDoiService.verify(doi)).thenReturn(doi); + when(xrefDoiService.name()).thenReturn("Crossref"); + + when(externalDoiServiceConnector.retrieveMetadata(doi, xrefDoiService)).thenReturn(null); + + controller.getXrefMetadata(request, response); + + verify(response).setStatus(500); + JsonObject result = parseJson(stringWriter.toString()); + assertEquals("There was an error getting the metadata from Crossref for " + doi, result.getString("error")); + } + + @Test + public void testGetXrefMetadata_ServiceError_404() throws IOException { + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + StringWriter stringWriter = new StringWriter(); + PrintWriter writer = new PrintWriter(stringWriter); + + String doi = "10.1234/5678"; + when(request.getParameter("doi")).thenReturn(doi); + when(response.getWriter()).thenReturn(writer); + when(xrefDoiService.verify(doi)).thenReturn(doi); + when(xrefDoiService.name()).thenReturn("Crossref"); + + JsonObject errorJson = Json.createObjectBuilder() + .add("error", "Not Found") + .add(ExternalDoiServiceConnector.HTTP_STATUS_CODE, 404) + .build(); + + when(externalDoiServiceConnector.retrieveMetadata(doi, xrefDoiService)).thenReturn(errorJson); + + controller.getXrefMetadata(request, response); + + verify(response).setStatus(404); + JsonObject result = parseJson(stringWriter.toString()); + assertEquals("The resource for DOI " + doi + " could not be found on Crossref.", result.getString("error")); + } + + @Test + public void testGetXrefMetadata_NoJournalId() throws IOException { + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + StringWriter stringWriter = new StringWriter(); + PrintWriter writer = new PrintWriter(stringWriter); + + String doi = "10.1234/5678"; + when(request.getParameter("doi")).thenReturn(doi); + when(response.getWriter()).thenReturn(writer); + when(xrefDoiService.verify(doi)).thenReturn(doi); + + JsonObject xrefJson = Json.createObjectBuilder().add("a", "b").build(); + + when(externalDoiServiceConnector.retrieveMetadata(doi, xrefDoiService)).thenReturn(xrefJson); + when(elideConnector.resolveJournal(xrefJson)).thenReturn(null); + + controller.getXrefMetadata(request, response); + + verify(response).setStatus(422); + JsonObject result = parseJson(stringWriter.toString()); + assertEquals("Insufficient information to locate or specify a journal entry.", result.getString("error")); + } + + @Test + public void testGetUnpaywallMetadata_Success() throws IOException { + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + StringWriter stringWriter = new StringWriter(); + PrintWriter writer = new PrintWriter(stringWriter); + + String doi = "10.1234/5678"; + when(request.getParameter("doi")).thenReturn(doi); + when(response.getWriter()).thenReturn(writer); + when(unpaywallDoiService.verify(doi)).thenReturn(doi); + + JsonObject unpaywallJson = Json.createObjectBuilder().add("a", "b").build(); + JsonObject processedJson = Json.createObjectBuilder().add("processed", "true").build(); + + when(externalDoiServiceConnector.retrieveMetadata(doi, unpaywallDoiService)).thenReturn(unpaywallJson); + when(unpaywallDoiService.processObject(unpaywallJson)).thenReturn(processedJson); + + controller.getUnpaywallMetadata(request, response); + + verify(response).setStatus(200); + JsonObject result = parseJson(stringWriter.toString()); + assertEquals(processedJson, result); + } + + @Test + public void testGetUnpaywallMetadata_InvalidDoi() throws IOException { + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + StringWriter stringWriter = new StringWriter(); + PrintWriter writer = new PrintWriter(stringWriter); + + String doi = "invalid-doi"; + when(request.getParameter("doi")).thenReturn(doi); + when(response.getWriter()).thenReturn(writer); + when(unpaywallDoiService.verify(doi)).thenReturn(null); + + controller.getUnpaywallMetadata(request, response); + + verify(response).setStatus(400); + JsonObject result = parseJson(stringWriter.toString()); + assertEquals("Supplied DOI is not in valid DOI format.", result.getString("error")); + } + + @Test + public void testGetUnpaywallMetadata_ServiceError_Null() throws IOException { + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + StringWriter stringWriter = new StringWriter(); + PrintWriter writer = new PrintWriter(stringWriter); + + String doi = "10.1234/5678"; + when(request.getParameter("doi")).thenReturn(doi); + when(response.getWriter()).thenReturn(writer); + when(unpaywallDoiService.verify(doi)).thenReturn(doi); + when(unpaywallDoiService.name()).thenReturn("Unpaywall"); + + when(externalDoiServiceConnector.retrieveMetadata(doi, unpaywallDoiService)).thenReturn(null); + + controller.getUnpaywallMetadata(request, response); + + verify(response).setStatus(500); + JsonObject result = parseJson(stringWriter.toString()); + assertEquals("There was an error getting the metadata from Unpaywall for " + doi, result.getString("error")); + } + + @Test + public void testGetUnpaywallMetadata_ServiceError_WithCode() throws IOException { + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + StringWriter stringWriter = new StringWriter(); + PrintWriter writer = new PrintWriter(stringWriter); + + String doi = "10.1234/5678"; + when(request.getParameter("doi")).thenReturn(doi); + when(response.getWriter()).thenReturn(writer); + when(unpaywallDoiService.verify(doi)).thenReturn(doi); + + JsonObject errorJson = Json.createObjectBuilder() + .add("error", "Some Error") + .add(ExternalDoiServiceConnector.HTTP_STATUS_CODE, 503) + .build(); + + when(externalDoiServiceConnector.retrieveMetadata(doi, unpaywallDoiService)).thenReturn(errorJson); + + controller.getUnpaywallMetadata(request, response); + + verify(response).setStatus(503); + JsonObject result = parseJson(stringWriter.toString()); + assertTrue(result.getString("error").contains("Some Error")); + } + + private JsonObject parseJson(String json) { + try (JsonReader reader = Json.createReader(new StringReader(json))) { + return reader.readObject(); + } + } +} \ No newline at end of file From bc3dac02a5b2a1ba3ca89f1261b9ff4f50fffbe2 Mon Sep 17 00:00:00 2001 From: Mark Patton Date: Wed, 18 Mar 2026 15:16:48 -0400 Subject: [PATCH 2/5] Cleanup PassDoiServiceController constructor Spring dependency injection --- .../pass/doi/service/ElideConnector.java | 2 ++ .../service/ExternalDoiServiceConnector.java | 2 ++ .../doi/service/PassDoiServiceController.java | 30 ++++--------------- .../pass/doi/service/UnpaywallDoiService.java | 2 ++ .../pass/doi/service/XrefDoiService.java | 2 ++ .../ExternalDoiServiceConnectorTest.java | 7 +++-- 6 files changed, 17 insertions(+), 28 deletions(-) diff --git a/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/ElideConnector.java b/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/ElideConnector.java index 9958276f..c715ef0e 100644 --- a/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/ElideConnector.java +++ b/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/ElideConnector.java @@ -37,6 +37,7 @@ import org.eclipse.pass.object.model.Journal; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; /** * This class manages Journal objects related to a journal lookup on Crossref - creating or updating @@ -44,6 +45,7 @@ * * @author jrm */ +@Component public class ElideConnector { private static final Logger LOG = LoggerFactory.getLogger(ElideConnector.class); diff --git a/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/ExternalDoiServiceConnector.java b/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/ExternalDoiServiceConnector.java index 990bb1e9..8ab913ae 100644 --- a/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/ExternalDoiServiceConnector.java +++ b/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/ExternalDoiServiceConnector.java @@ -34,12 +34,14 @@ import okhttp3.Response; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; /** * A class which manages the retrieval of JSON from external DOI services (Unpaywall, Crossref) * * @author jrm */ +@Component public class ExternalDoiServiceConnector { private static final Logger LOG = LoggerFactory.getLogger(ExternalDoiServiceConnector.class); static final String HTTP_STATUS_CODE = "HTTP_STATUS_CODE"; diff --git a/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/PassDoiServiceController.java b/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/PassDoiServiceController.java index 4e22c902..ae2e496a 100644 --- a/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/PassDoiServiceController.java +++ b/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/PassDoiServiceController.java @@ -19,14 +19,13 @@ import java.io.IOException; import java.io.Writer; -import com.yahoo.elide.RefreshableElide; import jakarta.json.Json; import jakarta.json.JsonObject; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @@ -44,29 +43,10 @@ public class PassDoiServiceController { private final ExternalDoiService xrefDoiService; private final ExternalDoiService unpaywallDoiService; - /** - * @param refreshableElide the RefreshableElide - */ - @Autowired - public PassDoiServiceController(RefreshableElide refreshableElide) { - this.elideConnector = new ElideConnector(refreshableElide); - this.externalDoiServiceConnector = new ExternalDoiServiceConnector(); - this.xrefDoiService = new XrefDoiService(); - this.unpaywallDoiService = new UnpaywallDoiService(); - } - - /** - * Constructor for testing, allows injection of mock connectors and services. - * - * @param elideConnector - * @param externalDoiServiceConnector - * @param xrefDoiService - * @param unpaywallDoiService - */ - PassDoiServiceController(ElideConnector elideConnector, - ExternalDoiServiceConnector externalDoiServiceConnector, - ExternalDoiService xrefDoiService, - ExternalDoiService unpaywallDoiService) { + public PassDoiServiceController(ElideConnector elideConnector, + ExternalDoiServiceConnector externalDoiServiceConnector, + @Qualifier("xrefDoiService") ExternalDoiService xrefDoiService, + @Qualifier("unpaywallDoiService") ExternalDoiService unpaywallDoiService) { this.elideConnector = elideConnector; this.externalDoiServiceConnector = externalDoiServiceConnector; this.xrefDoiService = xrefDoiService; diff --git a/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/UnpaywallDoiService.java b/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/UnpaywallDoiService.java index 23d4c663..e515f9bf 100644 --- a/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/UnpaywallDoiService.java +++ b/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/UnpaywallDoiService.java @@ -27,12 +27,14 @@ import jakarta.json.JsonArrayBuilder; import jakarta.json.JsonObject; import jakarta.json.JsonValue; +import org.springframework.stereotype.Service; /** * The UnpaywallDoiService class is an implementation of the ExternalDoiService abstract class to interface with * the Unpaywall API. The Unpaywall API is a RESTful API that returns JSON metadata for a given DOI. More information * about the Unpaywall API can be found here: Unpaywall API */ +@Service() public class UnpaywallDoiService extends ExternalDoiService { String UNPAYWALL_BASEURI = "https://api.unpaywall.org/v2/"; diff --git a/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/XrefDoiService.java b/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/XrefDoiService.java index 752a874d..2b8db38d 100644 --- a/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/XrefDoiService.java +++ b/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/XrefDoiService.java @@ -33,6 +33,7 @@ import jakarta.json.JsonObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; /** * The XrefDoiService class is an implementation of the ExternalDoiService abstract class to interface with the Crossref @@ -44,6 +45,7 @@ * address. The default email address used by is pass@jhu.edu and can be overridden by setting the environment variable * PASS_DOI_SERVICE_MAILTO */ +@Service() public class XrefDoiService extends ExternalDoiService { private final static String XREF_BASEURI = "https://api.crossref.org/v1/works/"; private final static Logger LOG = LoggerFactory.getLogger(XrefDoiService.class); diff --git a/pass-core-doi-service/src/test/java/org/eclipse/pass/doi/service/ExternalDoiServiceConnectorTest.java b/pass-core-doi-service/src/test/java/org/eclipse/pass/doi/service/ExternalDoiServiceConnectorTest.java index 45e131c8..60be5d85 100644 --- a/pass-core-doi-service/src/test/java/org/eclipse/pass/doi/service/ExternalDoiServiceConnectorTest.java +++ b/pass-core-doi-service/src/test/java/org/eclipse/pass/doi/service/ExternalDoiServiceConnectorTest.java @@ -18,6 +18,7 @@ * Unit tests that check the behavior of retrieveMetadata by mocking the external service. */ public class ExternalDoiServiceConnectorTest { + private final OkHttpClient okhttpClient = new OkHttpClient(); private ExternalDoiService mockService(String baseUrl) { return new ExternalDoiService() { @@ -53,7 +54,7 @@ void testRetrieveMetadataNotFound() throws IOException { try (MockWebServer server = new MockWebServer()) { server.enqueue(new MockResponse().setResponseCode(404)); - ExternalDoiServiceConnector underTest = new ExternalDoiServiceConnector(new OkHttpClient()); + ExternalDoiServiceConnector underTest = new ExternalDoiServiceConnector(okhttpClient); ExternalDoiService service = mockService(server.url("/").toString()); JsonObject result = underTest.retrieveMetadata("10.123/abc", service); @@ -68,7 +69,7 @@ void testRetrieveMetadataBadIO() throws IOException { try (MockWebServer server = new MockWebServer()) { server.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)); - ExternalDoiServiceConnector underTest = new ExternalDoiServiceConnector(new OkHttpClient()); + ExternalDoiServiceConnector underTest = new ExternalDoiServiceConnector(okhttpClient); ExternalDoiService service = mockService(server.url("/").toString()); JsonObject result = underTest.retrieveMetadata("10.123/abc", service); @@ -82,7 +83,7 @@ void testRetrieveMetadataBadJson() throws IOException { try (MockWebServer server = new MockWebServer()) { server.enqueue(new MockResponse().setBody("This is not JSON")); - ExternalDoiServiceConnector underTest = new ExternalDoiServiceConnector(new OkHttpClient()); + ExternalDoiServiceConnector underTest = new ExternalDoiServiceConnector(okhttpClient); ExternalDoiService service = mockService(server.url("/").toString()); JsonObject result = underTest.retrieveMetadata("10.123/abc", service); From 13ad19c5bb5751500b284be347dabd128cdf3662 Mon Sep 17 00:00:00 2001 From: Mark Patton Date: Thu, 19 Mar 2026 11:05:49 -0400 Subject: [PATCH 3/5] Cleanups of pass-core-doi-service tests --- .../pass/doi/service/UnpaywallDoiService.java | 2 +- .../pass/doi/service/XrefDoiService.java | 2 +- ...nalDoiServiceConnectorIntegrationTest.java | 2 +- .../ExternalDoiServiceConnectorTest.java | 23 ++++++++++++++----- .../service/PassDoiServiceControllerTest.java | 23 +++++++++++++++++++ .../pass/doi/service/DoiServiceTest.java | 6 +++++ 6 files changed, 49 insertions(+), 9 deletions(-) diff --git a/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/UnpaywallDoiService.java b/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/UnpaywallDoiService.java index e515f9bf..dda7a66e 100644 --- a/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/UnpaywallDoiService.java +++ b/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/UnpaywallDoiService.java @@ -34,7 +34,7 @@ * the Unpaywall API. The Unpaywall API is a RESTful API that returns JSON metadata for a given DOI. More information * about the Unpaywall API can be found here: Unpaywall API */ -@Service() +@Service public class UnpaywallDoiService extends ExternalDoiService { String UNPAYWALL_BASEURI = "https://api.unpaywall.org/v2/"; diff --git a/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/XrefDoiService.java b/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/XrefDoiService.java index 2b8db38d..101bfa59 100644 --- a/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/XrefDoiService.java +++ b/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/XrefDoiService.java @@ -45,7 +45,7 @@ * address. The default email address used by is pass@jhu.edu and can be overridden by setting the environment variable * PASS_DOI_SERVICE_MAILTO */ -@Service() +@Service public class XrefDoiService extends ExternalDoiService { private final static String XREF_BASEURI = "https://api.crossref.org/v1/works/"; private final static Logger LOG = LoggerFactory.getLogger(XrefDoiService.class); diff --git a/pass-core-doi-service/src/test/java/org/eclipse/pass/doi/service/ExternalDoiServiceConnectorIntegrationTest.java b/pass-core-doi-service/src/test/java/org/eclipse/pass/doi/service/ExternalDoiServiceConnectorIntegrationTest.java index 14446649..cea0e971 100644 --- a/pass-core-doi-service/src/test/java/org/eclipse/pass/doi/service/ExternalDoiServiceConnectorIntegrationTest.java +++ b/pass-core-doi-service/src/test/java/org/eclipse/pass/doi/service/ExternalDoiServiceConnectorIntegrationTest.java @@ -26,7 +26,7 @@ import org.junit.jupiter.api.TestInstance; /** - * Unit tests for the connector. This test class is @Disableed in production, and is only used to + * Integration test for the connector. This test class is @Disableed in production, and is only used to * verify behavior against live services when either the connector class or the external APIs * are changed * diff --git a/pass-core-doi-service/src/test/java/org/eclipse/pass/doi/service/ExternalDoiServiceConnectorTest.java b/pass-core-doi-service/src/test/java/org/eclipse/pass/doi/service/ExternalDoiServiceConnectorTest.java index 60be5d85..68423e4e 100644 --- a/pass-core-doi-service/src/test/java/org/eclipse/pass/doi/service/ExternalDoiServiceConnectorTest.java +++ b/pass-core-doi-service/src/test/java/org/eclipse/pass/doi/service/ExternalDoiServiceConnectorTest.java @@ -1,3 +1,19 @@ +/* + * + * Copyright 2025 Johns Hopkins University + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.eclipse.pass.doi.service; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -8,7 +24,6 @@ import java.util.Map; import jakarta.json.JsonObject; -import okhttp3.OkHttpClient; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.SocketPolicy; @@ -18,7 +33,7 @@ * Unit tests that check the behavior of retrieveMetadata by mocking the external service. */ public class ExternalDoiServiceConnectorTest { - private final OkHttpClient okhttpClient = new OkHttpClient(); + private ExternalDoiServiceConnector underTest = new ExternalDoiServiceConnector(); private ExternalDoiService mockService(String baseUrl) { return new ExternalDoiService() { @@ -54,7 +69,6 @@ void testRetrieveMetadataNotFound() throws IOException { try (MockWebServer server = new MockWebServer()) { server.enqueue(new MockResponse().setResponseCode(404)); - ExternalDoiServiceConnector underTest = new ExternalDoiServiceConnector(okhttpClient); ExternalDoiService service = mockService(server.url("/").toString()); JsonObject result = underTest.retrieveMetadata("10.123/abc", service); @@ -69,7 +83,6 @@ void testRetrieveMetadataBadIO() throws IOException { try (MockWebServer server = new MockWebServer()) { server.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)); - ExternalDoiServiceConnector underTest = new ExternalDoiServiceConnector(okhttpClient); ExternalDoiService service = mockService(server.url("/").toString()); JsonObject result = underTest.retrieveMetadata("10.123/abc", service); @@ -83,7 +96,6 @@ void testRetrieveMetadataBadJson() throws IOException { try (MockWebServer server = new MockWebServer()) { server.enqueue(new MockResponse().setBody("This is not JSON")); - ExternalDoiServiceConnector underTest = new ExternalDoiServiceConnector(okhttpClient); ExternalDoiService service = mockService(server.url("/").toString()); JsonObject result = underTest.retrieveMetadata("10.123/abc", service); @@ -98,7 +110,6 @@ void testRetrieveMetadataSuccess() throws IOException { String json = "{\"foo\":\"bar\"}"; server.enqueue(new MockResponse().setBody(json)); - ExternalDoiServiceConnector underTest = new ExternalDoiServiceConnector(new OkHttpClient()); ExternalDoiService service = mockService(server.url("/").toString()); JsonObject result = underTest.retrieveMetadata("10.4137/cmc.s38446", service); diff --git a/pass-core-doi-service/src/test/java/org/eclipse/pass/doi/service/PassDoiServiceControllerTest.java b/pass-core-doi-service/src/test/java/org/eclipse/pass/doi/service/PassDoiServiceControllerTest.java index 3c6c28eb..075b4448 100644 --- a/pass-core-doi-service/src/test/java/org/eclipse/pass/doi/service/PassDoiServiceControllerTest.java +++ b/pass-core-doi-service/src/test/java/org/eclipse/pass/doi/service/PassDoiServiceControllerTest.java @@ -1,3 +1,19 @@ +/* + * + * Copyright 2025 Johns Hopkins University + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.eclipse.pass.doi.service; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -19,6 +35,13 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +/** + * These unit tests check how the controller handles success and error conditions from the external + * Crossref and Unpaywall services by mocking those services. + * + * There is an integration test in pass-core-main called DoiServiceTest that actually hits the live + * external services. + */ public class PassDoiServiceControllerTest { private PassDoiServiceController controller; private ElideConnector elideConnector; diff --git a/pass-core-main/src/test/java/org/eclipse/pass/doi/service/DoiServiceTest.java b/pass-core-main/src/test/java/org/eclipse/pass/doi/service/DoiServiceTest.java index 12f684e8..13009706 100644 --- a/pass-core-main/src/test/java/org/eclipse/pass/doi/service/DoiServiceTest.java +++ b/pass-core-main/src/test/java/org/eclipse/pass/doi/service/DoiServiceTest.java @@ -46,6 +46,12 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +/** + * This integration test of the DOI API hits the live CrossRef and Unpaywall services. + * + * There is a unit test in pass-core-doi-service called PassDoiServiceControllerTest which checks + * various failure conditions around integrating with the external services which are not tested here. + */ class DoiServiceTest extends SimpleIntegrationTest { private static final String CREDENTIALS = Credentials.basic(BACKEND_USER, BACKEND_PASSWORD); From 8e3b490de9ec0c42efb5720f19f3a9e31dd0f2d3 Mon Sep 17 00:00:00 2001 From: Mark Patton Date: Thu, 19 Mar 2026 13:54:58 -0400 Subject: [PATCH 4/5] Cleanup PassDoiServiceControllerTest for Sonarqube --- .../service/PassDoiServiceControllerTest.java | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/pass-core-doi-service/src/test/java/org/eclipse/pass/doi/service/PassDoiServiceControllerTest.java b/pass-core-doi-service/src/test/java/org/eclipse/pass/doi/service/PassDoiServiceControllerTest.java index 075b4448..4bd587f2 100644 --- a/pass-core-doi-service/src/test/java/org/eclipse/pass/doi/service/PassDoiServiceControllerTest.java +++ b/pass-core-doi-service/src/test/java/org/eclipse/pass/doi/service/PassDoiServiceControllerTest.java @@ -42,7 +42,7 @@ * There is an integration test in pass-core-main called DoiServiceTest that actually hits the live * external services. */ -public class PassDoiServiceControllerTest { +class PassDoiServiceControllerTest { private PassDoiServiceController controller; private ElideConnector elideConnector; private ExternalDoiServiceConnector externalDoiServiceConnector; @@ -65,7 +65,7 @@ public void setUp() { } @Test - public void testGetXrefMetadata_Success() throws IOException { + void testGetXrefMetadata_Success() throws IOException { HttpServletRequest request = mock(HttpServletRequest.class); HttpServletResponse response = mock(HttpServletResponse.class); StringWriter stringWriter = new StringWriter(); @@ -94,7 +94,7 @@ public void testGetXrefMetadata_Success() throws IOException { } @Test - public void testGetXrefMetadata_InvalidDoi() throws IOException { + void testGetXrefMetadata_InvalidDoi() throws IOException { HttpServletRequest request = mock(HttpServletRequest.class); HttpServletResponse response = mock(HttpServletResponse.class); StringWriter stringWriter = new StringWriter(); @@ -113,7 +113,7 @@ public void testGetXrefMetadata_InvalidDoi() throws IOException { } @Test - public void testGetXrefMetadata_ServiceError_NullCheck() throws IOException { + void testGetXrefMetadata_ServiceError_NullCheck() throws IOException { HttpServletRequest request = mock(HttpServletRequest.class); HttpServletResponse response = mock(HttpServletResponse.class); StringWriter stringWriter = new StringWriter(); @@ -135,7 +135,7 @@ public void testGetXrefMetadata_ServiceError_NullCheck() throws IOException { } @Test - public void testGetXrefMetadata_ServiceError_404() throws IOException { + void testGetXrefMetadata_ServiceError_404() throws IOException { HttpServletRequest request = mock(HttpServletRequest.class); HttpServletResponse response = mock(HttpServletResponse.class); StringWriter stringWriter = new StringWriter(); @@ -162,7 +162,7 @@ public void testGetXrefMetadata_ServiceError_404() throws IOException { } @Test - public void testGetXrefMetadata_NoJournalId() throws IOException { + void testGetXrefMetadata_NoJournalId() throws IOException { HttpServletRequest request = mock(HttpServletRequest.class); HttpServletResponse response = mock(HttpServletResponse.class); StringWriter stringWriter = new StringWriter(); @@ -186,7 +186,7 @@ public void testGetXrefMetadata_NoJournalId() throws IOException { } @Test - public void testGetUnpaywallMetadata_Success() throws IOException { + void testGetUnpaywallMetadata_Success() throws IOException { HttpServletRequest request = mock(HttpServletRequest.class); HttpServletResponse response = mock(HttpServletResponse.class); StringWriter stringWriter = new StringWriter(); @@ -211,7 +211,7 @@ public void testGetUnpaywallMetadata_Success() throws IOException { } @Test - public void testGetUnpaywallMetadata_InvalidDoi() throws IOException { + void testGetUnpaywallMetadata_InvalidDoi() throws IOException { HttpServletRequest request = mock(HttpServletRequest.class); HttpServletResponse response = mock(HttpServletResponse.class); StringWriter stringWriter = new StringWriter(); @@ -230,7 +230,7 @@ public void testGetUnpaywallMetadata_InvalidDoi() throws IOException { } @Test - public void testGetUnpaywallMetadata_ServiceError_Null() throws IOException { + void testGetUnpaywallMetadata_ServiceError_Null() throws IOException { HttpServletRequest request = mock(HttpServletRequest.class); HttpServletResponse response = mock(HttpServletResponse.class); StringWriter stringWriter = new StringWriter(); @@ -252,7 +252,7 @@ public void testGetUnpaywallMetadata_ServiceError_Null() throws IOException { } @Test - public void testGetUnpaywallMetadata_ServiceError_WithCode() throws IOException { + void testGetUnpaywallMetadata_ServiceError_WithCode() throws IOException { HttpServletRequest request = mock(HttpServletRequest.class); HttpServletResponse response = mock(HttpServletResponse.class); StringWriter stringWriter = new StringWriter(); From 6a9498bc93e99837307655848558fd1aa2c801e7 Mon Sep 17 00:00:00 2001 From: Mark Patton Date: Fri, 20 Mar 2026 13:38:53 -0400 Subject: [PATCH 5/5] Remove unused constructor from ExternalDoiServiceConnector --- .../eclipse/pass/doi/service/ExternalDoiServiceConnector.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/ExternalDoiServiceConnector.java b/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/ExternalDoiServiceConnector.java index 8ab913ae..3779cf24 100644 --- a/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/ExternalDoiServiceConnector.java +++ b/pass-core-doi-service/src/main/java/org/eclipse/pass/doi/service/ExternalDoiServiceConnector.java @@ -48,10 +48,6 @@ public class ExternalDoiServiceConnector { private final OkHttpClient client; - ExternalDoiServiceConnector(OkHttpClient client) { - this.client = client; - } - ExternalDoiServiceConnector() { OkHttpClient.Builder builder = new OkHttpClient.Builder(); builder.connectTimeout(30, SECONDS);