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.
- package/.env-local +4 -0
- package/LICENSE.md +8 -0
- package/README.md +8 -0
- package/apidoc.json +9 -0
- package/bin/server.js +183 -0
- package/dev-docs/README.md +4 -0
- package/dev-docs/creation-prompt.md +34 -0
- package/dev-docs/rest2nostr-poxy-api.plan.md +163 -0
- package/dev-docs/test-plan-for-rest2nostr.plan.md +161 -0
- package/dev-docs/unit-test-prompt.md +13 -0
- package/examples/01-create-account.js +67 -0
- package/examples/02-read-posts.js +44 -0
- package/examples/03-write-post.js +55 -0
- package/examples/04-read-alice-posts.js +49 -0
- package/examples/05-get-follow-list.js +53 -0
- package/examples/06-update-follow-list.js +63 -0
- package/examples/07-liking-event.js +59 -0
- package/examples/README.md +90 -0
- package/index.js +11 -0
- package/package.json +37 -0
- package/production/docker/Dockerfile +85 -0
- package/production/docker/cleanup-images.sh +5 -0
- package/production/docker/docker-compose.yml +19 -0
- package/production/docker/start-rest2nostr.sh +3 -0
- package/src/adapters/full-node-rpc.js +133 -0
- package/src/adapters/index.js +217 -0
- package/src/adapters/wlogger.js +79 -0
- package/src/config/env/common.js +64 -0
- package/src/config/env/development.js +7 -0
- package/src/config/env/production.js +7 -0
- package/src/config/index.js +14 -0
- package/src/controllers/index.js +56 -0
- package/src/controllers/rest-api/full-node/blockchain/controller.js +553 -0
- package/src/controllers/rest-api/full-node/blockchain/index.js +66 -0
- package/src/controllers/rest-api/index.js +55 -0
- package/src/controllers/timer-controller.js +72 -0
- package/src/entities/event.js +71 -0
- package/src/use-cases/full-node-blockchain-use-cases.js +134 -0
- package/src/use-cases/index.js +29 -0
- package/test/integration/api/event-integration.js +250 -0
- package/test/integration/api/req-integration.js +173 -0
- package/test/integration/api/subscription-integration.js +198 -0
- package/test/integration/use-cases/manage-subscription-integration.js +163 -0
- package/test/integration/use-cases/publish-event-integration.js +104 -0
- package/test/integration/use-cases/query-events-integration.js +95 -0
- package/test/unit/adapters/full-node-rpc-unit.js +122 -0
- package/test/unit/bin/server-unit.js +63 -0
- package/test/unit/controllers/blockchain-controller-unit.js +215 -0
- package/test/unit/controllers/rest-api-index-unit.js +85 -0
- package/test/unit/entities/event-unit.js +139 -0
- package/test/unit/mocks/controller-mocks.js +98 -0
- package/test/unit/mocks/event-mocks.js +194 -0
- package/test/unit/use-cases/full-node-blockchain-use-cases-unit.js +137 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Integration tests for GET /req/:subId endpoint.
|
|
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 Server from '../../../bin/server.js'
|
|
11
|
+
import { getPublicKey } from 'nostr-tools/pure'
|
|
12
|
+
import { hexToBytes } from '@noble/hashes/utils.js'
|
|
13
|
+
|
|
14
|
+
describe('#req-integration.js', () => {
|
|
15
|
+
let server
|
|
16
|
+
const baseUrl = 'http://localhost:3002' // Use different port for tests
|
|
17
|
+
|
|
18
|
+
before(async () => {
|
|
19
|
+
// Start test server
|
|
20
|
+
server = new Server()
|
|
21
|
+
server.config.port = 3002
|
|
22
|
+
await server.startServer()
|
|
23
|
+
|
|
24
|
+
// Wait for server to be ready
|
|
25
|
+
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
after(async () => {
|
|
29
|
+
// Stop server
|
|
30
|
+
if (server && server.server) {
|
|
31
|
+
await new Promise((resolve) => {
|
|
32
|
+
server.server.close(() => {
|
|
33
|
+
resolve()
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('GET /req/:subId', () => {
|
|
40
|
+
it('should query kind 1 events (posts) - covers examples 02, 04', async () => {
|
|
41
|
+
// JB55's public key from example 02
|
|
42
|
+
const jb55 = '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
|
|
43
|
+
|
|
44
|
+
// Create subscription ID
|
|
45
|
+
const subId = 'read-posts-' + Date.now()
|
|
46
|
+
|
|
47
|
+
// Create filters - read posts from JB55
|
|
48
|
+
const filters = {
|
|
49
|
+
limit: 2,
|
|
50
|
+
kinds: [1],
|
|
51
|
+
authors: [jb55]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Query events using GET /req/:subId
|
|
55
|
+
const filtersJson = encodeURIComponent(JSON.stringify([filters]))
|
|
56
|
+
const url = `${baseUrl}/req/${subId}?filters=${filtersJson}`
|
|
57
|
+
|
|
58
|
+
const response = await fetch(url)
|
|
59
|
+
const events = await response.json()
|
|
60
|
+
|
|
61
|
+
// Assert response
|
|
62
|
+
assert.equal(response.status, 200)
|
|
63
|
+
assert.isArray(events)
|
|
64
|
+
// May be empty if no events exist, but structure should be correct
|
|
65
|
+
if (events.length > 0) {
|
|
66
|
+
assert.property(events[0], 'id')
|
|
67
|
+
assert.property(events[0], 'pubkey')
|
|
68
|
+
assert.property(events[0], 'created_at')
|
|
69
|
+
assert.property(events[0], 'kind')
|
|
70
|
+
assert.property(events[0], 'content')
|
|
71
|
+
assert.equal(events[0].kind, 1)
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('should query Alice posts - covers example 04', async () => {
|
|
76
|
+
// Alice's public key
|
|
77
|
+
const alicePrivKeyHex = '3292a48aa331aeccce003d50d70fbd79617ba91860abbd2c78fa4a8301e36bc0'
|
|
78
|
+
const alicePrivKeyBin = hexToBytes(alicePrivKeyHex)
|
|
79
|
+
const alicePubKey = getPublicKey(alicePrivKeyBin)
|
|
80
|
+
|
|
81
|
+
// Create subscription ID
|
|
82
|
+
const subId = 'read-alice-posts-' + Date.now()
|
|
83
|
+
|
|
84
|
+
// Create filters - read posts from Alice
|
|
85
|
+
const filters = {
|
|
86
|
+
limit: 2,
|
|
87
|
+
kinds: [1],
|
|
88
|
+
authors: [alicePubKey]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Query events using GET /req/:subId
|
|
92
|
+
const filtersJson = encodeURIComponent(JSON.stringify([filters]))
|
|
93
|
+
const url = `${baseUrl}/req/${subId}?filters=${filtersJson}`
|
|
94
|
+
|
|
95
|
+
const response = await fetch(url)
|
|
96
|
+
const events = await response.json()
|
|
97
|
+
|
|
98
|
+
// Assert response
|
|
99
|
+
assert.equal(response.status, 200)
|
|
100
|
+
assert.isArray(events)
|
|
101
|
+
if (events.length > 0) {
|
|
102
|
+
assert.equal(events[0].pubkey, alicePubKey)
|
|
103
|
+
assert.equal(events[0].kind, 1)
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('should query kind 3 events (follow list) - covers example 05', async () => {
|
|
108
|
+
// Alice's public key
|
|
109
|
+
const alicePrivKeyHex = '3292a48aa331aeccce003d50d70fbd79617ba91860abbd2c78fa4a8301e36bc0'
|
|
110
|
+
const alicePrivKeyBin = hexToBytes(alicePrivKeyHex)
|
|
111
|
+
const alicePubKey = getPublicKey(alicePrivKeyBin)
|
|
112
|
+
|
|
113
|
+
// Create subscription ID
|
|
114
|
+
const subId = 'get-follow-list-' + Date.now()
|
|
115
|
+
|
|
116
|
+
// Create filters - get follow list (kind 3) from Alice
|
|
117
|
+
const filters = {
|
|
118
|
+
limit: 5,
|
|
119
|
+
kinds: [3],
|
|
120
|
+
authors: [alicePubKey]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Query events using GET /req/:subId
|
|
124
|
+
const filtersJson = encodeURIComponent(JSON.stringify([filters]))
|
|
125
|
+
const url = `${baseUrl}/req/${subId}?filters=${filtersJson}`
|
|
126
|
+
|
|
127
|
+
const response = await fetch(url)
|
|
128
|
+
const events = await response.json()
|
|
129
|
+
|
|
130
|
+
// Assert response
|
|
131
|
+
assert.equal(response.status, 200)
|
|
132
|
+
assert.isArray(events)
|
|
133
|
+
if (events.length > 0) {
|
|
134
|
+
assert.equal(events[0].kind, 3)
|
|
135
|
+
assert.equal(events[0].pubkey, alicePubKey)
|
|
136
|
+
assert.isArray(events[0].tags)
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('should handle filters as individual query params', async () => {
|
|
141
|
+
const subId = 'test-sub-' + Date.now()
|
|
142
|
+
const url = `${baseUrl}/req/${subId}?kinds=[1]&limit=10`
|
|
143
|
+
|
|
144
|
+
const response = await fetch(url)
|
|
145
|
+
const events = await response.json()
|
|
146
|
+
|
|
147
|
+
assert.equal(response.status, 200)
|
|
148
|
+
assert.isArray(events)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('should return 400 when subscription ID is missing', async () => {
|
|
152
|
+
const url = `${baseUrl}/req/?filters=${encodeURIComponent(JSON.stringify([{ kinds: [1] }]))}`
|
|
153
|
+
|
|
154
|
+
const response = await fetch(url)
|
|
155
|
+
await response.json()
|
|
156
|
+
|
|
157
|
+
// Should return 404 or 400
|
|
158
|
+
assert.isAtLeast(response.status, 400)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('should return 400 when filters JSON is invalid', async () => {
|
|
162
|
+
const subId = 'test-sub-' + Date.now()
|
|
163
|
+
const url = `${baseUrl}/req/${subId}?filters=invalid-json{`
|
|
164
|
+
|
|
165
|
+
const response = await fetch(url)
|
|
166
|
+
const result = await response.json()
|
|
167
|
+
|
|
168
|
+
assert.equal(response.status, 400)
|
|
169
|
+
assert.property(result, 'error')
|
|
170
|
+
assert.include(result.error, 'Invalid filters JSON')
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
})
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Integration tests for POST /req/:subId SSE subscription and DELETE /req/:subId.
|
|
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 Server from '../../../bin/server.js'
|
|
11
|
+
|
|
12
|
+
describe('#subscription-integration.js', () => {
|
|
13
|
+
let server
|
|
14
|
+
const baseUrl = 'http://localhost:3003' // Use different port for tests
|
|
15
|
+
|
|
16
|
+
before(async () => {
|
|
17
|
+
// Start test server
|
|
18
|
+
server = new Server()
|
|
19
|
+
server.config.port = 3003
|
|
20
|
+
await server.startServer()
|
|
21
|
+
|
|
22
|
+
// Wait for server to be ready
|
|
23
|
+
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
after(async function () {
|
|
27
|
+
this.timeout(10000) // Increase timeout for cleanup
|
|
28
|
+
// Stop server
|
|
29
|
+
if (server && server.server) {
|
|
30
|
+
// Close all connections forcefully if available
|
|
31
|
+
if (server.server.closeAllConnections) {
|
|
32
|
+
server.server.closeAllConnections()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
await new Promise((resolve) => {
|
|
36
|
+
const timeout = setTimeout(() => {
|
|
37
|
+
resolve() // Force resolve after 2 seconds
|
|
38
|
+
}, 2000)
|
|
39
|
+
|
|
40
|
+
server.server.close(() => {
|
|
41
|
+
clearTimeout(timeout)
|
|
42
|
+
resolve()
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('POST /req/:subId', () => {
|
|
49
|
+
it('should create SSE subscription', async () => {
|
|
50
|
+
const subId = 'test-sub-' + Date.now()
|
|
51
|
+
const filters = { kinds: [1], limit: 10 }
|
|
52
|
+
|
|
53
|
+
const response = await fetch(`${baseUrl}/req/${subId}`, {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: {
|
|
56
|
+
'Content-Type': 'application/json'
|
|
57
|
+
},
|
|
58
|
+
body: JSON.stringify(filters)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// Assert SSE headers
|
|
62
|
+
assert.equal(response.headers.get('content-type'), 'text/event-stream')
|
|
63
|
+
assert.equal(response.headers.get('cache-control'), 'no-cache')
|
|
64
|
+
assert.equal(response.headers.get('connection'), 'keep-alive')
|
|
65
|
+
|
|
66
|
+
// Read initial connection message
|
|
67
|
+
const reader = response.body.getReader()
|
|
68
|
+
const decoder = new TextDecoder()
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const { value } = await reader.read()
|
|
72
|
+
const text = decoder.decode(value)
|
|
73
|
+
assert.include(text, 'connected')
|
|
74
|
+
assert.include(text, subId)
|
|
75
|
+
} finally {
|
|
76
|
+
reader.releaseLock()
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('should return 400 when subscription ID is missing', async () => {
|
|
81
|
+
const filters = { kinds: [1] }
|
|
82
|
+
|
|
83
|
+
const response = await fetch(`${baseUrl}/req/`, {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
headers: {
|
|
86
|
+
'Content-Type': 'application/json'
|
|
87
|
+
},
|
|
88
|
+
body: JSON.stringify(filters)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// Should return 404 or 400
|
|
92
|
+
assert.isAtLeast(response.status, 400)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('should return 400 when filters are missing', async () => {
|
|
96
|
+
const subId = 'test-sub-' + Date.now()
|
|
97
|
+
|
|
98
|
+
const response = await fetch(`${baseUrl}/req/${subId}`, {
|
|
99
|
+
method: 'POST',
|
|
100
|
+
headers: {
|
|
101
|
+
'Content-Type': 'application/json'
|
|
102
|
+
},
|
|
103
|
+
body: JSON.stringify({})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const result = await response.json()
|
|
107
|
+
|
|
108
|
+
assert.equal(response.status, 400)
|
|
109
|
+
assert.property(result, 'error')
|
|
110
|
+
assert.include(result.error, 'Filters are required')
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe('DELETE /req/:subId', () => {
|
|
115
|
+
it('should close a subscription', async () => {
|
|
116
|
+
const subId = 'test-sub-' + Date.now()
|
|
117
|
+
|
|
118
|
+
const response = await fetch(`${baseUrl}/req/${subId}`, {
|
|
119
|
+
method: 'DELETE'
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// May return 200 if subscription exists, or 500 if it doesn't
|
|
123
|
+
// The important thing is it doesn't crash
|
|
124
|
+
assert.isAtMost(response.status, 500)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('should return 400 when subscription ID is missing', async () => {
|
|
128
|
+
const response = await fetch(`${baseUrl}/req/`, {
|
|
129
|
+
method: 'DELETE'
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
// Should return 404 or 400
|
|
133
|
+
assert.isAtLeast(response.status, 400)
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
describe('PUT /req/:subId', () => {
|
|
138
|
+
it('should create SSE subscription (alternative method)', async function () {
|
|
139
|
+
this.timeout(10000) // Increase timeout for this test
|
|
140
|
+
|
|
141
|
+
const subId = 'test-sub-' + Date.now()
|
|
142
|
+
const filters = { kinds: [1], limit: 10 }
|
|
143
|
+
|
|
144
|
+
const controller = new AbortController()
|
|
145
|
+
const timeoutId = setTimeout(() => controller.abort(), 8000)
|
|
146
|
+
|
|
147
|
+
let response
|
|
148
|
+
try {
|
|
149
|
+
response = await fetch(`${baseUrl}/req/${subId}`, {
|
|
150
|
+
method: 'PUT',
|
|
151
|
+
headers: {
|
|
152
|
+
'Content-Type': 'application/json'
|
|
153
|
+
},
|
|
154
|
+
body: JSON.stringify(filters),
|
|
155
|
+
signal: controller.signal
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// Assert SSE headers
|
|
159
|
+
assert.equal(response.headers.get('content-type'), 'text/event-stream')
|
|
160
|
+
|
|
161
|
+
clearTimeout(timeoutId)
|
|
162
|
+
|
|
163
|
+
// Consume the stream to prevent hanging
|
|
164
|
+
const reader = response.body.getReader()
|
|
165
|
+
const decoder = new TextDecoder()
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
// Read initial connection message with timeout
|
|
169
|
+
const readPromise = reader.read()
|
|
170
|
+
const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve({ value: null, done: true }), 2000))
|
|
171
|
+
const { value, done } = await Promise.race([readPromise, timeoutPromise])
|
|
172
|
+
|
|
173
|
+
if (value && !done) {
|
|
174
|
+
const text = decoder.decode(value)
|
|
175
|
+
assert.include(text, 'connected')
|
|
176
|
+
}
|
|
177
|
+
} finally {
|
|
178
|
+
reader.releaseLock()
|
|
179
|
+
}
|
|
180
|
+
} catch (err) {
|
|
181
|
+
// AbortController aborted - this is expected
|
|
182
|
+
if (err.name !== 'AbortError') {
|
|
183
|
+
throw err
|
|
184
|
+
}
|
|
185
|
+
} finally {
|
|
186
|
+
clearTimeout(timeoutId)
|
|
187
|
+
// Always close the subscription
|
|
188
|
+
try {
|
|
189
|
+
await fetch(`${baseUrl}/req/${subId}`, {
|
|
190
|
+
method: 'DELETE'
|
|
191
|
+
})
|
|
192
|
+
} catch (err) {
|
|
193
|
+
// Ignore errors when closing
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
})
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Integration tests for ManageSubscriptionUseCase 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 ManageSubscriptionUseCase from '../../../src/use-cases/manage-subscription.js'
|
|
12
|
+
|
|
13
|
+
describe('#manage-subscription-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 ManageSubscriptionUseCase({ adapters })
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
after(async () => {
|
|
26
|
+
// Clean up all subscriptions and disconnect from all relays
|
|
27
|
+
// Note: This is a simplified cleanup - in production you'd track all subscriptions
|
|
28
|
+
if (adapters && adapters.nostrRelays) {
|
|
29
|
+
await Promise.allSettled(
|
|
30
|
+
adapters.nostrRelays.map(relay => relay.disconnect())
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('#createSubscription()', () => {
|
|
36
|
+
it('should successfully create a subscription', async () => {
|
|
37
|
+
const subscriptionId = 'test-sub-' + Date.now()
|
|
38
|
+
const filters = [{ kinds: [1], limit: 5 }]
|
|
39
|
+
|
|
40
|
+
let eventReceived = false
|
|
41
|
+
let eoseReceived = false
|
|
42
|
+
|
|
43
|
+
const onEvent = (event) => {
|
|
44
|
+
eventReceived = true
|
|
45
|
+
assert.property(event, 'id')
|
|
46
|
+
assert.property(event, 'kind')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const onEose = () => {
|
|
50
|
+
eoseReceived = true
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const onClosed = () => {
|
|
54
|
+
// Handler for closed events
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
await uut.createSubscription(subscriptionId, filters, onEvent, onEose, onClosed)
|
|
58
|
+
|
|
59
|
+
// Assert subscription exists
|
|
60
|
+
assert.isTrue(uut.hasSubscription(subscriptionId))
|
|
61
|
+
|
|
62
|
+
// Wait a bit for events/EOSE
|
|
63
|
+
await new Promise(resolve => setTimeout(resolve, 2000))
|
|
64
|
+
|
|
65
|
+
// EOSE should be received (or events)
|
|
66
|
+
// Note: May not receive events if none exist, but EOSE should come
|
|
67
|
+
assert.isTrue(eoseReceived || eventReceived)
|
|
68
|
+
|
|
69
|
+
// Clean up
|
|
70
|
+
if (uut.hasSubscription(subscriptionId)) {
|
|
71
|
+
await uut.closeSubscription(subscriptionId)
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('should prevent duplicate subscriptions', async () => {
|
|
76
|
+
const subscriptionId = 'test-dup-' + Date.now()
|
|
77
|
+
const filters = [{ kinds: [1] }]
|
|
78
|
+
|
|
79
|
+
await uut.createSubscription(subscriptionId, filters)
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
await uut.createSubscription(subscriptionId, filters)
|
|
83
|
+
assert.equal(true, false, 'unexpected result')
|
|
84
|
+
} catch (err) {
|
|
85
|
+
assert.include(err.message, 'already exists')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Clean up
|
|
89
|
+
if (uut.hasSubscription(subscriptionId)) {
|
|
90
|
+
await uut.closeSubscription(subscriptionId)
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('should handle subscription with no events', async () => {
|
|
95
|
+
const subscriptionId = 'test-empty-' + Date.now()
|
|
96
|
+
const filters = [{ kinds: [99999], limit: 1 }] // Unlikely to have events
|
|
97
|
+
|
|
98
|
+
let eoseReceived = false
|
|
99
|
+
|
|
100
|
+
const onEose = () => {
|
|
101
|
+
eoseReceived = true
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
await uut.createSubscription(subscriptionId, filters, null, onEose, null)
|
|
105
|
+
|
|
106
|
+
// Wait for EOSE
|
|
107
|
+
await new Promise(resolve => setTimeout(resolve, 2000))
|
|
108
|
+
|
|
109
|
+
// Should receive EOSE even with no events
|
|
110
|
+
assert.isTrue(eoseReceived)
|
|
111
|
+
|
|
112
|
+
// Clean up
|
|
113
|
+
if (uut.hasSubscription(subscriptionId)) {
|
|
114
|
+
await uut.closeSubscription(subscriptionId)
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('#closeSubscription()', () => {
|
|
120
|
+
it('should successfully close a subscription', async () => {
|
|
121
|
+
const subscriptionId = 'test-close-' + Date.now()
|
|
122
|
+
const filters = [{ kinds: [1] }]
|
|
123
|
+
|
|
124
|
+
await uut.createSubscription(subscriptionId, filters)
|
|
125
|
+
assert.isTrue(uut.hasSubscription(subscriptionId))
|
|
126
|
+
|
|
127
|
+
await uut.closeSubscription(subscriptionId)
|
|
128
|
+
|
|
129
|
+
// Assert subscription is removed
|
|
130
|
+
assert.isFalse(uut.hasSubscription(subscriptionId))
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('should return successfully when closing non-existent subscription (idempotent)', async () => {
|
|
134
|
+
const subscriptionId = 'non-existent-sub'
|
|
135
|
+
|
|
136
|
+
// Should not throw - idempotent operation
|
|
137
|
+
await uut.closeSubscription(subscriptionId)
|
|
138
|
+
|
|
139
|
+
// Should return successfully without error
|
|
140
|
+
assert.isTrue(true, 'closeSubscription should succeed for non-existent subscription')
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('#hasSubscription()', () => {
|
|
145
|
+
it('should return false for non-existent subscription', () => {
|
|
146
|
+
assert.isFalse(uut.hasSubscription('non-existent'))
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('should return true for existing subscription', async () => {
|
|
150
|
+
const subscriptionId = 'test-has-' + Date.now()
|
|
151
|
+
const filters = [{ kinds: [1] }]
|
|
152
|
+
|
|
153
|
+
assert.isFalse(uut.hasSubscription(subscriptionId))
|
|
154
|
+
await uut.createSubscription(subscriptionId, filters)
|
|
155
|
+
assert.isTrue(uut.hasSubscription(subscriptionId))
|
|
156
|
+
|
|
157
|
+
// Clean up
|
|
158
|
+
if (uut.hasSubscription(subscriptionId)) {
|
|
159
|
+
await uut.closeSubscription(subscriptionId)
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Integration tests for PublishEventUseCase 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 PublishEventUseCase from '../../../src/use-cases/publish-event.js'
|
|
12
|
+
import { finalizeEvent, generateSecretKey } from 'nostr-tools/pure'
|
|
13
|
+
|
|
14
|
+
describe('#publish-event-integration.js', () => {
|
|
15
|
+
let adapters
|
|
16
|
+
let uut
|
|
17
|
+
|
|
18
|
+
before(async () => {
|
|
19
|
+
// Initialize adapters (will connect to real relay)
|
|
20
|
+
adapters = new Adapters()
|
|
21
|
+
await adapters.start()
|
|
22
|
+
|
|
23
|
+
uut = new PublishEventUseCase({ adapters })
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
after(async () => {
|
|
27
|
+
// Clean up adapters - disconnect from all relays
|
|
28
|
+
if (adapters && adapters.nostrRelays) {
|
|
29
|
+
await Promise.allSettled(
|
|
30
|
+
adapters.nostrRelays.map(relay => relay.disconnect())
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('#execute()', () => {
|
|
36
|
+
it('should successfully publish a valid event', async () => {
|
|
37
|
+
// Generate keys
|
|
38
|
+
const sk = generateSecretKey()
|
|
39
|
+
|
|
40
|
+
// Create event template
|
|
41
|
+
const eventTemplate = {
|
|
42
|
+
kind: 1,
|
|
43
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
44
|
+
tags: [],
|
|
45
|
+
content: 'Integration test post from use case'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Sign the event
|
|
49
|
+
const signedEvent = finalizeEvent(eventTemplate, sk)
|
|
50
|
+
|
|
51
|
+
// Execute use case
|
|
52
|
+
const result = await uut.execute(signedEvent)
|
|
53
|
+
|
|
54
|
+
// Assert result
|
|
55
|
+
assert.property(result, 'accepted')
|
|
56
|
+
assert.property(result, 'message')
|
|
57
|
+
assert.property(result, 'eventId')
|
|
58
|
+
assert.equal(result.eventId, signedEvent.id)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('should reject invalid event structure', async () => {
|
|
62
|
+
const invalidEvent = {
|
|
63
|
+
id: 'invalid',
|
|
64
|
+
pubkey: 'invalid',
|
|
65
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
66
|
+
kind: 1,
|
|
67
|
+
tags: [],
|
|
68
|
+
content: 'Test',
|
|
69
|
+
sig: 'invalid'
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
await uut.execute(invalidEvent)
|
|
74
|
+
assert.equal(true, false, 'unexpected result')
|
|
75
|
+
} catch (err) {
|
|
76
|
+
assert.include(err.message, 'Invalid event structure')
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('should handle relay rejection', async () => {
|
|
81
|
+
// Generate keys
|
|
82
|
+
const sk = generateSecretKey()
|
|
83
|
+
|
|
84
|
+
// Create a duplicate event (if we send same event twice)
|
|
85
|
+
const eventTemplate = {
|
|
86
|
+
kind: 1,
|
|
87
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
88
|
+
tags: [],
|
|
89
|
+
content: 'Duplicate test post'
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const signedEvent = finalizeEvent(eventTemplate, sk)
|
|
93
|
+
|
|
94
|
+
// Publish first time
|
|
95
|
+
const result1 = await uut.execute(signedEvent)
|
|
96
|
+
assert.property(result1, 'accepted')
|
|
97
|
+
|
|
98
|
+
// Try to publish again (may be rejected as duplicate)
|
|
99
|
+
const result2 = await uut.execute(signedEvent)
|
|
100
|
+
assert.property(result2, 'accepted')
|
|
101
|
+
// Result may be accepted or rejected depending on relay
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
})
|