Notify about project deletion status

Notify the scheduling and the result of the execution of a project
deletion on remote targets.

This is useful for consumers who are interested in understanding not
just when a project deletion is initiated, but also when it is
completed.

The same event notification was applied to ref updates through the
propagation of RefReplicationScheduled and RefReplicationDone events,
but it was never applied to project deletions.

Feature: Issue 13894
Change-Id: I9b8197e67f4eddcc51c408c2db4c5991487a3d5e
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/DeleteProjectTask.java b/src/main/java/com/googlesource/gerrit/plugins/replication/DeleteProjectTask.java
index 8ea7227..965ca94 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/DeleteProjectTask.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/DeleteProjectTask.java
@@ -22,36 +22,46 @@
 import com.google.gerrit.server.util.IdGenerator;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import com.googlesource.gerrit.plugins.replication.events.ProjectDeletionState;
 import java.util.Optional;
 import org.eclipse.jgit.transport.URIish;
 
 public class DeleteProjectTask implements Runnable {
   interface Factory {
-    DeleteProjectTask create(URIish replicateURI, Project.NameKey project);
+
+    DeleteProjectTask create(
+        URIish replicateURI, Project.NameKey project, ProjectDeletionState state);
   }
 
   private final DynamicItem<AdminApiFactory> adminApiFactory;
   private final int id;
   private final URIish replicateURI;
   private final Project.NameKey project;
+  private final ProjectDeletionState state;
 
   @Inject
   DeleteProjectTask(
       DynamicItem<AdminApiFactory> adminApiFactory,
       IdGenerator ig,
+      @Assisted ProjectDeletionState state,
       @Assisted URIish replicateURI,
       @Assisted Project.NameKey project) {
     this.adminApiFactory = adminApiFactory;
     this.id = ig.next();
     this.replicateURI = replicateURI;
     this.project = project;
+    this.state = state;
   }
 
   @Override
   public void run() {
     Optional<AdminApi> adminApi = adminApiFactory.get().create(replicateURI);
     if (adminApi.isPresent()) {
-      adminApi.get().deleteProject(project);
+      if (adminApi.get().deleteProject(project)) {
+        state.setSucceeded(replicateURI);
+      } else {
+        state.setFailed(replicateURI);
+      }
       return;
     }
 
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java b/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
index 2722f6c..e0a9354 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
@@ -63,6 +63,7 @@
 import com.google.inject.assistedinject.FactoryModuleBuilder;
 import com.google.inject.servlet.RequestScoped;
 import com.googlesource.gerrit.plugins.replication.ReplicationState.RefPushResult;
+import com.googlesource.gerrit.plugins.replication.events.ProjectDeletionState;
 import com.googlesource.gerrit.plugins.replication.events.RefReplicatedEvent;
 import com.googlesource.gerrit.plugins.replication.events.ReplicationScheduledEvent;
 import java.io.IOException;
@@ -461,10 +462,12 @@
     }
   }
 
-  void scheduleDeleteProject(URIish uri, Project.NameKey project) {
+  void scheduleDeleteProject(URIish uri, Project.NameKey project, ProjectDeletionState state) {
+    repLog.atFine().log("scheduling deletion of project {} at {}", project, uri);
     @SuppressWarnings("unused")
     ScheduledFuture<?> ignored =
-        pool.schedule(deleteProjectFactory.create(uri, project), 0, TimeUnit.SECONDS);
+        pool.schedule(deleteProjectFactory.create(uri, project, state), 0, TimeUnit.SECONDS);
+    state.setScheduled(uri);
   }
 
   void scheduleUpdateHead(URIish uri, Project.NameKey project, String newHead) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationModule.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationModule.java
index c3adfaf..4d2faed 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationModule.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationModule.java
@@ -32,6 +32,7 @@
 import com.google.inject.Scopes;
 import com.google.inject.assistedinject.FactoryModuleBuilder;
 import com.google.inject.internal.UniqueAnnotations;
+import com.googlesource.gerrit.plugins.replication.events.ProjectDeletionState;
 import com.googlesource.gerrit.plugins.replication.events.RefReplicatedEvent;
 import com.googlesource.gerrit.plugins.replication.events.RefReplicationDoneEvent;
 import com.googlesource.gerrit.plugins.replication.events.ReplicationScheduledEvent;
@@ -80,6 +81,7 @@
         .to(StartReplicationCapability.class);
 
     install(new FactoryModuleBuilder().build(PushAll.Factory.class));
+    install(new FactoryModuleBuilder().build(ProjectDeletionState.Factory.class));
 
     bind(EventBus.class).in(Scopes.SINGLETON);
     bind(ReplicationDestinations.class).to(DestinationsCollection.class);
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationQueue.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationQueue.java
index 65e4a72..ed474ae 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationQueue.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationQueue.java
@@ -33,8 +33,11 @@
 import com.googlesource.gerrit.plugins.replication.PushResultProcessing.GitUpdateProcessing;
 import com.googlesource.gerrit.plugins.replication.ReplicationConfig.FilterType;
 import com.googlesource.gerrit.plugins.replication.ReplicationTasksStorage.ReplicateRefUpdate;
+import com.googlesource.gerrit.plugins.replication.events.ProjectDeletionState;
 import java.net.URISyntaxException;
+import java.util.Collection;
 import java.util.HashSet;
+import java.util.Map;
 import java.util.Optional;
 import java.util.Queue;
 import java.util.Set;
@@ -60,6 +63,7 @@
   private final DynamicItem<EventDispatcher> dispatcher;
   private final Provider<ReplicationDestinations> destinations; // For Guice circular dependency
   private final ReplicationTasksStorage replicationTasksStorage;
+  private final ProjectDeletionState.Factory projectDeletionStateFactory;
   private volatile boolean running;
   private final AtomicBoolean replaying = new AtomicBoolean();
   private final Queue<ReferenceUpdatedEvent> beforeStartupEventsQueue;
@@ -72,7 +76,8 @@
       Provider<ReplicationDestinations> rd,
       DynamicItem<EventDispatcher> dis,
       ReplicationStateListeners sl,
-      ReplicationTasksStorage rts) {
+      ReplicationTasksStorage rts,
+      ProjectDeletionState.Factory pd) {
     replConfig = rc;
     workQueue = wq;
     dispatcher = dis;
@@ -80,6 +85,7 @@
     stateLog = sl;
     replicationTasksStorage = rts;
     beforeStartupEventsQueue = Queues.newConcurrentLinkedQueue();
+    projectDeletionStateFactory = pd;
   }
 
   @Override
@@ -245,8 +251,12 @@
   @Override
   public void onProjectDeleted(ProjectDeletedListener.Event event) {
     Project.NameKey p = Project.nameKey(event.getProjectName());
-    destinations.get().getURIs(Optional.empty(), p, FilterType.PROJECT_DELETION).entries().stream()
-        .forEach(e -> e.getKey().scheduleDeleteProject(e.getValue(), p));
+    ProjectDeletionState state = projectDeletionStateFactory.create(p);
+    Collection<Map.Entry<Destination, URIish>> projectsToDelete =
+        destinations.get().getURIs(Optional.empty(), p, FilterType.PROJECT_DELETION).entries();
+
+    projectsToDelete.forEach(e -> state.setToProcess(e.getValue()));
+    projectsToDelete.forEach(e -> e.getKey().scheduleDeleteProject(e.getValue(), p, state));
   }
 
   @Override
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/events/ProjectDeletionReplicationDoneEvent.java b/src/main/java/com/googlesource/gerrit/plugins/replication/events/ProjectDeletionReplicationDoneEvent.java
new file mode 100644
index 0000000..4e66743
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/events/ProjectDeletionReplicationDoneEvent.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// 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.googlesource.gerrit.plugins.replication.events;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.events.ProjectEvent;
+
+public class ProjectDeletionReplicationDoneEvent extends ProjectEvent {
+  public static final String TYPE = "project-deletion-replication-done";
+
+  private final String project;
+
+  public ProjectDeletionReplicationDoneEvent(String project) {
+    super(TYPE);
+    this.project = project;
+  }
+
+  @Override
+  public Project.NameKey getProjectNameKey() {
+    return Project.nameKey(project);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/events/ProjectDeletionReplicationFailedEvent.java b/src/main/java/com/googlesource/gerrit/plugins/replication/events/ProjectDeletionReplicationFailedEvent.java
new file mode 100644
index 0000000..e350716
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/events/ProjectDeletionReplicationFailedEvent.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// 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.googlesource.gerrit.plugins.replication.events;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.events.ProjectEvent;
+import org.eclipse.jgit.transport.URIish;
+
+public class ProjectDeletionReplicationFailedEvent extends ProjectEvent {
+  public static final String TYPE = "project-deletion-replication-failed";
+
+  private final String project;
+  private final String targetUri;
+
+  public ProjectDeletionReplicationFailedEvent(String project, URIish targetUri) {
+    super(TYPE);
+    this.project = project;
+    this.targetUri = targetUri.toASCIIString();
+  }
+
+  @Override
+  public Project.NameKey getProjectNameKey() {
+    return Project.nameKey(project);
+  }
+
+  @VisibleForTesting
+  public String getTargetUri() {
+    return targetUri;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/events/ProjectDeletionReplicationScheduledEvent.java b/src/main/java/com/googlesource/gerrit/plugins/replication/events/ProjectDeletionReplicationScheduledEvent.java
new file mode 100644
index 0000000..d179106
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/events/ProjectDeletionReplicationScheduledEvent.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// 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.googlesource.gerrit.plugins.replication.events;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.events.ProjectEvent;
+import org.eclipse.jgit.transport.URIish;
+
+public class ProjectDeletionReplicationScheduledEvent extends ProjectEvent {
+  public static final String TYPE = "project-deletion-replication-scheduled";
+
+  private final String project;
+  private final String targetUri;
+
+  public ProjectDeletionReplicationScheduledEvent(String project, URIish targetUri) {
+    super(TYPE);
+    this.project = project;
+    this.targetUri = targetUri.toASCIIString();
+  }
+
+  @Override
+  public Project.NameKey getProjectNameKey() {
+    return Project.nameKey(project);
+  }
+
+  @VisibleForTesting
+  public String getTargetUri() {
+    return targetUri;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/events/ProjectDeletionReplicationSucceededEvent.java b/src/main/java/com/googlesource/gerrit/plugins/replication/events/ProjectDeletionReplicationSucceededEvent.java
new file mode 100644
index 0000000..852c9fe
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/events/ProjectDeletionReplicationSucceededEvent.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// 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.googlesource.gerrit.plugins.replication.events;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.events.ProjectEvent;
+import org.eclipse.jgit.transport.URIish;
+
+public class ProjectDeletionReplicationSucceededEvent extends ProjectEvent {
+  public static final String TYPE = "project-deletion-replication-succeeded";
+
+  private final String project;
+  private final String targetUri;
+
+  public ProjectDeletionReplicationSucceededEvent(String project, URIish targetUri) {
+    super(TYPE);
+    this.project = project;
+    this.targetUri = targetUri.toASCIIString();
+  }
+
+  @Override
+  public Project.NameKey getProjectNameKey() {
+    return Project.nameKey(project);
+  }
+
+  @VisibleForTesting
+  public String getTargetUri() {
+    return targetUri;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/events/ProjectDeletionState.java b/src/main/java/com/googlesource/gerrit/plugins/replication/events/ProjectDeletionState.java
new file mode 100644
index 0000000..e091915
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/events/ProjectDeletionState.java
@@ -0,0 +1,96 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// 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.googlesource.gerrit.plugins.replication.events;
+
+import static com.googlesource.gerrit.plugins.replication.events.ProjectDeletionState.ProjectDeletionStatus.FAILED;
+import static com.googlesource.gerrit.plugins.replication.events.ProjectDeletionState.ProjectDeletionStatus.SCHEDULED;
+import static com.googlesource.gerrit.plugins.replication.events.ProjectDeletionState.ProjectDeletionStatus.SUCCEEDED;
+import static com.googlesource.gerrit.plugins.replication.events.ProjectDeletionState.ProjectDeletionStatus.TO_PROCESS;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.server.events.EventDispatcher;
+import com.google.gerrit.server.events.ProjectEvent;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import org.eclipse.jgit.transport.URIish;
+
+public class ProjectDeletionState {
+  public interface Factory {
+    ProjectDeletionState create(Project.NameKey project);
+  }
+
+  private final DynamicItem<EventDispatcher> eventDispatcher;
+  private final Project.NameKey project;
+  private final ConcurrentMap<URIish, ProjectDeletionStatus> statusByURI =
+      new ConcurrentHashMap<>();
+
+  @Inject
+  public ProjectDeletionState(
+      DynamicItem<EventDispatcher> eventDispatcher, @Assisted Project.NameKey project) {
+    this.eventDispatcher = eventDispatcher;
+    this.project = project;
+  }
+
+  public void setToProcess(URIish uri) {
+    statusByURI.put(uri, TO_PROCESS);
+  }
+
+  public void setScheduled(URIish uri) {
+    setStatusAndBroadcastEvent(
+        uri, SCHEDULED, new ProjectDeletionReplicationScheduledEvent(project.get(), uri));
+  }
+
+  public void setSucceeded(URIish uri) {
+    setStatusAndBroadcastEvent(
+        uri, SUCCEEDED, new ProjectDeletionReplicationSucceededEvent(project.get(), uri));
+    notifyIfDeletionDoneOnAllNodes();
+  }
+
+  public void setFailed(URIish uri) {
+    setStatusAndBroadcastEvent(
+        uri, FAILED, new ProjectDeletionReplicationFailedEvent(project.get(), uri));
+    notifyIfDeletionDoneOnAllNodes();
+  }
+
+  private void setStatusAndBroadcastEvent(
+      URIish uri, ProjectDeletionStatus status, ProjectEvent event) {
+    statusByURI.put(uri, status);
+    eventDispatcher.get().postEvent(project, event);
+  }
+
+  public void notifyIfDeletionDoneOnAllNodes() {
+    synchronized (statusByURI) {
+      if (!statusByURI.isEmpty()
+          && statusByURI.values().stream()
+              .noneMatch(s -> s.equals(TO_PROCESS) || s.equals(SCHEDULED))) {
+
+        statusByURI.clear();
+        eventDispatcher
+            .get()
+            .postEvent(project, new ProjectDeletionReplicationDoneEvent(project.get()));
+      }
+    }
+  }
+
+  public enum ProjectDeletionStatus {
+    TO_PROCESS,
+    SCHEDULED,
+    FAILED,
+    SUCCEEDED;
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationDaemon.java b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationDaemon.java
index 8b43c9f..202d77e 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationDaemon.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationDaemon.java
@@ -23,6 +23,8 @@
 import com.google.gerrit.acceptance.WaitUtil;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.events.ProjectDeletedListener;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
 import java.io.IOException;
@@ -233,4 +235,24 @@
       config.save();
     }
   }
+
+  protected ProjectDeletedListener.Event projectDeletedEvent(String projectNameDeleted) {
+    return new ProjectDeletedListener.Event() {
+      @Override
+      public String getProjectName() {
+        return projectNameDeleted;
+      }
+
+      @Override
+      public NotifyHandling getNotify() {
+        return NotifyHandling.NONE;
+      }
+    };
+  }
+
+  protected void setProjectDeletionReplication(String remoteName, boolean replicateProjectDeletion)
+      throws IOException {
+    config.setBoolean("remote", remoteName, "replicateProjectDeletions", replicateProjectDeletion);
+    config.save();
+  }
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationEventsIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationEventsIT.java
index 62d78ee..13afc0d 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationEventsIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationEventsIT.java
@@ -24,16 +24,24 @@
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.events.ProjectDeletedListener;
 import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.events.EventDispatcher;
+import com.google.gerrit.server.events.ProjectEvent;
 import com.google.gerrit.server.events.RefEvent;
 import com.google.inject.Inject;
+import com.googlesource.gerrit.plugins.replication.events.ProjectDeletionReplicationDoneEvent;
+import com.googlesource.gerrit.plugins.replication.events.ProjectDeletionReplicationFailedEvent;
+import com.googlesource.gerrit.plugins.replication.events.ProjectDeletionReplicationScheduledEvent;
+import com.googlesource.gerrit.plugins.replication.events.ProjectDeletionReplicationSucceededEvent;
 import com.googlesource.gerrit.plugins.replication.events.RefReplicatedEvent;
 import com.googlesource.gerrit.plugins.replication.events.RefReplicationDoneEvent;
 import com.googlesource.gerrit.plugins.replication.events.ReplicationScheduledEvent;
 import java.time.Duration;
 import java.util.List;
 import java.util.Optional;
+import java.util.function.Predicate;
 import java.util.function.Supplier;
 import org.junit.Before;
 import org.junit.Test;
@@ -46,6 +54,7 @@
 public class ReplicationEventsIT extends ReplicationDaemon {
   private static final Duration TEST_POST_EVENT_TIMEOUT = Duration.ofSeconds(1);
 
+  @Inject private DynamicSet<ProjectDeletedListener> deletedListeners;
   @Inject private DynamicItem<EventDispatcher> eventDispatcher;
   private TestDispatcher testDispatcher;
 
@@ -113,10 +122,156 @@
     assertThat(testDispatcher.getEvents(RefReplicationDoneEvent.class).size()).isEqualTo(1);
   }
 
+  @Test
+  public void shouldEmitProjectDeletionEventsForOneRemote() throws Exception {
+    String projectName = project.get();
+    setReplicationTarget("replica", project.get());
+
+    reloadConfig();
+
+    for (ProjectDeletedListener l : deletedListeners) {
+      l.onProjectDeleted(projectDeletedEvent(projectName));
+    }
+
+    List<ProjectDeletionReplicationScheduledEvent> scheduledEvents =
+        testDispatcher.getEvents(project, ProjectDeletionReplicationScheduledEvent.class);
+    assertThat(scheduledEvents).hasSize(1);
+
+    assertThatAnyMatch(
+        scheduledEvents,
+        e -> project.equals(e.getProjectNameKey()) && e.getTargetUri().endsWith("replica.git"));
+
+    waitForProjectEvent(
+        () -> testDispatcher.getEvents(project, ProjectDeletionReplicationSucceededEvent.class), 1);
+
+    assertThatAnyMatch(
+        testDispatcher.getEvents(project, ProjectDeletionReplicationSucceededEvent.class),
+        e -> project.equals(e.getProjectNameKey()) && e.getTargetUri().endsWith("replica.git"));
+
+    waitForProjectEvent(
+        () -> testDispatcher.getEvents(project, ProjectDeletionReplicationDoneEvent.class), 1);
+
+    assertThatAnyMatch(
+        testDispatcher.getEvents(project, ProjectDeletionReplicationDoneEvent.class),
+        e -> project.equals(e.getProjectNameKey()));
+  }
+
+  @Test
+  public void shouldEmitProjectDeletionEventsForMultipleRemotesWhenSucceeding() throws Exception {
+    String projectName = project.get();
+    setReplicationTarget("replica1", projectName);
+    setReplicationTarget("replica2", projectName);
+
+    reloadConfig();
+
+    for (ProjectDeletedListener l : deletedListeners) {
+      l.onProjectDeleted(projectDeletedEvent(projectName));
+    }
+
+    List<ProjectDeletionReplicationScheduledEvent> scheduledEvents =
+        testDispatcher.getEvents(project, ProjectDeletionReplicationScheduledEvent.class);
+    assertThat(scheduledEvents).hasSize(2);
+
+    assertThatAnyMatch(
+        scheduledEvents,
+        e -> project.equals(e.getProjectNameKey()) && e.getTargetUri().endsWith("replica1.git"));
+    assertThatAnyMatch(
+        scheduledEvents,
+        e -> project.equals(e.getProjectNameKey()) && e.getTargetUri().endsWith("replica2.git"));
+
+    waitForProjectEvent(
+        () -> testDispatcher.getEvents(project, ProjectDeletionReplicationSucceededEvent.class), 2);
+
+    List<ProjectDeletionReplicationSucceededEvent> successEvents =
+        testDispatcher.getEvents(project, ProjectDeletionReplicationSucceededEvent.class);
+
+    assertThatAnyMatch(
+        successEvents,
+        e -> project.equals(e.getProjectNameKey()) && e.getTargetUri().endsWith("replica1.git"));
+    assertThatAnyMatch(
+        successEvents,
+        e -> project.equals(e.getProjectNameKey()) && e.getTargetUri().endsWith("replica2.git"));
+
+    waitForProjectEvent(
+        () -> testDispatcher.getEvents(project, ProjectDeletionReplicationDoneEvent.class), 1);
+
+    assertThatAnyMatch(
+        testDispatcher.getEvents(project, ProjectDeletionReplicationDoneEvent.class),
+        e -> project.equals(e.getProjectNameKey()));
+  }
+
+  @Test
+  public void shouldEmitProjectDeletionEventsForMultipleRemotesWhenFailing() throws Exception {
+    String projectName = project.get();
+    setReplicationTarget("replica1", projectName);
+
+    setReplicationDestination(
+        "not-existing-replica", "not-existing-replica", Optional.of(projectName));
+    setProjectDeletionReplication("not-existing-replica", true);
+
+    reloadConfig();
+
+    for (ProjectDeletedListener l : deletedListeners) {
+      l.onProjectDeleted(projectDeletedEvent(projectName));
+    }
+
+    List<ProjectDeletionReplicationScheduledEvent> scheduledEvents =
+        testDispatcher.getEvents(project, ProjectDeletionReplicationScheduledEvent.class);
+    assertThat(scheduledEvents).hasSize(2);
+
+    assertThatAnyMatch(
+        scheduledEvents,
+        e -> project.equals(e.getProjectNameKey()) && e.getTargetUri().endsWith("replica1.git"));
+    assertThatAnyMatch(
+        scheduledEvents,
+        e ->
+            project.equals(e.getProjectNameKey())
+                && e.getTargetUri().endsWith("not-existing-replica.git"));
+
+    waitForProjectEvent(
+        () -> testDispatcher.getEvents(project, ProjectDeletionReplicationSucceededEvent.class), 1);
+
+    waitForProjectEvent(
+        () -> testDispatcher.getEvents(project, ProjectDeletionReplicationFailedEvent.class), 1);
+
+    assertThatAnyMatch(
+        testDispatcher.getEvents(project, ProjectDeletionReplicationSucceededEvent.class),
+        e -> project.equals(e.getProjectNameKey()) && e.getTargetUri().endsWith("replica1.git"));
+
+    assertThatAnyMatch(
+        testDispatcher.getEvents(project, ProjectDeletionReplicationFailedEvent.class),
+        e ->
+            project.equals(e.getProjectNameKey())
+                && e.getTargetUri().endsWith("not-existing-replica.git"));
+
+    waitForProjectEvent(
+        () -> testDispatcher.getEvents(project, ProjectDeletionReplicationDoneEvent.class), 1);
+
+    assertThatAnyMatch(
+        testDispatcher.getEvents(project, ProjectDeletionReplicationDoneEvent.class),
+        e -> project.equals(e.getProjectNameKey()));
+  }
+
   private <T extends RefEvent> void waitForRefEvent(Supplier<List<T>> events, String refName)
       throws InterruptedException {
     WaitUtil.waitUntil(
         () -> events.get().stream().filter(e -> refName.equals(e.getRefName())).count() == 1,
         TEST_POST_EVENT_TIMEOUT);
   }
+
+  private <T extends ProjectEvent> void waitForProjectEvent(Supplier<List<T>> events, int count)
+      throws InterruptedException {
+    WaitUtil.waitUntil(() -> events.get().size() == count, TEST_POST_EVENT_TIMEOUT);
+  }
+
+  private Project.NameKey setReplicationTarget(String replica, String ofProject) throws Exception {
+    Project.NameKey replicaProject = createTestProject(String.format("%s%s", ofProject, replica));
+    setReplicationDestination(replica, replica, Optional.of(ofProject));
+    setProjectDeletionReplication(replica, true);
+    return replicaProject;
+  }
+
+  private <T extends ProjectEvent> void assertThatAnyMatch(List<T> events, Predicate<T> p) {
+    assertThat(events.stream().anyMatch(p)).isTrue();
+  }
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationIT.java
index ba6f94d..a174e91 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationIT.java
@@ -23,13 +23,11 @@
 import com.google.gerrit.acceptance.UseLocalDisk;
 import com.google.gerrit.acceptance.WaitUtil;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.events.ProjectDeletedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.inject.Inject;
-import java.io.IOException;
 import java.time.Duration;
 import java.util.Optional;
 import java.util.function.Supplier;
@@ -87,21 +85,8 @@
     setProjectDeletionReplication("foo", true);
     reloadConfig();
 
-    ProjectDeletedListener.Event event =
-        new ProjectDeletedListener.Event() {
-          @Override
-          public String getProjectName() {
-            return projectNameDeleted;
-          }
-
-          @Override
-          public NotifyHandling getNotify() {
-            return NotifyHandling.NONE;
-          }
-        };
-
     for (ProjectDeletedListener l : deletedListeners) {
-      l.onProjectDeleted(event);
+      l.onProjectDeleted(projectDeletedEvent(projectNameDeleted));
     }
 
     waitUntil(() -> !nonEmptyProjectExists(replicaProject));
@@ -363,12 +348,6 @@
     }
   }
 
-  private void setProjectDeletionReplication(String remoteName, boolean replicateProjectDeletion)
-      throws IOException {
-    config.setBoolean("remote", remoteName, "replicateProjectDeletions", replicateProjectDeletion);
-    config.save();
-  }
-
   private void waitUntil(Supplier<Boolean> waitCondition) throws InterruptedException {
     WaitUtil.waitUntil(waitCondition, TEST_TIMEOUT);
   }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/TestDispatcher.java b/src/test/java/com/googlesource/gerrit/plugins/replication/TestDispatcher.java
index bc5c35c..901200b 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/TestDispatcher.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/TestDispatcher.java
@@ -27,6 +27,7 @@
 import java.util.stream.Collectors;
 
 public class TestDispatcher implements EventDispatcher {
+  private final List<ProjectEvent> projectEvents = new LinkedList<>();
   private final List<RefEvent> refEvents = new LinkedList<>();
   private final List<Event> events = new LinkedList<>();
 
@@ -39,8 +40,9 @@
   }
 
   @Override
-  public void postEvent(
-      Project.NameKey projectName, ProjectEvent event) {} // Not used in replication
+  public void postEvent(Project.NameKey projectName, ProjectEvent event) {
+    projectEvents.add(event);
+  }
 
   @Override
   public void postEvent(Event event) {
@@ -51,6 +53,13 @@
     return getEvents(branch).stream().filter(clazz::isInstance).collect(Collectors.toList());
   }
 
+  public <T extends ProjectEvent> List<T> getEvents(Project.NameKey project, Class<T> clazz) {
+    return getEvents(project).stream()
+        .filter(clazz::isInstance)
+        .map(clazz::cast)
+        .collect(Collectors.toList());
+  }
+
   public <T extends RefEvent> List<T> getEvents(Class<T> clazz) {
     return events.stream().filter(clazz::isInstance).map(clazz::cast).collect(Collectors.toList());
   }
@@ -60,4 +69,10 @@
         .filter(e -> e.getBranchNameKey().equals(branch))
         .collect(Collectors.toList());
   }
+
+  private List<ProjectEvent> getEvents(Project.NameKey project) {
+    return projectEvents.stream()
+        .filter(e -> e.getProjectNameKey().equals(project))
+        .collect(Collectors.toList());
+  }
 }