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,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
+ })