From d65773c6bf2ac93ae6081297fa130eca85fe9e91 Mon Sep 17 00:00:00 2001 From: Simon Brandhof Date: Fri, 24 Jan 2014 00:55:25 +0100 Subject: [PATCH] SONAR-5010 improve testability of web service and fix media type of response --- .../java/org/sonar/api/server/ws/Request.java | 11 + .../org/sonar/api/server/ws/Response.java | 7 +- .../sonar/api/server/ws/SimpleRequest.java | 59 ----- .../sonar/api/server/ws/SimpleResponse.java | 64 ------ .../org/sonar/api/server/ws/RequestTest.java | 106 +++++++++ .../api/server/ws/SimpleRequestTest.java | 44 ---- sonar-server/pom.xml | 5 - .../org/sonar/server/issue/ws/IssuesWs.java | 8 +- .../org/sonar/server/ws/ServletResponse.java | 41 +++- .../org/sonar/server/ws/WebServiceEngine.java | 4 +- .../issue/filter/IssueFilterWsTest.java | 21 +- .../issue/ws/IssueShowWsHandlerTest.java | 55 +++-- .../sonar/server/issue/ws/IssuesWsTest.java | 2 +- .../org/sonar/server/rule/RuleTagsWsTest.java | 26 +-- .../org/sonar/server/rule/RulesWsTest.java | 6 +- .../org/sonar/server/ws/ListingWsTest.java | 18 +- .../sonar/server/ws/WebServiceEngineTest.java | 127 +++++++++- .../java/org/sonar/server/ws/WsTester.java | 98 -------- .../index.json | 0 sonar-testing-harness/pom.xml | 30 +-- .../org/sonar/api/server/ws/WsTester.java | 216 ++++++++++++++++++ 21 files changed, 565 insertions(+), 383 deletions(-) delete mode 100644 sonar-plugin-api/src/main/java/org/sonar/api/server/ws/SimpleRequest.java delete mode 100644 sonar-plugin-api/src/main/java/org/sonar/api/server/ws/SimpleResponse.java create mode 100644 sonar-plugin-api/src/test/java/org/sonar/api/server/ws/RequestTest.java delete mode 100644 sonar-plugin-api/src/test/java/org/sonar/api/server/ws/SimpleRequestTest.java delete mode 100644 sonar-server/src/test/java/org/sonar/server/ws/WsTester.java rename sonar-server/src/test/resources/org/sonar/server/ws/{ListingWebServiceTest => ListingWsTest}/index.json (100%) create mode 100644 sonar-testing-harness/src/main/java/org/sonar/api/server/ws/WsTester.java diff --git a/sonar-plugin-api/src/main/java/org/sonar/api/server/ws/Request.java b/sonar-plugin-api/src/main/java/org/sonar/api/server/ws/Request.java index a3e00fdc02a..3892b275ebc 100644 --- a/sonar-plugin-api/src/main/java/org/sonar/api/server/ws/Request.java +++ b/sonar-plugin-api/src/main/java/org/sonar/api/server/ws/Request.java @@ -65,4 +65,15 @@ public abstract class Request { String s = param(key); return s == null ? defaultValue : Integer.parseInt(s); } + + @CheckForNull + public Boolean booleanParam(String key) { + String s = param(key); + return s == null ? null : Boolean.parseBoolean(s); + } + + public boolean booleanParam(String key, boolean defaultValue) { + String s = param(key); + return s == null ? defaultValue : Boolean.parseBoolean(s); + } } diff --git a/sonar-plugin-api/src/main/java/org/sonar/api/server/ws/Response.java b/sonar-plugin-api/src/main/java/org/sonar/api/server/ws/Response.java index 6ca4184bd17..b22d92d1455 100644 --- a/sonar-plugin-api/src/main/java/org/sonar/api/server/ws/Response.java +++ b/sonar-plugin-api/src/main/java/org/sonar/api/server/ws/Response.java @@ -31,6 +31,11 @@ import java.io.OutputStream; */ public interface Response { + interface Stream { + Stream setMediaType(String s); + OutputStream output(); + } + int status(); Response setStatus(int httpStatus); @@ -39,6 +44,6 @@ public interface Response { XmlWriter newXmlWriter(); - OutputStream stream(); + Stream stream(); } diff --git a/sonar-plugin-api/src/main/java/org/sonar/api/server/ws/SimpleRequest.java b/sonar-plugin-api/src/main/java/org/sonar/api/server/ws/SimpleRequest.java deleted file mode 100644 index 9689d060db6..00000000000 --- a/sonar-plugin-api/src/main/java/org/sonar/api/server/ws/SimpleRequest.java +++ /dev/null @@ -1,59 +0,0 @@ -/* - * SonarQube, open source software quality management tool. - * Copyright (C) 2008-2013 SonarSource - * mailto:contact AT sonarsource DOT com - * - * SonarQube is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * SonarQube is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ -package org.sonar.api.server.ws; - -import com.google.common.collect.Maps; - -import javax.annotation.CheckForNull; -import java.util.Map; - -public class SimpleRequest extends Request { - - private String method = "GET"; - private Map params = Maps.newHashMap(); - - @Override - public String method() { - return method; - } - - public SimpleRequest setMethod(String s) { - this.method = s; - return this; - } - - public SimpleRequest setParams(Map m) { - this.params = m; - return this; - } - - public SimpleRequest setParam(String key, @CheckForNull String value) { - if (value != null) { - params.put(key, value); - } - return this; - } - - @Override - @CheckForNull - public String param(String key) { - return params.get(key); - } -} diff --git a/sonar-plugin-api/src/main/java/org/sonar/api/server/ws/SimpleResponse.java b/sonar-plugin-api/src/main/java/org/sonar/api/server/ws/SimpleResponse.java deleted file mode 100644 index d2de13b9f63..00000000000 --- a/sonar-plugin-api/src/main/java/org/sonar/api/server/ws/SimpleResponse.java +++ /dev/null @@ -1,64 +0,0 @@ -/* - * SonarQube, open source software quality management tool. - * Copyright (C) 2008-2013 SonarSource - * mailto:contact AT sonarsource DOT com - * - * SonarQube is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * SonarQube is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ -package org.sonar.api.server.ws; - -import org.apache.commons.io.Charsets; -import org.sonar.api.utils.text.JsonWriter; -import org.sonar.api.utils.text.XmlWriter; - -import java.io.ByteArrayOutputStream; -import java.io.OutputStream; -import java.io.OutputStreamWriter; - -public class SimpleResponse implements Response { - private int httpStatus = 200; - private final ByteArrayOutputStream output = new ByteArrayOutputStream(); - - @Override - public JsonWriter newJsonWriter() { - return JsonWriter.of(new OutputStreamWriter(output, Charsets.UTF_8)); - } - - @Override - public XmlWriter newXmlWriter() { - return XmlWriter.of(new OutputStreamWriter(output, Charsets.UTF_8)); - } - - @Override - public OutputStream stream() { - return output; - } - - // for unit testing - public String outputAsString() { - return new String(output.toByteArray(), Charsets.UTF_8); - } - - @Override - public int status() { - return httpStatus; - } - - @Override - public Response setStatus(int httpStatus) { - this.httpStatus = httpStatus; - return this; - } -} diff --git a/sonar-plugin-api/src/test/java/org/sonar/api/server/ws/RequestTest.java b/sonar-plugin-api/src/test/java/org/sonar/api/server/ws/RequestTest.java new file mode 100644 index 00000000000..f7f4f8f738d --- /dev/null +++ b/sonar-plugin-api/src/test/java/org/sonar/api/server/ws/RequestTest.java @@ -0,0 +1,106 @@ +/* + * SonarQube, open source software quality management tool. + * Copyright (C) 2008-2013 SonarSource + * mailto:contact AT sonarsource DOT com + * + * SonarQube is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * SonarQube is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.api.server.ws; + +import org.junit.Test; + +import javax.annotation.CheckForNull; +import java.util.HashMap; +import java.util.Map; + +import static org.fest.assertions.Assertions.assertThat; +import static org.fest.assertions.Fail.fail; + +public class RequestTest { + + static class SimpleRequest extends Request { + + private final Map params = new HashMap(); + + @Override + public String method() { + return "GET"; + } + + public SimpleRequest setParam(String key, @CheckForNull String value) { + if (value != null) { + params.put(key, value); + } + return this; + } + + @Override + @CheckForNull + public String param(String key) { + return params.get(key); + } + } + + + SimpleRequest request = new SimpleRequest(); + + @Test + public void required_param_is_missing() throws Exception { + try { + request.requiredParam("foo"); + fail(); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessage("Parameter 'foo' is missing"); + } + } + + @Test + public void required_param_is_set() throws Exception { + String value = request.setParam("foo", "bar").requiredParam("foo"); + assertThat(value).isEqualTo("bar"); + } + + @Test + public void default_value_of_optional_param() throws Exception { + String value = request.param("foo", "bar"); + assertThat(value).isEqualTo("bar"); + } + + @Test + public void string_param() throws Exception { + String value = request.setParam("foo", "bar").param("foo", "default"); + assertThat(value).isEqualTo("bar"); + } + + @Test + public void int_param() throws Exception { + assertThat(request.setParam("foo", "123").intParam("foo")).isEqualTo(123); + assertThat(request.setParam("foo", "123").intParam("xxx")).isNull(); + assertThat(request.setParam("foo", "123").intParam("foo", 456)).isEqualTo(123); + assertThat(request.setParam("foo", "123").intParam("xxx", 456)).isEqualTo(456); + } + + @Test + public void boolean_param() throws Exception { + assertThat(request.setParam("foo", "true").booleanParam("foo")).isTrue(); + assertThat(request.setParam("foo", "false").booleanParam("foo")).isFalse(); + assertThat(request.setParam("foo", "true").booleanParam("xxx")).isNull(); + + assertThat(request.setParam("foo", "true").booleanParam("foo", true)).isTrue(); + assertThat(request.setParam("foo", "true").booleanParam("foo", false)).isTrue(); + assertThat(request.setParam("foo", "true").booleanParam("xxx", true)).isTrue(); + assertThat(request.setParam("foo", "true").booleanParam("xxx", false)).isFalse(); + } +} diff --git a/sonar-plugin-api/src/test/java/org/sonar/api/server/ws/SimpleRequestTest.java b/sonar-plugin-api/src/test/java/org/sonar/api/server/ws/SimpleRequestTest.java deleted file mode 100644 index d9e5bcafa38..00000000000 --- a/sonar-plugin-api/src/test/java/org/sonar/api/server/ws/SimpleRequestTest.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * SonarQube, open source software quality management tool. - * Copyright (C) 2008-2013 SonarSource - * mailto:contact AT sonarsource DOT com - * - * SonarQube is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * SonarQube is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ -package org.sonar.api.server.ws; - -import com.google.common.collect.ImmutableMap; -import org.junit.Test; - -import static org.fest.assertions.Assertions.assertThat; - -public class SimpleRequestTest { - @Test - public void string_params() { - Request request = new SimpleRequest().setParams(ImmutableMap.of("foo", "bar")); - assertThat(request.param("none")).isNull(); - assertThat(request.param("foo")).isEqualTo("bar"); - } - - @Test - public void int_params() { - Request request = new SimpleRequest().setParams(ImmutableMap.of("foo", "123")); - assertThat(request.intParam("none")).isNull(); - assertThat(request.intParam("foo")).isEqualTo(123); - - assertThat(request.intParam("none", 456)).isEqualTo(456); - assertThat(request.intParam("foo", 456)).isEqualTo(123); - } -} diff --git a/sonar-server/pom.xml b/sonar-server/pom.xml index 78efac7d53b..234bad82b26 100644 --- a/sonar-server/pom.xml +++ b/sonar-server/pom.xml @@ -166,11 +166,6 @@ dbunit test - - org.skyscreamer - jsonassert - test - org.codehaus.sonar sonar-testing-harness diff --git a/sonar-server/src/main/java/org/sonar/server/issue/ws/IssuesWs.java b/sonar-server/src/main/java/org/sonar/server/issue/ws/IssuesWs.java index 488b7b78353..aefdf0501b4 100644 --- a/sonar-server/src/main/java/org/sonar/server/issue/ws/IssuesWs.java +++ b/sonar-server/src/main/java/org/sonar/server/issue/ws/IssuesWs.java @@ -23,10 +23,10 @@ import org.sonar.api.server.ws.WebService; public class IssuesWs implements WebService { - private final IssueShowWsHandler detailHandler; + private final IssueShowWsHandler showHandler; - public IssuesWs(IssueShowWsHandler detailHandler) { - this.detailHandler = detailHandler; + public IssuesWs(IssueShowWsHandler showHandler) { + this.showHandler = showHandler; } @Override @@ -38,7 +38,7 @@ public class IssuesWs implements WebService { .setDescription("Detail of issue") .setSince("4.2") .setPrivate(true) - .setHandler(detailHandler) + .setHandler(showHandler) .newParam("key", "Issue key"); controller.done(); diff --git a/sonar-server/src/main/java/org/sonar/server/ws/ServletResponse.java b/sonar-server/src/main/java/org/sonar/server/ws/ServletResponse.java index 0a2354f87b7..cabb16ba9a3 100644 --- a/sonar-server/src/main/java/org/sonar/server/ws/ServletResponse.java +++ b/sonar-server/src/main/java/org/sonar/server/ws/ServletResponse.java @@ -34,6 +34,24 @@ import java.io.StringWriter; public class ServletResponse implements Response { + private class ServletStream implements Stream { + + @Override + public Stream setMediaType(String s) { + source.setContentType(s); + return this; + } + + @Override + public OutputStream output() { + try { + return source.getOutputStream(); + } catch (IOException e) { + throw new IllegalStateException("Fail to get servlet output stream", e); + } + } + } + private final HttpServletResponse source; private int httpStatus = 200; @@ -43,25 +61,17 @@ public class ServletResponse implements Response { @Override public JsonWriter newJsonWriter() { - return JsonWriter.of(new Buffer()); + return JsonWriter.of(new Buffer("application/json")); } @Override public XmlWriter newXmlWriter() { - try { - return XmlWriter.of(source.getWriter()); - } catch (IOException e) { - throw new IllegalStateException(e); - } + return XmlWriter.of(new Buffer("application/xml")); } @Override - public OutputStream stream() { - try { - return source.getOutputStream(); - } catch (IOException e) { - throw new IllegalStateException(e); - } + public Stream stream() { + return new ServletStream(); } @Override @@ -76,11 +86,18 @@ public class ServletResponse implements Response { } private class Buffer extends StringWriter { + private final String mediaType; + + private Buffer(String mediaType) { + this.mediaType = mediaType; + } + @Override public void close() throws IOException { super.close(); source.setStatus(httpStatus); + source.setContentType(mediaType); ServletOutputStream stream = null; try { stream = source.getOutputStream(); diff --git a/sonar-server/src/main/java/org/sonar/server/ws/WebServiceEngine.java b/sonar-server/src/main/java/org/sonar/server/ws/WebServiceEngine.java index f0a92d9b4dc..83ba59bdcf1 100644 --- a/sonar-server/src/main/java/org/sonar/server/ws/WebServiceEngine.java +++ b/sonar-server/src/main/java/org/sonar/server/ws/WebServiceEngine.java @@ -111,7 +111,9 @@ public class WebServiceEngine implements ServerComponent, Startable { // Reset response by directly using the stream. Response#newJsonWriter() // must not be used because it potentially contains some partial response - JsonWriter json = JsonWriter.of(new OutputStreamWriter(response.stream())); + Response.Stream stream = response.stream(); + stream.setMediaType("application/json"); + JsonWriter json = JsonWriter.of(new OutputStreamWriter(stream.output())); json.beginObject(); json.name("errors").beginArray(); json.beginObject().prop("msg", message).endObject(); diff --git a/sonar-server/src/test/java/org/sonar/server/issue/filter/IssueFilterWsTest.java b/sonar-server/src/test/java/org/sonar/server/issue/filter/IssueFilterWsTest.java index 250a1009a73..d8033bf565c 100644 --- a/sonar-server/src/test/java/org/sonar/server/issue/filter/IssueFilterWsTest.java +++ b/sonar-server/src/test/java/org/sonar/server/issue/filter/IssueFilterWsTest.java @@ -20,11 +20,10 @@ package org.sonar.server.issue.filter; import org.junit.Test; -import org.sonar.api.server.ws.SimpleRequest; import org.sonar.api.server.ws.WebService; +import org.sonar.api.server.ws.WsTester; import org.sonar.core.issue.DefaultIssueFilter; import org.sonar.server.user.MockUserSession; -import org.sonar.server.ws.WsTester; import java.util.Arrays; @@ -55,16 +54,14 @@ public class IssueFilterWsTest { @Test public void anonymous_page() throws Exception { MockUserSession.set().setLogin(null); - SimpleRequest request = new SimpleRequest(); - tester.execute("page", request).assertJson(getClass(), "anonymous_page.json"); + tester.newRequest("page").execute().assertJson(getClass(), "anonymous_page.json"); } @Test public void logged_in_page() throws Exception { MockUserSession.set().setLogin("eric").setUserId(123); - SimpleRequest request = new SimpleRequest(); - tester.execute("page", request) - .assertHttpStatus(200) + tester.newRequest("page").execute() + .assertStatus(200) .assertJson(getClass(), "logged_in_page.json"); } @@ -75,9 +72,8 @@ public class IssueFilterWsTest { new DefaultIssueFilter().setId(6L).setName("My issues"), new DefaultIssueFilter().setId(13L).setName("Blocker issues") )); - SimpleRequest request = new SimpleRequest(); - tester.execute("page", request) - .assertHttpStatus(200) + tester.newRequest("page").execute() + .assertStatus(200) .assertJson(getClass(), "logged_in_page_with_favorites.json"); } @@ -88,9 +84,8 @@ public class IssueFilterWsTest { new DefaultIssueFilter().setId(13L).setName("Blocker issues").setData("severity=BLOCKER") ); - SimpleRequest request = new SimpleRequest().setParam("id", "13"); - tester.execute("page", request) - .assertHttpStatus(200) + tester.newRequest("page").setParam("id", "13").execute() + .assertStatus(200) .assertJson(getClass(), "logged_in_page_with_selected_filter.json"); } } diff --git a/sonar-server/src/test/java/org/sonar/server/issue/ws/IssueShowWsHandlerTest.java b/sonar-server/src/test/java/org/sonar/server/issue/ws/IssueShowWsHandlerTest.java index 7c8285bd316..1b145086fea 100644 --- a/sonar-server/src/test/java/org/sonar/server/issue/ws/IssueShowWsHandlerTest.java +++ b/sonar-server/src/test/java/org/sonar/server/issue/ws/IssueShowWsHandlerTest.java @@ -38,7 +38,7 @@ import org.sonar.api.issue.internal.FieldDiffs; import org.sonar.api.issue.internal.WorkDayDuration; import org.sonar.api.rule.RuleKey; import org.sonar.api.rules.Rule; -import org.sonar.api.server.ws.SimpleRequest; +import org.sonar.api.server.ws.WsTester; import org.sonar.api.user.User; import org.sonar.api.utils.DateUtils; import org.sonar.api.web.UserRole; @@ -53,7 +53,6 @@ import org.sonar.server.issue.IssueService; import org.sonar.server.technicaldebt.TechnicalDebtFormatter; import org.sonar.server.user.MockUserSession; import org.sonar.server.user.UserSession; -import org.sonar.server.ws.WsTester; import java.util.ArrayList; import java.util.Date; @@ -133,8 +132,8 @@ public class IssueShowWsHandlerTest { issues.add(issue); MockUserSession.set(); - SimpleRequest request = new SimpleRequest().setParam("key", issueKey); - tester.execute("show", request).assertJson(getClass(), "show_issue.json"); + WsTester.TestRequest request = tester.newRequest("show").setParam("key", issueKey); + request.execute().assertJson(getClass(), "show_issue.json"); } @Test @@ -146,8 +145,8 @@ public class IssueShowWsHandlerTest { result.addActionPlans(newArrayList((ActionPlan) new DefaultActionPlan().setKey("AP-ABCD").setName("Version 4.2"))); MockUserSession.set(); - SimpleRequest request = new SimpleRequest().setParam("key", issue.key()); - tester.execute("show", request).assertJson(getClass(), "show_issue_with_action_plan.json"); + WsTester.TestRequest request = tester.newRequest("show").setParam("key", issue.key()); + request.execute().assertJson(getClass(), "show_issue_with_action_plan.json"); } @Test @@ -164,8 +163,8 @@ public class IssueShowWsHandlerTest { )); MockUserSession.set(); - SimpleRequest request = new SimpleRequest().setParam("key", issue.key()); - tester.execute("show", request).assertJson(getClass(), "show_issue_with_users.json"); + WsTester.TestRequest request = tester.newRequest("show").setParam("key", issue.key()); + request.execute().assertJson(getClass(), "show_issue_with_users.json"); } @Test @@ -178,8 +177,8 @@ public class IssueShowWsHandlerTest { when(technicalDebtFormatter.format(any(Locale.class), eq(technicalDebt))).thenReturn("2 hours 1 minutes"); MockUserSession.set(); - SimpleRequest request = new SimpleRequest().setParam("key", issue.key()); - tester.execute("show", request).assertJson(getClass(), "show_issue_with_technical_debt.json"); + WsTester.TestRequest request = tester.newRequest("show").setParam("key", issue.key()); + request.execute().assertJson(getClass(), "show_issue_with_technical_debt.json"); } @Test @@ -199,8 +198,8 @@ public class IssueShowWsHandlerTest { when(i18n.formatDateTime(any(Locale.class), eq(closedDate))).thenReturn("Jan 24, 2014 10:03 AM"); MockUserSession.set(); - SimpleRequest request = new SimpleRequest().setParam("key", issue.key()); - tester.execute("show", request).assertJson(getClass(), "show_issue_with_dates.json"); + WsTester.TestRequest request = tester.newRequest("show").setParam("key", issue.key()); + request.execute().assertJson(getClass(), "show_issue_with_dates.json"); } @Test @@ -232,8 +231,8 @@ public class IssueShowWsHandlerTest { when(i18n.instant(any(Locale.class), eq(date2))).thenReturn("10 days"); MockUserSession.set().setLogin("arthur"); - SimpleRequest request = new SimpleRequest().setParam("key", issue.key()); - tester.execute("show", request).assertJson(getClass(), "show_issue_with_comments.json"); + WsTester.TestRequest request = tester.newRequest("show").setParam("key", issue.key()); + request.execute().assertJson(getClass(), "show_issue_with_comments.json"); } @Test @@ -246,8 +245,8 @@ public class IssueShowWsHandlerTest { when(issueService.listTransitions(eq(issue), any(UserSession.class))).thenReturn(newArrayList(Transition.create("reopen", "RESOLVED", "REOPEN"))); MockUserSession.set().setLogin("john"); - SimpleRequest request = new SimpleRequest().setParam("key", issue.key()); - tester.execute("show", request).assertJson(getClass(), "show_issue_with_transitions.json"); + WsTester.TestRequest request = tester.newRequest("show").setParam("key", issue.key()); + request.execute().assertJson(getClass(), "show_issue_with_transitions.json"); } @Test @@ -257,8 +256,8 @@ public class IssueShowWsHandlerTest { issues.add(issue); MockUserSession.set().setLogin("john"); - SimpleRequest request = new SimpleRequest().setParam("key", issue.key()); - tester.execute("show", request).assertJson(getClass(), "show_issue_with_actions.json"); + WsTester.TestRequest request = tester.newRequest("show").setParam("key", issue.key()); + request.execute().assertJson(getClass(), "show_issue_with_actions.json"); } @Test @@ -268,8 +267,8 @@ public class IssueShowWsHandlerTest { issues.add(issue); MockUserSession.set().setLogin("john").addProjectPermissions(UserRole.ISSUE_ADMIN, issue.projectKey()); - SimpleRequest request = new SimpleRequest().setParam("key", issue.key()); - tester.execute("show", request).assertJson(getClass(), "show_issue_with_severity_action.json"); + WsTester.TestRequest request = tester.newRequest("show").setParam("key", issue.key()); + request.execute().assertJson(getClass(), "show_issue_with_severity_action.json"); } @Test @@ -279,8 +278,8 @@ public class IssueShowWsHandlerTest { issues.add(issue); MockUserSession.set().setLogin("john"); - SimpleRequest request = new SimpleRequest().setParam("key", issue.key()); - tester.execute("show", request).assertJson(getClass(), "show_issue_with_assign_to_me_action.json"); + WsTester.TestRequest request = tester.newRequest("show").setParam("key", issue.key()); + request.execute().assertJson(getClass(), "show_issue_with_assign_to_me_action.json"); } @Test @@ -295,8 +294,8 @@ public class IssueShowWsHandlerTest { )); MockUserSession.set().setLogin("john"); - SimpleRequest request = new SimpleRequest().setParam("key", issue.key()); - tester.execute("show", request).assertJson(getClass(), "show_issue_without_assign_to_me_action.json"); + WsTester.TestRequest request = tester.newRequest("show").setParam("key", issue.key()); + request.execute().assertJson(getClass(), "show_issue_without_assign_to_me_action.json"); } @Test @@ -310,8 +309,8 @@ public class IssueShowWsHandlerTest { when(actionService.listAvailableActions(issue)).thenReturn(newArrayList(action)); MockUserSession.set().setLogin("john"); - SimpleRequest request = new SimpleRequest().setParam("key", issue.key()); - tester.execute("show", request).assertJson(getClass(), "show_issue_with_actions_defined_by_plugins.json"); + WsTester.TestRequest request = tester.newRequest("show").setParam("key", issue.key()); + request.execute().assertJson(getClass(), "show_issue_with_actions_defined_by_plugins.json"); } @Test @@ -339,8 +338,8 @@ public class IssueShowWsHandlerTest { when(i18n.formatDateTime(any(Locale.class), eq(date2))).thenReturn("Fev 23, 2014 10:03 AM"); MockUserSession.set(); - SimpleRequest request = new SimpleRequest().setParam("key", issue.key()); - tester.execute("show", request).assertJson(getClass(), "show_issue_with_changelog.json"); + WsTester.TestRequest request = tester.newRequest("show").setParam("key", issue.key()); + request.execute().assertJson(getClass(), "show_issue_with_changelog.json"); } private DefaultIssue createStandardIssue() { diff --git a/sonar-server/src/test/java/org/sonar/server/issue/ws/IssuesWsTest.java b/sonar-server/src/test/java/org/sonar/server/issue/ws/IssuesWsTest.java index bf66f868edf..d985ac3530c 100644 --- a/sonar-server/src/test/java/org/sonar/server/issue/ws/IssuesWsTest.java +++ b/sonar-server/src/test/java/org/sonar/server/issue/ws/IssuesWsTest.java @@ -21,7 +21,7 @@ package org.sonar.server.issue.ws; import org.junit.Test; import org.sonar.api.server.ws.WebService; -import org.sonar.server.ws.WsTester; +import org.sonar.api.server.ws.WsTester; import static org.fest.assertions.Assertions.assertThat; import static org.mockito.Mockito.mock; diff --git a/sonar-server/src/test/java/org/sonar/server/rule/RuleTagsWsTest.java b/sonar-server/src/test/java/org/sonar/server/rule/RuleTagsWsTest.java index 252bed2a329..af9004fff34 100644 --- a/sonar-server/src/test/java/org/sonar/server/rule/RuleTagsWsTest.java +++ b/sonar-server/src/test/java/org/sonar/server/rule/RuleTagsWsTest.java @@ -25,15 +25,13 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.runners.MockitoJUnitRunner; -import org.sonar.api.server.ws.SimpleRequest; import org.sonar.api.server.ws.WebService; +import org.sonar.api.server.ws.WsTester; import org.sonar.core.rule.RuleTagDto; -import org.sonar.server.ws.WsTester; import static org.fest.assertions.Assertions.assertThat; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyZeroInteractions; -import static org.mockito.Mockito.when; +import static org.fest.assertions.Fail.fail; +import static org.mockito.Mockito.*; @RunWith(MockitoJUnitRunner.class) public class RuleTagsWsTest { @@ -75,8 +73,7 @@ public class RuleTagsWsTest { @Test public void list_tags() throws Exception { when(ruleTags.listAllTags()).thenReturn(ImmutableList.of("tag1", "tag2", "tag3")); - SimpleRequest request = new SimpleRequest(); - tester.execute("list", request).assertJson(getClass(), "list.json"); + tester.newRequest("list").execute().assertJson(getClass(), "list.json"); verify(ruleTags).listAllTags(); } @@ -86,18 +83,19 @@ public class RuleTagsWsTest { Long tagId = 42L; RuleTagDto newTag = new RuleTagDto().setId(tagId).setTag(tag); when(ruleTags.create("newtag")).thenReturn(newTag); - SimpleRequest request = new SimpleRequest(); - request.setParam("tag", tag); - tester.execute("create", request).assertJson(getClass(), "create_ok.json"); + + WsTester.TestRequest request = tester.newRequest("create").setParam("tag", tag); + request.execute().assertJson(getClass(), "create_ok.json"); verify(ruleTags).create(tag); } - @Test(expected=IllegalArgumentException.class) + @Test public void create_missing_parameter() throws Exception { - SimpleRequest request = new SimpleRequest(); + WsTester.TestRequest request = tester.newRequest("create"); try { - tester.execute("create", request); - } finally { + request.execute(); + fail(); + } catch (IllegalArgumentException e) { verifyZeroInteractions(ruleTags); } } diff --git a/sonar-server/src/test/java/org/sonar/server/rule/RulesWsTest.java b/sonar-server/src/test/java/org/sonar/server/rule/RulesWsTest.java index 8487236a966..7484d6cda5e 100644 --- a/sonar-server/src/test/java/org/sonar/server/rule/RulesWsTest.java +++ b/sonar-server/src/test/java/org/sonar/server/rule/RulesWsTest.java @@ -20,9 +20,8 @@ package org.sonar.server.rule; import org.junit.Test; -import org.sonar.api.server.ws.SimpleRequest; import org.sonar.api.server.ws.WebService; -import org.sonar.server.ws.WsTester; +import org.sonar.api.server.ws.WsTester; import static org.fest.assertions.Assertions.assertThat; @@ -47,7 +46,6 @@ public class RulesWsTest { @Test public void search_for_rules() throws Exception { - SimpleRequest request = new SimpleRequest(); - tester.execute("search", request).assertJson(getClass(), "search.json"); + tester.newRequest("search").execute().assertJson(getClass(), "search.json"); } } diff --git a/sonar-server/src/test/java/org/sonar/server/ws/ListingWsTest.java b/sonar-server/src/test/java/org/sonar/server/ws/ListingWsTest.java index de28438d8fe..0e73f856202 100644 --- a/sonar-server/src/test/java/org/sonar/server/ws/ListingWsTest.java +++ b/sonar-server/src/test/java/org/sonar/server/ws/ListingWsTest.java @@ -19,9 +19,7 @@ */ package org.sonar.server.ws; -import org.apache.commons.io.IOUtils; import org.junit.Test; -import org.skyscreamer.jsonassert.JSONAssert; import org.sonar.api.server.ws.*; import static org.fest.assertions.Assertions.assertThat; @@ -29,10 +27,10 @@ import static org.fest.assertions.Assertions.assertThat; public class ListingWsTest { ListingWs ws = new ListingWs(); - WsTester tester = new WsTester(ws); @Test public void define_ws() throws Exception { + WsTester tester = new WsTester(ws); WebService.Controller controller = tester.controller("api/webservices"); assertThat(controller).isNotNull(); assertThat(controller.path()).isEqualTo("api/webservices"); @@ -51,18 +49,8 @@ public class ListingWsTest { @Test public void index() throws Exception { - // register web services, including itself - WebService.Context context = new WebService.Context(); - ws.define(context); - new MetricWebService().define(context); - - SimpleResponse response = new SimpleResponse(); - ws.list(context.controllers(), response); - - JSONAssert.assertEquals( - IOUtils.toString(getClass().getResource("/org/sonar/server/ws/ListingWebServiceTest/index.json")), - response.outputAsString(), true - ); + WsTester tester = new WsTester(ws, new MetricWebService()); + tester.newRequest("api/webservices", "index").execute().assertJson(getClass(), "index.json"); } static class MetricWebService implements WebService { diff --git a/sonar-server/src/test/java/org/sonar/server/ws/WebServiceEngineTest.java b/sonar-server/src/test/java/org/sonar/server/ws/WebServiceEngineTest.java index e681e2a44fe..a5aff474302 100644 --- a/sonar-server/src/test/java/org/sonar/server/ws/WebServiceEngineTest.java +++ b/sonar-server/src/test/java/org/sonar/server/ws/WebServiceEngineTest.java @@ -19,25 +19,128 @@ */ package org.sonar.server.ws; +import org.apache.commons.io.Charsets; import org.apache.commons.io.IOUtils; import org.junit.After; import org.junit.Before; import org.junit.Test; -import org.sonar.api.server.ws.*; +import org.sonar.api.server.ws.Request; +import org.sonar.api.server.ws.RequestHandler; +import org.sonar.api.server.ws.Response; +import org.sonar.api.server.ws.WebService; +import org.sonar.api.utils.text.JsonWriter; +import org.sonar.api.utils.text.XmlWriter; + +import javax.annotation.CheckForNull; +import java.io.ByteArrayOutputStream; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.util.HashMap; +import java.util.Map; import static org.fest.assertions.Assertions.assertThat; +import static org.mockito.Mockito.mock; public class WebServiceEngineTest { + private static class SimpleRequest extends Request { + private String method = "GET"; + private Map params = new HashMap(); + + @Override + public String method() { + return method; + } + + public SimpleRequest setMethod(String s) { + this.method = s; + return this; + } + + public SimpleRequest setParams(Map m) { + this.params = m; + return this; + } + + public SimpleRequest setParam(String key, @CheckForNull String value) { + if (value != null) { + params.put(key, value); + } + return this; + } + + @Override + @CheckForNull + public String param(String key) { + return params.get(key); + } + + } + + private static class SimpleResponse implements Response { + public class SimpleStream implements Response.Stream { + private String mediaType; + + @CheckForNull + public String mediaType() { + return mediaType; + } + + @Override + public Response.Stream setMediaType(String s) { + this.mediaType = s; + return this; + } + + @Override + public OutputStream output() { + return output; + } + } + + private int status = 200; + private final ByteArrayOutputStream output = new ByteArrayOutputStream(); + + @Override + public JsonWriter newJsonWriter() { + return JsonWriter.of(new OutputStreamWriter(output, Charsets.UTF_8)); + } + + @Override + public XmlWriter newXmlWriter() { + return XmlWriter.of(new OutputStreamWriter(output, Charsets.UTF_8)); + } + + @Override + public Stream stream() { + return new SimpleStream(); + } + + @Override + public int status() { + return status; + } + + @Override + public Response setStatus(int httpStatus) { + this.status = httpStatus; + return this; + } + + public String outputAsString() { + return new String(output.toByteArray(), Charsets.UTF_8); + } + } + WebServiceEngine engine = new WebServiceEngine(new WebService[]{new SystemWebService()}); @Before - public void before() { + public void start() { engine.start(); } @After - public void after() { + public void stop() { engine.stop(); } @@ -78,7 +181,7 @@ public class WebServiceEngineTest { } @Test - public void method_not_allowed() throws Exception { + public void method_get_not_allowed() throws Exception { Request request = new SimpleRequest(); SimpleResponse response = new SimpleResponse(); engine.execute(request, response, "api/system", "ping"); @@ -87,6 +190,16 @@ public class WebServiceEngineTest { assertThat(response.outputAsString()).isEqualTo("{\"errors\":[{\"msg\":\"HTTP method POST is required\"}]}"); } + @Test + public void method_post_required() throws Exception { + Request request = new SimpleRequest().setMethod("POST"); + SimpleResponse response = new SimpleResponse(); + engine.execute(request, response, "api/system", "ping"); + + assertThat(response.status()).isEqualTo(200); + assertThat(response.outputAsString()).isEqualTo("pong"); + } + @Test public void required_parameter_is_not_set() throws Exception { Request request = new SimpleRequest(); @@ -137,7 +250,7 @@ public class WebServiceEngineTest { .setHandler(new RequestHandler() { @Override public void handle(Request request, Response response) throws Exception { - response.stream().write("good".getBytes()); + response.stream().output().write("good".getBytes()); } }); newController.newAction("ping") @@ -145,7 +258,7 @@ public class WebServiceEngineTest { .setHandler(new RequestHandler() { @Override public void handle(Request request, Response response) throws Exception { - response.stream().write("pong".getBytes()); + response.stream().output().write("pong".getBytes()); } }); newController.newAction("fail") @@ -164,7 +277,7 @@ public class WebServiceEngineTest { @Override public void handle(Request request, Response response) throws Exception { IOUtils.write( - request.requiredParam("message") + " by " + request.param("author", "-"), response.stream()); + request.requiredParam("message") + " by " + request.param("author", "-"), response.stream().output()); } }); newController.done(); diff --git a/sonar-server/src/test/java/org/sonar/server/ws/WsTester.java b/sonar-server/src/test/java/org/sonar/server/ws/WsTester.java deleted file mode 100644 index 27810226172..00000000000 --- a/sonar-server/src/test/java/org/sonar/server/ws/WsTester.java +++ /dev/null @@ -1,98 +0,0 @@ -/* - * SonarQube, open source software quality management tool. - * Copyright (C) 2008-2013 SonarSource - * mailto:contact AT sonarsource DOT com - * - * SonarQube is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * SonarQube is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ -package org.sonar.server.ws; - -import org.apache.commons.io.IOUtils; -import org.skyscreamer.jsonassert.JSONAssert; -import org.sonar.api.server.ws.Request; -import org.sonar.api.server.ws.RequestHandler; -import org.sonar.api.server.ws.SimpleResponse; -import org.sonar.api.server.ws.WebService; - -import javax.annotation.CheckForNull; - -import java.net.URL; - -import static org.junit.Assert.assertEquals; - -/** - * TODO move to sonar-plugin-api with type "test-jar" - */ -public class WsTester { - - private final WebService.Context context = new WebService.Context(); - private String wsPath = null; - - public WsTester(WebService ws) { - ws.define(context); - if (!context.controllers().isEmpty()) { - wsPath = context.controllers().get(0).path(); - } - } - - public WebService.Context context() { - return context; - } - - @CheckForNull - public WebService.Controller controller(String path) { - return context.controller(path); - } - - public Result execute(String actionKey, Request request) throws Exception { - if (wsPath == null) { - throw new IllegalStateException("Ws path is not defined"); - } - SimpleResponse response = new SimpleResponse(); - RequestHandler handler = context.controller(wsPath).action(actionKey).handler(); - handler.handle(request, response); - return new Result(response); - } - - public static class Result { - private final SimpleResponse response; - - private Result(SimpleResponse response) { - this.response = response; - } - - public Result assertHttpStatus(int httpStatus) { - assertEquals(httpStatus, response.status()); - return this; - } - - public Result assertJson(String expectedJson) throws Exception { - String json = response.outputAsString(); - JSONAssert.assertEquals(expectedJson, json, true); - return this; - } - - public Result assertJson(Class clazz, String jsonResourcePath) throws Exception { - String json = response.outputAsString(); - String path = clazz.getSimpleName() + "/" + jsonResourcePath; - URL url = clazz.getResource(path); - if (url == null) { - throw new IllegalStateException("Cannot find " + path); - } - JSONAssert.assertEquals(IOUtils.toString(url), json, true); - return this; - } - } -} diff --git a/sonar-server/src/test/resources/org/sonar/server/ws/ListingWebServiceTest/index.json b/sonar-server/src/test/resources/org/sonar/server/ws/ListingWsTest/index.json similarity index 100% rename from sonar-server/src/test/resources/org/sonar/server/ws/ListingWebServiceTest/index.json rename to sonar-server/src/test/resources/org/sonar/server/ws/ListingWsTest/index.json diff --git a/sonar-testing-harness/pom.xml b/sonar-testing-harness/pom.xml index 777d2790c72..e32a3a72f43 100644 --- a/sonar-testing-harness/pom.xml +++ b/sonar-testing-harness/pom.xml @@ -13,42 +13,46 @@ - junit - junit + org.easytesting + fest-assert org.hamcrest hamcrest-all - org.easytesting - fest-assert + org.skyscreamer + jsonassert - xmlunit - xmlunit + com.google.code.findbugs + jsr305 + + + junit + junit + org.codehaus.sonar - sonar-plugin-api + sonar-channel ${project.version} + true org.codehaus.sonar sonar-plugin-api ${project.version} - test-jar - org.codehaus.sonar - sonar-channel + sonar-plugin-api ${project.version} - true + test-jar - com.google.code.findbugs - jsr305 + xmlunit + xmlunit diff --git a/sonar-testing-harness/src/main/java/org/sonar/api/server/ws/WsTester.java b/sonar-testing-harness/src/main/java/org/sonar/api/server/ws/WsTester.java new file mode 100644 index 00000000000..aad0844ea5c --- /dev/null +++ b/sonar-testing-harness/src/main/java/org/sonar/api/server/ws/WsTester.java @@ -0,0 +1,216 @@ +/* + * SonarQube, open source software quality management tool. + * Copyright (C) 2008-2013 SonarSource + * mailto:contact AT sonarsource DOT com + * + * SonarQube is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * SonarQube is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.api.server.ws; + +import org.apache.commons.io.Charsets; +import org.apache.commons.io.IOUtils; +import org.skyscreamer.jsonassert.JSONAssert; +import org.sonar.api.utils.text.JsonWriter; +import org.sonar.api.utils.text.XmlWriter; + +import javax.annotation.CheckForNull; +import java.io.ByteArrayOutputStream; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.net.URL; +import java.util.HashMap; +import java.util.Map; + +import static org.fest.assertions.Assertions.assertThat; + +/** + * @since 4.2 + */ +public class WsTester { + + public static class TestRequest extends Request { + + private final WebService.Controller controller; + private final String actionKey; + private String method = "GET"; + private Map params = new HashMap(); + + private TestRequest(WebService.Controller controller, String actionKey) { + this.controller = controller; + this.actionKey = actionKey; + } + + @Override + public String method() { + return method; + } + + public TestRequest setMethod(String s) { + this.method = s; + return this; + } + + public TestRequest setParams(Map m) { + this.params = m; + return this; + } + + public TestRequest setParam(String key, @CheckForNull String value) { + if (value != null) { + params.put(key, value); + } + return this; + } + + @Override + @CheckForNull + public String param(String key) { + return params.get(key); + } + + public Result execute() throws Exception { + WebService.Action action = controller.action(actionKey); + if (action == null) { + throw new IllegalArgumentException("Action not found: " + actionKey); + } + TestResponse response = new TestResponse(); + action.handler().handle(this, response); + return new Result(response); + } + } + + public static class TestResponse implements Response { + + public class TestStream implements Response.Stream { + private String mediaType; + + @CheckForNull + public String mediaType() { + return mediaType; + } + + @Override + public Response.Stream setMediaType(String s) { + this.mediaType = s; + return this; + } + + @Override + public OutputStream output() { + return output; + } + } + + private int status = 200; + private final ByteArrayOutputStream output = new ByteArrayOutputStream(); + + @Override + public JsonWriter newJsonWriter() { + return JsonWriter.of(new OutputStreamWriter(output, Charsets.UTF_8)); + } + + @Override + public XmlWriter newXmlWriter() { + return XmlWriter.of(new OutputStreamWriter(output, Charsets.UTF_8)); + } + + @Override + public Stream stream() { + return new TestStream(); + } + + @Override + public int status() { + return status; + } + + @Override + public Response setStatus(int httpStatus) { + this.status = httpStatus; + return this; + } + } + + + public static class Result { + private final TestResponse response; + + private Result(TestResponse response) { + this.response = response; + } + + public Result assertStatus(int httpStatus) { + assertThat(httpStatus).isEqualTo(response.status()); + return this; + } + + public String outputAsString() { + return new String(response.output.toByteArray(), Charsets.UTF_8); + } + + public Result assertJson(String expectedJson) throws Exception { + String json = outputAsString(); + JSONAssert.assertEquals(expectedJson, json, true); + return this; + } + + /** + * Compares JSON response with JSON file available in classpath. For example if class + * is org.foo.BarTest and filename is index.json, then file must be located + * at src/test/resources/org/foo/BarTest/index.json. + * + * @param clazz the test class + * @param jsonResourceFilename name of the file containing the expected JSON + */ + public Result assertJson(Class clazz, String expectedJsonFilename) throws Exception { + String path = clazz.getSimpleName() + "/" + expectedJsonFilename; + URL url = clazz.getResource(path); + if (url == null) { + throw new IllegalStateException("Cannot find " + path); + } + String json = outputAsString(); + JSONAssert.assertEquals(IOUtils.toString(url), json, true); + return this; + } + } + + private final WebService.Context context = new WebService.Context(); + + public WsTester(WebService... webServices) { + for (WebService webService : webServices) { + webService.define(context); + } + } + + public WebService.Context context() { + return context; + } + + @CheckForNull + public WebService.Controller controller(String path) { + return context.controller(path); + } + + public TestRequest newRequest(String actionKey) { + if (context.controllers().size() != 1) { + throw new IllegalStateException("The method newRequest(String) requires to define one, and only one, controller"); + } + return new TestRequest(context.controllers().get(0), actionKey); + } + + public TestRequest newRequest(String controllerPath, String actionKey) { + return new TestRequest(context.controller(controllerPath), actionKey); + } +} -- 2.39.5