Initial version of sync-events plugin

This plugins allows to share stream events between two Gerrit instances
sharing the same git repositories and database.

The plugin needs to be installed in both instances and every time a
stream event occurs in one of the instances, the event is forwarded to
th other instance which re-plays it. This way, the output of the
stream-events command is the same, no matter what instance a client is
connected to.

Change-Id: I979ed4e21aa4c74de945b64242c3fee76b403861
diff --git a/.buckconfig b/.buckconfig
new file mode 100644
index 0000000..dd9e0cf
--- /dev/null
+++ b/.buckconfig
@@ -0,0 +1,14 @@
+[alias]
+  sync-events = //:sync-events
+  plugin = //:sync-events
+  src = //:sync-events-sources
+
+[java]
+  src_roots = java, resources
+
+[project]
+  ignore = .git
+
+[cache]
+  mode = dir
+  dir = buck-out/cache
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..175600f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+/.buckd
+/.buckversion
+/.classpath
+/.project
+/.settings/
+/.watchmanconfig
+/buck-out/
+/bucklets
+/eclipse-out/
diff --git a/BUCK b/BUCK
new file mode 100644
index 0000000..367d58e
--- /dev/null
+++ b/BUCK
@@ -0,0 +1,61 @@
+include_defs('//bucklets/gerrit_plugin.bucklet')
+include_defs('//bucklets/java_sources.bucklet')
+include_defs('//bucklets/maven_jar.bucklet')
+
+SOURCES = glob(['src/main/java/**/*.java'])
+RESOURCES = glob(['src/main/resources/**/*'])
+
+DEPS = [
+  ':wiremock',
+]
+
+PROVIDED_DEPS = GERRIT_TESTS + [
+  '//lib:gson',
+]
+
+TEST_DEPS = GERRIT_PLUGIN_API + PROVIDED_DEPS + DEPS + [
+  ':sync-events__plugin',
+]
+
+gerrit_plugin(
+  name = 'sync-events',
+  srcs = SOURCES,
+  resources = RESOURCES,
+  manifest_entries = [
+    'Gerrit-PluginName: sync-events',
+    'Gerrit-ApiType: plugin',
+    'Gerrit-Module: com.ericsson.gerrit.plugins.syncevents.Module',
+    'Gerrit-HttpModule: com.ericsson.gerrit.plugins.syncevents.HttpModule',
+    'Implementation-Title: sync-events plugin',
+    'Implementation-URL: https://gerrit-review.googlesource.com/#/admin/projects/plugins/sync-events',
+    'Implementation-Vendor: Ericsson',
+  ],
+  provided_deps = PROVIDED_DEPS,
+  deps = DEPS,
+)
+
+java_sources(
+  name = 'sync-events-sources',
+  srcs = SOURCES + RESOURCES,
+)
+
+java_library(
+  name = 'classpath',
+  deps = TEST_DEPS,
+)
+
+java_test(
+  name = 'sync-events_tests',
+  srcs = glob(['src/test/java/**/*.java']),
+  labels = ['sync-events'],
+  source_under_test = [':sync-events__plugin'],
+  deps = TEST_DEPS,
+)
+
+maven_jar(
+  name = 'wiremock',
+  id = 'com.github.tomakehurst:wiremock:1.58:standalone',
+  sha1 = '21c8386a95c5dc54a9c55839c5a95083e42412ae',
+  license = 'Apache2.0',
+  attach_source = False,
+)
diff --git a/lib/BUCK b/lib/BUCK
new file mode 100644
index 0000000..8892994
--- /dev/null
+++ b/lib/BUCK
@@ -0,0 +1,8 @@
+include_defs('//bucklets/maven_jar.bucklet')
+
+maven_jar(
+  name = 'gson',
+  id = 'com.google.code.gson:gson:2.1',
+  sha1 = '2e66da15851f9f5b5079228f856c2f090ba98c38',
+  license = 'Apache2.0',
+)
diff --git a/lib/gerrit/BUCK b/lib/gerrit/BUCK
new file mode 100644
index 0000000..fe8119e
--- /dev/null
+++ b/lib/gerrit/BUCK
@@ -0,0 +1,20 @@
+include_defs('//bucklets/maven_jar.bucklet')
+
+VER = '2.13-SNAPSHOT'
+REPO = MAVEN_LOCAL
+
+maven_jar(
+  name = 'plugin-api',
+  id = 'com.google.gerrit:gerrit-plugin-api:' + VER,
+  license = 'Apache2.0',
+  attach_source = False,
+  repository = REPO,
+)
+
+maven_jar(
+  name = 'acceptance-framework',
+  id = 'com.google.gerrit:gerrit-acceptance-framework:' + VER,
+  license = 'Apache2.0',
+  attach_source = False,
+  repository = REPO,
+)
diff --git a/src/main/java/com/ericsson/gerrit/plugins/syncevents/Configuration.java b/src/main/java/com/ericsson/gerrit/plugins/syncevents/Configuration.java
new file mode 100644
index 0000000..01a6db9
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/syncevents/Configuration.java
@@ -0,0 +1,86 @@
+// Copyright (C) 2015 Ericsson
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.syncevents;
+
+import com.google.common.base.CharMatcher;
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.config.PluginConfigFactory;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+class Configuration {
+  private static final int DEFAULT_TIMEOUT_MS = 5000;
+  private static final int DEFAULT_MAX_TRIES = 5;
+  private static final int DEFAULT_RETRY_INTERVAL = 1000;
+  private static final int DEFAULT_THREAD_POOL_SIZE = 1;
+
+  private final String url;
+  private final String user;
+  private final String password;
+  private final int connectionTimeout;
+  private final int socketTimeout;
+  private final int maxTries;
+  private final int retryInterval;
+  private final int threadPoolSize;
+
+  @Inject
+  Configuration(PluginConfigFactory config,
+      @PluginName String pluginName) {
+    PluginConfig cfg = config.getFromGerritConfig(pluginName, true);
+    url = Strings.nullToEmpty(cfg.getString("url"));
+    user = Strings.nullToEmpty(cfg.getString("user"));
+    password = Strings.nullToEmpty(cfg.getString("password"));
+    connectionTimeout = cfg.getInt("connectionTimeout", DEFAULT_TIMEOUT_MS);
+    socketTimeout = cfg.getInt("socketTimeout", DEFAULT_TIMEOUT_MS);
+    maxTries = cfg.getInt("maxTries", DEFAULT_MAX_TRIES);
+    retryInterval = cfg.getInt("retryInterval", DEFAULT_RETRY_INTERVAL);
+    threadPoolSize = cfg.getInt("threadPoolSize", DEFAULT_THREAD_POOL_SIZE);
+  }
+
+  int getConnectionTimeout() {
+    return connectionTimeout;
+  }
+
+  int getMaxTries() {
+    return maxTries;
+  }
+
+  int getRetryInterval() {
+    return retryInterval;
+  }
+
+  int getSocketTimeout() {
+    return socketTimeout;
+  }
+
+  String getUrl() {
+    return CharMatcher.is('/').trimTrailingFrom(url);
+  }
+
+  String getUser() {
+    return user;
+  }
+
+  String getPassword() {
+    return password;
+  }
+
+  int getThreadPoolSize() {
+    return threadPoolSize;
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/syncevents/Context.java b/src/main/java/com/ericsson/gerrit/plugins/syncevents/Context.java
new file mode 100644
index 0000000..ba677c9
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/syncevents/Context.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2015 Ericsson
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.syncevents;
+
+/**
+ * Allows to tag a forwarded event to avoid infinitely looping events.
+ */
+class Context {
+  private static final ThreadLocal<Boolean> FORWARDED_EVENT =
+      new ThreadLocal<Boolean>() {
+        @Override
+        protected Boolean initialValue() {
+          return false;
+        }
+      };
+
+  private Context() {
+  }
+
+  static Boolean isForwardedEvent() {
+    return FORWARDED_EVENT.get();
+  }
+
+  static void setForwardedEvent() {
+    FORWARDED_EVENT.set(true);
+  }
+
+  static void unsetForwardedEvent() {
+    FORWARDED_EVENT.remove();
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/syncevents/EventHandler.java b/src/main/java/com/ericsson/gerrit/plugins/syncevents/EventHandler.java
new file mode 100644
index 0000000..73f8708
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/syncevents/EventHandler.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2015 Ericsson
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.syncevents;
+
+import com.google.gerrit.common.EventListener;
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.server.events.Event;
+import com.google.gerrit.server.events.ProjectEvent;
+import com.google.inject.Inject;
+
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+
+class EventHandler implements EventListener {
+  private final ScheduledThreadPoolExecutor executor;
+  private final RestSession restClient;
+  private final String pluginName;
+
+  @Inject
+  EventHandler(RestSession restClient,
+      @SyncEventExecutor ScheduledThreadPoolExecutor executor,
+      @PluginName String pluginName) {
+    this.restClient = restClient;
+    this.executor = executor;
+    this.pluginName = pluginName;
+  }
+
+  @Override
+  public void onEvent(Event event) {
+    if (!Context.isForwardedEvent() && event instanceof ProjectEvent) {
+      executor.execute(new SyncEventTask(event));
+    }
+  }
+
+  class SyncEventTask implements Runnable {
+    private Event event;
+
+    SyncEventTask(Event event) {
+      this.event = event;
+    }
+
+    @Override
+    public void run() {
+      restClient.send(event);
+    }
+
+    @Override
+    public String toString() {
+      return String.format("[%s] Send event '%s' to target instance",
+          pluginName, event.type);
+    }
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/syncevents/HttpClientProvider.java b/src/main/java/com/ericsson/gerrit/plugins/syncevents/HttpClientProvider.java
new file mode 100644
index 0000000..63783fc
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/syncevents/HttpClientProvider.java
@@ -0,0 +1,202 @@
+// Copyright (C) 2015 Ericsson
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.syncevents;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+import org.apache.http.HttpResponse;
+import org.apache.http.auth.AuthScope;
+import org.apache.http.auth.UsernamePasswordCredentials;
+import org.apache.http.client.HttpRequestRetryHandler;
+import org.apache.http.client.ServiceUnavailableRetryStrategy;
+import org.apache.http.client.config.RequestConfig;
+import org.apache.http.config.Registry;
+import org.apache.http.config.RegistryBuilder;
+import org.apache.http.conn.HttpClientConnectionManager;
+import org.apache.http.conn.socket.ConnectionSocketFactory;
+import org.apache.http.conn.socket.PlainConnectionSocketFactory;
+import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
+import org.apache.http.impl.client.BasicCredentialsProvider;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
+import org.apache.http.protocol.HttpContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.net.URI;
+import java.security.KeyManagementException;
+import java.security.NoSuchAlgorithmException;
+import java.security.cert.X509Certificate;
+
+import javax.net.ssl.HostnameVerifier;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLException;
+import javax.net.ssl.SSLSession;
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.X509TrustManager;
+
+/**
+ * Provides an HTTP client with SSL capabilities.
+ */
+class HttpClientProvider implements Provider<CloseableHttpClient> {
+  private static final Logger log =
+      LoggerFactory.getLogger(HttpClientProvider.class);
+  private static final int CONNECTIONS_PER_ROUTE = 100;
+  // Up to 2 target instances with the max number of connections per host:
+  private static final int MAX_CONNECTIONS = 2 * CONNECTIONS_PER_ROUTE;
+
+  private static final int ERROR_CODES = 500;
+  private static final int MAX_CONNECTION_INACTIVITY = 10000;
+
+  private final Configuration cfg;
+  private final SSLConnectionSocketFactory sslSocketFactory;
+
+  @Inject
+  HttpClientProvider(Configuration cfg) {
+    this.cfg = cfg;
+    this.sslSocketFactory = buildSslSocketFactory();
+  }
+
+  @Override
+  public CloseableHttpClient get() {
+    return HttpClients.custom().setSSLSocketFactory(sslSocketFactory)
+        .setConnectionManager(customConnectionManager())
+        .setDefaultCredentialsProvider(buildCredentials())
+        .setDefaultRequestConfig(customRequestConfig())
+        .setRetryHandler(customRetryHandler())
+        .setServiceUnavailableRetryStrategy(customServiceUnavailRetryStrategy())
+        .build();
+  }
+
+  private RequestConfig customRequestConfig() {
+    return RequestConfig.custom().setConnectTimeout(cfg.getConnectionTimeout())
+        .setSocketTimeout(cfg.getSocketTimeout())
+        .setConnectionRequestTimeout(cfg.getConnectionTimeout())
+        .build();
+  }
+
+  private HttpRequestRetryHandler customRetryHandler() {
+    return new HttpRequestRetryHandler() {
+
+      @Override
+      public boolean retryRequest(IOException exception, int executionCount,
+          HttpContext context) {
+        if (executionCount > cfg.getMaxTries()
+            || exception instanceof SSLException) {
+          return false;
+        }
+        logRetry(exception.getMessage());
+        try {
+          Thread.sleep(cfg.getRetryInterval());
+        } catch (InterruptedException e) {
+          log.debug("Ignoring InterruptedException", e);
+        }
+        return true;
+      }
+    };
+  }
+
+  private ServiceUnavailableRetryStrategy customServiceUnavailRetryStrategy() {
+    return new ServiceUnavailableRetryStrategy() {
+      @Override
+      public boolean retryRequest(HttpResponse response, int executionCount,
+          HttpContext context) {
+        if (executionCount > cfg.getMaxTries()) {
+          return false;
+        }
+        if (response.getStatusLine().getStatusCode() >= ERROR_CODES) {
+          logRetry(response.getStatusLine().getReasonPhrase());
+          return true;
+        }
+        return false;
+      }
+
+      @Override
+      public long getRetryInterval() {
+        return cfg.getRetryInterval();
+      }
+    };
+  }
+
+  private void logRetry(String cause) {
+    log.warn("Retrying request to '" + cfg.getUrl() + "' Cause: " + cause);
+  }
+
+  private HttpClientConnectionManager customConnectionManager() {
+    Registry<ConnectionSocketFactory> socketFactoryRegistry = RegistryBuilder
+        .<ConnectionSocketFactory> create().register("https", sslSocketFactory)
+        .register("http", PlainConnectionSocketFactory.INSTANCE).build();
+    PoolingHttpClientConnectionManager connManager =
+        new PoolingHttpClientConnectionManager(socketFactoryRegistry);
+    connManager.setDefaultMaxPerRoute(CONNECTIONS_PER_ROUTE);
+    connManager.setMaxTotal(MAX_CONNECTIONS);
+    connManager.setValidateAfterInactivity(MAX_CONNECTION_INACTIVITY);
+    return connManager;
+  }
+
+  private SSLConnectionSocketFactory buildSslSocketFactory() {
+    return new SSLConnectionSocketFactory(buildSslContext(),
+        new DummyHostnameVerifier());
+  }
+
+  private SSLContext buildSslContext() {
+    try {
+      TrustManager[] trustAllCerts =
+          new TrustManager[] {new DummyX509TrustManager()};
+      SSLContext context = SSLContext.getInstance("TLS");
+      context.init(null, trustAllCerts, null);
+      return context;
+    } catch (KeyManagementException | NoSuchAlgorithmException e) {
+      log.warn("Error building SSLContext object", e);
+      return null;
+    }
+  }
+
+  private BasicCredentialsProvider buildCredentials() {
+    URI uri = URI.create(cfg.getUrl());
+    BasicCredentialsProvider creds = new BasicCredentialsProvider();
+    creds.setCredentials(new AuthScope(uri.getHost(), uri.getPort()),
+        new UsernamePasswordCredentials(cfg.getUser(), cfg.getPassword()));
+    return creds;
+  }
+
+  private static class DummyX509TrustManager implements X509TrustManager {
+    @Override
+    public X509Certificate[] getAcceptedIssuers() {
+      return new X509Certificate[0];
+    }
+
+    @Override
+    public void checkClientTrusted(X509Certificate[] certs, String authType) {
+      // no check
+    }
+
+    @Override
+    public void checkServerTrusted(X509Certificate[] certs, String authType) {
+      // no check
+    }
+  }
+
+  private static class DummyHostnameVerifier implements HostnameVerifier {
+    @Override
+    public boolean verify(String hostname, SSLSession session) {
+      // always accept
+      return true;
+    }
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/syncevents/HttpModule.java b/src/main/java/com/ericsson/gerrit/plugins/syncevents/HttpModule.java
new file mode 100644
index 0000000..58cc1fc
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/syncevents/HttpModule.java
@@ -0,0 +1,24 @@
+// Copyright (C) 2015 Ericsson
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.syncevents;
+
+import com.google.gerrit.httpd.plugins.HttpPluginModule;
+
+class HttpModule extends HttpPluginModule {
+  @Override
+  protected void configureServlets() {
+    serve("/event").with(SyncEventsRestApiServlet.class);
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/syncevents/HttpSession.java b/src/main/java/com/ericsson/gerrit/plugins/syncevents/HttpSession.java
new file mode 100644
index 0000000..b1c32e5
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/syncevents/HttpSession.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2015 Ericsson
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.syncevents;
+
+import com.google.common.base.Strings;
+import com.google.common.net.MediaType;
+import com.google.inject.Inject;
+
+import com.ericsson.gerrit.plugins.syncevents.SyncEventsResponseHandler.SyncResult;
+
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.impl.client.CloseableHttpClient;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+class HttpSession {
+  private final CloseableHttpClient httpClient;
+  private final String url;
+
+  @Inject
+  HttpSession(CloseableHttpClient httpClient,
+      @SyncUrl String url) {
+    this.httpClient = httpClient;
+    this.url = url;
+  }
+
+  SyncResult post(String endpoint, String content) throws IOException {
+    HttpPost post = new HttpPost(url + endpoint);
+    if (!Strings.isNullOrEmpty(content)) {
+      post.addHeader("Content-Type", MediaType.JSON_UTF_8.toString());
+      post.setEntity(new StringEntity(content, StandardCharsets.UTF_8));
+    }
+    return httpClient.execute(post, new SyncEventsResponseHandler());
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/syncevents/Module.java b/src/main/java/com/ericsson/gerrit/plugins/syncevents/Module.java
new file mode 100644
index 0000000..39d9453
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/syncevents/Module.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2015 Ericsson
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.syncevents;
+
+import com.google.gerrit.common.EventDispatcher;
+import com.google.gerrit.common.EventListener;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.inject.Provides;
+import com.google.inject.Scopes;
+
+import org.apache.http.impl.client.CloseableHttpClient;
+
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+
+class Module extends LifecycleModule {
+
+  @Override
+  protected void configure() {
+    bind(CloseableHttpClient.class).toProvider(HttpClientProvider.class)
+        .in(Scopes.SINGLETON);
+    bind(Configuration.class);
+    bind(HttpSession.class);
+    bind(RestSession.class);
+    bind(ScheduledThreadPoolExecutor.class)
+        .annotatedWith(SyncEventExecutor.class)
+        .toProvider(SyncEventExecutorProvider.class);
+    listener().to(SyncEventExecutorProvider.class);
+    DynamicSet.bind(binder(), EventListener.class).to(EventHandler.class);
+    DynamicItem.bind(binder(), EventDispatcher.class).to(SyncEventBroker.class);
+  }
+
+  @Provides
+  @SyncUrl
+  String syncUrl(Configuration config) {
+    return config.getUrl();
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/syncevents/RestSession.java b/src/main/java/com/ericsson/gerrit/plugins/syncevents/RestSession.java
new file mode 100644
index 0000000..94e1b90
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/syncevents/RestSession.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2015 Ericsson
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.syncevents;
+
+import com.google.common.base.Joiner;
+import com.google.common.base.Supplier;
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.server.events.Event;
+import com.google.gerrit.server.events.SupplierSerializer;
+import com.google.gson.GsonBuilder;
+import com.google.inject.Inject;
+
+import com.ericsson.gerrit.plugins.syncevents.SyncEventsResponseHandler.SyncResult;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+
+class RestSession {
+  private static final Logger log = LoggerFactory.getLogger(RestSession.class);
+  private final HttpSession httpSession;
+  private final String pluginName;
+
+  @Inject
+  RestSession(HttpSession httpClient,
+      @PluginName String pluginName) {
+    this.httpSession = httpClient;
+    this.pluginName = pluginName;
+  }
+
+  boolean send(Event event) {
+    String serializedEvent = new GsonBuilder()
+        .registerTypeAdapter(Supplier.class, new SupplierSerializer()).create()
+        .toJson(event);
+    try {
+      SyncResult result = httpSession.post(buildEndpoint(), serializedEvent);
+      if (result.isSuccessful()) {
+        return true;
+      }
+      log.error(
+          "Unable to send event '" + event.type + "' " + result.getMessage());
+    } catch (IOException e) {
+      log.error("Error trying to send event " + event.type, e);
+    }
+    return false;
+  }
+
+  private String buildEndpoint() {
+    return Joiner.on("/").join("/plugins", pluginName, "event");
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/syncevents/SyncEventBroker.java b/src/main/java/com/ericsson/gerrit/plugins/syncevents/SyncEventBroker.java
new file mode 100644
index 0000000..f869097
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/syncevents/SyncEventBroker.java
@@ -0,0 +1,42 @@
+// Copyright (C) 2016 Ericsson
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.syncevents;
+
+import com.google.gerrit.common.EventBroker;
+import com.google.gerrit.common.EventListener;
+import com.google.gerrit.common.UserScopedEventListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.events.Event;
+import com.google.gerrit.server.notedb.ChangeNotes.Factory;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.inject.Inject;
+
+public class SyncEventBroker extends EventBroker {
+
+  @Inject
+  public SyncEventBroker(DynamicSet<UserScopedEventListener> listeners,
+      DynamicSet<EventListener> unrestrictedListeners,
+      ProjectCache projectCache,
+      Factory notesFactory) {
+    super(listeners, unrestrictedListeners, projectCache, notesFactory);
+  }
+
+  @Override
+  protected void fireEventForUnrestrictedListeners(Event event) {
+    if (!Context.isForwardedEvent()) {
+      super.fireEventForUnrestrictedListeners(event);
+    }
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/syncevents/SyncEventExecutor.java b/src/main/java/com/ericsson/gerrit/plugins/syncevents/SyncEventExecutor.java
new file mode 100644
index 0000000..841be21
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/syncevents/SyncEventExecutor.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2015 Ericsson
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.syncevents;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+
+import java.lang.annotation.Retention;
+
+@Retention(RUNTIME)
+@BindingAnnotation
+@interface SyncEventExecutor {
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/syncevents/SyncEventExecutorProvider.java b/src/main/java/com/ericsson/gerrit/plugins/syncevents/SyncEventExecutorProvider.java
new file mode 100644
index 0000000..3ddec77
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/syncevents/SyncEventExecutorProvider.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2015 Ericsson
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.syncevents;
+
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+
+@Singleton
+class SyncEventExecutorProvider
+    implements Provider<ScheduledThreadPoolExecutor>, LifecycleListener {
+  private WorkQueue.Executor executor;
+
+  @Inject
+  SyncEventExecutorProvider(WorkQueue workQueue,
+      @PluginName String pluginName,
+      Configuration config) {
+    executor = workQueue.createQueue(config.getThreadPoolSize(),
+        "Sync stream events [" + pluginName + " plugin]");
+  }
+
+  @Override
+  public void start() {
+    // do nothing
+  }
+
+  @Override
+  public void stop() {
+    executor.shutdown();
+    executor.unregisterWorkQueue();
+    executor = null;
+  }
+
+  @Override
+  public ScheduledThreadPoolExecutor get() {
+    return executor;
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/syncevents/SyncEventsResponseHandler.java b/src/main/java/com/ericsson/gerrit/plugins/syncevents/SyncEventsResponseHandler.java
new file mode 100644
index 0000000..59cebf3
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/syncevents/SyncEventsResponseHandler.java
@@ -0,0 +1,74 @@
+// Copyright (C) 2015 Ericsson
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.syncevents;
+
+import static javax.servlet.http.HttpServletResponse.SC_NO_CONTENT;
+
+import com.ericsson.gerrit.plugins.syncevents.SyncEventsResponseHandler.SyncResult;
+
+import org.apache.http.HttpEntity;
+import org.apache.http.HttpResponse;
+import org.apache.http.client.ResponseHandler;
+import org.apache.http.util.EntityUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+
+class SyncEventsResponseHandler implements ResponseHandler<SyncResult> {
+
+  static class SyncResult {
+    private boolean successful;
+    private String message;
+
+    SyncResult(boolean successful, String message) {
+      this.successful = successful;
+      this.message = message;
+    }
+
+    boolean isSuccessful() {
+      return successful;
+    }
+
+    String getMessage() {
+      return message;
+    }
+  }
+
+  private static final Logger log = LoggerFactory
+      .getLogger(SyncEventsResponseHandler.class);
+
+  @Override
+  public SyncResult handleResponse(HttpResponse response) {
+    return new SyncResult(isSuccessful(response), parseResponse(response));
+  }
+
+  private boolean isSuccessful(HttpResponse response) {
+    return response.getStatusLine().getStatusCode() == SC_NO_CONTENT;
+  }
+
+  private String parseResponse(HttpResponse response) {
+    HttpEntity entity = response.getEntity();
+    String asString = "";
+    if (entity != null) {
+      try {
+        asString = EntityUtils.toString(entity);
+      } catch (IOException e) {
+        log.error("Error parsing entity", e);
+      }
+    }
+    return asString;
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/syncevents/SyncEventsRestApiServlet.java b/src/main/java/com/ericsson/gerrit/plugins/syncevents/SyncEventsRestApiServlet.java
new file mode 100644
index 0000000..cbb903d
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/syncevents/SyncEventsRestApiServlet.java
@@ -0,0 +1,97 @@
+// Copyright (C) 2015 Ericsson
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.syncevents;
+
+import static com.google.common.net.MediaType.JSON_UTF_8;
+import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
+import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
+import static javax.servlet.http.HttpServletResponse.SC_NO_CONTENT;
+
+import com.google.common.base.Supplier;
+import com.google.common.io.CharStreams;
+import com.google.common.net.MediaType;
+import com.google.gerrit.common.EventDispatcher;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.events.Event;
+import com.google.gerrit.server.events.EventDeserializer;
+import com.google.gerrit.server.events.SupplierDeserializer;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+@Singleton
+class SyncEventsRestApiServlet extends HttpServlet {
+  private static final long serialVersionUID = -1L;
+  private static final Logger logger = LoggerFactory
+      .getLogger(SyncEventsRestApiServlet.class);
+
+  private final EventDispatcher dispatcher;
+  private final SchemaFactory<ReviewDb> schema;
+
+  @Inject
+  SyncEventsRestApiServlet(EventDispatcher dispatcher,
+      SchemaFactory<ReviewDb> schema) {
+    this.dispatcher = dispatcher;
+    this.schema = schema;
+  }
+
+  @Override
+  protected void doPost(HttpServletRequest req, HttpServletResponse rsp)
+      throws IOException, ServletException {
+    rsp.setContentType("text/plain");
+    rsp.setCharacterEncoding("UTF-8");
+    try {
+      Context.setForwardedEvent();
+      Event event = getEventFromRequest(req);
+      try (ReviewDb db = schema.open()) {
+        dispatcher.postEvent(event, db);
+      }
+      rsp.setStatus(SC_NO_CONTENT);
+    } catch (OrmException e) {
+      rsp.sendError(SC_NOT_FOUND, "Change not found\n");
+      logger.debug("Error trying to find a change ", e);
+    } catch (IOException e) {
+      rsp.sendError(SC_BAD_REQUEST, e.getMessage());
+      logger.error("Unable to re-trigger event", e);
+    } finally {
+      Context.unsetForwardedEvent();
+    }
+  }
+
+  private Event getEventFromRequest(HttpServletRequest req) throws IOException {
+    if (MediaType.parse(req.getContentType()).is(JSON_UTF_8)) {
+      String jsonEvent = CharStreams.toString(req.getReader());
+      Gson gson = new GsonBuilder()
+          .registerTypeAdapter(Event.class, new EventDeserializer())
+          .registerTypeAdapter(Supplier.class, new SupplierDeserializer())
+          .create();
+      return gson.fromJson(jsonEvent, Event.class);
+    }
+    return null;
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/syncevents/SyncUrl.java b/src/main/java/com/ericsson/gerrit/plugins/syncevents/SyncUrl.java
new file mode 100644
index 0000000..851aec8
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/syncevents/SyncUrl.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2015 Ericsson
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.syncevents;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+
+import java.lang.annotation.Retention;
+
+@Retention(RUNTIME)
+@BindingAnnotation
+@interface SyncUrl {
+}
diff --git a/src/main/resources/Documentation/about.md b/src/main/resources/Documentation/about.md
new file mode 100644
index 0000000..f887cca
--- /dev/null
+++ b/src/main/resources/Documentation/about.md
@@ -0,0 +1,13 @@
+The @PLUGIN@ plugin allows to share stream events between two Gerrit instances
+sharing the same git repositories and database.
+
+The plugin needs to be installed in both instances and every time a stream event occurs in
+one of the instances (see [more events info]
+(https://gerrit-review.googlesource.com/Documentation/cmd-stream-events.html#events)),
+the event is forwarded to the other instance which re-plays it. This way, the output
+of the stream-events command is the same, no matter what instance a client is
+connected to.
+
+For this to work, http must be enabled in both instances and the plugin
+must be configured with valid credentials. For further information, refer to
+[config](config.html) documentation.
diff --git a/src/main/resources/Documentation/build.md b/src/main/resources/Documentation/build.md
new file mode 100644
index 0000000..3babed2
--- /dev/null
+++ b/src/main/resources/Documentation/build.md
@@ -0,0 +1,106 @@
+Build
+=====
+
+This plugin is built with Buck.
+
+Two build modes are supported: Standalone and in Gerrit tree. Standalone
+build mode is recommended, as this mode doesn't require local Gerrit
+tree to exist.
+
+Build standalone
+----------------
+
+Clone bucklets library:
+
+```
+  git clone https://gerrit.googlesource.com/bucklets
+
+```
+and link it to @PLUGIN@ directory:
+
+```
+  cd @PLUGIN@ && ln -s ../bucklets .
+```
+
+Add link to the .buckversion file:
+
+```
+  cd @PLUGIN@ && ln -s bucklets/buckversion .buckversion
+```
+
+Add link to the .watchmanconfig file:
+
+```
+  cd @PLUGIN@ && ln -s bucklets/watchmanconfig .watchmanconfig
+```
+
+To build the plugin, issue the following command:
+
+```
+  buck build plugin
+```
+
+The output is created in:
+
+```
+  buck-out/gen/@PLUGIN@.jar
+```
+
+This project can be imported into the Eclipse IDE:
+
+```
+  ./bucklets/tools/eclipse.py
+```
+
+To execute the tests run:
+
+```
+  buck test
+```
+
+To build plugin sources run:
+
+```
+  buck build src
+```
+
+The output is created in:
+
+```
+  buck-out/gen/@PLUGIN@-sources.jar
+```
+
+Build in Gerrit tree
+--------------------
+
+Clone or link this plugin to the plugins directory of Gerrit's source
+tree, and issue the command:
+
+```
+  buck build plugins/@PLUGIN@
+```
+
+The output is created in:
+
+```
+  buck-out/gen/plugins/@PLUGIN@/@PLUGIN@.jar
+```
+
+This project can be imported into the Eclipse IDE:
+
+```
+  ./tools/eclipse/project.py
+```
+
+To execute the tests run:
+
+```
+  buck test --include @PLUGIN@
+```
+
+How to build the Gerrit Plugin API is described in the [Gerrit
+documentation](../../../Documentation/dev-buck.html#_extension_and_plugin_api_jar_files).
+
+[Back to @PLUGIN@ documentation index][index]
+
+[index]: index.html
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
new file mode 100644
index 0000000..f01df3f
--- /dev/null
+++ b/src/main/resources/Documentation/config.md
@@ -0,0 +1,45 @@
+@PLUGIN@ Configuration
+=========================
+
+The @PLUGIN@ plugin must be installed in both instances and the following fields
+should be specified in the corresponding Gerrit configuration file:
+
+File 'gerrit.config'
+--------------------
+
+plugin.@PLUGIN@.url
+:   Specify the URL for the secondary (target) instance.
+
+plugin.@PLUGIN@.user
+:   Username to connect to the secondary (target) instance.
+
+plugin.@PLUGIN@.password
+:   Password to connect to the secondary (target) instance. This value can
+    also be defined in secure.config.
+
+@PLUGIN@ plugin uses REST API calls to send events to the target instance. It
+is possible to customize the parameters of the underlying http client doing these
+calls by specifying the following fields:
+
+@PLUGIN@.connectionTimeout
+:   Maximum interval of time in milliseconds the plugin waits for a connection
+    to the target instance. When not specified, the default value is set to 5000ms.
+
+@PLUGIN@.socketTimeout
+:   Maximum interval of time in milliseconds the plugin waits for a response from the
+    target instance once the connection has been established. When not specified,
+    the default value is set to 5000ms.
+
+@PLUGIN@.maxTries
+:   Maximum number of times the plugin should attempt to send the event to the
+    target instance. Setting this value to 0 will disable retries. When not
+    specified, the default value is 5. After this number of failed tries, an error
+    is logged.
+
+@PLUGIN@.retryInterval
+:   The interval of time in milliseconds between the subsequent auto-retries.
+    When not specified, the default value is set to 1000ms.
+
+@PLUGIN@.threadPoolSize
+:   Maximum number of threads used to send stream events to the target instance.
+    Defaults to 1.
diff --git a/src/test/java/com/ericsson/gerrit/plugins/syncevents/ConfigurationTest.java b/src/test/java/com/ericsson/gerrit/plugins/syncevents/ConfigurationTest.java
new file mode 100644
index 0000000..da0708e
--- /dev/null
+++ b/src/test/java/com/ericsson/gerrit/plugins/syncevents/ConfigurationTest.java
@@ -0,0 +1,103 @@
+// Copyright (C) 2015 Ericsson
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.syncevents;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.easymock.EasyMock.expect;
+
+import org.easymock.EasyMockSupport;
+import org.junit.Before;
+import org.junit.Test;
+
+import com.ericsson.gerrit.plugins.syncevents.Configuration;
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.config.PluginConfigFactory;
+
+public class ConfigurationTest extends EasyMockSupport {
+  private static final String PASS = "fakePass";
+  private static final String USER = "fakeUser";
+  private static final String URL = "fakeUrl";
+  private static final String EMPTY = "";
+  private static final int TIMEOUT = 5000;
+  private static final int MAX_TRIES = 5;
+  private static final int RETRY_INTERVAL = 1000;
+  private static final int THREAD_POOL_SIZE = 1;
+
+  private PluginConfigFactory cfgFactoryMock;
+  private PluginConfig configMock;
+  private Configuration configuration;
+  private String pluginName = "sync-events";
+
+  @Before
+  public void setUp() throws Exception {
+    configMock = createNiceMock(PluginConfig.class);
+    cfgFactoryMock = createMock(PluginConfigFactory.class);
+    expect(cfgFactoryMock.getFromGerritConfig(pluginName, true))
+        .andStubReturn(configMock);
+  }
+
+  @Test
+  public void testValuesPresentInGerritConfig() throws Exception {
+    buildMocks(true);
+    assertThat(configuration.getUrl()).isEqualTo(URL);
+    assertThat(configuration.getUser()).isEqualTo(USER);
+    assertThat(configuration.getPassword()).isEqualTo(PASS);
+    assertThat(configuration.getConnectionTimeout()).isEqualTo(TIMEOUT);
+    assertThat(configuration.getSocketTimeout()).isEqualTo(TIMEOUT);
+    assertThat(configuration.getMaxTries()).isEqualTo(MAX_TRIES);
+    assertThat(configuration.getRetryInterval()).isEqualTo(RETRY_INTERVAL);
+    assertThat(configuration.getThreadPoolSize()).isEqualTo(THREAD_POOL_SIZE);
+  }
+
+  @Test
+  public void testValuesNotPresentInGerritConfig() throws Exception {
+    buildMocks(false);
+    assertThat(configuration.getUrl()).isEqualTo(EMPTY);
+    assertThat(configuration.getUser()).isEqualTo(EMPTY);
+    assertThat(configuration.getPassword()).isEqualTo(EMPTY);
+    assertThat(configuration.getConnectionTimeout()).isEqualTo(0);
+    assertThat(configuration.getSocketTimeout()).isEqualTo(0);
+    assertThat(configuration.getMaxTries()).isEqualTo(0);
+    assertThat(configuration.getRetryInterval()).isEqualTo(0);
+    assertThat(configuration.getThreadPoolSize()).isEqualTo(0);
+  }
+
+  @Test
+  public void testUrlTrailingSlashIsDropped() throws Exception {
+    expect(configMock.getString("url")).andReturn(URL + "/");
+    replayAll();
+    configuration = new Configuration(cfgFactoryMock, pluginName);
+    assertThat(configuration).isNotNull();
+    assertThat(configuration.getUrl()).isEqualTo(URL);
+  }
+
+  private void buildMocks(boolean values) {
+    expect(configMock.getString("url")).andReturn(values ? URL : null);
+    expect(configMock.getString("user")).andReturn(values ? USER : null);
+    expect(configMock.getString("password")).andReturn(values ? PASS : null);
+    expect(configMock.getInt("connectionTimeout", TIMEOUT))
+        .andReturn(values ? TIMEOUT : 0);
+    expect(configMock.getInt("socketTimeout", TIMEOUT))
+        .andReturn(values ? TIMEOUT : 0);
+    expect(configMock.getInt("maxTries", MAX_TRIES))
+        .andReturn(values ? MAX_TRIES : 0);
+    expect(configMock.getInt("retryInterval", RETRY_INTERVAL))
+        .andReturn(values ? RETRY_INTERVAL : 0);
+    expect(configMock.getInt("threadPoolSize", THREAD_POOL_SIZE))
+        .andReturn(values ? THREAD_POOL_SIZE : 0);
+    replayAll();
+    configuration = new Configuration(cfgFactoryMock, pluginName);
+  }
+}
diff --git a/src/test/java/com/ericsson/gerrit/plugins/syncevents/ContextTest.java b/src/test/java/com/ericsson/gerrit/plugins/syncevents/ContextTest.java
new file mode 100644
index 0000000..bb36c4d
--- /dev/null
+++ b/src/test/java/com/ericsson/gerrit/plugins/syncevents/ContextTest.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2015 Ericsson
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.syncevents;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.easymock.EasyMockSupport;
+import org.junit.Test;
+
+import com.ericsson.gerrit.plugins.syncevents.Context;
+
+public class ContextTest extends EasyMockSupport {
+
+  @Test
+  public void testInitialValueNotNull() throws Exception {
+    assertThat(Context.isForwardedEvent()).isNotNull();
+    assertThat(Context.isForwardedEvent()).isFalse();
+  }
+
+  @Test
+  public void testSetForwardedEvent() throws Exception {
+    Context.setForwardedEvent();
+    try {
+      assertThat(Context.isForwardedEvent()).isTrue();
+    } finally {
+      Context.unsetForwardedEvent();
+    }
+  }
+
+  @Test
+  public void testUnsetForwardedEvent() throws Exception {
+    Context.setForwardedEvent();
+    Context.unsetForwardedEvent();
+    assertThat(Context.isForwardedEvent()).isFalse();
+  }
+}
diff --git a/src/test/java/com/ericsson/gerrit/plugins/syncevents/EventHandlerTest.java b/src/test/java/com/ericsson/gerrit/plugins/syncevents/EventHandlerTest.java
new file mode 100644
index 0000000..aefd111
--- /dev/null
+++ b/src/test/java/com/ericsson/gerrit/plugins/syncevents/EventHandlerTest.java
@@ -0,0 +1,99 @@
+// Copyright (C) 2015 Ericsson
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.syncevents;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.easymock.EasyMock.expect;
+
+import com.google.gerrit.server.events.Event;
+import com.google.gerrit.server.events.RefEvent;
+
+import org.easymock.EasyMockSupport;
+import org.junit.Test;
+
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+
+public class EventHandlerTest extends EasyMockSupport {
+  private static final String PLUGIN_NAME = "sync-event";
+
+  private Event event;
+  private EventHandler eventHandler;
+  private RestSession restClient;
+  private ScheduledThreadPoolExecutor pool;
+
+  @Test
+  public void testRightEventAndNotForwarded() throws Exception {
+    setUpMocks(true, true);
+    resetAll();
+    expect(restClient.send(event)).andReturn(true);
+    replayAll();
+    eventHandler.onEvent(event);
+    verifyAll();
+  }
+
+  @Test
+  public void testRightEventBitItIsForwarded() throws Exception {
+    setUpMocks(false, true);
+    Context.setForwardedEvent();
+    eventHandler.onEvent(event);
+    Context.unsetForwardedEvent();
+    verifyAll();
+  }
+
+  @Test
+  public void testBadEventAndNotForwarded() throws Exception {
+    setUpMocks(false, false);
+    eventHandler.onEvent(event);
+    verifyAll();
+  }
+
+  @Test
+  public void testBadEventAndItIsForwarded() throws Exception {
+    setUpMocks(false, false);
+    Context.setForwardedEvent();
+    eventHandler.onEvent(event);
+    Context.unsetForwardedEvent();
+    verifyAll();
+  }
+
+  private void setUpMocks(boolean mockRestClient, boolean rightEvent) {
+    pool = new PoolMock(1);
+    if (mockRestClient) {
+      restClient = createMock(RestSession.class);
+    } else {
+      restClient = null;
+    }
+    if (rightEvent) {
+      event = createNiceMock(RefEvent.class);
+    } else {
+      event = createNiceMock(Event.class);
+    }
+    replayAll();
+    eventHandler = new EventHandler(restClient, pool, PLUGIN_NAME);
+  }
+
+  private class PoolMock extends ScheduledThreadPoolExecutor {
+    PoolMock(int corePoolSize) {
+      super(corePoolSize);
+    }
+
+    @Override
+    public void execute(Runnable command) {
+      assertThat(command.toString()).isEqualTo(String
+          .format("[%s] Send event '%s' to target instance", PLUGIN_NAME, null));
+      command.run();
+    }
+  }
+}
diff --git a/src/test/java/com/ericsson/gerrit/plugins/syncevents/HttpClientProviderTest.java b/src/test/java/com/ericsson/gerrit/plugins/syncevents/HttpClientProviderTest.java
new file mode 100644
index 0000000..0af08b4
--- /dev/null
+++ b/src/test/java/com/ericsson/gerrit/plugins/syncevents/HttpClientProviderTest.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2015 Ericsson
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.syncevents;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.easymock.EasyMock.expect;
+
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.google.inject.Scopes;
+
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.easymock.EasyMockSupport;
+import org.junit.Before;
+import org.junit.Test;
+
+public class HttpClientProviderTest extends EasyMockSupport {
+  private static final int TIME_INTERVAL = 1000;
+  private static final String EMPTY = "";
+
+  private Configuration config;
+
+  @Before
+  public void setUp() throws Exception {
+    config = createNiceMock(Configuration.class);
+    expect(config.getUrl()).andReturn(EMPTY);
+    expect(config.getUser()).andReturn(EMPTY);
+    expect(config.getPassword()).andReturn(EMPTY);
+    expect(config.getMaxTries()).andReturn(1);
+    expect(config.getConnectionTimeout()).andReturn(TIME_INTERVAL);
+    expect(config.getSocketTimeout()).andReturn(TIME_INTERVAL);
+    expect(config.getRetryInterval()).andReturn(TIME_INTERVAL);
+    replayAll();
+  }
+
+  @Test
+  public void testGet() throws Exception {
+    Injector injector = Guice.createInjector(new TestModule());
+    CloseableHttpClient httpClient1 =
+        injector.getInstance(CloseableHttpClient.class);
+    assertThat(httpClient1).isNotNull();
+    CloseableHttpClient httpClient2 =
+        injector.getInstance(CloseableHttpClient.class);
+    assertThat(httpClient1).isEqualTo(httpClient2);
+  }
+
+  class TestModule extends LifecycleModule {
+    @Override
+    protected void configure() {
+      bind(Configuration.class).toInstance(config);
+      bind(CloseableHttpClient.class).toProvider(HttpClientProvider.class)
+          .in(Scopes.SINGLETON);
+    }
+  }
+}
diff --git a/src/test/java/com/ericsson/gerrit/plugins/syncevents/HttpSessionTest.java b/src/test/java/com/ericsson/gerrit/plugins/syncevents/HttpSessionTest.java
new file mode 100644
index 0000000..e7575a2
--- /dev/null
+++ b/src/test/java/com/ericsson/gerrit/plugins/syncevents/HttpSessionTest.java
@@ -0,0 +1,161 @@
+// Copyright (C) 2015 Ericsson
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.syncevents;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.post;
+import static com.github.tomakehurst.wiremock.client.WireMock.stubFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static com.google.common.truth.Truth.assertThat;
+import static org.easymock.EasyMock.expect;
+
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.easymock.EasyMockSupport;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+
+import com.ericsson.gerrit.plugins.syncevents.Configuration;
+import com.ericsson.gerrit.plugins.syncevents.HttpClientProvider;
+import com.ericsson.gerrit.plugins.syncevents.HttpSession;
+import com.ericsson.gerrit.plugins.syncevents.SyncEventsResponseHandler.SyncResult;
+import com.github.tomakehurst.wiremock.http.Fault;
+import com.github.tomakehurst.wiremock.junit.WireMockRule;
+import com.github.tomakehurst.wiremock.stubbing.Scenario;
+
+public class HttpSessionTest extends EasyMockSupport {
+  private static final int MAX_TRIES = 5;
+  private static final int RETRY_INTERVAL = 1000;
+  private static final int TIMEOUT = 1000;
+  private static final int ERROR = 500;
+  private static final int OK = 204;
+  private static final int NOT_FOUND = 404;
+  private static final int UNAUTHORIZED = 401;
+
+  private static final String ENDPOINT = "/plugins/sync-events/event";
+  private static final String BODY = "SerializedEvent";
+  private static final String ERROR_MESSAGE = "Error message";
+  private static final String REQUEST_MADE = "Request made";
+  private static final String RETRY_AT_ERROR = "Retry at error";
+  private static final String RETRY_AT_DELAY = "Retry at delay";
+  private static final String URL = "http://localhost:18888";
+
+  private Configuration cfg;
+  private CloseableHttpClient httpClient;
+  private HttpSession httpSession;
+
+  @Rule
+  public WireMockRule wireMockRule = new WireMockRule(18888);
+
+  @Before
+  public void setUp() throws Exception {
+    cfg = createMock(Configuration.class);
+    expect(cfg.getUrl()).andReturn(URL).anyTimes();
+    expect(cfg.getUser()).andReturn("user");
+    expect(cfg.getPassword()).andReturn("pass");
+    expect(cfg.getMaxTries()).andReturn(MAX_TRIES).anyTimes();
+    expect(cfg.getConnectionTimeout()).andReturn(TIMEOUT).anyTimes();
+    expect(cfg.getSocketTimeout()).andReturn(TIMEOUT).anyTimes();
+    expect(cfg.getRetryInterval()).andReturn(RETRY_INTERVAL).anyTimes();
+    replayAll();
+    httpClient = new HttpClientProvider(cfg).get();
+    httpSession = new HttpSession(httpClient, URL);
+  }
+
+  @Test
+  public void testResponseOK() throws Exception {
+    stubFor(post(urlEqualTo(ENDPOINT)).willReturn(aResponse().withStatus(OK)));
+    assertThat(httpSession.post(ENDPOINT, BODY).isSuccessful()).isTrue();
+  }
+
+  @Test
+  public void testResponseOKEmptyBody() throws Exception {
+    stubFor(post(urlEqualTo(ENDPOINT)).willReturn(aResponse().withStatus(OK)));
+    assertThat(httpSession.post(ENDPOINT, "").isSuccessful()).isTrue();
+  }
+
+  @Test
+  public void testNotAuthorized() throws Exception {
+    String expected = "unauthorized";
+    stubFor(post(urlEqualTo(ENDPOINT)).willReturn(
+        aResponse().withStatus(UNAUTHORIZED).withBody(expected)));
+
+    SyncResult result = httpSession.post(ENDPOINT, BODY);
+    assertThat(result.isSuccessful()).isFalse();
+    assertThat(result.getMessage()).isEqualTo(expected);
+  }
+
+  @Test
+  public void testNotFound() throws Exception {
+    String expected = "not found";
+    stubFor(post(urlEqualTo(ENDPOINT)).willReturn(
+        aResponse().withStatus(NOT_FOUND).withBody(expected)));
+
+    SyncResult result = httpSession.post(ENDPOINT, BODY);
+    assertThat(result.isSuccessful()).isFalse();
+    assertThat(result.getMessage()).isEqualTo(expected);
+  }
+
+  @Test
+  public void testBadResponseRetryThenOK() throws Exception {
+    stubFor(post(urlEqualTo(ENDPOINT)).inScenario(RETRY_AT_ERROR)
+        .whenScenarioStateIs(Scenario.STARTED).willSetStateTo(REQUEST_MADE)
+        .willReturn(aResponse().withStatus(ERROR)));
+    stubFor(post(urlEqualTo(ENDPOINT)).inScenario(RETRY_AT_ERROR)
+        .whenScenarioStateIs(REQUEST_MADE)
+        .willReturn(aResponse().withStatus(OK)));
+
+    assertThat(httpSession.post(ENDPOINT, BODY).isSuccessful()).isTrue();
+  }
+
+  @Test
+  public void testBadResponseRetryThenGiveUp() throws Exception {
+    stubFor(post(urlEqualTo(ENDPOINT)).willReturn(
+        aResponse().withStatus(ERROR).withBody(ERROR_MESSAGE)));
+
+    assertThat(httpSession.post(ENDPOINT, BODY).isSuccessful()).isFalse();
+    assertThat(httpSession.post(ENDPOINT, BODY).getMessage())
+        .isEqualTo(ERROR_MESSAGE);
+  }
+
+  @Test
+  public void testRetryAfterDelay() throws Exception {
+    stubFor(post(urlEqualTo(ENDPOINT)).inScenario(RETRY_AT_DELAY)
+        .whenScenarioStateIs(Scenario.STARTED).willSetStateTo(REQUEST_MADE)
+        .willReturn(aResponse().withStatus(ERROR).withFixedDelay(TIMEOUT / 2)));
+    stubFor(post(urlEqualTo(ENDPOINT)).inScenario(RETRY_AT_DELAY)
+        .whenScenarioStateIs(REQUEST_MADE)
+        .willReturn(aResponse().withStatus(OK)));
+
+    assertThat(httpSession.post(ENDPOINT, BODY).isSuccessful()).isTrue();
+  }
+
+  @Test
+  public void testGiveUpAtTimeout() throws Exception {
+    stubFor(post(urlEqualTo(ENDPOINT)).inScenario(RETRY_AT_DELAY)
+        .whenScenarioStateIs(Scenario.STARTED).willSetStateTo(REQUEST_MADE)
+        .willReturn(aResponse().withStatus(ERROR).withFixedDelay(TIMEOUT)));
+
+    assertThat(httpSession.post(ENDPOINT, BODY).isSuccessful()).isFalse();
+  }
+
+  @Test
+  public void testResponseWithMalformedResponse() throws Exception {
+    stubFor(post(urlEqualTo(ENDPOINT)).willReturn(
+        aResponse().withFault(Fault.MALFORMED_RESPONSE_CHUNK)));
+
+    assertThat(httpSession.post(ENDPOINT, BODY).isSuccessful()).isFalse();
+  }
+}
diff --git a/src/test/java/com/ericsson/gerrit/plugins/syncevents/ModuleTest.java b/src/test/java/com/ericsson/gerrit/plugins/syncevents/ModuleTest.java
new file mode 100644
index 0000000..5ecab72
--- /dev/null
+++ b/src/test/java/com/ericsson/gerrit/plugins/syncevents/ModuleTest.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2015 Ericsson
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.syncevents;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.easymock.EasyMock.expect;
+
+import org.easymock.EasyMockSupport;
+import org.junit.Test;
+
+import com.ericsson.gerrit.plugins.syncevents.Configuration;
+import com.ericsson.gerrit.plugins.syncevents.Module;
+
+public class ModuleTest extends EasyMockSupport {
+
+  @Test
+  public void testSyncUrlProvider() {
+    Configuration configMock = createNiceMock(Configuration.class);
+    String expected = "someUrl";
+    expect(configMock.getUrl()).andReturn(expected);
+    replayAll();
+    Module module = new Module();
+    assertThat(module.syncUrl(configMock)).isEqualTo(expected);
+  }
+}
diff --git a/src/test/java/com/ericsson/gerrit/plugins/syncevents/RestSessionTest.java b/src/test/java/com/ericsson/gerrit/plugins/syncevents/RestSessionTest.java
new file mode 100644
index 0000000..631e094
--- /dev/null
+++ b/src/test/java/com/ericsson/gerrit/plugins/syncevents/RestSessionTest.java
@@ -0,0 +1,79 @@
+// Copyright (C) 2015 Ericsson
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.syncevents;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.easymock.EasyMock.expect;
+
+import com.google.common.base.Joiner;
+import com.google.gerrit.server.events.Event;
+import com.google.gson.GsonBuilder;
+
+import com.ericsson.gerrit.plugins.syncevents.HttpSession;
+import com.ericsson.gerrit.plugins.syncevents.RestSession;
+import com.ericsson.gerrit.plugins.syncevents.SyncEventsResponseHandler.SyncResult;
+
+import org.easymock.EasyMockSupport;
+import org.junit.Test;
+
+import java.io.IOException;
+
+public class RestSessionTest extends EasyMockSupport {
+  private static final String PLUGIN_NAME = "sync-events";
+
+  private RestSession restClient;
+  private Event event;
+
+  @Test
+  public void testEventSentOK() throws Exception {
+    event = setUpMocks(true, "", false);
+    assertThat(restClient.send(event)).isTrue();
+  }
+
+  @Test
+  public void testEventSentFailed() throws Exception {
+    event = setUpMocks(false, "Error", false);
+    assertThat(restClient.send(event)).isFalse();
+  }
+
+  @Test
+  public void testEventSentThrowsException() throws Exception {
+    event = setUpMocks(false, "Exception", true);
+    assertThat(restClient.send(event)).isFalse();
+  }
+
+  private Event setUpMocks(boolean ok, String msg, boolean exception)
+      throws Exception {
+    String request = Joiner.on("/").join("/plugins", PLUGIN_NAME, "event");
+    SyncEventTest testEvent = new SyncEventTest();
+    String content = new GsonBuilder().create().toJson(testEvent);
+    HttpSession httpSession = createNiceMock(HttpSession.class);
+    if (exception) {
+      expect(httpSession.post(request, content)).andThrow(new IOException());
+    } else {
+      SyncResult result = new SyncResult(ok, msg);
+      expect(httpSession.post(request, content)).andReturn(result);
+    }
+    restClient = new RestSession(httpSession, PLUGIN_NAME);
+    replayAll();
+    return testEvent;
+  }
+
+  class SyncEventTest extends Event {
+    public SyncEventTest() {
+      super("test-event");
+    }
+  }
+}
diff --git a/src/test/java/com/ericsson/gerrit/plugins/syncevents/SyncEventBrokerTest.java b/src/test/java/com/ericsson/gerrit/plugins/syncevents/SyncEventBrokerTest.java
new file mode 100644
index 0000000..f8a6e39
--- /dev/null
+++ b/src/test/java/com/ericsson/gerrit/plugins/syncevents/SyncEventBrokerTest.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2016 Ericsson
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.syncevents;
+
+import com.google.gerrit.common.EventListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.events.Event;
+
+import org.easymock.EasyMockSupport;
+import org.junit.Before;
+import org.junit.Test;
+
+public class SyncEventBrokerTest extends EasyMockSupport {
+
+  private EventListener listenerMock;
+  private SyncEventBroker broker;
+  private Event event = new Event(null) {};
+
+  @Before
+  public void setUp() {
+    listenerMock = createMock(EventListener.class);
+    DynamicSet<EventListener> listeners = DynamicSet.emptySet();
+    listeners.add(listenerMock);
+    broker = new SyncEventBroker(null, listeners, null, null);
+  }
+
+  @Test
+  public void shouldDispatchEvent() {
+    listenerMock.onEvent(event);
+    replayAll();
+    broker.fireEventForUnrestrictedListeners(event);
+    verifyAll();
+  }
+
+  @Test
+  public void shouldNotDispatchForwardedEvents() {
+    replayAll();
+    Context.setForwardedEvent();
+    try {
+      broker.fireEventForUnrestrictedListeners(event);
+    } finally {
+      Context.unsetForwardedEvent();
+    }
+    verifyAll();
+  }
+}
diff --git a/src/test/java/com/ericsson/gerrit/plugins/syncevents/SyncEventExecutorProviderTest.java b/src/test/java/com/ericsson/gerrit/plugins/syncevents/SyncEventExecutorProviderTest.java
new file mode 100644
index 0000000..006d32b
--- /dev/null
+++ b/src/test/java/com/ericsson/gerrit/plugins/syncevents/SyncEventExecutorProviderTest.java
@@ -0,0 +1,66 @@
+// Copyright (C) 2016 Ericsson
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.syncevents;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.easymock.EasyMock.expect;
+import static org.easymock.EasyMock.expectLastCall;
+
+import com.google.gerrit.server.git.WorkQueue;
+
+import org.easymock.EasyMockSupport;
+import org.junit.Before;
+import org.junit.Test;
+
+public class SyncEventExecutorProviderTest extends EasyMockSupport {
+  private WorkQueue.Executor executorMock;
+  private SyncEventExecutorProvider syncEventsExecutorProvider;
+
+  @Before
+  public void setUp() throws Exception {
+    executorMock = createStrictMock(WorkQueue.Executor.class);
+    WorkQueue workQueueMock = createNiceMock(WorkQueue.class);
+    expect(
+        workQueueMock.createQueue(4, "Sync stream events [SyncEvents plugin]"))
+            .andReturn(executorMock);
+    Configuration configMock = createStrictMock(Configuration.class);
+    expect(configMock.getThreadPoolSize()).andReturn(4);
+    replayAll();
+    syncEventsExecutorProvider =
+        new SyncEventExecutorProvider(workQueueMock, "SyncEvents", configMock);
+  }
+
+  @Test
+  public void shouldReturnExecutor() throws Exception {
+    assertThat(syncEventsExecutorProvider.get()).isEqualTo(executorMock);
+  }
+
+  @Test
+  public void testStop() throws Exception {
+    resetAll();
+    executorMock.shutdown();
+    expectLastCall().once();
+    executorMock.unregisterWorkQueue();
+    expectLastCall().once();
+    replayAll();
+
+    syncEventsExecutorProvider.start();
+    assertThat(syncEventsExecutorProvider.get()).isEqualTo(executorMock);
+    syncEventsExecutorProvider.stop();
+    verifyAll();
+    assertThat(syncEventsExecutorProvider.get()).isNull();
+  }
+}
+
diff --git a/src/test/java/com/ericsson/gerrit/plugins/syncevents/SyncEventsResponseHandlerTest.java b/src/test/java/com/ericsson/gerrit/plugins/syncevents/SyncEventsResponseHandlerTest.java
new file mode 100644
index 0000000..0817a29
--- /dev/null
+++ b/src/test/java/com/ericsson/gerrit/plugins/syncevents/SyncEventsResponseHandlerTest.java
@@ -0,0 +1,71 @@
+// Copyright (C) 2015 Ericsson
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.syncevents;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.easymock.EasyMock.expect;
+
+import com.ericsson.gerrit.plugins.syncevents.SyncEventsResponseHandler;
+import com.ericsson.gerrit.plugins.syncevents.SyncEventsResponseHandler.SyncResult;
+
+import org.apache.http.HttpResponse;
+import org.apache.http.StatusLine;
+import org.apache.http.entity.StringEntity;
+import org.easymock.EasyMockSupport;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.UnsupportedEncodingException;
+
+public class SyncEventsResponseHandlerTest extends EasyMockSupport {
+  private static final int ERROR = 400;
+  private static final int OK = 204;
+  private static final String EMPTY_ENTITY = "";
+  private static final String ERROR_ENTITY = "Error";
+
+  private SyncEventsResponseHandler handler;
+
+  @Before
+  public void setUp() throws Exception {
+    handler = new SyncEventsResponseHandler();
+  }
+
+  @Test
+  public void testIsSuccessful() throws Exception {
+    HttpResponse response = setupMocks(OK, EMPTY_ENTITY);
+    SyncResult result = handler.handleResponse(response);
+    assertThat(result.isSuccessful()).isTrue();
+    assertThat(result.getMessage()).isEmpty();
+  }
+
+  @Test
+  public void testIsNotSuccessful() throws Exception {
+    HttpResponse response = setupMocks(ERROR, ERROR_ENTITY);
+    SyncResult result = handler.handleResponse(response);
+    assertThat(result.isSuccessful()).isFalse();
+    assertThat(result.getMessage()).contains(ERROR_ENTITY);
+  }
+
+  private HttpResponse setupMocks(int httpCode, String entity)
+      throws UnsupportedEncodingException {
+    StatusLine status = createNiceMock(StatusLine.class);
+    expect(status.getStatusCode()).andReturn(httpCode).anyTimes();
+    HttpResponse response = createNiceMock(HttpResponse.class);
+    expect(response.getStatusLine()).andReturn(status).anyTimes();
+    expect(response.getEntity()).andReturn(new StringEntity(entity)).anyTimes();
+    replayAll();
+    return response;
+  }
+}
diff --git a/src/test/java/com/ericsson/gerrit/plugins/syncevents/SyncEventsRestApiServletTest.java b/src/test/java/com/ericsson/gerrit/plugins/syncevents/SyncEventsRestApiServletTest.java
new file mode 100644
index 0000000..37de2db
--- /dev/null
+++ b/src/test/java/com/ericsson/gerrit/plugins/syncevents/SyncEventsRestApiServletTest.java
@@ -0,0 +1,135 @@
+// Copyright (C) 2016 Ericsson
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.syncevents;
+
+import static javax.servlet.http.HttpServletResponse.SC_NO_CONTENT;
+import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
+import static org.easymock.EasyMock.expect;
+import static org.easymock.EasyMock.expectLastCall;
+import static org.easymock.EasyMock.isA;
+
+import com.google.common.net.MediaType;
+import com.google.gerrit.common.EventDispatcher;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.events.EventTypes;
+import com.google.gerrit.server.events.RefEvent;
+import com.google.gwtorm.client.KeyUtil;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.gwtorm.server.StandardKeyEncoder;
+
+import org.easymock.EasyMockSupport;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.StringReader;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+public class SyncEventsRestApiServletTest extends EasyMockSupport {
+
+  private EventDispatcher dispatcher = createStrictMock(EventDispatcher.class);
+  private SyncEventsRestApiServlet syncEventsRestApiServlet;
+  private HttpServletRequest req;
+  private HttpServletResponse rsp;
+
+  @BeforeClass
+  public static void setup() {
+    EventTypes.register(RefReplicationDoneEvent.TYPE, RefReplicationDoneEvent.class);
+    KeyUtil.setEncoderImpl(new StandardKeyEncoder());
+  }
+
+  @Before
+  public void createSyncEventsRestApiServlet() throws Exception {
+    @SuppressWarnings("unchecked")
+    SchemaFactory<ReviewDb> schema = createNiceMock(SchemaFactory.class);
+    ReviewDb db = createNiceMock(ReviewDb.class);
+    expect(schema.open()).andReturn(db).anyTimes();
+    syncEventsRestApiServlet = new SyncEventsRestApiServlet(dispatcher, schema);
+    req = createNiceMock(HttpServletRequest.class);
+    rsp = createNiceMock(HttpServletResponse.class);
+    expect(req.getContentType()).andReturn(MediaType.JSON_UTF_8.toString());
+  }
+
+  @Test
+  public void testDoPostRefReplicationDoneEvent() throws Exception {
+    String event = "{\"project\":\"gerrit/test-sync-index\",\"ref\":"
+        + "\"refs/changes/76/669676/2\",\"nodesCount\":1,\"type\":"
+        + "\"ref-replication-done\",\"eventCreatedOn\":1451415011}";
+    expect(req.getReader())
+        .andReturn(new BufferedReader(new StringReader(event)));
+    dispatcher.postEvent(isA(RefReplicationDoneEvent.class), isA(ReviewDb.class));
+    rsp.setStatus(SC_NO_CONTENT);
+    expectLastCall().once();
+    replayAll();
+
+    syncEventsRestApiServlet.doPost(req, rsp);
+    verifyAll();
+  }
+
+  @Test
+  public void testDoPostDispatcherFailure() throws Exception {
+    String event = "{\"project\":\"gerrit/test-sync-index\",\"ref\":"
+        + "\"refs/changes/76/669676/2\",\"nodesCount\":1,\"type\":"
+        + "\"ref-replication-done\",\"eventCreatedOn\":1451415011}";
+    expect(req.getReader())
+        .andReturn(new BufferedReader(new StringReader(event)));
+    dispatcher.postEvent(isA(RefReplicationDoneEvent.class), isA(ReviewDb.class));
+    expectLastCall().andThrow(new OrmException("some Error"));
+    rsp.sendError(SC_NOT_FOUND, "Change not found\n");
+    expectLastCall().once();
+    replayAll();
+
+    syncEventsRestApiServlet.doPost(req, rsp);
+    verifyAll();
+  }
+
+  @Test
+  public void testDoPostBadRequest() throws Exception {
+    expect(req.getReader()).andThrow(new IOException());
+    replayAll();
+    syncEventsRestApiServlet.doPost(req, rsp);
+    verifyAll();
+  }
+
+  static class RefReplicationDoneEvent extends RefEvent {
+    public static final String TYPE = "ref-replication-done";
+    public final String project;
+    public final String ref;
+    public final int nodesCount;
+
+    public RefReplicationDoneEvent(String project, String ref, int nodesCount) {
+      super(TYPE);
+      this.project = project;
+      this.ref = ref;
+      this.nodesCount = nodesCount;
+    }
+
+    @Override
+    public Project.NameKey getProjectNameKey() {
+      return new Project.NameKey(project);
+    }
+
+    @Override
+    public String getRefName() {
+      return ref;
+    }
+  }
+}