Add tests

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