# HG changeset patch
# Node ID d336e68512e1dbdd55247b89c2080d13bccb472c
# Parent  477afe9145207f1bac2ea2ceeb2cf3bd20e5fe2c
diff --git a/ibis/ibisbatch.c b/ibis/ibisbatch.c
new file mode 100644
--- /dev/null
+++ b/ibis/ibisbatch.c
@@ -0,0 +1,367 @@
+/*
+ * Ibis - IRCv3 Library
+ * Copyright (C) 2022-2025 Ibis Developers
+ *
+ * Ibis is the legal property of its developers, whose names are too numerous
+ * to list here.  Please refer to the COPYRIGHT file distributed with this
+ * source distribution.
+ *
+ * This library is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this library; if not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <gio/gio.h>
+
+#include "ibisbatch.h"
+
+#include "ibisconstants.h"
+#include "ibisstring.h"
+
+struct _IbisBatch {
+	GObject parent;
+
+	char *reference_tag;
+	char *parent_tag;
+	char *batch_type;
+
+	GPtrArray *messages;
+};
+
+enum {
+	PROP_0,
+	PROP_REFERENCE_TAG,
+	PROP_PARENT_TAG,
+	PROP_BATCH_TYPE,
+	N_PROPERTIES,
+};
+static GParamSpec *properties[N_PROPERTIES] = {NULL, };
+
+G_DEFINE_QUARK(ibis-batch-add-message-error, ibis_batch_add_message_error)
+G_DEFINE_QUARK(ibis-batch-parse-error, ibis_batch_parse_error)
+
+/******************************************************************************
+ * Helpers
+ *****************************************************************************/
+static void
+ibis_batch_set_batch_type(IbisBatch *batch, const char *batch_type) {
+	g_return_if_fail(IBIS_IS_BATCH(batch));
+	g_return_if_fail(!ibis_str_is_empty(batch_type));
+
+	if(g_set_str(&batch->batch_type, batch_type)) {
+		g_object_notify_by_pspec(G_OBJECT(batch), properties[PROP_BATCH_TYPE]);
+	}
+}
+
+static void
+ibis_batch_set_parent_tag(IbisBatch *batch, const char *parent_tag) {
+	g_return_if_fail(IBIS_IS_BATCH(batch));
+
+	if(g_set_str(&batch->parent_tag, parent_tag)) {
+		g_object_notify_by_pspec(G_OBJECT(batch), properties[PROP_PARENT_TAG]);
+	}
+}
+
+static void
+ibis_batch_set_reference_tag(IbisBatch *batch, const char *reference_tag) {
+	g_return_if_fail(IBIS_IS_BATCH(batch));
+	g_return_if_fail(!ibis_str_is_empty(reference_tag));
+
+	if(g_set_str(&batch->reference_tag, reference_tag)) {
+		g_object_notify_by_pspec(G_OBJECT(batch),
+		                         properties[PROP_REFERENCE_TAG]);
+	}
+}
+
+/******************************************************************************
+ * GListModel Implementation
+ *****************************************************************************/
+static GType
+ibis_batch_get_item_type(G_GNUC_UNUSED GListModel *model) {
+	return IBIS_TYPE_MESSAGE;
+}
+
+static guint
+ibis_batch_get_n_items(GListModel *model) {
+	IbisBatch *batch = IBIS_BATCH(model);
+
+	return batch->messages->len;
+}
+
+static gpointer
+ibis_batch_get_item(GListModel *model, guint position) {
+	IbisBatch *batch = IBIS_BATCH(model);
+	IbisMessage *message = NULL;
+
+	if(position < batch->messages->len) {
+		message = g_ptr_array_index(batch->messages, position);
+
+		if(IBIS_IS_MESSAGE(message)) {
+			g_object_ref(message);
+		}
+	}
+
+	return message;
+}
+
+static void
+ibis_batch_list_model_iface_init(GListModelInterface *iface) {
+	iface->get_item_type = ibis_batch_get_item_type;
+	iface->get_n_items = ibis_batch_get_n_items;
+	iface->get_item = ibis_batch_get_item;
+}
+
+/******************************************************************************
+ * GObject Implementation
+ *****************************************************************************/
+G_DEFINE_FINAL_TYPE_WITH_CODE(IbisBatch,
+                              ibis_batch,
+                              G_TYPE_OBJECT,
+                              G_IMPLEMENT_INTERFACE(G_TYPE_LIST_MODEL,
+                                                    ibis_batch_list_model_iface_init))
+
+static void
+ibis_batch_finalize(GObject *obj) {
+	IbisBatch *batch = IBIS_BATCH(obj);
+
+	g_clear_pointer(&batch->batch_type, g_free);
+	g_clear_pointer(&batch->parent_tag, g_free);
+	g_clear_pointer(&batch->reference_tag, g_free);
+	g_clear_pointer(&batch->messages, g_ptr_array_unref);
+
+	G_OBJECT_CLASS(ibis_batch_parent_class)->finalize(obj);
+}
+
+static void
+ibis_batch_get_property(GObject *obj, guint param_id, GValue *value,
+                        GParamSpec *pspec)
+{
+	IbisBatch *batch = IBIS_BATCH(obj);
+
+	switch(param_id) {
+	case PROP_BATCH_TYPE:
+		g_value_set_string(value, ibis_batch_get_batch_type(batch));
+		break;
+	case PROP_PARENT_TAG:
+		g_value_set_string(value, ibis_batch_get_parent_tag(batch));
+		break;
+	case PROP_REFERENCE_TAG:
+		g_value_set_string(value, ibis_batch_get_reference_tag(batch));
+		break;
+	default:
+		G_OBJECT_WARN_INVALID_PROPERTY_ID(obj, param_id, pspec);
+		break;
+	}
+}
+
+static void
+ibis_batch_set_property(GObject *obj, guint param_id, const GValue *value,
+                        GParamSpec *pspec)
+{
+	IbisBatch *batch = IBIS_BATCH(obj);
+
+	switch(param_id) {
+	case PROP_BATCH_TYPE:
+		ibis_batch_set_batch_type(batch, g_value_get_string(value));
+		break;
+	case PROP_PARENT_TAG:
+		ibis_batch_set_parent_tag(batch, g_value_get_string(value));
+		break;
+	case PROP_REFERENCE_TAG:
+		ibis_batch_set_reference_tag(batch, g_value_get_string(value));
+		break;
+	default:
+		G_OBJECT_WARN_INVALID_PROPERTY_ID(obj, param_id, pspec);
+		break;
+	}
+}
+
+static void
+ibis_batch_init(IbisBatch *batch) {
+	batch->messages = g_ptr_array_new_full(1, g_object_unref);
+}
+
+static void
+ibis_batch_class_init(IbisBatchClass *klass) {
+	GObjectClass *obj_class = G_OBJECT_CLASS(klass);
+
+	obj_class->finalize = ibis_batch_finalize;
+	obj_class->get_property = ibis_batch_get_property;
+	obj_class->set_property = ibis_batch_set_property;
+
+	/**
+	 * IbisBatch:reference-tag
+	 *
+	 * The reference tag of the batch.
+	 *
+	 * Since: 0.12
+	 */
+	properties[PROP_REFERENCE_TAG] = g_param_spec_string(
+		"reference-tag", NULL, NULL,
+		NULL,
+		G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisBatch:parent-tag
+	 *
+	 * The parent tag of the batch.
+	 *
+	 * Since: 0.12
+	 */
+	properties[PROP_PARENT_TAG] = g_param_spec_string(
+		"parent-tag", NULL, NULL,
+		NULL,
+		G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS);
+
+	/**
+	 * IbisBatch:batch-type:
+	 *
+	 * The type of the batch.
+	 *
+	 * Since: 0.12
+	 */
+	properties[PROP_BATCH_TYPE] = g_param_spec_string(
+		"batch-type", NULL, NULL,
+		NULL,
+		G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS);
+
+	g_object_class_install_properties(obj_class, N_PROPERTIES, properties);
+}
+
+/******************************************************************************
+ * Public API
+ *****************************************************************************/
+gboolean
+ibis_batch_add_message(IbisBatch *batch, IbisMessage *message, GError **error)
+{
+	IbisTags *tags = NULL;
+	const char *message_batch = NULL;
+	guint position = 0;
+
+	g_return_val_if_fail(IBIS_IS_BATCH(batch), FALSE);
+	g_return_val_if_fail(IBIS_IS_MESSAGE(message), FALSE);
+
+	tags = ibis_message_get_tags(message);
+	message_batch = ibis_tags_lookup(tags, IBIS_TAG_BATCH);
+	if(!ibis_str_equal(batch->reference_tag, message_batch)) {
+		g_set_error(error,
+		            IBIS_BATCH_ADD_MESSAGE_ERROR,
+		            IBIS_BATCH_ADD_MESSAGE_ERROR_INVALID_REFERENCE_TAG,
+		            "invalid reference tag '%s', expected '%s'",
+		            message_batch,
+		            batch->reference_tag);
+
+		return FALSE;
+	}
+
+	/* Store the length as the position for the new item. */
+	position = batch->messages->len;
+	g_ptr_array_add(batch->messages, g_object_ref(message));
+
+	/* Use the position to notify that we added an item at that position. */
+	g_list_model_items_changed(G_LIST_MODEL(batch), position, 0, 1);
+
+	return TRUE;
+}
+
+const char *
+ibis_batch_get_batch_type(IbisBatch *batch) {
+	g_return_val_if_fail(IBIS_IS_BATCH(batch), NULL);
+
+	return batch->batch_type;
+}
+
+const char *
+ibis_batch_get_parent_tag(IbisBatch *batch) {
+	g_return_val_if_fail(IBIS_IS_BATCH(batch), NULL);
+
+	return batch->parent_tag;
+}
+
+const char *
+ibis_batch_get_reference_tag(IbisBatch *batch) {
+	g_return_val_if_fail(IBIS_IS_BATCH(batch), NULL);
+
+	return batch->reference_tag;
+}
+
+IbisBatch *
+ibis_batch_parse(IbisMessage *message, GError **error) {
+	IbisTags *tags = NULL;
+	GStrv params = NULL;
+	const char *batch_type = NULL;
+	const char *parent_tag = NULL;
+	const char *reference_tag = NULL;
+	guint n_params = 0;
+
+	g_return_val_if_fail(IBIS_IS_MESSAGE(message), NULL);
+
+	params = ibis_message_get_params(message);
+	if(params != NULL) {
+		n_params = g_strv_length(params);
+	}
+
+	if(n_params < 2) {
+		g_set_error(error,
+		            IBIS_BATCH_PARSE_ERROR,
+		            IBIS_BATCH_PARSE_ERROR_NOT_ENOUGH_PARAMS,
+		            "expected at least 2 parameters but only found %u",
+		            n_params);
+
+		return NULL;
+	}
+
+	/* Make sure the reference tag starts with a +. */
+	reference_tag = params[0];
+	if(reference_tag[0] != '+') {
+		g_set_error(error,
+		            IBIS_BATCH_PARSE_ERROR,
+		            IBIS_BATCH_PARSE_ERROR_INVALID_REFERENCE_TAG,
+		            "invalid reference tag '%s'",
+		            reference_tag);
+
+		return NULL;
+	}
+
+	/* Now make sure we have an actual reference tag. */
+	if(ibis_str_is_empty(reference_tag + 1)) {
+		g_set_error(error,
+		            IBIS_BATCH_PARSE_ERROR,
+		            IBIS_BATCH_PARSE_ERROR_INVALID_REFERENCE_TAG,
+		            "invalid reference tag '%s'",
+		            reference_tag);
+
+		return NULL;
+	}
+	reference_tag = reference_tag + 1;
+
+	batch_type = params[1];
+	if(ibis_str_is_empty(batch_type)) {
+		g_set_error(error,
+		            IBIS_BATCH_PARSE_ERROR,
+		            IBIS_BATCH_PARSE_ERROR_INVALID_BATCH_TYPE,
+		            "invalid batch type '%s'",
+		            batch_type);
+
+		return NULL;
+	}
+
+	tags = ibis_message_get_tags(message);
+	parent_tag = ibis_tags_lookup(tags, IBIS_TAG_BATCH);
+
+	return g_object_new(
+		IBIS_TYPE_BATCH,
+		"batch-type", batch_type,
+		"parent-tag", parent_tag,
+		"reference-tag", reference_tag,
+		NULL);
+}
diff --git a/ibis/ibisbatch.h b/ibis/ibisbatch.h
new file mode 100644
--- /dev/null
+++ b/ibis/ibisbatch.h
@@ -0,0 +1,183 @@
+/*
+ * Ibis - IRCv3 Library
+ * Copyright (C) 2022-2025 Ibis Developers
+ *
+ * Ibis is the legal property of its developers, whose names are too numerous
+ * to list here.  Please refer to the COPYRIGHT file distributed with this
+ * source distribution.
+ *
+ * This library is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this library; if not, see <https://www.gnu.org/licenses/>.
+ */
+
+#if !defined(IBIS_GLOBAL_HEADER_INSIDE) && !defined(IBIS_COMPILATION)
+# error "only <ibis.h> may be included directly"
+#endif
+
+#ifndef IBIS_BATCH_H
+#define IBIS_BATCH_H
+
+#include <glib.h>
+
+#include <glib-object.h>
+
+#include "ibismessage.h"
+#include "ibisversion.h"
+
+G_BEGIN_DECLS
+
+#define IBIS_BATCH_ADD_MESSAGE_ERROR ibis_batch_add_message_error_quark()
+
+/**
+ * ibis_batch_add_message_error_quark:
+ *
+ * The error domain to identify errors when adding messages to a batch.
+ *
+ * Returns: The error domain.
+ *
+ * Since: 0.12
+ */
+IBIS_AVAILABLE_IN_0_12
+GQuark ibis_batch_add_message_error_quark(void);
+
+/**
+ * IbisBatchAddMessageError:
+ * @IBIS_BATCH_ADD_MESSAGE_ERROR_INVALID_REFERENCE_TAG: the specified reference
+ *  tag did not match the reference tag of the batch.
+ *
+ * Errors returned from [method@Batch.add_message].
+ *
+ * Since: 0.12
+ */
+typedef enum {
+	IBIS_BATCH_ADD_MESSAGE_ERROR_INVALID_REFERENCE_TAG,
+} IbisBatchAddMessageError;
+
+#define IBIS_BATCH_PARSE_ERROR ibis_batch_parse_error_quark()
+
+/**
+ * ibis_batch_parse_error_quark:
+ *
+ * The error domain to identify errors when parsing a batch.
+ *
+ * Returns: The error domain.
+ *
+ * Since: 0.12
+ */
+IBIS_AVAILABLE_IN_0_12
+GQuark ibis_batch_parse_error_quark(void);
+
+/**
+ * IbisBatchParseError:
+ * @IBIS_BATCH_PARSE_ERROR_NOT_ENOUGH_PARAMS: batch messages must have at least
+ *  two parameters. The reference tag and the batch type.
+ * @IBIS_BATCH_PARSE_ERROR_INVALID_REFERENCE_TAG: the reference tag must start
+ *  with a '+' and have at least one additional character.
+ * @IBIS_BATCH_PARSE_ERROR_INVALID_BATCH_TYPE: the specified batch type was
+ *  invalid.
+ *
+ * Errors returned from [ctor@Batch.parse].
+ *
+ * Since: 0.12
+ */
+typedef enum {
+	IBIS_BATCH_PARSE_ERROR_NOT_ENOUGH_PARAMS,
+	IBIS_BATCH_PARSE_ERROR_INVALID_REFERENCE_TAG,
+	IBIS_BATCH_PARSE_ERROR_INVALID_BATCH_TYPE,
+} IbisBatchParseError;
+
+#define IBIS_TYPE_BATCH (ibis_batch_get_type())
+
+/**
+ * IbisBatch:
+ *
+ * An implementation of batches as defined by
+ * https://ircv3.net/specs/extensions/batch.
+ *
+ * Since: 0.12
+ */
+IBIS_AVAILABLE_IN_0_12
+G_DECLARE_FINAL_TYPE(IbisBatch, ibis_batch, IBIS, BATCH, GObject)
+
+/**
+ * ibis_batch_add_message:
+ * @message: (transfer none): the message to add
+ * @error: (out) (optional): a return address for a #GError
+ *
+ * Adds a message to the batch.
+ *
+ * If the message is not tagged with the same reference tag as the batch,
+ * @error will be set and false will be returned.
+ *
+ *
+ * Returns: true on success.
+ *
+ * Since: 0.12
+ */
+IBIS_AVAILABLE_IN_0_12
+gboolean ibis_batch_add_message(IbisBatch *batch, IbisMessage *message, GError **error);
+
+/**
+ * ibis_batch_get_batch_type:
+ *
+ * Gets the type of the batch.
+ *
+ * Returns: the type of the batch.
+ *
+ * Since: 0.12
+ */
+IBIS_AVAILABLE_IN_0_12
+const char *ibis_batch_get_batch_type(IbisBatch *batch);
+
+/**
+ * ibis_batch_get_parent_tag:
+ *
+ * Gets the parent tag of the batch.
+ *
+ * Returns: (nullable): The reference tag of the parent batch if there is one.
+ *
+ * Since: 0.12
+ */
+IBIS_AVAILABLE_IN_0_12
+const char *ibis_batch_get_parent_tag(IbisBatch *batch);
+
+/**
+ * ibis_batch_get_reference_tag:
+ *
+ * Gets the reference tag of the batch.
+ *
+ * Returns: The reference tag of the batch.
+ *
+ * Since: 0.12
+ */
+IBIS_AVAILABLE_IN_0_12
+const char *ibis_batch_get_reference_tag(IbisBatch *batch);
+
+/**
+ * ibis_batch_parse: (constructor)
+ * @message: a batch message
+ * @error: (out) (optional): a return address for a #GError
+ *
+ * Creates a batch from a batch message.
+ *
+ * Returns: (transfer full) (nullable): The new batch on success, otherwise
+ *          null with @error possibly set.
+ *
+ * Since: 0.12
+ */
+IBIS_AVAILABLE_IN_0_12
+IbisBatch *ibis_batch_parse(IbisMessage *message, GError **error);
+
+G_END_DECLS
+
+#endif /* IBIS_BATCH_H */
diff --git a/ibis/ibisconstants.h b/ibis/ibisconstants.h
--- a/ibis/ibisconstants.h
+++ b/ibis/ibisconstants.h
@@ -1412,6 +1412,18 @@
 #define IBIS_MSG_AWAY "AWAY" IBIS_AVAILABLE_MACRO_IN_0_1
 
 /**
+ * IBIS_MSG_BATCH:
+ *
+ * A constant for the %BATCH message.
+ *
+ * See the [batch specification](https://ircv3.net/specs/extensions/batch) for
+ * more information.
+ *
+ * Since: 0.12
+ */
+#define IBIS_MSG_BATCH "BATCH" IBIS_AVAILABLE_MACRO_IN_0_12
+
+/**
  * IBIS_MSG_CAP:
  *
  * A constant for the %CAP message.
diff --git a/ibis/meson.build b/ibis/meson.build
--- a/ibis/meson.build
+++ b/ibis/meson.build
@@ -1,4 +1,5 @@
 IBIS_SOURCES = [
+  'ibisbatch.c',
   'ibiscapabilities.c',
   'ibischanmodes.c',
   'ibisclient.c',
@@ -16,6 +17,7 @@
 ]
 
 IBIS_HEADERS = [
+  'ibisbatch.h',
   'ibiscapabilities.h',
   'ibischanmodes.h',
   'ibisclient.h',
@@ -73,6 +75,7 @@
 # Enums
 ###############################################################################
 IBIS_ENUM_HEADERS = [
+  'ibisbatch.h',
   'ibischanmodes.h',
   'ibisclient.h',
 ]
diff --git a/ibis/tests/meson.build b/ibis/tests/meson.build
--- a/ibis/tests/meson.build
+++ b/ibis/tests/meson.build
@@ -1,4 +1,5 @@
 test_programs = [
+  'batch',
   'chanmodes',
   'client',
   'ctcp_message',
diff --git a/ibis/tests/test_batch.c b/ibis/tests/test_batch.c
new file mode 100644
--- /dev/null
+++ b/ibis/tests/test_batch.c
@@ -0,0 +1,405 @@
+/*
+ * Ibis - IRCv3 Library
+ * Copyright (C) 2022-2025 Ibis Developers
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this library; if not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <glib.h>
+#include <glib/gi18n.h>
+
+#include <ibis.h>
+
+/******************************************************************************
+ * Callbacks
+ *****************************************************************************/
+static void
+test_ibis_batch_items_changed_counter(GListModel *model,
+                                      G_GNUC_UNUSED guint position,
+                                      G_GNUC_UNUSED guint removed,
+                                      G_GNUC_UNUSED guint added,
+                                      gpointer data)
+{
+	gint *counter = data;
+
+	g_assert_true(IBIS_IS_BATCH(model));
+
+	*counter = *counter + 1;
+}
+
+/******************************************************************************
+ * Tests
+ *****************************************************************************/
+static void
+test_ibis_batch_properties(void) {
+	IbisBatch *batch = NULL;
+	char *batch_type = NULL;
+	char *parent_tag = NULL;
+	char *reference_tag = NULL;
+
+	batch = g_object_new(
+		IBIS_TYPE_BATCH,
+		"batch-type", "batch",
+		"parent-tag", "parent",
+		"reference-tag", "batch1",
+		NULL);
+
+	g_assert_true(IBIS_IS_BATCH(batch));
+
+	g_object_get(
+		G_OBJECT(batch),
+		"batch-type", &batch_type,
+		"parent-tag", &parent_tag,
+		"reference-tag", &reference_tag,
+		NULL);
+
+	g_assert_cmpstr(batch_type, ==, "batch");
+	g_clear_pointer(&batch_type, g_free);
+
+	g_assert_cmpstr(parent_tag, ==, "parent");
+	g_clear_pointer(&parent_tag, g_free);
+
+	g_assert_cmpstr(reference_tag, ==, "batch1");
+	g_clear_pointer(&reference_tag, g_free);
+
+	g_assert_finalize_object(batch);
+}
+
+static void
+test_ibis_batch_parse_normal(void) {
+	IbisBatch *batch = NULL;
+	IbisMessage *message = NULL;
+	GError *error = NULL;
+	const char *value = NULL;
+
+	message = ibis_message_new(IBIS_MSG_BATCH);
+	ibis_message_set_params(message, "+1", "magic", NULL);
+
+	batch = ibis_batch_parse(message, &error);
+	g_assert_no_error(error);
+	g_assert_true(IBIS_IS_BATCH(batch));
+
+	value = ibis_batch_get_batch_type(batch);
+	g_assert_cmpstr(value, ==, "magic");
+
+	value = ibis_batch_get_parent_tag(batch);
+	g_assert_null(value);
+
+	value = ibis_batch_get_reference_tag(batch);
+	g_assert_cmpstr(value, ==, "1");
+
+	g_assert_finalize_object(batch);
+	g_assert_finalize_object(message);
+}
+
+static void
+test_ibis_batch_parse_nested(void) {
+	IbisBatch *batch = NULL;
+	IbisMessage *message = NULL;
+	IbisTags *tags = NULL;
+	GError *error = NULL;
+	const char *value = NULL;
+
+	message = ibis_message_new(IBIS_MSG_BATCH);
+	ibis_message_set_params(message, "+1", "magic", NULL);
+
+	tags = ibis_message_get_tags(message);
+	ibis_tags_add(tags, IBIS_TAG_BATCH, "parent");
+
+	batch = ibis_batch_parse(message, &error);
+	g_assert_no_error(error);
+	g_assert_true(IBIS_IS_BATCH(batch));
+
+	value = ibis_batch_get_batch_type(batch);
+	g_assert_cmpstr(value, ==, "magic");
+
+	value = ibis_batch_get_parent_tag(batch);
+	g_assert_cmpstr(value, ==, "parent");
+
+	value = ibis_batch_get_reference_tag(batch);
+	g_assert_cmpstr(value, ==, "1");
+
+	g_assert_finalize_object(batch);
+	g_assert_finalize_object(message);
+}
+
+static void
+test_ibis_batch_parse_minimum_parameters(void) {
+	IbisBatch *batch = NULL;
+	IbisMessage *message = NULL;
+	GError *error = NULL;
+
+	/* First test with no parameters. */
+	message = ibis_message_new(IBIS_MSG_BATCH);
+
+	batch = ibis_batch_parse(message, &error);
+	g_assert_error(error,
+	               IBIS_BATCH_PARSE_ERROR,
+	               IBIS_BATCH_PARSE_ERROR_NOT_ENOUGH_PARAMS);
+	g_clear_error(&error);
+	g_assert_null(batch);
+
+	/* Now add a single parameter. */
+	ibis_message_set_params(message, "+foo", NULL);
+
+	batch = ibis_batch_parse(message, &error);
+	g_assert_error(error,
+	               IBIS_BATCH_PARSE_ERROR,
+	               IBIS_BATCH_PARSE_ERROR_NOT_ENOUGH_PARAMS);
+	g_clear_error(&error);
+	g_assert_null(batch);
+
+	/* Finally parse with two parameters. */
+	ibis_message_set_params(message, "+bar", "batch-type", NULL);
+
+	batch = ibis_batch_parse(message, &error);
+	g_assert_no_error(error);
+	g_assert_true(IBIS_IS_BATCH(batch));
+	g_assert_finalize_object(batch);
+
+	g_assert_finalize_object(message);
+}
+
+static void
+test_ibis_batch_parse_invalid_reference_tag(void) {
+	IbisBatch *batch = NULL;
+	IbisMessage *message = NULL;
+	GError *error = NULL;
+
+	message = ibis_message_new(IBIS_MSG_BATCH);
+
+	/* Start with an empty reference tag. */
+	ibis_message_set_params(message, "", "batch-type", NULL);
+
+	batch = ibis_batch_parse(message, &error);
+	g_assert_error(error,
+	               IBIS_BATCH_PARSE_ERROR,
+	               IBIS_BATCH_PARSE_ERROR_INVALID_REFERENCE_TAG);
+	g_clear_error(&error);
+	g_assert_null(batch);
+
+	/* Now a reference tag that doesn't start with a +. */
+	ibis_message_set_params(message, "-", "batch-type", NULL);
+
+	batch = ibis_batch_parse(message, &error);
+	g_assert_error(error,
+	               IBIS_BATCH_PARSE_ERROR,
+	               IBIS_BATCH_PARSE_ERROR_INVALID_REFERENCE_TAG);
+	g_clear_error(&error);
+	g_assert_null(batch);
+
+	/* Finally with a valid reference tag. */
+	ibis_message_set_params(message, "+foo", "batch-type", NULL);
+
+	batch = ibis_batch_parse(message, &error);
+	g_assert_no_error(error);
+	g_assert_true(IBIS_IS_BATCH(batch));
+	g_assert_finalize_object(batch);
+
+	g_assert_finalize_object(message);
+}
+
+static void
+test_ibis_batch_parse_invalid_batch_type(void) {
+	IbisBatch *batch = NULL;
+	IbisMessage *message = NULL;
+	GError *error = NULL;
+
+	message = ibis_message_new(IBIS_MSG_BATCH);
+
+	/* Start with an empty batch type. */
+	ibis_message_set_params(message, "+foo", "", NULL);
+
+	batch = ibis_batch_parse(message, &error);
+	g_assert_error(error,
+	               IBIS_BATCH_PARSE_ERROR,
+	               IBIS_BATCH_PARSE_ERROR_INVALID_BATCH_TYPE);
+	g_clear_error(&error);
+	g_assert_null(batch);
+
+	/* Finally with a valid batch type. */
+	ibis_message_set_params(message, "+foo", "batch-type", NULL);
+
+	batch = ibis_batch_parse(message, &error);
+	g_assert_no_error(error);
+	g_assert_true(IBIS_IS_BATCH(batch));
+	g_assert_finalize_object(batch);
+
+	g_assert_finalize_object(message);
+}
+
+static void
+test_ibis_batch_add_message_normal(void) {
+	IbisBatch *batch = NULL;
+	IbisMessage *batch_message = NULL;
+	GError *error = NULL;
+	guint counter = 0;
+
+	batch_message = ibis_message_new(IBIS_MSG_BATCH);
+	ibis_message_set_params(batch_message, "+foo", "testing", NULL);
+	batch = ibis_batch_parse(batch_message, &error);
+	g_assert_no_error(error);
+	g_assert_finalize_object(batch_message);
+
+	g_signal_connect(batch, "items-changed",
+	                 G_CALLBACK(test_ibis_batch_items_changed_counter),
+	                 &counter);
+
+	for(guint i = 0; i < 5; i++) {
+		IbisMessage *message = NULL;
+		IbisTags *tags = NULL;
+		gboolean result = FALSE;
+		guint n_items = 0;
+
+		/* Reset the items changed counter. */
+		counter = 0;
+
+		message = ibis_message_new(IBIS_MSG_PRIVMSG);
+		tags = ibis_message_get_tags(message);
+		ibis_tags_add(tags, IBIS_TAG_BATCH, "foo");
+
+		result = ibis_batch_add_message(batch, message, &error);
+		g_assert_no_error(error);
+		g_assert_true(result);
+
+		/* Make sure the items changed signal got called. */
+		g_assert_cmpuint(counter, ==, 1);
+
+		/* Make sure the number of items is correct. */
+		n_items = g_list_model_get_n_items(G_LIST_MODEL(batch));
+		g_assert_cmpuint(n_items, ==, i + 1);
+
+		/* The messages still exist in the batch, so we can't use
+		 * g_assert_finalize_object here.
+		 */
+		g_clear_object(&message);
+	}
+
+	g_assert_finalize_object(batch);
+}
+
+static void
+test_ibis_batch_add_message_invalid_reference_tag(void) {
+	IbisBatch *batch = NULL;
+	IbisMessage *batch_message = NULL;
+	IbisMessage *message = NULL;
+	IbisTags *tags = NULL;
+	GError *error = NULL;
+	guint counter = 0;
+	guint n_items = 0;
+	gboolean result = FALSE;
+
+	batch_message = ibis_message_new(IBIS_MSG_BATCH);
+	ibis_message_set_params(batch_message, "+foo", "testing", NULL);
+	batch = ibis_batch_parse(batch_message, &error);
+	g_assert_no_error(error);
+	g_assert_finalize_object(batch_message);
+
+	g_signal_connect(batch, "items-changed",
+	                 G_CALLBACK(test_ibis_batch_items_changed_counter),
+	                 &counter);
+
+	/* Attempt to add a message without the batch tag. */
+	message = ibis_message_new(IBIS_MSG_PRIVMSG);
+	counter = 0;
+
+	result = ibis_batch_add_message(batch, message, &error);
+	g_assert_error(error,
+	               IBIS_BATCH_ADD_MESSAGE_ERROR,
+	               IBIS_BATCH_ADD_MESSAGE_ERROR_INVALID_REFERENCE_TAG);
+	g_clear_error(&error);
+	g_assert_false(result);
+	g_assert_cmpuint(counter, ==, 0);
+	n_items = g_list_model_get_n_items(G_LIST_MODEL(batch));
+	g_assert_cmpuint(n_items, ==, 0);
+	g_clear_object(&message);
+
+	/* Add a null batch tag. */
+	message = ibis_message_new(IBIS_MSG_PRIVMSG);
+	tags = ibis_message_get_tags(message);
+	ibis_tags_add(tags, IBIS_TAG_BATCH, NULL);
+	counter = 0;
+
+	result = ibis_batch_add_message(batch, message, &error);
+	g_assert_error(error,
+	               IBIS_BATCH_ADD_MESSAGE_ERROR,
+	               IBIS_BATCH_ADD_MESSAGE_ERROR_INVALID_REFERENCE_TAG);
+	g_clear_error(&error);
+	g_assert_false(result);
+	g_assert_cmpuint(counter, ==, 0);
+	n_items = g_list_model_get_n_items(G_LIST_MODEL(batch));
+	g_assert_cmpuint(n_items, ==, 0);
+	g_clear_object(&message);
+
+	/* Add an empty batch tag. */
+	message = ibis_message_new(IBIS_MSG_PRIVMSG);
+	tags = ibis_message_get_tags(message);
+	ibis_tags_add(tags, IBIS_TAG_BATCH, "");
+	counter = 0;
+
+	result = ibis_batch_add_message(batch, message, &error);
+	g_assert_error(error,
+	               IBIS_BATCH_ADD_MESSAGE_ERROR,
+	               IBIS_BATCH_ADD_MESSAGE_ERROR_INVALID_REFERENCE_TAG);
+	g_clear_error(&error);
+	g_assert_false(result);
+	g_assert_cmpuint(counter, ==, 0);
+	n_items = g_list_model_get_n_items(G_LIST_MODEL(batch));
+	g_assert_cmpuint(n_items, ==, 0);
+	g_clear_object(&message);
+
+	/* Add with the correct reference tag. */
+	message = ibis_message_new(IBIS_MSG_PRIVMSG);
+	tags = ibis_message_get_tags(message);
+	ibis_tags_add(tags, IBIS_TAG_BATCH, "foo");
+	counter = 0;
+
+	result = ibis_batch_add_message(batch, message, &error);
+	g_assert_no_error(error);
+	g_assert_true(result);
+	g_assert_cmpuint(counter, ==, 1);
+	n_items = g_list_model_get_n_items(G_LIST_MODEL(batch));
+	g_assert_cmpuint(n_items, ==, 1);
+	g_clear_object(&message);
+
+	g_assert_finalize_object(batch);
+}
+
+/******************************************************************************
+ * Main
+ *****************************************************************************/
+int
+main(int argc, char *argv[]) {
+	g_test_init(&argc, &argv, NULL);
+
+	g_test_set_nonfatal_assertions();
+
+	g_test_add_func("/ibis/batch/properties", test_ibis_batch_properties);
+
+	g_test_add_func("/ibis/batch/parse/normal", test_ibis_batch_parse_normal);
+	g_test_add_func("/ibis/batch/parse/nested", test_ibis_batch_parse_nested);
+	g_test_add_func("/ibis/batch/parse/minimum-parameters",
+	                test_ibis_batch_parse_minimum_parameters);
+	g_test_add_func("/ibis/batch/parse/invalid-reference-tag",
+	                test_ibis_batch_parse_invalid_reference_tag);
+	g_test_add_func("/ibis/batch/parse/invalid-batch-type",
+	                test_ibis_batch_parse_invalid_batch_type);
+
+	g_test_add_func("/ibis/batch/add_message/normal",
+	                test_ibis_batch_add_message_normal);
+	g_test_add_func("/ibis/batch/add_message/invalid-reference-tag",
+	                test_ibis_batch_add_message_invalid_reference_tag);
+
+	return g_test_run();
+}
