diff --git a/.changeset/nip-25-reactions.md b/.changeset/nip-25-reactions.md new file mode 100644 index 00000000..7eace2ef --- /dev/null +++ b/.changeset/nip-25-reactions.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +Add NIP-25 Reactions support for kind 7 and kind 17 events: reaction utility helpers (`isReactionEvent`, `isExternalContentReactionEvent`, `isLikeReaction`, `isDislikeReaction`, `parseReaction`), schema validation enforcing required `e` tag on kind 7 and required `k`/`i` tags on kind 17, unit tests, and integration tests. diff --git a/.changeset/nip-65-relay-list-metadata.md b/.changeset/nip-65-relay-list-metadata.md new file mode 100644 index 00000000..35cec677 --- /dev/null +++ b/.changeset/nip-65-relay-list-metadata.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +Add NIP-65 Relay List Metadata support for kind 10002 events: relay list utility with `isRelayListEvent` and `parseRelayList` helpers, unit tests, and relay information document updated to advertise NIP-65 (#577). diff --git a/README.md b/README.md index 8ac5c31b..f3cb6694 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ NIPs with a relay-specific implementation are listed here. - [x] NIP-44: Encrypted Payloads (Versioned) - [x] NIP-45: Event Counts - [x] NIP-62: Request to Vanish +- [x] NIP-65: Relay List Metadata ## Requirements diff --git a/package.json b/package.json index 675394da..3ebcfbdc 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ 33, 40, 44, - 45 + 45, + 65 ], "supportedNipExtensions": [], "main": "src/index.ts", diff --git a/src/@types/event.ts b/src/@types/event.ts index dee845c7..5b044337 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -49,6 +49,19 @@ export interface DBEvent { expires_at?: number } +export type RelayListEntry = { + url: string + marker?: 'read' | 'write' +} + +export type ReactionEntry = { + targetEventId?: string + targetPubkey?: string + targetAddress?: string + targetKind?: number + content: string +} + export interface CanonicalEvent { 0: 0 1: string diff --git a/src/cli/commands/info.ts b/src/cli/commands/info.ts index 513e3e4d..3c9450c2 100644 --- a/src/cli/commands/info.ts +++ b/src/cli/commands/info.ts @@ -56,7 +56,12 @@ const getEventCount = async (): Promise => { } const getRelayUptimeSeconds = async (): Promise => { - const idResult = await runCommandWithOutput('docker', ['compose', 'ps', '-q', 'nostream'], { timeoutMs: 1000 }) + let idResult: { code: number; stdout: string; stderr: string } + try { + idResult = await runCommandWithOutput('docker', ['compose', 'ps', '-q', 'nostream'], { timeoutMs: 1000 }) + } catch { + return null + } if (idResult.code !== 0) { return null } diff --git a/src/constants/base.ts b/src/constants/base.ts index f1daba11..760434be 100644 --- a/src/constants/base.ts +++ b/src/constants/base.ts @@ -11,6 +11,8 @@ export enum EventKinds { SEAL = 13, DIRECT_MESSAGE = 14, FILE_MESSAGE = 15, + // NIP-25: External content reaction + EXTERNAL_CONTENT_REACTION = 17, REQUEST_TO_VANISH = 62, // Channels CHANNEL_CREATION = 40, @@ -32,6 +34,8 @@ export enum EventKinds { ZAP_RECEIPT = 9735, // Replaceable events REPLACEABLE_FIRST = 10000, + // NIP-65: Relay List Metadata + RELAY_LIST = 10002, REPLACEABLE_LAST = 19999, // Ephemeral events EPHEMERAL_FIRST = 20000, @@ -54,6 +58,10 @@ export enum EventTags { Invoice = 'bolt11', // NIP-03: target event kind on an OpenTimestamps attestation Kind = 'k', + // NIP-25: Reactions + Address = 'a', + Index = 'i', + Emoji = 'emoji', } export const ALL_RELAYS = 'ALL_RELAYS' diff --git a/src/factories/event-strategy-factory.ts b/src/factories/event-strategy-factory.ts index 289804f7..e480986c 100644 --- a/src/factories/event-strategy-factory.ts +++ b/src/factories/event-strategy-factory.ts @@ -8,6 +8,8 @@ import { isReplaceableEvent, isRequestToVanishEvent, } from '../utils/event' +import { isRelayListEvent } from '../utils/nip65' +import { isExternalContentReactionEvent, isReactionEvent } from '../utils/nip25' import { DefaultEventStrategy } from '../handlers/event-strategies/default-event-strategy' import { DeleteEventStrategy } from '../handlers/event-strategies/delete-event-strategy' import { EphemeralEventStrategy } from '../handlers/event-strategies/ephemeral-event-strategy' @@ -33,7 +35,7 @@ export const eventStrategyFactory = return new GiftWrapEventStrategy(adapter, eventRepository) } else if (isOpenTimestampsEvent(event)) { return new TimestampEventStrategy(adapter, eventRepository) - } else if (isReplaceableEvent(event)) { + } else if (isRelayListEvent(event) || isReplaceableEvent(event)) { return new ReplaceableEventStrategy(adapter, eventRepository) } else if (isEphemeralEvent(event)) { return new EphemeralEventStrategy(adapter) @@ -43,5 +45,9 @@ export const eventStrategyFactory = return new ParameterizedReplaceableEventStrategy(adapter, eventRepository) } + if (isReactionEvent(event) || isExternalContentReactionEvent(event)) { + return new DefaultEventStrategy(adapter, eventRepository) + } + return new DefaultEventStrategy(adapter, eventRepository) } diff --git a/src/schemas/event-schema.ts b/src/schemas/event-schema.ts index 6aa2cf22..3408bd7d 100644 --- a/src/schemas/event-schema.ts +++ b/src/schemas/event-schema.ts @@ -1,5 +1,6 @@ import { z } from 'zod' +import { EventKinds, EventTags } from '../constants/base' import { createdAtSchema, idSchema, kindSchema, pubkeySchema, signatureSchema, tagSchema } from './base-schema' /** @@ -29,3 +30,46 @@ export const eventSchema = z sig: signatureSchema, }) .strict() + .superRefine((event, ctx) => { + if (event.kind === EventKinds.RELAY_LIST) { + event.tags.forEach((tag, index) => { + if (tag[0] === EventTags.Relay && !z.string().url().safeParse(tag[1]).success) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid relay URL`, + path: ['tags', index, 1], + }) + } + }) + } + + if (event.kind === EventKinds.REACTION) { + const hasETag = event.tags.some((tag) => tag[0] === EventTags.Event) + if (!hasETag) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Reaction event must have at least one e tag', + path: ['tags'], + }) + } + } + + if (event.kind === EventKinds.EXTERNAL_CONTENT_REACTION) { + const hasKTag = event.tags.some((tag) => tag[0] === EventTags.Kind) + const hasITag = event.tags.some((tag) => tag[0] === EventTags.Index) + if (!hasKTag) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'External content reaction must have a k tag', + path: ['tags'], + }) + } + if (!hasITag) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'External content reaction must have an i tag', + path: ['tags'], + }) + } + } + }) diff --git a/src/utils/nip25.ts b/src/utils/nip25.ts new file mode 100644 index 00000000..f78bf3be --- /dev/null +++ b/src/utils/nip25.ts @@ -0,0 +1,28 @@ +import { Event, ReactionEntry } from '../@types/event' +import { EventKinds, EventTags } from '../constants/base' + +export const isReactionEvent = (event: Event): boolean => event.kind === EventKinds.REACTION + +export const isExternalContentReactionEvent = (event: Event): boolean => + event.kind === EventKinds.EXTERNAL_CONTENT_REACTION + +export const isLikeReaction = (event: Event): boolean => + isReactionEvent(event) && (event.content === '+' || event.content === '') + +export const isDislikeReaction = (event: Event): boolean => + isReactionEvent(event) && event.content === '-' + +export const parseReaction = (event: Event): ReactionEntry => { + const eTags = event.tags.filter((tag) => tag[0] === EventTags.Event) + const pTags = event.tags.filter((tag) => tag[0] === EventTags.Pubkey) + const aTags = event.tags.filter((tag) => tag[0] === EventTags.Address) + const kTag = event.tags.find((tag) => tag[0] === EventTags.Kind) + + return { + targetEventId: eTags.length > 0 ? eTags[eTags.length - 1][1] : undefined, + targetPubkey: pTags.length > 0 ? pTags[pTags.length - 1][1] : undefined, + targetAddress: aTags.length > 0 ? aTags[aTags.length - 1][1] : undefined, + targetKind: kTag ? Number(kTag[1]) : undefined, + content: event.content, + } +} diff --git a/src/utils/nip65.ts b/src/utils/nip65.ts new file mode 100644 index 00000000..11eb553c --- /dev/null +++ b/src/utils/nip65.ts @@ -0,0 +1,12 @@ +import { Event, RelayListEntry } from '../@types/event' +import { EventKinds, EventTags } from '../constants/base' + +export const isRelayListEvent = (event: Event): boolean => event.kind === EventKinds.RELAY_LIST + +export const parseRelayList = (event: Event): RelayListEntry[] => + event.tags + .filter((tag) => tag[0] === EventTags.Relay && tag.length >= 2) + .map((tag) => ({ + url: tag[1], + marker: tag[2] === 'read' || tag[2] === 'write' ? tag[2] : undefined, + })) diff --git a/test/integration/features/nip-25/nip-25.feature b/test/integration/features/nip-25/nip-25.feature new file mode 100644 index 00000000..52fa6b9d --- /dev/null +++ b/test/integration/features/nip-25/nip-25.feature @@ -0,0 +1,24 @@ +Feature: NIP-25 Reactions + Scenario: Alice likes Bob's note + Given someone called Alice + And someone called Bob + When Bob sends a text_note event with content "hello world" + And Alice reacts to Bob's note with "+" + And Alice subscribes to her reaction events + Then Alice receives a reaction event with content "+" + + Scenario: Alice dislikes Bob's note + Given someone called Alice + And someone called Bob + When Bob sends a text_note event with content "hello world" + And Alice reacts to Bob's note with "-" + And Alice subscribes to her reaction events + Then Alice receives a reaction event with content "-" + + Scenario: Alice reacts with an emoji + Given someone called Alice + And someone called Bob + When Bob sends a text_note event with content "hello world" + And Alice reacts to Bob's note with "🤙" + And Alice subscribes to her reaction events + Then Alice receives a reaction event with content "🤙" diff --git a/test/integration/features/nip-25/nip-25.feature.ts b/test/integration/features/nip-25/nip-25.feature.ts new file mode 100644 index 00000000..0a0f5c0d --- /dev/null +++ b/test/integration/features/nip-25/nip-25.feature.ts @@ -0,0 +1,49 @@ +import { Then, When, World } from '@cucumber/cucumber' +import { expect } from 'chai' +import WebSocket from 'ws' +import { Event } from '../../../../src/@types/event' +import { EventKinds } from '../../../../src/constants/base' +import { createEvent, createSubscription, sendEvent, waitForNextEvent } from '../helpers' + +When(/^(\w+) reacts to (\w+)'s note with "([^"]+)"$/, async function (reactor: string, author: string, content: string) { + const ws = this.parameters.clients[reactor] as WebSocket + const { pubkey, privkey } = this.parameters.identities[reactor] + const targetEvent = this.parameters.events[author][this.parameters.events[author].length - 1] as Event + + const event: Event = await createEvent( + { + pubkey, + kind: EventKinds.REACTION, + content, + tags: [ + ['e', targetEvent.id], + ['p', targetEvent.pubkey], + ], + }, + privkey, + ) + + await sendEvent(ws, event) + this.parameters.events[reactor].push(event) +}) + +When(/^(\w+) subscribes to (?:her|his|their) reaction events$/, async function (this: World>, name: string) { + const ws = this.parameters.clients[name] as WebSocket + const { pubkey } = this.parameters.identities[name] + const subscription = { + name: `test-${Math.random()}`, + filters: [{ kinds: [EventKinds.REACTION], authors: [pubkey] }], + } + this.parameters.subscriptions[name].push(subscription) + + await createSubscription(ws, subscription.name, subscription.filters) +}) + +Then(/^(\w+) receives a reaction event with content "([^"]+)"$/, async function (name: string, content: string) { + const ws = this.parameters.clients[name] as WebSocket + const subscription = this.parameters.subscriptions[name][this.parameters.subscriptions[name].length - 1] + const receivedEvent = await waitForNextEvent(ws, subscription.name) + + expect(receivedEvent.kind).to.equal(EventKinds.REACTION) + expect(receivedEvent.content).to.equal(content) +}) diff --git a/test/integration/features/nip-65/nip-65.feature b/test/integration/features/nip-65/nip-65.feature new file mode 100644 index 00000000..14b45615 --- /dev/null +++ b/test/integration/features/nip-65/nip-65.feature @@ -0,0 +1,27 @@ +Feature: NIP-65 Relay List Metadata + Scenario: Alice publishes a relay list and retrieves it + Given someone called Alice + When Alice sends a relay_list event with relays "wss://alice.relay.com" + And Alice subscribes to her relay_list events + Then Alice receives a relay_list event with relays "wss://alice.relay.com" + + Scenario: Alice updates her relay list and only the latest is kept + Given someone called Alice + When Alice sends a relay_list event with relays "wss://old.relay.com" + And Alice sends a relay_list event with relays "wss://new.relay.com" + And Alice subscribes to her relay_list events + Then Alice receives 1 relay_list event and EOSE + And the relay_list event has relays "wss://new.relay.com" + + Scenario: Bob can query Alice's relay list + Given someone called Alice + And someone called Bob + When Alice sends a relay_list event with relays "wss://alice.relay.com" + And Bob subscribes to author Alice + Then Bob receives a relay_list event with relays "wss://alice.relay.com" + + Scenario: Alice publishes a relay list with read and write markers + Given someone called Alice + When Alice sends a relay_list event with a read relay "wss://read.relay.com" and a write relay "wss://write.relay.com" + And Alice subscribes to her relay_list events + Then Alice receives a relay_list event with a read relay "wss://read.relay.com" and a write relay "wss://write.relay.com" diff --git a/test/integration/features/nip-65/nip-65.feature.ts b/test/integration/features/nip-65/nip-65.feature.ts new file mode 100644 index 00000000..7680cff6 --- /dev/null +++ b/test/integration/features/nip-65/nip-65.feature.ts @@ -0,0 +1,104 @@ +import { Then, When, World } from '@cucumber/cucumber' +import { expect } from 'chai' +import WebSocket from 'ws' +import { Event } from '../../../../src/@types/event' +import { EventKinds } from '../../../../src/constants/base' +import { createEvent, createSubscription, sendEvent, waitForEventCount, waitForNextEvent } from '../helpers' + +When(/^(\w+) sends a relay_list event with relays "([^"]+)"$/, async function (name: string, relayUrl: string) { + const ws = this.parameters.clients[name] as WebSocket + const { pubkey, privkey } = this.parameters.identities[name] + + const event: Event = await createEvent( + { + pubkey, + kind: EventKinds.RELAY_LIST, + content: '', + tags: [['r', relayUrl]], + }, + privkey, + ) + + await sendEvent(ws, event) + this.parameters.events[name].push(event) +}) + +When( + /^(\w+) sends a relay_list event with a read relay "([^"]+)" and a write relay "([^"]+)"$/, + async function (name: string, readRelay: string, writeRelay: string) { + const ws = this.parameters.clients[name] as WebSocket + const { pubkey, privkey } = this.parameters.identities[name] + + const event: Event = await createEvent( + { + pubkey, + kind: EventKinds.RELAY_LIST, + content: '', + tags: [ + ['r', readRelay, 'read'], + ['r', writeRelay, 'write'], + ], + }, + privkey, + ) + + await sendEvent(ws, event) + this.parameters.events[name].push(event) + }, +) + +When( + /^(\w+) subscribes to (?:her|his|their) relay_list events$/, + async function (this: World>, name: string) { + const ws = this.parameters.clients[name] as WebSocket + const { pubkey } = this.parameters.identities[name] + const subscription = { + name: `test-${Math.random()}`, + filters: [{ kinds: [EventKinds.RELAY_LIST], authors: [pubkey] }], + } + this.parameters.subscriptions[name].push(subscription) + + await createSubscription(ws, subscription.name, subscription.filters) + }, +) + +Then(/^(\w+) receives a relay_list event with relays "([^"]+)"$/, async function (name: string, relayUrl: string) { + const ws = this.parameters.clients[name] as WebSocket + const subscription = this.parameters.subscriptions[name][this.parameters.subscriptions[name].length - 1] + const receivedEvent = await waitForNextEvent(ws, subscription.name) + + expect(receivedEvent.kind).to.equal(EventKinds.RELAY_LIST) + expect(receivedEvent.tags).to.deep.include(['r', relayUrl]) +}) + +Then( + /^(\w+) receives a relay_list event with a read relay "([^"]+)" and a write relay "([^"]+)"$/, + async function (name: string, readRelay: string, writeRelay: string) { + const ws = this.parameters.clients[name] as WebSocket + const subscription = this.parameters.subscriptions[name][this.parameters.subscriptions[name].length - 1] + const receivedEvent = await waitForNextEvent(ws, subscription.name) + + expect(receivedEvent.kind).to.equal(EventKinds.RELAY_LIST) + expect(receivedEvent.tags).to.deep.include(['r', readRelay, 'read']) + expect(receivedEvent.tags).to.deep.include(['r', writeRelay, 'write']) + }, +) + +Then(/^(\w+) receives (\d+) relay_list event(?:s)? and EOSE$/, async function (name: string, count: string) { + const ws = this.parameters.clients[name] as WebSocket + const subscription = this.parameters.subscriptions[name][this.parameters.subscriptions[name].length - 1] + const events = await waitForEventCount(ws, subscription.name, Number(count), true) + + expect(events.length).to.equal(Number(count)) + expect(events[0].kind).to.equal(EventKinds.RELAY_LIST) + + this.parameters.lastRelayListEvents = events +}) + +Then( + /^the relay_list event has relays "([^"]+)"$/, + async function (this: World>, relayUrl: string) { + const events: Event[] = this.parameters.lastRelayListEvents + expect(events[0].tags).to.deep.include(['r', relayUrl]) + }, +) diff --git a/test/unit/factories/event-strategy-factory.spec.ts b/test/unit/factories/event-strategy-factory.spec.ts index 444c5e7b..01bb04ad 100644 --- a/test/unit/factories/event-strategy-factory.spec.ts +++ b/test/unit/factories/event-strategy-factory.spec.ts @@ -47,6 +47,11 @@ describe('eventStrategyFactory', () => { expect(factory([event, adapter])).to.be.an.instanceOf(ReplaceableEventStrategy) }) + it('returns ReplaceableEventStrategy given a relay_list event (NIP-65)', () => { + event.kind = EventKinds.RELAY_LIST + expect(factory([event, adapter])).to.be.an.instanceOf(ReplaceableEventStrategy) + }) + it('returns EphemeralEventStrategy given an ephemeral event', () => { event.kind = EventKinds.EPHEMERAL_FIRST expect(factory([event, adapter])).to.be.an.instanceOf(EphemeralEventStrategy) @@ -81,4 +86,14 @@ describe('eventStrategyFactory', () => { event.kind = EventKinds.TEXT_NOTE expect(factory([event, adapter])).to.be.an.instanceOf(DefaultEventStrategy) }) + + it('returns DefaultEventStrategy given a reaction event (NIP-25)', () => { + event.kind = EventKinds.REACTION + expect(factory([event, adapter])).to.be.an.instanceOf(DefaultEventStrategy) + }) + + it('returns DefaultEventStrategy given an external content reaction event (NIP-25)', () => { + event.kind = EventKinds.EXTERNAL_CONTENT_REACTION + expect(factory([event, adapter])).to.be.an.instanceOf(DefaultEventStrategy) + }) }) diff --git a/test/unit/schemas/event-schema.spec.ts b/test/unit/schemas/event-schema.spec.ts index 1587b154..08e7deb7 100644 --- a/test/unit/schemas/event-schema.spec.ts +++ b/test/unit/schemas/event-schema.spec.ts @@ -3,7 +3,7 @@ import { expect } from 'chai' import { Event } from '../../../src/@types/event' import { eventSchema } from '../../../src/schemas/event-schema' -import { EventTags } from '../../../src/constants/base' +import { EventKinds, EventTags } from '../../../src/constants/base' import { validateSchema } from '../../../src/utils/validation' describe('NIP-01', () => { @@ -109,6 +109,108 @@ describe('NIP-01', () => { }) }) +describe('NIP-65', () => { + const relayListBase: Event = { + id: 'fa4dd948576fe182f5d0e3120b9df42c83dffa1c884754d5e4d3b0a2f98a01c5', + pubkey: 'edfa27d49d2af37ee331e1225bb6ed1912c6d999281b36d8018ad99bc3573c29', + created_at: 1660306803, + kind: EventKinds.RELAY_LIST, + tags: [], + content: '', + sig: '313a9b8cd68267a51da84e292c0937d1f3686c6757c4584f50fcedad2b13fad755e6226924f79880fb5aa9de95c04231a4823981513ac9e7092bad7488282a96', + } + + it('accepts relay_list event with valid wss relay URL', () => { + const event = { ...relayListBase, tags: [[EventTags.Relay, 'wss://relay.example.com']] } + const result = validateSchema(eventSchema)(event) + expect(result.error).to.be.undefined + }) + + it('accepts relay_list event with valid wss relay URL and read marker', () => { + const event = { ...relayListBase, tags: [[EventTags.Relay, 'wss://relay.example.com', 'read']] } + const result = validateSchema(eventSchema)(event) + expect(result.error).to.be.undefined + }) + + it('accepts relay_list event with valid wss relay URL and write marker', () => { + const event = { ...relayListBase, tags: [[EventTags.Relay, 'wss://relay.example.com', 'write']] } + const result = validateSchema(eventSchema)(event) + expect(result.error).to.be.undefined + }) + + it('accepts relay_list event with no relay tags', () => { + const event = { ...relayListBase, tags: [] } + const result = validateSchema(eventSchema)(event) + expect(result.error).to.be.undefined + }) + + it('rejects relay_list event with invalid relay URL', () => { + const event = { ...relayListBase, tags: [[EventTags.Relay, 'not-a-url']] } + const result = validateSchema(eventSchema)(event) + expect(result.error).to.not.be.undefined + }) + + it('rejects relay_list event with empty relay URL', () => { + const event = { ...relayListBase, tags: [[EventTags.Relay, '']] } + const result = validateSchema(eventSchema)(event) + expect(result.error).to.not.be.undefined + }) + + it('does not validate relay URL on non-relay_list events with r tags', () => { + const event = { ...relayListBase, kind: EventKinds.TEXT_NOTE, tags: [[EventTags.Relay, 'not-a-url']] } + const result = validateSchema(eventSchema)(event) + expect(result.error).to.be.undefined + }) +}) + +describe('NIP-25', () => { + const base: Event = { + id: 'fa4dd948576fe182f5d0e3120b9df42c83dffa1c884754d5e4d3b0a2f98a01c5', + pubkey: 'edfa27d49d2af37ee331e1225bb6ed1912c6d999281b36d8018ad99bc3573c29', + created_at: 1660306803, + kind: EventKinds.REACTION, + tags: [], + content: '+', + sig: '313a9b8cd68267a51da84e292c0937d1f3686c6757c4584f50fcedad2b13fad755e6226924f79880fb5aa9de95c04231a4823981513ac9e7092bad7488282a96', + } + + it('accepts reaction with e tag', () => { + const event = { ...base, tags: [[EventTags.Event, 'a'.repeat(64)]] } + expect(validateSchema(eventSchema)(event).error).to.be.undefined + }) + + it('rejects reaction missing e tag', () => { + expect(validateSchema(eventSchema)({ ...base, tags: [] }).error).to.not.be.undefined + }) + + it('accepts external content reaction with k and i tags', () => { + const event = { + ...base, + kind: EventKinds.EXTERNAL_CONTENT_REACTION, + tags: [[EventTags.Kind, 'web'], [EventTags.Index, 'https://example.com']], + } + expect(validateSchema(eventSchema)(event).error).to.be.undefined + }) + + it('rejects external content reaction missing k tag', () => { + const event = { + ...base, + kind: EventKinds.EXTERNAL_CONTENT_REACTION, + tags: [[EventTags.Index, 'https://example.com']], + } + expect(validateSchema(eventSchema)(event).error).to.not.be.undefined + }) + + it('rejects external content reaction missing i tag', () => { + const event = { + ...base, + kind: EventKinds.EXTERNAL_CONTENT_REACTION, + tags: [[EventTags.Kind, 'web']], + } + expect(validateSchema(eventSchema)(event).error).to.not.be.undefined + }) +}) + describe('NIP-14', () => { it('accepts subject tag on text note events', () => { const event: Event = { diff --git a/test/unit/utils/nip25.spec.ts b/test/unit/utils/nip25.spec.ts new file mode 100644 index 00000000..3d5c90e5 --- /dev/null +++ b/test/unit/utils/nip25.spec.ts @@ -0,0 +1,90 @@ +import { expect } from 'chai' +import { Event } from '../../../src/@types/event' +import { EventKinds } from '../../../src/constants/base' +import { + isDislikeReaction, + isExternalContentReactionEvent, + isLikeReaction, + isReactionEvent, + parseReaction, +} from '../../../src/utils/nip25' + +const baseEvent = (): Partial => ({ tags: [], content: '+' }) + +describe('NIP-25', () => { + describe('isReactionEvent', () => { + it('returns true for kind 7', () => + expect(isReactionEvent({ ...baseEvent(), kind: EventKinds.REACTION } as Event)).to.equal(true)) + + it('returns false for other kinds', () => + expect(isReactionEvent({ ...baseEvent(), kind: EventKinds.TEXT_NOTE } as Event)).to.equal(false)) + }) + + describe('isExternalContentReactionEvent', () => { + it('returns true for kind 17', () => + expect( + isExternalContentReactionEvent({ ...baseEvent(), kind: EventKinds.EXTERNAL_CONTENT_REACTION } as Event), + ).to.equal(true)) + + it('returns false for kind 7', () => + expect( + isExternalContentReactionEvent({ ...baseEvent(), kind: EventKinds.REACTION } as Event), + ).to.equal(false)) + }) + + describe('isLikeReaction', () => { + it('returns true for "+"', () => + expect(isLikeReaction({ ...baseEvent(), kind: EventKinds.REACTION, content: '+' } as Event)).to.equal(true)) + + it('returns true for empty content', () => + expect(isLikeReaction({ ...baseEvent(), kind: EventKinds.REACTION, content: '' } as Event)).to.equal(true)) + + it('returns false for "-"', () => + expect(isLikeReaction({ ...baseEvent(), kind: EventKinds.REACTION, content: '-' } as Event)).to.equal(false)) + }) + + describe('isDislikeReaction', () => { + it('returns true for "-"', () => + expect(isDislikeReaction({ ...baseEvent(), kind: EventKinds.REACTION, content: '-' } as Event)).to.equal(true)) + + it('returns false for "+"', () => + expect(isDislikeReaction({ ...baseEvent(), kind: EventKinds.REACTION, content: '+' } as Event)).to.equal(false)) + }) + + describe('parseReaction', () => { + it('picks the last e tag as targetEventId', () => { + const event = { + ...baseEvent(), + kind: EventKinds.REACTION, + tags: [['e', 'aaa'], ['e', 'bbb']], + } as unknown as Event + expect(parseReaction(event).targetEventId).to.equal('bbb') + }) + + it('picks the last p tag as targetPubkey', () => { + const event = { + ...baseEvent(), + kind: EventKinds.REACTION, + tags: [['p', 'pk1'], ['p', 'pk2']], + } as unknown as Event + expect(parseReaction(event).targetPubkey).to.equal('pk2') + }) + + it('parses k tag as targetKind number', () => { + const event = { + ...baseEvent(), + kind: EventKinds.REACTION, + tags: [['k', '1']], + } as unknown as Event + expect(parseReaction(event).targetKind).to.equal(1) + }) + + it('returns undefined fields when tags are absent', () => { + const event = { ...baseEvent(), kind: EventKinds.REACTION, tags: [] } as unknown as Event + const result = parseReaction(event) + expect(result.targetEventId).to.be.undefined + expect(result.targetPubkey).to.be.undefined + expect(result.targetKind).to.be.undefined + }) + }) +}) diff --git a/test/unit/utils/nip65.spec.ts b/test/unit/utils/nip65.spec.ts new file mode 100644 index 00000000..939ecd15 --- /dev/null +++ b/test/unit/utils/nip65.spec.ts @@ -0,0 +1,90 @@ +import { expect } from 'chai' +import { Event } from '../../../src/@types/event' +import { isRelayListEvent, parseRelayList } from '../../../src/utils/nip65' + +const baseEvent = (): Partial => ({ + kind: 10002, + tags: [], + content: '', +}) + +describe('NIP-65', () => { + describe('isRelayListEvent', () => { + it('returns true for kind 10002', () => { + expect(isRelayListEvent({ ...baseEvent(), kind: 10002 } as Event)).to.equal(true) + }) + + it('returns false for kind 0 (set_metadata)', () => { + expect(isRelayListEvent({ ...baseEvent(), kind: 0 } as Event)).to.equal(false) + }) + + it('returns false for kind 3 (contact_list)', () => { + expect(isRelayListEvent({ ...baseEvent(), kind: 3 } as Event)).to.equal(false) + }) + + it('returns false for kind 1 (text_note)', () => { + expect(isRelayListEvent({ ...baseEvent(), kind: 1 } as Event)).to.equal(false) + }) + }) + + describe('parseRelayList', () => { + it('returns empty array when tags is empty', () => { + const event = { ...baseEvent(), tags: [] } as unknown as Event + expect(parseRelayList(event)).to.deep.equal([]) + }) + + it('parses a relay tag with no marker as read+write', () => { + const event = { ...baseEvent(), tags: [['r', 'wss://relay.example.com']] } as unknown as Event + expect(parseRelayList(event)).to.deep.equal([{ url: 'wss://relay.example.com', marker: undefined }]) + }) + + it('parses a relay tag with read marker', () => { + const event = { ...baseEvent(), tags: [['r', 'wss://relay.example.com', 'read']] } as unknown as Event + expect(parseRelayList(event)).to.deep.equal([{ url: 'wss://relay.example.com', marker: 'read' }]) + }) + + it('parses a relay tag with write marker', () => { + const event = { ...baseEvent(), tags: [['r', 'wss://relay.example.com', 'write']] } as unknown as Event + expect(parseRelayList(event)).to.deep.equal([{ url: 'wss://relay.example.com', marker: 'write' }]) + }) + + it('sets marker to undefined when tag[2] is an unrecognized string', () => { + const event = { ...baseEvent(), tags: [['r', 'wss://relay.example.com', 'both']] } as unknown as Event + expect(parseRelayList(event)).to.deep.equal([{ url: 'wss://relay.example.com', marker: undefined }]) + }) + + it('ignores tags where tag[0] is not "r"', () => { + const event = { + ...baseEvent(), + tags: [ + ['p', 'somepubkey'], + ['e', 'someeventid'], + ], + } as unknown as Event + expect(parseRelayList(event)).to.deep.equal([]) + }) + + it('ignores tags shorter than 2 elements', () => { + const event = { ...baseEvent(), tags: [['r']] } as unknown as Event + expect(parseRelayList(event)).to.deep.equal([]) + }) + + it('parses a mixed list correctly', () => { + const event = { + ...baseEvent(), + tags: [ + ['r', 'wss://alice.relay.com'], + ['r', 'wss://bob.relay.com', 'write'], + ['r', 'wss://carol.relay.com', 'read'], + ['p', 'somepubkey'], + ], + } as unknown as Event + + expect(parseRelayList(event)).to.deep.equal([ + { url: 'wss://alice.relay.com', marker: undefined }, + { url: 'wss://bob.relay.com', marker: 'write' }, + { url: 'wss://carol.relay.com', marker: 'read' }, + ]) + }) + }) +})