Add DM normalization and integration tests

This commit is contained in:
thePR0M3TH3AN committed 2026-01-19 09:32:14 -05:00
1 parent d5b4909be0
commit 8f04692165
3 files changed
+436

No files matched your search

+88
View File
@@ -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);
});
+112
View File
@@ -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");
});
+236
View File
@@ -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;
}
}
});