mirror of
https://github.com/Permissionless-Software-Foundation/psf-bch-api-base.git
synced 2026-09-21 16:52:00 -07:00
first commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.env
|
||||
logs/
|
||||
docs/
|
||||
coverage/
|
||||
@@ -0,0 +1,8 @@
|
||||
The MIT License (MIT)
|
||||
Copyright (c) 2025 Christopher Troutner
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,8 @@
|
||||
# psf-bch-api
|
||||
|
||||
This is a REST API for communicating with Bitcoin Cash infrastructure. It replaces [bch-api](https://github.com/Permissionless-Software-Foundation/bch-api), and it implements [x402-bch protocol](https://github.com/x402-bch/x402-bch) to handle payments to access the API.
|
||||
|
||||
## License
|
||||
|
||||
[MIT](./LICENSE.md)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "REST2NOSTR Proxy API",
|
||||
"version": "1.0.0",
|
||||
"description": "REST API proxy for Nostr WebSocket protocol",
|
||||
"title": "REST2NOSTR Proxy API",
|
||||
"url": "https://nostr-relay-api.psfoundation.info",
|
||||
"sampleUrl": "https://nostr-relay-api.psfoundation.info"
|
||||
}
|
||||
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
Express server for REST2NOSTR Proxy API.
|
||||
The architecture of the code follows the Clean Architecture pattern.
|
||||
*/
|
||||
|
||||
// npm libraries
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import dotenv from 'dotenv'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname, join } from 'path'
|
||||
|
||||
// Local libraries
|
||||
import config from '../src/config/index.js'
|
||||
import Controllers from '../src/controllers/index.js'
|
||||
import wlogger from '../src/adapters/wlogger.js'
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config()
|
||||
|
||||
// Set up global error handlers to prevent server crashes
|
||||
// These must be set up before the server starts to catch any unhandled errors
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
console.error('Unhandled Rejection at:', promise, 'reason:', reason)
|
||||
wlogger.error('Unhandled Rejection:', {
|
||||
promise: promise.toString(),
|
||||
reason: reason instanceof Error ? reason.stack : String(reason)
|
||||
})
|
||||
// Don't exit the process - log and continue
|
||||
// The server should remain running to handle other requests
|
||||
})
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
console.error('Uncaught Exception:', error)
|
||||
wlogger.error('Uncaught Exception:', {
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
})
|
||||
// For uncaught exceptions, we should exit gracefully
|
||||
// but give time for the process manager to restart
|
||||
console.log('Exiting after 5 seconds due to uncaught exception. Process manager should restart.')
|
||||
setTimeout(() => {
|
||||
process.exit(1)
|
||||
}, 5000)
|
||||
})
|
||||
|
||||
class Server {
|
||||
constructor () {
|
||||
// Encapsulate dependencies
|
||||
this.controllers = new Controllers()
|
||||
this.config = config
|
||||
this.process = process
|
||||
}
|
||||
|
||||
async startServer () {
|
||||
try {
|
||||
// Create an Express instance.
|
||||
const app = express()
|
||||
|
||||
// MIDDLEWARE START
|
||||
app.use(express.json())
|
||||
app.use(express.urlencoded({ extended: true }))
|
||||
|
||||
app.use(cors({
|
||||
origin: true, // Allow all origins (more reliable than '*')
|
||||
credentials: false, // Set to true if you need to support credentials
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With']
|
||||
}))
|
||||
|
||||
// Endpoint logging middleware
|
||||
app.use((req, res, next) => {
|
||||
console.log(`Endpoint called: ${req.method} ${req.path}`)
|
||||
res.on('finish', () => {
|
||||
console.log(`Endpoint responded: ${req.method} ${req.path} - ${res.statusCode}`)
|
||||
})
|
||||
next()
|
||||
})
|
||||
|
||||
// Request logging middleware
|
||||
app.use((req, res, next) => {
|
||||
wlogger.info(`${req.method} ${req.path}`)
|
||||
next()
|
||||
})
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err, req, res, next) => {
|
||||
wlogger.error('Express error:', err)
|
||||
|
||||
// Handle JSON parsing errors
|
||||
if (err instanceof SyntaxError && err.status === 400 && 'body' in err) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid JSON in request body'
|
||||
})
|
||||
}
|
||||
|
||||
// Default to 500 for other errors
|
||||
res.status(500).json({
|
||||
error: err.message || 'Internal server error'
|
||||
})
|
||||
})
|
||||
|
||||
// Wait for any adapters to initialize.
|
||||
await this.controllers.initAdapters()
|
||||
|
||||
// Wait for any use-libraries to initialize.
|
||||
await this.controllers.initUseCases()
|
||||
|
||||
// Attach REST API controllers to the app.
|
||||
this.controllers.attachRESTControllers(app)
|
||||
|
||||
// Initialize any other controller libraries.
|
||||
this.controllers.initControllers()
|
||||
|
||||
// Serve static assets from docs directory
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
app.use('/assets', express.static(join(__dirname, '..', 'docs', 'assets')))
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
service: 'rest2nostr',
|
||||
version: config.version
|
||||
})
|
||||
})
|
||||
|
||||
// Root endpoint
|
||||
app.get('/', (req, res) => {
|
||||
const docsPath = join(__dirname, '..', 'docs', 'index.html')
|
||||
res.sendFile(docsPath)
|
||||
})
|
||||
|
||||
// MIDDLEWARE END
|
||||
|
||||
console.log(`Running server in environment: ${this.config.env}`)
|
||||
wlogger.info(`Running server in environment: ${this.config.env}`)
|
||||
|
||||
this.server = app.listen(this.config.port, () => {
|
||||
console.log(`Server started on port ${this.config.port}`)
|
||||
wlogger.info(`Server started on port ${this.config.port}`)
|
||||
})
|
||||
|
||||
this.server.on('error', (err) => {
|
||||
console.error('Server error:', err)
|
||||
wlogger.error('Server error:', err)
|
||||
})
|
||||
|
||||
this.server.on('close', () => {
|
||||
console.log('Server closed')
|
||||
wlogger.info('Server closed')
|
||||
})
|
||||
|
||||
return app
|
||||
} catch (err) {
|
||||
console.error('Could not start server. Error: ', err)
|
||||
wlogger.error('Could not start server. Error: ', err)
|
||||
|
||||
console.log(
|
||||
'Exiting after 5 seconds. Depending on process manager to restart.'
|
||||
)
|
||||
await this.sleep(5000)
|
||||
this.process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
sleep (ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
}
|
||||
|
||||
// Start the server if this file is run directly
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
if (process.argv[1] === __filename) {
|
||||
const server = new Server()
|
||||
server.startServer().catch(err => {
|
||||
console.error('Failed to start server:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
||||
export default Server
|
||||
@@ -0,0 +1,4 @@
|
||||
# Developer Documentation
|
||||
|
||||
- [prompt.md](./prompt.md) - This is the original prompt [trout](https://github.com/christroutner) gave the AI to build the app. He uses Cursor 2.0 in Planning mode and the Composer 1 LLM.
|
||||
- [plan](./rest2nostr-poxy-api.plan.md) - This is the plan created by the Cursor AI from the prompt and support files it was given. This is the plan it used to build the app.
|
||||
@@ -0,0 +1,34 @@
|
||||
## Build a REST API implementing REST2NOSTR Proxy
|
||||
|
||||
Your task is to plan out the building of a REST API server that implements the idea for a REST2NOSTR proxy. The code for this REST API server app should be placed in the `/app` directory. You can edit any files in the `/app` directory. The REST API server should be built using node.js JavaScript and the express.js library. It should also use dotenv to manage the use of environment variables. It should use [api-doc](https://www.npmjs.com/package/api-doc) to generate API documentation for the REST API.
|
||||
|
||||
### Nostr
|
||||
|
||||
Nostr is a social media protocol. It is defined by nip-01.md in the `nostr/` directory. This is the primary specification that defines Nostr, but there are many other NIPS for other features. If you need to dig deeper into the Nostr standards you can explore the [NIPs on Github](https://github.com/nostr-protocol/nips/tree/master).
|
||||
|
||||
The `nostr-sandbox/` directory contains a series of small example code snippets for common social media use-cases using Nostr. You can study this code understand how a Nostr Client would interact with a Nostr Relay.
|
||||
|
||||
[This discussion thread on GitHub](https://github.com/nostr-protocol/nips/issues/1549) introduces a concept called REST2NOSTR. It solves an common issue experienced by JavaScript web developers: a high preference for REST APIs over Websockets. The proposal is to create a REST API server that operates as a proxy for the Websockets protocol used by Nostr Relays. This would allow Client developers to interact with a Nostr Relay over a familiar REST API, instead of using Websockets. Here is a summary of the API endpoints the discussion proposes:
|
||||
|
||||
| REST Method | Endpoint | Nostr WebSocket Equivalent | Purpose |
|
||||
| --- | --- | --- | --- |
|
||||
| `POST` | `/event` | `["EVENT", <event>]` | Publish a signed Nostr event to the relay. |
|
||||
| `GET` | `/req/` | `["REQ", <sub_id>, <filters>]` | Retrieve a list of events based on filters (stateless query). |
|
||||
| `POST`/`PUT` | `/req/` | `["REQ", <sub_id>, <filters>]` | Establish a subscription (for long-polling or SSE). |
|
||||
| `DELETE` | `/req/` | `["CLOSE", <sub_id>]` | Close an existing subscription. |
|
||||
|
||||
Your task is to plan out the building of a REST API server that implements the idea for a REST2NOSTR proxy. All code examples in the `nostr-sandbox/` directory that interact with a Relay over Websockets should be able to be implemented using the new REST API. The code examples are a good benchmark to use as a frame of reference as to weather the REST API has been implemented correctly. It would be a good idea to create an `examples/` directory that contain many of these code example, refactored for use with the new REST API.
|
||||
|
||||
There is a similar implementation of REST2NOSTR available at [https://nostr-api.com/](https://nostr-api.com/). While the API and documentation are available, the source code for that implementation is not available. However, it is good to study as an example of the kind of output we are looking for.
|
||||
|
||||
### Follow the Clean Architecture code pattern
|
||||
|
||||
As you plan out the code layout, you should follow the Clean Architecture patterns. Here is background information you can follow to ensure you follow the Clean Architecture pattern:
|
||||
|
||||
* [Clean Architecture Summary](https://raw.githubusercontent.com/christroutner/trouts-blog/refs/heads/master/blog/2021-07-06-clean-architecture/index.md) - This is a markdown document that summarizes the Clean Architecture pattern, and links to additional support information.
|
||||
* [ipfs-service-provider](https://github.com/Permissionless-Software-Foundation/ipfs-service-provider) - This is a node.js JavaScript code base that follows the Clean Architecture patterns. Notice that within the `src` directory, the sub-directories are split up according to the guidance in the Clean Architecture Summary article. This is the primary pattern you should follow.
|
||||
* A copy of the ipfs-service-provider repository has been copied to the `clean-architecture/` directory, so that you can study the code locally.
|
||||
|
||||
### Summary
|
||||
|
||||
Your task is to plan out the building of a REST API server that implements the idea for a REST2NOSTR proxy. The code for this REST API server app should be placed in the `/app` directory. You can edit any files in the `/app` directory. The REST API server should be built using node.js JavaScript and the express.js library. It should also use dotenv to manage the use of environment variables. It should use [api-doc](https://www.npmjs.com/package/api-doc) to generate API documentation for the REST API.
|
||||
@@ -0,0 +1,163 @@
|
||||
# REST2NOSTR Proxy API Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Build a REST API server in `/app` that proxies Nostr WebSocket protocol to REST endpoints, enabling JavaScript developers to interact with Nostr relays via familiar REST APIs instead of WebSockets.
|
||||
|
||||
## Architecture
|
||||
|
||||
Follow Clean Architecture pattern with these layers:
|
||||
|
||||
- **Entities** (`src/entities/`): Domain models (Event, Subscription, etc.)
|
||||
- **Use Cases** (`src/use-cases/`): Business logic (PublishEvent, QueryEvents, ManageSubscription)
|
||||
- **Adapters** (`src/adapters/`): External interfaces (NostrRelay WebSocket client, logger)
|
||||
- **Controllers** (`src/controllers/rest-api/`): Express.js route handlers
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
/app
|
||||
├── src/
|
||||
│ ├── entities/
|
||||
│ │ └── event.js
|
||||
│ ├── use-cases/
|
||||
│ │ ├── index.js
|
||||
│ │ ├── publish-event.js
|
||||
│ │ ├── query-events.js
|
||||
│ │ └── manage-subscription.js
|
||||
│ ├── adapters/
|
||||
│ │ ├── index.js
|
||||
│ │ ├── nostr-relay.js
|
||||
│ │ └── wlogger.js
|
||||
│ ├── controllers/
|
||||
│ │ ├── index.js
|
||||
│ │ └── rest-api/
|
||||
│ │ ├── index.js
|
||||
│ │ ├── event/
|
||||
│ │ │ ├── controller.js
|
||||
│ │ │ └── index.js
|
||||
│ │ └── req/
|
||||
│ │ ├── controller.js
|
||||
│ │ └── index.js
|
||||
│ └── config/
|
||||
│ ├── index.js
|
||||
│ └── env/
|
||||
│ ├── common.js
|
||||
│ ├── development.js
|
||||
│ └── production.js
|
||||
├── examples/
|
||||
│ └── [refactored sandbox examples]
|
||||
├── bin/
|
||||
│ └── server.js
|
||||
├── .env.example
|
||||
├── .env
|
||||
├── index.js
|
||||
├── package.json
|
||||
└── apidoc.json
|
||||
```
|
||||
|
||||
## Core Endpoints
|
||||
|
||||
### POST /event
|
||||
|
||||
- Maps to: `["EVENT", <event>]`
|
||||
- Publish a signed Nostr event to relay
|
||||
- Body: JSON event object
|
||||
- Response: `{"accepted": true/false, "message": ""}` (maps to `["OK", ...]`)
|
||||
|
||||
### GET /req/:subId
|
||||
|
||||
- Maps to: `["REQ", <sub_id>, <filters>]`
|
||||
- Stateless query - returns events immediately
|
||||
- Query params: filters (JSON encoded or separate params)
|
||||
- Response: Array of events
|
||||
|
||||
### POST /req/:subId
|
||||
|
||||
- Maps to: `["REQ", <sub_id>, <filters>]`
|
||||
- Establish subscription for Server-Sent Events (SSE)
|
||||
- Body: filters object
|
||||
- Response: SSE stream of events
|
||||
|
||||
### DELETE /req/:subId
|
||||
|
||||
- Maps to: `["CLOSE", <sub_id>]`
|
||||
- Close an existing subscription
|
||||
- Response: Confirmation
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Package Dependencies
|
||||
|
||||
- `express`: REST API framework
|
||||
- `dotenv`: Environment variable management
|
||||
- `apidoc`: API documentation generation
|
||||
- `ws` or `nostr-tools`: WebSocket client for Nostr relays
|
||||
- `winston`: Logging (following example pattern)
|
||||
|
||||
### 2. Adapter Layer (`src/adapters/`)
|
||||
|
||||
- **nostr-relay.js**: WebSocket client wrapper
|
||||
- Connect to configured relay(s)
|
||||
- Send `EVENT`, `REQ`, `CLOSE` messages
|
||||
- Handle relay responses (`EVENT`, `OK`, `EOSE`, `CLOSED`, `NOTICE`)
|
||||
- Manage connection pooling for multiple relays
|
||||
- **wlogger.js**: Winston-based logger
|
||||
|
||||
### 3. Use Cases (`src/use-cases/`)
|
||||
|
||||
- **publish-event.js**: Validate event, send to relay, return OK response
|
||||
- **query-events.js**: Stateless query - send REQ, collect events until EOSE, return results
|
||||
- **manage-subscription.js**: Create/close subscriptions, handle SSE streaming
|
||||
|
||||
### 4. Controllers (`src/controllers/rest-api/`)
|
||||
|
||||
- **event/controller.js**: Handle POST /event
|
||||
- **req/controller.js**: Handle GET/POST/DELETE /req/:subId
|
||||
- Express middleware for validation, error handling
|
||||
- SSE support for subscription endpoint
|
||||
|
||||
### 5. Configuration (`src/config/`)
|
||||
|
||||
- Environment-based config (development, production, test)
|
||||
- Default relay URL(s) from environment variables
|
||||
- Port, logging level, etc.
|
||||
|
||||
### 6. Subscription Management
|
||||
|
||||
- Store active subscriptions in memory (Map with subId as key)
|
||||
- Map subscription IDs to WebSocket connections
|
||||
- Handle cleanup on DELETE /req/:subId
|
||||
- Support SSE streaming for POST /req/:subId
|
||||
|
||||
### 7. Examples (`examples/`)
|
||||
|
||||
Refactor sandbox examples to use REST API:
|
||||
|
||||
- `01-create-account/` - Use REST API to publish kind 0 event
|
||||
- `02-read-posts/` - Use GET /req/:subId for stateless query
|
||||
- `03-write-post/` - Use POST /event
|
||||
- `04-read-alice-posts/` - Use GET /req/:subId with author filter
|
||||
- `14-get-follow-list/` - Use GET /req/:subId with kind 3 filter
|
||||
- Additional examples as needed
|
||||
|
||||
### 8. API Documentation
|
||||
|
||||
- Use api-doc annotations in controller files
|
||||
- Generate docs with `npm run docs`
|
||||
- Follow pattern from clean-architecture example
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `NOSTR_RELAY_URL`: Default relay WebSocket URL (e.g., `wss://nostr-relay.psfoundation.info`)
|
||||
- `PORT`: Server port (default: 3000)
|
||||
- `NODE_ENV`: Environment (development, production, test)
|
||||
- `LOG_LEVEL`: Logging level (info, debug, error)
|
||||
|
||||
## Key Implementation Notes
|
||||
|
||||
- WebSocket connections: Maintain persistent connections to relay(s) in adapter
|
||||
- Error handling: Map Nostr relay errors to appropriate HTTP status codes
|
||||
- Validation: Validate Nostr events before forwarding (event structure, signature)
|
||||
- SSE: Use Express response.write() for Server-Sent Events in subscription endpoint
|
||||
- Stateless queries: For GET /req/:subId, collect events until EOSE, then close subscription automatically
|
||||
@@ -0,0 +1,161 @@
|
||||
# Test Plan for REST2NOSTR Application
|
||||
|
||||
## Overview
|
||||
|
||||
Create unit and integration tests for the REST2NOSTR Express.js application following patterns from `tests/testing-example-code/`. Tests will use mocha, chai, and sinon, and cover all code paths exercised by the examples in `/app/examples/`.
|
||||
|
||||
## Test Structure
|
||||
|
||||
Create test directory structure:
|
||||
|
||||
- `app/test/unit/` - Unit tests with mocked dependencies
|
||||
- `app/test/integration/` - Integration tests with real dependencies
|
||||
- `app/test/unit/mocks/` - Mock data for unit tests
|
||||
|
||||
## Unit Tests
|
||||
|
||||
### 1. Entity Tests (`test/unit/entities/`)
|
||||
|
||||
- **event-unit.js**: Test Event entity validation and serialization
|
||||
- Test `isValid()` with valid events
|
||||
- Test `isValid()` with invalid events (missing fields, wrong types, wrong lengths)
|
||||
- Test `toJSON()` serialization
|
||||
- Use mock event data from `mocks/event-mocks.js`
|
||||
|
||||
### 2. Use Case Tests (`test/unit/use-cases/`)
|
||||
|
||||
- **publish-event-unit.js**: Test PublishEventUseCase
|
||||
- Mock NostrRelayAdapter.sendEvent()
|
||||
- Test successful event publishing
|
||||
- Test invalid event rejection
|
||||
- Test adapter error handling
|
||||
- Use mocks from `mocks/nostr-relay-mocks.js`
|
||||
|
||||
- **query-events-unit.js**: Test QueryEventsUseCase
|
||||
- Mock NostrRelayAdapter.sendReq() and sendClose()
|
||||
- Test successful query with events returned
|
||||
- Test query timeout handling
|
||||
- Test CLOSED message handling
|
||||
- Test EOSE handling
|
||||
|
||||
- **manage-subscription-unit.js**: Test ManageSubscriptionUseCase
|
||||
- Mock NostrRelayAdapter.sendReq() and sendClose()
|
||||
- Test subscription creation
|
||||
- Test subscription closure
|
||||
- Test duplicate subscription prevention
|
||||
- Test handler callbacks (onEvent, onEose, onClosed)
|
||||
|
||||
### 3. Controller Tests (`test/unit/controllers/`)
|
||||
|
||||
- **event-controller-unit.js**: Test EventRESTControllerLib
|
||||
- Mock UseCases.publishEvent.execute()
|
||||
- Test POST /event with valid event data
|
||||
- Test POST /event with missing event data
|
||||
- Test error handling
|
||||
- Use Express request/response mocks
|
||||
|
||||
- **req-controller-unit.js**: Test ReqRESTControllerLib
|
||||
- Mock UseCases.queryEvents.execute() and manageSubscription methods
|
||||
- Test GET /req/:subId with filters (JSON string and parsed)
|
||||
- Test GET /req/:subId with individual query params
|
||||
- Test POST /req/:subId for SSE subscription
|
||||
- Test DELETE /req/:subId for closing subscription
|
||||
- Test error handling for invalid filters
|
||||
- Test missing subscription ID validation
|
||||
|
||||
### 4. Adapter Tests (`test/unit/adapters/`)
|
||||
|
||||
- **nostr-relay-unit.js**: Test NostrRelayAdapter
|
||||
- Mock WebSocket connections
|
||||
- Test connection establishment
|
||||
- Test sendEvent() and OK response handling
|
||||
- Test sendReq() and EVENT/EOSE/CLOSED message handling
|
||||
- Test sendClose()
|
||||
- Test message queuing when disconnected
|
||||
- Test reconnection logic
|
||||
- Test error handling
|
||||
|
||||
### 5. Server Tests (`test/unit/bin/`)
|
||||
|
||||
- **server-unit.js**: Test Server class
|
||||
- Mock Express app, Controllers, and adapters
|
||||
- Test server initialization
|
||||
- Test middleware attachment
|
||||
- Test route attachment
|
||||
- Test error handling
|
||||
- Test health check endpoint
|
||||
|
||||
## Integration Tests
|
||||
|
||||
### 1. API Endpoint Tests (`test/integration/api/`)
|
||||
|
||||
- **event-integration.js**: Test POST /event endpoint
|
||||
- Create a test server instance
|
||||
- Test publishing kind 0 event (profile metadata) - covers example 01
|
||||
- Test publishing kind 1 event (text post) - covers example 03
|
||||
- Test publishing kind 3 event (follow list) - covers example 06
|
||||
- Test publishing kind 7 event (reaction/like) - covers example 07
|
||||
- Test invalid event rejection
|
||||
- Use real Nostr relay connection (may need test relay or mock relay)
|
||||
|
||||
- **req-integration.js**: Test GET /req/:subId endpoint
|
||||
- Create a test server instance
|
||||
- Test querying kind 1 events (posts) - covers examples 02, 04
|
||||
- Test querying kind 3 events (follow list) - covers example 05
|
||||
- Test with various filter combinations
|
||||
- Test with filters as JSON string query param
|
||||
- Test with individual query params
|
||||
- Test error handling
|
||||
|
||||
- **subscription-integration.js**: Test POST /req/:subId SSE subscription
|
||||
- Create a test server instance
|
||||
- Test SSE subscription creation
|
||||
- Test event streaming
|
||||
- Test EOSE handling
|
||||
- Test subscription closure
|
||||
- Test DELETE /req/:subId endpoint
|
||||
|
||||
### 2. Use Case Integration Tests (`test/integration/use-cases/`)
|
||||
|
||||
- **publish-event-integration.js**: Test PublishEventUseCase with real adapter
|
||||
- **query-events-integration.js**: Test QueryEventsUseCase with real adapter
|
||||
- **manage-subscription-integration.js**: Test ManageSubscriptionUseCase with real adapter
|
||||
|
||||
## Mock Data Files
|
||||
|
||||
Create mock data files in `test/unit/mocks/`:
|
||||
|
||||
- **event-mocks.js**: Mock event data for various event kinds (0, 1, 3, 7)
|
||||
- **nostr-relay-mocks.js**: Mock responses from Nostr relay (OK, EVENT, EOSE, CLOSED)
|
||||
- **controller-mocks.js**: Mock Express request/response objects
|
||||
|
||||
## Key Considerations
|
||||
|
||||
1. **ES Modules**: The app uses ES modules (import/export) while the example uses CommonJS. Tests should use ES modules with `.js` extension and proper import syntax.
|
||||
|
||||
2. **Test Environment**: Integration tests will need a running Nostr relay. Consider:
|
||||
|
||||
- Using a test relay URL from config
|
||||
- Or creating a mock WebSocket server for integration tests
|
||||
- Or documenting that tests require a relay URL in environment
|
||||
|
||||
3. **Test Coverage**: Ensure tests cover:
|
||||
|
||||
- All endpoints used by examples (POST /event, GET /req/:subId)
|
||||
- All event kinds (0, 1, 3, 7)
|
||||
- Error paths and edge cases
|
||||
- Validation logic
|
||||
|
||||
4. **Test Data**: Use the same test keys and data patterns from examples where applicable (e.g., Alice's private key, Bob's public key)
|
||||
|
||||
5. **Async Handling**: Properly handle async/await in tests, especially for WebSocket operations and SSE streams
|
||||
|
||||
6. **Cleanup**: Ensure proper cleanup of WebSocket connections and subscriptions in tests
|
||||
|
||||
## Test Execution
|
||||
|
||||
Tests will run using existing npm scripts:
|
||||
|
||||
- `npm test` - Runs unit tests with linting and coverage
|
||||
- `npm run test:integration` - Runs integration tests with extended timeout
|
||||
- `npm run coverage` - Generates coverage report
|
||||
@@ -0,0 +1,13 @@
|
||||
The express.js app in the `app/` directory needs unit and integration tests. The scope of this task is to create a plan for adding these
|
||||
|
||||
When considering the creation of tests, follow the patters provided in the example available in the `testing/testing-example-code/` directory. This is simple node.js application which uses the following testing libraries:
|
||||
|
||||
- mocha as a test runner
|
||||
- chai as an assertion library
|
||||
- sinon as a stubing library
|
||||
|
||||
The code example show common patterns for writing both unit and integration tests.
|
||||
|
||||
The `/app/examples/` directory contains working code that I've tested and verified works correctly. The unit and integration tests you create should cover the same code paths exercised by these examples.
|
||||
|
||||
The testing dependencies are already installed and listed in the package.json file under the `app/` directory. The package.json file also contains the `test`, `test:integration`, and `coverage` scripts that we'll use to run tests and measure test coverage.
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
Example script creating a key-pair for a Nostr account and publishing profile metadata.
|
||||
Refactored to use REST API instead of WebSocket.
|
||||
|
||||
Run the server with `npm start` in the main directory, before running this example.
|
||||
*/
|
||||
|
||||
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'
|
||||
import * as nip19 from 'nostr-tools/nip19'
|
||||
import { bytesToHex } from '@noble/hashes/utils.js'
|
||||
|
||||
const API_URL = process.env.API_URL || 'http://localhost:5942'
|
||||
|
||||
// Generate keys
|
||||
const sk = generateSecretKey() // `sk` is a Uint8Array
|
||||
const nsec = nip19.nsecEncode(sk)
|
||||
const skHex = bytesToHex(sk)
|
||||
|
||||
const pk = getPublicKey(sk) // `pk` is a hex string
|
||||
const npub = nip19.npubEncode(pk)
|
||||
|
||||
console.log('private key:', skHex)
|
||||
console.log('encoded private key:', nsec)
|
||||
console.log()
|
||||
console.log('public key:', pk)
|
||||
console.log('encoded public key:', npub)
|
||||
console.log()
|
||||
|
||||
// Create profile metadata event (kind 0)
|
||||
const profileMetadata = {
|
||||
name: 'Alice',
|
||||
about: 'Hello, I am Alice!',
|
||||
picture: 'https://example.com/alice.jpg'
|
||||
}
|
||||
|
||||
const eventTemplate = {
|
||||
kind: 0,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: JSON.stringify(profileMetadata)
|
||||
}
|
||||
|
||||
// Sign the event
|
||||
const signedEvent = finalizeEvent(eventTemplate, sk)
|
||||
console.log('Signed event:', JSON.stringify(signedEvent, null, 2))
|
||||
|
||||
// Publish to REST API
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/event`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(signedEvent)
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
console.log('Publish result:', result)
|
||||
|
||||
if (result.accepted) {
|
||||
console.log('Profile metadata published successfully!')
|
||||
} else {
|
||||
console.error('Failed to publish:', result.message)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error publishing event:', err)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
Example script for reading posts (kind 1 events) from a relay.
|
||||
Refactored to use REST API instead of WebSocket.
|
||||
|
||||
Run the server with `npm start` in the main directory, before running this example.
|
||||
*/
|
||||
|
||||
const API_URL = process.env.API_URL || 'http://localhost:5942'
|
||||
|
||||
// JB55's public key
|
||||
const jb55 = '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
|
||||
|
||||
// Create subscription ID
|
||||
const subId = 'read-posts-' + Date.now()
|
||||
|
||||
// Create filters - read posts from JB55
|
||||
const filters = {
|
||||
limit: 2,
|
||||
kinds: [1],
|
||||
authors: [jb55]
|
||||
}
|
||||
|
||||
try {
|
||||
// Query events using GET /req/:subId
|
||||
const filtersJson = encodeURIComponent(JSON.stringify([filters]))
|
||||
const url = `${API_URL}/req/${subId}?filters=${filtersJson}`
|
||||
|
||||
console.log(`Querying events from ${API_URL}`)
|
||||
console.log('Filters:', JSON.stringify(filters, null, 2))
|
||||
|
||||
const response = await fetch(url)
|
||||
const events = await response.json()
|
||||
|
||||
console.log(`\nReceived ${events.length} events:`)
|
||||
events.forEach((ev, index) => {
|
||||
console.log(`\nEvent ${index + 1}:`)
|
||||
console.log(' ID:', ev.id)
|
||||
console.log(' Author:', ev.pubkey)
|
||||
console.log(' Created:', new Date(ev.created_at * 1000).toISOString())
|
||||
console.log(' Content:', ev.content)
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Error reading posts:', err)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
Example script for writing a post to a relay.
|
||||
Refactored to use REST API instead of WebSocket.
|
||||
|
||||
Run the server with `npm start` in the main directory, before running this example.
|
||||
*/
|
||||
|
||||
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure'
|
||||
import { hexToBytes } from '@noble/hashes/utils.js'
|
||||
|
||||
const API_URL = process.env.API_URL || 'http://localhost:5942'
|
||||
|
||||
// Alice is our user making the post.
|
||||
const alicePrivKeyHex = '3292a48aa331aeccce003d50d70fbd79617ba91860abbd2c78fa4a8301e36bc0'
|
||||
const alicePrivKeyBin = hexToBytes(alicePrivKeyHex)
|
||||
const alicePubKey = getPublicKey(alicePrivKeyBin)
|
||||
console.log(`Alice Public Key: ${alicePubKey}`)
|
||||
|
||||
const now = new Date()
|
||||
|
||||
// Generate a post.
|
||||
const eventTemplate = {
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: `This is a test message posted at ${now.toLocaleString()}`
|
||||
}
|
||||
console.log(`eventTemplate: ${JSON.stringify(eventTemplate, null, 2)}`)
|
||||
|
||||
// Sign the post
|
||||
const signedEvent = finalizeEvent(eventTemplate, alicePrivKeyBin)
|
||||
console.log('signedEvent:', JSON.stringify(signedEvent, null, 2))
|
||||
|
||||
// Publish to REST API
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/event`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(signedEvent)
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
console.log('result:', result)
|
||||
|
||||
if (result.accepted) {
|
||||
console.log('Post published successfully!')
|
||||
console.log('Event ID:', result.eventId)
|
||||
} else {
|
||||
console.error('Failed to publish:', result.message)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error publishing post:', err)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
Example script for reading posts from user Alice.
|
||||
Refactored to use REST API instead of WebSocket.
|
||||
|
||||
Run the server with `npm start` in the main directory, before running this example.
|
||||
*/
|
||||
|
||||
import { getPublicKey } from 'nostr-tools/pure'
|
||||
import { hexToBytes } from '@noble/hashes/utils.js'
|
||||
|
||||
const API_URL = process.env.API_URL || 'http://localhost:5942'
|
||||
|
||||
// Alice is our user.
|
||||
const alicePrivKeyHex = '3292a48aa331aeccce003d50d70fbd79617ba91860abbd2c78fa4a8301e36bc0'
|
||||
const alicePrivKeyBin = hexToBytes(alicePrivKeyHex)
|
||||
const alicePubKey = getPublicKey(alicePrivKeyBin)
|
||||
console.log(`Alice Public Key: ${alicePubKey}`)
|
||||
|
||||
// Create subscription ID
|
||||
const subId = 'read-alice-posts-' + Date.now()
|
||||
|
||||
// Create filters - read posts from Alice
|
||||
const filters = {
|
||||
limit: 2,
|
||||
kinds: [1],
|
||||
authors: [alicePubKey]
|
||||
}
|
||||
|
||||
try {
|
||||
// Query events using GET /req/:subId
|
||||
const filtersJson = encodeURIComponent(JSON.stringify([filters]))
|
||||
const url = `${API_URL}/req/${subId}?filters=${filtersJson}`
|
||||
|
||||
console.log(`Querying events from ${API_URL}`)
|
||||
console.log('Filters:', JSON.stringify(filters, null, 2))
|
||||
|
||||
const response = await fetch(url)
|
||||
const events = await response.json()
|
||||
|
||||
console.log(`\nReceived ${events.length} events from Alice:`)
|
||||
events.forEach((ev, index) => {
|
||||
console.log(`\nEvent ${index + 1}:`)
|
||||
console.log(' ID:', ev.id)
|
||||
console.log(' Created:', new Date(ev.created_at * 1000).toISOString())
|
||||
console.log(' Content:', ev.content)
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Error reading Alice posts:', err)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
Example script for getting a follow list (kind 3 events).
|
||||
Refactored to use REST API instead of WebSocket.
|
||||
|
||||
Run the server with `npm start` in the main directory, before running this example.
|
||||
*/
|
||||
|
||||
import { getPublicKey } from 'nostr-tools/pure'
|
||||
import { hexToBytes } from '@noble/hashes/utils.js'
|
||||
|
||||
const API_URL = process.env.API_URL || 'http://localhost:5942'
|
||||
|
||||
// Alice is our user to get the follow list.
|
||||
const alicePrivKeyHex = '3292a48aa331aeccce003d50d70fbd79617ba91860abbd2c78fa4a8301e36bc0'
|
||||
const alicePrivKeyBin = hexToBytes(alicePrivKeyHex)
|
||||
const alicePubKey = getPublicKey(alicePrivKeyBin)
|
||||
console.log(`Alice Public Key: ${alicePubKey}`)
|
||||
|
||||
// Create subscription ID
|
||||
const subId = 'get-follow-list-' + Date.now()
|
||||
|
||||
// Create filters - get follow list (kind 3) from Alice
|
||||
const filters = {
|
||||
limit: 5,
|
||||
kinds: [3],
|
||||
authors: [alicePubKey]
|
||||
}
|
||||
|
||||
try {
|
||||
// Query events using GET /req/:subId
|
||||
const filtersJson = encodeURIComponent(JSON.stringify([filters]))
|
||||
const url = `${API_URL}/req/${subId}?filters=${filtersJson}`
|
||||
|
||||
console.log(`Querying follow list from ${API_URL}`)
|
||||
console.log('Filters:', JSON.stringify(filters, null, 2))
|
||||
|
||||
const response = await fetch(url)
|
||||
const events = await response.json()
|
||||
|
||||
if (events.length > 0) {
|
||||
// Get the most recent follow list (kind 3 events are replaceable)
|
||||
const followListEvent = events[0]
|
||||
const aliceFollowList = followListEvent.tags.filter(tag => tag[0] === 'p')
|
||||
console.log(`\nAlice Follow list (${aliceFollowList.length} followed users):`)
|
||||
aliceFollowList.forEach((tag, index) => {
|
||||
console.log(` ${index + 1}. ${tag[1]}${tag[3] ? ` (${tag[3]})` : ''}`)
|
||||
})
|
||||
} else {
|
||||
console.log('\nNo follow list found for Alice')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error getting follow list:', err)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
Example to update the follow list with a new list of people to follow.
|
||||
Refactored to use REST API instead of WebSocket.
|
||||
|
||||
Run the server with `npm start` in the main directory, before running this example.
|
||||
*/
|
||||
|
||||
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure'
|
||||
import { hexToBytes } from '@noble/hashes/utils.js'
|
||||
|
||||
const API_URL = process.env.API_URL || 'http://localhost:5942'
|
||||
|
||||
// Alice wants to update her follow list
|
||||
const alicePrivKeyHex = '3292a48aa331aeccce003d50d70fbd79617ba91860abbd2c78fa4a8301e36bc0'
|
||||
const alicePrivKeyBin = hexToBytes(alicePrivKeyHex)
|
||||
const alicePubKey = getPublicKey(alicePrivKeyBin)
|
||||
console.log(`Alice Public Key: ${alicePubKey}`)
|
||||
|
||||
// Bob is the person to be added to the new follow list
|
||||
const bobPrivKeyHex = 'd2e71a977bc3900d6b0f787421e3d1a666cd12ca625482b0d9eeffd23489c99f'
|
||||
const bobPrivKeyBin = hexToBytes(bobPrivKeyHex)
|
||||
const bobPubKey = getPublicKey(bobPrivKeyBin)
|
||||
console.log(`Bob Public Key: ${bobPubKey}`)
|
||||
|
||||
const psf = 'wss://nostr-relay.psfoundation.info'
|
||||
|
||||
const followList = [
|
||||
['p', bobPubKey, psf, 'bob']
|
||||
]
|
||||
|
||||
// Generate a follow list event (kind 3)
|
||||
const eventTemplate = {
|
||||
kind: 3,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: followList,
|
||||
content: ''
|
||||
}
|
||||
console.log(`eventTemplate: ${JSON.stringify(eventTemplate, null, 2)}`)
|
||||
|
||||
// Sign the event
|
||||
const signedEvent = finalizeEvent(eventTemplate, alicePrivKeyBin)
|
||||
|
||||
// Publish to REST API
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/event`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(signedEvent)
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
console.log('Publish result:', result)
|
||||
|
||||
if (result.accepted) {
|
||||
console.log('Follow list updated successfully!')
|
||||
} else {
|
||||
console.error('Failed to update follow list:', result.message)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error updating follow list:', err)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
Example script for adding a reaction (like) to an event.
|
||||
Refactored to use REST API instead of WebSocket.
|
||||
https://github.com/nostr-protocol/nips/blob/master/25.md
|
||||
|
||||
Run the server with `npm start` in the main directory, before running this example.
|
||||
*/
|
||||
|
||||
import { hexToBytes } from '@noble/hashes/utils.js'
|
||||
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure'
|
||||
|
||||
const API_URL = process.env.API_URL || 'http://localhost:5942'
|
||||
|
||||
const bobPrivKeyHex = 'd2e71a977bc3900d6b0f787421e3d1a666cd12ca625482b0d9eeffd23489c99f'
|
||||
const bobPrivKeyBin = hexToBytes(bobPrivKeyHex)
|
||||
const bobPubKey = getPublicKey(bobPrivKeyBin)
|
||||
|
||||
const psf = 'wss://nostr-relay.psfoundation.info'
|
||||
|
||||
const evIdToLike = 'd09b4c5da59be3cd2768aa53fa78b77bf4859084c94f3bf26d401f004a9c8167'
|
||||
const evIdAuthorPubKey = '2c7e76c0f8dc1dca9d0197c7d19be580a8d074ccada6a2f6ebe056ae41092e92'
|
||||
|
||||
// Generate like event (kind 7)
|
||||
const likeEventTemplate = {
|
||||
kind: 7,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
pubkey: bobPubKey,
|
||||
tags: [
|
||||
['e', evIdToLike, psf], // "e" tag includes event id, relay reference
|
||||
['p', evIdAuthorPubKey, psf] // "p" tag includes author pubkey, relay reference
|
||||
],
|
||||
content: '+'
|
||||
}
|
||||
|
||||
// Sign the event
|
||||
const signedEvent = finalizeEvent(likeEventTemplate, bobPrivKeyBin)
|
||||
console.log('signedEvent:', JSON.stringify(signedEvent, null, 2))
|
||||
|
||||
// Publish to REST API
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/event`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(signedEvent)
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
console.log('result:', result)
|
||||
|
||||
if (result.accepted) {
|
||||
console.log('Like published successfully!')
|
||||
} else {
|
||||
console.error('Failed to publish like:', result.message)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error publishing like:', err)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
# REST2NOSTR Examples
|
||||
|
||||
This directory contains examples refactored from the `nostr-sandbox/` directory to use the REST API instead of WebSocket connections.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
npm install nostr-tools @noble/hashes
|
||||
```
|
||||
|
||||
2. Start the REST2NOSTR proxy server:
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
3. Set the API_URL environment variable if the server is not running on localhost:3000:
|
||||
```bash
|
||||
export API_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### 01-create-account.js
|
||||
Creates a new Nostr account keypair and publishes profile metadata (kind 0 event).
|
||||
|
||||
```bash
|
||||
node examples/01-create-account.js
|
||||
```
|
||||
|
||||
### 02-read-posts.js
|
||||
Reads posts (kind 1 events) from a specific author using GET /req/:subId.
|
||||
|
||||
```bash
|
||||
node examples/02-read-posts.js
|
||||
```
|
||||
|
||||
### 03-write-post.js
|
||||
Publishes a text post (kind 1 event) using POST /event.
|
||||
|
||||
```bash
|
||||
node examples/03-write-post.js
|
||||
```
|
||||
|
||||
### 04-read-alice-posts.js
|
||||
Reads posts from Alice's account using GET /req/:subId with author filter.
|
||||
|
||||
```bash
|
||||
node examples/04-read-alice-posts.js
|
||||
```
|
||||
|
||||
### 14-get-follow-list.js
|
||||
Retrieves a user's follow list (kind 3 event) using GET /req/:subId.
|
||||
|
||||
```bash
|
||||
node examples/05-get-follow-list.js
|
||||
```
|
||||
|
||||
### 15-update-follow-list.js
|
||||
Updates a user's follow list (kind 3 event) using POST /event.
|
||||
|
||||
```bash
|
||||
node examples/06-update-follow-list.js
|
||||
```
|
||||
|
||||
### 17-liking-event.js
|
||||
Adds a reaction/like to an event (kind 7 event) using POST /event.
|
||||
|
||||
```bash
|
||||
node examples/07-liking-event.js
|
||||
```
|
||||
|
||||
## API Endpoints Used
|
||||
|
||||
- **POST /event**: Publish events to the relay
|
||||
- **GET /req/:subId**: Stateless query for events (returns immediately)
|
||||
|
||||
## Differences from WebSocket Examples
|
||||
|
||||
1. **No WebSocket connections**: All communication is via HTTP REST API
|
||||
2. **Stateless queries**: GET /req/:subId returns events immediately rather than streaming
|
||||
3. **Event publishing**: POST /event returns immediately with acceptance status
|
||||
4. **No subscription management**: For stateless queries, subscriptions are automatically closed after EOSE
|
||||
|
||||
## Notes
|
||||
|
||||
- These examples use the same private keys as the original sandbox examples for consistency
|
||||
- The REST API handles WebSocket connections to relays internally
|
||||
- For real-time streaming, use POST /req/:subId which supports Server-Sent Events (SSE)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
Main entry point for REST2NOSTR Proxy API
|
||||
*/
|
||||
|
||||
import Server from './bin/server.js'
|
||||
|
||||
const server = new Server()
|
||||
server.startServer().catch(err => {
|
||||
console.error('Failed to start server:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
Generated
+7990
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "psf-bch-api",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node bin/server.js",
|
||||
"docs": "./node_modules/.bin/apidoc -i src/ -o docs",
|
||||
"lint": "standard --env mocha --fix",
|
||||
"test": "npm run lint && TEST=unit c8 mocha 'test/unit/**/*.js' --exit",
|
||||
"test:integration": "mocha --timeout 25000 'test/integration/**/*.js' --exit",
|
||||
"coverage": "c8 --reporter=html mocha 'test/unit/**/*.js' --exit"
|
||||
},
|
||||
"author": "Chris Troutner <chris.troutner@gmail.com>",
|
||||
"license": "MIT",
|
||||
"description": "REST API proxy to Bitcoin Cash infrastructure",
|
||||
"dependencies": {
|
||||
"cors": "2.8.5",
|
||||
"dotenv": "16.3.1",
|
||||
"express": "5.1.0",
|
||||
"winston": "3.11.0",
|
||||
"winston-daily-rotate-file": "4.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"apidoc": "1.2.0",
|
||||
"c8": "10.1.3",
|
||||
"chai": "6.2.0",
|
||||
"mocha": "11.7.4",
|
||||
"sinon": "21.0.0",
|
||||
"standard": "17.1.2"
|
||||
},
|
||||
"apidoc": {
|
||||
"title": "psf-bch-api",
|
||||
"url": "http://localhost:3070"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
# Create a Dockerized API server
|
||||
#
|
||||
|
||||
#IMAGE BUILD COMMANDS
|
||||
# ct-base-ubuntu = ubuntu 18.04 + nodejs v10 LTS
|
||||
#FROM christroutner/ct-base-ubuntu
|
||||
FROM ubuntu:22.04
|
||||
MAINTAINER Chris Troutner <chris.troutner@gmail.com>
|
||||
|
||||
#Update the OS and install any OS packages needed.
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y sudo git curl nano gnupg wget zip unzip python3
|
||||
|
||||
#Install Node and NPM
|
||||
RUN curl -sL https://deb.nodesource.com/setup_20.x -o nodesource_setup.sh
|
||||
RUN bash nodesource_setup.sh
|
||||
RUN apt-get install -y nodejs build-essential
|
||||
|
||||
#Create the user 'safeuser' and add them to the sudo group.
|
||||
RUN useradd -ms /bin/bash safeuser
|
||||
RUN adduser safeuser sudo
|
||||
|
||||
#Set password to 'password' change value below if you want a different password
|
||||
RUN echo safeuser:password | chpasswd
|
||||
|
||||
#Set the working directory to be the home directory
|
||||
WORKDIR /home/safeuser
|
||||
|
||||
#Setup NPM for non-root global install
|
||||
RUN mkdir /home/safeuser/.npm-global
|
||||
RUN chown -R safeuser .npm-global
|
||||
RUN echo "export PATH=~/.npm-global/bin:$PATH" >> /home/safeuser/.profile
|
||||
RUN runuser -l safeuser -c "npm config set prefix '~/.npm-global'"
|
||||
|
||||
# Update to the latest version of npm.
|
||||
#RUN npm install -g npm@8.3.0
|
||||
|
||||
# npm mirror to prevent direct dependency on npm.
|
||||
#RUN npm set registry http://94.130.170.209:4873/
|
||||
|
||||
# Switch to user account.
|
||||
#USER safeuser
|
||||
# Prep 'sudo' commands.
|
||||
#RUN echo 'abcd8765' | sudo -S pwd
|
||||
|
||||
#RUN npm install -g node-gyp
|
||||
|
||||
# Clone the rest.bitcoin.com repository
|
||||
WORKDIR /home/safeuser
|
||||
RUN git clone https://github.com/christroutner/REST2NOSTR
|
||||
|
||||
# Switch to the desired branch. `master` is usually stable,
|
||||
# and `stage` has the most up-to-date changes.
|
||||
WORKDIR /home/safeuser/REST2NOSTR
|
||||
|
||||
# For development: switch to unstable branch
|
||||
#RUN git checkout pin-ipfs
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Generate the API docs
|
||||
RUN npm run docs
|
||||
|
||||
#VOLUME /home/safeuser/keys
|
||||
|
||||
# Make leveldb folders
|
||||
#RUN mkdir leveldb
|
||||
#WORKDIR /home/safeuser/psf-slp-indexer/leveldb
|
||||
#RUN mkdir current
|
||||
#RUN mkdir zips
|
||||
#RUN mkdir backup
|
||||
#WORKDIR /home/safeuser/psf-slp-indexer/leveldb/zips
|
||||
#COPY restore-auto.sh restore-auto.sh
|
||||
#WORKDIR /home/safeuser/psf-slp-indexer
|
||||
|
||||
# Expose the port the API will be served on.
|
||||
#EXPOSE 5011
|
||||
|
||||
# Start the application.
|
||||
#COPY start-production.sh start-production.sh
|
||||
VOLUME start-rest2nostr.sh
|
||||
CMD ["./start-rest2nostr.sh"]
|
||||
|
||||
#CMD ["npm", "start"]
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Remove all untagged docker images.
|
||||
docker rmi $(docker images | grep "^<none>" | awk '{print $3}')
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Start the service with the command 'docker-compose up -d'
|
||||
|
||||
services:
|
||||
rest2nostr:
|
||||
build: .
|
||||
container_name: rest2nostr
|
||||
logging:
|
||||
driver: 'json-file'
|
||||
options:
|
||||
max-size: '10m'
|
||||
max-file: '10'
|
||||
#mem_limit: 500mb
|
||||
#links:
|
||||
# - mongo-slp-indexer
|
||||
ports:
|
||||
- '5942:5942' # <host port>:<container port>
|
||||
volumes:
|
||||
- ./start-rest2nostr.sh:/home/safeuser/REST2NOSTR/start-rest2nostr.sh
|
||||
restart: always
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
npm start
|
||||
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
This is a top-level library that encapsulates all the additional Adapters.
|
||||
The concept of Adapters comes from Clean Architecture:
|
||||
https://troutsblog.com/blog/clean-architecture
|
||||
*/
|
||||
|
||||
// Load individual adapter libraries.
|
||||
// import NostrRelayAdapter from './nostr-relay.js'
|
||||
import config from '../config/index.js'
|
||||
|
||||
class Adapters {
|
||||
constructor (localConfig = {}) {
|
||||
// Encapsulate dependencies
|
||||
this.config = config
|
||||
|
||||
// Determine relay URLs: prefer localConfig, fall back to config
|
||||
// let relayUrls = []
|
||||
// if (localConfig.relayUrls && Array.isArray(localConfig.relayUrls)) {
|
||||
// relayUrls = localConfig.relayUrls
|
||||
// } else if (localConfig.relayUrl) {
|
||||
// // Backward compatibility: single relay URL
|
||||
// relayUrls = [localConfig.relayUrl]
|
||||
// } else {
|
||||
// relayUrls = config.nostrRelayUrls
|
||||
// }
|
||||
|
||||
// Create one adapter per relay URL
|
||||
// this.nostrRelays = relayUrls.map(relayUrl => new NostrRelayAdapter({ relayUrl }))
|
||||
|
||||
// Maintain backward compatibility: expose first relay as nostrRelay
|
||||
// This allows existing code to work during transition
|
||||
// this.nostrRelay = this.nostrRelays[0]
|
||||
}
|
||||
|
||||
async start () {
|
||||
// try {
|
||||
// Connect to all Nostr relays concurrently
|
||||
// const connectPromises = this.nostrRelays.map(async (relay, index) => {
|
||||
// try {
|
||||
// await relay.connect()
|
||||
// console.log(`Nostr relay adapter ${index + 1}/${this.nostrRelays.length} started: ${relay.relayUrl}`)
|
||||
// return { success: true, relay }
|
||||
// } catch (err) {
|
||||
// console.error(`Failed to connect to relay ${relay.relayUrl}:`, err.message)
|
||||
// return { success: false, relay, error: err }
|
||||
// }
|
||||
// })
|
||||
|
||||
// const results = await Promise.allSettled(connectPromises)
|
||||
|
||||
// const successful = results.filter(r => r.status === 'fulfilled' && r.value.success).length
|
||||
// const failed = results.length - successful
|
||||
|
||||
// if (successful === 0) {
|
||||
// throw new Error('Failed to connect to any Nostr relay')
|
||||
// }
|
||||
|
||||
// if (failed > 0) {
|
||||
// console.warn(`Connected to ${successful}/${this.nostrRelays.length} relays. Some relays failed to connect.`)
|
||||
// } else {
|
||||
// console.log(`All ${successful} Nostr relay adapters started successfully.`)
|
||||
// }
|
||||
|
||||
return true
|
||||
// } catch (err) {
|
||||
// console.error('Error in adapters/index.js/start()')
|
||||
// throw err
|
||||
// }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all relay adapters
|
||||
* @returns {Array<NostrRelayAdapter>}
|
||||
*/
|
||||
getRelays () {
|
||||
return this.nostrRelays
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast an event to all relays
|
||||
* @param {Object} event - Event object to broadcast
|
||||
* @returns {Promise<Array>} Array of results from each relay: { accepted, message, relayUrl }
|
||||
*/
|
||||
async broadcastEvent (event) {
|
||||
const broadcastPromises = this.nostrRelays.map(async (relay) => {
|
||||
try {
|
||||
const result = await relay.sendEvent(event)
|
||||
return {
|
||||
accepted: result.accepted,
|
||||
message: result.message || '',
|
||||
relayUrl: relay.relayUrl,
|
||||
success: true
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
accepted: false,
|
||||
message: err.message || 'Error broadcasting to relay',
|
||||
relayUrl: relay.relayUrl,
|
||||
success: false,
|
||||
error: err
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return Promise.allSettled(broadcastPromises).then(results => {
|
||||
return results.map((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
return result.value
|
||||
} else {
|
||||
return {
|
||||
accepted: false,
|
||||
message: result.reason?.message || 'Unknown error',
|
||||
relayUrl: this.nostrRelays[index].relayUrl,
|
||||
success: false,
|
||||
error: result.reason
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Query all relays concurrently and merge results
|
||||
* @param {Array} filters - Array of filter objects
|
||||
* @param {string} subscriptionId - Unique subscription ID (will be modified per relay)
|
||||
* @returns {Promise<Array>} Merged and de-duplicated array of events
|
||||
*/
|
||||
async queryAllRelays (filters, subscriptionId) {
|
||||
// Create unique subscription IDs for each relay
|
||||
const subscriptionIds = this.nostrRelays.map((relay, index) =>
|
||||
`${subscriptionId}-relay-${index}`
|
||||
)
|
||||
|
||||
// Collect events from all relays
|
||||
const allEvents = []
|
||||
const relayStatuses = this.nostrRelays.map(() => ({
|
||||
eoseReceived: false,
|
||||
closedReceived: false,
|
||||
closedMessage: ''
|
||||
}))
|
||||
|
||||
// Query all relays concurrently
|
||||
const queryPromises = this.nostrRelays.map(async (relay, index) => {
|
||||
const subscriptionIdForRelay = subscriptionIds[index]
|
||||
const status = relayStatuses[index]
|
||||
|
||||
// Create handlers for this relay
|
||||
const handlers = {
|
||||
onEvent: (event) => {
|
||||
allEvents.push(event)
|
||||
},
|
||||
onEose: () => {
|
||||
status.eoseReceived = true
|
||||
},
|
||||
onClosed: (message) => {
|
||||
status.closedReceived = true
|
||||
status.closedMessage = message
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await relay.sendReq(subscriptionIdForRelay, filters, handlers)
|
||||
|
||||
// Wait for EOSE or CLOSED with timeout
|
||||
const timeout = 30000 // 30 seconds
|
||||
const startTime = Date.now()
|
||||
|
||||
while (!status.eoseReceived && !status.closedReceived && (Date.now() - startTime) < timeout) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
}
|
||||
|
||||
// Clean up subscription - always try to close, even if EOSE didn't come
|
||||
try {
|
||||
await relay.sendClose(subscriptionIdForRelay)
|
||||
} catch (err) {
|
||||
// Ignore close errors
|
||||
}
|
||||
|
||||
if (status.closedReceived) {
|
||||
throw new Error(`Subscription closed on ${relay.relayUrl}: ${status.closedMessage}`)
|
||||
}
|
||||
|
||||
// If EOSE never came but timeout expired, that's OK - we proceed with what we got
|
||||
if (!status.eoseReceived && (Date.now() - startTime) >= timeout) {
|
||||
// Timeout reached without EOSE - proceed anyway with events collected so far
|
||||
}
|
||||
} catch (err) {
|
||||
// Ensure cleanup even on error
|
||||
try {
|
||||
await relay.sendClose(subscriptionIdForRelay)
|
||||
} catch (closeErr) {
|
||||
// Ignore close errors
|
||||
}
|
||||
// Log error but don't fail the entire query
|
||||
console.warn(`Query failed for relay ${relay.relayUrl}:`, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
// Wait for all queries to complete
|
||||
await Promise.allSettled(queryPromises)
|
||||
|
||||
// Merge and de-duplicate events by event ID
|
||||
const eventMap = new Map()
|
||||
allEvents.forEach(event => {
|
||||
if (event && event.id && !eventMap.has(event.id)) {
|
||||
eventMap.set(event.id, event)
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(eventMap.values())
|
||||
}
|
||||
}
|
||||
|
||||
export default Adapters
|
||||
@@ -0,0 +1,241 @@
|
||||
/*
|
||||
Nostr Relay WebSocket adapter.
|
||||
Handles WebSocket connections to Nostr relays and manages message sending/receiving.
|
||||
*/
|
||||
|
||||
import WebSocket from 'ws'
|
||||
import config from '../config/index.js'
|
||||
import wlogger from './wlogger.js'
|
||||
|
||||
class NostrRelayAdapter {
|
||||
constructor (localConfig = {}) {
|
||||
this.config = config
|
||||
this.relayUrl = localConfig.relayUrl || config.nostrRelayUrl
|
||||
this.ws = null
|
||||
this.isConnected = false
|
||||
this.reconnectAttempts = 0
|
||||
this.maxReconnectAttempts = 5
|
||||
this.reconnectDelay = 5000 // 5 seconds
|
||||
this.messageHandlers = new Map() // Map subscription_id to handlers
|
||||
this.pendingMessages = [] // Queue messages while disconnected
|
||||
this.eventResolvers = new Map() // Map event_id to promise resolvers for OK responses
|
||||
this.subscriptionHandlers = new Map() // Map subscription_id to event handlers
|
||||
|
||||
// Bind methods
|
||||
this.connect = this.connect.bind(this)
|
||||
this.disconnect = this.disconnect.bind(this)
|
||||
this.sendEvent = this.sendEvent.bind(this)
|
||||
this.sendReq = this.sendReq.bind(this)
|
||||
this.sendClose = this.sendClose.bind(this)
|
||||
this.handleMessage = this.handleMessage.bind(this)
|
||||
this.handleError = this.handleError.bind(this)
|
||||
this.handleClose = this.handleClose.bind(this)
|
||||
}
|
||||
|
||||
async connect () {
|
||||
if (this.ws && this.isConnected) {
|
||||
return true
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
wlogger.info(`Connecting to Nostr relay: ${this.relayUrl}`)
|
||||
this.ws = new WebSocket(this.relayUrl)
|
||||
|
||||
this.ws.on('open', () => {
|
||||
wlogger.info('Connected to Nostr relay')
|
||||
this.isConnected = true
|
||||
this.reconnectAttempts = 0
|
||||
|
||||
// Send any pending messages
|
||||
while (this.pendingMessages.length > 0) {
|
||||
const message = this.pendingMessages.shift()
|
||||
this.ws.send(JSON.stringify(message))
|
||||
}
|
||||
|
||||
resolve(true)
|
||||
})
|
||||
|
||||
this.ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString())
|
||||
this.handleMessage(message)
|
||||
} catch (err) {
|
||||
wlogger.error('Error parsing relay message:', err)
|
||||
}
|
||||
})
|
||||
|
||||
this.ws.on('error', this.handleError)
|
||||
this.ws.on('close', this.handleClose)
|
||||
|
||||
// Timeout after 10 seconds
|
||||
setTimeout(() => {
|
||||
if (!this.isConnected) {
|
||||
reject(new Error('Connection timeout'))
|
||||
}
|
||||
}, 10000)
|
||||
} catch (err) {
|
||||
wlogger.error('Error connecting to relay:', err)
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async disconnect () {
|
||||
if (this.ws) {
|
||||
this.ws.close()
|
||||
this.ws = null
|
||||
this.isConnected = false
|
||||
wlogger.info('Disconnected from Nostr relay')
|
||||
}
|
||||
}
|
||||
|
||||
handleMessage (message) {
|
||||
if (!Array.isArray(message) || message.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const [type, ...args] = message
|
||||
|
||||
switch (type) {
|
||||
case 'EVENT':
|
||||
// ["EVENT", <subscription_id>, <event>]
|
||||
if (args.length >= 2) {
|
||||
const subscriptionId = args[0]
|
||||
const event = args[1]
|
||||
const handler = this.subscriptionHandlers.get(subscriptionId)
|
||||
if (handler) {
|
||||
handler.onEvent(event)
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'OK':
|
||||
// ["OK", <event_id>, <true|false>, <message>]
|
||||
if (args.length >= 2) {
|
||||
const eventId = args[0]
|
||||
const accepted = args[1]
|
||||
const message = args[2] || ''
|
||||
const resolver = this.eventResolvers.get(eventId)
|
||||
if (resolver) {
|
||||
resolver({ accepted, message })
|
||||
this.eventResolvers.delete(eventId)
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'EOSE':
|
||||
// ["EOSE", <subscription_id>]
|
||||
if (args.length >= 1) {
|
||||
const subscriptionId = args[0]
|
||||
const handler = this.subscriptionHandlers.get(subscriptionId)
|
||||
if (handler) {
|
||||
handler.onEose()
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'CLOSED':
|
||||
// ["CLOSED", <subscription_id>, <message>]
|
||||
if (args.length >= 1) {
|
||||
const subscriptionId = args[0]
|
||||
const message = args[1] || ''
|
||||
const handler = this.subscriptionHandlers.get(subscriptionId)
|
||||
if (handler) {
|
||||
handler.onClosed(message)
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'NOTICE':
|
||||
// ["NOTICE", <message>]
|
||||
if (args.length >= 1) {
|
||||
const message = args[0]
|
||||
wlogger.warn('Relay notice:', message)
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
wlogger.warn('Unknown message type from relay:', type)
|
||||
}
|
||||
}
|
||||
|
||||
handleError (error) {
|
||||
wlogger.error('WebSocket error:', error)
|
||||
this.isConnected = false
|
||||
}
|
||||
|
||||
handleClose () {
|
||||
const now = new Date()
|
||||
wlogger.warn(`WebSocket connection closed at ${now.toLocaleString()}`)
|
||||
this.isConnected = false
|
||||
|
||||
// Attempt to reconnect
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.reconnectAttempts++
|
||||
wlogger.info(`Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`)
|
||||
setTimeout(() => {
|
||||
this.connect().catch(err => {
|
||||
wlogger.error('Reconnection failed:', err)
|
||||
})
|
||||
}, this.reconnectDelay)
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage (message) {
|
||||
if (!this.isConnected || !this.ws) {
|
||||
// Queue message for when connection is established
|
||||
this.pendingMessages.push(message)
|
||||
await this.connect()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
this.ws.send(JSON.stringify(message))
|
||||
} catch (err) {
|
||||
wlogger.error('Error sending message:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async sendEvent (event) {
|
||||
// ["EVENT", <event>]
|
||||
const message = ['EVENT', event]
|
||||
await this.sendMessage(message)
|
||||
|
||||
// Return a promise that resolves when we get the OK response
|
||||
return new Promise((resolve, reject) => {
|
||||
this.eventResolvers.set(event.id, resolve)
|
||||
// Timeout after 30 seconds
|
||||
setTimeout(() => {
|
||||
if (this.eventResolvers.has(event.id)) {
|
||||
this.eventResolvers.delete(event.id)
|
||||
reject(new Error('Timeout waiting for OK response'))
|
||||
}
|
||||
}, 30000)
|
||||
})
|
||||
}
|
||||
|
||||
async sendReq (subscriptionId, filters, handlers) {
|
||||
// ["REQ", <subscription_id>, <filters>]
|
||||
await this.connect()
|
||||
|
||||
// Store handlers for this subscription
|
||||
this.subscriptionHandlers.set(subscriptionId, handlers)
|
||||
|
||||
const message = ['REQ', subscriptionId, ...filters]
|
||||
await this.sendMessage(message)
|
||||
}
|
||||
|
||||
async sendClose (subscriptionId) {
|
||||
// ["CLOSE", <subscription_id>]
|
||||
const message = ['CLOSE', subscriptionId]
|
||||
await this.sendMessage(message)
|
||||
|
||||
// Clean up handlers
|
||||
this.subscriptionHandlers.delete(subscriptionId)
|
||||
this.messageHandlers.delete(subscriptionId)
|
||||
}
|
||||
}
|
||||
|
||||
export default NostrRelayAdapter
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
Instantiates and configures the Winston logging library. This utility library
|
||||
can be called by other parts of the application to conveniently tap into the
|
||||
logging library.
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import winston from 'winston'
|
||||
import 'winston-daily-rotate-file'
|
||||
|
||||
// Local libraries
|
||||
import config from '../config/index.js'
|
||||
|
||||
// Hack to get __dirname back.
|
||||
// https://blog.logrocket.com/alternatives-dirname-node-js-es-modules/
|
||||
import * as url from 'url'
|
||||
const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
let _this = null
|
||||
|
||||
class Wlogger {
|
||||
constructor (localConfig = {}) {
|
||||
this.config = config
|
||||
|
||||
// Configure daily-rotation transport.
|
||||
this.transport = new winston.transports.DailyRotateFile({
|
||||
filename: `${__dirname.toString()}/../../logs/rest2nostr-${
|
||||
this.config.env
|
||||
}-%DATE%.log`,
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
zippedArchive: false,
|
||||
maxSize: '1m', // 1 megabyte
|
||||
maxFiles: '5d', // 5 days
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.json()
|
||||
)
|
||||
})
|
||||
|
||||
this.transport.on('rotate', this.notifyRotation)
|
||||
|
||||
// This controls what goes into the log FILES
|
||||
this.wlogger = winston.createLogger({
|
||||
level: this.config.logLevel || 'info',
|
||||
format: winston.format.json(),
|
||||
transports: [
|
||||
this.transport
|
||||
]
|
||||
})
|
||||
|
||||
// Bind 'this' object to all methods
|
||||
this.notifyRotation = this.notifyRotation.bind(this)
|
||||
this.outputToConsole = this.outputToConsole.bind(this)
|
||||
|
||||
_this = this
|
||||
}
|
||||
|
||||
notifyRotation (oldFilename, newFilename) {
|
||||
_this.wlogger.info('Rotating log files')
|
||||
}
|
||||
|
||||
outputToConsole () {
|
||||
this.wlogger.add(
|
||||
new winston.transports.Console({
|
||||
format: winston.format.simple(),
|
||||
level: this.config.logLevel || 'info'
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const logger = new Wlogger()
|
||||
|
||||
// Allow the logger to write to the console.
|
||||
logger.outputToConsole()
|
||||
|
||||
const wlogger = logger.wlogger
|
||||
|
||||
export { wlogger as default, Wlogger }
|
||||
Vendored
+52
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
This file is used to store unsecure, application-specific data common to all
|
||||
environments.
|
||||
*/
|
||||
|
||||
// Hack to get __dirname back.
|
||||
// https://blog.logrocket.com/alternatives-dirname-node-js-es-modules/
|
||||
import * as url from 'url'
|
||||
import { readFileSync } from 'fs'
|
||||
|
||||
const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
|
||||
const pkgInfo = JSON.parse(readFileSync(`${__dirname.toString()}/../../../package.json`))
|
||||
|
||||
const version = pkgInfo.version
|
||||
|
||||
export default {
|
||||
// Server port
|
||||
port: process.env.PORT || 5942,
|
||||
|
||||
// Environment
|
||||
env: process.env.NODE_ENV || 'development',
|
||||
|
||||
// Logging level
|
||||
logLevel: process.env.LOG_LEVEL || 'info',
|
||||
|
||||
// Nostr relay configuration (array of relay URLs)
|
||||
nostrRelayUrls: (() => {
|
||||
// Support NOSTR_RELAY_URLS (plural) as comma-separated string or JSON array
|
||||
if (process.env.NOSTR_RELAY_URLS) {
|
||||
try {
|
||||
// Try parsing as JSON array first
|
||||
const parsed = JSON.parse(process.env.NOSTR_RELAY_URLS)
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.filter(url => url && typeof url === 'string')
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON, treat as comma-separated string
|
||||
return process.env.NOSTR_RELAY_URLS.split(',').map(url => url.trim()).filter(url => url.length > 0)
|
||||
}
|
||||
}
|
||||
// Backward compatibility: support NOSTR_RELAY_URL (singular)
|
||||
if (process.env.NOSTR_RELAY_URL) {
|
||||
return [process.env.NOSTR_RELAY_URL]
|
||||
}
|
||||
|
||||
// Default
|
||||
return ['wss://nostr-relay.psfoundation.info', 'wss://relay.damus.io']
|
||||
})(),
|
||||
|
||||
// Version
|
||||
version
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/*
|
||||
These are the environment settings for the DEVELOPMENT environment.
|
||||
*/
|
||||
|
||||
export default {
|
||||
env: 'development'
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/*
|
||||
These are the environment settings for the PRODUCTION environment.
|
||||
*/
|
||||
|
||||
export default {
|
||||
env: 'production'
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import common from './env/common.js'
|
||||
|
||||
import development from './env/development.js'
|
||||
import production from './env/production.js'
|
||||
|
||||
const env = process.env.NODE_ENV || 'development'
|
||||
console.log(`Loading config for this environment: ${env}`)
|
||||
|
||||
let config = development
|
||||
if (env === 'production') {
|
||||
config = production
|
||||
}
|
||||
|
||||
export default Object.assign({}, common, config)
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
This is a top-level library that encapsulates all the additional Controllers.
|
||||
The concept of Controllers comes from Clean Architecture:
|
||||
https://troutsblog.com/blog/clean-architecture
|
||||
*/
|
||||
|
||||
// Local libraries
|
||||
import Adapters from '../adapters/index.js'
|
||||
import UseCases from '../use-cases/index.js'
|
||||
import RESTControllers from './rest-api/index.js'
|
||||
import TimerController from './timer-controller.js'
|
||||
import config from '../config/index.js'
|
||||
|
||||
class Controllers {
|
||||
constructor (localConfig = {}) {
|
||||
// Encapsulate dependencies
|
||||
this.adapters = new Adapters(localConfig)
|
||||
this.useCases = new UseCases({ adapters: this.adapters })
|
||||
this.config = config
|
||||
this.timerController = new TimerController({ adapters: this.adapters, useCases: this.useCases })
|
||||
|
||||
// Bind 'this' object to all subfunctions
|
||||
this.initAdapters = this.initAdapters.bind(this)
|
||||
this.initUseCases = this.initUseCases.bind(this)
|
||||
this.attachRESTControllers = this.attachRESTControllers.bind(this)
|
||||
}
|
||||
|
||||
// Spin up any adapter libraries that have async startup needs.
|
||||
async initAdapters () {
|
||||
await this.adapters.start()
|
||||
}
|
||||
|
||||
// Run any Use Cases to startup the app.
|
||||
async initUseCases () {
|
||||
await this.useCases.start()
|
||||
}
|
||||
|
||||
// Initialize all the controllers.
|
||||
async initControllers () {
|
||||
this.timerController.startTimerControllers()
|
||||
}
|
||||
|
||||
// Top-level function for this library.
|
||||
// Start the various Controllers and attach them to the app.
|
||||
attachRESTControllers (app) {
|
||||
const restControllers = new RESTControllers({
|
||||
adapters: this.adapters,
|
||||
useCases: this.useCases
|
||||
})
|
||||
|
||||
// Attach the REST API Controllers to the Express app.
|
||||
restControllers.attachRESTControllers(app)
|
||||
}
|
||||
}
|
||||
|
||||
export default Controllers
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
REST API Controller library for the /event route
|
||||
*/
|
||||
|
||||
// Local libraries
|
||||
import wlogger from '../../../adapters/wlogger.js'
|
||||
|
||||
class EventRESTControllerLib {
|
||||
constructor (localConfig = {}) {
|
||||
// Dependency Injection.
|
||||
this.adapters = localConfig.adapters
|
||||
if (!this.adapters) {
|
||||
throw new Error(
|
||||
'Instance of Adapters library required when instantiating /event REST Controller.'
|
||||
)
|
||||
}
|
||||
this.useCases = localConfig.useCases
|
||||
if (!this.useCases) {
|
||||
throw new Error(
|
||||
'Instance of Use Cases library required when instantiating /event REST Controller.'
|
||||
)
|
||||
}
|
||||
|
||||
// Bind 'this' object to all subfunctions
|
||||
this.publishEvent = this.publishEvent.bind(this)
|
||||
this.handleError = this.handleError.bind(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} /event Publish a Nostr event
|
||||
* @apiPermission public
|
||||
* @apiName PublishEvent
|
||||
* @apiGroup Event
|
||||
*
|
||||
* @apiDescription Publish a signed Nostr event to the relay. Maps to the Nostr WebSocket protocol message: ["EVENT", <event>]
|
||||
*
|
||||
* @apiParam {String} id Event ID (32-bytes lowercase hex-encoded sha256)
|
||||
* @apiParam {String} pubkey Public key of event creator (32-bytes lowercase hex-encoded)
|
||||
* @apiParam {Number} created_at Unix timestamp in seconds
|
||||
* @apiParam {Number} kind Integer between 0 and 65535
|
||||
* @apiParam {Array} tags Array of tag arrays
|
||||
* @apiParam {String} content Event content (arbitrary string)
|
||||
* @apiParam {String} sig Signature (64-bytes lowercase hex)
|
||||
*
|
||||
* @apiExample {json} Example usage:
|
||||
* {
|
||||
* "id": "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36",
|
||||
* "pubkey": "2c7e76c0f8dc1dca9d0197c7d19be580a8d074ccada6a2f6ebe056ae41092e9",
|
||||
* "created_at": 1672531200,
|
||||
* "kind": 1,
|
||||
* "tags": [],
|
||||
* "content": "Hello, Nostr!",
|
||||
* "sig": "abc123..."
|
||||
* }
|
||||
*
|
||||
* @apiSuccess {Boolean} accepted Whether the event was accepted by the relay
|
||||
* @apiSuccess {String} message Optional message from the relay
|
||||
* @apiSuccess {String} eventId The event ID
|
||||
*
|
||||
* @apiError {String} error Error message
|
||||
*/
|
||||
async publishEvent (req, res) {
|
||||
try {
|
||||
const eventData = req.body
|
||||
|
||||
// Check if eventData is missing or empty
|
||||
if (!eventData || (typeof eventData === 'object' && Object.keys(eventData).length === 0)) {
|
||||
return res.status(400).json({
|
||||
error: 'Event data is required'
|
||||
})
|
||||
}
|
||||
|
||||
const result = await this.useCases.publishEvent.execute(eventData)
|
||||
|
||||
if (result.accepted) {
|
||||
return res.status(200).json(result)
|
||||
} else {
|
||||
return res.status(400).json(result)
|
||||
}
|
||||
} catch (err) {
|
||||
return this.handleError(err, req, res)
|
||||
}
|
||||
}
|
||||
|
||||
handleError (err, req, res) {
|
||||
wlogger.error('Error in EventRESTController:', err)
|
||||
|
||||
// Return 400 for validation errors, 500 for other errors
|
||||
// Validation errors indicate the client sent bad data
|
||||
const isValidationError = err.message && err.message.includes('Invalid event structure')
|
||||
const statusCode = isValidationError ? 400 : 500
|
||||
|
||||
return res.status(statusCode).json({
|
||||
error: err.message || 'Internal server error'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default EventRESTControllerLib
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
REST API library for the /event route.
|
||||
*/
|
||||
|
||||
// Public npm libraries.
|
||||
import express from 'express'
|
||||
|
||||
// Local libraries.
|
||||
import EventRESTControllerLib from './controller.js'
|
||||
|
||||
class EventRouter {
|
||||
constructor (localConfig = {}) {
|
||||
// Dependency Injection.
|
||||
this.adapters = localConfig.adapters
|
||||
if (!this.adapters) {
|
||||
throw new Error(
|
||||
'Instance of Adapters library required when instantiating Event REST Controller.'
|
||||
)
|
||||
}
|
||||
this.useCases = localConfig.useCases
|
||||
if (!this.useCases) {
|
||||
throw new Error(
|
||||
'Instance of Use Cases library required when instantiating Event REST Controller.'
|
||||
)
|
||||
}
|
||||
|
||||
const dependencies = {
|
||||
adapters: this.adapters,
|
||||
useCases: this.useCases
|
||||
}
|
||||
|
||||
// Encapsulate dependencies.
|
||||
this.eventRESTController = new EventRESTControllerLib(dependencies)
|
||||
|
||||
// Instantiate the router and set the base route.
|
||||
this.baseUrl = '/event'
|
||||
this.router = express.Router()
|
||||
}
|
||||
|
||||
attach (app) {
|
||||
if (!app) {
|
||||
throw new Error(
|
||||
'Must pass app object when attaching REST API controllers.'
|
||||
)
|
||||
}
|
||||
|
||||
// Define the routes and attach the controller.
|
||||
this.router.post('/', this.eventRESTController.publishEvent)
|
||||
|
||||
// Attach the Controller routes to the Express app.
|
||||
app.use(this.baseUrl, this.router)
|
||||
}
|
||||
}
|
||||
|
||||
export default EventRouter
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
This index file for the Clean Architecture Controllers loads dependencies,
|
||||
creates instances, and attaches the controller to REST API endpoints for
|
||||
Express.
|
||||
*/
|
||||
|
||||
// Local libraries
|
||||
// import EventRouter from './event/index.js'
|
||||
// import ReqRouter from './req/index.js'
|
||||
import config from '../../config/index.js'
|
||||
|
||||
class RESTControllers {
|
||||
constructor (localConfig = {}) {
|
||||
// Dependency Injection.
|
||||
this.adapters = localConfig.adapters
|
||||
if (!this.adapters) {
|
||||
throw new Error(
|
||||
'Instance of Adapters library required when instantiating REST Controller libraries.'
|
||||
)
|
||||
}
|
||||
this.useCases = localConfig.useCases
|
||||
if (!this.useCases) {
|
||||
throw new Error(
|
||||
'Instance of Use Cases library required when instantiating REST Controller libraries.'
|
||||
)
|
||||
}
|
||||
|
||||
// Bind 'this' object to all subfunctions.
|
||||
this.attachRESTControllers = this.attachRESTControllers.bind(this)
|
||||
|
||||
// Encapsulate dependencies
|
||||
this.config = config
|
||||
}
|
||||
|
||||
attachRESTControllers (app) {
|
||||
// const dependencies = {
|
||||
// adapters: this.adapters,
|
||||
// useCases: this.useCases
|
||||
// }
|
||||
|
||||
// Attach the REST API Controllers associated with the /event route
|
||||
// const eventRouter = new EventRouter(dependencies)
|
||||
// eventRouter.attach(app)
|
||||
|
||||
// Attach the REST API Controllers associated with the /req route
|
||||
// const reqRouter = new ReqRouter(dependencies)
|
||||
// reqRouter.attach(app)
|
||||
}
|
||||
}
|
||||
|
||||
export default RESTControllers
|
||||
@@ -0,0 +1,296 @@
|
||||
/*
|
||||
REST API Controller library for the /req route
|
||||
*/
|
||||
|
||||
// Local libraries
|
||||
import wlogger from '../../../adapters/wlogger.js'
|
||||
|
||||
class ReqRESTControllerLib {
|
||||
constructor (localConfig = {}) {
|
||||
// Dependency Injection.
|
||||
this.adapters = localConfig.adapters
|
||||
if (!this.adapters) {
|
||||
throw new Error(
|
||||
'Instance of Adapters library required when instantiating /req REST Controller.'
|
||||
)
|
||||
}
|
||||
this.useCases = localConfig.useCases
|
||||
if (!this.useCases) {
|
||||
throw new Error(
|
||||
'Instance of Use Cases library required when instantiating /req REST Controller.'
|
||||
)
|
||||
}
|
||||
|
||||
// Bind 'this' object to all subfunctions
|
||||
this.queryEvents = this.queryEvents.bind(this)
|
||||
this.createSubscription = this.createSubscription.bind(this)
|
||||
this.closeSubscription = this.closeSubscription.bind(this)
|
||||
this.handleError = this.handleError.bind(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} /req/:subId Query events (stateless)
|
||||
* @apiPermission public
|
||||
* @apiName QueryEvents
|
||||
* @apiGroup Request
|
||||
*
|
||||
* @apiDescription Query events from the relay with filters. Returns events immediately. Maps to: ["REQ", <sub_id>, <filters>]
|
||||
*
|
||||
* @apiParam {String} subId Subscription ID (unique identifier)
|
||||
* @apiParam {String} filters JSON-encoded filters object (query parameter)
|
||||
*
|
||||
* @apiExample {curl} Example usage:
|
||||
* curl -X GET "http://localhost:3000/req/sub1?filters=[{\"kinds\":[1],\"limit\":10}]"
|
||||
*
|
||||
* @apiSuccess {Array} events Array of Nostr events
|
||||
*
|
||||
* @apiError {String} error Error message
|
||||
*/
|
||||
async queryEvents (req, res) {
|
||||
try {
|
||||
const { subId } = req.params
|
||||
let filters = req.query.filters
|
||||
|
||||
if (!subId) {
|
||||
return res.status(400).json({
|
||||
error: 'Subscription ID is required'
|
||||
})
|
||||
}
|
||||
|
||||
// Parse filters from query string
|
||||
if (typeof filters === 'string') {
|
||||
try {
|
||||
filters = JSON.parse(filters)
|
||||
} catch (err) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid filters JSON'
|
||||
})
|
||||
}
|
||||
} else if (!filters) {
|
||||
// If no filters provided, accept filters from query params
|
||||
filters = {}
|
||||
if (req.query.kinds) {
|
||||
filters.kinds = JSON.parse(req.query.kinds)
|
||||
}
|
||||
if (req.query.authors) {
|
||||
filters.authors = JSON.parse(req.query.authors)
|
||||
}
|
||||
if (req.query.ids) {
|
||||
filters.ids = JSON.parse(req.query.ids)
|
||||
}
|
||||
if (req.query.limit) {
|
||||
filters.limit = parseInt(req.query.limit)
|
||||
}
|
||||
if (req.query.since) {
|
||||
filters.since = parseInt(req.query.since)
|
||||
}
|
||||
if (req.query.until) {
|
||||
filters.until = parseInt(req.query.until)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure filters is an array (Nostr protocol expects array of filters)
|
||||
const filtersArray = Array.isArray(filters) ? filters : [filters]
|
||||
|
||||
const events = await this.useCases.queryEvents.execute(filtersArray, subId)
|
||||
|
||||
return res.status(200).json(events)
|
||||
} catch (err) {
|
||||
return this.handleError(err, req, res)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} /req/:subId Create subscription (SSE)
|
||||
* @apiPermission public
|
||||
* @apiName CreateSubscription
|
||||
* @apiGroup Request
|
||||
*
|
||||
* @apiDescription Create a subscription for Server-Sent Events. Maps to: ["REQ", <sub_id>, <filters>]
|
||||
*
|
||||
* @apiParam {String} subId Subscription ID (unique identifier)
|
||||
* @apiParam {Object} filters Filters object in request body
|
||||
*
|
||||
* @apiExample {json} Example usage:
|
||||
* {
|
||||
* "kinds": [1],
|
||||
* "authors": ["2c7e76c0f8dc1dca9d0197c7d19be580a8d074ccada6a2f6ebe056ae41092e9"]
|
||||
* }
|
||||
*
|
||||
* @apiSuccess {String} message Success message
|
||||
*
|
||||
* @apiError {String} error Error message
|
||||
*/
|
||||
async createSubscription (req, res) {
|
||||
try {
|
||||
const { subId } = req.params
|
||||
const filters = req.body
|
||||
|
||||
if (!subId) {
|
||||
return res.status(400).json({
|
||||
error: 'Subscription ID is required'
|
||||
})
|
||||
}
|
||||
|
||||
if (!filters || (typeof filters === 'object' && Object.keys(filters).length === 0)) {
|
||||
return res.status(400).json({
|
||||
error: 'Filters are required'
|
||||
})
|
||||
}
|
||||
|
||||
// Ensure filters is an array
|
||||
const filtersArray = Array.isArray(filters) ? filters : [filters]
|
||||
|
||||
// Set up Server-Sent Events
|
||||
res.setHeader('Content-Type', 'text/event-stream')
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
res.setHeader('Connection', 'keep-alive')
|
||||
res.setHeader('X-Accel-Buffering', 'no') // Disable buffering in nginx
|
||||
|
||||
// Track if response is still writable
|
||||
let isResponseWritable = true
|
||||
|
||||
// Helper function to safely write to SSE stream
|
||||
const safeWrite = (data) => {
|
||||
if (!isResponseWritable) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
if (!res.writable || res.destroyed || res.closed) {
|
||||
isResponseWritable = false
|
||||
return false
|
||||
}
|
||||
return res.write(data)
|
||||
} catch (err) {
|
||||
wlogger.warn(`Error writing to SSE stream for subscription ${subId}:`, err.message)
|
||||
isResponseWritable = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Handle response stream errors
|
||||
res.on('error', (err) => {
|
||||
wlogger.warn(`Response stream error for subscription ${subId}:`, err.message)
|
||||
isResponseWritable = false
|
||||
// Clean up subscription on stream error
|
||||
this.useCases.manageSubscription.closeSubscription(subId).catch(closeErr => {
|
||||
wlogger.error('Error closing subscription on stream error:', closeErr)
|
||||
})
|
||||
})
|
||||
|
||||
// Send initial connection message
|
||||
if (!safeWrite(`data: ${JSON.stringify({ type: 'connected', subscriptionId: subId })}\n\n`)) {
|
||||
wlogger.warn(`Failed to send initial connection message for subscription ${subId}`)
|
||||
return res.status(500).json({ error: 'Failed to establish SSE connection' })
|
||||
}
|
||||
|
||||
// Handle events
|
||||
const onEvent = (event) => {
|
||||
if (!safeWrite(`data: ${JSON.stringify({ type: 'event', data: event })}\n\n`)) {
|
||||
wlogger.debug(`Cannot write event to SSE stream for subscription ${subId} - connection may be closed`)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle EOSE
|
||||
const onEose = () => {
|
||||
if (!safeWrite(`data: ${JSON.stringify({ type: 'eose' })}\n\n`)) {
|
||||
wlogger.debug(`Cannot write EOSE to SSE stream for subscription ${subId} - connection may be closed`)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle CLOSED
|
||||
const onClosed = (message) => {
|
||||
if (safeWrite(`data: ${JSON.stringify({ type: 'closed', message })}\n\n`)) {
|
||||
try {
|
||||
if (!res.destroyed && !res.closed) {
|
||||
res.end()
|
||||
}
|
||||
} catch (err) {
|
||||
wlogger.warn(`Error ending SSE stream for subscription ${subId}:`, err.message)
|
||||
}
|
||||
}
|
||||
isResponseWritable = false
|
||||
}
|
||||
|
||||
// Create subscription
|
||||
await this.useCases.manageSubscription.createSubscription(
|
||||
subId,
|
||||
filtersArray,
|
||||
onEvent,
|
||||
onEose,
|
||||
onClosed
|
||||
)
|
||||
|
||||
// Handle client disconnect
|
||||
req.on('close', () => {
|
||||
wlogger.info(`Client disconnected from subscription ${subId}`)
|
||||
isResponseWritable = false
|
||||
this.useCases.manageSubscription.closeSubscription(subId).catch(err => {
|
||||
wlogger.error('Error closing subscription on disconnect:', err)
|
||||
})
|
||||
})
|
||||
|
||||
// Handle response finish
|
||||
res.on('finish', () => {
|
||||
isResponseWritable = false
|
||||
})
|
||||
} catch (err) {
|
||||
return this.handleError(err, req, res)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {put} /req/:subId Create subscription (SSE) - alternative method
|
||||
* @apiPermission public
|
||||
* @apiName CreateSubscriptionPut
|
||||
* @apiGroup Request
|
||||
*
|
||||
* @apiDescription Same as POST /req/:subId - create a subscription for Server-Sent Events
|
||||
*/
|
||||
async createSubscriptionPut (req, res) {
|
||||
return this.createSubscription(req, res)
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {delete} /req/:subId Close subscription
|
||||
* @apiPermission public
|
||||
* @apiName CloseSubscription
|
||||
* @apiGroup Request
|
||||
*
|
||||
* @apiDescription Close an existing subscription. Maps to: ["CLOSE", <sub_id>]
|
||||
*
|
||||
* @apiParam {String} subId Subscription ID to close
|
||||
*
|
||||
* @apiSuccess {String} message Success message
|
||||
*
|
||||
* @apiError {String} error Error message
|
||||
*/
|
||||
async closeSubscription (req, res) {
|
||||
try {
|
||||
const { subId } = req.params
|
||||
|
||||
if (!subId) {
|
||||
return res.status(400).json({
|
||||
error: 'Subscription ID is required'
|
||||
})
|
||||
}
|
||||
|
||||
await this.useCases.manageSubscription.closeSubscription(subId)
|
||||
|
||||
return res.status(200).json({
|
||||
message: `Subscription ${subId} closed successfully`
|
||||
})
|
||||
} catch (err) {
|
||||
return this.handleError(err, req, res)
|
||||
}
|
||||
}
|
||||
|
||||
handleError (err, req, res) {
|
||||
wlogger.error('Error in ReqRESTController:', err)
|
||||
return res.status(500).json({
|
||||
error: err.message || 'Internal server error'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default ReqRESTControllerLib
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
REST API library for the /req route.
|
||||
*/
|
||||
|
||||
// Public npm libraries.
|
||||
import express from 'express'
|
||||
|
||||
// Local libraries.
|
||||
import ReqRESTControllerLib from './controller.js'
|
||||
|
||||
class ReqRouter {
|
||||
constructor (localConfig = {}) {
|
||||
// Dependency Injection.
|
||||
this.adapters = localConfig.adapters
|
||||
if (!this.adapters) {
|
||||
throw new Error(
|
||||
'Instance of Adapters library required when instantiating Req REST Controller.'
|
||||
)
|
||||
}
|
||||
this.useCases = localConfig.useCases
|
||||
if (!this.useCases) {
|
||||
throw new Error(
|
||||
'Instance of Use Cases library required when instantiating Req REST Controller.'
|
||||
)
|
||||
}
|
||||
|
||||
const dependencies = {
|
||||
adapters: this.adapters,
|
||||
useCases: this.useCases
|
||||
}
|
||||
|
||||
// Encapsulate dependencies.
|
||||
this.reqRESTController = new ReqRESTControllerLib(dependencies)
|
||||
|
||||
// Instantiate the router and set the base route.
|
||||
this.router = express.Router()
|
||||
}
|
||||
|
||||
attach (app) {
|
||||
if (!app) {
|
||||
throw new Error(
|
||||
'Must pass app object when attaching REST API controllers.'
|
||||
)
|
||||
}
|
||||
|
||||
// Define the routes and attach the controller.
|
||||
// Handle empty subId case first
|
||||
this.router.get('/', (req, res) => {
|
||||
res.status(400).json({
|
||||
error: 'Subscription ID is required'
|
||||
})
|
||||
})
|
||||
this.router.post('/', (req, res) => {
|
||||
res.status(400).json({
|
||||
error: 'Subscription ID is required'
|
||||
})
|
||||
})
|
||||
this.router.delete('/', (req, res) => {
|
||||
res.status(400).json({
|
||||
error: 'Subscription ID is required'
|
||||
})
|
||||
})
|
||||
|
||||
// Routes with subId parameter
|
||||
this.router.get('/:subId', this.reqRESTController.queryEvents)
|
||||
this.router.post('/:subId', this.reqRESTController.createSubscription)
|
||||
this.router.put('/:subId', this.reqRESTController.createSubscriptionPut)
|
||||
this.router.delete('/:subId', this.reqRESTController.closeSubscription)
|
||||
|
||||
// Attach the Controller routes to the Express app.
|
||||
app.use('/req', this.router)
|
||||
}
|
||||
}
|
||||
|
||||
export default ReqRouter
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
Timer-based controller functions.
|
||||
This controller handles scheduled tasks and timers.
|
||||
*/
|
||||
|
||||
// Local libraries
|
||||
import wlogger from '../adapters/wlogger.js'
|
||||
|
||||
class TimerController {
|
||||
constructor (localConfig = {}) {
|
||||
// Dependency Injection
|
||||
this.adapters = localConfig.adapters
|
||||
if (!this.adapters) {
|
||||
throw new Error(
|
||||
'Instance of Adapters library required when instantiating TimerController.'
|
||||
)
|
||||
}
|
||||
this.useCases = localConfig.useCases
|
||||
if (!this.useCases) {
|
||||
throw new Error(
|
||||
'Instance of Use Cases library required when instantiating TimerController.'
|
||||
)
|
||||
}
|
||||
|
||||
// Constants
|
||||
this.SHUTDOWN_INTERVAL_MS = 10 * 60 * 1000 // 10 minutes in milliseconds
|
||||
this.LIVENESS_CHECK_INTERVAL_MS = 1 * 60 * 1000 // 1 minute in milliseconds
|
||||
|
||||
// Handlers
|
||||
this.shutdownHandler = null
|
||||
this.livenessCheckHandler = null
|
||||
|
||||
// Bind 'this' object to all subfunctions
|
||||
this.startTimerControllers = this.startTimerControllers.bind(this)
|
||||
this.stopTimerControllers = this.stopTimerControllers.bind(this)
|
||||
this.shutdown = this.shutdown.bind(this)
|
||||
this.livenessCheck = this.livenessCheck.bind(this)
|
||||
}
|
||||
|
||||
startTimerControllers () {
|
||||
console.log('Starting Timer Controllers.')
|
||||
|
||||
// this.shutdownHandler = setInterval(() => {
|
||||
// this.shutdown()
|
||||
// }, this.SHUTDOWN_INTERVAL_MS)
|
||||
|
||||
// this.livenessCheckHandler = setInterval(() => {
|
||||
// this.livenessCheck()
|
||||
// }, this.LIVENESS_CHECK_INTERVAL_MS)
|
||||
}
|
||||
|
||||
stopTimerControllers () {
|
||||
console.log('Stopping Timer Controllers.')
|
||||
|
||||
clearInterval(this.shutdownHandler)
|
||||
this.shutdownHandler = null
|
||||
clearInterval(this.livenessCheckHandler)
|
||||
this.livenessCheckHandler = null
|
||||
}
|
||||
|
||||
// Execute the shutdown callback
|
||||
shutdown () {
|
||||
wlogger.info(`TimerController: Shutting down application at ${new Date().toISOString()}, depending on process manager to restart application.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
livenessCheck () {
|
||||
wlogger.info(`TimerController: Liveness check at ${new Date().toISOString()}`)
|
||||
}
|
||||
}
|
||||
|
||||
export default TimerController
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
Event entity - represents a Nostr event.
|
||||
This is a domain model following Clean Architecture principles.
|
||||
*/
|
||||
|
||||
class Event {
|
||||
constructor (data) {
|
||||
this.id = data.id
|
||||
this.pubkey = data.pubkey
|
||||
this.created_at = data.created_at
|
||||
this.kind = data.kind
|
||||
this.tags = data.tags || []
|
||||
this.content = data.content
|
||||
this.sig = data.sig
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the event structure
|
||||
* @returns {boolean} True if valid
|
||||
*/
|
||||
isValid () {
|
||||
if (!this.id || !this.pubkey || !this.created_at || this.kind === undefined || !this.sig) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Basic type checks
|
||||
if (typeof this.id !== 'string' || this.id.length !== 64) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof this.pubkey !== 'string' || this.pubkey.length !== 64) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof this.created_at !== 'number') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof this.kind !== 'number' || this.kind < 0 || this.kind > 65535) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof this.sig !== 'string' || this.sig.length !== 128) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!Array.isArray(this.tags)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to plain object
|
||||
* @returns {Object} Plain event object
|
||||
*/
|
||||
toJSON () {
|
||||
return {
|
||||
id: this.id,
|
||||
pubkey: this.pubkey,
|
||||
created_at: this.created_at,
|
||||
kind: this.kind,
|
||||
tags: this.tags,
|
||||
content: this.content,
|
||||
sig: this.sig
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Event
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
This is a top-level library that encapsulates all the additional Use Cases.
|
||||
The concept of Use Cases comes from Clean Architecture:
|
||||
https://troutsblog.com/blog/clean-architecture
|
||||
*/
|
||||
|
||||
// Local libraries
|
||||
// import PublishEventUseCase from './publish-event.js'
|
||||
// import QueryEventsUseCase from './query-events.js'
|
||||
// import ManageSubscriptionUseCase from './manage-subscription.js'
|
||||
|
||||
class UseCases {
|
||||
constructor (localConfig = {}) {
|
||||
this.adapters = localConfig.adapters
|
||||
if (!this.adapters) {
|
||||
throw new Error(
|
||||
'Instance of adapters must be passed in when instantiating Use Cases library.'
|
||||
)
|
||||
}
|
||||
|
||||
// this.publishEvent = new PublishEventUseCase({ adapters: this.adapters })
|
||||
// this.queryEvents = new QueryEventsUseCase({ adapters: this.adapters })
|
||||
// this.manageSubscription = new ManageSubscriptionUseCase({ adapters: this.adapters })
|
||||
}
|
||||
|
||||
// Run any startup Use Cases at the start of the app.
|
||||
async start () {
|
||||
console.log('Use Cases have been started.')
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export default UseCases
|
||||
@@ -0,0 +1,216 @@
|
||||
/*
|
||||
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
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
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
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
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,250 @@
|
||||
/*
|
||||
Integration tests for POST /event endpoint.
|
||||
These tests require a running Nostr relay.
|
||||
*/
|
||||
|
||||
// npm libraries
|
||||
import { assert } from 'chai'
|
||||
|
||||
// Unit under test
|
||||
import Server from '../../../bin/server.js'
|
||||
import { finalizeEvent, getPublicKey, generateSecretKey } from 'nostr-tools/pure'
|
||||
import { hexToBytes } from '@noble/hashes/utils.js'
|
||||
|
||||
describe('#event-integration.js', () => {
|
||||
let server
|
||||
const baseUrl = 'http://localhost:3001' // Use different port for tests
|
||||
|
||||
before(async () => {
|
||||
// Start test server
|
||||
server = new Server()
|
||||
server.config.port = 3001
|
||||
await server.startServer()
|
||||
|
||||
// Wait for server to be ready
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
// Stop server
|
||||
if (server && server.server) {
|
||||
await new Promise((resolve) => {
|
||||
server.server.close(() => {
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('POST /event', () => {
|
||||
it('should publish kind 0 event (profile metadata) - covers example 01', async () => {
|
||||
// Generate keys
|
||||
const sk = generateSecretKey()
|
||||
|
||||
// Create profile metadata event (kind 0)
|
||||
const profileMetadata = {
|
||||
name: 'Test User',
|
||||
about: 'Integration test user',
|
||||
picture: 'https://example.com/test.jpg'
|
||||
}
|
||||
|
||||
const eventTemplate = {
|
||||
kind: 0,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: JSON.stringify(profileMetadata)
|
||||
}
|
||||
|
||||
// Sign the event
|
||||
const signedEvent = finalizeEvent(eventTemplate, sk)
|
||||
|
||||
// Publish to REST API
|
||||
const response = await fetch(`${baseUrl}/event`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(signedEvent)
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
// Assert response
|
||||
assert.equal(response.status, 200)
|
||||
assert.property(result, 'accepted')
|
||||
assert.property(result, 'eventId')
|
||||
assert.equal(result.eventId, signedEvent.id)
|
||||
})
|
||||
|
||||
it('should publish kind 1 event (text post) - covers example 03', async () => {
|
||||
// Alice's private key from examples
|
||||
const alicePrivKeyHex = '3292a48aa331aeccce003d50d70fbd79617ba91860abbd2c78fa4a8301e36bc0'
|
||||
const alicePrivKeyBin = hexToBytes(alicePrivKeyHex)
|
||||
const alicePubKey = getPublicKey(alicePrivKeyBin)
|
||||
|
||||
// Generate a post
|
||||
const eventTemplate = {
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: 'Integration test post'
|
||||
}
|
||||
|
||||
// Sign the post
|
||||
const signedEvent = finalizeEvent(eventTemplate, alicePrivKeyBin)
|
||||
|
||||
// Publish to REST API
|
||||
const response = await fetch(`${baseUrl}/event`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(signedEvent)
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
// Assert response
|
||||
assert.equal(response.status, 200)
|
||||
assert.property(result, 'accepted')
|
||||
assert.property(result, 'eventId')
|
||||
assert.equal(result.eventId, signedEvent.id)
|
||||
assert.equal(signedEvent.pubkey, alicePubKey)
|
||||
})
|
||||
|
||||
it('should publish kind 3 event (follow list) - covers example 06', async () => {
|
||||
// Alice's private key
|
||||
const alicePrivKeyHex = '3292a48aa331aeccce003d50d70fbd79617ba91860abbd2c78fa4a8301e36bc0'
|
||||
const alicePrivKeyBin = hexToBytes(alicePrivKeyHex)
|
||||
|
||||
// Bob's public key
|
||||
const bobPrivKeyHex = 'd2e71a977bc3900d6b0f787421e3d1a666cd12ca625482b0d9eeffd23489c99f'
|
||||
const bobPrivKeyBin = hexToBytes(bobPrivKeyHex)
|
||||
const bobPubKey = getPublicKey(bobPrivKeyBin)
|
||||
|
||||
const psf = 'wss://nostr-relay.psfoundation.info'
|
||||
|
||||
const followList = [
|
||||
['p', bobPubKey, psf, 'bob']
|
||||
]
|
||||
|
||||
// Generate a follow list event (kind 3)
|
||||
const eventTemplate = {
|
||||
kind: 3,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: followList,
|
||||
content: ''
|
||||
}
|
||||
|
||||
// Sign the event
|
||||
const signedEvent = finalizeEvent(eventTemplate, alicePrivKeyBin)
|
||||
|
||||
// Publish to REST API
|
||||
const response = await fetch(`${baseUrl}/event`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(signedEvent)
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
// Assert response
|
||||
assert.equal(response.status, 200)
|
||||
assert.property(result, 'accepted')
|
||||
assert.property(result, 'eventId')
|
||||
assert.equal(result.eventId, signedEvent.id)
|
||||
})
|
||||
|
||||
it('should publish kind 7 event (reaction/like) - covers example 07', async () => {
|
||||
// Bob's private key
|
||||
const bobPrivKeyHex = 'd2e71a977bc3900d6b0f787421e3d1a666cd12ca625482b0d9eeffd23489c99f'
|
||||
const bobPrivKeyBin = hexToBytes(bobPrivKeyHex)
|
||||
const bobPubKey = getPublicKey(bobPrivKeyBin)
|
||||
|
||||
const psf = 'wss://nostr-relay.psfoundation.info'
|
||||
|
||||
// Use a test event ID
|
||||
const evIdToLike = 'd09b4c5da59be3cd2768aa53fa78b77bf4859084c94f3bf26d401f004a9c8167'
|
||||
const evIdAuthorPubKey = '2c7e76c0f8dc1dca9d0197c7d19be580a8d074ccada6a2f6ebe056ae41092e92'
|
||||
|
||||
// Generate like event (kind 7)
|
||||
const likeEventTemplate = {
|
||||
kind: 7,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
pubkey: bobPubKey,
|
||||
tags: [
|
||||
['e', evIdToLike, psf],
|
||||
['p', evIdAuthorPubKey, psf]
|
||||
],
|
||||
content: '+'
|
||||
}
|
||||
|
||||
// Sign the event
|
||||
const signedEvent = finalizeEvent(likeEventTemplate, bobPrivKeyBin)
|
||||
|
||||
// Publish to REST API
|
||||
const response = await fetch(`${baseUrl}/event`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(signedEvent)
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
// Assert response
|
||||
assert.equal(response.status, 200)
|
||||
assert.property(result, 'accepted')
|
||||
assert.property(result, 'eventId')
|
||||
assert.equal(result.eventId, signedEvent.id)
|
||||
})
|
||||
|
||||
it('should reject invalid event', async () => {
|
||||
const invalidEvent = {
|
||||
id: 'invalid',
|
||||
pubkey: 'invalid',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: 'Test',
|
||||
sig: 'invalid'
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/event`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(invalidEvent)
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
// Should reject invalid event - error response format
|
||||
assert.equal(response.status, 400)
|
||||
assert.property(result, 'error')
|
||||
assert.include(result.error, 'Invalid event structure')
|
||||
})
|
||||
|
||||
it('should return 400 when event data is missing', async () => {
|
||||
// Send empty body - Express will parse as undefined, controller should handle it
|
||||
const response = await fetch(`${baseUrl}/event`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: ''
|
||||
})
|
||||
|
||||
// Empty body should be parsed as undefined by Express
|
||||
const result = await response.json()
|
||||
|
||||
assert.equal(response.status, 400)
|
||||
assert.property(result, 'error')
|
||||
assert.include(result.error, 'Event data is required')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
Integration tests for GET /req/:subId endpoint.
|
||||
These tests require a running Nostr relay.
|
||||
*/
|
||||
|
||||
// npm libraries
|
||||
import { assert } from 'chai'
|
||||
|
||||
// Unit under test
|
||||
import Server from '../../../bin/server.js'
|
||||
import { getPublicKey } from 'nostr-tools/pure'
|
||||
import { hexToBytes } from '@noble/hashes/utils.js'
|
||||
|
||||
describe('#req-integration.js', () => {
|
||||
let server
|
||||
const baseUrl = 'http://localhost:3002' // Use different port for tests
|
||||
|
||||
before(async () => {
|
||||
// Start test server
|
||||
server = new Server()
|
||||
server.config.port = 3002
|
||||
await server.startServer()
|
||||
|
||||
// Wait for server to be ready
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
// Stop server
|
||||
if (server && server.server) {
|
||||
await new Promise((resolve) => {
|
||||
server.server.close(() => {
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('GET /req/:subId', () => {
|
||||
it('should query kind 1 events (posts) - covers examples 02, 04', async () => {
|
||||
// JB55's public key from example 02
|
||||
const jb55 = '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
|
||||
|
||||
// Create subscription ID
|
||||
const subId = 'read-posts-' + Date.now()
|
||||
|
||||
// Create filters - read posts from JB55
|
||||
const filters = {
|
||||
limit: 2,
|
||||
kinds: [1],
|
||||
authors: [jb55]
|
||||
}
|
||||
|
||||
// Query events using GET /req/:subId
|
||||
const filtersJson = encodeURIComponent(JSON.stringify([filters]))
|
||||
const url = `${baseUrl}/req/${subId}?filters=${filtersJson}`
|
||||
|
||||
const response = await fetch(url)
|
||||
const events = await response.json()
|
||||
|
||||
// Assert response
|
||||
assert.equal(response.status, 200)
|
||||
assert.isArray(events)
|
||||
// May be empty if no events exist, but structure should be correct
|
||||
if (events.length > 0) {
|
||||
assert.property(events[0], 'id')
|
||||
assert.property(events[0], 'pubkey')
|
||||
assert.property(events[0], 'created_at')
|
||||
assert.property(events[0], 'kind')
|
||||
assert.property(events[0], 'content')
|
||||
assert.equal(events[0].kind, 1)
|
||||
}
|
||||
})
|
||||
|
||||
it('should query Alice posts - covers example 04', async () => {
|
||||
// Alice's public key
|
||||
const alicePrivKeyHex = '3292a48aa331aeccce003d50d70fbd79617ba91860abbd2c78fa4a8301e36bc0'
|
||||
const alicePrivKeyBin = hexToBytes(alicePrivKeyHex)
|
||||
const alicePubKey = getPublicKey(alicePrivKeyBin)
|
||||
|
||||
// Create subscription ID
|
||||
const subId = 'read-alice-posts-' + Date.now()
|
||||
|
||||
// Create filters - read posts from Alice
|
||||
const filters = {
|
||||
limit: 2,
|
||||
kinds: [1],
|
||||
authors: [alicePubKey]
|
||||
}
|
||||
|
||||
// Query events using GET /req/:subId
|
||||
const filtersJson = encodeURIComponent(JSON.stringify([filters]))
|
||||
const url = `${baseUrl}/req/${subId}?filters=${filtersJson}`
|
||||
|
||||
const response = await fetch(url)
|
||||
const events = await response.json()
|
||||
|
||||
// Assert response
|
||||
assert.equal(response.status, 200)
|
||||
assert.isArray(events)
|
||||
if (events.length > 0) {
|
||||
assert.equal(events[0].pubkey, alicePubKey)
|
||||
assert.equal(events[0].kind, 1)
|
||||
}
|
||||
})
|
||||
|
||||
it('should query kind 3 events (follow list) - covers example 05', async () => {
|
||||
// Alice's public key
|
||||
const alicePrivKeyHex = '3292a48aa331aeccce003d50d70fbd79617ba91860abbd2c78fa4a8301e36bc0'
|
||||
const alicePrivKeyBin = hexToBytes(alicePrivKeyHex)
|
||||
const alicePubKey = getPublicKey(alicePrivKeyBin)
|
||||
|
||||
// Create subscription ID
|
||||
const subId = 'get-follow-list-' + Date.now()
|
||||
|
||||
// Create filters - get follow list (kind 3) from Alice
|
||||
const filters = {
|
||||
limit: 5,
|
||||
kinds: [3],
|
||||
authors: [alicePubKey]
|
||||
}
|
||||
|
||||
// Query events using GET /req/:subId
|
||||
const filtersJson = encodeURIComponent(JSON.stringify([filters]))
|
||||
const url = `${baseUrl}/req/${subId}?filters=${filtersJson}`
|
||||
|
||||
const response = await fetch(url)
|
||||
const events = await response.json()
|
||||
|
||||
// Assert response
|
||||
assert.equal(response.status, 200)
|
||||
assert.isArray(events)
|
||||
if (events.length > 0) {
|
||||
assert.equal(events[0].kind, 3)
|
||||
assert.equal(events[0].pubkey, alicePubKey)
|
||||
assert.isArray(events[0].tags)
|
||||
}
|
||||
})
|
||||
|
||||
it('should handle filters as individual query params', async () => {
|
||||
const subId = 'test-sub-' + Date.now()
|
||||
const url = `${baseUrl}/req/${subId}?kinds=[1]&limit=10`
|
||||
|
||||
const response = await fetch(url)
|
||||
const events = await response.json()
|
||||
|
||||
assert.equal(response.status, 200)
|
||||
assert.isArray(events)
|
||||
})
|
||||
|
||||
it('should return 400 when subscription ID is missing', async () => {
|
||||
const url = `${baseUrl}/req/?filters=${encodeURIComponent(JSON.stringify([{ kinds: [1] }]))}`
|
||||
|
||||
const response = await fetch(url)
|
||||
await response.json()
|
||||
|
||||
// Should return 404 or 400
|
||||
assert.isAtLeast(response.status, 400)
|
||||
})
|
||||
|
||||
it('should return 400 when filters JSON is invalid', async () => {
|
||||
const subId = 'test-sub-' + Date.now()
|
||||
const url = `${baseUrl}/req/${subId}?filters=invalid-json{`
|
||||
|
||||
const response = await fetch(url)
|
||||
const result = await response.json()
|
||||
|
||||
assert.equal(response.status, 400)
|
||||
assert.property(result, 'error')
|
||||
assert.include(result.error, 'Invalid filters JSON')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
Integration tests for POST /req/:subId SSE subscription and DELETE /req/:subId.
|
||||
These tests require a running Nostr relay.
|
||||
*/
|
||||
|
||||
// npm libraries
|
||||
import { assert } from 'chai'
|
||||
|
||||
// Unit under test
|
||||
import Server from '../../../bin/server.js'
|
||||
|
||||
describe('#subscription-integration.js', () => {
|
||||
let server
|
||||
const baseUrl = 'http://localhost:3003' // Use different port for tests
|
||||
|
||||
before(async () => {
|
||||
// Start test server
|
||||
server = new Server()
|
||||
server.config.port = 3003
|
||||
await server.startServer()
|
||||
|
||||
// Wait for server to be ready
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
})
|
||||
|
||||
after(async function () {
|
||||
this.timeout(10000) // Increase timeout for cleanup
|
||||
// Stop server
|
||||
if (server && server.server) {
|
||||
// Close all connections forcefully if available
|
||||
if (server.server.closeAllConnections) {
|
||||
server.server.closeAllConnections()
|
||||
}
|
||||
|
||||
await new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
resolve() // Force resolve after 2 seconds
|
||||
}, 2000)
|
||||
|
||||
server.server.close(() => {
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('POST /req/:subId', () => {
|
||||
it('should create SSE subscription', async () => {
|
||||
const subId = 'test-sub-' + Date.now()
|
||||
const filters = { kinds: [1], limit: 10 }
|
||||
|
||||
const response = await fetch(`${baseUrl}/req/${subId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(filters)
|
||||
})
|
||||
|
||||
// Assert SSE headers
|
||||
assert.equal(response.headers.get('content-type'), 'text/event-stream')
|
||||
assert.equal(response.headers.get('cache-control'), 'no-cache')
|
||||
assert.equal(response.headers.get('connection'), 'keep-alive')
|
||||
|
||||
// Read initial connection message
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
try {
|
||||
const { value } = await reader.read()
|
||||
const text = decoder.decode(value)
|
||||
assert.include(text, 'connected')
|
||||
assert.include(text, subId)
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
})
|
||||
|
||||
it('should return 400 when subscription ID is missing', async () => {
|
||||
const filters = { kinds: [1] }
|
||||
|
||||
const response = await fetch(`${baseUrl}/req/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(filters)
|
||||
})
|
||||
|
||||
// Should return 404 or 400
|
||||
assert.isAtLeast(response.status, 400)
|
||||
})
|
||||
|
||||
it('should return 400 when filters are missing', async () => {
|
||||
const subId = 'test-sub-' + Date.now()
|
||||
|
||||
const response = await fetch(`${baseUrl}/req/${subId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
assert.equal(response.status, 400)
|
||||
assert.property(result, 'error')
|
||||
assert.include(result.error, 'Filters are required')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /req/:subId', () => {
|
||||
it('should close a subscription', async () => {
|
||||
const subId = 'test-sub-' + Date.now()
|
||||
|
||||
const response = await fetch(`${baseUrl}/req/${subId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
|
||||
// May return 200 if subscription exists, or 500 if it doesn't
|
||||
// The important thing is it doesn't crash
|
||||
assert.isAtMost(response.status, 500)
|
||||
})
|
||||
|
||||
it('should return 400 when subscription ID is missing', async () => {
|
||||
const response = await fetch(`${baseUrl}/req/`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
|
||||
// Should return 404 or 400
|
||||
assert.isAtLeast(response.status, 400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT /req/:subId', () => {
|
||||
it('should create SSE subscription (alternative method)', async function () {
|
||||
this.timeout(10000) // Increase timeout for this test
|
||||
|
||||
const subId = 'test-sub-' + Date.now()
|
||||
const filters = { kinds: [1], limit: 10 }
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 8000)
|
||||
|
||||
let response
|
||||
try {
|
||||
response = await fetch(`${baseUrl}/req/${subId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(filters),
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
// Assert SSE headers
|
||||
assert.equal(response.headers.get('content-type'), 'text/event-stream')
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
// Consume the stream to prevent hanging
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
try {
|
||||
// Read initial connection message with timeout
|
||||
const readPromise = reader.read()
|
||||
const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve({ value: null, done: true }), 2000))
|
||||
const { value, done } = await Promise.race([readPromise, timeoutPromise])
|
||||
|
||||
if (value && !done) {
|
||||
const text = decoder.decode(value)
|
||||
assert.include(text, 'connected')
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
} catch (err) {
|
||||
// AbortController aborted - this is expected
|
||||
if (err.name !== 'AbortError') {
|
||||
throw err
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
// Always close the subscription
|
||||
try {
|
||||
await fetch(`${baseUrl}/req/${subId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
} catch (err) {
|
||||
// Ignore errors when closing
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
Integration tests for ManageSubscriptionUseCase with real adapter.
|
||||
These tests require a running Nostr relay.
|
||||
*/
|
||||
|
||||
// npm libraries
|
||||
import { assert } from 'chai'
|
||||
|
||||
// Unit under test
|
||||
import Adapters from '../../../src/adapters/index.js'
|
||||
import ManageSubscriptionUseCase from '../../../src/use-cases/manage-subscription.js'
|
||||
|
||||
describe('#manage-subscription-integration.js', () => {
|
||||
let adapters
|
||||
let uut
|
||||
|
||||
before(async () => {
|
||||
// Initialize adapters (will connect to real relay)
|
||||
adapters = new Adapters()
|
||||
await adapters.start()
|
||||
|
||||
uut = new ManageSubscriptionUseCase({ adapters })
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
// Clean up all subscriptions and disconnect from all relays
|
||||
// Note: This is a simplified cleanup - in production you'd track all subscriptions
|
||||
if (adapters && adapters.nostrRelays) {
|
||||
await Promise.allSettled(
|
||||
adapters.nostrRelays.map(relay => relay.disconnect())
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
describe('#createSubscription()', () => {
|
||||
it('should successfully create a subscription', async () => {
|
||||
const subscriptionId = 'test-sub-' + Date.now()
|
||||
const filters = [{ kinds: [1], limit: 5 }]
|
||||
|
||||
let eventReceived = false
|
||||
let eoseReceived = false
|
||||
|
||||
const onEvent = (event) => {
|
||||
eventReceived = true
|
||||
assert.property(event, 'id')
|
||||
assert.property(event, 'kind')
|
||||
}
|
||||
|
||||
const onEose = () => {
|
||||
eoseReceived = true
|
||||
}
|
||||
|
||||
const onClosed = () => {
|
||||
// Handler for closed events
|
||||
}
|
||||
|
||||
await uut.createSubscription(subscriptionId, filters, onEvent, onEose, onClosed)
|
||||
|
||||
// Assert subscription exists
|
||||
assert.isTrue(uut.hasSubscription(subscriptionId))
|
||||
|
||||
// Wait a bit for events/EOSE
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
// EOSE should be received (or events)
|
||||
// Note: May not receive events if none exist, but EOSE should come
|
||||
assert.isTrue(eoseReceived || eventReceived)
|
||||
|
||||
// Clean up
|
||||
if (uut.hasSubscription(subscriptionId)) {
|
||||
await uut.closeSubscription(subscriptionId)
|
||||
}
|
||||
})
|
||||
|
||||
it('should prevent duplicate subscriptions', async () => {
|
||||
const subscriptionId = 'test-dup-' + Date.now()
|
||||
const filters = [{ kinds: [1] }]
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
// Clean up
|
||||
if (uut.hasSubscription(subscriptionId)) {
|
||||
await uut.closeSubscription(subscriptionId)
|
||||
}
|
||||
})
|
||||
|
||||
it('should handle subscription with no events', async () => {
|
||||
const subscriptionId = 'test-empty-' + Date.now()
|
||||
const filters = [{ kinds: [99999], limit: 1 }] // Unlikely to have events
|
||||
|
||||
let eoseReceived = false
|
||||
|
||||
const onEose = () => {
|
||||
eoseReceived = true
|
||||
}
|
||||
|
||||
await uut.createSubscription(subscriptionId, filters, null, onEose, null)
|
||||
|
||||
// Wait for EOSE
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
// Should receive EOSE even with no events
|
||||
assert.isTrue(eoseReceived)
|
||||
|
||||
// Clean up
|
||||
if (uut.hasSubscription(subscriptionId)) {
|
||||
await uut.closeSubscription(subscriptionId)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('#closeSubscription()', () => {
|
||||
it('should successfully close a subscription', async () => {
|
||||
const subscriptionId = 'test-close-' + Date.now()
|
||||
const filters = [{ kinds: [1] }]
|
||||
|
||||
await uut.createSubscription(subscriptionId, filters)
|
||||
assert.isTrue(uut.hasSubscription(subscriptionId))
|
||||
|
||||
await uut.closeSubscription(subscriptionId)
|
||||
|
||||
// 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')
|
||||
})
|
||||
})
|
||||
|
||||
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-has-' + Date.now()
|
||||
const filters = [{ kinds: [1] }]
|
||||
|
||||
assert.isFalse(uut.hasSubscription(subscriptionId))
|
||||
await uut.createSubscription(subscriptionId, filters)
|
||||
assert.isTrue(uut.hasSubscription(subscriptionId))
|
||||
|
||||
// Clean up
|
||||
if (uut.hasSubscription(subscriptionId)) {
|
||||
await uut.closeSubscription(subscriptionId)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
Integration tests for PublishEventUseCase with real adapter.
|
||||
These tests require a running Nostr relay.
|
||||
*/
|
||||
|
||||
// npm libraries
|
||||
import { assert } from 'chai'
|
||||
|
||||
// Unit under test
|
||||
import Adapters from '../../../src/adapters/index.js'
|
||||
import PublishEventUseCase from '../../../src/use-cases/publish-event.js'
|
||||
import { finalizeEvent, generateSecretKey } from 'nostr-tools/pure'
|
||||
|
||||
describe('#publish-event-integration.js', () => {
|
||||
let adapters
|
||||
let uut
|
||||
|
||||
before(async () => {
|
||||
// Initialize adapters (will connect to real relay)
|
||||
adapters = new Adapters()
|
||||
await adapters.start()
|
||||
|
||||
uut = new PublishEventUseCase({ adapters })
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
// Clean up adapters - disconnect from all relays
|
||||
if (adapters && adapters.nostrRelays) {
|
||||
await Promise.allSettled(
|
||||
adapters.nostrRelays.map(relay => relay.disconnect())
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
describe('#execute()', () => {
|
||||
it('should successfully publish a valid event', async () => {
|
||||
// Generate keys
|
||||
const sk = generateSecretKey()
|
||||
|
||||
// Create event template
|
||||
const eventTemplate = {
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: 'Integration test post from use case'
|
||||
}
|
||||
|
||||
// Sign the event
|
||||
const signedEvent = finalizeEvent(eventTemplate, sk)
|
||||
|
||||
// Execute use case
|
||||
const result = await uut.execute(signedEvent)
|
||||
|
||||
// Assert result
|
||||
assert.property(result, 'accepted')
|
||||
assert.property(result, 'message')
|
||||
assert.property(result, 'eventId')
|
||||
assert.equal(result.eventId, signedEvent.id)
|
||||
})
|
||||
|
||||
it('should reject invalid event structure', async () => {
|
||||
const invalidEvent = {
|
||||
id: 'invalid',
|
||||
pubkey: 'invalid',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: 'Test',
|
||||
sig: 'invalid'
|
||||
}
|
||||
|
||||
try {
|
||||
await uut.execute(invalidEvent)
|
||||
assert.equal(true, false, 'unexpected result')
|
||||
} catch (err) {
|
||||
assert.include(err.message, 'Invalid event structure')
|
||||
}
|
||||
})
|
||||
|
||||
it('should handle relay rejection', async () => {
|
||||
// Generate keys
|
||||
const sk = generateSecretKey()
|
||||
|
||||
// Create a duplicate event (if we send same event twice)
|
||||
const eventTemplate = {
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: 'Duplicate test post'
|
||||
}
|
||||
|
||||
const signedEvent = finalizeEvent(eventTemplate, sk)
|
||||
|
||||
// Publish first time
|
||||
const result1 = await uut.execute(signedEvent)
|
||||
assert.property(result1, 'accepted')
|
||||
|
||||
// Try to publish again (may be rejected as duplicate)
|
||||
const result2 = await uut.execute(signedEvent)
|
||||
assert.property(result2, 'accepted')
|
||||
// Result may be accepted or rejected depending on relay
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
Integration tests for QueryEventsUseCase with real adapter.
|
||||
These tests require a running Nostr relay.
|
||||
*/
|
||||
|
||||
// npm libraries
|
||||
import { assert } from 'chai'
|
||||
|
||||
// Unit under test
|
||||
import Adapters from '../../../src/adapters/index.js'
|
||||
import QueryEventsUseCase from '../../../src/use-cases/query-events.js'
|
||||
|
||||
describe('#query-events-integration.js', () => {
|
||||
let adapters
|
||||
let uut
|
||||
|
||||
before(async () => {
|
||||
// Initialize adapters (will connect to real relay)
|
||||
adapters = new Adapters()
|
||||
await adapters.start()
|
||||
|
||||
uut = new QueryEventsUseCase({ adapters })
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
// Clean up adapters - disconnect from all relays
|
||||
if (adapters && adapters.nostrRelays) {
|
||||
await Promise.allSettled(
|
||||
adapters.nostrRelays.map(relay => relay.disconnect())
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
describe('#execute()', () => {
|
||||
it('should successfully query events', async () => {
|
||||
const filters = [{ kinds: [1], limit: 5 }]
|
||||
const subscriptionId = 'test-query-' + Date.now()
|
||||
|
||||
const events = await uut.execute(filters, subscriptionId)
|
||||
|
||||
// Assert result is an array
|
||||
assert.isArray(events)
|
||||
|
||||
// If events are returned, verify structure
|
||||
if (events.length > 0) {
|
||||
assert.property(events[0], 'id')
|
||||
assert.property(events[0], 'pubkey')
|
||||
assert.property(events[0], 'created_at')
|
||||
assert.property(events[0], 'kind')
|
||||
assert.property(events[0], 'content')
|
||||
assert.equal(events[0].kind, 1)
|
||||
}
|
||||
})
|
||||
|
||||
it('should handle empty results', async () => {
|
||||
// Query for events that likely don't exist
|
||||
const filters = [{ kinds: [99999], limit: 1 }]
|
||||
const subscriptionId = 'test-empty-' + Date.now()
|
||||
|
||||
const events = await uut.execute(filters, subscriptionId)
|
||||
|
||||
// Should return empty array, not throw
|
||||
assert.isArray(events)
|
||||
assert.equal(events.length, 0)
|
||||
})
|
||||
|
||||
it('should handle multiple filters', async function () {
|
||||
// Increase timeout for this test - needs to be longer than use case timeout (30s)
|
||||
this.timeout(35000)
|
||||
|
||||
const filters = [
|
||||
{ kinds: [1], limit: 2 },
|
||||
{ kinds: [3], limit: 2 }
|
||||
]
|
||||
const subscriptionId = 'test-multi-' + Date.now()
|
||||
|
||||
const events = await uut.execute(filters, subscriptionId)
|
||||
|
||||
// Should return array (may be empty)
|
||||
assert.isArray(events)
|
||||
})
|
||||
|
||||
it('should timeout if EOSE not received', async () => {
|
||||
// This test may take up to 30 seconds
|
||||
// Use a filter that might not return EOSE quickly
|
||||
const filters = [{ kinds: [1] }] // No limit, might timeout
|
||||
const subscriptionId = 'test-timeout-' + Date.now()
|
||||
|
||||
// Should eventually return (even if empty)
|
||||
const events = await uut.execute(filters, subscriptionId)
|
||||
|
||||
assert.isArray(events)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,304 @@
|
||||
/*
|
||||
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,63 @@
|
||||
/*
|
||||
Unit tests for Server class.
|
||||
Note: Full server testing requires integration tests due to ES module limitations.
|
||||
These tests focus on testable logic.
|
||||
*/
|
||||
|
||||
// npm libraries
|
||||
import { assert } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
|
||||
// Unit under test
|
||||
import Server from '../../../bin/server.js'
|
||||
|
||||
describe('#server.js', () => {
|
||||
let sandbox
|
||||
let uut
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
uut = new Server()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sandbox.restore()
|
||||
})
|
||||
|
||||
describe('#startServer()', () => {
|
||||
// Note: Full server startup testing requires integration tests
|
||||
// due to ES module import limitations with Express and Controllers
|
||||
it('should have startServer method', () => {
|
||||
assert.isFunction(uut.startServer)
|
||||
})
|
||||
|
||||
it('should have controllers property', () => {
|
||||
assert.property(uut, 'controllers')
|
||||
})
|
||||
|
||||
it('should have config property', () => {
|
||||
assert.property(uut, 'config')
|
||||
})
|
||||
})
|
||||
|
||||
describe('#sleep()', () => {
|
||||
it('should sleep for specified milliseconds', async () => {
|
||||
const start = Date.now()
|
||||
await uut.sleep(50)
|
||||
const end = Date.now()
|
||||
|
||||
// Should have slept at least 50ms (allowing some margin)
|
||||
assert.isAtLeast(end - start, 40)
|
||||
})
|
||||
})
|
||||
|
||||
describe('#constructor()', () => {
|
||||
it('should initialize with controllers and config', () => {
|
||||
const server = new Server()
|
||||
|
||||
assert.property(server, 'controllers')
|
||||
assert.property(server, 'config')
|
||||
assert.property(server, 'process')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,187 @@
|
||||
/*
|
||||
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')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,344 @@
|
||||
/*
|
||||
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,139 @@
|
||||
/*
|
||||
Unit tests for the Event entity.
|
||||
*/
|
||||
|
||||
// npm libraries
|
||||
import { assert } from 'chai'
|
||||
|
||||
// Mocking data libraries
|
||||
import {
|
||||
mockKind0Event,
|
||||
mockKind1Event,
|
||||
mockKind3Event,
|
||||
mockKind7Event,
|
||||
mockInvalidEventMissingId,
|
||||
mockInvalidEventWrongIdLength,
|
||||
mockInvalidEventMissingPubkey,
|
||||
mockInvalidEventWrongPubkeyLength,
|
||||
mockInvalidEventMissingCreatedAt,
|
||||
mockInvalidEventWrongCreatedAtType,
|
||||
mockInvalidEventMissingKind,
|
||||
mockInvalidEventKindOutOfRange,
|
||||
mockInvalidEventMissingSig,
|
||||
mockInvalidEventWrongSigLength,
|
||||
mockInvalidEventTagsNotArray
|
||||
} from '../mocks/event-mocks.js'
|
||||
|
||||
// Unit under test
|
||||
import Event from '../../../src/entities/event.js'
|
||||
|
||||
describe('#event.js', () => {
|
||||
describe('#isValid()', () => {
|
||||
it('should return true for valid kind 0 event', () => {
|
||||
const event = new Event(mockKind0Event)
|
||||
assert.isTrue(event.isValid())
|
||||
})
|
||||
|
||||
it('should return true for valid kind 1 event', () => {
|
||||
const event = new Event(mockKind1Event)
|
||||
assert.isTrue(event.isValid())
|
||||
})
|
||||
|
||||
it('should return true for valid kind 3 event', () => {
|
||||
const event = new Event(mockKind3Event)
|
||||
assert.isTrue(event.isValid())
|
||||
})
|
||||
|
||||
it('should return true for valid kind 7 event', () => {
|
||||
const event = new Event(mockKind7Event)
|
||||
assert.isTrue(event.isValid())
|
||||
})
|
||||
|
||||
it('should return false for event missing id', () => {
|
||||
const event = new Event(mockInvalidEventMissingId)
|
||||
assert.isFalse(event.isValid())
|
||||
})
|
||||
|
||||
it('should return false for event with wrong id length', () => {
|
||||
const event = new Event(mockInvalidEventWrongIdLength)
|
||||
assert.isFalse(event.isValid())
|
||||
})
|
||||
|
||||
it('should return false for event missing pubkey', () => {
|
||||
const event = new Event(mockInvalidEventMissingPubkey)
|
||||
assert.isFalse(event.isValid())
|
||||
})
|
||||
|
||||
it('should return false for event with wrong pubkey length', () => {
|
||||
const event = new Event(mockInvalidEventWrongPubkeyLength)
|
||||
assert.isFalse(event.isValid())
|
||||
})
|
||||
|
||||
it('should return false for event missing created_at', () => {
|
||||
const event = new Event(mockInvalidEventMissingCreatedAt)
|
||||
assert.isFalse(event.isValid())
|
||||
})
|
||||
|
||||
it('should return false for event with wrong created_at type', () => {
|
||||
const event = new Event(mockInvalidEventWrongCreatedAtType)
|
||||
assert.isFalse(event.isValid())
|
||||
})
|
||||
|
||||
it('should return false for event missing kind', () => {
|
||||
const event = new Event(mockInvalidEventMissingKind)
|
||||
assert.isFalse(event.isValid())
|
||||
})
|
||||
|
||||
it('should return false for event with kind out of range', () => {
|
||||
const event = new Event(mockInvalidEventKindOutOfRange)
|
||||
assert.isFalse(event.isValid())
|
||||
})
|
||||
|
||||
it('should return false for event missing sig', () => {
|
||||
const event = new Event(mockInvalidEventMissingSig)
|
||||
assert.isFalse(event.isValid())
|
||||
})
|
||||
|
||||
it('should return false for event with wrong sig length', () => {
|
||||
const event = new Event(mockInvalidEventWrongSigLength)
|
||||
assert.isFalse(event.isValid())
|
||||
})
|
||||
|
||||
it('should return false for event with tags not an array', () => {
|
||||
const event = new Event(mockInvalidEventTagsNotArray)
|
||||
assert.isFalse(event.isValid())
|
||||
})
|
||||
})
|
||||
|
||||
describe('#toJSON()', () => {
|
||||
it('should serialize event to JSON correctly', () => {
|
||||
const event = new Event(mockKind1Event)
|
||||
const json = event.toJSON()
|
||||
|
||||
assert.property(json, 'id')
|
||||
assert.property(json, 'pubkey')
|
||||
assert.property(json, 'created_at')
|
||||
assert.property(json, 'kind')
|
||||
assert.property(json, 'tags')
|
||||
assert.property(json, 'content')
|
||||
assert.property(json, 'sig')
|
||||
|
||||
assert.equal(json.id, mockKind1Event.id)
|
||||
assert.equal(json.pubkey, mockKind1Event.pubkey)
|
||||
assert.equal(json.created_at, mockKind1Event.created_at)
|
||||
assert.equal(json.kind, mockKind1Event.kind)
|
||||
assert.deepEqual(json.tags, mockKind1Event.tags)
|
||||
assert.equal(json.content, mockKind1Event.content)
|
||||
assert.equal(json.sig, mockKind1Event.sig)
|
||||
})
|
||||
|
||||
it('should serialize event with tags correctly', () => {
|
||||
const event = new Event(mockKind3Event)
|
||||
const json = event.toJSON()
|
||||
|
||||
assert.isArray(json.tags)
|
||||
assert.equal(json.tags.length, 1)
|
||||
assert.deepEqual(json.tags, mockKind3Event.tags)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
Mock Express request/response objects for controller unit tests.
|
||||
*/
|
||||
|
||||
// Mock Express request object
|
||||
export function createMockRequest (overrides = {}) {
|
||||
return {
|
||||
body: {},
|
||||
params: {},
|
||||
query: {},
|
||||
method: 'GET',
|
||||
path: '/',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
// Mock Express response object
|
||||
export function createMockResponse () {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
jsonData: null,
|
||||
statusValue: null,
|
||||
headers: {},
|
||||
writeData: [],
|
||||
endCalled: false,
|
||||
writable: true, // Stream is writable by default
|
||||
destroyed: false, // Stream is not destroyed by default
|
||||
closed: false, // Stream is not closed by default
|
||||
eventHandlers: {} // Store event handlers
|
||||
}
|
||||
|
||||
res.status = function (code) {
|
||||
res.statusCode = code
|
||||
res.statusValue = code
|
||||
return res
|
||||
}
|
||||
|
||||
res.json = function (data) {
|
||||
res.jsonData = data
|
||||
return res
|
||||
}
|
||||
|
||||
res.setHeader = function (name, value) {
|
||||
res.headers[name] = value
|
||||
return res
|
||||
}
|
||||
|
||||
res.write = function (data) {
|
||||
res.writeData.push(data)
|
||||
return true
|
||||
}
|
||||
|
||||
res.end = function () {
|
||||
res.endCalled = true
|
||||
return res
|
||||
}
|
||||
|
||||
res.on = function (event, callback) {
|
||||
// Store event handlers for different event types
|
||||
if (!res.eventHandlers[event]) {
|
||||
res.eventHandlers[event] = []
|
||||
}
|
||||
res.eventHandlers[event].push(callback)
|
||||
|
||||
// For backward compatibility with existing tests
|
||||
if (event === 'close') {
|
||||
res.closeCallback = callback
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// Helper to trigger an event (useful for testing)
|
||||
res.trigger = function (event, ...args) {
|
||||
if (res.eventHandlers[event]) {
|
||||
for (const handler of res.eventHandlers[event]) {
|
||||
handler(...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// Helper to create a mock request with body
|
||||
export function createMockRequestWithBody (body) {
|
||||
return createMockRequest({ body })
|
||||
}
|
||||
|
||||
// Helper to create a mock request with params
|
||||
export function createMockRequestWithParams (params) {
|
||||
return createMockRequest({ params })
|
||||
}
|
||||
|
||||
// Helper to create a mock request with query
|
||||
export function createMockRequestWithQuery (query) {
|
||||
return createMockRequest({ query })
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
/*
|
||||
Mock event data for unit tests.
|
||||
Contains mock Nostr events for various event kinds.
|
||||
*/
|
||||
|
||||
// Alice's public key from examples
|
||||
const alicePubKey = '2c7e76c0f8dc1dca9d0197c7d19be580a8d074ccada6a2f6ebe056ae41092e92'
|
||||
const bobPubKey = 'b'.repeat(64)
|
||||
|
||||
// Valid event ID (64 hex chars)
|
||||
const validEventId = 'd09b4c5da59be3cd2768aa53fa78b77bf4859084c94f3bf26d401f004a9c8167'
|
||||
// Valid signature (128 hex chars)
|
||||
const validSig = 'a'.repeat(128)
|
||||
|
||||
// Kind 0: Profile metadata event
|
||||
const mockKind0Event = {
|
||||
id: validEventId,
|
||||
pubkey: alicePubKey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 0,
|
||||
tags: [],
|
||||
content: JSON.stringify({
|
||||
name: 'Alice',
|
||||
about: 'Hello, I am Alice!',
|
||||
picture: 'https://example.com/alice.jpg'
|
||||
}),
|
||||
sig: validSig
|
||||
}
|
||||
|
||||
// Kind 1: Text post event
|
||||
const mockKind1Event = {
|
||||
id: validEventId,
|
||||
pubkey: alicePubKey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: 'This is a test message',
|
||||
sig: validSig
|
||||
}
|
||||
|
||||
// Kind 3: Follow list event
|
||||
const mockKind3Event = {
|
||||
id: validEventId,
|
||||
pubkey: alicePubKey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 3,
|
||||
tags: [
|
||||
['p', bobPubKey, 'wss://nostr-relay.psfoundation.info', 'bob']
|
||||
],
|
||||
content: '',
|
||||
sig: validSig
|
||||
}
|
||||
|
||||
// Kind 7: Reaction/like event
|
||||
const mockKind7Event = {
|
||||
id: validEventId,
|
||||
pubkey: bobPubKey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 7,
|
||||
tags: [
|
||||
['e', validEventId, 'wss://nostr-relay.psfoundation.info'],
|
||||
['p', alicePubKey, 'wss://nostr-relay.psfoundation.info']
|
||||
],
|
||||
content: '+',
|
||||
sig: validSig
|
||||
}
|
||||
|
||||
// Invalid events for testing validation
|
||||
const mockInvalidEventMissingId = {
|
||||
pubkey: alicePubKey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: 'Test',
|
||||
sig: validSig
|
||||
}
|
||||
|
||||
const mockInvalidEventWrongIdLength = {
|
||||
id: 'short',
|
||||
pubkey: alicePubKey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: 'Test',
|
||||
sig: validSig
|
||||
}
|
||||
|
||||
const mockInvalidEventMissingPubkey = {
|
||||
id: validEventId,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: 'Test',
|
||||
sig: validSig
|
||||
}
|
||||
|
||||
const mockInvalidEventWrongPubkeyLength = {
|
||||
id: validEventId,
|
||||
pubkey: 'short',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: 'Test',
|
||||
sig: validSig
|
||||
}
|
||||
|
||||
const mockInvalidEventMissingCreatedAt = {
|
||||
id: validEventId,
|
||||
pubkey: alicePubKey,
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: 'Test',
|
||||
sig: validSig
|
||||
}
|
||||
|
||||
const mockInvalidEventWrongCreatedAtType = {
|
||||
id: validEventId,
|
||||
pubkey: alicePubKey,
|
||||
created_at: 'not-a-number',
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: 'Test',
|
||||
sig: validSig
|
||||
}
|
||||
|
||||
const mockInvalidEventMissingKind = {
|
||||
id: validEventId,
|
||||
pubkey: alicePubKey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: 'Test',
|
||||
sig: validSig
|
||||
}
|
||||
|
||||
const mockInvalidEventKindOutOfRange = {
|
||||
id: validEventId,
|
||||
pubkey: alicePubKey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 70000, // Out of range (0-65535)
|
||||
tags: [],
|
||||
content: 'Test',
|
||||
sig: validSig
|
||||
}
|
||||
|
||||
const mockInvalidEventMissingSig = {
|
||||
id: validEventId,
|
||||
pubkey: alicePubKey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: 'Test'
|
||||
}
|
||||
|
||||
const mockInvalidEventWrongSigLength = {
|
||||
id: validEventId,
|
||||
pubkey: alicePubKey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: 'Test',
|
||||
sig: 'short'
|
||||
}
|
||||
|
||||
const mockInvalidEventTagsNotArray = {
|
||||
id: validEventId,
|
||||
pubkey: alicePubKey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 1,
|
||||
tags: 'not-an-array',
|
||||
content: 'Test',
|
||||
sig: validSig
|
||||
}
|
||||
|
||||
export {
|
||||
mockKind0Event,
|
||||
mockKind1Event,
|
||||
mockKind3Event,
|
||||
mockKind7Event,
|
||||
mockInvalidEventMissingId,
|
||||
mockInvalidEventWrongIdLength,
|
||||
mockInvalidEventMissingPubkey,
|
||||
mockInvalidEventWrongPubkeyLength,
|
||||
mockInvalidEventMissingCreatedAt,
|
||||
mockInvalidEventWrongCreatedAtType,
|
||||
mockInvalidEventMissingKind,
|
||||
mockInvalidEventKindOutOfRange,
|
||||
mockInvalidEventMissingSig,
|
||||
mockInvalidEventWrongSigLength,
|
||||
mockInvalidEventTagsNotArray,
|
||||
alicePubKey,
|
||||
bobPubKey,
|
||||
validEventId,
|
||||
validSig
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
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,242 @@
|
||||
/*
|
||||
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')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
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')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
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