diff --git a/package-lock.json b/package-lock.json index 42a70b2..f058b0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ ], "devDependencies": { "@vitest/coverage-v8": "^3.0.5", + "fake-indexeddb": "^6.0.0", "gts": "^6.0.2", "happy-dom": "^16.8.1", "typedoc": "^0.27.9", @@ -3944,6 +3945,16 @@ "node": ">=4" } }, + "node_modules/fake-indexeddb": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.0.0.tgz", + "integrity": "sha512-YEboHE5VfopUclOck7LncgIqskAqnv4q0EWbYCaxKKjAvO93c+TJIaBuGy8CBFdbg9nKdpN3AuPRwVBJ4k7NrQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", diff --git a/package.json b/package.json index 90199d8..91ac591 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "typedoc-plugin-markdown": "^4.4.2", "typedoc-vitepress-theme": "^1.1.2", "typescript": "^5.6.3", + "fake-indexeddb": "^6.0.0", "vitepress": "^1.6.3" } } diff --git a/packages/app/__tests__/collection.test.ts b/packages/app/__tests__/collection.test.ts index c3a54de..dc5294d 100644 --- a/packages/app/__tests__/collection.test.ts +++ b/packages/app/__tests__/collection.test.ts @@ -1,23 +1,19 @@ import {describe, it, expect, beforeEach, vi, afterEach} from "vitest" import {get, writable} from "svelte/store" import {collection} from "../src/collection" -import * as freshness from "../src/freshness" - -// Mock the freshness module -vi.mock("../src/freshness", () => ({ - getFreshness: vi.fn(), - setFreshnessThrottled: vi.fn(), -})) +import {freshness, setFreshnessImmediate} from "../src/freshness" +import {now} from "@welshman/lib" describe("collection", () => { beforeEach(() => { + vi.useFakeTimers() vi.clearAllMocks() - // Reset mock implementations - vi.mocked(freshness.getFreshness).mockImplementation(() => 0) }) afterEach(() => { vi.resetModules() + vi.useRealTimers() + freshness.set({}) }) describe("basic functionality", () => { @@ -112,26 +108,46 @@ describe("collection", () => { }) it("should respect freshness checks", async () => { + await vi.advanceTimersByTimeAsync(1000) const store = writable>([{id: "1", value: "stale"}]) const mockLoad = vi.fn() - vi.mocked(freshness.getFreshness).mockReturnValue(Date.now()) - const col = collection({ name: "test", store, getKey: item => item.id, load: mockLoad, }) - + // force freshness + setFreshnessImmediate({ns: "test", key: "1", ts: now()}) await col.loadItem("1") // Should not call load because item is fresh - expect(mockLoad).not.toHaveBeenCalled() + expect(mockLoad).toHaveBeenCalledTimes(0) + }) + + it("should reload stale items", async () => { + const mockLoad = vi.fn() + const store = writable([{id: "1", value: "test"}]) + + const col = collection({ + name: "test", + store, + getKey: (item: any) => item.id, + load: mockLoad, + }) + + // load the item to set freshness + await col.loadItem("1") + + await vi.advanceTimersByTimeAsync(4000 * 1000) + + await col.loadItem("1") + expect(mockLoad).toHaveBeenCalledTimes(2) }) it("should implement exponential backoff for failed attempts", async () => { const store = writable>([]) - const mockLoad = vi.fn().mockRejectedValue(new Error("Failed to load")) + const mockLoad = vi.fn().mockResolvedValue(undefined) const col = collection({ name: "test", @@ -141,9 +157,12 @@ describe("collection", () => { }) // First attempt - await col.loadItem("1").catch(() => {}) + await col.loadItem("1") expect(mockLoad).toHaveBeenCalledTimes(1) + //force freshness + setFreshnessImmediate({ns: "test", key: "1", ts: now()}) + // Immediate retry should be throttled await col.loadItem("1").catch(() => {}) expect(mockLoad).toHaveBeenCalledTimes(1) @@ -200,33 +219,17 @@ describe("collection", () => { describe("error handling", () => { it("should handle loader failures gracefully", async () => { const store = writable>([]) - const mockLoad = vi.fn().mockRejectedValue(new Error("Load failed")) - + const mockLoad = vi.fn(() => { + return Promise.reject("load failed") + }) const col = collection({ name: "test", store, getKey: item => item.id, load: mockLoad, }) - const result = await col.loadItem("1") expect(result).toBeUndefined() }) - - it("should clean up pending promises after load completion", async () => { - const store = writable>([]) - const mockLoad = vi.fn().mockResolvedValue({id: "1", value: "loaded"}) - - const col = collection({ - name: "test", - store, - getKey: item => item.id, - load: mockLoad, - }) - - await col.loadItem("1") - // @ts-ignore - accessing private property for testing - expect(col["pending"].size).toBe(0) - }) }) }) diff --git a/packages/app/__tests__/commands.test.ts b/packages/app/__tests__/commands.test.ts new file mode 100644 index 0000000..8d9ba21 --- /dev/null +++ b/packages/app/__tests__/commands.test.ts @@ -0,0 +1,261 @@ +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" + +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()) + // Reset any module state + vi.resetModules() + // Clear any cached data + vi.clearAllMocks() + }) + + afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + + thunkWorker.clear() + thunkWorker.pause() + thunkWorker.resume() + // vi.resetAllMocks() + }) + + describe("follow commands", () => { + it("should create new follows list if none exists", async () => { + const publishThunkSpy = vi.spyOn(thunkModule, "publishThunk") + await follow(["p", pubkey1]) + + 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]) + + 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]) + + expect(publishThunkSpy).toHaveBeenCalledWith({ + event: expect.objectContaining({ + kind: MUTES, + tags: expect.arrayContaining([["p", pubkey1]]), + }), + relays: ["relay1", "relay2"], + }) + }) + + it("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]) + + 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]) + + expect(publishThunkSpy).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + kind: PINS, + tags: expect.arrayContaining([["e", event1]]), + }), + relays: ["relay1", "relay2"], + }), + ) + }) + + it("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]) + + 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 new file mode 100644 index 0000000..1ab5a62 --- /dev/null +++ b/packages/app/__tests__/relay.test.ts @@ -0,0 +1,167 @@ +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 new file mode 100644 index 0000000..47271b9 --- /dev/null +++ b/packages/app/__tests__/relaySelection.test.ts @@ -0,0 +1,121 @@ +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 new file mode 100644 index 0000000..7aef0ab --- /dev/null +++ b/packages/app/__tests__/router.test.ts @@ -0,0 +1,296 @@ +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 new file mode 100644 index 0000000..900783a --- /dev/null +++ b/packages/app/__tests__/search.test.ts @@ -0,0 +1,118 @@ +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 new file mode 100644 index 0000000..9e7d158 --- /dev/null +++ b/packages/app/__tests__/storage.test.ts @@ -0,0 +1,198 @@ +import {describe, it, expect, vi, beforeEach, afterEach} from "vitest" +import {writable, get} from "svelte/store" +import {Repository} from "@welshman/util" +import {Tracker} from "@welshman/net" +import { + initStorage, + closeStorage, + clearStorage, + storageAdapters, + dead, + getAll, + bulkPut, + bulkDelete, +} from "../src/storage" + +describe("storage", () => { + const DB_NAME = "test-db" + const DB_VERSION = 1 + + beforeEach(async () => { + vi.clearAllMocks() + dead.set(false) + vi.useFakeTimers() + }) + + afterEach(async () => { + vi.useRealTimers() + await closeStorage() + // Clean up the test database + await new Promise((resolve, reject) => { + const req = indexedDB.deleteDatabase(DB_NAME) + req.onsuccess = () => resolve(undefined) + req.onerror = () => reject(req.error) + }) + }) + + describe("basic operations", () => { + it("should initialize storage and store items", async () => { + const store = writable<{id: string; value: string}[]>([]) + const adapters = { + items: storageAdapters.fromCollectionStore("id", store), + } + + initStorage(DB_NAME, DB_VERSION, adapters) + + await vi.runAllTimersAsync() + + store.set([ + {id: "1", value: "test1"}, + {id: "2", value: "test2"}, + ]) + + const itemsPromise = getAll("items") + await vi.runAllTimersAsync() + const items = await itemsPromise + + expect(items).toHaveLength(2) + expect(items).toContainEqual({id: "1", value: "test1"}) + expect(items).toContainEqual({id: "2", value: "test2"}) + }) + + it("should update items when store changes", async () => { + const store = writable<{id: string; value: string}[]>([]) + const adapters = { + items: storageAdapters.fromCollectionStore("id", store), + } + + initStorage(DB_NAME, DB_VERSION, adapters) + + await vi.runAllTimersAsync() + + // init storage with the first item + store.set([{id: "1", value: "test1"}]) + + store.update(items => [...items, {id: "2", value: "test2"}]) + + const itemsPromise = getAll("items") + await vi.runAllTimersAsync() + const items = await itemsPromise + + expect(items).toHaveLength(2) + expect(items).toContainEqual({id: "2", value: "test2"}) + }) + + it("should remove items when deleted from store", async () => { + const store = writable<{id: string; value: string}[]>() + const adapters = { + items: storageAdapters.fromCollectionStore("id", store), + } + + initStorage(DB_NAME, DB_VERSION, adapters) + + await vi.runAllTimersAsync() + + store.set([ + {id: "1", value: "test1"}, + {id: "2", value: "test2"}, + ]) + + store.update(items => items.filter(item => item.id !== "1")) + + const itemsPromise = getAll("items") + await vi.runAllTimersAsync() + const items = await itemsPromise + + expect(items).toHaveLength(1) + expect(items[0]).toEqual({id: "2", value: "test2"}) + }) + }) + + describe("storage adapters", () => { + it("should handle repository adapter", async () => { + const repository = new Repository() + const adapters = { + events: storageAdapters.fromRepository(repository), + } + + initStorage(DB_NAME, DB_VERSION, adapters) + + await vi.runAllTimersAsync() + + const event = { + id: "test-id", + pubkey: "test-pubkey", + kind: 1, + created_at: 123, + content: "test", + tags: [], + } + + repository.publish(event) + + const eventsPromise = getAll("events") + + await vi.runAllTimersAsync() + const events = await eventsPromise + + expect(events).toContainEqual(event) + }) + + it("should handle tracker adapter", async () => { + const tracker = new Tracker() + const adapters = { + relays: storageAdapters.fromTracker(tracker), + } + + initStorage(DB_NAME, DB_VERSION, adapters) + await vi.runAllTimersAsync() + + tracker.track("event1", "relay1") + tracker.track("event1", "relay2") + + const relaysPromise = getAll("relays") + await vi.runAllTimersAsync() + const relays = await relaysPromise + + expect(relays).toContainEqual({ + key: "event1", + value: ["relay1", "relay2"], + }) + }) + }) + + describe("error handling", () => { + it("should handle initialization errors", async () => { + const badAdapter = { + keyPath: undefined, + store: writable([]), + options: {}, + } + + const rejectPromise = initStorage(DB_NAME, DB_VERSION, {bad: badAdapter}) + + await vi.runAllTimersAsync() + + // we can initialize storage with an undefined keypath + expect(rejectPromise).to.not.rejects + }) + + it("should prevent multiple initializations", async () => { + const adapters = { + test: { + keyPath: "id", + store: writable([]), + options: {}, + }, + } + + initStorage(DB_NAME, DB_VERSION, adapters) + + await vi.runAllTimersAsync() + + await expect(initStorage(DB_NAME, DB_VERSION, adapters)).rejects.toThrow( + "Db initialized multiple times", + ) + }) + }) +}) diff --git a/packages/app/__tests__/subscribe.test.ts b/packages/app/__tests__/subscribe.test.ts new file mode 100644 index 0000000..6da54c7 --- /dev/null +++ b/packages/app/__tests__/subscribe.test.ts @@ -0,0 +1,384 @@ +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 new file mode 100644 index 0000000..268ca76 --- /dev/null +++ b/packages/app/__tests__/tags.test.ts @@ -0,0 +1,336 @@ +import {COMMENT, getAddress, MUTES, NOTE} from "@welshman/util" +import {beforeEach, describe, expect, it, vi} from "vitest" +import { + tagEvent, + tagEventForComment, + tagEventForQuote, + tagEventForReaction, + tagEventForReply, + tagEventPubkeys, + tagPubkey, + 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) + const id2 = "22".repeat(32) + + const pubkey = "aa".repeat(32) + const pubkey1 = "bb".repeat(32) + const pubkey2 = "cc".repeat(32) + + const mockEvent: any = { + id, + pubkey, + kind: 1, + tags: [], + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe("tagZapSplit", () => { + it("should create zap split tag with default split", () => { + const result = tagZapSplit(pubkey1) + expect(result).toEqual(["zap", pubkey1, "pubkey-relay-url", "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"]) + }) + }) + + 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"]) + }) + }) + + describe("tagEvent", () => { + 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]) + }) + + it("should include address tag for replaceable events", () => { + const replaceableEvent = {...mockEvent, kind: MUTES} + const result = tagEvent(replaceableEvent) + expect(result).toHaveLength(2) + expect(result[1][0]).toBe("a") + }) + }) + + describe("tagEventPubkeys", () => { + it("should extract and tag unique pubkeys from event", () => { + const event = { + ...mockEvent, + tags: [ + ["p", pubkey1], + ["p", pubkey2], + ], + } + const result = tagEventPubkeys(event) + expect(result).toHaveLength(3) // event.pubkey + 2 tagged pubkeys + expect(result.every(tag => tag[0] === "p")).toBe(true) + }) + }) + + describe("tagEventForQuote", () => { + it("should create quote tag", () => { + const result = tagEventForQuote(mockEvent) + expect(result).toEqual(["q", mockEvent.id, "event-relay-url", mockEvent.pubkey]) + }) + }) + + describe("tagEventForReply", () => { + it("should handle reply to event with no existing tags", () => { + const result = tagEventForReply(mockEvent) + expect(result.some(tag => tag[0] === "e")).toBe(true) + expect(result.some(tag => tag[3] === "root")).toBe(true) + }) + + it("should handle reply to event with root", () => { + const eventWithRoot = { + ...mockEvent, + tags: [ + ["e", id1, "", "root"], + ["p", pubkey1], + ], + } + const result = tagEventForReply(eventWithRoot) + const p = result.filter(tag => tag[0] === "p") + const e = result.filter(tag => tag[0] === "e") + // p[0] should be the author of the event + expect(p[0][1]).toBe(pubkey) + // p[1] should be the pubkey mentioned in the event + expect(p[1][1]).toBe(pubkey1) + // e[0] the "e" root tag should have been propagated + expect(e[0][1]).toBe(id1) + // e[1] should be the event id + expect(e[1][1]).toBe(id) + }) + + it("should handle reply to event with root and mention tags", () => { + const eventWithRoots = { + ...mockEvent, + tags: [ + ["e", id1, "relay-url"], // deprecated root tag + ["e", id2, "relay-url"], // deprecated reply type + ], + } + const result = tagEventForReply(eventWithRoots) + + const p = result.filter(tag => tag[0] === "p") + const e = result.filter(tag => tag[0] === "e") + + // p[0] should be the author of the event + expect(p[0][1]).toBe(pubkey) + // e[0] should be the root propagated + expect(e[0][1]).toBe(id1) + expect(e[0][3]).toBe("root") + // e[1] should be treated as a mention, it is the note the parent replied to + expect(e[1][1]).toBe(id2) + expect(e[1][3]).toBe("mention") + // e[2] should be the event id and marked as a reply + expect(e[2][1]).toBe(id) + expect(e[2][3]).toBe("reply") + }) + + it("should handle replaceable events", () => { + const replaceableEvent = { + ...mockEvent, + kind: MUTES, + tags: [ + ["e", id1, "relay-url", "root"], + ["e", id2, "relay-url", "mention"], + ], + } + const result = tagEventForReply(replaceableEvent) + + const p = result.filter(tag => tag[0] === "p") + const e = result.filter(tag => tag[0] === "e") + const a = result.filter(tag => tag[0] === "a") + + // p[0] should be the author of the event + expect(p[0][1]).toBe(pubkey) + // e[0] should be the root propagated + expect(e[0][1]).toBe(id1) + expect(e[0][3]).toBe("root") + // e[1] should be treated as a mention, it is the note the parent replied to + expect(e[1][1]).toBe(id2) + expect(e[1][3]).toBe("mention") + // e[2] should be the event id and marked as a reply + expect(e[2][1]).toBe(id) + expect(e[2][3]).toBe("reply") + + // a[0] should be the address of the replaceable event + expect(a[0][1]).toBe(getAddress(replaceableEvent)) + + console.log(result) + }) + }) + + describe("tagEventForComment", () => { + it("should create comment tags for basic event", () => { + const result = tagEventForComment(mockEvent) + expect(result.some(tag => tag[0] === "K")).toBe(true) + expect(result.some(tag => tag[0] === "P")).toBe(true) + expect(result.some(tag => tag[0] === "E")).toBe(true) + }) + + it("should handle replaceable events", () => { + const replaceableEvent = {...mockEvent, kind: MUTES} + const result = tagEventForComment(replaceableEvent) + expect(result.some(tag => tag[0] === "A")).toBe(true) + expect(result.some(tag => tag[0] === "a")).toBe(true) + }) + + it("should preserve root tags and point to the direct parent", () => { + const eventWithTags = { + ...mockEvent, + kind: COMMENT, + tags: [ + ["e", id2, "relay-url", "root"], + ["p", pubkey2, "relay-url"], + ["k", NOTE.toString()], + ["E", id1, "relay-url", "root"], + ["P", pubkey1, "relay-url"], + ["K", NOTE.toString()], + ], + } + const result = tagEventForComment(eventWithTags) + + // Should preserve uppercase variants of existing tags + // expect(result.some(tag => tag[0] === "E" && tag[1] === id1)).toBe(true) + // expect(result.some(tag => tag[0] === "P" && tag[1] === pubkey1)).toBe(true) + // expect(result.some(tag => tag[0] === "K" && tag[1] === NOTE.toString())).toBe(true) + + // Should also add lowercase variants + expect(result.some(tag => tag[0] === "e" && tag[1] === eventWithTags.id)).toBe(true) + expect(result.some(tag => tag[0] === "p" && tag[1] === eventWithTags.pubkey)).toBe(true) + expect(result.some(tag => tag[0] === "k" && tag[1] === COMMENT.toString())).toBe(true) + }) + + it("should handle events with multiple root tags", () => { + const eventWithMultipleRoots = { + ...mockEvent, + tags: [ + ["e", id1, "relay-url", "root"], + ["e", id2, "relay-url", "root"], + ], + } + const result = tagEventForComment(eventWithMultipleRoots) + + // First root should be uppercase + expect(result.some(tag => tag[0] === "E" && tag[1] === id1)).toBe(true) + // Subsequent roots should be lowercase + expect(result.some(tag => tag[0] === "e" && tag[1] === id2)).toBe(true) + }) + + it("should handle events with mixed tag types", () => { + const eventWithMixedTags = { + ...mockEvent, + kind: MUTES, + tags: [ + ["e", id, "relay-url", "root"], + ["p", pubkey1, "relay-url"], + ["i", id1], + ["a", "some-address", "relay-url"], + ["custom", "value"], + ], + } + const result = tagEventForComment(eventWithMixedTags) + + // Should propagate root tags (e, p, i, a) to uppercase + expect(result.some(tag => tag[0] === "E" && tag[1] === id)).toBe(true) + expect(result.some(tag => tag[0] === "P" && tag[1] === pubkey1)).toBe(true) + expect(result.some(tag => tag[0] === "I" && tag[1] === id1)).toBe(true) + expect(result.some(tag => tag[0] === "A" && tag[1] === "some-address")).toBe(true) + + // Should include parent variants in lowercase + expect(result.some(tag => tag[0] === "e" && tag[1] === id)).toBe(true) + expect(result.some(tag => tag[0] === "p" && tag[1] === pubkey1)).toBe(true) + + // Should not include non-relevant tags + expect(result.some(tag => tag[0] === "custom")).toBe(false) + }) + + it("should add event metadata tags when no root tags exist", () => { + const eventWithoutRoots = { + ...mockEvent, + tags: [["custom", "value"]], + } + const result = tagEventForComment(eventWithoutRoots) + + // Should add uppercase metadata tags (roots) + expect(result.some(tag => tag[0] === "K" && tag[1] === String(mockEvent.kind))).toBe(true) + expect(result.some(tag => tag[0] === "P" && tag[1] === mockEvent.pubkey)).toBe(true) + expect(result.some(tag => tag[0] === "E" && tag[1] === mockEvent.id)).toBe(true) + + // Should add lowercase variants (parents) + expect(result.some(tag => tag[0] === "k" && tag[1] === String(mockEvent.kind))).toBe(true) + expect(result.some(tag => tag[0] === "p" && tag[1] === mockEvent.pubkey)).toBe(true) + expect(result.some(tag => tag[0] === "e" && tag[1] === mockEvent.id)).toBe(true) + }) + }) + + describe("tagEventForReaction", () => { + it("should create reaction tags", () => { + const result = tagEventForReaction(mockEvent) + expect(result.some(tag => tag[0] === "k")).toBe(true) + expect(result.some(tag => tag[0] === "e")).toBe(true) + }) + + it("should include author tag if different from current user", () => { + const result = tagEventForReaction(mockEvent) + expect(result.some(tag => tag[0] === "p")).toBe(true) + }) + + it("should handle replaceable events", () => { + const replaceableEvent = {...mockEvent, kind: MUTES} + const result = tagEventForReaction(replaceableEvent) + expect(result.some(tag => tag[0] === "a")).toBe(true) + }) + }) +}) diff --git a/packages/app/__tests__/thunk.test.ts b/packages/app/__tests__/thunk.test.ts new file mode 100644 index 0000000..cd49769 --- /dev/null +++ b/packages/app/__tests__/thunk.test.ts @@ -0,0 +1,286 @@ +import {now} from "@welshman/lib" +import {publish, PublishStatus} from "@welshman/net" +import {NOTE} from "@welshman/util" +import {EventEmitter} from "events" +import {afterEach, beforeEach, describe, expect, it, vi} from "vitest" +import {repository, tracker} from "../src/core" +import * as sessionModule from "../src/session" +import { + abortThunk, + makeThunk, + mergeThunks, + prepEvent, + publishThunk, + publishThunks, + thunkWorker, + walkThunks, +} from "../src/thunk" + +// Mock dependencies +vi.mock("@welshman/net", () => ({ + publish: vi.fn(), + PublishStatus: { + Pending: "pending", + Success: "success", + Failure: "failure", + Timeout: "timeout", + Aborted: "aborted", + }, +})) + +vi.mock("../src/session", () => ({ + pubkey: { + get: vi.fn().mockReturnValue("aa".repeat(32)), + }, + getSession: vi.fn(), + getSigner: vi.fn(), +})) + +vi.mock("../src/core", () => ({ + repository: { + publish: vi.fn(), + removeEvent: vi.fn(), + getEvent: vi.fn(), + }, + tracker: { + track: vi.fn(), + }, +})) + +const pubkey = "aa".repeat(32) +const id = "00".repeat(32) +const mockEvent = { + id, + pubkey, + kind: NOTE, + created_at: now(), + content: "test content", + tags: [], +} + +const mockRequest = { + event: mockEvent, + relays: ["relay1", "relay2"], +} + +describe("thunk", () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + }) + + afterEach(() => { + vi.useRealTimers() + vi.resetModules() + thunkWorker.clear() + thunkWorker.pause() // clear timeout + thunkWorker.resume() + }) + + describe("prepEvent", () => { + it("should prepare an event with stamp, own, and hash", () => { + const result = prepEvent(mockEvent) + expect(result).toHaveProperty("id") + expect(result).toHaveProperty("pubkey") + expect(result).toHaveProperty("created_at") + }) + }) + + describe("makeThunk", () => { + it("should create a thunk with required properties", () => { + const thunk = makeThunk(mockRequest) + expect(thunk).toHaveProperty("event") + expect(thunk).toHaveProperty("request") + expect(thunk).toHaveProperty("controller") + expect(thunk).toHaveProperty("result") + expect(thunk).toHaveProperty("status") + }) + }) + + describe("mergeThunks", () => { + it("should merge multiple thunks", () => { + const thunk1 = makeThunk(mockRequest) + const thunk2 = makeThunk(mockRequest) + const merged = mergeThunks([thunk1, thunk2]) + + expect(merged).toHaveProperty("thunks") + expect(merged.thunks).toHaveLength(2) + expect(merged).toHaveProperty("controller") + expect(merged).toHaveProperty("result") + expect(merged).toHaveProperty("status") + }) + + it("should abort all thunks when merged controller aborts", () => { + const thunk1 = makeThunk(mockRequest) + const thunk2 = makeThunk(mockRequest) + const merged = mergeThunks([thunk1, thunk2]) + + merged.controller.abort() + + expect(thunk1.controller.signal.aborted).toBe(true) + expect(thunk2.controller.signal.aborted).toBe(true) + }) + }) + + describe("walkThunks", () => { + it("should iterate through nested thunks", () => { + const thunk1 = makeThunk(mockRequest) + const thunk2 = makeThunk(mockRequest) + const merged = mergeThunks([thunk1, thunk2]) + const thunks = Array.from(walkThunks([merged, thunk1])) + + expect(thunks).toHaveLength(3) + }) + }) + + describe("publishThunk", () => { + it("should create and publish a thunk", async () => { + const result = publishThunk(mockRequest) + + expect(repository.publish).toHaveBeenCalled() + expect(result).toHaveProperty("event") + expect(result).toHaveProperty("request") + }) + + it("should handle abort", () => { + const thunk = publishThunk(mockRequest) + thunk.controller.abort() + + expect(repository.removeEvent).toHaveBeenCalledWith(thunk.event.id) + }) + }) + + describe("publishThunks", () => { + it("should publish multiple thunks", () => { + const requests = [mockRequest, mockRequest] + const result = publishThunks(requests) + + expect(repository.publish).toHaveBeenCalledTimes(2) + expect(result.thunks).toHaveLength(2) + }) + }) + + describe("abortThunk", () => { + it("should abort a thunk and clean up", () => { + const thunk = makeThunk(mockRequest) + abortThunk(thunk) + + expect(repository.removeEvent).toHaveBeenCalledWith(thunk.event.id) + }) + }) +}) + +describe("thunkWorker", async () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + }) + + afterEach(() => { + vi.useRealTimers() + vi.resetModules() + thunkWorker.clear() + }) + + const mockSigner = { + sign: vi.fn().mockResolvedValue({...mockEvent, sig: "test-sig"}), + } + vi.mocked(sessionModule.getSigner).mockReturnValue(mockSigner) + + it("should handle publishing events", async () => { + const thunk = makeThunk(mockRequest) + + thunkWorker.push(thunk) + + await vi.runAllTimersAsync() + + expect(mockSigner.sign).toHaveBeenCalled() + }) + + it("should handle delayed publishing", async () => { + const thunk = makeThunk({...mockRequest, delay: 100}) + const startTime = Date.now() + + thunkWorker.push(thunk) + + await vi.runAllTimersAsync() + + const endTime = Date.now() + // worker delays work by 50ms, so total delay should be 150ms + expect(endTime - startTime).toBe(150) + }) + + it("should update status during publishing", async () => { + // Create mock emitter + const mockEmitter = new EventEmitter() + vi.mocked(publish).mockReturnValue({ + emitter: mockEmitter, + }) + + const thunk = makeThunk(mockRequest) + const statuses: Map = new Map() + + // Subscribe to status updates + thunk.status.subscribe(status => { + for (const [key, value] of Object.entries(status)) { + statuses.set(key, value) + } + }) + + // Start the publish process + thunkWorker.push(thunk) + + // Wait for initial async operations + await vi.runAllTimersAsync() + + // Simulate publish status updates + mockEmitter.emit("*", PublishStatus.Pending, "relay1", "Connecting...") + + await vi.runAllTimersAsync() + + expect(statuses.get("relay1")).toEqual({ + status: PublishStatus.Pending, + message: "Connecting...", + }) + + mockEmitter.emit("*", PublishStatus.Success, "relay1", "Published") + + await vi.runAllTimersAsync() + + expect(statuses.get("relay1")).toEqual({ + status: PublishStatus.Success, + message: "Published", + }) + + // Verify tracker was called on success + expect(tracker.track).toHaveBeenCalledWith(thunk.event.id, "relay1") + + // Verify all relays complete resolves the result + mockEmitter.emit("*", PublishStatus.Success, "relay2", "Published") + + await vi.runAllTimersAsync() + + const finalStatus = await thunk.result + expect(finalStatus).toEqual({ + relay1: {status: PublishStatus.Success, message: "Published"}, + relay2: {status: PublishStatus.Success, message: "Published"}, + }) + }) + + it("should handle publish failures", async () => { + const mockSigner = { + sign: vi.fn().mockRejectedValue(new Error("Signing failed")), + } + vi.mocked(sessionModule.getSigner).mockReturnValue(mockSigner) + + const thunk = makeThunk(mockRequest) + + thunkWorker.push(thunk) + + await vi.runAllTimersAsync() + + expect(mockSigner.sign).toHaveBeenCalled() + + // in case of failure, the worker will just stop its task, event is not removed + }) +}) diff --git a/packages/app/__tests__/zappers.test.ts b/packages/app/__tests__/zappers.test.ts new file mode 100644 index 0000000..d9d5fc4 --- /dev/null +++ b/packages/app/__tests__/zappers.test.ts @@ -0,0 +1,229 @@ +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/collection.ts b/packages/app/src/collection.ts index 4958d79..5462a25 100644 --- a/packages/app/src/collection.ts +++ b/packages/app/src/collection.ts @@ -55,6 +55,8 @@ export const collection = ({ try { await promise + } catch (e) { + console.warn(`Failed to load ${name} item ${key}`, e) } finally { pending.delete(key) } diff --git a/packages/content/src/index.ts b/packages/content/src/index.ts index 4331f0d..56784e8 100644 --- a/packages/content/src/index.ts +++ b/packages/content/src/index.ts @@ -421,14 +421,16 @@ export const truncate = ( // Otherwise, truncate more then necessary so that when the user expands the note // they have more than just a tiny bit to look at. Truncating a single word is annoying. sizes.every((size, i) => { + currentSize += size + if (currentSize > minLength) { - content = content.slice(0, i).concat({type: ParsedType.Ellipsis, value: "…", raw: ""}) + content = content + .slice(0, Math.max(1, i)) // do not truncate the first element in profit of an ellipsis + .concat({type: ParsedType.Ellipsis, value: "…", raw: ""}) return false } - currentSize += size - return true }) diff --git a/packages/dvm/.eslintignore b/packages/dvm/.eslintignore index 43e824a..4c72c12 100644 --- a/packages/dvm/.eslintignore +++ b/packages/dvm/.eslintignore @@ -1,2 +1,3 @@ build normalize-url +__tests__ \ No newline at end of file diff --git a/packages/dvm/__tests__/handler.test.ts b/packages/dvm/__tests__/handler.test.ts new file mode 100644 index 0000000..4d408be --- /dev/null +++ b/packages/dvm/__tests__/handler.test.ts @@ -0,0 +1,288 @@ +import {publish, subscribe} from "@welshman/net" +import type {StampedEvent, TrustedEvent} from "@welshman/util" +import {finalizeEvent} from "nostr-tools/pure" +import {afterAll, beforeEach, describe, expect, it, vi} from "vitest" +import {DVM, type DVMHandler, type DVMOpts} from "../src/handler" +import {now} from "@welshman/lib" + +// Mock dependencies +vi.mock("nostr-tools/pure", () => ({ + getPublicKey: vi.fn().mockReturnValue("ee".repeat(32)), + finalizeEvent: vi.fn(template => ({...template, sig: "ff".repeat(64)})), +})) + +vi.mock("@welshman/net", () => ({ + subscribe: vi.fn(), + publish: vi.fn(), +})) + +describe("DVM", () => { + let dvm: DVM + let mockHandler: DVMHandler + let mockSubscription: any + let mockPublish: any + + const sk = "ff".repeat(32) + const id = "dd".repeat(32) + const pubkey = "ee".repeat(32) + + beforeEach(() => { + // Reset mocks + vi.clearAllMocks() + + vi.useFakeTimers() + + // Setup mock handler + mockHandler = { + handleEvent: vi.fn().mockImplementation(async function* (e: TrustedEvent) { + yield {kind: 1, tags: [], content: "response"} as StampedEvent + }), + } + + // Setup mock subscription + mockSubscription = { + on: vi.fn((event, callback) => { + if (event === "complete") { + // Simulate completion after a delay + setTimeout(callback, 0) + } + }), + } + vi.mocked(subscribe).mockReturnValue(mockSubscription) + + // Setup mock publish + mockPublish = { + emitter: { + on: vi.fn((event, callback) => { + if (event === "success") { + callback() + } + }), + }, + } + vi.mocked(publish).mockReturnValue(mockPublish) + + // Create DVM instance + const opts: DVMOpts = { + sk: sk, + relays: ["relay1", "relay2"], + handlers: { + "1": () => mockHandler, + }, + } + + dvm = new DVM(opts) + }) + + afterAll(() => { + vi.useRealTimers() + }) + + describe("initialization", () => { + it("should initialize with provided handlers", () => { + expect(dvm.handlers.get(1)).toBeDefined() + expect(dvm.active).toBe(false) + }) + + it("should parse handler kinds as integers", () => { + const dvm = new DVM({ + sk, + relays: [], + handlers: {"1": () => mockHandler}, + }) + expect(dvm.handlers.get(1)).toBeDefined() + }) + }) + + describe("start", () => { + it("should start subscription with correct filters", async () => { + await Promise.all([ + dvm.start(), + vi.advanceTimersByTimeAsync(1000), + new Promise(resolve => setTimeout(() => resolve(dvm.stop()), 1000)), + ]) + + expect(subscribe).toHaveBeenCalledWith({ + relays: ["relay1", "relay2"], + filters: [{kinds: [1], since: now()}], + }) + }) + + it("should include pubkey filter when requireMention is true", async () => { + dvm = new DVM({ + sk, + relays: ["relay1"], + handlers: {"1": () => mockHandler}, + requireMention: true, + }) + + await Promise.all([ + dvm.start(), + vi.advanceTimersByTimeAsync(1000), + new Promise(resolve => setTimeout(() => resolve(dvm.stop()), 1000)), + ]) + + expect(subscribe).toHaveBeenCalledWith({ + relays: ["relay1"], + filters: [ + { + kinds: [1], + since: now() - 1, + "#p": [pubkey], + }, + ], + }) + }) + }) + + describe("event handling", () => { + it("should ignore duplicate events", async () => { + const event = {id, kind: 1, tags: [], content: ""} as any + + await dvm.onEvent(event) + await dvm.onEvent(event) + + expect(mockHandler.handleEvent).toHaveBeenCalledTimes(1) + }) + + it("should ignore events without handlers", async () => { + const event = {id, kind: 2} as TrustedEvent + + await dvm.onEvent(event) + + expect(mockHandler.handleEvent).not.toHaveBeenCalled() + }) + + it("should add required tags to response events", async () => { + const request = { + id, + kind: 1, + pubkey, + tags: [["i", "input123"]], + } as TrustedEvent + + mockHandler.handleEvent.mockImplementation(async function* () { + yield {kind: 1, tags: []} as StampedEvent + }) + + await dvm.onEvent(request) + + vi.advanceTimersByTimeAsync(100) + + expect(publish).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + tags: expect.arrayContaining([ + ["request", expect.any(String)], + ["i", "input123"], + ["p", pubkey], + ["e", id], + ["expiration", (now() + 60 * 60).toString()], // default expireAfter is 1 hour + ]), + }), + }), + ) + }) + + it("should not add request tag to job status events", async () => { + const request = { + id, + kind: 1, + pubkey, + tags: [], + } as any + + mockHandler.handleEvent.mockImplementation(async function* () { + yield {kind: 7000, tags: []} as StampedEvent + }) + + await dvm.onEvent(request) + + const publishedEvent = vi.mocked(publish).mock.calls[0][0].event + expect(publishedEvent.tags).not.toContainEqual(expect.arrayContaining(["request"])) + }) + + it("should handle custom expiration time", async () => { + dvm = new DVM({ + sk, + relays: ["relay1"], + handlers: {"1": () => mockHandler}, + expireAfter: 120, // 2 minutes + }) + + const request = { + id, + kind: 1, + pubkey, + tags: [], + } as any + + await dvm.onEvent(request) + + expect(publish).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + tags: expect.arrayContaining([["expiration", (now() + 120).toString()]]), + }), + }), + ) + }) + }) + + describe("publish", () => { + it("should finalize and publish events", async () => { + const template = { + kind: 1, + tags: [], + content: "test", + } as any + + await dvm.publish(template) + + expect(finalizeEvent).toHaveBeenCalledWith(template, expect.any(Uint8Array)) + expect(publish).toHaveBeenCalledWith({ + event: expect.any(Object), + relays: ["relay1", "relay2"], + }) + }) + }) + + describe("cleanup", () => { + it("should stop all handlers", () => { + const stopHandler = { + stop: vi.fn(), + handleEvent: vi.fn(), + } + + dvm = new DVM({ + sk: sk, + relays: ["relay1"], + handlers: {"1": () => stopHandler}, + }) + + dvm.stop() + + expect(stopHandler.stop).toHaveBeenCalled() + expect(dvm.active).toBe(false) + }) + }) + + describe("logging", () => { + it("should log events when enabled", async () => { + const consoleSpy = vi.spyOn(console, "info") + dvm.logEvents = true + + const request = { + id: "req123", + kind: 1, + pubkey: "pub123", + tags: [], + } as any + + await dvm.onEvent(request) + + expect(consoleSpy).toHaveBeenCalledWith("Handling request", request) + expect(consoleSpy).toHaveBeenCalledWith("Publishing event", expect.any(Object)) + }) + }) +}) diff --git a/packages/dvm/__tests__/request.test.ts b/packages/dvm/__tests__/request.test.ts new file mode 100644 index 0000000..88ce103 --- /dev/null +++ b/packages/dvm/__tests__/request.test.ts @@ -0,0 +1,219 @@ +import {now} from "@welshman/lib" +import {subscribe, publish, SubscriptionEvent} from "@welshman/net" +import type {SignedEvent, TrustedEvent} from "@welshman/util" +import {vi, describe, it, expect, beforeEach} from "vitest" +import {makeDvmRequest, DVMEvent} from "../src/request" + +// Mock dependencies +vi.mock(import("@welshman/lib"), async importOriginal => ({ + ...(await importOriginal()), + Emitter: vi.fn().mockImplementation(() => ({ + emit: vi.fn(), + on: vi.fn(), + })), +})) + +vi.mock("@welshman/net", () => ({ + subscribe: vi.fn(), + publish: vi.fn(), + SubscriptionEvent: { + Event: "event", + }, +})) + +describe("DVM Request", () => { + let mockSubscription: any + let mockPublish: any + let baseEvent: SignedEvent + + const id = "dd".repeat(32) + const pubkey = "ee".repeat(32) + const sig = "ff".repeat(64) + + beforeEach(() => { + vi.clearAllMocks() + + // Setup mock subscription + mockSubscription = { + on: vi.fn(), + close: vi.fn(), + } + vi.mocked(subscribe).mockReturnValue(mockSubscription) + + // Setup mock publish + mockPublish = { + emitter: {on: vi.fn()}, + } + vi.mocked(publish).mockReturnValue(mockPublish) + + // Base event for testing + baseEvent = { + id, + kind: 5000, + pubkey, + content: "", + tags: [], + created_at: now(), + sig, + } + }) + + describe("makeDvmRequest", () => { + it("should create subscription with correct filters", () => { + const request = makeDvmRequest({ + event: baseEvent, + relays: ["relay1", "relay2"], + }) + + expect(subscribe).toHaveBeenCalledWith({ + relays: ["relay1", "relay2"], + timeout: 30000, + filters: [ + { + kinds: [6000, 7000], // kind + 1000 and progress events + since: now() - 60, // now() - 60 + "#e": [id], + }, + ], + }) + }) + + it("should respect custom timeout", () => { + const request = makeDvmRequest({ + event: baseEvent, + relays: ["relay1"], + timeout: 5000, + }) + + expect(subscribe).toHaveBeenCalledWith( + expect.objectContaining({ + timeout: 5000, + }), + ) + }) + + it("should disable progress events when reportProgress is false", () => { + makeDvmRequest({ + event: baseEvent, + relays: ["relay1"], + reportProgress: false, + }) + + expect(subscribe).toHaveBeenCalledWith( + expect.objectContaining({ + filters: [ + { + kinds: [6000], // Only result kind, no progress events + since: expect.any(Number), + "#e": [baseEvent.id], + }, + ], + }), + ) + }) + + it("should publish request event", () => { + makeDvmRequest({ + event: baseEvent, + relays: ["relay1"], + }) + + expect(publish).toHaveBeenCalledWith({ + event: baseEvent, + relays: ["relay1"], + timeout: 30000, + }) + }) + }) + + describe("event handling", () => { + it("should emit progress events", () => { + const request = makeDvmRequest({ + event: baseEvent, + relays: ["relay1"], + }) + + // Get the event handler + const eventHandler = mockSubscription.on.mock.calls.find( + call => call[0] === SubscriptionEvent.Event, + )[1] + + // Simulate progress event + const progressEvent = { + kind: 7000, + content: "progress", + } as TrustedEvent + + eventHandler("relay1", progressEvent) + + expect(request.emitter.emit).toHaveBeenCalledWith(DVMEvent.Progress, "relay1", progressEvent) + }) + + it("should emit and auto-close on result events", () => { + const request = makeDvmRequest({ + event: baseEvent, + relays: ["relay1"], + }) + + // Get the event handler + const eventHandler = mockSubscription.on.mock.calls.find( + call => call[0] === SubscriptionEvent.Event, + )[1] + + // Simulate result event + const resultEvent = { + kind: 6000, + content: "result", + } as TrustedEvent + + eventHandler("relay1", resultEvent) + + expect(request.emitter.emit).toHaveBeenCalledWith(DVMEvent.Result, "relay1", resultEvent) + expect(request.sub.close).toHaveBeenCalled() + }) + + it("should not auto-close when autoClose is false", () => { + const request = makeDvmRequest({ + event: baseEvent, + relays: ["relay1"], + autoClose: false, + }) + + // Get the event handler + const eventHandler = mockSubscription.on.mock.calls.find( + call => call[0] === SubscriptionEvent.Event, + )[1] + + // Simulate result event + const resultEvent = { + kind: 6000, + content: "result", + } as TrustedEvent + + eventHandler("relay1", resultEvent) + + expect(request.sub.close).not.toHaveBeenCalled() + }) + }) + + describe("request object structure", () => { + it("should return correctly structured request object", () => { + const requestOpts = { + event: baseEvent, + relays: ["relay1"], + timeout: 5000, + autoClose: false, + reportProgress: false, + } + + const request = makeDvmRequest(requestOpts) + + expect(request).toEqual({ + request: requestOpts, + emitter: expect.any(Object), + sub: mockSubscription, + pub: mockPublish, + }) + }) + }) +}) diff --git a/packages/signer/.eslintignore b/packages/signer/.eslintignore index 43e824a..4c72c12 100644 --- a/packages/signer/.eslintignore +++ b/packages/signer/.eslintignore @@ -1,2 +1,3 @@ build normalize-url +__tests__ \ No newline at end of file diff --git a/packages/signer/__tests__/common.ts b/packages/signer/__tests__/common.ts new file mode 100644 index 0000000..c65dbeb --- /dev/null +++ b/packages/signer/__tests__/common.ts @@ -0,0 +1,58 @@ +import {ISigner} from "@welshman/signer" +import {StampedEvent} from "@welshman/util" +import {beforeEach, describe, expect, it} from "vitest" + +// Common test suite for all signers +export const testSigner = (name: string, createSigner: () => ISigner) => { + describe(name, () => { + let signer: ISigner + + beforeEach(() => { + signer = createSigner() + }) + + describe("getPubkey", () => { + it("should return valid public key", async () => { + const pubkey = await signer.getPubkey() + expect(pubkey).toMatch(/^[0-9a-f]{64}$/) // hex pubkey + }) + }) + + describe("sign", () => { + it("should sign event correctly", async () => { + const event: StampedEvent = { + kind: 1, + created_at: 1000, + tags: [], + content: "test", + } + const signed = await signer.sign(event) + expect(signed.sig).toMatch(/^[0-9a-f]{128}$/) // hex signature + }) + }) + + describe("nip04", () => { + it("should encrypt and decrypt messages", async () => { + const message = "test message" + const pubkey = await signer.getPubkey() + + const encrypted = await signer.nip04.encrypt(pubkey, message) + const decrypted = await signer.nip04.decrypt(pubkey, encrypted) + + expect(decrypted).toBe(message) + }) + }) + + describe("nip44", () => { + it("should encrypt and decrypt messages", async () => { + const message = "test message" + const pubkey = await signer.getPubkey() + + const encrypted = await signer.nip44.encrypt(pubkey, message) + const decrypted = await signer.nip44.decrypt(pubkey, encrypted) + + expect(decrypted).toBe(message) + }) + }) + }) +} diff --git a/packages/signer/__tests__/nip01.test.ts b/packages/signer/__tests__/nip01.test.ts new file mode 100644 index 0000000..b65d54f --- /dev/null +++ b/packages/signer/__tests__/nip01.test.ts @@ -0,0 +1,13 @@ +import {describe, expect, it} from "vitest" +import {Nip01Signer} from "../src/signers/nip01" +import {testSigner} from "./common" + +describe("Nip01Signer", () => { + testSigner("Nip01Signer", () => Nip01Signer.fromSecret("ee".repeat(32))) + + // Additional NIP-01 specific tests + it("should create ephemeral signer", () => { + const signer = Nip01Signer.ephemeral() + expect(signer).toBeInstanceOf(Nip01Signer) + }) +}) diff --git a/packages/signer/__tests__/nip07.test.ts b/packages/signer/__tests__/nip07.test.ts new file mode 100644 index 0000000..7ca8223 --- /dev/null +++ b/packages/signer/__tests__/nip07.test.ts @@ -0,0 +1,31 @@ +import {beforeEach, describe, expect, it, vi} from "vitest" +import {Nip07Signer} from "../src/signers/nip07" +import {testSigner} from "./common" +import {SignedEvent} from "@welshman/util" + +describe("Nip07Signer", () => { + beforeEach(() => { + // Mock window.nostr + ;(window as any).nostr = { + getPublicKey: vi.fn().mockResolvedValue("ee".repeat(32)), + signEvent: vi.fn().mockResolvedValue({sig: "ff".repeat(64)} as SignedEvent), + nip04: { + encrypt: vi.fn((pubkey, message) => "encrypted:" + message), + decrypt: vi.fn((pubkey, encryptedMessage) => encryptedMessage.split("encrypted:")[1]), + }, + nip44: { + encrypt: vi.fn((pubkey, message) => "encrypted:" + message), + decrypt: vi.fn((pubkey, encryptedMessage) => encryptedMessage.split("encrypted:")[1]), + }, + } + }) + + testSigner("Nip07Signer", () => new Nip07Signer()) + + // Additional NIP-07 specific tests + it("should handle missing extension", async () => { + delete (window as any).nostr + const signer = new Nip07Signer() + await expect(signer.getPubkey()).rejects.toThrow("Nip07 is not enabled") + }) +}) diff --git a/packages/signer/__tests__/nip46.test.ts b/packages/signer/__tests__/nip46.test.ts new file mode 100644 index 0000000..6638d63 --- /dev/null +++ b/packages/signer/__tests__/nip46.test.ts @@ -0,0 +1,673 @@ +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", + }) + }) + }) +}) diff --git a/packages/signer/__tests__/nip55.test.ts b/packages/signer/__tests__/nip55.test.ts new file mode 100644 index 0000000..cd3b6f1 --- /dev/null +++ b/packages/signer/__tests__/nip55.test.ts @@ -0,0 +1,34 @@ +import {NostrSignerPlugin} from "nostr-signer-capacitor-plugin" +import {describe, beforeEach, vi, it, expect} from "vitest" +import {Nip55Signer} from "../src/signers/nip55" +import {testSigner} from "./common" +import {npubEncode} from "nostr-tools/nip19" + +vi.mock("nostr-signer-capacitor-plugin", () => ({ + NostrSignerPlugin: { + setPackageName: vi.fn().mockResolvedValue(undefined), + getPublicKey: vi.fn(() => ({npub: npubEncode("ee".repeat(32))})), + signEvent: vi.fn().mockResolvedValue({ + event: JSON.stringify({sig: "ee".repeat(64)}), + }), + nip04Encrypt: vi.fn(({plainText}) => ({result: "encrypted:" + plainText})), + nip04Decrypt: vi.fn(({encryptedText}) => ({result: encryptedText.split("encrypted:")[1]})), + nip44Encrypt: vi.fn(({plainText}) => ({result: "encrypted:" + plainText})), + nip44Decrypt: vi.fn(({encryptedText}) => ({result: encryptedText.split("encrypted:")[1]})), + }, +})) + +describe("Nip55Signer", () => { + beforeEach(() => { + // Mock NostrSignerPlugin + }) + + testSigner("Nip55Signer", () => new Nip55Signer("test-package")) + + // Additional NIP-55 specific tests + it("should handle package initialization", async () => { + const signer = new Nip55Signer("test-package") + await signer.getPubkey() + expect(NostrSignerPlugin.setPackageName).toHaveBeenCalledWith({packageName: "test-package"}) + }) +}) diff --git a/packages/signer/src/signers/nip07.ts b/packages/signer/src/signers/nip07.ts index 9f6b371..7bde3d6 100644 --- a/packages/signer/src/signers/nip07.ts +++ b/packages/signer/src/signers/nip07.ts @@ -31,7 +31,7 @@ export class Nip07Signer implements ISigner { return promise } - getPubkey = async () => getNip07()!.getPublicKey()! + getPubkey = async () => this.#then(ext => ext.getPublicKey() as string) sign = async (template: StampedEvent) => { const event = hash(own(template, await this.getPubkey())) diff --git a/packages/signer/src/signers/nip46.ts b/packages/signer/src/signers/nip46.ts index e756a99..db2a1fb 100644 --- a/packages/signer/src/signers/nip46.ts +++ b/packages/signer/src/signers/nip46.ts @@ -104,7 +104,8 @@ export class Nip46Receiver extends Emitter { ) { super() } - + // start listening to the remote signer for incoming events + // broadcast any event returned by the remote signer start = async () => { if (this.sub) return @@ -150,7 +151,7 @@ export class Nip46Sender extends Emitter { ) { super() } - + // send a request to the remote signer, emitting the request and the pub public send = async (request: Nip46Request) => { const {id, method, params} = request const {relays, signerPubkey, algorithm = "nip44"} = this.params @@ -168,6 +169,7 @@ export class Nip46Sender extends Emitter { this.emit(Nip46Event.Send, {...request, pub}) } + // process the queue of requests public process = async () => { if (this.processing) { return @@ -190,6 +192,7 @@ export class Nip46Sender extends Emitter { } } + // enqueue a request to the queue and process it enqueue = (request: Nip46Request) => { this.queue.push(request) this.process() @@ -200,6 +203,7 @@ export class Nip46Sender extends Emitter { } } +// NIP 46 request object constructor export class Nip46Request { id = randomId() promise = defer() @@ -209,6 +213,7 @@ export class Nip46Request { readonly params: string[], ) {} + // listen for a response from the remote signer and resolve/reject the in class promise listen = async (receiver: Nip46Receiver) => { await receiver.start() @@ -233,6 +238,7 @@ export class Nip46Request { receiver.on(Nip46Event.Receive, onReceive) } + // send the request to the remote signer send = async (sender: Nip46Sender) => { sender.enqueue(this) } diff --git a/vitest.config.ts b/vitest.config.ts index cd38e14..3405bf6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,7 @@ import {defineConfig} from "vitest/config" export default defineConfig({ test: { environment: "happy-dom", + setupFiles: "./vitest.setup.ts", include: ["packages/**/*.test.ts"], coverage: { provider: "v8", diff --git a/vitest.setup.ts b/vitest.setup.ts new file mode 100644 index 0000000..b054ed9 --- /dev/null +++ b/vitest.setup.ts @@ -0,0 +1 @@ +import "fake-indexeddb/auto"