feat: add integration tests w/ docker

This commit is contained in:
Ricardo Arturo Cabral Mejia 2022-10-28 00:44:30 -04:00
parent 82225c47b1
commit 851693a966
No known key found for this signature in database
GPG Key ID: 5931EBF43A650245
16 changed files with 405 additions and 40 deletions

View File

@ -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

View File

@ -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 = {

View File

@ -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:

View File

@ -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',
},

64
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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 {

View File

@ -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)
})

View File

@ -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)
}
}

View File

@ -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)

View File

@ -90,7 +90,7 @@ export class EventRepository implements IEventRepository {
)
)
),
}),
} as any),
),
],
])(currentFilter[filterName] as string[])

View File

@ -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:

View File

@ -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

View File

@ -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<Record<string, any>>, 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<Event>, privkey: any): Promise<Event> {
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<void> {
return new Promise<void>((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<void> {
return new Promise<void>((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<void>((resolve, reject) => {
ws.send(JSON.stringify(['EVENT', event]), (err) => {
if (err) {
reject(err)
return
}
resolve()
})
})
}
async function waitForNextEvent(ws: WebSocket, subscription: string): Promise<Event> {
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)
})
}

View File

@ -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()
})
})

View File

@ -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,