git » sdk » commit bdf23de

Store full presence stanzas

author Stephen Paul Weber
2026-04-22 03:24:38 UTC
committer Stephen Paul Weber
2026-04-22 16:09:03 UTC
parent 76adb17a3cf930fd06aec2eee5a8fe0b55bc5cc0

Store full presence stanzas

And keep caps in a singleton instead of a copy per presence

Makefile +6 -2
borogove/AvailableChatIterator.hx +1 -1
borogove/Caps.hx +9 -7
borogove/CapsRepo.hx +44 -0
borogove/Chat.hx +12 -18
borogove/Client.hx +23 -21
borogove/Presence.hx +36 -9
borogove/persistence/IDB.js +5 -2
borogove/persistence/Sqlite.hx +14 -46
test/TestAll.hx +2 -0
test/TestCaps.hx +11 -0
test/TestCapsRepo.hx +89 -0
test/TestPresence.hx +45 -0

diff --git a/Makefile b/Makefile
index 326fca4..1e6a575 100644
--- a/Makefile
+++ b/Makefile
@@ -25,7 +25,8 @@ hx-build-dep:
 
 npm/borogove-browser.js:
 	haxe browserjs.hxml
-	sed -i '/;var $$hx_exports = typeof exports != "undefined" ? exports : globalThis;/{N;N;N;d;}' npm/borogove-browser.js
+	sed -i '/;var $$hx_exports = typeof exports != "undefined" ? exports : globalThis;/d' npm/borogove-browser.js
+	sed -i '/\$$hx_exports.*|| {};/d' npm/borogove-browser.js
 	sed -i 's/^$$hx_exports[^=]*=\(.*\);$$/export {\1 };/g' npm/borogove-browser.js
 	sed -i 's/"\[Symbol.asyncIterator\]"() {/[Symbol.asyncIterator]() {/g' npm/borogove-browser.js
 	cd npm && npx cjstoesm borogove-browser.js
@@ -34,13 +35,16 @@ npm/borogove-browser.js:
 	mv npm/browser-no-sqlite.js npm/borogove-browser.js
 	awk -f optional-sqlite-types.awk npm/borogove-browser.d.ts
 	mv npm/no-sqlite.d.ts npm/borogove-browser.d.ts
+	echo "export class borogove_Presence {}" >> npm/borogove-browser.d.ts
 
 npm/borogove.js:
 	haxe nodejs.hxml
-	sed -i '/;var $$hx_exports = typeof exports != "undefined" ? exports : globalThis;/{N;N;N;d;}' npm/borogove.js
+	sed -i '/;var $$hx_exports = typeof exports != "undefined" ? exports : globalThis;/d' npm/borogove.js
+	sed -i '/\$$hx_exports.*|| {};/d' npm/borogove.js
 	sed -i 's/^$$hx_exports[^=]*=\(.*\);$$/export {\1 };/g' npm/borogove.js
 	sed -i 's/"\[Symbol.asyncIterator\]"() {/[Symbol.asyncIterator]() {/g' npm/borogove.js
 	cd npm && npx cjstoesm borogove.js
+	echo "export class borogove_Presence {}" >> npm/borogove.d.ts
 
 npm: npm/borogove-browser.js npm/borogove.js borogove/persistence/IDB.js borogove/persistence/MediaStoreCache.js borogove/persistence/sqlite-worker1.mjs
 	cp borogove/persistence/IDB.js npm
diff --git a/borogove/AvailableChatIterator.hx b/borogove/AvailableChatIterator.hx
index 0ea3792..ec617b5 100644
--- a/borogove/AvailableChatIterator.hx
+++ b/borogove/AvailableChatIterator.hx
@@ -137,7 +137,7 @@ class AvailableChatIterator {
 						resolve(null);
 					}
 				} else {
-					persistence.storeCaps(resultCaps);
+					client.capsRepo.add(resultCaps);
 					final identity = resultCaps.identities[0];
 					final displayName = identity?.name ?? query;
 					final note = jid.asString() + (identity == null ? "" : " (" + identity.type + ")");
diff --git a/borogove/Caps.hx b/borogove/Caps.hx
index 121e66b..eb055f8 100644
--- a/borogove/Caps.hx
+++ b/borogove/Caps.hx
@@ -91,13 +91,15 @@ class Caps {
 			node: node,
 			ver: ver()
 		}).up();
-		stanza.tag("c", {
-			xmlns: "urn:xmpp:caps",
-		}).textTag(
-			"hash",
-			Hash.sha256(hashInput()).toBase64(),
-			{ xmlns: "urn:xmpp:hashes:2", algo: "sha-256" }
-		).up();
+		if (identities.length > 0 || features.length > 0 || data.length > 0) {
+			stanza.tag("c", {
+				xmlns: "urn:xmpp:caps",
+			}).textTag(
+				"hash",
+				Hash.sha256(hashInput()).toBase64(),
+				{ xmlns: "urn:xmpp:hashes:2", algo: "sha-256" }
+			).up();
+		}
 		return stanza;
 	}
 
diff --git a/borogove/CapsRepo.hx b/borogove/CapsRepo.hx
new file mode 100644
index 0000000..09b1493
--- /dev/null
+++ b/borogove/CapsRepo.hx
@@ -0,0 +1,44 @@
+package borogove;
+
+import thenshim.Promise;
+
+@:nullSafety(StrictThreaded)
+class CapsRepo {
+	private final persistence: Persistence;
+	private final cache: Map<String, Caps> = [];
+
+	public function new(persistence: Persistence) {
+		this.persistence = persistence;
+	}
+
+	public function add(caps: Caps) {
+		persistence.storeCaps(caps);
+		cache[caps.ver()] = caps;
+	}
+
+	public function getAsync(presence: Presence): Promise<Null<Caps>> {
+		final ver = presence.ver;
+		if (ver == null) return Promise.resolve(cast null);
+
+		final cached = cache[ver];
+		if (cached != null) return Promise.resolve(cached);
+
+		return persistence.getCaps(ver).then(result -> {
+			final caps = result;
+			if (caps != null) cache[caps.ver()] = caps;
+			return Promise.resolve(cast caps);
+		});
+	}
+
+	public function get(presence: Presence) {
+		final ver = presence.ver;
+		if (ver != null) {
+			final cached = cache[ver];
+			if (cached != null) return cached;
+
+			getAsync(presence); // Fetch and put in cache for later
+		}
+
+		return new Caps("", [], [], []);
+	}
+}
diff --git a/borogove/Chat.hx b/borogove/Chat.hx
index 8c4db7a..dd126c7 100644
--- a/borogove/Chat.hx
+++ b/borogove/Chat.hx
@@ -601,17 +601,6 @@ abstract class Chat {
 		this.presence.set(resource, presence);
 	}
 
-	@:allow(borogove)
-	private function setCaps(resource:String, caps:Caps) {
-		final presence = presence.get(resource);
-		if (presence != null) {
-			presence.caps = caps;
-			setPresence(resource, presence);
-		} else {
-			setPresence(resource, new Presence(caps, null, null));
-		}
-	}
-
 	@:allow(borogove)
 	private function removePresence(resource:String) {
 		presence.remove(resource);
@@ -624,14 +613,14 @@ abstract class Chat {
 			hasNext: iter.hasNext,
 			next: () -> {
 				final n = iter.next();
-				return { key: n.key, value: n.value.caps };
+				return { key: n.key, value: client.capsRepo.get(n.value) };
 			}
 		};
 	}
 
 	@:allow(borogove)
 	private function getResourceCaps(resource:String):Caps {
-		return presence[resource]?.caps ?? new Caps("", [], [], []);
+		return client.capsRepo.get(presence[resource]);
 	}
 
 	@:allow(borogove)
@@ -676,8 +665,8 @@ abstract class Chat {
 	**/
 	public function canAudioCall():Bool {
 #if !NO_JINGLE
-		for (resource => p in presence) {
-			if (p.caps?.features?.contains("urn:xmpp:jingle:apps:rtp:audio") ?? false) return true;
+		for (resource => caps in getCaps()) {
+			if (caps.features?.contains("urn:xmpp:jingle:apps:rtp:audio") ?? false) return true;
 		}
 #end
 		return false;
@@ -688,8 +677,8 @@ abstract class Chat {
 	**/
 	public function canVideoCall():Bool {
 #if !NO_JINGLE
-		for (resource => p in presence) {
-			if (p.caps?.features?.contains("urn:xmpp:jingle:apps:rtp:video") ?? false) return true;
+		for (resource => caps in getCaps()) {
+			if (caps.features?.contains("urn:xmpp:jingle:apps:rtp:video") ?? false) return true;
 		}
 #end
 		return false;
@@ -1635,7 +1624,7 @@ class Channel extends Chat {
 		discoGet.onFinished(() -> {
 			if (discoGet.getResult() != null) {
 				disco = discoGet.getResult();
-				persistence.storeCaps(discoGet.getResult());
+				client.capsRepo.add(discoGet.getResult());
 				persistence.storeChats(client.accountId(), [this]);
 			}
 			if (callback != null) callback();
@@ -2112,6 +2101,11 @@ class SerializedChat {
 		var filterN = notificationsFiltered ?? false;
 		var mention = notifyMention;
 
+		// Init capsRepo with all caps for this chat
+		for (resource => p in presence) {
+			if (p != null) client.capsRepo.getAsync(p);
+		}
+
 		final chat = if (klass == "DirectChat") {
 			new DirectChat(client, stream, persistence, chatId, uiState, isBookmarked, isBlocked, extensionsStanza, readUpToId, readUpToBy, omemoContactDeviceIDs);
 		} else if (klass == "Channel") {
diff --git a/borogove/Client.hx b/borogove/Client.hx
index 383e809..731d6b4 100644
--- a/borogove/Client.hx
+++ b/borogove/Client.hx
@@ -66,7 +66,9 @@ class Client extends EventEmitter {
 	private var jid(default,null):JID;
 	@:allow(borogove)
 	private var chats: Array<Chat> = [];
-	private var persistence: Persistence;
+	private final persistence: Persistence;
+	@:allow(borogove)
+	private final capsRepo: CapsRepo;
 	private final caps = new Caps(
 		"https://borogove.dev",
 		[],
@@ -134,6 +136,7 @@ class Client extends EventEmitter {
 		promiseFactory.scheduler.addNext = mainLoop.run;
 		#end
 		super();
+		capsRepo = new CapsRepo(persistence);
 		this.jid = JID.parse(accountId);
 		this._displayName = this.jid.node ?? this.jid.asString();
 		this.persistence = persistence;
@@ -337,8 +340,9 @@ class Client extends EventEmitter {
 		});
 
 		stream.on("presence", function(event) {
-			final stanza:Stanza = event.stanza;
-			final c = stanza.getChild("c", "http://jabber.org/protocol/caps");
+			final stanza: Stanza = event.stanza;
+			final presence: Presence = stanza;
+
 			if (stanza.attr.get("from") != null && stanza.attr.get("type") == null) {
 				final from = JID.parse(stanza.attr.get("from"));
 				final chat = getChat(from.asBare().asString());
@@ -347,32 +351,29 @@ class Client extends EventEmitter {
 					return EventUnhandled;
 				}
 
-				final mucUser = stanza.getChild("x", "http://jabber.org/protocol/muc#user");
-				final avatarSha1Hex = stanza.findText("{vcard-temp:x:update}x/photo#");
-				final avatarSha1 = avatarSha1Hex == null || avatarSha1Hex == "" ? null : Hash.fromHex("sha-1", avatarSha1Hex);
-
-				if (c == null) {
-					chat.setPresence(JID.parse(stanza.attr.get("from")).resource, new Presence(null, mucUser, avatarSha1));
+				if (presence.ver == null) {
+					chat.setPresence(JID.parse(stanza.attr.get("from")).resource, stanza);
 					persistence.storeChats(this.accountId(), [chat]);
 					if (chat.livePresence()) this.trigger("chats/update", [chat]);
 				} else {
 					final handleCaps = (caps) -> {
-						chat.setPresence(JID.parse(stanza.attr.get("from")).resource, new Presence(caps, mucUser, avatarSha1));
-						if (mucUser == null || chat.livePresence()) persistence.storeChats(this.accountId(), [chat]);
+						chat.setPresence(JID.parse(stanza.attr.get("from")).resource, stanza);
+						if (presence.mucUser == null || chat.livePresence()) persistence.storeChats(this.accountId(), [chat]);
 						return chat;
 					};
 
-					persistence.getCaps(c.attr.get("ver")).then((caps) -> {
+					capsRepo.getAsync(presence).then((caps) -> {
 						if (caps == null) {
-							final pending = pendingCaps.get(c.attr.get("ver"));
+							final ver = presence.ver;
+							final pending = pendingCaps.get(ver);
 							if (pending == null) {
-								pendingCaps.set(c.attr.get("ver"), [handleCaps]);
-								final discoGet = new DiscoInfoGet(stanza.attr.get("from"), c.attr.get("node") + "#" + c.attr.get("ver"));
+								pendingCaps.set(ver, [handleCaps]);
+								final discoGet = new DiscoInfoGet(stanza.attr.get("from"), presence.capsNode + "#" + ver);
 								discoGet.onFinished(() -> {
 									final chatsToUpdate: Map<String, Chat> = [];
-									final handlers = pendingCaps.get(c.attr.get("ver")) ?? [];
-									pendingCaps.remove(c.attr.get("ver"));
-									if (discoGet.getResult() != null) persistence.storeCaps(discoGet.getResult());
+									final handlers = pendingCaps.get(ver) ?? [];
+									pendingCaps.remove(ver);
+									if (discoGet.getResult() != null) capsRepo.add(discoGet.getResult());
 									for (handler in handlers) {
 										final c = handler(discoGet.getResult());
 										if (c.livePresence()) chatsToUpdate.set(c.chatId, c);
@@ -389,7 +390,8 @@ class Client extends EventEmitter {
 					});
 				}
 				final channel = Std.downcast(chat, Channel);
-				if (channel != null && avatarSha1 != null && brokenAvatars[avatarSha1Hex] == null) {
+				final avatarSha1 = presence.avatarHash;
+				if (channel != null && avatarSha1 != null && brokenAvatars[avatarSha1.toHex()] == null) {
 					if (from.isBare()) {
 						chat.setAvatarSha1(avatarSha1.hash);
 						persistence.storeChats(this.accountId(), [chat]);
@@ -402,7 +404,7 @@ class Client extends EventEmitter {
 							vcardGet.onFinished(() -> {
 								final vcard = vcardGet.getResult();
 								if (vcard.photo == null) {
-									brokenAvatars[avatarSha1Hex] = from;
+									brokenAvatars[avatarSha1.toHex()] = from;
 									return;
 								}
 								persistence.storeMedia(vcard.photo.mime, vcard.photo.data.getData()).then(_ -> {
@@ -1662,7 +1664,7 @@ class Client extends EventEmitter {
 					this.trigger("chats/update", [chat]);
 				}
 			} else {
-				persistence.storeCaps(resultCaps);
+				capsRepo.add(resultCaps);
 				if (resultCaps.isChannel(jid)) {
 					final chat = new Channel(this, this.stream, this.persistence, jid, uiState, false, false, null, resultCaps);
 					chat.setupNotifications();
diff --git a/borogove/Presence.hx b/borogove/Presence.hx
index 987e916..05dde65 100644
--- a/borogove/Presence.hx
+++ b/borogove/Presence.hx
@@ -3,15 +3,42 @@ package borogove;
 import borogove.MucUser;
 import borogove.Hash;
 
+@:nullSafety(StrictThreaded)
+@:forward(toString)
 @:expose
-class Presence {
-	public var caps:Null<Caps>;
-	public final mucUser:Null<MucUser>;
-	public final avatarHash:Null<Hash>;
-
-	public function new(caps: Null<Caps>, mucUser: Null<MucUser>, avatarHash: Null<Hash>) {
-		this.caps = caps;
-		this.mucUser = mucUser;
-		this.avatarHash = avatarHash;
+abstract Presence(Stanza) from Stanza to Stanza {
+	public var capsNode(get, never): Null<String>;
+	public var ver(get, never): Null<String>;
+	public var mucUser(get, never): Null<MucUser>;
+	public var avatarHash(get, never): Null<Hash>;
+
+	public function new(caps: Null<Caps>, mucUser: Null<MucUser>, avatarHash: Null<Hash>): Presence {
+		final stanza = new Stanza("presence", { xmlns: "jabber:client" });
+		if (caps != null) caps.addC(stanza);
+		if (mucUser != null) stanza.addChild(mucUser);
+		if (avatarHash != null) {
+			stanza.tag("x", { xmlns: "vcard-temp:x:update" }).textTag("photo", avatarHash.toHex()).up();
+		}
+
+		this = stanza;
+	}
+
+	private inline function get_capsNode() {
+		final c = this.getChild("c", "http://jabber.org/protocol/caps");
+		return c?.attr?.get("node");
+	}
+
+	private inline function get_ver() {
+		final c = this.getChild("c", "http://jabber.org/protocol/caps");
+		return c?.attr?.get("ver");
+	}
+
+	private inline function get_mucUser() {
+		return this.getChild("x", "http://jabber.org/protocol/muc#user");
+	}
+
+	private inline function get_avatarHash() {
+		final avatarSha1Hex = this.findText("{vcard-temp:x:update}x/photo#");
+		return avatarSha1Hex == null || avatarSha1Hex == "" ? null : Hash.fromHex("sha-1", avatarSha1Hex);
 	}
 }
diff --git a/borogove/persistence/IDB.js b/borogove/persistence/IDB.js
index f176f22..3601a06 100644
--- a/borogove/persistence/IDB.js
+++ b/borogove/persistence/IDB.js
@@ -444,7 +444,7 @@ export default async (dbname, media, tokenize, stemmer) => {
 					trusted: chat.trusted,
 					isBookmarked: chat.isBookmarked,
 					avatarSha1: chat.avatarSha1,
-					presence: new Map([...chat.presence.entries()].map(([k, p]) => [k, { caps: p.caps?.ver(), mucUser: p.mucUser?.toString(), avatarHash: p.avatarHash?.serializeUri() }])),
+					presence: new Map([...chat.presence.entries()].map(([k, p]) => [k, p.toString()])),
 					displayName: chat.displayName,
 					uiState: chat.uiState,
 					isBlocked: chat.isBlocked,
@@ -470,7 +470,10 @@ export default async (dbname, media, tokenize, stemmer) => {
 				r.isBookmarked,
 				r.avatarSha1,
 				new Map(await Promise.all((r.presence instanceof Map ? [...r.presence.entries()] : Object.entries(r.presence)).map(
-					async ([k, p]) => [k, new borogove_Presence(p.caps && await this.getCaps(p.caps), p.mucUser && borogove_Stanza.parse(p.mucUser), p.avatarHash && borogove_Hash.fromUri(p.avatarHash))]
+					async ([k, p]) => [k,
+						typeof(p) === "string" ? borogove_Stanza.parse(p) :
+						borogove_Presence._new(p.caps && await this.getCaps(p.caps), p.mucUser && borogove_Stanza.parse(p.mucUser), p.avatarHash && borogove_Hash.fromUri(p.avatarHash))
+					]
 				))),
 				r.displayName,
 				r.uiState,
diff --git a/borogove/persistence/Sqlite.hx b/borogove/persistence/Sqlite.hx
index 9214bb5..520f524 100644
--- a/borogove/persistence/Sqlite.hx
+++ b/borogove/persistence/Sqlite.hx
@@ -275,22 +275,10 @@ class Sqlite implements Persistence implements KeyValueStore {
 
 		storeChatTimer = haxe.Timer.delay(() -> {
 			final mapPresence = (chat: Chat) -> {
-				final storePresence: DynamicAccess<{ ?caps: String, ?mucUser: String, ?avatarHash: String }> = {};
-				final caps: Map<BytesData, Caps> = [];
+				final storePresence: DynamicAccess<String> = {};
 				for (resource => presence in chat.presence) {
-					if (storePresence[resource ?? ""] == null) storePresence[resource ?? ""] = {};
-					if (presence.caps != null) {
-						caps[presence.caps.verRaw().hash] = presence.caps;
-						storePresence[resource ?? ""].caps = presence.caps.ver();
-					}
-					if (presence.mucUser != null) {
-						storePresence[resource ?? ""].mucUser = presence.mucUser.toString();
-					}
-					if (presence.avatarHash != null) {
-						storePresence[resource ?? ""].avatarHash = presence.avatarHash.serializeUri();
-					}
+					if (storePresence[resource ?? ""] == null) storePresence[resource ?? ""] = presence.toString();
 				}
-				storeCapsSet(caps);
 				return storePresence;
 			};
 			final q = new StringBuf();
@@ -329,44 +317,24 @@ class Sqlite implements Persistence implements KeyValueStore {
 			"SELECT chat_id, trusted, bookmarked, avatar_sha1, fn, ui_state, blocked, extensions, read_up_to_id, read_up_to_by, notifications_filtered, notify_mention, notify_reply, json(caps) AS caps, caps_ver, json(presence) AS presence, class FROM chats LEFT JOIN caps ON chats.caps_ver=caps.sha1 WHERE account_id=?",
 			[accountId]
 		).then(result -> {
-			final fetchCaps: Map<BytesData, Bool> = [];
-			final chats: Array<Dynamic> = [];
+			final chats: Array<SerializedChat> = [];
 			for (row in result) {
 				final capsJson = row.caps == null ? null : Json.parse(row.caps);
 				row.capsObj = capsJson == null ? null : hydrateCaps(capsJson, row.caps_ver);
 				final presenceJson: DynamicAccess<Dynamic> = Json.parse(row.presence);
-				row.presenceJson = presenceJson;
-				for (resource => presence in presenceJson) {
-					if (presence.caps != null) fetchCaps[Base64.decode(presence.caps).getData()] = true;
-				}
-				chats.push(row);
-			}
-			final fetchCapsSha1s = { iterator: () -> fetchCaps.keys() }.array();
-			return db.exec(
-				"SELECT sha1, json(caps) AS caps FROM caps WHERE sha1 IN (" + fetchCapsSha1s.map(_ -> "?").join(",") + ")",
-				fetchCapsSha1s
-			).then(capsResult -> { chats: chats, caps: capsResult });
-		}).then(result -> {
-			final capsMap: Map<String, Caps> = [];
-			for (row in result.caps) {
-				final json = Json.parse(row.caps);
-				capsMap[Base64.encode(Bytes.ofData(row.sha1))] = hydrateCaps(json, row.sha1);
-			}
-			result.caps = null;
-			final chats = [];
-			var row = null;
-			while ((row = result.chats.pop()) != null) {
 				final presenceMap: Map<String, Presence> = [];
-				final presenceJson: DynamicAccess<Dynamic> = row.presenceJson;
-				for (resource in presenceJson.keys()) {
-					final presence = presenceJson.get(resource);
-					presenceJson.remove(resource);
-					presenceMap[resource] = new Presence(
-						presence.caps == null ? null : capsMap[presence.caps],
-						presence.mucUser == null || Config.constrainedMemoryMode ? null : Stanza.parse(presence.mucUser),
-						presence.avatarHash == null ? null : Hash.fromUri(presence.avatarHash)
-					);
+				for (resource => presence in presenceJson) {
+					if (Std.isOfType(presence, String)) {
+						presenceMap[resource] = Stanza.parse(presence);
+					} else {
+						presenceMap[resource] = new Presence(
+							presence.caps == null ? null : new Caps("", [], [], [], Base64.decode(presence.caps).getData()),
+							presence.mucUser == null ? null : Stanza.parse(presence.mucUser),
+							presence.avatarHash == null ? null : Hash.fromUri(presence.avatarHash)
+						);
+					}
 				}
+
 				// FIXME: Empty OMEMO contact device ids hardcoded in next line
 				chats.push(new SerializedChat(row.chat_id, row.trusted != 0, row.bookmarked != 0, row.avatar_sha1, presenceMap, row.fn, row.ui_state, row.blocked != 0, row.extensions, row.read_up_to_id, row.read_up_to_by, row.notifications_filtered == null ? null : row.notifications_filtered != 0, row.notify_mention != 0, row.notify_reply != 0, row.capsObj, [], Reflect.field(row, "class")));
 			}
diff --git a/test/TestAll.hx b/test/TestAll.hx
index e65fc0c..af8f144 100644
--- a/test/TestAll.hx
+++ b/test/TestAll.hx
@@ -6,6 +6,8 @@ import utest.ui.Report;
 class TestAll {
 	public static function main() {
 		utest.UTest.run([
+			new TestPresence(),
+			new TestCapsRepo(),
 			new TestChatMessage(),
 			new TestSessionDescription(),
 			new TestChatMessageBuilder(),
diff --git a/test/TestCaps.hx b/test/TestCaps.hx
index bcd3901..f9291ac 100644
--- a/test/TestCaps.hx
+++ b/test/TestCaps.hx
@@ -2,6 +2,7 @@ package test;
 
 import utest.Assert;
 import utest.Async;
+
 import borogove.Caps;
 import borogove.Hash;
 import borogove.Stanza;
@@ -109,4 +110,14 @@ class TestCaps extends utest.Test {
 			s.toString()
 		);
 	}
+
+	public function testAddCEmpty() {
+		final s = new Stanza("presence");
+		final emptyCaps = new Caps("node1", [], [], []);
+		emptyCaps.addC(s);
+		Assert.equals(
+			'<presence><c xmlns="http://jabber.org/protocol/caps" ver="2jmj7l5rSw0yVb/vlWAYkK/YBwk=" node="node1" hash="sha-1"/></presence>',
+			s.toString()
+		);
+	}
 }
diff --git a/test/TestCapsRepo.hx b/test/TestCapsRepo.hx
new file mode 100644
index 0000000..ffe083f
--- /dev/null
+++ b/test/TestCapsRepo.hx
@@ -0,0 +1,89 @@
+package test;
+
+import utest.Assert;
+import utest.Async;
+import thenshim.Promise;
+import borogove.Caps;
+import borogove.CapsRepo;
+import borogove.Presence;
+import borogove.Stanza;
+import borogove.persistence.Dummy;
+
+class CapsRepoMockPersistence extends Dummy {
+	public var storedCaps: Array<Caps> = [];
+	public var capsMap: Map<String, Caps> = [];
+
+	public function new() {
+		super();
+	}
+
+	override public function storeCaps(caps: Caps) {
+		storedCaps.push(caps);
+		capsMap[caps.ver()] = caps;
+	}
+
+	override public function getCaps(ver: String): Promise<Caps> {
+		return Promise.resolve(capsMap[ver]);
+	}
+}
+
+class TestCapsRepo extends utest.Test {
+	public function testAddAndGet(async: Async) {
+		final persistence = new CapsRepoMockPersistence();
+		final repo = new CapsRepo(persistence);
+		final caps = new Caps("node1", [], ["feat1"], []);
+		final ver = caps.ver();
+
+		repo.add(caps);
+		Assert.equals(1, persistence.storedCaps.length);
+		Assert.equals(caps, persistence.storedCaps[0]);
+
+		final presence = Stanza.parse('<presence><c xmlns="http://jabber.org/protocol/caps" node="node1" ver="$ver"/></presence>');
+
+		repo.getAsync(presence).then(retrieved -> {
+			Assert.equals(caps, retrieved);
+
+			// Should be cached now, no second call to persistence
+			persistence.capsMap.remove(ver);
+			return repo.getAsync(presence);
+		}).then(retrieved -> {
+			Assert.equals(caps, retrieved);
+			async.done();
+		});
+	}
+
+	public function testGetSync() {
+		final persistence = new CapsRepoMockPersistence();
+		final repo = new CapsRepo(persistence);
+		final caps = new Caps("node1", [], ["feat1"], []);
+		final ver = caps.ver();
+
+		repo.add(caps);
+
+		final presence = Stanza.parse('<presence><c xmlns="http://jabber.org/protocol/caps" node="node1" ver="$ver"/></presence>');
+		final retrieved = repo.get(presence);
+		Assert.equals(caps, retrieved);
+	}
+
+	public function testGetSyncNotCached(async: Async) {
+		final persistence = new CapsRepoMockPersistence();
+		final repo = new CapsRepo(persistence);
+		final caps = new Caps("node1", [], ["feat1"], []);
+		final ver = caps.ver();
+
+		// Not adding to repo, but adding to persistence
+		persistence.storeCaps(caps);
+
+		final presence = Stanza.parse('<presence><c xmlns="http://jabber.org/protocol/caps" node="node1" ver="$ver"/></presence>');
+		final retrieved = repo.get(presence);
+		// Should return empty caps initially
+		Assert.equals("", retrieved.node);
+
+		// But it should have triggered an async fetch, so it should be in cache soon
+		haxe.Timer.delay(() -> {
+			final retrieved2 = repo.get(presence);
+			Assert.equals(caps.node, retrieved2.node);
+			async.done();
+		}, 1);
+	}
+}
diff --git a/test/TestPresence.hx b/test/TestPresence.hx
new file mode 100644
index 0000000..b1c8b1e
--- /dev/null
+++ b/test/TestPresence.hx
@@ -0,0 +1,45 @@
+package test;
+
+import utest.Assert;
+
+import borogove.Presence;
+import borogove.Stanza;
+import borogove.Caps;
+import borogove.Hash;
+import borogove.MucUser;
+
+class TestPresence extends utest.Test {
+	public function testFromStanza() {
+		final stanza = Stanza.parse('<presence from="user@example.com/res">
+			<c xmlns="http://jabber.org/protocol/caps" node="http://example.com" ver="12345"/>
+			<x xmlns="http://jabber.org/protocol/muc#user"><item affiliation="member" role="participant"/></x>
+			<x xmlns="vcard-temp:x:update"><photo>deadbeef</photo></x>
+		</presence>');
+		final presence: Presence = stanza;
+
+		Assert.equals("http://example.com", presence.capsNode);
+		Assert.equals("12345", presence.ver);
+		Assert.notNull(presence.mucUser);
+		Assert.equals("deadbeef", presence.avatarHash.toHex());
+	}
+
+	public function testNew() {
+		final caps = new Caps("http://example.com", [], [], []);
+		final presence = new Presence(caps, null, Hash.fromHex("sha-1", "deadbeef"));
+
+		Assert.equals("http://example.com", presence.capsNode);
+		Assert.equals(caps.ver(), presence.ver);
+		Assert.isNull(presence.mucUser);
+		Assert.equals("deadbeef", presence.avatarHash.toHex());
+
+		Assert.stringContains('xmlns="vcard-temp:x:update"', presence.toString());
+		Assert.stringContains("<photo>deadbeef</photo>", presence.toString());
+	}
+
+	public function testNoCaps() {
+		final stanza = Stanza.parse('<presence/>');
+		final presence: Presence = stanza;
+		Assert.isNull(presence.capsNode);
+		Assert.isNull(presence.ver);
+	}
+}