mirror of
https://github.com/Permissionless-Software-Foundation/psf-bch-api-base.git
synced 2026-09-21 16:52:00 -07:00
fix(tests): Updating unit tests
This commit is contained in:
@@ -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'
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user