diff --git a/package-lock.json b/package-lock.json index 89e17ec..3be5ee9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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==" } } }, diff --git a/package.json b/package.json index 45f2ecc..9001227 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/@types/repositories.ts b/src/@types/repositories.ts index c12ec68..6eefc06 100644 --- a/src/@types/repositories.ts +++ b/src/@types/repositories.ts @@ -14,5 +14,6 @@ export interface IEventRepository { create(event: Event): Promise upsert(event: Event): Promise findByFilters(filters: SubscriptionFilter[]): IQueryResult + insertStubs(pubkey: string, eventIdsToDelete: EventId[]): Promise deleteByPubkeyAndIds(pubkey: Pubkey, ids: EventId[]): Promise } diff --git a/src/adapters/web-socket-adapter.ts b/src/adapters/web-socket-adapter.ts index 8a4a699..371b940 100644 --- a/src/adapters/web-socket-adapter.ts +++ b/src/adapters/web-socket-adapter.ts @@ -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 } diff --git a/src/adapters/web-socket-server-adapter.ts b/src/adapters/web-socket-server-adapter.ts index 5da8578..977dc02 100644 --- a/src/adapters/web-socket-server-adapter.ts +++ b/src/adapters/web-socket-server-adapter.ts @@ -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() { diff --git a/src/app/worker.ts b/src/app/worker.ts index 09566b2..42ed397 100644 --- a/src/app/worker.ts +++ b/src/app/worker.ts @@ -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) } } diff --git a/src/handlers/event-strategies/delete-event-strategy.ts b/src/handlers/event-strategies/delete-event-strategy.ts index 539cc06..ae7d2e6 100644 --- a/src/handlers/event-strategies/delete-event-strategy.ts +++ b/src/handlers/event-strategies/delete-event-strategy.ts @@ -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> public async execute(event: Event): Promise { 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, + ) + } } } } diff --git a/src/handlers/event-strategies/ephemeral-event-strategy.ts b/src/handlers/event-strategies/ephemeral-event-strategy.ts index 1e34c12..716c77d 100644 --- a/src/handlers/event-strategies/ephemeral-event-strategy.ts +++ b/src/handlers/event-strategies/ephemeral-event-strategy.ts @@ -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 { debug('received ephemeral event: %o', event) + this.webSocket.emit( + WebSocketAdapterEvent.Message, + createCommandResult(event.id, true, ''), + ) this.webSocket.emit(WebSocketAdapterEvent.Broadcast, event) } } diff --git a/src/handlers/event-strategies/replaceable-event-strategy.ts b/src/handlers/event-strategies/replaceable-event-strategy.ts index a3b7a79..5f58a82 100644 --- a/src/handlers/event-strategies/replaceable-event-strategy.ts +++ b/src/handlers/event-strategies/replaceable-event-strategy.ts @@ -16,10 +16,30 @@ export class ReplaceableEventStrategy implements IEventStrategy { 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: '), + ) + } } } } diff --git a/src/handlers/subscribe-message-handler.ts b/src/handlers/subscribe-message-handler.ts index fdd2f13..f0737c1 100644 --- a/src/handlers/subscribe-message-handler.ts +++ b/src/handlers/subscribe-message-handler.ts @@ -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), diff --git a/src/repositories/event-repository.ts b/src/repositories/event-repository.ts index 03f7f71..9a11f2d 100644 --- a/src/repositories/event-repository.ts +++ b/src/repositories/event-repository.ts @@ -10,6 +10,7 @@ import { forEach, forEachObjIndexed, groupBy, + identity, ifElse, invoker, is, @@ -229,11 +230,32 @@ export class EventRepository implements IEventRepository { } as Promise } - public deleteByPubkeyAndIds(pubkey: string, ids: EventId[]): Promise { - debug('deleting events from %s: %o', pubkey, ids) + public insertStubs(pubkey: string, eventIdsToDelete: EventId[]): Promise { + 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 + } + + public deleteByPubkeyAndIds(pubkey: string, eventIdsToDelete: EventId[]): Promise { + 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()'), diff --git a/test/integration/docker-compose.yml b/test/integration/docker-compose.yml index 965d467..c9cca09 100644 --- a/test/integration/docker-compose.yml +++ b/test/integration/docker-compose.yml @@ -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 diff --git a/test/integration/features/helpers.ts b/test/integration/features/helpers.ts index 5967bac..26a2e37 100644 --- a/test/integration/features/helpers.ts +++ b/test/integration/features/helpers.ts @@ -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 { const host = 'ws://localhost:18808' const ws = new WebSocket(host) - await new Promise((resolve, reject) => { + return new Promise((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 { return new Promise((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 { return new Promise((resolve, reject) => { - function cleanup() { - ws.removeListener('message', onMessage) - ws.removeListener('error', onError) - } + const observable = streams.get(ws) as Observable - 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((resolve, reject) => { - ws.send(JSON.stringify(['EVENT', event]), (err) => { - if (err) { - reject(err) - return - } - resolve() }) }) } -export async function waitForNextEvent(ws: WebSocket, subscription: string): Promise { +export async function sendEvent(ws: WebSocket, event: Event, successful = true) { + 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])) + } + } 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 { return new Promise((resolve, reject) => { - ws.on('message', onMessage) - ws.once('error', onError) + const observable = streams.get(ws) as Observable - 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 - 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 { - return new Promise((resolve, reject) => { - function cleanup() { - ws.removeListener('message', onMessage) - ws.removeListener('error', onError) - } +export async function waitForNotice(ws: WebSocket): Promise { + return new Promise((resolve) => { + const observable = streams.get(ws) as Observable - 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 { - return new Promise((resolve, reject) => { - function cleanup() { - ws.removeListener('message', onMessage) - ws.removeListener('error', onError) - } +export async function waitForCommand(ws: WebSocket): Promise { + return new Promise((resolve) => { + const observable = streams.get(ws) as Observable - 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) + }) }) } diff --git a/test/integration/features/nip-01/nip-01.feature b/test/integration/features/nip-01/nip-01.feature index b5fe5c8..084e41c 100644 --- a/test/integration/features/nip-01/nip-01.feature +++ b/test/integration/features/nip-01/nip-01.feature @@ -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 diff --git a/test/integration/features/nip-01/nip-01.feature.ts b/test/integration/features/nip-01/nip-01.feature.ts index 5f71d59..dd75bce 100644 --- a/test/integration/features/nip-01/nip-01.feature.ts +++ b/test/integration/features/nip-01/nip-01.feature.ts @@ -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) diff --git a/test/integration/features/nip-09/nip-09.feature b/test/integration/features/nip-09/nip-09.feature index e954089..663724f 100644 --- a/test/integration/features/nip-09/nip-09.feature +++ b/test/integration/features/nip-09/nip-09.feature @@ -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 diff --git a/test/integration/features/nip-09/nip-09.feature.ts b/test/integration/features/nip-09/nip-09.feature.ts index 50c588d..4c5e55b 100644 --- a/test/integration/features/nip-09/nip-09.feature.ts +++ b/test/integration/features/nip-09/nip-09.feature.ts @@ -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) -}) \ No newline at end of file +}) + +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) +}) diff --git a/test/integration/features/nip-16/event_treatment.feature b/test/integration/features/nip-16/nip-16.feature similarity index 97% rename from test/integration/features/nip-16/event_treatment.feature rename to test/integration/features/nip-16/nip-16.feature index 29a7649..53225b4 100644 --- a/test/integration/features/nip-16/event_treatment.feature +++ b/test/integration/features/nip-16/nip-16.feature @@ -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 diff --git a/test/integration/features/nip-16/event_treatment.ts b/test/integration/features/nip-16/nip-16.feature.ts similarity index 100% rename from test/integration/features/nip-16/event_treatment.ts rename to test/integration/features/nip-16/nip-16.feature.ts diff --git a/test/integration/features/nip-28/nip-28.feature b/test/integration/features/nip-28/nip-28.feature index 2e6a0a0..5de9d33 100644 --- a/test/integration/features/nip-28/nip-28.feature +++ b/test/integration/features/nip-28/nip-28.feature @@ -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\"}' diff --git a/test/integration/features/nip-28/nip-28.ts b/test/integration/features/nip-28/nip-28.feature.ts similarity index 100% rename from test/integration/features/nip-28/nip-28.ts rename to test/integration/features/nip-28/nip-28.feature.ts diff --git a/test/integration/features/shared.ts b/test/integration/features/shared.ts index 7c2fd74..de22ee6 100644 --- a/test/integration/features/shared.ts +++ b/test/integration/features/shared.ts @@ -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>() + +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>, from: string, to: string) { @@ -93,5 +106,20 @@ When(/(\w+) subscribes to author (\w+)$/, async function(this: World((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') }) diff --git a/test/unit/handlers/delegated-event-message-handler.spec.ts b/test/unit/handlers/delegated-event-message-handler.spec.ts index 63ff54b..b8a857e 100644 --- a/test/unit/handlers/delegated-event-message-handler.spec.ts +++ b/test/unit/handlers/delegated-event-message-handler.spec.ts @@ -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 }) diff --git a/test/unit/handlers/event-strategies/delete-event-strategy.spec.ts b/test/unit/handlers/event-strategies/delete-event-strategy.spec.ts index 636f687..bdcb67b 100644 --- a/test/unit/handlers/event-strategies/delete-event-strategy.spec.ts +++ b/test/unit/handlers/event-strategies/delete-event-strategy.spec.ts @@ -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> @@ -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 }) }) diff --git a/test/unit/handlers/event-strategies/ephemeral-event-strategy.spec.ts b/test/unit/handlers/event-strategies/ephemeral-event-strategy.spec.ts index 5bf1f27..5aa3b63 100644 --- a/test/unit/handlers/event-strategies/ephemeral-event-strategy.spec.ts +++ b/test/unit/handlers/event-strategies/ephemeral-event-strategy.spec.ts @@ -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 ) diff --git a/test/unit/handlers/event-strategies/replaceable-event-strategy.spec.ts b/test/unit/handlers/event-strategies/replaceable-event-strategy.spec.ts index c373197..74d1599 100644 --- a/test/unit/handlers/event-strategies/replaceable-event-strategy.spec.ts +++ b/test/unit/handlers/event-strategies/replaceable-event-strategy.spec.ts @@ -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: '] + ) }) }) }) diff --git a/test/unit/repositories/event-repository.spec.ts b/test/unit/repositories/event-repository.spec.ts index ac33f72..a8b3723 100644 --- a/test/unit/repositories/event-repository.spec.ts +++ b/test/unit/repositories/event-repository.spec.ts @@ -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() diff --git a/test/unit/utils/settings.spec.ts b/test/unit/utils/settings.spec.ts index b7e02fc..b42baee 100644 --- a/test/unit/utils/settings.spec.ts +++ b/test/unit/utils/settings.spec.ts @@ -70,7 +70,7 @@ describe('SettingsStatic', () => { let sandbox: Sinon.SinonSandbox beforeEach(() => { - SettingsStatic._settings = undefined + SettingsStatic._settings = undefined as any sandbox = Sinon.createSandbox()