Use i18n for cli exception (#1989)

For scm exceptions, use the translations from plugins.json in the cli rest endpoint.
This commit is contained in:
René Pfeuffer
2022-04-06 14:30:30 +02:00
committed by GitHub
parent 02b3c5c810
commit e926711157
11 changed files with 796 additions and 388 deletions

View File

@@ -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();
}
}
}

View File

@@ -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 {

View File

@@ -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<String, JsonNode> cache;
private I18nCollector collector;
@BeforeEach
void mockCache() {
when(cacheManager.<String, JsonNode>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<JsonNode> 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<JsonNode> 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<JsonNode> json = collector.findJson("de");
assertThat(json).get().isSameAs(jsonNode);
}
@Test
void shouldStoreToCacheInProductionStage() throws IOException {
stage(Stage.PRODUCTION);
Optional<JsonNode> json = collector.findJson("de");
verifyJson(json);
verify(cache).put(eq("de"), any());
}
}
private void verifyJson(Optional<JsonNode> 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<Path> directories) throws MalformedURLException {
List<URL> 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<Path> 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);
}
}

View File

@@ -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<String, JsonNode> 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.<String, JsonNode>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<Path> directories) throws MalformedURLException {
List<URL> 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<Path> 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)));
}
}