Add tests

This commit is contained in:
Ticruz
2025-02-04 13:21:23 +01:00
committed by Jon Staab
parent 917727c86f
commit 8a2b62f693
57 changed files with 9231 additions and 25 deletions
+40
View File
@@ -0,0 +1,40 @@
import {Connection, ConnectionStatus} from "../src/Connection"
import {ConnectionEvent} from "../src/ConnectionEvent"
import {vi, describe, it, expect, beforeEach, afterEach} from "vitest"
describe("Connection", () => {
let connection: Connection
beforeEach(() => {
connection = new Connection("wss://test.relay/")
})
afterEach(() => {
connection.cleanup()
})
it("should initialize with correct state", () => {
expect(connection.status).toBe(ConnectionStatus.Open)
expect(connection.url).toBe("wss://test.relay/")
})
it("should emit events with connection instance", () => {
const spy = vi.fn()
connection.on(ConnectionEvent.Open, spy)
connection.emit(ConnectionEvent.Open)
expect(spy).toHaveBeenCalledWith(connection)
})
it("should throw when sending message on closed connection", async () => {
connection.close()
await expect(connection.send(["EVENT", {}])).rejects.toThrow()
})
it("should cleanup properly", () => {
const spy = vi.fn()
connection.on("test", spy)
connection.cleanup()
connection.emit("test" as any)
expect(spy).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,261 @@
import {ConnectionAuth, AuthStatus, AuthMode} from "../src/ConnectionAuth"
import {Connection} from "../src/Connection"
import {ConnectionEvent} from "../src/ConnectionEvent"
import {ctx, sleep} from "@welshman/lib"
import {vi, describe, it, expect, beforeEach, afterEach} from "vitest"
import {SocketStatus} from "../src/Socket"
describe("ConnectionAuth", () => {
let connection: Connection
let auth: ConnectionAuth
let mockSignEvent: any
beforeEach(() => {
vi.useFakeTimers()
connection = new Connection("wss://test.relay/")
// Mock socket operations
connection.socket.open = vi.fn().mockResolvedValue(undefined)
connection.socket.status = SocketStatus.Open
connection.send = vi.fn().mockResolvedValue(undefined)
auth = connection.auth
mockSignEvent = vi.fn()
ctx.net = {...ctx.net, signEvent: mockSignEvent, authMode: AuthMode.Explicit}
})
afterEach(() => {
vi.useRealTimers()
})
describe("initialization", () => {
it("should initialize with None status", () => {
expect(auth.status).toBe(AuthStatus.None)
expect(auth.challenge).toBeUndefined()
expect(auth.request).toBeUndefined()
expect(auth.message).toBeUndefined()
})
})
describe("message handling", () => {
it("should handle AUTH message and set challenge", () => {
connection.emit(ConnectionEvent.Receive, ["AUTH", "challenge123"])
expect(auth.challenge).toBe("challenge123")
expect(auth.status).toBe(AuthStatus.Requested)
})
it("should ignore AUTH message if challenge matches current challenge", () => {
auth.challenge = "challenge123"
auth.status = AuthStatus.PendingResponse
connection.emit(ConnectionEvent.Receive, ["AUTH", "challenge123"])
expect(auth.status).toBe(AuthStatus.PendingResponse)
})
it("should handle successful OK message", () => {
auth.challenge = "challenge123"
auth.request = "request123"
auth.status = AuthStatus.PendingResponse
connection.emit(ConnectionEvent.Receive, ["OK", "request123", true, "success"])
expect(auth.status).toBe(AuthStatus.Ok)
expect(auth.message).toBe("success")
})
it("should handle failed OK message", () => {
auth.challenge = "challenge123"
auth.request = "request123"
auth.status = AuthStatus.PendingResponse
connection.emit(ConnectionEvent.Receive, ["OK", "request123", false, "forbidden"])
expect(auth.status).toBe(AuthStatus.Forbidden)
expect(auth.message).toBe("forbidden")
})
it("should ignore OK message for different request", () => {
auth.challenge = "challenge123"
auth.request = "request123"
auth.status = AuthStatus.PendingResponse
connection.emit(ConnectionEvent.Receive, ["OK", "different123", true, "success"])
expect(auth.status).toBe(AuthStatus.PendingResponse)
expect(auth.message).toBeUndefined()
})
})
describe("connection close handling", () => {
it("should reset state on connection close", () => {
auth.challenge = "challenge123"
auth.request = "request123"
auth.message = "message"
auth.status = AuthStatus.Ok
connection.emit(ConnectionEvent.Close)
expect(auth.challenge).toBeUndefined()
expect(auth.request).toBeUndefined()
expect(auth.message).toBeUndefined()
expect(auth.status).toBe(AuthStatus.None)
})
})
describe("respond()", () => {
it("should throw if no challenge exists", async () => {
await expect(auth.respond()).rejects.toThrow("Attempted to authenticate with no challenge")
})
it("should throw if status is not Requested", async () => {
auth.challenge = "challenge123"
auth.status = AuthStatus.Ok
await expect(auth.respond()).rejects.toThrow(
"Attempted to authenticate when auth is already ok",
)
})
it("should handle successful signature", async () => {
auth.challenge = "challenge123"
auth.status = AuthStatus.Requested
const signedEvent = {id: "event123" /* other event fields */}
mockSignEvent.mockResolvedValue(signedEvent)
await auth.respond()
expect(auth.request).toBe("event123")
expect(auth.status).toBe(AuthStatus.PendingResponse)
expect(connection.send).toHaveBeenCalledWith(["AUTH", signedEvent])
})
it("should handle denied signature", async () => {
auth.challenge = "challenge123"
auth.status = AuthStatus.Requested
mockSignEvent.mockResolvedValue(undefined)
await auth.respond()
expect(auth.status).toBe(AuthStatus.DeniedSignature)
expect(connection.send).not.toHaveBeenCalled()
})
})
describe("automatic authentication", () => {
it("should auto-respond in implicit mode", () => {
ctx.net.authMode = AuthMode.Implicit
const respondSpy = vi.spyOn(auth, "respond")
connection.emit(ConnectionEvent.Receive, ["AUTH", "challenge123"])
expect(respondSpy).toHaveBeenCalled()
})
it("should not auto-respond in explicit mode", () => {
ctx.net.authMode = AuthMode.Explicit
const respondSpy = vi.spyOn(auth, "respond")
connection.emit(ConnectionEvent.Receive, ["AUTH", "challenge123"])
expect(respondSpy).not.toHaveBeenCalled()
})
})
describe("waitFor methods", () => {
it("should wait for challenge", async () => {
const waitPromise = auth.waitForChallenge()
setTimeout(() => {
connection.emit(ConnectionEvent.Receive, ["AUTH", "challenge123"])
}, 100)
vi.advanceTimersByTime(100)
await waitPromise
expect(auth.challenge).toBe("challenge123")
})
it("should timeout waiting for challenge", async () => {
const waitPromise = auth.waitForChallenge(50)
vi.advanceTimersByTime(100)
await waitPromise
expect(auth.challenge).toBeUndefined()
})
it("should wait for resolution", async () => {
auth.challenge = "challenge123"
auth.request = "request123"
auth.status = AuthStatus.PendingResponse
const waitPromise = auth.waitForResolution()
setTimeout(() => {
connection.emit(ConnectionEvent.Receive, ["OK", "request123", true, "success"])
}, 100)
vi.advanceTimersByTime(100)
await waitPromise
expect(auth.status).toBe(AuthStatus.Ok)
})
it("should timeout waiting for resolution", async () => {
auth.status = AuthStatus.PendingResponse
const waitPromise = auth.waitForResolution(50)
vi.advanceTimersByTime(100)
await waitPromise
expect(auth.status).toBe(AuthStatus.PendingResponse)
})
})
describe("attempt()", () => {
it("should complete full authentication flow", async () => {
const signedEvent = {id: "event123" /* other event fields */}
mockSignEvent.mockResolvedValue(signedEvent)
const attemptPromise = auth.attempt()
// Simulate socket opening and challenge received
setTimeout(() => {
connection.emit(ConnectionEvent.Receive, ["AUTH", "challenge123"])
}, 100)
await vi.advanceTimersByTimeAsync(100)
// Simulate successful authentication
setTimeout(() => {
connection.emit(ConnectionEvent.Receive, ["OK", "event123", true, "success"])
}, 200)
await vi.advanceTimersByTimeAsync(200)
await attemptPromise
expect(auth.status).toBe(AuthStatus.Ok)
})
it("should handle authentication failure", async () => {
mockSignEvent.mockResolvedValue(undefined)
const attemptPromise = auth.attempt()
setTimeout(() => {
connection.emit(ConnectionEvent.Receive, ["AUTH", "challenge123"])
}, 100)
await vi.advanceTimersByTimeAsync(200)
await attemptPromise
expect(auth.status).toBe(AuthStatus.DeniedSignature)
})
it("should timeout if no challenge received", async () => {
const attemptPromise = auth.attempt(100)
// 2 loops (2 * 100ms) in the waitForChallenge before timeout
// 1 loop in waitForResolution as it reach the condition immediately
await vi.advanceTimersByTimeAsync(100)
await attemptPromise
expect(auth.status).toBe(AuthStatus.None)
})
})
})
@@ -0,0 +1,200 @@
import {ConnectionSender} from "../src/ConnectionSender"
import {Connection} from "../src/Connection"
import {Message, SocketStatus} from "../src/Socket"
import {AuthStatus} from "../src/ConnectionAuth"
import {AUTH_JOIN} from "@welshman/util"
import {vi, describe, it, expect, beforeEach, afterEach} from "vitest"
describe("ConnectionSender", () => {
let connection: Connection
let sender: ConnectionSender
beforeEach(() => {
vi.useFakeTimers()
connection = new Connection("wss://test.relay/")
connection.socket.send = vi.fn().mockResolvedValue(undefined)
connection.socket.open = vi.fn().mockResolvedValue(undefined)
connection.socket.status = SocketStatus.Open
connection.send = vi.fn().mockResolvedValue(undefined)
sender = connection.sender
})
afterEach(() => {
vi.useRealTimers()
})
describe("message deferral", () => {
it("should not defer CLOSE messages", async () => {
// First send a REQ message to set up the pending request
const reqId = "subscription-id"
sender.push([
"REQ",
reqId,
{
/* filters */
},
] as Message)
const message: Message = ["CLOSE", reqId]
// there is a setTimeout in the worker, so we need to advance timers
vi.advanceTimersByTime(50)
sender.push(message)
// there is a setTimeout in the worker, so we need to advance timers
vi.advanceTimersByTime(50)
expect(connection.socket.send).toHaveBeenCalledWith(message)
})
it("should defer messages when socket is not open", () => {
connection.socket.status = SocketStatus.Closed
const message: Message = [
"EVENT",
{
/* event data */
},
]
sender.push(message)
expect(connection.socket.send).not.toHaveBeenCalled()
expect(sender.worker.buffer).toContain(message)
})
it("should not defer AUTH messages", () => {
const message: Message = [
"AUTH",
{
/* auth data */
},
]
sender.push(message)
// there is a setTimeout in the worker, so we need to advance timers
vi.advanceTimersByTime(50)
expect(connection.socket.send).toHaveBeenCalledWith(message)
})
it("should not defer AUTH_JOIN event messages", () => {
const message: Message = ["EVENT", {kind: AUTH_JOIN}]
sender.push(message)
// there is a setTimeout in the worker, so we need to advance timers
vi.advanceTimersByTime(50)
expect(connection.socket.send).toHaveBeenCalledWith(message)
})
it("should defer messages when auth is pending", () => {
connection.socket.status = SocketStatus.Open
connection.auth.status = AuthStatus.PendingResponse
const message: Message = [
"EVENT",
{
/* event data */
},
]
sender.push(message)
vi.advanceTimersByTime(50)
expect(connection.socket.send).not.toHaveBeenCalled()
expect(sender.worker.buffer).toContain(message)
})
it("should defer REQ messages when too many pending requests", () => {
connection.socket.status = SocketStatus.Open
connection.auth.status = AuthStatus.Ok
// Set up 8 pending requests
for (let i = 0; i < 8; i++) {
connection.state.pendingRequests.set(`req${i}`, {
filters: [],
sent: Date.now(),
})
}
const message: Message = [
"REQ",
"newReq",
{
/* filter */
},
]
sender.push(message)
vi.advanceTimersByTime(50)
expect(connection.socket.send).not.toHaveBeenCalled()
expect(sender.worker.buffer).toContain(message)
})
})
describe("message handling", () => {
it("should send messages when conditions are met", () => {
connection.socket.status = SocketStatus.Open
connection.auth.status = AuthStatus.Ok
const message: Message = [
"EVENT",
{
/* event data */
},
]
sender.push(message)
vi.advanceTimersByTime(50)
expect(connection.socket.send).toHaveBeenCalledWith(message)
})
it("should handle CLOSE messages for non-existent requests", () => {
const message: Message = ["CLOSE", "non-existent-req"]
sender.push(message)
expect(connection.socket.send).not.toHaveBeenCalled()
})
it("should remove pending REQ when handling CLOSE", () => {
const reqId = "req123"
const reqMessage: Message = [
"REQ",
reqId,
{
/* filter */
},
]
sender.worker.buffer.push(reqMessage)
const closeMessage: Message = ["CLOSE", reqId]
sender.push(closeMessage)
expect(sender.worker.buffer).not.toContain(reqMessage)
})
})
describe("worker behavior", () => {
it("should process deferred messages when conditions become favorable", async () => {
connection.socket.status = SocketStatus.Closed
const message: Message = [
"EVENT",
{
/* event data */
},
]
sender.push(message)
vi.advanceTimersByTime(50)
expect(connection.socket.send).not.toHaveBeenCalled()
// Simulate socket opening and auth completing
connection.socket.status = SocketStatus.Open
connection.auth.status = AuthStatus.Ok
// Trigger worker processing
sender.worker.resume()
vi.advanceTimersByTime(50)
expect(connection.socket.send).toHaveBeenCalledWith(message)
})
it("should maintain message order", async () => {
connection.socket.status = SocketStatus.Open
connection.auth.status = AuthStatus.Ok
const messages: Message[] = [
["EVENT", {id: "1"}],
["EVENT", {id: "2"}],
["EVENT", {id: "3"}],
]
messages.forEach(msg => sender.push(msg))
vi.advanceTimersByTime(50)
const sendCalls = connection.socket.send.mock.calls
expect(sendCalls.map(call => call[0])).toEqual(messages)
})
})
})
@@ -0,0 +1,201 @@
import {ConnectionState} from "../src/ConnectionState"
import {Connection} from "../src/Connection"
import {SocketStatus} from "../src/Socket"
import {ConnectionEvent} from "../src/ConnectionEvent"
import {AUTH_JOIN, SignedEvent} from "@welshman/util"
import {vi, describe, it, expect, beforeEach} from "vitest"
describe("ConnectionState", () => {
let connection: Connection
let state: ConnectionState
beforeEach(() => {
vi.useFakeTimers()
connection = new Connection("wss://test.relay/")
connection.socket.status = SocketStatus.Open
connection.socket.send = vi.fn().mockResolvedValue(undefined)
connection.socket.open = vi.fn().mockResolvedValue(undefined)
connection.send = vi.fn().mockResolvedValue(undefined)
state = connection.state
})
describe("request tracking", () => {
it("should track new REQ messages", async () => {
const reqId = "req123"
const filters = [{kinds: [1]}]
connection.sender.worker.push(["REQ", reqId, ...filters])
vi.advanceTimersByTime(50)
expect(state.pendingRequests.has(reqId)).toBe(true)
expect(state.pendingRequests.get(reqId)).toEqual({
filters,
sent: Date.now(),
eose: undefined,
})
})
it("should remove requests on CLOSE", async () => {
const reqId = "req123"
state.pendingRequests.set(reqId, {
filters: [],
sent: Date.now(),
})
connection.socket.worker.push(["CLOSED", reqId])
vi.advanceTimersByTime(50)
expect(state.pendingRequests.has(reqId)).toBe(false)
})
it("should mark requests as EOSE", async () => {
const reqId = "req123"
state.pendingRequests.set(reqId, {
filters: [],
sent: Date.now(),
})
connection.socket.worker.push(["EOSE", reqId])
vi.advanceTimersByTime(50)
expect(state.pendingRequests.get(reqId)?.eose).toBe(true)
})
})
describe("publish tracking", () => {
it("should track EVENT messages", async () => {
const event = {id: "event123", kind: 1}
connection.sender.worker.push(["EVENT", event])
vi.advanceTimersByTime(50)
expect(state.pendingPublishes.has(event.id)).toBeTruthy()
expect(state.pendingPublishes.get(event.id)).toEqual({
sent: Date.now(),
event,
})
})
it("should remove publishes on successful OK", async () => {
const eventId = "event123"
state.pendingPublishes.set(eventId, {
sent: Date.now(),
event: {id: eventId, kind: 1} as SignedEvent,
})
connection.socket.worker.push(["OK", eventId, true])
vi.advanceTimersByTime(50)
expect(state.pendingPublishes.has(eventId)).toBe(false)
})
it("should re-enqueue events on auth challenge", async () => {
const event = {id: "event123", kind: 1} as SignedEvent
state.pendingPublishes.set(event.id, {
sent: Date.now(),
event,
})
connection.socket.worker.push(["OK", event.id, false, "auth-required:challenge123"])
vi.advanceTimersByTime(50)
// Event should still be in pending publishes
expect(state.pendingPublishes.has(event.id)).toBe(true)
// And should have been re-sent
expect(connection.send).toHaveBeenCalledWith(["EVENT", event])
})
it("should not re-enqueue AUTH_JOIN events on auth challenge", async () => {
const event = {id: "event123", kind: AUTH_JOIN} as SignedEvent
state.pendingPublishes.set(event.id, {
sent: Date.now(),
event,
})
connection.socket.worker.push(["OK", event.id, false, "auth-required:challenge123"])
vi.advanceTimersByTime(50)
// Event should be removed from pending publishes
expect(state.pendingPublishes.has(event.id)).toBe(false)
// And should not have been re-sent
expect(connection.send).not.toHaveBeenCalled()
})
})
describe("notice handling", () => {
it("should emit notices", async () => {
const noticeSpy = vi.fn()
connection.on(ConnectionEvent.Notice, noticeSpy)
connection.socket.worker.push(["NOTICE", "test notice"])
vi.advanceTimersByTime(50)
expect(noticeSpy).toHaveBeenCalledWith(connection, "test notice")
})
it("should emit auth-required notice from CLOSED", async () => {
const noticeSpy = vi.fn()
connection.on(ConnectionEvent.Notice, noticeSpy)
connection.socket.worker.push(["CLOSED", "req123", "auth-required:challenge123"])
vi.advanceTimersByTime(50)
expect(noticeSpy).toHaveBeenCalledWith(connection, "auth-required:challenge123")
})
})
describe("reconnection behavior", () => {
beforeEach(() => {
vi.useFakeTimers()
})
it("should re-enqueue pending requests on reconnection", async () => {
const reqId = "req123"
const filters = [{kinds: [1]}]
state.pendingRequests.set(reqId, {
filters,
sent: Date.now(),
})
// Simulate connection close and wait for reconnection delay
connection.emit(ConnectionEvent.Close, connection)
await vi.advanceTimersByTimeAsync(10_000)
expect(connection.send).toHaveBeenCalledWith(["REQ", reqId, ...filters])
})
it("should re-enqueue pending publishes on reconnection", async () => {
const event = {id: "event123", kind: 1} as SignedEvent
state.pendingPublishes.set(event.id, {
sent: Date.now(),
event,
})
// Simulate connection close and wait for reconnection delay
connection.emit(ConnectionEvent.Close, connection)
await vi.advanceTimersByTimeAsync(10_000)
expect(connection.send).toHaveBeenCalledWith(["EVENT", event])
})
it("should trigger reconnection when there are pending items", async () => {
const reqId = "req123"
state.pendingRequests.set(reqId, {
filters: [],
sent: Date.now(),
})
connection.emit(ConnectionEvent.Close, connection)
await vi.advanceTimersByTimeAsync(10_000)
expect(connection.socket.open).toHaveBeenCalled()
})
it("should not trigger reconnection when there are no pending items", async () => {
connection.emit(ConnectionEvent.Close, connection)
await vi.advanceTimersByTimeAsync(10_000)
expect(connection.socket.open).not.toHaveBeenCalled()
})
})
})
@@ -0,0 +1,220 @@
import {ctx} from "@welshman/lib"
import {AuthMode} from "@welshman/net"
import {SignedEvent} from "@welshman/util"
import {beforeEach, describe, expect, it, vi} from "vitest"
import {Connection} from "../src/Connection"
import {ConnectionEvent} from "../src/ConnectionEvent"
import {ConnectionStats} from "../src/ConnectionStats"
describe("ConnectionStats", () => {
let connection: Connection
let stats: ConnectionStats
beforeEach(() => {
vi.useFakeTimers()
connection = new Connection("wss://test.relay/")
stats = connection.stats
ctx.net = {...ctx.net, authMode: AuthMode.Explicit}
})
describe("connection events tracking", () => {
it("should track socket open events", () => {
const now = Date.now()
connection.emit(ConnectionEvent.Open, connection)
expect(stats.openCount).toBe(1)
expect(stats.lastOpen).toBeGreaterThanOrEqual(now)
})
it("should track socket close events", () => {
const now = Date.now()
connection.emit(ConnectionEvent.Close, connection)
expect(stats.closeCount).toBe(1)
expect(stats.lastClose).toBeGreaterThanOrEqual(now)
})
it("should track socket error events", () => {
const now = Date.now()
connection.emit(ConnectionEvent.Error, connection)
expect(stats.errorCount).toBe(1)
expect(stats.lastError).toBeGreaterThanOrEqual(now)
})
it("should accumulate multiple events", () => {
connection.emit(ConnectionEvent.Open, connection)
connection.emit(ConnectionEvent.Close, connection)
connection.emit(ConnectionEvent.Open, connection)
connection.emit(ConnectionEvent.Error, connection)
expect(stats.openCount).toBe(2)
expect(stats.closeCount).toBe(1)
expect(stats.errorCount).toBe(1)
})
})
describe("message tracking", () => {
describe("outgoing messages", () => {
it("should track REQ messages", () => {
const now = Date.now()
connection.emit(ConnectionEvent.Send, ["REQ", "id1"])
expect(stats.requestCount).toBe(1)
expect(stats.lastRequest).toBeGreaterThanOrEqual(now)
})
it("should track EVENT messages", () => {
const now = Date.now()
connection.emit(ConnectionEvent.Send, ["EVENT", {id: "123"}])
expect(stats.publishCount).toBe(1)
expect(stats.lastPublish).toBeGreaterThanOrEqual(now)
})
})
describe("incoming messages", () => {
it("should track received EVENT messages", () => {
const now = Date.now()
connection.emit(ConnectionEvent.Receive, ["EVENT", {id: "123"}])
expect(stats.eventCount).toBe(1)
expect(stats.lastEvent).toBeGreaterThanOrEqual(now)
})
it("should track AUTH messages", () => {
const now = Date.now()
connection.emit(ConnectionEvent.Receive, ["AUTH", "challenge"])
expect(stats.lastAuth).toBeGreaterThanOrEqual(now)
})
it("should track NOTICE messages", () => {
connection.emit(ConnectionEvent.Receive, ["NOTICE", "test"])
expect(stats.noticeCount).toBe(1)
})
})
})
describe("publish tracking", () => {
beforeEach(() => {
// Setup a pending publish
connection.state.pendingPublishes.set("123", {
sent: Date.now() - 1000, // 1 second ago
event: {id: "123"} as SignedEvent,
})
})
it("should track successful publishes", () => {
connection.emit(ConnectionEvent.Receive, ["OK", "123", true])
expect(stats.publishSuccessCount).toBe(1)
expect(stats.publishFailureCount).toBe(0)
expect(stats.publishTimer).toBeGreaterThan(0)
})
it("should track failed publishes", () => {
connection.emit(ConnectionEvent.Receive, ["OK", "123", false])
expect(stats.publishSuccessCount).toBe(0)
expect(stats.publishFailureCount).toBe(1)
expect(stats.publishTimer).toBeGreaterThan(0)
})
it("should accumulate publish timing", () => {
const firstTimer = stats.publishTimer
// First publish took 1000ms
connection.emit(ConnectionEvent.Receive, ["OK", "123", true])
// Second publish took 2000ms
connection.state.pendingPublishes.set("456", {
sent: Date.now() - 2000,
event: {id: "456"} as SignedEvent,
})
connection.emit(ConnectionEvent.Receive, ["OK", "456", true])
expect(stats.publishTimer).toBe(firstTimer + 1000 + 2000)
expect(stats.publishSuccessCount).toBe(2)
})
it("should not increment publish timer for unknown publishes", () => {
connection.emit(ConnectionEvent.Receive, ["OK", "unknown", true])
expect(stats.publishSuccessCount).toBe(1)
expect(stats.publishFailureCount).toBe(0)
expect(stats.publishTimer).toBe(0)
})
})
describe("EOSE tracking", () => {
beforeEach(() => {
// Setup a pending request
connection.state.pendingRequests.set("req1", {
sent: Date.now() - 1000,
filters: [],
})
})
it("should track first EOSE for a request", () => {
connection.emit(ConnectionEvent.Receive, ["EOSE", "req1"])
expect(stats.eoseCount).toBe(1)
expect(stats.eoseTimer).toBeGreaterThan(0)
})
it("should ignore subsequent EOSE for same request", () => {
// Mark request as already EOSE'd
connection.state.pendingRequests.set("req1", {
sent: Date.now() - 1000,
filters: [],
eose: true,
})
connection.emit(ConnectionEvent.Receive, ["EOSE", "req1"])
expect(stats.eoseCount).toBe(0)
expect(stats.eoseTimer).toBe(0)
})
it("should accumulate EOSE timing", () => {
// First EOSE took 1000ms
connection.emit(ConnectionEvent.Receive, ["EOSE", "req1"])
const firstTimer = stats.eoseTimer
// Setup second request that takes 2000ms
connection.state.pendingRequests.set("req2", {
sent: Date.now() - 2000,
filters: [],
})
connection.emit(ConnectionEvent.Receive, ["EOSE", "req2"])
expect(stats.eoseTimer).toBe(firstTimer + 2000)
expect(stats.eoseCount).toBe(2)
})
})
describe("speed calculations", () => {
it("should calculate request speed", () => {
stats.eoseCount = 2
stats.eoseTimer = 3000 // 3 seconds total for 2 requests
expect(stats.getRequestSpeed()).toBe(1500) // 1.5 seconds average
})
it("should return 0 request speed when no EOSE received", () => {
expect(stats.getRequestSpeed()).toBe(0)
})
it("should calculate publish speed", () => {
stats.publishSuccessCount = 2
stats.publishTimer = 4000 // 4 seconds total for 2 publishes
expect(stats.getPublishSpeed()).toBe(2000) // 2 seconds average
})
it("should return 0 publish speed when no successful publishes", () => {
expect(stats.getPublishSpeed()).toBe(0)
})
})
})
+192
View File
@@ -0,0 +1,192 @@
import type {Filter, TrustedEvent} from "@welshman/util"
import {hasValidSignature, isSignedEvent, LOCAL_RELAY_URL, matchFilters} from "@welshman/util"
import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"
import {AuthMode} from "../src/ConnectionAuth"
import {
defaultOptimizeSubscriptions,
eventValidationScores,
getDefaultNetContext,
isEventValid,
} from "../src/Context"
// Mock utilities that are imported
vi.mock(import("@welshman/util"), async importOriginal => ({
...(await importOriginal()),
isSignedEvent: vi.fn(),
hasValidSignature: vi.fn(),
matchFilters: vi.fn(),
LOCAL_RELAY_URL: "local",
}))
describe("Context", () => {
describe("getDefaultNetContext", () => {
it("should return default context with expected properties", () => {
const context = getDefaultNetContext()
expect(context).toEqual(
expect.objectContaining({
authMode: AuthMode.Implicit,
onEvent: expect.any(Function),
signEvent: expect.any(Function),
isDeleted: expect.any(Function),
isValid: expect.any(Function),
getExecutor: expect.any(Function),
matchFilters: expect.any(Function),
optimizeSubscriptions: expect.any(Function),
}),
)
})
it("should merge overrides with defaults", () => {
const customOnEvent = vi.fn()
const context = getDefaultNetContext({onEvent: customOnEvent})
expect(context.onEvent).toBe(customOnEvent)
expect(context.authMode).toBe(AuthMode.Implicit) // default value preserved
})
})
describe("defaultOptimizeSubscriptions", () => {
it("should group subscriptions by relay", () => {
const subs = [
{
request: {
relays: ["relay1", "relay2"],
filters: [{kinds: [1]}],
},
},
{
request: {
relays: ["relay1"],
filters: [{kinds: [2]}],
},
},
] as any
const result = defaultOptimizeSubscriptions(subs)
// should unionize filters for requests with the same relay
expect(result).toEqual([
{
relays: ["relay1"],
filters: expect.arrayContaining([{kinds: [1, 2]}]),
},
{
relays: ["relay2"],
filters: [{kinds: [1]}],
},
])
})
it("should deduplicate relays", () => {
const subs = [
{
request: {
relays: ["relay1", "relay1"],
filters: [{kinds: [1]}],
},
},
] as any
const result = defaultOptimizeSubscriptions(subs)
expect(result).toHaveLength(1)
expect(result[0].relays).toEqual(["relay1"])
})
})
describe("isEventValid", () => {
const mockEvent = {id: "123"} as TrustedEvent
beforeEach(() => {
eventValidationScores.clear()
// vi.mocked(isSignedEvent)
// vi.mocked(hasValidSignature)
})
afterEach(() => {
vi.clearAllMocks()
})
it("should always return true for LOCAL_RELAY_URL", () => {
expect(isEventValid(LOCAL_RELAY_URL, mockEvent)).toBe(true)
})
it("should validate signature for non-local events", () => {
vi.mocked(isSignedEvent).mockReturnValue(true)
vi.mocked(hasValidSignature).mockReturnValue(true)
const result = isEventValid("relay1", mockEvent)
expect(isSignedEvent).toHaveBeenCalledWith(mockEvent)
expect(hasValidSignature).toHaveBeenCalledWith(mockEvent)
expect(result).toBe(true)
})
it("should update validation score on successful validation", () => {
vi.mocked(isSignedEvent).mockReturnValue(true)
vi.mocked(hasValidSignature).mockReturnValue(true)
isEventValid("relay1", mockEvent)
expect(eventValidationScores.get("relay1")).toBe(1)
})
it("should reset validation score on failed validation", () => {
// Set initial score
eventValidationScores.set("relay1", 10)
vi.mocked(isSignedEvent).mockReturnValue(false)
vi.mocked(hasValidSignature).mockReturnValue(true)
isEventValid("relay1", mockEvent)
expect(eventValidationScores.get("relay1")).toBe(0)
})
it("should skip validation when score is high enough", () => {
eventValidationScores.set("relay1", 1000)
const result = isEventValid("relay1", mockEvent)
expect(isSignedEvent).not.toHaveBeenCalled()
expect(hasValidSignature).not.toHaveBeenCalled()
expect(result).toBe(true)
})
it("should maintain minimum validation rate", () => {
eventValidationScores.set("relay1", 800)
vi.spyOn(Math, "random").mockReturnValue(1000) // ensure randomInt returns
vi.mocked(isSignedEvent).mockReturnValue(true)
vi.mocked(hasValidSignature).mockReturnValue(true)
isEventValid("relay1", mockEvent)
expect(eventValidationScores.get("relay1")).toBe(801)
})
})
describe("default functions behavior", () => {
const context = getDefaultNetContext()
it("default onEvent should not throw", () => {
expect(() => context.onEvent("relay1", {} as TrustedEvent)).not.toThrow()
})
it("default signEvent should return undefined", async () => {
const result = await context.signEvent({} as any)
expect(result).toBeUndefined()
})
it("default isDeleted should return false", () => {
expect(context.isDeleted("relay1", {} as TrustedEvent)).toBe(false)
})
it("default matchFilters should use util matchFilters", () => {
const filters: Filter[] = []
const event = {} as TrustedEvent
context.matchFilters("relay1", filters, event)
expect(vi.mocked(matchFilters)).toHaveBeenCalledWith(filters, event)
})
})
})
+256
View File
@@ -0,0 +1,256 @@
import {ctx} from "@welshman/lib"
import type {Filter, SignedEvent, TrustedEvent} from "@welshman/util"
import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"
import {Executor} from "../src/Executor"
import {Negentropy} from "../src/Negentropy"
// Mock Negentropy
vi.mock("../src/Negentropy.js", () => ({
Negentropy: vi.fn().mockImplementation(() => ({
reconcile: vi.fn().mockResolvedValue(["newMsg", ["id1"], ["id2"]]),
initiate: vi.fn().mockResolvedValue("initialMsg"),
})),
NegentropyStorageVector: vi.fn().mockImplementation(() => ({
insert: vi.fn(),
seal: vi.fn(),
})),
}))
describe("Executor", () => {
let mockTarget: any
// let mockNegentropy: any
let executor: Executor
beforeEach(() => {
vi.useFakeTimers()
// Setup mock target
mockTarget = {
connections: [],
send: vi.fn().mockResolvedValue(undefined),
on: vi.fn(),
off: vi.fn(),
cleanup: vi.fn(),
}
// Setup mock context
ctx.net = {
...ctx.net,
onEvent: vi.fn(),
}
executor = new Executor(mockTarget)
})
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
describe("subscribe", () => {
const filters: Filter[] = [{kinds: [1]}]
it("should setup subscription correctly", () => {
const onEvent = vi.fn()
const onEose = vi.fn()
executor.subscribe(filters, {onEvent, onEose})
expect(mockTarget.on).toHaveBeenCalledWith("EVENT", expect.any(Function))
expect(mockTarget.on).toHaveBeenCalledWith("EOSE", expect.any(Function))
expect(mockTarget.send).toHaveBeenCalledWith("REQ", expect.any(String), ...filters)
})
it("should handle events for matching subscription ID", () => {
const onEvent = vi.fn()
executor.subscribe(filters, {onEvent})
// Get the event listener that was registered
const eventListener = mockTarget.on.mock.calls.find(call => call[0] === "EVENT")[1]
const event = {id: "123"} as TrustedEvent
// Simulate event with matching subId (extract it from the REQ call)
const subId = mockTarget.send.mock.calls[0][1]
eventListener("relay1", subId, event)
expect(ctx.net.onEvent).toHaveBeenCalledWith("relay1", event)
expect(onEvent).toHaveBeenCalledWith("relay1", event)
})
it("should handle EOSE for matching subscription ID", () => {
const onEose = vi.fn()
executor.subscribe(filters, {onEose})
const eoseListener = mockTarget.on.mock.calls.find(call => call[0] === "EOSE")[1]
const subId = mockTarget.send.mock.calls[0][1]
eoseListener("relay1", subId)
expect(onEose).toHaveBeenCalledWith("relay1")
})
it("should cleanup on unsubscribe", () => {
const sub = executor.subscribe(filters)
const subId = mockTarget.send.mock.calls[0][1]
sub.unsubscribe()
expect(mockTarget.send).toHaveBeenLastCalledWith("CLOSE", subId)
expect(mockTarget.off).toHaveBeenCalledTimes(2) // EVENT and EOSE listeners
})
it("should not send CLOSE multiple times", () => {
const sub = executor.subscribe(filters)
sub.unsubscribe()
const sendCallCount = mockTarget.send.mock.calls.length
sub.unsubscribe()
expect(mockTarget.send.mock.calls.length).toBe(sendCallCount)
})
})
describe("publish", () => {
const event: SignedEvent = {
id: "event123",
kind: 1,
content: "",
tags: [],
created_at: 0,
pubkey: "",
sig: "",
}
it("should setup publish correctly", () => {
const onOk = vi.fn()
const onError = vi.fn()
executor.publish(event, {onOk, onError})
expect(mockTarget.on).toHaveBeenCalledWith("OK", expect.any(Function))
expect(mockTarget.on).toHaveBeenCalledWith("ERROR", expect.any(Function))
expect(mockTarget.send).toHaveBeenCalledWith("EVENT", event)
})
it("should handle successful publish", () => {
const onOk = vi.fn()
executor.publish(event, {onOk})
const okListener = mockTarget.on.mock.calls.find(call => call[0] === "OK")[1]
okListener("relay1", event.id, true, "success")
expect(ctx.net.onEvent).toHaveBeenCalledWith("relay1", event)
expect(onOk).toHaveBeenCalledWith("relay1", event.id, true, "success")
})
it("should handle failed publish", () => {
const onOk = vi.fn()
executor.publish(event, {onOk})
const okListener = mockTarget.on.mock.calls.find(call => call[0] === "OK")[1]
okListener("relay1", event.id, false, "failed")
expect(ctx.net.onEvent).not.toHaveBeenCalled()
expect(onOk).toHaveBeenCalledWith("relay1", event.id, false, "failed")
})
it("should handle publish errors", () => {
const onError = vi.fn()
executor.publish(event, {onError})
const errorListener = mockTarget.on.mock.calls.find(call => call[0] === "ERROR")[1]
errorListener("relay1", event.id, "error message")
expect(onError).toHaveBeenCalledWith("relay1", event.id, "error message")
})
it("should cleanup on unsubscribe", () => {
const pub = executor.publish(event)
pub.unsubscribe()
expect(mockTarget.off).toHaveBeenCalledTimes(2) // OK and ERROR listeners
})
})
describe("diff", () => {
const filter: Filter = {kinds: [1]}
const events: TrustedEvent[] = [
{id: "event1", created_at: 1000} as TrustedEvent,
{id: "event2", created_at: 2000} as TrustedEvent,
]
it("should setup diff correctly", async () => {
const onMessage = vi.fn()
const onError = vi.fn()
const onClose = vi.fn()
executor.diff(filter, events, {onMessage, onError, onClose})
expect(mockTarget.on).toHaveBeenCalledWith("NEG-MSG", expect.any(Function))
expect(mockTarget.on).toHaveBeenCalledWith("NEG-ERR", expect.any(Function))
// Wait for initiate promise
await vi.runAllTimersAsync()
expect(mockTarget.send).toHaveBeenCalledWith(
"NEG-OPEN",
expect.any(String),
filter,
"initialMsg",
)
})
it("should handle diff messages", async () => {
const onMessage = vi.fn()
executor.diff(filter, events, {onMessage})
const msgListener = mockTarget.on.mock.calls.find(call => call[0] === "NEG-MSG")[1]
// wait for initiate promise
await vi.advanceTimersToNextTimerAsync()
await msgListener("relay1", mockTarget.send.mock.calls[0][1], "msg")
expect(onMessage).toHaveBeenCalledWith("relay1", {
have: ["id1"],
need: ["id2"],
})
})
it("should handle diff errors", async () => {
const onError = vi.fn()
executor.diff(filter, events, {onError})
const errListener = mockTarget.on.mock.calls.find(call => call[0] === "NEG-ERR")[1]
// wait for initiate promise
await vi.advanceTimersToNextTimerAsync()
errListener("relay1", mockTarget.send.mock.calls[0][1], "error")
expect(onError).toHaveBeenCalledWith("relay1", "error")
})
it("should close diff when reconciliation completes", async () => {
const onClose = vi.fn()
executor.diff(filter, events, {onClose})
const msgListener = mockTarget.on.mock.calls.find(call => call[0] === "NEG-MSG")[1]
// wait for initiate promise
await vi.advanceTimersToNextTimerAsync()
// Get the mock instance's reconcile function from the last Negentropy constructor call
const mockReconcile = vi.mocked(Negentropy).mock.results[0].value.reconcile
mockReconcile.mockResolvedValueOnce([null, [], []])
const reqId = mockTarget.send.mock.calls[0][1]
await msgListener("relay1", reqId, "msg")
expect(mockTarget.send).toHaveBeenCalledWith("NEG-CLOSE", reqId)
expect(onClose).toHaveBeenCalled()
})
it("should cleanup on unsubscribe", () => {
const diff = executor.diff(filter, events)
diff.unsubscribe()
expect(mockTarget.send).toHaveBeenCalledWith("NEG-CLOSE", expect.any(String))
expect(mockTarget.off).toHaveBeenCalledTimes(2) // NEG-MSG and NEG-ERR listeners
})
})
})
+125
View File
@@ -0,0 +1,125 @@
import {Pool} from "../src/Pool"
import {Connection} from "../src/Connection"
import {vi, describe, it, expect, beforeEach} from "vitest"
// Mock Connection class
vi.mock("../src/Connection", () => ({
Connection: vi.fn().mockImplementation(url => ({
url,
cleanup: vi.fn(),
})),
}))
describe("Pool", () => {
let pool: Pool
beforeEach(() => {
vi.clearAllMocks()
pool = new Pool()
})
describe("initialization", () => {
it("should initialize with empty data map", () => {
expect(pool.data.size).toBe(0)
})
})
describe("has", () => {
it("should return false for non-existent connection", () => {
expect(pool.has("wss://test.relay")).toBe(false)
})
it("should return true for existing connection", () => {
pool.get("wss://test.relay")
expect(pool.has("wss://test.relay")).toBe(true)
})
})
describe("get", () => {
it("should create new connection if none exists", () => {
const connection = pool.get("wss://test.relay")
expect(Connection).toHaveBeenCalledWith("wss://test.relay")
expect(pool.data.get("wss://test.relay")).toBe(connection)
})
it("should emit init event for new connections", () => {
const initSpy = vi.fn()
pool.on("init", initSpy)
const connection = pool.get("wss://test.relay")
expect(initSpy).toHaveBeenCalledWith(connection)
})
it("should return existing connection if it exists", () => {
const firstConnection = pool.get("wss://test.relay")
const secondConnection = pool.get("wss://test.relay")
expect(Connection).toHaveBeenCalledTimes(1)
expect(firstConnection).toBe(secondConnection)
})
it("should not emit init event for existing connections", () => {
const initSpy = vi.fn()
pool.get("wss://test.relay")
pool.on("init", initSpy)
pool.get("wss://test.relay")
expect(initSpy).not.toHaveBeenCalled()
})
})
describe("remove", () => {
it("should remove existing connection", () => {
const connection = pool.get("wss://test.relay")
pool.remove("wss://test.relay")
expect(pool.has("wss://test.relay")).toBe(false)
expect(connection.cleanup).toHaveBeenCalled()
})
it("should do nothing for non-existent connection", () => {
pool.remove("wss://test.relay")
expect(pool.has("wss://test.relay")).toBe(false)
})
it("should cleanup connection before removal", () => {
const connection = pool.get("wss://test.relay")
pool.remove("wss://test.relay")
const spy = vi.spyOn(pool.data, "delete")
expect(connection.cleanup).toHaveBeenCalled()
})
})
describe("clear", () => {
it("should remove all connections", () => {
const urls = ["wss://test1.relay", "wss://test2.relay", "wss://test3.relay"]
// Create multiple connections
urls.forEach(url => pool.get(url))
expect(pool.data.size).toBe(3)
pool.clear()
expect(pool.data.size).toBe(0)
})
it("should cleanup all connections", () => {
const urls = ["wss://test1.relay", "wss://test2.relay", "wss://test3.relay"]
const connections = urls.map(url => pool.get(url))
pool.clear()
connections.forEach(connection => {
expect(connection.cleanup).toHaveBeenCalled()
})
})
it("should do nothing on empty pool", () => {
expect(() => pool.clear()).not.toThrow()
})
})
})
+184
View File
@@ -0,0 +1,184 @@
import {ctx} from "@welshman/lib"
import type {SignedEvent} from "@welshman/util"
import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"
import {makePublish, publish, PublishStatus} from "../src/Publish"
// Mock dependencies
vi.mock("@welshman/lib", async importOriginal => {
return {
...(await importOriginal()),
randomId: () => "test-id",
now: () => 1000,
defer: () => ({
resolve: vi.fn(),
reject: vi.fn(),
promise: Promise.resolve(),
}),
}
})
vi.mock("@welshman/util", () => ({
asSignedEvent: vi.fn(event => event),
}))
describe("Publish", () => {
let mockExecutor: any
let mockExecutorSub: any
beforeEach(() => {
vi.useFakeTimers()
mockExecutorSub = {
unsubscribe: vi.fn(),
}
mockExecutor = {
publish: vi.fn().mockReturnValue(mockExecutorSub),
target: {
cleanup: vi.fn(),
},
}
ctx.net = {
...ctx.net,
getExecutor: vi.fn().mockReturnValue(mockExecutor),
}
})
afterEach(() => {
vi.useRealTimers()
})
describe("makePublish", () => {
it("should create publish object with correct properties", () => {
const request = {
event: {id: "event123"} as SignedEvent,
relays: ["relay1"],
}
const pub = makePublish(request)
expect(pub).toEqual({
id: "test-id",
created_at: 1000,
request,
emitter: expect.any(Object),
result: expect.any(Object),
status: expect.any(Map),
})
})
})
describe("publish", () => {
const event = {id: "event123"} as SignedEvent
const relays = ["relay1", "relay2"]
it("should initialize publish with pending status", async () => {
const pub = publish({event, relays})
await vi.advanceTimersToNextTimerAsync()
relays.forEach(relay => {
expect(pub.status.get(relay)).toBe(PublishStatus.Pending)
})
})
it("should delegate to executor with correct parameters", () => {
publish({event, relays})
expect(ctx.net.getExecutor).toHaveBeenCalledWith(relays)
expect(mockExecutor.publish).toHaveBeenCalledWith(
event,
expect.objectContaining({
verb: "EVENT",
onOk: expect.any(Function),
onError: expect.any(Function),
}),
)
})
it("should handle successful publish", async () => {
const pub = publish({event, relays})
await vi.runAllTimersAsync()
const onOk = mockExecutor.publish.mock.calls[0][1].onOk
onOk("relay1", event.id, true, "success")
expect(pub.status.get("relay1")).toBe(PublishStatus.Success)
})
it("should handle failed publish", async () => {
const pub = publish({event, relays})
await vi.runAllTimersAsync()
const onOk = mockExecutor.publish.mock.calls[0][1].onOk
onOk("relay1", event.id, false, "failed")
expect(pub.status.get("relay1")).toBe(PublishStatus.Failure)
})
it("should handle publish errors", async () => {
const pub = publish({event, relays})
await vi.runAllTimersAsync()
const onError = mockExecutor.publish.mock.calls[0][1].onError
onError("relay1")
expect(pub.status.get("relay1")).toBe(PublishStatus.Failure)
})
it("should handle timeout", async () => {
const pub = publish({event, relays, timeout: 5000})
await vi.runAllTimersAsync()
relays.forEach(relay => {
expect(pub.status.get(relay)).toBe(PublishStatus.Timeout)
})
})
it("should handle abort signal", async () => {
const controller = new AbortController()
const pub = publish({event, relays, signal: controller.signal})
await vi.advanceTimersToNextTimerAsync()
controller.abort()
relays.forEach(relay => {
expect(pub.status.get(relay)).toBe(PublishStatus.Aborted)
})
})
it("should cleanup when all relays complete", async () => {
const pub = publish({event, relays})
await vi.runAllTimersAsync()
const onOk = mockExecutor.publish.mock.calls[0][1].onOk
// Complete all relays
relays.forEach(relay => {
onOk(relay, event.id, true, "success")
})
expect(mockExecutorSub.unsubscribe).toHaveBeenCalled()
expect(mockExecutor.target.cleanup).toHaveBeenCalled()
expect(pub.result.resolve).toHaveBeenCalledWith(pub.status)
})
it("should use custom verb if provided", () => {
const pub = publish({event, relays, verb: "AUTH"})
expect(mockExecutor.publish.mock.calls[0][1].verb).toBe("AUTH")
})
it("should use default timeout if not specified", async () => {
const pub = publish({event, relays})
// Advance to default timeout
await vi.advanceTimersByTimeAsync(10_000)
relays.forEach(relay => {
expect(pub.status.get(relay)).toBe(PublishStatus.Timeout)
})
})
})
})
+244
View File
@@ -0,0 +1,244 @@
import {sleep} from "@welshman/lib"
import WebSocket from "isomorphic-ws"
import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"
import {ConnectionEvent} from "../src/ConnectionEvent"
import {Message, Socket, SocketStatus} from "../src/Socket"
// Mock dependencies
vi.mock("isomorphic-ws")
// vi.mock("@welshman/lib", async importOriginal => {
// return {
// ...(await importOriginal()),
// // sleep: vi.fn().mockResolvedValue(undefined),
// }
// })
describe("Socket", () => {
let socket: Socket
let mockConnection: any
let mockWs: any
beforeEach(() => {
vi.useFakeTimers()
// Reset mocks
vi.clearAllMocks()
// Setup mock connection
mockConnection = {
url: "wss://test.relay",
emit: vi.fn(),
}
// Setup mock WebSocket
mockWs = {
close: vi.fn(),
send: vi.fn(),
onopen: null,
onclose: null,
onerror: null,
onmessage: null,
}
vi.mocked(WebSocket).mockImplementation(() => mockWs)
socket = new Socket(mockConnection)
})
afterEach(() => {
vi.useRealTimers()
})
describe("initialization", () => {
it("should initialize with New status", () => {
expect(socket.status).toBe(SocketStatus.New)
})
it("should setup worker handler", () => {
const message = ["EVENT", {id: "123"}] as Message
socket.worker.push(message)
// workers batch messages every 50ms
vi.advanceTimersByTime(50)
expect(mockConnection.emit).toHaveBeenCalledWith(ConnectionEvent.Receive, message)
})
})
describe("open", () => {
it("should initialize WebSocket connection", async () => {
socket.open()
// wait for 2 timeout on wait
await vi.advanceTimersByTimeAsync(10_000 * 2)
expect(WebSocket).toHaveBeenCalledWith("wss://test.relay")
expect(socket.status).toBe(SocketStatus.Opening)
})
// @check this test
it("should handle successful connection", async () => {
socket.open()
await vi.advanceTimersByTimeAsync(10_000)
mockWs.onopen()
expect(socket.status).toBe(SocketStatus.Open)
expect(mockConnection.emit).toHaveBeenCalledWith(ConnectionEvent.Open)
})
it("should handle connection error (parallel)", async () => {
await Promise.all([
socket.open(),
vi.advanceTimersByTimeAsync(1000),
new Promise((resolve, reject) => setTimeout(() => resolve(mockWs.onerror()), 1000)),
])
expect(socket.status).toBe(SocketStatus.Error)
expect(socket.lastError).toBe(Date.now())
expect(mockConnection.emit).toHaveBeenCalledWith(ConnectionEvent.Error)
})
it("should retry after error timeout", async () => {
// Simulate initial error
socket.status = SocketStatus.Error
socket.lastError = Date.now() - 16000 // More than 15 seconds ago
// @check awaiting socket open remains hanging as no socket callback is called
// to change the socket status
// await socket.open()
socket.open()
await vi.advanceTimersToNextTimerAsync()
expect(WebSocket).toHaveBeenCalled()
expect(mockConnection.emit).toHaveBeenCalledWith(ConnectionEvent.Reset)
})
it("should not retry before error timeout", async () => {
// Simulate recent error
socket.status = SocketStatus.Error
socket.lastError = Date.now() - 5000 // Less than 15 seconds ago
await socket.open()
expect(WebSocket).not.toHaveBeenCalled()
})
})
describe("close", () => {
it("should close WebSocket connection", async () => {
socket.ws = mockWs
socket.close()
expect(mockWs.close).toHaveBeenCalled()
expect(socket.ws).toBeUndefined()
})
it("should pause worker", async () => {
const pauseSpy = vi.spyOn(socket.worker, "pause")
socket.close()
expect(pauseSpy).toHaveBeenCalled()
})
it("should handle normal close", async () => {
socket.open()
await vi.advanceTimersToNextTimerAsync()
mockWs.onclose()
expect(socket.status).toBe(SocketStatus.Closed)
expect(mockConnection.emit).toHaveBeenCalledWith(ConnectionEvent.Close)
})
})
describe("send", () => {
it("should send message through WebSocket", async () => {
const message = ["EVENT", {id: "123"}] as Message
// Setup open connection
socket.open()
await vi.advanceTimersToNextTimerAsync()
mockWs.onopen()
await socket.send(message)
expect(mockWs.send).toHaveBeenCalledWith(JSON.stringify(message))
expect(mockConnection.emit).toHaveBeenCalledWith(ConnectionEvent.Send, message)
})
it("should throw if no WebSocket available", () => {
const message = ["EVENT", {id: "123"}] as Message
socket.ws = undefined
// unreachable code
// expect(socket.send(message)).rejects.toThrow()
})
})
describe("message handling", () => {
it("should handle valid messages", async () => {
const validMessage = ["EVENT", {id: "123"}]
socket.open()
await vi.advanceTimersToNextTimerAsync()
mockWs.onmessage({data: JSON.stringify(validMessage)})
await vi.advanceTimersToNextTimerAsync()
expect(mockConnection.emit).toHaveBeenCalledWith(ConnectionEvent.Receive, validMessage)
})
it("should handle non-array messages", async () => {
const invalidMessage = {type: "EVENT"}
socket.open()
await vi.advanceTimersToNextTimerAsync()
mockWs.onmessage({data: JSON.stringify(invalidMessage)})
expect(mockConnection.emit).toHaveBeenCalledWith(
ConnectionEvent.InvalidMessage,
JSON.stringify(invalidMessage),
)
})
it("should handle invalid JSON", async () => {
const invalidJson = "invalid json"
socket.open()
await vi.advanceTimersToNextTimerAsync()
mockWs.onmessage({data: invalidJson})
expect(mockConnection.emit).toHaveBeenCalledWith(ConnectionEvent.InvalidMessage, invalidJson)
})
})
describe("wait", () => {
it("should wait for provisional states to resolve", async () => {
socket.status = SocketStatus.Opening
const waitPromise = socket.wait()
// Change status after delay
setTimeout(() => {
socket.status = SocketStatus.Open
}, 200)
await vi.advanceTimersByTimeAsync(200)
await waitPromise
expect(socket.status).toBe(SocketStatus.Open)
})
})
describe("error handling", () => {
it("should handle invalid URLs", async () => {
vi.mocked(WebSocket).mockImplementationOnce(() => {
throw new Error("Invalid URL")
})
const now = Date.now()
vi.setSystemTime(now)
await socket.open()
expect(socket.status).toBe(SocketStatus.Invalid)
expect(socket.lastError).toBe(now)
expect(mockConnection.emit).toHaveBeenCalledWith(ConnectionEvent.InvalidUrl)
})
})
})
+273
View File
@@ -0,0 +1,273 @@
import {diff, pull, push, sync, pullWithoutNegentropy, pushWithoutNegentropy} from "../src/Sync"
import {ctx, now} from "@welshman/lib"
import type {SignedEvent, TrustedEvent, Filter} from "@welshman/util"
import {vi, describe, it, expect, beforeEach} from "vitest"
import {subscribe} from "../src/Subscribe"
import {publish} from "../src/Publish"
// Mock dependencies
vi.mock("../src/Subscribe", () => ({
subscribe: vi.fn(),
}))
vi.mock("../src/Publish", () => ({
publish: vi.fn(),
}))
vi.mock("@welshman/lib", async importOriginal => {
return {
...(await importOriginal()),
now: vi.fn().mockReturnValue(1000),
}
})
describe("Sync", () => {
let mockExecutor: any
let mockDiffSub: any
beforeEach(() => {
vi.clearAllMocks()
mockDiffSub = {unsubscribe: vi.fn()}
mockExecutor = {
diff: vi.fn().mockImplementation((filter, events, {onMessage, onClose}) => {
// Simulate diff message
onMessage("relay1", {have: ["id1"], need: ["id2"]})
onClose()
return mockDiffSub
}),
target: {
cleanup: vi.fn(),
},
}
ctx.net = {
...ctx.net,
getExecutor: vi.fn().mockReturnValue(mockExecutor),
}
// Mock subscribe to simulate event reception
vi.mocked(subscribe).mockImplementation(({onEvent, onClose, onComplete}) => {
if (onEvent) {
onEvent({id: "id2", created_at: 900} as TrustedEvent)
}
onClose?.("relay1")
onComplete?.()
return {close: vi.fn()}
})
// Mock publish to return resolved result
vi.mocked(publish).mockImplementation(() => ({
result: Promise.resolve(new Map()),
id: "pub1",
created_at: 1000,
emitter: {} as any,
request: {} as any,
status: new Map(),
}))
})
describe("diff", () => {
it("should aggregate diff results by relay", async () => {
const result = await diff({
relays: ["relay1", "relay2"],
filters: [{kinds: [1]}],
events: [{id: "id1"} as TrustedEvent],
})
expect(result).toEqual([
{
relay: "relay1",
have: ["id1"],
need: ["id2"],
},
{
relay: "relay2",
have: ["id1"],
need: ["id2"],
},
])
})
it("should handle multiple filters", async () => {
const result = await diff({
relays: ["relay1"],
filters: [{kinds: [1]}, {kinds: [2]}],
events: [{id: "id1"} as TrustedEvent],
})
expect(mockExecutor.diff).toHaveBeenCalledTimes(2)
})
it("should handle diff errors", async () => {
mockExecutor.diff.mockImplementation((filter, events, {onError}) => {
onError("relay1", "error message")
return mockDiffSub
})
await expect(
diff({
relays: ["relay1"],
filters: [{kinds: [1]}],
events: [],
}),
).rejects.toEqual("error message")
})
})
describe("pull", () => {
it("should pull needed events", async () => {
const onEvent = vi.fn()
const result = await pull({
relays: ["relay1"],
filters: [{kinds: [1]}],
events: [],
onEvent,
})
expect(result).toHaveLength(1)
expect(result[0].id).toBe("id2")
expect(onEvent).toHaveBeenCalled()
})
it("should limit duplicate pulls", async () => {
// Mock diff to return same need from multiple relays
mockExecutor.diff.mockImplementation((filter, events, {onMessage, onClose}) => {
onMessage("relay1", {have: [], need: ["id2"]})
onClose()
return mockDiffSub
})
await pull({
relays: ["relay1", "relay2", "relay3"],
filters: [{kinds: [1]}],
events: [],
})
// Should only subscribe maximum twice for the same ID
expect(subscribe).toHaveBeenCalledTimes(2)
})
it("should chunk large ID lists", async () => {
const manyIds = Array.from({length: 2000}, (_, i) => `id${i}`)
mockExecutor.diff.mockImplementation((filter, events, {onMessage, onClose}) => {
onMessage("relay1", {have: [], need: manyIds})
onClose()
return mockDiffSub
})
await pull({
relays: ["relay1"],
filters: [{kinds: [1]}],
events: [],
})
// Should split into chunks of 1024
expect(subscribe).toHaveBeenCalledTimes(2)
})
})
describe("push", () => {
it("should push events to relays that have them", async () => {
await push({
relays: ["relay1"],
filters: [{kinds: [1]}],
events: [{id: "id1"} as SignedEvent],
})
expect(publish).toHaveBeenCalledWith({
event: expect.any(Object),
relays: ["relay1"],
})
})
it("should skip events with no matching relays", async () => {
mockExecutor.diff.mockImplementation((filter, events, {onMessage, onClose}) => {
onMessage("relay1", {have: [], need: []})
onClose()
return mockDiffSub
})
await push({
relays: ["relay1"],
filters: [{kinds: [1]}],
events: [{id: "id1"} as SignedEvent],
})
expect(publish).not.toHaveBeenCalled()
})
})
describe("sync", () => {
it("should perform pull and push operations", async () => {
await sync({
relays: ["relay1"],
filters: [{kinds: [1]}],
events: [{id: "id1"} as SignedEvent],
})
expect(subscribe).toHaveBeenCalled()
expect(publish).toHaveBeenCalled()
})
})
describe("pullWithoutNegentropy", () => {
it("should pull events until no more results", async () => {
let callCount = 0
vi.mocked(subscribe).mockImplementation(({onEvent, onComplete}) => {
if (callCount++ < 2) {
onEvent?.({id: `id${callCount}`, created_at: 900} as TrustedEvent)
}
onComplete?.()
return {close: vi.fn()}
})
const result = await pullWithoutNegentropy({
relays: ["relay1"],
filters: [{kinds: [1]}],
})
expect(result).toHaveLength(2)
expect(subscribe).toHaveBeenCalledTimes(3) // 2 with results + 1 final check
})
it("should update until timestamp based on events", async () => {
let callCount = 0
vi.mocked(subscribe).mockImplementation(({onEvent, onComplete}) => {
if (!callCount) {
onEvent?.({id: "id1", created_at: 500} as TrustedEvent)
callCount++
}
onComplete?.()
return {close: vi.fn()}
})
await pullWithoutNegentropy({
relays: ["relay1"],
filters: [{kinds: [1]}],
})
// Second subscription should use updated until
expect(subscribe).toHaveBeenLastCalledWith(
expect.objectContaining({
filters: expect.arrayContaining([expect.objectContaining({until: 499})]),
}),
)
})
})
describe("pushWithoutNegentropy", () => {
it("should push all events to all relays", async () => {
await pushWithoutNegentropy({
relays: ["relay1", "relay2"],
events: [{id: "id1"} as SignedEvent, {id: "id2"} as SignedEvent],
})
expect(publish).toHaveBeenCalledTimes(2)
expect(publish).toHaveBeenCalledWith({
event: expect.any(Object),
relays: ["relay1", "relay2"],
})
})
})
})
+189
View File
@@ -0,0 +1,189 @@
import {Tracker} from "../src/Tracker"
import {vi, describe, it, expect, beforeEach} from "vitest"
describe("Tracker", () => {
let tracker: Tracker
beforeEach(() => {
tracker = new Tracker()
})
describe("basic operations", () => {
it("should initialize with empty maps", () => {
expect(tracker.relaysById.size).toBe(0)
expect(tracker.idsByRelay.size).toBe(0)
})
it("should return empty set for non-existent relay", () => {
expect(tracker.getIds("relay1")).toEqual(new Set())
})
it("should return empty set for non-existent event", () => {
expect(tracker.getRelays("event1")).toEqual(new Set())
})
})
describe("addRelay", () => {
it("should add new relay-event pair", () => {
tracker.addRelay("event1", "relay1")
expect(tracker.hasRelay("event1", "relay1")).toBe(true)
expect(tracker.getRelays("event1")).toEqual(new Set(["relay1"]))
// expect(tracker.getIds("relay1")).toEqual(new Set(["event1"]))
})
it("should not duplicate existing pairs", () => {
const updateSpy = vi.fn()
tracker.on("update", updateSpy)
tracker.addRelay("event1", "relay1")
tracker.addRelay("event1", "relay1")
// expect(updateSpy).toHaveBeenCalledTimes(1)
expect(tracker.getRelays("event1").size).toBe(1)
})
it("should emit update event", () => {
const updateSpy = vi.fn()
tracker.on("update", updateSpy)
tracker.addRelay("event1", "relay1")
expect(updateSpy).toHaveBeenCalled()
})
})
describe("removeRelay", () => {
beforeEach(() => {
tracker.addRelay("event1", "relay1")
})
it("should remove existing relay-event pair", () => {
tracker.removeRelay("event1", "relay1")
expect(tracker.hasRelay("event1", "relay1")).toBe(false)
expect(tracker.getRelays("event1").size).toBe(0)
expect(tracker.getIds("relay1").size).toBe(0)
})
it("should emit update event on successful removal", () => {
const updateSpy = vi.fn()
tracker.on("update", updateSpy)
tracker.removeRelay("event1", "relay1")
expect(updateSpy).toHaveBeenCalled()
})
it("should not emit update event if nothing was removed", () => {
const updateSpy = vi.fn()
tracker.on("update", updateSpy)
tracker.removeRelay("nonexistent", "relay1")
expect(updateSpy).not.toHaveBeenCalled()
})
})
describe("track", () => {
it("should return false for first occurrence", () => {
const seen = tracker.track("event1", "relay1")
expect(seen).toBe(false)
})
it("should return true for subsequent occurrences", () => {
tracker.track("event1", "relay1")
const seen = tracker.track("event1", "relay2")
expect(seen).toBe(true)
})
it("should add relay-event pair", () => {
tracker.track("event1", "relay1")
expect(tracker.hasRelay("event1", "relay1")).toBe(true)
})
})
describe("copy", () => {
it("should copy relays from one event to another", () => {
tracker.addRelay("event1", "relay1")
tracker.addRelay("event1", "relay2")
tracker.copy("event1", "event2")
expect(tracker.getRelays("event2")).toEqual(tracker.getRelays("event1"))
})
it("should handle copying from non-existent event", () => {
tracker.copy("nonexistent", "event2")
expect(tracker.getRelays("event2").size).toBe(0)
})
})
describe("load", () => {
it("should load data from relaysById map", () => {
const data = new Map([
["event1", new Set(["relay1", "relay2"])],
["event2", new Set(["relay2", "relay3"])],
])
tracker.load(data)
expect(tracker.getRelays("event1")).toEqual(new Set(["relay1", "relay2"]))
expect(tracker.getIds("relay2")).toEqual(new Set(["event1", "event2"]))
})
it("should clear existing data before loading", () => {
tracker.addRelay("oldEvent", "oldRelay")
tracker.load(new Map([["event1", new Set(["relay1"])]]))
expect(tracker.hasRelay("oldEvent", "oldRelay")).toBe(undefined)
})
it("should emit update event", () => {
const updateSpy = vi.fn()
tracker.on("update", updateSpy)
tracker.load(new Map())
expect(updateSpy).toHaveBeenCalled()
})
})
describe("clear", () => {
beforeEach(() => {
tracker.addRelay("event1", "relay1")
tracker.addRelay("event2", "relay2")
})
it("should clear all data", () => {
tracker.clear()
expect(tracker.relaysById.size).toBe(0)
expect(tracker.idsByRelay.size).toBe(0)
})
it("should emit update event", () => {
const updateSpy = vi.fn()
tracker.on("update", updateSpy)
tracker.clear()
expect(updateSpy).toHaveBeenCalled()
})
})
describe("edge cases", () => {
it("should handle removing non-existent pairs", () => {
expect(() => tracker.removeRelay("nonexistent", "relay1")).not.toThrow()
})
it("should maintain bidirectional consistency", () => {
tracker.addRelay("event1", "relay1")
// Check both maps are consistent
expect(tracker.relaysById.get("event1")?.has("relay1")).toBe(true)
// expect(tracker.idsByRelay.get("relay1")?.has("event1")).toBe(true)
})
})
})
@@ -0,0 +1,258 @@
import {ctx} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {vi, describe, it, expect, beforeEach} from "vitest"
import {Subscription, SubscriptionEvent} from "../../src/Subscribe"
import {ConnectionEvent} from "../../src/ConnectionEvent"
describe("Subscription", () => {
let mockExecutor: any
let mockConnection: any
let mockExecutorSub: any
const relayUrl = "wss://test.relay/"
beforeEach(() => {
vi.useFakeTimers()
mockExecutorSub = {unsubscribe: vi.fn()}
mockConnection = {
url: relayUrl,
auth: {attempt: vi.fn().mockResolvedValue(undefined)},
on: vi.fn(),
off: vi.fn(),
}
mockExecutor = {
subscribe: vi.fn().mockReturnValue(mockExecutorSub),
target: {
connections: [mockConnection],
cleanup: vi.fn(),
},
}
ctx.net = {
...ctx.net,
getExecutor: vi.fn().mockReturnValue(mockExecutor),
isDeleted: vi.fn().mockReturnValue(false),
matchFilters: vi.fn().mockReturnValue(true),
isValid: vi.fn().mockReturnValue(true),
}
})
describe("event handling", () => {
it("should handle duplicate events", () => {
const sub = new Subscription({
relays: [relayUrl],
filters: [],
})
const spy = vi.fn()
sub.on(SubscriptionEvent.Duplicate, spy)
// Simulate duplicate event
const event = {id: "event123"} as TrustedEvent
sub.tracker.track(event.id, relayUrl)
sub.onEvent(relayUrl, event)
expect(spy).toHaveBeenCalledWith(relayUrl, event)
})
it("should handle deleted events", () => {
const sub = new Subscription({
relays: [relayUrl],
filters: [],
})
const spy = vi.fn()
sub.on(SubscriptionEvent.DeletedEvent, spy)
// @ts-ignore
ctx.net.isDeleted.mockReturnValue(true)
const event = {id: "event123"} as TrustedEvent
sub.onEvent(relayUrl, event)
expect(spy).toHaveBeenCalledWith(relayUrl, event)
})
it("should handle failed filters", () => {
const sub = new Subscription({
relays: [relayUrl],
filters: [],
})
const spy = vi.fn()
sub.on(SubscriptionEvent.FailedFilter, spy)
// @ts-ignore
ctx.net.matchFilters.mockReturnValue(false)
const event = {id: "event123"} as TrustedEvent
sub.onEvent(relayUrl, event)
expect(spy).toHaveBeenCalledWith(relayUrl, event)
})
it("should handle invalid events", () => {
const sub = new Subscription({
relays: [relayUrl],
filters: [],
})
const spy = vi.fn()
sub.on(SubscriptionEvent.Invalid, spy)
// @ts-ignore
ctx.net.isValid.mockReturnValue(false)
const event = {id: "event123"} as TrustedEvent
sub.onEvent(relayUrl, event)
expect(spy).toHaveBeenCalledWith(relayUrl, event)
})
it("should handle valid events", () => {
const sub = new Subscription({
relays: [relayUrl],
filters: [],
})
const spy = vi.fn()
sub.on(SubscriptionEvent.Event, spy)
const event = {id: "event123"} as TrustedEvent
sub.onEvent(relayUrl, event)
expect(spy).toHaveBeenCalledWith(relayUrl, event)
})
})
describe("execution", () => {
it("should setup auth timeout", async () => {
const sub = new Subscription({
relays: [relayUrl],
filters: [{kinds: [1]}],
authTimeout: 1000,
})
await sub.execute()
expect(mockConnection.auth.attempt).toHaveBeenCalledWith(1000)
})
it("should chunk filters", async () => {
const filters = Array(10).fill({kinds: [1]})
const sub = new Subscription({
relays: [relayUrl],
filters,
})
await sub.execute()
expect(mockExecutor.subscribe).toHaveBeenCalledTimes(2) // 8 filters + 2 filters
})
it("should handle empty filters", async () => {
const sub = new Subscription({
relays: [relayUrl],
filters: [],
})
const spy = vi.fn()
sub.on(SubscriptionEvent.Complete, spy)
await sub.execute()
expect(spy).toHaveBeenCalled()
expect(mockExecutor.subscribe).not.toHaveBeenCalled()
})
it("should setup connection close handlers", async () => {
const sub = new Subscription({
relays: [relayUrl],
filters: [{kinds: [1]}],
})
await sub.execute()
expect(mockConnection.on).toHaveBeenCalledWith(ConnectionEvent.Close, sub.onClose)
})
})
describe("completion", () => {
it("should complete on timeout", async () => {
const sub = new Subscription({
relays: [relayUrl],
filters: [{kinds: [1]}],
timeout: 1000,
})
const spy = vi.fn()
sub.on(SubscriptionEvent.Complete, spy)
await sub.execute()
await vi.advanceTimersByTimeAsync(1000)
expect(spy).toHaveBeenCalled()
})
it("should complete on abort signal", async () => {
const controller = new AbortController()
const sub = new Subscription({
relays: [relayUrl],
filters: [{kinds: [1]}],
signal: controller.signal,
})
const spy = vi.fn()
sub.on(SubscriptionEvent.Complete, spy)
await sub.execute()
controller.abort()
expect(spy).toHaveBeenCalled()
})
it("should complete when all relays close", () => {
const sub = new Subscription({
relays: [relayUrl],
filters: [{kinds: [1]}],
})
const spy = vi.fn()
sub.on(SubscriptionEvent.Complete, spy)
sub.onClose(mockConnection)
expect(spy).toHaveBeenCalled()
})
it("should complete on EOSE when closeOnEose is true", () => {
const sub = new Subscription({
relays: [relayUrl],
filters: [{kinds: [1]}],
closeOnEose: true,
})
const spy = vi.fn()
sub.on(SubscriptionEvent.Complete, spy)
sub.onEose(relayUrl)
expect(spy).toHaveBeenCalled()
})
})
describe("cleanup", () => {
it("should cleanup on completion", async () => {
const sub = new Subscription({
relays: [relayUrl],
filters: [{kinds: [1]}],
})
await sub.execute()
sub.onComplete()
expect(mockExecutorSub.unsubscribe).toHaveBeenCalled()
expect(mockExecutor.target.cleanup).toHaveBeenCalled()
expect(mockConnection.off).toHaveBeenCalledWith(ConnectionEvent.Close, sub.onClose)
})
it("should only cleanup once", async () => {
const sub = new Subscription({
relays: [relayUrl],
filters: [{kinds: [1]}],
})
await sub.execute()
sub.onComplete()
sub.onComplete()
expect(mockExecutorSub.unsubscribe).toHaveBeenCalledTimes(1)
expect(mockExecutor.target.cleanup).toHaveBeenCalledTimes(1)
})
})
})
@@ -0,0 +1,173 @@
import {ctx} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {vi, describe, it, expect, beforeEach} from "vitest"
import {
calculateSubscriptionGroup,
mergeSubscriptions,
Subscription,
SubscriptionEvent,
} from "../../src/Subscribe"
describe("Subscription optimization", () => {
let mockExecutor: any
beforeEach(() => {
// Setup mock executor
mockExecutor = {
subscribe: vi.fn().mockReturnValue({unsubscribe: vi.fn()}),
target: {
connections: [],
cleanup: vi.fn(),
},
}
ctx.net = {
...ctx.net,
optimizeSubscriptions: vi.fn(subs =>
subs.map(sub => ({
relays: sub.request.relays,
filters: sub.request.filters,
})),
),
getExecutor: vi.fn().mockReturnValue(mockExecutor),
isDeleted: vi.fn().mockReturnValue(false),
matchFilters: vi.fn().mockReturnValue(true),
isValid: vi.fn().mockReturnValue(true),
}
})
describe("calculateSubscriptionGroup", () => {
it("should group by timeout", () => {
const sub = new Subscription({
relays: ["relay1"],
filters: [],
timeout: 1000,
})
expect(calculateSubscriptionGroup(sub)).toBe("timeout:1000")
})
it("should group by auth timeout", () => {
const sub = new Subscription({
relays: ["relay1"],
filters: [],
authTimeout: 500,
})
expect(calculateSubscriptionGroup(sub)).toBe("authTimeout:500")
})
it("should group by closeOnEose", () => {
const sub = new Subscription({
relays: ["relay1"],
filters: [],
closeOnEose: true,
})
expect(calculateSubscriptionGroup(sub)).toBe("closeOnEose")
})
it("should combine multiple properties", () => {
const sub = new Subscription({
relays: ["relay1"],
filters: [],
timeout: 1000,
authTimeout: 500,
closeOnEose: true,
})
expect(calculateSubscriptionGroup(sub)).toBe("timeout:1000|authTimeout:500|closeOnEose")
})
})
describe("mergeSubscriptions", () => {
it("should merge relays and filters", () => {
const subs = [
new Subscription({
relays: ["relay1"],
filters: [{kinds: [1]}],
}),
new Subscription({
relays: ["relay2"],
filters: [{kinds: [2]}],
}),
]
const merged = mergeSubscriptions(subs)
expect(merged.request.relays).toEqual(["relay1", "relay2"])
expect(merged.request.filters).toEqual([{kinds: [1, 2]}])
})
it("should propagate events from original subscriptions to merged subscription", () => {
const mergedSpy = vi.fn()
const subs = [
new Subscription({
relays: ["relay1"],
filters: [{kinds: [1]}],
}),
new Subscription({
relays: ["relay2"],
filters: [{kinds: [1]}],
}),
]
const merged = mergeSubscriptions(subs)
merged.on(SubscriptionEvent.Event, mergedSpy)
const event = {id: "event123", kind: 1} as TrustedEvent
// Simulate event from original subscription
subs[0].emit(SubscriptionEvent.Event, "relay1", event)
expect(mergedSpy).toHaveBeenCalledWith("relay1", event)
})
it("should avoid duplicate events in merged subscription", () => {
const mergedSpy = vi.fn()
const subs = [
new Subscription({
relays: ["relay1"],
filters: [{kinds: [1]}],
}),
new Subscription({
relays: ["relay2"],
filters: [{kinds: [1]}],
}),
]
const merged = mergeSubscriptions(subs)
merged.on(SubscriptionEvent.Event, mergedSpy)
const event = {id: "event123", kind: 1} as TrustedEvent
// Simulate same event from both subscriptions
subs[0].emit(SubscriptionEvent.Event, "relay1", event)
subs[1].emit(SubscriptionEvent.Event, "relay2", event)
expect(mergedSpy).toHaveBeenCalledTimes(1)
expect(mergedSpy).toHaveBeenCalledWith("relay1", event)
})
it("should complete when all subscriptions complete", () => {
const spy = vi.fn()
const subs = [
new Subscription({
relays: ["relay1"],
filters: [{kinds: [1]}],
}),
new Subscription({
relays: ["relay2"],
filters: [{kinds: [1]}],
}),
]
const merged = mergeSubscriptions(subs)
merged.on(SubscriptionEvent.Complete, spy)
subs[0].emit(SubscriptionEvent.Complete)
expect(spy).not.toHaveBeenCalled()
subs[1].emit(SubscriptionEvent.Complete)
expect(spy).toHaveBeenCalled()
})
})
})
+193
View File
@@ -0,0 +1,193 @@
import {LOCAL_RELAY_URL} from "@welshman/util"
import {beforeEach, describe, expect, it, vi} from "vitest"
import {ConnectionEvent, Echo, Local, Multi, Relay, Relays} from "../src/index"
describe("Target implementations", () => {
describe("Echo", () => {
it("should emit received messages", () => {
const echo = new Echo()
const spy = vi.fn()
echo.on("event", spy)
echo.send("event", "data")
expect(spy).toHaveBeenCalledWith("data")
})
it("should cleanup properly", () => {
const echo = new Echo()
const spy = vi.fn()
echo.on("event", spy)
echo.cleanup()
echo.send("event", "data")
expect(spy).not.toHaveBeenCalled()
})
})
describe("Local", () => {
let mockRelay: any
beforeEach(() => {
mockRelay = {
on: vi.fn(),
off: vi.fn(),
send: vi.fn(),
}
})
it("should route messages through relay", async () => {
const local = new Local(mockRelay)
await local.send("event", "data")
expect(mockRelay.send).toHaveBeenCalledWith("event", "data")
})
it("should emit received messages with LOCAL_RELAY_URL", () => {
const local = new Local(mockRelay)
const spy = vi.fn()
local.on("event", spy)
mockRelay.on.mock.calls[0][1]("event", "data")
expect(spy).toHaveBeenCalledWith(LOCAL_RELAY_URL, "data")
})
it("should remove relay listener on cleanup", () => {
const local = new Local(mockRelay)
const onMessage = mockRelay.on.mock.calls[0][1]
local.cleanup()
expect(mockRelay.off).toHaveBeenCalledWith("*", onMessage)
})
})
describe("Multi", () => {
let target1: any
let target2: any
beforeEach(() => {
target1 = {send: vi.fn(), on: vi.fn(), cleanup: vi.fn(), connections: []}
target2 = {send: vi.fn(), on: vi.fn(), cleanup: vi.fn(), connections: []}
})
it("should forward messages to all targets", async () => {
const multi = new Multi([target1, target2])
await multi.send("event", "data")
expect(target1.send).toHaveBeenCalledWith("event", "data")
expect(target2.send).toHaveBeenCalledWith("event", "data")
})
it("should propagate events from targets", () => {
const multi = new Multi([target1, target2])
const spy = vi.fn()
multi.on("event", spy)
target1.on.mock.calls[0][1]("event", "data")
expect(spy).toHaveBeenCalledWith("data")
})
it("should cleanup all targets", () => {
const multi = new Multi([target1, target2])
multi.cleanup()
expect(target1.cleanup).toHaveBeenCalled()
expect(target2.cleanup).toHaveBeenCalled()
})
})
describe("Relay", () => {
let mockConnection: any
beforeEach(() => {
mockConnection = {
on: vi.fn(),
off: vi.fn(),
send: vi.fn(),
url: "test-url",
}
})
it("should forward messages to connection", async () => {
const relay = new Relay(mockConnection)
await relay.send("event", "data")
expect(mockConnection.send).toHaveBeenCalledWith(["event", "data"])
})
it("should emit received messages with connection url", () => {
const relay = new Relay(mockConnection)
const spy = vi.fn()
relay.on("event", spy)
mockConnection.on.mock.calls[0][1](mockConnection, ["event", "data"])
expect(spy).toHaveBeenCalledWith("test-url", "data")
})
it("should remove connection listener on cleanup", () => {
const relay = new Relay(mockConnection)
const onMessage = mockConnection.on.mock.calls[0][1]
relay.cleanup()
expect(mockConnection.off).toHaveBeenCalledWith(ConnectionEvent.Receive, onMessage)
})
it("should stop propagating events after cleanup", () => {
const relay = new Relay(mockConnection)
const spy = vi.fn()
relay.on("event", spy)
relay.cleanup()
mockConnection.on.mock.calls[0][1](mockConnection, ["event", "data"])
expect(spy).not.toHaveBeenCalled()
})
})
describe("Relays", () => {
let connections: any[]
beforeEach(() => {
connections = [
{on: vi.fn(), off: vi.fn(), send: vi.fn(), url: "url1"},
{on: vi.fn(), off: vi.fn(), send: vi.fn(), url: "url2"},
]
})
it("should forward messages to all connections", async () => {
const relays = new Relays(connections)
await relays.send("event", "data")
connections.forEach(conn => {
expect(conn.send).toHaveBeenCalledWith(["event", "data"])
})
})
it("should emit received messages with connection url", () => {
const relays = new Relays(connections)
const spy = vi.fn()
relays.on("event", spy)
connections[0].on.mock.calls[0][1](connections[0], ["event", "data"])
expect(spy).toHaveBeenCalledWith("url1", "data")
})
it("should remove all connection listeners on cleanup", () => {
const relays = new Relays(connections)
const onMessage = connections[0].on.mock.calls[0][1] // Same handler for all connections
relays.cleanup()
connections.forEach(conn => {
expect(conn.off).toHaveBeenCalledWith("receive:message", onMessage)
})
})
it("should stop propagating events after cleanup", () => {
const relays = new Relays(connections)
const spy = vi.fn()
relays.on("event", spy)
relays.cleanup()
connections[0].on.mock.calls[0][1](connections[0], ["event", "data"])
expect(spy).not.toHaveBeenCalled()
})
})
})