clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/standalone-relay
tinyrelay / scripts / chat.test.mjs
chat.test.mjs 8.4K
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";67class 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 = () => {};2627const 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 () => true38};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;4445test("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});5152test("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});5657test("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});6061test("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});6566test("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});6970test("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});7374test("custom chat elements are registered", () => { for (const name of ["direct-chats", "direct-thread", "direct-compose", "direct-start", "direct-settings"]) assert.ok(customElements.get(name)); });7576test("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});