diff --git a/tests/dm-db.test.mjs b/tests/dm-db.test.mjs new file mode 100644 index 00000000..c1ecb03b --- /dev/null +++ b/tests/dm-db.test.mjs @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import { test, afterEach } from "node:test"; + +import { + getConversation, + listMessagesByConversation, + updateConversationFromMessage, + writeMessages, +} from "../js/storage/dmDb.js"; + +const { indexedDB, IDBKeyRange } = await import("fake-indexeddb"); + +global.indexedDB = indexedDB; +globalThis.indexedDB = indexedDB; +global.IDBKeyRange = IDBKeyRange; +globalThis.IDBKeyRange = IDBKeyRange; + +async function deleteDatabase(name) { + await new Promise((resolve) => { + const request = indexedDB.deleteDatabase(name); + request.onsuccess = () => resolve(); + request.onerror = () => resolve(); + request.onblocked = () => resolve(); + }); +} + +afterEach(async () => { + await deleteDatabase("bitvidDm"); +}); + +test("writeMessages normalizes records before storing", async () => { + const conversationId = "dm:abcd:efgh"; + const result = await writeMessages({ + id: "message-1", + conversationId: ` ${conversationId} `, + senderPubkey: "SENDERPUBKEY", + receiverPubkey: "RECEIVERPUBKEY", + createdAt: 1_700_000_500, + kind: 4, + content: "Hello", + tags: [["p", "RECEIVERPUBKEY"]], + status: "published", + seen: true, + encryptionScheme: "nip04", + }); + + assert.equal(result.length, 1); + + const stored = await listMessagesByConversation(conversationId); + assert.equal(stored.length, 1); + assert.equal(stored[0].conversation_id, conversationId); + assert.equal(stored[0].sender_pubkey, "senderpubkey"); + assert.equal(stored[0].receiver_pubkey, "receiverpubkey"); + assert.equal(stored[0].status, "published"); + assert.equal(stored[0].seen, true); + assert.equal(stored[0].encryption_scheme, "nip04"); +}); + +test("updateConversationFromMessage persists metadata updates", async () => { + const conversationId = "dm:meta:updates"; + const message = { + id: "message-2", + conversation_id: conversationId, + sender_pubkey: "sender", + receiver_pubkey: "receiver", + created_at: 1_700_000_510, + kind: 4, + content: "Preview payload", + seen: false, + }; + + const updated = await updateConversationFromMessage(message, { + preview: "Preview payload", + unseenDelta: 1, + downloadedUntil: 1_700_000_510, + }); + + assert.ok(updated); + assert.equal(updated.conversation_id, conversationId); + assert.equal(updated.last_message_preview, "Preview payload"); + assert.equal(updated.unseen_count, 1); + + const stored = await getConversation(conversationId); + assert.ok(stored); + assert.equal(stored.conversation_id, conversationId); + assert.equal(stored.last_message_preview, "Preview payload"); + assert.equal(stored.unseen_count, 1); +}); diff --git a/tests/dm-normalization.test.mjs b/tests/dm-normalization.test.mjs new file mode 100644 index 00000000..b4f30497 --- /dev/null +++ b/tests/dm-normalization.test.mjs @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import { test, afterEach } from "node:test"; + +import { NostrService } from "../js/services/nostrService.js"; + +const { indexedDB, IDBKeyRange } = await import("fake-indexeddb"); + +global.indexedDB = indexedDB; +globalThis.indexedDB = indexedDB; +global.IDBKeyRange = IDBKeyRange; +globalThis.IDBKeyRange = IDBKeyRange; + +async function deleteDatabase(name) { + await new Promise((resolve) => { + const request = indexedDB.deleteDatabase(name); + request.onsuccess = () => resolve(); + request.onerror = () => resolve(); + request.onblocked = () => resolve(); + }); +} + +afterEach(async () => { + await deleteDatabase("bitvidDm"); + await deleteDatabase("bitvidSettings"); +}); + +test("direct message normalization hashes conversations consistently", async () => { + const service = new NostrService({ logger: () => {} }); + const sender = "b".repeat(64); + const receiver = "a".repeat(64); + + const incoming = { + ok: true, + direction: "incoming", + plaintext: "Hello there", + sender: { pubkey: sender }, + recipients: [{ pubkey: receiver }], + event: { + id: "event-incoming", + created_at: 1_700_000_001, + kind: 4, + tags: [["p", receiver]], + pubkey: sender, + }, + }; + + const outgoing = { + ok: true, + direction: "outgoing", + plaintext: "Hello there", + sender: { pubkey: sender }, + recipients: [{ pubkey: receiver }], + event: { + id: "event-outgoing", + created_at: 1_700_000_002, + kind: 4, + tags: [["p", receiver]], + pubkey: sender, + }, + }; + + const incomingRecord = await service.persistDirectMessageRecord(incoming, { + actorPubkey: receiver, + }); + const outgoingRecord = await service.persistDirectMessageRecord(outgoing, { + actorPubkey: sender, + }); + + const expectedId = `dm:${[sender, receiver].sort().join(":")}`; + + assert.equal(incomingRecord.conversation_id, expectedId); + assert.equal(outgoingRecord.conversation_id, expectedId); +}); + +test("direct message list dedupes entries by event id", async () => { + const service = new NostrService({ logger: () => {} }); + const sender = "c".repeat(64); + const receiver = "d".repeat(64); + + const baseMessage = { + ok: true, + direction: "incoming", + actorPubkey: receiver, + plaintext: "First payload", + sender: { pubkey: sender }, + recipients: [{ pubkey: receiver }], + event: { + id: "event-dedupe", + created_at: 1_700_000_010, + kind: 4, + tags: [["p", receiver]], + pubkey: sender, + }, + }; + + service.applyDirectMessage(baseMessage, { reason: "initial" }); + + service.applyDirectMessage( + { + ...baseMessage, + plaintext: "Updated payload", + preview: "Updated payload", + }, + { reason: "duplicate" }, + ); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const messages = service.getDirectMessages(); + assert.equal(messages.length, 1); + assert.equal(messages[0].plaintext, "Updated payload"); +}); diff --git a/tests/nostr/dm-direct-message-flow.test.mjs b/tests/nostr/dm-direct-message-flow.test.mjs new file mode 100644 index 00000000..302d9f83 --- /dev/null +++ b/tests/nostr/dm-direct-message-flow.test.mjs @@ -0,0 +1,236 @@ +import assert from "node:assert/strict"; +import { test, afterEach } from "node:test"; + +import { NostrClient, clearActiveSigner, setActiveSigner } from "../../js/nostr/client.js"; +import { NostrService } from "../../js/services/nostrService.js"; +import { listMessagesByConversation } from "../../js/storage/dmDb.js"; + +const { indexedDB, IDBKeyRange } = await import("fake-indexeddb"); + +global.indexedDB = indexedDB; +globalThis.indexedDB = indexedDB; +global.IDBKeyRange = IDBKeyRange; +globalThis.IDBKeyRange = IDBKeyRange; + +async function deleteDatabase(name) { + await new Promise((resolve) => { + const request = indexedDB.deleteDatabase(name); + request.onsuccess = () => resolve(); + request.onerror = () => resolve(); + request.onblocked = () => resolve(); + }); +} + +afterEach(async () => { + await deleteDatabase("bitvidDm"); + await deleteDatabase("bitvidSettings"); + clearActiveSigner(); +}); + +class FakeRelayPool { + constructor() { + this.subscriptions = new Set(); + this.queue = []; + this.published = []; + } + + publish(urls, event) { + this.queue.push(event); + this.published.push(event); + return { + on(eventName, handler) { + if (eventName === "ok") { + setTimeout(() => handler(), 0); + } + return true; + }, + }; + } + + list() { + return Promise.resolve([]); + } + + sub() { + const handlers = new Map(); + const subscription = { + on(eventName, handler) { + handlers.set(eventName, handler); + }, + emit(eventName, payload) { + const handler = handlers.get(eventName); + if (handler) { + handler(payload); + } + }, + unsub: () => { + this.subscriptions.delete(subscription); + }, + }; + + this.subscriptions.add(subscription); + return subscription; + } + + flushQueue({ duplicate = false } = {}) { + const batch = [...this.queue]; + this.queue = []; + batch.forEach((event) => { + this.deliver(event); + if (duplicate) { + this.deliver(event); + } + }); + } + + deliver(event) { + for (const subscription of this.subscriptions) { + subscription.emit("event", event); + } + } +} + +test("two clients can send, decrypt, notify, and mark DMs as seen", async () => { + const previousCanonical = globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__; + const previousNostrTools = globalThis.NostrTools; + const previousReady = globalThis.nostrToolsReady; + + const nostrTools = await import("nostr-tools"); + + try { + globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = nostrTools; + globalThis.NostrTools = nostrTools; + globalThis.nostrToolsReady = Promise.resolve({ ok: true, value: nostrTools }); + + const senderSecret = nostrTools.generateSecretKey(); + const receiverSecret = nostrTools.generateSecretKey(); + const senderPrivateKey = nostrTools.utils.bytesToHex(senderSecret); + const receiverPrivateKey = nostrTools.utils.bytesToHex(receiverSecret); + + const senderPubkey = nostrTools.getPublicKey(senderPrivateKey); + const receiverPubkey = nostrTools.getPublicKey(receiverPrivateKey); + + const createSigner = (privateKey, pubkey) => ({ + type: "test", + pubkey, + signEvent: (event) => { + const finalized = nostrTools.finalizeEvent(event, privateKey); + return { ...event, id: finalized.id, sig: finalized.sig }; + }, + nip04Encrypt: (target, plaintext) => + nostrTools.nip04.encrypt(privateKey, target, plaintext), + nip04Decrypt: (target, ciphertext) => + nostrTools.nip04.decrypt(privateKey, target, ciphertext), + }); + + const senderSigner = createSigner(senderPrivateKey, senderPubkey); + const receiverSigner = createSigner(receiverPrivateKey, receiverPubkey); + + const relayPool = new FakeRelayPool(); + + const senderClient = new NostrClient(); + senderClient.pool = relayPool; + senderClient.relays = ["wss://relay.unit.test"]; + senderClient.readRelays = []; + senderClient.writeRelays = []; + senderClient.pubkey = senderPubkey; + + const receiverClient = new NostrClient(); + receiverClient.pool = relayPool; + receiverClient.relays = ["wss://relay.unit.test"]; + receiverClient.readRelays = []; + receiverClient.writeRelays = []; + receiverClient.pubkey = receiverPubkey; + + const receiverService = new NostrService({ logger: () => {} }); + receiverService.nostrClient = receiverClient; + + const notifications = []; + receiverService.on("directMessages:notification", (payload) => { + notifications.push(payload); + }); + + const received = []; + let subscription = null; + const subscribe = () => { + subscription = receiverClient.subscribeDirectMessages(receiverPubkey, { + onMessage: (message, { event }) => { + received.push(message); + receiverService.applyDirectMessage( + { ...message, actorPubkey: receiverPubkey }, + { reason: "subscription", event }, + ); + }, + }); + }; + + setActiveSigner(receiverSigner); + subscribe(); + + setActiveSigner(senderSigner); + const sendResult = await senderClient.sendDirectMessage( + nostrTools.nip19.npubEncode(receiverPubkey), + "Hello from sender", + ); + + assert.equal(sendResult.ok, true); + + setActiveSigner(receiverSigner); + relayPool.flushQueue({ duplicate: true }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + assert.equal(received.length, 1, "duplicate relay events should be deduped"); + assert.equal(notifications.length, 1, "incoming messages should notify once"); + + const conversationId = `dm:${[senderPubkey, receiverPubkey].sort().join(":")}`; + assert.equal(receiverService.getDirectMessageUnseenCount(conversationId), 1); + + const stored = await listMessagesByConversation(conversationId); + assert.equal(stored.length, 1, "message should persist in the DM store"); + + await receiverService.acknowledgeRenderedDirectMessages( + conversationId, + received[0].timestamp || stored[0].created_at, + ); + + assert.equal(receiverService.getDirectMessageUnseenCount(conversationId), 0); + + subscription.unsub(); + subscribe(); + + relayPool.deliver(relayPool.published[0]); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + assert.equal( + receiverService.getDirectMessages().length, + 1, + "reconnection deliveries should not duplicate messages", + ); + assert.equal( + receiverService.getDirectMessageUnseenCount(conversationId), + 0, + "replayed events should not reset seen state", + ); + assert.equal(notifications.length, 1, "replays should not trigger notifications"); + } finally { + if (previousCanonical === undefined) { + delete globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__; + } else { + globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = previousCanonical; + } + + if (previousNostrTools === undefined) { + delete globalThis.NostrTools; + } else { + globalThis.NostrTools = previousNostrTools; + } + + if (previousReady === undefined) { + delete globalThis.nostrToolsReady; + } else { + globalThis.nostrToolsReady = previousReady; + } + } +});