From 3fff9000bfd7d0a3512de9f81449d6c0cb8b9e3d Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Sun, 9 Nov 2025 19:18:39 -0800 Subject: [PATCH 1/3] feat(blockchain): Adding blockchain full-node endpoints --- .env-local | 4 + apidoc.json | 10 +- package-lock.json | 104 +++- package.json | 1 + src/adapters/full-node-rpc.js | 134 +++++ src/adapters/index.js | 3 + src/config/env/common.js | 12 + .../full-node/blockchain/controller.js | 553 ++++++++++++++++++ .../rest-api/full-node/blockchain/index.js | 66 +++ src/controllers/rest-api/index.js | 12 +- src/use-cases/blockchain/index.js | 134 +++++ src/use-cases/index.js | 2 + 12 files changed, 1024 insertions(+), 11 deletions(-) create mode 100644 .env-local create mode 100644 src/adapters/full-node-rpc.js create mode 100644 src/controllers/rest-api/full-node/blockchain/controller.js create mode 100644 src/controllers/rest-api/full-node/blockchain/index.js create mode 100644 src/use-cases/blockchain/index.js diff --git a/.env-local b/.env-local new file mode 100644 index 0000000..36974a9 --- /dev/null +++ b/.env-local @@ -0,0 +1,4 @@ +# Full Node Connection +RPC_BASEURL=http://172.17.0.1:8332 +RPC_USERNAME=bitcoin +RPC_PASSWORD=password diff --git a/apidoc.json b/apidoc.json index d2eba9d..b7c7631 100644 --- a/apidoc.json +++ b/apidoc.json @@ -1,9 +1,9 @@ { - "name": "REST2NOSTR Proxy API", + "name": "psf-bch-api REST API", "version": "1.0.0", - "description": "REST API proxy for Nostr WebSocket protocol", - "title": "REST2NOSTR Proxy API", - "url": "https://nostr-relay-api.psfoundation.info", - "sampleUrl": "https://nostr-relay-api.psfoundation.info" + "description": "REST API proxy to Bitcoin Cash infrastructure", + "title": "psf-bch-api REST API", + "url": "http://localhost:5942", + "sampleUrl": "http://localhost:5942" } diff --git a/package-lock.json b/package-lock.json index e2ebd7f..ce90e62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "axios": "1.7.7", "cors": "2.8.5", "dotenv": "16.3.1", "express": "5.1.0", @@ -1469,6 +1470,12 @@ "node": ">= 0.4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -1485,6 +1492,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axios": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", + "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1938,6 +1956,18 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", @@ -2156,6 +2186,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2458,7 +2497,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3352,6 +3390,26 @@ "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", "license": "MIT" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -3385,6 +3443,43 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3759,7 +3854,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -5849,6 +5943,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", diff --git a/package.json b/package.json index 709d1c8..cb67874 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "license": "MIT", "description": "REST API proxy to Bitcoin Cash infrastructure", "dependencies": { + "axios": "1.7.7", "cors": "2.8.5", "dotenv": "16.3.1", "express": "5.1.0", diff --git a/src/adapters/full-node-rpc.js b/src/adapters/full-node-rpc.js new file mode 100644 index 0000000..c355e85 --- /dev/null +++ b/src/adapters/full-node-rpc.js @@ -0,0 +1,134 @@ +/* + Adapter library for interacting with a BCH full node over JSON-RPC. +*/ + +import axios from 'axios' +import wlogger from './wlogger.js' +import config from '../config/index.js' + +class FullNodeRPCAdapter { + constructor (localConfig = {}) { + this.config = localConfig.config || config + + if (!this.config.fullNode || !this.config.fullNode.rpcBaseUrl) { + throw new Error('Full node RPC configuration is required') + } + + const { + rpcBaseUrl, + rpcUsername, + rpcPassword, + rpcTimeoutMs = 15000 + } = this.config.fullNode + console.log('this.config.fullNode', this.config.fullNode) + + this.requestIdPrefix = this.config.fullNode.rpcRequestIdPrefix || 'psf-bch-api' + + this.http = axios.create({ + baseURL: rpcBaseUrl, + timeout: rpcTimeoutMs, + auth: { + username: rpcUsername, + password: rpcPassword + } + }) + + this.defaultRequestPayload = { + jsonrpc: '1.0' + } + } + + async call (method, params = [], requestId) { + const id = requestId || `${this.requestIdPrefix}-${method}` + + try { + const response = await this.http.post('', { + ...this.defaultRequestPayload, + id, + method, + params + }) + + if (response.data && response.data.error) { + const rpcError = this._formatError(response.data.error.message, 400) + throw rpcError + } + + return response.data.result + } catch (err) { + throw this._handleError(err) + } + } + + _handleError (err) { + const { status, message } = this.decodeError(err) + const error = new Error(message) + error.status = status + error.originalError = err + return error + } + + decodeError (err) { + try { + if ( + err.response && + err.response.data && + err.response.data.error && + err.response.data.error.message + ) { + return this._formatError(err.response.data.error.message, 400) + } + + if (err.response && err.response.data) { + return this._formatError(err.response.data, err.response.status || 500) + } + + if (err.message) { + if (err.message.includes('ENOTFOUND') || err.message.includes('ENETUNREACH') || err.message.includes('EAI_AGAIN')) { + return this._formatError( + 'Network error: Could not communicate with full node or other external service.', + 503 + ) + } + } + + if (err.code && (err.code === 'ECONNABORTED' || err.code === 'ECONNREFUSED')) { + return this._formatError( + 'Network error: Could not communicate with full node or other external service.', + 503 + ) + } + + if (err.error && typeof err.error === 'string' && err.error.includes('429')) { + return this._formatError('429 Too Many Requests', 429) + } + + if (err.message) { + return this._formatError(err.message, err.status || 422) + } + + return this._formatError('Unhandled full node error', 500) + } catch (decodeError) { + wlogger.error('Unhandled error in FullNodeRPCAdapter.decodeError()', decodeError) + return this._formatError('Internal server error', 500) + } + } + + validateArraySize (length, options = {}) { + const { isProUser = false } = options + const freemiumLimit = Number(this.config.fullNode?.freemiumArrayLimit || 20) + const proLimit = Number(this.config.fullNode?.proArrayLimit || freemiumLimit) + + const limit = isProUser ? proLimit : freemiumLimit + return length <= limit + } + + _formatError (message, status = 500) { + return { + message: message || 'Internal server error', + status: status || 500 + } + } +} + +export default FullNodeRPCAdapter diff --git a/src/adapters/index.js b/src/adapters/index.js index 7ea8a21..ffabb9d 100644 --- a/src/adapters/index.js +++ b/src/adapters/index.js @@ -6,6 +6,7 @@ // Load individual adapter libraries. // import NostrRelayAdapter from './nostr-relay.js' +import FullNodeRPCAdapter from './full-node-rpc.js' import config from '../config/index.js' class Adapters { @@ -30,6 +31,8 @@ class Adapters { // Maintain backward compatibility: expose first relay as nostrRelay // This allows existing code to work during transition // this.nostrRelay = this.nostrRelays[0] + + this.fullNode = new FullNodeRPCAdapter({ config: this.config }) } async start () { diff --git a/src/config/env/common.js b/src/config/env/common.js index 2b39799..07dd8a1 100644 --- a/src/config/env/common.js +++ b/src/config/env/common.js @@ -3,10 +3,13 @@ environments. */ +import dotenv from 'dotenv' + // Hack to get __dirname back. // https://blog.logrocket.com/alternatives-dirname-node-js-es-modules/ import * as url from 'url' import { readFileSync } from 'fs' +dotenv.config() const __dirname = url.fileURLToPath(new URL('.', import.meta.url)) const pkgInfo = JSON.parse(readFileSync(`${__dirname.toString()}/../../../package.json`)) @@ -47,6 +50,15 @@ export default { return ['wss://nostr-relay.psfoundation.info', 'wss://relay.damus.io'] })(), + // Full node RPC configuration + fullNode: { + rpcBaseUrl: process.env.RPC_BASEURL || 'http://127.0.0.1:8332', + rpcUsername: process.env.RPC_USERNAME || '', + rpcPassword: process.env.RPC_PASSWORD || '', + rpcTimeoutMs: Number(process.env.RPC_TIMEOUT_MS || 15000), + rpcRequestIdPrefix: process.env.RPC_REQUEST_ID_PREFIX || 'psf-bch-api' + }, + // Version version } diff --git a/src/controllers/rest-api/full-node/blockchain/controller.js b/src/controllers/rest-api/full-node/blockchain/controller.js new file mode 100644 index 0000000..f31c51e --- /dev/null +++ b/src/controllers/rest-api/full-node/blockchain/controller.js @@ -0,0 +1,553 @@ +/* + REST API Controller for the /full-node/blockchain routes. +*/ + +import wlogger from '../../../../adapters/wlogger.js' + +class BlockchainRESTController { + constructor (localConfig = {}) { + this.adapters = localConfig.adapters + if (!this.adapters) { + throw new Error( + 'Instance of Adapters library required when instantiating Blockchain REST Controller.' + ) + } + + this.useCases = localConfig.useCases + if (!this.useCases || !this.useCases.blockchain) { + throw new Error( + 'Instance of Blockchain use cases required when instantiating Blockchain REST Controller.' + ) + } + + this.blockchainUseCases = this.useCases.blockchain + + // Bind functions + this.root = this.root.bind(this) + this.getBestBlockHash = this.getBestBlockHash.bind(this) + this.getBlockchainInfo = this.getBlockchainInfo.bind(this) + this.getBlockCount = this.getBlockCount.bind(this) + this.getBlockHeaderSingle = this.getBlockHeaderSingle.bind(this) + this.getBlockHeaderBulk = this.getBlockHeaderBulk.bind(this) + this.getChainTips = this.getChainTips.bind(this) + this.getDifficulty = this.getDifficulty.bind(this) + this.getMempoolEntrySingle = this.getMempoolEntrySingle.bind(this) + this.getMempoolEntryBulk = this.getMempoolEntryBulk.bind(this) + this.getMempoolAncestorsSingle = this.getMempoolAncestorsSingle.bind(this) + this.getMempoolInfo = this.getMempoolInfo.bind(this) + this.getRawMempool = this.getRawMempool.bind(this) + this.getTxOut = this.getTxOut.bind(this) + this.getTxOutPost = this.getTxOutPost.bind(this) + this.getTxOutProofSingle = this.getTxOutProofSingle.bind(this) + this.getTxOutProofBulk = this.getTxOutProofBulk.bind(this) + this.verifyTxOutProofSingle = this.verifyTxOutProofSingle.bind(this) + this.verifyTxOutProofBulk = this.verifyTxOutProofBulk.bind(this) + this.getBlock = this.getBlock.bind(this) + this.getBlockHash = this.getBlockHash.bind(this) + this.handleError = this.handleError.bind(this) + } + + /** + * @api {get} /full-node/blockchain/ Service status + * @apiName BlockchainRoot + * @apiGroup Blockchain + * + * @apiDescription Returns the status of the blockchain service. + * + * @apiSuccess {String} status Service identifier + */ + async root (req, res) { + return res.status(200).json({ status: 'blockchain' }) + } + + /** + * @api {get} /full-node/blockchain/getBestBlockHash Get best block hash + * @apiName GetBestBlockHash + * @apiGroup Blockchain + * @apiDescription Returns the hash of the best (tip) block in the longest block chain. + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v5/blockchain/getBestBlockHash" -H "accept: application/json" + * + * @apiSuccess {String} bestBlockHash Hash of the best block + */ + async getBestBlockHash (req, res) { + try { + const result = await this.blockchainUseCases.getBestBlockHash() + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {get} /full-node/blockchain/getBlockchainInfo Get blockchain info + * @apiName GetBlockchainInfo + * @apiGroup Blockchain + * @apiDescription Returns various state info regarding blockchain processing. + */ + async getBlockchainInfo (req, res) { + try { + const result = await this.blockchainUseCases.getBlockchainInfo() + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {get} /full-node/blockchain/getBlockCount Get block count + * @apiName GetBlockCount + * @apiGroup Blockchain + * @apiDescription Returns the number of blocks in the longest blockchain. + */ + async getBlockCount (req, res) { + try { + const result = await this.blockchainUseCases.getBlockCount() + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {get} /full-node/blockchain/getBlockHeader/:hash Get single block header + * @apiName GetSingleBlockHeader + * @apiGroup Blockchain + * @apiDescription Returns serialized block header data. + * + * @apiParam {String} hash Block hash + * @apiParam {Boolean} verbose Return verbose data (default false) + */ + async getBlockHeaderSingle (req, res) { + try { + const hash = req.params.hash + if (!hash) { + return res.status(400).json({ error: 'hash can not be empty' }) + } + + const verbose = req.query.verbose?.toString() === 'true' + const result = await this.blockchainUseCases.getBlockHeader({ hash, verbose }) + + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {post} /full-node/blockchain/getBlockHeader Get multiple block headers + * @apiName GetBulkBlockHeader + * @apiGroup Blockchain + * @apiDescription Returns serialized block header data for multiple hashes. + * + * @apiParam {String[]} hashes Block hashes + * @apiParam {Boolean} verbose Return verbose data (default false) + */ + async getBlockHeaderBulk (req, res) { + try { + const hashes = req.body.hashes + const verbose = !!req.body.verbose + + if (!Array.isArray(hashes)) { + return res.status(400).json({ + error: 'hashes needs to be an array. Use GET for single hash.' + }) + } + + if (!this.adapters.fullNode.validateArraySize(hashes.length, { isProUser: Boolean(req.locals?.proLimit) })) { + return res.status(400).json({ error: 'Array too large.' }) + } + + for (const hash of hashes) { + if (!hash || hash.length !== 64) { + return res.status(400).json({ error: `This is not a hash: ${hash}` }) + } + } + + const result = await this.blockchainUseCases.getBlockHeaders({ hashes, verbose }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {get} /full-node/blockchain/getChainTips Get chain tips + * @apiName GetChainTips + * @apiGroup Blockchain + * @apiDescription Returns information about known tips in the block tree. + */ + async getChainTips (req, res) { + try { + const result = await this.blockchainUseCases.getChainTips() + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {get} /full-node/blockchain/getDifficulty Get difficulty + * @apiName GetDifficulty + * @apiGroup Blockchain + * @apiDescription Returns the current difficulty value. + */ + async getDifficulty (req, res) { + try { + const result = await this.blockchainUseCases.getDifficulty() + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {get} /full-node/blockchain/getMempoolEntry/:txid Get single mempool entry + * @apiName GetMempoolEntry + * @apiGroup Blockchain + * @apiDescription Returns mempool data for a transaction. + */ + async getMempoolEntrySingle (req, res) { + try { + const txid = req.params.txid + if (!txid) { + return res.status(400).json({ error: 'txid can not be empty' }) + } + + const result = await this.blockchainUseCases.getMempoolEntry({ txid }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {post} /full-node/blockchain/getMempoolEntry Get bulk mempool entry + * @apiName GetMempoolEntryBulk + * @apiGroup Blockchain + * @apiDescription Returns mempool data for multiple transactions. + */ + async getMempoolEntryBulk (req, res) { + try { + const txids = req.body.txids + + if (!Array.isArray(txids)) { + return res.status(400).json({ + error: 'txids needs to be an array. Use GET for single txid.' + }) + } + + if (!this.adapters.fullNode.validateArraySize(txids.length, { isProUser: Boolean(req.locals?.proLimit) })) { + return res.status(400).json({ error: 'Array too large.' }) + } + + for (const txid of txids) { + if (!txid || txid.length !== 64) { + return res.status(400).json({ error: 'This is not a txid' }) + } + } + + const result = await this.blockchainUseCases.getMempoolEntries({ txids }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {get} /full-node/blockchain/getMempoolAncestors/:txid Get mempool ancestors + * @apiName GetMempoolAncestors + * @apiGroup Blockchain + * @apiDescription Returns mempool ancestor data for a transaction. + */ + async getMempoolAncestorsSingle (req, res) { + try { + const txid = req.params.txid + if (!txid) { + return res.status(400).json({ error: 'txid can not be empty' }) + } + + let verbose = false + if (req.query.verbose && req.query.verbose.toString() === 'true') { + verbose = true + } + + const result = await this.blockchainUseCases.getMempoolAncestors({ txid, verbose }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {get} /full-node/blockchain/getMempoolInfo Get mempool info + * @apiName GetMempoolInfo + * @apiGroup Blockchain + * @apiDescription Returns details on the state of the mempool. + */ + async getMempoolInfo (req, res) { + try { + const result = await this.blockchainUseCases.getMempoolInfo() + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {get} /full-node/blockchain/getRawMempool Get raw mempool + * @apiName GetRawMempool + * @apiGroup Blockchain + * @apiDescription Returns all transaction ids in the mempool. + * + * @apiParam {Boolean} verbose Return verbose data (default false) + */ + async getRawMempool (req, res) { + try { + const verbose = req.query.verbose === 'true' + const result = await this.blockchainUseCases.getRawMempool({ verbose }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {get} /full-node/blockchain/getTxOut/:txid/:n Get transaction output + * @apiName GetTxOut + * @apiGroup Blockchain + * @apiDescription Returns details about an unspent transaction output. + */ + async getTxOut (req, res) { + try { + const txid = req.params.txid + if (!txid) { + return res.status(400).json({ error: 'txid can not be empty' }) + } + + const nRaw = req.params.n + if (nRaw === undefined || nRaw === '') { + return res.status(400).json({ error: 'n can not be empty' }) + } + + const n = parseInt(nRaw) + const includeMempool = req.query.includeMempool === 'true' + + const result = await this.blockchainUseCases.getTxOut({ + txid, + n, + includeMempool + }) + + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {post} /full-node/blockchain/getTxOut Validate a UTXO + * @apiName GetTxOutPost + * @apiGroup Blockchain + * @apiDescription Returns details about an unspent transaction output. + */ + async getTxOutPost (req, res) { + try { + const txid = req.body.txid + if (!txid) { + return res.status(400).json({ error: 'txid can not be empty' }) + } + + const voutRaw = req.body.vout + if (voutRaw === undefined || voutRaw === '') { + return res.status(400).json({ error: 'vout can not be empty' }) + } + + const n = parseInt(voutRaw) + const mempool = req.body.mempool !== undefined ? !!req.body.mempool : true + + const result = await this.blockchainUseCases.getTxOut({ + txid, + n, + includeMempool: mempool + }) + + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {get} /full-node/blockchain/getTxOutProof/:txid Get TxOut proof + * @apiName GetTxOutProofSingle + * @apiGroup Blockchain + * @apiDescription Returns a hex-encoded proof that the transaction was included in a block. + */ + async getTxOutProofSingle (req, res) { + try { + const txid = req.params.txid + if (!txid) { + return res.status(400).json({ error: 'txid can not be empty' }) + } + + const result = await this.blockchainUseCases.getTxOutProof({ txid }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {post} /full-node/blockchain/getTxOutProof Get TxOut proofs + * @apiName GetTxOutProofBulk + * @apiGroup Blockchain + * @apiDescription Returns hex-encoded proofs for transactions. + */ + async getTxOutProofBulk (req, res) { + try { + const txids = req.body.txids + + if (!Array.isArray(txids)) { + return res.status(400).json({ + error: 'txids needs to be an array. Use GET for single txid.' + }) + } + + if (!this.adapters.fullNode.validateArraySize(txids.length, { isProUser: Boolean(req.locals?.proLimit) })) { + return res.status(400).json({ error: 'Array too large.' }) + } + + for (const txid of txids) { + if (!txid || txid.length !== 64) { + return res.status(400).json({ + error: `Invalid txid. Double check your txid is valid: ${txid}` + }) + } + } + + const result = await this.blockchainUseCases.getTxOutProofs({ txids }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {get} /full-node/blockchain/verifyTxOutProof/:proof Verify TxOut proof + * @apiName VerifyTxOutProofSingle + * @apiGroup Blockchain + * @apiDescription Verifies a hex-encoded proof was included in a block. + */ + async verifyTxOutProofSingle (req, res) { + try { + const proof = req.params.proof + if (!proof) { + return res.status(400).json({ error: 'proof can not be empty' }) + } + + const result = await this.blockchainUseCases.verifyTxOutProof({ proof }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {post} /full-node/blockchain/verifyTxOutProof Verify TxOut proofs + * @apiName VerifyTxOutProofBulk + * @apiGroup Blockchain + * @apiDescription Verifies hex-encoded proofs were included in blocks. + */ + async verifyTxOutProofBulk (req, res) { + try { + const proofs = req.body.proofs + + if (!Array.isArray(proofs)) { + return res.status(400).json({ + error: 'proofs needs to be an array. Use GET for single proof.' + }) + } + + if (!this.adapters.fullNode.validateArraySize(proofs.length, { isProUser: Boolean(req.locals?.proLimit) })) { + return res.status(400).json({ error: 'Array too large.' }) + } + + for (const proof of proofs) { + if (!proof) { + return res.status(400).json({ error: `proof can not be empty: ${proof}` }) + } + } + + const result = await this.blockchainUseCases.verifyTxOutProofs({ proofs }) + const flattened = result.map(entry => Array.isArray(entry) ? entry[0] : entry) + + return res.status(200).json(flattened) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {post} /full-node/blockchain/getBlock Get block details + * @apiName GetBlock + * @apiGroup Blockchain + * @apiDescription Returns block details for a hash. + */ + async getBlock (req, res) { + try { + const blockhash = req.body.blockhash + if (!blockhash) { + return res.status(400).json({ error: 'blockhash can not be empty' }) + } + + let verbosity = req.body.verbosity + if (verbosity === undefined || verbosity === null) { + verbosity = 1 + } + + const result = await this.blockchainUseCases.getBlock({ + blockhash, + verbosity + }) + + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {get} /full-node/blockchain/getBlockHash/:height Get block hash + * @apiName GetBlockHash + * @apiGroup Blockchain + * @apiDescription Returns the hash of a block by height. + */ + async getBlockHash (req, res) { + try { + const heightRaw = req.params.height + if (!heightRaw) { + return res.status(400).json({ error: 'height can not be empty' }) + } + + const height = parseInt(heightRaw) + const result = await this.blockchainUseCases.getBlockHash({ height }) + + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + handleError (err, res) { + wlogger.error('Error in BlockchainRESTController:', err) + + const status = err.status || 500 + const message = err.message || 'Internal server error' + + return res.status(status).json({ error: message }) + } +} + +export default BlockchainRESTController diff --git a/src/controllers/rest-api/full-node/blockchain/index.js b/src/controllers/rest-api/full-node/blockchain/index.js new file mode 100644 index 0000000..1490217 --- /dev/null +++ b/src/controllers/rest-api/full-node/blockchain/index.js @@ -0,0 +1,66 @@ +/* + REST API router for /full-node/blockchain routes. +*/ + +import express from 'express' +import BlockchainRESTController from './controller.js' + +class BlockchainRouter { + constructor (localConfig = {}) { + this.adapters = localConfig.adapters + if (!this.adapters) { + throw new Error( + 'Instance of Adapters library required when instantiating Blockchain REST Router.' + ) + } + + this.useCases = localConfig.useCases + if (!this.useCases) { + throw new Error( + 'Instance of Use Cases library required when instantiating Blockchain REST Router.' + ) + } + + const dependencies = { + adapters: this.adapters, + useCases: this.useCases + } + + this.blockchainController = new BlockchainRESTController(dependencies) + + this.baseUrl = '/full-node/blockchain' + this.router = express.Router() + } + + attach (app) { + if (!app) { + throw new Error('Must pass app object when attaching REST API controllers.') + } + + this.router.get('/', this.blockchainController.root) + this.router.get('/getBestBlockHash', this.blockchainController.getBestBlockHash) + this.router.get('/getBlockchainInfo', this.blockchainController.getBlockchainInfo) + this.router.get('/getBlockCount', this.blockchainController.getBlockCount) + this.router.get('/getBlockHeader/:hash', this.blockchainController.getBlockHeaderSingle) + this.router.post('/getBlockHeader', this.blockchainController.getBlockHeaderBulk) + this.router.get('/getChainTips', this.blockchainController.getChainTips) + this.router.get('/getDifficulty', this.blockchainController.getDifficulty) + this.router.get('/getMempoolEntry/:txid', this.blockchainController.getMempoolEntrySingle) + this.router.post('/getMempoolEntry', this.blockchainController.getMempoolEntryBulk) + this.router.get('/getMempoolAncestors/:txid', this.blockchainController.getMempoolAncestorsSingle) + this.router.get('/getMempoolInfo', this.blockchainController.getMempoolInfo) + this.router.get('/getRawMempool', this.blockchainController.getRawMempool) + this.router.get('/getTxOut/:txid/:n', this.blockchainController.getTxOut) + this.router.post('/getTxOut', this.blockchainController.getTxOutPost) + this.router.get('/getTxOutProof/:txid', this.blockchainController.getTxOutProofSingle) + this.router.post('/getTxOutProof', this.blockchainController.getTxOutProofBulk) + this.router.get('/verifyTxOutProof/:proof', this.blockchainController.verifyTxOutProofSingle) + this.router.post('/verifyTxOutProof', this.blockchainController.verifyTxOutProofBulk) + this.router.post('/getBlock', this.blockchainController.getBlock) + this.router.get('/getBlockHash/:height', this.blockchainController.getBlockHash) + + app.use(this.baseUrl, this.router) + } +} + +export default BlockchainRouter diff --git a/src/controllers/rest-api/index.js b/src/controllers/rest-api/index.js index 37e1e21..efb6923 100644 --- a/src/controllers/rest-api/index.js +++ b/src/controllers/rest-api/index.js @@ -7,6 +7,7 @@ // Local libraries // import EventRouter from './event/index.js' // import ReqRouter from './req/index.js' +import BlockchainRouter from './full-node/blockchain/index.js' import config from '../../config/index.js' class RESTControllers { @@ -33,10 +34,10 @@ class RESTControllers { } attachRESTControllers (app) { - // const dependencies = { - // adapters: this.adapters, - // useCases: this.useCases - // } + const dependencies = { + adapters: this.adapters, + useCases: this.useCases + } // Attach the REST API Controllers associated with the /event route // const eventRouter = new EventRouter(dependencies) @@ -45,6 +46,9 @@ class RESTControllers { // Attach the REST API Controllers associated with the /req route // const reqRouter = new ReqRouter(dependencies) // reqRouter.attach(app) + + const blockchainRouter = new BlockchainRouter(dependencies) + blockchainRouter.attach(app) } } diff --git a/src/use-cases/blockchain/index.js b/src/use-cases/blockchain/index.js new file mode 100644 index 0000000..bb60818 --- /dev/null +++ b/src/use-cases/blockchain/index.js @@ -0,0 +1,134 @@ +/* + Use cases for interacting with the BCH full node blockchain RPC interface. +*/ + +import wlogger from '../../adapters/wlogger.js' + +class BlockchainUseCases { + constructor (localConfig = {}) { + this.adapters = localConfig.adapters + + if (!this.adapters) { + throw new Error('Adapters instance required when instantiating Blockchain use cases.') + } + + this.fullNode = this.adapters.fullNode + if (!this.fullNode) { + throw new Error('Full node adapter required when instantiating Blockchain use cases.') + } + } + + async getBestBlockHash () { + return this.fullNode.call('getbestblockhash') + } + + async getBlockchainInfo () { + return this.fullNode.call('getblockchaininfo') + } + + async getBlockCount () { + return this.fullNode.call('getblockcount') + } + + async getBlockHeader ({ hash, verbose = false }) { + return this.fullNode.call('getblockheader', [hash, verbose]) + } + + async getBlockHeaders ({ hashes, verbose = false }) { + try { + const promises = hashes.map(hash => + this.fullNode.call('getblockheader', [hash, verbose], `getblockheader-${hash}`) + ) + + return await Promise.all(promises) + } catch (err) { + wlogger.error('Error in BlockchainUseCases.getBlockHeaders()', err) + throw err + } + } + + async getChainTips () { + return this.fullNode.call('getchaintips') + } + + async getDifficulty () { + return this.fullNode.call('getdifficulty') + } + + async getMempoolEntry ({ txid }) { + return this.fullNode.call('getmempoolentry', [txid]) + } + + async getMempoolEntries ({ txids }) { + try { + const promises = txids.map(txid => + this.fullNode.call('getmempoolentry', [txid], `getmempoolentry-${txid}`) + ) + + return await Promise.all(promises) + } catch (err) { + wlogger.error('Error in BlockchainUseCases.getMempoolEntries()', err) + throw err + } + } + + async getMempoolAncestors ({ txid, verbose = false }) { + return this.fullNode.call('getmempoolancestors', [txid, verbose]) + } + + async getMempoolInfo () { + return this.fullNode.call('getmempoolinfo') + } + + async getRawMempool ({ verbose = false }) { + return this.fullNode.call('getrawmempool', [verbose]) + } + + async getTxOut ({ txid, n, includeMempool }) { + return this.fullNode.call('gettxout', [txid, n, includeMempool]) + } + + async getTxOutProof ({ txid }) { + return this.fullNode.call('gettxoutproof', [[txid]]) + } + + async getTxOutProofs ({ txids }) { + try { + const promises = txids.map(txid => + this.fullNode.call('gettxoutproof', [[txid]], `gettxoutproof-${txid}`) + ) + + return await Promise.all(promises) + } catch (err) { + wlogger.error('Error in BlockchainUseCases.getTxOutProofs()', err) + throw err + } + } + + async verifyTxOutProof ({ proof }) { + return this.fullNode.call('verifytxoutproof', [proof]) + } + + async verifyTxOutProofs ({ proofs }) { + try { + const promises = proofs.map(proof => + this.fullNode.call('verifytxoutproof', [proof], `verifytxoutproof-${proof.slice(0, 16)}`) + ) + + return await Promise.all(promises) + } catch (err) { + wlogger.error('Error in BlockchainUseCases.verifyTxOutProofs()', err) + throw err + } + } + + async getBlock ({ blockhash, verbosity }) { + return this.fullNode.call('getblock', [blockhash, verbosity]) + } + + async getBlockHash ({ height }) { + return this.fullNode.call('getblockhash', [height]) + } +} + +export default BlockchainUseCases diff --git a/src/use-cases/index.js b/src/use-cases/index.js index 73d2e0f..ac1eeb8 100644 --- a/src/use-cases/index.js +++ b/src/use-cases/index.js @@ -8,6 +8,7 @@ // import PublishEventUseCase from './publish-event.js' // import QueryEventsUseCase from './query-events.js' // import ManageSubscriptionUseCase from './manage-subscription.js' +import BlockchainUseCases from './blockchain/index.js' class UseCases { constructor (localConfig = {}) { @@ -21,6 +22,7 @@ class UseCases { // this.publishEvent = new PublishEventUseCase({ adapters: this.adapters }) // this.queryEvents = new QueryEventsUseCase({ adapters: this.adapters }) // this.manageSubscription = new ManageSubscriptionUseCase({ adapters: this.adapters }) + this.blockchain = new BlockchainUseCases({ adapters: this.adapters }) } // Run any startup Use Cases at the start of the app. From 78197762e4284ee7535e0aa2378e6d3fa65959a4 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Sun, 9 Nov 2025 19:24:48 -0800 Subject: [PATCH 2/3] Getting rid of placeholder controller libs --- src/adapters/nostr-relay.js | 241 -------------- src/controllers/rest-api/event/controller.js | 99 ------ src/controllers/rest-api/event/index.js | 55 ---- src/controllers/rest-api/req/controller.js | 296 ------------------ src/controllers/rest-api/req/index.js | 75 ----- ...x.js => full-node-blockchain-use-cases.js} | 2 +- src/use-cases/index.js | 2 +- 7 files changed, 2 insertions(+), 768 deletions(-) delete mode 100644 src/adapters/nostr-relay.js delete mode 100644 src/controllers/rest-api/event/controller.js delete mode 100644 src/controllers/rest-api/event/index.js delete mode 100644 src/controllers/rest-api/req/controller.js delete mode 100644 src/controllers/rest-api/req/index.js rename src/use-cases/{blockchain/index.js => full-node-blockchain-use-cases.js} (98%) diff --git a/src/adapters/nostr-relay.js b/src/adapters/nostr-relay.js deleted file mode 100644 index f89d031..0000000 --- a/src/adapters/nostr-relay.js +++ /dev/null @@ -1,241 +0,0 @@ -/* - Nostr Relay WebSocket adapter. - Handles WebSocket connections to Nostr relays and manages message sending/receiving. -*/ - -import WebSocket from 'ws' -import config from '../config/index.js' -import wlogger from './wlogger.js' - -class NostrRelayAdapter { - constructor (localConfig = {}) { - this.config = config - this.relayUrl = localConfig.relayUrl || config.nostrRelayUrl - this.ws = null - this.isConnected = false - this.reconnectAttempts = 0 - this.maxReconnectAttempts = 5 - this.reconnectDelay = 5000 // 5 seconds - this.messageHandlers = new Map() // Map subscription_id to handlers - this.pendingMessages = [] // Queue messages while disconnected - this.eventResolvers = new Map() // Map event_id to promise resolvers for OK responses - this.subscriptionHandlers = new Map() // Map subscription_id to event handlers - - // Bind methods - this.connect = this.connect.bind(this) - this.disconnect = this.disconnect.bind(this) - this.sendEvent = this.sendEvent.bind(this) - this.sendReq = this.sendReq.bind(this) - this.sendClose = this.sendClose.bind(this) - this.handleMessage = this.handleMessage.bind(this) - this.handleError = this.handleError.bind(this) - this.handleClose = this.handleClose.bind(this) - } - - async connect () { - if (this.ws && this.isConnected) { - return true - } - - return new Promise((resolve, reject) => { - try { - wlogger.info(`Connecting to Nostr relay: ${this.relayUrl}`) - this.ws = new WebSocket(this.relayUrl) - - this.ws.on('open', () => { - wlogger.info('Connected to Nostr relay') - this.isConnected = true - this.reconnectAttempts = 0 - - // Send any pending messages - while (this.pendingMessages.length > 0) { - const message = this.pendingMessages.shift() - this.ws.send(JSON.stringify(message)) - } - - resolve(true) - }) - - this.ws.on('message', (data) => { - try { - const message = JSON.parse(data.toString()) - this.handleMessage(message) - } catch (err) { - wlogger.error('Error parsing relay message:', err) - } - }) - - this.ws.on('error', this.handleError) - this.ws.on('close', this.handleClose) - - // Timeout after 10 seconds - setTimeout(() => { - if (!this.isConnected) { - reject(new Error('Connection timeout')) - } - }, 10000) - } catch (err) { - wlogger.error('Error connecting to relay:', err) - reject(err) - } - }) - } - - async disconnect () { - if (this.ws) { - this.ws.close() - this.ws = null - this.isConnected = false - wlogger.info('Disconnected from Nostr relay') - } - } - - handleMessage (message) { - if (!Array.isArray(message) || message.length === 0) { - return - } - - const [type, ...args] = message - - switch (type) { - case 'EVENT': - // ["EVENT", , ] - if (args.length >= 2) { - const subscriptionId = args[0] - const event = args[1] - const handler = this.subscriptionHandlers.get(subscriptionId) - if (handler) { - handler.onEvent(event) - } - } - break - - case 'OK': - // ["OK", , , ] - if (args.length >= 2) { - const eventId = args[0] - const accepted = args[1] - const message = args[2] || '' - const resolver = this.eventResolvers.get(eventId) - if (resolver) { - resolver({ accepted, message }) - this.eventResolvers.delete(eventId) - } - } - break - - case 'EOSE': - // ["EOSE", ] - if (args.length >= 1) { - const subscriptionId = args[0] - const handler = this.subscriptionHandlers.get(subscriptionId) - if (handler) { - handler.onEose() - } - } - break - - case 'CLOSED': - // ["CLOSED", , ] - if (args.length >= 1) { - const subscriptionId = args[0] - const message = args[1] || '' - const handler = this.subscriptionHandlers.get(subscriptionId) - if (handler) { - handler.onClosed(message) - } - } - break - - case 'NOTICE': - // ["NOTICE", ] - if (args.length >= 1) { - const message = args[0] - wlogger.warn('Relay notice:', message) - } - break - - default: - wlogger.warn('Unknown message type from relay:', type) - } - } - - handleError (error) { - wlogger.error('WebSocket error:', error) - this.isConnected = false - } - - handleClose () { - const now = new Date() - wlogger.warn(`WebSocket connection closed at ${now.toLocaleString()}`) - this.isConnected = false - - // Attempt to reconnect - if (this.reconnectAttempts < this.maxReconnectAttempts) { - this.reconnectAttempts++ - wlogger.info(`Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`) - setTimeout(() => { - this.connect().catch(err => { - wlogger.error('Reconnection failed:', err) - }) - }, this.reconnectDelay) - } - } - - async sendMessage (message) { - if (!this.isConnected || !this.ws) { - // Queue message for when connection is established - this.pendingMessages.push(message) - await this.connect() - return - } - - try { - this.ws.send(JSON.stringify(message)) - } catch (err) { - wlogger.error('Error sending message:', err) - throw err - } - } - - async sendEvent (event) { - // ["EVENT", ] - const message = ['EVENT', event] - await this.sendMessage(message) - - // Return a promise that resolves when we get the OK response - return new Promise((resolve, reject) => { - this.eventResolvers.set(event.id, resolve) - // Timeout after 30 seconds - setTimeout(() => { - if (this.eventResolvers.has(event.id)) { - this.eventResolvers.delete(event.id) - reject(new Error('Timeout waiting for OK response')) - } - }, 30000) - }) - } - - async sendReq (subscriptionId, filters, handlers) { - // ["REQ", , ] - await this.connect() - - // Store handlers for this subscription - this.subscriptionHandlers.set(subscriptionId, handlers) - - const message = ['REQ', subscriptionId, ...filters] - await this.sendMessage(message) - } - - async sendClose (subscriptionId) { - // ["CLOSE", ] - const message = ['CLOSE', subscriptionId] - await this.sendMessage(message) - - // Clean up handlers - this.subscriptionHandlers.delete(subscriptionId) - this.messageHandlers.delete(subscriptionId) - } -} - -export default NostrRelayAdapter diff --git a/src/controllers/rest-api/event/controller.js b/src/controllers/rest-api/event/controller.js deleted file mode 100644 index f0af007..0000000 --- a/src/controllers/rest-api/event/controller.js +++ /dev/null @@ -1,99 +0,0 @@ -/* - REST API Controller library for the /event route -*/ - -// Local libraries -import wlogger from '../../../adapters/wlogger.js' - -class EventRESTControllerLib { - constructor (localConfig = {}) { - // Dependency Injection. - this.adapters = localConfig.adapters - if (!this.adapters) { - throw new Error( - 'Instance of Adapters library required when instantiating /event REST Controller.' - ) - } - this.useCases = localConfig.useCases - if (!this.useCases) { - throw new Error( - 'Instance of Use Cases library required when instantiating /event REST Controller.' - ) - } - - // Bind 'this' object to all subfunctions - this.publishEvent = this.publishEvent.bind(this) - this.handleError = this.handleError.bind(this) - } - - /** - * @api {post} /event Publish a Nostr event - * @apiPermission public - * @apiName PublishEvent - * @apiGroup Event - * - * @apiDescription Publish a signed Nostr event to the relay. Maps to the Nostr WebSocket protocol message: ["EVENT", ] - * - * @apiParam {String} id Event ID (32-bytes lowercase hex-encoded sha256) - * @apiParam {String} pubkey Public key of event creator (32-bytes lowercase hex-encoded) - * @apiParam {Number} created_at Unix timestamp in seconds - * @apiParam {Number} kind Integer between 0 and 65535 - * @apiParam {Array} tags Array of tag arrays - * @apiParam {String} content Event content (arbitrary string) - * @apiParam {String} sig Signature (64-bytes lowercase hex) - * - * @apiExample {json} Example usage: - * { - * "id": "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36", - * "pubkey": "2c7e76c0f8dc1dca9d0197c7d19be580a8d074ccada6a2f6ebe056ae41092e9", - * "created_at": 1672531200, - * "kind": 1, - * "tags": [], - * "content": "Hello, Nostr!", - * "sig": "abc123..." - * } - * - * @apiSuccess {Boolean} accepted Whether the event was accepted by the relay - * @apiSuccess {String} message Optional message from the relay - * @apiSuccess {String} eventId The event ID - * - * @apiError {String} error Error message - */ - async publishEvent (req, res) { - try { - const eventData = req.body - - // Check if eventData is missing or empty - if (!eventData || (typeof eventData === 'object' && Object.keys(eventData).length === 0)) { - return res.status(400).json({ - error: 'Event data is required' - }) - } - - const result = await this.useCases.publishEvent.execute(eventData) - - if (result.accepted) { - return res.status(200).json(result) - } else { - return res.status(400).json(result) - } - } catch (err) { - return this.handleError(err, req, res) - } - } - - handleError (err, req, res) { - wlogger.error('Error in EventRESTController:', err) - - // Return 400 for validation errors, 500 for other errors - // Validation errors indicate the client sent bad data - const isValidationError = err.message && err.message.includes('Invalid event structure') - const statusCode = isValidationError ? 400 : 500 - - return res.status(statusCode).json({ - error: err.message || 'Internal server error' - }) - } -} - -export default EventRESTControllerLib diff --git a/src/controllers/rest-api/event/index.js b/src/controllers/rest-api/event/index.js deleted file mode 100644 index 1377969..0000000 --- a/src/controllers/rest-api/event/index.js +++ /dev/null @@ -1,55 +0,0 @@ -/* - REST API library for the /event route. -*/ - -// Public npm libraries. -import express from 'express' - -// Local libraries. -import EventRESTControllerLib from './controller.js' - -class EventRouter { - constructor (localConfig = {}) { - // Dependency Injection. - this.adapters = localConfig.adapters - if (!this.adapters) { - throw new Error( - 'Instance of Adapters library required when instantiating Event REST Controller.' - ) - } - this.useCases = localConfig.useCases - if (!this.useCases) { - throw new Error( - 'Instance of Use Cases library required when instantiating Event REST Controller.' - ) - } - - const dependencies = { - adapters: this.adapters, - useCases: this.useCases - } - - // Encapsulate dependencies. - this.eventRESTController = new EventRESTControllerLib(dependencies) - - // Instantiate the router and set the base route. - this.baseUrl = '/event' - this.router = express.Router() - } - - attach (app) { - if (!app) { - throw new Error( - 'Must pass app object when attaching REST API controllers.' - ) - } - - // Define the routes and attach the controller. - this.router.post('/', this.eventRESTController.publishEvent) - - // Attach the Controller routes to the Express app. - app.use(this.baseUrl, this.router) - } -} - -export default EventRouter diff --git a/src/controllers/rest-api/req/controller.js b/src/controllers/rest-api/req/controller.js deleted file mode 100644 index a7e0fdc..0000000 --- a/src/controllers/rest-api/req/controller.js +++ /dev/null @@ -1,296 +0,0 @@ -/* - REST API Controller library for the /req route -*/ - -// Local libraries -import wlogger from '../../../adapters/wlogger.js' - -class ReqRESTControllerLib { - constructor (localConfig = {}) { - // Dependency Injection. - this.adapters = localConfig.adapters - if (!this.adapters) { - throw new Error( - 'Instance of Adapters library required when instantiating /req REST Controller.' - ) - } - this.useCases = localConfig.useCases - if (!this.useCases) { - throw new Error( - 'Instance of Use Cases library required when instantiating /req REST Controller.' - ) - } - - // Bind 'this' object to all subfunctions - this.queryEvents = this.queryEvents.bind(this) - this.createSubscription = this.createSubscription.bind(this) - this.closeSubscription = this.closeSubscription.bind(this) - this.handleError = this.handleError.bind(this) - } - - /** - * @api {get} /req/:subId Query events (stateless) - * @apiPermission public - * @apiName QueryEvents - * @apiGroup Request - * - * @apiDescription Query events from the relay with filters. Returns events immediately. Maps to: ["REQ", , ] - * - * @apiParam {String} subId Subscription ID (unique identifier) - * @apiParam {String} filters JSON-encoded filters object (query parameter) - * - * @apiExample {curl} Example usage: - * curl -X GET "http://localhost:3000/req/sub1?filters=[{\"kinds\":[1],\"limit\":10}]" - * - * @apiSuccess {Array} events Array of Nostr events - * - * @apiError {String} error Error message - */ - async queryEvents (req, res) { - try { - const { subId } = req.params - let filters = req.query.filters - - if (!subId) { - return res.status(400).json({ - error: 'Subscription ID is required' - }) - } - - // Parse filters from query string - if (typeof filters === 'string') { - try { - filters = JSON.parse(filters) - } catch (err) { - return res.status(400).json({ - error: 'Invalid filters JSON' - }) - } - } else if (!filters) { - // If no filters provided, accept filters from query params - filters = {} - if (req.query.kinds) { - filters.kinds = JSON.parse(req.query.kinds) - } - if (req.query.authors) { - filters.authors = JSON.parse(req.query.authors) - } - if (req.query.ids) { - filters.ids = JSON.parse(req.query.ids) - } - if (req.query.limit) { - filters.limit = parseInt(req.query.limit) - } - if (req.query.since) { - filters.since = parseInt(req.query.since) - } - if (req.query.until) { - filters.until = parseInt(req.query.until) - } - } - - // Ensure filters is an array (Nostr protocol expects array of filters) - const filtersArray = Array.isArray(filters) ? filters : [filters] - - const events = await this.useCases.queryEvents.execute(filtersArray, subId) - - return res.status(200).json(events) - } catch (err) { - return this.handleError(err, req, res) - } - } - - /** - * @api {post} /req/:subId Create subscription (SSE) - * @apiPermission public - * @apiName CreateSubscription - * @apiGroup Request - * - * @apiDescription Create a subscription for Server-Sent Events. Maps to: ["REQ", , ] - * - * @apiParam {String} subId Subscription ID (unique identifier) - * @apiParam {Object} filters Filters object in request body - * - * @apiExample {json} Example usage: - * { - * "kinds": [1], - * "authors": ["2c7e76c0f8dc1dca9d0197c7d19be580a8d074ccada6a2f6ebe056ae41092e9"] - * } - * - * @apiSuccess {String} message Success message - * - * @apiError {String} error Error message - */ - async createSubscription (req, res) { - try { - const { subId } = req.params - const filters = req.body - - if (!subId) { - return res.status(400).json({ - error: 'Subscription ID is required' - }) - } - - if (!filters || (typeof filters === 'object' && Object.keys(filters).length === 0)) { - return res.status(400).json({ - error: 'Filters are required' - }) - } - - // Ensure filters is an array - const filtersArray = Array.isArray(filters) ? filters : [filters] - - // Set up Server-Sent Events - res.setHeader('Content-Type', 'text/event-stream') - res.setHeader('Cache-Control', 'no-cache') - res.setHeader('Connection', 'keep-alive') - res.setHeader('X-Accel-Buffering', 'no') // Disable buffering in nginx - - // Track if response is still writable - let isResponseWritable = true - - // Helper function to safely write to SSE stream - const safeWrite = (data) => { - if (!isResponseWritable) { - return false - } - try { - if (!res.writable || res.destroyed || res.closed) { - isResponseWritable = false - return false - } - return res.write(data) - } catch (err) { - wlogger.warn(`Error writing to SSE stream for subscription ${subId}:`, err.message) - isResponseWritable = false - return false - } - } - - // Handle response stream errors - res.on('error', (err) => { - wlogger.warn(`Response stream error for subscription ${subId}:`, err.message) - isResponseWritable = false - // Clean up subscription on stream error - this.useCases.manageSubscription.closeSubscription(subId).catch(closeErr => { - wlogger.error('Error closing subscription on stream error:', closeErr) - }) - }) - - // Send initial connection message - if (!safeWrite(`data: ${JSON.stringify({ type: 'connected', subscriptionId: subId })}\n\n`)) { - wlogger.warn(`Failed to send initial connection message for subscription ${subId}`) - return res.status(500).json({ error: 'Failed to establish SSE connection' }) - } - - // Handle events - const onEvent = (event) => { - if (!safeWrite(`data: ${JSON.stringify({ type: 'event', data: event })}\n\n`)) { - wlogger.debug(`Cannot write event to SSE stream for subscription ${subId} - connection may be closed`) - } - } - - // Handle EOSE - const onEose = () => { - if (!safeWrite(`data: ${JSON.stringify({ type: 'eose' })}\n\n`)) { - wlogger.debug(`Cannot write EOSE to SSE stream for subscription ${subId} - connection may be closed`) - } - } - - // Handle CLOSED - const onClosed = (message) => { - if (safeWrite(`data: ${JSON.stringify({ type: 'closed', message })}\n\n`)) { - try { - if (!res.destroyed && !res.closed) { - res.end() - } - } catch (err) { - wlogger.warn(`Error ending SSE stream for subscription ${subId}:`, err.message) - } - } - isResponseWritable = false - } - - // Create subscription - await this.useCases.manageSubscription.createSubscription( - subId, - filtersArray, - onEvent, - onEose, - onClosed - ) - - // Handle client disconnect - req.on('close', () => { - wlogger.info(`Client disconnected from subscription ${subId}`) - isResponseWritable = false - this.useCases.manageSubscription.closeSubscription(subId).catch(err => { - wlogger.error('Error closing subscription on disconnect:', err) - }) - }) - - // Handle response finish - res.on('finish', () => { - isResponseWritable = false - }) - } catch (err) { - return this.handleError(err, req, res) - } - } - - /** - * @api {put} /req/:subId Create subscription (SSE) - alternative method - * @apiPermission public - * @apiName CreateSubscriptionPut - * @apiGroup Request - * - * @apiDescription Same as POST /req/:subId - create a subscription for Server-Sent Events - */ - async createSubscriptionPut (req, res) { - return this.createSubscription(req, res) - } - - /** - * @api {delete} /req/:subId Close subscription - * @apiPermission public - * @apiName CloseSubscription - * @apiGroup Request - * - * @apiDescription Close an existing subscription. Maps to: ["CLOSE", ] - * - * @apiParam {String} subId Subscription ID to close - * - * @apiSuccess {String} message Success message - * - * @apiError {String} error Error message - */ - async closeSubscription (req, res) { - try { - const { subId } = req.params - - if (!subId) { - return res.status(400).json({ - error: 'Subscription ID is required' - }) - } - - await this.useCases.manageSubscription.closeSubscription(subId) - - return res.status(200).json({ - message: `Subscription ${subId} closed successfully` - }) - } catch (err) { - return this.handleError(err, req, res) - } - } - - handleError (err, req, res) { - wlogger.error('Error in ReqRESTController:', err) - return res.status(500).json({ - error: err.message || 'Internal server error' - }) - } -} - -export default ReqRESTControllerLib diff --git a/src/controllers/rest-api/req/index.js b/src/controllers/rest-api/req/index.js deleted file mode 100644 index 2662136..0000000 --- a/src/controllers/rest-api/req/index.js +++ /dev/null @@ -1,75 +0,0 @@ -/* - REST API library for the /req route. -*/ - -// Public npm libraries. -import express from 'express' - -// Local libraries. -import ReqRESTControllerLib from './controller.js' - -class ReqRouter { - constructor (localConfig = {}) { - // Dependency Injection. - this.adapters = localConfig.adapters - if (!this.adapters) { - throw new Error( - 'Instance of Adapters library required when instantiating Req REST Controller.' - ) - } - this.useCases = localConfig.useCases - if (!this.useCases) { - throw new Error( - 'Instance of Use Cases library required when instantiating Req REST Controller.' - ) - } - - const dependencies = { - adapters: this.adapters, - useCases: this.useCases - } - - // Encapsulate dependencies. - this.reqRESTController = new ReqRESTControllerLib(dependencies) - - // Instantiate the router and set the base route. - this.router = express.Router() - } - - attach (app) { - if (!app) { - throw new Error( - 'Must pass app object when attaching REST API controllers.' - ) - } - - // Define the routes and attach the controller. - // Handle empty subId case first - this.router.get('/', (req, res) => { - res.status(400).json({ - error: 'Subscription ID is required' - }) - }) - this.router.post('/', (req, res) => { - res.status(400).json({ - error: 'Subscription ID is required' - }) - }) - this.router.delete('/', (req, res) => { - res.status(400).json({ - error: 'Subscription ID is required' - }) - }) - - // Routes with subId parameter - this.router.get('/:subId', this.reqRESTController.queryEvents) - this.router.post('/:subId', this.reqRESTController.createSubscription) - this.router.put('/:subId', this.reqRESTController.createSubscriptionPut) - this.router.delete('/:subId', this.reqRESTController.closeSubscription) - - // Attach the Controller routes to the Express app. - app.use('/req', this.router) - } -} - -export default ReqRouter diff --git a/src/use-cases/blockchain/index.js b/src/use-cases/full-node-blockchain-use-cases.js similarity index 98% rename from src/use-cases/blockchain/index.js rename to src/use-cases/full-node-blockchain-use-cases.js index bb60818..1986ff6 100644 --- a/src/use-cases/blockchain/index.js +++ b/src/use-cases/full-node-blockchain-use-cases.js @@ -2,7 +2,7 @@ Use cases for interacting with the BCH full node blockchain RPC interface. */ -import wlogger from '../../adapters/wlogger.js' +import wlogger from '../adapters/wlogger.js' class BlockchainUseCases { constructor (localConfig = {}) { diff --git a/src/use-cases/index.js b/src/use-cases/index.js index ac1eeb8..32b8a52 100644 --- a/src/use-cases/index.js +++ b/src/use-cases/index.js @@ -8,7 +8,7 @@ // import PublishEventUseCase from './publish-event.js' // import QueryEventsUseCase from './query-events.js' // import ManageSubscriptionUseCase from './manage-subscription.js' -import BlockchainUseCases from './blockchain/index.js' +import BlockchainUseCases from './full-node-blockchain-use-cases.js' class UseCases { constructor (localConfig = {}) { From 6096d52f845175ba29201f1f060fe9e29b7acfd8 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Sun, 9 Nov 2025 19:39:21 -0800 Subject: [PATCH 3/3] fix(tests): Updating unit tests --- src/adapters/full-node-rpc.js | 1 - src/use-cases/index.js | 6 - src/use-cases/manage-subscription.js | 216 ----------- src/use-cases/publish-event.js | 87 ----- src/use-cases/query-events.js | 41 --- test/unit/adapters/full-node-rpc-unit.js | 122 +++++++ test/unit/adapters/nostr-relay-unit.js | 304 ---------------- .../controllers/blockchain-controller-unit.js | 215 +++++++++++ .../unit/controllers/event-controller-unit.js | 187 ---------- test/unit/controllers/req-controller-unit.js | 344 ------------------ test/unit/controllers/rest-api-index-unit.js | 85 +++++ test/unit/mocks/nostr-relay-mocks.js | 58 --- .../full-node-blockchain-use-cases-unit.js | 137 +++++++ .../use-cases/manage-subscription-unit.js | 242 ------------ test/unit/use-cases/publish-event-unit.js | 146 -------- test/unit/use-cases/query-events-unit.js | 106 ------ 16 files changed, 559 insertions(+), 1738 deletions(-) delete mode 100644 src/use-cases/manage-subscription.js delete mode 100644 src/use-cases/publish-event.js delete mode 100644 src/use-cases/query-events.js create mode 100644 test/unit/adapters/full-node-rpc-unit.js delete mode 100644 test/unit/adapters/nostr-relay-unit.js create mode 100644 test/unit/controllers/blockchain-controller-unit.js delete mode 100644 test/unit/controllers/event-controller-unit.js delete mode 100644 test/unit/controllers/req-controller-unit.js create mode 100644 test/unit/controllers/rest-api-index-unit.js delete mode 100644 test/unit/mocks/nostr-relay-mocks.js create mode 100644 test/unit/use-cases/full-node-blockchain-use-cases-unit.js delete mode 100644 test/unit/use-cases/manage-subscription-unit.js delete mode 100644 test/unit/use-cases/publish-event-unit.js delete mode 100644 test/unit/use-cases/query-events-unit.js diff --git a/src/adapters/full-node-rpc.js b/src/adapters/full-node-rpc.js index c355e85..89709fd 100644 --- a/src/adapters/full-node-rpc.js +++ b/src/adapters/full-node-rpc.js @@ -20,7 +20,6 @@ class FullNodeRPCAdapter { rpcPassword, rpcTimeoutMs = 15000 } = this.config.fullNode - console.log('this.config.fullNode', this.config.fullNode) this.requestIdPrefix = this.config.fullNode.rpcRequestIdPrefix || 'psf-bch-api' diff --git a/src/use-cases/index.js b/src/use-cases/index.js index 32b8a52..fadb53a 100644 --- a/src/use-cases/index.js +++ b/src/use-cases/index.js @@ -5,9 +5,6 @@ */ // Local libraries -// import PublishEventUseCase from './publish-event.js' -// import QueryEventsUseCase from './query-events.js' -// import ManageSubscriptionUseCase from './manage-subscription.js' import BlockchainUseCases from './full-node-blockchain-use-cases.js' class UseCases { @@ -19,9 +16,6 @@ class UseCases { ) } - // this.publishEvent = new PublishEventUseCase({ adapters: this.adapters }) - // this.queryEvents = new QueryEventsUseCase({ adapters: this.adapters }) - // this.manageSubscription = new ManageSubscriptionUseCase({ adapters: this.adapters }) this.blockchain = new BlockchainUseCases({ adapters: this.adapters }) } diff --git a/src/use-cases/manage-subscription.js b/src/use-cases/manage-subscription.js deleted file mode 100644 index c272e3d..0000000 --- a/src/use-cases/manage-subscription.js +++ /dev/null @@ -1,216 +0,0 @@ -/* - Use case: Manage subscriptions for Server-Sent Events (SSE). - This encapsulates the business logic for creating and managing subscriptions. -*/ - -import wlogger from '../adapters/wlogger.js' - -class ManageSubscriptionUseCase { - constructor (localConfig = {}) { - this.adapters = localConfig.adapters - if (!this.adapters) { - throw new Error('Adapters instance required') - } - if (!this.adapters.nostrRelays || !Array.isArray(this.adapters.nostrRelays) || this.adapters.nostrRelays.length === 0) { - throw new Error('NostrRelay adapters array required') - } - // Map subscriptionId to { relaySubscriptions: Map, handlers, seenEventIds } - this.activeSubscriptions = new Map() - } - - /** - * Create a subscription for SSE streaming across all relays - * @param {string} subscriptionId - Unique subscription ID - * @param {Array} filters - Array of filter objects - * @param {Function} onEvent - Callback for events - * @param {Function} onEose - Callback for EOSE - * @param {Function} onClosed - Callback for CLOSED - * @returns {Promise} - */ - async createSubscription (subscriptionId, filters, onEvent, onEose, onClosed) { - try { - if (this.activeSubscriptions.has(subscriptionId)) { - throw new Error(`Subscription ${subscriptionId} already exists`) - } - - wlogger.info(`Creating subscription ${subscriptionId} across ${this.adapters.nostrRelays.length} relay(s)`) - - // Track seen event IDs to de-duplicate across relays - const seenEventIds = new Set() - - // Track EOSE and CLOSED status per relay - const relayStatuses = this.adapters.nostrRelays.map(() => ({ - eoseReceived: false, - closedReceived: false - })) - - // Create unified handlers that merge events from all relays - const handlers = { - onEvent: (event) => { - // De-duplicate events by ID across all relays - if (event && event.id && !seenEventIds.has(event.id)) { - seenEventIds.add(event.id) - if (onEvent) { - onEvent(event) - } - } - }, - onEose: () => { - // Call onEose only once when all relays have sent EOSE - // This is called from the per-relay handler only when all relays have EOSE - if (onEose) { - onEose() - } - }, - onClosed: (message) => { - if (onClosed) { - onClosed(message) - } - // Clean up subscription if any relay closes it - const subscriptionInfo = this.activeSubscriptions.get(subscriptionId) - if (subscriptionInfo && subscriptionInfo.eoseTimeoutId) { - clearTimeout(subscriptionInfo.eoseTimeoutId) - } - this.activeSubscriptions.delete(subscriptionId) - } - } - - // Create subscription per relay with unique subscription IDs - const relaySubscriptions = new Map() - const subscriptionPromises = this.adapters.nostrRelays.map(async (relay, index) => { - const relaySubscriptionId = `${subscriptionId}-relay-${index}` - relaySubscriptions.set(index, relaySubscriptionId) - - // Create per-relay handlers that update shared state - const relayHandlers = { - onEvent: (event) => { - handlers.onEvent(event) - }, - onEose: () => { - relayStatuses[index].eoseReceived = true - // Check if all relays have sent EOSE - if (relayStatuses.every(s => s.eoseReceived)) { - // Clear the timeout since we got EOSE from all relays - const subscriptionInfo = this.activeSubscriptions.get(subscriptionId) - if (subscriptionInfo && subscriptionInfo.eoseTimeoutId) { - clearTimeout(subscriptionInfo.eoseTimeoutId) - subscriptionInfo.eoseTimeoutId = null - } - handlers.onEose() - } - }, - onClosed: (message) => { - relayStatuses[index].closedReceived = true - handlers.onClosed(message) - } - } - - await relay.sendReq(relaySubscriptionId, filters, relayHandlers) - }) - - // Store subscription info - this.activeSubscriptions.set(subscriptionId, { - relaySubscriptions, - handlers, - seenEventIds, - relayStatuses, - eoseTimeoutId: null - }) - - // Subscribe to all relays concurrently - const results = await Promise.allSettled(subscriptionPromises) - - // Check if any relay subscription failed and clean up if so - const hasFailures = results.some(result => result.status === 'rejected') - if (hasFailures) { - const subscriptionInfo = this.activeSubscriptions.get(subscriptionId) - if (subscriptionInfo && subscriptionInfo.eoseTimeoutId) { - clearTimeout(subscriptionInfo.eoseTimeoutId) - } - this.activeSubscriptions.delete(subscriptionId) - const errors = results - .filter(result => result.status === 'rejected') - .map(result => result.reason) - throw new Error(`Failed to create subscription on some relays: ${errors.map(e => e.message).join(', ')}`) - } - - // Set up EOSE timeout fallback - if not all relays send EOSE within 10 seconds, call onEose anyway - const subscriptionInfo = this.activeSubscriptions.get(subscriptionId) - const EOSE_TIMEOUT_MS = 10000 // 10 seconds - subscriptionInfo.eoseTimeoutId = setTimeout(() => { - // Check if subscription still exists and if all relays have sent EOSE - if (this.activeSubscriptions.has(subscriptionId)) { - const currentInfo = this.activeSubscriptions.get(subscriptionId) - const allEoseReceived = currentInfo.relayStatuses.every(s => s.eoseReceived) - if (!allEoseReceived) { - wlogger.warn(`EOSE timeout reached for subscription ${subscriptionId} - calling onEose callback anyway`) - if (handlers.onEose) { - handlers.onEose() - } - } - } - }, EOSE_TIMEOUT_MS) - } catch (err) { - wlogger.error('Error creating subscription:', err) - const subscriptionInfo = this.activeSubscriptions.get(subscriptionId) - if (subscriptionInfo && subscriptionInfo.eoseTimeoutId) { - clearTimeout(subscriptionInfo.eoseTimeoutId) - } - this.activeSubscriptions.delete(subscriptionId) - throw err - } - } - - /** - * Close a subscription across all relays - * @param {string} subscriptionId - Subscription ID to close - * @returns {Promise} - */ - async closeSubscription (subscriptionId) { - try { - if (!this.activeSubscriptions.has(subscriptionId)) { - // Subscription doesn't exist - already closed, treat as success (idempotent) - wlogger.info(`Subscription ${subscriptionId} already closed or does not exist`) - return - } - - wlogger.info(`Closing subscription ${subscriptionId} across all relays`) - - const subscriptionInfo = this.activeSubscriptions.get(subscriptionId) - const { relaySubscriptions } = subscriptionInfo - - // Clear EOSE timeout if it exists - if (subscriptionInfo.eoseTimeoutId) { - clearTimeout(subscriptionInfo.eoseTimeoutId) - } - - // Close subscriptions on all relays concurrently - const closePromises = Array.from(relaySubscriptions.entries()).map(async ([relayIndex, relaySubscriptionId]) => { - try { - await this.adapters.nostrRelays[relayIndex].sendClose(relaySubscriptionId) - } catch (err) { - wlogger.warn(`Error closing subscription on relay ${relayIndex}:`, err.message) - } - }) - - await Promise.allSettled(closePromises) - this.activeSubscriptions.delete(subscriptionId) - } catch (err) { - wlogger.error('Error closing subscription:', err) - // Clean up even if there's an error - this.activeSubscriptions.delete(subscriptionId) - throw err - } - } - - /** - * Check if a subscription exists - * @param {string} subscriptionId - Subscription ID - * @returns {boolean} - */ - hasSubscription (subscriptionId) { - return this.activeSubscriptions.has(subscriptionId) - } -} - -export default ManageSubscriptionUseCase diff --git a/src/use-cases/publish-event.js b/src/use-cases/publish-event.js deleted file mode 100644 index a9230ab..0000000 --- a/src/use-cases/publish-event.js +++ /dev/null @@ -1,87 +0,0 @@ -/* - Use case: Publish a Nostr event to the relay. - This encapsulates the business logic for publishing events. -*/ - -import Event from '../entities/event.js' -import wlogger from '../adapters/wlogger.js' - -class PublishEventUseCase { - constructor (localConfig = {}) { - this.adapters = localConfig.adapters - if (!this.adapters) { - throw new Error('Adapters instance required') - } - if (!this.adapters.nostrRelays || !Array.isArray(this.adapters.nostrRelays) || this.adapters.nostrRelays.length === 0) { - throw new Error('NostrRelay adapters array required') - } - } - - /** - * Publish an event to all Nostr relays (broadcast) - * @param {Object} eventData - Event data (must be signed) - * @returns {Promise} Result with accepted status, message, and relay results - */ - async execute (eventData) { - try { - // Create event entity - const event = new Event(eventData) - - // Validate event - if (!event.isValid()) { - throw new Error('Invalid event structure') - } - - wlogger.info(`Publishing event ${event.id} (kind ${event.kind}) to ${this.adapters.nostrRelays.length} relay(s)`) - - // Broadcast event to all relays - const results = await this.adapters.broadcastEvent(event.toJSON()) - - // Aggregate results - const acceptedRelays = results.filter(r => r.accepted) - const rejectedRelays = results.filter(r => !r.accepted) - const failedRelays = results.filter(r => !r.success) - - const atLeastOneAccepted = acceptedRelays.length > 0 - const allAccepted = acceptedRelays.length === results.length && failedRelays.length === 0 - - // Build aggregated message - let message = '' - if (allAccepted) { - message = `Accepted by all ${acceptedRelays.length} relay(s)` - } else if (atLeastOneAccepted) { - message = `Accepted by ${acceptedRelays.length}/${results.length} relay(s)` - if (rejectedRelays.length > 0) { - message += `, rejected by ${rejectedRelays.length} relay(s)` - } - if (failedRelays.length > 0) { - message += `, failed to reach ${failedRelays.length} relay(s)` - } - } else { - message = `Rejected or failed by all ${results.length} relay(s)` - if (rejectedRelays.length > 0) { - const rejectionMessages = rejectedRelays.map(r => r.message).filter(m => m).join('; ') - if (rejectionMessages) { - message += `: ${rejectionMessages}` - } - } - } - - wlogger.info(`Event ${event.id} ${atLeastOneAccepted ? 'accepted' : 'rejected/failed'}: ${message}`) - - return { - accepted: atLeastOneAccepted, - message, - eventId: event.id, - relayResults: results, - acceptedCount: acceptedRelays.length, - totalRelays: results.length - } - } catch (err) { - wlogger.error('Error in PublishEventUseCase:', err) - throw err - } - } -} - -export default PublishEventUseCase diff --git a/src/use-cases/query-events.js b/src/use-cases/query-events.js deleted file mode 100644 index fb3cb26..0000000 --- a/src/use-cases/query-events.js +++ /dev/null @@ -1,41 +0,0 @@ -/* - Use case: Query events from the relay (stateless). - This encapsulates the business logic for querying events. -*/ - -import wlogger from '../adapters/wlogger.js' - -class QueryEventsUseCase { - constructor (localConfig = {}) { - this.adapters = localConfig.adapters - if (!this.adapters) { - throw new Error('Adapters instance required') - } - if (!this.adapters.nostrRelays || !Array.isArray(this.adapters.nostrRelays) || this.adapters.nostrRelays.length === 0) { - throw new Error('NostrRelay adapters array required') - } - } - - /** - * Query events with filters from all relays (stateless - returns immediately) - * @param {Array} filters - Array of filter objects - * @param {string} subscriptionId - Unique subscription ID - * @returns {Promise} Array of events (merged and de-duplicated from all relays) - */ - async execute (filters, subscriptionId) { - try { - wlogger.info(`Querying events with subscription ${subscriptionId} from ${this.adapters.nostrRelays.length} relay(s)`) - - // Query all relays concurrently and merge results - const events = await this.adapters.queryAllRelays(filters, subscriptionId) - - wlogger.info(`Query returned ${events.length} events from ${this.adapters.nostrRelays.length} relay(s)`) - return events - } catch (err) { - wlogger.error('Error in QueryEventsUseCase:', err) - throw err - } - } -} - -export default QueryEventsUseCase diff --git a/test/unit/adapters/full-node-rpc-unit.js b/test/unit/adapters/full-node-rpc-unit.js new file mode 100644 index 0000000..42794aa --- /dev/null +++ b/test/unit/adapters/full-node-rpc-unit.js @@ -0,0 +1,122 @@ +/* + Unit tests for FullNodeRPCAdapter. +*/ + +import { assert } from 'chai' +import sinon from 'sinon' +import axios from 'axios' + +import FullNodeRPCAdapter from '../../../src/adapters/full-node-rpc.js' + +describe('#full-node-rpc.js', () => { + let sandbox + let axiosCreateStub + let mockAxiosInstance + + const baseConfig = { + fullNode: { + rpcBaseUrl: 'http://127.0.0.1:8332', + rpcUsername: 'user', + rpcPassword: 'pass', + rpcTimeoutMs: 1000, + rpcRequestIdPrefix: 'test' + } + } + + beforeEach(() => { + sandbox = sinon.createSandbox() + mockAxiosInstance = { + post: sandbox.stub() + } + axiosCreateStub = sandbox.stub(axios, 'create').returns(mockAxiosInstance) + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('#constructor()', () => { + it('should throw if full node config is missing', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new FullNodeRPCAdapter({ config: {} }) + }, /Full node RPC configuration is required/) + }) + + it('should create axios client with provided configuration', () => { + // eslint-disable-next-line no-new + new FullNodeRPCAdapter({ config: baseConfig }) + + assert.isTrue(axiosCreateStub.calledOnce) + const options = axiosCreateStub.getCall(0).args[0] + assert.equal(options.baseURL, baseConfig.fullNode.rpcBaseUrl) + assert.equal(options.timeout, baseConfig.fullNode.rpcTimeoutMs) + assert.deepEqual(options.auth, { + username: baseConfig.fullNode.rpcUsername, + password: baseConfig.fullNode.rpcPassword + }) + }) + }) + + describe('#call()', () => { + it('should call RPC method and return result', async () => { + mockAxiosInstance.post.resolves({ data: { result: 'hash' } }) + const uut = new FullNodeRPCAdapter({ config: baseConfig }) + + const result = await uut.call('getbestblockhash', []) + + assert.equal(result, 'hash') + assert.isTrue(mockAxiosInstance.post.calledOnce) + const [, payload] = mockAxiosInstance.post.getCall(0).args + assert.deepEqual(payload, { + jsonrpc: '1.0', + id: 'test-getbestblockhash', + method: 'getbestblockhash', + params: [] + }) + }) + + it('should use custom request id when provided', async () => { + mockAxiosInstance.post.resolves({ data: { result: 123 } }) + const uut = new FullNodeRPCAdapter({ config: baseConfig }) + + await uut.call('getblockcount', [], 'custom-id') + + const [, payload] = mockAxiosInstance.post.getCall(0).args + assert.equal(payload.id, 'custom-id') + }) + + it('should throw formatted error when RPC returns error', async () => { + mockAxiosInstance.post.resolves({ + data: { + error: { message: 'RPC error' } + } + }) + const uut = new FullNodeRPCAdapter({ config: baseConfig }) + + try { + await uut.call('failing', []) + assert.fail('Unexpected success') + } catch (err) { + assert.equal(err.message, 'RPC error') + assert.equal(err.status, 400) + } + }) + + it('should translate network errors into 503 status', async () => { + mockAxiosInstance.post.rejects(new Error('ENOTFOUND fullnode')) + const uut = new FullNodeRPCAdapter({ config: baseConfig }) + + try { + await uut.call('getblockcount', []) + assert.fail('Unexpected success') + } catch (err) { + assert.equal( + err.message, + 'Network error: Could not communicate with full node or other external service.' + ) + assert.equal(err.status, 503) + } + }) + }) +}) diff --git a/test/unit/adapters/nostr-relay-unit.js b/test/unit/adapters/nostr-relay-unit.js deleted file mode 100644 index 1baa9cf..0000000 --- a/test/unit/adapters/nostr-relay-unit.js +++ /dev/null @@ -1,304 +0,0 @@ -/* - Unit tests for NostrRelayAdapter. -*/ - -/* -// npm libraries -import { assert } from 'chai' -import sinon from 'sinon' - -// Mocking data libraries -import { - mockKind1Event, - validEventId -} from '../mocks/event-mocks.js' -import { - mockOkAccepted, - mockEventMessage, - mockEoseMessage, - mockClosedMessage -} from '../mocks/nostr-relay-mocks.js' - -// Unit under test -// Note: WebSocket mocking for ES modules is complex. These tests focus on -// testing the adapter's logic that can be tested without full WebSocket mocking. -import NostrRelayAdapter from '../../../src/adapters/nostr-relay.js' - -describe('#nostr-relay.js', () => { - let sandbox - let uut - - beforeEach(() => { - sandbox = sinon.createSandbox() - - uut = new NostrRelayAdapter({ - relayUrl: 'wss://test-relay.example.com' - }) - }) - - afterEach(() => { - sandbox.restore() - }) - - describe('#connect()', () => { - it('should return immediately if already connected', async () => { - // Manually set connection state - uut.isConnected = true - uut.ws = { close: sandbox.stub() } - - await uut.connect() - - // Should not create new connection - assert.isTrue(uut.isConnected) - }) - - // Note: Full WebSocket connection testing requires integration tests - // due to ES module import limitations - }) - - describe('#sendEvent()', () => { - it('should queue message when disconnected', async () => { - uut.isConnected = false - uut.ws = null - - // Mock connect to resolve immediately - uut.connect = sandbox.stub().resolves() - - // Start sending (will queue) - uut.sendEvent(mockKind1Event).catch(() => { - // Expected to fail or timeout without real WebSocket - }) - - // Should queue message and attempt connection - // Wait a bit for async operations - await new Promise(resolve => setTimeout(resolve, 10)) - assert.isTrue(uut.pendingMessages.length > 0 || uut.connect.called) - }) - - it('should set up event resolver', async () => { - uut.isConnected = true - uut.ws = { send: sandbox.stub() } - // Mock sendMessage to resolve immediately - uut.sendMessage = sandbox.stub().resolves() - - // Start sending - const sendPromise = uut.sendEvent(mockKind1Event).catch(() => { - // Expected without real WebSocket response - }) - - // Wait a tick for Promise constructor to run - await new Promise(resolve => setImmediate(resolve)) - - // Verify resolver was set up - assert.isTrue(uut.eventResolvers.has(mockKind1Event.id)) - - // Clean up - uut.eventResolvers.delete(mockKind1Event.id) - // Prevent timeout error - sendPromise.catch(() => {}) - }) - - // Note: Full sendEvent testing with WebSocket responses requires integration tests - }) - - describe('#sendReq()', () => { - it('should store handlers for subscription', async () => { - uut.isConnected = true - uut.ws = { send: sandbox.stub() } - uut.connect = sandbox.stub().resolves() - - const subscriptionId = 'test-sub-123' - const filters = [{ kinds: [1] }] - const handlers = { - onEvent: sandbox.stub(), - onEose: sandbox.stub(), - onClosed: sandbox.stub() - } - - await uut.sendReq(subscriptionId, filters, handlers) - - // Assert handlers were stored - assert.isTrue(uut.subscriptionHandlers.has(subscriptionId)) - assert.deepEqual(uut.subscriptionHandlers.get(subscriptionId), handlers) - }) - - it('should connect before sending if disconnected', async () => { - uut.isConnected = false - uut.connect = sandbox.stub().resolves() - - const subscriptionId = 'test-sub-123' - const filters = [{ kinds: [1] }] - const handlers = {} - - await uut.sendReq(subscriptionId, filters, handlers) - - assert.isTrue(uut.connect.called) - }) - }) - - describe('#sendClose()', () => { - it('should clean up handlers for subscription', async () => { - uut.isConnected = true - uut.ws = { send: sandbox.stub() } - - const subscriptionId = 'test-sub-123' - uut.subscriptionHandlers.set(subscriptionId, {}) - uut.messageHandlers.set(subscriptionId, {}) - - await uut.sendClose(subscriptionId) - - // Assert handlers were cleaned up - assert.isFalse(uut.subscriptionHandlers.has(subscriptionId)) - assert.isFalse(uut.messageHandlers.has(subscriptionId)) - }) - }) - - describe('#handleMessage()', () => { - it('should handle EVENT message', () => { - // Use the subscription ID from the mock message - const subscriptionId = 'subscription-id-123' - const onEventHandler = sandbox.stub() - uut.subscriptionHandlers.set(subscriptionId, { - onEvent: onEventHandler - }) - - const message = mockEventMessage - uut.handleMessage(message) - - assert.isTrue(onEventHandler.calledOnce) - assert.deepEqual(onEventHandler.getCall(0).args[0], mockKind1Event) - }) - - it('should handle EOSE message', () => { - // Use the subscription ID from the mock message - const subscriptionId = 'subscription-id-123' - const onEoseHandler = sandbox.stub() - uut.subscriptionHandlers.set(subscriptionId, { - onEose: onEoseHandler - }) - - const message = mockEoseMessage - uut.handleMessage(message) - - assert.isTrue(onEoseHandler.calledOnce) - }) - - it('should handle CLOSED message', () => { - // Use the subscription ID from the mock message - const subscriptionId = 'subscription-id-123' - const onClosedHandler = sandbox.stub() - uut.subscriptionHandlers.set(subscriptionId, { - onClosed: onClosedHandler - }) - - const message = mockClosedMessage - uut.handleMessage(message) - - assert.isTrue(onClosedHandler.calledOnce) - assert.equal(onClosedHandler.getCall(0).args[0], 'subscription closed') - }) - - it('should handle OK message', () => { - const eventId = validEventId - let resolver = null - uut.eventResolvers.set(eventId, (result) => { - resolver = result - }) - - const message = mockOkAccepted - uut.handleMessage(message) - - assert.isNotNull(resolver) - assert.isTrue(resolver.accepted) - assert.isFalse(uut.eventResolvers.has(eventId)) - }) - - it('should handle NOTICE message', () => { - const message = ['NOTICE', 'rate limited'] - // Should not throw - uut.handleMessage(message) - }) - - it('should ignore invalid message format', () => { - const message = 'invalid' - // Should not throw - uut.handleMessage(message) - }) - - it('should ignore empty messages', () => { - const message = [] - // Should not throw - uut.handleMessage(message) - }) - }) - - describe('#disconnect()', () => { - it('should disconnect from relay', async () => { - const mockWs = { close: sandbox.stub() } - uut.isConnected = true - uut.ws = mockWs - - await uut.disconnect() - - assert.isTrue(mockWs.close.called) - assert.isFalse(uut.isConnected) - assert.isNull(uut.ws) - }) - - it('should handle disconnect when already disconnected', async () => { - uut.isConnected = false - uut.ws = null - - await uut.disconnect() - - assert.isFalse(uut.isConnected) - }) - }) - - describe('#handleError()', () => { - it('should handle WebSocket errors', () => { - uut.isConnected = true - const error = new Error('WebSocket error') - - uut.handleError(error) - - assert.isFalse(uut.isConnected) - }) - }) - - describe('#handleClose()', () => { - it('should attempt reconnection on close', async () => { - uut.isConnected = true - uut.reconnectAttempts = 0 - uut.maxReconnectAttempts = 5 - - // Mock connect to avoid actual connection - uut.connect = sandbox.stub().resolves() - - uut.handleClose() - - // Wait for reconnection attempt - await new Promise(resolve => setTimeout(resolve, 110)) - - // Should attempt reconnection - assert.equal(uut.reconnectAttempts, 1) - }) - - it('should stop reconnecting after max attempts', async () => { - uut.isConnected = true - uut.reconnectAttempts = 5 - uut.maxReconnectAttempts = 5 - - uut.connect = sandbox.stub().resolves() - - uut.handleClose() - - await new Promise(resolve => setTimeout(resolve, 110)) - - // Should not increment beyond max - assert.equal(uut.reconnectAttempts, 5) - }) - }) -}) - -*/ diff --git a/test/unit/controllers/blockchain-controller-unit.js b/test/unit/controllers/blockchain-controller-unit.js new file mode 100644 index 0000000..2c150de --- /dev/null +++ b/test/unit/controllers/blockchain-controller-unit.js @@ -0,0 +1,215 @@ +/* + Unit tests for BlockchainRESTController. +*/ + +import { assert } from 'chai' +import sinon from 'sinon' + +import BlockchainRESTController from '../../../src/controllers/rest-api/full-node/blockchain/controller.js' +import { + createMockRequest, + createMockResponse +} from '../mocks/controller-mocks.js' + +describe('#blockchain-controller.js', () => { + let sandbox + let mockUseCases + let mockAdapters + let uut + + const createBlockchainUseCaseStubs = () => ({ + getBestBlockHash: sandbox.stub().resolves('hash'), + getBlockchainInfo: sandbox.stub().resolves({}), + getBlockCount: sandbox.stub().resolves(123), + getBlockHeader: sandbox.stub().resolves({ header: true }), + getBlockHeaders: sandbox.stub().resolves(['header']), + getChainTips: sandbox.stub().resolves(['tip']), + getDifficulty: sandbox.stub().resolves(1), + getMempoolEntry: sandbox.stub().resolves({}), + getMempoolEntries: sandbox.stub().resolves([]), + getMempoolAncestors: sandbox.stub().resolves([]), + getMempoolInfo: sandbox.stub().resolves({ size: 1 }), + getRawMempool: sandbox.stub().resolves(['tx']), + getTxOut: sandbox.stub().resolves({ value: 1 }), + getTxOutProof: sandbox.stub().resolves('proof'), + getTxOutProofs: sandbox.stub().resolves(['proof']), + verifyTxOutProof: sandbox.stub().resolves(['txid']), + verifyTxOutProofs: sandbox.stub().resolves([['txid']]), + getBlock: sandbox.stub().resolves({ hash: 'abc' }), + getBlockHash: sandbox.stub().resolves('blockhash') + }) + + beforeEach(() => { + sandbox = sinon.createSandbox() + mockAdapters = { + fullNode: { + validateArraySize: sandbox.stub().returns(true) + } + } + mockUseCases = { + blockchain: createBlockchainUseCaseStubs() + } + uut = new BlockchainRESTController({ + adapters: mockAdapters, + useCases: mockUseCases + }) + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('#constructor()', () => { + it('should require adapters', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new BlockchainRESTController({ useCases: mockUseCases }) + }, /Adapters library required/) + }) + + it('should require blockchain use cases', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new BlockchainRESTController({ adapters: mockAdapters, useCases: {} }) + }, /Blockchain use cases required/) + }) + }) + + describe('#root()', () => { + it('should return service status', async () => { + const req = createMockRequest() + const res = createMockResponse() + + await uut.root(req, res) + + assert.equal(res.statusValue, 200) + assert.deepEqual(res.jsonData, { status: 'blockchain' }) + }) + }) + + describe('#getBestBlockHash()', () => { + it('should return hash on success', async () => { + const req = createMockRequest() + const res = createMockResponse() + + await uut.getBestBlockHash(req, res) + + assert.equal(res.statusValue, 200) + assert.equal(res.jsonData, 'hash') + assert.isTrue(mockUseCases.blockchain.getBestBlockHash.calledOnce) + }) + + it('should handle errors via handleError()', async () => { + const error = new Error('failure') + error.status = 422 + mockUseCases.blockchain.getBestBlockHash.rejects(error) + const req = createMockRequest() + const res = createMockResponse() + + await uut.getBestBlockHash(req, res) + + assert.equal(res.statusValue, 422) + assert.deepEqual(res.jsonData, { error: 'failure' }) + }) + }) + + describe('#getBlockHeaderSingle()', () => { + it('should return 400 if hash is missing', async () => { + const req = createMockRequest() + const res = createMockResponse() + + await uut.getBlockHeaderSingle(req, res) + + assert.equal(res.statusValue, 400) + assert.property(res.jsonData, 'error') + }) + + it('should call use case with verbose flag', async () => { + const hash = 'a'.repeat(64) + const req = createMockRequest({ + params: { hash }, + query: { verbose: 'true' } + }) + const res = createMockResponse() + + await uut.getBlockHeaderSingle(req, res) + + assert.equal(res.statusValue, 200) + assert.isTrue( + mockUseCases.blockchain.getBlockHeader.calledOnceWithExactly({ + hash, + verbose: true + }) + ) + }) + }) + + describe('#getBlockHeaderBulk()', () => { + it('should return error if hashes is not array', async () => { + const req = createMockRequest({ + body: { hashes: 'not-an-array' }, + locals: {} + }) + const res = createMockResponse() + + await uut.getBlockHeaderBulk(req, res) + + assert.equal(res.statusValue, 400) + assert.include(res.jsonData.error, 'hashes needs to be an array') + }) + + it('should validate array size and call use case', async () => { + const hash = 'a'.repeat(64) + const req = createMockRequest({ + body: { hashes: [hash], verbose: true }, + locals: { proLimit: false } + }) + const res = createMockResponse() + mockUseCases.blockchain.getBlockHeaders.resolves(['result']) + + await uut.getBlockHeaderBulk(req, res) + + assert.equal(res.statusValue, 200) + assert.deepEqual(res.jsonData, ['result']) + assert.isTrue( + mockAdapters.fullNode.validateArraySize.calledOnceWithExactly(1, { isProUser: false }) + ) + assert.isTrue( + mockUseCases.blockchain.getBlockHeaders.calledOnceWithExactly({ + hashes: [hash], + verbose: true + }) + ) + }) + + it('should return error if array size invalid', async () => { + mockAdapters.fullNode.validateArraySize.returns(false) + const req = createMockRequest({ + body: { hashes: ['a'.repeat(64)] }, + locals: {} + }) + const res = createMockResponse() + + await uut.getBlockHeaderBulk(req, res) + + assert.equal(res.statusValue, 400) + assert.equal(res.jsonData.error, 'Array too large.') + }) + }) + + describe('#verifyTxOutProofBulk()', () => { + it('should flatten proof responses', async () => { + mockUseCases.blockchain.verifyTxOutProofs.resolves([['txid-a'], ['txid-b']]) + const req = createMockRequest({ + body: { proofs: ['proof-a', 'proof-b'] }, + locals: {} + }) + const res = createMockResponse() + + await uut.verifyTxOutProofBulk(req, res) + + assert.equal(res.statusValue, 200) + assert.deepEqual(res.jsonData, ['txid-a', 'txid-b']) + }) + }) +}) diff --git a/test/unit/controllers/event-controller-unit.js b/test/unit/controllers/event-controller-unit.js deleted file mode 100644 index bdbac92..0000000 --- a/test/unit/controllers/event-controller-unit.js +++ /dev/null @@ -1,187 +0,0 @@ -/* - Unit tests for EventRESTControllerLib. -*/ - -// npm libraries -import { assert } from 'chai' -import sinon from 'sinon' - -// Mocking data libraries -import { - mockKind1Event, - mockKind0Event -} from '../mocks/event-mocks.js' -import { - createMockRequestWithBody, - createMockResponse -} from '../mocks/controller-mocks.js' - -// Unit under test -import EventRESTControllerLib from '../../../src/controllers/rest-api/event/controller.js' - -describe('#event-controller.js', () => { - let sandbox - let mockUseCases - let uut - - beforeEach(() => { - sandbox = sinon.createSandbox() - - // Create mock use cases - mockUseCases = { - publishEvent: { - execute: sandbox.stub() - } - } - - uut = new EventRESTControllerLib({ - adapters: {}, - useCases: mockUseCases - }) - }) - - afterEach(() => { - sandbox.restore() - }) - - describe('#publishEvent()', () => { - it('should successfully publish an event', async () => { - const req = createMockRequestWithBody(mockKind1Event) - const res = createMockResponse() - - mockUseCases.publishEvent.execute.resolves({ - accepted: true, - message: 'event saved', - eventId: mockKind1Event.id - }) - - await uut.publishEvent(req, res) - - // Assert use case was called - assert.isTrue(mockUseCases.publishEvent.execute.calledOnce) - assert.deepEqual(mockUseCases.publishEvent.execute.getCall(0).args[0], mockKind1Event) - - // Assert response - assert.equal(res.statusValue, 200) - assert.property(res.jsonData, 'accepted') - assert.isTrue(res.jsonData.accepted) - assert.equal(res.jsonData.eventId, mockKind1Event.id) - }) - - it('should return 400 when event is rejected', async () => { - const req = createMockRequestWithBody(mockKind1Event) - const res = createMockResponse() - - mockUseCases.publishEvent.execute.resolves({ - accepted: false, - message: 'duplicate: event already exists', - eventId: mockKind1Event.id - }) - - await uut.publishEvent(req, res) - - // Assert response status is 400 - assert.equal(res.statusValue, 400) - assert.property(res.jsonData, 'accepted') - assert.isFalse(res.jsonData.accepted) - }) - - it('should return 400 when event data is missing', async () => { - const req = createMockRequestWithBody(null) - const res = createMockResponse() - - await uut.publishEvent(req, res) - - // Assert use case was not called - assert.isFalse(mockUseCases.publishEvent.execute.called) - - // Assert error response - assert.equal(res.statusValue, 400) - assert.property(res.jsonData, 'error') - assert.include(res.jsonData.error, 'Event data is required') - }) - - it('should handle use case errors', async () => { - const req = createMockRequestWithBody(mockKind1Event) - const res = createMockResponse() - - mockUseCases.publishEvent.execute.rejects(new Error('Network error')) - - await uut.publishEvent(req, res) - - // Assert error response - assert.equal(res.statusValue, 500) - assert.property(res.jsonData, 'error') - assert.include(res.jsonData.error, 'Network error') - }) - - it('should return 400 for validation errors', async () => { - const req = createMockRequestWithBody(mockKind1Event) - const res = createMockResponse() - - mockUseCases.publishEvent.execute.rejects(new Error('Invalid event structure')) - - await uut.publishEvent(req, res) - - // Assert validation error returns 400 - assert.equal(res.statusValue, 400) - assert.property(res.jsonData, 'error') - assert.include(res.jsonData.error, 'Invalid event structure') - }) - - it('should handle errors with missing message', async () => { - const req = createMockRequestWithBody(mockKind1Event) - const res = createMockResponse() - - const error = new Error() - error.message = undefined - mockUseCases.publishEvent.execute.rejects(error) - - await uut.publishEvent(req, res) - - // Assert error response with default message - assert.equal(res.statusValue, 500) - assert.property(res.jsonData, 'error') - assert.equal(res.jsonData.error, 'Internal server error') - }) - - it('should publish different event kinds', async () => { - const req = createMockRequestWithBody(mockKind0Event) - const res = createMockResponse() - - mockUseCases.publishEvent.execute.resolves({ - accepted: true, - message: 'event saved', - eventId: mockKind0Event.id - }) - - await uut.publishEvent(req, res) - - assert.isTrue(mockUseCases.publishEvent.execute.calledOnce) - assert.equal(res.statusValue, 200) - assert.isTrue(res.jsonData.accepted) - }) - }) - - describe('#constructor()', () => { - it('should require adapters instance', () => { - try { - // eslint-disable-next-line no-new - new EventRESTControllerLib({ useCases: mockUseCases }) - assert.equal(true, false, 'unexpected result') - } catch (err) { - assert.include(err.message, 'Adapters library required') - } - }) - - it('should require useCases instance', () => { - try { - // eslint-disable-next-line no-new - new EventRESTControllerLib({ adapters: {} }) - assert.equal(true, false, 'unexpected result') - } catch (err) { - assert.include(err.message, 'Use Cases library required') - } - }) - }) -}) diff --git a/test/unit/controllers/req-controller-unit.js b/test/unit/controllers/req-controller-unit.js deleted file mode 100644 index b9ff10e..0000000 --- a/test/unit/controllers/req-controller-unit.js +++ /dev/null @@ -1,344 +0,0 @@ -/* - Unit tests for ReqRESTControllerLib. -*/ - -// npm libraries -import { assert } from 'chai' -import sinon from 'sinon' - -// Mocking data libraries -import { mockEventsArray } from '../mocks/nostr-relay-mocks.js' -import { - createMockRequestWithParams, - createMockResponse -} from '../mocks/controller-mocks.js' - -// Unit under test -import ReqRESTControllerLib from '../../../src/controllers/rest-api/req/controller.js' - -describe('#req-controller.js', () => { - let sandbox - let mockUseCases - let uut - - beforeEach(() => { - sandbox = sinon.createSandbox() - - // Create mock use cases - mockUseCases = { - queryEvents: { - execute: sandbox.stub() - }, - manageSubscription: { - createSubscription: sandbox.stub(), - closeSubscription: sandbox.stub() - } - } - - uut = new ReqRESTControllerLib({ - adapters: {}, - useCases: mockUseCases - }) - }) - - afterEach(() => { - sandbox.restore() - }) - - describe('#queryEvents()', () => { - it('should successfully query events with filters as JSON string', async () => { - const filters = [{ kinds: [1], limit: 10 }] - const filtersJson = JSON.stringify(filters) - const req = createMockRequestWithParams({ subId: 'test-sub-123' }) - req.query = { filters: filtersJson } - const res = createMockResponse() - - mockUseCases.queryEvents.execute.resolves(mockEventsArray) - - await uut.queryEvents(req, res) - - // Assert use case was called with parsed filters - assert.isTrue(mockUseCases.queryEvents.execute.calledOnce) - const executeArgs = mockUseCases.queryEvents.execute.getCall(0).args - assert.deepEqual(executeArgs[0], filters) - assert.equal(executeArgs[1], 'test-sub-123') - - // Assert response - assert.equal(res.statusValue, 200) - assert.isArray(res.jsonData) - assert.equal(res.jsonData.length, mockEventsArray.length) - }) - - it('should successfully query events with individual query params', async () => { - const req = createMockRequestWithParams({ subId: 'test-sub-123' }) - req.query = { - kinds: JSON.stringify([1]), - authors: JSON.stringify(['abc123']), - limit: '10' - } - const res = createMockResponse() - - mockUseCases.queryEvents.execute.resolves(mockEventsArray) - - await uut.queryEvents(req, res) - - // Assert use case was called - assert.isTrue(mockUseCases.queryEvents.execute.calledOnce) - const executeArgs = mockUseCases.queryEvents.execute.getCall(0).args - assert.isArray(executeArgs[0]) - assert.equal(executeArgs[0][0].kinds[0], 1) - assert.equal(executeArgs[0][0].authors[0], 'abc123') - assert.equal(executeArgs[0][0].limit, 10) - }) - - it('should handle empty filters', async () => { - const req = createMockRequestWithParams({ subId: 'test-sub-123' }) - req.query = {} - const res = createMockResponse() - - mockUseCases.queryEvents.execute.resolves([]) - - await uut.queryEvents(req, res) - - // Assert use case was called with empty filters array - assert.isTrue(mockUseCases.queryEvents.execute.calledOnce) - const executeArgs = mockUseCases.queryEvents.execute.getCall(0).args - assert.deepEqual(executeArgs[0], [{}]) - }) - - it('should return 400 when subscription ID is missing', async () => { - const req = createMockRequestWithParams({}) - const res = createMockResponse() - - await uut.queryEvents(req, res) - - // Assert use case was not called - assert.isFalse(mockUseCases.queryEvents.execute.called) - - // Assert error response - assert.equal(res.statusValue, 400) - assert.property(res.jsonData, 'error') - assert.include(res.jsonData.error, 'Subscription ID is required') - }) - - it('should return 400 when filters JSON is invalid', async () => { - const req = createMockRequestWithParams({ subId: 'test-sub-123' }) - req.query = { filters: 'invalid-json{' } - const res = createMockResponse() - - await uut.queryEvents(req, res) - - // Assert error response - assert.equal(res.statusValue, 400) - assert.property(res.jsonData, 'error') - assert.include(res.jsonData.error, 'Invalid filters JSON') - }) - - it('should handle use case errors', async () => { - const req = createMockRequestWithParams({ subId: 'test-sub-123' }) - req.query = { filters: JSON.stringify([{ kinds: [1] }]) } - const res = createMockResponse() - - mockUseCases.queryEvents.execute.rejects(new Error('Query failed')) - - await uut.queryEvents(req, res) - - // Assert error response - assert.equal(res.statusValue, 500) - assert.property(res.jsonData, 'error') - assert.include(res.jsonData.error, 'Query failed') - }) - }) - - describe('#createSubscription()', () => { - it('should successfully create SSE subscription', async () => { - const req = createMockRequestWithParams({ subId: 'test-sub-123' }) - req.body = { kinds: [1] } - const res = createMockResponse() - - mockUseCases.manageSubscription.createSubscription.resolves() - - await uut.createSubscription(req, res) - - // Assert use case was called - assert.isTrue(mockUseCases.manageSubscription.createSubscription.calledOnce) - const createArgs = mockUseCases.manageSubscription.createSubscription.getCall(0).args - assert.equal(createArgs[0], 'test-sub-123') - assert.isArray(createArgs[1]) - assert.equal(createArgs[1][0].kinds[0], 1) - assert.isFunction(createArgs[2]) // onEvent - assert.isFunction(createArgs[3]) // onEose - assert.isFunction(createArgs[4]) // onClosed - - // Assert SSE headers - assert.equal(res.headers['Content-Type'], 'text/event-stream') - assert.equal(res.headers['Cache-Control'], 'no-cache') - assert.equal(res.headers.Connection, 'keep-alive') - - // Assert initial connection message was written - assert.isTrue(res.writeData.length > 0) - }) - - it('should return 400 when subscription ID is missing', async () => { - const req = createMockRequestWithParams({}) - req.body = { kinds: [1] } - const res = createMockResponse() - - await uut.createSubscription(req, res) - - // Assert error response - assert.equal(res.statusValue, 400) - assert.property(res.jsonData, 'error') - assert.include(res.jsonData.error, 'Subscription ID is required') - }) - - it('should return 400 when filters are missing', async () => { - const req = createMockRequestWithParams({ subId: 'test-sub-123' }) - req.body = {} - const res = createMockResponse() - - await uut.createSubscription(req, res) - - // Assert error response - assert.equal(res.statusValue, 400) - assert.property(res.jsonData, 'error') - assert.include(res.jsonData.error, 'Filters are required') - }) - - it('should handle filters as array', async () => { - const req = createMockRequestWithParams({ subId: 'test-sub-123' }) - req.body = [{ kinds: [1] }, { kinds: [3] }] - const res = createMockResponse() - - mockUseCases.manageSubscription.createSubscription.resolves() - - await uut.createSubscription(req, res) - - // Assert filters array was passed correctly - const createArgs = mockUseCases.manageSubscription.createSubscription.getCall(0).args - assert.isArray(createArgs[1]) - assert.equal(createArgs[1].length, 2) - }) - - it('should handle client disconnect', async () => { - const req = createMockRequestWithParams({ subId: 'test-sub-123' }) - req.body = { kinds: [1] } - req.on = sinon.stub() - const res = createMockResponse() - - mockUseCases.manageSubscription.createSubscription.resolves() - mockUseCases.manageSubscription.closeSubscription.resolves() - - await uut.createSubscription(req, res) - - // Assert close handler was set up - assert.isTrue(req.on.calledWith('close')) - - // Simulate client disconnect - const closeCallback = req.on.getCall(0).args[1] - await closeCallback() - - // Assert closeSubscription was called - assert.isTrue(mockUseCases.manageSubscription.closeSubscription.calledOnce) - assert.equal(mockUseCases.manageSubscription.closeSubscription.getCall(0).args[0], 'test-sub-123') - }) - }) - - describe('#closeSubscription()', () => { - it('should successfully close a subscription', async () => { - const req = createMockRequestWithParams({ subId: 'test-sub-123' }) - const res = createMockResponse() - - mockUseCases.manageSubscription.closeSubscription.resolves() - - await uut.closeSubscription(req, res) - - // Assert use case was called - assert.isTrue(mockUseCases.manageSubscription.closeSubscription.calledOnce) - assert.equal(mockUseCases.manageSubscription.closeSubscription.getCall(0).args[0], 'test-sub-123') - - // Assert response - assert.equal(res.statusValue, 200) - assert.property(res.jsonData, 'message') - assert.include(res.jsonData.message, 'closed successfully') - }) - - it('should return 400 when subscription ID is missing', async () => { - const req = createMockRequestWithParams({}) - const res = createMockResponse() - - await uut.closeSubscription(req, res) - - // Assert error response - assert.equal(res.statusValue, 400) - assert.property(res.jsonData, 'error') - assert.include(res.jsonData.error, 'Subscription ID is required') - }) - - it('should handle use case errors', async () => { - const req = createMockRequestWithParams({ subId: 'test-sub-123' }) - const res = createMockResponse() - - mockUseCases.manageSubscription.closeSubscription.rejects(new Error('Relay connection error')) - - await uut.closeSubscription(req, res) - - // Assert error response - assert.equal(res.statusValue, 500) - assert.property(res.jsonData, 'error') - assert.include(res.jsonData.error, 'Relay connection error') - }) - - it('should handle idempotent close (subscription already closed)', async () => { - const req = createMockRequestWithParams({ subId: 'test-sub-123' }) - const res = createMockResponse() - - // closeSubscription resolves successfully even if subscription doesn't exist - mockUseCases.manageSubscription.closeSubscription.resolves() - - await uut.closeSubscription(req, res) - - // Assert success response even for already-closed subscription - assert.equal(res.statusValue, 200) - assert.property(res.jsonData, 'message') - assert.include(res.jsonData.message, 'closed successfully') - }) - }) - - describe('#createSubscriptionPut()', () => { - it('should call createSubscription', async () => { - const req = createMockRequestWithParams({ subId: 'test-sub-123' }) - req.body = { kinds: [1] } - const res = createMockResponse() - - mockUseCases.manageSubscription.createSubscription.resolves() - - await uut.createSubscriptionPut(req, res) - - // Assert createSubscription was called - assert.isTrue(mockUseCases.manageSubscription.createSubscription.calledOnce) - }) - }) - - describe('#constructor()', () => { - it('should require adapters instance', () => { - try { - // eslint-disable-next-line no-new - new ReqRESTControllerLib({ useCases: mockUseCases }) - assert.equal(true, false, 'unexpected result') - } catch (err) { - assert.include(err.message, 'Adapters library required') - } - }) - - it('should require useCases instance', () => { - try { - // eslint-disable-next-line no-new - new ReqRESTControllerLib({ adapters: {} }) - assert.equal(true, false, 'unexpected result') - } catch (err) { - assert.include(err.message, 'Use Cases library required') - } - }) - }) -}) diff --git a/test/unit/controllers/rest-api-index-unit.js b/test/unit/controllers/rest-api-index-unit.js new file mode 100644 index 0000000..17f84ce --- /dev/null +++ b/test/unit/controllers/rest-api-index-unit.js @@ -0,0 +1,85 @@ +/* + Unit tests for RESTControllers index. +*/ + +import { assert } from 'chai' +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' + +describe('#controllers/rest-api/index.js', () => { + let sandbox + let mockAdapters + let mockUseCases + + const createBlockchainUseCaseStubs = () => ({ + getBestBlockHash: () => {}, + getBlockchainInfo: () => {}, + getBlockCount: () => {}, + getBlockHeader: () => {}, + getBlockHeaders: () => {}, + getChainTips: () => {}, + getDifficulty: () => {}, + getMempoolEntry: () => {}, + getMempoolEntries: () => {}, + getMempoolAncestors: () => {}, + getMempoolInfo: () => {}, + getRawMempool: () => {}, + getTxOut: () => {}, + getTxOutProof: () => {}, + getTxOutProofs: () => {}, + verifyTxOutProof: () => {}, + verifyTxOutProofs: () => {}, + getBlock: () => {}, + getBlockHash: () => {} + }) + + beforeEach(() => { + sandbox = sinon.createSandbox() + mockAdapters = { + fullNode: { + validateArraySize: sandbox.stub().returns(true) + } + } + mockUseCases = { + blockchain: createBlockchainUseCaseStubs() + } + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('#constructor()', () => { + it('should require adapters instance', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new RESTControllers({ useCases: mockUseCases }) + }, /Adapters library required/) + }) + + it('should require useCases instance', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new RESTControllers({ adapters: mockAdapters }) + }, /Use Cases library required/) + }) + }) + + describe('#attachRESTControllers()', () => { + it('should instantiate blockchain router and attach to app', () => { + const attachStub = sandbox.stub(BlockchainRouter.prototype, 'attach') + const restControllers = new RESTControllers({ + adapters: mockAdapters, + useCases: mockUseCases + }) + const app = {} + + restControllers.attachRESTControllers(app) + + assert.isTrue(attachStub.calledOnce) + assert.equal(attachStub.getCall(0).args[0], app) + }) + }) +}) diff --git a/test/unit/mocks/nostr-relay-mocks.js b/test/unit/mocks/nostr-relay-mocks.js deleted file mode 100644 index 5ab77f3..0000000 --- a/test/unit/mocks/nostr-relay-mocks.js +++ /dev/null @@ -1,58 +0,0 @@ -/* - Mock responses from Nostr relay for unit tests. - Contains mock messages that would come from a Nostr relay WebSocket. -*/ - -import { mockKind1Event, validEventId } from './event-mocks.js' - -// Mock OK response (event accepted) -const mockOkAccepted = ['OK', validEventId, true, 'event saved'] - -// Mock OK response (event rejected) -const mockOkRejected = ['OK', validEventId, false, 'duplicate: event already exists'] - -// Mock EVENT message (from relay) -const mockEventMessage = ['EVENT', 'subscription-id-123', mockKind1Event] - -// Mock EOSE message (end of stored events) -const mockEoseMessage = ['EOSE', 'subscription-id-123'] - -// Mock CLOSED message -const mockClosedMessage = ['CLOSED', 'subscription-id-123', 'subscription closed'] - -// Mock NOTICE message -const mockNoticeMessage = ['NOTICE', 'rate limited: slow down'] - -// Mock successful sendEvent response -const mockSendEventSuccess = { - accepted: true, - message: 'event saved' -} - -// Mock failed sendEvent response -const mockSendEventFailure = { - accepted: false, - message: 'duplicate: event already exists' -} - -// Mock events array for query tests -const mockEventsArray = [ - mockKind1Event, - { - ...mockKind1Event, - id: 'b'.repeat(64), - content: 'Another test message' - } -] - -export { - mockOkAccepted, - mockOkRejected, - mockEventMessage, - mockEoseMessage, - mockClosedMessage, - mockNoticeMessage, - mockSendEventSuccess, - mockSendEventFailure, - mockEventsArray -} diff --git a/test/unit/use-cases/full-node-blockchain-use-cases-unit.js b/test/unit/use-cases/full-node-blockchain-use-cases-unit.js new file mode 100644 index 0000000..3fd3e1a --- /dev/null +++ b/test/unit/use-cases/full-node-blockchain-use-cases-unit.js @@ -0,0 +1,137 @@ +/* + Unit tests for BlockchainUseCases. +*/ + +import { assert } from 'chai' +import sinon from 'sinon' + +import BlockchainUseCases from '../../../src/use-cases/full-node-blockchain-use-cases.js' + +describe('#full-node-blockchain-use-cases.js', () => { + let sandbox + let mockAdapters + let uut + + const createAdapters = () => { + return { + fullNode: { + call: sandbox.stub() + } + } + } + + beforeEach(() => { + sandbox = sinon.createSandbox() + mockAdapters = createAdapters() + uut = new BlockchainUseCases({ adapters: mockAdapters }) + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('#constructor()', () => { + it('should require adapters', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new BlockchainUseCases() + }, /Adapters instance required/) + }) + + it('should require full node adapter', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new BlockchainUseCases({ adapters: {} }) + }, /Full node adapter required/) + }) + }) + + describe('#getBestBlockHash()', () => { + it('should call full node adapter without parameters', async () => { + mockAdapters.fullNode.call.resolves('hash') + + const result = await uut.getBestBlockHash() + + assert.equal(result, 'hash') + assert.isTrue(mockAdapters.fullNode.call.calledOnceWithExactly('getbestblockhash')) + }) + }) + + describe('#getBlockHeaders()', () => { + it('should call adapter for each hash and return aggregated result', async () => { + const hashes = ['a'.repeat(64), 'b'.repeat(64)] + mockAdapters.fullNode.call + .onFirstCall().resolves('header-1') + .onSecondCall().resolves('header-2') + + const result = await uut.getBlockHeaders({ hashes, verbose: true }) + + assert.deepEqual(result, ['header-1', 'header-2']) + assert.isTrue( + mockAdapters.fullNode.call.calledWithExactly( + 'getblockheader', + [hashes[0], true], + `getblockheader-${hashes[0]}` + ) + ) + assert.isTrue( + mockAdapters.fullNode.call.calledWithExactly( + 'getblockheader', + [hashes[1], true], + `getblockheader-${hashes[1]}` + ) + ) + }) + + it('should rethrow errors from adapter', async () => { + const hashes = ['a'.repeat(64)] + mockAdapters.fullNode.call.rejects(new Error('failure')) + + try { + await uut.getBlockHeaders({ hashes }) + assert.fail('Unexpected success') + } catch (err) { + assert.equal(err.message, 'failure') + } + }) + }) + + describe('#getTxOut()', () => { + it('should pass parameters to full node call', async () => { + mockAdapters.fullNode.call.resolves({ value: 1 }) + + const result = await uut.getTxOut({ + txid: 'txid', + n: 0, + includeMempool: true + }) + + assert.deepEqual(result, { value: 1 }) + assert.isTrue( + mockAdapters.fullNode.call.calledOnceWithExactly( + 'gettxout', + ['txid', 0, true] + ) + ) + }) + }) + + describe('#verifyTxOutProofs()', () => { + it('should call adapter for each proof and return aggregated results', async () => { + const proofs = ['proof-1', 'proof-2'] + mockAdapters.fullNode.call.onFirstCall().resolves(['txid-1']) + mockAdapters.fullNode.call.onSecondCall().resolves(['txid-2']) + + const result = await uut.verifyTxOutProofs({ proofs }) + + assert.deepEqual(result, [['txid-1'], ['txid-2']]) + assert.isTrue( + mockAdapters.fullNode.call.calledWithExactly( + 'verifytxoutproof', + ['proof-1'], + `verifytxoutproof-${proofs[0].slice(0, 16)}` + ) + ) + }) + }) +}) diff --git a/test/unit/use-cases/manage-subscription-unit.js b/test/unit/use-cases/manage-subscription-unit.js deleted file mode 100644 index da34af2..0000000 --- a/test/unit/use-cases/manage-subscription-unit.js +++ /dev/null @@ -1,242 +0,0 @@ -/* - Unit tests for ManageSubscriptionUseCase. -*/ - -// npm libraries -import { assert } from 'chai' -import sinon from 'sinon' - -// Mocking data libraries -import { mockKind1Event } from '../mocks/event-mocks.js' - -// Unit under test -import ManageSubscriptionUseCase from '../../../src/use-cases/manage-subscription.js' - -describe('#manage-subscription.js', () => { - let sandbox - let mockAdapters - let uut - - beforeEach(() => { - sandbox = sinon.createSandbox() - - // Create mock adapters with multiple relays support - const mockRelay1 = { - relayUrl: 'wss://relay1.example.com', - sendReq: sandbox.stub(), - sendClose: sandbox.stub() - } - const mockRelay2 = { - relayUrl: 'wss://relay2.example.com', - sendReq: sandbox.stub(), - sendClose: sandbox.stub() - } - - mockAdapters = { - nostrRelays: [mockRelay1, mockRelay2] - } - - uut = new ManageSubscriptionUseCase({ adapters: mockAdapters }) - }) - - afterEach(() => { - sandbox.restore() - }) - - describe('#createSubscription()', () => { - it('should successfully create a subscription across all relays', async () => { - const subscriptionId = 'test-sub-123' - const filters = [{ kinds: [1] }] - let onEventCalled = false - let onEoseCalled = false - let onClosedCalled = false - - const onEvent = (event) => { - onEventCalled = true - } - const onEose = () => { - onEoseCalled = true - } - const onClosed = (message) => { - onClosedCalled = true - } - - // Mock adapters to resolve - mockAdapters.nostrRelays[0].sendReq.resolves() - mockAdapters.nostrRelays[1].sendReq.resolves() - - await uut.createSubscription(subscriptionId, filters, onEvent, onEose, onClosed) - - // Assert adapters were called for both relays - assert.isTrue(mockAdapters.nostrRelays[0].sendReq.calledOnce) - assert.isTrue(mockAdapters.nostrRelays[1].sendReq.calledOnce) - - // Assert subscription is tracked - assert.isTrue(uut.hasSubscription(subscriptionId)) - - // Test handlers - get them from the subscription info - const subscriptionInfo = uut.activeSubscriptions.get(subscriptionId) - const handlers = subscriptionInfo.handlers - - // Test event handler (should de-duplicate) - handlers.onEvent(mockKind1Event) - assert.isTrue(onEventCalled) - - // Simulate EOSE from both relays - const relayStatuses = subscriptionInfo.relayStatuses - relayStatuses[0].eoseReceived = true - relayStatuses[1].eoseReceived = true - handlers.onEose() - assert.isTrue(onEoseCalled) - - handlers.onClosed('test message') - assert.isTrue(onClosedCalled) - assert.isFalse(uut.hasSubscription(subscriptionId)) - }) - - it('should prevent duplicate subscriptions', async () => { - const subscriptionId = 'test-sub-123' - const filters = [{ kinds: [1] }] - - mockAdapters.nostrRelays[0].sendReq.resolves() - mockAdapters.nostrRelays[1].sendReq.resolves() - - await uut.createSubscription(subscriptionId, filters) - - try { - await uut.createSubscription(subscriptionId, filters) - assert.equal(true, false, 'unexpected result') - } catch (err) { - assert.include(err.message, 'already exists') - } - }) - - it('should clean up subscription on error', async () => { - const subscriptionId = 'test-sub-123' - const filters = [{ kinds: [1] }] - - mockAdapters.nostrRelays[0].sendReq.rejects(new Error('Connection error')) - mockAdapters.nostrRelays[1].sendReq.resolves() - - try { - await uut.createSubscription(subscriptionId, filters) - assert.equal(true, false, 'unexpected result') - } catch (err) { - // Should clean up even if some relays fail - assert.isFalse(uut.hasSubscription(subscriptionId)) - } - }) - - it('should handle missing callbacks gracefully', async () => { - const subscriptionId = 'test-sub-123' - const filters = [{ kinds: [1] }] - - mockAdapters.nostrRelays[0].sendReq.resolves() - mockAdapters.nostrRelays[1].sendReq.resolves() - - await uut.createSubscription(subscriptionId, filters, null, null, null) - - // Should not throw when handlers are null - const subscriptionInfo = uut.activeSubscriptions.get(subscriptionId) - const handlers = subscriptionInfo.handlers - handlers.onEvent(mockKind1Event) - handlers.onEose() - handlers.onClosed('test') - }) - }) - - describe('#closeSubscription()', () => { - it('should successfully close a subscription across all relays', async () => { - const subscriptionId = 'test-sub-123' - const filters = [{ kinds: [1] }] - - mockAdapters.nostrRelays[0].sendReq.resolves() - mockAdapters.nostrRelays[1].sendReq.resolves() - mockAdapters.nostrRelays[0].sendClose.resolves() - mockAdapters.nostrRelays[1].sendClose.resolves() - - // Create subscription first - await uut.createSubscription(subscriptionId, filters) - assert.isTrue(uut.hasSubscription(subscriptionId)) - - // Close subscription - await uut.closeSubscription(subscriptionId) - - // Assert adapters were called for both relays - assert.isTrue(mockAdapters.nostrRelays[0].sendClose.calledOnce) - assert.isTrue(mockAdapters.nostrRelays[1].sendClose.calledOnce) - - // Assert subscription is removed - assert.isFalse(uut.hasSubscription(subscriptionId)) - }) - - it('should return successfully when closing non-existent subscription (idempotent)', async () => { - const subscriptionId = 'non-existent-sub' - - // Should not throw - idempotent operation - await uut.closeSubscription(subscriptionId) - - // Should return successfully without error - assert.isTrue(true, 'closeSubscription should succeed for non-existent subscription') - }) - - it('should clean up subscription even on error', async () => { - const subscriptionId = 'test-sub-123' - const filters = [{ kinds: [1] }] - - mockAdapters.nostrRelays[0].sendReq.resolves() - mockAdapters.nostrRelays[1].sendReq.resolves() - mockAdapters.nostrRelays[0].sendClose.rejects(new Error('Close error')) - mockAdapters.nostrRelays[1].sendClose.resolves() - - // Create subscription first - await uut.createSubscription(subscriptionId, filters) - - // Close should succeed even if one relay fails - await uut.closeSubscription(subscriptionId) - - // Should still clean up - assert.isFalse(uut.hasSubscription(subscriptionId)) - }) - }) - - describe('#hasSubscription()', () => { - it('should return false for non-existent subscription', () => { - assert.isFalse(uut.hasSubscription('non-existent')) - }) - - it('should return true for existing subscription', async () => { - const subscriptionId = 'test-sub-123' - const filters = [{ kinds: [1] }] - - mockAdapters.nostrRelays[0].sendReq.resolves() - mockAdapters.nostrRelays[1].sendReq.resolves() - - assert.isFalse(uut.hasSubscription(subscriptionId)) - await uut.createSubscription(subscriptionId, filters) - assert.isTrue(uut.hasSubscription(subscriptionId)) - }) - }) - - describe('#constructor()', () => { - it('should require adapters instance', () => { - try { - // eslint-disable-next-line no-new - new ManageSubscriptionUseCase() - assert.equal(true, false, 'unexpected result') - } catch (err) { - assert.include(err.message, 'Adapters instance required') - } - }) - - it('should require NostrRelay adapters array', () => { - try { - // eslint-disable-next-line no-new - new ManageSubscriptionUseCase({ adapters: {} }) - assert.equal(true, false, 'unexpected result') - } catch (err) { - assert.include(err.message, 'NostrRelay adapters array required') - } - }) - }) -}) diff --git a/test/unit/use-cases/publish-event-unit.js b/test/unit/use-cases/publish-event-unit.js deleted file mode 100644 index d7d7174..0000000 --- a/test/unit/use-cases/publish-event-unit.js +++ /dev/null @@ -1,146 +0,0 @@ -/* - Unit tests for PublishEventUseCase. -*/ - -// npm libraries -import { assert } from 'chai' -import sinon from 'sinon' - -// Mocking data libraries -import { - mockKind1Event, - mockInvalidEventMissingId -} from '../mocks/event-mocks.js' - -// Unit under test -import PublishEventUseCase from '../../../src/use-cases/publish-event.js' - -describe('#publish-event.js', () => { - let sandbox - let mockAdapters - let uut - - beforeEach(() => { - sandbox = sinon.createSandbox() - - // Create mock adapters with multiple relays support - mockAdapters = { - nostrRelays: [ - { relayUrl: 'wss://relay1.example.com' }, - { relayUrl: 'wss://relay2.example.com' } - ], - broadcastEvent: sandbox.stub() - } - - uut = new PublishEventUseCase({ adapters: mockAdapters }) - }) - - afterEach(() => { - sandbox.restore() - }) - - describe('#execute()', () => { - it('should successfully publish a valid event to all relays', async () => { - // Mock broadcast response - at least one relay accepts - mockAdapters.broadcastEvent.resolves([ - { accepted: true, message: 'event saved', relayUrl: 'wss://relay1.example.com', success: true }, - { accepted: true, message: 'event saved', relayUrl: 'wss://relay2.example.com', success: true } - ]) - - const result = await uut.execute(mockKind1Event) - - // Assert adapter was called correctly - assert.isTrue(mockAdapters.broadcastEvent.calledOnce) - const callArgs = mockAdapters.broadcastEvent.getCall(0).args[0] - assert.equal(callArgs.id, mockKind1Event.id) - assert.equal(callArgs.kind, mockKind1Event.kind) - - // Assert result - assert.property(result, 'accepted') - assert.property(result, 'message') - assert.property(result, 'eventId') - assert.property(result, 'relayResults') - assert.property(result, 'acceptedCount') - assert.property(result, 'totalRelays') - assert.isTrue(result.accepted) - assert.equal(result.eventId, mockKind1Event.id) - assert.equal(result.acceptedCount, 2) - assert.equal(result.totalRelays, 2) - }) - - it('should handle event rejection from all relays', async () => { - // Mock broadcast response - all relays reject - mockAdapters.broadcastEvent.resolves([ - { accepted: false, message: 'duplicate', relayUrl: 'wss://relay1.example.com', success: true }, - { accepted: false, message: 'duplicate', relayUrl: 'wss://relay2.example.com', success: true } - ]) - - const result = await uut.execute(mockKind1Event) - - // Assert result shows rejection - assert.isFalse(result.accepted) - assert.property(result, 'message') - assert.equal(result.eventId, mockKind1Event.id) - assert.equal(result.acceptedCount, 0) - }) - - it('should succeed if at least one relay accepts', async () => { - // Mock broadcast response - one accepts, one rejects - mockAdapters.broadcastEvent.resolves([ - { accepted: true, message: 'event saved', relayUrl: 'wss://relay1.example.com', success: true }, - { accepted: false, message: 'duplicate', relayUrl: 'wss://relay2.example.com', success: true } - ]) - - const result = await uut.execute(mockKind1Event) - - // Should succeed if at least one accepts - assert.isTrue(result.accepted) - assert.equal(result.acceptedCount, 1) - assert.equal(result.totalRelays, 2) - }) - - it('should throw error for invalid event structure', async () => { - try { - await uut.execute(mockInvalidEventMissingId) - assert.equal(true, false, 'unexpected result') - } catch (err) { - assert.include(err.message, 'Invalid event structure') - assert.isFalse(mockAdapters.broadcastEvent.called) - } - }) - - it('should handle adapter errors', async () => { - // Mock adapter error - const adapterError = new Error('Network error') - mockAdapters.broadcastEvent.rejects(adapterError) - - try { - await uut.execute(mockKind1Event) - assert.equal(true, false, 'unexpected result') - } catch (err) { - assert.equal(err.message, 'Network error') - assert.isTrue(mockAdapters.broadcastEvent.calledOnce) - } - }) - - it('should require adapters instance', () => { - try { - // eslint-disable-next-line no-new - new PublishEventUseCase() - assert.equal(true, false, 'unexpected result') - } catch (err) { - assert.include(err.message, 'Adapters instance required') - } - }) - - it('should require NostrRelay adapters array', () => { - try { - // eslint-disable-next-line no-new - new PublishEventUseCase({ adapters: {} }) - assert.equal(true, false, 'unexpected result') - } catch (err) { - assert.include(err.message, 'NostrRelay adapters array required') - } - }) - }) -}) diff --git a/test/unit/use-cases/query-events-unit.js b/test/unit/use-cases/query-events-unit.js deleted file mode 100644 index 89f28df..0000000 --- a/test/unit/use-cases/query-events-unit.js +++ /dev/null @@ -1,106 +0,0 @@ -/* - Unit tests for QueryEventsUseCase. -*/ - -// npm libraries -import { assert } from 'chai' -import sinon from 'sinon' - -// Mocking data libraries -import { mockEventsArray } from '../mocks/nostr-relay-mocks.js' - -// Unit under test -import QueryEventsUseCase from '../../../src/use-cases/query-events.js' - -describe('#query-events.js', () => { - let sandbox - let mockAdapters - let uut - - beforeEach(() => { - sandbox = sinon.createSandbox() - - // Create mock adapters with multiple relays support - mockAdapters = { - nostrRelays: [ - { relayUrl: 'wss://relay1.example.com' }, - { relayUrl: 'wss://relay2.example.com' } - ], - queryAllRelays: sandbox.stub() - } - - uut = new QueryEventsUseCase({ adapters: mockAdapters }) - }) - - afterEach(() => { - sandbox.restore() - }) - - describe('#execute()', () => { - it('should successfully query events from all relays and return merged results', async () => { - const filters = [{ kinds: [1], limit: 10 }] - const subscriptionId = 'test-sub-123' - - // Mock queryAllRelays to return events - mockAdapters.queryAllRelays.resolves(mockEventsArray) - - const result = await uut.execute(filters, subscriptionId) - - // Assert adapter was called correctly - assert.isTrue(mockAdapters.queryAllRelays.calledOnce) - const callArgs = mockAdapters.queryAllRelays.getCall(0).args - assert.deepEqual(callArgs[0], filters) - assert.equal(callArgs[1], subscriptionId) - - // Assert result contains events - assert.isArray(result) - assert.equal(result.length, mockEventsArray.length) - }) - - it('should handle errors from queryAllRelays', async () => { - const filters = [{ kinds: [1] }] - const subscriptionId = 'test-sub-123' - - mockAdapters.queryAllRelays.rejects(new Error('Query failed')) - - try { - await uut.execute(filters, subscriptionId) - assert.equal(true, false, 'unexpected result') - } catch (err) { - assert.include(err.message, 'Query failed') - } - }) - - it('should return empty array when no events found', async () => { - const filters = [{ kinds: [1] }] - const subscriptionId = 'test-sub-123' - - mockAdapters.queryAllRelays.resolves([]) - - const result = await uut.execute(filters, subscriptionId) - - assert.isArray(result) - assert.equal(result.length, 0) - }) - - it('should require adapters instance', () => { - try { - // eslint-disable-next-line no-new - new QueryEventsUseCase() - assert.equal(true, false, 'unexpected result') - } catch (err) { - assert.include(err.message, 'Adapters instance required') - } - }) - - it('should require NostrRelay adapters array', () => { - try { - // eslint-disable-next-line no-new - new QueryEventsUseCase({ adapters: {} }) - assert.equal(true, false, 'unexpected result') - } catch (err) { - assert.include(err.message, 'NostrRelay adapters array required') - } - }) - }) -})