psf-bch-api 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.env-local +4 -0
  2. package/LICENSE.md +8 -0
  3. package/README.md +8 -0
  4. package/apidoc.json +9 -0
  5. package/bin/server.js +183 -0
  6. package/dev-docs/README.md +4 -0
  7. package/dev-docs/creation-prompt.md +34 -0
  8. package/dev-docs/rest2nostr-poxy-api.plan.md +163 -0
  9. package/dev-docs/test-plan-for-rest2nostr.plan.md +161 -0
  10. package/dev-docs/unit-test-prompt.md +13 -0
  11. package/examples/01-create-account.js +67 -0
  12. package/examples/02-read-posts.js +44 -0
  13. package/examples/03-write-post.js +55 -0
  14. package/examples/04-read-alice-posts.js +49 -0
  15. package/examples/05-get-follow-list.js +53 -0
  16. package/examples/06-update-follow-list.js +63 -0
  17. package/examples/07-liking-event.js +59 -0
  18. package/examples/README.md +90 -0
  19. package/index.js +11 -0
  20. package/package.json +37 -0
  21. package/production/docker/Dockerfile +85 -0
  22. package/production/docker/cleanup-images.sh +5 -0
  23. package/production/docker/docker-compose.yml +19 -0
  24. package/production/docker/start-rest2nostr.sh +3 -0
  25. package/src/adapters/full-node-rpc.js +133 -0
  26. package/src/adapters/index.js +217 -0
  27. package/src/adapters/wlogger.js +79 -0
  28. package/src/config/env/common.js +64 -0
  29. package/src/config/env/development.js +7 -0
  30. package/src/config/env/production.js +7 -0
  31. package/src/config/index.js +14 -0
  32. package/src/controllers/index.js +56 -0
  33. package/src/controllers/rest-api/full-node/blockchain/controller.js +553 -0
  34. package/src/controllers/rest-api/full-node/blockchain/index.js +66 -0
  35. package/src/controllers/rest-api/index.js +55 -0
  36. package/src/controllers/timer-controller.js +72 -0
  37. package/src/entities/event.js +71 -0
  38. package/src/use-cases/full-node-blockchain-use-cases.js +134 -0
  39. package/src/use-cases/index.js +29 -0
  40. package/test/integration/api/event-integration.js +250 -0
  41. package/test/integration/api/req-integration.js +173 -0
  42. package/test/integration/api/subscription-integration.js +198 -0
  43. package/test/integration/use-cases/manage-subscription-integration.js +163 -0
  44. package/test/integration/use-cases/publish-event-integration.js +104 -0
  45. package/test/integration/use-cases/query-events-integration.js +95 -0
  46. package/test/unit/adapters/full-node-rpc-unit.js +122 -0
  47. package/test/unit/bin/server-unit.js +63 -0
  48. package/test/unit/controllers/blockchain-controller-unit.js +215 -0
  49. package/test/unit/controllers/rest-api-index-unit.js +85 -0
  50. package/test/unit/entities/event-unit.js +139 -0
  51. package/test/unit/mocks/controller-mocks.js +98 -0
  52. package/test/unit/mocks/event-mocks.js +194 -0
  53. package/test/unit/use-cases/full-node-blockchain-use-cases-unit.js +137 -0
@@ -0,0 +1,95 @@
1
+ /*
2
+ Integration tests for QueryEventsUseCase with real adapter.
3
+ These tests require a running Nostr relay.
4
+ */
5
+
6
+ // npm libraries
7
+ import { assert } from 'chai'
8
+
9
+ // Unit under test
10
+ import Adapters from '../../../src/adapters/index.js'
11
+ import QueryEventsUseCase from '../../../src/use-cases/query-events.js'
12
+
13
+ describe('#query-events-integration.js', () => {
14
+ let adapters
15
+ let uut
16
+
17
+ before(async () => {
18
+ // Initialize adapters (will connect to real relay)
19
+ adapters = new Adapters()
20
+ await adapters.start()
21
+
22
+ uut = new QueryEventsUseCase({ adapters })
23
+ })
24
+
25
+ after(async () => {
26
+ // Clean up adapters - disconnect from all relays
27
+ if (adapters && adapters.nostrRelays) {
28
+ await Promise.allSettled(
29
+ adapters.nostrRelays.map(relay => relay.disconnect())
30
+ )
31
+ }
32
+ })
33
+
34
+ describe('#execute()', () => {
35
+ it('should successfully query events', async () => {
36
+ const filters = [{ kinds: [1], limit: 5 }]
37
+ const subscriptionId = 'test-query-' + Date.now()
38
+
39
+ const events = await uut.execute(filters, subscriptionId)
40
+
41
+ // Assert result is an array
42
+ assert.isArray(events)
43
+
44
+ // If events are returned, verify structure
45
+ if (events.length > 0) {
46
+ assert.property(events[0], 'id')
47
+ assert.property(events[0], 'pubkey')
48
+ assert.property(events[0], 'created_at')
49
+ assert.property(events[0], 'kind')
50
+ assert.property(events[0], 'content')
51
+ assert.equal(events[0].kind, 1)
52
+ }
53
+ })
54
+
55
+ it('should handle empty results', async () => {
56
+ // Query for events that likely don't exist
57
+ const filters = [{ kinds: [99999], limit: 1 }]
58
+ const subscriptionId = 'test-empty-' + Date.now()
59
+
60
+ const events = await uut.execute(filters, subscriptionId)
61
+
62
+ // Should return empty array, not throw
63
+ assert.isArray(events)
64
+ assert.equal(events.length, 0)
65
+ })
66
+
67
+ it('should handle multiple filters', async function () {
68
+ // Increase timeout for this test - needs to be longer than use case timeout (30s)
69
+ this.timeout(35000)
70
+
71
+ const filters = [
72
+ { kinds: [1], limit: 2 },
73
+ { kinds: [3], limit: 2 }
74
+ ]
75
+ const subscriptionId = 'test-multi-' + Date.now()
76
+
77
+ const events = await uut.execute(filters, subscriptionId)
78
+
79
+ // Should return array (may be empty)
80
+ assert.isArray(events)
81
+ })
82
+
83
+ it('should timeout if EOSE not received', async () => {
84
+ // This test may take up to 30 seconds
85
+ // Use a filter that might not return EOSE quickly
86
+ const filters = [{ kinds: [1] }] // No limit, might timeout
87
+ const subscriptionId = 'test-timeout-' + Date.now()
88
+
89
+ // Should eventually return (even if empty)
90
+ const events = await uut.execute(filters, subscriptionId)
91
+
92
+ assert.isArray(events)
93
+ })
94
+ })
95
+ })
@@ -0,0 +1,122 @@
1
+ /*
2
+ Unit tests for FullNodeRPCAdapter.
3
+ */
4
+
5
+ import { assert } from 'chai'
6
+ import sinon from 'sinon'
7
+ import axios from 'axios'
8
+
9
+ import FullNodeRPCAdapter from '../../../src/adapters/full-node-rpc.js'
10
+
11
+ describe('#full-node-rpc.js', () => {
12
+ let sandbox
13
+ let axiosCreateStub
14
+ let mockAxiosInstance
15
+
16
+ const baseConfig = {
17
+ fullNode: {
18
+ rpcBaseUrl: 'http://127.0.0.1:8332',
19
+ rpcUsername: 'user',
20
+ rpcPassword: 'pass',
21
+ rpcTimeoutMs: 1000,
22
+ rpcRequestIdPrefix: 'test'
23
+ }
24
+ }
25
+
26
+ beforeEach(() => {
27
+ sandbox = sinon.createSandbox()
28
+ mockAxiosInstance = {
29
+ post: sandbox.stub()
30
+ }
31
+ axiosCreateStub = sandbox.stub(axios, 'create').returns(mockAxiosInstance)
32
+ })
33
+
34
+ afterEach(() => {
35
+ sandbox.restore()
36
+ })
37
+
38
+ describe('#constructor()', () => {
39
+ it('should throw if full node config is missing', () => {
40
+ assert.throws(() => {
41
+ // eslint-disable-next-line no-new
42
+ new FullNodeRPCAdapter({ config: {} })
43
+ }, /Full node RPC configuration is required/)
44
+ })
45
+
46
+ it('should create axios client with provided configuration', () => {
47
+ // eslint-disable-next-line no-new
48
+ new FullNodeRPCAdapter({ config: baseConfig })
49
+
50
+ assert.isTrue(axiosCreateStub.calledOnce)
51
+ const options = axiosCreateStub.getCall(0).args[0]
52
+ assert.equal(options.baseURL, baseConfig.fullNode.rpcBaseUrl)
53
+ assert.equal(options.timeout, baseConfig.fullNode.rpcTimeoutMs)
54
+ assert.deepEqual(options.auth, {
55
+ username: baseConfig.fullNode.rpcUsername,
56
+ password: baseConfig.fullNode.rpcPassword
57
+ })
58
+ })
59
+ })
60
+
61
+ describe('#call()', () => {
62
+ it('should call RPC method and return result', async () => {
63
+ mockAxiosInstance.post.resolves({ data: { result: 'hash' } })
64
+ const uut = new FullNodeRPCAdapter({ config: baseConfig })
65
+
66
+ const result = await uut.call('getbestblockhash', [])
67
+
68
+ assert.equal(result, 'hash')
69
+ assert.isTrue(mockAxiosInstance.post.calledOnce)
70
+ const [, payload] = mockAxiosInstance.post.getCall(0).args
71
+ assert.deepEqual(payload, {
72
+ jsonrpc: '1.0',
73
+ id: 'test-getbestblockhash',
74
+ method: 'getbestblockhash',
75
+ params: []
76
+ })
77
+ })
78
+
79
+ it('should use custom request id when provided', async () => {
80
+ mockAxiosInstance.post.resolves({ data: { result: 123 } })
81
+ const uut = new FullNodeRPCAdapter({ config: baseConfig })
82
+
83
+ await uut.call('getblockcount', [], 'custom-id')
84
+
85
+ const [, payload] = mockAxiosInstance.post.getCall(0).args
86
+ assert.equal(payload.id, 'custom-id')
87
+ })
88
+
89
+ it('should throw formatted error when RPC returns error', async () => {
90
+ mockAxiosInstance.post.resolves({
91
+ data: {
92
+ error: { message: 'RPC error' }
93
+ }
94
+ })
95
+ const uut = new FullNodeRPCAdapter({ config: baseConfig })
96
+
97
+ try {
98
+ await uut.call('failing', [])
99
+ assert.fail('Unexpected success')
100
+ } catch (err) {
101
+ assert.equal(err.message, 'RPC error')
102
+ assert.equal(err.status, 400)
103
+ }
104
+ })
105
+
106
+ it('should translate network errors into 503 status', async () => {
107
+ mockAxiosInstance.post.rejects(new Error('ENOTFOUND fullnode'))
108
+ const uut = new FullNodeRPCAdapter({ config: baseConfig })
109
+
110
+ try {
111
+ await uut.call('getblockcount', [])
112
+ assert.fail('Unexpected success')
113
+ } catch (err) {
114
+ assert.equal(
115
+ err.message,
116
+ 'Network error: Could not communicate with full node or other external service.'
117
+ )
118
+ assert.equal(err.status, 503)
119
+ }
120
+ })
121
+ })
122
+ })
@@ -0,0 +1,63 @@
1
+ /*
2
+ Unit tests for Server class.
3
+ Note: Full server testing requires integration tests due to ES module limitations.
4
+ These tests focus on testable logic.
5
+ */
6
+
7
+ // npm libraries
8
+ import { assert } from 'chai'
9
+ import sinon from 'sinon'
10
+
11
+ // Unit under test
12
+ import Server from '../../../bin/server.js'
13
+
14
+ describe('#server.js', () => {
15
+ let sandbox
16
+ let uut
17
+
18
+ beforeEach(() => {
19
+ sandbox = sinon.createSandbox()
20
+ uut = new Server()
21
+ })
22
+
23
+ afterEach(() => {
24
+ sandbox.restore()
25
+ })
26
+
27
+ describe('#startServer()', () => {
28
+ // Note: Full server startup testing requires integration tests
29
+ // due to ES module import limitations with Express and Controllers
30
+ it('should have startServer method', () => {
31
+ assert.isFunction(uut.startServer)
32
+ })
33
+
34
+ it('should have controllers property', () => {
35
+ assert.property(uut, 'controllers')
36
+ })
37
+
38
+ it('should have config property', () => {
39
+ assert.property(uut, 'config')
40
+ })
41
+ })
42
+
43
+ describe('#sleep()', () => {
44
+ it('should sleep for specified milliseconds', async () => {
45
+ const start = Date.now()
46
+ await uut.sleep(50)
47
+ const end = Date.now()
48
+
49
+ // Should have slept at least 50ms (allowing some margin)
50
+ assert.isAtLeast(end - start, 40)
51
+ })
52
+ })
53
+
54
+ describe('#constructor()', () => {
55
+ it('should initialize with controllers and config', () => {
56
+ const server = new Server()
57
+
58
+ assert.property(server, 'controllers')
59
+ assert.property(server, 'config')
60
+ assert.property(server, 'process')
61
+ })
62
+ })
63
+ })
@@ -0,0 +1,215 @@
1
+ /*
2
+ Unit tests for BlockchainRESTController.
3
+ */
4
+
5
+ import { assert } from 'chai'
6
+ import sinon from 'sinon'
7
+
8
+ import BlockchainRESTController from '../../../src/controllers/rest-api/full-node/blockchain/controller.js'
9
+ import {
10
+ createMockRequest,
11
+ createMockResponse
12
+ } from '../mocks/controller-mocks.js'
13
+
14
+ describe('#blockchain-controller.js', () => {
15
+ let sandbox
16
+ let mockUseCases
17
+ let mockAdapters
18
+ let uut
19
+
20
+ const createBlockchainUseCaseStubs = () => ({
21
+ getBestBlockHash: sandbox.stub().resolves('hash'),
22
+ getBlockchainInfo: sandbox.stub().resolves({}),
23
+ getBlockCount: sandbox.stub().resolves(123),
24
+ getBlockHeader: sandbox.stub().resolves({ header: true }),
25
+ getBlockHeaders: sandbox.stub().resolves(['header']),
26
+ getChainTips: sandbox.stub().resolves(['tip']),
27
+ getDifficulty: sandbox.stub().resolves(1),
28
+ getMempoolEntry: sandbox.stub().resolves({}),
29
+ getMempoolEntries: sandbox.stub().resolves([]),
30
+ getMempoolAncestors: sandbox.stub().resolves([]),
31
+ getMempoolInfo: sandbox.stub().resolves({ size: 1 }),
32
+ getRawMempool: sandbox.stub().resolves(['tx']),
33
+ getTxOut: sandbox.stub().resolves({ value: 1 }),
34
+ getTxOutProof: sandbox.stub().resolves('proof'),
35
+ getTxOutProofs: sandbox.stub().resolves(['proof']),
36
+ verifyTxOutProof: sandbox.stub().resolves(['txid']),
37
+ verifyTxOutProofs: sandbox.stub().resolves([['txid']]),
38
+ getBlock: sandbox.stub().resolves({ hash: 'abc' }),
39
+ getBlockHash: sandbox.stub().resolves('blockhash')
40
+ })
41
+
42
+ beforeEach(() => {
43
+ sandbox = sinon.createSandbox()
44
+ mockAdapters = {
45
+ fullNode: {
46
+ validateArraySize: sandbox.stub().returns(true)
47
+ }
48
+ }
49
+ mockUseCases = {
50
+ blockchain: createBlockchainUseCaseStubs()
51
+ }
52
+ uut = new BlockchainRESTController({
53
+ adapters: mockAdapters,
54
+ useCases: mockUseCases
55
+ })
56
+ })
57
+
58
+ afterEach(() => {
59
+ sandbox.restore()
60
+ })
61
+
62
+ describe('#constructor()', () => {
63
+ it('should require adapters', () => {
64
+ assert.throws(() => {
65
+ // eslint-disable-next-line no-new
66
+ new BlockchainRESTController({ useCases: mockUseCases })
67
+ }, /Adapters library required/)
68
+ })
69
+
70
+ it('should require blockchain use cases', () => {
71
+ assert.throws(() => {
72
+ // eslint-disable-next-line no-new
73
+ new BlockchainRESTController({ adapters: mockAdapters, useCases: {} })
74
+ }, /Blockchain use cases required/)
75
+ })
76
+ })
77
+
78
+ describe('#root()', () => {
79
+ it('should return service status', async () => {
80
+ const req = createMockRequest()
81
+ const res = createMockResponse()
82
+
83
+ await uut.root(req, res)
84
+
85
+ assert.equal(res.statusValue, 200)
86
+ assert.deepEqual(res.jsonData, { status: 'blockchain' })
87
+ })
88
+ })
89
+
90
+ describe('#getBestBlockHash()', () => {
91
+ it('should return hash on success', async () => {
92
+ const req = createMockRequest()
93
+ const res = createMockResponse()
94
+
95
+ await uut.getBestBlockHash(req, res)
96
+
97
+ assert.equal(res.statusValue, 200)
98
+ assert.equal(res.jsonData, 'hash')
99
+ assert.isTrue(mockUseCases.blockchain.getBestBlockHash.calledOnce)
100
+ })
101
+
102
+ it('should handle errors via handleError()', async () => {
103
+ const error = new Error('failure')
104
+ error.status = 422
105
+ mockUseCases.blockchain.getBestBlockHash.rejects(error)
106
+ const req = createMockRequest()
107
+ const res = createMockResponse()
108
+
109
+ await uut.getBestBlockHash(req, res)
110
+
111
+ assert.equal(res.statusValue, 422)
112
+ assert.deepEqual(res.jsonData, { error: 'failure' })
113
+ })
114
+ })
115
+
116
+ describe('#getBlockHeaderSingle()', () => {
117
+ it('should return 400 if hash is missing', async () => {
118
+ const req = createMockRequest()
119
+ const res = createMockResponse()
120
+
121
+ await uut.getBlockHeaderSingle(req, res)
122
+
123
+ assert.equal(res.statusValue, 400)
124
+ assert.property(res.jsonData, 'error')
125
+ })
126
+
127
+ it('should call use case with verbose flag', async () => {
128
+ const hash = 'a'.repeat(64)
129
+ const req = createMockRequest({
130
+ params: { hash },
131
+ query: { verbose: 'true' }
132
+ })
133
+ const res = createMockResponse()
134
+
135
+ await uut.getBlockHeaderSingle(req, res)
136
+
137
+ assert.equal(res.statusValue, 200)
138
+ assert.isTrue(
139
+ mockUseCases.blockchain.getBlockHeader.calledOnceWithExactly({
140
+ hash,
141
+ verbose: true
142
+ })
143
+ )
144
+ })
145
+ })
146
+
147
+ describe('#getBlockHeaderBulk()', () => {
148
+ it('should return error if hashes is not array', async () => {
149
+ const req = createMockRequest({
150
+ body: { hashes: 'not-an-array' },
151
+ locals: {}
152
+ })
153
+ const res = createMockResponse()
154
+
155
+ await uut.getBlockHeaderBulk(req, res)
156
+
157
+ assert.equal(res.statusValue, 400)
158
+ assert.include(res.jsonData.error, 'hashes needs to be an array')
159
+ })
160
+
161
+ it('should validate array size and call use case', async () => {
162
+ const hash = 'a'.repeat(64)
163
+ const req = createMockRequest({
164
+ body: { hashes: [hash], verbose: true },
165
+ locals: { proLimit: false }
166
+ })
167
+ const res = createMockResponse()
168
+ mockUseCases.blockchain.getBlockHeaders.resolves(['result'])
169
+
170
+ await uut.getBlockHeaderBulk(req, res)
171
+
172
+ assert.equal(res.statusValue, 200)
173
+ assert.deepEqual(res.jsonData, ['result'])
174
+ assert.isTrue(
175
+ mockAdapters.fullNode.validateArraySize.calledOnceWithExactly(1, { isProUser: false })
176
+ )
177
+ assert.isTrue(
178
+ mockUseCases.blockchain.getBlockHeaders.calledOnceWithExactly({
179
+ hashes: [hash],
180
+ verbose: true
181
+ })
182
+ )
183
+ })
184
+
185
+ it('should return error if array size invalid', async () => {
186
+ mockAdapters.fullNode.validateArraySize.returns(false)
187
+ const req = createMockRequest({
188
+ body: { hashes: ['a'.repeat(64)] },
189
+ locals: {}
190
+ })
191
+ const res = createMockResponse()
192
+
193
+ await uut.getBlockHeaderBulk(req, res)
194
+
195
+ assert.equal(res.statusValue, 400)
196
+ assert.equal(res.jsonData.error, 'Array too large.')
197
+ })
198
+ })
199
+
200
+ describe('#verifyTxOutProofBulk()', () => {
201
+ it('should flatten proof responses', async () => {
202
+ mockUseCases.blockchain.verifyTxOutProofs.resolves([['txid-a'], ['txid-b']])
203
+ const req = createMockRequest({
204
+ body: { proofs: ['proof-a', 'proof-b'] },
205
+ locals: {}
206
+ })
207
+ const res = createMockResponse()
208
+
209
+ await uut.verifyTxOutProofBulk(req, res)
210
+
211
+ assert.equal(res.statusValue, 200)
212
+ assert.deepEqual(res.jsonData, ['txid-a', 'txid-b'])
213
+ })
214
+ })
215
+ })
@@ -0,0 +1,85 @@
1
+ /*
2
+ Unit tests for RESTControllers index.
3
+ */
4
+
5
+ import { assert } from 'chai'
6
+ import sinon from 'sinon'
7
+
8
+ import RESTControllers from '../../../src/controllers/rest-api/index.js'
9
+ import BlockchainRouter from '../../../src/controllers/rest-api/full-node/blockchain/index.js'
10
+
11
+ describe('#controllers/rest-api/index.js', () => {
12
+ let sandbox
13
+ let mockAdapters
14
+ let mockUseCases
15
+
16
+ const createBlockchainUseCaseStubs = () => ({
17
+ getBestBlockHash: () => {},
18
+ getBlockchainInfo: () => {},
19
+ getBlockCount: () => {},
20
+ getBlockHeader: () => {},
21
+ getBlockHeaders: () => {},
22
+ getChainTips: () => {},
23
+ getDifficulty: () => {},
24
+ getMempoolEntry: () => {},
25
+ getMempoolEntries: () => {},
26
+ getMempoolAncestors: () => {},
27
+ getMempoolInfo: () => {},
28
+ getRawMempool: () => {},
29
+ getTxOut: () => {},
30
+ getTxOutProof: () => {},
31
+ getTxOutProofs: () => {},
32
+ verifyTxOutProof: () => {},
33
+ verifyTxOutProofs: () => {},
34
+ getBlock: () => {},
35
+ getBlockHash: () => {}
36
+ })
37
+
38
+ beforeEach(() => {
39
+ sandbox = sinon.createSandbox()
40
+ mockAdapters = {
41
+ fullNode: {
42
+ validateArraySize: sandbox.stub().returns(true)
43
+ }
44
+ }
45
+ mockUseCases = {
46
+ blockchain: createBlockchainUseCaseStubs()
47
+ }
48
+ })
49
+
50
+ afterEach(() => {
51
+ sandbox.restore()
52
+ })
53
+
54
+ describe('#constructor()', () => {
55
+ it('should require adapters instance', () => {
56
+ assert.throws(() => {
57
+ // eslint-disable-next-line no-new
58
+ new RESTControllers({ useCases: mockUseCases })
59
+ }, /Adapters library required/)
60
+ })
61
+
62
+ it('should require useCases instance', () => {
63
+ assert.throws(() => {
64
+ // eslint-disable-next-line no-new
65
+ new RESTControllers({ adapters: mockAdapters })
66
+ }, /Use Cases library required/)
67
+ })
68
+ })
69
+
70
+ describe('#attachRESTControllers()', () => {
71
+ it('should instantiate blockchain router and attach to app', () => {
72
+ const attachStub = sandbox.stub(BlockchainRouter.prototype, 'attach')
73
+ const restControllers = new RESTControllers({
74
+ adapters: mockAdapters,
75
+ useCases: mockUseCases
76
+ })
77
+ const app = {}
78
+
79
+ restControllers.attachRESTControllers(app)
80
+
81
+ assert.isTrue(attachStub.calledOnce)
82
+ assert.equal(attachStub.getCall(0).args[0], app)
83
+ })
84
+ })
85
+ })