| author | Stephen Paul Weber
<singpolyma@singpolyma.net> 2026-05-05 04:11:25 UTC |
| committer | Stephen Paul Weber
<singpolyma@singpolyma.net> 2026-05-05 04:15:43 UTC |
| parent | 56ee845da7f5b211ca0713609ef25751ea5f3217 |
| .gitignore | +1 | -0 |
| Makefile | +5 | -1 |
| borogove/Util.hx | +22 | -0 |
| borogove/XEP0393.hx | +1 | -1 |
| borogove/persistence/SqliteDriver.js.hx | +93 | -3 |
| nodejs.hxml | +2 | -1 |
| test/TestAll.hx | +9 | -4 |
| test/TestChatMessageBuilder.hx | +2 | -2 |
| test/TestSessionDescription.hx | +1 | -1 |
| test/TestSqlite.hx | +847 | -0 |
| testjs.hxml | +26 | -0 |
diff --git a/.gitignore b/.gitignore index f10dcde..c6137e0 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ haxedoc.xml cpp venv docs/js/borogove*.md +.cache/ # Playwright /test-results/ diff --git a/Makefile b/Makefile index 5dbc206..a5f0e6f 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,16 @@ HAXE_PATH=$$HOME/Software/haxe-4.3.1/hxnodejs/12,1,0/src -.PHONY: all test doc hx-build-dep cpp/libborogove.dso npm/borogove-browser.js npm/borogove.js cpp playwright +.PHONY: all test doc hx-build-dep cpp/libborogove.dso npm/borogove-browser.js npm/borogove.js cpp playwright ci all: npm libborogove.batteriesincluded.so libborogove.so libborogove.a test: haxe test.hxml +ci: test playwright + mkdir .cache + haxe testjs.hxml + hx-build-dep: haxelib --quiet git jsImport https://github.com/back2dos/jsImport haxelib --quiet install datetime diff --git a/borogove/Util.hx b/borogove/Util.hx index 26cd50b..b3b1bea 100644 --- a/borogove/Util.hx +++ b/borogove/Util.hx @@ -7,6 +7,28 @@ import js.html.TextEncoder; final textEncoder = new TextEncoder(); #end +#if macro +import haxe.macro.Compiler; +import haxe.macro.Context.*; + +using haxe.io.Path; +using StringTools; +using sys.io.File; +using sys.FileSystem; + +class DummyRequireMacro { + static final META = ':dummyRequire'; + static function init() { + onGenerate(types -> { + var tmp = Compiler.getOutput().directory() + '/tmp${Std.random(1 << 29)}.js'; + tmp.saveContent("if (typeof(require) === 'undefined') globalThis.require = (){};"); + Compiler.includeFile(tmp); + onAfterGenerate(tmp.deleteFile); + }); + } +} +#end + function setupTrace() { #if js haxe.Log.trace = (v, ?infos) -> { diff --git a/borogove/XEP0393.hx b/borogove/XEP0393.hx index 3a7f742..a972e99 100644 --- a/borogove/XEP0393.hx +++ b/borogove/XEP0393.hx @@ -123,7 +123,7 @@ class XEP0393 { } if (xhtml.name == "blockquote") { - return ~/^/gm.replace(s.toString(), "> ") + "\n"; + return ~/^|(?<=\n)(?!$)/g.replace(s.toString(), "> ") + "\n"; } return s.toString(); diff --git a/borogove/persistence/SqliteDriver.js.hx b/borogove/persistence/SqliteDriver.js.hx index 58530bf..4dd192f 100644 --- a/borogove/persistence/SqliteDriver.js.hx +++ b/borogove/persistence/SqliteDriver.js.hx @@ -2,17 +2,107 @@ package borogove.persistence; import haxe.io.Bytes; import thenshim.Promise; +using StringTools; typedef Promiser = (String, Dynamic) -> Promise<Dynamic>; #if nodejs +@:js.import("node:worker_threads", "Worker") +extern class Worker { + public function new(code: String, options: { eval: Bool, workerData: Any }); + public function postMessage(data: Any): Void; + public function on(event: String, handler: (Dynamic) -> Void): Void; +} + class SqliteDriver { + static inline final WORKER = ' + import { workerData, parentPort } from "node:worker_threads"; + import { DatabaseSync } from "node:sqlite"; + const db = new DatabaseSync(workerData.dbfile); + + if (workerData.writer) { + db.exec("PRAGMA journal_mode=WAL"); + db.exec("PRAGMA synchronous=NORMAL"); + db.exec("PRAGMA temp_store=2"); + } + + parentPort.on("message", ({ id, qs }) => { + const lastQ = qs.pop(); + if (qs.length > 0) db.exec("BEGIN TRANSACTION"); + try { + for (const q of qs) { + db.exec(q); + } + parentPort.postMessage({ id, result: db.prepare(lastQ).all() }); + if (qs.length > 0) db.exec("COMMIT"); + } catch (error) { + if (qs.length > 0) db.exec("ROLLBACK"); + parentPort.postMessage({ id, error }); + } + }); + '; + private final writePool: Array<Worker> = []; + private var readPool: Array<Worker> = []; + private final pending: Map<Int, { resolve: (Array<Dynamic>) -> Void, reject: (Any) -> Void }> = new Map(); + private final ready: Promise<Bool>; + private var setReady: (Bool)->Void; + private var reqId = 0; + + private function mkWorker(dbfile: String, writer: Bool) { + final worker = new Worker(WORKER, { eval: true, workerData: { dbfile: dbfile, writer: writer } }); + worker.on("message", (data: { id: Int, ?result: Array<Dynamic>, ?error: Any}) -> { + if (data.error != null) { + pending[data.id].reject(data.error); + } else { + pending[data.id].resolve(data.result); + } + pending.remove(data.id); + }); + return worker; + } + public function new(dbfile: String, migrate: (Array<String>->Promise<haxe.iterators.ArrayIterator<Dynamic>>)->Promise<Any>) { - throw "TODO"; + ready = new Promise((resolve, reject) -> setReady = resolve); + + writePool.push(mkWorker(dbfile, true)); + if (~/:memory:|mode=memory/.match(dbfile)) { + readPool = writePool; + } else { + for (i in 0...10) { + readPool.push(mkWorker(dbfile, false)); + } + } + + migrate((sql) -> this.execute(writePool, sql.map(q -> { sql: q, params: [] }))).then(_ -> { + setReady(true); + }); + } + + private function execute(pool: Array<Worker>, qs: Array<{ sql: String, ?params: Array<Dynamic> }>): Promise<haxe.iterators.ArrayIterator<Dynamic>> { + final worker = pool.pop(); + if (worker == null) { + return new Promise((resolve, reject) -> haxe.Timer.delay(() -> resolve(null), 10)).then(_ -> + execute(pool, qs) + ); + } + final id = reqId++; + final promise = new Promise((resolve, reject) -> pending[id] = { resolve: resolve, reject: reject }); + worker.postMessage({ id: id, qs: qs.map(q -> Sqlite.prepare(q)) }); + return promise.then(result -> { + pool.push(worker); + return result.iterator(); + }); + } + + public function execMany(qs: Array<{ sql: String, ?params: Array<Dynamic> }>): Promise<haxe.iterators.ArrayIterator<Dynamic>> { + return ready.then(_ -> { + final pool = StringTools.startsWith(qs[0].sql, "SELECT") ? readPool : writePool; + return execute(pool, qs); + }); } - public function exec(sql: haxe.extern.EitherType<String, Array<String>>, ?params: Array<Dynamic>): Promise<haxe.iterators.ArrayIterator<Dynamic>> { - throw "TODO"; + public function exec(sql: String, ?params: Array<Dynamic>) { + return execMany([{ sql: sql, params: params }]); } } #else diff --git a/nodejs.hxml b/nodejs.hxml index e9108ee..058d4bc 100644 --- a/nodejs.hxml +++ b/nodejs.hxml @@ -1,5 +1,6 @@ --library HtmlParser --library datetime +--library fractional-indexing --library haxe-strings --library hsluv --library hxnodejs @@ -8,7 +9,6 @@ --library thenshim --library tink_http --library uuidv7 ---library fractional-indexing borogove.Client borogove.Register @@ -17,6 +17,7 @@ borogove.Version borogove.persistence.Sqlite borogove.Html +--macro borogove.Util.DummyRequireMacro.init() -D analyzer-optimize -D js-es=6 -D hxtsdgen_enums_ts diff --git a/test/TestAll.hx b/test/TestAll.hx index 6c8dc1f..64a413c 100644 --- a/test/TestAll.hx +++ b/test/TestAll.hx @@ -6,25 +6,30 @@ import utest.ui.Report; class TestAll { public static function main() { utest.UTest.run([ - new TestPresence(), new TestCapsRepo(), new TestChatMessage(), new TestSessionDescription(), new TestChatMessageBuilder(), new TestStanza(), +#if !nodejs new TestCaps(), + new TestPresence(), new TestClient(), + new TestSortId(), + new TestParticipant(), + new TestChat(), +#end new TestXEP0393(), new TestEmojiUtil(), new TestJID(), new TestStringUtil(), new TestUtil(), new TestReaction(), - new TestSortId(), new TestHtml(), - new TestChat(), new TestStatus(), - new TestParticipant(), +#if !eval + new TestSqlite(), +#end ]); } } diff --git a/test/TestChatMessageBuilder.hx b/test/TestChatMessageBuilder.hx index 17a3777..c4c57c9 100644 --- a/test/TestChatMessageBuilder.hx +++ b/test/TestChatMessageBuilder.hx @@ -20,9 +20,9 @@ class TestChatMessageBuilder extends utest.Test { public function testConvertHtmlToText() { final msg = new ChatMessageBuilder(); - msg.setBody(Html.fromString("<blockquote>Hello<br>you</blockquote><img alt=':boop:'><br><b>hi</b> <em>hi</em> <s>hey</s> <tt>up</tt><pre>hello<br>you")); + msg.setBody(Html.fromString("<blockquote>Hello<br><br>you</blockquote><img alt=':boop:'><br><b>hi</b> <em>hi</em> <s>hey</s> <tt>up</tt><pre>hello<br>you")); Assert.equals( - "> Hello\n> you\n\n:boop:\n*hi* _hi_ ~hey~ `up`\n```\nhello\nyou\n```", + "> Hello\n> \n> you\n\n:boop:\n*hi* _hi_ ~hey~ `up`\n```\nhello\nyou\n```", msg.text ); } diff --git a/test/TestSessionDescription.hx b/test/TestSessionDescription.hx index fdc327a..9cc0bd8 100644 --- a/test/TestSessionDescription.hx +++ b/test/TestSessionDescription.hx @@ -87,7 +87,7 @@ class TestSessionDescription extends utest.Test { "a=ssrc:691851057 cname:{df71a836-615d-4bab-bf3e-7f2ee9d2f0a1}\r\n"; public function testConvertStanzaToSDP() { - final session = SessionDescription.fromStanza(Stanza.fromXml(Xml.parse(stanzaSource)), false); + final session = SessionDescription.fromStanza(Stanza.parse(stanzaSource), false); Assert.equals(sdpExample, session.toSdp()); } diff --git a/test/TestSqlite.hx b/test/TestSqlite.hx new file mode 100644 index 0000000..5853e4e --- /dev/null +++ b/test/TestSqlite.hx @@ -0,0 +1,847 @@ +package test; + +import haxe.io.Bytes; +import haxe.io.BytesData; +import thenshim.Promise; +import thenshim.PromiseTools; +import utest.Assert; +import utest.Async; + +import borogove.persistence.Sqlite; +import borogove.persistence.MediaStore; +import borogove.persistence.KeyValueStore; +import borogove.ChatMessageBuilder; +import borogove.JID; +import borogove.ID; +import borogove.Message; +import borogove.Chat; +import borogove.Status; +import borogove.Reaction; +import borogove.ReactionUpdate; +import borogove.Html; +import borogove.Hash; + +using Lambda; +using thenshim.PromiseTools; + +@:access(borogove) +class MockMediaStore implements MediaStore { + private var kv: Null<KeyValueStore> = null; + + public function new() { } + + @:allow(borogove) + private function setKV(kv: KeyValueStore) { + this.kv = kv; + } + + public function getMediaPath(uri: String): Promise<Null<String>> { + final hash = Hash.fromUri(uri); + if (hash.algorithm == "sha-256") { + return kv.get(hash.serializeUri()).then(v -> + Promise.resolve(v == null ? null : hash.serializeUri()) + ); + } else { + return kv.get(hash.serializeUri()).then(sha256uri -> { + final sha256 = sha256uri == null ? null : Hash.fromUri(sha256uri); + if (sha256 == null) { + return Promise.resolve(null); + } else { + return getMediaPath(sha256.toUri()); + } + }); + } + } + + public function hasMedia(hashAlgorithm:String, hash:BytesData): Promise<Bool> { + final hash = new Hash(hashAlgorithm, hash); + return getMediaPath(hash.toUri()).then(path -> path != null); + } + + public function removeMedia(hashAlgorithm: String, hash: BytesData) { + final hash = new Hash(hashAlgorithm, hash); + getMediaPath(hash.toUri()).then(p -> kv.set(p, null)); + } + + public function storeMedia(mime: String, bd: BytesData): Promise<Bool> { + final bytes = Bytes.ofData(bd); + final sha1 = Hash.sha1(bytes); + final sha256 = Hash.sha256(bytes); + return thenshim.PromiseTools.all([ + kv.set(sha1.serializeUri(), sha256.serializeUri()), + kv.set(sha256.serializeUri(), mime) + ]).then(_ -> true); + } +} + +@:access(borogove) +class TestSqlite extends utest.Test { + var persistence: Sqlite; + var mediaStore: MockMediaStore; + + public function setup() { + mediaStore = new MockMediaStore(); + persistence = new Sqlite("file:" + ID.unique() + "?mode=memory&cache=shared", mediaStore); + } + + public function testOrder(async: Async) { + final account = "alice@example.com"; + final builder = new ChatMessageBuilder({ + serverId: "1", + serverIdBy: "alice@example.com", + senderId: "hatter@example.com", + direction: MessageReceived, + }); + builder.sortId = "a0"; + builder.to = JID.parse("alice@example.com"); + builder.from = JID.parse("hatter@example.com"); + builder.recipients = [builder.to]; + builder.replyTo = [builder.from]; + + final builder2 = new ChatMessageBuilder({ + serverId: "2", + serverIdBy: "alice@example.com", + senderId: "hatter@example.com", + direction: MessageReceived, + }); + builder2.sortId = "b0"; + builder2.to = JID.parse("alice@example.com"); + builder2.from = JID.parse("hatter@example.com"); + builder2.recipients = [builder2.to]; + builder2.replyTo = [builder2.from]; + + persistence.storeMessages(account, [ + builder2.build(), + builder.build(), + ]).then(_ -> { + return persistence.getMessagesBefore(account, "hatter@example.com", null); + }).then(result -> { + Assert.equals(2, result.length); + Assert.equals("1", result[0].serverId); + Assert.equals("2", result[1].serverId); + async.done(); + }).catchError(e -> { + Assert.fail(Std.string(e)); + async.done(); + }); + } + + public function testMessagesBefore(async: Async) { + final account = "alice@example.com"; + final builder = new ChatMessageBuilder({ + serverId: "1", + serverIdBy: "teaparty@example.com", + senderId: "teaparty@example.com/hatter", + direction: MessageReceived, + type: MessageChannel, + timestamp: "2020-01-01T00:00:01Z", + }); + builder.sortId = "a0"; + builder.to = JID.parse("alice@example.com"); + builder.from = JID.parse("teaparty@example.com/hatter"); + builder.replyTo = [builder.from.asBare()]; + + final builder2 = new ChatMessageBuilder({ + serverId: "2", + serverIdBy: "teaparty@example.com", + senderId: "teaparty@example.com/hatter", + direction: MessageReceived, + type: MessageChannel, + timestamp: "2020-01-01T00:00:00Z", + }); + builder2.sortId = "b0"; + builder2.to = JID.parse("alice@example.com"); + builder2.from = JID.parse("teaparty@example.com/hatter"); + builder2.replyTo = [builder2.from.asBare()]; + + final builder3 = new ChatMessageBuilder({ + serverId: "3", + serverIdBy: "alice@example.com", + senderId: "teaparty@example.com/hatter", + direction: MessageReceived, + type: MessageChannelPrivate, + timestamp: "2020-01-01T00:00:03Z", + }); + builder3.sortId = "a0"; + builder3.to = JID.parse("alice@example.com"); + builder3.from = JID.parse("teaparty@example.com/hatter"); + builder3.replyTo = [builder3.from.asBare()]; + + persistence.storeMessages(account, [ + builder2.build(), + builder3.build(), + builder.build(), + ]).then(_ -> { + return persistence.getMessagesBefore(account, "teaparty@example.com", null); + }).then(result -> { + Assert.equals(3, result.length); + Assert.equals("1", result[0].serverId); + Assert.equals("2", result[1].serverId); + Assert.equals("3", result[2].serverId); + async.done(); + }).catchError(e -> { + Assert.fail(Std.string(e)); + async.done(); + }); + } + + public function testMessagesBeforePoint(async: Async) { + final account = "alice@example.com"; + final builder = new ChatMessageBuilder({ + serverId: "1", + serverIdBy: "teaparty@example.com", + senderId: "teaparty@example.com/hatter", + direction: MessageReceived, + type: MessageChannel, + timestamp: "2020-01-01T00:00:01Z", + }); + builder.sortId = "a0"; + builder.to = JID.parse("alice@example.com"); + builder.from = JID.parse("teaparty@example.com/hatter"); + builder.replyTo = [builder.from.asBare()]; + + final builder2 = new ChatMessageBuilder({ + serverId: "2", + serverIdBy: "teaparty@example.com", + senderId: "teaparty@example.com/hatter", + direction: MessageReceived, + type: MessageChannel, + timestamp: "2020-01-01T00:00:00Z", + }); + builder2.sortId = "b0"; + builder2.to = JID.parse("alice@example.com"); + builder2.from = JID.parse("teaparty@example.com/hatter"); + builder2.replyTo = [builder2.from.asBare()]; + + final builder3 = new ChatMessageBuilder({ + serverId: "3", + serverIdBy: "alice@example.com", + senderId: "teaparty@example.com/hatter", + direction: MessageReceived, + type: MessageChannelPrivate, + timestamp: "2020-01-01T00:00:03Z", + }); + builder3.sortId = "Z~"; + builder3.to = JID.parse("alice@example.com"); + builder3.from = JID.parse("teaparty@example.com/hatter"); + builder3.replyTo = [builder3.from.asBare()]; + + final builder4 = new ChatMessageBuilder({ + serverId: "4", + serverIdBy: "teaparty@example.com", + senderId: "teaparty@example.com/hatter", + direction: MessageReceived, + type: MessageChannel, + timestamp: "2020-01-01T00:00:04Z", + }); + builder4.sortId = "c0"; + builder4.to = JID.parse("alice@example.com"); + builder4.from = JID.parse("teaparty@example.com/hatter"); + builder4.replyTo = [builder4.from.asBare()]; + + persistence.storeMessages(account, [ + builder2.build(), + builder4.build(), + builder3.build(), + builder.build(), + ]).then(_ -> { + return persistence.getMessagesBefore(account, "teaparty@example.com", builder4.build()); + }).then(result -> { + Assert.equals(3, result.length); + Assert.equals("1", result[0].serverId); + Assert.equals("2", result[1].serverId); + Assert.equals("3", result[2].serverId); + async.done(); + }).catchError(e -> { + Assert.fail(Std.string(e)); + async.done(); + }); + } + + public function testMessagesBeforePM(async: Async) { + final account = "alice@example.com"; + final builder = new ChatMessageBuilder({ + serverId: "1", + serverIdBy: "teaparty@example.com", + senderId: "teaparty@example.com/hatter", + direction: MessageReceived, + type: MessageChannel, + timestamp: "2020-01-01T00:00:00Z", + }); + builder.sortId = "a0"; + builder.to = JID.parse("alice@example.com"); + builder.from = JID.parse("teaparty@example.com/hatter"); + builder.replyTo = [builder.from.asBare()]; + + final builder2 = new ChatMessageBuilder({ + serverId: "2", + serverIdBy: "teaparty@example.com", + senderId: "teaparty@example.com/hatter", + direction: MessageReceived, + type: MessageChannel, + timestamp: "2020-01-01T00:00:01Z", + }); + builder2.sortId = "b0"; + builder2.to = JID.parse("alice@example.com"); + builder2.from = JID.parse("teaparty@example.com/hatter"); + builder2.replyTo = [builder2.from.asBare()]; + + final builder3 = new ChatMessageBuilder({ + serverId: "3", + serverIdBy: "alice@example.com", + senderId: "teaparty@example.com/hatter", + direction: MessageReceived, + type: MessageChannelPrivate, + timestamp: "2020-01-01T00:00:03Z", + }); + builder3.sortId = "Z~"; + builder3.to = JID.parse("alice@example.com"); + builder3.from = JID.parse("teaparty@example.com/hatter"); + builder3.replyTo = [builder3.from.asBare()]; + + final builder4 = new ChatMessageBuilder({ + serverId: "4", + serverIdBy: "teaparty@example.com", + senderId: "teaparty@example.com/hatter", + direction: MessageReceived, + type: MessageChannel, + timestamp: "2020-01-01T00:00:04Z", + }); + builder4.sortId = "c0"; + builder4.to = JID.parse("alice@example.com"); + builder4.from = JID.parse("teaparty@example.com/hatter"); + builder4.replyTo = [builder4.from.asBare()]; + + persistence.storeMessages(account, [ + builder2.build(), + builder4.build(), + builder3.build(), + builder.build(), + ]).then(_ -> { + return persistence.getMessagesBefore(account, "teaparty@example.com", builder3.build()); + }).then(result -> { + Assert.equals(2, result.length); + Assert.equals("1", result[0].serverId); + Assert.equals("2", result[1].serverId); + async.done(); + }).catchError(e -> { + Assert.fail(Std.string(e)); + async.done(); + }); + } + + public function testMessagesAfter(async: Async) { + final account = "alice@example.com"; + final builder = new ChatMessageBuilder({ + serverId: "1", + serverIdBy: "teaparty@example.com", + senderId: "teaparty@example.com/hatter", + direction: MessageReceived, + type: MessageChannel, + timestamp: "2020-01-01T00:00:00Z", + }); + builder.sortId = "a0"; + builder.to = JID.parse("alice@example.com"); + builder.from = JID.parse("teaparty@example.com/hatter"); + builder.replyTo = [builder.from.asBare()]; + + final builder2 = new ChatMessageBuilder({ + serverId: "2", + serverIdBy: "teaparty@example.com", + senderId: "teaparty@example.com/hatter", + direction: MessageReceived, + type: MessageChannel, + timestamp: "2020-01-01T00:00:01Z", + }); + builder2.sortId = "b0"; + builder2.to = JID.parse("alice@example.com"); + builder2.from = JID.parse("teaparty@example.com/hatter"); + builder2.replyTo = [builder2.from.asBare()]; + + final builder3 = new ChatMessageBuilder({ + serverId: "3", + serverIdBy: "alice@example.com", + senderId: "teaparty@example.com/hatter", + direction: MessageReceived, + type: MessageChannelPrivate, + timestamp: "2020-01-01T00:00:03Z", + }); + builder3.sortId = "a1"; + builder3.to = JID.parse("alice@example.com"); + builder3.from = JID.parse("teaparty@example.com/hatter"); + builder3.replyTo = [builder3.from.asBare()]; + + persistence.storeMessages(account, [ + builder2.build(), + builder3.build(), + builder.build(), + ]).then(_ -> { + return persistence.getMessagesAfter(account, "teaparty@example.com", null); + }).then(result -> { + Assert.equals(3, result.length); + Assert.equals("1", result[0].serverId); + Assert.equals("2", result[1].serverId); + Assert.equals("3", result[2].serverId); + async.done(); + }).catchError(e -> { + Assert.fail(Std.string(e)); + async.done(); + }); + } + + public function testMessagesAfterPoint(async: Async) { + final account = "alice@example.com"; + final builder = new ChatMessageBuilder({ + serverId: "1", + serverIdBy: "teaparty@example.com", + senderId: "teaparty@example.com/hatter", + direction: MessageReceived, + type: MessageChannel, + timestamp: "2020-01-01T00:00:01Z", + }); + builder.sortId = "a0"; + builder.to = JID.parse("alice@example.com"); + builder.from = JID.parse("teaparty@example.com/hatter"); + builder.replyTo = [builder.from.asBare()]; + + final builder2 = new ChatMessageBuilder({ + serverId: "2", + serverIdBy: "teaparty@example.com", + senderId: "teaparty@example.com/hatter", + direction: MessageReceived, + type: MessageChannel, + timestamp: "2020-01-01T00:00:00Z", + }); + builder2.sortId = "b0"; + builder2.to = JID.parse("alice@example.com"); + builder2.from = JID.parse("teaparty@example.com/hatter"); + builder2.replyTo = [builder2.from.asBare()]; + + final builder3 = new ChatMessageBuilder({ + serverId: "3", + serverIdBy: "alice@example.com", + senderId: "teaparty@example.com/hatter", + direction: MessageReceived, + type: MessageChannelPrivate, + timestamp: "2020-01-01T00:00:03Z", + }); + builder3.sortId = "Z~"; + builder3.to = JID.parse("alice@example.com"); + builder3.from = JID.parse("teaparty@example.com/hatter"); + builder3.replyTo = [builder3.from.asBare()]; + + final builder4 = new ChatMessageBuilder({ + serverId: "4", + serverIdBy: "teaparty@example.com", + senderId: "teaparty@example.com/hatter", + direction: MessageReceived, + type: MessageChannel, + timestamp: "2020-01-01T00:00:04Z", + }); + builder4.sortId = "c0"; + builder4.to = JID.parse("alice@example.com"); + builder4.from = JID.parse("teaparty@example.com/hatter"); + builder4.replyTo = [builder4.from.asBare()]; + + persistence.storeMessages(account, [ + builder2.build(), + builder4.build(), + builder3.build(), + builder.build(), + ]).then(_ -> { + return persistence.getMessagesAfter(account, "teaparty@example.com", builder.build()); + }).then(result -> { + Assert.equals(3, result.length); + Assert.equals("2", result[0].serverId); + Assert.equals("3", result[1].serverId); + Assert.equals("4", result[2].serverId); + async.done(); + }).catchError(e -> { + Assert.fail(Std.string(e)); + async.done(); + }); + } + + public function testMessagesAfterPM(async: Async) { + final account = "alice@example.com"; + final builder = new ChatMessageBuilder({ + serverId: "1", + serverIdBy: "teaparty@example.com", + senderId: "teaparty@example.com/hatter", + direction: MessageReceived, + type: MessageChannel, + timestamp: "2020-01-01T00:00:00Z", + }); + builder.sortId = "a0"; + builder.to = JID.parse("alice@example.com"); + builder.from = JID.parse("teaparty@example.com/hatter"); + builder.replyTo = [builder.from.asBare()]; + + final builder2 = new ChatMessageBuilder({ + serverId: "2", + serverIdBy: "teaparty@example.com", + senderId: "teaparty@example.com/hatter", + direction: MessageReceived, + type: MessageChannel, + timestamp: "2020-01-01T00:00:01Z", + }); + builder2.sortId = "b0"; + builder2.to = JID.parse("alice@example.com"); + builder2.from = JID.parse("teaparty@example.com/hatter"); + builder2.replyTo = [builder2.from.asBare()]; + + final builder3 = new ChatMessageBuilder({ + serverId: "3", + serverIdBy: "alice@example.com", + senderId: "teaparty@example.com/hatter", + direction: MessageReceived, + type: MessageChannelPrivate, + timestamp: "2020-01-01T00:00:03Z", + }); + builder3.sortId = "Z~"; + builder3.to = JID.parse("alice@example.com"); + builder3.from = JID.parse("teaparty@example.com/hatter"); + builder3.replyTo = [builder3.from.asBare()]; + + final builder4 = new ChatMessageBuilder({ + serverId: "4", + serverIdBy: "teaparty@example.com", + senderId: "teaparty@example.com/hatter", + direction: MessageReceived, + type: MessageChannel, + timestamp: "2020-01-01T00:00:04Z", + }); + builder4.sortId = "c0"; + builder4.to = JID.parse("alice@example.com"); + builder4.from = JID.parse("teaparty@example.com/hatter"); + builder4.replyTo = [builder4.from.asBare()]; + + persistence.storeMessages(account, [ + builder2.build(), + builder4.build(), + builder3.build(), + builder.build(), + ]).then(_ -> { + return persistence.getMessagesAfter(account, "teaparty@example.com", builder3.build()); + }).then(result -> { + Assert.equals(1, result.length); + Assert.equals("4", result[0].serverId); + async.done(); + }).catchError(e -> { + Assert.fail(Std.string(e)); + async.done(); + }); + } + + public function testStoreChats(async: Async) { + final account = "alice@example.com"; + final chat = new DirectChat(cast null, cast null, persistence, "hatter@example.com"); + chat.displayName = "The Mad Hatter"; + chat.trusted = true; + chat.threads.set(null, "Tea Time"); + chat.threads.set("thread-1", "Introductions"); + + persistence.storeChats(account, [chat]); + haxe.Timer.delay(() -> { + persistence.getChats(account).then(chats -> { + Assert.equals(1, chats.length); + Assert.equals("hatter@example.com", chats[0].chatId); + Assert.equals("The Mad Hatter", chats[0].displayName); + Assert.isTrue(chats[0].trusted); + Assert.equals("DirectChat", chats[0].klass); + Assert.equals("Tea Time", chats[0].threads.get(null)); + Assert.equals("Introductions", chats[0].threads.get("thread-1")); + async.done(); + }).catchError(e -> { + Assert.fail(Std.string(e)); + async.done(); + }); + }, 200); + } + + public function testGetMessage(async: Async) { + final account = "alice@example.com"; + final builder = new ChatMessageBuilder({ + serverId: "srv1", + serverIdBy: "hatter@example.com", + localId: "loc1", + senderId: "hatter@example.com", + direction: MessageReceived, + }); + builder.sortId = "a0"; + builder.to = JID.parse("alice@example.com"); + builder.from = JID.parse("hatter@example.com"); + builder.recipients = [builder.to]; + builder.replyTo = [builder.from]; + + persistence.storeMessages(account, [builder.build()]).then(_ -> { + return persistence.getMessage(account, "hatter@example.com", "srv1", null); + }).then(byServerId -> { + Assert.notNull(byServerId); + Assert.equals("srv1", byServerId.serverId); + return persistence.getMessage(account, "hatter@example.com", null, "loc1"); + }).then(byLocalId -> { + Assert.notNull(byLocalId); + Assert.equals("loc1", byLocalId.localId); + async.done(); + }).catchError(e -> { + Assert.fail(Std.string(e)); + async.done(); + }); + } + + public function testStoreReaction(async: Async) { + final account = "alice@example.com"; + final builder = new ChatMessageBuilder({ + serverId: "srv1", + serverIdBy: "hatter@example.com", + senderId: "hatter@example.com", + direction: MessageReceived, + }); + builder.sortId = "a0"; + builder.to = JID.parse("alice@example.com"); + builder.from = JID.parse("hatter@example.com"); + builder.recipients = [builder.to]; + builder.replyTo = [builder.from]; + + persistence.storeMessages(account, [builder.build()]).then(_ -> { + final reaction = new Reaction("alice@example.com", "2020-01-01T00:00:01Z", "👍"); + final update = new ReactionUpdate( + "up1", + "srv1", + "hatter@example.com", + null, + "hatter@example.com", + "alice@example.com", + "2020-01-01T00:00:01Z", + [reaction], + EmojiReactions + ); + return persistence.storeReaction(account, update); + }).then(msg -> { + Assert.notNull(msg); + final reactions = msg.reactions; + Assert.equals(1, Lambda.count({ iterator: () -> reactions.iterator() })); + Assert.isTrue(reactions.exists("👍")); + Assert.equals(1, reactions.get("👍").length); + async.done(); + }).catchError(e -> { + Assert.fail(Std.string(e)); + async.done(); + }); + } + + public function testUpdateMessageStatus(async: Async) { + final account = "alice@example.com"; + final builder = new ChatMessageBuilder({ + localId: "loc1", + senderId: "alice@example.com", + direction: MessageSent, + }); + builder.sortId = "a0"; + builder.to = JID.parse("hatter@example.com"); + builder.from = JID.parse("alice@example.com"); + builder.recipients = [builder.to]; + builder.replyTo = [builder.from]; + + persistence.storeMessages(account, [builder.build()]).then(_ -> { + return persistence.updateMessageStatus(account, "loc1", MessageDeliveredToServer, "Delivered"); + }).then(updated -> { + Assert.equals(MessageDeliveredToServer, updated.status); + Assert.equals("Delivered", updated.statusText); + async.done(); + }).catchError(e -> { + Assert.fail(Std.string(e)); + async.done(); + }); + } + + public function testSearchMessages(async: Async) { + final account = "alice@example.com"; + final builder = new ChatMessageBuilder({ + serverId: "srv1", + serverIdBy: "hatter@example.com", + senderId: "hatter@example.com", + direction: MessageReceived, + }); + builder.sortId = "a0"; + builder.setBody(Html.text("Hello world")); + builder.to = JID.parse("alice@example.com"); + builder.from = JID.parse("hatter@example.com"); + builder.recipients = [builder.to]; + builder.replyTo = [builder.from]; + + final builder2 = new ChatMessageBuilder({ + serverId: "srv2", + serverIdBy: "hatter@example.com", + senderId: "hatter@example.com", + direction: MessageReceived, + }); + builder2.sortId = "a1"; + builder2.setBody(Html.text("Goodbye world")); + builder2.to = JID.parse("alice@example.com"); + builder2.from = JID.parse("hatter@example.com"); + builder2.recipients = [builder2.to]; + builder2.replyTo = [builder2.from]; + + persistence.storeMessages(account, [builder.build(), builder2.build()]).then(_ -> { + return persistence.searchMessages(account, "hatter@example.com", "hello"); + }).then(results -> { + Assert.equals(1, results.length); + Assert.equals("Hello world", results[0].text); + async.done(); + }).catchError(e -> { + Assert.fail(Std.string(e)); + async.done(); + }); + } + + public function testRemoveAccount(async: Async) { + final account1 = "alice@example.com"; + final account2 = "bob@example.com"; + + persistence.storeLogin(account1, "client1", "Alice", null); + persistence.storeLogin(account2, "client2", "Bob", null); + + persistence.listAccounts().then(accountsBefore -> { + Assert.contains(account1, accountsBefore); + Assert.contains(account2, accountsBefore); + persistence.removeAccount(account1, true); + return persistence.listAccounts(); + }).then(accountsAfter -> { + Assert.notContains(account1, accountsAfter); + Assert.contains(account2, accountsAfter); + async.done(); + }).catchError(e -> { + Assert.fail(Std.string(e)); + async.done(); + }); + } + + public function testGetChatUnreadDetails(async: Async) { + final account = "alice@example.com"; + final chat = new DirectChat(cast null, cast null, persistence, "hatter@example.com"); + chat.readUpToId = "srv1"; + + final builder = new ChatMessageBuilder({ + serverId: "srv1", + serverIdBy: "hatter@example.com", + senderId: "hatter@example.com", + direction: MessageReceived, + }); + builder.sortId = "a0"; + builder.to = JID.parse("alice@example.com"); + builder.from = JID.parse("hatter@example.com"); + builder.recipients = [builder.to]; + builder.replyTo = [builder.from]; + + final builder2 = new ChatMessageBuilder({ + serverId: "srv2", + serverIdBy: "hatter@example.com", + senderId: "hatter@example.com", + direction: MessageReceived, + }); + builder2.sortId = "a1"; + builder2.to = JID.parse("alice@example.com"); + builder2.from = JID.parse("hatter@example.com"); + builder2.recipients = [builder2.to]; + builder2.replyTo = [builder2.from]; + + persistence.storeMessages(account, [builder.build(), builder2.build()]).then(_ -> { + return persistence.getChatUnreadDetails(account, chat); + }).then(result -> { + Assert.equals(1, result.unreadCount); + Assert.equals("srv2", result.message.serverId); + async.done(); + }).catchError(e -> { + Assert.fail(Std.string(e)); + async.done(); + }); + } + + public function testMedia(async: Async) { + final bytes = haxe.io.Bytes.ofString("hello").getData(); + persistence.storeMedia("image/png", bytes).then(_ -> { + return persistence.hasMedia("sha-256", Hash.sha256(haxe.io.Bytes.ofData(bytes)).hash); + }).then(hasBefore -> { + Assert.isTrue(hasBefore); + persistence.removeMedia("sha-256", Hash.sha256(haxe.io.Bytes.ofData(bytes)).hash); + return persistence.hasMedia("sha-256", Hash.sha256(haxe.io.Bytes.ofData(bytes)).hash); + }).then(hasAfter -> { + Assert.isFalse(hasAfter); + async.done(); + }).catchError(e -> { + Assert.fail(Std.string(e)); + async.done(); + }); + } + + public function testHydrateReplyTo(async: Async) { + final account = "alice@example.com"; + final builder = new ChatMessageBuilder({ + serverId: "parent", + serverIdBy: "hatter@example.com", + localId: "loc1", + senderId: "hatter@example.com", + direction: MessageReceived, + }); + builder.sortId = "a0"; + builder.to = JID.parse("alice@example.com"); + builder.from = JID.parse("hatter@example.com"); + builder.recipients = [builder.to]; + builder.replyTo = [builder.from]; + final parentStub = builder.build(); + + builder.setBody(Html.text("Hello")); + final parentMsg = builder.build(); + + final builder2 = new ChatMessageBuilder({ + serverId: "child", + serverIdBy: "hatter@example.com", + localId: "loc2", + senderId: "hatter@example.com", + direction: MessageReceived, + }); + builder2.sortId = "a1"; + builder2.to = JID.parse("alice@example.com"); + builder2.from = JID.parse("hatter@example.com"); + builder2.recipients = [builder2.to]; + builder2.replyTo = [builder2.from]; + builder2.replyToMessage = parentStub; + final childMsg = builder2.build(); + + persistence.storeMessages(account, [parentMsg]).then(_ -> { + return persistence.storeMessages(account, [childMsg]); + }).then(msgs -> { + final childStored = msgs[0]; + Assert.notNull(childStored.replyToMessage); + Assert.equals("Hello", childStored.replyToMessage.text); + async.done(); + }).catchError(e -> { + Assert.fail(Std.string(e)); + async.done(); + }); + } + + public function testStoreChatsWithStatus(async: Async) { + final account = "alice@example.com"; + final chat = new DirectChat(cast null, cast null, persistence, "hatter@example.com"); + chat.displayName = "The Mad Hatter"; + chat.trusted = true; + chat.status = new Status("🎩", "Time for tea!"); + + persistence.storeChats(account, [chat]); + haxe.Timer.delay(() -> { + persistence.getChats(account).then(chats -> { + Assert.equals(1, chats.length); + Assert.equals("hatter@example.com", chats[0].chatId); + Assert.equals("🎩", chats[0].status.emoji); + Assert.equals("Time for tea!", chats[0].status.text); + async.done(); + }).catchError(e -> { + Assert.fail(Std.string(e)); + async.done(); + }); + }, 200); + } +} diff --git a/testjs.hxml b/testjs.hxml new file mode 100644 index 0000000..c7db676 --- /dev/null +++ b/testjs.hxml @@ -0,0 +1,26 @@ +--library HtmlParser +--library datetime +--library fractional-indexing +--library haxe-strings +--library hsluv +--library hxnodejs +--library jsImport +--library thenshim +--library tink_http +--library uuidv7 + +--library utest + +--macro borogove.Util.DummyRequireMacro.init() +-D test +-D analyzer-optimize +-D js-es=6 +-D js_global=globalThis +-D NO_OMEMO +-D no-traces +-main test.TestAll +-w -WDeprecated +--js .cache/test.js + +--cmd npx cjstoesm -s true .cache/test.js +--cmd nodejs --disable-warning=ExperimentalWarning .cache/test.js