changeset 995:0d71bfa6c97a

tests: add plugin tests
author David Demelier <markand@malikania.fr>
date Thu, 11 Feb 2021 17:39:22 +0100
parents 56114ae85868
children 2a6d753f79f6
files CMakeLists.txt irccd/jsapi-plugin.c irccd/jsapi-server.c irccd/jsapi-util.c lib/CMakeLists.txt lib/irccd.pc lib/irccd/util.c lib/irccd/util.h plugins/hangman/hangman.js plugins/history/history.js plugins/joke/joke.7 plugins/joke/joke.js plugins/plugin/plugin.js plugins/tictactoe/tictactoe.js tests/CMakeLists.txt tests/data/answers.conf tests/data/error.json tests/data/joke/error-empty.json tests/data/joke/error-invalid.json tests/data/joke/error-not-array.json tests/data/joke/error-toobig.json tests/data/joke/jokes.json tests/data/words-seq.conf tests/data/words.conf tests/test-plugin-ask.c tests/test-plugin-auth.c tests/test-plugin-hangman.c tests/test-plugin-history.c tests/test-plugin-joke.c tests/test-plugin-logger.c tests/test-plugin-plugin.c tests/test-plugin-tictactoe.c
diffstat 32 files changed, 1834 insertions(+), 83 deletions(-) [+]
line wrap: on
line diff
--- a/CMakeLists.txt	Wed Feb 10 21:52:32 2021 +0100
+++ b/CMakeLists.txt	Thu Feb 11 17:39:22 2021 +0100
@@ -24,7 +24,7 @@
 set(IRCCD_VERSION_MAJOR 4)
 set(IRCCD_VERSION_MINOR 0)
 set(IRCCD_VERSION_PATCH 0)
-set(IRCCD_VERSION ${IRCCD_VERSION_MAJOR}.${IRCCD_VERSION_MINOR}.${IRCCD_VERSION_PATCH})
+set(IRCCD_VERSION "${IRCCD_VERSION_MAJOR}.${IRCCD_VERSION_MINOR}.${IRCCD_VERSION_PATCH}")
 
 option(IRCCD_WITH_JS "Enable Javascript" On)
 option(IRCCD_WITH_SSL "Enable SSL support" On)
--- a/irccd/jsapi-plugin.c	Wed Feb 10 21:52:32 2021 +0100
+++ b/irccd/jsapi-plugin.c	Thu Feb 11 17:39:22 2021 +0100
@@ -128,13 +128,7 @@
 static struct irc_plugin *
 find(duk_context *ctx)
 {
-	const char *name = duk_require_string(ctx, 0);
-	struct irc_plugin *plg = irc_bot_plugin_get(name);
-
-	if (!plg)
-		(void)duk_error(ctx, DUK_ERR_REFERENCE_ERROR, "plugin %s not found", name);
-
-	return plg;
+	return irc_bot_plugin_get(duk_require_string(ctx, 0));
 }
 
 static int
@@ -151,6 +145,8 @@
 		return 0;
 
 	duk_push_object(ctx);
+	duk_push_string(ctx, p->name);
+	duk_put_prop_string(ctx, -2, "name");
 	duk_push_string(ctx, p->author);
 	duk_put_prop_string(ctx, -2, "author");
 	duk_push_string(ctx, p->license);
--- a/irccd/jsapi-server.c	Wed Feb 10 21:52:32 2021 +0100
+++ b/irccd/jsapi-server.c	Thu Feb 11 17:39:22 2021 +0100
@@ -220,14 +220,12 @@
 static int
 Server_prototype_isSelf(duk_context *ctx)
 {
-	(void)ctx;
-#if 0
-	return wrap(ctx, [] (auto ctx) {
-		return duk::push(ctx, self(ctx)->is_self(duk::require<std::string>(ctx, 0)));
-	});
-#endif
+	const struct irc_server *s = self(ctx);
+	const char *target = duk_require_string(ctx, 0);
 
-	return 0;
+	duk_push_boolean(ctx, strncmp(target, s->ident.nickname, strlen(s->ident.nickname)) == 0);
+
+	return 1;
 }
 
 static int
--- a/irccd/jsapi-util.c	Wed Feb 10 21:52:32 2021 +0100
+++ b/irccd/jsapi-util.c	Thu Feb 11 17:39:22 2021 +0100
@@ -80,18 +80,17 @@
 			(void)duk_error(ctx, DUK_ERR_TYPE_ERROR, "keyword name must be a string");
 		}
 
-		if (strcmp(duk_get_string(ctx, -2), "date") == 0) {
+		if (strcmp(duk_get_string(ctx, -2), "date") == 0)
 			pkg->subst.time = duk_get_number(ctx, -1);
-			continue;
+		else {
+			pkg->kw = irc_util_reallocarray(pkg->kw, ++pkg->subst.keywordsz,
+			    sizeof (*pkg->kw));
+			pkg->kw[pkg->subst.keywordsz - 1].key =
+			    irc_util_strdup(duk_get_string_default(ctx, -2, ""));
+			pkg->kw[pkg->subst.keywordsz - 1].value =
+			    irc_util_strdup(duk_get_string_default(ctx, -1, ""));
 		}
 
-		pkg->kw = irc_util_reallocarray(pkg->kw, ++pkg->subst.keywordsz,
-		    sizeof (*pkg->kw));
-		pkg->kw[pkg->subst.keywordsz - 1].key =
-		    irc_util_strdup(duk_get_string_default(ctx, -2, ""));
-		pkg->kw[pkg->subst.keywordsz - 1].value =
-		    irc_util_strdup(duk_get_string_default(ctx, -1, ""));
-
 		duk_pop_n(ctx, 2);
 	}
 
--- a/lib/CMakeLists.txt	Wed Feb 10 21:52:32 2021 +0100
+++ b/lib/CMakeLists.txt	Thu Feb 11 17:39:22 2021 +0100
@@ -58,7 +58,7 @@
 	${libirccd_BINARY_DIR}/irccd/config.h
 )
 
-add_library(libirccd-static ${SOURCES})
+add_library(libirccd-static ${SOURCES} ${HEADERS})
 set_target_properties(libirccd-static PROPERTIES PREFIX "")
 
 # This is what we export to the world.
--- a/lib/irccd.pc	Wed Feb 10 21:52:32 2021 +0100
+++ b/lib/irccd.pc	Thu Feb 11 17:39:22 2021 +0100
@@ -1,5 +1,5 @@
 Name: irccd
 Description: Native C interface for irccd plugins
-Version: @IRCCD_VERSION_MAJOR@
+Version: @IRCCD_VERSION@
 Cflags: -I@CMAKE_INSTALL_FULL_INCLUDEDIR@ -I@CMAKE_INSTALL_FULL_INCLUDEDIR@/irccd/extern -I@OPENSSL_INCLUDE_DIR@
 Libs: @EXTRA_LIBS@
--- a/lib/irccd/util.c	Wed Feb 10 21:52:32 2021 +0100
+++ b/lib/irccd/util.c	Thu Feb 11 17:39:22 2021 +0100
@@ -132,7 +132,7 @@
 }
 
 size_t
-irc_util_split(char *line, const char **args, size_t max)
+irc_util_split(char *line, const char **args, size_t max, char delim)
 {
 	size_t idx;
 
@@ -140,7 +140,7 @@
 		return 0;
 
 	for (idx = 0; idx < max; ++idx) {
-		char *sp = strchr(line, ' ');
+		char *sp = strchr(line, delim);
 
 		if (!sp || idx + 1 >= max) {
 			args[idx++] = line;
--- a/lib/irccd/util.h	Wed Feb 10 21:52:32 2021 +0100
+++ b/lib/irccd/util.h	Thu Feb 11 17:39:22 2021 +0100
@@ -54,7 +54,7 @@
 irc_util_dirname(const char *);
 
 size_t
-irc_util_split(char *, const char **, size_t);
+irc_util_split(char *, const char **, size_t, char);
 
 char *
 irc_util_printf(char *, size_t, const char *, ...);
--- a/plugins/hangman/hangman.js	Wed Feb 10 21:52:32 2021 +0100
+++ b/plugins/hangman/hangman.js	Thu Feb 11 17:39:22 2021 +0100
@@ -136,11 +136,13 @@
 		path = Plugin.paths.config + "/words.conf";
 
 	try {
-		Logger.info("loading words...");
+		Logger.info("loading words from " + path);
 
 		var file = new File(path, "r");
 		var line;
 
+		Hangman.words.all = [];
+
 		while ((line = file.readline()) !== undefined)
 			if (Hangman.isWord(line))
 				Hangman.words.all.push(line);
@@ -272,7 +274,7 @@
 {
 	var kw = {
 		channel: channel,
-		command: server.info().commandChar + Plugin.info().name,
+		command: server.info().prefix + Plugin.info().name,
 		nickname: Util.splituser(origin),
 		origin: origin,
 		plugin: Plugin.info().name,
@@ -319,7 +321,7 @@
 	var game = Hangman.find(server, channel);
 	var kw = {
 		channel: channel,
-		command: server.info().commandChar + Plugin.info().name,
+		command: server.info().prefix + Plugin.info().name,
 		nickname: Util.splituser(origin),
 		origin: origin,
 		plugin: Plugin.info().name,
--- a/plugins/history/history.js	Wed Feb 10 21:52:32 2021 +0100
+++ b/plugins/history/history.js	Thu Feb 11 17:39:22 2021 +0100
@@ -48,7 +48,7 @@
 
 function command(server)
 {
-	return server.info().commandChar + "history";
+	return server.info().prefix + "history";
 }
 
 function path(server, channel)
@@ -193,11 +193,20 @@
 
 function onLoad()
 {
+	/*
+	 * If the plugin is loaded on-demand, we ask a name list for every
+	 * server and every channel of them to update our database.
+	 */
 	var table = Server.list();
 
-	for (var k in table)
-		for (var c in table[k].info().channels)
-			table[k].names(c);
+	for (var k in table) {
+		var channels = table[k].info().channels;
+
+		for (var i = 0; i < channels.length; ++i) {
+			if (channels[i].joined)
+				table[k].names(channels[i].name);
+		}
+	}
 }
 
 function onNames(server, channel, list)
--- a/plugins/joke/joke.7	Wed Feb 10 21:52:32 2021 +0100
+++ b/plugins/joke/joke.7	Thu Feb 11 17:39:22 2021 +0100
@@ -95,7 +95,7 @@
 .Bl -tag -width 14n -offset Ds
 .It Va error
 Template when an internal error occured. Keywords:
-.Em channel , nickname , origin , server .
+.Em channel , command , nickname , origin , plugin , server .
 .El
 .\" SEE ALSO
 .Sh SEE ALSO
--- a/plugins/joke/joke.js	Wed Feb 10 21:52:32 2021 +0100
+++ b/plugins/joke/joke.js	Thu Feb 11 17:39:22 2021 +0100
@@ -172,6 +172,8 @@
 		} catch (e) {
 			Logger.warning(e.message);
 			server.message(channel, Util.format(Plugin.templates.error, {
+				plugin: Plugin.info().name,
+				command: server.info().prefix + Plugin.info().name,
 				server: server.toString(),
 				channel: channel,
 				origin: origin,
--- a/plugins/plugin/plugin.js	Wed Feb 10 21:52:32 2021 +0100
+++ b/plugins/plugin/plugin.js	Thu Feb 11 17:39:22 2021 +0100
@@ -46,7 +46,7 @@
 	{
 		return {
 			channel: channel,
-			command: server.info().commandChar + Plugin.info().name,
+			command: server.info().prefix + Plugin.info().name,
 			nickname: Util.splituser(origin),
 			origin: origin,
 			plugin: Plugin.info().name,
--- a/plugins/tictactoe/tictactoe.js	Wed Feb 10 21:52:32 2021 +0100
+++ b/plugins/tictactoe/tictactoe.js	Thu Feb 11 17:39:22 2021 +0100
@@ -95,24 +95,6 @@
 }
 
 /**
- * Request a game after the name list gets received.
- *
- * @param server the server object
- * @param channel the channel
- * @param origin the originator
- * @return the object or undefined if not running
- */
-Game.postpone = function (server, channel, origin, target)
-{
-	/*
-	 * Get list of users on the channel to avoid playing against a non existing
-	 * target.
-	 */
-	Game.requests[Game.id(server, channel)] = new Game(server, channel, origin, target);
-	server.names(channel);
-}
-
-/**
  * Populate a set of keywords.
  *
  * @param server the server object
@@ -124,7 +106,7 @@
 {
 	var kw = {
 		channel: channel,
-		command: server.info().commandChar + Plugin.info().name,
+		command: server.info().prefix + Plugin.info().name,
 		plugin: Plugin.info().name,
 		server: server.info().name
 	};
@@ -179,6 +161,37 @@
 }
 
 /**
+ * Check if the target is valid.
+ *
+ * @param server the server object
+ * @param channel the channel string
+ * @param nickname the nickname who requested the game
+ * @param target the opponent
+ * @return true if target is valid
+ */
+Game.isValid = function (server, channel, nickname, target)
+{
+	if (target === "" || target === nickname || target === server.info().nickname)
+		return false;
+
+	var channels = server.info().channels;
+	var ch;
+	
+	for (var i = 0; i < channels.length; ++i) {
+		if (channels[i].name === channel) {
+			ch = channels[i];
+			break;
+		}
+	}
+
+	for (var i = 0; i < ch.users.length; ++i)
+		if (ch.users[i].nickname === target)
+			return true;
+
+	return false;
+}
+
+/**
  * Show the game grid and the next player line.
  */
 Game.prototype.show = function ()
@@ -289,42 +302,29 @@
 	return true;
 }
 
-function onNames(server, channel, list)
-{
-	var id = Game.id(server, channel);
-	var game = Game.requests[id];
-
-	// Names can come from any other plugin/event.
-	if (!game)
-		return;
-
-	// Not a valid target? destroy the game.
-	if (list.indexOf(game.target) < 0)
-		server.message(channel, Util.format(Plugin.templates.invalid,
-			Game.keywords(server, channel, game.origin)));
-	else {
-		Game.map[id] = game;
-		game.show();
-	}
-
-	delete Game.requests[id];
-}
-
 function onCommand(server, origin, channel, message)
 {
+	channel = channel.toLowerCase();
+
 	var target = message.trim();
 	var nickname = Util.splituser(origin);
 
 	if (Game.exists(server, channel))
 		server.message(channel, Util.format(Plugin.templates.running, Game.keywords(server, channel, origin)));
-	else if (target === "" || target === nickname || target === server.info().nickname)
+	else if (!Game.isValid(server, channel, nickname, target))
 		server.message(channel, Util.format(Plugin.templates.invalid, Game.keywords(server, channel, origin)));
-	else
-		Game.postpone(server, channel, origin, message);
+	else {
+		var game = new Game(server, channel, origin, target);
+
+		Game.map[Game.id(server, channel)] = game;
+		game.show();
+	}
 }
 
 function onMessage(server, origin, channel, message)
 {
+	channel = channel.toLowerCase();
+
 	var nickname = Util.splituser(origin);
 	var game = Game.find(server, channel);
 
@@ -351,10 +351,10 @@
 
 function onKick(server, origin, channel, target)
 {
-	Game.clear(server, target, channel);
+	Game.clear(server, target, channel.toLowerCase());
 }
 
 function onPart(server, origin, channel)
 {
-	Game.clear(server, origin, channel);
+	Game.clear(server, origin, channel.toLowerCase());
 }
--- a/tests/CMakeLists.txt	Wed Feb 10 21:52:32 2021 +0100
+++ b/tests/CMakeLists.txt	Thu Feb 11 17:39:22 2021 +0100
@@ -41,6 +41,14 @@
 		test-jsapi-timer
 		test-jsapi-unicode
 		test-jsapi-util
+		test-plugin-ask
+		test-plugin-auth
+		test-plugin-hangman
+		test-plugin-history
+		test-plugin-joke
+		test-plugin-logger
+		test-plugin-plugin
+		test-plugin-tictactoe
 	)
 endif ()
 
@@ -53,6 +61,8 @@
 		${t}
 		PRIVATE
 			IRCCD_EXECUTABLE="$<TARGET_FILE:irccd>"
+			CMAKE_SOURCE_DIR="${CMAKE_SOURCE_DIR}"
+			# TODO: change those names.
 			BINARY="${tests_BINARY_DIR}"
 			SOURCE="${tests_SOURCE_DIR}"
 	)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/data/answers.conf	Thu Feb 11 17:39:22 2021 +0100
@@ -0,0 +1,2 @@
+NO
+YES
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/data/error.json	Thu Feb 11 17:39:22 2021 +0100
@@ -0,0 +1,1 @@
+this is not a json file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/data/joke/error-empty.json	Thu Feb 11 17:39:22 2021 +0100
@@ -0,0 +1,9 @@
+[
+	[
+	],
+	[
+	],
+	[
+		false
+	]
+]
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/data/joke/error-invalid.json	Thu Feb 11 17:39:22 2021 +0100
@@ -0,0 +1,12 @@
+[
+	[
+	],
+	[
+		1234,
+		true,
+		"still has a string though"
+	],
+	[
+		"a"
+	]
+]
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/data/joke/error-not-array.json	Thu Feb 11 17:39:22 2021 +0100
@@ -0,0 +1,3 @@
+{
+	"reason": "this is not a valid jokes database"
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/data/joke/error-toobig.json	Thu Feb 11 17:39:22 2021 +0100
@@ -0,0 +1,15 @@
+[
+	[
+		"xxx",
+		"xxx",
+		"xxx"
+	],
+	[
+		"a"
+	],
+	[
+		"yyy",
+		"yyy",
+		"yyy"
+	]
+]
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/data/joke/jokes.json	Thu Feb 11 17:39:22 2021 +0100
@@ -0,0 +1,9 @@
+[
+	[
+		"aaa"
+	],
+	[
+		"bbbb",
+		"bbbb"
+	]
+]
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/data/words-seq.conf	Thu Feb 11 17:39:22 2021 +0100
@@ -0,0 +1,3 @@
+abc
+abcd
+abcde
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/data/words.conf	Thu Feb 11 17:39:22 2021 +0100
@@ -0,0 +1,1 @@
+sky
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test-plugin-ask.c	Thu Feb 11 17:39:22 2021 +0100
@@ -0,0 +1,111 @@
+/*
+ * test-plugin-ask.c -- test ask plugin
+ *
+ * Copyright (c) 2013-2021 David Demelier <markand@malikania.fr>
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <err.h>
+
+#define GREATEST_USE_ABBREVS 0
+#include <greatest.h>
+
+#include <irccd/compat.h>
+#include <irccd/js-plugin.h>
+#include <irccd/plugin.h>
+#include <irccd/server.h>
+
+static struct irc_server *server;
+static struct irc_plugin *plugin;
+
+static void
+setup(void *udata)
+{
+	(void)udata;
+
+	server = irc_server_new("test", "t", "t", "t", "127.0.0.1", 6667);
+	plugin = js_plugin_open("test", CMAKE_SOURCE_DIR "/plugins/ask/ask.js");
+
+	if (!plugin)
+		errx(1, "could not load plugin");
+
+	irc_server_incref(server);
+	irc_plugin_set_option(plugin, "file", SOURCE "/data/answers.conf");
+	irc_plugin_load(plugin);
+
+	/* Fake server connected to send data. */
+	server->state = IRC_SERVER_STATE_CONNECTED;
+}
+
+static void
+teardown(void *udata)
+{
+	(void)udata;
+
+	irc_plugin_finish(plugin);
+	irc_server_decref(server);
+}
+
+GREATEST_TEST
+basics_simple(void)
+{
+	int no = 0, yes = 0;
+
+	/*
+	 * Invoke the plugin 1000 times, it will be very unlucky to not have
+	 * both answers in that amount of tries.
+	 */
+	for (int i = 0; i < 1000; ++i) {
+		irc_plugin_handle(plugin, &(const struct irc_event) {
+			.type = IRC_EVENT_COMMAND,
+			.server = server,
+			.message = {
+				.message = "",
+				.origin = "jean",
+				.channel = "#test"
+			}
+		});
+
+		if (strcmp(server->conn.out, "PRIVMSG #test :jean, NO\r\n") == 0)
+			yes = 1;
+		else if (strcmp(server->conn.out, "PRIVMSG #test :jean, YES\r\n") == 0)
+			no = 1;
+
+		memset(server->conn.out, 0, sizeof (server->conn.out));
+	}
+
+	GREATEST_ASSERT(no);
+	GREATEST_ASSERT(yes);
+
+	GREATEST_PASS();
+}
+
+GREATEST_SUITE(suite_basics)
+{
+	GREATEST_SET_SETUP_CB(setup, NULL);
+	GREATEST_SET_TEARDOWN_CB(teardown, NULL);
+	GREATEST_RUN_TEST(basics_simple);
+}
+
+GREATEST_MAIN_DEFS();
+
+int
+main(int argc, char **argv)
+{
+	GREATEST_MAIN_BEGIN();
+	GREATEST_RUN_SUITE(suite_basics);
+	GREATEST_MAIN_END();
+
+	return 0;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test-plugin-auth.c	Thu Feb 11 17:39:22 2021 +0100
@@ -0,0 +1,133 @@
+/*
+ * test-plugin-auth.c -- test auth plugin
+ *
+ * Copyright (c) 2013-2021 David Demelier <markand@malikania.fr>
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <err.h>
+
+#define GREATEST_USE_ABBREVS 0
+#include <greatest.h>
+
+#include <irccd/compat.h>
+#include <irccd/js-plugin.h>
+#include <irccd/plugin.h>
+#include <irccd/server.h>
+
+/*
+ * 0 -> nickserv without nickname
+ * 1 -> nickserv with nickname
+ * 2 -> quakenet
+ */
+static struct irc_server *servers[3];
+static struct irc_plugin *plugin;
+
+static void
+setup(void *udata)
+{
+	(void)udata;
+
+	servers[0] = irc_server_new("nickserv1", "t", "t", "t", "127.0.0.1", 6667);
+	servers[1] = irc_server_new("nickserv2", "t", "t", "t", "127.0.0.1", 6667);
+	servers[2] = irc_server_new("quakenet", "t", "t", "t", "127.0.0.1", 6667);
+	plugin = js_plugin_open("test", CMAKE_SOURCE_DIR "/plugins/auth/auth.js");
+
+	if (!plugin)
+		errx(1, "could not load plugin");
+
+	irc_server_incref(servers[0]);
+	irc_server_incref(servers[1]);
+	irc_server_incref(servers[2]);
+	irc_plugin_set_option(plugin, "nickserv1.type", "nickserv");
+	irc_plugin_set_option(plugin, "nickserv1.password", "plopation");
+	irc_plugin_set_option(plugin, "nickserv2.type", "nickserv");
+	irc_plugin_set_option(plugin, "nickserv2.password", "something");
+	irc_plugin_set_option(plugin, "nickserv2.username", "jean");
+	irc_plugin_set_option(plugin, "quakenet.type", "quakenet");
+	irc_plugin_set_option(plugin, "quakenet.password", "hello");
+	irc_plugin_set_option(plugin, "quakenet.username", "mario");
+	irc_plugin_load(plugin);
+
+	/* Fake server connected to send data. */
+	servers[0]->state = servers[1]->state = servers[2]->state = IRC_SERVER_STATE_CONNECTED;
+}
+
+static void
+teardown(void *udata)
+{
+	(void)udata;
+
+	irc_plugin_finish(plugin);
+	irc_server_decref(servers[0]);
+	irc_server_decref(servers[1]);
+	irc_server_decref(servers[2]);
+}
+
+GREATEST_TEST
+basics_nickserv1(void)
+{
+	irc_plugin_handle(plugin, &(const struct irc_event) {
+		.type = IRC_EVENT_CONNECT,
+		.server = servers[0]
+	});
+
+	GREATEST_ASSERT_STR_EQ("PRIVMSG NickServ :identify plopation\r\n", servers[0]->conn.out);
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+basics_nickserv2(void)
+{
+	irc_plugin_handle(plugin, &(const struct irc_event) {
+		.type = IRC_EVENT_CONNECT,
+		.server = servers[1]
+	});
+
+	GREATEST_ASSERT_STR_EQ("PRIVMSG NickServ :identify jean something\r\n", servers[1]->conn.out);
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+basics_quakenet(void)
+{
+	irc_plugin_handle(plugin, &(const struct irc_event) {
+		.type = IRC_EVENT_CONNECT,
+		.server = servers[2]
+	});
+
+	GREATEST_ASSERT_STR_EQ("PRIVMSG Q@CServe.quakenet.org :AUTH mario hello\r\n", servers[2]->conn.out);
+	GREATEST_PASS();
+}
+
+GREATEST_SUITE(suite_basics)
+{
+	GREATEST_SET_SETUP_CB(setup, NULL);
+	GREATEST_SET_TEARDOWN_CB(teardown, NULL);
+	GREATEST_RUN_TEST(basics_nickserv1);
+	GREATEST_RUN_TEST(basics_nickserv2);
+	GREATEST_RUN_TEST(basics_quakenet);
+}
+
+GREATEST_MAIN_DEFS();
+
+int
+main(int argc, char **argv)
+{
+	GREATEST_MAIN_BEGIN();
+	GREATEST_RUN_SUITE(suite_basics);
+	GREATEST_MAIN_END();
+
+	return 0;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test-plugin-hangman.c	Thu Feb 11 17:39:22 2021 +0100
@@ -0,0 +1,280 @@
+/*
+ * main.cpp -- test hangman plugin
+ *
+ * Copyright (c) 2013-2021 David Demelier <markand@malikania.fr>
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <err.h>
+
+#define GREATEST_USE_ABBREVS 0
+#include <greatest.h>
+
+#include <irccd/compat.h>
+#include <irccd/js-plugin.h>
+#include <irccd/log.h>
+#include <irccd/plugin.h>
+#include <irccd/server.h>
+
+#define CALL(t, m) do {                                                 \
+	memset(server->conn.out, 0, sizeof (server->conn.out));         \
+	irc_plugin_handle(plugin, &(const struct irc_event) {           \
+		.type = t,                                              \
+		.server = server,                                       \
+			.message = {                                    \
+			.origin = "jean!jean@localhost",                \
+			.channel = "#hangman",                          \
+			.message = m                                    \
+		}                                                       \
+	});                                                             \
+} while (0)
+
+#define CALL_EX(t, o, c, m) do {                                        \
+	memset(server->conn.out, 0, sizeof (server->conn.out));         \
+	irc_plugin_handle(plugin, &(const struct irc_event) {           \
+		.type = t,                                              \
+		.server = server,                                       \
+			.message = {                                    \
+			.origin = o,                                    \
+			.channel = c,                                   \
+			.message = m                                    \
+		}                                                       \
+	});                                                             \
+} while (0)
+
+static struct irc_server *server;
+static struct irc_plugin *plugin;
+
+static void
+setup(void *udata)
+{
+	(void)udata;
+
+	server = irc_server_new("test", "t", "t", "t", "127.0.0.1", 6667);
+	plugin = js_plugin_open("hangman", CMAKE_SOURCE_DIR "/plugins/hangman/hangman.js");
+
+	if (!plugin)
+		errx(1, "could not load plugin");
+
+	irc_log_to_console();
+	irc_server_incref(server);
+	irc_plugin_set_template(plugin, "asked", "asked=#{plugin}:#{command}:#{server}:#{channel}:#{origin}:#{nickname}:#{letter}");
+	irc_plugin_set_template(plugin, "dead", "dead=#{plugin}:#{command}:#{server}:#{channel}:#{origin}:#{nickname}:#{word}");
+	irc_plugin_set_template(plugin, "found", "found=#{plugin}:#{command}:#{server}:#{channel}:#{origin}:#{nickname}:#{word}");
+	irc_plugin_set_template(plugin, "start", "start=#{plugin}:#{command}:#{server}:#{channel}:#{origin}:#{nickname}:#{word}");
+	irc_plugin_set_template(plugin, "running", "running=#{plugin}:#{command}:#{server}:#{channel}:#{origin}:#{nickname}:#{word}");
+	irc_plugin_set_template(plugin, "win", "win=#{plugin}:#{command}:#{server}:#{channel}:#{origin}:#{nickname}:#{word}");
+	irc_plugin_set_template(plugin, "wrong-letter", "wrong-letter=#{plugin}:#{command}:#{server}:#{channel}:#{origin}:#{nickname}:#{letter}");
+	irc_plugin_set_template(plugin, "wrong-player", "wrong-player=#{plugin}:#{command}:#{server}:#{channel}:#{origin}:#{nickname}:#{letter}");
+	irc_plugin_set_template(plugin, "wrong-word", "wrong-word=#{plugin}:#{command}:#{server}:#{channel}:#{origin}:#{nickname}:#{word}");
+	irc_plugin_set_option(plugin, "file", SOURCE "/data/words.conf");
+	irc_plugin_set_option(plugin, "collaborative", "false");
+	irc_plugin_load(plugin);
+
+	/* Fake server connected to send data. */
+	server->state = IRC_SERVER_STATE_CONNECTED;
+}
+
+static void
+teardown(void *udata)
+{
+	(void)udata;
+
+	irc_plugin_finish(plugin);
+	irc_server_decref(server);
+}
+
+GREATEST_TEST
+basics_asked(void)
+{
+	CALL(IRC_EVENT_COMMAND, "");
+	GREATEST_ASSERT_STR_EQ("PRIVMSG #hangman :start=hangman:!hangman:test:#hangman:jean!jean@localhost:jean:_ _ _\r\n", server->conn.out);
+
+	CALL(IRC_EVENT_MESSAGE, "s");
+	GREATEST_ASSERT_STR_EQ("PRIVMSG #hangman :found=hangman:!hangman:test:#hangman:jean!jean@localhost:jean:s _ _\r\n", server->conn.out);
+
+	CALL(IRC_EVENT_MESSAGE, "s");
+	GREATEST_ASSERT_STR_EQ("PRIVMSG #hangman :asked=hangman:!hangman:test:#hangman:jean!jean@localhost:jean:s\r\n", server->conn.out);
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+basics_dead(void)
+{
+	CALL(IRC_EVENT_COMMAND, "");
+	CALL(IRC_EVENT_MESSAGE, "a");
+	CALL(IRC_EVENT_MESSAGE, "b");
+	CALL(IRC_EVENT_MESSAGE, "c");
+	CALL(IRC_EVENT_MESSAGE, "d");
+	CALL(IRC_EVENT_MESSAGE, "e");
+	CALL(IRC_EVENT_MESSAGE, "f");
+	CALL(IRC_EVENT_MESSAGE, "g");
+	CALL(IRC_EVENT_MESSAGE, "h");
+	CALL(IRC_EVENT_MESSAGE, "i");
+	CALL(IRC_EVENT_MESSAGE, "j");
+	GREATEST_ASSERT_STR_EQ("PRIVMSG #hangman :dead=hangman:!hangman:test:#hangman:jean!jean@localhost:jean:sky\r\n", server->conn.out);
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+basics_found(void)
+{
+	CALL(IRC_EVENT_COMMAND, "");
+	CALL(IRC_EVENT_MESSAGE, "s");
+	GREATEST_ASSERT_STR_EQ("PRIVMSG #hangman :found=hangman:!hangman:test:#hangman:jean!jean@localhost:jean:s _ _\r\n", server->conn.out);
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+basics_start(void)
+{
+	CALL(IRC_EVENT_COMMAND, "");
+	GREATEST_ASSERT_STR_EQ("PRIVMSG #hangman :start=hangman:!hangman:test:#hangman:jean!jean@localhost:jean:_ _ _\r\n", server->conn.out);
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+basics_win1(void)
+{
+	CALL(IRC_EVENT_COMMAND, "");
+	CALL(IRC_EVENT_MESSAGE, "s");
+	CALL(IRC_EVENT_MESSAGE, "k");
+	CALL(IRC_EVENT_MESSAGE, "y");
+	GREATEST_ASSERT_STR_EQ("PRIVMSG #hangman :win=hangman:!hangman:test:#hangman:jean!jean@localhost:jean:sky\r\n", server->conn.out);
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+basics_win2(void)
+{
+	CALL(IRC_EVENT_COMMAND, "");
+	CALL(IRC_EVENT_COMMAND, "sky");
+	GREATEST_ASSERT_STR_EQ("PRIVMSG #hangman :win=hangman:!hangman:test:#hangman:jean!jean@localhost:jean:sky\r\n", server->conn.out);
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+basics_wrong_letter(void)
+{
+	CALL(IRC_EVENT_COMMAND, "");
+	CALL(IRC_EVENT_MESSAGE, "x");
+	GREATEST_ASSERT_STR_EQ("PRIVMSG #hangman :wrong-letter=hangman:!hangman:test:#hangman:jean!jean@localhost:jean:x\r\n", server->conn.out);
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+basics_wrong_word(void)
+{
+	CALL(IRC_EVENT_COMMAND, "");
+	CALL(IRC_EVENT_COMMAND, "cheese");
+	GREATEST_ASSERT_STR_EQ("PRIVMSG #hangman :wrong-word=hangman:!hangman:test:#hangman:jean!jean@localhost:jean:cheese\r\n", server->conn.out);
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+basics_collaborative_enabled(void)
+{
+	irc_plugin_set_option(plugin, "collaborative", "true");
+
+	CALL(IRC_EVENT_COMMAND, "");
+	CALL(IRC_EVENT_MESSAGE, "s");
+
+	/* Forbidden to play twice. */
+	CALL(IRC_EVENT_MESSAGE, "k");
+	GREATEST_ASSERT_STR_EQ("PRIVMSG #hangman :wrong-player=hangman:!hangman:test:#hangman:jean!jean@localhost:jean:k\r\n", server->conn.out);
+
+	/* Use a different nickname now. */
+	CALL_EX(IRC_EVENT_MESSAGE, "francis!francis@localhost", "#hangman", "k");
+	GREATEST_ASSERT_STR_EQ("PRIVMSG #hangman :found=hangman:!hangman:test:#hangman:francis!francis@localhost:francis:s k _\r\n", server->conn.out);
+
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+basics_case_insensitive(void)
+{
+	CALL_EX(IRC_EVENT_COMMAND, "jean!jean@localhost", "#hangman", "");
+
+	CALL_EX(IRC_EVENT_MESSAGE, "jean!jean@localhost", "#HANGMAN", "s");
+	GREATEST_ASSERT_STR_EQ("PRIVMSG #hangman :found=hangman:!hangman:test:#hangman:jean!jean@localhost:jean:s _ _\r\n", server->conn.out);
+
+	CALL_EX(IRC_EVENT_MESSAGE, "jean!jean@localhost", "#HaNGMaN", "k");
+	GREATEST_ASSERT_STR_EQ("PRIVMSG #hangman :found=hangman:!hangman:test:#hangman:jean!jean@localhost:jean:s k _\r\n", server->conn.out);
+
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+basics_query(void)
+{
+	/*
+	 * Set collaborative mode but in query it must be ignored since there is
+	 * only one player against the bot.
+	 */
+	irc_plugin_set_option(plugin, "collaborative", "true");
+
+	CALL_EX(IRC_EVENT_COMMAND, "jean!jean@localhost", "t", "");
+	GREATEST_ASSERT_STR_EQ("PRIVMSG jean!jean@localhost :start=hangman:!hangman:test:jean!jean@localhost:jean!jean@localhost:jean:_ _ _\r\n", server->conn.out);
+
+	CALL_EX(IRC_EVENT_MESSAGE, "jean!jean@localhost", "t", "s");
+	GREATEST_ASSERT_STR_EQ("PRIVMSG jean!jean@localhost :found=hangman:!hangman:test:jean!jean@localhost:jean!jean@localhost:jean:s _ _\r\n", server->conn.out);
+
+	CALL_EX(IRC_EVENT_MESSAGE, "jean!jean@localhost", "t", "k");
+	GREATEST_ASSERT_STR_EQ("PRIVMSG jean!jean@localhost :found=hangman:!hangman:test:jean!jean@localhost:jean!jean@localhost:jean:s k _\r\n", server->conn.out);
+
+	CALL_EX(IRC_EVENT_COMMAND, "jean!jean@localhost", "t", "sky");
+	GREATEST_ASSERT_STR_EQ("PRIVMSG jean!jean@localhost :win=hangman:!hangman:test:jean!jean@localhost:jean!jean@localhost:jean:sky\r\n", server->conn.out);
+
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+basics_running(void)
+{
+	CALL(IRC_EVENT_COMMAND, "");
+	CALL(IRC_EVENT_MESSAGE, "y");
+	CALL(IRC_EVENT_COMMAND, "");
+	GREATEST_ASSERT_STR_EQ("PRIVMSG #hangman :running=hangman:!hangman:test:#hangman:jean!jean@localhost:jean:_ _ y\r\n", server->conn.out);
+	GREATEST_PASS();
+}
+
+GREATEST_SUITE(suite_basics)
+{
+	GREATEST_SET_SETUP_CB(setup, NULL);
+	GREATEST_SET_TEARDOWN_CB(teardown, NULL);
+	GREATEST_RUN_TEST(basics_asked);
+	GREATEST_RUN_TEST(basics_dead);
+	GREATEST_RUN_TEST(basics_found);
+	GREATEST_RUN_TEST(basics_start);
+	GREATEST_RUN_TEST(basics_win1);
+	GREATEST_RUN_TEST(basics_win2);
+	GREATEST_RUN_TEST(basics_wrong_letter);
+	GREATEST_RUN_TEST(basics_wrong_word);
+	GREATEST_RUN_TEST(basics_collaborative_enabled);
+	GREATEST_RUN_TEST(basics_case_insensitive);
+	GREATEST_RUN_TEST(basics_query);
+	GREATEST_RUN_TEST(basics_running);
+}
+
+GREATEST_MAIN_DEFS();
+
+int
+main(int argc, char **argv)
+{
+	GREATEST_MAIN_BEGIN();
+	GREATEST_RUN_SUITE(suite_basics);
+	GREATEST_MAIN_END();
+
+	return 0;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test-plugin-history.c	Thu Feb 11 17:39:22 2021 +0100
@@ -0,0 +1,178 @@
+/*
+ * test-plugin-history.c -- test history plugin
+ *
+ * Copyright (c) 2013-2021 David Demelier <markand@malikania.fr>
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <err.h>
+
+#define GREATEST_USE_ABBREVS 0
+#include <greatest.h>
+
+#include <irccd/compat.h>
+#include <irccd/js-plugin.h>
+#include <irccd/log.h>
+#include <irccd/plugin.h>
+#include <irccd/server.h>
+
+#define CALL(t, m) do {                                                 \
+	memset(server->conn.out, 0, sizeof (server->conn.out));         \
+	irc_plugin_handle(plugin, &(const struct irc_event) {           \
+		.type = t,                                              \
+		.server = server,                                       \
+			.message = {                                    \
+			.origin = "jean!jean@localhost",                \
+			.channel = "#history",                          \
+			.message = m                                    \
+		}                                                       \
+	});                                                             \
+} while (0)
+
+#define CALL_EX(t, o, c, m) do {                                        \
+	memset(server->conn.out, 0, sizeof (server->conn.out));         \
+	irc_plugin_handle(plugin, &(const struct irc_event) {           \
+		.type = t,                                              \
+		.server = server,                                       \
+			.message = {                                    \
+			.origin = o,                                    \
+			.channel = c,                                   \
+			.message = m                                    \
+		}                                                       \
+	});                                                             \
+} while (0)
+
+static struct irc_server *server;
+static struct irc_plugin *plugin;
+
+static void
+setup(void *udata)
+{
+	(void)udata;
+
+	remove(BINARY "/seen.json");
+
+	server = irc_server_new("test", "t", "t", "t", "127.0.0.1", 6667);
+	plugin = js_plugin_open("history", CMAKE_SOURCE_DIR "/plugins/history/history.js");
+
+	if (!plugin)
+		errx(1, "could not load plugin");
+
+	irc_log_to_console();
+	irc_server_incref(server);
+	irc_plugin_set_template(plugin, "error", "error=#{plugin}:#{command}:#{server}:#{channel}:#{origin}:#{nickname}");
+	irc_plugin_set_template(plugin, "seen", "seen=#{plugin}:#{command}:#{server}:#{channel}:#{origin}:#{nickname}:#{target}:%H:%M");
+	irc_plugin_set_template(plugin, "said", "said=#{plugin}:#{command}:#{server}:#{channel}:#{origin}:#{nickname}:#{target}:#{message}:%H:%M");
+	irc_plugin_set_template(plugin, "unknown", "unknown=#{plugin}:#{command}:#{server}:#{channel}:#{origin}:#{nickname}:#{target}");
+	irc_plugin_set_option(plugin, "file", BINARY "/seen.json");
+	irc_plugin_load(plugin);
+
+	/* Fake server connected to send data. */
+	server->state = IRC_SERVER_STATE_CONNECTED;
+}
+
+static void
+teardown(void *udata)
+{
+	(void)udata;
+
+	irc_plugin_finish(plugin);
+	irc_server_decref(server);
+}
+
+GREATEST_TEST
+basics_error(void)
+{
+	irc_plugin_set_option(plugin, "file", SOURCE "/data/error.json");
+	CALL(IRC_EVENT_COMMAND, "seen francis");
+	GREATEST_ASSERT_STR_EQ("PRIVMSG #history :error=history:!history:test:#history:jean!jean@localhost:jean\r\n", server->conn.out);
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+basics_seen(void)
+{
+	int d1, d2;
+
+	CALL_EX(IRC_EVENT_MESSAGE, "jean!jean@localhost", "#history", "hello");
+	CALL_EX(IRC_EVENT_COMMAND, "francis!francis@localhost", "#history", "seen jean");
+
+	GREATEST_ASSERT_EQ(2, sscanf(server->conn.out, "PRIVMSG #history :seen=history:!history:test:#history:francis!francis@localhost:francis:jean:%d:%d\r\n", &d1, &d2));
+
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+basics_said(void)
+{
+	int d1, d2;
+
+	CALL_EX(IRC_EVENT_MESSAGE, "jean!jean@localhost", "#history", "hello");
+	CALL_EX(IRC_EVENT_COMMAND, "francis!francis@localhost", "#history", "said jean");
+
+	GREATEST_ASSERT_EQ(2, sscanf(server->conn.out, "PRIVMSG #history :said=history:!history:test:#history:francis!francis@localhost:francis:jean:hello:%d:%d", &d1, &d2));
+
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+basics_unknown(void)
+{
+	CALL_EX(IRC_EVENT_MESSAGE, "jean!jean@localhost", "#history", "hello");
+	CALL_EX(IRC_EVENT_COMMAND, "francis!francis@localhost", "#history", "said nobody");
+
+	GREATEST_ASSERT_STR_EQ("PRIVMSG #history :unknown=history:!history:test:#history:francis!francis@localhost:francis:nobody\r\n", server->conn.out);
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+basics_case_insensitive(void)
+{
+	int d1, d2;
+
+	CALL_EX(IRC_EVENT_MESSAGE, "JeaN!JeaN@localhost", "#history", "hello");
+
+	CALL_EX(IRC_EVENT_COMMAND, "destructor!dst@localhost", "#HISTORY", "said JEAN");
+	GREATEST_ASSERT_EQ(2, sscanf(server->conn.out, "PRIVMSG #history :said=history:!history:test:#history:destructor!dst@localhost:destructor:jean:hello:%d:%d\r\n", &d1, &d2));
+
+	CALL_EX(IRC_EVENT_COMMAND, "destructor!dst@localhost", "#HiSToRy", "said JeaN");
+	GREATEST_ASSERT_EQ(2, sscanf(server->conn.out, "PRIVMSG #history :said=history:!history:test:#history:destructor!dst@localhost:destructor:jean:hello:%d:%d\r\n", &d1, &d2));
+
+	GREATEST_PASS();
+}
+
+GREATEST_SUITE(suite_basics)
+{
+	GREATEST_SET_SETUP_CB(setup, NULL);
+	GREATEST_SET_TEARDOWN_CB(teardown, NULL);
+	GREATEST_RUN_TEST(basics_error);
+	GREATEST_RUN_TEST(basics_seen);
+	GREATEST_RUN_TEST(basics_said);
+	GREATEST_RUN_TEST(basics_unknown);
+	GREATEST_RUN_TEST(basics_case_insensitive);
+}
+
+GREATEST_MAIN_DEFS();
+
+int
+main(int argc, char **argv)
+{
+	GREATEST_MAIN_BEGIN();
+	GREATEST_RUN_SUITE(suite_basics);
+	GREATEST_MAIN_END();
+
+	return 0;
+}
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test-plugin-joke.c	Thu Feb 11 17:39:22 2021 +0100
@@ -0,0 +1,200 @@
+/*
+ * main.cpp -- test joke plugin
+ *
+ * Copyright (c) 2013-2021 David Demelier <markand@malikania.fr>
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <err.h>
+
+#define GREATEST_USE_ABBREVS 0
+#include <greatest.h>
+
+#include <irccd/compat.h>
+#include <irccd/js-plugin.h>
+#include <irccd/plugin.h>
+#include <irccd/server.h>
+
+#define CALL() do {                                                     \
+	memset(server->conn.out, 0, sizeof (server->conn.out));         \
+	irc_plugin_handle(plugin, &(const struct irc_event) {           \
+		.type = IRC_EVENT_COMMAND,                              \
+		.server = server,                                       \
+			.message = {                                    \
+			.origin = "jean!jean@localhost",                \
+			.channel = "#joke",                             \
+			.message = ""                                   \
+		}                                                       \
+	});                                                             \
+} while (0)
+
+static struct irc_server *server;
+static struct irc_plugin *plugin;
+
+static void
+setup(void *udata)
+{
+	(void)udata;
+
+	server = irc_server_new("test", "t", "t", "t", "127.0.0.1", 6667);
+	plugin = js_plugin_open("joke", CMAKE_SOURCE_DIR "/plugins/joke/joke.js");
+
+	if (!plugin)
+		errx(1, "could not load plugin");
+
+	irc_server_incref(server);
+	irc_plugin_set_template(plugin, "error", "error=#{plugin}:#{command}:#{server}:#{channel}:#{origin}:#{nickname}");
+
+	irc_plugin_set_option(plugin, "file", SOURCE "/data/joke/jokes.json");
+	irc_plugin_load(plugin);
+
+	/* Fake server connected to send data. */
+	server->state = IRC_SERVER_STATE_CONNECTED;
+}
+
+static void
+teardown(void *udata)
+{
+	(void)udata;
+
+	irc_plugin_finish(plugin);
+	irc_server_decref(server);
+}
+
+GREATEST_TEST
+basics_simple(void)
+{
+	/*
+	 * jokes.json have two jokes.
+	 *
+	 * aaa
+	 *
+	 * And
+	 *
+	 * bbbb
+	 * bbbb
+	 */
+	int aaa = 0, bbbb = 0;
+
+	for (int i = 0; i < 2; ++i) {
+		CALL();
+
+		if (strcmp(server->conn.out, "PRIVMSG #joke :aaa\r\n") == 0)
+			aaa = 1;
+		else if (strcmp(server->conn.out, "PRIVMSG #joke :bbbb\r\nPRIVMSG #joke :bbbb\r\n") == 0)
+			bbbb = 1;
+	}
+
+	GREATEST_ASSERT(aaa);
+	GREATEST_ASSERT(bbbb);
+
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+errors_toobig(void)
+{
+	/*
+	 * The jokes "xxx" and "yyy" are both 3-lines which we disallow. only a
+	 * must be said.
+	 */
+	irc_plugin_set_option(plugin, "file", SOURCE "/data/joke/error-toobig.json");
+	irc_plugin_set_option(plugin, "max-list-lines", "2");
+
+	for (int i = 0; i < 64; ++i) {
+		CALL();
+		GREATEST_ASSERT_STR_EQ("PRIVMSG #joke :a\r\n", server->conn.out);
+	}
+
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+errors_invalid(void)
+{
+	/* Only a is the valid joke in this file. */
+	irc_plugin_set_option(plugin, "file", SOURCE "/data/joke/error-invalid.json");
+	irc_plugin_set_option(plugin, "max-list-lines", "2");
+
+	for (int i = 0; i < 64; ++i) {
+		CALL();
+		GREATEST_ASSERT_STR_EQ("PRIVMSG #joke :a\r\n", server->conn.out);
+	}
+
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+errors_not_found(void)
+{
+	irc_plugin_set_option(plugin, "file", "doesnotexist.json");
+
+	CALL();
+	GREATEST_ASSERT_STR_EQ("PRIVMSG #joke :error=joke:!joke:test:#joke:jean!jean@localhost:jean\r\n", server->conn.out);
+
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+errors_not_array(void)
+{
+	irc_plugin_set_option(plugin, "file", SOURCE "/data/joke/error-not-array.json");
+
+	CALL();
+	GREATEST_ASSERT_STR_EQ("PRIVMSG #joke :error=joke:!joke:test:#joke:jean!jean@localhost:jean\r\n", server->conn.out);
+
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+errors_empty(void)
+{
+	irc_plugin_set_option(plugin, "file", SOURCE "/data/joke/error-empty.json");
+
+	CALL();
+	GREATEST_ASSERT_STR_EQ("PRIVMSG #joke :error=joke:!joke:test:#joke:jean!jean@localhost:jean\r\n", server->conn.out);
+
+	GREATEST_PASS();
+}
+
+GREATEST_SUITE(suite_basics)
+{
+	GREATEST_SET_SETUP_CB(setup, NULL);
+	GREATEST_SET_TEARDOWN_CB(teardown, NULL);
+	GREATEST_RUN_TEST(basics_simple);
+}
+
+GREATEST_SUITE(suite_errors)
+{
+	GREATEST_SET_SETUP_CB(setup, NULL);
+	GREATEST_SET_TEARDOWN_CB(teardown, NULL);
+	GREATEST_RUN_TEST(errors_toobig);
+	GREATEST_RUN_TEST(errors_invalid);
+	GREATEST_RUN_TEST(errors_not_found);
+	GREATEST_RUN_TEST(errors_not_array);
+	GREATEST_RUN_TEST(errors_empty);
+}
+
+GREATEST_MAIN_DEFS();
+
+int
+main(int argc, char **argv)
+{
+	GREATEST_MAIN_BEGIN();
+	GREATEST_RUN_SUITE(suite_basics);
+	GREATEST_RUN_SUITE(suite_errors);
+	GREATEST_MAIN_END();
+
+	return 0;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test-plugin-logger.c	Thu Feb 11 17:39:22 2021 +0100
@@ -0,0 +1,274 @@
+/*
+ * test-plugin-logger.c -- test logger plugin
+ *
+ * Copyright (c) 2013-2021 David Demelier <markand@malikania.fr>
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <err.h>
+
+#define GREATEST_USE_ABBREVS 0
+#include <greatest.h>
+
+#include <irccd/compat.h>
+#include <irccd/js-plugin.h>
+#include <irccd/log.h>
+#include <irccd/plugin.h>
+#include <irccd/server.h>
+
+static struct irc_server *server;
+static struct irc_plugin *plugin;
+
+static void
+setup(void *udata)
+{
+	(void)udata;
+
+	remove(BINARY "/log");
+
+	server = irc_server_new("test", "t", "t", "t", "127.0.0.1", 6667);
+	plugin = js_plugin_open("logger", CMAKE_SOURCE_DIR "/plugins/logger/logger.js");
+
+	if (!plugin)
+		errx(1, "could not load plugin");
+
+	irc_log_to_console();
+	irc_server_incref(server);
+	irc_plugin_set_template(plugin, "join", "join=#{server}:#{channel}:#{origin}:#{nickname}");
+	irc_plugin_set_template(plugin, "kick", "kick=#{server}:#{channel}:#{origin}:#{nickname}:#{target}:#{reason}");
+	irc_plugin_set_template(plugin, "me", "me=#{server}:#{channel}:#{origin}:#{nickname}:#{message}");
+	irc_plugin_set_template(plugin, "message", "message=#{server}:#{channel}:#{origin}:#{nickname}:#{message}");
+	irc_plugin_set_template(plugin, "mode", "mode=#{server}:#{origin}:#{channel}:#{mode}:#{limit}:#{user}:#{mask}");
+	irc_plugin_set_template(plugin, "notice", "notice=#{server}:#{origin}:#{channel}:#{message}");
+	irc_plugin_set_template(plugin, "part", "part=#{server}:#{channel}:#{origin}:#{nickname}:#{reason}");
+	irc_plugin_set_template(plugin, "query", "query=#{server}:#{origin}:#{nickname}:#{message}");
+	irc_plugin_set_template(plugin, "topic", "topic=#{server}:#{channel}:#{origin}:#{nickname}:#{topic}");
+	irc_plugin_set_option(plugin, "file", BINARY "/log");
+	irc_plugin_load(plugin);
+
+	/* Fake server connected to send data. */
+	server->state = IRC_SERVER_STATE_CONNECTED;
+}
+
+static void
+teardown(void *udata)
+{
+	(void)udata;
+
+	irc_plugin_finish(plugin);
+	irc_server_decref(server);
+}
+
+static const char *
+last(void)
+{
+	static char buf[1024];
+	FILE *fp;
+
+	buf[0] = '\0';
+
+	if (!(fp = fopen(BINARY "/log", "r")))
+		err(1, "fopen");
+	if (!(fgets(buf, sizeof (buf), fp)))
+		err(1, "fgets");
+
+	fclose(fp);
+
+	buf[strcspn(buf, "\r\n")] = '\0';
+
+	return buf;
+}
+
+GREATEST_TEST
+basics_join(void)
+{
+	irc_plugin_handle(plugin, &(const struct irc_event) {
+		.type = IRC_EVENT_JOIN,
+		.server = server,
+		.join = {
+			.origin = "jean!jean@localhost",
+			.channel = "#staff"
+		}
+	});
+
+	GREATEST_ASSERT_STR_EQ("join=test:#staff:jean!jean@localhost:jean", last());
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+basics_kick(void)
+{
+	irc_plugin_handle(plugin, &(const struct irc_event) {
+		.type = IRC_EVENT_KICK,
+		.server = server,
+		.kick = {
+			.origin = "jean!jean@localhost",
+			.channel = "#staff",
+			.target = "badboy",
+			.reason = "please do not flood"
+		}
+	});
+
+	GREATEST_ASSERT_STR_EQ("kick=test:#staff:jean!jean@localhost:jean:badboy:please do not flood", last());
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+basics_me(void)
+{
+	irc_plugin_handle(plugin, &(const struct irc_event) {
+		.type = IRC_EVENT_ME,
+		.server = server,
+		.message = {
+			.origin = "jean!jean@localhost",
+			.channel = "#staff",
+			.message = "is drinking water"
+		}
+	});
+
+	GREATEST_ASSERT_STR_EQ("me=test:#staff:jean!jean@localhost:jean:is drinking water", last());
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+basics_message(void)
+{
+	irc_plugin_handle(plugin, &(const struct irc_event) {
+		.type = IRC_EVENT_MESSAGE,
+		.server = server,
+		.message = {
+			.origin = "jean!jean@localhost",
+			.channel = "#staff",
+			.message = "hello guys"
+		}
+	});
+
+	GREATEST_ASSERT_STR_EQ("message=test:#staff:jean!jean@localhost:jean:hello guys", last());
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+basics_mode(void)
+{
+	irc_plugin_handle(plugin, &(const struct irc_event) {
+		.type = IRC_EVENT_MODE,
+		.server = server,
+		.mode = {
+			.origin = "jean!jean@localhost",
+			.channel = "chris",
+			.mode = "+i",
+			.limit = "l",
+			.user = "u",
+			.mask = "m"
+		}
+	});
+
+	GREATEST_ASSERT_STR_EQ("mode=test:jean!jean@localhost:chris:+i:l:u:m", last());
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+basics_notice(void)
+{
+	irc_plugin_handle(plugin, &(const struct irc_event) {
+		.type = IRC_EVENT_NOTICE,
+		.server = server,
+		.notice = {
+			.origin = "jean!jean@localhost",
+			.channel = "chris",
+			.notice = "tu veux voir mon chat ?"
+		}
+	});
+
+	GREATEST_ASSERT_STR_EQ("notice=test:jean!jean@localhost:chris:tu veux voir mon chat ?", last());
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+basics_part(void)
+{
+	irc_plugin_handle(plugin, &(const struct irc_event) {
+		.type = IRC_EVENT_PART,
+		.server = server,
+		.part = {
+			.origin = "jean!jean@localhost",
+			.channel = "#staff",
+			.reason = "too noisy here"
+		}
+	});
+
+	GREATEST_ASSERT_STR_EQ("part=test:#staff:jean!jean@localhost:jean:too noisy here", last());
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+basics_topic(void)
+{
+	irc_plugin_handle(plugin, &(const struct irc_event) {
+		.type = IRC_EVENT_TOPIC,
+		.server = server,
+		.topic = {
+			.origin = "jean!jean@localhost",
+			.channel = "#staff",
+			.topic = "oh yeah yeaaaaaaaah"
+		}
+	});
+
+	GREATEST_ASSERT_STR_EQ("topic=test:#staff:jean!jean@localhost:jean:oh yeah yeaaaaaaaah", last());
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+basics_case_insensitive(void)
+{
+	irc_plugin_handle(plugin, &(const struct irc_event) {
+		.type = IRC_EVENT_MESSAGE,
+		.server = server,
+		.message = {
+			.origin = "jean!jean@localhost",
+			.channel = "#STAFF",
+			.message = "hello guys"
+		}
+	});
+
+	GREATEST_ASSERT_STR_EQ("message=test:#staff:jean!jean@localhost:jean:hello guys", last());
+	GREATEST_PASS();
+}
+
+GREATEST_SUITE(suite_basics)
+{
+	GREATEST_SET_SETUP_CB(setup, NULL);
+	GREATEST_SET_TEARDOWN_CB(teardown, NULL);
+	GREATEST_RUN_TEST(basics_join);
+	GREATEST_RUN_TEST(basics_kick);
+	GREATEST_RUN_TEST(basics_me);
+	GREATEST_RUN_TEST(basics_message);
+	GREATEST_RUN_TEST(basics_mode);
+	GREATEST_RUN_TEST(basics_notice);
+	GREATEST_RUN_TEST(basics_part);
+	GREATEST_RUN_TEST(basics_topic);
+	GREATEST_RUN_TEST(basics_case_insensitive);
+}
+
+GREATEST_MAIN_DEFS();
+
+int
+main(int argc, char **argv)
+{
+	GREATEST_MAIN_BEGIN();
+	GREATEST_RUN_SUITE(suite_basics);
+	GREATEST_MAIN_END();
+
+	return 0;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test-plugin-plugin.c	Thu Feb 11 17:39:22 2021 +0100
@@ -0,0 +1,169 @@
+/*
+ * test-plugin-plugin.c -- test plugin plugin
+ *
+ * Copyright (c) 2013-2021 David Demelier <markand@malikania.fr>
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <err.h>
+#include <string.h>
+
+#define GREATEST_USE_ABBREVS 0
+#include <greatest.h>
+
+#include <irccd/compat.h>
+#include <irccd/irccd.h>
+#include <irccd/js-plugin.h>
+#include <irccd/log.h>
+#include <irccd/plugin.h>
+#include <irccd/server.h>
+#include <irccd/util.h>
+
+#define CALL(t, m) do {                                                 \
+	memset(server->conn.out, 0, sizeof (server->conn.out));         \
+	irc_plugin_handle(plugin, &(const struct irc_event) {           \
+		.type = t,                                              \
+		.server = server,                                       \
+			.message = {                                    \
+			.origin = "jean!jean@localhost",                \
+			.channel = "#plugin",                           \
+			.message = m                                    \
+		}                                                       \
+	});                                                             \
+} while (0)
+
+static struct irc_server *server;
+static struct irc_plugin *plugin, *fake;
+
+static struct irc_plugin *
+fake_new(int n)
+{
+	struct irc_plugin *p;
+
+	p = irc_util_calloc(1, sizeof (*p));
+	snprintf(p->name, sizeof (p->name), "plugin-n-%d", n);
+
+	return p;
+}
+
+static void
+setup(void *udata)
+{
+	(void)udata;
+
+	server = irc_server_new("test", "t", "t", "t", "127.0.0.1", 6667);
+	plugin = js_plugin_open("plugin", CMAKE_SOURCE_DIR "/plugins/plugin/plugin.js");
+
+	if (!plugin)
+		errx(1, "could not load plugin");
+
+	/* Prepare a fake plugin. */
+	fake = irc_util_calloc(1, sizeof (*fake));
+	fake->author = "David";
+	fake->version = "0.0.0.0.0.0.1";
+	fake->license = "BEER";
+	fake->description = "Fake White Beer 2000";
+	strcpy(fake->name, "fake");
+
+	irc_bot_init();
+	irc_bot_plugin_add(fake);
+
+	irc_plugin_set_template(plugin, "usage", "usage=#{plugin}:#{command}:#{server}:#{channel}:#{origin}:#{nickname}");
+	irc_plugin_set_template(plugin, "info", "info=#{plugin}:#{command}:#{server}:#{channel}:#{origin}:#{nickname}:#{author}:#{license}:#{name}:#{summary}:#{version}");
+	irc_plugin_set_template(plugin, "not-found", "not-found=#{plugin}:#{command}:#{server}:#{channel}:#{origin}:#{nickname}:#{name}");
+	irc_plugin_set_template(plugin, "too-long", "too-long=#{plugin}:#{command}:#{server}:#{channel}:#{origin}:#{nickname}");
+	irc_server_incref(server);
+
+	irc_plugin_load(plugin);
+
+	/* Fake server connected to send data. */
+	server->state = IRC_SERVER_STATE_CONNECTED;
+}
+
+static void
+teardown(void *udata)
+{
+	(void)udata;
+
+	irc_bot_finish();
+	irc_plugin_finish(plugin);
+	irc_server_decref(server);
+}
+
+GREATEST_TEST
+basics_usage(void)
+{
+	CALL(IRC_EVENT_COMMAND, "");
+	GREATEST_ASSERT_STR_EQ("PRIVMSG #plugin :usage=plugin:!plugin:test:#plugin:jean!jean@localhost:jean\r\n", server->conn.out);
+
+	CALL(IRC_EVENT_COMMAND, "fail");
+	GREATEST_ASSERT_STR_EQ("PRIVMSG #plugin :usage=plugin:!plugin:test:#plugin:jean!jean@localhost:jean\r\n", server->conn.out);
+
+	CALL(IRC_EVENT_COMMAND, "info");
+	GREATEST_ASSERT_STR_EQ("PRIVMSG #plugin :usage=plugin:!plugin:test:#plugin:jean!jean@localhost:jean\r\n", server->conn.out);
+
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+basics_info(void)
+{
+	CALL(IRC_EVENT_COMMAND, "info fake");
+	GREATEST_ASSERT_STR_EQ("PRIVMSG #plugin :info=plugin:!plugin:test:#plugin:jean!jean@localhost:jean:David:BEER:fake:Fake White Beer 2000:0.0.0.0.0.0.1\r\n", server->conn.out);
+
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+basics_not_found(void)
+{
+	CALL(IRC_EVENT_COMMAND, "info doesnotexist");
+	GREATEST_ASSERT_STR_EQ("PRIVMSG #plugin :not-found=plugin:!plugin:test:#plugin:jean!jean@localhost:jean:doesnotexist\r\n", server->conn.out);
+
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+basics_too_long(void)
+{
+	for (int i = 0; i < 100; ++i)
+		irc_bot_plugin_add(fake_new(i));
+
+	CALL(IRC_EVENT_COMMAND, "list");
+	GREATEST_ASSERT_STR_EQ("PRIVMSG #plugin :too-long=plugin:!plugin:test:#plugin:jean!jean@localhost:jean\r\n", server->conn.out);
+
+	GREATEST_PASS();
+}
+
+GREATEST_SUITE(suite_basics)
+{
+	GREATEST_SET_SETUP_CB(setup, NULL);
+	GREATEST_SET_TEARDOWN_CB(teardown, NULL);
+	GREATEST_RUN_TEST(basics_usage);
+	GREATEST_RUN_TEST(basics_info);
+	GREATEST_RUN_TEST(basics_not_found);
+	GREATEST_RUN_TEST(basics_too_long);
+}
+
+GREATEST_MAIN_DEFS();
+
+int
+main(int argc, char **argv)
+{
+	GREATEST_MAIN_BEGIN();
+	GREATEST_RUN_SUITE(suite_basics);
+	GREATEST_MAIN_END();
+
+	return 0;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test-plugin-tictactoe.c	Thu Feb 11 17:39:22 2021 +0100
@@ -0,0 +1,335 @@
+/*
+ * test-plugin-tictactoe.c -- test tictactoe plugin
+ *
+ * Copyright (c) 2013-2021 David Demelier <markand@malikania.fr>
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+
+#include <err.h>
+
+#define GREATEST_USE_ABBREVS 0
+#include <greatest.h>
+
+#include <irccd/compat.h>
+#include <irccd/js-plugin.h>
+#include <irccd/log.h>
+#include <irccd/plugin.h>
+#include <irccd/server.h>
+#include <irccd/util.h>
+
+#define CALL(t, m) do {                                                 \
+	memset(server->conn.out, 0, sizeof (server->conn.out));         \
+	irc_plugin_handle(plugin, &(const struct irc_event) {           \
+		.type = t,                                              \
+		.server = server,                                       \
+			.message = {                                    \
+			.origin = "jean!jean@localhost",                \
+			.channel = "#hangman",                          \
+			.message = m                                    \
+		}                                                       \
+	});                                                             \
+} while (0)
+
+#define CALL_EX(t, o, c, m) do {                                        \
+	memset(server->conn.out, 0, sizeof (server->conn.out));         \
+	irc_plugin_handle(plugin, &(const struct irc_event) {           \
+		.type = t,                                              \
+		.server = server,                                       \
+			.message = {                                    \
+			.origin = o,                                    \
+			.channel = c,                                   \
+			.message = m                                    \
+		}                                                       \
+	});                                                             \
+} while (0)
+
+static struct irc_server *server;
+static struct irc_plugin *plugin;
+
+static void
+setup(void *udata)
+{
+	(void)udata;
+
+	server = irc_server_new("test", "t", "t", "t", "127.0.0.1", 6667);
+	plugin = js_plugin_open("tictactoe", CMAKE_SOURCE_DIR "/plugins/tictactoe/tictactoe.js");
+
+	if (!plugin)
+		errx(1, "could not load plugin");
+
+	irc_log_to_console();
+	irc_server_incref(server);
+	irc_plugin_set_template(plugin, "draw", "draw=#{channel}:#{command}:#{nickname}:#{plugin}:#{server}");
+	irc_plugin_set_template(plugin, "invalid", "invalid=#{channel}:#{command}:#{nickname}:#{origin}:#{plugin}:#{server}");
+	irc_plugin_set_template(plugin, "running", "running=#{channel}:#{command}:#{nickname}:#{origin}:#{plugin}:#{server}");
+	irc_plugin_set_template(plugin, "turn", "turn=#{channel}:#{command}:#{nickname}:#{plugin}:#{server}");
+	irc_plugin_set_template(plugin, "used", "used=#{channel}:#{command}:#{nickname}:#{origin}:#{plugin}:#{server}");
+	irc_plugin_set_template(plugin, "win", "win=#{channel}:#{command}:#{nickname}:#{plugin}:#{server}");
+	irc_plugin_load(plugin);
+
+	/* We need tw players on a channel to play the game. */
+	irc_server_join(server, "#tictactoe", NULL);
+	irc_channel_add(LIST_FIRST(&server->channels), "a", 0, 0);
+	irc_channel_add(LIST_FIRST(&server->channels), "b", 0, 0);
+	
+	/* Fake server connected to send data. */
+	server->state = IRC_SERVER_STATE_CONNECTED;
+}
+
+static void
+teardown(void *udata)
+{
+	(void)udata;
+
+	irc_plugin_finish(plugin);
+	irc_server_decref(server);
+}
+
+static char
+next(void)
+{
+	const char *lines[5] = {0};
+	char player = 0, *buf;
+
+	/* We need to skip 4 lines.*/
+	buf = irc_util_strdup(server->conn.out);
+	irc_util_split(buf, lines, 5, '\n');
+
+	if (!lines[4] || sscanf(lines[4], "PRIVMSG #tictactoe :turn=#tictactoe:!tictactoe:%c:tictactoe:test\r\n", &player) != 1)
+		errx(1, "could not determine player");
+
+	free(buf);
+
+	return player;
+}
+
+static void
+play(const char *value)
+{
+	char player[] = { next(), '\0' };
+
+	CALL_EX(IRC_EVENT_MESSAGE, player, "#tictactoe", (char *)value);
+}
+
+GREATEST_TEST
+basics_win(void)
+{
+	const char *lines[5] = {0};
+	char k1, k2;
+
+	CALL_EX(IRC_EVENT_COMMAND, "a", "#tictactoe", "b");
+
+	play("a 1");
+	play("b1");
+	play("a 2");
+	play("b2");
+	play("a3");
+
+	GREATEST_ASSERT_EQ(5U, irc_util_split(server->conn.out, lines, 5, '\n'));
+	GREATEST_ASSERT_EQ(0, sscanf(lines[0], "PRIVMSG #tictactoe :  a b c\r"));
+	GREATEST_ASSERT_EQ(2, sscanf(lines[1], "PRIVMSG #tictactoe :1 %c %c .\r", &k1, &k2));
+	GREATEST_ASSERT_EQ(2, sscanf(lines[2], "PRIVMSG #tictactoe :2 %c %c .\r", &k1, &k2));
+	GREATEST_ASSERT_EQ(1, sscanf(lines[3], "PRIVMSG #tictactoe :3 %c . .\r", &k1));
+	GREATEST_ASSERT_EQ(1, sscanf(lines[4], "PRIVMSG #tictactoe :win=#tictactoe:!tictactoe:%c:tictactoe:test\r\n", &k1));
+
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+basics_draw(void)
+{
+	/*
+	 *   a b c
+	 * 1 o x o
+	 * 2 o x x
+	 * 3 x o x
+	 */
+	const char *lines[5] = {0};
+	char k1, k2, k3;
+
+	CALL_EX(IRC_EVENT_COMMAND, "a", "#tictactoe", "b");
+
+	play("b 2");
+	play("c 1");
+	play("c 3");
+	play("b 3");
+	play("c 2");
+	play("a 2");
+	play("a 3");
+	play("a 1");
+	play("b 1");
+
+	GREATEST_ASSERT_EQ(5U, irc_util_split(server->conn.out, lines, 5, '\n'));
+	GREATEST_ASSERT_EQ(0, sscanf(lines[0], "PRIVMSG #tictactoe :  a b c\r"));
+	GREATEST_ASSERT_EQ(3, sscanf(lines[1], "PRIVMSG #tictactoe :1 %c %c %c\r", &k1, &k2, &k3));
+	GREATEST_ASSERT_EQ(3, sscanf(lines[2], "PRIVMSG #tictactoe :2 %c %c %c\r", &k1, &k2, &k3));
+	GREATEST_ASSERT_EQ(3, sscanf(lines[3], "PRIVMSG #tictactoe :3 %c %c %c\r", &k1, &k2, &k3));
+	GREATEST_ASSERT_EQ(1, sscanf(lines[4], "PRIVMSG #tictactoe :draw=#tictactoe:!tictactoe:%c:tictactoe:test\r\n", &k1));
+
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+basics_used(void)
+{
+	char k1, k2;
+
+	CALL_EX(IRC_EVENT_COMMAND, "a", "#tictactoe", "b");
+
+	play("a 1");
+	play("a 1");
+
+	GREATEST_ASSERT_EQ(2, sscanf(server->conn.out, "PRIVMSG #tictactoe :used=#tictactoe:!tictactoe:%c:%c:tictactoe:test\r\n", &k1, &k2));
+
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+basics_invalid(void)
+{
+	char k1, k2;
+
+	/* Player select itself. */
+	CALL_EX(IRC_EVENT_COMMAND, "a", "#tictactoe", "a");
+	GREATEST_ASSERT_EQ(2, sscanf(server->conn.out, "PRIVMSG #tictactoe :invalid=#tictactoe:!tictactoe:%c:%c:tictactoe:test\r\n", &k1, &k2));
+
+	/* Player select the bot. */
+	CALL_EX(IRC_EVENT_COMMAND, "a", "#tictactoe", "t");
+	GREATEST_ASSERT_EQ(2, sscanf(server->conn.out, "PRIVMSG #tictactoe :invalid=#tictactoe:!tictactoe:%c:%c:tictactoe:test\r\n", &k1, &k2));
+
+	/* Someone not on the channel. */
+	CALL_EX(IRC_EVENT_COMMAND, "a", "#tictactoe", "jean");
+	GREATEST_ASSERT_EQ(2, sscanf(server->conn.out, "PRIVMSG #tictactoe :invalid=#tictactoe:!tictactoe:%c:%c:tictactoe:test\r\n", &k1, &k2));
+
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+basics_random(void)
+{
+	/*
+	 * Ensure that the first player is not always the originator, start the
+	 * game for at most 100 times to avoid forever loop.
+	 */
+	int count = 0, a = 0, b = 0;
+
+	/* Last player turn is the winner. */
+	while (count++ < 100) {
+		CALL_EX(IRC_EVENT_COMMAND, "a", "#tictactoe", "b");
+
+		play("a 1");
+		play("b 1");
+		play("a 2");
+		play("b 2");
+
+		/* This is the player that will win. */
+		if (next() == 'a')
+			a = 1;
+		else
+			b = 1;
+
+		play("a 3");
+	}
+
+	GREATEST_ASSERT(a);
+	GREATEST_ASSERT(b);
+
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+basics_disconnect(void)
+{
+	CALL_EX(IRC_EVENT_COMMAND, "a", "#tictactoe", "b");
+
+	irc_plugin_handle(plugin, &(const struct irc_event) {
+		.type = IRC_EVENT_DISCONNECT,
+		.server = server
+	});
+
+	play("a 1");
+	GREATEST_ASSERT_STR_EQ("", server->conn.out);
+	
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+basics_kick(void)
+{
+	CALL_EX(IRC_EVENT_COMMAND, "a", "#tictactoe", "b");
+
+	irc_plugin_handle(plugin, &(const struct irc_event) {
+		.type = IRC_EVENT_KICK,
+		.server = server,
+		.kick = {
+			.origin = "god",
+			.channel = "#TiCTaCToE",
+			.target = "a",
+			.reason = "No reason, I do what I want."
+		}
+	});
+
+	play("a 1");
+	GREATEST_ASSERT_STR_EQ("", server->conn.out);
+
+	GREATEST_PASS();
+}
+
+GREATEST_TEST
+basics_part(void)
+{
+	CALL_EX(IRC_EVENT_COMMAND, "a", "#tictactoe", "b");
+
+	irc_plugin_handle(plugin, &(const struct irc_event) {
+		.type = IRC_EVENT_PART,
+		.server = server,
+		.part = {
+			.origin = "a",
+			.channel = "#TiCTaCToE",
+			.reason = "I'm too bad at this game."
+		}
+	});
+
+	play("a 1");
+	GREATEST_ASSERT_STR_EQ("", server->conn.out);
+
+	GREATEST_PASS();
+}
+
+GREATEST_SUITE(suite_basics)
+{
+	GREATEST_SET_SETUP_CB(setup, NULL);
+	GREATEST_SET_TEARDOWN_CB(teardown, NULL);
+	GREATEST_RUN_TEST(basics_win);
+	GREATEST_RUN_TEST(basics_draw);
+	GREATEST_RUN_TEST(basics_used);
+	GREATEST_RUN_TEST(basics_invalid);
+	GREATEST_RUN_TEST(basics_random);
+	GREATEST_RUN_TEST(basics_disconnect);
+	GREATEST_RUN_TEST(basics_kick);
+	GREATEST_RUN_TEST(basics_part);
+}
+
+GREATEST_MAIN_DEFS();
+
+int
+main(int argc, char **argv)
+{
+	GREATEST_MAIN_BEGIN();
+	GREATEST_RUN_SUITE(suite_basics);
+	GREATEST_MAIN_END();
+
+	return 0;
+}