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