fix: integration tests

Signed-off-by: Ricardo Arturo Cabral Mejía <me@ricardocabral.io>
This commit is contained in:
Ricardo Arturo Cabral Mejía 2023-01-15 21:43:03 -05:00
parent 3fa30724d7
commit 1db3343ef8
28 changed files with 380 additions and 223 deletions

21
package-lock.json generated
View File

@ -18,6 +18,7 @@
"pg-query-stream": "4.2.4",
"ramda": "0.28.0",
"redis": "4.5.1",
"rxjs": "7.8.0",
"tor-control-ts": "^1.0.0",
"ws": "8.11.0"
},
@ -10979,10 +10980,9 @@
}
},
"node_modules/rxjs": {
"version": "7.5.7",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.5.7.tgz",
"integrity": "sha512-z9MzKh/UcOqB3i20H6rtrlaE/CgjLOvheWK/9ILrbhROGTweAi1BaFsTT9FbwZi5Trr1qNRs+MXkhmR06awzQA==",
"dev": true,
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.0.tgz",
"integrity": "sha512-F2+gxDshqmIub1KdvZkaEfGDwLNpPvk9Fs6LD/MyQxNgMds/WH9OdDDXOmxUZpME+iSK3rQCctkL0DYyytUqMg==",
"dependencies": {
"tslib": "^2.1.0"
}
@ -10990,8 +10990,7 @@
"node_modules/rxjs/node_modules/tslib": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz",
"integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==",
"dev": true
"integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA=="
},
"node_modules/safe-buffer": {
"version": "5.2.1",
@ -20749,10 +20748,9 @@
}
},
"rxjs": {
"version": "7.5.7",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.5.7.tgz",
"integrity": "sha512-z9MzKh/UcOqB3i20H6rtrlaE/CgjLOvheWK/9ILrbhROGTweAi1BaFsTT9FbwZi5Trr1qNRs+MXkhmR06awzQA==",
"dev": true,
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.0.tgz",
"integrity": "sha512-F2+gxDshqmIub1KdvZkaEfGDwLNpPvk9Fs6LD/MyQxNgMds/WH9OdDDXOmxUZpME+iSK3rQCctkL0DYyytUqMg==",
"requires": {
"tslib": "^2.1.0"
},
@ -20760,8 +20758,7 @@
"tslib": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz",
"integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==",
"dev": true
"integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA=="
}
}
},

View File

@ -118,6 +118,7 @@
"ramda": "0.28.0",
"redis": "4.5.1",
"tor-control-ts": "^1.0.0",
"rxjs": "7.8.0",
"ws": "8.11.0"
},
"config": {

View File

@ -14,5 +14,6 @@ export interface IEventRepository {
create(event: Event): Promise<number>
upsert(event: Event): Promise<number>
findByFilters(filters: SubscriptionFilter[]): IQueryResult<DBEvent[]>
insertStubs(pubkey: string, eventIdsToDelete: EventId[]): Promise<number>
deleteByPubkeyAndIds(pubkey: Pubkey, ids: EventId[]): Promise<number>
}

View File

@ -41,7 +41,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
this.alive = true
this.subscriptions = new Map()
this.clientId = Buffer.from(this.request.headers['sec-websocket-key'], 'base64').toString('hex')
this.clientId = Buffer.from(this.request.headers['sec-websocket-key'] as string, 'base64').toString('hex')
const remoteIpHeader = this.settings().network?.remote_ip_header ?? 'x-forwarded-for'
this.clientAddress = (this.request.headers[remoteIpHeader] ?? this.request.socket.remoteAddress) as string
@ -88,7 +88,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
public onBroadcast(event: Event): void {
this.webSocketServer.emit(WebSocketServerAdapterEvent.Broadcast, event)
if (cluster.isWorker) {
if (cluster.isWorker && typeof process.send === 'function') {
process.send({
eventName: WebSocketServerAdapterEvent.Broadcast,
event,
@ -99,8 +99,9 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
public onSendEvent(event: Event): void {
this.subscriptions.forEach((filters, subscriptionId) => {
if (
filters.map(isEventMatchingFilter).some((Matches) => Matches(event))
filters.map(isEventMatchingFilter).some((isMatch) => isMatch(event))
) {
debug('sending event to client %s: %o', this.clientId, event)
this.sendMessage(createOutgoingEventMessage(subscriptionId, event))
}
})
@ -134,7 +135,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
private async onClientMessage(raw: Buffer) {
let abortable = false
let messageHandler: IMessageHandler & IAbortable
let messageHandler: IMessageHandler & IAbortable | undefined = undefined
try {
if (await this.isRateLimited(this.clientAddress)) {
this.sendMessage(createNoticeMessage('rate limited'))
@ -144,7 +145,12 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
const message = attemptValidation(messageSchema)(JSON.parse(raw.toString('utf8')))
messageHandler = this.createMessageHandler([message, this]) as IMessageHandler & IAbortable
abortable = typeof messageHandler?.abort === 'function'
if (!messageHandler) {
debug('unhandled message: no handler found: %o', message)
return
}
abortable = typeof messageHandler.abort === 'function'
if (abortable) {
const handlers = abortableMessageHandlers.get(this.client) ?? []
@ -152,7 +158,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
abortableMessageHandlers.set(this.client, handlers)
}
await messageHandler?.handleMessage(message)
await messageHandler.handleMessage(message)
} catch (error) {
if (error instanceof Error) {
if (error.name === 'AbortError') {
@ -169,11 +175,13 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
console.error('unable to handle message', error)
}
} finally {
if (abortable) {
if (abortable && messageHandler) {
const handlers = abortableMessageHandlers.get(this.client)
const index = handlers.indexOf(messageHandler)
if (index >= 0) {
handlers.splice(index, 1)
if (handlers) {
const index = handlers.indexOf(messageHandler)
if (index >= 0) {
handlers.splice(index, 1)
}
}
}
}
@ -185,7 +193,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
ipWhitelist = [],
} = this.settings().limits?.message ?? {}
if (ipWhitelist.includes(client)) {
if (!Array.isArray(rateLimits) || !rateLimits.length || ipWhitelist.includes(client)) {
return false
}

View File

@ -57,8 +57,7 @@ export class WebSocketServerAdapter extends WebServerAdapter implements IWebSock
if (!propEq('readyState', OPEN)(webSocket)) {
return
}
const webSocketAdapter = this.webSocketsAdapters.get(webSocket)
debug('broadcasting event to client %s: %o', webSocketAdapter.getClientId(), event)
const webSocketAdapter = this.webSocketsAdapters.get(webSocket) as IWebSocketAdapter
webSocketAdapter.emit(WebSocketAdapterEvent.Event, event)
})
}
@ -73,9 +72,10 @@ export class WebSocketServerAdapter extends WebServerAdapter implements IWebSock
}
private onHeartbeat() {
this.webSocketServer.clients.forEach((webSocket) =>
this.webSocketsAdapters.get(webSocket).emit(WebSocketAdapterEvent.Heartbeat)
)
this.webSocketServer.clients.forEach((webSocket) => {
const webSocketAdapter = this.webSocketsAdapters.get(webSocket) as IWebSocketAdapter
webSocketAdapter.emit(WebSocketAdapterEvent.Heartbeat)
})
}
protected onClose() {

View File

@ -41,6 +41,6 @@ export class AppWorker implements IRunnable {
public close(callback?: () => void) {
debug('closing')
this.adapter.close(callback)
this.adapter.close(callback as () => void)
}
}

View File

@ -5,6 +5,7 @@ import { EventTags } from '../../constants/base'
import { IEventRepository } from '../../@types/repositories'
import { IEventStrategy } from '../../@types/message-handlers'
import { IWebSocketAdapter } from '../../@types/adapters'
import { Tag } from '../../@types/base'
import { WebSocketAdapterEvent } from '../../constants/adapter'
const debug = createLogger('delete-event-strategy')
@ -17,25 +18,37 @@ export class DeleteEventStrategy implements IEventStrategy<Event, Promise<void>>
public async execute(event: Event): Promise<void> {
debug('received delete event: %o', event)
const count = await this.eventRepository.create(event)
this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, true, (count) ? '' : 'duplicate:'))
const ids = event.tags.reduce(
(eventIds, tag) => (tag.length >= 2 && tag[0] === EventTags.Event)
if (count) {
this.webSocket.emit(WebSocketAdapterEvent.Broadcast, event)
}
const isValidETag = (tag: Tag) =>
tag.length >= 2
&& tag[0] === EventTags.Event
&& /^[0-9a-f]{64}$/.test(tag[1])
const eventIdsToDelete = event.tags.reduce(
(eventIds, tag) => isValidETag(tag)
? [...eventIds, tag[1]]
: eventIds,
[] as string[]
)
if (ids.length) {
await this.eventRepository.deleteByPubkeyAndIds(
if (eventIdsToDelete.length) {
const count = await this.eventRepository.deleteByPubkeyAndIds(
event.pubkey,
ids
eventIdsToDelete
)
}
if (count) {
this.webSocket.emit(WebSocketAdapterEvent.Broadcast, event)
if (!count) {
await this.eventRepository.insertStubs(
event.pubkey,
eventIdsToDelete,
)
}
}
}
}

View File

@ -1,3 +1,4 @@
import { createCommandResult } from '../../utils/messages'
import { createLogger } from '../../factories/logger-factory'
import { Event } from '../../@types/event'
import { IEventStrategy } from '../../@types/message-handlers'
@ -13,6 +14,10 @@ export class EphemeralEventStrategy implements IEventStrategy<Event, Promise<voi
public async execute(event: Event): Promise<void> {
debug('received ephemeral event: %o', event)
this.webSocket.emit(
WebSocketAdapterEvent.Message,
createCommandResult(event.id, true, ''),
)
this.webSocket.emit(WebSocketAdapterEvent.Broadcast, event)
}
}

View File

@ -16,10 +16,30 @@ export class ReplaceableEventStrategy implements IEventStrategy<Event, Promise<v
public async execute(event: Event): Promise<void> {
debug('received replaceable event: %o', event)
const count = await this.eventRepository.upsert(event)
this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, true, (count) ? '' : 'duplicate:'))
if (count) {
this.webSocket.emit(WebSocketAdapterEvent.Broadcast, event)
try {
const count = await this.eventRepository.upsert(event)
this.webSocket.emit(
WebSocketAdapterEvent.Message,
createCommandResult(event.id, true, (count) ? '' : 'duplicate:'),
)
if (count) {
this.webSocket.emit(WebSocketAdapterEvent.Broadcast, event)
}
} catch (error: unknown) {
if (error instanceof Error) {
if (error.message.endsWith('duplicate key value violates unique constraint "events_event_id_unique"')) {
this.webSocket.emit(
WebSocketAdapterEvent.Message,
createCommandResult(event.id, false, 'rejected: event already exists'),
)
return
}
this.webSocket.emit(
WebSocketAdapterEvent.Message,
createCommandResult(event.id, false, 'error: '),
)
}
}
}
}

View File

@ -1,13 +1,13 @@
import { anyPass, equals, map, uniqWith } from 'ramda'
import { anyPass, equals, isNil, map, propSatisfies, uniqWith } from 'ramda'
import { pipeline } from 'stream/promises'
import { createEndOfStoredEventsNoticeMessage, createNoticeMessage, createOutgoingEventMessage } from '../utils/messages'
import { DBEvent, Event } from '../@types/event'
import { IAbortable, IMessageHandler } from '../@types/message-handlers'
import { isEventMatchingFilter, toNostrEvent } from '../utils/event'
import { streamEach, streamEnd, streamFilter, streamMap } from '../utils/stream'
import { SubscriptionFilter, SubscriptionId } from '../@types/subscription'
import { createLogger } from '../factories/logger-factory'
import { Event } from '../@types/event'
import { IEventRepository } from '../@types/repositories'
import { ISettings } from '../@types/settings'
import { IWebSocketAdapter } from '../@types/adapters'
@ -57,12 +57,10 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable {
const findEvents = this.eventRepository.findByFilters(filters).stream()
const isNotDeleted = (row: DBEvent) => { console.log(row); return true }
try {
await pipeline(
findEvents,
streamFilter(isNotDeleted),
streamFilter(propSatisfies(isNil, 'deleted_at')),
streamMap(toNostrEvent),
streamFilter(isSubscribedToEvent),
streamEach(sendEvent),

View File

@ -10,6 +10,7 @@ import {
forEach,
forEachObjIndexed,
groupBy,
identity,
ifElse,
invoker,
is,
@ -229,11 +230,32 @@ export class EventRepository implements IEventRepository {
} as Promise<number>
}
public deleteByPubkeyAndIds(pubkey: string, ids: EventId[]): Promise<number> {
debug('deleting events from %s: %o', pubkey, ids)
public insertStubs(pubkey: string, eventIdsToDelete: EventId[]): Promise<number> {
return this.dbClient('events').insert(
eventIdsToDelete.map(
applySpec({
event_id: pipe(identity, toBuffer),
event_pubkey: pipe(always(pubkey), toBuffer),
event_created_at: always(Math.floor(Date.now() / 1000)),
event_kind: always(5),
event_tags: always('[]'),
event_content: always(''),
event_signature: pipe(always(''), toBuffer),
event_delegator: always(null),
event_deduplication: pipe(always([pubkey, 5]), toJSON),
})
)
)
.onConflict()
.ignore() as Promise<any>
}
public deleteByPubkeyAndIds(pubkey: string, eventIdsToDelete: EventId[]): Promise<number> {
debug('deleting events from %s: %o', pubkey, eventIdsToDelete)
return this.dbClient('events')
.where('event_pubkey', toBuffer(pubkey))
.whereIn('event_id', map(toBuffer)(ids))
.whereIn('event_id', map(toBuffer)(eventIdsToDelete))
.whereNull('deleted_at')
.update({
deleted_at: this.dbClient.raw('now()'),

View File

@ -17,7 +17,7 @@ services:
REDIS_USER: default
REDIS_PASSWORD: nostr_ts_relay_test
NOSTR_CONFIG_DIR: /code
DEBUG: knex:query,primary:event-repository
DEBUG: ""
volumes:
- ../../package.json:/code/package.json
- ../../settings.sample.json:/code/settings.sample.json

View File

@ -1,30 +1,31 @@
import * as secp256k1 from '@noble/secp256k1'
import { createHash, createHmac, Hash } from 'crypto'
import WebSocket, { RawData } from 'ws'
import { Observable } from 'rxjs'
import WebSocket from 'ws'
import { CommandResult, MessageType, OutgoingMessage } from '../../../src/@types/messages'
import { Event } from '../../../src/@types/event'
import { MessageType } from '../../../src/@types/messages'
import { serializeEvent } from '../../../src/utils/event'
import { streams } from './shared'
import { SubscriptionFilter } from '../../../src/@types/subscription'
secp256k1.utils.sha256Sync = (...messages: Uint8Array[]) =>
messages.reduce((hash: Hash, message: Uint8Array) => hash.update(message), createHash('sha256')).digest()
export async function connect(_name: string) {
export async function connect(_name: string): Promise<WebSocket> {
const host = 'ws://localhost:18808'
const ws = new WebSocket(host)
await new Promise<void>((resolve, reject) => {
return new Promise<WebSocket>((resolve, reject) => {
ws
.once('open', () => {
resolve()
resolve(ws)
})
.once('error', reject)
.once('close', () => {
ws.removeAllListeners()
})
})
return ws
}
let eventCount = 0
@ -71,87 +72,80 @@ export async function createSubscription(
subscriptionFilters: SubscriptionFilter[],
): Promise<void> {
return new Promise<void>((resolve, reject) => {
const message = JSON.stringify([
const data = JSON.stringify([
'REQ',
subscriptionName,
...subscriptionFilters,
])
ws.send(message, (error?: Error) => {
ws.send(data, (error?: Error) => {
if (error) {
reject(error)
return
} else {
resolve()
}
resolve()
})
})
}
export async function waitForEOSE(ws: WebSocket, subscription: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
function cleanup() {
ws.removeListener('message', onMessage)
ws.removeListener('error', onError)
}
const observable = streams.get(ws) as Observable<OutgoingMessage>
function onError(error: Error) {
reject(error)
cleanup()
}
ws.once('error', onError)
function onMessage(raw: RawData) {
const message = JSON.parse(raw.toString('utf8'))
const sub = observable.subscribe((message: OutgoingMessage) => {
if (message[0] === MessageType.EOSE && message[1] === subscription) {
resolve()
cleanup()
sub.unsubscribe()
} else if (message[0] === MessageType.NOTICE) {
reject(new Error(message[1]))
cleanup()
sub.unsubscribe()
}
}
ws.on('message', onMessage)
})
}
export async function sendEvent(ws: WebSocket, event: Event) {
return new Promise<void>((resolve, reject) => {
ws.send(JSON.stringify(['EVENT', event]), (err) => {
if (err) {
reject(err)
return
}
resolve()
})
})
}
export async function waitForNextEvent(ws: WebSocket, subscription: string): Promise<Event> {
export async function sendEvent(ws: WebSocket, event: Event, successful = true) {
return new Promise<OutgoingMessage>((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]))
}
} else if (message[0] === MessageType.NOTICE) {
sub.unsubscribe()
reject(new Error(message[1]))
}
})
ws.send(JSON.stringify(['EVENT', event]), (err) => {
if (err) {
sub.unsubscribe()
reject(err)
}
})
})
}
export async function waitForNextEvent(ws: WebSocket, subscription: string, content?: string): Promise<Event> {
return new Promise((resolve, reject) => {
ws.on('message', onMessage)
ws.once('error', onError)
const observable = streams.get(ws) as Observable<OutgoingMessage>
function cleanup() {
ws.removeListener('message', onMessage)
ws.removeListener('error', onError)
}
function onError(error: Error) {
reject(error)
cleanup()
}
function onMessage(raw: RawData) {
const message = JSON.parse(raw.toString('utf8'))
observable.subscribe((message: OutgoingMessage) => {
if (message[0] === MessageType.EVENT && message[1] === subscription) {
resolve(message[2])
cleanup()
const event = message[2] as Event
if (typeof content !== 'string' || event.content === content) {
resolve(message[2])
}
} else if (message[0] === MessageType.NOTICE) {
reject(new Error(message[1]))
cleanup()
}
}
})
})
}
@ -164,27 +158,15 @@ export async function waitForEventCount(
const events: Event[] = []
return new Promise((resolve, reject) => {
ws.on('message', onMessage)
ws.once('error', onError)
function cleanup() {
ws.removeListener('message', onMessage)
ws.removeListener('error', onError)
}
const observable = streams.get(ws) as Observable<OutgoingMessage>
function onError(error: Error) {
reject(error)
cleanup()
}
function onMessage(raw: RawData) {
const message = JSON.parse(raw.toString('utf8'))
observable.subscribe((message: OutgoingMessage) => {
if (message[0] === MessageType.EVENT && message[1] === subscription) {
events.push(message[2])
if (!eose && events.length === count) {
resolve(events)
cleanup()
} else if (events.length > count) {
reject(new Error(`Expected ${count} but got ${events.length} events`))
cleanup()
}
} else if (message[0] === MessageType.EOSE && message[1] === subscription) {
if (!eose) {
@ -194,61 +176,33 @@ export async function waitForEventCount(
} else {
resolve(events)
}
cleanup()
} else if (message[0] === MessageType.NOTICE) {
reject(new Error(message[1]))
cleanup()
}
}
})
})
}
export async function waitForNotice(ws: WebSocket): Promise<void> {
return new Promise<void>((resolve, reject) => {
function cleanup() {
ws.removeListener('message', onMessage)
ws.removeListener('error', onError)
}
export async function waitForNotice(ws: WebSocket): Promise<string> {
return new Promise<string>((resolve) => {
const observable = streams.get(ws) as Observable<OutgoingMessage>
function onError(error: Error) {
reject(error)
cleanup()
}
ws.once('error', onError)
function onMessage(raw: RawData) {
const message = JSON.parse(raw.toString('utf8'))
observable.subscribe((message: OutgoingMessage) => {
if (message[0] === MessageType.NOTICE) {
resolve(message[1])
cleanup()
}
}
ws.on('message', onMessage)
})
})
}
export async function waitForCommand(ws: WebSocket): Promise<any> {
return new Promise<void>((resolve, reject) => {
function cleanup() {
ws.removeListener('message', onMessage)
ws.removeListener('error', onError)
}
export async function waitForCommand(ws: WebSocket): Promise<CommandResult> {
return new Promise<CommandResult>((resolve) => {
const observable = streams.get(ws) as Observable<OutgoingMessage>
function onError(error: Error) {
reject(error)
cleanup()
}
ws.once('error', onError)
function onMessage(raw: RawData) {
const message = JSON.parse(raw.toString('utf8'))
observable.subscribe((message: OutgoingMessage) => {
if (message[0] === MessageType.OK) {
resolve(message)
cleanup()
}
}
ws.on('message', onMessage)
})
})
}

View File

@ -8,42 +8,48 @@ Feature: NIP-01
Scenario: Alice posts a set_metadata event
Given someone called Alice
And Alice subscribes to author Alice
When Alice sends a set_metadata event
And Alice subscribes to author Alice
Then Alice receives a set_metadata event from Alice
Scenario: Alice reposts a set_metadata event
Given someone called Alice
When Alice sends a set_metadata event
And Alice subscribes to author Alice
Then Alice receives a set_metadata event from Alice
Scenario: Alice posts a text_note event
Given someone called Alice
And Alice subscribes to author Alice
When Alice sends a text_note event with content "hello world"
And Alice subscribes to author Alice
Then Alice receives a text_note event from Alice with content "hello world"
Scenario: Alice posts a recommend_server event
Given someone called Alice
And Alice subscribes to author Alice
When Alice sends a recommend_server event with content "https://nostr-relay.wlvs.space"
And Alice subscribes to author Alice
Then Alice receives a recommend_server event from Alice with content "https://nostr-relay.wlvs.space"
Scenario: Alice can't post a text_note event with an invalid signature
Given someone called Alice
When Alice sends a text_note event with invalid signature
Then Alice receives an unsuccessful result
When Alice drafts a text_note event with invalid signature
Then Alice sends their last draft event unsuccessfully
Scenario: Alice and Bob exchange text_note events
Given someone called Alice
And someone called Bob
And Alice subscribes to author Bob
And Bob subscribes to author Alice
When Bob sends a text_note event with content "hello alice"
And Alice subscribes to author Bob
Then Alice receives a text_note event from Bob with content "hello alice"
When Alice sends a text_note event with content "hello bob"
And Bob subscribes to author Alice
Then Bob receives a text_note event from Alice with content "hello bob"
Scenario: Alice is interested in text_note events
Given someone called Alice
And someone called Bob
And Alice subscribes to text_note events
When Bob sends a text_note event with content "hello nostr"
And Alice subscribes to text_note events
Then Alice receives a text_note event from Bob with content "hello nostr"
Scenario: Alice is interested in the #NostrNovember hashtag
@ -57,15 +63,14 @@ Feature: NIP-01
Given someone called Alice
And someone called Bob
And someone called Charlie
And Bob subscribes to author Bob
And Charlie subscribes to author Charlie
When Bob sends a text_note event with content "I'm Bob"
And Bob subscribes to author Bob
And Bob receives a text_note event from Bob with content "I'm Bob"
And Charlie sends a set_metadata event
And Charlie subscribes to author Charlie
And Charlie receives a set_metadata event from Charlie
And Alice subscribes to text_note events from Bob and set_metadata events from Charlie
Then Alice receives 2 events from Bob and Charlie
Scenario: Alice is interested in Bob's events from back in November

View File

@ -18,6 +18,7 @@ import {
waitForNotice,
} from '../helpers'
import { Event } from '../../../../src/@types/event'
import { isDraft } from '../shared'
chai.use(sinonChai)
const { expect } = chai
@ -132,15 +133,15 @@ When(/^(\w+) sends a text_note event with content "([^"]+)" on (\d+)$/, async fu
this.parameters.events[name].push(event)
})
When(/(\w+) sends a text_note event with invalid signature/, async function(name: string) {
const ws = this.parameters.clients[name] as WebSocket
When(/(\w+) drafts a text_note event with invalid signature/, async function(name: string) {
const { pubkey, privkey } = this.parameters.identities[name]
const event: Event = await createEvent({ pubkey, kind: 1, content: "I'm cheating" }, privkey)
event.sig = 'f'.repeat(128)
await sendEvent(ws, event)
event[isDraft] = true
this.parameters.events[name].push(event)
})
@ -157,7 +158,9 @@ When(/(\w+) sends a recommend_server event with content "(.+?)"/, async function
Then(/(\w+) receives a set_metadata event from (\w+)/, async function(name: string, author: 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)
const event = this.parameters.events[author][this.parameters.events[author].length - 1]
const receivedEvent = await waitForNextEvent(ws, subscription.name, event.content)
expect(receivedEvent.kind).to.equal(0)
expect(receivedEvent.pubkey).to.equal(this.parameters.identities[author].pubkey)
@ -166,7 +169,7 @@ Then(/(\w+) receives a set_metadata event from (\w+)/, async function(name: stri
Then(/(\w+) receives a text_note event from (\w+) with content "([^"]+?)"/, async function(name: string, author: 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)
const receivedEvent = await waitForNextEvent(ws, subscription.name, content)
expect(receivedEvent.kind).to.equal(1)
expect(receivedEvent.pubkey).to.equal(this.parameters.identities[author].pubkey)
@ -181,7 +184,7 @@ Then(/(\w+) receives a text_note event from (\w+) with content "(.+?)" on (\d+)/
) {
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)
const receivedEvent = await waitForNextEvent(ws, subscription.name, content)
expect(receivedEvent.kind).to.equal(1)
expect(receivedEvent.pubkey).to.equal(this.parameters.identities[author].pubkey)
@ -225,7 +228,7 @@ Then(/(\w+) receives (\d+) events from (\w+) and (\w+)/, async function(
Then(/(\w+) receives a recommend_server event from (\w+) with content "(.+?)"/, async function(name: string, author: 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)
const receivedEvent = await waitForNextEvent(ws, subscription.name, content)
expect(receivedEvent.kind).to.equal(2)
expect(receivedEvent.pubkey).to.equal(this.parameters.identities[author].pubkey)

View File

@ -1,14 +1,34 @@
@Test
Feature: NIP-09
Scenario: Charlie deletes an event
Given someone called Charlie
Scenario: Alice deletes her text_note
Given someone called Alice
And someone called Bob
And Charlie sends a text_note event with content "Twitter > Nostr"
And Charlie subscribes to author Charlie
And Charlie receives a text_note event from Charlie with content "Twitter > Nostr"
And Charlie unsubscribes from author Charlie
When Charlie sends a delete event for their last event
And Charlie subscribes to author Charlie
And Charlie receives 1 delete event from Charlie and EOSE
Then Bob subscribes to author Charlie
Then Bob receives 1 delete event from Charlie and EOSE
And Alice sends a text_note event with content "Twitter > Nostr"
When Alice sends a delete event for their last event
And Alice subscribes to author Alice
Then Alice receives 1 delete event from Alice and EOSE
Scenario: Alice deletes her set_metadata
Given someone called Alice
And someone called Bob
And Alice drafts a set_metadata event
When Alice sends a delete event for their last event
And Alice subscribes to author Alice
Then Alice receives 1 delete event from Alice and EOSE
Scenario: Alice sends a delete before deleted text_note
Given someone called Alice
And someone called Bob
And Alice drafts a text_note event with content "Twitter > Nostr"
When Alice sends a delete event for their last event
And Alice sends their last draft event successfully
And Alice subscribes to author Alice
Then Alice receives 1 delete event from Alice and EOSE
Scenario: Alice sends a delete before deleted set_metadata
Given someone called Alice
And someone called Bob
And Alice drafts a set_metadata event
When Alice sends a delete event for their last event
And Alice sends their last draft event unsuccessfully
And Alice subscribes to author Alice
Then Alice receives 1 delete event from Alice and EOSE

View File

@ -1,10 +1,11 @@
import { Then, When } from '@cucumber/cucumber'
import { Given, Then, When } from '@cucumber/cucumber'
import { expect } from 'chai'
import WebSocket from 'ws'
import { createEvent, sendEvent, waitForEventCount } from '../helpers'
import { Event } from '../../../../src/@types/event'
import { EventTags } from '../../../../src/constants/base'
import { isDraft } from '../shared'
import { Tag } from '../../../../src/@types/base'
When(/^(\w+) sends a delete event for their last event$/, async function(
@ -19,14 +20,13 @@ When(/^(\w+) sends a delete event for their last event$/, async function(
const event: Event = await createEvent({ pubkey, kind: 5, content: '', tags }, privkey)
console.log('event', event)
await sendEvent(ws, event)
this.parameters.events[name].push(event)
})
Then(
/(\w+) receives (\d+) delete events? from (\w+) and EOSE/,
/(\w+) receives (\d+) delete events? from (\w+) and EOSE$/,
async function(name: string, count: string, author: string) {
const ws = this.parameters.clients[name] as WebSocket
const subscription = this.parameters.subscriptions[name][this.parameters.subscriptions[name].length - 1]
@ -34,4 +34,36 @@ Then(
expect(event.kind).to.equal(5)
expect(event.pubkey).to.equal(this.parameters.identities[author].pubkey)
})
})
Then(
/(\w+) receives (\d+) delete events? from (\w+)$/,
async function(name: string, count: string, author: string) {
const ws = this.parameters.clients[name] as WebSocket
const subscription = this.parameters.subscriptions[name][this.parameters.subscriptions[name].length - 1]
const [event] = await waitForEventCount(ws, subscription.name, Number(count), false)
expect(event.kind).to.equal(5)
expect(event.pubkey).to.equal(this.parameters.identities[author].pubkey)
})
When(/^(\w+) drafts a text_note event with content "([^"]+)"$/, async function(name: string, content: string) {
const { pubkey, privkey } = this.parameters.identities[name]
const event: Event = await createEvent({ pubkey, kind: 1, content }, privkey)
event[isDraft] = true
this.parameters.events[name].push(event)
})
Given(/^(\w+) drafts a set_metadata event$/, async function(name: string) {
const { pubkey, privkey } = this.parameters.identities[name]
const content = JSON.stringify({ name })
const event: Event = await createEvent({ pubkey, kind: 0, content }, privkey)
event[isDraft] = true
this.parameters.events[name].push(event)
})

View File

@ -1,4 +1,4 @@
Feature: NIP-36 Event treatment
Feature: NIP-16 Event treatment
Scenario: Alice sends a replaceable event
Given someone called Alice
And Alice subscribes to author Alice

View File

@ -23,7 +23,7 @@ Feature: NIP-28
When Alice sends a channel_metadata event with content '{\"name\": \"Replaced\", \"about\": \"A different test channel.\", \"picture\": \"https://placekitten.com/400/400\"}'
Then Alice receives a channel_metadata event from Alice with content '{\"name\": \"Replaced\", \"about\": \"A different test channel.\", \"picture\": \"https://placekitten.com/400/400\"}'
Scenario: Alice replaces metadata for a channel
Scenario: Alice replaces metadata for a channel twice
Given someone called Alice
And Alice subscribes to author Alice
And Alice sends a channel_creation event with content '{\"name\": \"Original\", \"about\": \"A test channel.\", \"picture\": \"https://placekitten.com/200/200\"}'

View File

@ -8,26 +8,31 @@ import {
When,
World,
} from '@cucumber/cucumber'
import { fromEvent, map, Observable, ReplaySubject, Subject, takeUntil } from 'rxjs'
import WebSocket, { MessageEvent } from 'ws'
import { assocPath } from 'ramda'
import WebSocket from 'ws'
import { connect, createIdentity, createSubscription } from './helpers'
import { connect, createIdentity, createSubscription, sendEvent } from './helpers'
import { AppWorker } from '../../../src/app/worker'
import { CacheClient } from '../../../src/@types/cache'
//import { CacheClient } from '../../../src/@types/cache'
import { DatabaseClient } from '../../../src/@types/base'
import { getCacheClient } from '../../../src/cache/client'
import { Event } from '../../../src/@types/event'
//import { getCacheClient } from '../../../src/cache/client'
import { getDbClient } from '../../../src/database/client'
import { SettingsStatic } from '../../../src/utils/settings'
import { workerFactory } from '../../../src/factories/worker-factory'
export const isDraft = Symbol('draft')
let worker: AppWorker
let dbClient: DatabaseClient
let cacheClient: CacheClient
//let cacheClient: CacheClient
BeforeAll({ timeout: 6000 }, async function () {
export const streams = new WeakMap<WebSocket, Observable<unknown>>()
BeforeAll({ timeout: 1000 }, async function () {
process.env.RELAY_PORT = '18808'
cacheClient = getCacheClient()
dbClient = getDbClient()
await dbClient.raw('SELECT 1=1')
@ -40,15 +45,10 @@ BeforeAll({ timeout: 6000 }, async function () {
})
AfterAll(async function() {
worker.close(async () => {
await Promise.all([
cacheClient.disconnect(),
dbClient.destroy(),
])
})
worker.close(async () => dbClient.destroy())
})
Before(async function () {
Before(function () {
this.parameters.identities = {}
this.parameters.subscriptions = {}
this.parameters.clients = {}
@ -74,11 +74,24 @@ After(async function () {
})
Given(/someone called (\w+)/, async function(name: string) {
const connection = connect(name)
const connection = await connect(name)
this.parameters.identities[name] = this.parameters.identities[name] ?? createIdentity(name)
this.parameters.clients[name] = await connection
this.parameters.clients[name] = connection
this.parameters.subscriptions[name] = []
this.parameters.events[name] = []
const subject = new Subject()
connection.once('close', subject.next.bind(subject))
const project = (raw: MessageEvent) => JSON.parse(raw.data.toString('utf8'))
const replaySubject = new ReplaySubject(2, 1000)
fromEvent(connection, 'message').pipe(map(project) as any,takeUntil(subject)).subscribe(replaySubject)
streams.set(
connection,
replaySubject,
)
})
When(/(\w+) subscribes to author (\w+)$/, async function(this: World<Record<string, any>>, from: string, to: string) {
@ -93,5 +106,20 @@ When(/(\w+) subscribes to author (\w+)$/, async function(this: World<Record<stri
Then(/(\w+) unsubscribes from author \w+/, async function(from: string) {
const ws = this.parameters.clients[from] as WebSocket
const subscription = this.parameters.subscriptions[from].pop()
ws.send(JSON.stringify(['CLOSE', subscription.name]))
return new Promise<void>((resolve, reject) => {
ws.send(JSON.stringify(['CLOSE', subscription.name]), (err) => err ? reject(err) : resolve())
})
})
Then(/^(\w+) sends their last draft event (successfully|unsuccessfully)$/, async function(
name: string,
successfullyOrNot: string,
) {
const ws = this.parameters.clients[name] as WebSocket
const event = this.parameters.events[name].findLast((event: Event) => event[isDraft])
delete event[isDraft]
await sendEvent(ws, event, (successfullyOrNot) === 'successfully')
})

View File

@ -151,9 +151,10 @@ describe('DelegatedEventMessageHandler', () => {
})
it('does not reject if strategy rejects', async () => {
const error = new Error('mistakes were made')
isEventValidStub.returns(undefined)
canAcceptEventStub.returns(undefined)
strategyExecuteStub.rejects()
strategyExecuteStub.rejects(error)
return expect(handler.handleMessage(message)).to.eventually.be.fulfilled
})

View File

@ -22,8 +22,8 @@ describe('DeleteEventStrategy', () => {
id: 'id',
pubkey: 'pubkey',
tags: [
[EventTags.Event, 'event id 1'],
[EventTags.Event, 'event id 2'],
[EventTags.Event, '00000000'.repeat(8)],
[EventTags.Event, 'ffffffff'.repeat(8)],
],
} as any
let webSocket: IWebSocketAdapter
@ -32,6 +32,7 @@ describe('DeleteEventStrategy', () => {
let webSocketEmitStub: Sinon.SinonStub
let eventRepositoryCreateStub: Sinon.SinonStub
let eventRepositoryDeleteByPubkeyAndIdsStub: Sinon.SinonStub
let eventRepositoryInsertStubsStub: Sinon.SinonStub
let strategy: IEventStrategy<Event, Promise<void>>
@ -42,6 +43,7 @@ describe('DeleteEventStrategy', () => {
eventRepositoryCreateStub = sandbox.stub(EventRepository.prototype, 'create')
eventRepositoryDeleteByPubkeyAndIdsStub = sandbox.stub(EventRepository.prototype, 'deleteByPubkeyAndIds')
eventRepositoryInsertStubsStub = sandbox.stub(EventRepository.prototype, 'insertStubs')
webSocketEmitStub = sandbox.stub()
webSocket = {
@ -64,12 +66,27 @@ describe('DeleteEventStrategy', () => {
expect(eventRepositoryCreateStub).to.have.been.calledOnceWithExactly(event)
})
it('inserts stubs', async () => {
await strategy.execute(event)
expect(eventRepositoryInsertStubsStub).to.have.been.calledOnceWithExactly(
event.pubkey,
[
'0000000000000000000000000000000000000000000000000000000000000000',
'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff',
]
)
})
it('deletes events if it has e tags', async () => {
await strategy.execute(event)
expect(eventRepositoryDeleteByPubkeyAndIdsStub).to.have.been.calledOnceWithExactly(
event.pubkey,
['event id 1', 'event id 2'],
[
'0000000000000000000000000000000000000000000000000000000000000000',
'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff',
]
)
})
@ -117,6 +134,7 @@ describe('DeleteEventStrategy', () => {
expect(eventRepositoryCreateStub).to.have.been.calledOnceWithExactly(event)
expect(eventRepositoryDeleteByPubkeyAndIdsStub).not.to.have.been.called
expect(eventRepositoryInsertStubsStub).to.not.have.been.called
expect(webSocketEmitStub).not.to.have.been.called
})
})

View File

@ -8,6 +8,7 @@ import { EphemeralEventStrategy } from '../../../../src/handlers/event-strategie
import { Event } from '../../../../src/@types/event'
import { IEventStrategy } from '../../../../src/@types/message-handlers'
import { IWebSocketAdapter } from '../../../../src/@types/adapters'
import { MessageType } from '../../../../src/@types/messages'
import { WebSocketAdapterEvent } from '../../../../src/constants/adapter'
const { expect } = chai
@ -39,7 +40,16 @@ describe('EphemeralEventStrategy', () => {
it('broadcasts event', async () => {
await strategy.execute(event)
expect(webSocketEmitStub).to.have.been.calledOnceWithExactly(
expect(webSocketEmitStub.firstCall).to.have.been.calledWithExactly(
WebSocketAdapterEvent.Message,
[
MessageType.OK,
event.id,
true,
'',
]
)
expect(webSocketEmitStub.secondCall).to.have.been.calledWithExactly(
WebSocketAdapterEvent.Broadcast,
event
)

View File

@ -89,10 +89,13 @@ describe('ReplaceableEventStrategy', () => {
const error = new Error()
eventRepositoryUpsertStub.rejects(error)
await expect(strategy.execute(event)).to.eventually.be.rejectedWith(error)
await strategy.execute(event)
expect(eventRepositoryUpsertStub).to.have.been.calledOnceWithExactly(event)
expect(webSocketEmitStub).not.to.have.been.called
expect(webSocketEmitStub).to.have.been.calledOnceWithExactly(
WebSocketAdapterEvent.Message,
['OK', 'id', false, 'error: ']
)
})
})
})

View File

@ -437,6 +437,24 @@ describe('EventRepository', () => {
})
})
describe('insertStubs', () => {
let clock: sinon.SinonFakeTimers
beforeEach(() => {
clock = sinon.useFakeTimers(1673835425)
})
afterEach(() => {
clock.restore()
})
it('insert stubs by pubkey & event ids', () => {
const query = repository.insertStubs('001122', ['aabbcc', 'ddeeff']).toString()
expect(query).to.equal('insert into "events" ("event_content", "event_created_at", "event_deduplication", "event_delegator", "event_id", "event_kind", "event_pubkey", "event_signature", "event_tags") values (\'\', 1673835, \'["001122",5]\', NULL, X\'aabbcc\', 5, X\'001122\', X\'\', \'[]\'), (\'\', 1673835, \'["001122",5]\', NULL, X\'ddeeff\', 5, X\'001122\', X\'\', \'[]\') on conflict do nothing')
})
})
describe('deleteByPubkeyAndIds', () => {
it('marks event as deleted by pubkey & event_id if not deleted', () => {
const query = repository.deleteByPubkeyAndIds('001122', ['aabbcc', 'ddeeff']).toString()

View File

@ -70,7 +70,7 @@ describe('SettingsStatic', () => {
let sandbox: Sinon.SinonSandbox
beforeEach(() => {
SettingsStatic._settings = undefined
SettingsStatic._settings = undefined as any
sandbox = Sinon.createSandbox()