More tests
This commit is contained in:
Generated
+11
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Array<{id: string; value: string}>>([{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<Array<{id: string; value: string}>>([])
|
||||
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<Array<{id: string; value: string}>>([])
|
||||
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<Array<{id: string; value: string}>>([])
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"],
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<string, Function> = {}
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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"])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<string, TestItem> = {
|
||||
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
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<string, any> = new Map<string, any>()
|
||||
|
||||
// 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
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -55,6 +55,8 @@ export const collection = <T, LoadArgs extends any[]>({
|
||||
|
||||
try {
|
||||
await promise
|
||||
} catch (e) {
|
||||
console.warn(`Failed to load ${name} item ${key}`, e)
|
||||
} finally {
|
||||
pending.delete(key)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
build
|
||||
normalize-url
|
||||
__tests__
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,2 +1,3 @@
|
||||
build
|
||||
normalize-url
|
||||
__tests__
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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"})
|
||||
})
|
||||
})
|
||||
@@ -31,7 +31,7 @@ export class Nip07Signer implements ISigner {
|
||||
return promise
|
||||
}
|
||||
|
||||
getPubkey = async () => getNip07()!.getPublicKey()!
|
||||
getPubkey = async () => this.#then<string>(ext => ext.getPublicKey() as string)
|
||||
|
||||
sign = async (template: StampedEvent) => {
|
||||
const event = hash(own(template, await this.getPubkey()))
|
||||
|
||||
@@ -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<Nip46ResponseWithResult, Nip46ResponseWithError>()
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
import "fake-indexeddb/auto"
|
||||
Reference in New Issue
Block a user