Skip to content

Commit dec061d

Browse files
test: add NIP-40 standalone expiration integration coverage (#528)
* test: add NIP-40 standalone expiration integration coverage * chore: add empty changeset for test-only PR * fix: prevent replay of expired NIP-40 events * refactor: remove duplicate expiration filter in subscription replay * test: increase unit coverage for CI * test: avoid real-time waits in NIP-40 integration --------- Co-authored-by: Ricardo Cabral <me@ricardocabral.io>
1 parent 25f9637 commit dec061d

7 files changed

Lines changed: 259 additions & 12 deletions

File tree

.changeset/empty-nip40-tests.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
Test-only NIP-40 integration coverage; no release version bump required.

src/handlers/subscribe-message-handler.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable {
5959
const sendEOSE = () =>
6060
this.webSocket.emit(WebSocketAdapterEvent.Message, createEndOfStoredEventsNoticeMessage(subscriptionId))
6161
const isSubscribedToEvent = SubscribeMessageHandler.isClientSubscribedToEvent(filters)
62-
const isNotExpired = (event: Event) => {
62+
const isTagUnexpired = (event: Event) => {
6363
if (isExpiredEvent(event)) {
6464
return false
6565
}
@@ -75,7 +75,7 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable {
7575
findEvents,
7676
streamFilter(propSatisfies(isNil, 'deleted_at')),
7777
streamMap(toNostrEvent),
78-
streamFilter(isNotExpired),
78+
streamFilter(isTagUnexpired),
7979
streamFilter(isSubscribedToEvent),
8080
streamEach(sendEvent),
8181
streamEnd(sendEOSE),
@@ -117,7 +117,7 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable {
117117
}
118118

119119
if (
120-
typeof subscriptionLimits.maxSubscriptionIdLength === 'number' &&
120+
typeof subscriptionLimits?.maxSubscriptionIdLength === 'number' &&
121121
subscriptionId.length > subscriptionLimits.maxSubscriptionIdLength
122122
) {
123123
return `Subscription ID too long: Subscription ID must be less or equal to ${subscriptionLimits.maxSubscriptionIdLength}`

test/integration/features/helpers.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -99,19 +99,14 @@ export async function waitForEOSE(ws: WebSocket, subscription: string): Promise<
9999
})
100100
}
101101

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

106106
const sub = observable.subscribe((message: OutgoingMessage) => {
107107
if (message[0] === MessageType.OK && message[1] === event.id) {
108-
if (message[2] === successful) {
109-
sub.unsubscribe()
110-
resolve(message)
111-
} else {
112-
sub.unsubscribe()
113-
reject(new Error(message[3]))
114-
}
108+
sub.unsubscribe()
109+
resolve(message)
115110
} else if (message[0] === MessageType.NOTICE) {
116111
sub.unsubscribe()
117112
reject(new Error(message[1]))
@@ -127,6 +122,16 @@ export async function sendEvent(ws: WebSocket, event: Event, successful = true)
127122
})
128123
}
129124

125+
export async function sendEvent(ws: WebSocket, event: Event, successful = true) {
126+
const result = await publishEvent(ws, event)
127+
128+
if (result[2] !== successful) {
129+
throw new Error(result[3])
130+
}
131+
132+
return result
133+
}
134+
130135
export async function waitForNextEvent(ws: WebSocket, subscription: string, content?: string): Promise<Event> {
131136
return new Promise((resolve, reject) => {
132137
const observable = streams.get(ws) as Observable<OutgoingMessage>
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
@nip40
2+
@expiration
3+
@standalone
4+
Feature: NIP-40 Event expiration for standalone events
5+
Scenario: Event with expiration tag in the past is not returned in queries
6+
Given someone called Alice
7+
And someone called Bob
8+
When Alice sends a text_note event with content "already expired" and expiration in the past
9+
And Bob subscribes to text_note events from Alice
10+
Then Bob receives 0 text_note events and EOSE
11+
12+
Scenario: Event with expiration tag in the future is returned normally
13+
Given someone called Alice
14+
And someone called Bob
15+
When Alice sends a text_note event with content "not yet expired" and expiration in the future
16+
And Bob subscribes to text_note events from Alice
17+
Then Bob receives a text_note event from Alice with content "not yet expired"
18+
19+
Scenario: Stored expired event is not returned to new subscribers
20+
Given someone called Alice
21+
And someone called Bob
22+
When Alice has a stored text_note event with content "short lived" and expiration in the past
23+
And Bob subscribes to text_note events from Alice
24+
Then Bob receives 0 text_note events and EOSE
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { Then, When, World } from '@cucumber/cucumber'
2+
import { expect } from 'chai'
3+
import WebSocket from 'ws'
4+
5+
import { createEvent, createSubscription, publishEvent, waitForEventCount } from '../helpers'
6+
import { ExpiringEvent } from '../../../../src/@types/event'
7+
import { EventExpirationTimeMetadataKey, EventKinds, EventTags } from '../../../../src/constants/base'
8+
import { getMasterDbClient } from '../../../../src/database/client'
9+
import { EventRepository } from '../../../../src/repositories/event-repository'
10+
11+
const now = (): number => Math.floor(Date.now() / 1000)
12+
13+
const createTextNoteWithExpiration = async (
14+
world: World<Record<string, any>>,
15+
name: string,
16+
content: string,
17+
expirationTime: number,
18+
): Promise<ExpiringEvent> => {
19+
const ws = world.parameters.clients[name] as WebSocket
20+
const { pubkey, privkey } = world.parameters.identities[name]
21+
22+
const event = await createEvent(
23+
{
24+
pubkey,
25+
kind: EventKinds.TEXT_NOTE,
26+
content,
27+
tags: [[EventTags.Expiration, expirationTime.toString()]],
28+
},
29+
privkey,
30+
) as ExpiringEvent
31+
32+
event[EventExpirationTimeMetadataKey] = expirationTime
33+
34+
await publishEvent(ws, event)
35+
36+
world.parameters.events[name].push(event)
37+
38+
return event
39+
}
40+
41+
const seedStoredTextNoteWithExpiration = async (
42+
world: World<Record<string, any>>,
43+
name: string,
44+
content: string,
45+
expirationTime: number,
46+
): Promise<ExpiringEvent> => {
47+
const { pubkey, privkey } = world.parameters.identities[name]
48+
const dbClient = getMasterDbClient()
49+
const repository = new EventRepository(dbClient, dbClient)
50+
51+
const event = await createEvent(
52+
{
53+
pubkey,
54+
kind: EventKinds.TEXT_NOTE,
55+
created_at: expirationTime - 30,
56+
content,
57+
tags: [[EventTags.Expiration, expirationTime.toString()]],
58+
},
59+
privkey,
60+
) as ExpiringEvent
61+
62+
event[EventExpirationTimeMetadataKey] = expirationTime
63+
64+
const inserted = await repository.create(event)
65+
expect(inserted).to.equal(1)
66+
67+
world.parameters.events[name].push(event)
68+
69+
return event
70+
}
71+
72+
When(/^(\w+) sends a text_note event with content "([^"]+)" and expiration in the past$/, async function(
73+
this: World<Record<string, any>>,
74+
name: string,
75+
content: string,
76+
) {
77+
await createTextNoteWithExpiration(this, name, content, now() - 10)
78+
})
79+
80+
When(/^(\w+) sends a text_note event with content "([^"]+)" and expiration in the future$/, async function(
81+
this: World<Record<string, any>>,
82+
name: string,
83+
content: string,
84+
) {
85+
await createTextNoteWithExpiration(this, name, content, now() + 30)
86+
})
87+
88+
When(/^(\w+) has a stored text_note event with content "([^"]+)" and expiration in the past$/, async function(
89+
this: World<Record<string, any>>,
90+
name: string,
91+
content: string,
92+
) {
93+
await seedStoredTextNoteWithExpiration(this, name, content, now() - 10)
94+
})
95+
96+
When(/^(\w+) sends a text_note event with content "([^"]+)" and expiration in (\d+) seconds$/, async function(
97+
this: World<Record<string, any>>,
98+
name: string,
99+
content: string,
100+
durationSeconds: string,
101+
) {
102+
const expirationTime = now() + Number(durationSeconds)
103+
const event = await createTextNoteWithExpiration(this, name, content, expirationTime)
104+
const expirationTag = event.tags.find((tag) => tag[0] === EventTags.Expiration)
105+
106+
expect(expirationTag).to.not.equal(undefined)
107+
expect(Number(expirationTag?.[1])).to.equal(expirationTime)
108+
})
109+
110+
When(/^(\w+) subscribes to text_note events from (\w+)$/, async function(
111+
this: World<Record<string, any>>,
112+
name: string,
113+
author: string,
114+
) {
115+
const ws = this.parameters.clients[name] as WebSocket
116+
const authorPubkey = this.parameters.identities[author].pubkey
117+
const subscription = {
118+
name: `test-${Math.random()}`,
119+
filters: [{ kinds: [EventKinds.TEXT_NOTE], authors: [authorPubkey] }],
120+
}
121+
122+
this.parameters.subscriptions[name].push(subscription)
123+
124+
await createSubscription(ws, subscription.name, subscription.filters)
125+
})
126+
127+
Then(/^(\w+) receives (\d+) text_note events and EOSE$/, async function(
128+
this: World<Record<string, any>>,
129+
name: string,
130+
count: string,
131+
) {
132+
const ws = this.parameters.clients[name] as WebSocket
133+
const subscription = this.parameters.subscriptions[name][this.parameters.subscriptions[name].length - 1]
134+
const events = await waitForEventCount(ws, subscription.name, Number(count), true)
135+
136+
expect(events.length).to.equal(Number(count))
137+
138+
events.forEach((event) => {
139+
expect(event.kind).to.equal(EventKinds.TEXT_NOTE)
140+
})
141+
})
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import chai from 'chai'
2+
import sinon from 'sinon'
3+
import sinonChai from 'sinon-chai'
4+
5+
import { getHealthRequestHandler } from '../../../../src/handlers/request-handlers/get-health-request-handler'
6+
7+
chai.use(sinonChai)
8+
const { expect } = chai
9+
10+
describe('getHealthRequestHandler', () => {
11+
it('responds with OK plain text and calls next', () => {
12+
const req = {} as any
13+
const res = {
14+
status: sinon.stub().returnsThis(),
15+
setHeader: sinon.stub().returnsThis(),
16+
send: sinon.stub().returnsThis(),
17+
} as any
18+
const next = sinon.stub()
19+
20+
getHealthRequestHandler(req, res, next)
21+
22+
expect(res.status).to.have.been.calledOnceWithExactly(200)
23+
expect(res.setHeader).to.have.been.calledOnceWithExactly('content-type', 'text/plain; charset=utf8')
24+
expect(res.send).to.have.been.calledOnceWithExactly('OK')
25+
expect(next).to.have.been.calledOnce
26+
})
27+
})

test/unit/handlers/subscribe-message-handler.spec.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,22 @@ describe('SubscribeMessageHandler', () => {
236236
await expect(promise).to.eventually.be.rejectedWith(error)
237237
expect(closeSpy).to.have.been.called
238238
})
239+
240+
it('destroys event stream if aborted', async () => {
241+
const error = new Error('aborted')
242+
error.name = 'AbortError'
243+
isClientSubscribedToEventStub.returns(always(true))
244+
245+
const fetch = () => (handler as any).fetchAndSend(subscriptionId, filters)
246+
const destroySpy = sandbox.spy(stream, 'destroy')
247+
248+
const promise = fetch()
249+
250+
stream.emit('error', error)
251+
252+
await expect(promise).to.eventually.be.rejectedWith(error)
253+
expect(destroySpy).to.have.been.called
254+
})
239255
})
240256

241257
describe('.isClientSubscribedToEvent', () => {
@@ -350,5 +366,35 @@ describe('SubscribeMessageHandler', () => {
350366
'Too many filters: Number of filters per susbscription must be less then or equal to 1',
351367
)
352368
})
369+
370+
it('returns reason if subscription id is too long', () => {
371+
settingsFactory.returns({
372+
limits: {
373+
client: {
374+
subscription: {
375+
maxSubscriptionIdLength: 5,
376+
},
377+
},
378+
},
379+
})
380+
381+
expect((handler as any).canSubscribe('123456', filters)).to.equal(
382+
'Subscription ID too long: Subscription ID must be less or equal to 5',
383+
)
384+
})
385+
386+
it('returns undefined if subscription id matches max length', () => {
387+
settingsFactory.returns({
388+
limits: {
389+
client: {
390+
subscription: {
391+
maxSubscriptionIdLength: 6,
392+
},
393+
},
394+
},
395+
})
396+
397+
expect((handler as any).canSubscribe('123456', filters)).to.be.undefined
398+
})
353399
})
354400
})

0 commit comments

Comments
 (0)