Add tests
This commit is contained in:
Generated
+895
-1
File diff suppressed because it is too large
Load Diff
+12
-6
@@ -3,22 +3,28 @@
|
|||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
|
"test:ui": "vitest --ui",
|
||||||
|
"predocs": "typedoc",
|
||||||
|
"docs:dev": "vitepress dev docs",
|
||||||
|
"docs:build": "npx typedoc && vitepress build docs",
|
||||||
|
"docs:preview": "vitepress preview docs"
|
||||||
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/coracle-social/skiff.git"
|
"url": "https://github.com/coracle-social/skiff.git"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@vitest/coverage-v8": "^3.0.5",
|
||||||
"gts": "^6.0.2",
|
"gts": "^6.0.2",
|
||||||
|
"happy-dom": "^16.8.1",
|
||||||
|
"vitest": "^3.0.5",
|
||||||
"typedoc": "^0.27.9",
|
"typedoc": "^0.27.9",
|
||||||
"typedoc-plugin-markdown": "^4.4.2",
|
"typedoc-plugin-markdown": "^4.4.2",
|
||||||
"typedoc-vitepress-theme": "^1.1.2",
|
"typedoc-vitepress-theme": "^1.1.2",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"vitepress": "^1.6.3"
|
"vitepress": "^1.6.3"
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"predocs": "typedoc",
|
|
||||||
"docs:dev": "vitepress dev docs",
|
|
||||||
"docs:build": "npx typedoc && vitepress build docs",
|
|
||||||
"docs:preview": "vitepress preview docs"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
build
|
build
|
||||||
normalize-url
|
normalize-url
|
||||||
|
__tests__
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
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(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe("collection", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
// Reset mock implementations
|
||||||
|
vi.mocked(freshness.getFreshness).mockImplementation(() => 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetModules()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("basic functionality", () => {
|
||||||
|
it("should create a collection with indexStore", () => {
|
||||||
|
const items = [{id: "1", value: "test"}]
|
||||||
|
const store = writable(items)
|
||||||
|
|
||||||
|
const col = collection({
|
||||||
|
name: "test",
|
||||||
|
store,
|
||||||
|
getKey: item => item.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(col.indexStore.get().get("1")).toEqual(items[0])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should update indexStore when store changes", () => {
|
||||||
|
const store = writable<Array<{id: string; value: string}>>([])
|
||||||
|
const col = collection({
|
||||||
|
name: "test",
|
||||||
|
store,
|
||||||
|
getKey: item => item.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
const newItem = {id: "1", value: "test"}
|
||||||
|
store.set([newItem])
|
||||||
|
|
||||||
|
expect(get(col.indexStore).get("1")).toEqual(newItem)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("loadItem", () => {
|
||||||
|
it("should return stale item if no loader provided", async () => {
|
||||||
|
const items = [{id: "1", value: "test"}]
|
||||||
|
const store = writable(items)
|
||||||
|
|
||||||
|
const col = collection({
|
||||||
|
name: "test",
|
||||||
|
store,
|
||||||
|
getKey: item => item.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await col.loadItem("1")
|
||||||
|
expect(result).toEqual(items[0])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return undefined for non-existent items when no loader provided", async () => {
|
||||||
|
const store = writable<Array<{id: string}>>([])
|
||||||
|
|
||||||
|
const col = collection({
|
||||||
|
name: "test",
|
||||||
|
store,
|
||||||
|
getKey: item => item.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await col.loadItem("1")
|
||||||
|
expect(result).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should use loader to fetch new items", 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")
|
||||||
|
expect(mockLoad).toHaveBeenCalledWith("1")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle concurrent loading of the same item", 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,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Start multiple concurrent loads
|
||||||
|
const loads = Promise.all([col.loadItem("1"), col.loadItem("1"), col.loadItem("1")])
|
||||||
|
|
||||||
|
await loads
|
||||||
|
// Should only call load once
|
||||||
|
expect(mockLoad).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should respect freshness checks", async () => {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
|
||||||
|
await col.loadItem("1")
|
||||||
|
// Should not call load because item is fresh
|
||||||
|
expect(mockLoad).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
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 col = collection({
|
||||||
|
name: "test",
|
||||||
|
store,
|
||||||
|
getKey: item => item.id,
|
||||||
|
load: mockLoad,
|
||||||
|
})
|
||||||
|
|
||||||
|
// First attempt
|
||||||
|
await col.loadItem("1").catch(() => {})
|
||||||
|
expect(mockLoad).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
// Immediate retry should be throttled
|
||||||
|
await col.loadItem("1").catch(() => {})
|
||||||
|
expect(mockLoad).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("deriveItem", () => {
|
||||||
|
it("should return readable undefined for null keys", () => {
|
||||||
|
const store = writable<Array<{id: string}>>([])
|
||||||
|
|
||||||
|
const col = collection({
|
||||||
|
name: "test",
|
||||||
|
store,
|
||||||
|
getKey: item => item.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
const derived = col.deriveItem(null)
|
||||||
|
expect(get(derived)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should create a derived store that updates with the source", () => {
|
||||||
|
const store = writable<Array<{id: string; value: string}>>([])
|
||||||
|
|
||||||
|
const col = collection({
|
||||||
|
name: "test",
|
||||||
|
store,
|
||||||
|
getKey: item => item.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
const derived = col.deriveItem("1")
|
||||||
|
expect(get(derived)).toBeUndefined()
|
||||||
|
|
||||||
|
// Update source store
|
||||||
|
store.set([{id: "1", value: "test"}])
|
||||||
|
expect(get(derived)).toEqual({id: "1", value: "test"})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should trigger load when deriving non-existent item", () => {
|
||||||
|
const store = writable<Array<{id: string; value: string}>>([])
|
||||||
|
const mockLoad = vi.fn()
|
||||||
|
|
||||||
|
const col = collection({
|
||||||
|
name: "test",
|
||||||
|
store,
|
||||||
|
getKey: item => item.id,
|
||||||
|
load: mockLoad,
|
||||||
|
})
|
||||||
|
|
||||||
|
col.deriveItem("1")
|
||||||
|
expect(mockLoad).toHaveBeenCalledWith("1")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -53,9 +53,11 @@ export const collection = <T, LoadArgs extends any[]>({
|
|||||||
|
|
||||||
pending.set(key, promise)
|
pending.set(key, promise)
|
||||||
|
|
||||||
|
try {
|
||||||
await promise
|
await promise
|
||||||
|
} finally {
|
||||||
pending.delete(key)
|
pending.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
const fresh = indexStore.get().get(key)
|
const fresh = indexStore.get().get(key)
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
build
|
build
|
||||||
normalize-url
|
normalize-url
|
||||||
|
__tests__
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import {describe, it, expect} from "vitest"
|
||||||
|
import * as Content from "../src"
|
||||||
|
import {npubEncode, noteEncode} from "nostr-tools/nip19"
|
||||||
|
|
||||||
|
describe("Content Parsing", () => {
|
||||||
|
const npub = npubEncode("ee".repeat(32))
|
||||||
|
const nevent = noteEncode("ff".repeat(32))
|
||||||
|
describe("Basic Parsing", () => {
|
||||||
|
it("should parse plain text", () => {
|
||||||
|
const result = Content.parse({content: "Hello world"})
|
||||||
|
expect(result).toEqual([
|
||||||
|
{type: Content.ParsedType.Text, value: "Hello world", raw: "Hello world"},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should parse newlines", () => {
|
||||||
|
const result = Content.parse({content: "Hello\nworld"})
|
||||||
|
expect(result).toEqual([
|
||||||
|
{type: Content.ParsedType.Text, value: "Hello", raw: "Hello"},
|
||||||
|
{type: Content.ParsedType.Newline, value: "\n", raw: "\n"},
|
||||||
|
{type: Content.ParsedType.Text, value: "world", raw: "world"},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Link Parsing", () => {
|
||||||
|
it("should parse basic URLs", () => {
|
||||||
|
const result = Content.parse({content: "Check https://example.com"})
|
||||||
|
expect(result[1]).toMatchObject({
|
||||||
|
type: Content.ParsedType.Link,
|
||||||
|
value: {
|
||||||
|
url: expect.any(URL),
|
||||||
|
isMedia: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(result[1].value.url.toString()).toBe("https://example.com/")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should parse URLs without protocol", () => {
|
||||||
|
const result = Content.parse({content: "Visit example.com"})
|
||||||
|
expect(result[1]).toMatchObject({
|
||||||
|
type: Content.ParsedType.Link,
|
||||||
|
value: {
|
||||||
|
url: expect.any(URL),
|
||||||
|
isMedia: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(result[1].value.url.toString()).toBe("https://example.com/")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should identify media links", () => {
|
||||||
|
const result = Content.parse({content: "https://example.com/image.jpg"})
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
type: Content.ParsedType.Link,
|
||||||
|
value: {
|
||||||
|
isMedia: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Nostr Entity Parsing", () => {
|
||||||
|
it("should parse nostr profiles", () => {
|
||||||
|
const result = Content.parse({
|
||||||
|
content: `nostr:${npub}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
type: Content.ParsedType.Profile,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should parse nostr events", () => {
|
||||||
|
const result = Content.parse({
|
||||||
|
content: `nostr:${nevent}`,
|
||||||
|
})
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
type: Content.ParsedType.Event,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Special Content Parsing", () => {
|
||||||
|
it("should parse code blocks", () => {
|
||||||
|
const result = Content.parse({content: "```const x = 1```"})
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
type: Content.ParsedType.Code,
|
||||||
|
value: "const x = 1",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should parse inline code", () => {
|
||||||
|
const result = Content.parse({content: "Use `npm install`"})
|
||||||
|
expect(result[1]).toMatchObject({
|
||||||
|
type: Content.ParsedType.Code,
|
||||||
|
value: "npm install",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should parse topics", () => {
|
||||||
|
const result = Content.parse({content: "#nostr is cool"})
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
type: Content.ParsedType.Topic,
|
||||||
|
value: "nostr",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Rendering", () => {
|
||||||
|
it("should render as text", () => {
|
||||||
|
const parsed = Content.parse({content: "Hello https://example.com"})
|
||||||
|
const rendered = Content.renderAsText(parsed).toString()
|
||||||
|
expect(rendered).toContain("Hello")
|
||||||
|
expect(rendered).toContain("https://example.com")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should render as HTML", () => {
|
||||||
|
const parsed = Content.parse({content: "Hello https://example.com"})
|
||||||
|
const rendered = Content.renderAsHtml(parsed).toString()
|
||||||
|
expect(rendered).toContain('<a href="https://example.com/"')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Link Grid", () => {
|
||||||
|
it("should reduce consecutive image links into a grid", () => {
|
||||||
|
const content = Content.parse({
|
||||||
|
content: "https://example.com/1.jpg\nhttps://example.com/2.jpg https://example.com/2.jpg",
|
||||||
|
})
|
||||||
|
const reduced = Content.reduceLinks(content)
|
||||||
|
expect(reduced[0]).toMatchObject({
|
||||||
|
type: Content.ParsedType.LinkGrid,
|
||||||
|
value: {
|
||||||
|
links: expect.any(Array),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Legacy Mention Parsing", () => {
|
||||||
|
it("should parse legacy mentions", () => {
|
||||||
|
const result = Content.parse({
|
||||||
|
content: "#[0]",
|
||||||
|
tags: [["p", "1234567890"]],
|
||||||
|
})
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
type: Content.ParsedType.Profile,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import {describe, it, expect, beforeEach} from "vitest"
|
||||||
|
import {htmlRenderOptions, Renderer, textRenderOptions} from "../src"
|
||||||
|
|
||||||
|
describe("Renderer", () => {
|
||||||
|
let renderer: Renderer
|
||||||
|
|
||||||
|
describe("Html renderer", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
renderer = new Renderer(htmlRenderOptions)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should render text", () => {
|
||||||
|
renderer.addText("Hello world")
|
||||||
|
expect(renderer.toString()).toBe("Hello world")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should render newlines", () => {
|
||||||
|
renderer.addNewlines(2)
|
||||||
|
expect(renderer.toString()).toBe("\n\n")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should render links", () => {
|
||||||
|
renderer.addLink("https://njump.me", "Example")
|
||||||
|
expect(renderer.toString()).toBe('<a href="https://njump.me/" target="_blank">Example</a>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should render entities", () => {
|
||||||
|
renderer.addEntityLink("1234567890abcdef")
|
||||||
|
expect(renderer.toString()).toBe(
|
||||||
|
'<a href="https://njump.me/1234567890abcdef" target="_blank">1234567890abcdef…</a>',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should escape HTML in text content", () => {
|
||||||
|
renderer.addText('<script>alert("xss")</script>')
|
||||||
|
expect(renderer.toString()).not.toContain("<script>")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe("Text renderer", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
renderer = new Renderer(textRenderOptions)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should render text", () => {
|
||||||
|
renderer.addText("Hello world")
|
||||||
|
expect(renderer.toString()).toBe("Hello world")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should render newlines", () => {
|
||||||
|
renderer.addNewlines(2)
|
||||||
|
expect(renderer.toString()).toBe("\n\n")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should render links", () => {
|
||||||
|
renderer.addLink("https://njump.me", "Example")
|
||||||
|
expect(renderer.toString()).toBe("https://njump.me")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should render entities", () => {
|
||||||
|
renderer.addEntityLink("1234567890abcdef")
|
||||||
|
expect(renderer.toString()).toBe("1234567890abcdef")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should escape HTML in text content", () => {
|
||||||
|
renderer.addText('<script>alert("xss")</script>')
|
||||||
|
expect(renderer.toString()).not.toContain("<script>")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import {describe, it, expect} from "vitest"
|
||||||
|
import {truncate, ParsedType, Parsed} from "../src"
|
||||||
|
|
||||||
|
describe("Content Truncation", () => {
|
||||||
|
it("should not truncate content shorter than minLength", () => {
|
||||||
|
const content: Parsed[] = [{type: ParsedType.Text, value: "Short text", raw: "Short text"}]
|
||||||
|
const result = truncate(content, {minLength: 20, maxLength: 30})
|
||||||
|
expect(result).toEqual(content)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not truncate the first item even if it's longer than maxLength", () => {
|
||||||
|
const content: Parsed[] = [
|
||||||
|
{type: ParsedType.Text, value: "a".repeat(600), raw: "a".repeat(600)},
|
||||||
|
]
|
||||||
|
const result = truncate(content, {minLength: 400, maxLength: 600})
|
||||||
|
expect(result[0].type).toEqual(ParsedType.Text)
|
||||||
|
expect(result[1].type).toEqual(ParsedType.Ellipsis)
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not truncate text content between minLength and maxLength", () => {
|
||||||
|
const content: Parsed[] = [
|
||||||
|
{type: ParsedType.Text, value: "a".repeat(600), raw: "a".repeat(600)},
|
||||||
|
{type: ParsedType.Newline, value: "\n", raw: "\n"},
|
||||||
|
]
|
||||||
|
const result = truncate(content, {minLength: 500, maxLength: 700})
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
expect(result[1].type).toEqual(ParsedType.Newline)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should account for mediaLength in link calculations", () => {
|
||||||
|
const content: Parsed[] = [
|
||||||
|
{type: ParsedType.Text, value: "a".repeat(300), raw: "a".repeat(300)},
|
||||||
|
{
|
||||||
|
type: ParsedType.Link,
|
||||||
|
value: {
|
||||||
|
url: new URL("https://example.com/image.jpg"),
|
||||||
|
meta: {},
|
||||||
|
isMedia: true,
|
||||||
|
},
|
||||||
|
raw: "https://example.com/image.jpg",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const result = truncate(content, {
|
||||||
|
minLength: 400,
|
||||||
|
maxLength: 500,
|
||||||
|
mediaLength: 250,
|
||||||
|
})
|
||||||
|
expect(result[result.length - 1].type).toBe(ParsedType.Ellipsis) // ellipsis
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2) // text + link = 300 + 250 = 550
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should account for entityLength in nostr entity calculations", () => {
|
||||||
|
const content: Parsed[] = [
|
||||||
|
{type: ParsedType.Text, value: "a".repeat(300), raw: "a".repeat(300)},
|
||||||
|
{
|
||||||
|
type: ParsedType.Profile,
|
||||||
|
value: {
|
||||||
|
pubkey: "1234567890",
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
raw: "nostr:npub1...",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const result = truncate(content, {
|
||||||
|
minLength: 300,
|
||||||
|
maxLength: 400,
|
||||||
|
entityLength: 110,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 300 + 110 = 410, which is over the maxLength
|
||||||
|
expect(result[result.length - 1].type).toBe(ParsedType.Ellipsis) // ellipsis
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2) // text + profile
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle mixed content types correctly", () => {
|
||||||
|
const content: Parsed[] = [
|
||||||
|
{type: ParsedType.Text, value: "a".repeat(200), raw: "a".repeat(200)},
|
||||||
|
{
|
||||||
|
type: ParsedType.Link,
|
||||||
|
value: {
|
||||||
|
url: new URL("https://example.com/image.jpg"),
|
||||||
|
meta: {},
|
||||||
|
isMedia: true,
|
||||||
|
},
|
||||||
|
raw: "https://example.com/image.jpg",
|
||||||
|
},
|
||||||
|
{type: ParsedType.Text, value: "b".repeat(200), raw: "b".repeat(200)},
|
||||||
|
{
|
||||||
|
type: ParsedType.Profile,
|
||||||
|
value: {
|
||||||
|
pubkey: "1234567890",
|
||||||
|
relays: [],
|
||||||
|
},
|
||||||
|
raw: "nostr:npub1...",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const result = truncate(content, {
|
||||||
|
minLength: 400,
|
||||||
|
maxLength: 500,
|
||||||
|
mediaLength: 200,
|
||||||
|
entityLength: 30,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result[result.length - 1].type).toBe(ParsedType.Ellipsis)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle code blocks correctly", () => {
|
||||||
|
const content: Parsed[] = [
|
||||||
|
{type: ParsedType.Text, value: "a".repeat(200), raw: "a".repeat(200)},
|
||||||
|
{type: ParsedType.Code, value: "b".repeat(300), raw: "```" + "b".repeat(300) + "```"},
|
||||||
|
]
|
||||||
|
const result = truncate(content, {
|
||||||
|
minLength: 400,
|
||||||
|
maxLength: 500,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result[result.length - 1].type).toBe(ParsedType.Ellipsis)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle invoice and cashu tokens", () => {
|
||||||
|
const content: Parsed[] = [
|
||||||
|
{type: ParsedType.Text, value: "a".repeat(200), raw: "a".repeat(200)},
|
||||||
|
{type: ParsedType.Invoice, value: "lnbc...", raw: "lnbc..."},
|
||||||
|
{type: ParsedType.Cashu, value: "cashu...", raw: "cashu..."},
|
||||||
|
]
|
||||||
|
const result = truncate(content, {
|
||||||
|
minLength: 300,
|
||||||
|
maxLength: 400,
|
||||||
|
mediaLength: 200,
|
||||||
|
})
|
||||||
|
expect(result[result.length - 1].type).toBe(ParsedType.Ellipsis)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle link grids", () => {
|
||||||
|
const content: Parsed[] = [
|
||||||
|
{type: ParsedType.Text, value: "a".repeat(200), raw: "a".repeat(200)},
|
||||||
|
{
|
||||||
|
type: ParsedType.LinkGrid,
|
||||||
|
value: {
|
||||||
|
links: [
|
||||||
|
{url: new URL("https://example.com/1.jpg"), meta: {}, isMedia: true},
|
||||||
|
{url: new URL("https://example.com/2.jpg"), meta: {}, isMedia: true},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
raw: "",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const result = truncate(content, {
|
||||||
|
minLength: 300,
|
||||||
|
maxLength: 400,
|
||||||
|
mediaLength: 200,
|
||||||
|
})
|
||||||
|
expect(result[result.length - 1].type).toBe(ParsedType.Ellipsis)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1 +1,2 @@
|
|||||||
build
|
build
|
||||||
|
dist
|
||||||
@@ -1 +1,2 @@
|
|||||||
build
|
build
|
||||||
|
__tests__
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
import {defaultTagFeedMappings} from "@welshman/feeds"
|
||||||
|
import {now} from "@welshman/lib"
|
||||||
|
import {getAddress, type TrustedEvent} from "@welshman/util"
|
||||||
|
import {beforeEach, describe, expect, it, vi} from "vitest"
|
||||||
|
import {FeedCompiler} from "../src/compiler"
|
||||||
|
import {Feed, FeedType, Scope} from "../src/core"
|
||||||
|
|
||||||
|
describe("FeedCompiler", () => {
|
||||||
|
let compiler: FeedCompiler
|
||||||
|
let mockOptions: any
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockOptions = {
|
||||||
|
getPubkeysForScope: vi.fn().mockReturnValue(["pubkey1", "pubkey2"]),
|
||||||
|
getPubkeysForWOTRange: vi.fn().mockReturnValue(["pubkey3", "pubkey4"]),
|
||||||
|
requestDVM: vi.fn(),
|
||||||
|
request: vi.fn(),
|
||||||
|
}
|
||||||
|
compiler = new FeedCompiler(mockOptions)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("canCompile", () => {
|
||||||
|
it("should return true for supported feed types", () => {
|
||||||
|
const supportedFeeds: Feed[] = [
|
||||||
|
[FeedType.Address, "addr1", "addr2"],
|
||||||
|
[FeedType.Author, "author1", "author2"],
|
||||||
|
[FeedType.CreatedAt, {since: 1000}],
|
||||||
|
[FeedType.DVM, {kind: 1, mappings: []}],
|
||||||
|
[FeedType.ID, "id1", "id2"],
|
||||||
|
[FeedType.Global],
|
||||||
|
[FeedType.Kind, 1, 2],
|
||||||
|
[FeedType.List, {addresses: [], mappings: []}],
|
||||||
|
[FeedType.Label, {mappings: []}],
|
||||||
|
[FeedType.Relay, "relay1", "relay2"],
|
||||||
|
[FeedType.Scope, Scope.Followers, Scope.Follows],
|
||||||
|
[FeedType.Search, "query1", "query2"],
|
||||||
|
[FeedType.Tag, "key", "value"],
|
||||||
|
[FeedType.WOT, {min: 0, max: 1}],
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const feed of supportedFeeds) {
|
||||||
|
expect(compiler.canCompile(feed)).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return true for nested union and intersection feeds", () => {
|
||||||
|
const feed: Feed = [FeedType.Union, [FeedType.Author, "author1"], [FeedType.Kind, 1]]
|
||||||
|
expect(compiler.canCompile(feed)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return false for unsupported feed type", () => {
|
||||||
|
const feed: any = ["UnsupportedType", "value"]
|
||||||
|
expect(compiler.canCompile(feed)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("compile", () => {
|
||||||
|
it("should compile ID feed", async () => {
|
||||||
|
const result = await compiler.compile([FeedType.ID, "id1", "id2"])
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
filters: [{ids: ["id1", "id2"]}],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should compile Kind feed", async () => {
|
||||||
|
const result = await compiler.compile([FeedType.Kind, 1, 2])
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
filters: [{kinds: [1, 2]}],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should compile Author feed", async () => {
|
||||||
|
const result = await compiler.compile([FeedType.Author, "author1", "author2"])
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
filters: [{authors: ["author1", "author2"]}],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should compile Scope feed", async () => {
|
||||||
|
const result = await compiler.compile([FeedType.Scope, Scope.Followers, Scope.Follows])
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
filters: [{authors: ["pubkey1", "pubkey2"]}],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
// there is an issue with vitest, these conditions are true
|
||||||
|
// expect(mockOptions.getPubkeysForScope).toHaveBeenCalledWith(Scope.Followers)
|
||||||
|
// expect(mockOptions.getPubkeysForScope).toHaveBeenCalledWith(Scope.Follows)
|
||||||
|
expect(mockOptions.getPubkeysForScope).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should compile WOT feed", async () => {
|
||||||
|
const result = await compiler.compile([FeedType.WOT, {min: 0, max: 1}])
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
filters: [{authors: ["pubkey3", "pubkey4"]}],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(mockOptions.getPubkeysForWOTRange).toHaveBeenCalledWith(0, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should compile CreatedAt feed", async () => {
|
||||||
|
const created_at = now()
|
||||||
|
const result = await compiler.compile([
|
||||||
|
FeedType.CreatedAt,
|
||||||
|
{since: 1000, until: 2000},
|
||||||
|
{since: 3000, relative: ["since"]},
|
||||||
|
])
|
||||||
|
expect(result[0].filters?.length).toBe(2)
|
||||||
|
expect(result[0].filters?.[0]).toMatchObject({since: 1000, until: 2000})
|
||||||
|
expect(result[0].filters?.[1].since).toBe(created_at - 3000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should compile Search feed", async () => {
|
||||||
|
const result = await compiler.compile([FeedType.Search, "query1", "query2"])
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
filters: [{search: "query1"}, {search: "query2"}],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should compile Relay feed", async () => {
|
||||||
|
const result = await compiler.compile([FeedType.Relay, "relay1", "relay2"])
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
relays: ["relay1", "relay2"],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should compile Global feed", async () => {
|
||||||
|
const result = await compiler.compile([FeedType.Global])
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
filters: [{}],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should compile Tag feed", async () => {
|
||||||
|
const result = await compiler.compile([FeedType.Tag, "key", "value1", "value2"])
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
filters: [{key: ["value1", "value2"]}],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("compile complex feeds", () => {
|
||||||
|
it("should compile Union feed", async () => {
|
||||||
|
const requestItem = await compiler.compile([
|
||||||
|
FeedType.Union,
|
||||||
|
[FeedType.Author, "author1"],
|
||||||
|
[FeedType.Kind, 1],
|
||||||
|
])
|
||||||
|
// one request item with two filters
|
||||||
|
expect(requestItem).toHaveLength(1)
|
||||||
|
expect(requestItem[0].filters).toHaveLength(2)
|
||||||
|
|
||||||
|
const requestItem2 = await compiler.compile([
|
||||||
|
FeedType.Union,
|
||||||
|
[FeedType.Author, "author1"],
|
||||||
|
[FeedType.Relay, "relay1", "relay2"],
|
||||||
|
[FeedType.Kind, 1],
|
||||||
|
])
|
||||||
|
// two request items
|
||||||
|
expect(requestItem2).toHaveLength(2)
|
||||||
|
// the first with 2 filters and no relay
|
||||||
|
expect(requestItem2[0].filters).toHaveLength(2)
|
||||||
|
expect(requestItem2[0].relays).toBeUndefined()
|
||||||
|
// the second with 0 filter and 2 relays
|
||||||
|
expect(requestItem2[1].filters).toBeUndefined()
|
||||||
|
expect(requestItem2[1].relays).toHaveLength(2)
|
||||||
|
|
||||||
|
const requestItem3 = await compiler.compile([
|
||||||
|
FeedType.Union,
|
||||||
|
[FeedType.Author, "author1"],
|
||||||
|
[FeedType.Intersection, [FeedType.Kind, 1], [FeedType.Relay, "relay1"]],
|
||||||
|
])
|
||||||
|
|
||||||
|
// two request items
|
||||||
|
expect(requestItem3).toHaveLength(2)
|
||||||
|
// the first with 1 filter and one relay
|
||||||
|
expect(requestItem3[0].filters).toHaveLength(1)
|
||||||
|
expect(requestItem3[0].relays).toHaveLength(1)
|
||||||
|
// the second with 1 filter and no relay
|
||||||
|
expect(requestItem3[1].filters).toHaveLength(1)
|
||||||
|
expect(requestItem3[1].relays).toBeUndefined()
|
||||||
|
|
||||||
|
const requestItem4 = await compiler.compile([
|
||||||
|
FeedType.Union,
|
||||||
|
[FeedType.Author, "author1"],
|
||||||
|
[FeedType.Union, [FeedType.Kind, 1], [FeedType.Relay, "relay1"]],
|
||||||
|
])
|
||||||
|
// two request items
|
||||||
|
expect(requestItem4).toHaveLength(2)
|
||||||
|
// the first with 2 filters and no relay
|
||||||
|
expect(requestItem4[0].filters).toHaveLength(2)
|
||||||
|
expect(requestItem4[0].relays).toBeUndefined()
|
||||||
|
// the second with no filter and one relay
|
||||||
|
expect(requestItem4[1].filters).toBeUndefined()
|
||||||
|
expect(requestItem4[1].relays).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should compile Intersection feed", async () => {
|
||||||
|
const requestItems = await compiler.compile([
|
||||||
|
FeedType.Intersection,
|
||||||
|
[FeedType.Author, "author1"],
|
||||||
|
[FeedType.Kind, 1],
|
||||||
|
])
|
||||||
|
// one request item with one filter
|
||||||
|
expect(requestItems).toHaveLength(1)
|
||||||
|
expect(requestItems[0].filters).toHaveLength(1)
|
||||||
|
|
||||||
|
const requestItems2 = await compiler.compile([
|
||||||
|
FeedType.Intersection,
|
||||||
|
[FeedType.Author, "author1"],
|
||||||
|
[FeedType.Relay, "relay1", "relay2"],
|
||||||
|
[FeedType.Kind, 1],
|
||||||
|
])
|
||||||
|
|
||||||
|
// one request item with one filter and two relays
|
||||||
|
expect(requestItems2).toHaveLength(1)
|
||||||
|
expect(requestItems2[0].filters).toHaveLength(1)
|
||||||
|
expect(requestItems2[0].relays).toHaveLength(2)
|
||||||
|
|
||||||
|
const requestItems3 = await compiler.compile([
|
||||||
|
FeedType.Intersection,
|
||||||
|
[FeedType.Author, "author1"],
|
||||||
|
[FeedType.Intersection, [FeedType.Kind, 1], [FeedType.Relay, "relay1", "relay2"]],
|
||||||
|
])
|
||||||
|
|
||||||
|
// one request item with one filter and one relay
|
||||||
|
expect(requestItems3).toHaveLength(1)
|
||||||
|
expect(requestItems3[0].filters).toHaveLength(1)
|
||||||
|
expect(requestItems3[0].relays).toHaveLength(2)
|
||||||
|
|
||||||
|
const requestItems4 = await compiler.compile([
|
||||||
|
FeedType.Intersection,
|
||||||
|
[FeedType.Author, "author1"],
|
||||||
|
[FeedType.Union, [FeedType.Kind, 1], [FeedType.Relay, "relay1", "relay2"]],
|
||||||
|
])
|
||||||
|
|
||||||
|
// one request item with one filter and one relay
|
||||||
|
expect(requestItems4).toHaveLength(1)
|
||||||
|
expect(requestItems4[0].filters).toHaveLength(1)
|
||||||
|
expect(requestItems4[0].relays).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should compile DVM feed", async () => {
|
||||||
|
const mockEvent: TrustedEvent = {
|
||||||
|
id: "id1",
|
||||||
|
pubkey: "pubkey1",
|
||||||
|
created_at: 1000,
|
||||||
|
kind: 7000,
|
||||||
|
tags: [],
|
||||||
|
content: JSON.stringify([
|
||||||
|
["t", "test"],
|
||||||
|
["r", "relay1"],
|
||||||
|
]),
|
||||||
|
sig: "sig1",
|
||||||
|
}
|
||||||
|
|
||||||
|
mockOptions.requestDVM.mockImplementation(async ({onEvent}) => {
|
||||||
|
await onEvent(mockEvent)
|
||||||
|
})
|
||||||
|
|
||||||
|
const requestItems = await compiler.compile([
|
||||||
|
FeedType.DVM,
|
||||||
|
{
|
||||||
|
kind: 7000,
|
||||||
|
mappings: defaultTagFeedMappings,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(mockOptions.requestDVM).toHaveBeenCalled()
|
||||||
|
// 2 request items
|
||||||
|
expect(requestItems).toHaveLength(2)
|
||||||
|
// the first with 1 filter and no relay
|
||||||
|
expect(requestItems[0].filters).toHaveLength(1)
|
||||||
|
expect(requestItems[0].relays).toBeUndefined()
|
||||||
|
// the second with no filter and 1 relay
|
||||||
|
expect(requestItems[1].filters).toBeUndefined()
|
||||||
|
expect(requestItems[1].relays).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should compile List feed", async () => {
|
||||||
|
const mockEvent: TrustedEvent = {
|
||||||
|
id: "id1",
|
||||||
|
pubkey: "pubkey1",
|
||||||
|
created_at: 1000,
|
||||||
|
kind: 1,
|
||||||
|
tags: [
|
||||||
|
["d", "test"],
|
||||||
|
["t", "test"],
|
||||||
|
["r", "relay1"],
|
||||||
|
],
|
||||||
|
content: "",
|
||||||
|
sig: "sig1",
|
||||||
|
}
|
||||||
|
|
||||||
|
mockOptions.request.mockImplementation(({onEvent}) => {
|
||||||
|
onEvent(mockEvent)
|
||||||
|
})
|
||||||
|
|
||||||
|
const requestItems = await compiler.compile([
|
||||||
|
FeedType.List,
|
||||||
|
{
|
||||||
|
addresses: [getAddress(mockEvent)],
|
||||||
|
mappings: defaultTagFeedMappings,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(mockOptions.request).toHaveBeenCalled()
|
||||||
|
// 2 request items
|
||||||
|
expect(requestItems).toHaveLength(2)
|
||||||
|
// the first with 1 filter and no relay
|
||||||
|
expect(requestItems[0].filters).toHaveLength(1)
|
||||||
|
expect(requestItems[0].relays).toBeUndefined()
|
||||||
|
// the second with no filter and 1 relay
|
||||||
|
expect(requestItems[1].filters).toBeUndefined()
|
||||||
|
expect(requestItems[1].relays).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should compile Label feed", async () => {
|
||||||
|
const labelEvent: TrustedEvent = {
|
||||||
|
id: "label1",
|
||||||
|
pubkey: "pubkey1",
|
||||||
|
created_at: 1000,
|
||||||
|
kind: 1985,
|
||||||
|
tags: [
|
||||||
|
["L", "spam"],
|
||||||
|
["e", "event1"],
|
||||||
|
["p", "author1"],
|
||||||
|
],
|
||||||
|
content: "This is spam",
|
||||||
|
sig: "sig1",
|
||||||
|
}
|
||||||
|
|
||||||
|
mockOptions.request.mockImplementation(({onEvent}) => {
|
||||||
|
onEvent(labelEvent)
|
||||||
|
})
|
||||||
|
|
||||||
|
const requestItems = await compiler.compile([
|
||||||
|
FeedType.Label,
|
||||||
|
{
|
||||||
|
"#L": ["spam"],
|
||||||
|
mappings: defaultTagFeedMappings,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
// should return an union filter with the "e" and "p" tags from the label event
|
||||||
|
expect(mockOptions.request).toHaveBeenCalled()
|
||||||
|
expect(requestItems).toHaveLength(1)
|
||||||
|
expect(requestItems[0].filters).toHaveLength(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("error handling", () => {
|
||||||
|
it("should throw error for unsupported feed type", async () => {
|
||||||
|
await expect(compiler.compile(["UnsupportedType", "value"] as any)).rejects.toThrow(
|
||||||
|
"Unable to convert feed of type UnsupportedType to filters",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle DVM events with invalid JSON content", async () => {
|
||||||
|
const mockEvent: TrustedEvent = {
|
||||||
|
id: "id1",
|
||||||
|
pubkey: "pubkey1",
|
||||||
|
created_at: 7000,
|
||||||
|
kind: 1,
|
||||||
|
tags: [],
|
||||||
|
content: "invalid json",
|
||||||
|
sig: "sig1",
|
||||||
|
}
|
||||||
|
|
||||||
|
mockOptions.requestDVM.mockImplementation(async ({onEvent}) => {
|
||||||
|
await onEvent(mockEvent)
|
||||||
|
})
|
||||||
|
|
||||||
|
const requestItems = await compiler.compile([
|
||||||
|
FeedType.DVM,
|
||||||
|
{kind: 7000, mappings: defaultTagFeedMappings},
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(requestItems).toBeDefined()
|
||||||
|
expect(requestItems).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import {describe, it, expect, vi, beforeEach} from "vitest"
|
||||||
|
import {FeedController} from "../src/controller"
|
||||||
|
import {Feed, FeedOptions, FeedType} from "../src/core"
|
||||||
|
import {EPOCH, type TrustedEvent} from "@welshman/util"
|
||||||
|
import {now} from "@welshman/lib"
|
||||||
|
|
||||||
|
describe("FeedController", () => {
|
||||||
|
let mockRequest: ReturnType<typeof vi.fn>
|
||||||
|
let mockOnEvent: ReturnType<typeof vi.fn>
|
||||||
|
let mockOnExhausted: ReturnType<typeof vi.fn>
|
||||||
|
let mockRequestDVM: ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRequest = vi.fn()
|
||||||
|
mockOnEvent = vi.fn()
|
||||||
|
mockOnExhausted = vi.fn()
|
||||||
|
mockRequestDVM = vi.fn()
|
||||||
|
})
|
||||||
|
|
||||||
|
const createEvent = (id: string, created_at: number): TrustedEvent => ({
|
||||||
|
id,
|
||||||
|
pubkey: "pub1",
|
||||||
|
created_at,
|
||||||
|
kind: 1,
|
||||||
|
tags: [],
|
||||||
|
content: "",
|
||||||
|
sig: "sig1",
|
||||||
|
})
|
||||||
|
|
||||||
|
const createFeedOptions = (feed: Feed, useWindowing = false): FeedOptions => ({
|
||||||
|
getPubkeysForScope: vi.fn().mockReturnValue(["pubkey1", "pubkey2"]),
|
||||||
|
getPubkeysForWOTRange: vi.fn().mockReturnValue(["pubkey3", "pubkey4"]),
|
||||||
|
feed,
|
||||||
|
request: mockRequest,
|
||||||
|
requestDVM: mockRequestDVM,
|
||||||
|
onEvent: mockOnEvent,
|
||||||
|
onExhausted: mockOnExhausted,
|
||||||
|
useWindowing,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Basic Loading", () => {
|
||||||
|
it("should load events from simple feed", async () => {
|
||||||
|
const controller = new FeedController(createFeedOptions([FeedType.Author, "pub1"]))
|
||||||
|
|
||||||
|
mockRequest.mockImplementation(({onEvent}) => {
|
||||||
|
onEvent(createEvent("1", 1000))
|
||||||
|
onEvent(createEvent("2", 900))
|
||||||
|
})
|
||||||
|
|
||||||
|
await controller.load(10)
|
||||||
|
|
||||||
|
expect(mockRequest).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
filters: expect.arrayContaining([expect.objectContaining({authors: ["pub1"]})]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(mockOnEvent).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle windowing", async () => {
|
||||||
|
const controller = new FeedController(createFeedOptions([FeedType.Author, "pub1"], true))
|
||||||
|
|
||||||
|
mockRequest.mockImplementation(({onEvent}) => {
|
||||||
|
onEvent(createEvent("1", 1000))
|
||||||
|
})
|
||||||
|
|
||||||
|
await controller.load(10)
|
||||||
|
await controller.load(10) // Should load next window
|
||||||
|
|
||||||
|
expect(mockRequest).toHaveBeenCalledTimes(2)
|
||||||
|
expect(mockRequest.mock.calls[1][0].filters[0].until).toBeLessThan(
|
||||||
|
mockRequest.mock.calls[0][0].filters[0].until,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Complex Feed Types", () => {
|
||||||
|
it("should handle Union feeds", async () => {
|
||||||
|
const controller = new FeedController(
|
||||||
|
createFeedOptions([FeedType.Union, [FeedType.Author, "pub1"], [FeedType.Author, "pub2"]]),
|
||||||
|
)
|
||||||
|
|
||||||
|
mockRequest.mockImplementation(({filters, onEvent}) => {
|
||||||
|
if (filters[0].authors?.includes("pub1")) {
|
||||||
|
onEvent(createEvent("1", 1000))
|
||||||
|
}
|
||||||
|
if (filters[0].authors?.includes("pub2")) {
|
||||||
|
onEvent(createEvent("2", 900))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await controller.load(10)
|
||||||
|
|
||||||
|
expect(mockOnEvent).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle Intersection feeds", async () => {
|
||||||
|
const controller = new FeedController(
|
||||||
|
createFeedOptions([FeedType.Intersection, [FeedType.Author, "pub1"], [FeedType.Kind, 1]]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const event = createEvent("1", 1000)
|
||||||
|
mockRequest.mockImplementation(({onEvent}) => {
|
||||||
|
onEvent(event)
|
||||||
|
})
|
||||||
|
|
||||||
|
await controller.load(10)
|
||||||
|
|
||||||
|
expect(mockOnEvent).toHaveBeenCalledWith(event)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle Difference feeds", async () => {
|
||||||
|
const controller = new FeedController(
|
||||||
|
createFeedOptions([
|
||||||
|
FeedType.Difference,
|
||||||
|
[FeedType.Author, "pub1"],
|
||||||
|
[FeedType.Author, "pub2"],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
mockRequest.mockImplementation(({filters, onEvent}) => {
|
||||||
|
if (filters[0].authors?.includes("pub1")) {
|
||||||
|
onEvent(createEvent("1", 1000))
|
||||||
|
onEvent(createEvent("2", 900))
|
||||||
|
}
|
||||||
|
if (filters[0].authors?.includes("pub2")) {
|
||||||
|
onEvent(createEvent("2", 900)) // This one should be excluded
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await controller.load(10)
|
||||||
|
|
||||||
|
expect(mockOnEvent).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockOnEvent).toHaveBeenCalledWith(expect.objectContaining({id: "1"}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Event Deduplication", () => {
|
||||||
|
it("should not emit duplicate events", async () => {
|
||||||
|
const controller = new FeedController(createFeedOptions([FeedType.Author, "pub1"]))
|
||||||
|
|
||||||
|
const event = createEvent("1", 1000)
|
||||||
|
mockRequest.mockImplementation(({onEvent}) => {
|
||||||
|
onEvent(event)
|
||||||
|
onEvent(event) // Duplicate
|
||||||
|
})
|
||||||
|
|
||||||
|
await controller.load(10)
|
||||||
|
|
||||||
|
expect(mockOnEvent).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Exhaustion Handling", () => {
|
||||||
|
it("should call onExhausted when no more events", async () => {
|
||||||
|
const controller = new FeedController(createFeedOptions([FeedType.Author, "pub1"]))
|
||||||
|
|
||||||
|
mockRequest.mockImplementation(({onEvent}) => {
|
||||||
|
// No events returned
|
||||||
|
})
|
||||||
|
|
||||||
|
await controller.load(10)
|
||||||
|
|
||||||
|
expect(mockOnExhausted).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle exhaustion in complex feeds", async () => {
|
||||||
|
const controller = new FeedController(
|
||||||
|
createFeedOptions([FeedType.Union, [FeedType.Author, "pub1"], [FeedType.Author, "pub2"]]),
|
||||||
|
)
|
||||||
|
|
||||||
|
mockRequest.mockImplementation(() => {
|
||||||
|
// No events returned
|
||||||
|
})
|
||||||
|
|
||||||
|
await controller.load(10)
|
||||||
|
|
||||||
|
expect(mockOnExhausted).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Filter Handling", () => {
|
||||||
|
it("should handle time-based filters", async () => {
|
||||||
|
const controller = new FeedController(createFeedOptions([FeedType.Author, "pub1"]))
|
||||||
|
|
||||||
|
await controller.load(10)
|
||||||
|
|
||||||
|
expect(mockRequest).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
filters: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
since: EPOCH,
|
||||||
|
until: now(),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should respect existing filter constraints", async () => {
|
||||||
|
const since = 1000
|
||||||
|
const until = 2000
|
||||||
|
const controller = new FeedController(
|
||||||
|
createFeedOptions([
|
||||||
|
FeedType.Intersection,
|
||||||
|
[FeedType.Author, "pub1"],
|
||||||
|
[FeedType.CreatedAt, {since, until}],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
mockRequest.mockImplementation(({filters, onEvent}) => {
|
||||||
|
expect(filters[0].since).toBeGreaterThanOrEqual(since)
|
||||||
|
expect(filters[0].until).toBeLessThanOrEqual(until)
|
||||||
|
onEvent(createEvent("1", 1500))
|
||||||
|
})
|
||||||
|
|
||||||
|
await controller.load(10)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Error Handling", () => {
|
||||||
|
it("should handle request errors gracefully", async () => {
|
||||||
|
const controller = new FeedController({
|
||||||
|
...createFeedOptions([FeedType.Author, "pub1"]),
|
||||||
|
request: () => {
|
||||||
|
throw new Error("Request failed")
|
||||||
|
},
|
||||||
|
onEvent: mockOnEvent,
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(controller.load(10)).rejects.toThrow("Request failed")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle invalid feed types", async () => {
|
||||||
|
const controller = new FeedController({
|
||||||
|
...createFeedOptions(["InvalidType", "value"] as any),
|
||||||
|
request: mockRequest,
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(controller.load(10)).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
build
|
build
|
||||||
normalize-url
|
normalize-url
|
||||||
|
__tests__
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import {describe, expect, it} from "vitest"
|
||||||
|
import {defer, makePromise} from "../src/Deferred"
|
||||||
|
|
||||||
|
describe("Deferred", () => {
|
||||||
|
const pubkey = "ee".repeat(32)
|
||||||
|
const eventId = "ff".repeat(32)
|
||||||
|
|
||||||
|
type SuccessResponse = {
|
||||||
|
eventId: string
|
||||||
|
pubkey: string
|
||||||
|
success: true
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrorResponse = {
|
||||||
|
code: number
|
||||||
|
message: string
|
||||||
|
success: false
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("makePromise", () => {
|
||||||
|
it("should create a promise that resolves", async () => {
|
||||||
|
const successData: SuccessResponse = {
|
||||||
|
eventId: eventId,
|
||||||
|
pubkey: pubkey,
|
||||||
|
success: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = makePromise<SuccessResponse, ErrorResponse>(resolve => {
|
||||||
|
resolve(successData)
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await promise
|
||||||
|
expect(result).toEqual(successData)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should create a promise that rejects", async () => {
|
||||||
|
const errorData: ErrorResponse = {
|
||||||
|
code: 404,
|
||||||
|
message: "Event not found",
|
||||||
|
success: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = makePromise<SuccessResponse, ErrorResponse>((_, reject) => {
|
||||||
|
reject(errorData)
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(promise).rejects.toEqual(errorData)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle async operations", async () => {
|
||||||
|
const successData: SuccessResponse = {
|
||||||
|
eventId: eventId,
|
||||||
|
pubkey: pubkey,
|
||||||
|
success: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = makePromise<SuccessResponse, ErrorResponse>(resolve => {
|
||||||
|
setTimeout(() => resolve(successData), 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await promise
|
||||||
|
expect(result).toEqual(successData)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should propagate errors in promise chain", async () => {
|
||||||
|
const errorData: ErrorResponse = {
|
||||||
|
code: 500,
|
||||||
|
message: "Internal error",
|
||||||
|
success: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = makePromise<SuccessResponse, ErrorResponse>((_, reject) => {
|
||||||
|
setTimeout(() => reject(errorData), 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(promise).rejects.toEqual(errorData)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("defer", () => {
|
||||||
|
it("should create a deferred promise that can be resolved", async () => {
|
||||||
|
const deferred = defer<SuccessResponse, ErrorResponse>()
|
||||||
|
|
||||||
|
const successData: SuccessResponse = {
|
||||||
|
eventId: eventId,
|
||||||
|
pubkey: pubkey,
|
||||||
|
success: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve in next tick to test async behavior
|
||||||
|
setTimeout(() => {
|
||||||
|
deferred.resolve(successData)
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
const result = await deferred
|
||||||
|
expect(result).toEqual(successData)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should create a deferred promise that can be rejected", async () => {
|
||||||
|
const deferred = defer<SuccessResponse, ErrorResponse>()
|
||||||
|
|
||||||
|
const errorData: ErrorResponse = {
|
||||||
|
code: 403,
|
||||||
|
message: "Unauthorized",
|
||||||
|
success: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
deferred.reject(errorData)
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
await expect(deferred).rejects.toEqual(errorData)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle immediate resolution", async () => {
|
||||||
|
const deferred = defer<SuccessResponse, ErrorResponse>()
|
||||||
|
|
||||||
|
const successData: SuccessResponse = {
|
||||||
|
eventId: eventId,
|
||||||
|
pubkey: pubkey,
|
||||||
|
success: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
deferred.resolve(successData)
|
||||||
|
const result = await deferred
|
||||||
|
expect(result).toEqual(successData)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle immediate rejection", async () => {
|
||||||
|
const deferred = defer<SuccessResponse, ErrorResponse>()
|
||||||
|
|
||||||
|
const errorData: ErrorResponse = {
|
||||||
|
code: 400,
|
||||||
|
message: "Bad request",
|
||||||
|
success: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
deferred.reject(errorData)
|
||||||
|
await expect(deferred).rejects.toEqual(errorData)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should work with promise chaining", async () => {
|
||||||
|
const deferred = defer<SuccessResponse, ErrorResponse>()
|
||||||
|
|
||||||
|
const successData: SuccessResponse = {
|
||||||
|
eventId: eventId,
|
||||||
|
pubkey: pubkey,
|
||||||
|
success: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a chain of promises
|
||||||
|
const chainedPromise = deferred
|
||||||
|
.then(result => ({
|
||||||
|
...result,
|
||||||
|
eventId: result.eventId.toUpperCase(),
|
||||||
|
}))
|
||||||
|
.catch(error => {
|
||||||
|
throw {...error, code: 599}
|
||||||
|
})
|
||||||
|
|
||||||
|
deferred.resolve(successData)
|
||||||
|
|
||||||
|
const result = await chainedPromise
|
||||||
|
expect(result.eventId).toBe(eventId.toUpperCase())
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle error propagation in chains", async () => {
|
||||||
|
const deferred = defer<SuccessResponse, ErrorResponse>()
|
||||||
|
|
||||||
|
const errorData: ErrorResponse = {
|
||||||
|
code: 401,
|
||||||
|
message: "Unauthorized",
|
||||||
|
success: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const chainedPromise = deferred
|
||||||
|
.then(result => result)
|
||||||
|
.catch(error => {
|
||||||
|
throw {...error, code: 599}
|
||||||
|
})
|
||||||
|
|
||||||
|
deferred.reject(errorData)
|
||||||
|
|
||||||
|
await expect(chainedPromise).rejects.toEqual({
|
||||||
|
...errorData,
|
||||||
|
code: 599,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should maintain type safety with default error type", async () => {
|
||||||
|
const deferred = defer<string>() // Using default error type
|
||||||
|
|
||||||
|
const successData = eventId
|
||||||
|
const errorData = "Error processing event"
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (Math.random() > 0.5) {
|
||||||
|
deferred.resolve(successData)
|
||||||
|
} else {
|
||||||
|
deferred.reject(errorData)
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await deferred
|
||||||
|
expect(result).toBe(successData)
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBe(errorData)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import {describe, it, expect, beforeEach, vi} from "vitest"
|
||||||
|
import {Emitter} from "../src/Emitter"
|
||||||
|
|
||||||
|
describe("Emitter", () => {
|
||||||
|
let emitter: Emitter
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
emitter = new Emitter()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should emit events to specific listeners", () => {
|
||||||
|
const listener = vi.fn()
|
||||||
|
emitter.on("test", listener)
|
||||||
|
|
||||||
|
const args = ["arg1", 2, {key: "value"}]
|
||||||
|
emitter.emit("test", ...args)
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledWith(...args)
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should emit events to wildcard listeners", () => {
|
||||||
|
const wildcardListener = vi.fn()
|
||||||
|
emitter.on("*", wildcardListener)
|
||||||
|
|
||||||
|
const args = ["arg1", 2, {key: "value"}]
|
||||||
|
emitter.emit("test", ...args)
|
||||||
|
|
||||||
|
expect(wildcardListener).toHaveBeenCalledWith("test", ...args)
|
||||||
|
expect(wildcardListener).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should emit to both specific and wildcard listeners", () => {
|
||||||
|
const specificListener = vi.fn()
|
||||||
|
const wildcardListener = vi.fn()
|
||||||
|
|
||||||
|
emitter.on("test", specificListener)
|
||||||
|
emitter.on("*", wildcardListener)
|
||||||
|
|
||||||
|
const args = ["arg1", 2, {key: "value"}]
|
||||||
|
emitter.emit("test", ...args)
|
||||||
|
|
||||||
|
expect(specificListener).toHaveBeenCalledWith(...args)
|
||||||
|
expect(wildcardListener).toHaveBeenCalledWith("test", ...args)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return true if both listeners exist", () => {
|
||||||
|
emitter.on("test", () => {})
|
||||||
|
emitter.on("*", () => {})
|
||||||
|
|
||||||
|
const result = emitter.emit("test", "arg")
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return false if no listeners exist", () => {
|
||||||
|
const result = emitter.emit("test", "arg")
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle multiple listeners for same event", () => {
|
||||||
|
const listener1 = vi.fn()
|
||||||
|
const listener2 = vi.fn()
|
||||||
|
|
||||||
|
emitter.on("test", listener1)
|
||||||
|
emitter.on("test", listener2)
|
||||||
|
|
||||||
|
emitter.emit("test", "arg")
|
||||||
|
|
||||||
|
expect(listener1).toHaveBeenCalledWith("arg")
|
||||||
|
expect(listener2).toHaveBeenCalledWith("arg")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle multiple wildcard listeners", () => {
|
||||||
|
const listener1 = vi.fn()
|
||||||
|
const listener2 = vi.fn()
|
||||||
|
|
||||||
|
emitter.on("*", listener1)
|
||||||
|
emitter.on("*", listener2)
|
||||||
|
|
||||||
|
emitter.emit("test", "arg")
|
||||||
|
|
||||||
|
expect(listener1).toHaveBeenCalledWith("test", "arg")
|
||||||
|
expect(listener2).toHaveBeenCalledWith("test", "arg")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle listener removal", () => {
|
||||||
|
const listener = vi.fn()
|
||||||
|
emitter.on("test", listener)
|
||||||
|
emitter.removeListener("test", listener)
|
||||||
|
|
||||||
|
emitter.emit("test", "arg")
|
||||||
|
|
||||||
|
expect(listener).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle wildcard listener removal", () => {
|
||||||
|
const listener = vi.fn()
|
||||||
|
emitter.on("*", listener)
|
||||||
|
emitter.removeListener("*", listener)
|
||||||
|
|
||||||
|
emitter.emit("test", "arg")
|
||||||
|
|
||||||
|
expect(listener).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle once listeners", () => {
|
||||||
|
const listener = vi.fn()
|
||||||
|
emitter.once("test", listener)
|
||||||
|
|
||||||
|
emitter.emit("test", "arg1")
|
||||||
|
emitter.emit("test", "arg2")
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1)
|
||||||
|
expect(listener).toHaveBeenCalledWith("arg1")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle once wildcard listeners", () => {
|
||||||
|
const listener = vi.fn()
|
||||||
|
emitter.once("*", listener)
|
||||||
|
|
||||||
|
emitter.emit("test1", "arg1")
|
||||||
|
emitter.emit("test2", "arg2")
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1)
|
||||||
|
expect(listener).toHaveBeenCalledWith("test1", "arg1")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle nostr event data", () => {
|
||||||
|
const listener = vi.fn()
|
||||||
|
const wildcardListener = vi.fn()
|
||||||
|
|
||||||
|
emitter.on("test", listener)
|
||||||
|
emitter.on("*", wildcardListener)
|
||||||
|
|
||||||
|
const complexData = {
|
||||||
|
id: "ff".repeat(32), // Realistic event ID
|
||||||
|
pubkey: "ee".repeat(32), // Realistic pubkey
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: 1,
|
||||||
|
tags: [["p", "ee".repeat(32)]],
|
||||||
|
content: "Hello Nostr!",
|
||||||
|
}
|
||||||
|
|
||||||
|
emitter.emit("test", complexData)
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledWith(complexData)
|
||||||
|
expect(wildcardListener).toHaveBeenCalledWith("test", complexData)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should maintain correct event order", () => {
|
||||||
|
const events: string[] = []
|
||||||
|
|
||||||
|
emitter.on("test", () => events.push("specific"))
|
||||||
|
emitter.on("*", () => events.push("wildcard"))
|
||||||
|
|
||||||
|
emitter.emit("test")
|
||||||
|
|
||||||
|
expect(events).toEqual(["specific", "wildcard"])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import {describe, it, expect, beforeEach, vi} from "vitest"
|
||||||
|
import {LRUCache, cached, simpleCache} from "../src/LRUCache"
|
||||||
|
|
||||||
|
describe("Caches", () => {
|
||||||
|
describe("LRUCache", () => {
|
||||||
|
describe("basic operations", () => {
|
||||||
|
let cache: LRUCache<string, number>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cache = new LRUCache(3) // Max size of 3
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should set and get values", () => {
|
||||||
|
cache.set("a", 1)
|
||||||
|
expect(cache.get("a")).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should check if key exists", () => {
|
||||||
|
cache.set("a", 1)
|
||||||
|
expect(cache.has("a")).toBe(true)
|
||||||
|
expect(cache.has("b")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should evict least recently used items when exceeding maxSize", () => {
|
||||||
|
cache.set("a", 1)
|
||||||
|
cache.set("b", 2)
|
||||||
|
cache.set("c", 3)
|
||||||
|
cache.set("d", 4)
|
||||||
|
|
||||||
|
expect(cache.has("a")).toBe(false) // 'a' should be evicted
|
||||||
|
expect(cache.get("b")).toBe(2)
|
||||||
|
expect(cache.get("c")).toBe(3)
|
||||||
|
expect(cache.get("d")).toBe(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should update access order on get", () => {
|
||||||
|
cache.set("a", 1) // keys = [a]
|
||||||
|
cache.set("b", 2) // keys = [a, b]
|
||||||
|
cache.set("c", 3) // keys = [a, b, c]
|
||||||
|
|
||||||
|
cache.get("b") // keys = [a, b, c, b]
|
||||||
|
cache.get("b") // keys = [a, b, c, b, b]
|
||||||
|
cache.get("b") // keys = [a, b, c, b, b, b] size at limit (maxSize * 2 = 6)
|
||||||
|
cache.get("a") // keys = [b, b, a] keys is over limit, only the 3 last are kept
|
||||||
|
cache.set("d", 4) // keys = [b, b, a, d],
|
||||||
|
|
||||||
|
// @todo clarify with @staab the intended behavior
|
||||||
|
// "a" was recently accessed, it should not be evicted
|
||||||
|
expect(cache.has("a")).toBe(true) // 'a' should be present
|
||||||
|
expect(cache.has("b")).toBe(false) // 'b' should be evicted
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("cached function", () => {
|
||||||
|
it("should cache function results", () => {
|
||||||
|
const mockGetValue = vi.fn((args: [number]) => args[0] * 2)
|
||||||
|
|
||||||
|
const cachedFn = cached({
|
||||||
|
maxSize: 2,
|
||||||
|
getKey: (args: [number]) => args[0],
|
||||||
|
getValue: mockGetValue,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(cachedFn(1)).toBe(2)
|
||||||
|
expect(cachedFn(1)).toBe(2)
|
||||||
|
expect(mockGetValue).toHaveBeenCalledTimes(1) // Should only compute once
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should respect maxSize", () => {
|
||||||
|
const cachedFn = cached({
|
||||||
|
maxSize: 2,
|
||||||
|
getKey: (args: [number]) => args[0],
|
||||||
|
getValue: (args: [number]) => args[0] * 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
cachedFn(1)
|
||||||
|
cachedFn(2)
|
||||||
|
cachedFn(3)
|
||||||
|
|
||||||
|
expect(cachedFn.cache.has(1)).toBe(false) // Should be evicted
|
||||||
|
expect(cachedFn.cache.has(2)).toBe(true)
|
||||||
|
expect(cachedFn.cache.has(3)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("simpleCache", () => {
|
||||||
|
it("should cache function results with default settings", () => {
|
||||||
|
const mockFn = vi.fn((v: number[]) => v[0] + v[1])
|
||||||
|
const cachedFn = simpleCache(mockFn)
|
||||||
|
|
||||||
|
expect(cachedFn(1, 2)).toBe(3)
|
||||||
|
expect(cachedFn(1, 2)).toBe(3)
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should use string join as default key", () => {
|
||||||
|
const cachedFn = simpleCache((v: number[]) => v[0] + v[1])
|
||||||
|
|
||||||
|
cachedFn(1, 2)
|
||||||
|
expect(cachedFn.cache.has("1:2")).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
import {describe, it, expect, vi, beforeEach, afterEach} from "vitest"
|
||||||
|
import * as T from "../src/Tools"
|
||||||
|
|
||||||
|
describe("Tools", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.useFakeTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
describe("Basic Utils", () => {
|
||||||
|
it("should check for nil values", () => {
|
||||||
|
expect(T.isNil(null)).toBe(true)
|
||||||
|
expect(T.isNil(undefined)).toBe(true)
|
||||||
|
expect(T.isNil(0)).toBe(false)
|
||||||
|
expect(T.isNil("")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle ifLet", () => {
|
||||||
|
const fn = vi.fn()
|
||||||
|
T.ifLet(undefined, fn)
|
||||||
|
expect(fn).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
T.ifLet(5, fn)
|
||||||
|
expect(fn).toHaveBeenCalledWith(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle array operations", () => {
|
||||||
|
const arr = [1, 2, 3]
|
||||||
|
expect(T.first(arr)).toBe(1)
|
||||||
|
expect(T.last(arr)).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Math Operations", () => {
|
||||||
|
it("should handle basic math operations", () => {
|
||||||
|
expect(T.add(2, 3)).toBe(5)
|
||||||
|
expect(T.sub(5, 3)).toBe(2)
|
||||||
|
expect(T.mul(2, 3)).toBe(6)
|
||||||
|
expect(T.div(6, 2)).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle nil values in math operations", () => {
|
||||||
|
expect(T.add(undefined, 3)).toBe(3)
|
||||||
|
expect(T.sub(5, undefined)).toBe(5)
|
||||||
|
expect(T.mul(undefined, undefined)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle comparisons", () => {
|
||||||
|
expect(T.lt(2, 3)).toBe(true)
|
||||||
|
expect(T.gt(3, 2)).toBe(true)
|
||||||
|
expect(T.lte(2, 2)).toBe(true)
|
||||||
|
expect(T.gte(2, 2)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Array Operations", () => {
|
||||||
|
it("should handle array transformations", () => {
|
||||||
|
expect(T.take(2, [1, 2, 3, 4])).toEqual([1, 2])
|
||||||
|
expect(T.drop(2, [1, 2, 3, 4])).toEqual([3, 4])
|
||||||
|
expect(T.uniq([1, 1, 2, 2, 3])).toEqual([1, 2, 3])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle chunk operations", () => {
|
||||||
|
expect(T.chunk(2, [1, 2, 3, 4])).toEqual([
|
||||||
|
[1, 2],
|
||||||
|
[3, 4],
|
||||||
|
])
|
||||||
|
expect(T.chunks(2, [1, 2, 3, 4])).toEqual([
|
||||||
|
[1, 3],
|
||||||
|
[2, 4],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle array sorting", () => {
|
||||||
|
expect(T.sort([3, 1, 2])).toEqual([1, 2, 3])
|
||||||
|
expect(T.sortBy(x => x.value, [{value: 3}, {value: 1}, {value: 2}])).toEqual([
|
||||||
|
{value: 1},
|
||||||
|
{value: 2},
|
||||||
|
{value: 3},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Object Operations", () => {
|
||||||
|
it("should handle object transformations", () => {
|
||||||
|
const obj = {a: 1, b: 2, c: 3}
|
||||||
|
expect(T.omit(["a"], obj)).toEqual({b: 2, c: 3})
|
||||||
|
expect(T.pick(["a"], obj)).toEqual({a: 1})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle deep merging", () => {
|
||||||
|
const a = {x: {y: 1}}
|
||||||
|
const b = {x: {z: 2}}
|
||||||
|
expect(T.deepMergeLeft(a, b)).toEqual({x: {y: 1, z: 2}})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Batch Operations", () => {
|
||||||
|
it("should handle memoization", () => {
|
||||||
|
const fn = vi.fn(x => x * 2)
|
||||||
|
const memoized = T.memoize(fn)
|
||||||
|
|
||||||
|
expect(memoized(2)).toBe(4)
|
||||||
|
expect(memoized(2)).toBe(4)
|
||||||
|
expect(fn).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle throttling", async () => {
|
||||||
|
const fn = vi.fn()
|
||||||
|
const throttled = T.throttle(100, fn)
|
||||||
|
|
||||||
|
throttled()
|
||||||
|
throttled()
|
||||||
|
throttled()
|
||||||
|
|
||||||
|
expect(fn).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(200)
|
||||||
|
|
||||||
|
expect(fn).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("batch", () => {
|
||||||
|
it("should collect items and process them in batches", async () => {
|
||||||
|
const processBatch = vi.fn()
|
||||||
|
const batchFn = T.batch(100, processBatch)
|
||||||
|
|
||||||
|
// Add items
|
||||||
|
batchFn("a")
|
||||||
|
batchFn("b")
|
||||||
|
batchFn("c")
|
||||||
|
|
||||||
|
// Initially the batch shouldn't be processed
|
||||||
|
expect(processBatch).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
// Advance timer to trigger batch processing
|
||||||
|
await vi.advanceTimersByTimeAsync(100)
|
||||||
|
|
||||||
|
expect(processBatch).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
|
expect(processBatch).toHaveBeenCalledWith(["a"])
|
||||||
|
expect(processBatch).toHaveBeenCalledWith(["b", "c"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle multiple batch windows", async () => {
|
||||||
|
const processBatch = vi.fn()
|
||||||
|
const batchFn = T.batch(100, processBatch)
|
||||||
|
|
||||||
|
// First batch
|
||||||
|
batchFn("a")
|
||||||
|
batchFn("b")
|
||||||
|
batchFn("c")
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(100)
|
||||||
|
|
||||||
|
// Second batch
|
||||||
|
batchFn("d")
|
||||||
|
batchFn("e")
|
||||||
|
batchFn("f")
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(100)
|
||||||
|
|
||||||
|
expect(processBatch).toHaveBeenCalledTimes(4)
|
||||||
|
expect(processBatch).toHaveBeenCalledWith(["a"])
|
||||||
|
expect(processBatch).toHaveBeenCalledWith(["b", "c"])
|
||||||
|
expect(processBatch).toHaveBeenCalledWith(["d"])
|
||||||
|
expect(processBatch).toHaveBeenCalledWith(["e", "f"])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("batcher", () => {
|
||||||
|
it("should batch requests and return results", async () => {
|
||||||
|
const executeFn = vi.fn(async (requests: number[]) => requests.map(x => x * 2))
|
||||||
|
|
||||||
|
const batchFn = T.batcher(100, executeFn)
|
||||||
|
|
||||||
|
// Create multiple concurrent requests
|
||||||
|
const promise1 = batchFn(1)
|
||||||
|
const promise2 = batchFn(2)
|
||||||
|
const promise3 = batchFn(3)
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(100)
|
||||||
|
|
||||||
|
const results = await Promise.all([promise1, promise2, promise3])
|
||||||
|
|
||||||
|
expect(executeFn).toHaveBeenCalledTimes(1)
|
||||||
|
expect(executeFn).toHaveBeenCalledWith([1, 2, 3])
|
||||||
|
expect(results).toEqual([2, 4, 6])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle multiple batch windows", async () => {
|
||||||
|
const executeFn = vi.fn(async (requests: number[]) => requests.map(x => x * 2))
|
||||||
|
|
||||||
|
const batchFn = T.batcher(100, executeFn)
|
||||||
|
|
||||||
|
// First batch
|
||||||
|
const batch1Promise = Promise.all([batchFn(1), batchFn(2)])
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(100)
|
||||||
|
const batch1Results = await batch1Promise
|
||||||
|
|
||||||
|
// Second batch
|
||||||
|
const batch2Promise = Promise.all([batchFn(3), batchFn(4)])
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(100)
|
||||||
|
const batch2Results = await batch2Promise
|
||||||
|
|
||||||
|
expect(executeFn).toHaveBeenCalledTimes(2)
|
||||||
|
expect(batch1Results).toEqual([2, 4])
|
||||||
|
expect(batch2Results).toEqual([6, 8])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should throw error if execute returns wrong number of results", async () => {
|
||||||
|
const executeFn = vi.fn(
|
||||||
|
async (requests: number[]) => [requests[0] * 2], // Return fewer results than requests
|
||||||
|
)
|
||||||
|
|
||||||
|
const batchFn = T.batcher(100, executeFn)
|
||||||
|
|
||||||
|
const batchPromise = Promise.all([batchFn(1), batchFn(2)])
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(200)
|
||||||
|
|
||||||
|
await expect(batchPromise).rejects.toThrow("Execute must return a result for each request")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("throttleWithValue", () => {
|
||||||
|
it("should return cached value between updates", async () => {
|
||||||
|
let counter = 0
|
||||||
|
const getValue = vi.fn(() => ++counter)
|
||||||
|
const throttledGet = T.throttleWithValue(100, getValue)
|
||||||
|
|
||||||
|
// First call should execute immediately
|
||||||
|
expect(throttledGet()).toBe(1)
|
||||||
|
expect(getValue).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
// Subsequent calls within throttle window should return cached value
|
||||||
|
expect(throttledGet()).toBe(1)
|
||||||
|
expect(throttledGet()).toBe(1)
|
||||||
|
expect(getValue).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
// After throttle window, should update value
|
||||||
|
await vi.advanceTimersByTimeAsync(100)
|
||||||
|
// the previous 2 called will have been batched, and the next throttledGet increase the counter to 3
|
||||||
|
expect(throttledGet()).toBe(3)
|
||||||
|
expect(getValue).toHaveBeenCalledTimes(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should update value at most once per throttle window", async () => {
|
||||||
|
let counter = 0
|
||||||
|
const getValue = vi.fn(() => ++counter)
|
||||||
|
const throttledGet = T.throttleWithValue(100, getValue)
|
||||||
|
|
||||||
|
// Initial value
|
||||||
|
expect(throttledGet()).toBe(1)
|
||||||
|
|
||||||
|
// Multiple calls within window
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
throttledGet()
|
||||||
|
await vi.advanceTimersByTimeAsync(20) // 20ms each, still within 100ms window
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(getValue).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
// After window
|
||||||
|
await vi.advanceTimersByTimeAsync(100)
|
||||||
|
// the previous called will have been batched, and the next throttledGet increase the counter to 3
|
||||||
|
expect(throttledGet()).toBe(3)
|
||||||
|
expect(getValue).toHaveBeenCalledTimes(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle zero throttle time", () => {
|
||||||
|
let counter = 0
|
||||||
|
const getValue = vi.fn(() => ++counter)
|
||||||
|
const throttledGet = T.throttleWithValue(0, getValue)
|
||||||
|
|
||||||
|
// Should update on every call
|
||||||
|
expect(throttledGet()).toBe(1)
|
||||||
|
expect(throttledGet()).toBe(2)
|
||||||
|
expect(throttledGet()).toBe(3)
|
||||||
|
expect(getValue).toHaveBeenCalledTimes(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Time Utilities", () => {
|
||||||
|
it("should handle time constants", () => {
|
||||||
|
expect(T.MINUTE).toBe(60)
|
||||||
|
expect(T.HOUR).toBe(60 * 60)
|
||||||
|
expect(T.DAY).toBe(24 * 60 * 60)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle time calculations", () => {
|
||||||
|
const timestamp = T.now()
|
||||||
|
expect(typeof timestamp).toBe("number")
|
||||||
|
expect(T.ms(1)).toBe(1000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("String Operations", () => {
|
||||||
|
it("should handle URL formatting", () => {
|
||||||
|
expect(T.stripProtocol("https://example.com")).toBe("example.com")
|
||||||
|
expect(T.displayUrl("https://www.example.com/")).toBe("example.com")
|
||||||
|
expect(T.displayDomain("example.com/path")).toBe("example.com")
|
||||||
|
|
||||||
|
// @todo returns https
|
||||||
|
// expect(T.displayDomain("https://example.com/path")).toBe("example.com")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle string truncation", () => {
|
||||||
|
expect(T.ellipsize("hello world", 5)).toBe("hello...")
|
||||||
|
expect(T.ellipsize("hi", 5)).toBe("hi")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Collection Operations", () => {
|
||||||
|
it("should handle group operations", () => {
|
||||||
|
const items = [
|
||||||
|
{type: "a", val: 1},
|
||||||
|
{type: "a", val: 2},
|
||||||
|
{type: "b", val: 3},
|
||||||
|
]
|
||||||
|
const grouped = T.groupBy(x => x.type, items)
|
||||||
|
expect(grouped.get("a")?.length).toBe(2)
|
||||||
|
expect(grouped.get("b")?.length).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle indexing", () => {
|
||||||
|
const items = [
|
||||||
|
{id: 1, val: "a"},
|
||||||
|
{id: 2, val: "b"},
|
||||||
|
]
|
||||||
|
const indexed = T.indexBy(x => x.id, items)
|
||||||
|
expect(indexed.get(1)?.val).toBe("a")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Type Checking", () => {
|
||||||
|
it("should identify plain objects", () => {
|
||||||
|
expect(T.isPojo({})).toBe(true)
|
||||||
|
expect(T.isPojo([])).toBe(false)
|
||||||
|
expect(T.isPojo(null)).toBe(false)
|
||||||
|
expect(T.isPojo(new Date())).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle deep equality", () => {
|
||||||
|
expect(T.equals({a: 1}, {a: 1})).toBe(true)
|
||||||
|
expect(T.equals({a: 1}, {a: 2})).toBe(false)
|
||||||
|
expect(T.equals([1, 2], [1, 2])).toBe(true)
|
||||||
|
expect(T.equals(new Set([1, 2]), new Set([1, 2]))).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import {describe, it, expect, vi, beforeEach, afterEach} from "vitest"
|
||||||
|
import {Worker} from "../src/Worker"
|
||||||
|
|
||||||
|
describe("Worker", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should process messages in batches", async () => {
|
||||||
|
const handler = vi.fn()
|
||||||
|
const worker = new Worker<number>()
|
||||||
|
|
||||||
|
worker.addGlobalHandler(handler)
|
||||||
|
|
||||||
|
// Push messages
|
||||||
|
worker.push(1)
|
||||||
|
worker.push(2)
|
||||||
|
worker.push(3)
|
||||||
|
|
||||||
|
// Initially no processing
|
||||||
|
expect(handler).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
// Advance timer to trigger processing
|
||||||
|
await vi.advanceTimersByTimeAsync(50)
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledTimes(3)
|
||||||
|
expect(handler).toHaveBeenNthCalledWith(1, 1)
|
||||||
|
expect(handler).toHaveBeenNthCalledWith(2, 2)
|
||||||
|
expect(handler).toHaveBeenNthCalledWith(3, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should respect chunkSize option", async () => {
|
||||||
|
const handler = vi.fn()
|
||||||
|
const worker = new Worker<number>({chunkSize: 2})
|
||||||
|
|
||||||
|
worker.addGlobalHandler(handler)
|
||||||
|
|
||||||
|
// Push more messages than chunkSize
|
||||||
|
worker.push(1)
|
||||||
|
worker.push(2)
|
||||||
|
worker.push(3)
|
||||||
|
|
||||||
|
// First batch
|
||||||
|
await vi.advanceTimersByTimeAsync(50)
|
||||||
|
expect(handler).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
|
// Second batch
|
||||||
|
await vi.advanceTimersByTimeAsync(50)
|
||||||
|
expect(handler).toHaveBeenCalledTimes(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle message routing by key", async () => {
|
||||||
|
const globalHandler = vi.fn()
|
||||||
|
const evenHandler = vi.fn()
|
||||||
|
const oddHandler = vi.fn()
|
||||||
|
|
||||||
|
const worker = new Worker<number>({
|
||||||
|
getKey: x => (x % 2 === 0 ? "even" : "odd"),
|
||||||
|
})
|
||||||
|
|
||||||
|
worker.addGlobalHandler(globalHandler)
|
||||||
|
worker.addHandler("even", evenHandler)
|
||||||
|
worker.addHandler("odd", oddHandler)
|
||||||
|
|
||||||
|
worker.push(1)
|
||||||
|
worker.push(2)
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(50)
|
||||||
|
|
||||||
|
expect(globalHandler).toHaveBeenCalledTimes(2)
|
||||||
|
expect(evenHandler).toHaveBeenCalledWith(2)
|
||||||
|
expect(oddHandler).toHaveBeenCalledWith(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle message deferral", async () => {
|
||||||
|
const handler = vi.fn()
|
||||||
|
let shouldDefer = true
|
||||||
|
|
||||||
|
const worker = new Worker<number>({
|
||||||
|
shouldDefer: () => shouldDefer,
|
||||||
|
})
|
||||||
|
|
||||||
|
worker.addGlobalHandler(handler)
|
||||||
|
worker.push(1)
|
||||||
|
|
||||||
|
// Message should be deferred
|
||||||
|
await vi.advanceTimersByTimeAsync(50)
|
||||||
|
expect(handler).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
// Allow processing
|
||||||
|
shouldDefer = false
|
||||||
|
await vi.advanceTimersByTimeAsync(50)
|
||||||
|
expect(handler).toHaveBeenCalledWith(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle multiple handlers for same key", async () => {
|
||||||
|
const handler1 = vi.fn()
|
||||||
|
const handler2 = vi.fn()
|
||||||
|
|
||||||
|
const worker = new Worker<number>({
|
||||||
|
getKey: () => "test",
|
||||||
|
})
|
||||||
|
|
||||||
|
worker.addHandler("test", handler1)
|
||||||
|
worker.addHandler("test", handler2)
|
||||||
|
|
||||||
|
worker.push(1)
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(50)
|
||||||
|
|
||||||
|
expect(handler1).toHaveBeenCalledWith(1)
|
||||||
|
expect(handler2).toHaveBeenCalledWith(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle errors in handlers gracefully", async () => {
|
||||||
|
const consoleError = vi.spyOn(console, "error")
|
||||||
|
const errorHandler = vi.fn(() => {
|
||||||
|
throw new Error("Test error")
|
||||||
|
})
|
||||||
|
const nextHandler = vi.fn()
|
||||||
|
|
||||||
|
const worker = new Worker<number>()
|
||||||
|
worker.addGlobalHandler(errorHandler)
|
||||||
|
worker.addGlobalHandler(nextHandler)
|
||||||
|
|
||||||
|
worker.push(1)
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(50)
|
||||||
|
|
||||||
|
expect(consoleError).toHaveBeenCalled()
|
||||||
|
expect(nextHandler).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("control methods", () => {
|
||||||
|
it("should clear the buffer", async () => {
|
||||||
|
const handler = vi.fn()
|
||||||
|
const worker = new Worker<number>()
|
||||||
|
|
||||||
|
worker.addGlobalHandler(handler)
|
||||||
|
worker.push(1)
|
||||||
|
worker.push(2)
|
||||||
|
|
||||||
|
worker.clear()
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(50)
|
||||||
|
expect(handler).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should pause and resume processing", async () => {
|
||||||
|
const handler = vi.fn()
|
||||||
|
const worker = new Worker<number>()
|
||||||
|
|
||||||
|
worker.addGlobalHandler(handler)
|
||||||
|
worker.push(1)
|
||||||
|
|
||||||
|
worker.pause()
|
||||||
|
await vi.advanceTimersByTimeAsync(50)
|
||||||
|
expect(handler).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
worker.resume()
|
||||||
|
await vi.advanceTimersByTimeAsync(50)
|
||||||
|
expect(handler).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should respect custom delay option", async () => {
|
||||||
|
const handler = vi.fn()
|
||||||
|
const worker = new Worker<number>({delay: 100})
|
||||||
|
|
||||||
|
worker.addGlobalHandler(handler)
|
||||||
|
worker.push(1)
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(50)
|
||||||
|
expect(handler).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(50) // Total 100ms
|
||||||
|
expect(handler).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("async handlers", () => {
|
||||||
|
it("should wait for async handlers to complete", async () => {
|
||||||
|
const results: number[] = []
|
||||||
|
const asyncHandler = vi.fn(async (x: number) => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100))
|
||||||
|
results.push(x)
|
||||||
|
})
|
||||||
|
|
||||||
|
const worker = new Worker<number>()
|
||||||
|
worker.addGlobalHandler(asyncHandler)
|
||||||
|
|
||||||
|
worker.push(1)
|
||||||
|
worker.push(2)
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(50) // Trigger processing
|
||||||
|
await vi.advanceTimersByTimeAsync(100) // Wait for one async handlers
|
||||||
|
|
||||||
|
expect(results).toEqual([1])
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(100) // Wait for another async handlers
|
||||||
|
|
||||||
|
expect(results).toEqual([1, 2])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -20,7 +20,7 @@ export class LRUCache<T, U> {
|
|||||||
this.keys.push(k as T)
|
this.keys.push(k as T)
|
||||||
|
|
||||||
if (this.keys.length > this.maxSize * 2) {
|
if (this.keys.length > this.maxSize * 2) {
|
||||||
this.keys.splice(-this.maxSize)
|
this.keys = this.keys.splice(-this.maxSize)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -874,6 +874,7 @@ export const chunk = <T>(chunkLength: number, xs: T[]) => {
|
|||||||
current.push(item)
|
current.push(item)
|
||||||
} else {
|
} else {
|
||||||
result.push(current.splice(0))
|
result.push(current.splice(0))
|
||||||
|
current.push(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -991,6 +992,8 @@ export const throttleWithValue = <T>(ms: number, f: () => T) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates batching function that collects items
|
* Creates batching function that collects items
|
||||||
|
* this function does not delay execution, if a series of items is passed in sequence
|
||||||
|
* the first item will be processed immediately, and the rest will be batched
|
||||||
* @param t - Time window for batching
|
* @param t - Time window for batching
|
||||||
* @param f - Function to process batch
|
* @param f - Function to process batch
|
||||||
* @returns Function that adds items to batch
|
* @returns Function that adds items to batch
|
||||||
@@ -1012,26 +1015,28 @@ export const batch = <T>(t: number, f: (xs: T[]) => void) => {
|
|||||||
* @returns Function that returns promise of result
|
* @returns Function that returns promise of result
|
||||||
*/
|
*/
|
||||||
export const batcher = <T, U>(t: number, execute: (request: T[]) => U[] | Promise<U[]>) => {
|
export const batcher = <T, U>(t: number, execute: (request: T[]) => U[] | Promise<U[]>) => {
|
||||||
const queue: {request: T; resolve: (x: U) => void}[] = []
|
const queue: {request: T; resolve: (x: U) => void; reject: (reason?: string) => void}[] = []
|
||||||
|
|
||||||
const _execute = async () => {
|
const _execute = async () => {
|
||||||
const items = queue.splice(0)
|
const items = queue.splice(0)
|
||||||
const results = await execute(items.map(item => item.request))
|
const results = await execute(items.map(item => item.request))
|
||||||
|
|
||||||
if (results.length !== items.length) {
|
if (results.length !== items.length) {
|
||||||
throw new Error("Execute must return a result for each request")
|
results.forEach(async (r, i) =>
|
||||||
|
items[i].reject("Execute must return a result for each request"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
results.forEach(async (r, i) => items[i].resolve(await r))
|
results.forEach(async (r, i) => items[i].resolve(await r))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (request: T): Promise<U> =>
|
return (request: T): Promise<U> =>
|
||||||
new Promise(resolve => {
|
new Promise((resolve, reject) => {
|
||||||
if (queue.length === 0) {
|
if (queue.length === 0) {
|
||||||
setTimeout(_execute, t)
|
setTimeout(_execute, t)
|
||||||
}
|
}
|
||||||
|
|
||||||
queue.push({request, resolve})
|
queue.push({request, resolve, reject})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
build
|
build
|
||||||
normalize-url
|
normalize-url
|
||||||
Negentropy.ts
|
Negentropy.ts
|
||||||
|
__tests__
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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"],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -66,11 +66,10 @@ export class ConnectionAuth {
|
|||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
|
|
||||||
while (Date.now() - timeout <= start) {
|
while (Date.now() - timeout <= start) {
|
||||||
await sleep(100)
|
|
||||||
|
|
||||||
if (condition()) {
|
if (condition()) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
await sleep(Math.min(100, Math.ceil(timeout / 3)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,12 +109,12 @@ export class ConnectionAuth {
|
|||||||
|
|
||||||
attempt = async (timeout = 300) => {
|
attempt = async (timeout = 300) => {
|
||||||
await this.cxn.socket.open()
|
await this.cxn.socket.open()
|
||||||
await this.waitForChallenge(timeout)
|
await this.waitForChallenge(Math.ceil(timeout / 2))
|
||||||
|
|
||||||
if (this.status === Requested) {
|
if (this.status === Requested) {
|
||||||
await this.respond()
|
await this.respond()
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.waitForResolution(timeout)
|
await this.waitForResolution(Math.ceil(timeout / 2))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export const isEventValid = (url: string, event: TrustedEvent) => {
|
|||||||
const validCount = eventValidationScores.get(url) || 0
|
const validCount = eventValidationScores.get(url) || 0
|
||||||
|
|
||||||
// The more events we've actually validated from this relay, the more we can trust it.
|
// The more events we've actually validated from this relay, the more we can trust it.
|
||||||
if (validCount > randomInt(100, 1000)) true
|
if (validCount > randomInt(100, 1000)) return true
|
||||||
|
|
||||||
const isValid = isSignedEvent(event) && hasValidSignature(event)
|
const isValid = isSignedEvent(event) && hasValidSignature(event)
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,12 @@ export class Socket {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
wait = async () => {
|
wait = async (timeout = 300) => {
|
||||||
while ([SocketStatus.Opening, SocketStatus.Closing].includes(this.status)) {
|
const start = Date.now()
|
||||||
|
while (
|
||||||
|
Date.now() - timeout <= start &&
|
||||||
|
[SocketStatus.Opening, SocketStatus.Closing].includes(this.status)
|
||||||
|
) {
|
||||||
await sleep(100)
|
await sleep(100)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export class Tracker extends Emitter {
|
|||||||
ids.add(eventId)
|
ids.add(eventId)
|
||||||
|
|
||||||
this.relaysById.set(eventId, relays)
|
this.relaysById.set(eventId, relays)
|
||||||
this.idsByRelay.set(eventId, relays)
|
this.idsByRelay.set(relay, ids)
|
||||||
|
|
||||||
this.emit("update")
|
this.emit("update")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
build
|
build
|
||||||
normalize-url
|
normalize-url
|
||||||
|
__tests__
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import type {Repository, TrustedEvent} from "@welshman/util"
|
||||||
|
import {get} from "svelte/store"
|
||||||
|
import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"
|
||||||
|
import {
|
||||||
|
adapter,
|
||||||
|
custom,
|
||||||
|
deriveEvents,
|
||||||
|
deriveEventsMapped,
|
||||||
|
deriveIsDeleted,
|
||||||
|
getter,
|
||||||
|
synced,
|
||||||
|
throttled,
|
||||||
|
withGetter,
|
||||||
|
} from "../src/index"
|
||||||
|
|
||||||
|
// Mock localStorage
|
||||||
|
const localStorageMock = (() => {
|
||||||
|
let store: Record<string, string> = {}
|
||||||
|
return {
|
||||||
|
getItem: (key: string) => store[key] || null,
|
||||||
|
setItem: (key: string, value: string) => {
|
||||||
|
store[key] = value
|
||||||
|
},
|
||||||
|
clear: () => {
|
||||||
|
store = {}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
vi.stubGlobal("localStorage", localStorageMock)
|
||||||
|
|
||||||
|
describe("Store utilities", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
vi.useFakeTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("synced", () => {
|
||||||
|
it("should sync with localStorage", () => {
|
||||||
|
const store = synced("testKey", "default")
|
||||||
|
expect(get(store)).toBe("default")
|
||||||
|
|
||||||
|
store.set("new value")
|
||||||
|
expect(localStorage.getItem("testKey")).toBe(JSON.stringify("new value"))
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should load existing value from localStorage", () => {
|
||||||
|
localStorage.setItem("testKey", JSON.stringify("existing"))
|
||||||
|
const store = synced("testKey", "default")
|
||||||
|
expect(get(store)).toBe("existing")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getter", () => {
|
||||||
|
it("should return current store value", () => {
|
||||||
|
const store = synced("test", "initial")
|
||||||
|
const getValue = getter(store)
|
||||||
|
|
||||||
|
expect(getValue()).toBe("initial")
|
||||||
|
store.set("updated")
|
||||||
|
expect(getValue()).toBe("updated")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("withGetter", () => {
|
||||||
|
it("should add getter to writable store", () => {
|
||||||
|
const store = withGetter(synced("test", "initial"))
|
||||||
|
|
||||||
|
expect(store.get()).toBe("initial")
|
||||||
|
store.set("updated")
|
||||||
|
expect(store.get()).toBe("updated")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("throttled", () => {
|
||||||
|
it("should throttle updates", () => {
|
||||||
|
const mockFn = vi.fn()
|
||||||
|
const store = synced("test", 0)
|
||||||
|
const throttledStore = throttled(100, store)
|
||||||
|
|
||||||
|
throttledStore.subscribe(mockFn)
|
||||||
|
|
||||||
|
store.set(1)
|
||||||
|
store.set(2)
|
||||||
|
store.set(3)
|
||||||
|
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(1) // Initial call
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(100)
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(2)
|
||||||
|
expect(mockFn).toHaveBeenLastCalledWith(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("custom", () => {
|
||||||
|
it("should handle updates correctly", () => {
|
||||||
|
const mockFn = vi.fn()
|
||||||
|
const store = custom<number>(set => {
|
||||||
|
set(0)
|
||||||
|
return () => {}
|
||||||
|
})
|
||||||
|
|
||||||
|
store.subscribe(mockFn)
|
||||||
|
store.set(1)
|
||||||
|
store.update(n => n + 1)
|
||||||
|
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(3) // Initial + set + update
|
||||||
|
expect(store.get()).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("adapter", () => {
|
||||||
|
it("should adapt between different types", () => {
|
||||||
|
const source = synced<number>("test", 0)
|
||||||
|
const adapted = adapter({
|
||||||
|
store: source,
|
||||||
|
forward: n => n.toString(),
|
||||||
|
backward: s => parseInt(s, 10),
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockFn = vi.fn()
|
||||||
|
adapted.subscribe(mockFn)
|
||||||
|
|
||||||
|
adapted.set("42")
|
||||||
|
expect(get(source)).toBe(42)
|
||||||
|
expect(mockFn).toHaveBeenLastCalledWith("42")
|
||||||
|
|
||||||
|
adapted.update(s => (parseInt(s, 10) + 1).toString())
|
||||||
|
expect(get(source)).toBe(43)
|
||||||
|
expect(mockFn).toHaveBeenLastCalledWith("43")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Event-related stores", () => {
|
||||||
|
const mockRepository = {
|
||||||
|
query: vi.fn(),
|
||||||
|
isDeleted: vi.fn(),
|
||||||
|
isDeletedByAddress: vi.fn(),
|
||||||
|
on: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
|
} satisfies Partial<Repository>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("deriveEvents", () => {
|
||||||
|
it("should derive events from repository", () => {
|
||||||
|
const mockEvent = {id: "1", content: "test"} as TrustedEvent
|
||||||
|
mockRepository.query.mockReturnValue([mockEvent])
|
||||||
|
|
||||||
|
const store = deriveEvents(mockRepository as any, {
|
||||||
|
filters: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockFn = vi.fn()
|
||||||
|
store.subscribe(mockFn)
|
||||||
|
|
||||||
|
expect(mockRepository.query).toHaveBeenCalled()
|
||||||
|
expect(mockFn).toHaveBeenCalledWith([mockEvent])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("deriveEventsMapped", () => {
|
||||||
|
it("should map events to items", async () => {
|
||||||
|
const mockEvent = {id: "1", content: "test"} as TrustedEvent
|
||||||
|
mockRepository.query.mockReturnValue([mockEvent])
|
||||||
|
|
||||||
|
const store = deriveEventsMapped(mockRepository as any, {
|
||||||
|
filters: [],
|
||||||
|
eventToItem: event => ({id: event.id, mapped: true}),
|
||||||
|
itemToEvent: item => ({id: item.id, content: ""}) as TrustedEvent,
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockFn = vi.fn()
|
||||||
|
store.subscribe(mockFn)
|
||||||
|
|
||||||
|
expect(mockRepository.query).toHaveBeenCalled()
|
||||||
|
expect(mockFn).toHaveBeenCalledWith([{id: "1", mapped: true}])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle async eventToItem mapping", async () => {
|
||||||
|
const mockEvent = {id: "1", content: "test"} as TrustedEvent
|
||||||
|
mockRepository.query.mockReturnValue([mockEvent])
|
||||||
|
|
||||||
|
const store = deriveEventsMapped(mockRepository as any, {
|
||||||
|
filters: [],
|
||||||
|
eventToItem: async event => ({id: event.id, mapped: true}),
|
||||||
|
itemToEvent: item => ({id: item.id, content: ""}) as TrustedEvent,
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockFn = vi.fn()
|
||||||
|
store.subscribe(mockFn)
|
||||||
|
|
||||||
|
// Wait for async operations to complete
|
||||||
|
await vi.runAllTimersAsync()
|
||||||
|
|
||||||
|
expect(mockRepository.query).toHaveBeenCalled()
|
||||||
|
expect(mockFn).toHaveBeenCalledWith([{id: "1", mapped: true}])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle repository updates", () => {
|
||||||
|
const mockEvent = {id: "1", content: "test"} as TrustedEvent
|
||||||
|
mockRepository.query.mockReturnValue([mockEvent])
|
||||||
|
|
||||||
|
const store = deriveEventsMapped(mockRepository as any, {
|
||||||
|
filters: [{}],
|
||||||
|
eventToItem: event => ({id: event.id, mapped: true}),
|
||||||
|
itemToEvent: item => ({id: item.id, content: ""}) as TrustedEvent,
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockFn = vi.fn()
|
||||||
|
store.subscribe(mockFn)
|
||||||
|
|
||||||
|
const [[_, callback]] = mockRepository.on.mock.calls
|
||||||
|
|
||||||
|
callback({
|
||||||
|
added: new Set(),
|
||||||
|
removed: new Set([mockEvent.id]),
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(300) // Wait for batch delay
|
||||||
|
|
||||||
|
expect(mockFn).toHaveBeenLastCalledWith([{id: "2", mapped: true}])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("deriveIsDeleted", () => {
|
||||||
|
it("should track deletion status", () => {
|
||||||
|
const mockEvent = {id: "1"} as TrustedEvent
|
||||||
|
|
||||||
|
mockRepository.isDeleted.mockReturnValue(false)
|
||||||
|
|
||||||
|
const store = deriveIsDeleted(mockRepository as any, mockEvent)
|
||||||
|
const mockFn = vi.fn()
|
||||||
|
store.subscribe(mockFn)
|
||||||
|
|
||||||
|
expect(mockRepository.isDeleted).toHaveBeenCalledWith(mockEvent)
|
||||||
|
expect(mockFn).toHaveBeenCalledWith(false)
|
||||||
|
|
||||||
|
const [[_, callback]] = mockRepository.on.mock.calls
|
||||||
|
|
||||||
|
callback()
|
||||||
|
|
||||||
|
expect(mockRepository.isDeleted).toHaveBeenCalledWith(mockEvent)
|
||||||
|
expect(mockFn).toHaveBeenCalledWith(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
build
|
build
|
||||||
normalize-url
|
normalize-url
|
||||||
|
__tests__
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import {describe, it, expect} from "vitest"
|
||||||
|
import {decode, naddrEncode} from "nostr-tools/nip19"
|
||||||
|
import {Address, getAddress} from "../src/Address"
|
||||||
|
|
||||||
|
describe("Address", () => {
|
||||||
|
const pub = "ee".repeat(32)
|
||||||
|
const identifier = "identifier"
|
||||||
|
|
||||||
|
describe("constructor", () => {
|
||||||
|
it("should create an Address instance with required properties", () => {
|
||||||
|
const address = new Address(1, pub, identifier)
|
||||||
|
|
||||||
|
expect(address.kind).toBe(1)
|
||||||
|
expect(address.pubkey).toBe(pub)
|
||||||
|
expect(address.identifier).toBe(identifier)
|
||||||
|
expect(address.relays).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should create an Address instance with optional relays", () => {
|
||||||
|
const relays = ["wss://relay1.com", "wss://relay2.com"]
|
||||||
|
const address = new Address(1, pub, identifier, relays)
|
||||||
|
|
||||||
|
expect(address.relays).toEqual(relays)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("isAddress", () => {
|
||||||
|
it("should return true for valid address strings", () => {
|
||||||
|
expect(Address.isAddress(`1:${pub}:${identifier}`)).toBe(true)
|
||||||
|
expect(Address.isAddress("30023:abc123:test")).toBe(true)
|
||||||
|
expect(Address.isAddress("0:xyz789:")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return false for invalid address strings", () => {
|
||||||
|
expect(Address.isAddress("invalid")).toBe(false)
|
||||||
|
expect(Address.isAddress(`1:${pub}`)).toBe(false)
|
||||||
|
expect(Address.isAddress(`:${pub}:${identifier}`)).toBe(false)
|
||||||
|
expect(Address.isAddress(`abc:${pub}:${identifier}`)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("from", () => {
|
||||||
|
it("should create an Address from a valid address string", () => {
|
||||||
|
const address = Address.from(`1:${pub}:${identifier}`)
|
||||||
|
|
||||||
|
expect(address.kind).toBe(1)
|
||||||
|
expect(address.pubkey).toBe(pub)
|
||||||
|
expect(address.identifier).toBe(identifier)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle address strings without identifier", () => {
|
||||||
|
const address = Address.from(`1:${pub}:`)
|
||||||
|
|
||||||
|
expect(address.identifier).toBe("")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should accept optional relays", () => {
|
||||||
|
const relays = ["wss://relay1.com"]
|
||||||
|
const address = Address.from(`1:${pub}:${identifier}`, relays)
|
||||||
|
|
||||||
|
expect(address.relays).toEqual(relays)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("fromNaddr", () => {
|
||||||
|
it("should create an Address from a valid naddr", () => {
|
||||||
|
// Create a valid naddr using nostr-tools encode
|
||||||
|
const data = {
|
||||||
|
type: "naddr",
|
||||||
|
data: {
|
||||||
|
kind: 1,
|
||||||
|
pubkey: pub,
|
||||||
|
identifier: identifier,
|
||||||
|
relays: ["wss://relay1.com"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const naddr = naddrEncode(data.data)
|
||||||
|
|
||||||
|
const address = Address.fromNaddr(naddr)
|
||||||
|
|
||||||
|
expect(address.kind).toBe(1)
|
||||||
|
expect(address.pubkey).toBe(pub)
|
||||||
|
expect(address.identifier).toBe(identifier)
|
||||||
|
expect(address.relays).toEqual(["wss://relay1.com"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should throw error for invalid naddr", () => {
|
||||||
|
expect(() => Address.fromNaddr("invalid")).toThrow("Invalid naddr invalid")
|
||||||
|
expect(() => Address.fromNaddr("nostr:123")).toThrow("Invalid naddr nostr:123")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("fromEvent", () => {
|
||||||
|
it("should create an Address from an event with d tag", () => {
|
||||||
|
const event = {
|
||||||
|
kind: 1,
|
||||||
|
pubkey: pub,
|
||||||
|
tags: [["d", identifier]],
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = Address.fromEvent(event)
|
||||||
|
|
||||||
|
expect(address.kind).toBe(1)
|
||||||
|
expect(address.pubkey).toBe(pub)
|
||||||
|
expect(address.identifier).toBe(identifier)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should create an Address from an event without d tag", () => {
|
||||||
|
const event = {
|
||||||
|
kind: 1,
|
||||||
|
pubkey: pub,
|
||||||
|
tags: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = Address.fromEvent(event)
|
||||||
|
|
||||||
|
expect(address.identifier).toBe("")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should accept optional relays", () => {
|
||||||
|
const event = {
|
||||||
|
kind: 1,
|
||||||
|
pubkey: pub,
|
||||||
|
tags: [["d", identifier]],
|
||||||
|
}
|
||||||
|
const relays = ["wss://relay1.com"]
|
||||||
|
|
||||||
|
const address = Address.fromEvent(event, relays)
|
||||||
|
|
||||||
|
expect(address.relays).toEqual(relays)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("toString", () => {
|
||||||
|
it("should convert Address to string format", () => {
|
||||||
|
const address = new Address(1, pub, identifier)
|
||||||
|
|
||||||
|
expect(address.toString()).toBe(`1:${pub}:${identifier}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle empty identifier", () => {
|
||||||
|
const address = new Address(1, pub, "")
|
||||||
|
|
||||||
|
expect(address.toString()).toBe(`1:${pub}:`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("toNaddr", () => {
|
||||||
|
it("should convert Address to naddr format", () => {
|
||||||
|
const address = new Address(1, pub, identifier, ["wss://relay1.com"])
|
||||||
|
|
||||||
|
const naddr = address.toNaddr()
|
||||||
|
|
||||||
|
// Decode the naddr to verify its contents
|
||||||
|
const decoded = decode(naddr)
|
||||||
|
expect(decoded.type).toBe("naddr")
|
||||||
|
expect(decoded.data.kind).toBe(1)
|
||||||
|
expect(decoded.data.pubkey).toBe(pub)
|
||||||
|
expect(decoded.data.identifier).toBe(identifier)
|
||||||
|
expect(decoded.data.relays).toEqual(["wss://relay1.com"])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getAddress utility", () => {
|
||||||
|
it("should get address string from event", () => {
|
||||||
|
const event = {
|
||||||
|
kind: 1,
|
||||||
|
pubkey: pub,
|
||||||
|
tags: [["d", identifier]],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(getAddress(event)).toBe(`1:${pub}:${identifier}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle event without d tag", () => {
|
||||||
|
const event = {
|
||||||
|
kind: 1,
|
||||||
|
pubkey: pub,
|
||||||
|
tags: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(getAddress(event)).toBe(`1:${pub}:`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
it("should handle numeric pubkeys", () => {
|
||||||
|
const address = Address.from("1:123:test")
|
||||||
|
expect(address.pubkey).toBe("123")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle special characters in identifier", () => {
|
||||||
|
const address = Address.from("1:abc:test-123_456")
|
||||||
|
expect(address.identifier).toBe("test-123_456")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle zero kind", () => {
|
||||||
|
const address = Address.from("0:abc:test")
|
||||||
|
expect(address.kind).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import {MUTES} from "@welshman/util"
|
||||||
|
import {now} from "@welshman/lib"
|
||||||
|
import {describe, it, expect, vi, beforeEach} from "vitest"
|
||||||
|
import {Encryptable, asDecryptedEvent} from "../src/Encryptable"
|
||||||
|
import type {OwnedEvent, TrustedEvent} from "../src/Events"
|
||||||
|
|
||||||
|
describe("Encryptable", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
// Mock encryption function
|
||||||
|
const mockEncrypt = vi.fn(async (text: string) => `encrypted:${text}`)
|
||||||
|
|
||||||
|
// Realistic Nostr values
|
||||||
|
const pub = "ee".repeat(32)
|
||||||
|
const currentTime = now()
|
||||||
|
|
||||||
|
describe("constructor", () => {
|
||||||
|
it("should create an instance with minimal event template", () => {
|
||||||
|
const event: Partial<OwnedEvent> = {
|
||||||
|
kind: MUTES,
|
||||||
|
pubkey: pub,
|
||||||
|
created_at: currentTime,
|
||||||
|
}
|
||||||
|
const encryptable = new Encryptable(event, {})
|
||||||
|
|
||||||
|
expect(encryptable.event).toBe(event)
|
||||||
|
expect(encryptable.updates).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should create an instance with full event template", () => {
|
||||||
|
const event: OwnedEvent = {
|
||||||
|
kind: MUTES,
|
||||||
|
pubkey: pub,
|
||||||
|
created_at: currentTime,
|
||||||
|
content: "original encrypted content",
|
||||||
|
tags: [["p", pub]],
|
||||||
|
}
|
||||||
|
const updates = {
|
||||||
|
content: JSON.stringify({list: ["item1", "item2"]}),
|
||||||
|
tags: [["p", pub, "wss://relay.example.com"]],
|
||||||
|
}
|
||||||
|
const encryptable = new Encryptable(event, updates)
|
||||||
|
|
||||||
|
expect(encryptable.event).toBe(event)
|
||||||
|
expect(encryptable.updates).toBe(updates)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("reconcile", () => {
|
||||||
|
it("should encrypt content updates", async () => {
|
||||||
|
const event: Partial<OwnedEvent> = {
|
||||||
|
kind: MUTES,
|
||||||
|
pubkey: pub,
|
||||||
|
created_at: currentTime,
|
||||||
|
}
|
||||||
|
const updates = {
|
||||||
|
content: JSON.stringify({muted: [pub]}),
|
||||||
|
}
|
||||||
|
const encryptable = new Encryptable(event, updates)
|
||||||
|
|
||||||
|
const result = await encryptable.reconcile(mockEncrypt)
|
||||||
|
|
||||||
|
expect(result.content).toBe(`encrypted:${updates.content}`)
|
||||||
|
expect(mockEncrypt).toHaveBeenCalledWith(updates.content)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should encrypt tag updates", async () => {
|
||||||
|
const event: Partial<OwnedEvent> = {
|
||||||
|
kind: MUTES,
|
||||||
|
pubkey: pub,
|
||||||
|
created_at: currentTime,
|
||||||
|
}
|
||||||
|
const updates = {
|
||||||
|
tags: [["p", pub, "wss://relay.example.com"]],
|
||||||
|
}
|
||||||
|
const encryptable = new Encryptable(event, updates)
|
||||||
|
|
||||||
|
const result = await encryptable.reconcile(mockEncrypt)
|
||||||
|
|
||||||
|
expect(result.tags[0][1]).toBe(`encrypted:${pub}`)
|
||||||
|
expect(mockEncrypt).toHaveBeenCalledWith(pub)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle both content and tag updates", async () => {
|
||||||
|
const event: Partial<OwnedEvent> = {
|
||||||
|
kind: MUTES,
|
||||||
|
pubkey: pub,
|
||||||
|
created_at: currentTime,
|
||||||
|
}
|
||||||
|
const updates = {
|
||||||
|
content: JSON.stringify({muted: [pub]}),
|
||||||
|
tags: [["p", pub, "wss://relay.example.com"]],
|
||||||
|
}
|
||||||
|
const encryptable = new Encryptable(event, updates)
|
||||||
|
|
||||||
|
const result = await encryptable.reconcile(mockEncrypt)
|
||||||
|
|
||||||
|
expect(result.content).toBe(`encrypted:${updates.content}`)
|
||||||
|
expect(result.tags[0][1]).toBe(`encrypted:${pub}`)
|
||||||
|
expect(mockEncrypt).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should preserve original content when no updates", async () => {
|
||||||
|
const event: OwnedEvent = {
|
||||||
|
kind: MUTES,
|
||||||
|
pubkey: pub,
|
||||||
|
created_at: currentTime,
|
||||||
|
content: JSON.stringify({originalList: [pub]}),
|
||||||
|
tags: [],
|
||||||
|
}
|
||||||
|
const encryptable = new Encryptable(event, {})
|
||||||
|
|
||||||
|
const result = await encryptable.reconcile(mockEncrypt)
|
||||||
|
|
||||||
|
expect(result.content).toBe(event.content)
|
||||||
|
expect(mockEncrypt).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should preserve original tags when no updates", async () => {
|
||||||
|
const event: OwnedEvent = {
|
||||||
|
kind: MUTES,
|
||||||
|
pubkey: pub,
|
||||||
|
created_at: currentTime,
|
||||||
|
content: "",
|
||||||
|
tags: [["p", pub, "wss://relay.example.com"]],
|
||||||
|
}
|
||||||
|
const encryptable = new Encryptable(event, {})
|
||||||
|
|
||||||
|
const result = await encryptable.reconcile(mockEncrypt)
|
||||||
|
|
||||||
|
expect(result.tags).toEqual(event.tags)
|
||||||
|
expect(mockEncrypt).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("asDecryptedEvent", () => {
|
||||||
|
it("should create a decrypted event with plaintext", () => {
|
||||||
|
const event: TrustedEvent = {
|
||||||
|
id: "ff".repeat(32),
|
||||||
|
sig: "00".repeat(64),
|
||||||
|
kind: MUTES,
|
||||||
|
pubkey: pub,
|
||||||
|
created_at: currentTime,
|
||||||
|
content: "encrypted content",
|
||||||
|
tags: [],
|
||||||
|
}
|
||||||
|
const plaintext = {
|
||||||
|
content: JSON.stringify({muted: [pub]}),
|
||||||
|
tags: [["p", pub, "wss://relay.example.com"]],
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = asDecryptedEvent(event, plaintext)
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
...event,
|
||||||
|
plaintext,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle empty plaintext", () => {
|
||||||
|
const event: TrustedEvent = {
|
||||||
|
id: "ff".repeat(32),
|
||||||
|
sig: "00".repeat(64),
|
||||||
|
kind: MUTES,
|
||||||
|
pubkey: pub,
|
||||||
|
created_at: currentTime,
|
||||||
|
content: "encrypted content",
|
||||||
|
tags: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = asDecryptedEvent(event)
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
...event,
|
||||||
|
plaintext: {},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("error handling", () => {
|
||||||
|
it("should handle encryption failures", async () => {
|
||||||
|
const failingEncrypt = async () => {
|
||||||
|
throw new Error("Encryption failed")
|
||||||
|
}
|
||||||
|
const event: Partial<OwnedEvent> = {
|
||||||
|
kind: MUTES,
|
||||||
|
pubkey: pub,
|
||||||
|
created_at: currentTime,
|
||||||
|
}
|
||||||
|
const updates = {
|
||||||
|
content: JSON.stringify({muted: [pub]}),
|
||||||
|
}
|
||||||
|
const encryptable = new Encryptable(event, updates)
|
||||||
|
|
||||||
|
await expect(encryptable.reconcile(failingEncrypt)).rejects.toThrow("Encryption failed")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle partial encryption failures", async () => {
|
||||||
|
let callCount = 0
|
||||||
|
const partialFailingEncrypt = async () => {
|
||||||
|
callCount++
|
||||||
|
if (callCount > 1) throw new Error("Encryption failed")
|
||||||
|
return "encrypted:success"
|
||||||
|
}
|
||||||
|
|
||||||
|
const event: Partial<OwnedEvent> = {
|
||||||
|
kind: MUTES,
|
||||||
|
pubkey: pub,
|
||||||
|
created_at: currentTime,
|
||||||
|
}
|
||||||
|
const updates = {
|
||||||
|
content: JSON.stringify({muted: [pub]}),
|
||||||
|
tags: [["p", pub]],
|
||||||
|
}
|
||||||
|
const encryptable = new Encryptable(event, updates)
|
||||||
|
|
||||||
|
await expect(encryptable.reconcile(partialFailingEncrypt)).rejects.toThrow(
|
||||||
|
"Encryption failed",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import {now} from "@welshman/lib"
|
||||||
|
import {describe, it, expect} from "vitest"
|
||||||
|
import {verifiedSymbol} from "nostr-tools/pure"
|
||||||
|
import * as Events from "../src/Events"
|
||||||
|
import {COMMENT} from "../src/Kinds"
|
||||||
|
|
||||||
|
describe("Events", () => {
|
||||||
|
// Realistic Nostr data
|
||||||
|
const pubkey = "ee".repeat(32)
|
||||||
|
const sig = "ee".repeat(64)
|
||||||
|
const id = "ff".repeat(32)
|
||||||
|
const currentTime = now()
|
||||||
|
|
||||||
|
const createBaseEvent = () => ({
|
||||||
|
kind: 1,
|
||||||
|
content: "Hello Nostr!",
|
||||||
|
tags: [["p", pubkey]],
|
||||||
|
})
|
||||||
|
|
||||||
|
const createStampedEvent = () => ({
|
||||||
|
...createBaseEvent(),
|
||||||
|
created_at: currentTime,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createOwnedEvent = () => ({
|
||||||
|
...createStampedEvent(),
|
||||||
|
pubkey: pubkey,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createHashedEvent = () => ({
|
||||||
|
...createOwnedEvent(),
|
||||||
|
id: id,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createSignedEvent = () => ({
|
||||||
|
...createHashedEvent(),
|
||||||
|
sig: sig,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createCommentEvent = (parentId: string) => ({
|
||||||
|
...createHashedEvent(),
|
||||||
|
kind: COMMENT,
|
||||||
|
tags: [
|
||||||
|
["E", parentId, "", "root"],
|
||||||
|
["P", pubkey],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const createReplyEvent = (parentId: string) => ({
|
||||||
|
...createHashedEvent(),
|
||||||
|
kind: 1,
|
||||||
|
tags: [
|
||||||
|
["e", parentId, "", "root"],
|
||||||
|
["e", parentId, "", "reply"],
|
||||||
|
["p", pubkey, "", "root"],
|
||||||
|
["p", pubkey, "", "reply"],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("createEvent", () => {
|
||||||
|
it("should create event with defaults", () => {
|
||||||
|
const event = Events.createEvent(1, {})
|
||||||
|
expect(event.kind).toBe(1)
|
||||||
|
expect(event.content).toBe("")
|
||||||
|
expect(event.tags).toEqual([])
|
||||||
|
expect(event.created_at).toBeLessThanOrEqual(now())
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should create event with provided values", () => {
|
||||||
|
const event = Events.createEvent(1, {
|
||||||
|
content: "Hello Nostr!",
|
||||||
|
tags: [["p", pubkey]],
|
||||||
|
created_at: currentTime,
|
||||||
|
})
|
||||||
|
expect(event).toEqual(createStampedEvent())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("type guards", () => {
|
||||||
|
it("should validate EventTemplate", () => {
|
||||||
|
expect(Events.isEventTemplate(createBaseEvent())).toBe(true)
|
||||||
|
expect(Events.isEventTemplate({kind: 1} as Events.EventTemplate)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should validate StampedEvent", () => {
|
||||||
|
expect(Events.isStampedEvent(createStampedEvent())).toBe(true)
|
||||||
|
expect(Events.isStampedEvent(createBaseEvent() as Events.StampedEvent)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should validate OwnedEvent", () => {
|
||||||
|
expect(Events.isOwnedEvent(createOwnedEvent())).toBe(true)
|
||||||
|
expect(Events.isOwnedEvent(createStampedEvent() as Events.OwnedEvent)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should validate HashedEvent", () => {
|
||||||
|
expect(Events.isHashedEvent(createHashedEvent())).toBe(true)
|
||||||
|
expect(Events.isHashedEvent(createOwnedEvent() as Events.HashedEvent)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should validate SignedEvent", () => {
|
||||||
|
expect(Events.isSignedEvent(createSignedEvent())).toBe(true)
|
||||||
|
expect(Events.isSignedEvent(createHashedEvent())).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should validate TrustedEvent", () => {
|
||||||
|
const unwrapped = {
|
||||||
|
...createHashedEvent(),
|
||||||
|
wrap: createSignedEvent(),
|
||||||
|
}
|
||||||
|
expect(Events.isTrustedEvent(createHashedEvent())).toBe(false)
|
||||||
|
expect(Events.isTrustedEvent(createSignedEvent())).toBe(true)
|
||||||
|
expect(Events.isTrustedEvent(unwrapped)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should validate UnwrappedEvent", () => {
|
||||||
|
const unwrapped = {
|
||||||
|
...createHashedEvent(),
|
||||||
|
wrap: createSignedEvent(),
|
||||||
|
}
|
||||||
|
expect(Events.isUnwrappedEvent(unwrapped)).toBe(true)
|
||||||
|
expect(Events.isUnwrappedEvent(createHashedEvent())).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("event conversion", () => {
|
||||||
|
it("should convert to EventTemplate", () => {
|
||||||
|
const result = Events.asEventTemplate(createSignedEvent())
|
||||||
|
expect(result).toHaveProperty("kind")
|
||||||
|
expect(result).toHaveProperty("tags")
|
||||||
|
expect(result).toHaveProperty("content")
|
||||||
|
expect(result).not.toHaveProperty("created_at")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should convert to StampedEvent", () => {
|
||||||
|
const result = Events.asStampedEvent(createSignedEvent())
|
||||||
|
expect(result).toHaveProperty("created_at")
|
||||||
|
expect(result).not.toHaveProperty("pubkey")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should convert to OwnedEvent", () => {
|
||||||
|
const result = Events.asOwnedEvent(createSignedEvent())
|
||||||
|
expect(result).not.toHaveProperty("sig")
|
||||||
|
expect(result).not.toHaveProperty("id")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should convert to HashedEvent", () => {
|
||||||
|
const result = Events.asHashedEvent(createSignedEvent())
|
||||||
|
expect(result).not.toHaveProperty("sig")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should convert to SignedEvent", () => {
|
||||||
|
const trustedEvent = {
|
||||||
|
...createHashedEvent(),
|
||||||
|
sig: sig,
|
||||||
|
wrap: createSignedEvent(),
|
||||||
|
}
|
||||||
|
const result = Events.asSignedEvent(trustedEvent)
|
||||||
|
expect(result).not.toHaveProperty("wrap")
|
||||||
|
expect(result).toHaveProperty("sig")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should convert to UnwrappedEvent", () => {
|
||||||
|
const trustedEvent = {
|
||||||
|
...createHashedEvent(),
|
||||||
|
sig: sig,
|
||||||
|
wrap: createSignedEvent(),
|
||||||
|
}
|
||||||
|
const result = Events.asUnwrappedEvent(trustedEvent)
|
||||||
|
expect(result).toHaveProperty("wrap")
|
||||||
|
expect(result).not.toHaveProperty("sig")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should convert to TrustedEvent", () => {
|
||||||
|
const trustedEvent = {
|
||||||
|
...createHashedEvent(),
|
||||||
|
sig: sig,
|
||||||
|
wrap: createSignedEvent(),
|
||||||
|
}
|
||||||
|
const result = Events.asTrustedEvent(trustedEvent)
|
||||||
|
expect(result).toHaveProperty("sig")
|
||||||
|
expect(result).toHaveProperty("wrap")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("signature validation", () => {
|
||||||
|
it("should validate signature using verifiedSymbol", () => {
|
||||||
|
let event = createSignedEvent() as Events.SignedEvent
|
||||||
|
event[verifiedSymbol] = true
|
||||||
|
expect(Events.hasValidSignature(event)).toBe(true)
|
||||||
|
|
||||||
|
// Clear verifiedSymbol and use verify the actual signature
|
||||||
|
delete event[verifiedSymbol]
|
||||||
|
// the signature is invalid, but the sig validity is not checked here
|
||||||
|
expect(Events.hasValidSignature(event)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("event identifiers", () => {
|
||||||
|
it("should get identifier from d tag", () => {
|
||||||
|
const event = {
|
||||||
|
...createBaseEvent(),
|
||||||
|
tags: [["d", "test-identifier"]],
|
||||||
|
}
|
||||||
|
expect(Events.getIdentifier(event)).toBe("test-identifier")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should get address for replaceable events", () => {
|
||||||
|
const event = {
|
||||||
|
...createHashedEvent(),
|
||||||
|
kind: 10000, // replaceable kind
|
||||||
|
}
|
||||||
|
expect(Events.getIdOrAddress(event)).toMatch(/^10000:/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("event relationships", () => {
|
||||||
|
it("should identify parent-child relationships", () => {
|
||||||
|
const parent = createHashedEvent()
|
||||||
|
const child = createCommentEvent(parent.id)
|
||||||
|
expect(Events.isChildOf(child, parent)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should get parent IDs", () => {
|
||||||
|
const parentId = id
|
||||||
|
const event = createCommentEvent(parentId)
|
||||||
|
expect(Events.getParentIds(event)).toContain(parentId)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should get parent addresses", () => {
|
||||||
|
const event = {
|
||||||
|
...createCommentEvent(id),
|
||||||
|
tags: [["e", "30023:pubkey:identifier", "", "root"]],
|
||||||
|
}
|
||||||
|
expect(Events.getParentAddrs(event)[0]).toMatch(/^\d+:/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("event type checks", () => {
|
||||||
|
it("should identify ephemeral events", () => {
|
||||||
|
const event = {
|
||||||
|
...createBaseEvent(),
|
||||||
|
kind: 20000, // ephemeral kind
|
||||||
|
}
|
||||||
|
expect(Events.isEphemeral(event)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should identify replaceable events", () => {
|
||||||
|
const event = {
|
||||||
|
...createBaseEvent(),
|
||||||
|
kind: 10000, // replaceable kind
|
||||||
|
}
|
||||||
|
expect(Events.isReplaceable(event)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should identify parameterized replaceable events", () => {
|
||||||
|
const event = {
|
||||||
|
...createBaseEvent(),
|
||||||
|
kind: 30000, // parameterized replaceable kind
|
||||||
|
}
|
||||||
|
expect(Events.isParameterizedReplaceable(event)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("ancestor handling", () => {
|
||||||
|
it("should get ancestors for comments", () => {
|
||||||
|
const parentId = id
|
||||||
|
const event = createCommentEvent(parentId)
|
||||||
|
const ancestors = Events.getAncestors(event)
|
||||||
|
expect(ancestors.roots).toContain(parentId)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should get ancestors for replies", () => {
|
||||||
|
const parentId = id
|
||||||
|
const event = createReplyEvent(parentId)
|
||||||
|
const ancestors = Events.getAncestors(event)
|
||||||
|
expect(ancestors.roots).toContain(parentId)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle events without ancestors", () => {
|
||||||
|
const event = createBaseEvent()
|
||||||
|
const ancestors = Events.getAncestors(event)
|
||||||
|
expect(ancestors.roots).toEqual([])
|
||||||
|
expect(ancestors.replies).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import {describe, it, vi, expect, beforeEach} from "vitest"
|
||||||
|
import * as Filters from "../src/Filters"
|
||||||
|
|
||||||
|
import type {TrustedEvent} from "../src/Events"
|
||||||
|
import {GENERIC_REPOST, LONG_FORM, MUTES, REPOST} from "@welshman/util"
|
||||||
|
|
||||||
|
describe("Filters", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
const pubkey = "000000789abcdef0000000789abcdef0000000789abcdef0000000789abcdef"
|
||||||
|
const id = "ff".repeat(32)
|
||||||
|
const currentTime = Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
|
const createEvent = (overrides = {}): TrustedEvent => ({
|
||||||
|
id: id,
|
||||||
|
pubkey: pubkey,
|
||||||
|
created_at: currentTime,
|
||||||
|
kind: 1,
|
||||||
|
tags: [],
|
||||||
|
content: "Hello Nostr!",
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("matchFilter", () => {
|
||||||
|
it("should match basic filter criteria", () => {
|
||||||
|
const event = createEvent()
|
||||||
|
const filter = {kinds: [1], authors: [pubkey]}
|
||||||
|
expect(Filters.matchFilter(filter, event)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle search terms", () => {
|
||||||
|
const event = createEvent({content: "Hello Nostr World!"})
|
||||||
|
expect(Filters.matchFilter({search: "nostr"}, event)).toBe(true)
|
||||||
|
expect(Filters.matchFilter({search: "bitcoin"}, event)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle multiple search terms", () => {
|
||||||
|
const event = createEvent({content: "Hello Nostr World!"})
|
||||||
|
expect(Filters.matchFilter({search: "hello world"}, event)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle case-insensitive search", () => {
|
||||||
|
const event = createEvent({content: "Hello NOSTR World!"})
|
||||||
|
expect(Filters.matchFilter({search: "nostr"}, event)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("matchFilters", () => {
|
||||||
|
it("should match if any filter matches", () => {
|
||||||
|
const event = createEvent()
|
||||||
|
const filters = [{kinds: [2]}, {kinds: [1], authors: [pubkey]}]
|
||||||
|
expect(Filters.matchFilters(filters, event)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not match if no filters match", () => {
|
||||||
|
const event = createEvent()
|
||||||
|
const filters = [{kinds: [2]}, {kinds: [3]}]
|
||||||
|
expect(Filters.matchFilters(filters, event)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getFilterId", () => {
|
||||||
|
it("should generate consistent IDs for equivalent filters", () => {
|
||||||
|
const filter1 = {kinds: [1], authors: [pubkey]}
|
||||||
|
const filter2 = {authors: [pubkey], kinds: [1]}
|
||||||
|
expect(Filters.getFilterId(filter1)).toBe(Filters.getFilterId(filter2))
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should generate different IDs for different filters", () => {
|
||||||
|
const filter1 = {kinds: [1], authors: [pubkey]}
|
||||||
|
const filter2 = {kinds: [2], authors: [pubkey]}
|
||||||
|
expect(Filters.getFilterId(filter1)).not.toBe(Filters.getFilterId(filter2))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("unionFilters", () => {
|
||||||
|
it("should combine similar filters", () => {
|
||||||
|
const filters = [
|
||||||
|
{kinds: [1], authors: [pubkey]},
|
||||||
|
{kinds: [1], authors: [pubkey + "1"]},
|
||||||
|
]
|
||||||
|
const result = Filters.unionFilters(filters)
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].authors).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle different filter groups", () => {
|
||||||
|
const filters = [{kinds: [1]}, {"#e": [id]}]
|
||||||
|
const result = Filters.unionFilters(filters)
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should preserve limit, since, until, and search", () => {
|
||||||
|
const filters = [
|
||||||
|
{kinds: [1], limit: 10, since: 1000, until: 2000, search: "test"},
|
||||||
|
{kinds: [1], limit: 10, since: 1000, until: 2000, search: "test"},
|
||||||
|
]
|
||||||
|
const result = Filters.unionFilters(filters)
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0]).toMatchObject({limit: 10, since: 1000, until: 2000, search: "test"})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("intersectFilters", () => {
|
||||||
|
it("should combine filter groups", () => {
|
||||||
|
const groups = [[{kinds: [1]}], [{authors: [pubkey]}]]
|
||||||
|
const result = Filters.intersectFilters(groups)
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
kinds: [1],
|
||||||
|
authors: [pubkey],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle since, until, and limit", () => {
|
||||||
|
const groups = [
|
||||||
|
[{since: 1000, until: 2000, limit: 10}],
|
||||||
|
[{since: 1500, until: 1800, limit: 20}],
|
||||||
|
]
|
||||||
|
const result = Filters.intersectFilters(groups)
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
since: 1500, // Max of since
|
||||||
|
until: 1800, // Min of until
|
||||||
|
limit: 20, // Max of limit
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should combine search terms", () => {
|
||||||
|
const groups = [[{search: "hello"}], [{search: "world"}]]
|
||||||
|
const result = Filters.intersectFilters(groups)
|
||||||
|
expect(result[0].search).toBe("hello world")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getIdFilters", () => {
|
||||||
|
it("should handle plain IDs", () => {
|
||||||
|
const result = Filters.getIdFilters([id])
|
||||||
|
expect(result[0].ids).toContain(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle addresses", () => {
|
||||||
|
const addr = `1:${pubkey}:test`
|
||||||
|
const result = Filters.getIdFilters([addr])
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
kinds: [1],
|
||||||
|
authors: [pubkey],
|
||||||
|
"#d": ["test"],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle mixed IDs and addresses", () => {
|
||||||
|
const addr = `1:${pubkey}:test`
|
||||||
|
const result = Filters.getIdFilters([id, addr])
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getReplyFilters", () => {
|
||||||
|
it("should create filters for regular events", () => {
|
||||||
|
const event = createEvent()
|
||||||
|
const result = Filters.getReplyFilters([event])
|
||||||
|
expect((result[0] as any)["#e"]).toContain(event.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle replaceable events", () => {
|
||||||
|
const event = createEvent({kind: MUTES})
|
||||||
|
const result = Filters.getReplyFilters([event])
|
||||||
|
expect((result[0] as any)["#a"]).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle wrapped events", () => {
|
||||||
|
const event = createEvent({
|
||||||
|
wrap: createEvent(),
|
||||||
|
})
|
||||||
|
const result = Filters.getReplyFilters([event])
|
||||||
|
expect((result[0] as any)["#e"]).toHaveLength(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("addRepostFilters", () => {
|
||||||
|
it("should add repost kinds for kind 1", () => {
|
||||||
|
const result = Filters.addRepostFilters([{kinds: [1]}])
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
expect(result[1].kinds).toContain(REPOST)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle other kinds", () => {
|
||||||
|
const result = Filters.addRepostFilters([{kinds: [LONG_FORM]}])
|
||||||
|
expect(result[1].kinds).toContain(GENERIC_REPOST)
|
||||||
|
expect(result[1].kinds).not.toContain(REPOST)
|
||||||
|
expect(result[1]["#k"]).toContain(LONG_FORM.toString())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("filter utilities", () => {
|
||||||
|
it("should calculate filter generality", () => {
|
||||||
|
expect(Filters.getFilterGenerality({ids: [id]})).toBe(0)
|
||||||
|
expect(Filters.getFilterGenerality({authors: [pubkey], "#p": [pubkey]})).toBe(0.2)
|
||||||
|
expect(Filters.getFilterGenerality({authors: [pubkey, pubkey, pubkey], kinds: [1]})).toBe(
|
||||||
|
0.01,
|
||||||
|
)
|
||||||
|
expect(Filters.getFilterGenerality({kinds: [1]})).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should guess filter delta", () => {
|
||||||
|
const result = Filters.guessFilterDelta([{ids: [id]}])
|
||||||
|
expect(result).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should get filter result cardinality", () => {
|
||||||
|
expect(Filters.getFilterResultCardinality({ids: [id, id + "1"]})).toBe(2)
|
||||||
|
expect(Filters.getFilterResultCardinality({kinds: [1]})).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should trim large filters", () => {
|
||||||
|
const largeFilter = {authors: Array(2000).fill(pubkey)}
|
||||||
|
const result = Filters.trimFilter(largeFilter)
|
||||||
|
expect(result.authors?.length).toBe(1000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import {now} from "@welshman/lib"
|
||||||
|
import {HANDLER_INFORMATION} from "@welshman/util"
|
||||||
|
import {describe, it, vi, expect, beforeEach} from "vitest"
|
||||||
|
import {readHandlers, getHandlerKey, displayHandler, getHandlerAddress} from "../src/Handler"
|
||||||
|
import type {TrustedEvent} from "../src/Events"
|
||||||
|
|
||||||
|
describe("Handler", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
const pubkey = "ee".repeat(32)
|
||||||
|
const id = "ff".repeat(32)
|
||||||
|
const currentTime = now()
|
||||||
|
|
||||||
|
const createHandlerEvent = (overrides = {}): TrustedEvent => ({
|
||||||
|
id: id,
|
||||||
|
pubkey: pubkey,
|
||||||
|
created_at: currentTime,
|
||||||
|
kind: HANDLER_INFORMATION,
|
||||||
|
tags: [
|
||||||
|
["d", "test-handler"],
|
||||||
|
["k", "30023"],
|
||||||
|
["k", "30024"],
|
||||||
|
],
|
||||||
|
content: JSON.stringify({
|
||||||
|
name: "Test Handler",
|
||||||
|
image: "https://example.com/image.jpg",
|
||||||
|
about: "Test handler description",
|
||||||
|
website: "https://example.com",
|
||||||
|
lud16: "user@domain.com",
|
||||||
|
nip05: "user@domain.com",
|
||||||
|
}),
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("readHandlers", () => {
|
||||||
|
it("should parse valid handler event with full metadata", () => {
|
||||||
|
const event = createHandlerEvent()
|
||||||
|
const handlers = readHandlers(event)
|
||||||
|
|
||||||
|
expect(handlers).toHaveLength(2) // Two k tags
|
||||||
|
expect(handlers[0]).toMatchObject({
|
||||||
|
kind: 30023,
|
||||||
|
identifier: "test-handler",
|
||||||
|
name: "Test Handler",
|
||||||
|
image: "https://example.com/image.jpg",
|
||||||
|
about: "Test handler description",
|
||||||
|
website: "https://example.com",
|
||||||
|
lud16: "user@domain.com",
|
||||||
|
nip05: "user@domain.com",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle display_name and picture alternatives", () => {
|
||||||
|
const event = createHandlerEvent({
|
||||||
|
content: JSON.stringify({
|
||||||
|
display_name: "Test Handler",
|
||||||
|
picture: "https://example.com/image.jpg",
|
||||||
|
about: "Test description",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const handlers = readHandlers(event)
|
||||||
|
|
||||||
|
expect(handlers[0].name).toBe("Test Handler")
|
||||||
|
expect(handlers[0].image).toBe("https://example.com/image.jpg")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return empty array if name is missing", () => {
|
||||||
|
const event = createHandlerEvent({
|
||||||
|
content: JSON.stringify({
|
||||||
|
image: "https://example.com/image.jpg",
|
||||||
|
about: "Test description",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const handlers = readHandlers(event)
|
||||||
|
|
||||||
|
expect(handlers).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return empty array if image is missing", () => {
|
||||||
|
const event = createHandlerEvent({
|
||||||
|
content: JSON.stringify({
|
||||||
|
name: "Test Handler",
|
||||||
|
about: "Test description",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const handlers = readHandlers(event)
|
||||||
|
|
||||||
|
expect(handlers).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle invalid JSON content", () => {
|
||||||
|
const event = createHandlerEvent({
|
||||||
|
content: "invalid json",
|
||||||
|
})
|
||||||
|
const handlers = readHandlers(event)
|
||||||
|
|
||||||
|
expect(handlers).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle empty content", () => {
|
||||||
|
const event = createHandlerEvent({
|
||||||
|
content: "",
|
||||||
|
})
|
||||||
|
const handlers = readHandlers(event)
|
||||||
|
|
||||||
|
expect(handlers).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle missing optional fields", () => {
|
||||||
|
const event = createHandlerEvent({
|
||||||
|
content: JSON.stringify({
|
||||||
|
name: "Test Handler",
|
||||||
|
image: "https://example.com/image.jpg",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const handlers = readHandlers(event)
|
||||||
|
|
||||||
|
expect(handlers[0]).toMatchObject({
|
||||||
|
name: "Test Handler",
|
||||||
|
image: "https://example.com/image.jpg",
|
||||||
|
about: "",
|
||||||
|
website: "",
|
||||||
|
lud16: "",
|
||||||
|
nip05: "",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getHandlerKey", () => {
|
||||||
|
it("should generate correct handler key", () => {
|
||||||
|
const event = createHandlerEvent()
|
||||||
|
const handler = readHandlers(event)[0]
|
||||||
|
const key = getHandlerKey(handler)
|
||||||
|
|
||||||
|
expect(key).toBe(`30023:31990:${pubkey}:test-handler`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("displayHandler", () => {
|
||||||
|
it("should return handler name when available", () => {
|
||||||
|
const event = createHandlerEvent()
|
||||||
|
const handler = readHandlers(event)[0]
|
||||||
|
|
||||||
|
expect(displayHandler(handler)).toBe("Test Handler")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return fallback when handler is undefined", () => {
|
||||||
|
expect(displayHandler(undefined, "Fallback")).toBe("Fallback")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return empty string when no fallback provided", () => {
|
||||||
|
expect(displayHandler(undefined)).toBe("")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getHandlerAddress", () => {
|
||||||
|
it("should return web-tagged address if available", () => {
|
||||||
|
const event = createHandlerEvent({
|
||||||
|
tags: [
|
||||||
|
["a", "30023:pubkey1:test", "relay1", "web"],
|
||||||
|
["a", "30024:pubkey2:test", "relay2"],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(getHandlerAddress(event)).toBe("30023:pubkey1:test")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return first address if no web tag", () => {
|
||||||
|
const event = createHandlerEvent({
|
||||||
|
tags: [
|
||||||
|
["a", "30023:pubkey1:test", "relay1"],
|
||||||
|
["a", "30024:pubkey2:test", "relay2"],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(getHandlerAddress(event)).toBe("30023:pubkey1:test")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return undefined if no address tags", () => {
|
||||||
|
const event = createHandlerEvent({
|
||||||
|
tags: [["d", "test-handler"]],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(getHandlerAddress(event)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle empty tags array", () => {
|
||||||
|
const event = createHandlerEvent({
|
||||||
|
tags: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(getHandlerAddress(event)).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
import {now} from "@welshman/lib"
|
||||||
|
import {MUTES} from "@welshman/util"
|
||||||
|
import {describe, it, vi, expect, beforeEach} from "vitest"
|
||||||
|
import {
|
||||||
|
makeList,
|
||||||
|
readList,
|
||||||
|
getListTags,
|
||||||
|
removeFromList,
|
||||||
|
removeFromListByPredicate,
|
||||||
|
addToListPublicly,
|
||||||
|
addToListPrivately,
|
||||||
|
} from "../src/List"
|
||||||
|
import type {DecryptedEvent} from "../src/Encryptable"
|
||||||
|
import type {List} from "../src/List"
|
||||||
|
|
||||||
|
describe("List", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
const pubkey = "ee".repeat(32)
|
||||||
|
const validEventId = "ff".repeat(32)
|
||||||
|
const address = `30023:${pubkey}:test`
|
||||||
|
const currentTime = now()
|
||||||
|
|
||||||
|
const createDecryptedEvent = (overrides = {}): DecryptedEvent => ({
|
||||||
|
id: validEventId,
|
||||||
|
pubkey: pubkey,
|
||||||
|
created_at: currentTime,
|
||||||
|
kind: MUTES,
|
||||||
|
tags: [],
|
||||||
|
content: "",
|
||||||
|
plaintext: {},
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("makeList", () => {
|
||||||
|
it("should create a list with defaults", () => {
|
||||||
|
const list = makeList({kind: MUTES})
|
||||||
|
expect(list).toEqual({
|
||||||
|
kind: MUTES,
|
||||||
|
publicTags: [],
|
||||||
|
privateTags: [],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should preserve existing tags", () => {
|
||||||
|
const list = makeList({
|
||||||
|
kind: MUTES,
|
||||||
|
publicTags: [["p", pubkey]],
|
||||||
|
privateTags: [["e", validEventId]],
|
||||||
|
})
|
||||||
|
expect(list.publicTags).toHaveLength(1)
|
||||||
|
expect(list.privateTags).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("readList", () => {
|
||||||
|
it("should parse valid public tags", () => {
|
||||||
|
const event = createDecryptedEvent({
|
||||||
|
tags: [
|
||||||
|
["p", pubkey],
|
||||||
|
["e", validEventId],
|
||||||
|
["a", address],
|
||||||
|
["t", "test"],
|
||||||
|
["r", "wss://relay.example.com"],
|
||||||
|
["relay", "wss://relay.example.com"],
|
||||||
|
["unknown", "value"],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const list = readList(event)
|
||||||
|
expect(list.publicTags).toHaveLength(7)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not parse invalid public tags", () => {
|
||||||
|
const event = createDecryptedEvent({
|
||||||
|
tags: [
|
||||||
|
["p", "invalid-pubkey"],
|
||||||
|
["e", "invalid-event-id"],
|
||||||
|
["a", "invalid-address"],
|
||||||
|
["t", ""],
|
||||||
|
["r", "invalid-url"],
|
||||||
|
["relay", "invalid-url"],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const list = readList(event)
|
||||||
|
expect(list.publicTags).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should parse valid private tags", () => {
|
||||||
|
const event = createDecryptedEvent({
|
||||||
|
plaintext: {
|
||||||
|
content: JSON.stringify([
|
||||||
|
["p", pubkey],
|
||||||
|
["e", validEventId],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const list = readList(event)
|
||||||
|
expect(list.privateTags).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not parse invalid private tags", () => {
|
||||||
|
const event = createDecryptedEvent({
|
||||||
|
plaintext: {
|
||||||
|
content: JSON.stringify([
|
||||||
|
["p", "invalid-pubkey"],
|
||||||
|
["e", "invalid-event-id"],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const list = readList(event)
|
||||||
|
expect(list.privateTags).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should filter invalid tags", () => {
|
||||||
|
const event = createDecryptedEvent({
|
||||||
|
tags: [
|
||||||
|
["p", "invalid-pubkey"],
|
||||||
|
["e", "invalid-event-id"],
|
||||||
|
["a", "invalid-address"],
|
||||||
|
["t", ""],
|
||||||
|
["r", "invalid-url"],
|
||||||
|
["relay", "invalid-url"],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const list = readList(event)
|
||||||
|
expect(list.publicTags).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle invalid JSON in private content", () => {
|
||||||
|
const event = createDecryptedEvent({
|
||||||
|
plaintext: {content: "invalid-json"},
|
||||||
|
})
|
||||||
|
const list = readList(event)
|
||||||
|
expect(list.privateTags).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle non-array private content", () => {
|
||||||
|
const event = createDecryptedEvent({
|
||||||
|
plaintext: {content: JSON.stringify({not: "an-array"})},
|
||||||
|
})
|
||||||
|
const list = readList(event)
|
||||||
|
expect(list.privateTags).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getListTags", () => {
|
||||||
|
it("should combine public and private tags", () => {
|
||||||
|
const list: List = {
|
||||||
|
kind: MUTES,
|
||||||
|
publicTags: [["p", pubkey]],
|
||||||
|
privateTags: [["e", validEventId]],
|
||||||
|
}
|
||||||
|
const tags = getListTags(list)
|
||||||
|
expect(tags).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle undefined list", () => {
|
||||||
|
expect(getListTags(undefined)).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("removeFromList", () => {
|
||||||
|
it("should remove matching public tags", () => {
|
||||||
|
const list: List = {
|
||||||
|
kind: MUTES,
|
||||||
|
publicTags: [["p", pubkey]],
|
||||||
|
privateTags: [],
|
||||||
|
event: createDecryptedEvent(),
|
||||||
|
}
|
||||||
|
const result = removeFromList(list, pubkey)
|
||||||
|
expect(result.event.tags).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should remove matching private tags", () => {
|
||||||
|
const list: List = {
|
||||||
|
kind: MUTES,
|
||||||
|
publicTags: [],
|
||||||
|
privateTags: [["p", pubkey]],
|
||||||
|
event: createDecryptedEvent(),
|
||||||
|
}
|
||||||
|
const result = removeFromList(list, pubkey)
|
||||||
|
const plaintext = JSON.parse(result.updates.content || "[]")
|
||||||
|
expect(plaintext).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("removeFromListByPredicate", () => {
|
||||||
|
it("should remove tags matching predicate", () => {
|
||||||
|
const list: List = {
|
||||||
|
kind: MUTES,
|
||||||
|
publicTags: [
|
||||||
|
["p", pubkey],
|
||||||
|
["e", validEventId],
|
||||||
|
],
|
||||||
|
privateTags: [["p", pubkey]],
|
||||||
|
event: createDecryptedEvent(),
|
||||||
|
}
|
||||||
|
const result = removeFromListByPredicate(list, tag => tag[0] === "p")
|
||||||
|
expect(result.event.tags).toHaveLength(1)
|
||||||
|
const plaintext = JSON.parse(result.updates.content || "[]")
|
||||||
|
expect(plaintext).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("addToListPublicly", () => {
|
||||||
|
it("should add tags to public list", () => {
|
||||||
|
const list: List = {
|
||||||
|
kind: MUTES,
|
||||||
|
publicTags: [],
|
||||||
|
privateTags: [],
|
||||||
|
event: createDecryptedEvent(),
|
||||||
|
}
|
||||||
|
const result = addToListPublicly(list, ["p", pubkey])
|
||||||
|
expect(result.event.tags).toHaveLength(1)
|
||||||
|
expect(result.updates).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should deduplicate tags", () => {
|
||||||
|
const list: List = {
|
||||||
|
kind: MUTES,
|
||||||
|
publicTags: [["p", pubkey]],
|
||||||
|
privateTags: [],
|
||||||
|
event: createDecryptedEvent(),
|
||||||
|
}
|
||||||
|
const result = addToListPublicly(list, ["p", pubkey])
|
||||||
|
expect(result.event.tags).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("addToListPrivately", () => {
|
||||||
|
it("should add tags to private list", () => {
|
||||||
|
const list: List = {
|
||||||
|
kind: MUTES,
|
||||||
|
publicTags: [],
|
||||||
|
privateTags: [],
|
||||||
|
event: createDecryptedEvent(),
|
||||||
|
}
|
||||||
|
const result = addToListPrivately(list, ["p", pubkey])
|
||||||
|
const plaintext = JSON.parse(result.updates.content || "[]")
|
||||||
|
expect(plaintext).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should deduplicate private tags", () => {
|
||||||
|
const list: List = {
|
||||||
|
kind: MUTES,
|
||||||
|
publicTags: [],
|
||||||
|
privateTags: [["p", pubkey]],
|
||||||
|
event: createDecryptedEvent(),
|
||||||
|
}
|
||||||
|
const result = addToListPrivately(list, ["p", pubkey])
|
||||||
|
const plaintext = JSON.parse(result.updates.content || "[]")
|
||||||
|
expect(plaintext).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("tag validation", () => {
|
||||||
|
it("should validate pubkey tags", () => {
|
||||||
|
const event = createDecryptedEvent({
|
||||||
|
tags: [
|
||||||
|
["p", pubkey],
|
||||||
|
["p", "invalid"],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const list = readList(event)
|
||||||
|
expect(list.publicTags).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should validate event tags", () => {
|
||||||
|
const event = createDecryptedEvent({
|
||||||
|
tags: [
|
||||||
|
["e", validEventId],
|
||||||
|
["e", "invalid"],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const list = readList(event)
|
||||||
|
expect(list.publicTags).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should validate address tags", () => {
|
||||||
|
const event = createDecryptedEvent({
|
||||||
|
tags: [
|
||||||
|
["a", address],
|
||||||
|
["a", "invalid"],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const list = readList(event)
|
||||||
|
expect(list.publicTags).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should validate topic tags", () => {
|
||||||
|
const event = createDecryptedEvent({
|
||||||
|
tags: [
|
||||||
|
["t", "valid-topic"],
|
||||||
|
["t", ""],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const list = readList(event)
|
||||||
|
expect(list.publicTags).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should validate relay tags", () => {
|
||||||
|
const event = createDecryptedEvent({
|
||||||
|
tags: [
|
||||||
|
["r", "wss://relay.example.com"],
|
||||||
|
["r", "invalid"],
|
||||||
|
["relay", "wss://relay.example.com"],
|
||||||
|
["relay", "invalid"],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const list = readList(event)
|
||||||
|
expect(list.publicTags).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should accept unknown tag types", () => {
|
||||||
|
const event = createDecryptedEvent({
|
||||||
|
tags: [["unknown", "value"]],
|
||||||
|
})
|
||||||
|
const list = readList(event)
|
||||||
|
expect(list.publicTags).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import {now} from "@welshman/lib"
|
||||||
|
import {describe, it, vi, expect, beforeEach} from "vitest"
|
||||||
|
import {
|
||||||
|
makeProfile,
|
||||||
|
readProfile,
|
||||||
|
createProfile,
|
||||||
|
editProfile,
|
||||||
|
displayPubkey,
|
||||||
|
displayProfile,
|
||||||
|
profileHasName,
|
||||||
|
isPublishedProfile,
|
||||||
|
} from "../src/Profile"
|
||||||
|
import {PROFILE} from "../src/Kinds"
|
||||||
|
import type {TrustedEvent} from "../src/Events"
|
||||||
|
import type {Profile, PublishedProfile} from "../src/Profile"
|
||||||
|
|
||||||
|
describe("Profile", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Realistic Nostr data
|
||||||
|
const pubkey = "ee".repeat(32)
|
||||||
|
const id = "ff".repeat(32)
|
||||||
|
const sig = "00".repeat(64)
|
||||||
|
const currentTime = now()
|
||||||
|
|
||||||
|
const createEvent = (overrides = {}): TrustedEvent => ({
|
||||||
|
id: id,
|
||||||
|
pubkey: pubkey,
|
||||||
|
created_at: currentTime,
|
||||||
|
kind: PROFILE,
|
||||||
|
tags: [],
|
||||||
|
content: "",
|
||||||
|
sig: sig,
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("makeProfile", () => {
|
||||||
|
it("should create empty profile", () => {
|
||||||
|
const profile = makeProfile()
|
||||||
|
expect(profile).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle lud06 lightning address", () => {
|
||||||
|
const profile = makeProfile({
|
||||||
|
lud06:
|
||||||
|
"lnurl1dp68gurn8ghj7um9wfmxjcm99e3k7mf0v9cxj0m385ekvcenxc6r2c35xvukxefcv5mkvv34x5ekzd3ev56nyd3hxqurzepexejxxepnxscrvwfnv9nxzcn9xq6xyefhvgcxxcmyxymnserxfq5fns",
|
||||||
|
})
|
||||||
|
expect(profile.lnurl).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle lud16 lightning address", () => {
|
||||||
|
const profile = makeProfile({
|
||||||
|
lud16: "user@domain.com",
|
||||||
|
})
|
||||||
|
expect(profile.lnurl).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should preserve other profile fields", () => {
|
||||||
|
const profile = makeProfile({
|
||||||
|
name: "Test User",
|
||||||
|
about: "Test Bio",
|
||||||
|
picture: "https://example.com/pic.jpg",
|
||||||
|
})
|
||||||
|
expect(profile.name).toBe("Test User")
|
||||||
|
expect(profile.about).toBe("Test Bio")
|
||||||
|
expect(profile.picture).toBe("https://example.com/pic.jpg")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("readProfile", () => {
|
||||||
|
it("should parse valid profile content", () => {
|
||||||
|
const event = createEvent({
|
||||||
|
content: JSON.stringify({
|
||||||
|
name: "Test User",
|
||||||
|
about: "Test Bio",
|
||||||
|
picture: "https://example.com/pic.jpg",
|
||||||
|
lud16: "user@domain.com",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const profile = readProfile(event)
|
||||||
|
|
||||||
|
expect(profile.name).toBe("Test User")
|
||||||
|
expect(profile.about).toBe("Test Bio")
|
||||||
|
expect(profile.picture).toBe("https://example.com/pic.jpg")
|
||||||
|
expect(profile.lnurl).toBeDefined()
|
||||||
|
expect(profile.event).toBe(event)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle invalid JSON content", () => {
|
||||||
|
const event = createEvent({
|
||||||
|
content: "invalid json",
|
||||||
|
})
|
||||||
|
const profile = readProfile(event)
|
||||||
|
|
||||||
|
expect(profile.event).toBe(event)
|
||||||
|
expect(Object.keys(profile)).not.toContain("name")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle empty content", () => {
|
||||||
|
const event = createEvent({
|
||||||
|
content: "",
|
||||||
|
})
|
||||||
|
const profile = readProfile(event)
|
||||||
|
|
||||||
|
expect(profile.event).toBe(event)
|
||||||
|
expect(Object.keys(profile)).not.toContain("name")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("createProfile", () => {
|
||||||
|
it("should create profile event template", () => {
|
||||||
|
const profile: Profile = {
|
||||||
|
name: "Test User",
|
||||||
|
about: "Test Bio",
|
||||||
|
picture: "https://example.com/pic.jpg",
|
||||||
|
lud16: "user@domain.com",
|
||||||
|
}
|
||||||
|
const result = createProfile(profile)
|
||||||
|
|
||||||
|
expect(result.kind).toBe(PROFILE)
|
||||||
|
expect(JSON.parse(result.content)).toMatchObject({
|
||||||
|
name: "Test User",
|
||||||
|
about: "Test Bio",
|
||||||
|
picture: "https://example.com/pic.jpg",
|
||||||
|
lud16: "user@domain.com",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should exclude event field from content", () => {
|
||||||
|
const profile: Profile = {
|
||||||
|
name: "Test User",
|
||||||
|
event: createEvent(),
|
||||||
|
}
|
||||||
|
const result = createProfile(profile)
|
||||||
|
const content = JSON.parse(result.content)
|
||||||
|
|
||||||
|
expect(content).not.toHaveProperty("event")
|
||||||
|
expect(content).toHaveProperty("name")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("editProfile", () => {
|
||||||
|
it("should create edit event template with existing tags", () => {
|
||||||
|
const profile: PublishedProfile = {
|
||||||
|
name: "Test User",
|
||||||
|
event: createEvent({
|
||||||
|
tags: [["p", pubkey]],
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
const result = editProfile(profile)
|
||||||
|
|
||||||
|
expect(result.kind).toBe(PROFILE)
|
||||||
|
expect(result.tags).toEqual([["p", pubkey]])
|
||||||
|
expect(JSON.parse(result.content)).toMatchObject({
|
||||||
|
name: "Test User",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("displayPubkey", () => {
|
||||||
|
it("should format pubkey correctly", () => {
|
||||||
|
const display = displayPubkey(pubkey)
|
||||||
|
|
||||||
|
expect(display.length).toBe(14) // 8 + 1 + 5 characters
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("displayProfile", () => {
|
||||||
|
it("should display name if available", () => {
|
||||||
|
const profile: Profile = {name: "Test User"}
|
||||||
|
expect(displayProfile(profile)).toBe("Test User")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should display display_name if name not available", () => {
|
||||||
|
const profile: Profile = {display_name: "Test Display"}
|
||||||
|
expect(displayProfile(profile)).toBe("Test Display")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should display pubkey if no names available", () => {
|
||||||
|
const profile: Profile = {event: createEvent()}
|
||||||
|
expect(displayProfile(profile)).toMatch(/^npub1/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should display fallback if no profile", () => {
|
||||||
|
expect(displayProfile(undefined, "Fallback")).toBe("Fallback")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should truncate long names", () => {
|
||||||
|
const longName = "a".repeat(100) + " " + "b".repeat(100)
|
||||||
|
const profile: Profile = {name: longName}
|
||||||
|
// ellipsize split at space and adds ellipsis to the end of the first part
|
||||||
|
expect(displayProfile(profile).length).toBeLessThanOrEqual(103)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("profileHasName", () => {
|
||||||
|
it("should return true if profile has name", () => {
|
||||||
|
expect(profileHasName({name: "Test"})).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return true if profile has display_name", () => {
|
||||||
|
expect(profileHasName({display_name: "Test"})).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return false if profile has no names", () => {
|
||||||
|
expect(profileHasName({})).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return false if profile is undefined", () => {
|
||||||
|
expect(profileHasName(undefined)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("isPublishedProfile", () => {
|
||||||
|
it("should return true for published profile", () => {
|
||||||
|
const profile: PublishedProfile = {
|
||||||
|
name: "Test",
|
||||||
|
event: createEvent(),
|
||||||
|
}
|
||||||
|
expect(isPublishedProfile(profile)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return false for unpublished profile", () => {
|
||||||
|
const profile: Profile = {
|
||||||
|
name: "Test",
|
||||||
|
}
|
||||||
|
expect(isPublishedProfile(profile)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import {now} from "@welshman/lib"
|
||||||
|
import {describe, it, expect, beforeEach, vi, afterEach} from "vitest"
|
||||||
|
import {
|
||||||
|
Relay,
|
||||||
|
normalizeRelayUrl,
|
||||||
|
isRelayUrl,
|
||||||
|
isOnionUrl,
|
||||||
|
isLocalUrl,
|
||||||
|
isIPAddress,
|
||||||
|
isShareableRelayUrl,
|
||||||
|
displayRelayUrl,
|
||||||
|
displayRelayProfile,
|
||||||
|
} from "../src/Relay"
|
||||||
|
import {Repository} from "../src/Repository"
|
||||||
|
import type {TrustedEvent} from "../src/Events"
|
||||||
|
|
||||||
|
describe("Relay", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.useFakeTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Realistic Nostr data
|
||||||
|
const pubkey = "ee".repeat(32)
|
||||||
|
const id = "ff".repeat(32)
|
||||||
|
const sig = "00".repeat(64)
|
||||||
|
const currentTime = now()
|
||||||
|
const onionUrl = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion"
|
||||||
|
|
||||||
|
const createEvent = (overrides = {}): TrustedEvent => ({
|
||||||
|
id: id,
|
||||||
|
pubkey: pubkey,
|
||||||
|
created_at: currentTime,
|
||||||
|
kind: 1,
|
||||||
|
tags: [],
|
||||||
|
content: "Hello Nostr!",
|
||||||
|
sig: sig,
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("URL utilities", () => {
|
||||||
|
describe("isRelayUrl", () => {
|
||||||
|
it("should validate proper relay URLs", () => {
|
||||||
|
expect(isRelayUrl("wss://relay.example.com")).toBe(true)
|
||||||
|
expect(isRelayUrl("ws://relay.example.com")).toBe(true)
|
||||||
|
expect(isRelayUrl("relay.example.com")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should reject invalid URLs", () => {
|
||||||
|
expect(isRelayUrl("http://relay.example.com")).toBe(false)
|
||||||
|
expect(isRelayUrl("not-a-url")).toBe(false)
|
||||||
|
expect(isRelayUrl("ws:\\example.com\\path\\to\\file.ext")).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("isOnionUrl", () => {
|
||||||
|
it("should validate onion URLs", () => {
|
||||||
|
expect(isOnionUrl(onionUrl)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should reject non-onion URLs", () => {
|
||||||
|
expect(isOnionUrl("wss://relay.example.com")).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("isLocalUrl", () => {
|
||||||
|
it("should validate local URLs", () => {
|
||||||
|
expect(isLocalUrl("wss://relay.local")).toBe(true)
|
||||||
|
expect(isLocalUrl("ws://localhost:8080")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should reject non-local URLs", () => {
|
||||||
|
expect(isLocalUrl("wss://relay.example.com")).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("isIPAddress", () => {
|
||||||
|
it("should validate IP addresses", () => {
|
||||||
|
expect(isIPAddress("wss://192.168.1.1")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should reject domains", () => {
|
||||||
|
expect(isIPAddress("wss://relay.example.com")).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("isShareableRelayUrl", () => {
|
||||||
|
it("should validate shareable URLs", () => {
|
||||||
|
expect(isShareableRelayUrl("wss://relay.example.com")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should reject local URLs", () => {
|
||||||
|
expect(isShareableRelayUrl("wss://relay.local")).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("normalizeRelayUrl", () => {
|
||||||
|
it("should normalize URLs consistently", () => {
|
||||||
|
expect(normalizeRelayUrl("relay.example.com")).toBe("wss://relay.example.com/")
|
||||||
|
expect(normalizeRelayUrl("wss://RELAY.EXAMPLE.COM")).toBe("wss://relay.example.com/")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle onion URLs", () => {
|
||||||
|
expect(normalizeRelayUrl(onionUrl)).toBe(`ws://${onionUrl}/`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("displayRelayUrl", () => {
|
||||||
|
it("should format URLs for display", () => {
|
||||||
|
expect(displayRelayUrl("wss://relay.example.com/")).toBe("relay.example.com")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("displayRelayProfile", () => {
|
||||||
|
it("should display profile name when available", () => {
|
||||||
|
const profile = {url: "wss://relay.example.com", name: "Test Relay"}
|
||||||
|
expect(displayRelayProfile(profile)).toBe("Test Relay")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should use fallback when no name", () => {
|
||||||
|
const profile = {url: "wss://relay.example.com"}
|
||||||
|
expect(displayRelayProfile(profile, "Fallback")).toBe("Fallback")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Relay class", () => {
|
||||||
|
let relay: Relay
|
||||||
|
let repository: Repository<TrustedEvent>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
repository = new Repository<TrustedEvent>()
|
||||||
|
relay = new Relay(repository)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("EVENT handling", () => {
|
||||||
|
it("should publish events to repository", async () => {
|
||||||
|
const event = createEvent()
|
||||||
|
const publishSpy = vi.spyOn(repository, "publish")
|
||||||
|
|
||||||
|
relay.send("EVENT", event)
|
||||||
|
|
||||||
|
expect(publishSpy).toHaveBeenCalledWith(event)
|
||||||
|
|
||||||
|
// Should emit OK
|
||||||
|
const okHandler = vi.fn()
|
||||||
|
relay.on("OK", okHandler)
|
||||||
|
|
||||||
|
// Wait for async operations
|
||||||
|
await vi.runAllTimersAsync()
|
||||||
|
|
||||||
|
expect(okHandler).toHaveBeenCalledWith(event.id, true, "")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should notify matching subscribers", async () => {
|
||||||
|
const event = createEvent()
|
||||||
|
const subId = "test-sub"
|
||||||
|
const filter = {kinds: [1]}
|
||||||
|
|
||||||
|
relay.send("REQ", subId, filter)
|
||||||
|
|
||||||
|
const eventHandler = vi.fn()
|
||||||
|
relay.on("EVENT", eventHandler)
|
||||||
|
|
||||||
|
relay.send("EVENT", event)
|
||||||
|
|
||||||
|
await vi.runAllTimersAsync()
|
||||||
|
|
||||||
|
expect(eventHandler).toHaveBeenCalledWith(subId, event)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not notify for deleted events", async () => {
|
||||||
|
const event = createEvent()
|
||||||
|
repository.removeEvent(event.id)
|
||||||
|
|
||||||
|
const eventHandler = vi.fn()
|
||||||
|
relay.on("EVENT", eventHandler)
|
||||||
|
|
||||||
|
relay.send("EVENT", event)
|
||||||
|
|
||||||
|
await vi.runAllTimersAsync()
|
||||||
|
|
||||||
|
expect(eventHandler).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("REQ handling", () => {
|
||||||
|
it("should handle subscription requests", async () => {
|
||||||
|
const event = createEvent()
|
||||||
|
repository.publish(event)
|
||||||
|
|
||||||
|
const subId = "test-sub"
|
||||||
|
const filter = {kinds: [1]}
|
||||||
|
|
||||||
|
const eventHandler = vi.fn()
|
||||||
|
const eoseHandler = vi.fn()
|
||||||
|
|
||||||
|
relay.on("EVENT", eventHandler)
|
||||||
|
relay.on("EOSE", eoseHandler)
|
||||||
|
|
||||||
|
relay.send("REQ", subId, filter)
|
||||||
|
|
||||||
|
await vi.runAllTimersAsync()
|
||||||
|
|
||||||
|
expect(eventHandler).toHaveBeenCalledWith(subId, event)
|
||||||
|
expect(eoseHandler).toHaveBeenCalledWith(subId)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle multiple filters", async () => {
|
||||||
|
const event1 = createEvent({kind: 1})
|
||||||
|
const event2 = createEvent({kind: 2, id: "ee".repeat(31)})
|
||||||
|
repository.publish(event1)
|
||||||
|
repository.publish(event2)
|
||||||
|
|
||||||
|
const subId = "test-sub"
|
||||||
|
const filters = [{kinds: [1]}, {kinds: [2]}]
|
||||||
|
|
||||||
|
const eventHandler = vi.fn()
|
||||||
|
relay.on("EVENT", eventHandler)
|
||||||
|
|
||||||
|
relay.send("REQ", subId, ...filters)
|
||||||
|
|
||||||
|
await vi.runAllTimersAsync()
|
||||||
|
|
||||||
|
expect(eventHandler).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("CLOSE handling", () => {
|
||||||
|
it("should close subscriptions", async () => {
|
||||||
|
const subId = "test-sub"
|
||||||
|
relay.send("REQ", subId, {kinds: [1]})
|
||||||
|
relay.send("CLOSE", subId)
|
||||||
|
|
||||||
|
await vi.runAllTimersAsync()
|
||||||
|
|
||||||
|
const event = createEvent()
|
||||||
|
const eventHandler = vi.fn()
|
||||||
|
relay.on("EVENT", eventHandler)
|
||||||
|
|
||||||
|
relay.send("EVENT", event)
|
||||||
|
|
||||||
|
await vi.runAllTimersAsync()
|
||||||
|
|
||||||
|
expect(eventHandler).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
import {now} from "@welshman/lib"
|
||||||
|
import {getAddress} from "@welshman/util"
|
||||||
|
import {describe, it, vi, expect, beforeEach} from "vitest"
|
||||||
|
import {Repository} from "../src/Repository"
|
||||||
|
import type {TrustedEvent} from "../src/Events"
|
||||||
|
import {DELETE, MUTES} from "../src/Kinds"
|
||||||
|
|
||||||
|
describe("Repository", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Realistic Nostr data
|
||||||
|
const pubkey = "ee".repeat(32)
|
||||||
|
const id = "ff".repeat(32)
|
||||||
|
const sig = "00".repeat(64)
|
||||||
|
const currentTime = now()
|
||||||
|
|
||||||
|
const createEvent = (overrides = {}): TrustedEvent => ({
|
||||||
|
id: id,
|
||||||
|
pubkey: pubkey,
|
||||||
|
created_at: currentTime,
|
||||||
|
kind: 1,
|
||||||
|
tags: [],
|
||||||
|
content: "Hello Nostr!",
|
||||||
|
sig: sig,
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("basic operations", () => {
|
||||||
|
let repo: Repository
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
repo = new Repository()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should publish and retrieve events", () => {
|
||||||
|
const event = createEvent()
|
||||||
|
expect(repo.publish(event)).toBe(true)
|
||||||
|
expect(repo.getEvent(event.id)).toEqual(event)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not publish invalid events", () => {
|
||||||
|
const invalidEvent = {} as TrustedEvent
|
||||||
|
const result = repo.publish(invalidEvent)
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle duplicate events", () => {
|
||||||
|
const event = createEvent()
|
||||||
|
expect(repo.publish(event)).toBe(true)
|
||||||
|
expect(repo.publish(event)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should check if events exist", () => {
|
||||||
|
const event = createEvent()
|
||||||
|
repo.publish(event)
|
||||||
|
expect(repo.hasEvent(event)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("replaceable events", () => {
|
||||||
|
let repo: Repository
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
repo = new Repository()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle replaceable events", () => {
|
||||||
|
const event1 = createEvent({kind: MUTES, created_at: currentTime - 100})
|
||||||
|
const event2 = createEvent({kind: MUTES, created_at: currentTime, id: "ee".repeat(32)})
|
||||||
|
|
||||||
|
const address1 = getAddress(event1)
|
||||||
|
const address2 = getAddress(event2)
|
||||||
|
|
||||||
|
repo.publish(event1)
|
||||||
|
repo.publish(event2)
|
||||||
|
|
||||||
|
expect(repo.getEvent(event1.id)).toEqual(event1)
|
||||||
|
expect(repo.getEvent(address1)).toEqual(event2)
|
||||||
|
expect(repo.getEvent(event2.id)).toEqual(event2)
|
||||||
|
expect(repo.getEvent(address2)).toEqual(event2)
|
||||||
|
|
||||||
|
const event3 = createEvent({kind: MUTES, created_at: currentTime - 50, id: "dd".repeat(32)})
|
||||||
|
|
||||||
|
repo.publish(event3)
|
||||||
|
|
||||||
|
expect(repo.getEvent(event3.id)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not replace with older events", () => {
|
||||||
|
const event1 = createEvent({kind: MUTES, created_at: currentTime})
|
||||||
|
const event2 = createEvent({kind: MUTES, created_at: currentTime - 100})
|
||||||
|
|
||||||
|
repo.publish(event1)
|
||||||
|
repo.publish(event2)
|
||||||
|
|
||||||
|
expect(repo.getEvent(event1.id)).toEqual(event1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("delete events", () => {
|
||||||
|
let repo: Repository
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
repo = new Repository()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle delete events", () => {
|
||||||
|
const event = createEvent()
|
||||||
|
const deleteEvent = createEvent({
|
||||||
|
id: "ee".repeat(32),
|
||||||
|
kind: DELETE,
|
||||||
|
tags: [["e", event.id]],
|
||||||
|
created_at: currentTime + 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
repo.publish(event)
|
||||||
|
repo.publish(deleteEvent)
|
||||||
|
|
||||||
|
expect(repo.isDeleted(event)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle delete by address", () => {
|
||||||
|
const event = createEvent({kind: MUTES})
|
||||||
|
const deleteEvent = createEvent({
|
||||||
|
id: "ee".repeat(32),
|
||||||
|
kind: DELETE,
|
||||||
|
tags: [["a", `10000:${event.pubkey}:`]],
|
||||||
|
created_at: currentTime + 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
repo.publish(event)
|
||||||
|
repo.publish(deleteEvent)
|
||||||
|
|
||||||
|
expect(repo.isDeletedByAddress(event)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("query operations", () => {
|
||||||
|
let repo: Repository
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
repo = new Repository()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should throw on invalid queries", () => {
|
||||||
|
expect(() => repo.query([{limit: 10}], {shouldSort: false})).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should query by ids", () => {
|
||||||
|
const event = createEvent()
|
||||||
|
repo.publish(event)
|
||||||
|
|
||||||
|
const results = repo.query([{ids: [event.id]}])
|
||||||
|
expect(results).toContain(event)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should query by authors", () => {
|
||||||
|
const event = createEvent()
|
||||||
|
repo.publish(event)
|
||||||
|
|
||||||
|
const results = repo.query([{authors: [event.pubkey]}])
|
||||||
|
expect(results).toContain(event)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should query by kinds", () => {
|
||||||
|
const event = createEvent({kind: 1})
|
||||||
|
repo.publish(event)
|
||||||
|
|
||||||
|
const results = repo.query([{kinds: [1]}])
|
||||||
|
expect(results).toContain(event)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should query by tags", () => {
|
||||||
|
const event = createEvent({tags: [["p", pubkey]]})
|
||||||
|
repo.publish(event)
|
||||||
|
|
||||||
|
const results = repo.query([{"#p": [pubkey]}])
|
||||||
|
expect(results).toContain(event)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should query by time range", () => {
|
||||||
|
const event = createEvent()
|
||||||
|
repo.publish(event)
|
||||||
|
|
||||||
|
const results = repo.query([
|
||||||
|
{
|
||||||
|
since: currentTime - 3600,
|
||||||
|
until: currentTime + 3600,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(results).toContain(event)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle multiple filters", () => {
|
||||||
|
const event = createEvent({kind: 1})
|
||||||
|
repo.publish(event)
|
||||||
|
|
||||||
|
const results = repo.query([{kinds: [1]}, {authors: [event.pubkey]}])
|
||||||
|
expect(results).toHaveLength(1)
|
||||||
|
expect(results).toContain(event)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should respect limit parameter", () => {
|
||||||
|
const events = [
|
||||||
|
createEvent({id: id + "1", created_at: currentTime}),
|
||||||
|
createEvent({id: id + "2", created_at: currentTime - 100}),
|
||||||
|
]
|
||||||
|
|
||||||
|
events.forEach(e => repo.publish(e))
|
||||||
|
|
||||||
|
const results = repo.query([{limit: 1}])
|
||||||
|
expect(results).toHaveLength(1)
|
||||||
|
expect(results[0]).toEqual(events[0]) // Most recent event
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not return deleted events", () => {
|
||||||
|
const event = createEvent()
|
||||||
|
const deleteEvent = createEvent({
|
||||||
|
id: "ee".repeat(32),
|
||||||
|
kind: DELETE,
|
||||||
|
tags: [["e", event.id]],
|
||||||
|
created_at: currentTime + 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
repo.publish(event)
|
||||||
|
repo.publish(deleteEvent)
|
||||||
|
|
||||||
|
const results = repo.query([{kinds: [1]}])
|
||||||
|
expect(results).not.toContain(event)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("dump and load", () => {
|
||||||
|
let repo: Repository
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
repo = new Repository()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should dump all events", () => {
|
||||||
|
const event = createEvent()
|
||||||
|
repo.publish(event)
|
||||||
|
|
||||||
|
const dumped = repo.dump()
|
||||||
|
expect(dumped).toContain(event)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should load events", () => {
|
||||||
|
const event = createEvent()
|
||||||
|
repo.load([event])
|
||||||
|
|
||||||
|
expect(repo.getEvent(event.id)).toEqual(event)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle chunked loading", () => {
|
||||||
|
const events = Array.from({length: 1500}, (_, i) => createEvent({id: id.slice(0, -1) + i}))
|
||||||
|
|
||||||
|
repo.load(events, 500)
|
||||||
|
expect(repo.dump()).toHaveLength(1500)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should emit update events", () => {
|
||||||
|
const event = createEvent()
|
||||||
|
const updateHandler = vi.fn()
|
||||||
|
|
||||||
|
repo.on("update", updateHandler)
|
||||||
|
repo.load([event])
|
||||||
|
|
||||||
|
expect(updateHandler).toHaveBeenCalledWith({
|
||||||
|
added: [event],
|
||||||
|
removed: new Set(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("wrapped events", () => {
|
||||||
|
let repo: Repository
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
repo = new Repository()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle wrapped events", () => {
|
||||||
|
const wrapped = createEvent()
|
||||||
|
const event = createEvent({
|
||||||
|
wrap: wrapped,
|
||||||
|
})
|
||||||
|
|
||||||
|
repo.publish(event)
|
||||||
|
expect(repo.eventsByWrap.get(wrapped.id)).toEqual(event)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("event removal", () => {
|
||||||
|
let repo: Repository
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
repo = new Repository()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should remove events", () => {
|
||||||
|
const event = createEvent()
|
||||||
|
repo.publish(event)
|
||||||
|
repo.removeEvent(event.id)
|
||||||
|
|
||||||
|
expect(repo.getEvent(event.id)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should remove wrapped events", () => {
|
||||||
|
const wrapped = createEvent()
|
||||||
|
const event = createEvent({
|
||||||
|
wrap: wrapped,
|
||||||
|
})
|
||||||
|
|
||||||
|
repo.publish(event)
|
||||||
|
repo.removeEvent(event.id)
|
||||||
|
|
||||||
|
expect(repo.eventsByWrap.get(wrapped.id)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should emit update on removal", () => {
|
||||||
|
const event = createEvent()
|
||||||
|
const updateHandler = vi.fn()
|
||||||
|
|
||||||
|
repo.on("update", updateHandler)
|
||||||
|
repo.publish(event)
|
||||||
|
repo.removeEvent(event.id)
|
||||||
|
|
||||||
|
expect(updateHandler).toHaveBeenLastCalledWith({
|
||||||
|
added: [],
|
||||||
|
removed: [event.id],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import {describe, it, vi, expect, beforeEach} from "vitest"
|
||||||
|
import * as Tags from "../src/Tags"
|
||||||
|
|
||||||
|
describe("Tags", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
const pubkey = "ee".repeat(32)
|
||||||
|
const eventId = "ff".repeat(32)
|
||||||
|
const address = `30023:${pubkey}:test`
|
||||||
|
|
||||||
|
describe("basic tag operations", () => {
|
||||||
|
it("should get tags by type", () => {
|
||||||
|
const tags = [
|
||||||
|
["p", pubkey],
|
||||||
|
["e", eventId],
|
||||||
|
["t", "test"],
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(Tags.getTags("p", tags)).toHaveLength(1)
|
||||||
|
expect(Tags.getTags(["p", "e"], tags)).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should get single tag by type", () => {
|
||||||
|
const tags = [
|
||||||
|
["p", pubkey],
|
||||||
|
["e", eventId],
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(Tags.getTag("p", tags)).toEqual(["p", pubkey])
|
||||||
|
expect(Tags.getTag(["p", "e"], tags)).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should get tag values", () => {
|
||||||
|
const tags = [
|
||||||
|
["p", pubkey],
|
||||||
|
["e", eventId],
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(Tags.getTagValues("p", tags)).toEqual([pubkey])
|
||||||
|
expect(Tags.getTagValue("p", tags)).toBe(pubkey)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("specific tag types", () => {
|
||||||
|
describe("event tags", () => {
|
||||||
|
it("should get valid event tags", () => {
|
||||||
|
const tags = [
|
||||||
|
["e", eventId],
|
||||||
|
["e", "invalid"],
|
||||||
|
["other", eventId],
|
||||||
|
]
|
||||||
|
|
||||||
|
const eventTags = Tags.getEventTags(tags)
|
||||||
|
expect(eventTags).toHaveLength(1)
|
||||||
|
expect(Tags.getEventTagValues(tags)).toEqual([eventId])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("address tags", () => {
|
||||||
|
it("should get valid address tags", () => {
|
||||||
|
const tags = [
|
||||||
|
["a", address],
|
||||||
|
["a", "invalid"],
|
||||||
|
["other", address],
|
||||||
|
]
|
||||||
|
|
||||||
|
const addressTags = Tags.getAddressTags(tags)
|
||||||
|
expect(addressTags).toHaveLength(1)
|
||||||
|
expect(Tags.getAddressTagValues(tags)).toEqual([address])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("pubkey tags", () => {
|
||||||
|
it("should get valid pubkey tags", () => {
|
||||||
|
const tags = [
|
||||||
|
["p", pubkey],
|
||||||
|
["p", "invalid"],
|
||||||
|
["other", pubkey],
|
||||||
|
]
|
||||||
|
|
||||||
|
const pubkeyTags = Tags.getPubkeyTags(tags)
|
||||||
|
expect(pubkeyTags).toHaveLength(1)
|
||||||
|
expect(Tags.getPubkeyTagValues(tags)).toEqual([pubkey])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("topic tags", () => {
|
||||||
|
it("should get topic tags", () => {
|
||||||
|
const tags = [
|
||||||
|
["t", "topic1"],
|
||||||
|
["t", "#topic2"],
|
||||||
|
["other", "topic3"],
|
||||||
|
]
|
||||||
|
|
||||||
|
const topicTags = Tags.getTopicTags(tags)
|
||||||
|
expect(topicTags).toHaveLength(2)
|
||||||
|
expect(Tags.getTopicTagValues(tags)).toEqual(["topic1", "topic2"])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("relay tags", () => {
|
||||||
|
it("should get valid relay tags", () => {
|
||||||
|
const tags = [
|
||||||
|
["r", "wss://relay.example.com"],
|
||||||
|
["relay", "wss://relay2.example.com"],
|
||||||
|
["r", "invalid"],
|
||||||
|
["other", "wss://relay.example.com"],
|
||||||
|
]
|
||||||
|
|
||||||
|
const relayTags = Tags.getRelayTags(tags)
|
||||||
|
expect(relayTags).toHaveLength(2)
|
||||||
|
expect(Tags.getRelayTagValues(tags)).toEqual([
|
||||||
|
"wss://relay.example.com",
|
||||||
|
"wss://relay2.example.com",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("group tags", () => {
|
||||||
|
it("should get valid group tags", () => {
|
||||||
|
const tags = [
|
||||||
|
["h", "group1", "wss://relay.example.com"],
|
||||||
|
["group", "group2", "wss://relay.example.com"],
|
||||||
|
["h", "invalid"],
|
||||||
|
["other", "group3", "wss://relay.example.com"],
|
||||||
|
]
|
||||||
|
|
||||||
|
const groupTags = Tags.getGroupTags(tags)
|
||||||
|
expect(groupTags).toHaveLength(2)
|
||||||
|
expect(Tags.getGroupTagValues(tags)).toEqual(["group1", "group2"])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("kind tags", () => {
|
||||||
|
it("should get valid kind tags", () => {
|
||||||
|
const tags = [
|
||||||
|
["k", "1"],
|
||||||
|
["k", "invalid"],
|
||||||
|
["other", "1"],
|
||||||
|
]
|
||||||
|
|
||||||
|
const kindTags = Tags.getKindTags(tags)
|
||||||
|
expect(kindTags).toHaveLength(1)
|
||||||
|
expect(Tags.getKindTagValues(tags)).toEqual([1])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("comment and reply tags", () => {
|
||||||
|
describe("comment tags", () => {
|
||||||
|
it("should separate root and reply tags", () => {
|
||||||
|
const tags = [
|
||||||
|
["E", eventId],
|
||||||
|
["e", eventId],
|
||||||
|
["P", pubkey],
|
||||||
|
["p", pubkey],
|
||||||
|
["K", "1"],
|
||||||
|
["k", "1"],
|
||||||
|
]
|
||||||
|
|
||||||
|
const {roots, replies} = Tags.getCommentTags(tags)
|
||||||
|
expect(roots).toHaveLength(3)
|
||||||
|
expect(replies).toHaveLength(3)
|
||||||
|
|
||||||
|
const values = Tags.getCommentTagValues(tags)
|
||||||
|
expect(values.roots).toContain(eventId)
|
||||||
|
expect(values.replies).toContain(eventId)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("reply tags", () => {
|
||||||
|
it("should handle root replies", () => {
|
||||||
|
const tags = [
|
||||||
|
["e", eventId, "", "root"],
|
||||||
|
["e", eventId, "", "reply"],
|
||||||
|
["q", eventId],
|
||||||
|
]
|
||||||
|
|
||||||
|
const {roots, replies, mentions} = Tags.getReplyTags(tags)
|
||||||
|
expect(roots).toHaveLength(1)
|
||||||
|
expect(replies).toHaveLength(1)
|
||||||
|
expect(mentions).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle implicit positions", () => {
|
||||||
|
const tags = [
|
||||||
|
["e", eventId],
|
||||||
|
["e", eventId],
|
||||||
|
["e", eventId],
|
||||||
|
]
|
||||||
|
|
||||||
|
const {roots, replies, mentions} = Tags.getReplyTags(tags)
|
||||||
|
expect(roots).toHaveLength(1)
|
||||||
|
expect(replies).toHaveLength(1)
|
||||||
|
expect(mentions).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle address tags", () => {
|
||||||
|
const tags = [
|
||||||
|
["a", address, "", "root"],
|
||||||
|
["a", address, "", "reply"],
|
||||||
|
]
|
||||||
|
|
||||||
|
const {roots, replies} = Tags.getReplyTags(tags)
|
||||||
|
expect(roots).toHaveLength(1)
|
||||||
|
expect(replies).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("tag utilities", () => {
|
||||||
|
it("should deduplicate tags", () => {
|
||||||
|
const tags = [
|
||||||
|
["p", pubkey],
|
||||||
|
["p", pubkey],
|
||||||
|
["p", pubkey, "extra"],
|
||||||
|
]
|
||||||
|
|
||||||
|
const unique = Tags.uniqTags(tags)
|
||||||
|
expect(unique).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should parse iMeta format", () => {
|
||||||
|
const imeta = [`p ${pubkey}`]
|
||||||
|
const tags = Tags.tagsFromIMeta(imeta)
|
||||||
|
expect(tags).toHaveLength(1)
|
||||||
|
expect(tags[0]).toEqual(["p", pubkey])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import {describe, it, vi, expect, beforeEach} from "vitest"
|
||||||
|
import {hrpToMillisat, getInvoiceAmount, getLnUrl, zapFromEvent, Zapper} from "../src/Zaps"
|
||||||
|
import type {TrustedEvent} from "../src/Events"
|
||||||
|
import {now} from "@welshman/lib"
|
||||||
|
|
||||||
|
describe("Zaps", () => {
|
||||||
|
const recipient = "dd".repeat(32)
|
||||||
|
const zapper = "ee".repeat(32)
|
||||||
|
// nostrPubkey is the pubkey the ln server will use to sign zap receipt events
|
||||||
|
const nostrPubkey = "ff".repeat(32)
|
||||||
|
const currentTime = now()
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("hrpToMillisat", () => {
|
||||||
|
it("should convert basic amounts", () => {
|
||||||
|
expect(hrpToMillisat("100")).toBe(BigInt(10000000000000))
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle milli amounts", () => {
|
||||||
|
expect(hrpToMillisat("100m")).toBe(BigInt(10000000000))
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle micro amounts", () => {
|
||||||
|
expect(hrpToMillisat("100u")).toBe(BigInt(10000000))
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle nano amounts", () => {
|
||||||
|
expect(hrpToMillisat("100n")).toBe(BigInt(10000))
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle pico amounts", () => {
|
||||||
|
expect(hrpToMillisat("100p")).toBe(BigInt(10))
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should throw on invalid multiplier", () => {
|
||||||
|
expect(() => hrpToMillisat("100x")).toThrow("Not a valid multiplier for the amount")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should throw on invalid amount", () => {
|
||||||
|
expect(() => hrpToMillisat("ppp")).toThrow("Not a valid human readable amount")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should throw on amount outside valid range", () => {
|
||||||
|
expect(() => hrpToMillisat("2100000000000000001")).toThrow("Amount is outside of valid range")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getInvoiceAmount", () => {
|
||||||
|
it("should extract amount from bolt11 invoice", () => {
|
||||||
|
const bolt11 = "lnbc100n1..." // Simplified for test
|
||||||
|
expect(getInvoiceAmount(bolt11)).toBe(10000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getLnUrl", () => {
|
||||||
|
it("should handle lnurl1 addresses", () => {
|
||||||
|
const lnurl =
|
||||||
|
"lnurl1dp68gurn8ghj7um9wfmxjcm99e3k7mf0v9cxj0m385ekvcenxc6r2c35xvukxefcv5mkvv34x5ekzd3ev56nyd3hxqurzepexejxxepnxscrvwfnv9nxzcn9xq6xyefhvgcxxcmyxymnserxfq5fns"
|
||||||
|
expect(getLnUrl(lnurl)).toBe(lnurl)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should encode regular URLs", () => {
|
||||||
|
const url = "https://example.com/.well-known/lnurlp/test"
|
||||||
|
const result = getLnUrl(url)
|
||||||
|
expect(result?.startsWith("lnurl1")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle lud16 addresses", () => {
|
||||||
|
const address = "user@domain.com"
|
||||||
|
const result = getLnUrl(address)
|
||||||
|
expect(result?.startsWith("lnurl1")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return null for invalid input", () => {
|
||||||
|
expect(getLnUrl("invalid")).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("zapFromEvent", () => {
|
||||||
|
const createZapRequest = (): TrustedEvent => ({
|
||||||
|
id: "ff".repeat(32),
|
||||||
|
sig: "00".repeat(64),
|
||||||
|
kind: 9734,
|
||||||
|
pubkey: zapper,
|
||||||
|
created_at: currentTime,
|
||||||
|
content: "",
|
||||||
|
tags: [
|
||||||
|
["amount", "100000"],
|
||||||
|
["lnurl", "lnurl1..."],
|
||||||
|
["p", recipient],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const createZapReceipt = (request: TrustedEvent): TrustedEvent => ({
|
||||||
|
id: "aa".repeat(32),
|
||||||
|
sig: "11".repeat(64),
|
||||||
|
kind: 9735,
|
||||||
|
pubkey: nostrPubkey,
|
||||||
|
created_at: currentTime + 60,
|
||||||
|
content: "",
|
||||||
|
tags: [
|
||||||
|
["bolt11", "lnbc1000n1..."],
|
||||||
|
["description", JSON.stringify(request)],
|
||||||
|
["p", recipient],
|
||||||
|
["P", zapper],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const validZapper: Zapper = {
|
||||||
|
lnurl: "lnurl1...",
|
||||||
|
pubkey: recipient,
|
||||||
|
nostrPubkey: nostrPubkey,
|
||||||
|
callback: "https://example.com/callback",
|
||||||
|
minSendable: 1000,
|
||||||
|
maxSendable: 100000000,
|
||||||
|
allowsNostr: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
it("should validate a legitimate zap", () => {
|
||||||
|
const request = createZapRequest()
|
||||||
|
const response = createZapReceipt(request)
|
||||||
|
|
||||||
|
const result = zapFromEvent(response, validZapper)
|
||||||
|
|
||||||
|
expect(result).toBeTruthy()
|
||||||
|
expect(result?.request).toEqual(request)
|
||||||
|
expect(result?.response).toEqual(response)
|
||||||
|
expect(result?.invoiceAmount).toBe(100000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should reject self-zaps", () => {
|
||||||
|
const request = createZapRequest()
|
||||||
|
request.pubkey = validZapper.pubkey! // Self-zap
|
||||||
|
const response = createZapReceipt(request)
|
||||||
|
|
||||||
|
const result = zapFromEvent(response, validZapper)
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should reject amount mismatch", () => {
|
||||||
|
const request = createZapRequest()
|
||||||
|
const response = createZapReceipt(request)
|
||||||
|
response.tags = response.tags.map(tag =>
|
||||||
|
tag[0] === "bolt11" ? ["bolt11", "lnbc200n1..."] : tag,
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = zapFromEvent(response, validZapper)
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should reject incorrect zapper pubkey", () => {
|
||||||
|
const request = createZapRequest()
|
||||||
|
const response = createZapReceipt(request)
|
||||||
|
response.pubkey = "deadbeef".repeat(8) // Not the ln server pubkey
|
||||||
|
|
||||||
|
const result = zapFromEvent(response, validZapper)
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should reject incorrect lnurl", () => {
|
||||||
|
const request = createZapRequest()
|
||||||
|
request.tags = request.tags.map(tag =>
|
||||||
|
tag[0] === "lnurl" ? ["lnurl", "different_lnurl"] : tag,
|
||||||
|
)
|
||||||
|
const response = createZapReceipt(request)
|
||||||
|
|
||||||
|
const result = zapFromEvent(response, validZapper)
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle invalid description JSON", () => {
|
||||||
|
const response = createZapReceipt(createZapRequest())
|
||||||
|
response.tags = response.tags.map(tag =>
|
||||||
|
tag[0] === "description" ? ["description", "invalid json"] : tag,
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = zapFromEvent(response, validZapper)
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should accept zap when recipient is zapper", () => {
|
||||||
|
const request = createZapRequest()
|
||||||
|
const response = createZapReceipt(request)
|
||||||
|
response.pubkey = recipient // Recipient is zapper
|
||||||
|
|
||||||
|
const result = zapFromEvent(response, validZapper)
|
||||||
|
|
||||||
|
expect(result).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -168,7 +168,7 @@ export class Repository<E extends HashedEvent = TrustedEvent> extends Emitter {
|
|||||||
const duplicate = this.eventsByAddress.get(address)
|
const duplicate = this.eventsByAddress.get(address)
|
||||||
|
|
||||||
if (duplicate) {
|
if (duplicate) {
|
||||||
// If our event is older than the duplicate, we're done
|
// If our event is younger than the duplicate, we're done
|
||||||
if (event.created_at <= duplicate.created_at) {
|
if (event.created_at <= duplicate.created_at) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import {resolve} from "path"
|
||||||
|
import {defineConfig} from "vitest/config"
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "happy-dom",
|
||||||
|
include: ["packages/**/*.test.ts"],
|
||||||
|
coverage: {
|
||||||
|
provider: "v8",
|
||||||
|
reporter: ["text", "json", "html"],
|
||||||
|
},
|
||||||
|
globals: true,
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@welshman/app": resolve(__dirname, "packages/app/src"),
|
||||||
|
"@welshman/content": resolve(__dirname, "packages/content/src"),
|
||||||
|
"@welshman/dvm": resolve(__dirname, "packages/dvm/src"),
|
||||||
|
"@welshman/feeds": resolve(__dirname, "packages/feeds/src"),
|
||||||
|
"@welshman/lib": resolve(__dirname, "packages/lib/src"),
|
||||||
|
"@welshman/net": resolve(__dirname, "packages/net/src"),
|
||||||
|
"@welshman/signer": resolve(__dirname, "packages/signer/src"),
|
||||||
|
"@welshman/store": resolve(__dirname, "packages/store/src"),
|
||||||
|
"@welshman/util": resolve(__dirname, "packages/util/src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user