diff --git a/.changeset/empty-nip40-tests.md b/.changeset/empty-nip40-tests.md new file mode 100644 index 00000000..a1484b6d --- /dev/null +++ b/.changeset/empty-nip40-tests.md @@ -0,0 +1,4 @@ +--- +--- + +Test-only NIP-40 integration coverage; no release version bump required. diff --git a/src/handlers/subscribe-message-handler.ts b/src/handlers/subscribe-message-handler.ts index 1be58340..6d5c1e1e 100644 --- a/src/handlers/subscribe-message-handler.ts +++ b/src/handlers/subscribe-message-handler.ts @@ -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 } @@ -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), @@ -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}` diff --git a/test/integration/features/helpers.ts b/test/integration/features/helpers.ts index d7524f58..e4ed88d2 100644 --- a/test/integration/features/helpers.ts +++ b/test/integration/features/helpers.ts @@ -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((resolve, reject) => { +export async function publishEvent(ws: WebSocket, event: Event): Promise { + return new Promise((resolve, reject) => { const observable = streams.get(ws) as Observable 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])) @@ -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 { return new Promise((resolve, reject) => { const observable = streams.get(ws) as Observable diff --git a/test/integration/features/nip-40/nip-40.feature b/test/integration/features/nip-40/nip-40.feature new file mode 100644 index 00000000..4f222758 --- /dev/null +++ b/test/integration/features/nip-40/nip-40.feature @@ -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 diff --git a/test/integration/features/nip-40/nip-40.feature.ts b/test/integration/features/nip-40/nip-40.feature.ts new file mode 100644 index 00000000..176da159 --- /dev/null +++ b/test/integration/features/nip-40/nip-40.feature.ts @@ -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>, + name: string, + content: string, + expirationTime: number, +): Promise => { + 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>, + name: string, + content: string, + expirationTime: number, +): Promise => { + 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>, + 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>, + 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>, + 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>, + 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>, + 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>, + 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) + }) +}) diff --git a/test/unit/handlers/request-handlers/get-health-request-handler.spec.ts b/test/unit/handlers/request-handlers/get-health-request-handler.spec.ts new file mode 100644 index 00000000..0396fb3a --- /dev/null +++ b/test/unit/handlers/request-handlers/get-health-request-handler.spec.ts @@ -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 + }) +}) diff --git a/test/unit/handlers/subscribe-message-handler.spec.ts b/test/unit/handlers/subscribe-message-handler.spec.ts index 91b653dc..4ff024d8 100644 --- a/test/unit/handlers/subscribe-message-handler.spec.ts +++ b/test/unit/handlers/subscribe-message-handler.spec.ts @@ -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', () => { @@ -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 + }) }) })