fix(tests): Updating unit tests

This commit is contained in:
Chris Troutner
2025-11-09 19:39:21 -08:00
parent 78197762e4
commit 6096d52f84
16 changed files with 559 additions and 1738 deletions
-1
View File
@@ -20,7 +20,6 @@ class FullNodeRPCAdapter {
rpcPassword, rpcPassword,
rpcTimeoutMs = 15000 rpcTimeoutMs = 15000
} = this.config.fullNode } = this.config.fullNode
console.log('this.config.fullNode', this.config.fullNode)
this.requestIdPrefix = this.config.fullNode.rpcRequestIdPrefix || 'psf-bch-api' this.requestIdPrefix = this.config.fullNode.rpcRequestIdPrefix || 'psf-bch-api'
-6
View File
@@ -5,9 +5,6 @@
*/ */
// Local libraries // 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' import BlockchainUseCases from './full-node-blockchain-use-cases.js'
class UseCases { 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 }) this.blockchain = new BlockchainUseCases({ adapters: this.adapters })
} }
-216
View File
@@ -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<relayIndex, subscriptionId>, 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<void>}
*/
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<void>}
*/
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
-87
View File
@@ -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<Object>} 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
-41
View File
@@ -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>} 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
+122
View File
@@ -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)
}
})
})
})
-304
View File
@@ -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)
})
})
})
*/
@@ -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'])
})
})
})
@@ -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')
}
})
})
})
@@ -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')
}
})
})
})
@@ -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)
})
})
})
-58
View File
@@ -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
}
@@ -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)}`
)
)
})
})
})
@@ -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')
}
})
})
})
-146
View File
@@ -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')
}
})
})
})
-106
View File
@@ -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')
}
})
})
})