diff --git a/src/controllers/rest-api/full-node/control/controller.js b/src/controllers/rest-api/full-node/control/controller.js new file mode 100644 index 0000000..c07da65 --- /dev/null +++ b/src/controllers/rest-api/full-node/control/controller.js @@ -0,0 +1,68 @@ +/* + REST API Controller for the /full-node/control routes. +*/ + +import wlogger from '../../../../adapters/wlogger.js' + +class ControlRESTController { + constructor (localConfig = {}) { + this.adapters = localConfig.adapters + if (!this.adapters) { + throw new Error( + 'Instance of Adapters library required when instantiating Control REST Controller.' + ) + } + + this.useCases = localConfig.useCases + if (!this.useCases || !this.useCases.control) { + throw new Error( + 'Instance of Control use cases required when instantiating Control REST Controller.' + ) + } + + this.controlUseCases = this.useCases.control + + this.root = this.root.bind(this) + this.getNetworkInfo = this.getNetworkInfo.bind(this) + this.handleError = this.handleError.bind(this) + } + + /** + * @api {get} /full-node/control/ Service status + * @apiName ControlRoot + * @apiGroup Control + * + * @apiDescription Returns the status of the control service. + * + * @apiSuccess {String} status Service identifier + */ + async root (req, res) { + return res.status(200).json({ status: 'control' }) + } + + /** + * @api {get} /full-node/control/getNetworkInfo Get Network Info + * @apiName GetNetworkInfo + * @apiGroup Control + * @apiDescription RPC call that gets basic full node information. + */ + async getNetworkInfo (req, res) { + try { + const result = await this.controlUseCases.getNetworkInfo() + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + handleError (err, res) { + wlogger.error('Error in ControlRESTController:', err) + + const status = err.status || 500 + const message = err.message || 'Internal server error' + + return res.status(status).json({ error: message }) + } +} + +export default ControlRESTController diff --git a/src/controllers/rest-api/full-node/control/index.js b/src/controllers/rest-api/full-node/control/index.js new file mode 100644 index 0000000..f694408 --- /dev/null +++ b/src/controllers/rest-api/full-node/control/index.js @@ -0,0 +1,47 @@ +/* + REST API router for /full-node/control routes. +*/ + +import express from 'express' +import ControlRESTController from './controller.js' + +class ControlRouter { + constructor (localConfig = {}) { + this.adapters = localConfig.adapters + if (!this.adapters) { + throw new Error( + 'Instance of Adapters library required when instantiating Control REST Router.' + ) + } + + this.useCases = localConfig.useCases + if (!this.useCases) { + throw new Error( + 'Instance of Use Cases library required when instantiating Control REST Router.' + ) + } + + const dependencies = { + adapters: this.adapters, + useCases: this.useCases + } + + this.controlController = new ControlRESTController(dependencies) + + this.baseUrl = '/full-node/control' + this.router = express.Router() + } + + attach (app) { + if (!app) { + throw new Error('Must pass app object when attaching REST API controllers.') + } + + this.router.get('/', this.controlController.root) + this.router.get('/getNetworkInfo', this.controlController.getNetworkInfo) + + app.use(this.baseUrl, this.router) + } +} + +export default ControlRouter diff --git a/src/controllers/rest-api/full-node/dsproof/controller.js b/src/controllers/rest-api/full-node/dsproof/controller.js new file mode 100644 index 0000000..b96b0ee --- /dev/null +++ b/src/controllers/rest-api/full-node/dsproof/controller.js @@ -0,0 +1,90 @@ +/* + REST API Controller for the /full-node/dsproof routes. +*/ + +import wlogger from '../../../../adapters/wlogger.js' + +class DSProofRESTController { + constructor (localConfig = {}) { + this.adapters = localConfig.adapters + if (!this.adapters) { + throw new Error( + 'Instance of Adapters library required when instantiating DSProof REST Controller.' + ) + } + + this.useCases = localConfig.useCases + if (!this.useCases || !this.useCases.dsproof) { + throw new Error( + 'Instance of DSProof use cases required when instantiating DSProof REST Controller.' + ) + } + + this.dsproofUseCases = this.useCases.dsproof + + this.root = this.root.bind(this) + this.getDSProof = this.getDSProof.bind(this) + this.handleError = this.handleError.bind(this) + } + + /** + * @api {get} /full-node/dsproof/ Service status + * @apiName DSProofRoot + * @apiGroup DSProof + * + * @apiDescription Returns the status of the dsproof service. + * + * @apiSuccess {String} status Service identifier + */ + async root (req, res) { + return res.status(200).json({ status: 'dsproof' }) + } + + /** + * @api {get} /full-node/dsproof/getDSProof/:txid Get Double-Spend Proof + * @apiName GetDSProof + * @apiGroup DSProof + * @apiDescription Get information for a double-spend proof. + * + * @apiParam {String} txid Transaction ID + * @apiParam {String} verbose Verbose level (`false`, `true`) for compatibility with legacy API + */ + async getDSProof (req, res) { + try { + const txid = req.params.txid + + if (!txid) { + return res.status(400).json({ + success: false, + error: 'txid can not be empty' + }) + } + + if (txid.length !== 64) { + return res.status(400).json({ + success: false, + error: `txid must be of length 64 (not ${txid.length})` + }) + } + + let verbose = 2 + if (req.query.verbose === 'true') verbose = 3 + + const result = await this.dsproofUseCases.getDSProof({ txid, verbose }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + handleError (err, res) { + wlogger.error('Error in DSProofRESTController:', err) + + const status = err.status || 500 + const message = err.message || 'Internal server error' + + return res.status(status).json({ error: message }) + } +} + +export default DSProofRESTController diff --git a/src/controllers/rest-api/full-node/dsproof/index.js b/src/controllers/rest-api/full-node/dsproof/index.js new file mode 100644 index 0000000..3343244 --- /dev/null +++ b/src/controllers/rest-api/full-node/dsproof/index.js @@ -0,0 +1,47 @@ +/* + REST API router for /full-node/dsproof routes. +*/ + +import express from 'express' +import DSProofRESTController from './controller.js' + +class DSProofRouter { + constructor (localConfig = {}) { + this.adapters = localConfig.adapters + if (!this.adapters) { + throw new Error( + 'Instance of Adapters library required when instantiating DSProof REST Router.' + ) + } + + this.useCases = localConfig.useCases + if (!this.useCases) { + throw new Error( + 'Instance of Use Cases library required when instantiating DSProof REST Router.' + ) + } + + const dependencies = { + adapters: this.adapters, + useCases: this.useCases + } + + this.dsproofController = new DSProofRESTController(dependencies) + + this.baseUrl = '/full-node/dsproof' + this.router = express.Router() + } + + attach (app) { + if (!app) { + throw new Error('Must pass app object when attaching REST API controllers.') + } + + this.router.get('/', this.dsproofController.root) + this.router.get('/getDSProof/:txid', this.dsproofController.getDSProof) + + app.use(this.baseUrl, this.router) + } +} + +export default DSProofRouter diff --git a/src/controllers/rest-api/index.js b/src/controllers/rest-api/index.js index efb6923..34a325f 100644 --- a/src/controllers/rest-api/index.js +++ b/src/controllers/rest-api/index.js @@ -8,6 +8,8 @@ // import EventRouter from './event/index.js' // import ReqRouter from './req/index.js' import BlockchainRouter from './full-node/blockchain/index.js' +import ControlRouter from './full-node/control/index.js' +import DSProofRouter from './full-node/dsproof/index.js' import config from '../../config/index.js' class RESTControllers { @@ -49,6 +51,12 @@ class RESTControllers { const blockchainRouter = new BlockchainRouter(dependencies) blockchainRouter.attach(app) + + const controlRouter = new ControlRouter(dependencies) + controlRouter.attach(app) + + const dsproofRouter = new DSProofRouter(dependencies) + dsproofRouter.attach(app) } } diff --git a/src/use-cases/full-node-control-use-cases.js b/src/use-cases/full-node-control-use-cases.js new file mode 100644 index 0000000..511cf65 --- /dev/null +++ b/src/use-cases/full-node-control-use-cases.js @@ -0,0 +1,24 @@ +/* + Use cases for interacting with the BCH full node control RPC interface. +*/ + +class ControlUseCases { + constructor (localConfig = {}) { + this.adapters = localConfig.adapters + + if (!this.adapters) { + throw new Error('Adapters instance required when instantiating Control use cases.') + } + + this.fullNode = this.adapters.fullNode + if (!this.fullNode) { + throw new Error('Full node adapter required when instantiating Control use cases.') + } + } + + async getNetworkInfo () { + return this.fullNode.call('getnetworkinfo') + } +} + +export default ControlUseCases diff --git a/src/use-cases/full-node-dsproof-use-cases.js b/src/use-cases/full-node-dsproof-use-cases.js new file mode 100644 index 0000000..f4fb0cc --- /dev/null +++ b/src/use-cases/full-node-dsproof-use-cases.js @@ -0,0 +1,24 @@ +/* + Use cases for interacting with the BCH full node double-spend proof RPC interface. +*/ + +class DSProofUseCases { + constructor (localConfig = {}) { + this.adapters = localConfig.adapters + + if (!this.adapters) { + throw new Error('Adapters instance required when instantiating DSProof use cases.') + } + + this.fullNode = this.adapters.fullNode + if (!this.fullNode) { + throw new Error('Full node adapter required when instantiating DSProof use cases.') + } + } + + async getDSProof ({ txid, verbose }) { + return this.fullNode.call('getdsproof', [txid, verbose]) + } +} + +export default DSProofUseCases diff --git a/src/use-cases/index.js b/src/use-cases/index.js index fadb53a..d3441a9 100644 --- a/src/use-cases/index.js +++ b/src/use-cases/index.js @@ -6,6 +6,8 @@ // Local libraries import BlockchainUseCases from './full-node-blockchain-use-cases.js' +import ControlUseCases from './full-node-control-use-cases.js' +import DSProofUseCases from './full-node-dsproof-use-cases.js' class UseCases { constructor (localConfig = {}) { @@ -17,6 +19,8 @@ class UseCases { } this.blockchain = new BlockchainUseCases({ adapters: this.adapters }) + this.control = new ControlUseCases({ adapters: this.adapters }) + this.dsproof = new DSProofUseCases({ adapters: this.adapters }) } // Run any startup Use Cases at the start of the app. diff --git a/test/unit/controllers/control-controller-unit.js b/test/unit/controllers/control-controller-unit.js new file mode 100644 index 0000000..a933357 --- /dev/null +++ b/test/unit/controllers/control-controller-unit.js @@ -0,0 +1,88 @@ +/* + Unit tests for ControlRESTController. +*/ + +import { assert } from 'chai' +import sinon from 'sinon' + +import ControlRESTController from '../../../src/controllers/rest-api/full-node/control/controller.js' +import { createMockRequest, createMockResponse } from '../mocks/controller-mocks.js' + +describe('#control-controller.js', () => { + let sandbox + let mockAdapters + let mockUseCases + let uut + + beforeEach(() => { + sandbox = sinon.createSandbox() + mockAdapters = {} + mockUseCases = { + control: { + getNetworkInfo: sandbox.stub().resolves({ version: 1 }) + } + } + + uut = new ControlRESTController({ + adapters: mockAdapters, + useCases: mockUseCases + }) + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('#constructor()', () => { + it('should require adapters', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new ControlRESTController({ useCases: mockUseCases }) + }, /Adapters library required/) + }) + + it('should require control use cases', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new ControlRESTController({ adapters: mockAdapters, useCases: {} }) + }, /Control use cases required/) + }) + }) + + describe('#root()', () => { + it('should return control status', async () => { + const req = createMockRequest() + const res = createMockResponse() + + await uut.root(req, res) + + assert.equal(res.statusValue, 200) + assert.deepEqual(res.jsonData, { status: 'control' }) + }) + }) + + describe('#getNetworkInfo()', () => { + it('should return network info on success', async () => { + const req = createMockRequest() + const res = createMockResponse() + + await uut.getNetworkInfo(req, res) + + assert.equal(res.statusValue, 200) + assert.deepEqual(res.jsonData, { version: 1 }) + }) + + it('should handle errors via handleError', async () => { + const error = new Error('failure') + error.status = 503 + mockUseCases.control.getNetworkInfo.rejects(error) + const req = createMockRequest() + const res = createMockResponse() + + await uut.getNetworkInfo(req, res) + + assert.equal(res.statusValue, 503) + assert.deepEqual(res.jsonData, { error: 'failure' }) + }) + }) +}) diff --git a/test/unit/controllers/dsproof-controller-unit.js b/test/unit/controllers/dsproof-controller-unit.js new file mode 100644 index 0000000..2353ffc --- /dev/null +++ b/test/unit/controllers/dsproof-controller-unit.js @@ -0,0 +1,117 @@ +/* + Unit tests for DSProofRESTController. +*/ + +import { assert } from 'chai' +import sinon from 'sinon' + +import DSProofRESTController from '../../../src/controllers/rest-api/full-node/dsproof/controller.js' +import { createMockRequest, createMockResponse } from '../mocks/controller-mocks.js' + +describe('#dsproof-controller.js', () => { + let sandbox + let mockAdapters + let mockUseCases + let uut + + beforeEach(() => { + sandbox = sinon.createSandbox() + mockAdapters = {} + mockUseCases = { + dsproof: { + getDSProof: sandbox.stub().resolves({ proof: true }) + } + } + + uut = new DSProofRESTController({ + adapters: mockAdapters, + useCases: mockUseCases + }) + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('#constructor()', () => { + it('should require adapters', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new DSProofRESTController({ useCases: mockUseCases }) + }, /Adapters library required/) + }) + + it('should require dsproof use cases', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new DSProofRESTController({ adapters: mockAdapters, useCases: {} }) + }, /DSProof use cases required/) + }) + }) + + describe('#root()', () => { + it('should return dsproof status', async () => { + const req = createMockRequest() + const res = createMockResponse() + + await uut.root(req, res) + + assert.equal(res.statusValue, 200) + assert.deepEqual(res.jsonData, { status: 'dsproof' }) + }) + }) + + describe('#getDSProof()', () => { + it('should validate txid presence', async () => { + const req = createMockRequest() + const res = createMockResponse() + + await uut.getDSProof(req, res) + + assert.equal(res.statusValue, 400) + assert.include(res.jsonData.error, 'txid can not be empty') + }) + + it('should validate txid length', async () => { + const req = createMockRequest({ params: { txid: 'abc' } }) + const res = createMockResponse() + + await uut.getDSProof(req, res) + + assert.equal(res.statusValue, 400) + assert.include(res.jsonData.error, 'txid must be of length 64') + }) + + it('should call use case with derived verbose when valid', async () => { + const txid = 'a'.repeat(64) + const req = createMockRequest({ + params: { txid }, + query: { verbose: 'true' } + }) + const res = createMockResponse() + + await uut.getDSProof(req, res) + + assert.equal(res.statusValue, 200) + assert.isTrue(mockUseCases.dsproof.getDSProof.calledOnceWithExactly({ + txid, + verbose: 3 + })) + assert.deepEqual(res.jsonData, { proof: true }) + }) + + it('should handle errors via handleError', async () => { + const txid = 'a'.repeat(64) + const error = new Error('failure') + error.status = 422 + mockUseCases.dsproof.getDSProof.rejects(error) + const req = createMockRequest({ params: { txid } }) + const res = createMockResponse() + + await uut.getDSProof(req, res) + + assert.equal(res.statusValue, 422) + assert.deepEqual(res.jsonData, { error: 'failure' }) + }) + }) +}) diff --git a/test/unit/controllers/rest-api-index-unit.js b/test/unit/controllers/rest-api-index-unit.js index 17f84ce..4b4a7f6 100644 --- a/test/unit/controllers/rest-api-index-unit.js +++ b/test/unit/controllers/rest-api-index-unit.js @@ -7,6 +7,8 @@ import sinon from 'sinon' import RESTControllers from '../../../src/controllers/rest-api/index.js' import BlockchainRouter from '../../../src/controllers/rest-api/full-node/blockchain/index.js' +import ControlRouter from '../../../src/controllers/rest-api/full-node/control/index.js' +import DSProofRouter from '../../../src/controllers/rest-api/full-node/dsproof/index.js' describe('#controllers/rest-api/index.js', () => { let sandbox @@ -43,7 +45,13 @@ describe('#controllers/rest-api/index.js', () => { } } mockUseCases = { - blockchain: createBlockchainUseCaseStubs() + blockchain: createBlockchainUseCaseStubs(), + control: { + getNetworkInfo: () => {} + }, + dsproof: { + getDSProof: () => {} + } } }) @@ -68,8 +76,10 @@ describe('#controllers/rest-api/index.js', () => { }) describe('#attachRESTControllers()', () => { - it('should instantiate blockchain router and attach to app', () => { - const attachStub = sandbox.stub(BlockchainRouter.prototype, 'attach') + it('should instantiate routers and attach to app', () => { + const blockchainAttachStub = sandbox.stub(BlockchainRouter.prototype, 'attach') + const controlAttachStub = sandbox.stub(ControlRouter.prototype, 'attach') + const dsproofAttachStub = sandbox.stub(DSProofRouter.prototype, 'attach') const restControllers = new RESTControllers({ adapters: mockAdapters, useCases: mockUseCases @@ -78,8 +88,12 @@ describe('#controllers/rest-api/index.js', () => { restControllers.attachRESTControllers(app) - assert.isTrue(attachStub.calledOnce) - assert.equal(attachStub.getCall(0).args[0], app) + assert.isTrue(blockchainAttachStub.calledOnce) + assert.equal(blockchainAttachStub.getCall(0).args[0], app) + assert.isTrue(controlAttachStub.calledOnce) + assert.equal(controlAttachStub.getCall(0).args[0], app) + assert.isTrue(dsproofAttachStub.calledOnce) + assert.equal(dsproofAttachStub.getCall(0).args[0], app) }) }) }) diff --git a/test/unit/use-cases/full-node-control-use-cases-unit.js b/test/unit/use-cases/full-node-control-use-cases-unit.js new file mode 100644 index 0000000..caef4b9 --- /dev/null +++ b/test/unit/use-cases/full-node-control-use-cases-unit.js @@ -0,0 +1,53 @@ +/* + Unit tests for ControlUseCases. +*/ + +import { assert } from 'chai' + +import ControlUseCases from '../../../src/use-cases/full-node-control-use-cases.js' + +describe('#full-node-control-use-cases.js', () => { + let mockAdapters + let uut + + beforeEach(() => { + mockAdapters = { + fullNode: { + call: async () => ({}) + } + } + + uut = new ControlUseCases({ adapters: mockAdapters }) + }) + + describe('#constructor()', () => { + it('should require adapters', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new ControlUseCases() + }, /Adapters instance required/) + }) + + it('should require full node adapter', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new ControlUseCases({ adapters: {} }) + }, /Full node adapter required/) + }) + }) + + describe('#getNetworkInfo()', () => { + it('should call full node adapter with correct method', async () => { + let capturedMethod = '' + mockAdapters.fullNode.call = async method => { + capturedMethod = method + return { version: 1 } + } + + const result = await uut.getNetworkInfo() + + assert.equal(capturedMethod, 'getnetworkinfo') + assert.deepEqual(result, { version: 1 }) + }) + }) +}) diff --git a/test/unit/use-cases/full-node-dsproof-use-cases-unit.js b/test/unit/use-cases/full-node-dsproof-use-cases-unit.js new file mode 100644 index 0000000..36f4fb3 --- /dev/null +++ b/test/unit/use-cases/full-node-dsproof-use-cases-unit.js @@ -0,0 +1,54 @@ +/* + Unit tests for DSProofUseCases. +*/ + +import { assert } from 'chai' + +import DSProofUseCases from '../../../src/use-cases/full-node-dsproof-use-cases.js' + +describe('#full-node-dsproof-use-cases.js', () => { + let mockAdapters + let uut + + beforeEach(() => { + mockAdapters = { + fullNode: { + call: async () => ({}) + } + } + + uut = new DSProofUseCases({ adapters: mockAdapters }) + }) + + describe('#constructor()', () => { + it('should require adapters', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new DSProofUseCases() + }, /Adapters instance required/) + }) + + it('should require full node adapter', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new DSProofUseCases({ adapters: {} }) + }, /Full node adapter required/) + }) + }) + + describe('#getDSProof()', () => { + it('should pass txid and verbose parameters to adapter', async () => { + let capturedArgs = null + mockAdapters.fullNode.call = async (method, params) => { + capturedArgs = { method, params } + return { success: true } + } + + const result = await uut.getDSProof({ txid: 'a'.repeat(64), verbose: 2 }) + + assert.equal(capturedArgs.method, 'getdsproof') + assert.deepEqual(capturedArgs.params, ['a'.repeat(64), 2]) + assert.deepEqual(result, { success: true }) + }) + }) +})