| author | Stephen Paul Weber
<singpolyma@singpolyma.net> 2026-04-22 03:24:38 UTC |
| committer | Stephen Paul Weber
<singpolyma@singpolyma.net> 2026-04-22 16:09:03 UTC |
| parent | 76adb17a3cf930fd06aec2eee5a8fe0b55bc5cc0 |
| 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); + } +}