diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index cac0f4f..3655f38 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -27,7 +27,6 @@ jobs: runs-on: ubuntu-latest container: image: node:18-alpine3.16 - needs: [lint] steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 @@ -38,12 +37,11 @@ jobs: run: npm ci - name: Run ESLint run: npm run build - test: - name: Tests + test-and-cover: + name: Unit Tests runs-on: ubuntu-latest container: image: node:18-alpine3.16 - needs: [build] steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 @@ -52,23 +50,9 @@ jobs: cache: npm - name: Install package dependencies run: npm ci - - name: Run tests + - name: Run unit tests run: npm run test:unit - coverage: - name: Coverage - runs-on: ubuntu-latest - container: - image: node:18-alpine3.16 - needs: [build] - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 - with: - node-version-file: .nvmrc - cache: npm - - name: Install package dependencies - run: npm ci - - name: Run coverage + - name: Run coverage for unit tests run: npm run cover - name: Coveralls uses: coverallsapp/github-action@master @@ -76,3 +60,18 @@ jobs: path-to-lcov: ./.coverage/lcov.info flag-name: Unit github-token: ${{ secrets.GITHUB_TOKEN }} + test-integration: + name: Integration Tests + runs-on: ubuntu-latest + container: + image: node:18-alpine3.16 + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version-file: .nvmrc + cache: npm + - name: Install package dependencies + run: npm ci + - name: Run integration tests + run: npm run docker:test:integration diff --git a/cucumber.js b/cucumber.js index 7b8ea0d..f411b26 100644 --- a/cucumber.js +++ b/cucumber.js @@ -1,10 +1,10 @@ const config = [ 'test/integration/features/**/*.feature', '--require-module ts-node/register', - '--require tests/integration/features/**/*.ts', - '--format progress-bar', - '--format json:report.json', - '--publish-quiet', + '--require test/integration/features/**/*.ts', + '--require test/integration/features/*.ts', + '--format @cucumber/pretty-formatter', + '--publish', ].join(' ') module.exports = { diff --git a/docker-compose.yml b/docker-compose.yml index d2bbcaa..3d695d9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,8 @@ services: DB_USER: nostr_ts_relay DB_PASSWORD: nostr_ts_relay DB_NAME: nostr_ts_relay + DB_MIN_POOL_SIZE: 1 + DB_MAX_POOL_SIZE: 2 NOSTR_CONFIG_DIR: /home/node/ user: node:node volumes: diff --git a/knexfile.js b/knexfile.js index e1638e1..f48ac35 100644 --- a/knexfile.js +++ b/knexfile.js @@ -7,7 +7,6 @@ module.exports = { password: process.env.DB_PASSWORD ?? 'postgres', database: process.env.DB_NAME ?? 'nostr-ts-relay', }, - pool: { min: 4, max: 16 }, seeds: { directory: './seeds', }, diff --git a/package-lock.json b/package-lock.json index 83c3150..9a32c5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ }, "devDependencies": { "@cucumber/cucumber": "8.7.0", + "@cucumber/pretty-formatter": "1.0.0", "@types/chai": "^4.3.1", "@types/chai-as-promised": "^7.1.5", "@types/mocha": "^9.1.1", @@ -674,6 +675,34 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/@cucumber/pretty-formatter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@cucumber/pretty-formatter/-/pretty-formatter-1.0.0.tgz", + "integrity": "sha512-wcnIMN94HyaHGsfq72dgCvr1d8q6VGH4Y6Gl5weJ2TNZw1qn2UY85Iki4c9VdaLUONYnyYH3+178YB+9RFe/Hw==", + "dev": true, + "dependencies": { + "ansi-styles": "^5.0.0", + "cli-table3": "^0.6.0", + "figures": "^3.2.0", + "ts-dedent": "^2.0.0" + }, + "peerDependencies": { + "@cucumber/cucumber": ">=7.0.0", + "@cucumber/messages": "*" + } + }, + "node_modules/@cucumber/pretty-formatter/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/@cucumber/tag-expressions": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@cucumber/tag-expressions/-/tag-expressions-4.1.0.tgz", @@ -5104,6 +5133,15 @@ "tree-kill": "cli.js" } }, + "node_modules/ts-dedent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", + "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "dev": true, + "engines": { + "node": ">=6.10" + } + }, "node_modules/ts-node": { "version": "10.9.1", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", @@ -6136,6 +6174,26 @@ } } }, + "@cucumber/pretty-formatter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@cucumber/pretty-formatter/-/pretty-formatter-1.0.0.tgz", + "integrity": "sha512-wcnIMN94HyaHGsfq72dgCvr1d8q6VGH4Y6Gl5weJ2TNZw1qn2UY85Iki4c9VdaLUONYnyYH3+178YB+9RFe/Hw==", + "dev": true, + "requires": { + "ansi-styles": "^5.0.0", + "cli-table3": "^0.6.0", + "figures": "^3.2.0", + "ts-dedent": "^2.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true + } + } + }, "@cucumber/tag-expressions": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@cucumber/tag-expressions/-/tag-expressions-4.1.0.tgz", @@ -9499,6 +9557,12 @@ "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", "dev": true }, + "ts-dedent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", + "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "dev": true + }, "ts-node": { "version": "10.9.1", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", diff --git a/package.json b/package.json index 19b7778..91404c1 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ ], "main": "src/index.ts", "scripts": { - "dev": "ts-node src/index.ts", + "dev": "node -r ts-node/register src/index.ts", "clean": "rimraf ./dist", "build": "tsc --project tsconfig.build.json", "prestart": "npm run build", @@ -35,7 +35,8 @@ "predocker:compose:up": "[ -d \"$HOME/.nostr\" ] || mkdir -p $HOME/.nostr", "docker:compose:up": "docker compose up --build", "docker:compose:down": "docker compose down", - "docker:compose:rm": "docker compose rm" + "docker:compose:rm": "docker compose rm", + "docker:test:integration": "docker compose -f ./test/integration/docker-compose.yml up tests --build --exit-code-from tests" }, "repository": { "type": "git", @@ -53,6 +54,7 @@ "homepage": "https://github.com/Cameri/nostr-ts-relay#readme", "devDependencies": { "@cucumber/cucumber": "8.7.0", + "@cucumber/pretty-formatter": "1.0.0", "@types/chai": "^4.3.1", "@types/chai-as-promised": "^7.1.5", "@types/mocha": "^9.1.1", diff --git a/src/adapters/web-socket-adapter.ts b/src/adapters/web-socket-adapter.ts index 673c524..c0091be 100644 --- a/src/adapters/web-socket-adapter.ts +++ b/src/adapters/web-socket-adapter.ts @@ -1,3 +1,4 @@ +import cluster from 'cluster' import { EventEmitter } from 'stream' import { IncomingMessage as IncomingHttpMessage } from 'http' import { WebSocket } from 'ws' @@ -60,10 +61,12 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter public onBroadcast(event: Event): void { this.webSocketServer.emit(WebSocketServerAdapterEvent.Broadcast, event) - process.send({ - eventName: WebSocketServerAdapterEvent.Broadcast, - event, - }) + if (cluster.isWorker) { + process.send({ + eventName: WebSocketServerAdapterEvent.Broadcast, + event, + }) + } } public onSendEvent(event: Event): void { diff --git a/src/adapters/web-socket-server-adapter.ts b/src/adapters/web-socket-server-adapter.ts index fd8bf26..5ee36e3 100644 --- a/src/adapters/web-socket-server-adapter.ts +++ b/src/adapters/web-socket-server-adapter.ts @@ -44,6 +44,7 @@ export class WebSocketServerAdapter extends WebServerAdapter implements IWebSock } public close(callback: () => void): void { + this.onClose() this.webSocketServer.close(() => { this.webServer.close(callback) }) diff --git a/src/app/worker.ts b/src/app/worker.ts index 9c36fa1..f675cfd 100644 --- a/src/app/worker.ts +++ b/src/app/worker.ts @@ -6,7 +6,7 @@ export class AppWorker implements IRunnable { private readonly process: NodeJS.Process, private readonly adapter: IWebSocketServerAdapter ) { - process + this.process .on('message', this.onMessage.bind(this)) .on('SIGINT', this.onExit.bind(this)) .on('SIGHUP', this.onExit.bind(this)) @@ -34,11 +34,12 @@ export class AppWorker implements IRunnable { private onExit() { console.log(`worker ${process.pid} - exiting`) - this.adapter.close(() => { - // dbClient.destroy(() => { - // process.exit(0) - // }) - process.exit(0) + this.close(() => { + this.process.exit(0) }) } + + public close(callback?: () => void) { + this.adapter.close(callback) + } } diff --git a/src/factories/worker-factory.ts b/src/factories/worker-factory.ts index ef7f1c0..702fe82 100644 --- a/src/factories/worker-factory.ts +++ b/src/factories/worker-factory.ts @@ -9,7 +9,7 @@ import { getDbClient } from '../database/client' import { webSocketAdapterFactory } from './websocket-adapter-factory' import { WebSocketServerAdapter } from '../adapters/web-socket-server-adapter' -export const workerFactory = () => { +export const workerFactory = (): AppWorker => { const dbClient = getDbClient() const eventRepository = new EventRepository(dbClient) diff --git a/src/repositories/event-repository.ts b/src/repositories/event-repository.ts index 8ecc3e1..e02c543 100644 --- a/src/repositories/event-repository.ts +++ b/src/repositories/event-repository.ts @@ -90,7 +90,7 @@ export class EventRepository implements IEventRepository { ) ) ), - }), + } as any), ), ], ])(currentFilter[filterName] as string[]) diff --git a/test/integration/docker-compose.yml b/test/integration/docker-compose.yml new file mode 100644 index 0000000..99a1795 --- /dev/null +++ b/test/integration/docker-compose.yml @@ -0,0 +1,64 @@ +services: + tests: + build: + context: ../../ + dockerfile: Dockerfile.test + env_file: + - ../../test.env + environment: + NOSTR_CONFIG_DIR: /code + volumes: + - ../../src:/code/src + - ../../test:/code/test + working_dir: /code + ports: + - "8008:8008" + command: + ["sh", "-c", "whoami && pwd && ls -hall test/integration && npm run test:integration"] + depends_on: + db-test: + condition: service_healthy + migrations-test: + condition: service_completed_successfully + networks: + - nostr-ts-relay-test + links: + - db-test + db-test: + image: postgres + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: nostr_ts_relay_test + networks: + - nostr-ts-relay-test + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + timeout: 5s + start_period: 5s + retries: 0 + migrations-test: + image: node:18-alpine3.16 + environment: + DB_HOST: db-test + DB_PORT: 5432 + DB_USER: postgres + DB_PASSWORD: postgres + DB_NAME: nostr_ts_relay_test + entrypoint: + - sh + - -c + - 'cd code && npm install -g knex@2.3.0 && npm install knex --quiet && npm run db:migrate' + volumes: + - ../../package.json:/code/package.json + - ../../migrations:/code/migrations + - ../../knexfile.js:/code/knexfile.js + depends_on: + - db-test + networks: + - nostr-ts-relay-test + links: + - db-test + +networks: + nostr-ts-relay-test: diff --git a/test/integration/features/nip-01/nip-01.feature b/test/integration/features/nip-01/nip-01.feature new file mode 100644 index 0000000..b68def6 --- /dev/null +++ b/test/integration/features/nip-01/nip-01.feature @@ -0,0 +1,6 @@ +Feature: NIP-01 + Scenario: Alice posts set_metadata event + Given I am Alice + And I subscribe to author Alice + When I send a set_metadata event as Alice + Then I receive a set_metadata event from Alice diff --git a/test/integration/features/nip-01/nip-01.feature-step.ts b/test/integration/features/nip-01/nip-01.feature-step.ts new file mode 100644 index 0000000..d19a136 --- /dev/null +++ b/test/integration/features/nip-01/nip-01.feature-step.ts @@ -0,0 +1,201 @@ +import * as secp256k1 from '@noble/secp256k1' +import { + After, + Before, + Given, + Then, + When, + World, +} from '@cucumber/cucumber' +import { RawData, WebSocket } from 'ws' +import chai from 'chai' +import { createHmac } from 'crypto' +import sinonChai from 'sinon-chai' + +import { Event } from '../../../../src/@types/event' +import { MessageType } from '../../../../src/@types/messages' +import { serializeEvent } from '../../../../src/utils/event' +import { SubscriptionFilter } from '../../../../src/@types/subscription' + +chai.use(sinonChai) +const { expect } = chai + +Before(async function () { + const ws = new WebSocket('ws://localhost:8008') + this.parameters.ws = ws + await new Promise((resolve, reject) => { + ws + .once('open', resolve) + .once('error', reject) + }) +}) + +After(function () { + const ws = this.parameters.ws as WebSocket + if (ws && ws.readyState === WebSocket.OPEN) { + ws.close() + } +}) + +Given(/I am (\w+)/, function(name: string) { + this.parameters.authors = this.parameters.authors ?? {} + this.parameters.authors[name] = this.parameters.authors[name] ?? createIdentity(name) +}) + +When(/I subscribe to author (\w+)/, async function(this: World>, name: string) { + const ws = this.parameters.ws as WebSocket + const pubkey = this.parameters.authors[name].pubkey + this.parameters.subscriptions = this.parameters.subscriptions ?? [] + const subscription = { name: `test-${Math.random()}`, filters: [{ authors: [pubkey] }] } + this.parameters.subscriptions.push(subscription) + + await createSubscription(ws, subscription.name, subscription.filters) + + await waitForEOSE(ws, subscription.name) +}) + +When(/I send a set_metadata event as (\w+)/, async function(name: string) { + const ws = this.parameters.ws as WebSocket + const { pubkey, privkey } = this.parameters.authors[name] + + const content = JSON.stringify({ name }) + const event: Event = await createEvent({ pubkey, kind: 0, content }, privkey) + + await sendEvent(ws, event) + + this.parameters.events = this.parameters.events ?? [] + this.parameters.events.push(event) +}) + +Then(/I receive a set_metadata event from (\w+)/, async function(author: string) { + const expectedEvent = this.parameters.events.pop() + const subscription = this.parameters.subscriptions[this.parameters.subscriptions.length - 1] + const receivedEvent = await waitForNextEvent(this.parameters.ws, subscription.name) + expect(receivedEvent.pubkey).to.equal(this.parameters.authors[author].pubkey) + expect(receivedEvent).to.deep.equal(expectedEvent) +}) + +async function createEvent(input: Partial, privkey: any): Promise { + const event: Event = { + pubkey: input.pubkey, + kind: input.kind, + created_at: input.created_at ?? Math.floor(Date.now() / 1000), + content: input.content ?? '', + tags: input.tags ?? [], + } as any + + const id = Buffer.from( + await secp256k1.utils.sha256( + Buffer.from(JSON.stringify(serializeEvent(event))) + ) + ).toString('hex') + + const sig = Buffer.from( + await secp256k1.schnorr.sign(id, privkey) + ).toString('hex') + + return { id, ...event, sig } +} + +function createIdentity(name: string) { + const hmac = createHmac('sha256', process.env.SECRET ?? Math.random().toString()) + hmac.update(name) + const privkey = hmac.digest().toString('hex') + const pubkey = Buffer.from(secp256k1.getPublicKey(privkey, true)).toString('hex').substring(2) + const author = { + name, + privkey, + pubkey, + } + return author +} + +async function createSubscription( + ws: WebSocket, + subscriptionName: string, + subscriptionFilters: SubscriptionFilter[], +): Promise { + return new Promise((resolve, reject) => { + const message = JSON.stringify([ + 'REQ', + subscriptionName, + ...subscriptionFilters, + ]) + + ws.send(message, (error: Error) => { + if (error) { + reject(error) + return + } + resolve() + }) + }) +} + +async function waitForEOSE(ws: WebSocket, subscription: string): Promise { + return new Promise((resolve, reject) => { + function cleanup() { + ws.removeListener('message', onMessage) + ws.removeListener('error', onError) + } + + function onError(error: Error) { + reject(error) + cleanup() + } + ws.once('error', onError) + + function onMessage(raw: RawData) { + const message = JSON.parse(raw.toString('utf-8')) + if (message[0] === MessageType.EOSE && message[1] === subscription) { + resolve() + cleanup() + } else if (message[0] === MessageType.NOTICE) { + reject(new Error(message[1])) + cleanup() + } + } + + ws.on('message', onMessage) + }) +} + +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() + }) + }) +} + +async function waitForNextEvent(ws: WebSocket, subscription: string): Promise { + return new Promise((resolve, reject) => { + function cleanup() { + ws.removeListener('message', onMessage) + ws.removeListener('error', onError) + } + + function onError(error: Error) { + reject(error) + cleanup() + } + ws.once('error', onError) + + function onMessage(raw: RawData) { + ws.removeListener('error', onError) + const message = JSON.parse(raw.toString('utf-8')) + if (message[0] === MessageType.EVENT && message[1] === subscription) { + resolve(message[2]) + cleanup() + } else if (message[0] === MessageType.NOTICE) { + reject(new Error(message[1])) + cleanup() + } + } + ws.on('message', onMessage) + }) +} \ No newline at end of file diff --git a/test/integration/features/shared.ts b/test/integration/features/shared.ts new file mode 100644 index 0000000..1d2baaa --- /dev/null +++ b/test/integration/features/shared.ts @@ -0,0 +1,23 @@ +import { AfterAll, BeforeAll } from '@cucumber/cucumber' + +import { AppWorker } from '../../../src/app/worker' +import { DatabaseClient } from '../../../src/@types/base' +import { getDbClient } from '../../../src/database/client' +import { workerFactory } from '../../../src/factories/worker-factory' + +let worker: AppWorker + +let dbClient: DatabaseClient + +BeforeAll({ timeout: 6000 }, async function () { + dbClient = getDbClient() + await dbClient.raw('SELECT 1=1') + worker = workerFactory() + worker.run() +}) + +AfterAll(async function() { + worker.close(async () => { + await dbClient.destroy() + }) +}) diff --git a/tsconfig.json b/tsconfig.json index 7f1c881..4c96e18 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,7 @@ "target": "es6", "outDir": "./dist", "moduleResolution": "Node", - "types": ["node", "mocha"], + "types": ["node", "mocha", "@cucumber/cucumber"], "typeRoots": ["./node_modules/@types"], "incremental": true, "declarationMap": true,