diff --git a/.ackrc b/.ackrc index 244cbf6..4bb23ec 100644 --- a/.ackrc +++ b/.ackrc @@ -1,6 +1,5 @@ --ignore-dir=docs --ignore-dir=dist --ignore-dir=build -# --ignore-dir=__tests__ --ignore-dir=.svelte-kit --ignore-file=match:yarn.lock diff --git a/packages/app/__tests__/commands.test.ts b/packages/app/__tests__/commands.test.ts deleted file mode 100644 index d2ea6d2..0000000 --- a/packages/app/__tests__/commands.test.ts +++ /dev/null @@ -1,272 +0,0 @@ -import {ctx} from "@welshman/lib" -import {FOLLOWS, MUTES, PINS} from "@welshman/util" -import {afterEach, beforeEach, describe, expect, it, vi} from "vitest" -import {follow, mute, pin, unfollow, unmute, unpin} from "../src/commands" -import * as thunkModule from "../src/thunk" -import {thunkWorker} from "../src/thunk" -import {repository} from "../src/core" - -vi.mock(import("@welshman/lib"), async importOriginal => ({ - ...(await importOriginal()), - ctx: { - app: { - router: { - FromUser: vi.fn().mockReturnValue({ - getUrls: vi.fn().mockReturnValue(["relay1", "relay2"]), - }), - }, - }, - net: { - getExecutor: vi.fn(), - optimizeSubscriptions: vi.fn().mockReturnValue([]), - }, - }, -})) - -vi.mock(import("../src/session"), async importOriginal => ({ - ...(await importOriginal()), - nip44EncryptToSelf: vi.fn().mockImplementation(text => `encrypted:${text}`), - pubkey: { - get: () => "ee".repeat(32), - subscribe: run => { - run("ee".repeat(32)) - return () => null - }, - }, -})) - -describe("commands", () => { - const pubkey1 = "aa".repeat(32) - const pubkey2 = "bb".repeat(32) - - const event1 = "ee".repeat(32) - const event2 = "ff".repeat(32) - - beforeEach(async () => { - vi.useFakeTimers() - vi.setSystemTime(new Date()) - vi.resetModules() - vi.clearAllMocks() - - repository.load([]) - }) - - afterEach(() => { - vi.clearAllTimers() - vi.useRealTimers() - - thunkWorker.clear() - thunkWorker.pause() - thunkWorker.resume() - }) - - describe("follow commands", () => { - it("should create new follows list if none exists", async () => { - const publishThunkSpy = vi.spyOn(thunkModule, "publishThunk") - await follow(["p", pubkey1]) - await vi.runAllTimersAsync() - - expect(publishThunkSpy).toHaveBeenCalledWith( - expect.objectContaining({ - event: expect.objectContaining({ - kind: FOLLOWS, - tags: expect.arrayContaining([["p", pubkey1]]), - }), - relays: ["relay1", "relay2"], - }), - ) - }) - - it("should use existing follows list if available", async () => { - const publishThunkSpy = vi.spyOn(thunkModule, "publishThunk") - - await follow(["p", pubkey1]) - - await vi.runAllTimersAsync() - - await follow(["p", pubkey2]) - - await vi.runAllTimersAsync() - - expect(publishThunkSpy).toHaveBeenCalledWith( - expect.objectContaining({ - event: expect.objectContaining({ - kind: FOLLOWS, - tags: expect.arrayContaining([ - ["p", pubkey1], - ["p", pubkey2], - ]), - }), - relays: ["relay1", "relay2"], - }), - ) - }) - - it("should handle unfollow command", async () => { - const publishThunkSpy = vi.spyOn(thunkModule, "publishThunk") - - await follow(["p", pubkey1]) - - await vi.runAllTimersAsync() - - await unfollow(pubkey1) - - expect(publishThunkSpy).toHaveBeenCalledWith( - expect.objectContaining({ - event: expect.objectContaining({ - kind: FOLLOWS, - tags: expect.arrayContaining([]), - }), - relays: ["relay1", "relay2"], - }), - ) - }) - }) - - describe("mute commands", () => { - it("should create new mutes list if none exists", async () => { - const publishThunkSpy = vi.spyOn(thunkModule, "publishThunk") - - await mute(["p", pubkey1]) - - await vi.runAllTimersAsync() - - expect(publishThunkSpy).toHaveBeenCalledWith({ - event: expect.objectContaining({ - kind: MUTES, - tags: expect.arrayContaining([["p", pubkey1]]), - }), - relays: ["relay1", "relay2"], - }) - }) - - it.skip("should use existing mutes list if available", async () => { - const publishThunkSpy = vi.spyOn(thunkModule, "publishThunk") - - await mute(["p", pubkey1]) - - await vi.runAllTimersAsync() - - await mute(["p", pubkey2]) - - await vi.runAllTimersAsync() - - expect(publishThunkSpy).toHaveBeenCalledWith( - expect.objectContaining({ - event: expect.objectContaining({ - kind: MUTES, - tags: expect.arrayContaining([ - ["p", pubkey1], - ["p", pubkey2], - ]), - }), - relays: ["relay1", "relay2"], - }), - ) - }) - - it("should handle unmute command", async () => { - const publishThunkSpy = vi.spyOn(thunkModule, "publishThunk") - - await mute(["p", pubkey1]) - - await vi.runAllTimersAsync() - - await unmute("pubkey1") - - expect(publishThunkSpy).toHaveBeenCalledWith( - expect.objectContaining({ - event: expect.objectContaining({ - kind: MUTES, - tags: expect.arrayContaining([]), - }), - relays: ["relay1", "relay2"], - }), - ) - }) - }) - - describe("pin commands", () => { - it("should create new pins list if none exists", async () => { - const publishThunkSpy = vi.spyOn(thunkModule, "publishThunk") - await pin(["e", event1]) - - await vi.runAllTimersAsync() - - expect(publishThunkSpy).toHaveBeenCalledWith( - expect.objectContaining({ - event: expect.objectContaining({ - kind: PINS, - tags: expect.arrayContaining([["e", event1]]), - }), - relays: ["relay1", "relay2"], - }), - ) - }) - - it.skip("should use existing pins list if available", async () => { - const publishThunkSpy = vi.spyOn(thunkModule, "publishThunk") - - await pin(["e", event1]) - - await vi.runAllTimersAsync() - - await pin(["e", event2]) - - await vi.runAllTimersAsync() - - expect(publishThunkSpy).toHaveBeenCalledWith( - expect.objectContaining({ - event: expect.objectContaining({ - kind: PINS, - tags: expect.arrayContaining([ - ["e", event1], - ["e", event2], - ]), - }), - relays: ["relay1", "relay2"], - }), - ) - }) - - it("should handle unpin command", async () => { - const publishThunkSpy = vi.spyOn(thunkModule, "publishThunk") - - await pin(["e", event1]) - - await vi.runAllTimersAsync() - - await unpin("event1") - - expect(publishThunkSpy).toHaveBeenCalledWith( - expect.objectContaining({ - event: expect.objectContaining({ - kind: PINS, - tags: expect.arrayContaining([]), - }), - relays: ["relay1", "relay2"], - }), - ) - }) - }) - - describe("relay selection", () => { - it("should use correct relays from router", async () => { - const publishThunkSpy = vi.spyOn(thunkModule, "publishThunk") - const mockGetUrls = vi.fn().mockReturnValue(["relay3", "relay4"]) - vi.mocked(ctx.app.router.FromUser).mockReturnValue({ - getUrls: mockGetUrls, - }) - - await follow(["p", pubkey1]) - - expect(ctx.app.router.FromUser).toHaveBeenCalled() - expect(mockGetUrls).toHaveBeenCalled() - expect(publishThunkSpy).toHaveBeenCalledWith( - expect.objectContaining({ - relays: ["relay3", "relay4"], - }), - ) - }) - }) -}) diff --git a/packages/app/__tests__/relay.test.ts b/packages/app/__tests__/relay.test.ts deleted file mode 100644 index 1ab5a62..0000000 --- a/packages/app/__tests__/relay.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import {describe, it, expect, vi, beforeEach, afterEach} from "vitest" -import {ConnectionEvent} from "@welshman/net" -import type {Connection} from "@welshman/net" -import {now} from "@welshman/lib" -import {Relay, relays} from "../src/relays" -import {trackRelayStats} from "../src/relays" -import {get} from "svelte/store" - -describe("Relay Stats", () => { - const mockUrl = "wss://test.relay" - let mockConnection: Connection - - beforeEach(() => { - vi.useFakeTimers() - // Reset relays store - relays.set([]) - - // Create mock connection - mockConnection = { - url: mockUrl, - state: { - pendingPublishes: new Map(), - pendingRequests: new Map(), - }, - on: vi.fn(), - off: vi.fn(), - emit: vi.fn(), - } as any - }) - - afterEach(() => { - vi.useRealTimers() - vi.clearAllMocks() - }) - - describe("trackRelayStats", () => { - it("should subscribe to all connection events", () => { - trackRelayStats(mockConnection) - - expect(mockConnection.on).toHaveBeenCalledWith(ConnectionEvent.Open, expect.any(Function)) - expect(mockConnection.on).toHaveBeenCalledWith(ConnectionEvent.Close, expect.any(Function)) - expect(mockConnection.on).toHaveBeenCalledWith(ConnectionEvent.Send, expect.any(Function)) - expect(mockConnection.on).toHaveBeenCalledWith(ConnectionEvent.Receive, expect.any(Function)) - expect(mockConnection.on).toHaveBeenCalledWith(ConnectionEvent.Error, expect.any(Function)) - }) - - it("should unsubscribe from all events when cleanup is called", () => { - const cleanup = trackRelayStats(mockConnection) - cleanup() - - expect(mockConnection.off).toHaveBeenCalledWith(ConnectionEvent.Open, expect.any(Function)) - expect(mockConnection.off).toHaveBeenCalledWith(ConnectionEvent.Close, expect.any(Function)) - expect(mockConnection.off).toHaveBeenCalledWith(ConnectionEvent.Send, expect.any(Function)) - expect(mockConnection.off).toHaveBeenCalledWith(ConnectionEvent.Receive, expect.any(Function)) - expect(mockConnection.off).toHaveBeenCalledWith(ConnectionEvent.Error, expect.any(Function)) - }) - }) - - describe("Connection Event Handlers", () => { - let eventHandlers: Record = {} - - beforeEach(() => { - eventHandlers = {} - mockConnection.on.mockImplementation((event, handler) => { - eventHandlers[event] = handler - }) - trackRelayStats(mockConnection) - - // Add initial relay to the store - relays.set([{url: mockUrl}]) - - // Allow batched updates to process - vi.runAllTimers() - }) - - it("should update stats on connection open", () => { - eventHandlers[ConnectionEvent.Open](mockConnection) - vi.runAllTimers() - - const updatedRelays = get(relays) as Relay[] - expect(updatedRelays[0].stats?.open_count).toBe(1) - expect(updatedRelays[0].stats?.last_open).toBeGreaterThan(0) - }) - - it("should update stats on connection close", () => { - eventHandlers[ConnectionEvent.Close](mockConnection) - vi.runAllTimers() - - const updatedRelays = get(relays) as Relay[] - expect(updatedRelays[0].stats?.close_count).toBe(1) - expect(updatedRelays[0].stats?.last_close).toBeGreaterThan(0) - }) - - it("should update stats on REQ send", () => { - eventHandlers[ConnectionEvent.Send](mockConnection, ["REQ", "test"]) - vi.runAllTimers() - - const updatedRelays = get(relays) as Relay[] - expect(updatedRelays[0].stats?.request_count).toBe(1) - expect(updatedRelays[0].stats?.last_request).toBeGreaterThanOrEqual(now() - 1) - }) - - it("should update stats on EVENT send", () => { - eventHandlers[ConnectionEvent.Send](mockConnection, ["EVENT", {}]) - vi.runAllTimers() - - const updatedRelays = get(relays) as Relay[] - expect(updatedRelays[0].stats?.publish_count).toBe(1) - expect(updatedRelays[0].stats?.last_publish).toBeGreaterThanOrEqual(now() - 1) - }) - - it("should update stats on OK receive with success", () => { - const eventId = "test-event" - mockConnection.state.pendingPublishes.set(eventId, {sent: now() - 1000}) - - eventHandlers[ConnectionEvent.Receive](mockConnection, ["OK", eventId, true]) - vi.runAllTimers() - - const updatedRelays = get(relays) as Relay[] - expect(updatedRelays[0].stats?.publish_success_count).toBe(1) - expect(updatedRelays[0].stats?.publish_timer).toBe(1000) - }) - - it("should update stats on OK receive with failure", () => { - const eventId = "test-event" - mockConnection.state.pendingPublishes.set(eventId, {sent: Date.now() - 1000}) - - eventHandlers[ConnectionEvent.Receive](mockConnection, ["OK", eventId, false]) - vi.runAllTimers() - - const updatedRelays = get(relays) as Relay[] - expect(updatedRelays[0].stats?.publish_failure_count).toBe(1) - }) - - it("should update stats on EOSE receive", () => { - const subId = "test-sub" - mockConnection.state.pendingRequests.set(subId, {sent: now() - 1000}) - - eventHandlers[ConnectionEvent.Receive](mockConnection, ["EOSE", subId]) - vi.runAllTimers() - - const updatedRelays = get(relays) as Relay[] - expect(updatedRelays[0].stats?.eose_count).toBe(1) - expect(updatedRelays[0].stats?.eose_timer).toBe(1000) - }) - - it("should update stats on error", () => { - eventHandlers[ConnectionEvent.Error](mockConnection) - vi.runAllTimers() - - const updatedRelays = get(relays) as Relay[] - expect(updatedRelays[0].stats?.last_error).toBeGreaterThan(0) - expect(updatedRelays[0].stats?.recent_errors).toHaveLength(1) - }) - - it("should limit recent errors to 10", () => { - // Trigger 12 errors - for (let i = 0; i < 12; i++) { - eventHandlers[ConnectionEvent.Error](mockConnection) - vi.advanceTimersByTime(1000) - } - - const updatedRelays = get(relays) as Relay[] - expect(updatedRelays[0].stats?.recent_errors).toHaveLength(10) - }) - }) -}) diff --git a/packages/app/__tests__/relaySelection.test.ts b/packages/app/__tests__/relaySelection.test.ts deleted file mode 100644 index 47271b9..0000000 --- a/packages/app/__tests__/relaySelection.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import * as util from "@welshman/util" -import {afterEach, beforeEach, describe, expect, test, vi} from "vitest" -import * as relaySelectionModule from "../src/relaySelections" - -// Mock dependencies -vi.mock("@welshman/util", async imports => { - return { - ...(await imports()), - normalizeRelayUrl: vi.fn(url => url), - asDecryptedEvent: vi.fn(event => event), - readList: vi.fn(), - getListTags: vi.fn(() => []), - getRelayTags: vi.fn(() => []), - getRelayTagValues: vi.fn(() => []), - } -}) - -vi.mock("@welshman/store", async imports => { - return { - ...(await imports()), - deriveEventsMapped: vi.fn(() => ({subscribe: () => ({unsubscribe: () => {}})})), - } -}) - -vi.mock("../src/subscribe.js", () => ({ - load: vi.fn().mockResolvedValue([]), -})) - -vi.mock("../src/collection.js", () => ({ - collection: vi.fn(() => ({ - indexStore: {}, - deriveItem: vi.fn(), - loadItem: vi.fn(), - })), -})) - -describe("relaySelections", () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - describe("getRelayUrls", () => { - test("returns unique normalized relay URLs", () => { - // Setup - const mockList = { - tags: [ - ["r", "wss://relay1.com"], - ["r", "wss://relay2.com"], - ], - } - vi.mocked(util.getListTags).mockReturnValue(mockList.tags) - vi.mocked(util.getRelayTagValues).mockReturnValue([ - "wss://relay1.com", - "wss://relay2.com", - "wss://relay1.com", - ]) - - // Execute - const result = relaySelectionModule.getRelayUrls(mockList) - - // Verify - expect(util.getListTags).toHaveBeenCalledWith(mockList) - expect(util.getRelayTagValues).toHaveBeenCalledWith(mockList.tags) - expect(util.normalizeRelayUrl).toHaveBeenCalledTimes(3) - expect(result).toEqual(["wss://relay1.com", "wss://relay2.com"]) - }) - - test("returns empty array when list is undefined", () => { - vi.mocked(util.getListTags).mockReturnValue([]) - vi.mocked(util.getRelayTagValues).mockReturnValue([]) - - const result = relaySelectionModule.getRelayUrls(undefined) - - expect(result).toEqual([]) - }) - }) - - describe("getReadRelayUrls", () => { - test("returns read relay URLs", () => { - // Setup - const mockTags = [ - ["r", "wss://relay1.com", "read"], - ["r", "wss://relay2.com", "write"], - ["r", "wss://relay3.com"], // no marker is also read - ["r", "wss://relay4.com", "read"], - ] - vi.mocked(util.getListTags).mockReturnValue(mockTags) - vi.mocked(util.getRelayTags).mockReturnValue(mockTags) - - // Execute - const result = relaySelectionModule.getReadRelayUrls({tags: mockTags}) - - // Verify - expect(result).toEqual(["wss://relay1.com", "wss://relay3.com", "wss://relay4.com"]) - }) - }) - - describe("getWriteRelayUrls", () => { - test("returns write relay URLs", () => { - // Setup - const mockTags = [ - ["r", "wss://relay1.com", "read"], - ["r", "wss://relay2.com", "write"], - ["r", "wss://relay3.com"], // no marker is also write - ["r", "wss://relay4.com", "write"], - ] - vi.mocked(util.getListTags).mockReturnValue(mockTags) - vi.mocked(util.getRelayTags).mockReturnValue(mockTags) - - // Execute - const result = relaySelectionModule.getWriteRelayUrls({tags: mockTags}) - - // Verify - expect(result).toEqual(["wss://relay2.com", "wss://relay3.com", "wss://relay4.com"]) - }) - }) -}) diff --git a/packages/app/__tests__/router.test.ts b/packages/app/__tests__/router.test.ts deleted file mode 100644 index 7aef0ab..0000000 --- a/packages/app/__tests__/router.test.ts +++ /dev/null @@ -1,296 +0,0 @@ -import {ctx, now} from "@welshman/lib" -import {COMMENT, PROFILE, RELAYS, TrustedEvent} from "@welshman/util" -import {beforeEach, describe, expect, it, vi} from "vitest" -import {relaysByUrl} from "../src/relays" -import {relaySelectionsByPubkey} from "../src/relaySelections" -import { - RelayMode, - Router, - addMaximalFallbacks, - addMinimalFallbacks, - addNoFallbacks, - getFilterSelections, - getPubkeyRelays, - getRelayQuality, - makeRouter, -} from "../src/router" - -// Mock dependencies -vi.mock(import("@welshman/lib"), async imports => ({ - ...(await imports()), - ctx: { - net: { - pool: { - has: vi.fn(), - }, - }, - app: { - indexerRelays: ["wss://indexer1.com", "wss://indexer2.com"], - }, - }, -})) - -vi.mock(import("../src/relays"), async imports => ({ - ...(await imports()), - relaysByUrl: { - get: vi.fn(), - }, -})) - -vi.mock(import("../src/relaySelections"), async imports => ({ - ...(await imports()), - relaySelectionsByPubkey: { - get: vi.fn().mockReturnValue(new Map()), - }, - inboxRelaySelectionsByPubkey: { - get: vi.fn().mockReturnValue(new Map()), - }, -})) - -describe("Router", () => { - const id = "00".repeat(32) - const pubkey = "aa".repeat(32) - const pubkey1 = "bb".repeat(32) - const pubkey2 = "cc".repeat(32) - let router: Router - const mockEvent: TrustedEvent = { - id, - pubkey, - created_at: now(), - kind: COMMENT, - tags: [ - ["E", "11".repeat(32), "wss://relay.com", pubkey1], - ["P", pubkey2, "wss://relay2.com"], - ], - content: "test content", - sig: "test-sig", - } - - beforeEach(() => { - vi.clearAllMocks() - router = makeRouter({ - getUserPubkey: () => pubkey, - getPubkeyRelays: (user: string, mode?: RelayMode) => [`wss://${mode}.${user.slice(-4)}.com`], - getFallbackRelays: () => ["wss://fallback1.com", "wss://fallback2.com"], - getRelayQuality: () => 1, - getLimit: () => 2, - }) - ctx.app.router = router - }) - - describe("Basic Router Functions", () => { - it("should create router with default options", () => { - const router = makeRouter() - expect(router).toBeInstanceOf(Router) - }) - - it("should respect limit option", () => { - const urls = router.FromRelays(["wss://1.com", "wss://2.com", "wss://3.com"]).getUrls() - expect(urls).toHaveLength(2) - }) - - it("should filter invalid relay URLs", () => { - const urls = router.FromRelays(["invalid", "wss://valid.com"]).getUrls() - expect(urls).toHaveLength(2) - // invalid should be filtered out - expect(urls.includes("invalid")).toBe(false) - // one of the relay should be a fallback - expect(urls.some(url => url.startsWith("wss://fallback"))).toBe(true) - expect(urls[0]).toBe("wss://valid.com/") - }) - }) - - describe("Fallback Policies", () => { - it("should implement no fallbacks policy", () => { - expect(addNoFallbacks(1, 3)).toBe(0) - expect(addNoFallbacks(0, 3)).toBe(0) - }) - - it("should implement minimal fallbacks policy", () => { - expect(addMinimalFallbacks(1, 3)).toBe(0) - expect(addMinimalFallbacks(0, 3)).toBe(1) - }) - - it("should implement maximal fallbacks policy", () => { - expect(addMaximalFallbacks(1, 3)).toBe(2) - expect(addMaximalFallbacks(0, 3)).toBe(3) - }) - }) - - describe("RouterScenario", () => { - it("should apply weight to selections", () => { - const scenario = router.FromRelays(["wss://1.com", "wss://2.com"]).weight(0.5) - - expect(scenario.selections[0].weight).toBe(0.5) - }) - - it("should merge scenarios", () => { - const scenario1 = router.FromRelays(["wss://1.com"]) - const scenario2 = router.FromRelays(["wss://2.com"]) - const merged = router.merge([scenario1, scenario2]) - - expect(merged.selections).toHaveLength(2) - }) - - it("should respect security options", () => { - const urls = router - .FromRelays(["ws://insecure.com", "wss://secure.com"]) - .allowInsecure(false) - .getUrls() - - expect(urls).toContain("wss://secure.com/") - expect(urls).not.toContain("ws://insecure.com/") - }) - }) - - describe("Routing Scenarios", () => { - describe("ForUser/FromUser", () => { - it("should handle user routing", () => { - const readUrls = router.ForUser().getUrls() - const writeUrls = router.FromUser().getUrls() - - expect(readUrls).toContain(`wss://read.${pubkey.slice(-4)}.com/`) - expect(writeUrls).toContain(`wss://write.${pubkey.slice(-4)}.com/`) - }) - }) - - describe("Event Routing", () => { - it("should route for event author", () => { - const urls = router.Event(mockEvent).getUrls() - expect(urls[0]).toBe(`wss://write.${mockEvent.pubkey.slice(-4)}.com/`) - expect(urls.length).toBeGreaterThan(0) - }) - - it("should handle event replies", () => { - const urls = router.Replies(mockEvent).getUrls() - expect(urls[0]).toBe(`wss://read.${mockEvent.pubkey.slice(-4)}.com/`) - expect(urls.length).toBeGreaterThan(0) - }) - - it("should handle event ancestors", () => { - const urls = router.EventRoots(mockEvent).getUrls() - // should have the relay of the mention and the relay of the parent - expect(urls.length).toBe(2) - // @check, super random results - // expect(urls).contains("wss://relay.com/") - // expect(urls).contains("wss://relay2.com/") - }) - }) - - describe("Pubkey Routing", () => { - it("should route for single pubkey", () => { - const urls = router.ForPubkey("test-pubkey").getUrls() - expect(urls.length).toBeGreaterThan(0) - }) - - it("should route for multiple pubkeys", () => { - const urls = router.ForPubkeys(["pubkey1", "pubkey2"]).getUrls() - expect(urls.length).toBeGreaterThan(0) - }) - }) - }) - - describe("Relay Quality", () => { - beforeEach(() => { - vi.mocked(relaysByUrl.get).mockReturnValue( - new Map([ - [ - "wss://relay.com", - { - url: "wss://relay.com", - stats: { - recent_errors: [], - }, - }, - ], - [ - "wss://error.com", - { - url: "wss://error.com", - stats: { - recent_errors: [Date.now()], - }, - }, - ], - ]), - ) - }) - - it("should score connected relays highly", () => { - vi.mocked(ctx.net.pool.has).mockReturnValue(true) - expect(getRelayQuality("wss://relay.com")).toBe(1) - }) - - it("should penalize relays with recent errors", () => { - expect(getRelayQuality("wss://error.com")).toBe(0) - }) - - it("should handle relays without stats", () => { - vi.mocked(ctx.net.pool.has).mockReturnValue(false) - expect(getRelayQuality("wss://new.com")).toBe(0.8) - }) - }) - - describe("Relay Selection", () => { - beforeEach(() => { - vi.mocked(relaySelectionsByPubkey.get).mockReturnValue( - new Map([ - [ - "pubkey1", - { - event: {pubkey: "pubkey1"}, - publicTags: [ - ["r", "wss://read.com", "read"], - ["r", "wss://write.com", "write"], - ], - }, - ], - ]), - ) - }) - - it("should get read relays for pubkey", () => { - const relays = getPubkeyRelays("pubkey1", RelayMode.Read) - expect(relays).toContain("wss://read.com/") - }) - - it("should get write relays for pubkey", () => { - const relays = getPubkeyRelays("pubkey1", RelayMode.Write) - expect(relays).toContain("wss://write.com/") - }) - - it("should handle missing relay selections", () => { - const relays = getPubkeyRelays("unknown-pubkey") - expect(relays).toEqual([]) - }) - }) - - describe("Filter Selections", () => { - it("should handle search filters", () => { - const selections = getFilterSelections([ - { - search: "test", - }, - ]) - expect(selections.length).toBeGreaterThan(0) - }) - - it("should handle author filters", () => { - const selections = getFilterSelections([ - { - authors: ["pubkey1", "pubkey2"], - }, - ]) - expect(selections.length).toBeGreaterThan(0) - }) - - it("should handle indexed kinds", () => { - const selections = getFilterSelections([ - { - kinds: [PROFILE, RELAYS], - }, - ]) - expect(selections.length).toBeGreaterThan(0) - }) - }) -}) diff --git a/packages/app/__tests__/search.test.ts b/packages/app/__tests__/search.test.ts deleted file mode 100644 index 900783a..0000000 --- a/packages/app/__tests__/search.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import {describe, it, expect, vi} from "vitest" -import {createSearch} from "../src/search" -import type {SearchOptions} from "../src/search" - -describe("createSearch", () => { - // Test data - type TestItem = { - id: string - name: string - description: string - } - - const testItems: TestItem[] = [ - {id: "1", name: "Apple", description: "A fruit"}, - {id: "2", name: "Banana", description: "Yellow fruit"}, - {id: "3", name: "Orange", description: "Citrus fruit"}, - ] - - const baseOptions: SearchOptions = { - getValue: item => item.id, - fuseOptions: { - keys: ["name", "description"], - }, - } - - it("should create a search object with required methods", () => { - const search = createSearch(testItems, baseOptions) - - expect(search).toHaveProperty("options") - expect(search).toHaveProperty("getValue") - expect(search).toHaveProperty("getOption") - expect(search).toHaveProperty("searchOptions") - expect(search).toHaveProperty("searchValues") - }) - - it("should return all items when search term is empty", () => { - const search = createSearch(testItems, baseOptions) - const results = search.searchOptions("") - - expect(results).toHaveLength(testItems.length) - expect(results).toEqual(testItems) - }) - - it("should find items by name", () => { - const search = createSearch(testItems, baseOptions) - const results = search.searchOptions("Apple") - - expect(results).toHaveLength(1) - expect(results[0].name).toBe("Apple") - }) - - it("should find items by description", () => { - const search = createSearch(testItems, baseOptions) - const results = search.searchOptions("Citrus") - - expect(results).toHaveLength(1) - expect(results[0].name).toBe("Orange") - }) - - it("should return values using getValue function", () => { - const search = createSearch(testItems, baseOptions) - const results = search.searchValues("Apple") - - expect(results).toHaveLength(1) - expect(results[0]).toBe("1") // The id of Apple - }) - - it("should get option by value", () => { - const search = createSearch(testItems, baseOptions) - const item = search.getOption("1") - - expect(item).toBeDefined() - expect(item?.name).toBe("Apple") - }) - - it("should call onSearch callback when provided", () => { - const onSearch = vi.fn() - const search = createSearch(testItems, {...baseOptions, onSearch}) - - search.searchOptions("test") - expect(onSearch).toHaveBeenCalledWith("test") - }) - - it("should apply custom sort function when provided", () => { - const sortFn = vi.fn() - const items = [ - {id: "1", name: "test item", description: "exact match"}, - {id: "2", name: "testing", description: "partial match"}, - {id: "3", name: "other", description: "test somewhere"}, - ] - - const search = createSearch(items, {...baseOptions, sortFn}) - const results = search.searchOptions("test") - // Results should be sorted by score - expect(sortFn).toHaveBeenCalled() - }) - - it("should handle fuzzy matching", () => { - const search = createSearch(testItems, baseOptions) - const results = search.searchOptions("Aple") // Misspelled "Apple" - - expect(results.length).toBe(2) - expect(results[0].name).toBe("Apple") - }) - - it("should respect fuseOptions threshold", () => { - const search = createSearch(testItems, { - ...baseOptions, - fuseOptions: { - ...baseOptions.fuseOptions, - threshold: 0.1, // Very strict matching - }, - }) - - const results = search.searchOptions("Aple") - expect(results).toHaveLength(0) // Should not match with strict threshold - }) -}) diff --git a/packages/app/__tests__/storage.test.ts b/packages/app/__tests__/storage.test.ts index afdd23a..a3e0a50 100644 --- a/packages/app/__tests__/storage.test.ts +++ b/packages/app/__tests__/storage.test.ts @@ -1,6 +1,6 @@ import {describe, it, expect, vi, beforeEach, afterEach} from "vitest" import {writable, get} from "svelte/store" -import {Repository} from "@welshman/util" +import {Repository} from "@welshman/relay" import {Tracker} from "@welshman/net" import { initStorage, diff --git a/packages/app/__tests__/subscribe.test.ts b/packages/app/__tests__/subscribe.test.ts deleted file mode 100644 index 6da54c7..0000000 --- a/packages/app/__tests__/subscribe.test.ts +++ /dev/null @@ -1,384 +0,0 @@ -import {ctx} from "@welshman/lib" -import {subscribe as baseSubscribe, SubscriptionEvent} from "@welshman/net" -import {getFilterResultCardinality, LOCAL_RELAY_URL} from "@welshman/util" -import {afterEach, beforeEach, describe, expect, it, vi} from "vitest" -import {repository} from "../src/core.js" -import {load, subscribe} from "../src/subscribe.ts" - -// Mock dependencies -vi.mock("@welshman/lib", async () => ({ - ctx: { - app: { - requestDelay: 50, - authTimeout: 500, - requestTimeout: 3000, - }, - }, - isNil: vi.fn(x => x === null || x === undefined), -})) - -vi.mock("@welshman/util", async () => ({ - LOCAL_RELAY_URL: "ws://localhost:3000", - getFilterResultCardinality: vi.fn(), -})) - -vi.mock("@welshman/net", async () => { - const mockEmitter = { - emit: vi.fn(), - on: vi.fn(), - close: vi.fn(), - } - - return { - subscribe: vi.fn(() => mockEmitter), - SubscriptionEvent: { - Event: "event", - Complete: "complete", - Eose: "eose", - }, - } -}) - -vi.mock("../src/core.js", async () => ({ - repository: { - query: vi.fn(() => []), - }, -})) - -describe("subscribe.ts", () => { - beforeEach(() => { - vi.clearAllMocks() - vi.useFakeTimers() - }) - - afterEach(() => { - vi.restoreAllMocks() - vi.useRealTimers() - }) - - describe("subscribe", () => { - it("should pass request to baseSubscribe with default options", () => { - const request = { - filters: [{kinds: [1], limit: 10}], - } - - const result = subscribe(request) - - // Assert - expect(baseSubscribe).toHaveBeenCalledWith({ - filters: [{kinds: [1], limit: 10}], - relays: [], - delay: ctx.app.requestDelay, - authTimeout: ctx.app.authTimeout, - timeout: 0, // timeout should be 0 when closeOnEose is not set - }) - expect(result).toBeDefined() - }) - - it("should check filter cardinality when closeOnEose is true", () => { - const request = { - filters: [{kinds: [1], limit: 10}], - closeOnEose: true, - } - vi.mocked(getFilterResultCardinality).mockReturnValue(10) - - subscribe(request) - - // Assert - expect(getFilterResultCardinality).toHaveBeenCalledWith({kinds: [1], limit: 10}) - }) - - it("should use cached results when filter cardinality matches repository results", () => { - // Arrange - const filter = {kinds: [1], limit: 2} - const cachedEvents = [ - {id: "1", kind: 1, content: "test1", tags: [], pubkey: "pk1", created_at: 123, sig: "sig1"}, - {id: "2", kind: 1, content: "test2", tags: [], pubkey: "pk2", created_at: 456, sig: "sig2"}, - ] - - const request = { - filters: [filter], - closeOnEose: true, - } - - vi.mocked(getFilterResultCardinality).mockReturnValue(2) - vi.mocked(repository.query).mockReturnValue(cachedEvents) - - // Act - const sub = subscribe(request) - vi.runAllTimers() // Run setTimeout - - // Assert - expect(repository.query).toHaveBeenCalledWith([filter]) - expect(sub.emit).toHaveBeenCalledWith( - SubscriptionEvent.Event, - LOCAL_RELAY_URL, - cachedEvents[0], - ) - expect(sub.emit).toHaveBeenCalledWith( - SubscriptionEvent.Event, - LOCAL_RELAY_URL, - cachedEvents[1], - ) - expect(request.filters).toEqual([]) // All filters should be removed - }) - - it("should keep filter when repository has fewer results than cardinality", () => { - // Arrange - const filter = {kinds: [1], limit: 10} - const cachedEvents = [ - {id: "1", kind: 1, content: "test1", tags: [], pubkey: "pk1", created_at: 123, sig: "sig1"}, - ] - - const request = { - filters: [filter], - closeOnEose: true, - } - - vi.mocked(getFilterResultCardinality).mockReturnValue(10) - vi.mocked(repository.query).mockReturnValue(cachedEvents) - - // Act - subscribe(request) - - // Assert - expect(repository.query).toHaveBeenCalledWith([filter]) - expect(request.filters).toEqual([filter]) // Filter should be kept - }) - - it("should set timeout when closeOnEose is true", () => { - // Arrange - const request = { - filters: [{kinds: [1], limit: 10}], - closeOnEose: true, - } - - // Act - subscribe(request) - - // Assert - expect(baseSubscribe).toHaveBeenCalledWith( - expect.objectContaining({ - timeout: ctx.app.requestTimeout, - }), - ) - }) - - it("should respect custom options", () => { - // Arrange - const request = { - filters: [{kinds: [1], limit: 10}], - relays: ["wss://relay.example.com"], - delay: 100, - timeout: 5000, - authTimeout: 1000, - } - - // Act - subscribe(request) - - // Assert - expect(baseSubscribe).toHaveBeenCalledWith( - expect.objectContaining({ - relays: ["wss://relay.example.com"], - delay: 100, - timeout: 5000, - authTimeout: 1000, - }), - ) - }) - - it("should emit cached events asynchronously", () => { - // Arrange - const filter = {kinds: [1], limit: 2} - const cachedEvents = [ - {id: "1", kind: 1, content: "test1", tags: [], pubkey: "pk1", created_at: 123, sig: "sig1"}, - {id: "2", kind: 1, content: "test2", tags: [], pubkey: "pk2", created_at: 456, sig: "sig2"}, - ] - - const request = { - filters: [filter], - closeOnEose: true, - } - - vi.mocked(getFilterResultCardinality).mockReturnValue(2) - vi.mocked(repository.query).mockReturnValue(cachedEvents) - - // Act - const sub = subscribe(request) - - // Assert - should not have emitted events synchronously - expect(sub.emit).not.toHaveBeenCalled() - - // Fast-forward timers - vi.runAllTimers() - - // Now events should be emitted - expect(sub.emit).toHaveBeenCalledTimes(2) - expect(sub.emit).toHaveBeenCalledWith( - SubscriptionEvent.Event, - LOCAL_RELAY_URL, - cachedEvents[0], - ) - expect(sub.emit).toHaveBeenCalledWith( - SubscriptionEvent.Event, - LOCAL_RELAY_URL, - cachedEvents[1], - ) - }) - }) - - describe("load", () => { - it("should return a promise that resolves with events", async () => { - // Arrange - const request = { - filters: [{kinds: [1], limit: 10}], - } - - const mockEvents = [ - {id: "1", kind: 1, content: "test1", tags: [], pubkey: "pk1", created_at: 123, sig: "sig1"}, - {id: "2", kind: 1, content: "test2", tags: [], pubkey: "pk2", created_at: 456, sig: "sig2"}, - ] - - // Mock the subscribe function and event handling - vi.mocked(baseSubscribe).mockReturnValue({ - on: (event, handler) => { - // Simulate events - if (event === SubscriptionEvent.Event) { - mockEvents.forEach(evt => handler(LOCAL_RELAY_URL, evt)) - } - - // Simulate completion - if (event === SubscriptionEvent.Complete) { - setTimeout(() => handler(), 0) - } - - return {unsubscribe: vi.fn()} - }, - emit: vi.fn(), - close: vi.fn(), - }) - - // Act - const promise = load(request) - vi.runAllTimers() // Run setTimeout for completion - - // Assert - const events = await promise - expect(events).toEqual(mockEvents) - expect(baseSubscribe).toHaveBeenCalledWith( - expect.objectContaining({ - closeOnEose: true, - timeout: ctx.app.requestTimeout, - }), - ) - }) - - it("should override load options with request options", async () => { - // Arrange - const request = { - filters: [{kinds: [1], limit: 10}], - closeOnEose: false, // This should be overridden - timeout: 1000, // This should be overridden - } - - // Mock minimal subscription to resolve immediately - vi.mocked(baseSubscribe).mockReturnValue({ - on: (event, handler) => { - if (event === SubscriptionEvent.Complete) { - setTimeout(() => handler(), 0) - } - return {unsubscribe: vi.fn()} - }, - emit: vi.fn(), - close: vi.fn(), - }) - - const promise = load(request) - vi.runAllTimers() - - await promise - expect(baseSubscribe).toHaveBeenCalledWith( - expect.objectContaining({ - closeOnEose: false, - timeout: 1000, - }), - ) - }) - - it("should collect events from multiple sources", async () => { - // Arrange - const request = { - filters: [{kinds: [1], limit: 10}], - } - - const localEvents = [ - { - id: "1", - kind: 1, - content: "local1", - tags: [], - pubkey: "pk1", - created_at: 123, - sig: "sig1", - }, - ] - - const remoteEvents = [ - { - id: "2", - kind: 1, - content: "remote1", - tags: [], - pubkey: "pk2", - created_at: 456, - sig: "sig2", - }, - ] - - // Simulate cached events triggering local events first - vi.mocked(getFilterResultCardinality).mockReturnValue(1) - vi.mocked(repository.query).mockReturnValue(localEvents) - - // Mock subscription that also receives remote events - let eventHandler: any - let completeHandler: any - - vi.mocked(baseSubscribe).mockReturnValue({ - on: (event: string, handler: any) => { - if (event === SubscriptionEvent.Event) { - eventHandler = handler - // Simulate cached events - localEvents.forEach(evt => handler(LOCAL_RELAY_URL, evt)) - } - if (event === SubscriptionEvent.Complete) { - completeHandler = handler - } - return {unsubscribe: vi.fn()} - }, - emit: vi.fn(), - close: vi.fn(), - }) - - const promise = load(request) - - // Simulate receiving remote events - if (eventHandler) { - remoteEvents.forEach(evt => eventHandler("wss://remote.com", evt)) - } - - // Simulate completion - if (completeHandler) { - setTimeout(() => completeHandler(), 0) - } - - vi.runAllTimers() - - // Assert - const events = await promise - expect(events.length).toBe(2) - expect(events).toContainEqual(localEvents[0]) - expect(events).toContainEqual(remoteEvents[0]) - }) - }) -}) diff --git a/packages/app/__tests__/tags.test.ts b/packages/app/__tests__/tags.test.ts index 885170a..a3e4209 100644 --- a/packages/app/__tests__/tags.test.ts +++ b/packages/app/__tests__/tags.test.ts @@ -11,42 +11,6 @@ import { tagZapSplit, } from "../src/tags" -// Mock dependencies -vi.mock(import("@welshman/lib"), async imports => ({ - ...(await imports()), - ctx: { - app: { - router: { - FromPubkey: vi.fn().mockReturnValue({ - getUrl: () => "pubkey-relay-url", - }), - Event: vi.fn().mockReturnValue({ - getUrl: () => "event-relay-url", - }), - EventRoots: vi.fn().mockReturnValue({ - getUrl: () => "roots-relay-url", - }), - EventParents: vi.fn().mockReturnValue({ - getUrl: () => "parents-relay-url", - }), - }, - }, - }, - uniq: vi.fn(arr => Array.from(new Set(arr))), - remove: vi.fn((item, arr) => arr.filter(x => x !== item)), - nthEq: vi.fn((n, val) => (arr: any[]) => arr[n] === val), -})) - -vi.mock("../src/session", () => ({ - pubkey: { - get: vi.fn().mockReturnValue("current-user-pubkey"), - }, -})) - -vi.mock("../src/profiles", () => ({ - displayProfileByPubkey: vi.fn().mockReturnValue("display-name"), -})) - describe("tags", () => { const id = "00".repeat(32) const id1 = "11".repeat(32) @@ -70,19 +34,19 @@ describe("tags", () => { describe("tagZapSplit", () => { it("should create zap split tag with default split", () => { const result = tagZapSplit(pubkey1) - expect(result).toEqual(["zap", pubkey1, "pubkey-relay-url", "1"]) + expect(result).toEqual(["zap", pubkey1, expect.any(String), "1"]) }) it("should create zap split tag with custom split", () => { const result = tagZapSplit(pubkey1, 0.5) - expect(result).toEqual(["zap", pubkey1, "pubkey-relay-url", "0.5"]) + expect(result).toEqual(["zap", pubkey1, expect.any(String), "0.5"]) }) }) describe("tagPubkey", () => { it("should create pubkey tag with relay hint and display name", () => { const result = tagPubkey(pubkey1) - expect(result).toEqual(["p", pubkey1, "pubkey-relay-url", "display-name"]) + expect(result).toEqual(["p", pubkey1, expect.any(String), expect.any(String)]) }) }) @@ -90,7 +54,7 @@ describe("tags", () => { it("should create basic event tag", () => { const result = tagEvent(mockEvent) expect(result).toHaveLength(1) - expect(result[0]).toEqual(["e", mockEvent.id, "event-relay-url", "", mockEvent.pubkey]) + expect(result[0]).toEqual(["e", mockEvent.id, expect.any(String), "", mockEvent.pubkey]) }) it("should include address tag for replaceable events", () => { @@ -119,7 +83,7 @@ describe("tags", () => { describe("tagEventForQuote", () => { it("should create quote tag", () => { const result = tagEventForQuote(mockEvent) - expect(result).toEqual(["q", mockEvent.id, "event-relay-url", mockEvent.pubkey]) + expect(result).toEqual(["q", mockEvent.id, expect.any(String), mockEvent.pubkey]) }) }) @@ -262,11 +226,11 @@ describe("tags", () => { expect(result).toEqual([ ["K", String(NOTE)], - ["P", pubkey, "pubkey-relay-url"], - ["E", id, "event-relay-url", pubkey], + ["P", pubkey, expect.any(String)], + ["E", id, expect.any(String), pubkey], ["k", String(NOTE)], - ["p", pubkey, "pubkey-relay-url"], - ["e", id, "event-relay-url", pubkey], + ["p", pubkey, expect.any(String)], + ["e", id, expect.any(String), pubkey], ]) }) @@ -286,13 +250,13 @@ describe("tags", () => { expect(result).toEqual([ ["K", String(MUTES)], - ["P", pubkey, "pubkey-relay-url"], - ["E", id, "event-relay-url", pubkey], - ["A", getAddress(eventWithMixedTags), "event-relay-url", pubkey], + ["P", pubkey, expect.any(String)], + ["E", id, expect.any(String), pubkey], + ["A", getAddress(eventWithMixedTags), expect.any(String), pubkey], ["k", String(MUTES)], - ["p", pubkey, "pubkey-relay-url"], - ["e", id, "event-relay-url", pubkey], - ["a", getAddress(eventWithMixedTags), "event-relay-url", pubkey], + ["p", pubkey, expect.any(String)], + ["e", id, expect.any(String), pubkey], + ["a", getAddress(eventWithMixedTags), expect.any(String), pubkey], ]) }) diff --git a/packages/app/__tests__/zappers.test.ts b/packages/app/__tests__/zappers.test.ts deleted file mode 100644 index d9d5fc4..0000000 --- a/packages/app/__tests__/zappers.test.ts +++ /dev/null @@ -1,229 +0,0 @@ -import {describe, it, expect, vi, beforeEach, afterEach} from "vitest" -import {ctx, fetchJson, bech32ToHex, hexToBech32, tryCatch, postJson} from "@welshman/lib" -import {fetchZappers} from "../src/zappers.ts" - -// Mock dependencies -vi.mock("@welshman/lib", async imports => { - return { - ...(await imports()), - ctx: { - app: { - dufflepudUrl: undefined, // Will be modified in tests - }, - }, - identity: x => x, - fetchJson: vi.fn(), - bech32ToHex: vi.fn(), - hexToBech32: vi.fn(), - tryCatch: vi.fn(fn => { - try { - return fn() - } catch (e) { - return undefined - } - }), - postJson: vi.fn(), - } -}) - -describe("fetchZappers", () => { - const mockLnurls = [ - "lnurl1dp68gurn8ghj7um5v93kketj9ehx2amn9uh8wetvdskkkmn0wahz7mrww4excup0d3h82unvwqhkxctvd46820w0fjx", - "lnurl2wd68gurn8ghj7ur5v9kxjerrv9kzum5v93kket39ehx7mmwp5hxsmwda8hx", - ] - - const mockHexLnurls = ["41414141414141", "42424242424242"] - - const mockZapperInfo1 = { - callback: "https://zapper1.com/callback", - minSendable: 1000, - maxSendable: 100000000, - metadata: JSON.stringify([["text/plain", "Zapper One"]]), - } - - const mockZapperInfo2 = { - callback: "https://zapper2.com/callback", - minSendable: 2000, - maxSendable: 200000000, - metadata: JSON.stringify([["text/plain", "Zapper Two"]]), - } - - beforeEach(() => { - vi.clearAllMocks() - - // Default bech32ToHex mockup with 1:1 mapping to hexes - vi.mocked(bech32ToHex).mockImplementation(lnurl => { - if (lnurl === mockLnurls[0]) return mockHexLnurls[0] - if (lnurl === mockLnurls[1]) return mockHexLnurls[1] - throw new Error("Invalid lnurl") - }) - - // Default hexToBech32 mockup with inverse mapping - vi.mocked(hexToBech32).mockImplementation((prefix, hex) => { - if (hex === mockHexLnurls[0]) return mockLnurls[0] - if (hex === mockHexLnurls[1]) return mockLnurls[1] - throw new Error("Invalid hex") - }) - }) - - afterEach(() => { - vi.resetAllMocks() - }) - - it("should fetch zappers using dufflepud when URL is provided", async () => { - // Arrange - const dufflepudUrl = "https://dufflepud.com" - ctx.app.dufflepudUrl = dufflepudUrl - - vi.mocked(postJson).mockResolvedValue({ - data: [ - {lnurl: mockHexLnurls[0], info: mockZapperInfo1}, - {lnurl: mockHexLnurls[1], info: mockZapperInfo2}, - ], - }) - - // Act - const result = await fetchZappers(mockLnurls) - - // Assert - expect(postJson).toHaveBeenCalledWith(`${dufflepudUrl}/zapper/info`, {lnurls: mockHexLnurls}) - - expect(bech32ToHex).toHaveBeenCalledTimes(2) - expect(hexToBech32).toHaveBeenCalledTimes(2) - - expect(result.size).toBe(2) - expect(result.get(mockLnurls[0])).toEqual(mockZapperInfo1) - expect(result.get(mockLnurls[1])).toEqual(mockZapperInfo2) - }) - - it("should fetch zappers directly when dufflepud URL is not provided", async () => { - // Arrange - ctx.app.dufflepudUrl = undefined - - vi.mocked(fetchJson).mockImplementation(async url => { - if (url === mockHexLnurls[0]) return mockZapperInfo1 - if (url === mockHexLnurls[1]) return mockZapperInfo2 - throw new Error("Invalid URL") - }) - - // Act - const result = await fetchZappers(mockLnurls) - - // Assert - expect(fetchJson).toHaveBeenCalledWith(mockHexLnurls[0]) - expect(fetchJson).toHaveBeenCalledWith(mockHexLnurls[1]) - expect(postJson).not.toHaveBeenCalled() - - expect(result.size).toBe(2) - expect(result.get(mockLnurls[0])).toEqual(mockZapperInfo1) - expect(result.get(mockLnurls[1])).toEqual(mockZapperInfo2) - }) - - it("should handle invalid lnurls when using dufflepud", async () => { - // Arrange - ctx.app.dufflepudUrl = "https://dufflepud.com" - - // Make only the first lnurl valid - vi.mocked(bech32ToHex).mockImplementation(lnurl => { - if (lnurl === mockLnurls[0]) return mockHexLnurls[0] - throw new Error("Invalid lnurl") - }) - - vi.mocked(postJson).mockResolvedValue({ - data: [{lnurl: mockHexLnurls[0], info: mockZapperInfo1}], - }) - - // Act - const result = await fetchZappers(mockLnurls) - - // Assert - expect(postJson).toHaveBeenCalledWith(`${ctx.app.dufflepudUrl}/zapper/info`, { - lnurls: [mockHexLnurls[0]], - }) - - expect(result.size).toBe(1) - expect(result.get(mockLnurls[0])).toEqual(mockZapperInfo1) - expect(result.get(mockLnurls[1])).toBeUndefined() - }) - - it("should handle invalid lnurls when fetching directly", async () => { - // Arrange - ctx.app.dufflepudUrl = undefined - - // Make only the first lnurl valid - vi.mocked(bech32ToHex).mockImplementation(lnurl => { - if (lnurl === mockLnurls[0]) return mockHexLnurls[0] - throw new Error("Invalid lnurl") - }) - - vi.mocked(fetchJson).mockImplementation(async url => { - if (url === mockHexLnurls[0]) return mockZapperInfo1 - throw new Error("Invalid URL") - }) - - // Act - const result = await fetchZappers(mockLnurls) - - // Assert - expect(fetchJson).toHaveBeenCalledWith(mockHexLnurls[0]) - expect(fetchJson).toHaveBeenCalledTimes(1) - - expect(result.size).toBe(1) - expect(result.get(mockLnurls[0])).toEqual(mockZapperInfo1) - expect(result.get(mockLnurls[1])).toBeUndefined() - }) - - it("should handle empty lnurl list", async () => { - // Arrange - ctx.app.dufflepudUrl = "https://dufflepud.com" - - // Act - const result = await fetchZappers([]) - - // Assert - expect(postJson).not.toHaveBeenCalled() - expect(fetchJson).not.toHaveBeenCalled() - expect(result.size).toBe(0) - }) - - it("should handle malformed zapper responses", async () => { - // Arrange - ctx.app.dufflepudUrl = "https://dufflepud.com" - - vi.mocked(postJson).mockResolvedValue({ - // Missing data field - wrong_field: [], - }) - - // Act - const result = await fetchZappers(mockLnurls) - - // Assert - expect(postJson).toHaveBeenCalled() - expect(result.size).toBe(0) - }) - - it("should handle hexToBech32 errors when processing dufflepud response", async () => { - // Arrange - ctx.app.dufflepudUrl = "https://dufflepud.com" - - vi.mocked(hexToBech32).mockImplementation(() => { - throw new Error("Invalid hex") - }) - - vi.mocked(postJson).mockResolvedValue({ - data: [ - {lnurl: mockHexLnurls[0], info: mockZapperInfo1}, - {lnurl: mockHexLnurls[1], info: mockZapperInfo2}, - ], - }) - - // Act - const result = await fetchZappers(mockLnurls) - - // Assert - expect(postJson).toHaveBeenCalled() - expect(hexToBech32).toHaveBeenCalled() - expect(result.size).toBe(0) - }) -}) diff --git a/packages/app/src/commands.ts b/packages/app/src/commands.ts index 7a02c6e..08b1c6a 100644 --- a/packages/app/src/commands.ts +++ b/packages/app/src/commands.ts @@ -9,40 +9,40 @@ export const unfollow = async (value: string) => { const list = get(userFollows) || makeList({kind: FOLLOWS}) const event = await removeFromList(list, value).reconcile(nip44EncryptToSelf) - return publishThunk({event, relays: Router.getInstance().FromUser().getUrls()}) + return publishThunk({event, relays: Router.get().FromUser().getUrls()}) } export const follow = async (tag: string[]) => { const list = get(userFollows) || makeList({kind: FOLLOWS}) const event = await addToListPublicly(list, tag).reconcile(nip44EncryptToSelf) - return publishThunk({event, relays: Router.getInstance().FromUser().getUrls()}) + return publishThunk({event, relays: Router.get().FromUser().getUrls()}) } export const unmute = async (value: string) => { const list = get(userMutes) || makeList({kind: MUTES}) const event = await removeFromList(list, value).reconcile(nip44EncryptToSelf) - return publishThunk({event, relays: Router.getInstance().FromUser().getUrls()}) + return publishThunk({event, relays: Router.get().FromUser().getUrls()}) } export const mute = async (tag: string[]) => { const list = get(userMutes) || makeList({kind: MUTES}) const event = await addToListPublicly(list, tag).reconcile(nip44EncryptToSelf) - return publishThunk({event, relays: Router.getInstance().FromUser().getUrls()}) + return publishThunk({event, relays: Router.get().FromUser().getUrls()}) } export const unpin = async (value: string) => { const list = get(userPins) || makeList({kind: PINS}) const event = await removeFromList(list, value).reconcile(nip44EncryptToSelf) - return publishThunk({event, relays: Router.getInstance().FromUser().getUrls()}) + return publishThunk({event, relays: Router.get().FromUser().getUrls()}) } export const pin = async (tag: string[]) => { const list = get(userPins) || makeList({kind: PINS}) const event = await addToListPublicly(list, tag).reconcile(nip44EncryptToSelf) - return publishThunk({event, relays: Router.getInstance().FromUser().getUrls()}) + return publishThunk({event, relays: Router.get().FromUser().getUrls()}) } diff --git a/packages/app/src/feeds.ts b/packages/app/src/feeds.ts index 6efc7fe..8531888 100644 --- a/packages/app/src/feeds.ts +++ b/packages/app/src/feeds.ts @@ -39,8 +39,8 @@ export const requestDVM = async ({kind, onEvent, ...request}: DVMOpts) => { const $signer = signer.get() || new Nip01Signer(makeSecret()) const pubkey = await $signer.getPubkey() const relays = request.relays - ? Router.getInstance().FromRelays(request.relays).getUrls() - : Router.getInstance().FromPubkeys(getPubkeyTagValues(tags)).getUrls() + ? Router.get().FromRelays(request.relays).getUrls() + : Router.get().FromPubkeys(getPubkeyTagValues(tags)).getUrls() if (!tags.some(nthEq(0, "expiration"))) { tags.push(["expiration", String(now() + 60)]) diff --git a/packages/app/src/follows.ts b/packages/app/src/follows.ts index 8c955e8..9deeead 100644 --- a/packages/app/src/follows.ts +++ b/packages/app/src/follows.ts @@ -25,7 +25,7 @@ export const { await loadRelaySelections(pubkey, request) const filter = {kinds: [FOLLOWS], authors: [pubkey]} - const relays = Router.getInstance().FromPubkey(pubkey).getUrls() + const relays = Router.get().FromPubkey(pubkey).getUrls() await load({relays, ...request, filter}) }, diff --git a/packages/app/src/mutes.ts b/packages/app/src/mutes.ts index 6246a09..3b5b2d2 100644 --- a/packages/app/src/mutes.ts +++ b/packages/app/src/mutes.ts @@ -31,7 +31,7 @@ export const { await loadRelaySelections(pubkey, request) const filter = {kinds: [MUTES], authors: [pubkey]} - const relays = Router.getInstance().FromPubkey(pubkey).getUrls() + const relays = Router.get().FromPubkey(pubkey).getUrls() await load({relays, ...request, filter}) }, diff --git a/packages/app/src/pins.ts b/packages/app/src/pins.ts index da2c011..984a0e6 100644 --- a/packages/app/src/pins.ts +++ b/packages/app/src/pins.ts @@ -25,7 +25,7 @@ export const { await loadRelaySelections(pubkey, request) const filter = {kinds: [PINS], authors: [pubkey]} - const relays = Router.getInstance().FromPubkey(pubkey).getUrls() + const relays = Router.get().FromPubkey(pubkey).getUrls() await load({relays, ...request, filter}) }, diff --git a/packages/app/src/profiles.ts b/packages/app/src/profiles.ts index 3f55526..a4f5308 100644 --- a/packages/app/src/profiles.ts +++ b/packages/app/src/profiles.ts @@ -27,7 +27,7 @@ export const { load: async (pubkey: string, request: Partial = {}) => { await loadRelaySelections(pubkey, request) - const router = Router.getInstance() + const router = Router.get() const filter = {kinds: [PROFILE], authors: [pubkey]} const relays = router.merge([router.Index(), router.FromPubkey(pubkey)]).getUrls() diff --git a/packages/app/src/relaySelections.ts b/packages/app/src/relaySelections.ts index da3b11d..b3df256 100644 --- a/packages/app/src/relaySelections.ts +++ b/packages/app/src/relaySelections.ts @@ -48,7 +48,7 @@ export const { store: relaySelections, getKey: relaySelections => relaySelections.event.pubkey, load: async (pubkey: string, request: Partial = {}) => { - const router = Router.getInstance() + const router = Router.get() await load({ relays: router.merge([router.Index(), router.FromPubkey(pubkey)]).getUrls(), @@ -73,7 +73,7 @@ export const { store: inboxRelaySelections, getKey: inboxRelaySelections => inboxRelaySelections.event.pubkey, load: async (pubkey: string, request: Partial = {}) => { - const router = Router.getInstance() + const router = Router.get() await load({ relays: router.merge([router.Index(), router.FromPubkey(pubkey)]).getUrls(), diff --git a/packages/app/src/router.ts b/packages/app/src/router.ts index 30b5d57..9d970c2 100644 --- a/packages/app/src/router.ts +++ b/packages/app/src/router.ts @@ -194,7 +194,7 @@ export class Router { Object.assign(globalRouterOptions, options) } - static getInstance() { + static get() { return new Router(globalRouterOptions) } @@ -422,7 +422,7 @@ export const getFilterSelectionsForSearch = (filter: Filter) => { const relays = globalRouterOptions.getSearchRelays?.() || [] - return [{filter, scenario: Router.getInstance().FromRelays(relays).weight(10)}] + return [{filter, scenario: Router.get().FromRelays(relays).weight(10)}] } export const getFilterSelectionsForWraps = (filter: Filter) => { @@ -431,7 +431,7 @@ export const getFilterSelectionsForWraps = (filter: Filter) => { return [ { filter: {...filter, kinds: [WRAP]}, - scenario: Router.getInstance().UserInbox(), + scenario: Router.get().UserInbox(), }, ] } @@ -446,7 +446,7 @@ export const getFilterSelectionsForIndexedKinds = (filter: Filter) => { return [ { filter: {...filter, kinds}, - scenario: Router.getInstance().FromRelays(relays), + scenario: Router.get().FromRelays(relays), }, ] } @@ -458,12 +458,12 @@ export const getFilterSelectionsForAuthors = (filter: Filter) => { return chunks(chunkCount, filter.authors).map(authors => ({ filter: {...filter, authors}, - scenario: Router.getInstance().FromPubkeys(authors), + scenario: Router.get().FromPubkeys(authors), })) } export const getFilterSelectionsForUser = (filter: Filter) => [ - {filter, scenario: Router.getInstance().ForUser().weight(0.2)}, + {filter, scenario: Router.get().ForUser().weight(0.2)}, ] export const defaultFilterSelectionRules = [ @@ -493,7 +493,7 @@ export const getFilterSelections = ( const result = [] for (const [id, filter] of filtersById.entries()) { - const scenario = Router.getInstance().merge(scenariosById.get(id) || []) + const scenario = Router.get().merge(scenariosById.get(id) || []) result.push({filters: [filter], relays: scenario.getUrls()}) } diff --git a/packages/app/src/search.ts b/packages/app/src/search.ts index bc0e37d..3778190 100644 --- a/packages/app/src/search.ts +++ b/packages/app/src/search.ts @@ -56,7 +56,7 @@ export const searchProfiles = debounce(500, (search: string) => { if (search.length > 2) { load({ filter: {kinds: [PROFILE], search}, - relays: Router.getInstance().Search().getUrls(), + relays: Router.get().Search().getUrls(), }) } }) diff --git a/packages/app/src/tags.ts b/packages/app/src/tags.ts index f2801a4..3b28791 100644 --- a/packages/app/src/tags.ts +++ b/packages/app/src/tags.ts @@ -14,19 +14,19 @@ import {Router} from "./router.js" export const tagZapSplit = (pubkey: string, split = 1) => [ "zap", pubkey, - Router.getInstance().FromPubkey(pubkey).getUrl(), + Router.get().FromPubkey(pubkey).getUrl(), String(split), ] export const tagPubkey = (pubkey: string, ...args: unknown[]) => [ "p", pubkey, - Router.getInstance().FromPubkey(pubkey).getUrl(), + Router.get().FromPubkey(pubkey).getUrl(), displayProfileByPubkey(pubkey), ] export const tagEvent = (event: TrustedEvent, mark = "") => { - const url = Router.getInstance().Event(event).getUrl() + const url = Router.get().Event(event).getUrl() const tags = [["e", event.id, url, mark, event.pubkey]] if (isReplaceable(event)) { @@ -42,7 +42,7 @@ export const tagEventPubkeys = (event: TrustedEvent) => export const tagEventForQuote = (event: TrustedEvent) => [ "q", event.id, - Router.getInstance().Event(event).getUrl(), + Router.get().Event(event).getUrl(), event.pubkey, ] @@ -55,11 +55,11 @@ export const tagEventForReply = (event: TrustedEvent) => { // Root comes first if (roots.length > 0) { for (const t of roots) { - tags.push([...t.slice(0, 2), Router.getInstance().EventRoots(event).getUrl(), "root"]) + tags.push([...t.slice(0, 2), Router.get().EventRoots(event).getUrl(), "root"]) } } else { for (const t of replies) { - tags.push([...t.slice(0, 2), Router.getInstance().EventParents(event).getUrl(), "root"]) + tags.push([...t.slice(0, 2), Router.get().EventParents(event).getUrl(), "root"]) } } @@ -81,7 +81,7 @@ export const tagEventForReply = (event: TrustedEvent) => { // Finally, tag the event itself const mark = replies.length > 0 ? "reply" : "root" - const hint = Router.getInstance().Event(event).getUrl() + const hint = Router.get().Event(event).getUrl() // e-tag the event tags.push(["e", event.id, hint, mark, event.pubkey]) @@ -95,8 +95,8 @@ export const tagEventForReply = (event: TrustedEvent) => { } export const tagEventForComment = (event: TrustedEvent) => { - const pubkeyHint = Router.getInstance().FromPubkey(event.pubkey).getUrl() - const eventHint = Router.getInstance().Event(event).getUrl() + const pubkeyHint = Router.get().FromPubkey(event.pubkey).getUrl() + const eventHint = Router.get().Event(event).getUrl() const address = getAddress(event) const seenRoots = new Set() const tags: string[][] = [] @@ -130,7 +130,7 @@ export const tagEventForComment = (event: TrustedEvent) => { } export const tagEventForReaction = (event: TrustedEvent) => { - const hint = Router.getInstance().Event(event).getUrl() + const hint = Router.get().Event(event).getUrl() const tags: string[][] = [] // Mention the event's author diff --git a/packages/net/package.json b/packages/net/package.json index 89f4f3e..9c6b034 100644 --- a/packages/net/package.json +++ b/packages/net/package.json @@ -1,6 +1,6 @@ { "name": "@welshman/net", - "version": "0.0.48", + "version": "0.0.49", "author": "hodlbod", "license": "MIT", "description": "Utilities for connecting with nostr relays.", diff --git a/packages/relay/__tests__/relay.test.ts b/packages/relay/__tests__/relay.test.ts index 5551219..932f674 100644 --- a/packages/relay/__tests__/relay.test.ts +++ b/packages/relay/__tests__/relay.test.ts @@ -1,20 +1,10 @@ -import {now} from "@welshman/lib" import {describe, it, expect, beforeEach, vi, afterEach} from "vitest" -import { - Relay, - normalizeRelayUrl, - isRelayUrl, - isOnionUrl, - isLocalUrl, - isIPAddress, - isShareableRelayUrl, - displayRelayUrl, - displayRelayProfile, -} from "../src/Relay" -import {Repository} from "../src/Repository" -import type {TrustedEvent} from "../src/Events" +import {now} from "@welshman/lib" +import type {TrustedEvent} from "@welshman/util" +import {LocalRelay} from "../src/relay" +import {Repository} from "../src/repository" -describe("Relay", () => { +describe("LocalRelay", () => { beforeEach(() => { vi.clearAllMocks() vi.useFakeTimers() @@ -42,99 +32,13 @@ describe("Relay", () => { ...overrides, }) - describe("URL utilities", () => { - describe("isRelayUrl", () => { - it("should validate proper relay URLs", () => { - expect(isRelayUrl("wss://relay.example.com")).toBe(true) - expect(isRelayUrl("ws://relay.example.com")).toBe(true) - expect(isRelayUrl("relay.example.com")).toBe(true) - }) - - it("should reject invalid URLs", () => { - expect(isRelayUrl("http://relay.example.com")).toBe(false) - expect(isRelayUrl("not-a-url")).toBe(false) - expect(isRelayUrl("ws:\\example.com\\path\\to\\file.ext")).toBe(false) - }) - }) - - describe("isOnionUrl", () => { - it("should validate onion URLs", () => { - expect(isOnionUrl(onionUrl)).toBe(true) - }) - - it("should reject non-onion URLs", () => { - expect(isOnionUrl("wss://relay.example.com")).toBe(false) - }) - }) - - describe("isLocalUrl", () => { - it("should validate local URLs", () => { - expect(isLocalUrl("wss://relay.local")).toBe(true) - expect(isLocalUrl("ws://localhost:8080")).toBe(true) - }) - - it("should reject non-local URLs", () => { - expect(isLocalUrl("wss://relay.example.com")).toBe(false) - }) - }) - - describe("isIPAddress", () => { - it("should validate IP addresses", () => { - expect(isIPAddress("wss://192.168.1.1")).toBe(true) - }) - - it("should reject domains", () => { - expect(isIPAddress("wss://relay.example.com")).toBe(false) - }) - }) - - describe("isShareableRelayUrl", () => { - it("should validate shareable URLs", () => { - expect(isShareableRelayUrl("wss://relay.example.com")).toBe(true) - }) - - it("should reject local URLs", () => { - expect(isShareableRelayUrl("wss://relay.local")).toBe(false) - }) - }) - - describe("normalizeRelayUrl", () => { - it("should normalize URLs consistently", () => { - expect(normalizeRelayUrl("relay.example.com")).toBe("wss://relay.example.com/") - expect(normalizeRelayUrl("wss://RELAY.EXAMPLE.COM")).toBe("wss://relay.example.com/") - }) - - it("should handle onion URLs", () => { - expect(normalizeRelayUrl(onionUrl)).toBe(`ws://${onionUrl}/`) - }) - }) - - describe("displayRelayUrl", () => { - it("should format URLs for display", () => { - expect(displayRelayUrl("wss://relay.example.com/")).toBe("relay.example.com") - }) - }) - - describe("displayRelayProfile", () => { - it("should display profile name when available", () => { - const profile = {url: "wss://relay.example.com", name: "Test Relay"} - expect(displayRelayProfile(profile)).toBe("Test Relay") - }) - - it("should use fallback when no name", () => { - const profile = {url: "wss://relay.example.com"} - expect(displayRelayProfile(profile, "Fallback")).toBe("Fallback") - }) - }) - }) - - describe("Relay class", () => { - let relay: Relay + describe("LocalRelay class", () => { + let relay: LocalRelay let repository: Repository beforeEach(() => { repository = new Repository() - relay = new Relay(repository) + relay = new LocalRelay(repository) }) describe("EVENT handling", () => { diff --git a/packages/relay/__tests__/repository.test.ts b/packages/relay/__tests__/repository.test.ts index 3d58460..e7a9869 100644 --- a/packages/relay/__tests__/repository.test.ts +++ b/packages/relay/__tests__/repository.test.ts @@ -1,9 +1,7 @@ -import {now} from "@welshman/lib" -import {getAddress} from "@welshman/util" import {describe, it, vi, expect, beforeEach} from "vitest" +import {now} from "@welshman/lib" +import {getAddress, TrustedEvent, DELETE, MUTES} from "@welshman/util" import {Repository} from "../src/Repository" -import type {TrustedEvent} from "../src/Events" -import {DELETE, MUTES} from "../src/Kinds" describe("Repository", () => { beforeEach(() => { diff --git a/packages/signer/__tests__/nip46.test.ts b/packages/signer/__tests__/nip46.test.ts deleted file mode 100644 index 6638d63..0000000 --- a/packages/signer/__tests__/nip46.test.ts +++ /dev/null @@ -1,673 +0,0 @@ -import {afterAll, beforeEach, describe, expect, it, vi} from "vitest" -import { - Nip46Signer, - Nip46Broker, - Nip46Event, - Nip46Receiver, - Nip46Sender, - Nip46Request, - Nip46Response, - Nip46BrokerParams, -} from "../src/signers/nip46" -import {testSigner} from "./common" -import {NOSTR_CONNECT, SignedEvent, TrustedEvent} from "@welshman/util" -import {publish, subscribe, SubscriptionEvent} from "@welshman/net" -import {now} from "@welshman/lib" - -const mockSubscription = { - on: vi.fn(), - close: vi.fn(), -} - -vi.mock(import("@welshman/net"), async importOriginal => ({ - ...(await importOriginal()), - subscribe: vi.fn().mockImplementation(() => mockSubscription), - publish: vi.fn().mockImplementation(() => ({ - emitter: { - on: vi.fn(), - }, - })), -})) - -describe("Nip46Signer", () => { - let mockBroker: any - - const signerPubkey = "ee".repeat(32) - - beforeEach(() => { - vi.clearAllMocks() - vi.useFakeTimers() - mockBroker = { - getPublicKey: vi.fn().mockResolvedValue("ee".repeat(32)), - signEvent: vi.fn().mockResolvedValue({sig: "ff".repeat(64)} as SignedEvent), - nip04Encrypt: vi.fn((pubkey, message) => "encrypted:" + message), - nip04Decrypt: vi.fn((pubkey, encryptedMessage) => encryptedMessage.split("encrypted:")[1]), - nip44Encrypt: vi.fn((pubkey, message) => "encrypted:" + message), - nip44Decrypt: vi.fn((pubkey, encryptedMessage) => encryptedMessage.split("encrypted:")[1]), - } - }) - - afterAll(() => { - vi.useRealTimers() - }) - - testSigner("Nip46Signer", () => new Nip46Signer(mockBroker)) - - describe("Nip46Receiver", () => { - let mockSigner: any - let receiver: Nip46Receiver - - beforeEach(() => { - mockSigner = { - getPubkey: vi.fn().mockResolvedValue("test-pubkey"), - nip04: { - decrypt: vi.fn().mockResolvedValue('{"method":"test","params":[]}'), - }, - nip44: { - decrypt: vi.fn().mockResolvedValue('{"method":"test","params":[]}'), - }, - } - - receiver = new Nip46Receiver(mockSigner, { - relays: ["wss://relay.test"], - clientSecret: "test-secret", - }) - }) - - it("should setup subscription with correct filters", async () => { - receiver.start() - await vi.advanceTimersToNextTimerAsync() - expect(subscribe).toHaveBeenCalledWith({ - relays: ["wss://relay.test"], - filters: [ - { - kinds: [NOSTR_CONNECT], - "#p": ["test-pubkey"], - }, - ], - }) - }) - - it("should handle incoming events", async () => { - const receiveSpy = vi.fn() - receiver.on(Nip46Event.Receive, receiveSpy) - - receiver.start() - - await vi.advanceTimersToNextTimerAsync() - - // Get the event handler - const eventHandler = (mockSubscription as any).on.mock.calls.find( - call => call[0] === SubscriptionEvent.Event, - )[1] - - // Simulate incoming event - await eventHandler("wss://relay.test", { - pubkey: "sender-pubkey", - content: "encrypted-content", - } as TrustedEvent) - - expect(receiveSpy).toHaveBeenCalledWith( - expect.objectContaining({ - url: "wss://relay.test", - event: expect.any(Object), - }), - ) - }) - - it("should cleanup on stop", async () => { - receiver.start() - await vi.advanceTimersToNextTimerAsync() - receiver.stop() - - expect(mockSubscription.close).toHaveBeenCalled() - }) - }) - - describe("Nip46Sender", () => { - let mockSigner: any - let sender: Nip46Sender - let mockPublish: any - - beforeEach(() => { - vi.clearAllMocks() - mockSigner = { - getPubkey: vi.fn().mockResolvedValue("test-pubkey"), - sign: vi.fn(template => ({...template, sig: "ee".repeat(64)})), - nip44: { - encrypt: vi.fn((pub, message) => "encrypted:" + message), - }, - } - - mockPublish = { - emitter: {on: vi.fn()}, - } - vi.mocked(publish).mockReturnValue(mockPublish) - - sender = new Nip46Sender(mockSigner, { - relays: ["wss://relay.test"], - clientSecret: "test-secret", - signerPubkey, - }) - }) - - it("should encrypt and send request", async () => { - const request = new Nip46Request("test-method", ["param1"]) - await sender.send(request) - - expect(mockSigner.nip44.encrypt).toHaveBeenCalledWith(signerPubkey, expect.any(String)) - expect(publish).toHaveBeenCalledWith( - expect.objectContaining({ - relays: ["wss://relay.test"], - event: expect.any(Object), - }), - ) - }) - - it("should throw error if no signer pubkey", async () => { - sender = new Nip46Sender(mockSigner, { - relays: ["wss://relay.test"], - clientSecret: "test-secret", - }) - - const request = new Nip46Request("test-method", ["param1"]) - await expect(sender.send(request)).rejects.toThrow("signer pubkey") - }) - - it("should process queue sequentially", async () => { - const request1 = new Nip46Request("method1", ["param1"]) - const request2 = new Nip46Request("method2", ["param2"]) - - sender.enqueue(request1) - sender.enqueue(request2) - - await vi.runAllTimersAsync() - - // Check that requests were processed in order - const calls = vi.mocked(publish).mock.calls - expect(calls[0][0].event.content).toContain("method1") - expect(calls[1][0].event.content).toContain("method2") - }) - }) - - describe("Nip46Request", () => { - let mockReceiver: any - let mockSender: any - let request: Nip46Request - - beforeEach(() => { - vi.clearAllMocks() - vi.useFakeTimers() - mockReceiver = { - start: vi.fn().mockResolvedValue(undefined), - on: vi.fn(), - off: vi.fn(), - } - - mockSender = { - enqueue: vi.fn(), - } - - request = new Nip46Request("test-method", ["param1"]) - }) - - afterAll(() => { - vi.useRealTimers() - }) - - it("should handle successful response", async () => { - // Setup response handler - let responseHandler: (response: Nip46Response) => void - mockReceiver.on.mockImplementation((event, handler) => { - responseHandler = handler - }) - - // Start listening - const listenPromise = request.listen(mockReceiver) - - await vi.advanceTimersToNextTimerAsync() - - // Simulate successful response - responseHandler!({ - id: request.id, - url: "wss://relay.test", - event: {} as TrustedEvent, - result: "success", - }) - - await listenPromise - - const result = await request.promise - expect(result.result).toBe("success") - }) - - it("should handle error response", async () => { - let responseHandler: (response: Nip46Response) => void - mockReceiver.on.mockImplementation((event, handler) => { - responseHandler = handler - }) - - const listenPromise = request.listen(mockReceiver) - - await vi.advanceTimersToNextTimerAsync() - - responseHandler!({ - id: request.id, - url: "wss://relay.test", - event: {} as TrustedEvent, - error: "test error", - }) - - await listenPromise - - await expect(request.promise).rejects.toMatchObject({ - error: "test error", - }) - }) - - it("should handle auth_url result", async () => { - const popupSpy = vi.spyOn(window, "open") - let responseHandler: (response: Nip46Response) => void - mockReceiver.on.mockImplementation((event, handler) => { - responseHandler = handler - }) - - const listenPromise = request.listen(mockReceiver) - - await vi.advanceTimersToNextTimerAsync() - - responseHandler!({ - id: request.id, - url: "wss://relay.test", - event: {} as TrustedEvent, - result: "auth_url", - error: "https://auth.test", - }) - - await listenPromise - - expect(popupSpy).toHaveBeenCalledWith( - "https://auth.test", - expect.any(String), - expect.any(String), - ) - }) - - it("should ignore responses with different ids", async () => { - let responseHandler: (response: Nip46Response) => void - mockReceiver.on.mockImplementation((event, handler) => { - responseHandler = handler - }) - - const listenPromise = request.listen(mockReceiver) - - await vi.advanceTimersToNextTimerAsync() - - responseHandler!({ - id: "different-id", - url: "wss://relay.test", - event: {} as TrustedEvent, - result: "success", - }) - - await listenPromise - - // Promise should not be resolved - const promiseStatus = await Promise.race([ - request.promise, - vi.advanceTimersByTimeAsync(100).then(() => "timeout"), - ]) - - expect(promiseStatus).toBe("timeout") - }) - - it("should enqueue request on send", async () => { - await request.send(mockSender) - expect(mockSender.enqueue).toHaveBeenCalledWith(request) - }) - }) - - describe("Nip46Broker", () => { - let defaultParams: Nip46BrokerParams - const pubkey = "cc".repeat(32) - beforeEach(() => { - vi.clearAllMocks() - defaultParams = { - relays: ["wss://relay.test"], - clientSecret: "dd".repeat(32), - signerPubkey: "ee".repeat(32), - } - }) - - describe("singleton management", () => { - it("should maintain single instance with same params", () => { - const broker1 = Nip46Broker.get(defaultParams) - const broker2 = Nip46Broker.get(defaultParams) - expect(broker1).toBe(broker2) - }) - - it("should create new instance with different params", () => { - const broker1 = Nip46Broker.get(defaultParams) - const broker2 = Nip46Broker.get({ - ...defaultParams, - relays: ["wss://other.relay"], - }) - expect(broker1).not.toBe(broker2) - }) - - it("should teardown old instance when creating new one", () => { - const broker1 = Nip46Broker.get(defaultParams) - const teardownSpy = vi.spyOn(broker1, "teardown") - - Nip46Broker.get({ - ...defaultParams, - relays: ["wss://other.relay"], - }) - - expect(teardownSpy).toHaveBeenCalled() - }) - }) - - describe("URL handling", () => { - it("should parse bunker URL correctly", () => { - const url = `bunker://${pubkey}?relay=wss://relay1.test&relay=wss://relay2.test&secret=testsecret` - const result = Nip46Broker.parseBunkerUrl(url) - - expect(result).toEqual({ - signerPubkey: pubkey, - relays: ["wss://relay1.test/", "wss://relay2.test/"], - connectSecret: "testsecret", - }) - }) - - it("should handle invalid bunker URL", () => { - const result = Nip46Broker.parseBunkerUrl("invalid-url") - - expect(result).toEqual({ - signerPubkey: "", - connectSecret: "", - relays: [], - }) - }) - - it("should generate bunker URL", () => { - const broker = new Nip46Broker(defaultParams) - const url = broker.getBunkerUrl() - - expect(url).toContain("bunker://") - expect(url).toContain(defaultParams.signerPubkey) - expect(url).toContain(encodeURIComponent(defaultParams.relays[0])) - }) - - it("should throw when generating bunker URL without signerPubkey", () => { - const broker = new Nip46Broker({ - ...defaultParams, - signerPubkey: undefined, - }) - - expect(() => broker.getBunkerUrl()).toThrow("no signerPubkey") - }) - - it("should generate nostrconnect URL", async () => { - const broker = new Nip46Broker(defaultParams) - const url = await broker.makeNostrconnectUrl({app: "test"}) - - expect(url).toContain("nostrconnect://") - expect(url).toContain("app=test") - expect(url).toContain("secret=") - expect(url).toContain(encodeURIComponent(defaultParams.relays[0])) - }) - }) - - describe("connection handling", () => { - it("should handle nostrconnect response", async () => { - const broker = new Nip46Broker(defaultParams) - const url = await broker.makeNostrconnectUrl() - - // Start waiting for response - const connectPromise = broker.waitForNostrconnect(url) - - // Get the secret from the URL we're connecting to - const secret = new URL(url).searchParams.get("secret") - - // Simulate a response through the broker's receiver - broker.receiver.emit(Nip46Event.Receive, { - result: secret, - event: {pubkey: "responder-pubkey"}, - }) - - const response = await connectPromise - expect(broker.params.signerPubkey).toBe("responder-pubkey") - }) - - it("should handle connection abort", async () => { - const broker = new Nip46Broker(defaultParams) - const url = await broker.makeNostrconnectUrl() - const controller = new AbortController() - - const connectPromise = broker.waitForNostrconnect(url, controller) - controller.abort() - - await expect(connectPromise).rejects.toBeUndefined() - }) - }) - - describe("NIP-46 methods", () => { - let broker: Nip46Broker - - beforeEach(() => { - broker = new Nip46Broker(defaultParams) - }) - - it("should send ping request", async () => { - const pingPromise = broker.ping() - - // We need to wait a tick for the request to be created and registered - await vi.runAllTimersAsync() - - // Make sure we started the handshake with the remote signer - const sentHandler = (mockSubscription as any).on.mock.calls.find( - call => call[0] === SubscriptionEvent.Send, - )[1] - // the sub was sent - sentHandler() - - let req = {} as Nip46Request - - // catch up the send event to get the request id - broker.sender.on(Nip46Event.Send, (res: Nip46Request) => (req = res)) - - await vi.runAllTimersAsync() - - // The receiver should emit a response with the same ID as the request - broker.receiver.emit(Nip46Event.Receive, { - id: req?.id, - result: "pong", - error: undefined, - event: {} as TrustedEvent, - url: "wss://test.relay", - }) - - const result = await pingPromise - expect(result).toBe("pong") - }) - - it("should get public key", async () => { - const pubkeyPromise = broker.getPublicKey() - - await vi.runAllTimersAsync() - - // Make sure we started the handshake with the remote signer - const sentHandler = (mockSubscription as any).on.mock.calls.find( - call => call[0] === SubscriptionEvent.Send, - )[1] - // the sub handshake was sent - sentHandler() - - let req = {} as Nip46Request - - // catch up the send event to get the request id - broker.sender.on(Nip46Event.Send, (res: Nip46Request) => (req = res)) - - await vi.runAllTimersAsync() - - expect(req.method).toBe("get_public_key") - - // Simulate response - broker.receiver.emit(Nip46Event.Receive, { - id: req.id, - result: "test-pubkey", - error: undefined, - event: {} as TrustedEvent, - url: "wss://test.relay", - }) - - const result = await pubkeyPromise - expect(result).toBe("test-pubkey") - }) - - it("should sign event", async () => { - const event = {kind: 1, pubkey, created_at: now(), content: "test", tags: []} - const signedEvent = {...event, sig: "signature"} - - const signPromise = broker.signEvent(event) - - await vi.runAllTimersAsync() - - // Make sure we started the handshake with the remote signer - const sentHandler = (mockSubscription as any).on.mock.calls.find( - call => call[0] === SubscriptionEvent.Send, - )[1] - // the sub handshake was sent - sentHandler() - - let req = {} as Nip46Request - - // catch up the request send event to get the request id - broker.sender.on(Nip46Event.Send, (res: Nip46Request) => (req = res)) - - await vi.runAllTimersAsync() - - // Simulate response - broker.receiver.emit(Nip46Event.Receive, {id: req.id, result: JSON.stringify(signedEvent)}) - - const result = await signPromise - - expect(result).toEqual(signedEvent) - }) - - it("should handle encryption methods", async () => { - const encryptPromise = broker.nip04Encrypt("bb".repeat(32), "message") - - await vi.runAllTimersAsync() - - // Make sure we started the handshake with the remote signer - const sentHandler = (mockSubscription as any).on.mock.calls.find( - call => call[0] === SubscriptionEvent.Send, - )[1] - // the sub handshake was sent - sentHandler() - - let req = {} as Nip46Request - - // catch up the request send event to get the request id - broker.sender.on(Nip46Event.Send, (res: Nip46Request) => (req = res)) - - await vi.runAllTimersAsync() - - // Simulate response - broker.receiver.emit(Nip46Event.Receive, {id: req.id, result: "encrypted"}) - - const result = await encryptPromise - - expect(result).toBe("encrypted") - }) - }) - - describe("error handling", () => { - it("should handle request timeout", async () => { - // const broker = new Nip46Broker({ - // ...defaultParams, - // timeout: 100, - // }) - // const pingPromise = broker.ping() - // await expect(pingPromise).rejects.toThrow() - }) - - it("should handle request errors", async () => { - const broker = new Nip46Broker(defaultParams) - - const pingPromise = broker.ping() - - // We need to wait a tick for the request to be created and registered - await vi.runAllTimersAsync() - - // Make sure we started the handshake with the remote signer - const sentHandler = (mockSubscription as any).on.mock.calls.find( - call => call[0] === SubscriptionEvent.Send, - )[1] - // the sub was sent - sentHandler() - - let req = {} as Nip46Request - - // catch up the send event to get the request id - broker.sender.on(Nip46Event.Send, (res: Nip46Request) => (req = res)) - - await vi.runAllTimersAsync() - - // The receiver should emit a response with the same ID as the request - broker.receiver.emit(Nip46Event.Receive, { - id: req?.id, - result: "", - error: "test error", - event: {} as TrustedEvent, - url: "wss://test.relay", - }) - - await expect(pingPromise).rejects.toMatchObject({error: "test error"}) - }) - }) - - describe("state management", () => { - it("should update params correctly", () => { - const broker = new Nip46Broker(defaultParams) - const newParams = { - signerPubkey: "new-pubkey", - algorithm: "nip04" as const, - } - - broker.setParams(newParams) - - expect(broker.params).toEqual({ - ...defaultParams, - ...newParams, - }) - }) - - it("should cleanup on teardown", () => { - const broker = new Nip46Broker(defaultParams) - const senderStopSpy = vi.spyOn(broker.sender, "stop") - const receiverStopSpy = vi.spyOn(broker.receiver, "stop") - - broker.teardown() - - expect(senderStopSpy).toHaveBeenCalled() - expect(receiverStopSpy).toHaveBeenCalled() - }) - }) - }) - - describe("Nip46Broker", () => { - // Test broker-specific functionality - it("should parse bunker URL correctly", () => { - const url = `bunker://${signerPubkey}?relay=wss://relay1&relay=wss://relay2&secret=123` - const result = Nip46Broker.parseBunkerUrl(url) - expect(result).toEqual({ - signerPubkey, - relays: ["wss://relay1/", "wss://relay2/"], - connectSecret: "123", - }) - }) - }) -})