[network/kaidan] /: Persist roster via QXmppRosterStorage

Melvin Keskin <[email protected]>
Newsgroups gmane.comp.kde.cvs
Message-ID <[email protected]>
Git commit c749028c198dae7585c43a9c30b36fc53746b9d5 by Melvin Keskin, on behalf of Linus Jahn.
Committed on 26/07/2026 at 20:43.
Pushed by melvo into branch 'master'.

Persist roster via QXmppRosterStorage

Implement QXmppRosterStorage on top of the existing roster table so the
roster (server version + items) is cached between sessions and the
versioned roster request (RFC 6121 §2.6) can be used.

RosterStorage is a thin per-account wrapper that forwards every operation
to RosterDb with the account's JID. RosterDb is extended with the storage
backend methods that return QXmppTask via the existing runAsyncTask() helper.

All persistent roster-wire item attributes are round-tripped: name,
subscription type, subscription status ("ask"), pre-approval, groups and
the MIX channel participant ID. An upsert (update or insert) method only
overrides these wire columns and the version, so the remaining chat data
(encryption, read markers, pinning, notification rules, ...) of an existing
item is preserved.

The storage is registered on the QXmppRosterManager in ClientController.
As the manager now persists roster changes itself, RosterController no
longer mirrors them into RosterDb; it only keeps the side effects that are
not part of the roster storage (removing messages and OMEMO devices,
processing subscription requests).

Co-Authored-By: Claude Opus 4.8 <[email protected]>

M  +20   -0    src/AccountDb.cpp
M  +3    -0    src/AccountDb.h
M  +2    -0    src/CMakeLists.txt
M  +2    -0    src/ClientController.cpp
M  +50   -15   src/Database.cpp
M  +1    -0    src/Database.h
M  +4    -41   src/RosterController.cpp
M  +0    -1    src/RosterController.h
M  +166  -76   src/RosterDb.cpp
M  +19   -7    src/RosterDb.h
M  +2    -0    src/RosterItem.cpp
M  +6    -0    src/RosterItem.h
A  +61   -0    src/RosterStorage.cpp     [License: GPL(v3.0+)]
A  +32   -0    src/RosterStorage.h     [License: GPL(v3.0+)]
M  +6    -0    tests/CMakeLists.txt
A  +278  -0    tests/RosterStorageTest.cpp     [License: GPL(v3.0+)]

https://invent.kde.org/network/kaidan/-/commit/c749028c198dae7585c43a9c30b36fc53746b9d5

diff --git a/src/AccountDb.cpp b/src/AccountDb.cpp
index 65f7897b0..454e76303 100644
--- a/src/AccountDb.cpp
+++ b/src/AccountDb.cpp
@@ -213,6 +213,26 @@ QFuture<void> AccountDb::updateGeoLocationMapService(const QString &jid, Account
     return updateField(jid, QStringLiteral("geoLocationMapService"), writeValue(geoLocationMapService));
 }
 
+QString AccountDb::_fetchRosterVersion(const QString &jid)
+{
+    auto query = createQuery();
+    execQuery(query,
+              QStringLiteral("SELECT rosterVersion FROM " DB_TABLE_ACCOUNTS " "
+                             "WHERE jid = :jid"),
+              {{u":jid", jid}});
+
+    if (query.first()) {
+        return query.value(0).toString();
+    }
+
+    return {};
+}
+
+void AccountDb::_updateRosterVersion(const QString &jid, const QString &version)
+{
+    updateField(jid, QStringLiteral("rosterVersion"), writeValue(version));
+}
+
 QFuture<QString> AccountDb::fetchLatestMessageStanzaId(const QString &jid)
 {
     return run([this, jid]() {
diff --git a/src/AccountDb.h b/src/AccountDb.h
index 319cf937d..862ee8f85 100644
--- a/src/AccountDb.h
+++ b/src/AccountDb.h
@@ -39,6 +39,9 @@ public:
     QFuture<void> updateGeoLocationMapPreviewEnabled(const QString &jid, bool geoLocationMapPreviewEnabled);
     QFuture<void> updateGeoLocationMapService(const QString &jid, AccountSettings::GeoLocationMapService geoLocationMapService);
 
+    QString _fetchRosterVersion(const QString &jid);
+    void _updateRosterVersion(const QString &jid, const QString &version);
+
     /**
      * Fetches the stanza ID of the latest locally stored (existing or removed) message.
      */
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 4cafe2c9b..4607de899 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -190,6 +190,8 @@ target_sources(libkaidancore PRIVATE
     RosterItemWatcher.h
     RosterModel.cpp
     RosterModel.h
+    RosterStorage.cpp
+    RosterStorage.h
     Settings.cpp
     Settings.h
     SqlUtils.cpp
diff --git a/src/ClientController.cpp b/src/ClientController.cpp
index 72ea5344e..0eccd0e50 100644
--- a/src/ClientController.cpp
+++ b/src/ClientController.cpp
@@ -50,6 +50,7 @@
 #include "OmemoDb.h"
 #include "PresenceCache.h"
 #include "RegistrationController.h"
+#include "RosterStorage.h"
 #include "TrustDb.h"
 
 ClientController::ClientController(AccountSettings *accountSettings, QObject *parent)
@@ -76,6 +77,7 @@ ClientController::ClientController(AccountSettings *accountSettings, QObject *pa
     m_messageReceiptManager = m_client->addNewExtension<QXmppMessageReceiptManager>();
     m_registrationManager = m_client->addNewExtension<QXmppRegistrationManager>();
     m_rosterManager = m_client->addNewExtension<QXmppRosterManager>(m_client);
+    m_rosterManager->setStorage(std::make_unique<RosterStorage>(m_accountSettings));
     m_vCardManager = m_client->addNewExtension<QXmppVCardManager>();
     m_versionManager = m_client->addNewExtension<QXmppVersionManager>();
     m_mixManager = m_client->addNewExtension<QXmppMixManager>();
diff --git a/src/Database.cpp b/src/Database.cpp
index 417b177be..60dee5a6a 100644
--- a/src/Database.cpp
+++ b/src/Database.cpp
@@ -45,8 +45,8 @@ using namespace SqlUtils;
     }
 
 // Both need to be updated on version bump:
-#define DATABASE_LATEST_VERSION 58
-#define DATABASE_CONVERT_TO_LATEST_VERSION() DATABASE_CONVERT_TO_VERSION(58)
+#define DATABASE_LATEST_VERSION 59
+#define DATABASE_CONVERT_TO_LATEST_VERSION() DATABASE_CONVERT_TO_VERSION(59)
 
 #define SQL_BOOL "BOOL"
 #define SQL_BOOL_NOT_NULL "BOOL NOT NULL"
@@ -372,22 +372,22 @@ void Database::createNewDatabase()
                                                    SQL_ATTRIBUTE(port, SQL_BOOL) SQL_ATTRIBUTE(tlsErrorsIgnored, SQL_INTEGER)
                                                        SQL_ATTRIBUTE(tlsRequirement, SQL_INTEGER) SQL_ATTRIBUTE(plainAuthAllowed, SQL_BOOL)
                                                            SQL_ATTRIBUTE(passwordVisibility, SQL_INTEGER) SQL_ATTRIBUTE(userAgentDeviceId, SQL_TEXT)
-                                                               SQL_ATTRIBUTE(encryption, SQL_INTEGER)
-                                                                   SQL_ATTRIBUTE(automaticMediaDownloadsRule, SQL_INTEGER) "PRIMARY KEY(jid)"));
+                                                               SQL_ATTRIBUTE(encryption, SQL_INTEGER) SQL_ATTRIBUTE(automaticMediaDownloadsRule, SQL_INTEGER)
+                                                                   SQL_ATTRIBUTE(rosterVersion, SQL_TEXT) "PRIMARY KEY(jid)"));
 
     // roster
     execQuery(query,
-              SQL_CREATE_TABLE(DB_TABLE_ROSTER,
-                               SQL_ATTRIBUTE(accountJid, SQL_TEXT_NOT_NULL) SQL_ATTRIBUTE(jid, SQL_TEXT_NOT_NULL) SQL_ATTRIBUTE(name, SQL_TEXT)
-                                   SQL_ATTRIBUTE(subscription, SQL_INTEGER) SQL_ATTRIBUTE(groupChatParticipantId, SQL_TEXT)
-                                       SQL_ATTRIBUTE(groupChatName, SQL_TEXT) SQL_ATTRIBUTE(groupChatDescription, SQL_TEXT)
-                                           SQL_ATTRIBUTE(groupChatFlags, SQL_INTEGER) SQL_ATTRIBUTE(encryption, SQL_INTEGER)
-                                               SQL_ATTRIBUTE(lastReadOwnMessageId, SQL_TEXT) SQL_ATTRIBUTE(lastReadContactMessageId, SQL_TEXT)
-                                                   SQL_ATTRIBUTE(latestGroupChatMessageStanzaId, SQL_TEXT)
-                                                       SQL_ATTRIBUTE(latestGroupChatMessageStanzaTimestamp, SQL_TEXT) SQL_ATTRIBUTE(readMarkerPending, SQL_BOOL)
-                                                           SQL_ATTRIBUTE(pinningPosition, SQL_INTEGER_NOT_NULL) SQL_ATTRIBUTE(chatStateSendingEnabled, SQL_BOOL)
-                                                               SQL_ATTRIBUTE(readMarkerSendingEnabled, SQL_BOOL) SQL_ATTRIBUTE(notificationRule, SQL_INTEGER)
-                                                                   SQL_ATTRIBUTE(automaticMediaDownloadsRule, SQL_INTEGER) "PRIMARY KEY(accountJid, jid)"));
+              SQL_CREATE_TABLE(
+                  DB_TABLE_ROSTER,
+                  SQL_ATTRIBUTE(accountJid, SQL_TEXT_NOT_NULL) SQL_ATTRIBUTE(jid, SQL_TEXT_NOT_NULL) SQL_ATTRIBUTE(name, SQL_TEXT)
+                      SQL_ATTRIBUTE(subscription, SQL_INTEGER) SQL_ATTRIBUTE(subscriptionStatus, SQL_TEXT) SQL_ATTRIBUTE(subscriptionApproved, SQL_BOOL)
+                          SQL_ATTRIBUTE(groupChatParticipantId, SQL_TEXT) SQL_ATTRIBUTE(groupChatName, SQL_TEXT) SQL_ATTRIBUTE(groupChatDescription, SQL_TEXT)
+                              SQL_ATTRIBUTE(groupChatFlags, SQL_INTEGER) SQL_ATTRIBUTE(encryption, SQL_INTEGER) SQL_ATTRIBUTE(lastReadOwnMessageId, SQL_TEXT)
+                                  SQL_ATTRIBUTE(lastReadContactMessageId, SQL_TEXT) SQL_ATTRIBUTE(latestGroupChatMessageStanzaId, SQL_TEXT)
+                                      SQL_ATTRIBUTE(latestGroupChatMessageStanzaTimestamp, SQL_TEXT) SQL_ATTRIBUTE(readMarkerPending, SQL_BOOL)
+                                          SQL_ATTRIBUTE(pinningPosition, SQL_INTEGER_NOT_NULL) SQL_ATTRIBUTE(chatStateSendingEnabled, SQL_BOOL)
+                                              SQL_ATTRIBUTE(readMarkerSendingEnabled, SQL_BOOL) SQL_ATTRIBUTE(notificationRule, SQL_INTEGER)
+                                                  SQL_ATTRIBUTE(automaticMediaDownloadsRule, SQL_INTEGER) "PRIMARY KEY(accountJid, jid)"));
     execQuery(query,
               SQL_CREATE_TABLE(DB_TABLE_ROSTER_GROUPS,
                                SQL_ATTRIBUTE(accountJid, SQL_TEXT_NOT_NULL) SQL_ATTRIBUTE(chatJid, SQL_TEXT_NOT_NULL)
@@ -2050,4 +2050,39 @@ void Database::convertDatabaseToV58()
     d->version = 58;
 }
 
+void Database::convertDatabaseToV59()
+{
+    DATABASE_CONVERT_TO_VERSION(58)
+    QSqlQuery query(currentDatabase());
+
+    execQuery(query, QStringLiteral("ALTER TABLE accounts ADD rosterVersion " SQL_TEXT));
+
+    // Add the roster columns "subscriptionStatus" and "subscriptionApproved".
+    execQuery(query,
+              SQL_CREATE_TABLE(
+                  "roster_tmp",
+                  SQL_ATTRIBUTE(accountJid, SQL_TEXT_NOT_NULL) SQL_ATTRIBUTE(jid, SQL_TEXT_NOT_NULL) SQL_ATTRIBUTE(name, SQL_TEXT)
+                      SQL_ATTRIBUTE(subscription, SQL_INTEGER) SQL_ATTRIBUTE(subscriptionStatus, SQL_TEXT) SQL_ATTRIBUTE(subscriptionApproved, SQL_BOOL)
+                          SQL_ATTRIBUTE(groupChatParticipantId, SQL_TEXT) SQL_ATTRIBUTE(groupChatName, SQL_TEXT) SQL_ATTRIBUTE(groupChatDescription, SQL_TEXT)
+                              SQL_ATTRIBUTE(groupChatFlags, SQL_INTEGER) SQL_ATTRIBUTE(encryption, SQL_INTEGER) SQL_ATTRIBUTE(lastReadOwnMessageId, SQL_TEXT)
+                                  SQL_ATTRIBUTE(lastReadContactMessageId, SQL_TEXT) SQL_ATTRIBUTE(latestGroupChatMessageStanzaId, SQL_TEXT)
+                                      SQL_ATTRIBUTE(latestGroupChatMessageStanzaTimestamp, SQL_TEXT) SQL_ATTRIBUTE(readMarkerPending, SQL_BOOL)
+                                          SQL_ATTRIBUTE(pinningPosition, SQL_INTEGER_NOT_NULL) SQL_ATTRIBUTE(chatStateSendingEnabled, SQL_BOOL)
+                                              SQL_ATTRIBUTE(readMarkerSendingEnabled, SQL_BOOL) SQL_ATTRIBUTE(notificationRule, SQL_INTEGER)
+                                                  SQL_ATTRIBUTE(automaticMediaDownloadsRule, SQL_INTEGER) "PRIMARY KEY(accountJid, jid)"));
+
+    execQuery(query, QStringLiteral(R"(
+			INSERT INTO roster_tmp
+			SELECT accountJid, jid, name, subscription, NULL, NULL, groupChatParticipantId, groupChatName, groupChatDescription, groupChatFlags,
+            encryption, lastReadOwnMessageId, lastReadContactMessageId, latestGroupChatMessageStanzaId, latestGroupChatMessageStanzaTimestamp,
+            readMarkerPending, pinningPosition, chatStateSendingEnabled, readMarkerSendingEnabled, notificationRule, automaticMediaDownloadsRule
+			FROM roster
+		)"));
+
+    execQuery(query, QStringLiteral("DROP TABLE roster"));
+    execQuery(query, QStringLiteral("ALTER TABLE roster_tmp RENAME TO roster"));
+
+    d->version = 59;
+}
+
 #include "moc_Database.cpp"
diff --git a/src/Database.h b/src/Database.h
index 8fd06e1ab..d24e4d490 100644
--- a/src/Database.h
+++ b/src/Database.h
@@ -145,6 +145,7 @@ private:
     void convertDatabaseToV56();
     void convertDatabaseToV57();
     void convertDatabaseToV58();
+    void convertDatabaseToV59();
 
     std::unique_ptr<DatabasePrivate> d;
 
diff --git a/src/RosterController.cpp b/src/RosterController.cpp
index 15434d551..164467ee4 100644
--- a/src/RosterController.cpp
+++ b/src/RosterController.cpp
@@ -36,36 +36,11 @@ RosterController::RosterController(AccountSettings *accountSettings,
 {
     connect(m_manager, &QXmppRosterManager::rosterReceived, this, &RosterController::populateRoster);
 
-    connect(m_manager, &QXmppRosterManager::itemAdded, this, [this](const QString &jid) {
-        RosterItem item{m_accountSettings->jid(), m_manager->getRosterEntry(jid)};
-
-        item.encryption = m_accountSettings->encryption();
-        item.lastMessageDateTime = QDateTime::currentDateTimeUtc();
-
-        // Add the item to the dabatase.
-        // Any further usage of the item is done once it is added to RosterModel (see connection for RosterModel::itemAdded()).
-        // That way, it is not needed to retrieve the item multiple times from the database.
-        RosterDb::instance()->addItem(item);
-    });
-
-    connect(m_manager, &QXmppRosterManager::itemChanged, this, [this](const QString &jid) {
-        RosterDb::instance()->updateItem(m_accountSettings->jid(), jid, [jid, changedItem = m_manager->getRosterEntry(jid)](RosterItem &item) {
-            item.name = changedItem.name();
-            item.subscription = changedItem.subscriptionType();
-
-            const auto groups = changedItem.groups();
-            item.groups = {groups.cbegin(), groups.cend()};
-        });
-
-        if (m_isItemBeingChanged) {
-            m_isItemBeingChanged = false;
-        }
-    });
-
+    // The roster's persistence (adding, updating and replacing items) is handled by RosterStorage.
+    // Only the side effects that are not part of the roster storage are handled here.
     connect(m_manager, &QXmppRosterManager::itemRemoved, this, [this](const QString &jid) {
         const auto accountJid = m_accountSettings->jid();
         MessageDb::instance()->removeMessages(accountJid, jid);
-        RosterDb::instance()->removeItem(accountJid, jid);
 
         // Do not remove own devices in case the notes chat is removed.
         if (jid != accountJid) {
@@ -258,7 +233,6 @@ void RosterController::removeGroup(const QString &group)
 
 void RosterController::updateGroups(const QString &jid, const QList<QString> &groups)
 {
-    m_isItemBeingChanged = true;
     m_manager->updateRosterGroups(jid, {groups.cbegin(), groups.cend()});
 }
 
@@ -304,20 +278,12 @@ void RosterController::populateRoster()
 {
     qCDebug(KAIDAN_CORE_LOG) << "Populating roster";
 
-    const auto accountJid = m_accountSettings->jid();
+    // The roster items themselves are already persisted by RosterStorage before this signal is
+    // emitted.
 
-    QList<RosterItem> rosterItems;
     const auto jids = m_manager->getRosterBareJids();
 
     for (const auto &jid : jids) {
-        RosterItem rosterItem = {accountJid, m_manager->getRosterEntry(jid)};
-        rosterItem.encryption = m_accountSettings->encryption();
-        rosterItems.append(rosterItem);
-    }
-
-    for (auto itr = rosterItems.begin(); itr != rosterItems.end(); ++itr) {
-        const auto jid = itr->jid;
-
         // Process subscription requests from roster items that were received before the roster was
         // received.
         if (m_unprocessedSubscriptionRequests.contains(jid)) {
@@ -325,9 +291,6 @@ void RosterController::populateRoster()
         }
     }
 
-    // replace current contacts with new ones from server
-    RosterDb::instance()->replaceItems(accountJid, rosterItems);
-
     // Process subscription requests from strangers that were received before the roster was
     // received.
     for (auto itr = m_unprocessedSubscriptionRequests.begin(); itr != m_unprocessedSubscriptionRequests.end();) {
diff --git a/src/RosterController.h b/src/RosterController.h
index a5fc61599..00e455efc 100644
--- a/src/RosterController.h
+++ b/src/RosterController.h
@@ -99,7 +99,6 @@ private:
     QMap<QString, QXmppPresence> m_unrespondedSubscriptionRequests;
     QList<QString> m_pendingAutomaticInitialAdditionJids;
     bool m_addingOwnJidToRosterAllowed = true;
-    bool m_isItemBeingChanged = false;
 };
 
 Q_DECLARE_METATYPE(RosterController::ContactAdditionWithUriResult)
diff --git a/src/RosterDb.cpp b/src/RosterDb.cpp
index 5e646698e..35c26fa68 100644
--- a/src/RosterDb.cpp
+++ b/src/RosterDb.cpp
@@ -13,6 +13,7 @@
 #include <QSqlQuery>
 #include <QSqlRecord>
 // Kaidan
+#include "AccountDb.h"
 #include "Algorithms.h"
 #include "Globals.h"
 #include "GroupChatUser.h"
@@ -56,6 +57,10 @@ static QSqlRecord createUpdateRecord(const RosterItem &oldItem, const RosterItem
         rec.append(createSqlField(QStringLiteral("name"), newItem.name));
     if (oldItem.subscription != newItem.subscription)
         rec.append(createSqlField(QStringLiteral("subscription"), static_cast<int>(newItem.subscription)));
+    if (oldItem.subscriptionStatus != newItem.subscriptionStatus)
+        rec.append(createSqlField(QStringLiteral("subscriptionStatus"), newItem.subscriptionStatus));
+    if (oldItem.subscriptionApproved != newItem.subscriptionApproved)
+        rec.append(createSqlField(QStringLiteral("subscriptionApproved"), newItem.subscriptionApproved));
     if (oldItem.groupChatParticipantId != newItem.groupChatParticipantId)
         rec.append(createSqlField(QStringLiteral("groupChatParticipantId"), newItem.groupChatParticipantId));
     if (oldItem.groupChatName != newItem.groupChatName)
@@ -98,11 +103,40 @@ QFuture<QList<RosterItem>> RosterDb::fetchItems()
     });
 }
 
-QFuture<void> RosterDb::addItem(RosterItem item)
+QXmppTask<QXmppRosterStorage::RosterCache> RosterDb::fetchRosterCache(const QString &accountJid)
 {
-    return run([this, item]() mutable {
-        fetchLastMessage(item);
-        _addItem(item);
+    return runTask([this, accountJid]() {
+        const auto items = fetchWireItems(accountJid);
+
+        std::vector<QXmppRosterIq::Item> cacheItems;
+        cacheItems.reserve(items.size());
+
+        for (const auto &item : items) {
+            QXmppRosterIq::Item cacheItem;
+            cacheItem.setBareJid(item.jid);
+            cacheItem.setName(item.name);
+            cacheItem.setSubscriptionType(item.subscription);
+            cacheItem.setSubscriptionStatus(item.subscriptionStatus);
+            cacheItem.setIsApproved(item.subscriptionApproved);
+            cacheItem.setGroups({item.groups.cbegin(), item.groups.cend()});
+
+            if (!item.groupChatParticipantId.isEmpty()) {
+                cacheItem.setIsMixChannel(true);
+                cacheItem.setMixParticipantId(item.groupChatParticipantId);
+            }
+
+            cacheItems.push_back(std::move(cacheItem));
+        }
+
+        return QXmppRosterStorage::RosterCache{AccountDb::instance()->_fetchRosterVersion(accountJid), std::move(cacheItems)};
+    });
+}
+
+QXmppTask<void> RosterDb::replaceItems(const QString &accountJid, const QString &version, const QList<RosterItem> &items)
+{
+    return runTask([this, accountJid, version, items]() {
+        _replaceItems(accountJid, items);
+        AccountDb::instance()->_updateRosterVersion(accountJid, version);
     });
 }
 
@@ -113,90 +147,26 @@ QFuture<void> RosterDb::updateItem(const QString &accountJid, const QString &jid
     });
 }
 
-QFuture<void> RosterDb::replaceItems(const QString &accountJid, const QList<RosterItem> &items)
+QXmppTask<void> RosterDb::updateOrInsertItem(const QString &accountJid, const QString &version, RosterItem item)
 {
-    return run([this, accountJid, items]() {
-        // load current items
-        auto query = createQuery();
-        execQuery(query,
-                  QStringLiteral(R"(
-				SELECT *
-				FROM roster
-				WHERE accountJid = :accountJid
-			)"),
-                  {{u":accountJid", accountJid}});
-
-        auto oldItems = parseItemsFromQuery(query);
-
-        for (auto &oldItem : oldItems) {
-            fetchGroups(oldItem);
-        }
-
-        transaction();
-
-        QList<QString> newJids = transform(items, [](const RosterItem &rosterItem) {
-            return rosterItem.jid;
-        });
-
-        for (const auto &oldItem : std::as_const(oldItems)) {
-            const auto jid = oldItem.jid;
-
-            // We will remove the already existing JIDs, so we get a set of JIDs that
-            // are completely new.
-            //
-            // By calling remove(), we also find out whether the JID is already
-            // existing or not.
-            if (newJids.removeOne(jid)) {
-                auto itr = std::ranges::find_if(items, [jid](const RosterItem &rosterItem) {
-                    return rosterItem.jid == jid;
-                });
-
-                // item is also included in newJids -> update
-                _updateItem(accountJid, jid, [newItem = *itr](RosterItem &oldItem) {
-                    oldItem.name = newItem.name;
-                    oldItem.subscription = newItem.subscription;
-                    oldItem.groups = newItem.groups;
-                });
-            } else {
-                // item is not included in newJids -> delete
-                _removeItem(accountJid, jid);
-            }
-        }
-
-        // now add the completely new JIDs
-        for (const QString &jid : newJids) {
-            auto itr = std::ranges::find_if(items, [jid](const RosterItem &rosterItem) {
-                return rosterItem.jid == jid;
-            });
-            _addItem(*itr);
-        }
-
-        commit();
-        Q_EMIT itemsReplaced(accountJid);
+    return runTask([this, accountJid, version, item]() mutable {
+        _upsertItem(accountJid, std::move(item));
+        AccountDb::instance()->_updateRosterVersion(accountJid, version);
     });
 }
 
-QFuture<void> RosterDb::removeItem(const QString &accountJid, const QString &jid)
+QXmppTask<void> RosterDb::removeItem(const QString &accountJid, const QString &version, const QString &jid)
 {
-    return run([this, accountJid, jid]() {
+    return runTask([this, accountJid, version, jid]() {
         _removeItem(accountJid, jid);
+        AccountDb::instance()->_updateRosterVersion(accountJid, version);
     });
 }
 
 QFuture<void> RosterDb::removeItems(const QString &accountJid)
 {
     return run([this, accountJid]() {
-        auto query = createQuery();
-
-        execQuery(query,
-                  QStringLiteral("DELETE FROM " DB_TABLE_ROSTER " "
-                                 "WHERE accountJid = :accountJid"),
-                  {{u":accountJid", accountJid}});
-
-        removeGroups(accountJid);
-        GroupChatUserDb::instance()->_removeUsers(accountJid);
-
-        itemsRemoved(accountJid);
+        _removeItems(accountJid);
     });
 }
 
@@ -221,6 +191,26 @@ QList<RosterItem> RosterDb::fetchBasicItems()
     return parseItemsFromQuery(query);
 }
 
+QList<RosterItem> RosterDb::fetchWireItems(const QString &accountJid)
+{
+    auto query = createQuery();
+    execQuery(query,
+              QStringLiteral(R"(
+				SELECT *
+				FROM roster
+				WHERE accountJid = :accountJid
+			)"),
+              {{u":accountJid", accountJid}});
+
+    auto items = parseItemsFromQuery(query);
+
+    for (auto &item : items) {
+        fetchGroups(item);
+    }
+
+    return items;
+}
+
 void RosterDb::fetchGroups(RosterItem &item)
 {
     enum {
@@ -371,6 +361,8 @@ void RosterDb::_addItem(RosterItem item)
                {u"jid", item.jid},
                {u"name", item.name},
                {u"subscription", static_cast<int>(item.subscription)},
+               {u"subscriptionStatus", item.subscriptionStatus},
+               {u"subscriptionApproved", item.subscriptionApproved},
                {u"groupChatParticipantId", item.groupChatParticipantId},
                {u"groupChatName", item.groupChatName},
                {u"groupChatDescription", item.groupChatDescription},
@@ -433,6 +425,85 @@ void RosterDb::_updateItem(const QString &accountJid, const QString &jid, const
     }
 }
 
+void RosterDb::_upsertItem(const QString &accountJid, RosterItem item)
+{
+    auto query = createQuery();
+    execQuery(query,
+              QStringLiteral(R"(
+				SELECT COUNT(*)
+				FROM roster
+				WHERE accountJid = :accountJid AND jid = :jid
+			)"),
+              {
+                  {u":accountJid", accountJid},
+                  {u":jid", item.jid},
+              });
+
+    if (query.first() && query.value(0).toInt() > 0) {
+        // The item already exists: only override the roster-wire columns and keep all other
+        // conversation data (encryption, read markers, pinning, …) untouched.
+        _updateItem(accountJid, item.jid, [newItem = item](RosterItem &oldItem) {
+            oldItem.name = newItem.name;
+            oldItem.subscription = newItem.subscription;
+            oldItem.subscriptionStatus = newItem.subscriptionStatus;
+            oldItem.subscriptionApproved = newItem.subscriptionApproved;
+            oldItem.groups = newItem.groups;
+        });
+    } else {
+        fetchLastMessage(item);
+        _addItem(item);
+    }
+}
+
+void RosterDb::_replaceItems(const QString &accountJid, const QList<RosterItem> &items)
+{
+    auto oldItems = fetchWireItems(accountJid);
+
+    transaction();
+
+    QList<QString> newJids = transform(items, [](const RosterItem &rosterItem) {
+        return rosterItem.jid;
+    });
+
+    for (const auto &oldItem : std::as_const(oldItems)) {
+        const auto jid = oldItem.jid;
+
+        // We will remove the already existing JIDs, so we get a set of JIDs that
+        // are completely new.
+        //
+        // By calling remove(), we also find out whether the JID is already
+        // existing or not.
+        if (newJids.removeOne(jid)) {
+            auto itr = std::ranges::find_if(items, [jid](const RosterItem &rosterItem) {
+                return rosterItem.jid == jid;
+            });
+
+            // item is also included in newJids -> update
+            _updateItem(accountJid, jid, [newItem = *itr](RosterItem &oldItem) {
+                oldItem.name = newItem.name;
+                oldItem.subscription = newItem.subscription;
+                oldItem.subscriptionStatus = newItem.subscriptionStatus;
+                oldItem.subscriptionApproved = newItem.subscriptionApproved;
+                oldItem.groups = newItem.groups;
+            });
+        } else {
+            // item is not included in newJids -> delete
+            _removeItem(accountJid, jid);
+        }
+    }
+
+    // now add the completely new JIDs
+    for (const QString &jid : newJids) {
+        auto itr = std::ranges::find_if(items, [jid](const RosterItem &rosterItem) {
+            return rosterItem.jid == jid;
+        });
+        _addItem(*itr);
+    }
+
+    commit();
+    Q_EMIT itemsReplaced(accountJid);
+}
+
 void RosterDb::_removeItem(const QString &accountJid, const QString &jid)
 {
     Q_EMIT itemRemoved(accountJid, jid);
@@ -448,6 +519,21 @@ void RosterDb::_removeItem(const QString &accountJid, const QString &jid)
     GroupChatUserDb::instance()->_removeUsers(accountJid, jid);
 }
 
+void RosterDb::_removeItems(const QString &accountJid)
+{
+    auto query = createQuery();
+
+    execQuery(query,
+              QStringLiteral("DELETE FROM " DB_TABLE_ROSTER " "
+                             "WHERE accountJid = :accountJid"),
+              {{u":accountJid", accountJid}});
+
+    removeGroups(accountJid);
+    GroupChatUserDb::instance()->_removeUsers(accountJid);
+
+    Q_EMIT itemsRemoved(accountJid);
+}
+
 QList<RosterItem> RosterDb::parseItemsFromQuery(QSqlQuery &query)
 {
     QList<RosterItem> items;
@@ -467,6 +553,8 @@ RosterItem RosterDb::parseItemFromQuery(QSqlQuery &query)
     int idxJid = rec.indexOf(QStringLiteral("jid"));
     int idxName = rec.indexOf(QStringLiteral("name"));
     int idxSubscription = rec.indexOf(QStringLiteral("subscription"));
+    int idxSubscriptionStatus = rec.indexOf(QStringLiteral("subscriptionStatus"));
+    int idxSubscriptionApproved = rec.indexOf(QStringLiteral("subscriptionApproved"));
     int idxGroupChatParticipantId = rec.indexOf(QStringLiteral("groupChatParticipantId"));
     int idxGroupChatName = rec.indexOf(QStringLiteral("groupChatName"));
     int idxGroupChatDescription = rec.indexOf(QStringLiteral("groupChatDescription"));
@@ -489,6 +577,8 @@ RosterItem RosterDb::parseItemFromQuery(QSqlQuery &query)
     item.jid = query.value(idxJid).toString();
     item.name = query.value(idxName).toString();
     item.subscription = query.value(idxSubscription).value<QXmppRosterIq::Item::SubscriptionType>();
+    item.subscriptionStatus = query.value(idxSubscriptionStatus).toString();
+    item.subscriptionApproved = query.value(idxSubscriptionApproved).toBool();
     item.groupChatParticipantId = query.value(idxGroupChatParticipantId).toString();
     item.groupChatName = query.value(idxGroupChatName).toString();
     item.groupChatDescription = query.value(idxGroupChatDescription).toString();
diff --git a/src/RosterDb.h b/src/RosterDb.h
index 9d4e09cf0..7d945f5da 100644
--- a/src/RosterDb.h
+++ b/src/RosterDb.h
@@ -5,6 +5,9 @@
 
 #pragma once
 
+// QXmpp
+#include <QXmppRosterStorage.h>
+#include <QXmppTask.h>
 // Kaidan
 #include "DatabaseComponent.h"
 
@@ -21,25 +24,32 @@ public:
     static RosterDb *instance();
 
     QFuture<QList<RosterItem>> fetchItems();
+    QXmppTask<QXmppRosterStorage::RosterCache> fetchRosterCache(const QString &accountJid);
 
-    QFuture<void> addItem(RosterItem item);
-    Q_SIGNAL void itemAdded(const RosterItem &item);
+    QXmppTask<void> replaceItems(const QString &accountJid, const QString &version, const QList<RosterItem> &items);
+    Q_SIGNAL void itemsReplaced(const QString &accountJid);
 
     QFuture<void> updateItem(const QString &accountJid, const QString &jid, const std::function<void(RosterItem &)> &updateItem);
+    QXmppTask<void> updateOrInsertItem(const QString &accountJid, const QString &version, RosterItem item);
+    Q_SIGNAL void itemAdded(const RosterItem &item);
     Q_SIGNAL void itemUpdated(const RosterItem &item);
 
-    QFuture<void> replaceItems(const QString &accountJid, const QList<RosterItem> &items);
-    Q_SIGNAL void itemsReplaced(const QString &accountJid);
-
-    QFuture<void> removeItem(const QString &accountJid, const QString &jid);
+    QXmppTask<void> removeItem(const QString &accountJid, const QString &version, const QString &jid);
     Q_SIGNAL void itemRemoved(const QString &accountJid, const QString &jid);
 
     QFuture<void> removeItems(const QString &accountJid);
     Q_SIGNAL void itemsRemoved(const QString &accountJid);
 
 private:
+    template<typename Functor>
+    auto runTask(Functor function)
+    {
+        return runAsyncTask(this, dbWorker(), function);
+    }
+
     QList<RosterItem> _fetchItems();
     QList<RosterItem> fetchBasicItems();
+    QList<RosterItem> fetchWireItems(const QString &accountJid);
 
     void fetchGroups(RosterItem &item);
     void addGroups(const QString &accountJid, const QString &jid, const QList<QString> &groups);
@@ -55,8 +65,10 @@ private:
 
     void _addItem(RosterItem item);
     void _updateItem(const QString &accountJid, const QString &jid, const std::function<void(RosterItem &)> &updateItem);
-    void _replaceItem(const RosterItem &oldItem, const RosterItem &newItem);
+    void _upsertItem(const QString &accountJid, RosterItem item);
+    void _replaceItems(const QString &accountJid, const QList<RosterItem> &items);
     void _removeItem(const QString &accountJid, const QString &jid);
+    void _removeItems(const QString &accountJid);
 
     static QList<RosterItem> parseItemsFromQuery(QSqlQuery &query);
     static RosterItem parseItemFromQuery(QSqlQuery &query);
diff --git a/src/RosterItem.cpp b/src/RosterItem.cpp
index 43f9958ef..7f8472c89 100644
--- a/src/RosterItem.cpp
+++ b/src/RosterItem.cpp
@@ -15,6 +15,8 @@ RosterItem::RosterItem(const QString &accountJid, const QXmppRosterIq::Item &ite
     : accountJid(accountJid)
     , jid(item.bareJid())
     , subscription(item.subscriptionType())
+    , subscriptionStatus(item.subscriptionStatus())
+    , subscriptionApproved(item.isApproved())
     , groupChatParticipantId(item.mixParticipantId())
 {
     if (!item.isMixChannel()) {
diff --git a/src/RosterItem.h b/src/RosterItem.h
index 483187207..40923c056 100644
--- a/src/RosterItem.h
+++ b/src/RosterItem.h
@@ -125,6 +125,12 @@ public:
     // Type of this roster item's presence subscription.
     QXmppRosterIq::Item::SubscriptionType subscription = QXmppRosterIq::Item::NotSet;
 
+    // Pending presence subscription state (the "ask" attribute), e.g., "subscribe".
+    QString subscriptionStatus;
+
+    // Whether the contact's presence subscription has been pre-approved.
+    bool subscriptionApproved = false;
+
     // Roster groups (i.e., labels) used for filtering (e.g., "Family", "Friends" etc.).
     QList<QString> groups;
 
diff --git a/src/RosterStorage.cpp b/src/RosterStorage.cpp
new file mode 100644
index 000000000..b23677d58
--- /dev/null
+++ b/src/RosterStorage.cpp
@@ -0,0 +1,61 @@
+// SPDX-FileCopyrightText: 2026 Linus Jahn <[email protected]>
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "RosterStorage.h"
+
+// Kaidan
+#include "Account.h"
+#include "RosterDb.h"
+#include "RosterItem.h"
+
+RosterStorage::RosterStorage(AccountSettings *accountSettings)
+    : m_accountSettings(accountSettings)
+{
+}
+
+QXmppTask<QXmppRosterStorage::RosterCache> RosterStorage::load()
+{
+    return RosterDb::instance()->fetchRosterCache(m_accountSettings->jid());
+}
+
+QXmppTask<void> RosterStorage::replaceAll(const QString &version, const std::vector<QXmppRosterIq::Item> &items)
+{
+    const auto accountJid = m_accountSettings->jid();
+    const auto encryption = m_accountSettings->encryption();
+
+    QList<RosterItem> rosterItems;
+    rosterItems.reserve(items.size());
+
+    for (const auto &item : items) {
+        RosterItem rosterItem{accountJid, item};
+        rosterItem.encryption = encryption;
+        rosterItems.append(std::move(rosterItem));
+    }
+
+    return RosterDb::instance()->replaceItems(accountJid, version, rosterItems);
+}
+
+QXmppTask<void> RosterStorage::upsertItem(const QString &version, const QXmppRosterIq::Item &item)
+{
+    const auto accountJid = m_accountSettings->jid();
+
+    RosterItem rosterItem{accountJid, item};
+    rosterItem.encryption = m_accountSettings->encryption();
+
+    return RosterDb::instance()->updateOrInsertItem(accountJid, version, std::move(rosterItem));
+}
+
+QXmppTask<void> RosterStorage::removeItem(const QString &version, const QString &bareJid)
+{
+    return RosterDb::instance()->removeItem(m_accountSettings->jid(), version, bareJid);
+}
+
+QXmppTask<void> RosterStorage::clear()
+{
+    // The removal of all roster items of an account from the database is triggered after removing an account.
+    // Thus, the removal is not triggered here.
+    QXmppPromise<void> promise;
+    promise.finish();
+    return promise.task();
+}
diff --git a/src/RosterStorage.h b/src/RosterStorage.h
new file mode 100644
index 000000000..8db82d180
--- /dev/null
+++ b/src/RosterStorage.h
@@ -0,0 +1,32 @@
+// SPDX-FileCopyrightText: 2026 Linus Jahn <[email protected]>
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+// QXmpp
+#include <QXmppRosterStorage.h>
+
+class AccountSettings;
+
+/**
+ * Persists the roster (RFC 6121 §2.6 roster versioning) in Kaidan's roster table.
+ *
+ * This is a thin per-account adapter: every operation forwards to the RosterDb singleton with the
+ * account's JID. An upsert only overrides the roster-wire columns (name, subscription, groups) so
+ * that the remaining conversation data of an existing item is preserved.
+ */
+class RosterStorage : public QXmppRosterStorage
+{
+public:
+    explicit RosterStorage(AccountSettings *accountSettings);
+
+    QXmppTask<RosterCache> load() override;
+    QXmppTask<void> replaceAll(const QString &version, const std::vector<QXmppRosterIq::Item> &items) override;
+    QXmppTask<void> upsertItem(const QString &version, const QXmppRosterIq::Item &item) override;
+    QXmppTask<void> removeItem(const QString &version, const QString &bareJid) override;
+    QXmppTask<void> clear() override;
+
+private:
+    AccountSettings *const m_accountSettings;
+};
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 32f693c33..5866f1ee9 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -75,6 +75,12 @@ ecm_add_test(
     LINK_LIBRARIES Kaidan::Tests
 )
 
+ecm_add_test(
+    RosterStorageTest.cpp
+    TEST_NAME RosterStorageTest
+    LINK_LIBRARIES Kaidan::Tests
+)
+
 ecm_add_test(
     TrustDbTest.cpp
     TEST_NAME TrustDbTest
diff --git a/tests/RosterStorageTest.cpp b/tests/RosterStorageTest.cpp
new file mode 100644
index 000000000..1a698ee94
--- /dev/null
+++ b/tests/RosterStorageTest.cpp
@@ -0,0 +1,278 @@
+// SPDX-FileCopyrightText: 2026 Linus Jahn <[email protected]>
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// std
+#include <optional>
+// Qt
+#include <QTest>
+// QXmpp
+#include <QXmppRosterIq.h>
+// Kaidan
+#include "Account.h"
+#include "AccountDb.h"
+#include "Database.h"
+#include "Encryption.h"
+#include "GroupChatUserDb.h"
+#include "Keychain.h"
+#include "MessageDb.h"
+#include "RosterDb.h"
+#include "RosterItem.h"
+#include "RosterStorage.h"
+#include "Test.h"
+#include "TestUtils.h"
+
+static const auto accountJid = QStringLiteral("[email protected]");
+static const auto alice = QStringLiteral("[email protected]");
+static const auto bob = QStringLiteral("[email protected]");
+
+class RosterStorageTest : public Test
+{
+    Q_OBJECT
+
+public:
+    RosterStorageTest();
+
+private:
+    Q_SLOT void initTestCase() override;
+    Q_SLOT void init();
+
+    Q_SLOT void testUpsertAddsItem();
+    Q_SLOT void testUpsertPreservesConversationData();
+    Q_SLOT void testReplaceAllPreservesConversationData();
+    Q_SLOT void testRemoveItem();
+    Q_SLOT void testClear();
+    Q_SLOT void testLoadRoundTrip();
+    Q_SLOT void testWireAttributesRoundTrip();
+
+    static QXmppRosterIq::Item
+    wireItem(const QString &jid, const QString &name, QXmppRosterIq::Item::SubscriptionType subscription, const QSet<QString> &groups = {});
+    std::optional<RosterItem> fetchItem(const QString &jid);
+
+    Database db;
+    AccountDb *accountDb = nullptr;
+    MessageDb *messageDb = nullptr;
+    GroupChatUserDb *groupChatUserDb = nullptr;
+    RosterDb *rosterDb = nullptr;
+    AccountSettings *accountSettings = nullptr;
+    std::unique_ptr<RosterStorage> storage;
+};
+
+RosterStorageTest::RosterStorageTest()
+{
+    AccountSettings::Data settingsData;
+    settingsData.jid = accountJid;
+    accountSettings = new AccountSettings(settingsData, this);
+
+    accountDb = new AccountDb(this);
+    messageDb = new MessageDb(this);
+    groupChatUserDb = new GroupChatUserDb(this);
+    rosterDb = new RosterDb(this);
+
+    storage = std::make_unique<RosterStorage>(accountSettings);
+}
+
+void RosterStorageTest::initTestCase()
+{
+    Test::initTestCase();
+
+    // Allow storing the account's password without a system keychain backend.
+    QKeychainFuture::setUnencryptedFallback(true);
+
+    // An account row is required so that the roster version can be persisted in it.
+    AccountSettings::Data data;
+    data.jid = accountJid;
+    wait(accountDb->addAccount(data));
+}
+
+void RosterStorageTest::init()
+{
+    // Start each test with an empty roster and version.
+    wait(this, storage->clear());
+}
+
+QXmppRosterIq::Item
+RosterStorageTest::wireItem(const QString &jid, const QString &name, QXmppRosterIq::Item::SubscriptionType subscription, const QSet<QString> &groups)
+{
+    QXmppRosterIq::Item item;
+    item.setBareJid(jid);
+    item.setName(name);
+    item.setSubscriptionType(subscription);
+    item.setGroups(groups);
+    return item;
+}
+
+std::optional<RosterItem> RosterStorageTest::fetchItem(const QString &jid)
+{
+    const auto items = wait(RosterDb::instance()->fetchItems());
+
+    for (const auto &item : items) {
+        if (item.accountJid == accountJid && item.jid == jid) {
+            return item;
+        }
+    }
+
+    return std::nullopt;
+}
+
+void RosterStorageTest::testUpsertAddsItem()
+{
+    wait(this, storage->upsertItem(QStringLiteral("v1"), wireItem(alice, QStringLiteral("Alice"), QXmppRosterIq::Item::Both, {QStringLiteral("Friends")})));
+
+    const auto item = fetchItem(alice);
+    QVERIFY(item.has_value());
+    QCOMPARE(item->name, QStringLiteral("Alice"));
+    QCOMPARE(item->subscription, QXmppRosterIq::Item::Both);
+    QCOMPARE(item->groups, QList<QString>{QStringLiteral("Friends")});
+    // New items get the account's default encryption.
+    QCOMPARE(item->encryption, accountSettings->encryption());
+}
+
+void RosterStorageTest::testUpsertPreservesConversationData()
+{
+    wait(this, storage->upsertItem(QStringLiteral("v1"), wireItem(alice, QStringLiteral("Alice"), QXmppRosterIq::Item::Both)));
+
+    // Attach conversation data that is not part of the roster wire format.
+    wait(RosterDb::instance()->updateItem(accountJid, alice, [](RosterItem &item) {
+        item.encryption = Encryption::NoEncryption;
+        item.pinningPosition = 5;
+        item.lastReadContactMessageId = QStringLiteral("msg-42");
+        item.notificationRule = RosterItem::NotificationRule::Never;
+    }));
+
+    // A roster push that changes the wire data.
+    wait(this,
+         storage->upsertItem(QStringLiteral("v2"), wireItem(alice, QStringLiteral("Alice (renamed)"), QXmppRosterIq::Item::To, {QStringLiteral("Work")})));
+
+    const auto item = fetchItem(alice);
+    QVERIFY(item.has_value());
+
+    // Wire data is overridden.
+    QCOMPARE(item->name, QStringLiteral("Alice (renamed)"));
+    QCOMPARE(item->subscription, QXmppRosterIq::Item::To);
+    QCOMPARE(item->groups, QList<QString>{QStringLiteral("Work")});
+
+    // Conversation data is preserved.
+    QCOMPARE(item->encryption, Encryption::NoEncryption);
+    QCOMPARE(item->pinningPosition, 5);
+    QCOMPARE(item->lastReadContactMessageId, QStringLiteral("msg-42"));
+    QCOMPARE(item->notificationRule, RosterItem::NotificationRule::Never);
+}
+
+void RosterStorageTest::testReplaceAllPreservesConversationData()
+{
+    wait(this, storage->upsertItem(QStringLiteral("v1"), wireItem(alice, QStringLiteral("Alice"), QXmppRosterIq::Item::Both)));
+    wait(this, storage->upsertItem(QStringLiteral("v2"), wireItem(bob, QStringLiteral("Bob"), QXmppRosterIq::Item::Both)));
+
+    wait(RosterDb::instance()->updateItem(accountJid, alice, [](RosterItem &item) {
+        item.pinningPosition = 9;
+    }));
+
+    // A full roster snapshot: Alice is updated, Bob is gone, Carol is new.
+    const auto carol = QStringLiteral("[email protected]");
+    wait(this,
+         storage->replaceAll(QStringLiteral("v3"),
+                             {
+                                 wireItem(alice, QStringLiteral("Alice (renamed)"), QXmppRosterIq::Item::To),
+                                 wireItem(carol, QStringLiteral("Carol"), QXmppRosterIq::Item::Both),
+                             }));
+
+    const auto aliceItem = fetchItem(alice);
+    QVERIFY(aliceItem.has_value());
+    QCOMPARE(aliceItem->name, QStringLiteral("Alice (renamed)"));
+    QCOMPARE(aliceItem->subscription, QXmppRosterIq::Item::To);
+    // Conversation data of an existing item survives a full replace.
+    QCOMPARE(aliceItem->pinningPosition, 9);
+
+    QVERIFY(!fetchItem(bob).has_value());
+    QVERIFY(fetchItem(carol).has_value());
+}
+
+void RosterStorageTest::testRemoveItem()
+{
+    wait(this, storage->upsertItem(QStringLiteral("v1"), wireItem(alice, QStringLiteral("Alice"), QXmppRosterIq::Item::Both)));
+    QVERIFY(fetchItem(alice).has_value());
+
+    wait(this, storage->removeItem(QStringLiteral("v2"), alice));
+    QVERIFY(!fetchItem(alice).has_value());
+}
+
+void RosterStorageTest::testClear()
+{
+    wait(this, storage->upsertItem(QStringLiteral("v1"), wireItem(alice, QStringLiteral("Alice"), QXmppRosterIq::Item::Both)));
+    wait(this, storage->upsertItem(QStringLiteral("v2"), wireItem(bob, QStringLiteral("Bob"), QXmppRosterIq::Item::Both)));
+
+    wait(this, storage->clear());
+
+    // RosterStorage::clear() does not change the database (see the comments in its implementation).
+    // Thus, the items must stay unchanged.
+    QVERIFY(fetchItem(alice).has_value());
+    QVERIFY(fetchItem(bob).has_value());
+    QVERIFY(!wait(this, storage->load()).version.isEmpty());
+}
+
+void RosterStorageTest::testLoadRoundTrip()
+{
+    wait(this,
+         storage->replaceAll(QStringLiteral("ver-123"),
+                             {
+                                 wireItem(alice, QStringLiteral("Alice"), QXmppRosterIq::Item::Both, {QStringLiteral("Friends")}),
+                                 wireItem(bob, QStringLiteral("Bob"), QXmppRosterIq::Item::To),
+                             }));
+
+    const auto cache = wait(this, storage->load());
+    QCOMPARE(cache.version, QStringLiteral("ver-123"));
+    QCOMPARE(cache.items.size(), std::size_t(2));
+
+    QMap<QString, QXmppRosterIq::Item> itemsByJid;
+    for (const auto &item : cache.items) {
+        itemsByJid.insert(item.bareJid(), item);
+    }
+
+    QVERIFY(itemsByJid.contains(alice));
+    QCOMPARE(itemsByJid[alice].name(), QStringLiteral("Alice"));
+    QCOMPARE(itemsByJid[alice].subscriptionType(), QXmppRosterIq::Item::Both);
+    QCOMPARE(itemsByJid[alice].groups(), QSet<QString>{QStringLiteral("Friends")});
+
+    QVERIFY(itemsByJid.contains(bob));
+    QCOMPARE(itemsByJid[bob].subscriptionType(), QXmppRosterIq::Item::To);
+}
+
+void RosterStorageTest::testWireAttributesRoundTrip()
+{
+    // A contact with a pending ("ask") and pre-approved subscription.
+    QXmppRosterIq::Item contact;
+    contact.setBareJid(alice);
+    contact.setName(QStringLiteral("Alice"));
+    contact.setSubscriptionType(QXmppRosterIq::Item::From);
+    contact.setSubscriptionStatus(QStringLiteral("subscribe"));
+    contact.setIsApproved(true);
+    contact.setGroups({QStringLiteral("Friends")});
+
+    // A MIX channel.
+    const auto channelJid = QStringLiteral("[email protected]");
+    QXmppRosterIq::Item channel;
+    channel.setBareJid(channelJid);
+    channel.setIsMixChannel(true);
+    channel.setMixParticipantId(QStringLiteral("part-1"));
+
+    wait(this, storage->replaceAll(QStringLiteral("v1"), {contact, channel}));
+
+    QMap<QString, QXmppRosterIq::Item> itemsByJid;
+    for (const auto &item : wait(this, storage->load()).items) {
+        itemsByJid.insert(item.bareJid(), item);
+    }
+
+    QVERIFY(itemsByJid.contains(alice));
+    QCOMPARE(itemsByJid[alice].subscriptionType(), QXmppRosterIq::Item::From);
+    QCOMPARE(itemsByJid[alice].subscriptionStatus(), QStringLiteral("subscribe"));
+    QVERIFY(itemsByJid[alice].isApproved());
+
+    QVERIFY(itemsByJid.contains(channelJid));
+    QVERIFY(itemsByJid[channelJid].isMixChannel());
+    QCOMPARE(itemsByJid[channelJid].mixParticipantId(), QStringLiteral("part-1"));
+}
+
+QTEST_GUILESS_MAIN(RosterStorageTest)
+
+#include "RosterStorageTest.moc"
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.