Repository: mockbukkit/mockbukkit
Issue: AsyncPlayerPreLoginEvent not being called on ServerMock#addPlayer

I use AsyncPlayerPreLoginEvent to fetch player heads asynchronously. However, this doesn't work in test environment simply because ServerMock#addPlayer does not fire AsyncPlayerPreLoginEvent.

```java
@EventHandler
public void onAsyncPlayerPreLogin(AsyncPlayerPreLoginEvent e) {
    if (e.getLoginResult() != AsyncPlayerPreLoginEvent.Result.ALLOWED) return;

    ItemStack itemStack = new ItemStack(Material.SKULL_ITEM, 1, (short) SkullType.PLAYER.ordinal());
    SkullMeta skullMeta = (SkullMeta) itemStack.getItemMeta();
    skullMeta.setOwningPlayer(Bukkit.getOfflinePlayer(e.getUniqueId()));
    itemStack.setItemMeta(skullMeta);
    ...
}
```

Changed files:
src/test/java/be/seeseemelk/mockbukkit/ServerMockTest.java, src/main/java/be/seeseemelk/mockbukkit/plugin/PluginManagerMock.java, src/main/java/be/seeseemelk/mockbukkit/ServerMock.java, src/main/java/be/seeseemelk/mockbukkit/scheduler/BukkitSchedulerMock.java

diff --git a/src/main/java/be/seeseemelk/mockbukkit/ServerMock.java b/src/main/java/be/seeseemelk/mockbukkit/ServerMock.java
index 2ee2be4f..4bf2762e 100644
--- a/src/main/java/be/seeseemelk/mockbukkit/ServerMock.java
+++ b/src/main/java/be/seeseemelk/mockbukkit/ServerMock.java
@@ -40,6 +40,7 @@ import net.kyori.adventure.audience.Audience;
 import net.kyori.adventure.text.Component;
 import net.md_5.bungee.api.chat.BaseComponent;
 import org.apache.commons.lang.Validate;
+import org.apache.commons.lang3.StringUtils;
 import org.bukkit.BanEntry;
 import org.bukkit.BanList;
 import org.bukkit.BanList.Type;
@@ -76,6 +77,7 @@ import org.bukkit.entity.HumanEntity;
 import org.bukkit.entity.Player;
 import org.bukkit.entity.SpawnCategory;
 import org.bukkit.event.inventory.InventoryType;
+import org.bukkit.event.player.AsyncPlayerPreLoginEvent;
 import org.bukkit.event.player.PlayerJoinEvent;
 import org.bukkit.generator.ChunkGenerator.ChunkData;
 import org.bukkit.inventory.Inventory;
@@ -114,6 +116,7 @@ import java.util.Map;
 import java.util.Properties;
 import java.util.Set;
 import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
 import java.util.function.Consumer;
 import java.util.logging.Level;
 import java.util.logging.LogManager;
@@ -225,8 +228,23 @@ public class ServerMock extends Server.Spigot implements Server
 	{
 		AsyncCatcher.catchOp("player add");
 		playerList.addPlayer(player);
-		PlayerJoinEvent playerJoinEvent = new PlayerJoinEvent(player,
-				String.format(JOIN_MESSAGE, player.getDisplayName()));
+
+		CountDownLatch conditionLatch = new CountDownLatch(1);
+
+		AsyncPlayerPreLoginEvent preLoginEvent = new AsyncPlayerPreLoginEvent(player.getName(), player.getAddress().getAddress(), player.getUniqueId());
+		getPluginManager().callEventAsynchronously(preLoginEvent, (e) -> conditionLatch.countDown());
+
+		try
+		{
+			conditionLatch.await();
+		}
+		catch (InterruptedException e)
+		{
+			getLogger().severe("Interrupted while waiting for AsyncPlayerPreLoginEvent! " + (StringUtils.isEmpty(e.getMessage()) ? "" : e.getMessage()));
+			Thread.currentThread().interrupt();
+		}
+
+		PlayerJoinEvent playerJoinEvent = new PlayerJoinEvent(player, String.format(JOIN_MESSAGE, player.getDisplayName()));
 		Bukkit.getPluginManager().callEvent(playerJoinEvent);
 
 		player.setLastPlayed(getCurrentServerTime());
diff --git a/src/main/java/be/seeseemelk/mockbukkit/plugin/PluginManagerMock.java b/src/main/java/be/seeseemelk/mockbukkit/plugin/PluginManagerMock.java
index 9600e0ce..902e114f 100644
--- a/src/main/java/be/seeseemelk/mockbukkit/plugin/PluginManagerMock.java
+++ b/src/main/java/be/seeseemelk/mockbukkit/plugin/PluginManagerMock.java
@@ -55,6 +55,7 @@ import java.util.Map.Entry;
 import java.util.Random;
 import java.util.Set;
 import java.util.concurrent.ThreadLocalRandom;
+import java.util.function.Consumer;
 import java.util.function.Predicate;
 import java.util.logging.Level;
 import java.util.stream.Collectors;
@@ -441,6 +442,18 @@ public class PluginManagerMock implements PluginManager
 	 * @param event The asynchronous {@link Event} to call.
 	 */
 	public void callEventAsynchronously(@NotNull Event event)
+	{
+		callEventAsynchronously(event, null);
+	}
+
+	/**
+	 * This method invokes {@link #callEvent(Event)} from a different {@link Thread}
+	 * using the {@link BukkitSchedulerMock}.
+	 *
+	 * @param event The asynchronous {@link Event} to call.
+	 * @param func A function to invoke after the event has been called.
+	 */
+	public <T extends Event> void callEventAsynchronously(@NotNull T event, Consumer<T> func)
 	{
 		if (!event.isAsynchronous())
 		{
@@ -448,7 +461,7 @@ public class PluginManagerMock implements PluginManager
 		}
 
 		// Our Scheduler will call the Event on a dedicated Event Thread Executor
-		server.getScheduler().executeAsyncEvent(event);
+		server.getScheduler().executeAsyncEvent(event, func);
 	}
 
 	private void callRegisteredListener(@NotNull RegisteredListener registration, @NotNull Event event)
diff --git a/src/main/java/be/seeseemelk/mockbukkit/scheduler/BukkitSchedulerMock.java b/src/main/java/be/seeseemelk/mockbukkit/scheduler/BukkitSchedulerMock.java
index 3073db21..b0adea7e 100644
--- a/src/main/java/be/seeseemelk/mockbukkit/scheduler/BukkitSchedulerMock.java
+++ b/src/main/java/be/seeseemelk/mockbukkit/scheduler/BukkitSchedulerMock.java
@@ -103,8 +103,19 @@ public class BukkitSchedulerMock implements BukkitScheduler
 
 	public @NotNull Future<?> executeAsyncEvent(Event event)
 	{
-		Validate.notNull(event, "Cannot schedule an Event that is null!");
-		Future<?> future = asyncEventExecutor.submit(() -> MockBukkit.getMock().getPluginManager().callEvent(event));
+		return executeAsyncEvent(event, null);
+	}
+
+	public <T extends Event> @NotNull Future<?> executeAsyncEvent(T event, Consumer<T> func)
+	{
+		Validate.notNull(event, "Cannot call a null event!");
+		Future<?> future = asyncEventExecutor.submit(() -> {
+			MockBukkit.getMock().getPluginManager().callEvent(event);
+			if (func != null)
+			{
+				func.accept(event);
+			}
+		});
 		queuedAsyncEvents.add(future);
 		return future;
 	}
@@ -218,8 +229,8 @@ public class BukkitSchedulerMock implements BukkitScheduler
 			if (System.currentTimeMillis() > (systemTime + executorTimeout))
 			{
 				// If a plugin has left a runnable going and not cancelled it we could call this bad practice.
-				// We should force interrupt all those runnables forcing them to throw Interrupted Exceptions-
-				// if they handle that
+				// We should force interrupt all these runnables, forcing them to throw Interrupted Exceptions
+				// if they handle that.
 				for (ScheduledTask task : scheduledTasks.getCurrentTaskList())
 				{
 					if (task.isRunning())
diff --git a/src/test/java/be/seeseemelk/mockbukkit/ServerMockTest.java b/src/test/java/be/seeseemelk/mockbukkit/ServerMockTest.java
index 8c2b4e7b..9749bfab 100644
--- a/src/test/java/be/seeseemelk/mockbukkit/ServerMockTest.java
+++ b/src/test/java/be/seeseemelk/mockbukkit/ServerMockTest.java
@@ -22,6 +22,8 @@ import org.bukkit.block.data.BlockData;
 import org.bukkit.command.Command;
 import org.bukkit.entity.Player;
 import org.bukkit.event.inventory.InventoryType;
+import org.bukkit.event.player.AsyncPlayerPreLoginEvent;
+import org.bukkit.event.player.PlayerJoinEvent;
 import org.bukkit.inventory.ItemStack;
 import org.bukkit.inventory.Recipe;
 import org.bukkit.potion.PotionEffectType;
@@ -129,6 +131,20 @@ class ServerMockTest
 		assertNotEquals(player1, player2);
 	}
 
+	@Test
+	void addPlayer_Calls_AsyncPreLoginEvent()
+	{
+		PlayerMock player = server.addPlayer();
+		server.getPluginManager().assertEventFired(AsyncPlayerPreLoginEvent.class);
+	}
+
+	@Test
+	void addPlayer_Calls_PlayerJoinEvent()
+	{
+		PlayerMock player = server.addPlayer();
+		server.getPluginManager().assertEventFired(PlayerJoinEvent.class);
+	}
+
 	@Test
 	void setPlayers_Two_TwoUniquePlayers()
 	{