diff --git a/.eslintrc b/.eslintrc index 8b3c6a3..b388f4f 100644 --- a/.eslintrc +++ b/.eslintrc @@ -5,10 +5,12 @@ "es6": true, "node": true }, + "parser": "babel-eslint", "parserOptions": { "ecmaVersion": 9, "sourceType": "module" }, + "ignorePatterns": ["**/test/__fixtures__/**/*"], "rules": { "prettier/prettier": "error", "no-console": 2 diff --git a/README.md b/README.md index c2fd7ae..1f383e7 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,20 @@ Manages a go-ipfs instance maintaining the private network swarm key based on the value from a `dscp-node` instance. +## Local development +> install dependencies +```sh +npm i +``` +> start substrate node using docker-compose +```sh +docker-compose up -d // -d for silent +``` +> start ipfs nodejs wrapper +```sh +npm run dev +``` + ## Environment Variables `dscp-ipfs` is configured primarily using environment variables as follows: diff --git a/app/env.js b/app/env.js index 1c03037..67138b9 100644 --- a/app/env.js +++ b/app/env.js @@ -33,6 +33,8 @@ const vars = envalid.cleanEnv( METADATA_KEY_LENGTH: envalid.num({ default: 32 }), METADATA_VALUE_LITERAL_LENGTH: envalid.num({ default: 32 }), PROCESS_IDENTIFIER_LENGTH: envalid.num({ default: 32 }), + HEALTHCHECK_POLL_PERIOD_MS: envalid.num({ default: 30 * 1000, devDefault: 1000 }), + HEALTHCHECK_TIMEOUT_MS: envalid.num({ default: 2 * 1000, devDefault: 1000 }), }, { strict: true, diff --git a/app/ipfs.js b/app/ipfs.js index 40e3785..4a69c65 100644 --- a/app/ipfs.js +++ b/app/ipfs.js @@ -1,6 +1,7 @@ const { spawn } = require('child_process') const EventEmitter = require('events') +const { ConnectionError } = require('./utils/Errors') const { IPFS_PATH, IPFS_EXECUTABLE, IPFS_ARGS, IPFS_LOG_LEVEL } = require('./env') const logger = require('./logger') @@ -51,6 +52,7 @@ async function setupIpfs() { }) ipfs.on('close', unexpectedCloseListener) + that.ipfs = ipfs }, stop: async () => { logger.info('Stopping IPFS') @@ -82,6 +84,26 @@ async function setupIpfs() { return that } +async function ipfsHealthCheack(api, name = 'ipfs') { + try { + if (!api || !api.pid) throw new ConnectionError({ name }) + const { spawnfile, pid, killed } = api + + return { + name, + status: 'up', + details: { + spawnfile, + pid, + killed, + }, + } + } catch (error) { + return { name, status: 'error', error } + } +} + module.exports = { setupIpfs, + ipfsHealthCheack, } diff --git a/app/keyWatcher/index.js b/app/keyWatcher/index.js index 1807eae..211a108 100644 --- a/app/keyWatcher/index.js +++ b/app/keyWatcher/index.js @@ -1,9 +1,36 @@ const { createNodeApi } = require('./api') const { setupKeyWatcher } = require('./keyWatcher') +const { ConnectionError } = require('../utils/Errors') module.exports = { setupKeyWatcher: async ({ onUpdate }) => { const api = await createNodeApi() await setupKeyWatcher(api)({ onUpdate }) + return api + }, + nodeHealthCheck: async (api, name = 'substrate') => { + try { + if (!(await api._isConnected)) throw new ConnectionError({ name }) + const [chain, runtime] = await Promise.all([api._runtimeChain, api._runtimeVersion]) + + return { + name, + status: 'up', + details: { + chain, + runtime: { + name: runtime.specName, + versions: { + spec: runtime.specVersion.toNumber(), + impl: runtime.implVersion.toNumber(), + authoring: runtime.authoringVersion.toNumber(), + transaction: runtime.transactionVersion.toNumber(), + }, + }, + }, + } + } catch (error) { + return { name, status: 'error', error } + } }, } diff --git a/app/server.js b/app/server.js index f7cd51e..d9e1f55 100644 --- a/app/server.js +++ b/app/server.js @@ -3,28 +3,45 @@ const pinoHttp = require('pino-http') const { PORT } = require('./env') const logger = require('./logger') -const { setupKeyWatcher } = require('./keyWatcher') -const { setupIpfs } = require('./ipfs') +const { setupKeyWatcher, nodeHealthCheck } = require('./keyWatcher') +const { setupIpfs, ipfsHealthCheack } = require('./ipfs') +const ServiceWatcher = require('./utils/ServiceWatcher') async function createHttpServer() { const app = express() const requestLogger = pinoHttp({ logger }) const ipfs = await setupIpfs() - await setupKeyWatcher({ + const nodeApi = await setupKeyWatcher({ onUpdate: async (value) => { await ipfs.stop() await ipfs.start({ swarmKey: value }) }, }) - await app.use((req, res, next) => { + // setup service watcher + // TODO add methdo foro addng service watcher so it can be done + // by calling sw.addService + const sw = new ServiceWatcher({ + substrate: { + ...nodeApi._api, + healthCheck: nodeHealthCheck, + }, + ipfs: { + ...ipfs, + healthCheck: ipfsHealthCheack, + }, + }) + + app.use((req, res, next) => { if (req.path !== '/health') requestLogger(req, res) next() }) app.get('/health', async (req, res) => { - res.status(200).send({ status: 'ok' }) + const statusCode = Object.values(sw.report).some((srv) => ['down', 'error'].includes(srv.status)) ? 503 : 200 + + res.status(statusCode).send(sw.report) }) // Sorry - app.use checks arity @@ -38,35 +55,22 @@ async function createHttpServer() { } }) - return { app, ipfs } + return { app, ipfs, sw } } /* istanbul ignore next */ async function startServer() { try { - const { app, ipfs } = await createHttpServer() - + const { app, ipfs, sw } = await createHttpServer() const server = await new Promise((resolve, reject) => { - let resolved = false const server = app.listen(PORT, (err) => { - if (err) { - if (!resolved) { - resolved = true - reject(err) - } - } + if (err) return reject(err) logger.info(`Listening on port ${PORT} `) - if (!resolved) { - resolved = true - resolve(server) - } - }) - server.on('error', (err) => { - if (!resolved) { - resolved = true - reject(err) - } + resolve(server) + sw.start() }) + + server.on('error', (err) => reject(err)) }) const closeHandler = (exitCode) => async () => { diff --git a/app/utils/Errors.js b/app/utils/Errors.js new file mode 100644 index 0000000..d68aedc --- /dev/null +++ b/app/utils/Errors.js @@ -0,0 +1,21 @@ +class TimeoutError extends Error { + constructor(service) { + super() + this.type = this.constructor.name + this.service = service.name + this.message = 'Timeout error, no response from a service' + } +} + +class ConnectionError extends Error { + constructor(service) { + super() + this.service = service.name + this.message = 'Connection is not established, will retry during next polling cycle' + } +} + +module.exports = { + TimeoutError, + ConnectionError, +} diff --git a/app/utils/ServiceWatcher.js b/app/utils/ServiceWatcher.js new file mode 100644 index 0000000..ab33fb5 --- /dev/null +++ b/app/utils/ServiceWatcher.js @@ -0,0 +1,81 @@ +const { TimeoutError } = require('./Errors') +const { HEALTHCHECK_POLL_PERIOD_MS, HEALTHCHECK_TIMEOUT_MS } = require('../env') + +class ServiceWatcher { + #pollPeriod + #timeout + + // TODO add a method for updating this.services + constructor(apis) { + this.report = {} + this.#pollPeriod = HEALTHCHECK_POLL_PERIOD_MS + this.#timeout = HEALTHCHECK_TIMEOUT_MS + this.services = this.#init(apis) + } + + delay(ms, service = false) { + return new Promise((resolve, reject) => { + setTimeout(() => (service ? reject(new TimeoutError(service)) : resolve()), ms) + }) + } + + update(name, details = 'unknown') { + if (!name || typeof name !== 'string') return null // some handling + if (this.report[name] === details) return null // no need to update + + this.report = { + ...this.report, + [name]: details, + } + } + + // organize services and store in this.services + #init(services) { + return Object.keys(services) + .map((service) => { + const { healthCheck, ...api } = services[service] + return healthCheck + ? { + name: service, + poll: () => healthCheck(api, service), + } + : null + }) + .filter(Boolean) + } + + // fire and forget, cancel using ServiceWatcher.gen.return() + // or ServiceWatcher.gen.throw() + start() { + if (this.services.length < 1) return null + this.gen = this.#generator() + + const recursive = async (getAll = Promise.resolve([])) => { + try { + const services = await getAll + services.forEach(({ name, ...rest }) => this.update(name, rest)) + await this.delay(this.#pollPeriod) + } catch (error) { + // if no service assume that this is server error e.g. TypeError, Parse... + const name = error.service || 'server' + this.update(name, { error, status: 'error' }) + } + + const { value } = this.gen.next() + recursive(value) + } + + const { value } = this.gen.next() + recursive(value) + } + + // a generator function that returns poll fn for each service + *#generator() { + while (true) + yield Promise.all( + this.services.map((service) => Promise.race([service.poll(), this.delay(this.#timeout, service)])) + ) + } +} + +module.exports = ServiceWatcher diff --git a/helm/dscp-ipfs/Chart.yaml b/helm/dscp-ipfs/Chart.yaml index ecee30d..55c9085 100644 --- a/helm/dscp-ipfs/Chart.yaml +++ b/helm/dscp-ipfs/Chart.yaml @@ -1,8 +1,8 @@ apiVersion: v2 name: dscp-ipfs -appVersion: '2.0.2' +appVersion: '2.0.3' description: A Helm chart for dscp-ipfs -version: '2.0.2' +version: '2.0.3' type: application dependencies: - name: dscp-node diff --git a/helm/dscp-ipfs/values.yaml b/helm/dscp-ipfs/values.yaml index dac94fb..569c45a 100644 --- a/helm/dscp-ipfs/values.yaml +++ b/helm/dscp-ipfs/values.yaml @@ -33,7 +33,7 @@ config: image: repository: ghcr.io/digicatapult/dscp-ipfs pullPolicy: IfNotPresent - tag: 'v2.0.2' + tag: 'v2.0.3' storage: storageClass: "" diff --git a/package-lock.json b/package-lock.json index 782e8c5..84c11d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@digicatapult/dscp-ipfs", - "version": "2.0.2", + "version": "2.0.3", "lockfileVersion": 2, "requires": true, "packages": { @@ -17,6 +17,7 @@ "pino-http": "^5.5.0" }, "devDependencies": { + "babel-eslint": "^10.1.0", "chai": "^4.3.1", "delay": "^5.0.0", "depcheck": "^1.4.0", @@ -1340,6 +1341,36 @@ "node": ">=8.0.0" } }, + "node_modules/babel-eslint": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/babel-eslint/-/babel-eslint-10.1.0.tgz", + "integrity": "sha512-ifWaTHQ0ce+448CYop8AdrQiBsGrnC+bMgfyKFdi6EsPLTAWG+QfyDeM6OH+FmWnKvEq5NnBMLvlBUPKQZoDSg==", + "deprecated": "babel-eslint is now @babel/eslint-parser. This package will no longer receive updates.", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.0.0", + "@babel/parser": "^7.7.0", + "@babel/traverse": "^7.7.0", + "@babel/types": "^7.7.0", + "eslint-visitor-keys": "^1.0.0", + "resolve": "^1.12.0" + }, + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "eslint": ">= 4.12.1" + } + }, + "node_modules/babel-eslint/node_modules/eslint-visitor-keys": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -8475,6 +8506,28 @@ "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==" }, + "babel-eslint": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/babel-eslint/-/babel-eslint-10.1.0.tgz", + "integrity": "sha512-ifWaTHQ0ce+448CYop8AdrQiBsGrnC+bMgfyKFdi6EsPLTAWG+QfyDeM6OH+FmWnKvEq5NnBMLvlBUPKQZoDSg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/parser": "^7.7.0", + "@babel/traverse": "^7.7.0", + "@babel/types": "^7.7.0", + "eslint-visitor-keys": "^1.0.0", + "resolve": "^1.12.0" + }, + "dependencies": { + "eslint-visitor-keys": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "dev": true + } + } + }, "balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", diff --git a/package.json b/package.json index 7b65ff9..4693f4f 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "@digicatapult/dscp-ipfs", - "version": "2.0.2", + "version": "2.0.3", "description": "Service for WASP", "main": "app/index.js", "scripts": { "test": "NODE_ENV=test mocha --config ./test/mocharc.js ./test", "test:integration": "NODE_ENV=test mocha --config ./test/mocharc.js ./test/integration", - "test:unit": "NODE_ENV=test mocha --config ./test/mocharc-unit.js ./test", + "test:unit": "NODE_ENV=test mocha --config ./test/mocharc-unit.js ./test/unit", "lint": "eslint .", "depcheck": "depcheck", "start": "node app/index.js", @@ -39,6 +39,7 @@ "pino-http": "^5.5.0" }, "devDependencies": { + "babel-eslint": "^10.1.0", "chai": "^4.3.1", "delay": "^5.0.0", "depcheck": "^1.4.0", diff --git a/test/__fixtures__/ipfs-api-fn.js b/test/__fixtures__/ipfs-api-fn.js new file mode 100644 index 0000000..9f42427 --- /dev/null +++ b/test/__fixtures__/ipfs-api-fn.js @@ -0,0 +1,10 @@ +const { ipfsHealthCheack } = require('../../app/ipfs'); + +module.exports = { + available: { + pid: 10, + spawnfile: '/path/to/file/test/spawn.key', + killed: false, + healthCheck: ipfsHealthCheack, + } +} \ No newline at end of file diff --git a/test/__fixtures__/substrate-node-api-fn.js b/test/__fixtures__/substrate-node-api-fn.js new file mode 100644 index 0000000..847592f --- /dev/null +++ b/test/__fixtures__/substrate-node-api-fn.js @@ -0,0 +1,77 @@ +const { nodeHealthCheck } = require("../../app/keyWatcher") + +module.exports = { + timeout: { + get _isConnected() { + return new Promise(r => setTimeout(r, 5000)) + }, + healthCheck: nodeHealthCheck, + }, + unavailable: { + get _isConnected() { + return false + }, + healthCheck: nodeHealthCheck, + }, + available: { + healthCheck: nodeHealthCheck, + get _isConnected() { + return true + }, + get _runtimeChain() { + return "Test" + }, + get _runtimeVersion() { + return { + specName: "dscp-node", + implName: "dscp-node", + authoringVersion: 1, + specVersion: 300, + implVersion: 1, + apis: [ + [ + "0xdf6acb689907609b", + 3 + ], + [ + "0x37e397fc7c91f5e4", + 1 + ], + [ + "0x40fe3ad401f8959a", + 4 + ], + [ + "0xd2bc9897eed08f15", + 2 + ], + [ + "0xf78b278be53f454c", + 2 + ], + [ + "0xdd718d5cc53262d4", + 1 + ], + [ + "0xab3c0572291feb8b", + 1 + ], + [ + "0xed99c5acb25eedf5", + 2 + ], + [ + "0xbc9d89904f5b923f", + 1 + ], + [ + "0x37c8bb1350a9a2a8", + 1 + ] + ], + "transactionVersion": 1 + } + } + } +} \ No newline at end of file diff --git a/test/integration/helper/server.js b/test/integration/helper/server.js index c501c2f..e5e76e5 100644 --- a/test/integration/helper/server.js +++ b/test/integration/helper/server.js @@ -1,5 +1,4 @@ const { createHttpServer } = require('../../../app/server') - const logger = require('../../../app/logger') const { PORT } = require('../../../app/env') @@ -9,7 +8,7 @@ async function startServer(context) { context.ipfs = create.ipfs context.server = create.app.listen(PORT, (err) => { if (err) { - logger.error('Error starting app:', err) + logger.error('Error starting app:', err) reject(err) } else { logger.info(`Server is listening on port ${PORT}`) diff --git a/test/integration/httpServer.test.js b/test/integration/httpServer.test.js index fbd98b0..12924ad 100644 --- a/test/integration/httpServer.test.js +++ b/test/integration/httpServer.test.js @@ -4,7 +4,7 @@ const fetch = require('node-fetch') const { PORT } = require('../../app/env') -describe('health', function () { +describe('health checks', function () { const context = {} before(async function () { @@ -12,11 +12,7 @@ describe('health', function () { context.body = await context.response.json() }) - it('should return 200', function () { + it('returns 200 along with the report', () => { expect(context.response.status).to.equal(200) }) - - it('should return success', function () { - expect(context.body).to.deep.equal({ status: 'ok' }) - }) }) diff --git a/test/test.env b/test/test.env index dc4dbb3..b0cda9b 100644 --- a/test/test.env +++ b/test/test.env @@ -1,3 +1,5 @@ LOG_LEVEL=fatal IPFS_LOG_LEVEL=fatal NODE_HOST=localhost +HEALTHCHECK_POLL_PERIOD_MS=1000 +HEALTHCHECK_TIMEOUT_MS=1000 diff --git a/test/unit/ServiceWatcher.test.js b/test/unit/ServiceWatcher.test.js new file mode 100644 index 0000000..046b126 --- /dev/null +++ b/test/unit/ServiceWatcher.test.js @@ -0,0 +1,284 @@ +const { describe, it } = require('mocha') +const { expect } = require('chai') +const { spy } = require('sinon') + +const substrate = require('../__fixtures__/substrate-node-api-fn') +const ipfs = require('../__fixtures__/ipfs-api-fn') +const ServiceWatcher = require('../../app/utils/ServiceWatcher') +const { TimeoutError, ConnectionError } = require('../../app/utils/Errors') + +const connectionErrorMsg = 'Connection is not established, will retry during next polling cycle' + +describe('ServiceWatcher', function () { + this.timeout(5000) + + let SW + Number.prototype.toNumber = function () { + return parseInt(this) + } + beforeEach(() => { + SW = new ServiceWatcher({ substrate: substrate.available }) + }) + + afterEach(() => { + SW.gen?.return() + }) + + describe('delay method', () => { + it('rejects with TimeoutError if second argument is supplied', () => { + return SW.delay(10, { name: 'test' }) + .then((res) => { + throw new Error('was not supposed to succeed', res) + }) + .catch((err) => { + expect(err.message).to.be.equal('Timeout error, no response from a service') + }) + }) + + it('delays and resolves a promise without a result', async () => { + const result = await SW.delay(10) + expect(result).to.be.undefined + }) + }) + + describe('update method', () => { + describe('if invalid arguments provided', () => { + const invalidTypes = [[1, 2], 1, {}] + + it('returns null if first argument is not supplied and does not update report', () => { + SW.update() + expect(SW.report).to.deep.equal({}) + }) + + invalidTypes.forEach((type) => { + const typeText = type instanceof Array ? 'array' : null || typeof type + it(`also if first arguments is of a type: ${typeText}`, () => { + SW.update(type) + expect(SW.report).to.deep.equals({}) + }) + }) + }) + + it('updates this.report with supplied details', () => { + const details = { a: 'a', b: 'b', c: [] } + SW.update('test', details) + + expect(SW.report).to.deep.equal({ + test: details, + }) + }) + + it('sets details - unknown if second argumnent is not provided', () => { + SW.update('test-no-details') + + expect(SW.report).to.deep.equal({ + 'test-no-details': 'unknown', + }) + }) + }) + + describe('init method', () => { + it('returns an array of services with polling functions', () => { + SW = new ServiceWatcher({ substrate: substrate.available }) + expect(SW.services.length).to.equal(1) + expect(SW.services[0]).to.include({ + name: 'substrate', + }) + expect(SW.services[0].poll).to.be.a('function') + }) + + it('does not include services that do not have a polling function', () => { + SW = new ServiceWatcher({ service1: {}, service2: {} }) + expect(SW.services.length).to.equal(0) + }) + }) + + describe('if invalid argument supplied to constructor', () => { + beforeEach(async () => { + SW = new ServiceWatcher('some-test-data') + SW.start() + }) + + it('does not add to the services array', () => { + expect(SW.services).to.deep.equal([]) + }) + + it('does not create a new instance of generator', () => { + expect(SW.gen).to.be.undefined + }) + + it('and has nothing to report', () => { + expect(SW.report).to.deep.equal({}) + }) + }) + + describe('ipfs - service check', () => { + beforeEach(async () => { + SW = new ServiceWatcher({ ipfs: ipfs.available }) + SW.start() + await SW.delay(1000) + }) + + // TODO more coverage for ipfs + it('persist ipfs status and details in this.report object', () => { + expect(SW.report) // prettier-ignore + .to.have.property('ipfs') + .that.includes.all.keys('status', 'details') + .that.deep.equal({ + status: 'up', + details: { + killed: false, + pid: 10, + spawnfile: '/path/to/file/test/spawn.key', + }, + }) + }) + }) + + describe('substrate - service checks', () => { + beforeEach(() => { + SW = new ServiceWatcher({ substrate: substrate.available }) + }) + + describe('when service is unavailable', () => { + beforeEach(async () => { + SW = new ServiceWatcher({ substrate: substrate.unavailable }) + spy(SW, 'update') + SW.start() + await SW.delay(1500) + SW.gen.return() + }) + + it('creates an instance of ConnectionError', () => { + expect(SW.report.substrate) // prettier-ignore + .to.have.property('error') + .that.is.a.instanceOf(ConnectionError) + }) + + it('reflects status in this.report object with error message', () => { + expect(SW.report) // prettier-ignore + .to.have.property('substrate') + .that.includes.all.keys('status', 'error') + .that.deep.contain({ status: 'error' }) + expect(SW.report.substrate.error) // prettier-ignore + .to.have.all.keys('message', 'service') + .that.contains({ + message: connectionErrorMsg, + }) + }) + + it('does not stop polling', () => { + expect(SW.update.getCall(0).args[0]).to.equal('substrate') + expect(SW.update.getCall(1).args[0]).to.equal('substrate') + expect(SW.update.getCall(1).args[1]) + .to.have.property('error') + .that.have.all.keys('message', 'service') + .that.contains({ + message: connectionErrorMsg, + service: 'substrate', + }) + }) + }) + + describe('and reports correctly when service status changes', () => { + beforeEach(async () => { + SW = new ServiceWatcher({ substrate: substrate.unavailable }) + spy(SW, 'update') + SW.start() + await SW.delay(1000) + SW.services = [ + { + name: 'substrate', + poll: () => substrate.available.healthCheck(substrate.available, 'substrate'), + }, + ] + await SW.delay(1000) + }) + + it('handles correctly unavalaible service', () => { + expect(SW.update.getCall(0).args[0]).to.equal('substrate') + expect(SW.update.getCall(0).args[1]) // prettier-ignore + .to.include.all.keys('error', 'status') + .that.property('error') + .contains({ + message: connectionErrorMsg, + service: 'substrate', + }) + }) + + it('updates this.report indicating that service is available', () => { + expect(SW.update.callCount).to.equal(2) + expect(SW.update.getCall(1).args).to.deep.equal([ + 'substrate', + { + status: 'up', + details: { + chain: 'Test', + runtime: { + name: 'dscp-node', + versions: { + authoring: 1, + impl: 1, + spec: 300, + transaction: 1, + }, + }, + }, + }, + ]) + }) + }) + + describe('if it hits timeout first', () => { + beforeEach(async () => { + SW = new ServiceWatcher({ substrate: substrate.timeout }) + spy(SW, 'update') + SW.start() // using await so it hits timeout + await SW.delay(3000) + }) + + it('creates an instace of timeout error with error message', () => { + const { error } = SW.report.substrate + + expect(error).to.be.a.instanceOf(TimeoutError) + expect(error.message).to.equal('Timeout error, no response from a service') + }) + + it('updates this.report with new status and error object', () => { + expect(SW.report) // prettier-ignore + .to.have.property('substrate') + .that.includes.all.keys('status', 'error') + .that.deep.contain({ status: 'error' }) + }) + + it('continues polling', () => { + expect(SW.update.callCount).to.equal(2) + }) + }) + + it('persists substrate node status and details in this.report', async () => { + SW.start() + await SW.delay(2000) + SW.gen.return() + + expect(SW.report) // prettier-ignore + .to.have.property('substrate') + .that.includes.all.keys('status', 'details') + .that.deep.equal({ + status: 'up', // TODO implement snapshot assertation for mocha + details: { + chain: 'Test', + runtime: { + name: 'dscp-node', + versions: { + authoring: 1, + impl: 1, + spec: 300, + transaction: 1, + }, + }, + }, + }) + }) + }) +})