Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changeset/empty-nip40-tests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

Test-only NIP-40 integration coverage; no release version bump required.
6 changes: 3 additions & 3 deletions src/handlers/subscribe-message-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable {
const sendEOSE = () =>
this.webSocket.emit(WebSocketAdapterEvent.Message, createEndOfStoredEventsNoticeMessage(subscriptionId))
const isSubscribedToEvent = SubscribeMessageHandler.isClientSubscribedToEvent(filters)
const isNotExpired = (event: Event) => {
const isTagUnexpired = (event: Event) => {
if (isExpiredEvent(event)) {
return false
}
Expand All @@ -75,7 +75,7 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable {
findEvents,
streamFilter(propSatisfies(isNil, 'deleted_at')),
streamMap(toNostrEvent),
streamFilter(isNotExpired),
streamFilter(isTagUnexpired),
streamFilter(isSubscribedToEvent),
streamEach(sendEvent),
streamEnd(sendEOSE),
Expand Down Expand Up @@ -117,7 +117,7 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable {
}

if (
typeof subscriptionLimits.maxSubscriptionIdLength === 'number' &&
typeof subscriptionLimits?.maxSubscriptionIdLength === 'number' &&
subscriptionId.length > subscriptionLimits.maxSubscriptionIdLength
) {
return `Subscription ID too long: Subscription ID must be less or equal to ${subscriptionLimits.maxSubscriptionIdLength}`
Expand Down
23 changes: 14 additions & 9 deletions test/integration/features/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,19 +99,14 @@ export async function waitForEOSE(ws: WebSocket, subscription: string): Promise<
})
}

export async function sendEvent(ws: WebSocket, event: Event, successful = true) {
return new Promise<OutgoingMessage>((resolve, reject) => {
export async function publishEvent(ws: WebSocket, event: Event): Promise<CommandResult> {
return new Promise<CommandResult>((resolve, reject) => {
const observable = streams.get(ws) as Observable<OutgoingMessage>

const sub = observable.subscribe((message: OutgoingMessage) => {
if (message[0] === MessageType.OK && message[1] === event.id) {
if (message[2] === successful) {
sub.unsubscribe()
resolve(message)
} else {
sub.unsubscribe()
reject(new Error(message[3]))
}
sub.unsubscribe()
resolve(message)
} else if (message[0] === MessageType.NOTICE) {
sub.unsubscribe()
reject(new Error(message[1]))
Expand All @@ -127,6 +122,16 @@ export async function sendEvent(ws: WebSocket, event: Event, successful = true)
})
}

export async function sendEvent(ws: WebSocket, event: Event, successful = true) {
const result = await publishEvent(ws, event)

if (result[2] !== successful) {
throw new Error(result[3])
}

return result
}

export async function waitForNextEvent(ws: WebSocket, subscription: string, content?: string): Promise<Event> {
return new Promise((resolve, reject) => {
const observable = streams.get(ws) as Observable<OutgoingMessage>
Expand Down
24 changes: 24 additions & 0 deletions test/integration/features/nip-40/nip-40.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
@nip40
@expiration
@standalone
Feature: NIP-40 Event expiration for standalone events
Scenario: Event with expiration tag in the past is not returned in queries
Given someone called Alice
And someone called Bob
When Alice sends a text_note event with content "already expired" and expiration in the past
And Bob subscribes to text_note events from Alice
Then Bob receives 0 text_note events and EOSE

Scenario: Event with expiration tag in the future is returned normally
Given someone called Alice
And someone called Bob
When Alice sends a text_note event with content "not yet expired" and expiration in the future
And Bob subscribes to text_note events from Alice
Then Bob receives a text_note event from Alice with content "not yet expired"

Scenario: Stored expired event is not returned to new subscribers
Given someone called Alice
And someone called Bob
When Alice has a stored text_note event with content "short lived" and expiration in the past
And Bob subscribes to text_note events from Alice
Then Bob receives 0 text_note events and EOSE
141 changes: 141 additions & 0 deletions test/integration/features/nip-40/nip-40.feature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { Then, When, World } from '@cucumber/cucumber'
import { expect } from 'chai'
import WebSocket from 'ws'

import { createEvent, createSubscription, publishEvent, waitForEventCount } from '../helpers'
import { ExpiringEvent } from '../../../../src/@types/event'
import { EventExpirationTimeMetadataKey, EventKinds, EventTags } from '../../../../src/constants/base'
import { getMasterDbClient } from '../../../../src/database/client'
import { EventRepository } from '../../../../src/repositories/event-repository'

const now = (): number => Math.floor(Date.now() / 1000)

const createTextNoteWithExpiration = async (
world: World<Record<string, any>>,
name: string,
content: string,
expirationTime: number,
): Promise<ExpiringEvent> => {
const ws = world.parameters.clients[name] as WebSocket
const { pubkey, privkey } = world.parameters.identities[name]

const event = await createEvent(
{
pubkey,
kind: EventKinds.TEXT_NOTE,
content,
tags: [[EventTags.Expiration, expirationTime.toString()]],
},
privkey,
) as ExpiringEvent

event[EventExpirationTimeMetadataKey] = expirationTime

await publishEvent(ws, event)

world.parameters.events[name].push(event)

return event
}

const seedStoredTextNoteWithExpiration = async (
world: World<Record<string, any>>,
name: string,
content: string,
expirationTime: number,
): Promise<ExpiringEvent> => {
const { pubkey, privkey } = world.parameters.identities[name]
const dbClient = getMasterDbClient()
const repository = new EventRepository(dbClient, dbClient)

const event = await createEvent(
{
pubkey,
kind: EventKinds.TEXT_NOTE,
created_at: expirationTime - 30,
content,
tags: [[EventTags.Expiration, expirationTime.toString()]],
},
privkey,
) as ExpiringEvent

event[EventExpirationTimeMetadataKey] = expirationTime

const inserted = await repository.create(event)
expect(inserted).to.equal(1)

world.parameters.events[name].push(event)

return event
}

When(/^(\w+) sends a text_note event with content "([^"]+)" and expiration in the past$/, async function(
this: World<Record<string, any>>,
name: string,
content: string,
) {
await createTextNoteWithExpiration(this, name, content, now() - 10)
})

When(/^(\w+) sends a text_note event with content "([^"]+)" and expiration in the future$/, async function(
this: World<Record<string, any>>,
name: string,
content: string,
) {
await createTextNoteWithExpiration(this, name, content, now() + 30)
})

When(/^(\w+) has a stored text_note event with content "([^"]+)" and expiration in the past$/, async function(
this: World<Record<string, any>>,
name: string,
content: string,
) {
await seedStoredTextNoteWithExpiration(this, name, content, now() - 10)
})

When(/^(\w+) sends a text_note event with content "([^"]+)" and expiration in (\d+) seconds$/, async function(
this: World<Record<string, any>>,
name: string,
content: string,
durationSeconds: string,
) {
const expirationTime = now() + Number(durationSeconds)
const event = await createTextNoteWithExpiration(this, name, content, expirationTime)
const expirationTag = event.tags.find((tag) => tag[0] === EventTags.Expiration)

expect(expirationTag).to.not.equal(undefined)
expect(Number(expirationTag?.[1])).to.equal(expirationTime)
})

When(/^(\w+) subscribes to text_note events from (\w+)$/, async function(
this: World<Record<string, any>>,
name: string,
author: string,
) {
const ws = this.parameters.clients[name] as WebSocket
const authorPubkey = this.parameters.identities[author].pubkey
const subscription = {
name: `test-${Math.random()}`,
filters: [{ kinds: [EventKinds.TEXT_NOTE], authors: [authorPubkey] }],
}

this.parameters.subscriptions[name].push(subscription)

await createSubscription(ws, subscription.name, subscription.filters)
})

Then(/^(\w+) receives (\d+) text_note events and EOSE$/, async function(
this: World<Record<string, any>>,
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))

events.forEach((event) => {
expect(event.kind).to.equal(EventKinds.TEXT_NOTE)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import chai from 'chai'
import sinon from 'sinon'
import sinonChai from 'sinon-chai'

import { getHealthRequestHandler } from '../../../../src/handlers/request-handlers/get-health-request-handler'

chai.use(sinonChai)
const { expect } = chai

describe('getHealthRequestHandler', () => {
it('responds with OK plain text and calls next', () => {
const req = {} as any
const res = {
status: sinon.stub().returnsThis(),
setHeader: sinon.stub().returnsThis(),
send: sinon.stub().returnsThis(),
} as any
const next = sinon.stub()

getHealthRequestHandler(req, res, next)

expect(res.status).to.have.been.calledOnceWithExactly(200)
expect(res.setHeader).to.have.been.calledOnceWithExactly('content-type', 'text/plain; charset=utf8')
expect(res.send).to.have.been.calledOnceWithExactly('OK')
expect(next).to.have.been.calledOnce
})
})
46 changes: 46 additions & 0 deletions test/unit/handlers/subscribe-message-handler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,22 @@ describe('SubscribeMessageHandler', () => {
await expect(promise).to.eventually.be.rejectedWith(error)
expect(closeSpy).to.have.been.called
})

it('destroys event stream if aborted', async () => {
const error = new Error('aborted')
error.name = 'AbortError'
isClientSubscribedToEventStub.returns(always(true))

const fetch = () => (handler as any).fetchAndSend(subscriptionId, filters)
const destroySpy = sandbox.spy(stream, 'destroy')

const promise = fetch()

stream.emit('error', error)

await expect(promise).to.eventually.be.rejectedWith(error)
expect(destroySpy).to.have.been.called
})
})

describe('.isClientSubscribedToEvent', () => {
Expand Down Expand Up @@ -350,5 +366,35 @@ describe('SubscribeMessageHandler', () => {
'Too many filters: Number of filters per susbscription must be less then or equal to 1',
)
})

it('returns reason if subscription id is too long', () => {
settingsFactory.returns({
limits: {
client: {
subscription: {
maxSubscriptionIdLength: 5,
},
},
},
})

expect((handler as any).canSubscribe('123456', filters)).to.equal(
'Subscription ID too long: Subscription ID must be less or equal to 5',
)
})

it('returns undefined if subscription id matches max length', () => {
settingsFactory.returns({
limits: {
client: {
subscription: {
maxSubscriptionIdLength: 6,
},
},
},
})

expect((handler as any).canSubscribe('123456', filters)).to.be.undefined
})
})
})
Loading