tinyrelayfile
tinyrelay/scripts/chat.test.mjs/

scripts/chat.test.mjs raw
1import test from "node:test";
2import assert from "node:assert/strict";
3import fs from "node:fs";
4import vm from "node:vm";
5import {getEventHash, verifyEvent} from "nostr-tools/pure";
6
7class FakeNode {
8 constructor(name = "node") { this.nodeName = name; this.childNodes = []; this.dataset = {}; this.attributes = {}; this.style = {}; this.isConnected = true; }
9 append(...children) { this.childNodes.push(...children.flat().filter(Boolean)); }
10 replaceChildren(...children) { this.childNodes = children; }
11 setAttribute(name, value) { this.attributes[name] = String(value); }
12 getAttribute(name) { return this.attributes[name] || null; }
13 addEventListener() {}
14 removeEventListener() {}
15 querySelector() { return null; }
16 querySelectorAll() { return []; }
17 closest() { return null; }
18}
19class FakeElement extends FakeNode {}
20const definitions = new Map();
21globalThis.HTMLElement = FakeElement;
22globalThis.document = {visibilityState: "visible", createElement: name => new FakeNode(name), createTextNode: text => Object.assign(new FakeNode("text"), {textContent: text}), addEventListener() {}, removeEventListener() {}};
23globalThis.customElements = {get: name => definitions.get(name), define: (name, value) => definitions.set(name, value)};
24globalThis.location = {href: "https://relay.test/chat", pathname: "/chat"};
25globalThis.setInterval = () => 0; globalThis.clearInterval = () => {};
26
27const key = char => char.repeat(64);
28const alice = key("a"), bob = key("b"), carol = key("c");
29const rumor = (id, sender, recipient, created_at, content, kind = 14, extra = []) => ({id, kind, pubkey: sender, created_at, content, tags: [["p", recipient], ...extra]});
30const wrap = (id, r) => ({id, kind: 1059, pubkey: bob, created_at: r.created_at, tags: [["p", alice]], rumor: r});
31const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done; }); return {promise, resolve}; };
32const signer = {getPublicKey: async () => alice};
33const protocol = {
34 normalizePubkey: value => String(value).toLowerCase(),
35 decrypt: async (event, options) => { if (event.invalid) throw Error("invalid wrap"); if (event.recipient && event.recipient !== options.actor) throw Error("wrong recipient"); return {rumor: event.rumor}; },
36 relayList: value => value, queryRelayList: async () => ["wss://relay.test"],
37 build: async input => ({rumor: rumor("built", alice, input.recipient, 100, input.content), events: [{event: {id: "receiver-wrap"}}, {event: {id: "sender-wrap"}}]}), publishEvent: async () => true
38};
39let pages = [];
40globalThis.tiny = {chat: {protocol}, signer: () => signer, signedFetch: async () => ({ok: true, json: async () => pages.shift()}), localPath: value => value, util: {relayURL: value => value}, rooms: {linkify: value => Object.assign(new FakeNode("text"), {textContent: value})}, ui: {FormElement: FakeElement}, navigate: async () => {}, signing: {publish: async () => ({})}};
41globalThis.NostrSigner = {verifyEvent: () => true};
42vm.runInThisContext(fs.readFileSync("tinyclient/chat.js", "utf8"), {filename: "chat.js"});
43const ui = tiny.chat.ui;
44
45test("cache accepts files, rejects groups, deduplicates and folds reactions", async () => {
46 const value = ui.stateFor(alice), first = rumor(key("1"), alice, bob, 20, "one"), second = rumor("two", bob, alice, 10, "two");
47 const group = rumor("group", alice, bob, 30, "group", 14, [["p", carol]]), reaction = rumor("reaction", bob, alice, 31, "+", 7, [["e", first.id]]), outsiderReaction = rumor("outsider-reaction", carol, alice, 31, "+", 7, [["e", first.id]]), file = rumor("file", bob, alice, 32, "blossom://relay/file", 15, [["file-type", "video/mp4"]]);
48 await ui.cacheEvents(value, [wrap("one-wrap", first), wrap("one-duplicate", first), wrap("two-wrap", second), wrap("group-wrap", group), wrap("reaction-wrap", reaction), wrap("outsider-reaction-wrap", outsiderReaction), wrap("file-wrap", file)], signer);
49 assert.deepEqual(ui.messages(value).map(row => row.content), ["two", "one", "blossom://relay/file"]); assert.deepEqual([...ui.reactions(value, first)], [["♥", 1]]); assert.equal(value.events.size, 5); assert.equal(ui.participants(group).length, 3);
50});
51
52test("concurrent loads share one request and older pagination clears cursor", async () => {
53 ui.clear(); pages = [{events: [wrap("new", rumor("new", bob, alice, 100, "new"))], next_cursor: "older"}]; let calls = 0; const original = tiny.signedFetch; tiny.signedFetch = async (...args) => { calls++; return original(...args); };
54 await Promise.all([ui.load(alice), ui.load(alice)]); assert.equal(calls, 1); pages = [{events: [wrap("old", rumor("old", alice, bob, 1, "old"))], next_cursor: ""}]; await ui.load(alice, true); assert.equal(ui.stateFor(alice).cursor, ""); tiny.signedFetch = original;
55});
56
57test("refresh scans recent overlap without resetting an older cursor", async () => {
58 ui.clear(); pages = [{events: [wrap("seed", rumor("seed", bob, alice, 2000000000, "seed"))], next_cursor: "deep"}]; await ui.load(alice); assert.equal(ui.stateFor(alice).cursor, "deep"); pages = [{events: [wrap("fresh", rumor("fresh", bob, alice, 2000000100, "fresh"))], next_cursor: "overlap"}, {events: [wrap("overlap-old", rumor("overlap-old", alice, bob, 1999999999, "old"))], next_cursor: ""}]; const oldNow = Date.now; Date.now = () => 2000000200 * 1000; await ui.load(alice); Date.now = oldNow; assert.equal(ui.stateFor(alice).events.has("fresh"), true); assert.equal(ui.stateFor(alice).events.has("overlap-old"), true); assert.equal(ui.stateFor(alice).cursor, "deep");
59});
60
61test("partial delivery retries exact wraps and skips acknowledged copy", async () => {
62 ui.clear(); const value = ui.stateFor(alice); let buildCalls = 0, published = [], reportPending; protocol.build = async input => { buildCalls++; return {rumor: rumor("stable", alice, input.recipient, 200, input.content), events: [{event: {id: "wrap-a"}}, {event: {id: "wrap-b"}}]}; }; let attempt = 0; protocol.publishEvent = async event => { published.push(event.id); attempt++; return attempt === 1 || attempt === 3; };
63 const input = {recipient: bob, content: "retry"}; await assert.rejects(ui.deliver(value, input, null, pending => { reportPending = pending; }), /history copy failed/); assert.equal(buildCalls, 1); assert.deepEqual(published, ["wrap-a", "wrap-b"]); await ui.deliver(value, input, reportPending, pending => { reportPending = pending; }); assert.deepEqual(published, ["wrap-a", "wrap-b", "wrap-b"]); protocol.publishEvent = async () => true;
64});
65
66test("logout or signer replacement during pending decryption cannot cache plaintext", async () => {
67 ui.clear(); const value = ui.stateFor(alice), gate = deferred(); let activeActor = alice; const changingSigner = {getPublicKey: async () => activeActor}; protocol.decrypt = () => gate.promise; const pending = ui.cacheEvents(value, [wrap("pending", rumor("pending", bob, alice, 1, "secret"))], changingSigner); activeActor = bob; gate.resolve({rumor: rumor("pending", bob, alice, 1, "secret")}); await pending; assert.equal(value.events.size, 0); ui.clear(); protocol.decrypt = async event => ({rumor: event.rumor});
68});
69
70test("source parity fixture verifies and preserves its signed event hash", () => {
71 const source = JSON.parse(fs.readFileSync("scripts/fixtures/chat-client-parity.json", "utf8")).events[0]; assert.equal(verifyEvent(source), true); assert.equal(getEventHash({...source, id: undefined, sig: undefined}), source.id);
72});
73
74test("custom chat elements are registered", () => { for (const name of ["direct-chats", "direct-thread", "direct-compose", "direct-start", "direct-settings"]) assert.ok(customElements.get(name)); });
75
76test("wrapped retractions remove only their author's message or reaction", async () => {
77 ui.clear();
78 const value = ui.stateFor(alice);
79 const original = rumor(key("1"), alice, bob, 10, "message");
80 const like = rumor(key("2"), bob, alice, 11, "+", 7, [["e", original.id]]);
81 const malicious = rumor(key("3"), carol, alice, 12, "", 5, [["e", original.id]]);
82 await ui.cacheEvents(value, [wrap("orig", original), wrap("like", like), wrap("bad-delete", malicious)], signer);
83 assert.equal(ui.messages(value).length, 1);
84 assert.deepEqual([...ui.reactions(value, original)], [["♥", 1]]);
85 await ui.cacheEvents(value, [wrap("retract", rumor(key("4"), bob, alice, 13, "", 5, [["e", like.id]]))], signer);
86 assert.deepEqual([...ui.reactions(value, original)], []);
87 await ui.cacheEvents(value, [wrap("delete", rumor(key("5"), alice, bob, 14, "", 5, [["e", original.id]]))], signer);
88 assert.equal(ui.messages(value).length, 0);
89});

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs