mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-01 18:32:27 +00:00
Add DM normalization and integration tests
This commit is contained in:
1 parent
d5b4909be0
commit
8f04692165
3 files changed
+436
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user