diff --git a/tests/unit/frontends/test_webrtc.py b/tests/unit/frontends/test_webrtc.py
index b77b6486..c147bd36 100644
--- a/tests/unit/frontends/test_webrtc.py
+++ b/tests/unit/frontends/test_webrtc.py
@@ -28,7 +28,13 @@ from pytest_twisted import ensureDeferred as ed
from libervia.backend.core import exceptions
from libervia.backend.tools.common import data_format
-from libervia.frontends.tools import webrtc as webrtc_mod
+try:
+ from libervia.frontends.tools import webrtc as webrtc_mod
+except exceptions.MissingModule as e:
+ pytest.skip(
+ "Can't import webrtc due to missing modules: {e}",
+ allow_module_level=True
+ )
@pytest.fixture
diff --git a/tests/unit/test_plugin_misc_pubsub_contacts.py b/tests/unit/test_plugin_misc_pubsub_contacts.py
new file mode 100644
index 00000000..3d378f3d
--- /dev/null
+++ b/tests/unit/test_plugin_misc_pubsub_contacts.py
@@ -0,0 +1,420 @@
+#!/usr/bin/env python3
+
+# Tests for Libervia's Pubsub Contacts plugin
+# Copyright (C) 2009-2026 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program 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 Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+from twisted.words.xish import domish
+from wokkel.pubsub import NS_PUBSUB
+
+from libervia.backend.plugins.plugin_misc_pubsub_contacts import (
+ Contact,
+ Contacts,
+ ContactsData,
+ Group,
+ Groups,
+ NS_CONTACTS,
+)
+
+
+class TestGroup:
+ """Tests for Group model."""
+
+ def test_from_element(self):
+ """Group is correctly parsed from element."""
+ element = domish.Element((NS_CONTACTS, "group"))
+ element["id"] = "group123"
+ element["name"] = "My Group"
+ desc_elt = element.addElement((NS_CONTACTS, "description"))
+ desc_elt.addContent("A test group")
+
+ group = Group.from_element(element)
+
+ assert group.id == "group123"
+ assert group.name == "My Group"
+ assert group.description == "A test group"
+
+ def test_from_element_nested_in_parent(self):
+ """Group is correctly parsed from element's parent."""
+ parent = domish.Element((None, "parent"))
+ group_elt = parent.addElement((NS_CONTACTS, "group"))
+ group_elt["id"] = "group123"
+ group_elt["name"] = "Test Group"
+
+ group = Group.from_element(parent)
+
+ assert group.id == "group123"
+ assert group.name == "Test Group"
+
+ def test_from_element_set_encrypted(self):
+ """ "encrypted" flag is correctly set when present in metadata."""
+ element = domish.Element((NS_CONTACTS, "group"))
+ element["id"] = "group123"
+ metadata = {"encrypted": {"group123": True}}
+
+ group = Group.from_element(element, metadata)
+
+ assert group.encrypted is True
+
+ def test_from_element_invalid_type_raises_error(self):
+ """ValueError is raised when element is not a group element."""
+ element = domish.Element((NS_CONTACTS, "contact"))
+
+ try:
+ Group.from_element(element)
+ assert False, "Expected ValueError"
+ except ValueError as e:
+ assert "Invalid element" in str(e)
+
+ def test_to_element(self):
+ """Group element is correctly created with all fields."""
+ group = Group(
+ id="group123", name="My Group", description="A test group", encrypted=True
+ )
+ element = group.to_element()
+
+ assert element.uri == NS_CONTACTS
+ assert element.name == "group"
+ assert element["id"] == "group123"
+ assert element["name"] == "My Group"
+
+ desc_elements = list(element.elements(NS_CONTACTS, "description"))
+ assert len(desc_elements) == 1
+ assert str(desc_elements[0]) == "A test group"
+
+ def test_to_element_missing_name_raises_error(self):
+ """ValueError is raised when name is missing and id_only is False."""
+ group = Group(id="group123")
+
+ try:
+ group.to_element(id_only=False)
+ assert False, "Expected ValueError"
+ except ValueError as e:
+ assert "name must be set" in str(e)
+
+
+class TestGroups:
+ """Tests for Groups model."""
+
+ def test_append_and_iterate(self):
+ """Groups can be appended and iterated."""
+ groups = Groups([])
+ groups.append(Group(id="g1", name="Group 1"))
+ groups.append(Group(id="g2", name="Group 2"))
+
+ group_list = list(groups)
+ assert len(group_list) == 2
+ assert group_list[0].id == "g1"
+ assert group_list[1].id == "g2"
+
+ def test_len_and_remove(self):
+ """Groups len and remove work correctly."""
+ groups = Groups([])
+ groups.append(Group(id="g1", name="Group 1"))
+ groups.append(Group(id="g2", name="Group 2"))
+
+ assert len(groups) == 2
+ groups.remove(groups[0])
+ assert len(groups) == 1
+ assert groups[0].id == "g2"
+
+ def test_extend(self):
+ """Groups extend works correctly."""
+ groups = Groups([])
+ groups.extend([Group(id="g1"), Group(id="g2")])
+
+ assert len(groups) == 2
+
+ def test_clear(self):
+ """Groups clear works correctly."""
+ groups = Groups([Group(id="g1"), Group(id="g2")])
+ groups.clear()
+
+ assert len(groups) == 0
+
+ def test_sort(self):
+ """Groups sort works correctly."""
+ groups = Groups([Group(id="g2"), Group(id="g1")])
+ groups.sort(key=lambda g: g.id)
+
+ assert groups[0].id == "g1"
+ assert groups[1].id == "g2"
+
+ def test_getitem(self):
+ """Groups __getitem__ works correctly."""
+ groups = Groups([Group(id="g1"), Group(id="g2")])
+ assert groups[0].id == "g1"
+ assert groups[1].id == "g2"
+
+
+class TestContact:
+ """Tests for Contact model."""
+
+ def test_from_element_with_jid_id(self):
+ """Contact is correctly parsed from element with jid identity."""
+ contact_elt = domish.Element((NS_CONTACTS, "contact"))
+ contact_elt["name"] = "Test Name"
+ identity_elt = contact_elt.addElement((NS_CONTACTS, "identity"))
+ identity_elt["type"] = "jid"
+ identity_elt.addContent("user@example.com")
+
+ contact = Contact.from_element(contact_elt, ContactsData())
+
+ assert contact.identities == {"jid": "user@example.com"}
+ assert contact.name == "Test Name"
+ assert len(contact.groups) == 0
+
+ def test_from_element_with_group(self):
+ """Contact is correctly parsed with group reference."""
+ parent = domish.Element((NS_PUBSUB, "items"))
+ item_elt = parent.addElement((NS_PUBSUB, "item"))
+ item_elt["id"] = "contact123"
+ contact_elt = item_elt.addElement((NS_CONTACTS, "contact"))
+ identity_elt = contact_elt.addElement((NS_CONTACTS, "identity"))
+ identity_elt["type"] = "jid"
+ identity_elt.addContent("user@example.com")
+ group_elt = contact_elt.addElement((NS_CONTACTS, "group"))
+ group_elt["id"] = "g1"
+
+ contacts_data = ContactsData()
+ contacts_data.groups = Groups([Group(id="g1", name="Friends")])
+
+ contact = Contact.from_element(contact_elt, contacts_data)
+
+ assert contact.id == "contact123"
+ assert len(contact.groups) == 1
+ assert contact.groups[0].id == "g1"
+ assert contact.groups[0].name == "Friends"
+
+ def test_from_element_with_ancestor_item_id(self):
+ """Contact id is extracted from ancestor item element."""
+ parent = domish.Element((NS_PUBSUB, "items"))
+ item_elt = parent.addElement((NS_PUBSUB, "item"))
+ item_elt["id"] = "contact123"
+ contact_elt = item_elt.addElement((NS_CONTACTS, "contact"))
+ identity_elt = contact_elt.addElement((NS_CONTACTS, "identity"))
+ identity_elt["type"] = "jid"
+ identity_elt.addContent("user@example.com")
+
+ contact = Contact.from_element(contact_elt, ContactsData())
+
+ assert contact.id == "contact123"
+
+ def test_from_element_with_ancestor_item_encrypted(self):
+ """Contact encrypted flag is set from item metadata."""
+ parent = domish.Element((NS_PUBSUB, "items"))
+ item_elt = parent.addElement((NS_PUBSUB, "item"))
+ item_elt["id"] = "contact123"
+ item_elt["encrypted"] = "true"
+ contact_elt = item_elt.addElement((NS_CONTACTS, "contact"))
+ identity_elt = contact_elt.addElement((NS_CONTACTS, "identity"))
+ identity_elt["type"] = "jid"
+ identity_elt.addContent("user@example.com")
+
+ metadata = {"encrypted": {"contact123": True}}
+ contact = Contact.from_element(contact_elt, ContactsData(), metadata)
+
+ assert contact.encrypted is True
+
+ def test_to_element(self):
+ """Contact element is correctly created."""
+ contact = Contact(
+ id="contact123",
+ name="Test Name",
+ identities={"jid": "user@example.com"},
+ groups=Groups([Group(id="g1", name="Friends")]),
+ )
+ element = contact.to_element()
+
+ assert element.uri == NS_CONTACTS
+ assert element.name == "contact"
+ assert element["name"] == "Test Name"
+
+ identity_elements = list(element.elements(NS_CONTACTS, "identity"))
+ assert len(identity_elements) == 1
+ assert identity_elements[0]["type"] == "jid"
+ assert str(identity_elements[0]) == "user@example.com"
+
+ group_elements = list(element.elements(NS_CONTACTS, "group"))
+ assert len(group_elements) == 1
+ group_element = group_elements[0]
+ assert group_element["id"] == "g1"
+ assert not group_element.hasAttribute(
+ "name"
+ ), "name attribute should not be present in group with id_only"
+
+class TestContacts:
+ """Tests for Contacts root model."""
+
+ def test_append_and_iterate(self):
+ """Contacts can be appended and iterated."""
+ contacts = Contacts([])
+ contacts.append(Contact(identities={"jid": "user1@example.com"}, id="c1"))
+ contacts.append(Contact(identities={"jid": "user2@example.com"}, id="c2"))
+
+ contact_list = list(contacts)
+ assert len(contact_list) == 2
+ assert contact_list[0].id == "c1"
+ assert contact_list[1].id == "c2"
+
+ def test_len_and_remove(self):
+ """Contacts len and remove work correctly."""
+ contacts = Contacts([])
+ contacts.append(Contact(identities={"jid": "user1@example.com"}, id="c1"))
+ contacts.append(Contact(identities={"jid": "user2@example.com"}, id="c2"))
+
+ assert len(contacts) == 2
+ contacts.remove(contacts[0])
+ assert len(contacts) == 1
+ assert contacts[0].id == "c2"
+
+ def test_extend(self):
+ """Contacts extend works correctly."""
+ contacts = Contacts([])
+ contacts.extend(
+ [
+ Contact(identities={"jid": "user1@example.com"}),
+ Contact(identities={"jid": "user2@example.com"}),
+ ]
+ )
+
+ assert len(contacts) == 2
+
+ def test_clear(self):
+ """Contacts clear works correctly."""
+ contacts = Contacts(
+ [
+ Contact(identities={"jid": "user1@example.com"}),
+ Contact(identities={"jid": "user2@example.com"}),
+ ]
+ )
+ contacts.clear()
+
+ assert len(contacts) == 0
+
+ def test_sort(self):
+ """Contacts sort works correctly."""
+ contacts = Contacts(
+ [
+ Contact(identities={"jid": "user2@example.com"}, id="c2"),
+ Contact(identities={"jid": "user1@example.com"}, id="c1"),
+ ]
+ )
+ contacts.sort(key=lambda c: c.id)
+
+ assert contacts[0].id == "c1"
+ assert contacts[1].id == "c2"
+
+ def test_getitem(self):
+ """Contacts __getitem__ works correctly."""
+ contacts = Contacts(
+ [
+ Contact(identities={"jid": "user1@example.com"}, id="c1"),
+ Contact(identities={"jid": "user2@example.com"}, id="c2"),
+ ]
+ )
+ assert contacts[0].id == "c1"
+ assert contacts[1].id == "c2"
+
+
+class TestContactsData:
+ """Tests for ContactsData class."""
+
+ def test_jid_to_contacts_empty(self):
+ """jid_to_contacts returns empty dict when contacts is empty."""
+ data = ContactsData()
+ assert data.jid_to_contacts == {}
+
+ def test_jid_to_contacts(self):
+ """jid_to_contacts correctly maps JIDs to contacts."""
+ contact1 = Contact(
+ id="c1",
+ identities={"jid": "user1@example.com"},
+ )
+ contact2 = Contact(
+ id="c2",
+ identities={"jid": "user2@example.com"},
+ )
+ data = ContactsData(contacts=Contacts([contact1, contact2]))
+
+ jid_map = data.jid_to_contacts
+
+ assert len(jid_map) == 2
+ assert jid_map["user1@example.com"].id == "c1"
+ assert jid_map["user2@example.com"].id == "c2"
+
+ def test_jid_to_contacts_skips_contact_without_id(self):
+ """jid_to_contacts skips contacts without id."""
+ contact1 = Contact(
+ id="c1",
+ identities={"jid": "user1@example.com"},
+ )
+ contact2 = Contact(
+ id="c2",
+ identities={"jid": "user2@example.com"},
+ )
+ contact_no_id = Contact(
+ id=None,
+ identities={"jid": "user3@example.com"},
+ )
+ data = ContactsData(contacts=Contacts([contact1, contact2, contact_no_id]))
+
+ jid_map = data.jid_to_contacts
+
+ assert len(jid_map) == 2
+ assert "user1@example.com" in jid_map
+ assert "user2@example.com" in jid_map
+ assert "user3@example.com" not in jid_map
+
+ def test_ids_to_groups(self):
+ """ids_to_groups correctly maps group IDs to groups."""
+ group1 = Group(id="g1", name="Group 1")
+ group2 = Group(id="g2", name="Group 2")
+ data = ContactsData(groups=Groups([group1, group2]))
+
+ id_map = data.ids_to_groups
+
+ assert len(id_map) == 2
+ assert id_map["g1"].name == "Group 1"
+ assert id_map["g2"].name == "Group 2"
+
+ def test_ids_to_groups_skips_missing_id(self):
+ """ids_to_groups skips groups without ID."""
+ group = Group(id="g1", name="Group 1")
+ group_no_id = Group(name="No ID Group")
+ data = ContactsData(groups=Groups([group, group_no_id]))
+
+ id_map = data.ids_to_groups
+
+ assert len(id_map) == 1
+ assert "g1" in id_map
+
+ def test_reset(self):
+ """Reset clears cached mappings."""
+ contact = Contact(
+ id="c1",
+ identities={"jid": "user1@example.com"},
+ )
+ group = Group(id="g1", name="Group 1")
+ data = ContactsData(contacts=Contacts([contact]), groups=Groups([group]))
+
+ # Access the mappings to populate caches, and check that they are not empty.
+ assert len(data.jid_to_contacts) == 1
+ assert len(data.ids_to_groups) == 1
+
+ data.reset()
+
+ assert data._jid_to_contacts is None
+ assert data._ids_to_groups is None