From c8e0a2d7cc872d874aea62eaefd06a5dc8abfd95 Mon Sep 17 00:00:00 2001 From: Dagur Valberg Johannsson Date: Tue, 14 Apr 2026 08:42:14 +0200 Subject: [PATCH] Tests for `22601be4` (tcp zombie) --- packages/core/src/relay-client.test.ts | 220 +++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 packages/core/src/relay-client.test.ts diff --git a/packages/core/src/relay-client.test.ts b/packages/core/src/relay-client.test.ts new file mode 100644 index 0000000..df346c7 --- /dev/null +++ b/packages/core/src/relay-client.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, vi, afterEach, type Mock } from "vitest"; +import { generateRandomBytes, secp256k1 } from "@bitauth/libauth"; + +// Mock nostr-tools and isomorphic-ws before importing RelayClient +vi.mock("nostr-tools/nip59", () => ({ + wrapEvent: vi.fn(() => ({ kind: 1059, content: "wrapped" })), + unwrapEvent: vi.fn((_event: any, _key: any) => ({ + kind: 14, + content: '{"action":"dapp_ready","time":9999999999}', + pubkey: "aa".repeat(32), + })), +})); + +vi.mock("nostr-tools/pool", () => ({ + SimplePool: vi.fn(), + useWebSocketImplementation: vi.fn(), +})); + +vi.mock("isomorphic-ws", () => ({ default: vi.fn() })); + +import { RelayClient } from "./relay-client.js"; +import type { SimplePool, SubCloser } from "nostr-tools/pool"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makePrivateKey(): Uint8Array { + let key: Uint8Array; + do { + key = generateRandomBytes(32); + } while (typeof secp256k1.derivePublicKeyCompressed(key) === "string"); + return key; +} + +interface MockPoolHandles { + pool: SimplePool; + triggerEose: () => void; + triggerClose: (reasons: string[]) => void; + publishMock: Mock; +} + +function makeMockPool(): MockPoolHandles { + let onEose: (() => void) | null = null; + let onClose: ((reasons: string[]) => void) | null = null; + + const closeFn = vi.fn(); + const publishMock = vi.fn(() => [Promise.resolve("")]); + + const pool = { + subscribeMany: vi.fn( + ( + _urls: string[], + _filter: any, + callbacks: { + onevent: (event: any) => void; + oneose: () => void; + onclose: (reasons: string[]) => void; + }, + ) => { + onEose = callbacks.oneose; + onClose = callbacks.onclose; + return { close: closeFn } as SubCloser; + }, + ), + publish: publishMock, + close: vi.fn(), + } as unknown as SimplePool; + + return { + pool, + triggerEose: () => onEose?.(), + triggerClose: (reasons: string[]) => onClose?.(reasons), + publishMock, + }; +} + +function makeClient(pool: SimplePool) { + const privateKey = makePrivateKey(); + const pairedKey = makePrivateKey(); + return new RelayClient( + { + explicitRelayUrls: ["wss://test.relay:443"], + signerPrivateKey: privateKey, + pairedPublicKey: pairedKey, + logNetworkActivity: false, + }, + pool, + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("RelayClient — publish failure triggers disconnect", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("emits disconnect on publish failure", async () => { + const { pool, triggerEose, publishMock } = makeMockPool(); + const client = makeClient(pool); + + const disconnects: Error[] = []; + client.on("disconnect", (err: Error) => disconnects.push(err)); + + await client.connect(); + triggerEose(); + + publishMock.mockReturnValueOnce([Promise.reject(new Error("send failed"))]); + + await expect( + client.relay({ + action: "dapp_ready" as any, + time: Math.floor(Date.now() / 1000), + }), + ).rejects.toThrow(); // Promise.any wraps in AggregateError + + expect(disconnects).toHaveLength(1); + }); + + it("still throws the error to the caller", async () => { + const { pool, triggerEose, publishMock } = makeMockPool(); + const client = makeClient(pool); + client.on("disconnect", () => {}); // prevent unhandled + + await client.connect(); + triggerEose(); + + publishMock.mockReturnValueOnce([Promise.reject(new Error("relay down"))]); + + await expect( + client.relay({ + action: "dapp_ready" as any, + time: Math.floor(Date.now() / 1000), + }), + ).rejects.toThrow(); + }); + + it("does not double-emit disconnect from publish failure + onclose race", async () => { + const { pool, triggerEose, triggerClose, publishMock } = makeMockPool(); + const client = makeClient(pool); + + const disconnects: Error[] = []; + client.on("disconnect", (err: Error) => disconnects.push(err)); + + await client.connect(); + triggerEose(); + + publishMock.mockReturnValueOnce([Promise.reject(new Error("send failed"))]); + + await expect( + client.relay({ + action: "dapp_ready" as any, + time: Math.floor(Date.now() / 1000), + }), + ).rejects.toThrow(); + + // Subscription onclose also fires (race condition) + triggerClose(["relay gone"]); + expect(disconnects).toHaveLength(1); + }); + + it("reconnect resets the disconnect guard so future failures emit again", async () => { + const { pool, triggerEose, publishMock } = makeMockPool(); + const client = makeClient(pool); + + const disconnects: Error[] = []; + client.on("disconnect", (err: Error) => disconnects.push(err)); + + await client.connect(); + triggerEose(); + + // First publish failure + publishMock.mockReturnValueOnce([Promise.reject(new Error("fail 1"))]); + await client + .relay({ action: "dapp_ready" as any, time: 1 }) + .catch(() => {}); + expect(disconnects).toHaveLength(1); + + // Reconnect + await client.connect(); + triggerEose(); + + // Second publish failure — should emit again + publishMock.mockReturnValueOnce([Promise.reject(new Error("fail 2"))]); + await client + .relay({ action: "dapp_ready" as any, time: 2 }) + .catch(() => {}); + expect(disconnects).toHaveLength(2); + }); +}); + +describe("RelayClient — isConnected", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns false before connect", () => { + const { pool } = makeMockPool(); + const client = makeClient(pool); + expect(client.isConnected()).toBe(false); + }); + + it("returns true after connect", async () => { + const { pool } = makeMockPool(); + const client = makeClient(pool); + await client.connect(); + expect(client.isConnected()).toBe(true); + }); + + it("returns false after disconnect", async () => { + const { pool } = makeMockPool(); + const client = makeClient(pool); + await client.connect(); + await client.disconnect(); + expect(client.isConnected()).toBe(false); + }); +});