From e9267111570de36cbe377bf1520c213542a6319e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Wed, 6 Apr 2022 14:30:30 +0200 Subject: [PATCH] Use i18n for cli exception (#1989) For scm exceptions, use the translations from plugins.json in the cli rest endpoint. --- .../sonia/scm/cli/CliExceptionHandler.java | 80 ++++- .../scm/cli/CliExceptionHandlerFactory.java | 43 +++ .../main/java/sonia/scm/cli/CliProcessor.java | 6 +- .../java/sonia/scm/i18n/I18nCollector.java | 108 +++++++ .../java/sonia/scm/web/i18n/I18nServlet.java | 84 +---- .../resources/sonia/scm/cli/i18n.properties | 2 + .../sonia/scm/cli/i18n_de.properties | 2 + .../scm/cli/CliExceptionHandlerTest.java | 203 +++++++++++++ .../java/sonia/scm/cli/CliProcessorTest.java | 145 +++++---- .../sonia/scm/i18n/I18nCollectorTest.java | 225 ++++++++++++++ .../sonia/scm/web/i18n/I18nServletTest.java | 286 +++--------------- 11 files changed, 796 insertions(+), 388 deletions(-) create mode 100644 scm-webapp/src/main/java/sonia/scm/cli/CliExceptionHandlerFactory.java create mode 100644 scm-webapp/src/main/java/sonia/scm/i18n/I18nCollector.java create mode 100644 scm-webapp/src/test/java/sonia/scm/cli/CliExceptionHandlerTest.java create mode 100644 scm-webapp/src/test/java/sonia/scm/i18n/I18nCollectorTest.java diff --git a/scm-webapp/src/main/java/sonia/scm/cli/CliExceptionHandler.java b/scm-webapp/src/main/java/sonia/scm/cli/CliExceptionHandler.java index c8afc03c34..3af75af9dd 100644 --- a/scm-webapp/src/main/java/sonia/scm/cli/CliExceptionHandler.java +++ b/scm-webapp/src/main/java/sonia/scm/cli/CliExceptionHandler.java @@ -24,13 +24,31 @@ package sonia.scm.cli; +import com.fasterxml.jackson.databind.JsonNode; import picocli.CommandLine; import sonia.scm.ExceptionWithContext; +import sonia.scm.TransactionId; +import sonia.scm.i18n.I18nCollector; +import java.io.IOException; import java.io.PrintWriter; +import java.util.Locale; +import java.util.Optional; +import java.util.ResourceBundle; + +import static java.util.Optional.empty; +import static java.util.Optional.of; public class CliExceptionHandler implements CommandLine.IExecutionExceptionHandler { + private final I18nCollector i18nCollector; + private final String languageCode; + + CliExceptionHandler(I18nCollector i18nCollector, String languageCode) { + this.i18nCollector = i18nCollector; + this.languageCode = languageCode; + } + @Override public int handleExecutionException(Exception ex, CommandLine commandLine, CommandLine.ParseResult parseResult) { if (ex instanceof CliExitException) { @@ -38,15 +56,57 @@ public class CliExceptionHandler implements CommandLine.IExecutionExceptionHandl } PrintWriter stdErr = commandLine.getErr(); - stdErr.print("Execution error"); - if (ex instanceof ExceptionWithContext) { - String code = ((ExceptionWithContext) ex).getCode(); - stdErr.print(" " + code); - } - stdErr.print(": "); - stdErr.println(ex.getMessage()); - stdErr.println(); - commandLine.usage(stdErr); - return ExitCode.SERVER_ERROR; + stdErr.print(getMessage("errorExecutionFailed")); + stdErr.print(": "); + String message = ex.getMessage(); + if (ex instanceof ExceptionWithContext) { + String code = ((ExceptionWithContext) ex).getCode(); + message = getMessageFromI18nBundle(code).orElse(message); + } + stdErr.println(message); + TransactionId.get().ifPresent(transactionId -> stdErr.println(getMessage("transactionId") + ": " + transactionId)); + stdErr.println(); + commandLine.usage(stdErr); + return ExitCode.SERVER_ERROR; + } + + private String getMessage(String key) { + return ResourceBundle + .getBundle("sonia.scm.cli.i18n", new Locale(languageCode)) + .getString(key); + } + + private Optional getMessageFromI18nBundle(String code) { + Optional translatedMessage = getMessageFromI18nBundle(code, languageCode); + if (translatedMessage.isPresent()) { + return translatedMessage; + } else { + return getMessageFromI18nBundle(code, "en"); + } + } + + private Optional getMessageFromI18nBundle(String code, String languageCode) { + Optional jsonNode; + try { + jsonNode = i18nCollector.findJson(languageCode); + } catch (IOException e) { + return empty(); + } + if (!jsonNode.isPresent()) { + return empty(); + } + JsonNode errorsNode = jsonNode.get().get("errors"); + if (errorsNode == null) { + return empty(); + } + JsonNode codeNode = errorsNode.get(code); + if (codeNode == null) { + return empty(); + } + JsonNode descriptionNode = codeNode.get("description"); + if (descriptionNode == null) { + return empty(); + } + return of(descriptionNode.asText()); } } diff --git a/scm-webapp/src/main/java/sonia/scm/cli/CliExceptionHandlerFactory.java b/scm-webapp/src/main/java/sonia/scm/cli/CliExceptionHandlerFactory.java new file mode 100644 index 0000000000..d0897f23f1 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/cli/CliExceptionHandlerFactory.java @@ -0,0 +1,43 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.cli; + +import sonia.scm.i18n.I18nCollector; + +import javax.inject.Inject; + +class CliExceptionHandlerFactory { + + private final I18nCollector i18nCollector; + + @Inject + public CliExceptionHandlerFactory(I18nCollector i18nCollector) { + this.i18nCollector = i18nCollector; + } + + CliExceptionHandler createExceptionHandler(String languageCode) { + return new CliExceptionHandler(i18nCollector, languageCode); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/cli/CliProcessor.java b/scm-webapp/src/main/java/sonia/scm/cli/CliProcessor.java index 4807a02c2a..b379c06364 100644 --- a/scm-webapp/src/main/java/sonia/scm/cli/CliProcessor.java +++ b/scm-webapp/src/main/java/sonia/scm/cli/CliProcessor.java @@ -36,11 +36,13 @@ public class CliProcessor { private final CommandRegistry registry; private final Injector injector; private final CommandLine.Model.CommandSpec usageHelp; + private final CliExceptionHandlerFactory exceptionHandlerFactory; @Inject - public CliProcessor(CommandRegistry registry, Injector injector) { + public CliProcessor(CommandRegistry registry, Injector injector, CliExceptionHandlerFactory exceptionHandlerFactory) { this.registry = registry; this.injector = injector; + this.exceptionHandlerFactory = exceptionHandlerFactory; this.usageHelp = new CommandLine(HelpMixin.class).getCommandSpec(); } @@ -56,7 +58,7 @@ public class CliProcessor { cli.addSubcommand(AutoComplete.GenerateCompletion.class); cli.setErr(context.getStderr()); cli.setOut(context.getStdout()); - cli.setExecutionExceptionHandler(new CliExceptionHandler()); + cli.setExecutionExceptionHandler(exceptionHandlerFactory.createExceptionHandler(context.getLocale().getLanguage())); return cli.execute(args); } diff --git a/scm-webapp/src/main/java/sonia/scm/i18n/I18nCollector.java b/scm-webapp/src/main/java/sonia/scm/i18n/I18nCollector.java new file mode 100644 index 0000000000..e9b507d5aa --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/i18n/I18nCollector.java @@ -0,0 +1,108 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.i18n; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.legman.Subscribe; +import com.google.common.annotations.VisibleForTesting; +import lombok.extern.slf4j.Slf4j; +import sonia.scm.SCMContextProvider; +import sonia.scm.Stage; +import sonia.scm.cache.Cache; +import sonia.scm.cache.CacheManager; +import sonia.scm.lifecycle.RestartEvent; +import sonia.scm.plugin.PluginLoader; +import sonia.scm.util.JsonMerger; + +import javax.inject.Inject; +import java.io.IOException; +import java.net.URL; +import java.util.Enumeration; +import java.util.Optional; + +@Slf4j +public class I18nCollector { + + public static final String CACHE_NAME = "sonia.cache.plugins.translations"; + + private final SCMContextProvider context; + private final ClassLoader classLoader; + private final JsonMerger jsonMerger; + + private final Cache cache; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Inject + public I18nCollector(SCMContextProvider context, PluginLoader pluginLoader, JsonMerger jsonMerger, CacheManager cacheManager) { + this.cache = cacheManager.getCache(CACHE_NAME); + this.context = context; + this.classLoader = pluginLoader.getUberClassLoader(); + this.jsonMerger = jsonMerger; + } + + public Optional findJson(String languageCode) throws IOException { + if (isProductionStage()) { + return findJsonCached(languageCode); + } + return collectJsonFile(languageCode); + } + + private Optional findJsonCached(String languageCode) throws IOException { + JsonNode jsonNode = cache.get(languageCode); + if (jsonNode != null) { + log.debug("return json node from cache for language {}", languageCode); + return Optional.of(jsonNode); + } + + log.debug("collect json for language {}", languageCode); + Optional collected = collectJsonFile(languageCode); + collected.ifPresent(node -> cache.put(languageCode, node)); + return collected; + } + + @VisibleForTesting + protected boolean isProductionStage() { + return context.getStage() == Stage.PRODUCTION; + } + + private Optional collectJsonFile(String languageCode) throws IOException { + String path = String.format("locales/%s/plugins.json", languageCode); + log.debug("Collect plugin translations from path {} for every plugin", path); + JsonNode mergedJsonNode = null; + Enumeration resources = classLoader.getResources(path); + while (resources.hasMoreElements()) { + URL url = resources.nextElement(); + JsonNode jsonNode = objectMapper.readTree(url); + if (mergedJsonNode != null) { + mergedJsonNode = jsonMerger.fromJson(mergedJsonNode).mergeWithJson(jsonNode).toJsonNode(); + } else { + mergedJsonNode = jsonNode; + } + } + + return Optional.ofNullable(mergedJsonNode); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/web/i18n/I18nServlet.java b/scm-webapp/src/main/java/sonia/scm/web/i18n/I18nServlet.java index 2d68dc53b9..ceb1611b35 100644 --- a/scm-webapp/src/main/java/sonia/scm/web/i18n/I18nServlet.java +++ b/scm-webapp/src/main/java/sonia/scm/web/i18n/I18nServlet.java @@ -27,19 +27,12 @@ package sonia.scm.web.i18n; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.github.legman.Subscribe; import com.google.common.annotations.VisibleForTesting; import com.google.inject.Singleton; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import sonia.scm.SCMContextProvider; -import sonia.scm.Stage; -import sonia.scm.cache.Cache; -import sonia.scm.cache.CacheManager; import sonia.scm.filter.WebElement; -import sonia.scm.lifecycle.RestartEvent; -import sonia.scm.plugin.PluginLoader; -import sonia.scm.util.JsonMerger; +import sonia.scm.i18n.I18nCollector; import javax.inject.Inject; import javax.servlet.http.HttpServlet; @@ -47,8 +40,6 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.PrintWriter; -import java.net.URL; -import java.util.Enumeration; import java.util.Optional; @@ -63,27 +54,14 @@ public class I18nServlet extends HttpServlet { public static final String PLUGINS_JSON = "plugins.json"; public static final String PATTERN = "/locales/[a-z\\-A-Z]*/" + PLUGINS_JSON; - public static final String CACHE_NAME = "sonia.cache.plugins.translations"; + public static final int POSITION_OF_LANGUAGE_IN_PATH = 2; - private final SCMContextProvider context; - private final ClassLoader classLoader; - private final Cache cache; + private final I18nCollector i18nCollector; private final ObjectMapper objectMapper = new ObjectMapper(); - private final JsonMerger jsonMerger; - @Inject - public I18nServlet(SCMContextProvider context, PluginLoader pluginLoader, CacheManager cacheManager, JsonMerger jsonMerger) { - this.context = context; - this.classLoader = pluginLoader.getUberClassLoader(); - this.cache = cacheManager.getCache(CACHE_NAME); - this.jsonMerger = jsonMerger; - } - - @Subscribe(async = false) - public void handleRestartEvent(RestartEvent event) { - LOG.debug("Clear cache on restart event with reason {}", event.getReason()); - cache.clear(); + public I18nServlet(I18nCollector i18nCollector) { + this.i18nCollector = i18nCollector; } @VisibleForTesting @@ -91,11 +69,12 @@ public class I18nServlet extends HttpServlet { protected void doGet(HttpServletRequest request, HttpServletResponse response) { String path = request.getServletPath(); try { - Optional json = findJson(path); + String languageCode = extractLanguage(path); + Optional json = i18nCollector.findJson(languageCode); if (json.isPresent()) { write(response, json.get()); } else { - LOG.debug("could not find translation at {}", path); + LOG.debug("could not find translation for lanugage {}", languageCode); response.setStatus(HttpServletResponse.SC_NOT_FOUND); } } catch (IOException ex) { @@ -104,6 +83,10 @@ public class I18nServlet extends HttpServlet { } } + private String extractLanguage(String path) { + return path.split("/")[POSITION_OF_LANGUAGE_IN_PATH]; + } + private void write(HttpServletResponse response, JsonNode jsonNode) throws IOException { response.setCharacterEncoding("UTF-8"); response.setContentType("application/json"); @@ -113,47 +96,4 @@ public class I18nServlet extends HttpServlet { objectMapper.writeValue(writer, jsonNode); } } - - public Optional findJson(String path) throws IOException { - if (isProductionStage()) { - return findJsonCached(path); - } - return collectJsonFile(path); - } - - private Optional findJsonCached(String path) throws IOException { - JsonNode jsonNode = cache.get(path); - if (jsonNode != null) { - LOG.debug("return json node from cache for path {}", path); - return Optional.of(jsonNode); - } - - LOG.debug("collect json for path {}", path); - Optional collected = collectJsonFile(path); - collected.ifPresent(node -> cache.put(path, node)); - return collected; - } - - @VisibleForTesting - protected boolean isProductionStage() { - return context.getStage() == Stage.PRODUCTION; - } - - private Optional collectJsonFile(String path) throws IOException { - LOG.debug("Collect plugin translations from path {} for every plugin", path); - JsonNode mergedJsonNode = null; - Enumeration resources = classLoader.getResources(path.replaceFirst("/", "")); - while (resources.hasMoreElements()) { - URL url = resources.nextElement(); - JsonNode jsonNode = objectMapper.readTree(url); - if (mergedJsonNode != null) { - mergedJsonNode = jsonMerger.fromJson(mergedJsonNode).mergeWithJson(jsonNode).toJsonNode(); - } else { - mergedJsonNode = jsonNode; - } - } - - return Optional.ofNullable(mergedJsonNode); - } - } diff --git a/scm-webapp/src/main/resources/sonia/scm/cli/i18n.properties b/scm-webapp/src/main/resources/sonia/scm/cli/i18n.properties index 35b83aaf33..d147dd10f5 100644 --- a/scm-webapp/src/main/resources/sonia/scm/cli/i18n.properties +++ b/scm-webapp/src/main/resources/sonia/scm/cli/i18n.properties @@ -25,6 +25,8 @@ errorCommandFailed= ____________Command failed____________ errorUnknownError= Unknown error occurred. Check your server logs errorLabel= ERROR +errorExecutionFailed = Error +transactionId = Transaction ID repoNamespace = Namespace repoName = Name diff --git a/scm-webapp/src/main/resources/sonia/scm/cli/i18n_de.properties b/scm-webapp/src/main/resources/sonia/scm/cli/i18n_de.properties index 247aa55540..b4c28a8b3c 100644 --- a/scm-webapp/src/main/resources/sonia/scm/cli/i18n_de.properties +++ b/scm-webapp/src/main/resources/sonia/scm/cli/i18n_de.properties @@ -25,6 +25,8 @@ errorCommandFailed= ____________Befehl fehlgeschlagen____________ errorUnknownError= Unbekannter Fehler. Prüfen Sie Ihre Server Logs errorLabel= FEHLER +errorExecutionFailed = Fehler +transactionId = Transaktions-ID repoNamespace = Namespace repoName = Name diff --git a/scm-webapp/src/test/java/sonia/scm/cli/CliExceptionHandlerTest.java b/scm-webapp/src/test/java/sonia/scm/cli/CliExceptionHandlerTest.java new file mode 100644 index 0000000000..9e4857c244 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/cli/CliExceptionHandlerTest.java @@ -0,0 +1,203 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.cli; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import picocli.CommandLine; +import sonia.scm.AlreadyExistsException; +import sonia.scm.ContextEntry; +import sonia.scm.NotFoundException; +import sonia.scm.TransactionId; +import sonia.scm.group.Group; +import sonia.scm.i18n.I18nCollector; +import sonia.scm.store.StoreReadOnlyException; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PrintWriter; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CliExceptionHandlerTest { + + private static final String PLUGIN_BUNDLE_EN = "{" + + " \"errors\": {" + + " \"AGR7UzkhA1\": {" + + " \"displayName\": \"Not found\"," + + " \"description\": \"The requested entity could not be found. It may have been deleted in another session.\"" + + " }," + + " \"3FSIYtBJw1\": {" + + " \"displayName\": \"An entity could not be stored\"" + + " }" + + " }" + + "}"; + + private static final String PLUGIN_BUNDLE_DE = "{" + + " \"errors\": {" + + " \"3FSIYtBJw1\": {" + + " \"displayName\": \"Ein Datensatz konnte nicht gespeichert werden\"," + + " \"description\": \"Ein Datensatz konnte nicht gespeichert werden, da der entsprechende Speicher als schreibgeschützt markiert wurde. Weitere Hinweise finden sich im Log.\"" + + " }" + + " }" + + "}"; + + @Mock + private I18nCollector i18nCollector; + + @Mock + private CommandLine commandLine; + + private CliExceptionHandler handler; + + private final ByteArrayOutputStream errorStream = new ByteArrayOutputStream(); + private final PrintWriter stdErr = new PrintWriter(errorStream); + + @BeforeEach + void initErrorStream() { + when(commandLine.getErr()).thenReturn(stdErr); + } + + @Nested + class EnglishLanguageTest { + + @BeforeEach + void setUpHandler() { + handler = new CliExceptionHandler(i18nCollector, "en"); + } + + @Test + void shouldPrintSimpleException() { + int exitCode = callHandler(new NullPointerException("expected error")); + + assertThat(errorStream.toString()).contains("Error: expected error"); + assertThat(exitCode).isEqualTo(1); + } + + @Test + void shouldPrintTransactionIdIfPresent() { + TransactionId.set("42"); + + callHandler(new NullPointerException("expected error")); + + assertThat(errorStream.toString()).contains("Transaction ID: 42"); + } + + @Test + void shouldUseTranslatedMessageFromExceptionWithContext() throws IOException { + when(i18nCollector.findJson("en")) + .thenReturn(Optional.of(new ObjectMapper().readTree(PLUGIN_BUNDLE_EN))); + + callHandler(NotFoundException.notFound(new ContextEntry.ContextBuilder())); + + assertThat(errorStream.toString()).contains("Error: The requested entity could not be found. It may have been deleted in another session."); + } + + @Test + void shouldUseExceptionMessageForExceptionWithContextWithoutCodeNode() throws IOException { + when(i18nCollector.findJson("en")) + .thenReturn(Optional.of(new ObjectMapper().readTree(PLUGIN_BUNDLE_EN))); + + callHandler(new AlreadyExistsException(new Group("test", "hog"))); + + assertThat(errorStream.toString()).contains("Error: group with id hog already exists"); + } + + @Test + void shouldUseExceptionMessageForExceptionWithContextWithoutDescriptionNode() throws IOException { + when(i18nCollector.findJson("en")) + .thenReturn(Optional.of(new ObjectMapper().readTree(PLUGIN_BUNDLE_EN))); + + callHandler(new StoreReadOnlyException("test")); + + assertThat(errorStream.toString()).contains("Error: Store is read only, could not write location test"); + } + } + + @Nested + class GermanLanguageTest { + + @BeforeEach + void setUpHandler() { + handler = new CliExceptionHandler(i18nCollector, "de"); + } + + @Test + void shouldPrintSimpleException() { + int exitCode = callHandler(new NullPointerException("expected error")); + + assertThat(errorStream.toString()).contains("Fehler: expected error"); + assertThat(exitCode).isEqualTo(1); + } + + + @Test + void shouldPrintTransactionIdIfPresent() { + TransactionId.set("42"); + + callHandler(new NullPointerException("expected error")); + + assertThat(errorStream.toString()).contains("Transaktions-ID: 42"); + } + + @Test + void shouldUseTranslatedMessageFromExceptionWithContext() throws IOException { + when(i18nCollector.findJson("de")) + .thenReturn(Optional.of(new ObjectMapper().readTree(PLUGIN_BUNDLE_DE))); + + callHandler(new StoreReadOnlyException("test")); + + assertThat(errorStream.toString()).contains("Fehler: Ein Datensatz konnte nicht gespeichert werden, da der entsprechende Speicher als schreibgeschützt markiert wurde. Weitere Hinweise finden sich im Log."); + } + + @Test + void shouldUseFallbackExceptionMessageForExceptionWithContextWithoutLocalizedCodeNode() throws IOException { + when(i18nCollector.findJson("en")) + .thenReturn(Optional.of(new ObjectMapper().readTree(PLUGIN_BUNDLE_EN))); + when(i18nCollector.findJson("de")) + .thenReturn(Optional.of(new ObjectMapper().readTree(PLUGIN_BUNDLE_DE))); + + callHandler(NotFoundException.notFound(new ContextEntry.ContextBuilder())); + + assertThat(errorStream.toString()).contains("Fehler: The requested entity could not be found. It may have been deleted in another session."); + } + } + + private int callHandler(Exception ex) { + try { + return handler.handleExecutionException(ex, commandLine, null); + } finally { + stdErr.flush(); + } + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/cli/CliProcessorTest.java b/scm-webapp/src/test/java/sonia/scm/cli/CliProcessorTest.java index 8bf7d749c5..c8f84cc766 100644 --- a/scm-webapp/src/test/java/sonia/scm/cli/CliProcessorTest.java +++ b/scm-webapp/src/test/java/sonia/scm/cli/CliProcessorTest.java @@ -27,6 +27,7 @@ package sonia.scm.cli; import com.google.inject.Guice; import com.google.inject.Injector; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Answers; @@ -53,60 +54,103 @@ class CliProcessorTest { @Mock(answer = Answers.RETURNS_DEEP_STUBS) private CliContext context; + @Mock + private CliExceptionHandlerFactory exceptionHandlerFactory; + @Mock + private CliExceptionHandler exceptionHandler; - @BeforeEach - void setDefaultLocale() { - when(context.getLocale()).thenReturn(Locale.ENGLISH); + @Nested + class ForDefaultLanguageTest { + + @BeforeEach + void setDefaultLocale() { + when(context.getLocale()).thenReturn(Locale.ENGLISH); + when(exceptionHandlerFactory.createExceptionHandler("en")).thenReturn(exceptionHandler); + } + + @Test + void shouldExecutePingCommand() { + when(registry.createCommandTree()).thenReturn(Collections.singleton(new RegisteredCommandNode("ping", PingCommand.class))); + Injector injector = Guice.createInjector(); + CliProcessor cliProcessor = new CliProcessor(registry, injector, exceptionHandlerFactory); + + cliProcessor.execute(context, "ping"); + + verify(context.getStdout()).println("PONG"); + } + + @Test + void shouldExecutePingCommandWithExitCode0() { + when(registry.createCommandTree()).thenReturn(Collections.singleton(new RegisteredCommandNode("ping", PingCommand.class))); + Injector injector = Guice.createInjector(); + CliProcessor cliProcessor = new CliProcessor(registry, injector, exceptionHandlerFactory); + + int exitCode = cliProcessor.execute(context, "ping"); + + assertThat(exitCode).isZero(); + } + + @Test + void shouldPrintCommandOne() { + String result = executeHierarchyCommands("--help"); + + assertThat(result).contains("Commands:\n" + + " one"); + } + + @Test + void shouldPrintCommandTwo() { + String result = executeHierarchyCommands("one", "--help"); + + assertThat(result).contains("Commands:\n" + + " two"); + } + + @Test + void shouldPrintCommandThree() { + String result = executeHierarchyCommands("one", "two", "--help"); + + assertThat(result).contains("Commands:\n" + + " three"); + } } - @Test - void shouldExecutePingCommand() { - when(registry.createCommandTree()).thenReturn(Collections.singleton(new RegisteredCommandNode("ping", PingCommand.class))); - Injector injector = Guice.createInjector(); - CliProcessor cliProcessor = new CliProcessor(registry, injector); + @Nested + class ForAnotherLanguageTest { - cliProcessor.execute(context, "ping"); + @Mock + private CliExceptionHandler germanExceptionHandler; - verify(context.getStdout()).println("PONG"); - } + @BeforeEach + void setUpOtherLanguage() { + when(exceptionHandlerFactory.createExceptionHandler("de")).thenReturn(germanExceptionHandler); + when(context.getLocale()).thenReturn(Locale.GERMAN); + } - @Test - void shouldExecutePingCommandWithExitCode0() { - when(registry.createCommandTree()).thenReturn(Collections.singleton(new RegisteredCommandNode("ping", PingCommand.class))); - Injector injector = Guice.createInjector(); - CliProcessor cliProcessor = new CliProcessor(registry, injector); + @Test + void shouldUseResourceBundleFromAnnotationWithContextLocale() { + String helpForThree = executeHierarchyCommands("one", "two", "three", "--help"); - int exitCode = cliProcessor.execute(context, "ping"); + assertThat(helpForThree).contains("Dies ist meine App."); + } - assertThat(exitCode).isZero(); - } + @Test + void shouldUseDefaultWithoutResourceBundle() { + String helpForTwo = executeHierarchyCommands("one", "two", "--help"); - @Test - void shouldPrintCommandOne() { - String result = executeHierachyCommands("--help"); + assertThat(helpForTwo).contains("Dies ist meine App."); + } - assertThat(result).contains("Commands:\n" + - " one"); - } + @Test + void shouldUseExceptionHandlerForOtherLanguage() { + executeHierarchyCommands("one", "two", "--help"); - @Test - void shouldPrintCommandTwo() { - String result = executeHierachyCommands("one","--help"); - - assertThat(result).contains("Commands:\n" + - " two"); - } - - @Test - void shouldPrintCommandThree() { - String result = executeHierachyCommands("one", "two","--help"); - - assertThat(result).contains("Commands:\n" + - " three"); + verify(exceptionHandlerFactory).createExceptionHandler("de"); + } } @Nonnull - private String executeHierachyCommands(String... args) { + private String executeHierarchyCommands(String... args) { RegisteredCommandNode one = new RegisteredCommandNode("one", RootCommand.class); RegisteredCommandNode two = new RegisteredCommandNode("two", SubCommand.class); RegisteredCommandNode three = new RegisteredCommandNode("three", SubSubCommand.class); @@ -118,31 +162,12 @@ class CliProcessorTest { when(context.getStdout()).thenReturn(new PrintWriter(baos)); Injector injector = Guice.createInjector(); - CliProcessor cliProcessor = new CliProcessor(registry, injector); + CliProcessor cliProcessor = new CliProcessor(registry, injector, exceptionHandlerFactory); cliProcessor.execute(context, args); return baos.toString(); } - - @Test - void shouldUseResourceBundleFromAnnotationWithContextLocale() { - when(context.getLocale()).thenReturn(Locale.GERMAN); - - String helpForThree = executeHierachyCommands("one", "two", "three", "--help"); - - assertThat(helpForThree).contains("Dies ist meine App."); - } - - @Test - void shouldUseDefaultWithoutResourceBundle() { - when(context.getLocale()).thenReturn(Locale.GERMAN); - - String helpForTwo = executeHierachyCommands("one", "two", "--help"); - - assertThat(helpForTwo).contains("Dies ist meine App."); - } - @CommandLine.Command(name = "one") static class RootCommand implements Runnable { diff --git a/scm-webapp/src/test/java/sonia/scm/i18n/I18nCollectorTest.java b/scm-webapp/src/test/java/sonia/scm/i18n/I18nCollectorTest.java new file mode 100644 index 0000000000..4f6c31c5de --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/i18n/I18nCollectorTest.java @@ -0,0 +1,225 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.i18n; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.SCMContextProvider; +import sonia.scm.Stage; +import sonia.scm.cache.Cache; +import sonia.scm.cache.CacheManager; +import sonia.scm.plugin.PluginLoader; +import sonia.scm.util.JsonMerger; + +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class I18nCollectorTest { + + private static final String GIT_PLUGIN_JSON = json( + "{", + "'scm-git-plugin': {", + "'information': {", + "'clone' : 'Clone',", + "'create' : 'Create',", + "'replace' : 'Push'", + "}", + "}", + "}" + ); + + private static final String HG_PLUGIN_JSON = json( + "{", + "'scm-hg-plugin': {", + "'information': {", + "'clone' : 'Clone',", + "'create' : 'Create',", + "'replace' : 'Push'", + "}", + "}", + "}" + ); + + private static final String SVN_PLUGIN_JSON = json( + "{", + "'scm-svn-plugin': {", + "'information': {", + "'checkout' : 'Checkout'", + "}", + "}", + "}" + ); + + private static final String[] ALL_PLUGIN_JSON = new String[]{ + GIT_PLUGIN_JSON, HG_PLUGIN_JSON, SVN_PLUGIN_JSON + }; + + private static String json(String... parts) { + return String.join("\n", parts).replaceAll("'", "\""); + } + + @Mock + private SCMContextProvider context; + + @Mock + private PluginLoader pluginLoader; + + @Mock + private CacheManager cacheManager; + + @Mock + private Cache cache; + + private I18nCollector collector; + + @BeforeEach + void mockCache() { + when(cacheManager.getCache(I18nCollector.CACHE_NAME)).thenReturn(cache); + } + + @Nested + class WithoutResourcesTest { + + @BeforeEach + void mockClassLoader(@TempDir Path directory) throws IOException { + mockUberClassLoader(directory); + createCollector(); + } + + @Test + void shouldReturnEmptyForMissingLanguage() throws IOException { + Optional json = collector.findJson("de"); + assertThat(json).isEmpty(); + } + } + + @Nested + class WithResourcesTest { + + @BeforeEach + void mockClassLoader(@TempDir Path directory) throws IOException { + mockResources(directory); + createCollector(); + } + + @Test + void inDevelopmentStageShouldNotUseCache() throws IOException { + stage(Stage.DEVELOPMENT); + + Optional json = collector.findJson("de"); + + verifyJson(json); + verify(cache, never()).get(any()); + } + + @Test + void shouldGetFromCacheInProductionStage() throws IOException { + stage(Stage.PRODUCTION); + + ObjectMapper mapper = new ObjectMapper(); + JsonNode jsonNode = mapper.readTree(GIT_PLUGIN_JSON); + when(cache.get("de")).thenReturn(jsonNode); + + Optional json = collector.findJson("de"); + assertThat(json).get().isSameAs(jsonNode); + } + + @Test + void shouldStoreToCacheInProductionStage() throws IOException { + stage(Stage.PRODUCTION); + + Optional json = collector.findJson("de"); + + verifyJson(json); + verify(cache).put(eq("de"), any()); + } + } + + private void verifyJson(Optional json) { + assertThat(json).isNotEmpty(); + assertThat(json.get().get("scm-svn-plugin")).isNotNull(); + assertThat(json.get().get("scm-git-plugin")).isNotNull(); + assertThat(json.get().get("scm-hg-plugin")).isNotNull(); + } + + private void createCollector() { + collector = new I18nCollector(context, pluginLoader, new JsonMerger(new ObjectMapper()), cacheManager); + } + + private void mockUberClassLoader(Path... directories) throws MalformedURLException { + mockUberClassLoader(Arrays.asList(directories)); + } + + private void mockUberClassLoader(Collection directories) throws MalformedURLException { + List urls = new ArrayList<>(); + for (Path directory : directories) { + urls.add(directory.toUri().toURL()); + } + ClassLoader bootstrapLoader = ClassLoader.getSystemClassLoader().getParent(); + URLClassLoader classLoader = new URLClassLoader(urls.toArray(new URL[0]), bootstrapLoader); + when(pluginLoader.getUberClassLoader()).thenReturn(classLoader); + } + + private void stage(Stage stage) { + when(context.getStage()).thenReturn(stage); + } + + private void mockResources(Path directory) throws IOException { + List directories = new ArrayList<>(); + for (int i = 0; i < ALL_PLUGIN_JSON.length; i++) { + Path pluginDirectory = directory.resolve("plugin-" + i); + Path file = pluginDirectory.resolve("locales/de/plugins.json"); + Files.createDirectories(file.getParent()); + Files.write(file, ALL_PLUGIN_JSON[i].getBytes(StandardCharsets.UTF_8)); + directories.add(pluginDirectory); + } + mockUberClassLoader(directories); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/web/i18n/I18nServletTest.java b/scm-webapp/src/test/java/sonia/scm/web/i18n/I18nServletTest.java index fcaee56c42..a01c4913e7 100644 --- a/scm-webapp/src/test/java/sonia/scm/web/i18n/I18nServletTest.java +++ b/scm-webapp/src/test/java/sonia/scm/web/i18n/I18nServletTest.java @@ -24,294 +24,94 @@ package sonia.scm.web.i18n; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.github.legman.EventBus; import com.google.common.base.CharMatcher; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.api.io.TempDir; +import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import sonia.scm.SCMContextProvider; -import sonia.scm.Stage; -import sonia.scm.cache.Cache; -import sonia.scm.cache.CacheManager; -import sonia.scm.lifecycle.RestartEventFactory; -import sonia.scm.plugin.PluginLoader; -import sonia.scm.util.JsonMerger; +import sonia.scm.i18n.I18nCollector; -import javax.annotation.Nonnull; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.PrintWriter; -import java.net.MalformedURLException; -import java.net.URI; -import java.net.URL; -import java.net.URLClassLoader; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.List; -import java.util.Objects; +import static java.util.Optional.empty; +import static java.util.Optional.of; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.fail; import static org.mockito.Mockito.any; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class I18nServletTest { - private static final String GIT_PLUGIN_JSON = json( - "{", - "'scm-git-plugin': {", + private static final String GIT_PLUGIN = + json( + "{", + "'scm-git-plugin': {", "'information': {", - "'clone' : 'Clone',", - "'create' : 'Create',", - "'replace' : 'Push'", + "'clone' : 'Clone',", + "'create' : 'Create',", + "'replace' : 'Push'", "}", - "}", - "}" - ); - - private static final String HG_PLUGIN_JSON = json( - "{", - "'scm-hg-plugin': {", - "'information': {", - "'clone' : 'Clone',", - "'create' : 'Create',", - "'replace' : 'Push'", "}", - "}", - "}" - ); - - private static final String SVN_PLUGIN_JSON = json( - "{", - "'scm-svn-plugin': {", - "'information': {", - "'checkout' : 'Checkout'", - "}", - "}", - "}" - ); - - private static final String[] ALL_PLUGIN_JSON = new String[]{ - GIT_PLUGIN_JSON, HG_PLUGIN_JSON, SVN_PLUGIN_JSON - }; + "}" + ); private static String json(String... parts) { return String.join("\n", parts).replaceAll("'", "\""); } @Mock - private SCMContextProvider context; + private I18nCollector collector; + @InjectMocks + private I18nServlet servlet; @Mock - private PluginLoader pluginLoader; - + private HttpServletRequest request; @Mock - private CacheManager cacheManager; + private HttpServletResponse response; - @Mock - private Cache cache; @Test - void shouldNotHaveInvalidPluginsJsonFiles() throws Exception { - URI uri = Objects.requireNonNull(getClass().getClassLoader().getResource("locales/en/plugins.json")).toURI(); + void shouldFailWith404OnMissingResources() throws IOException { + String path = "/locales/de/plugins.json"; + when(request.getServletPath()).thenReturn(path); + when(collector.findJson("de")).thenReturn(empty()); - Path filePath = Paths.get(uri); - Path translationRootPath = filePath.getParent().getParent(); - assertThat(translationRootPath).isDirectoryContaining("glob:**/en"); + servlet.doGet(request, response); - Files - .list(translationRootPath) - .filter(Files::isDirectory) - .map(localePath -> localePath.resolve("plugins.json")) - .forEach(this::validatePluginsJson); + verify(response).setStatus(404); } - private void validatePluginsJson(Path path) { - try { - new ObjectMapper().readTree(path.toFile()); - } catch (IOException e) { - fail("error while parsing translation file " + path, e); - } + @Test + void shouldReturnJson() throws IOException { + String path = "/locales/de/plugins.json"; + when(request.getServletPath()).thenReturn(path); + when(collector.findJson("de")).thenReturn(of(new ObjectMapper().readTree(GIT_PLUGIN))); + + String json = doGetString(servlet, request, response); + + verifyHeaders(response); + assertJson(json); } - @Nested - class WithCacheManager { + private String doGetString(I18nServlet servlet, HttpServletRequest request, HttpServletResponse response) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + PrintWriter writer = new PrintWriter(baos); + when(response.getWriter()).thenReturn(writer); - @BeforeEach - void init() { - when(cacheManager.getCache(I18nServlet.CACHE_NAME)).thenReturn(cache); - } - - @Test - void shouldFailWith404OnMissingResources(@TempDir Path directory) throws IOException { - String path = "/locales/de/plugins.json"; - HttpServletRequest request = mock(HttpServletRequest.class); - when(request.getServletPath()).thenReturn(path); - HttpServletResponse response = mock(HttpServletResponse.class); - - mockUberClassLoader(directory); - - createServlet().doGet(request, response); - verify(response).setStatus(404); - } - - private void mockUberClassLoader(Path... directories) throws MalformedURLException { - mockUberClassLoader(Arrays.asList(directories)); - } - - private void mockUberClassLoader(Collection directories) throws MalformedURLException { - List urls = new ArrayList<>(); - for (Path directory : directories) { - urls.add(directory.toUri().toURL()); - } - ClassLoader bootstrapLoader = ClassLoader.getSystemClassLoader().getParent(); - URLClassLoader classLoader = new URLClassLoader(urls.toArray(new URL[0]), bootstrapLoader); - when(pluginLoader.getUberClassLoader()).thenReturn(classLoader); - } - - @Test - void shouldFailWith500OnIOException(@TempDir Path directory) throws IOException { - stage(Stage.DEVELOPMENT); - HttpServletRequest request = mock(HttpServletRequest.class); - when(request.getServletPath()).thenReturn("/locales/de/plugins.json"); - HttpServletResponse response = mock(HttpServletResponse.class); - - mockResource(directory, "locales/de/plugins.json", "invalid json"); - - createServlet().doGet(request, response); - - verify(response).setStatus(500); - } - - private void mockResource(Path directory, String resourcePath, String content) throws IOException { - Path file = directory.resolve(resourcePath); - Files.createDirectories(file.getParent()); - Files.write(file, content.getBytes(StandardCharsets.UTF_8)); - - mockUberClassLoader(directory); - } - - private void stage(Stage stage) { - when(context.getStage()).thenReturn(stage); - } - - @Test - void inDevelopmentStageShouldNotUseCache(@TempDir Path temp) throws IOException { - stage(Stage.DEVELOPMENT); - mockResources(temp, "locales/de/plugins.json"); - HttpServletRequest request = mock(HttpServletRequest.class); - when(request.getServletPath()).thenReturn("/locales/de/plugins.json"); - HttpServletResponse response = mock(HttpServletResponse.class); - - I18nServlet servlet = createServlet(); - String json = doGetString(servlet, request, response); - - assertJson(json); - verify(cache, never()).get(any()); - } - - @Nonnull - private I18nServlet createServlet() { - return new I18nServlet(context, pluginLoader, cacheManager, new JsonMerger(new ObjectMapper())); - } - - private String doGetString(I18nServlet servlet, HttpServletRequest request, HttpServletResponse response) throws IOException { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - PrintWriter writer = new PrintWriter(baos); - when(response.getWriter()).thenReturn(writer); - - servlet.doGet(request, response); - - writer.flush(); - return baos.toString(StandardCharsets.UTF_8.name()); - } - - private void mockResources(Path directory, String resourcePath) throws IOException { - List directories = new ArrayList<>(); - for (int i = 0; i < ALL_PLUGIN_JSON.length; i++) { - Path pluginDirectory = directory.resolve("plugin-" + i); - Path file = pluginDirectory.resolve(resourcePath); - Files.createDirectories(file.getParent()); - Files.write(file, ALL_PLUGIN_JSON[i].getBytes(StandardCharsets.UTF_8)); - directories.add(pluginDirectory); - } - mockUberClassLoader(directories); - } - - @Test - void shouldGetFromCacheInProductionStage() throws IOException { - String path = "/locales/de/plugins.json"; - stage(Stage.PRODUCTION); - HttpServletRequest request = mock(HttpServletRequest.class); - when(request.getServletPath()).thenReturn(path); - HttpServletResponse response = mock(HttpServletResponse.class); - - ObjectMapper mapper = new ObjectMapper(); - JsonNode jsonNode = mapper.readTree(GIT_PLUGIN_JSON); - when(cache.get(path)).thenReturn(jsonNode); - - I18nServlet servlet = createServlet(); - String json = doGetString(servlet, request, response); - assertThat(json).contains("scm-git-plugin").doesNotContain("scm-hg-plugin"); - verifyHeaders(response); - } - - @Test - void shouldStoreToCacheInProductionStage(@TempDir Path temp) throws IOException { - String path = "/locales/de/plugins.json"; - mockResources(temp, "locales/de/plugins.json"); - stage(Stage.PRODUCTION); - HttpServletRequest request = mock(HttpServletRequest.class); - when(request.getServletPath()).thenReturn(path); - HttpServletResponse response = mock(HttpServletResponse.class); - - I18nServlet servlet = createServlet(); - String json = doGetString(servlet, request, response); - - verify(cache).put(any(String.class), any(JsonNode.class)); - - verifyHeaders(response); - assertJson(json); - } - - - @Nested - class WithDefaultClassLoader { - - @BeforeEach - void init() { - when(pluginLoader.getUberClassLoader()).thenReturn(I18nServletTest.class.getClassLoader()); - } - - @Test - void shouldCleanCacheOnRestartEvent() { - I18nServlet servlet = createServlet(); - EventBus eventBus = new EventBus("forTestingOnly"); - eventBus.register(servlet); - eventBus.post(RestartEventFactory.create(I18nServlet.class, "Restart to reload the plugin resources")); - - verify(cache).clear(); - } - - } + servlet.doGet(request, response); + writer.flush(); + return baos.toString(StandardCharsets.UTF_8.name()); } private void verifyHeaders(HttpServletResponse response) { @@ -323,8 +123,6 @@ class I18nServletTest { private void assertJson(String actual) { assertThat(actual) .isNotEmpty() - .contains(CharMatcher.whitespace().removeFrom(GIT_PLUGIN_JSON.substring(1, GIT_PLUGIN_JSON.length() - 1))) - .contains(CharMatcher.whitespace().removeFrom(HG_PLUGIN_JSON.substring(1, HG_PLUGIN_JSON.length() - 1))) - .contains(CharMatcher.whitespace().removeFrom(SVN_PLUGIN_JSON.substring(1, SVN_PLUGIN_JSON.length() - 1))); + .contains(CharMatcher.whitespace().removeFrom(GIT_PLUGIN.substring(1, GIT_PLUGIN.length() - 1))); } }