Move domain stuff to sub directory, clean up base classes

This commit is contained in:
Jon Staab
2026-06-19 10:42:58 -07:00
parent bfd91f2d39
commit 1bd62d3024
95 changed files with 4956 additions and 1353 deletions
@@ -0,0 +1,110 @@
import {describe, it, expect} from "vitest"
import {makeSecret, BLOCKED_RELAYS, NOTE, getTagValues, normalizeRelayUrl} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {BlockedRelayList, BlockedRelayListBuilder} from "../src/kinds/BlockedRelayList"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const r1 = "wss://relay.one.example/"
const r2 = "wss://relay.two.example/"
const r3 = "wss://relay.three.example/"
const makeEvent = (o: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: BLOCKED_RELAYS,
tags: [],
content: "",
sig: "00".repeat(64),
...o,
}) as TrustedEvent
describe("BlockedRelayList", () => {
it("reads relay urls from relay tags", async () => {
const event = makeEvent({
tags: [
["relay", r1],
["relay", r2],
["alt", "x"],
],
})
const list = await BlockedRelayList.fromEvent(event)
expect(list.urls().sort()).toEqual([r1, r2].sort())
expect(list.includes(r1)).toBe(true)
expect(list.includes(r3)).toBe(false)
})
it("round-trips without duplicating tags and preserves passthrough", async () => {
const event = makeEvent({
tags: [
["relay", r1],
["relay", r2],
["alt", "x"],
],
})
const list = await BlockedRelayList.fromEvent(event)
const tmpl = await list.builder().toTemplate(signer)
expect(tmpl.kind).toBe(BLOCKED_RELAYS)
expect(tmpl.tags.filter(t => t[0] === "relay").length).toBe(2)
expect(tmpl.tags).toContainEqual(["alt", "x"])
})
it("builds from a fresh builder and normalizes urls", async () => {
const tmpl = await new BlockedRelayListBuilder()
.addRelay("wss://relay.one.example")
.toTemplate(signer)
expect(getTagValues("relay", tmpl.tags)).toEqual([normalizeRelayUrl("wss://relay.one.example")])
})
it("setRelays replaces existing relays", async () => {
const event = makeEvent({tags: [["relay", r1]]})
const list = await BlockedRelayList.fromEvent(event)
const tmpl = await list.builder().setRelays([r2, r3]).toTemplate(signer)
expect(getTagValues("relay", tmpl.tags).sort()).toEqual([r2, r3].sort())
})
it("round-trips public and private entries through encryption", async () => {
const event = await new BlockedRelayListBuilder()
.addRelay(r1)
.addPrivate(["relay", r2])
.toEvent(signer)
expect(event.kind).toBe(BLOCKED_RELAYS)
expect(getTagValues("relay", event.tags)).toEqual([r1])
expect(event.content).not.toBe("")
const decrypted = await BlockedRelayList.fromEvent(event, signer)
expect(decrypted.decrypted).toBe(true)
expect(decrypted.urls().sort()).toEqual([r1, r2].sort())
const publicOnly = await BlockedRelayList.fromEvent(event)
expect(publicOnly.decrypted).toBe(false)
expect(publicOnly.urls()).toEqual([r1])
})
it("preserves undecrypted ciphertext on pass-through", async () => {
const event = await new BlockedRelayListBuilder().addPrivate(["relay", r2]).toEvent(signer)
const undecrypted = await BlockedRelayList.fromEvent(event)
const tmpl = await undecrypted.builder().toTemplate(signer)
expect(tmpl.content).toBe(event.content)
})
it("throws on the wrong kind", async () => {
await expect(BlockedRelayList.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
@@ -0,0 +1,100 @@
import {describe, it, expect} from "vitest"
import {makeSecret, BLOSSOM_SERVERS, NOTE, getTagValues, normalizeRelayUrl} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {BlossomServerList, BlossomServerListBuilder} from "../src/kinds/BlossomServerList"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const s1 = "https://blossom.one.example/"
const s2 = "https://blossom.two.example/"
const s3 = "https://blossom.three.example/"
const norm = (url: string) => normalizeRelayUrl(url)
const makeEvent = (o: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: BLOSSOM_SERVERS,
tags: [],
content: "",
sig: "00".repeat(64),
...o,
}) as TrustedEvent
describe("BlossomServerList", () => {
it("reads server urls from server tags", async () => {
const event = makeEvent({
tags: [
["server", s1],
["server", s2],
["alt", "x"],
],
})
const list = await BlossomServerList.fromEvent(event)
expect(list.servers().sort()).toEqual([norm(s1), norm(s2)].sort())
expect(list.includes(s1)).toBe(true)
expect(list.includes(s3)).toBe(false)
})
it("round-trips without duplicating tags and preserves passthrough", async () => {
const event = makeEvent({
tags: [
["server", s1],
["server", s2],
["alt", "x"],
],
})
const list = await BlossomServerList.fromEvent(event)
const tmpl = await list.builder().toTemplate(signer)
expect(tmpl.kind).toBe(BLOSSOM_SERVERS)
expect(tmpl.tags.filter(t => t[0] === "server").length).toBe(2)
expect(tmpl.tags).toContainEqual(["alt", "x"])
})
it("builds from a fresh builder and normalizes urls", async () => {
const tmpl = await new BlossomServerListBuilder().addServer(s1).toTemplate(signer)
expect(getTagValues("server", tmpl.tags)).toEqual([norm(s1)])
})
it("setServers replaces existing servers", async () => {
const event = makeEvent({tags: [["server", s1]]})
const list = await BlossomServerList.fromEvent(event)
const tmpl = await list.builder().setServers([s2, s3]).toTemplate(signer)
expect(getTagValues("server", tmpl.tags).sort()).toEqual([norm(s2), norm(s3)].sort())
})
it("round-trips public and private entries through encryption", async () => {
const event = await new BlossomServerListBuilder()
.addServer(s1)
.addPrivate(["server", norm(s2)])
.toEvent(signer)
expect(getTagValues("server", event.tags)).toEqual([norm(s1)])
expect(event.content).not.toBe("")
const decrypted = await BlossomServerList.fromEvent(event, signer)
expect(decrypted.decrypted).toBe(true)
expect(decrypted.servers().sort()).toEqual([norm(s1), norm(s2)].sort())
const publicOnly = await BlossomServerList.fromEvent(event)
expect(publicOnly.decrypted).toBe(false)
expect(publicOnly.servers()).toEqual([norm(s1)])
})
it("throws on the wrong kind", async () => {
await expect(BlossomServerList.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
@@ -0,0 +1,135 @@
import {describe, it, expect} from "vitest"
import {
makeSecret,
BOOKMARKS,
NOTE,
getEventTagValues,
getTopicTagValues,
} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {BookmarkList, BookmarkListBuilder} from "../src/kinds/BookmarkList"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const noteId = "11".repeat(32)
const noteId2 = "22".repeat(32)
const address = `30023:${"aa".repeat(32)}:article-1`
const url = "https://example.com/post"
const makeEvent = (o: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: BOOKMARKS,
tags: [],
content: "",
sig: "00".repeat(64),
...o,
}) as TrustedEvent
describe("BookmarkList", () => {
it("reads mixed bookmark entries", async () => {
const event = makeEvent({
tags: [
["e", noteId],
["a", address],
["t", "nostr"],
["r", url],
["alt", "x"],
],
})
const list = await BookmarkList.fromEvent(event)
expect(list.ids()).toEqual([noteId])
expect(list.addresses()).toEqual([address])
expect(list.topics()).toEqual(["nostr"])
expect(list.urls()).toEqual([url])
})
it("round-trips without duplicating tags and preserves passthrough", async () => {
const event = makeEvent({
tags: [
["e", noteId],
["a", address],
["t", "nostr"],
["r", url],
["alt", "x"],
],
})
const list = await BookmarkList.fromEvent(event)
const tmpl = await list.builder().toTemplate(signer)
expect(tmpl.kind).toBe(BOOKMARKS)
expect(tmpl.tags.filter(t => t[0] === "e").length).toBe(1)
expect(tmpl.tags.filter(t => t[0] === "a").length).toBe(1)
expect(tmpl.tags.filter(t => t[0] === "t").length).toBe(1)
expect(tmpl.tags.filter(t => t[0] === "r").length).toBe(1)
expect(tmpl.tags).toContainEqual(["alt", "x"])
})
it("builds from a fresh builder", async () => {
const tmpl = await new BookmarkListBuilder()
.bookmarkPublicly(["e", noteId])
.bookmarkPublicly(["t", "nostr"])
.toTemplate(signer)
expect(getEventTagValues(tmpl.tags)).toEqual([noteId])
expect(getTopicTagValues(tmpl.tags)).toEqual(["nostr"])
})
it("removeBookmark removes by value", async () => {
const event = makeEvent({tags: [["e", noteId], ["e", noteId2]]})
const list = await BookmarkList.fromEvent(event)
const tmpl = await list.builder().removeBookmark(noteId).toTemplate(signer)
expect(getEventTagValues(tmpl.tags)).toEqual([noteId2])
})
it("round-trips public and private bookmarks through encryption", async () => {
const event = await new BookmarkListBuilder()
.bookmarkPublicly(["e", noteId])
.bookmarkPrivately(["e", noteId2])
.toEvent(signer)
expect(getEventTagValues(event.tags)).toEqual([noteId])
expect(event.content).not.toBe("")
const decrypted = await BookmarkList.fromEvent(event, signer)
expect(decrypted.decrypted).toBe(true)
expect(decrypted.ids().sort()).toEqual([noteId, noteId2].sort())
const publicOnly = await BookmarkList.fromEvent(event)
expect(publicOnly.decrypted).toBe(false)
expect(publicOnly.ids()).toEqual([noteId])
})
it("preserves undecrypted ciphertext on pass-through", async () => {
const event = await new BookmarkListBuilder().bookmarkPrivately(["e", noteId2]).toEvent(signer)
const undecrypted = await BookmarkList.fromEvent(event)
const tmpl = await undecrypted.builder().toTemplate(signer)
expect(tmpl.content).toBe(event.content)
})
it("refuses private mutation when undecrypted", async () => {
const event = await new BookmarkListBuilder().bookmarkPrivately(["e", noteId2]).toEvent(signer)
const undecrypted = await BookmarkList.fromEvent(event)
await expect(
undecrypted.builder().bookmarkPrivately(["e", noteId]).toEvent(signer),
).rejects.toThrow()
})
it("throws on the wrong kind", async () => {
await expect(BookmarkList.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
@@ -0,0 +1,108 @@
import {describe, it, expect} from "vitest"
import {makeSecret, CLASSIFIED, NOTE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {Classified, ClassifiedBuilder} from "../src/kinds/Classified"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: CLASSIFIED,
tags: [],
content: "",
sig: "00".repeat(64),
...overrides,
}) as TrustedEvent
describe("Classified", () => {
it("reads represented tags and content", async () => {
const event = makeEvent({
content: "for sale",
tags: [
["d", "abc"],
["title", "Bike"],
["summary", "A good bike"],
["price", "100", "USD"],
["status", "active"],
["image", "https://example.com/a.jpg"],
["image", "https://example.com/b.jpg"],
["t", "cycling"],
["alt", "x"],
],
})
const c = await Classified.fromEvent(event)
expect(c.identifier()).toBe("abc")
expect(c.title()).toBe("Bike")
expect(c.summary()).toBe("A good bike")
expect(c.price()).toEqual({amount: 100, currency: "USD"})
expect(c.status()).toBe("active")
expect(c.images()).toEqual(["https://example.com/a.jpg", "https://example.com/b.jpg"])
expect(c.topics()).toEqual(["cycling"])
expect(c.content()).toBe("for sale")
})
it("defaults the price currency to SAT", async () => {
const c = await Classified.fromEvent(makeEvent({tags: [["d", "x"], ["price", "50"]]}))
expect(c.price()).toEqual({amount: 50, currency: "SAT"})
})
it("round-trips with no duplicate represented tags", async () => {
const event = makeEvent({
content: "for sale",
tags: [
["d", "abc"],
["title", "Bike"],
["summary", "A good bike"],
["price", "100", "USD"],
["status", "active"],
["image", "https://example.com/a.jpg"],
["image", "https://example.com/b.jpg"],
["t", "cycling"],
["alt", "x"],
],
})
const tmpl = await (await Classified.fromEvent(event)).builder().toTemplate(signer)
for (const key of ["d", "title", "summary", "price", "status"]) {
expect(tmpl.tags.filter(t => t[0] === key).length).toBe(1)
}
expect(tmpl.tags.filter(t => t[0] === "image").length).toBe(2)
expect(tmpl.tags.filter(t => t[0] === "t").length).toBe(1)
expect(tmpl.tags).toContainEqual(["d", "abc"])
expect(tmpl.tags).toContainEqual(["price", "100", "USD"])
// Unknown passthrough tag survives.
expect(tmpl.tags).toContainEqual(["alt", "x"])
expect(tmpl.content).toBe("for sale")
})
it("builds from a fresh builder with an auto-generated d", async () => {
const tmpl = await new ClassifiedBuilder()
.setTitle("Fresh")
.setContent("desc")
.setPrice(25)
.setImages(["https://example.com/c.jpg"])
.setTopics(["misc"])
.toTemplate(signer)
expect(tmpl.kind).toBe(CLASSIFIED)
expect(tmpl.tags.find(t => t[0] === "d")?.[1]).toBeTruthy()
expect(tmpl.tags).toContainEqual(["title", "Fresh"])
expect(tmpl.tags).toContainEqual(["price", "25", "SAT"])
expect(tmpl.tags).toContainEqual(["image", "https://example.com/c.jpg"])
expect(tmpl.tags).toContainEqual(["t", "misc"])
expect(tmpl.content).toBe("desc")
})
it("throws on the wrong kind", async () => {
await expect(Classified.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
+109
View File
@@ -0,0 +1,109 @@
import {describe, it, expect} from "vitest"
import {makeSecret, COMMENT, NOTE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {Comment, CommentBuilder} from "../src/kinds/Comment"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const rootId = "aa".repeat(32)
const rootPubkey = "bb".repeat(32)
const parentId = "cc".repeat(32)
const parentPubkey = "dd".repeat(32)
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: COMMENT,
tags: [],
content: "",
sig: "00".repeat(64),
...overrides,
}) as TrustedEvent
describe("Comment", () => {
it("reads root and parent references", async () => {
const event = makeEvent({
content: "nice thread",
tags: [
["E", rootId],
["K", "11"],
["P", rootPubkey],
["e", parentId],
["k", "1111"],
["p", parentPubkey],
["alt", "x"],
],
})
const comment = await Comment.fromEvent(event)
expect(comment.content()).toBe("nice thread")
expect(comment.rootId()).toBe(rootId)
expect(comment.rootKind()).toBe("11")
expect(comment.rootPubkey()).toBe(rootPubkey)
expect(comment.parentId()).toBe(parentId)
expect(comment.parentKind()).toBe("1111")
expect(comment.parentPubkey()).toBe(parentPubkey)
expect(comment.root()).toEqual({id: rootId, address: undefined, kind: "11", pubkey: rootPubkey})
expect(comment.parent()).toEqual({
id: parentId,
address: undefined,
kind: "1111",
pubkey: parentPubkey,
})
})
it("round-trips with no duplicate reference tags", async () => {
const event = makeEvent({
content: "nice thread",
tags: [
["E", rootId],
["K", "11"],
["P", rootPubkey],
["e", parentId],
["k", "1111"],
["p", parentPubkey],
["alt", "x"],
],
})
const tmpl = await (await Comment.fromEvent(event)).builder().toTemplate(signer)
// Each represented reference key emits exactly once.
for (const key of ["E", "K", "P", "e", "k", "p"]) {
expect(tmpl.tags.filter(t => t[0] === key).length).toBe(1)
}
expect(tmpl.tags).toContainEqual(["E", rootId])
expect(tmpl.tags).toContainEqual(["e", parentId])
// Unknown passthrough tag survives.
expect(tmpl.tags).toContainEqual(["alt", "x"])
expect(tmpl.content).toBe("nice thread")
})
it("builds references from full events", async () => {
const root = makeEvent({id: rootId, pubkey: rootPubkey, kind: 11})
const parent = makeEvent({id: parentId, pubkey: parentPubkey, kind: 1111})
const tmpl = await new CommentBuilder()
.setContent("reply")
.setRootFromEvent(root)
.setParentFromEvent(parent)
.toTemplate(signer)
expect(tmpl.kind).toBe(COMMENT)
expect(tmpl.tags).toContainEqual(["E", rootId])
expect(tmpl.tags).toContainEqual(["K", "11"])
expect(tmpl.tags).toContainEqual(["P", rootPubkey])
expect(tmpl.tags).toContainEqual(["e", parentId])
expect(tmpl.tags).toContainEqual(["k", "1111"])
expect(tmpl.tags).toContainEqual(["p", parentPubkey])
expect(tmpl.content).toBe("reply")
})
it("throws on the wrong kind", async () => {
await expect(Comment.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
+104
View File
@@ -0,0 +1,104 @@
import {describe, it, expect} from "vitest"
import {makeSecret, EMOJIS, NOTE, getAddressTagValues} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {EmojiList, EmojiListBuilder} from "../src/kinds/EmojiList"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const setAddress = `30030:${"aa".repeat(32)}:my-emojis`
const setAddress2 = `30030:${"bb".repeat(32)}:more-emojis`
const emojiTag = ["emoji", "soapbox", "https://example.com/soapbox.png"]
const emojiTag2 = ["emoji", "ostrich", "https://example.com/ostrich.png"]
const makeEvent = (o: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: EMOJIS,
tags: [],
content: "",
sig: "00".repeat(64),
...o,
}) as TrustedEvent
describe("EmojiList", () => {
it("reads emoji-set addresses and inline emoji tags", async () => {
const event = makeEvent({
tags: [["a", setAddress], emojiTag, ["alt", "x"]],
})
const list = await EmojiList.fromEvent(event)
expect(list.addresses()).toEqual([setAddress])
expect(list.emojis()).toEqual([emojiTag])
})
it("round-trips without duplicating tags and preserves passthrough", async () => {
const event = makeEvent({
tags: [["a", setAddress], emojiTag, ["alt", "x"]],
})
const list = await EmojiList.fromEvent(event)
const tmpl = await list.builder().toTemplate(signer)
expect(tmpl.kind).toBe(EMOJIS)
expect(tmpl.tags.filter(t => t[0] === "a").length).toBe(1)
expect(tmpl.tags.filter(t => t[0] === "emoji").length).toBe(1)
expect(tmpl.tags).toContainEqual(["alt", "x"])
})
it("builds from a fresh builder", async () => {
const tmpl = await new EmojiListBuilder()
.addEmojiSet(setAddress)
.addEmoji("soapbox", "https://example.com/soapbox.png")
.toTemplate(signer)
expect(getAddressTagValues(tmpl.tags)).toEqual([setAddress])
expect(tmpl.tags).toContainEqual(emojiTag)
})
it("removeEmoji removes by value", async () => {
const event = makeEvent({tags: [emojiTag, emojiTag2]})
const list = await EmojiList.fromEvent(event)
const tmpl = await list.builder().removeEmoji("soapbox").toTemplate(signer)
expect(tmpl.tags.filter(t => t[0] === "emoji")).toEqual([emojiTag2])
})
it("round-trips public and private entries through encryption", async () => {
const event = await new EmojiListBuilder()
.addEmojiSet(setAddress)
.addPrivate(["a", setAddress2])
.toEvent(signer)
expect(getAddressTagValues(event.tags)).toEqual([setAddress])
expect(event.content).not.toBe("")
const decrypted = await EmojiList.fromEvent(event, signer)
expect(decrypted.decrypted).toBe(true)
expect(decrypted.addresses().sort()).toEqual([setAddress, setAddress2].sort())
const publicOnly = await EmojiList.fromEvent(event)
expect(publicOnly.decrypted).toBe(false)
expect(publicOnly.addresses()).toEqual([setAddress])
})
it("preserves undecrypted ciphertext on pass-through", async () => {
const event = await new EmojiListBuilder().addPrivate(["a", setAddress2]).toEvent(signer)
const undecrypted = await EmojiList.fromEvent(event)
const tmpl = await undecrypted.builder().toTemplate(signer)
expect(tmpl.content).toBe(event.content)
})
it("throws on the wrong kind", async () => {
await expect(EmojiList.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
+90
View File
@@ -0,0 +1,90 @@
import {describe, it, expect} from "vitest"
import {makeSecret, FEED, NOTE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {Feed, FeedBuilder} from "../src/kinds/Feed"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const definition = ["union", ["search", "nostr"]]
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: FEED,
tags: [],
content: "",
sig: "00".repeat(64),
...overrides,
}) as TrustedEvent
describe("Feed", () => {
it("reads represented tags", async () => {
const event = makeEvent({
tags: [
["d", "abc"],
["title", "My Feed"],
["description", "all the things"],
["feed", JSON.stringify(definition)],
["alt", "My Feed"],
],
})
const feed = await Feed.fromEvent(event)
expect(feed.identifier()).toBe("abc")
expect(feed.title()).toBe("My Feed")
expect(feed.description()).toBe("all the things")
expect(feed.definition()).toEqual(definition)
})
it("round-trips with no duplicate represented tags", async () => {
const event = makeEvent({
tags: [
["d", "abc"],
["title", "My Feed"],
["description", "all the things"],
["feed", JSON.stringify(definition)],
["alt", "My Feed"],
// "alt" is a represented/derived tag (mirrors title), so use a distinct
// unknown key for the passthrough assertion.
["zzz", "x"],
],
})
const tmpl = await (await Feed.fromEvent(event)).builder().toTemplate(signer)
for (const key of ["d", "title", "description", "feed", "alt"]) {
expect(tmpl.tags.filter(t => t[0] === key).length).toBe(1)
}
expect(tmpl.tags).toContainEqual(["d", "abc"])
expect(tmpl.tags).toContainEqual(["title", "My Feed"])
expect(tmpl.tags).toContainEqual(["feed", JSON.stringify(definition)])
// The derived "alt" tag mirrors the title.
expect(tmpl.tags).toContainEqual(["alt", "My Feed"])
// Unknown passthrough tag survives.
expect(tmpl.tags).toContainEqual(["zzz", "x"])
})
it("builds from a fresh builder with an auto-generated d", async () => {
const tmpl = await new FeedBuilder()
.setTitle("Fresh")
.setDescription("desc")
.setDefinition(definition)
.toTemplate(signer)
expect(tmpl.kind).toBe(FEED)
expect(tmpl.tags.find(t => t[0] === "d")?.[1]).toBeTruthy()
expect(tmpl.tags).toContainEqual(["title", "Fresh"])
expect(tmpl.tags).toContainEqual(["alt", "Fresh"])
expect(tmpl.tags).toContainEqual(["description", "desc"])
expect(tmpl.tags).toContainEqual(["feed", JSON.stringify(definition)])
})
it("throws on the wrong kind", async () => {
await expect(Feed.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
@@ -0,0 +1,79 @@
import {describe, it, expect} from "vitest"
import {makeSecret, FEEDS, NOTE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {FeedList, FeedListBuilder} from "../src/kinds/FeedList"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const addressA = `31890:${"22".repeat(32)}:feeda`
const addressB = `31890:${"33".repeat(32)}:feedb`
const makeEvent = (o: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: FEEDS,
tags: [],
content: "",
sig: "00".repeat(64),
...o,
}) as TrustedEvent
describe("FeedList", () => {
it("reads saved feed addresses", async () => {
const reader = await FeedList.fromEvent(
makeEvent({tags: [["a", addressA], ["alt", "x"]]}),
)
expect(reader.addresses()).toEqual([addressA])
expect(reader.includes(addressA)).toBe(true)
expect(reader.includes(addressB)).toBe(false)
})
it("round-trips without duplicating represented tags", async () => {
const reader = await FeedList.fromEvent(
makeEvent({tags: [["a", addressA], ["alt", "x"]]}),
)
const tmpl = await reader.builder().toTemplate(signer)
expect(tmpl.tags.filter(t => t[0] === "a").length).toBe(1)
expect(tmpl.tags).toContainEqual(["alt", "x"])
})
it("adds and removes feeds via a fresh builder", async () => {
const tmpl = await new FeedListBuilder()
.addFeed(addressA, "wss://relay.example.com/")
.addFeed(addressB)
.removeFeed(addressA)
.toTemplate(signer)
expect(tmpl.kind).toBe(FEEDS)
expect(tmpl.tags).toContainEqual(["a", addressB, ""])
expect(tmpl.tags.some(t => t[1] === addressA)).toBe(false)
})
it("round-trips public and private feeds through encryption", async () => {
const event = await new FeedListBuilder()
.addFeed(addressA)
.addFeedPrivately(addressB)
.toEvent(signer)
const decrypted = await FeedList.fromEvent(event, signer)
expect(decrypted.decrypted).toBe(true)
expect(decrypted.addresses().sort()).toEqual([addressA, addressB].sort())
const publicOnly = await FeedList.fromEvent(event)
expect(publicOnly.decrypted).toBe(false)
expect(publicOnly.addresses()).toEqual([addressA])
})
it("throws on the wrong kind", async () => {
await expect(FeedList.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
@@ -0,0 +1,103 @@
import {describe, it, expect} from "vitest"
import {makeSecret, FOLLOWS, NOTE, getPubkeyTagValues} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {FollowList, FollowListBuilder} from "../src/kinds/FollowList"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const a = "aa".repeat(32)
const b = "bb".repeat(32)
const c = "cc".repeat(32)
const makeEvent = (o: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: FOLLOWS,
tags: [],
content: "",
sig: "00".repeat(64),
...o,
}) as TrustedEvent
describe("FollowList", () => {
it("reads followed pubkeys", async () => {
const event = makeEvent({
tags: [
["p", a],
["p", b],
["t", "nostr"],
["alt", "x"],
],
})
const list = await FollowList.fromEvent(event)
expect(list.pubkeys().sort()).toEqual([a, b].sort())
expect(list.includes(a)).toBe(true)
expect(list.includes(c)).toBe(false)
})
it("round-trips without duplicating tags and preserves passthrough", async () => {
const event = makeEvent({
tags: [
["p", a],
["p", b],
["alt", "x"],
],
})
const list = await FollowList.fromEvent(event)
const tmpl = await list.builder().toTemplate(signer)
expect(tmpl.kind).toBe(FOLLOWS)
expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(2)
expect(tmpl.tags).toContainEqual(["alt", "x"])
})
it("builds from a fresh builder via addFollow", async () => {
const tmpl = await new FollowListBuilder()
.addFollow(["p", a])
.addFollow(["t", "nostr"])
.toTemplate(signer)
expect(getPubkeyTagValues(tmpl.tags)).toEqual([a])
expect(tmpl.tags).toContainEqual(["t", "nostr"])
})
it("removeFollow removes by value", async () => {
const event = makeEvent({tags: [["p", a], ["p", b]]})
const list = await FollowList.fromEvent(event)
const tmpl = await list.builder().removeFollow(a).toTemplate(signer)
expect(getPubkeyTagValues(tmpl.tags)).toEqual([b])
})
it("round-trips public and private follows through encryption", async () => {
const event = await new FollowListBuilder()
.addFollow(["p", a])
.addPrivate(["p", b])
.toEvent(signer)
expect(getPubkeyTagValues(event.tags)).toEqual([a])
expect(event.content).not.toBe("")
const decrypted = await FollowList.fromEvent(event, signer)
expect(decrypted.decrypted).toBe(true)
expect(decrypted.pubkeys().sort()).toEqual([a, b].sort())
const publicOnly = await FollowList.fromEvent(event)
expect(publicOnly.decrypted).toBe(false)
expect(publicOnly.pubkeys()).toEqual([a])
})
it("throws on the wrong kind", async () => {
await expect(FollowList.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
+110
View File
@@ -0,0 +1,110 @@
import {describe, it, expect} from "vitest"
import {makeSecret, COMMUNITIES, NOTE, getAddressTagValues} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {GroupList, GroupListBuilder} from "../src/kinds/GroupList"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const g1 = `34550:${"aa".repeat(32)}:dev`
const g2 = `34550:${"bb".repeat(32)}:art`
const g3 = `34550:${"cc".repeat(32)}:music`
const makeEvent = (o: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: COMMUNITIES,
tags: [],
content: "",
sig: "00".repeat(64),
...o,
}) as TrustedEvent
describe("GroupList", () => {
it("reads community addresses", async () => {
const event = makeEvent({
tags: [
["a", g1, "wss://relay.example/"],
["a", g2],
["alt", "x"],
],
})
const list = await GroupList.fromEvent(event)
expect(list.addresses().sort()).toEqual([g1, g2].sort())
})
it("round-trips without duplicating tags and preserves passthrough", async () => {
const event = makeEvent({
tags: [
["a", g1, "wss://relay.example/"],
["a", g2],
["alt", "x"],
],
})
const list = await GroupList.fromEvent(event)
const tmpl = await list.builder().toTemplate(signer)
expect(tmpl.kind).toBe(COMMUNITIES)
expect(tmpl.tags.filter(t => t[0] === "a").length).toBe(2)
expect(tmpl.tags).toContainEqual(["alt", "x"])
})
it("builds from a fresh builder with relay hint", async () => {
const tmpl = await new GroupListBuilder()
.addGroup(g1, "wss://relay.example/")
.addGroup(g2)
.toTemplate(signer)
expect(getAddressTagValues(tmpl.tags).sort()).toEqual([g1, g2].sort())
expect(tmpl.tags).toContainEqual(["a", g1, "wss://relay.example/"])
expect(tmpl.tags).toContainEqual(["a", g2, ""])
})
it("removeGroup removes by address", async () => {
const event = makeEvent({tags: [["a", g1], ["a", g2]]})
const list = await GroupList.fromEvent(event)
const tmpl = await list.builder().removeGroup(g1).toTemplate(signer)
expect(getAddressTagValues(tmpl.tags)).toEqual([g2])
})
it("round-trips public and private entries through encryption", async () => {
const event = await new GroupListBuilder()
.addGroup(g1)
.addPrivate(["a", g2, ""])
.toEvent(signer)
expect(getAddressTagValues(event.tags)).toEqual([g1])
expect(event.content).not.toBe("")
const decrypted = await GroupList.fromEvent(event, signer)
expect(decrypted.decrypted).toBe(true)
expect(decrypted.addresses().sort()).toEqual([g1, g2].sort())
const publicOnly = await GroupList.fromEvent(event)
expect(publicOnly.decrypted).toBe(false)
expect(publicOnly.addresses()).toEqual([g1])
})
it("preserves undecrypted ciphertext on pass-through", async () => {
const event = await new GroupListBuilder().addPrivate(["a", g2, ""]).toEvent(signer)
const undecrypted = await GroupList.fromEvent(event)
const tmpl = await undecrypted.builder().toTemplate(signer)
expect(tmpl.content).toBe(event.content)
})
it("throws on the wrong kind", async () => {
await expect(GroupList.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
+108
View File
@@ -0,0 +1,108 @@
import {describe, it, expect} from "vitest"
import {makeSecret, HANDLER_INFORMATION, NOTE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {Handler, HandlerBuilder} from "../src/kinds/Handler"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: HANDLER_INFORMATION,
tags: [],
content: "",
sig: "00".repeat(64),
...overrides,
}) as TrustedEvent
describe("Handler", () => {
it("parses JSON metadata content and k tags", async () => {
const event = makeEvent({
content: JSON.stringify({
name: "Coracle",
about: "a client",
image: "https://example.com/i.png",
website: "https://example.com",
lud16: "a@example.com",
nip05: "a@example.com",
}),
tags: [
["d", "myhandler"],
["k", "1"],
["k", "30023"],
["alt", "x"],
],
})
const handler = await Handler.fromEvent(event)
expect(handler.values.name).toBe("Coracle")
expect(handler.name()).toBe("Coracle")
expect(handler.about()).toBe("a client")
expect(handler.image()).toBe("https://example.com/i.png")
expect(handler.website()).toBe("https://example.com")
expect(handler.lud16()).toBe("a@example.com")
expect(handler.nip05()).toBe("a@example.com")
expect(handler.kinds()).toEqual([1, 30023])
})
it("maps display_name and picture aliases to canonical accessors", async () => {
const handler = await Handler.fromEvent(
makeEvent({
content: JSON.stringify({display_name: "Alias", picture: "https://example.com/p.png"}),
}),
)
expect(handler.name()).toBe("Alias")
expect(handler.image()).toBe("https://example.com/p.png")
})
it("round-trips with no duplication", async () => {
const event = makeEvent({
content: JSON.stringify({name: "Coracle", about: "a client"}),
tags: [
["d", "myhandler"],
["k", "1"],
["k", "30023"],
["alt", "x"],
],
})
const tmpl = await (await Handler.fromEvent(event)).builder().toTemplate(signer)
expect(tmpl.tags.filter(t => t[0] === "k").length).toBe(2)
// The d identifier is passed through untouched.
expect(tmpl.tags.filter(t => t[0] === "d").length).toBe(1)
expect(tmpl.tags).toContainEqual(["alt", "x"])
// Content re-serializes the parsed metadata.
const parsed = JSON.parse(tmpl.content)
expect(parsed.name).toBe("Coracle")
expect(parsed.about).toBe("a client")
})
it("builds from a fresh builder", async () => {
const tmpl = await new HandlerBuilder()
.setName("MyApp")
.setAbout("does things")
.setWebsite("https://my.app")
.setKinds([1, 7])
.toTemplate(signer)
expect(tmpl.kind).toBe(HANDLER_INFORMATION)
const parsed = JSON.parse(tmpl.content)
expect(parsed.name).toBe("MyApp")
expect(parsed.about).toBe("does things")
expect(parsed.website).toBe("https://my.app")
expect(tmpl.tags).toContainEqual(["k", "1"])
expect(tmpl.tags).toContainEqual(["k", "7"])
})
it("throws on the wrong kind", async () => {
await expect(Handler.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
@@ -0,0 +1,102 @@
import {describe, it, expect} from "vitest"
import {makeSecret, HANDLER_RECOMMENDATION, NOTE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {
HandlerRecommendation,
HandlerRecommendationBuilder,
} from "../src/kinds/HandlerRecommendation"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const webAddress = `31990:${"aa".repeat(32)}:web-handler`
const otherAddress = `31990:${"bb".repeat(32)}:other-handler`
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: HANDLER_RECOMMENDATION,
tags: [],
content: "",
sig: "00".repeat(64),
...overrides,
}) as TrustedEvent
describe("HandlerRecommendation", () => {
it("parses address tags and prefers the web handler", async () => {
const event = makeEvent({
tags: [
["d", "1"],
["a", otherAddress, "wss://relay.one", "android"],
["a", webAddress, "wss://relay.two", "web"],
["alt", "x"],
],
})
const rec = await HandlerRecommendation.fromEvent(event)
expect(rec.addresses()).toEqual([otherAddress, webAddress])
expect(rec.addressTags().length).toBe(2)
// Prefers the recommendation marked "web".
expect(rec.handlerAddress()).toBe(webAddress)
})
it("falls back to the first recommendation without a web marker", async () => {
const rec = await HandlerRecommendation.fromEvent(
makeEvent({tags: [["d", "1"], ["a", otherAddress, "", "android"]]}),
)
expect(rec.handlerAddress()).toBe(otherAddress)
})
it("round-trips with no duplication", async () => {
const event = makeEvent({
tags: [
["d", "1"],
["a", otherAddress, "wss://relay.one", "android"],
["a", webAddress, "wss://relay.two", "web"],
["alt", "x"],
],
})
const tmpl = await (await HandlerRecommendation.fromEvent(event)).builder().toTemplate(signer)
expect(tmpl.tags.filter(t => t[0] === "d").length).toBe(1)
expect(tmpl.tags.filter(t => t[0] === "a").length).toBe(2)
expect(tmpl.tags).toContainEqual(["alt", "x"])
// The d identifier round-trips its value.
expect(tmpl.tags.find(t => t[0] === "d")![1]).toBe("1")
})
it("builds from a fresh builder", async () => {
const builder = new HandlerRecommendationBuilder()
// The d identifier holds the recommended kind; it has no setter, so set it
// directly on the public field.
builder.identifier = "1"
const tmpl = await builder
.addRecommendation(webAddress, "wss://relay.one", "web")
// Duplicate addresses are ignored.
.addRecommendation(webAddress, "wss://relay.one", "web")
.toTemplate(signer)
expect(tmpl.kind).toBe(HANDLER_RECOMMENDATION)
expect(tmpl.tags).toContainEqual(["d", "1"])
expect(tmpl.tags.filter(t => t[0] === "a")).toEqual([
["a", webAddress, "wss://relay.one", "web"],
])
})
it("requires a d identifier", async () => {
await expect(
new HandlerRecommendationBuilder().addRecommendation(webAddress).toTemplate(signer),
).rejects.toThrow()
})
it("throws on the wrong kind", async () => {
await expect(HandlerRecommendation.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
@@ -0,0 +1,107 @@
import {describe, it, expect} from "vitest"
import {makeSecret, MESSAGING_RELAYS, NOTE, getTagValues, normalizeRelayUrl} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {MessagingRelayList, MessagingRelayListBuilder} from "../src/kinds/MessagingRelayList"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const r1 = "wss://inbox.one.example/"
const r2 = "wss://inbox.two.example/"
const r3 = "wss://inbox.three.example/"
const makeEvent = (o: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: MESSAGING_RELAYS,
tags: [],
content: "",
sig: "00".repeat(64),
...o,
}) as TrustedEvent
describe("MessagingRelayList", () => {
it("reads messaging relay urls", async () => {
const event = makeEvent({
tags: [
["relay", r1],
["relay", r2],
["alt", "x"],
],
})
const list = await MessagingRelayList.fromEvent(event)
expect(list.urls().sort()).toEqual([r1, r2].sort())
})
it("round-trips without duplicating tags and preserves passthrough", async () => {
const event = makeEvent({
tags: [
["relay", r1],
["relay", r2],
["alt", "x"],
],
})
const list = await MessagingRelayList.fromEvent(event)
const tmpl = await list.builder().toTemplate(signer)
expect(tmpl.kind).toBe(MESSAGING_RELAYS)
expect(tmpl.tags.filter(t => t[0] === "relay").length).toBe(2)
expect(tmpl.tags).toContainEqual(["alt", "x"])
})
it("builds from a fresh builder and normalizes urls", async () => {
const tmpl = await new MessagingRelayListBuilder()
.addRelay("wss://inbox.one.example")
.toTemplate(signer)
expect(getTagValues("relay", tmpl.tags)).toEqual([normalizeRelayUrl("wss://inbox.one.example")])
})
it("setRelays replaces existing relays", async () => {
const event = makeEvent({tags: [["relay", r1]]})
const list = await MessagingRelayList.fromEvent(event)
const tmpl = await list.builder().setRelays([r2, r3]).toTemplate(signer)
expect(getTagValues("relay", tmpl.tags).sort()).toEqual([r2, r3].sort())
})
it("round-trips public and private entries through encryption", async () => {
const event = await new MessagingRelayListBuilder()
.addRelay(r1)
.addPrivate(["relay", r2])
.toEvent(signer)
expect(getTagValues("relay", event.tags)).toEqual([r1])
expect(event.content).not.toBe("")
const decrypted = await MessagingRelayList.fromEvent(event, signer)
expect(decrypted.decrypted).toBe(true)
expect(decrypted.urls().sort()).toEqual([r1, r2].sort())
const publicOnly = await MessagingRelayList.fromEvent(event)
expect(publicOnly.decrypted).toBe(false)
expect(publicOnly.urls()).toEqual([r1])
})
it("preserves undecrypted ciphertext on pass-through", async () => {
const event = await new MessagingRelayListBuilder().addPrivate(["relay", r2]).toEvent(signer)
const undecrypted = await MessagingRelayList.fromEvent(event)
const tmpl = await undecrypted.builder().toTemplate(signer)
expect(tmpl.content).toBe(event.content)
})
it("throws on the wrong kind", async () => {
await expect(MessagingRelayList.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
+29 -27
View File
@@ -2,7 +2,7 @@ import {describe, it, expect} from "vitest"
import {makeSecret, MUTES, FOLLOWS, getPubkeyTagValues} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {MuteList} from "../src/MuteList"
import {MuteList, MuteListBuilder} from "../src/kinds/MuteList"
const signer = new Nip01Signer(makeSecret())
@@ -12,14 +12,7 @@ const c = "cc".repeat(32)
describe("MuteList", () => {
it("round-trips public and private mutes through encryption", async () => {
const list = MuteList.init().addPublicly(a).addPrivately(b)
expect(list.pubkeys.sort()).toEqual([a, b].sort())
expect(list.includes(a)).toBe(true)
expect(list.includes(b)).toBe(true)
expect(list.includes(c)).toBe(false)
const event = await list.toEvent(signer)
const event = await new MuteListBuilder().mutePublicly(a).mutePrivately(b).toEvent(signer)
expect(event.kind).toBe(MUTES)
expect(event.sig).toBeTruthy()
@@ -28,46 +21,55 @@ describe("MuteList", () => {
expect(event.content).not.toBe("")
// Re-parsing with a capable signer recovers the private entries.
const decrypted = await MuteList.parse(event, signer)
const decrypted = await MuteList.fromEvent(event, signer)
expect(decrypted.isDecrypted).toBe(true)
expect(decrypted.pubkeys.sort()).toEqual([a, b].sort())
expect(decrypted.decrypted).toBe(true)
expect(decrypted.pubkeys().sort()).toEqual([a, b].sort())
expect(decrypted.includes(a)).toBe(true)
expect(decrypted.includes(b)).toBe(true)
expect(decrypted.includes(c)).toBe(false)
// Parsing without a signer exposes only the public entries.
const publicOnly = await MuteList.parse(event)
const publicOnly = await MuteList.fromEvent(event)
expect(publicOnly.isDecrypted).toBe(false)
expect(publicOnly.pubkeys).toEqual([a])
expect(publicOnly.decrypted).toBe(false)
expect(publicOnly.pubkeys()).toEqual([a])
})
it("removes from both public and private entries", async () => {
const list = MuteList.init().addPublicly(a).addPrivately(b)
const event = await new MuteListBuilder()
.mutePublicly(a)
.mutePrivately(b)
.unmute(a)
.unmute(b)
.toEvent(signer)
list.remove(a)
list.remove(b)
const parsed = await MuteList.fromEvent(event, signer)
expect(list.pubkeys).toEqual([])
expect(parsed.pubkeys()).toEqual([])
})
it("preserves undecrypted ciphertext on pass-through serialization", async () => {
const event = await MuteList.init().addPrivately(b).toEvent(signer)
const undecrypted = await MuteList.parse(event)
const event = await new MuteListBuilder().mutePrivately(b).toEvent(signer)
const undecrypted = await MuteList.fromEvent(event)
// We never decrypted, so the original ciphertext must survive untouched.
const template = await undecrypted.toTemplate(signer)
const template = await undecrypted.builder().toTemplate(signer)
expect(template.content).toBe(event.content)
})
it("refuses private mutation when undecrypted", async () => {
const event = await MuteList.init().addPrivately(b).toEvent(signer)
const undecrypted = await MuteList.parse(event)
const event = await new MuteListBuilder().mutePrivately(b).toEvent(signer)
const undecrypted = await MuteList.fromEvent(event)
expect(() => undecrypted.addPrivately(c)).toThrow()
// Mutation is now deferred-validated: adding a private entry to a list we
// couldn't decrypt throws at emit time, not on the mutating call.
await expect(undecrypted.builder().mutePrivately(c).toEvent(signer)).rejects.toThrow()
})
it("toRumor encrypts but does not sign", async () => {
const rumor = await MuteList.init().addPrivately(b).toRumor(signer)
const rumor = await new MuteListBuilder().mutePrivately(b).toRumor(signer)
expect(rumor.id).toBeTruthy()
expect((rumor as TrustedEvent).sig).toBeUndefined()
@@ -77,6 +79,6 @@ describe("MuteList", () => {
it("throws on the wrong kind", async () => {
const event = {kind: FOLLOWS, tags: [], content: "", pubkey: a} as TrustedEvent
await expect(MuteList.parse(event)).rejects.toThrow()
await expect(MuteList.fromEvent(event)).rejects.toThrow()
})
})
+76
View File
@@ -0,0 +1,76 @@
import {describe, it, expect} from "vitest"
import {makeSecret, PINS, NOTE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {PinList, PinListBuilder} from "../src/kinds/PinList"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const eventId = "11".repeat(32)
const address = `31890:${"22".repeat(32)}:feed`
const makeEvent = (o: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: PINS,
tags: [],
content: "",
sig: "00".repeat(64),
...o,
}) as TrustedEvent
describe("PinList", () => {
it("reads pinned event ids and addresses", async () => {
const reader = await PinList.fromEvent(
makeEvent({tags: [["e", eventId], ["a", address], ["alt", "x"]]}),
)
expect(reader.ids()).toEqual([eventId])
expect(reader.addresses()).toEqual([address])
})
it("round-trips without duplicating represented tags", async () => {
const reader = await PinList.fromEvent(
makeEvent({tags: [["e", eventId], ["a", address], ["alt", "x"]]}),
)
const tmpl = await reader.builder().toTemplate(signer)
expect(tmpl.tags.filter(t => t[0] === "e").length).toBe(1)
expect(tmpl.tags.filter(t => t[0] === "a").length).toBe(1)
expect(tmpl.tags).toContainEqual(["alt", "x"])
})
it("builds from a fresh builder", async () => {
const tmpl = await new PinListBuilder().pinPublicly(["e", eventId]).toTemplate(signer)
expect(tmpl.kind).toBe(PINS)
expect(tmpl.tags).toContainEqual(["e", eventId])
})
it("round-trips public and private pins through encryption", async () => {
const event = await new PinListBuilder()
.pinPublicly(["e", eventId])
.pinPrivately(["a", address])
.toEvent(signer)
const decrypted = await PinList.fromEvent(event, signer)
expect(decrypted.decrypted).toBe(true)
expect(decrypted.ids()).toEqual([eventId])
expect(decrypted.addresses()).toEqual([address])
const publicOnly = await PinList.fromEvent(event)
expect(publicOnly.decrypted).toBe(false)
expect(publicOnly.ids()).toEqual([eventId])
expect(publicOnly.addresses()).toEqual([])
})
it("throws on the wrong kind", async () => {
await expect(PinList.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
+123
View File
@@ -0,0 +1,123 @@
import {describe, it, expect} from "vitest"
import {makeSecret, POLL, NOTE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {Poll, PollBuilder} from "../src/kinds/Poll"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: POLL,
tags: [],
content: "",
sig: "00".repeat(64),
...overrides,
}) as TrustedEvent
describe("Poll", () => {
it("parses the represented tags and plain-text title", async () => {
const event = makeEvent({
content: "Favorite color?",
tags: [
["option", "1", "Red"],
["option", "2", "Blue"],
["polltype", "multiplechoice"],
["endsAt", "1234"],
["relay", "wss://relay.one"],
["relay", "wss://relay.two"],
["alt", "x"],
],
})
const poll = await Poll.fromEvent(event)
expect(poll.title()).toBe("Favorite color?")
expect(poll.options()).toEqual([
{id: "1", label: "Red"},
{id: "2", label: "Blue"},
])
expect(poll.pollType()).toBe("multiplechoice")
expect(poll.endsAt()).toBe(1234)
expect(poll.relays()).toEqual(["wss://relay.one", "wss://relay.two"])
})
it("tallies results from response events", async () => {
const poll = await Poll.fromEvent(
makeEvent({
content: "Pick one",
tags: [
["option", "1", "Red"],
["option", "2", "Blue"],
],
}),
)
const responses = [
{pubkey: "a", created_at: 1, tags: [["response", "1"]]},
{pubkey: "b", created_at: 1, tags: [["response", "2"]]},
// Latest response per pubkey wins.
{pubkey: "a", created_at: 2, tags: [["response", "2"]]},
] as TrustedEvent[]
const result = poll.results(responses)
expect(result.voters).toBe(2)
expect(result.options.find(o => o.id === "1")!.votes).toBe(0)
expect(result.options.find(o => o.id === "2")!.votes).toBe(2)
})
it("round-trips with no duplication", async () => {
const event = makeEvent({
content: "Favorite color?",
tags: [
["option", "1", "Red"],
["option", "2", "Blue"],
["polltype", "multiplechoice"],
["endsAt", "1234"],
["relay", "wss://relay.one"],
["alt", "x"],
],
})
const tmpl = await (await Poll.fromEvent(event)).builder().toTemplate(signer)
expect(tmpl.content).toBe("Favorite color?")
expect(tmpl.tags.filter(t => t[0] === "option").length).toBe(2)
expect(tmpl.tags.filter(t => t[0] === "polltype").length).toBe(1)
expect(tmpl.tags.filter(t => t[0] === "endsAt").length).toBe(1)
expect(tmpl.tags.filter(t => t[0] === "relay").length).toBe(1)
// Unknown tag survives the round-trip.
expect(tmpl.tags).toContainEqual(["alt", "x"])
})
it("builds from a fresh builder", async () => {
const tmpl = await new PollBuilder()
.setTitle("Q?")
.addOption("Red", "1")
.addOption("Blue", "2")
.setPollType("multiplechoice")
.setEndsAt(9999)
.setRelays(["wss://relay.one"])
.toTemplate(signer)
expect(tmpl.kind).toBe(POLL)
expect(tmpl.content).toBe("Q?")
expect(tmpl.tags).toContainEqual(["option", "1", "Red"])
expect(tmpl.tags).toContainEqual(["polltype", "multiplechoice"])
expect(tmpl.tags).toContainEqual(["endsAt", "9999"])
expect(tmpl.tags).toContainEqual(["relay", "wss://relay.one"])
})
it("requires at least one option", async () => {
await expect(new PollBuilder().setTitle("Q?").toTemplate(signer)).rejects.toThrow()
})
it("throws on the wrong kind", async () => {
await expect(Poll.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
@@ -0,0 +1,81 @@
import {describe, it, expect} from "vitest"
import {makeSecret, POLL_RESPONSE, NOTE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {PollResponse, PollResponseBuilder} from "../src/kinds/PollResponse"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const poll = "aa".repeat(32)
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: POLL_RESPONSE,
tags: [],
content: "",
sig: "00".repeat(64),
...overrides,
}) as TrustedEvent
describe("PollResponse", () => {
it("parses the poll id and deduped selections", async () => {
const event = makeEvent({
tags: [
["e", poll],
["response", "1"],
["response", "2"],
["response", "1"],
["alt", "x"],
],
})
const response = await PollResponse.fromEvent(event)
expect(response.pollId()).toBe(poll)
expect(response.selections()).toEqual(["1", "2"])
})
it("round-trips with no duplication", async () => {
const event = makeEvent({
tags: [
["e", poll],
["response", "1"],
["response", "2"],
["alt", "x"],
],
})
const tmpl = await (await PollResponse.fromEvent(event)).builder().toTemplate(signer)
expect(tmpl.tags.filter(t => t[0] === "e").length).toBe(1)
expect(tmpl.tags.filter(t => t[0] === "response").length).toBe(2)
expect(tmpl.tags).toContainEqual(["alt", "x"])
})
it("builds from a fresh builder", async () => {
const tmpl = await new PollResponseBuilder()
.setPollId(poll)
.addSelection("1")
.addSelection("1")
.addSelection("2")
.toTemplate(signer)
expect(tmpl.kind).toBe(POLL_RESPONSE)
expect(tmpl.tags).toContainEqual(["e", poll])
expect(tmpl.tags.filter(t => t[0] === "response")).toEqual([
["response", "1"],
["response", "2"],
])
})
it("requires a poll id", async () => {
await expect(new PollResponseBuilder().addSelection("1").toTemplate(signer)).rejects.toThrow()
})
it("throws on the wrong kind", async () => {
await expect(PollResponse.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
+17 -14
View File
@@ -2,7 +2,7 @@ import {describe, it, expect} from "vitest"
import {makeSecret, PROFILE, NOTE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {Profile, displayPubkey} from "../src/Profile"
import {Profile, displayPubkey} from "../src/kinds/Profile"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
@@ -26,13 +26,13 @@ describe("Profile", () => {
tags: [["alt", "profile"]],
})
const profile = await Profile.parse(event)
const profile = await Profile.fromEvent(event)
expect(profile.values.name).toBe("alice")
expect(profile.hasName()).toBe(true)
expect(profile.name()).toBe("alice")
expect(profile.display()).toBe("alice")
const signed = await profile.toEvent(signer)
const signed = await profile.builder().toEvent(signer)
expect(signed.kind).toBe(PROFILE)
expect(JSON.parse(signed.content).name).toBe("alice")
@@ -40,28 +40,31 @@ describe("Profile", () => {
expect(signed.tags).toEqual([["alt", "profile"]])
})
it("derives lnurl from a lud16 address", () => {
const profile = Profile.init({lud16: "alice@example.com"})
it("derives lnurl from a lud16 address", async () => {
const profile = await Profile.fromEvent(
makeEvent({content: JSON.stringify({lud16: "alice@example.com"})}),
)
expect(profile.values.lnurl).toBeTruthy()
expect(profile.lnurl()).toBeTruthy()
})
it("gets and sets values by key", () => {
const profile = Profile.init({name: "alice"})
it("seeds the builder from the reader and edits values", async () => {
const profile = await Profile.fromEvent(makeEvent({content: JSON.stringify({name: "alice"})}))
profile.set("about", "hello")
const event = await profile.builder().about("hello").toEvent(signer)
const updated = await Profile.fromEvent(event)
expect(profile.get("name")).toBe("alice")
expect(profile.get("about")).toBe("hello")
expect(updated.name()).toBe("alice")
expect(updated.about()).toBe("hello")
})
it("display falls back to a shortened npub", async () => {
const profile = await Profile.parse(makeEvent({content: "{}"}))
const profile = await Profile.fromEvent(makeEvent({content: "{}"}))
expect(profile.display()).toBe(displayPubkey(pubkey))
})
it("throws on the wrong kind", async () => {
await expect(Profile.parse(makeEvent({kind: NOTE}))).rejects.toThrow()
await expect(Profile.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
@@ -0,0 +1,62 @@
import {describe, it, expect} from "vitest"
import {makeSecret, RELAY_INVITE, NOTE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {RelayInvite, RelayInviteBuilder} from "../src/kinds/RelayInvite"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: RELAY_INVITE,
tags: [],
content: "",
sig: "00".repeat(64),
...overrides,
}) as TrustedEvent
describe("RelayInvite", () => {
it("reads the claim tag", async () => {
const event = makeEvent({
tags: [
["claim", "secret-code"],
["alt", "x"],
],
})
const invite = await RelayInvite.fromEvent(event)
expect(invite.claim()).toBe("secret-code")
})
it("round-trips with no duplicate claim tag", async () => {
const event = makeEvent({
tags: [
["claim", "secret-code"],
["alt", "x"],
],
})
const tmpl = await (await RelayInvite.fromEvent(event)).builder().toTemplate(signer)
expect(tmpl.tags.filter(t => t[0] === "claim").length).toBe(1)
expect(tmpl.tags).toContainEqual(["claim", "secret-code"])
// Unknown passthrough tag survives.
expect(tmpl.tags).toContainEqual(["alt", "x"])
})
it("builds from a fresh builder", async () => {
const tmpl = await new RelayInviteBuilder().setClaim("fresh-code").toTemplate(signer)
expect(tmpl.kind).toBe(RELAY_INVITE)
expect(tmpl.tags).toContainEqual(["claim", "fresh-code"])
})
it("throws on the wrong kind", async () => {
await expect(RelayInvite.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
@@ -0,0 +1,65 @@
import {describe, it, expect} from "vitest"
import {makeSecret, RELAY_JOIN, NOTE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {RelayJoin, RelayJoinBuilder} from "../src/kinds/RelayJoin"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: RELAY_JOIN,
tags: [],
content: "",
sig: "00".repeat(64),
...overrides,
}) as TrustedEvent
describe("RelayJoin", () => {
it("reads claim tag and reason content", async () => {
const join = await RelayJoin.fromEvent(
makeEvent({tags: [["claim", "abc123"]], content: "please let me in"}),
)
expect(join.claim()).toBe("abc123")
expect(join.reason()).toBe("please let me in")
})
it("returns undefined for missing claim/reason", async () => {
const join = await RelayJoin.fromEvent(makeEvent())
expect(join.claim()).toBeUndefined()
expect(join.reason()).toBeUndefined()
})
it("round-trips with no duplicate tags and preserves passthrough/content", async () => {
const join = await RelayJoin.fromEvent(
makeEvent({tags: [["claim", "abc123"], ["alt", "x"]], content: "let me in"}),
)
const tmpl = await join.builder().toTemplate(signer)
expect(tmpl.kind).toBe(RELAY_JOIN)
expect(tmpl.tags.filter(t => t[0] === "claim").length).toBe(1)
expect(tmpl.tags).toContainEqual(["alt", "x"])
expect(tmpl.content).toBe("let me in")
})
it("builds from a fresh builder", async () => {
const tmpl = await new RelayJoinBuilder()
.setClaim("invite42")
.setReason("hello")
.toTemplate(signer)
expect(tmpl.tags).toContainEqual(["claim", "invite42"])
expect(tmpl.content).toBe("hello")
})
it("throws on the wrong kind", async () => {
await expect(RelayJoin.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
@@ -0,0 +1,46 @@
import {describe, it, expect} from "vitest"
import {makeSecret, RELAY_LEAVE, NOTE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {RelayLeave, RelayLeaveBuilder} from "../src/kinds/RelayLeave"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const group = "wss://relay.example.com"
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: RELAY_LEAVE,
tags: [],
content: "",
sig: "00".repeat(64),
...overrides,
}) as TrustedEvent
describe("RelayLeave", () => {
it("round-trips the group behavior tag without duplication", async () => {
const leave = await RelayLeave.fromEvent(makeEvent({tags: [["h", group], ["alt", "x"]]}))
expect(leave.group()).toBe(group)
const tmpl = await leave.builder().toTemplate(signer)
expect(tmpl.kind).toBe(RELAY_LEAVE)
expect(tmpl.tags.filter(t => t[0] === "h").length).toBe(1)
expect(tmpl.tags).toContainEqual(["h", group])
expect(tmpl.tags).toContainEqual(["alt", "x"])
})
it("sets the group via a fresh builder", async () => {
const tmpl = await new RelayLeaveBuilder().group(group).toTemplate(signer)
expect(tmpl.tags).toContainEqual(["h", group])
})
it("throws on the wrong kind", async () => {
await expect(RelayLeave.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
@@ -0,0 +1,92 @@
import {describe, it, expect} from "vitest"
import {makeSecret, RELAYS, NOTE, RelayMode, normalizeRelayUrl} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {RelayList, RelayListBuilder} from "../src/kinds/RelayList"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const both = "wss://both.example.com/"
const read = "wss://read.example.com/"
const write = "wss://write.example.com/"
const makeEvent = (o: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: RELAYS,
tags: [],
content: "",
sig: "00".repeat(64),
...o,
}) as TrustedEvent
describe("RelayList", () => {
it("reads relay urls split by read/write mode", async () => {
const reader = await RelayList.fromEvent(
makeEvent({
tags: [
["r", both],
["r", read, RelayMode.Read],
["r", write, RelayMode.Write],
["alt", "x"],
],
}),
)
expect(reader.urls().sort()).toEqual(
[both, read, write].map(normalizeRelayUrl).sort(),
)
expect(reader.readUrls().sort()).toEqual(
[both, read].map(normalizeRelayUrl).sort(),
)
expect(reader.writeUrls().sort()).toEqual(
[both, write].map(normalizeRelayUrl).sort(),
)
})
it("round-trips without duplicating represented tags", async () => {
const reader = await RelayList.fromEvent(
makeEvent({
tags: [["r", both], ["r", read, RelayMode.Read], ["alt", "x"]],
}),
)
const tmpl = await reader.builder().toTemplate(signer)
expect(tmpl.tags.filter(t => t[0] === "r").length).toBe(2)
expect(tmpl.tags).toContainEqual(["alt", "x"])
})
it("adds modeless and single-mode relays via a fresh builder", async () => {
const tmpl = await new RelayListBuilder()
.addRelay(read, RelayMode.Read)
.addRelay(write, RelayMode.Write)
.addRelay(both, RelayMode.Read)
.addRelay(both, RelayMode.Write)
.toTemplate(signer)
expect(tmpl.kind).toBe(RELAYS)
// both was added for read then write, so it should collapse to modeless.
expect(tmpl.tags).toContainEqual(["r", both])
expect(tmpl.tags).toContainEqual(["r", read, RelayMode.Read])
expect(tmpl.tags).toContainEqual(["r", write, RelayMode.Write])
})
it("downgrades a modeless relay when one mode is removed", async () => {
const tmpl = await new RelayListBuilder()
.addRelay(both, RelayMode.Read)
.addRelay(both, RelayMode.Write)
.removeRelay(both, RelayMode.Read)
.toTemplate(signer)
expect(tmpl.tags).toContainEqual(["r", both, RelayMode.Write])
expect(tmpl.tags).not.toContainEqual(["r", both])
})
it("throws on the wrong kind", async () => {
await expect(RelayList.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
@@ -0,0 +1,63 @@
import {describe, it, expect} from "vitest"
import {makeSecret, RELAY_MEMBERS, NOTE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {RelayMembers, RelayMembersBuilder} from "../src/kinds/RelayMembers"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const a = "aa".repeat(32)
const b = "bb".repeat(32)
const c = "cc".repeat(32)
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: RELAY_MEMBERS,
tags: [],
content: "",
sig: "00".repeat(64),
...overrides,
}) as TrustedEvent
describe("RelayMembers", () => {
it("reads members from p tags", async () => {
const members = await RelayMembers.fromEvent(makeEvent({tags: [["p", a], ["p", b], ["p", a]]}))
expect(members.pubkeys().sort()).toEqual([a, b].sort())
expect(members.isMember(a)).toBe(true)
expect(members.isMember(c)).toBe(false)
})
it("round-trips with deduped p tags and passthrough", async () => {
const members = await RelayMembers.fromEvent(
makeEvent({tags: [["p", a], ["p", b], ["alt", "x"]]}),
)
const tmpl = await members.builder().toTemplate(signer)
expect(tmpl.kind).toBe(RELAY_MEMBERS)
expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(2)
expect(tmpl.tags).toContainEqual(["p", a])
expect(tmpl.tags).toContainEqual(["p", b])
expect(tmpl.tags).toContainEqual(["alt", "x"])
})
it("adds and removes members via the builder", async () => {
const tmpl = await new RelayMembersBuilder()
.addPubkey(a)
.addPubkey(b)
.addPubkey(a)
.removePubkey(b)
.toTemplate(signer)
expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(1)
expect(tmpl.tags).toContainEqual(["p", a])
})
it("throws on the wrong kind", async () => {
await expect(RelayMembers.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
@@ -0,0 +1,84 @@
import {describe, it, expect} from "vitest"
import {makeSecret, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER, NOTE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {
RelayAddMember,
RelayAddMemberBuilder,
RelayRemoveMember,
RelayRemoveMemberBuilder,
} from "../src/kinds/RelayMembershipOp"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const a = "aa".repeat(32)
const b = "bb".repeat(32)
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: RELAY_ADD_MEMBER,
tags: [],
content: "",
sig: "00".repeat(64),
...overrides,
}) as TrustedEvent
describe("RelayAddMember", () => {
it("reads affected pubkeys, deduped", async () => {
const op = await RelayAddMember.fromEvent(makeEvent({tags: [["p", a], ["p", b], ["p", a]]}))
expect(op.pubkeys().sort()).toEqual([a, b].sort())
})
it("round-trips with no duplicate p tags and passthrough", async () => {
const op = await RelayAddMember.fromEvent(
makeEvent({tags: [["p", a], ["p", b], ["alt", "x"]]}),
)
const tmpl = await op.builder().toTemplate(signer)
expect(tmpl.kind).toBe(RELAY_ADD_MEMBER)
expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(2)
expect(tmpl.tags).toContainEqual(["p", a])
expect(tmpl.tags).toContainEqual(["alt", "x"])
})
it("builds fresh with the right kind", async () => {
const tmpl = await new RelayAddMemberBuilder().addPubkey(a).addPubkey(a).toTemplate(signer)
expect(tmpl.kind).toBe(RELAY_ADD_MEMBER)
expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(1)
expect(tmpl.tags).toContainEqual(["p", a])
})
it("throws on the wrong kind", async () => {
await expect(RelayAddMember.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
describe("RelayRemoveMember", () => {
it("reads affected pubkeys with the remove kind", async () => {
const op = await RelayRemoveMember.fromEvent(
makeEvent({kind: RELAY_REMOVE_MEMBER, tags: [["p", a]]}),
)
expect(op.kind).toBe(RELAY_REMOVE_MEMBER)
expect(op.pubkeys()).toEqual([a])
})
it("builds fresh with the remove kind", async () => {
const tmpl = await new RelayRemoveMemberBuilder().addPubkey(a).toTemplate(signer)
expect(tmpl.kind).toBe(RELAY_REMOVE_MEMBER)
expect(tmpl.tags).toContainEqual(["p", a])
})
it("throws when the add kind is read as a remove", async () => {
await expect(
RelayRemoveMember.fromEvent(makeEvent({kind: RELAY_ADD_MEMBER})),
).rejects.toThrow()
})
})
+106
View File
@@ -0,0 +1,106 @@
import {describe, it, expect} from "vitest"
import {makeSecret, NAMED_RELAYS, NOTE, normalizeRelayUrl} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {RelaySet, RelaySetBuilder} from "../src/kinds/RelaySet"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const relayA = "wss://set-a.example.com/"
const relayB = "wss://set-b.example.com/"
const makeEvent = (o: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: NAMED_RELAYS,
tags: [],
content: "",
sig: "00".repeat(64),
...o,
}) as TrustedEvent
describe("RelaySet", () => {
it("reads metadata and relay urls", async () => {
const reader = await RelaySet.fromEvent(
makeEvent({
tags: [
["d", "my-set"],
["title", "My Set"],
["description", "a set of relays"],
["image", "https://example.com/img.png"],
["relay", relayA],
["alt", "x"],
],
}),
)
expect(reader.identifier()).toBe("my-set")
expect(reader.title()).toBe("My Set")
expect(reader.description()).toBe("a set of relays")
expect(reader.image()).toBe("https://example.com/img.png")
expect(reader.urls()).toEqual([normalizeRelayUrl(relayA)])
})
it("round-trips metadata, d and relay tags exactly once each", async () => {
const reader = await RelaySet.fromEvent(
makeEvent({
tags: [
["d", "my-set"],
["title", "My Set"],
["description", "a set of relays"],
["image", "https://example.com/img.png"],
["relay", relayA],
["alt", "x"],
],
}),
)
const tmpl = await reader.builder().toTemplate(signer)
expect(tmpl.tags.filter(t => t[0] === "d").length).toBe(1)
expect(tmpl.tags.filter(t => t[0] === "title").length).toBe(1)
expect(tmpl.tags.filter(t => t[0] === "description").length).toBe(1)
expect(tmpl.tags.filter(t => t[0] === "image").length).toBe(1)
expect(tmpl.tags.filter(t => t[0] === "relay").length).toBe(1)
// Metadata round-trips with the exact same values.
expect(tmpl.tags).toContainEqual(["d", "my-set"])
expect(tmpl.tags).toContainEqual(["title", "My Set"])
expect(tmpl.tags).toContainEqual(["description", "a set of relays"])
expect(tmpl.tags).toContainEqual(["image", "https://example.com/img.png"])
expect(tmpl.tags).toContainEqual(["relay", normalizeRelayUrl(relayA)])
expect(tmpl.tags).toContainEqual(["alt", "x"])
})
it("auto-generates a d identifier on a fresh builder", async () => {
const builder = new RelaySetBuilder()
expect(builder.identifier).toBeTruthy()
const tmpl = await builder.setTitle("Fresh").addRelay(relayA).toTemplate(signer)
expect(tmpl.kind).toBe(NAMED_RELAYS)
expect(tmpl.tags.filter(t => t[0] === "d").length).toBe(1)
expect(tmpl.tags).toContainEqual(["title", "Fresh"])
expect(tmpl.tags).toContainEqual(["relay", normalizeRelayUrl(relayA)])
})
it("setRelays replaces relays but preserves metadata", async () => {
const reader = await RelaySet.fromEvent(
makeEvent({tags: [["d", "my-set"], ["title", "My Set"], ["relay", relayA]]}),
)
const tmpl = await reader.builder().setRelays([relayB]).toTemplate(signer)
expect(tmpl.tags).toContainEqual(["relay", normalizeRelayUrl(relayB)])
expect(tmpl.tags.some(t => t[0] === "relay" && t[1] === normalizeRelayUrl(relayA))).toBe(false)
expect(tmpl.tags).toContainEqual(["title", "My Set"])
expect(tmpl.tags).toContainEqual(["d", "my-set"])
})
it("throws on the wrong kind", async () => {
await expect(RelaySet.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
+81
View File
@@ -0,0 +1,81 @@
import {describe, it, expect} from "vitest"
import {makeSecret, REPORT, NOTE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {Report, ReportBuilder} from "../src/kinds/Report"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const reported = "aa".repeat(32)
const eventId = "bb".repeat(32)
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: REPORT,
tags: [],
content: "",
sig: "00".repeat(64),
...overrides,
}) as TrustedEvent
describe("Report", () => {
it("reads represented tags and content", async () => {
const event = makeEvent({
content: "this is spam",
tags: [
["p", reported],
["e", eventId, "spam"],
["alt", "x"],
],
})
const report = await Report.fromEvent(event)
expect(report.reportedPubkey()).toBe(reported)
expect(report.eventId()).toBe(eventId)
expect(report.reason()).toBe("spam")
expect(report.content()).toBe("this is spam")
})
it("round-trips with no duplicate represented tags", async () => {
const event = makeEvent({
content: "this is spam",
tags: [
["p", reported],
["e", eventId, "spam"],
["alt", "x"],
],
})
const tmpl = await (await Report.fromEvent(event)).builder().toTemplate(signer)
expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(1)
expect(tmpl.tags.filter(t => t[0] === "e").length).toBe(1)
expect(tmpl.tags).toContainEqual(["p", reported])
expect(tmpl.tags).toContainEqual(["e", eventId, "spam"])
// Unknown passthrough tag survives.
expect(tmpl.tags).toContainEqual(["alt", "x"])
expect(tmpl.content).toBe("this is spam")
})
it("builds from a fresh builder", async () => {
const tmpl = await new ReportBuilder()
.setReportedPubkey(reported)
.setEventId(eventId)
.setReason("impersonation")
.setContent("bad actor")
.toTemplate(signer)
expect(tmpl.kind).toBe(REPORT)
expect(tmpl.tags).toContainEqual(["p", reported])
expect(tmpl.tags).toContainEqual(["e", eventId, "impersonation"])
expect(tmpl.content).toBe("bad actor")
})
it("throws on the wrong kind", async () => {
await expect(Report.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
@@ -0,0 +1,69 @@
import {describe, it, expect} from "vitest"
import {makeSecret, ROOM_ADMINS, NOTE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {RoomAdmins, RoomAdminsBuilder} from "../src/kinds/RoomAdmins"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const a = "aa".repeat(32)
const b = "bb".repeat(32)
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: ROOM_ADMINS,
tags: [],
content: "",
sig: "00".repeat(64),
...overrides,
}) as TrustedEvent
describe("RoomAdmins", () => {
it("reads represented tags", async () => {
const room = await RoomAdmins.fromEvent(
makeEvent({tags: [["d", "room1"], ["p", a], ["p", b], ["alt", "x"]]}),
)
expect(room.h()).toBe("room1")
expect(room.identifier()).toBe("room1")
expect(room.pubkeys()).toEqual([a, b])
})
it("round-trips with no duplicated tags", async () => {
const room = await RoomAdmins.fromEvent(
makeEvent({tags: [["d", "room1"], ["p", a], ["p", b], ["alt", "x"]]}),
)
const tmpl = await room.builder().toTemplate(signer)
expect(tmpl.kind).toBe(ROOM_ADMINS)
expect(tmpl.tags.filter(t => t[0] === "d").length).toBe(1)
expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(2)
expect(tmpl.tags).toContainEqual(["d", "room1"])
expect(tmpl.tags).toContainEqual(["p", a])
expect(tmpl.tags).toContainEqual(["p", b])
// Unknown passthrough tag survives.
expect(tmpl.tags).toContainEqual(["alt", "x"])
})
it("builds from a fresh builder", async () => {
const tmpl = await new RoomAdminsBuilder()
.setH("room2")
.addPubkey(a)
.addPubkey(a) // dedup
.addPubkey(b)
.toTemplate(signer)
expect(tmpl.tags).toContainEqual(["d", "room2"])
expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(2)
expect(tmpl.tags).toContainEqual(["p", a])
expect(tmpl.tags).toContainEqual(["p", b])
})
it("throws on the wrong kind", async () => {
await expect(RoomAdmins.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
@@ -0,0 +1,46 @@
import {describe, it, expect} from "vitest"
import {makeSecret, ROOM_CREATE, NOTE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {RoomCreate, RoomCreateBuilder} from "../src/kinds/RoomCreate"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const group = "abcd1234"
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: ROOM_CREATE,
tags: [],
content: "",
sig: "00".repeat(64),
...overrides,
}) as TrustedEvent
describe("RoomCreate", () => {
it("round-trips the group behavior tag without duplication", async () => {
const create = await RoomCreate.fromEvent(makeEvent({tags: [["h", group], ["alt", "x"]]}))
expect(create.group()).toBe(group)
const tmpl = await create.builder().toTemplate(signer)
expect(tmpl.kind).toBe(ROOM_CREATE)
expect(tmpl.tags.filter(t => t[0] === "h").length).toBe(1)
expect(tmpl.tags).toContainEqual(["h", group])
expect(tmpl.tags).toContainEqual(["alt", "x"])
})
it("sets the group via a fresh builder", async () => {
const tmpl = await new RoomCreateBuilder().group(group).toTemplate(signer)
expect(tmpl.tags).toContainEqual(["h", group])
})
it("throws on the wrong kind", async () => {
await expect(RoomCreate.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
@@ -0,0 +1,64 @@
import {describe, it, expect} from "vitest"
import {makeSecret, ROOM_CREATE_PERMISSION, NOTE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {
RoomCreatePermission,
RoomCreatePermissionBuilder,
} from "../src/kinds/RoomCreatePermission"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const a = "aa".repeat(32)
const b = "bb".repeat(32)
const c = "cc".repeat(32)
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: ROOM_CREATE_PERMISSION,
tags: [],
content: "",
sig: "00".repeat(64),
...overrides,
}) as TrustedEvent
describe("RoomCreatePermission", () => {
it("reads permitted pubkeys from p tags", async () => {
const perm = await RoomCreatePermission.fromEvent(
makeEvent({tags: [["p", a], ["p", b], ["p", a]]}),
)
expect(perm.pubkeys().sort()).toEqual([a, b].sort())
expect(perm.canCreate(a)).toBe(true)
expect(perm.canCreate(c)).toBe(false)
})
it("round-trips with no duplicate p tags and passthrough", async () => {
const perm = await RoomCreatePermission.fromEvent(
makeEvent({tags: [["p", a], ["p", b], ["alt", "x"]]}),
)
const tmpl = await perm.builder().toTemplate(signer)
expect(tmpl.kind).toBe(ROOM_CREATE_PERMISSION)
expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(2)
expect(tmpl.tags).toContainEqual(["p", a])
expect(tmpl.tags).toContainEqual(["p", b])
expect(tmpl.tags).toContainEqual(["alt", "x"])
})
it("sets pubkeys via a fresh builder, deduped", async () => {
const tmpl = await new RoomCreatePermissionBuilder().setPubkeys([a, b, a]).toTemplate(signer)
expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(2)
expect(tmpl.tags).toContainEqual(["p", a])
expect(tmpl.tags).toContainEqual(["p", b])
})
it("throws on the wrong kind", async () => {
await expect(RoomCreatePermission.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
@@ -0,0 +1,69 @@
import {describe, it, expect} from "vitest"
import {makeSecret, ROOM_DELETE, NOTE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {RoomDelete, RoomDeleteBuilder} from "../src/kinds/RoomDelete"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: ROOM_DELETE,
tags: [],
content: "",
sig: "00".repeat(64),
...overrides,
}) as TrustedEvent
describe("RoomDelete", () => {
it("reads multiple repeatable h tags", async () => {
const del = await RoomDelete.fromEvent(
makeEvent({tags: [["h", "room1"], ["h", "room2"], ["h", "room3"], ["alt", "x"]]}),
)
expect(del.hs()).toEqual(["room1", "room2", "room3"])
expect(del.h()).toBe("room1")
})
it("round-trips multiple rooms, emitting one h each with no duplication", async () => {
const del = await RoomDelete.fromEvent(
makeEvent({tags: [["h", "room1"], ["h", "room2"], ["h", "room3"], ["alt", "x"]]}),
)
const tmpl = await del.builder().toTemplate(signer)
expect(tmpl.kind).toBe(ROOM_DELETE)
// Three rooms in, three h tags out — exactly one per room, no duplicates.
expect(tmpl.tags.filter(t => t[0] === "h").length).toBe(3)
expect(tmpl.tags).toContainEqual(["h", "room1"])
expect(tmpl.tags).toContainEqual(["h", "room2"])
expect(tmpl.tags).toContainEqual(["h", "room3"])
// Unknown passthrough tag survives.
expect(tmpl.tags).toContainEqual(["alt", "x"])
})
it("builds from a fresh builder and edits the room set", async () => {
const tmpl = await new RoomDeleteBuilder()
.addRoom("room1")
.addRoom("room1") // dedup
.addRoom("room2")
.removeRoom("room1")
.toTemplate(signer)
expect(tmpl.tags.filter(t => t[0] === "h").length).toBe(1)
expect(tmpl.tags).toContainEqual(["h", "room2"])
expect(tmpl.tags).not.toContainEqual(["h", "room1"])
})
it("requires at least one h tag", async () => {
await expect(new RoomDeleteBuilder().toTemplate(signer)).rejects.toThrow()
})
it("throws on the wrong kind", async () => {
await expect(RoomDelete.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
@@ -0,0 +1,77 @@
import {describe, it, expect} from "vitest"
import {makeSecret, ROOM_JOIN, NOTE, getTagValue} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {RoomJoin, RoomJoinBuilder} from "../src/kinds/RoomJoin"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: ROOM_JOIN,
tags: [],
content: "",
sig: "00".repeat(64),
...overrides,
}) as TrustedEvent
describe("RoomJoin", () => {
it("reads represented fields", async () => {
const join = await RoomJoin.fromEvent(
makeEvent({
tags: [["h", "room1"], ["claim", "invite-code"], ["alt", "x"]],
content: "please let me in",
}),
)
expect(join.group()).toBe("room1")
expect(join.code()).toBe("invite-code")
expect(join.reason()).toBe("please let me in")
})
it("round-trips with no duplicated tags", async () => {
const join = await RoomJoin.fromEvent(
makeEvent({
tags: [["h", "room1"], ["claim", "invite-code"], ["alt", "x"]],
content: "please let me in",
}),
)
const tmpl = await join.builder().toTemplate(signer)
expect(tmpl.kind).toBe(ROOM_JOIN)
// h round-trips via the base behavior tag.
expect(tmpl.tags.filter(t => t[0] === "h").length).toBe(1)
expect(tmpl.tags.filter(t => t[0] === "claim").length).toBe(1)
expect(getTagValue("h", tmpl.tags)).toBe("room1")
expect(tmpl.tags).toContainEqual(["claim", "invite-code"])
// Content (free-text reason) is preserved.
expect(tmpl.content).toBe("please let me in")
// Unknown passthrough tag survives.
expect(tmpl.tags).toContainEqual(["alt", "x"])
})
it("builds from a fresh builder", async () => {
const tmpl = await new RoomJoinBuilder()
.group("room2")
.setCode("xyz")
.setReason("hi there")
.toTemplate(signer)
expect(getTagValue("h", tmpl.tags)).toBe("room2")
expect(tmpl.tags).toContainEqual(["claim", "xyz"])
expect(tmpl.content).toBe("hi there")
})
it("requires an h/group", async () => {
await expect(new RoomJoinBuilder().setCode("xyz").toTemplate(signer)).rejects.toThrow()
})
it("throws on the wrong kind", async () => {
await expect(RoomJoin.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
@@ -0,0 +1,55 @@
import {describe, it, expect} from "vitest"
import {makeSecret, ROOM_LEAVE, NOTE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {RoomLeave, RoomLeaveBuilder} from "../src/kinds/RoomLeave"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const group = "abcd1234"
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: ROOM_LEAVE,
tags: [],
content: "",
sig: "00".repeat(64),
...overrides,
}) as TrustedEvent
describe("RoomLeave", () => {
it("reads the group via group() and h()", async () => {
const leave = await RoomLeave.fromEvent(makeEvent({tags: [["h", group]]}))
expect(leave.group()).toBe(group)
expect(leave.h()).toBe(group)
})
it("round-trips the group behavior tag without duplication", async () => {
const leave = await RoomLeave.fromEvent(makeEvent({tags: [["h", group], ["alt", "x"]]}))
const tmpl = await leave.builder().toTemplate(signer)
expect(tmpl.kind).toBe(ROOM_LEAVE)
expect(tmpl.tags.filter(t => t[0] === "h").length).toBe(1)
expect(tmpl.tags).toContainEqual(["h", group])
expect(tmpl.tags).toContainEqual(["alt", "x"])
})
it("sets the group via a fresh builder", async () => {
const tmpl = await new RoomLeaveBuilder().group(group).toTemplate(signer)
expect(tmpl.tags).toContainEqual(["h", group])
})
it("requires an h identifier", async () => {
await expect(new RoomLeaveBuilder().toTemplate(signer)).rejects.toThrow()
})
it("throws on the wrong kind", async () => {
await expect(RoomLeave.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
@@ -0,0 +1,79 @@
import {describe, it, expect} from "vitest"
import {makeSecret, ROOMS, NOTE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {RoomList, RoomListBuilder} from "../src/kinds/RoomList"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const relay = "wss://groups.example.com/"
const groupA = "groupa"
const groupB = "groupb"
const makeEvent = (o: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: ROOMS,
tags: [],
content: "",
sig: "00".repeat(64),
...o,
}) as TrustedEvent
describe("RoomList", () => {
it("reads joined groups", async () => {
const reader = await RoomList.fromEvent(
makeEvent({tags: [["group", groupA, relay], ["alt", "x"]]}),
)
expect(reader.groups()).toEqual([groupA])
expect(reader.groupTags()).toEqual([["group", groupA, relay]])
})
it("round-trips without duplicating represented tags", async () => {
const reader = await RoomList.fromEvent(
makeEvent({tags: [["group", groupA, relay], ["alt", "x"]]}),
)
const tmpl = await reader.builder().toTemplate(signer)
expect(tmpl.tags.filter(t => t[0] === "group").length).toBe(1)
expect(tmpl.tags).toContainEqual(["alt", "x"])
})
it("joins and leaves groups via a fresh builder", async () => {
const tmpl = await new RoomListBuilder()
.join(groupA, relay)
.join(groupB, relay)
.leave(groupA)
.toTemplate(signer)
expect(tmpl.kind).toBe(ROOMS)
expect(tmpl.tags).toContainEqual(["group", groupB, relay])
expect(tmpl.tags.some(t => t[1] === groupA)).toBe(false)
})
it("round-trips public and private groups through encryption", async () => {
const event = await new RoomListBuilder()
.join(groupA, relay)
.addPrivate(["group", groupB, relay])
.toEvent(signer)
const decrypted = await RoomList.fromEvent(event, signer)
expect(decrypted.decrypted).toBe(true)
expect(decrypted.groups().sort()).toEqual([groupA, groupB].sort())
const publicOnly = await RoomList.fromEvent(event)
expect(publicOnly.decrypted).toBe(false)
expect(publicOnly.groups()).toEqual([groupA])
})
it("throws on the wrong kind", async () => {
await expect(RoomList.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
@@ -0,0 +1,78 @@
import {describe, it, expect} from "vitest"
import {makeSecret, ROOM_MEMBERS, NOTE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {RoomMembers, RoomMembersBuilder} from "../src/kinds/RoomMembers"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const a = "aa".repeat(32)
const b = "bb".repeat(32)
const c = "cc".repeat(32)
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: ROOM_MEMBERS,
tags: [],
content: "",
sig: "00".repeat(64),
...overrides,
}) as TrustedEvent
describe("RoomMembers", () => {
it("reads represented tags", async () => {
const room = await RoomMembers.fromEvent(
makeEvent({tags: [["d", "room1"], ["p", a], ["p", b], ["alt", "x"]]}),
)
expect(room.h()).toBe("room1")
expect(room.members()).toEqual([a, b])
expect(room.isMember(a)).toBe(true)
expect(room.isMember(c)).toBe(false)
})
it("round-trips with no duplicated tags", async () => {
const room = await RoomMembers.fromEvent(
makeEvent({tags: [["d", "room1"], ["p", a], ["p", b], ["alt", "x"]]}),
)
const tmpl = await room.builder().toTemplate(signer)
expect(tmpl.kind).toBe(ROOM_MEMBERS)
expect(tmpl.tags.filter(t => t[0] === "d").length).toBe(1)
expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(2)
expect(tmpl.tags).toContainEqual(["d", "room1"])
expect(tmpl.tags).toContainEqual(["p", a])
expect(tmpl.tags).toContainEqual(["p", b])
// Unknown passthrough tag survives.
expect(tmpl.tags).toContainEqual(["alt", "x"])
})
it("requires an h identifier on a fresh builder", async () => {
await expect(new RoomMembersBuilder().addMember(a).toTemplate(signer)).rejects.toThrow()
})
it("builds from a fresh builder and edits membership", async () => {
const builder = new RoomMembersBuilder()
builder.h = "room2"
const tmpl = await builder
.addMember(a)
.addMember(a) // dedup
.addMember(b)
.removeMember(b)
.toTemplate(signer)
expect(tmpl.tags).toContainEqual(["d", "room2"])
expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(1)
expect(tmpl.tags).toContainEqual(["p", a])
expect(tmpl.tags).not.toContainEqual(["p", b])
})
it("throws on the wrong kind", async () => {
await expect(RoomMembers.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
@@ -0,0 +1,114 @@
import {describe, it, expect} from "vitest"
import {makeSecret, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER, NOTE, getTagValue} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {
RoomAddMember,
RoomAddMemberBuilder,
RoomRemoveMember,
RoomRemoveMemberBuilder,
} from "../src/kinds/RoomMembershipOp"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const a = "aa".repeat(32)
const b = "bb".repeat(32)
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: ROOM_ADD_MEMBER,
tags: [],
content: "",
sig: "00".repeat(64),
...overrides,
}) as TrustedEvent
describe("RoomAddMember", () => {
it("reads pubkeys and group", async () => {
const op = await RoomAddMember.fromEvent(
makeEvent({tags: [["h", "room1"], ["p", a], ["p", b], ["alt", "x"]]}),
)
expect(op.kind).toBe(ROOM_ADD_MEMBER)
expect(op.group()).toBe("room1")
expect(op.pubkeys()).toEqual([a, b])
})
it("round-trips with no duplicated tags", async () => {
const op = await RoomAddMember.fromEvent(
makeEvent({tags: [["h", "room1"], ["p", a], ["p", b], ["alt", "x"]]}),
)
const tmpl = await op.builder().toTemplate(signer)
expect(tmpl.kind).toBe(ROOM_ADD_MEMBER)
// h round-trips via the base behavior tag.
expect(tmpl.tags.filter(t => t[0] === "h").length).toBe(1)
expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(2)
expect(getTagValue("h", tmpl.tags)).toBe("room1")
expect(tmpl.tags).toContainEqual(["p", a])
expect(tmpl.tags).toContainEqual(["p", b])
// Unknown passthrough tag survives.
expect(tmpl.tags).toContainEqual(["alt", "x"])
})
it("builds from a fresh builder", async () => {
const tmpl = await new RoomAddMemberBuilder()
.group("room2")
.addPubkey(a)
.addPubkey(a) // dedup
.addPubkey(b)
.toTemplate(signer)
expect(tmpl.kind).toBe(ROOM_ADD_MEMBER)
expect(getTagValue("h", tmpl.tags)).toBe("room2")
expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(2)
expect(tmpl.tags).toContainEqual(["p", a])
expect(tmpl.tags).toContainEqual(["p", b])
})
it("throws on the wrong kind", async () => {
await expect(RoomAddMember.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
describe("RoomRemoveMember", () => {
it("uses the remove kind and reads pubkeys", async () => {
const op = await RoomRemoveMember.fromEvent(
makeEvent({kind: ROOM_REMOVE_MEMBER, tags: [["h", "room1"], ["p", a]]}),
)
expect(op.kind).toBe(ROOM_REMOVE_MEMBER)
expect(op.pubkeys()).toEqual([a])
})
it("round-trips through the remove builder", async () => {
const op = await RoomRemoveMember.fromEvent(
makeEvent({kind: ROOM_REMOVE_MEMBER, tags: [["h", "room1"], ["p", a], ["p", b]]}),
)
const tmpl = await op.builder().toTemplate(signer)
expect(tmpl.kind).toBe(ROOM_REMOVE_MEMBER)
expect(tmpl.tags.filter(t => t[0] === "h").length).toBe(1)
expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(2)
expect(getTagValue("h", tmpl.tags)).toBe("room1")
})
it("builds from a fresh remove builder", async () => {
const tmpl = await new RoomRemoveMemberBuilder().group("room2").addPubkey(a).toTemplate(signer)
expect(tmpl.kind).toBe(ROOM_REMOVE_MEMBER)
expect(getTagValue("h", tmpl.tags)).toBe("room2")
expect(tmpl.tags).toContainEqual(["p", a])
})
it("throws on the wrong kind", async () => {
await expect(
RoomRemoveMember.fromEvent(makeEvent({kind: ROOM_ADD_MEMBER, tags: [["p", a]]})),
).rejects.toThrow()
})
})
+100
View File
@@ -0,0 +1,100 @@
import {describe, it, expect} from "vitest"
import {makeSecret, ROOM_META, NOTE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {RoomMeta, RoomMetaBuilder} from "../src/kinds/RoomMeta"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: ROOM_META,
tags: [],
content: "",
sig: "00".repeat(64),
...overrides,
}) as TrustedEvent
describe("RoomMeta", () => {
it("reads represented tags", async () => {
const room = await RoomMeta.fromEvent(
makeEvent({
tags: [
["d", "room1"],
["name", "My Room"],
["about", "a place"],
["picture", "https://img", "256x256"],
["closed"],
["private"],
["livekit"],
["alt", "x"],
],
}),
)
expect(room.h()).toBe("room1")
expect(room.identifier()).toBe("room1")
expect(room.name()).toBe("My Room")
expect(room.about()).toBe("a place")
expect(room.picture()).toBe("https://img")
expect(room.pictureMeta()).toEqual(["256x256"])
expect(room.isClosed()).toBe(true)
expect(room.isHidden()).toBe(false)
expect(room.isPrivate()).toBe(true)
expect(room.isRestricted()).toBe(false)
expect(room.livekit()).toBe(true)
})
it("round-trips with no duplicated tags", async () => {
const room = await RoomMeta.fromEvent(
makeEvent({
tags: [
["d", "room1"],
["name", "My Room"],
["about", "a place"],
["picture", "https://img", "256x256"],
["closed"],
["private"],
["alt", "x"],
],
}),
)
const tmpl = await room.builder().toTemplate(signer)
expect(tmpl.kind).toBe(ROOM_META)
expect(tmpl.tags.filter(t => t[0] === "d").length).toBe(1)
expect(tmpl.tags.filter(t => t[0] === "name").length).toBe(1)
expect(tmpl.tags.filter(t => t[0] === "about").length).toBe(1)
expect(tmpl.tags.filter(t => t[0] === "picture").length).toBe(1)
expect(tmpl.tags.filter(t => t[0] === "closed").length).toBe(1)
expect(tmpl.tags.filter(t => t[0] === "private").length).toBe(1)
expect(tmpl.tags).toContainEqual(["d", "room1"])
expect(tmpl.tags).toContainEqual(["name", "My Room"])
expect(tmpl.tags).toContainEqual(["picture", "https://img", "256x256"])
// Unknown passthrough tag survives.
expect(tmpl.tags).toContainEqual(["alt", "x"])
})
it("builds from a fresh builder", async () => {
const tmpl = await new RoomMetaBuilder()
.setName("Fresh")
.setAbout("desc")
.setPicture("https://pic", ["100x100"])
.toTemplate(signer)
expect(tmpl.tags).toContainEqual(["name", "Fresh"])
expect(tmpl.tags).toContainEqual(["about", "desc"])
expect(tmpl.tags).toContainEqual(["picture", "https://pic", "100x100"])
// A d-identifier is auto-generated.
expect(tmpl.tags.find(t => t[0] === "d")?.[1]).toBeTruthy()
})
it("throws on the wrong kind", async () => {
await expect(RoomMeta.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
@@ -0,0 +1,81 @@
import {describe, it, expect} from "vitest"
import {makeSecret, SEARCH_RELAYS, NOTE, normalizeRelayUrl} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {SearchRelayList, SearchRelayListBuilder} from "../src/kinds/SearchRelayList"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const relayA = "wss://search-a.example.com/"
const relayB = "wss://search-b.example.com/"
const makeEvent = (o: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: SEARCH_RELAYS,
tags: [],
content: "",
sig: "00".repeat(64),
...o,
}) as TrustedEvent
describe("SearchRelayList", () => {
it("reads search relay urls", async () => {
const reader = await SearchRelayList.fromEvent(
makeEvent({tags: [["relay", relayA], ["alt", "x"]]}),
)
expect(reader.urls()).toEqual([normalizeRelayUrl(relayA)])
expect(reader.includes(relayA)).toBe(true)
expect(reader.includes(relayB)).toBe(false)
})
it("round-trips without duplicating represented tags", async () => {
const reader = await SearchRelayList.fromEvent(
makeEvent({tags: [["relay", relayA], ["alt", "x"]]}),
)
const tmpl = await reader.builder().toTemplate(signer)
expect(tmpl.tags.filter(t => t[0] === "relay").length).toBe(1)
expect(tmpl.tags).toContainEqual(["alt", "x"])
})
it("adds and removes relays via a fresh builder", async () => {
const tmpl = await new SearchRelayListBuilder()
.addRelay(relayA)
.addRelay(relayB)
.removeRelay(relayA)
.toTemplate(signer)
expect(tmpl.kind).toBe(SEARCH_RELAYS)
expect(tmpl.tags).toContainEqual(["relay", normalizeRelayUrl(relayB)])
expect(tmpl.tags.some(t => t[1] === normalizeRelayUrl(relayA))).toBe(false)
})
it("round-trips public and private relays through encryption", async () => {
const event = await new SearchRelayListBuilder()
.addRelay(relayA)
.addPrivate(["relay", relayB])
.toEvent(signer)
const decrypted = await SearchRelayList.fromEvent(event, signer)
expect(decrypted.decrypted).toBe(true)
expect(decrypted.urls().sort()).toEqual(
[normalizeRelayUrl(relayA), normalizeRelayUrl(relayB)].sort(),
)
const publicOnly = await SearchRelayList.fromEvent(event)
expect(publicOnly.decrypted).toBe(false)
expect(publicOnly.urls()).toEqual([normalizeRelayUrl(relayA)])
})
it("throws on the wrong kind", async () => {
await expect(SearchRelayList.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
+87
View File
@@ -0,0 +1,87 @@
import {describe, it, expect} from "vitest"
import {makeSecret, THREAD, NOTE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {Thread, ThreadBuilder} from "../src/kinds/Thread"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: THREAD,
tags: [],
content: "",
sig: "00".repeat(64),
...overrides,
}) as TrustedEvent
describe("Thread", () => {
it("reads title and content", async () => {
const event = makeEvent({
content: "thread body",
tags: [
["title", "Hello"],
["alt", "x"],
],
})
const thread = await Thread.fromEvent(event)
expect(thread.title()).toBe("Hello")
expect(thread.content()).toBe("thread body")
})
it("round-trips with no duplicate title tag", async () => {
const event = makeEvent({
content: "thread body",
tags: [
["title", "Hello"],
["alt", "x"],
],
})
const tmpl = await (await Thread.fromEvent(event)).builder().toTemplate(signer)
expect(tmpl.tags.filter(t => t[0] === "title").length).toBe(1)
expect(tmpl.tags).toContainEqual(["title", "Hello"])
// Unknown passthrough tag survives.
expect(tmpl.tags).toContainEqual(["alt", "x"])
expect(tmpl.content).toBe("thread body")
})
it("round-trips a group (h) behavior tag without rebuilding", async () => {
const event = makeEvent({
content: "thread body",
tags: [
["title", "Hello"],
["h", "room"],
],
})
const tmpl = await (await Thread.fromEvent(event)).builder().toTemplate(signer)
expect(tmpl.tags.filter(t => t[0] === "h").length).toBe(1)
expect(tmpl.tags).toContainEqual(["h", "room"])
})
it("builds from a fresh builder", async () => {
const tmpl = await new ThreadBuilder()
.setTitle("New thread")
.setContent("body")
.group("room")
.toTemplate(signer)
expect(tmpl.kind).toBe(THREAD)
expect(tmpl.tags).toContainEqual(["title", "New thread"])
expect(tmpl.tags).toContainEqual(["h", "room"])
expect(tmpl.content).toBe("body")
})
it("throws on the wrong kind", async () => {
await expect(Thread.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
+120
View File
@@ -0,0 +1,120 @@
import {describe, it, expect} from "vitest"
import {makeSecret, EVENT_TIME, NOTE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {TimeEvent, TimeEventBuilder} from "../src/kinds/TimeEvent"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
// Both timestamps fall within the same epoch-day so exactly one derived "D" tag
// is produced (range yields only `start` when end < start + DAY).
const start = 1700000000
const end = 1700003600
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: EVENT_TIME,
tags: [],
content: "",
sig: "00".repeat(64),
...overrides,
}) as TrustedEvent
describe("TimeEvent", () => {
it("reads represented tags and content", async () => {
const event = makeEvent({
content: "meetup",
tags: [
["d", "abc"],
["title", "Party"],
["location", "Town Hall"],
["start", String(start)],
["end", String(end)],
["alt", "x"],
],
})
const time = await TimeEvent.fromEvent(event)
expect(time.identifier()).toBe("abc")
expect(time.title()).toBe("Party")
expect(time.location()).toBe("Town Hall")
expect(time.start()).toBe(start)
expect(time.end()).toBe(end)
expect(time.content()).toBe("meetup")
})
it("falls back to the legacy name tag for title", async () => {
const event = makeEvent({tags: [["name", "Legacy"]]})
const time = await TimeEvent.fromEvent(event)
expect(time.title()).toBe("Legacy")
})
it("round-trips with no duplicate represented tags", async () => {
const event = makeEvent({
content: "meetup",
tags: [
["d", "abc"],
["title", "Party"],
["location", "Town Hall"],
["start", String(start)],
["end", String(end)],
["alt", "x"],
],
})
const tmpl = await (await TimeEvent.fromEvent(event)).builder().toTemplate(signer)
for (const key of ["d", "title", "location", "start", "end"]) {
expect(tmpl.tags.filter(t => t[0] === key).length).toBe(1)
}
expect(tmpl.tags).toContainEqual(["d", "abc"])
expect(tmpl.tags).toContainEqual(["title", "Party"])
expect(tmpl.tags).toContainEqual(["start", String(start)])
// Derived day index recomputed exactly once for this single-day span.
expect(tmpl.tags.filter(t => t[0] === "D").length).toBe(1)
// Unknown passthrough tag survives.
expect(tmpl.tags).toContainEqual(["alt", "x"])
expect(tmpl.content).toBe("meetup")
})
it("does not duplicate the D index on round-trip", async () => {
// Even though the source already carries a "D" tag, it is consumed on read
// and recomputed, so it must appear exactly once.
const event = makeEvent({
tags: [
["d", "abc"],
["start", String(start)],
["end", String(end)],
["D", "999999"],
],
})
const tmpl = await (await TimeEvent.fromEvent(event)).builder().toTemplate(signer)
expect(tmpl.tags.filter(t => t[0] === "D").length).toBe(1)
expect(tmpl.tags).not.toContainEqual(["D", "999999"])
})
it("builds from a fresh builder with an auto-generated d", async () => {
const tmpl = await new TimeEventBuilder()
.setTitle("Fresh")
.setStart(start)
.setEnd(end)
.toTemplate(signer)
expect(tmpl.kind).toBe(EVENT_TIME)
expect(tmpl.tags.find(t => t[0] === "d")?.[1]).toBeTruthy()
expect(tmpl.tags).toContainEqual(["title", "Fresh"])
expect(tmpl.tags).toContainEqual(["start", String(start)])
})
it("throws on the wrong kind", async () => {
await expect(TimeEvent.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
@@ -0,0 +1,82 @@
import {describe, it, expect} from "vitest"
import {makeSecret, TOPICS, NOTE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {TopicList, TopicListBuilder} from "../src/kinds/TopicList"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const topicA = "nostr"
const topicB = "bitcoin"
const address = `30015:${"22".repeat(32)}:interests`
const makeEvent = (o: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: TOPICS,
tags: [],
content: "",
sig: "00".repeat(64),
...o,
}) as TrustedEvent
describe("TopicList", () => {
it("reads followed topics and interest-set addresses", async () => {
const reader = await TopicList.fromEvent(
makeEvent({tags: [["t", topicA], ["a", address], ["alt", "x"]]}),
)
expect(reader.topics()).toEqual([topicA])
expect(reader.addresses()).toEqual([address])
expect(reader.includes(topicA)).toBe(true)
expect(reader.includes(topicB)).toBe(false)
})
it("round-trips without duplicating represented tags", async () => {
const reader = await TopicList.fromEvent(
makeEvent({tags: [["t", topicA], ["a", address], ["alt", "x"]]}),
)
const tmpl = await reader.builder().toTemplate(signer)
expect(tmpl.tags.filter(t => t[0] === "t").length).toBe(1)
expect(tmpl.tags.filter(t => t[0] === "a").length).toBe(1)
expect(tmpl.tags).toContainEqual(["alt", "x"])
})
it("follows and unfollows via a fresh builder", async () => {
const tmpl = await new TopicListBuilder()
.follow(topicA)
.follow(topicB)
.unfollow(topicA)
.toTemplate(signer)
expect(tmpl.kind).toBe(TOPICS)
expect(tmpl.tags).toContainEqual(["t", topicB])
expect(tmpl.tags.some(t => t[1] === topicA)).toBe(false)
})
it("round-trips public and private topics through encryption", async () => {
const event = await new TopicListBuilder()
.followPublicly(topicA)
.followPrivately(topicB)
.toEvent(signer)
const decrypted = await TopicList.fromEvent(event, signer)
expect(decrypted.decrypted).toBe(true)
expect(decrypted.topics().sort()).toEqual([topicA, topicB].sort())
const publicOnly = await TopicList.fromEvent(event)
expect(publicOnly.decrypted).toBe(false)
expect(publicOnly.topics()).toEqual([topicA])
})
it("throws on the wrong kind", async () => {
await expect(TopicList.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
+91
View File
@@ -0,0 +1,91 @@
import {describe, it, expect} from "vitest"
import {makeSecret, ZAP_GOAL, NOTE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {ZapGoal, ZapGoalBuilder} from "../src/kinds/ZapGoal"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: ZAP_GOAL,
tags: [],
content: "",
sig: "00".repeat(64),
...overrides,
}) as TrustedEvent
describe("ZapGoal", () => {
it("parses the represented tags and plain-text title", async () => {
const event = makeEvent({
content: "New server fund",
tags: [
["summary", "help us buy a server"],
["amount", "500000"],
["relays", "wss://relay.one"],
["relays", "wss://relay.two"],
["alt", "x"],
],
})
const goal = await ZapGoal.fromEvent(event)
expect(goal.title()).toBe("New server fund")
expect(goal.summary()).toBe("help us buy a server")
expect(goal.amount()).toBe(500000)
expect(goal.relays()).toEqual(["wss://relay.one", "wss://relay.two"])
})
it("defaults amount to 0 when missing or unparseable", async () => {
const goal = await ZapGoal.fromEvent(makeEvent({content: "Goal"}))
expect(goal.amount()).toBe(0)
})
it("round-trips with no duplication", async () => {
const event = makeEvent({
content: "New server fund",
tags: [
["summary", "help us buy a server"],
["amount", "500000"],
["relays", "wss://relay.one"],
["alt", "x"],
],
})
const tmpl = await (await ZapGoal.fromEvent(event)).builder().toTemplate(signer)
expect(tmpl.content).toBe("New server fund")
expect(tmpl.tags.filter(t => t[0] === "summary").length).toBe(1)
expect(tmpl.tags.filter(t => t[0] === "amount").length).toBe(1)
expect(tmpl.tags.filter(t => t[0] === "relays").length).toBe(1)
expect(tmpl.tags).toContainEqual(["alt", "x"])
})
it("builds from a fresh builder", async () => {
const tmpl = await new ZapGoalBuilder()
.setTitle("Goal")
.setSummary("a summary")
.setAmount(1000)
.setRelays(["wss://relay.one"])
.toTemplate(signer)
expect(tmpl.kind).toBe(ZAP_GOAL)
expect(tmpl.content).toBe("Goal")
expect(tmpl.tags).toContainEqual(["summary", "a summary"])
expect(tmpl.tags).toContainEqual(["amount", "1000"])
expect(tmpl.tags).toContainEqual(["relays", "wss://relay.one"])
})
it("requires a title", async () => {
await expect(new ZapGoalBuilder().setAmount(1000).toTemplate(signer)).rejects.toThrow()
})
it("throws on the wrong kind", async () => {
await expect(ZapGoal.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
@@ -0,0 +1,139 @@
import {describe, it, expect} from "vitest"
import {makeSecret, ZAP_RECEIPT, ZAP_REQUEST, NOTE} from "@welshman/util"
import type {TrustedEvent, Zapper} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {ZapReceipt, ZapReceiptBuilder} from "../src/kinds/ZapReceipt"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const sender = "aa".repeat(32)
const recipient = "bb".repeat(32)
const eventId = "cc".repeat(32)
// hrp "100n" => 100 * 1e11 / 1e9 = 10000 millisats.
const bolt11 = "lnbc100n1pjexample"
// The embedded kind-9734 zap request carried inside the JSON "description" tag.
const request = {
id: "11".repeat(32),
pubkey: sender,
created_at: 0,
kind: ZAP_REQUEST,
tags: [
["amount", "10000"],
["lnurl", "lnurl1xyz"],
],
content: "thanks!",
sig: "00".repeat(64),
}
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: ZAP_RECEIPT,
tags: [],
content: "",
sig: "00".repeat(64),
...overrides,
}) as TrustedEvent
describe("ZapReceipt", () => {
it("parses the represented tags and the embedded zap request", async () => {
const event = makeEvent({
tags: [
["bolt11", bolt11],
["description", JSON.stringify(request)],
["p", recipient],
["e", eventId],
["preimage", "abcd"],
["alt", "x"],
],
})
const receipt = await ZapReceipt.fromEvent(event)
expect(receipt.bolt11()).toBe(bolt11)
expect(receipt.invoiceAmount()).toBe(10000)
expect(receipt.recipient()).toBe(recipient)
expect(receipt.eventId()).toBe(eventId)
expect(receipt.preimage()).toBe("abcd")
// Parsed-values accessors derived from the JSON "description" tag.
expect(receipt.request()).toEqual(request)
expect(receipt.sender()).toBe(sender)
expect(receipt.comment()).toBe("thanks!")
})
it("returns undefined invoice amount for a malformed bolt11", async () => {
const receipt = await ZapReceipt.fromEvent(makeEvent({tags: [["bolt11", "not-an-invoice"]]}))
expect(receipt.invoiceAmount()).toBeUndefined()
})
it("verifies a legitimate zap from a zapper", async () => {
const event = makeEvent({
pubkey: "dd".repeat(32),
tags: [
["bolt11", bolt11],
["description", JSON.stringify(request)],
["p", recipient],
],
})
const receipt = await ZapReceipt.fromEvent(event)
const zapper = {
pubkey: recipient,
lnurl: "lnurl1xyz",
nostrPubkey: "dd".repeat(32),
} as Zapper
expect(receipt.verify(zapper)).toBe(true)
// A forged receipt (wrong nostrPubkey) is rejected.
expect(receipt.verify({...zapper, nostrPubkey: "ab".repeat(32)})).toBe(false)
})
it("round-trips with no duplication", async () => {
const event = makeEvent({
tags: [
["bolt11", bolt11],
["description", JSON.stringify(request)],
["p", recipient],
["e", eventId],
["preimage", "abcd"],
["alt", "x"],
],
})
const tmpl = await (await ZapReceipt.fromEvent(event)).builder().toTemplate(signer)
expect(tmpl.tags.filter(t => t[0] === "bolt11").length).toBe(1)
expect(tmpl.tags.filter(t => t[0] === "description").length).toBe(1)
expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(1)
expect(tmpl.tags.filter(t => t[0] === "e").length).toBe(1)
expect(tmpl.tags.filter(t => t[0] === "preimage").length).toBe(1)
expect(tmpl.tags).toContainEqual(["alt", "x"])
})
it("builds from a fresh builder", async () => {
const tmpl = await new ZapReceiptBuilder()
.setBolt11(bolt11)
.setDescription(JSON.stringify(request))
.setRecipient(recipient)
.setEventId(eventId)
.setPreimage("abcd")
.toTemplate(signer)
expect(tmpl.kind).toBe(ZAP_RECEIPT)
expect(tmpl.tags).toContainEqual(["bolt11", bolt11])
expect(tmpl.tags).toContainEqual(["p", recipient])
expect(tmpl.tags).toContainEqual(["e", eventId])
expect(tmpl.tags).toContainEqual(["preimage", "abcd"])
})
it("throws on the wrong kind", async () => {
await expect(ZapReceipt.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})
@@ -0,0 +1,100 @@
import {describe, it, expect} from "vitest"
import {makeSecret, ZAP_REQUEST, NOTE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {ZapRequest, ZapRequestBuilder} from "../src/kinds/ZapRequest"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const recipient = "aa".repeat(32)
const eventId = "bb".repeat(32)
const makeEvent = (overrides: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: ZAP_REQUEST,
tags: [],
content: "",
sig: "00".repeat(64),
...overrides,
}) as TrustedEvent
describe("ZapRequest", () => {
it("parses the represented tags and comment content", async () => {
const event = makeEvent({
content: "thanks!",
tags: [
["amount", "21000"],
["lnurl", "lnurl1xyz"],
["p", recipient],
["e", eventId],
["relays", "wss://relay.one", "wss://relay.two"],
["anon"],
["alt", "x"],
],
})
const req = await ZapRequest.fromEvent(event)
expect(req.amount()).toBe(21000)
expect(req.lnurl()).toBe("lnurl1xyz")
expect(req.recipient()).toBe(recipient)
expect(req.eventId()).toBe(eventId)
expect(req.relays()).toEqual(["wss://relay.one", "wss://relay.two"])
expect(req.isAnonymous()).toBe(true)
expect(req.comment()).toBe("thanks!")
})
it("round-trips with no duplication", async () => {
const event = makeEvent({
content: "thanks!",
tags: [
["amount", "21000"],
["lnurl", "lnurl1xyz"],
["p", recipient],
["e", eventId],
["relays", "wss://relay.one"],
["anon"],
["alt", "x"],
],
})
const tmpl = await (await ZapRequest.fromEvent(event)).builder().toTemplate(signer)
expect(tmpl.content).toBe("thanks!")
expect(tmpl.tags.filter(t => t[0] === "amount").length).toBe(1)
expect(tmpl.tags.filter(t => t[0] === "lnurl").length).toBe(1)
expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(1)
expect(tmpl.tags.filter(t => t[0] === "e").length).toBe(1)
expect(tmpl.tags.filter(t => t[0] === "relays").length).toBe(1)
expect(tmpl.tags.filter(t => t[0] === "anon").length).toBe(1)
expect(tmpl.tags).toContainEqual(["alt", "x"])
})
it("builds from a fresh builder", async () => {
const tmpl = await new ZapRequestBuilder()
.setAmount(1000)
.setLnurl("lnurl1abc")
.setRecipient(recipient)
.setEventId(eventId)
.setRelays(["wss://relay.one"])
.setAnonymous()
.setComment("hi")
.toTemplate(signer)
expect(tmpl.kind).toBe(ZAP_REQUEST)
expect(tmpl.content).toBe("hi")
expect(tmpl.tags).toContainEqual(["amount", "1000"])
expect(tmpl.tags).toContainEqual(["lnurl", "lnurl1abc"])
expect(tmpl.tags).toContainEqual(["p", recipient])
expect(tmpl.tags).toContainEqual(["e", eventId])
expect(tmpl.tags).toContainEqual(["relays", "wss://relay.one"])
expect(tmpl.tags).toContainEqual(["anon"])
})
it("throws on the wrong kind", async () => {
await expect(ZapRequest.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})