nostr-crypto-utils 0.1.5 → 0.1.6

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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,121 @@
1
+ import { generateKeyPair, signEvent, encrypt, decrypt, createEvent, validateEvent, validateSignedEvent, NostrEventKind, formatSubscriptionForRelay, formatEventForRelay, formatCloseForRelay } from '../index';
2
+ describe('NIP-01: Basic Protocol Flow', () => {
3
+ let keyPair;
4
+ beforeAll(async () => {
5
+ keyPair = await generateKeyPair();
6
+ });
7
+ test('Event Creation and Signing Flow', async () => {
8
+ // Create a text note (kind 1) as specified in NIP-01
9
+ const event = createEvent({
10
+ kind: NostrEventKind.TEXT_NOTE,
11
+ content: 'Hello Nostr!',
12
+ tags: [],
13
+ created_at: Math.floor(Date.now() / 1000)
14
+ });
15
+ // Sign the event
16
+ const signedEvent = await signEvent(event, keyPair.privateKey);
17
+ // Verify event structure according to NIP-01
18
+ const validation = validateSignedEvent(signedEvent);
19
+ expect(validation.isValid).toBe(true);
20
+ expect(signedEvent.pubkey).toBe(keyPair.publicKey);
21
+ expect(typeof signedEvent.created_at).toBe('number');
22
+ expect(Array.isArray(signedEvent.tags)).toBe(true);
23
+ });
24
+ test('Client-Relay Message Format', async () => {
25
+ // Test REQ message format
26
+ const reqMessage = formatSubscriptionForRelay({ id: 'test_sub', filters: [{ kinds: [1], limit: 10 }] });
27
+ expect(reqMessage[0]).toBe('REQ');
28
+ expect(Array.isArray(reqMessage)).toBe(true);
29
+ expect(reqMessage.length).toBeGreaterThan(2);
30
+ // Test EVENT message format
31
+ const event2 = createEvent({
32
+ kind: NostrEventKind.TEXT_NOTE,
33
+ content: 'test message',
34
+ tags: []
35
+ });
36
+ const signedEvent = await signEvent(event2, keyPair.privateKey);
37
+ const eventMessage = formatEventForRelay(signedEvent);
38
+ expect(eventMessage[0]).toBe('EVENT');
39
+ expect(eventMessage[1]).toMatchObject({
40
+ kind: NostrEventKind.TEXT_NOTE,
41
+ content: 'test message'
42
+ });
43
+ // Test CLOSE message format
44
+ const closeMessage = JSON.stringify(formatCloseForRelay('subscription_id'));
45
+ const closeParsed = JSON.parse(closeMessage);
46
+ expect(closeParsed[0]).toBe('CLOSE');
47
+ expect(closeParsed[1]).toBe('subscription_id');
48
+ });
49
+ });
50
+ describe('NIP-02: Contact List', () => {
51
+ test('Contact List Event Structure', async () => {
52
+ const keyPair = await generateKeyPair();
53
+ const contacts = createEvent({
54
+ kind: NostrEventKind.CONTACTS,
55
+ content: JSON.stringify([
56
+ {
57
+ pubkey: '0123456789abcdef',
58
+ relay: 'wss://relay.example.com',
59
+ petname: 'friend'
60
+ }
61
+ ]),
62
+ tags: [],
63
+ created_at: Math.floor(Date.now() / 1000)
64
+ });
65
+ const validation = validateEvent(contacts);
66
+ expect(validation.isValid).toBe(true);
67
+ expect(contacts.kind).toBe(NostrEventKind.CONTACTS);
68
+ });
69
+ });
70
+ describe('NIP-04: Encrypted Direct Messages', () => {
71
+ test('Message Encryption and Decryption', async () => {
72
+ const alice = await generateKeyPair();
73
+ const bob = await generateKeyPair();
74
+ const message = 'Secret message for testing';
75
+ // Encrypt message from Alice to Bob
76
+ const encrypted = await encrypt(message, bob.publicKey, alice.privateKey);
77
+ expect(encrypted).not.toBe(message);
78
+ // Bob decrypts message from Alice
79
+ const decrypted = await decrypt(encrypted, alice.publicKey, bob.privateKey);
80
+ expect(decrypted).toBe(message);
81
+ });
82
+ test('Encrypted DM Event Structure', async () => {
83
+ const sender = await generateKeyPair();
84
+ const recipient = await generateKeyPair();
85
+ const message = 'Secret message';
86
+ const encrypted = await encrypt(message, recipient.publicKey, sender.privateKey);
87
+ const dmEvent = createEvent({
88
+ kind: NostrEventKind.ENCRYPTED_DIRECT_MESSAGE,
89
+ content: encrypted,
90
+ tags: [['p', recipient.publicKey]],
91
+ created_at: Math.floor(Date.now() / 1000)
92
+ });
93
+ const validation = validateEvent(dmEvent);
94
+ expect(validation.isValid).toBe(true);
95
+ expect(dmEvent.kind).toBe(NostrEventKind.ENCRYPTED_DIRECT_MESSAGE);
96
+ expect(dmEvent.tags[0][0]).toBe('p');
97
+ expect(dmEvent.tags[0][1]).toBe(recipient.publicKey);
98
+ });
99
+ });
100
+ describe('NIP-09: Event Deletion', () => {
101
+ test('Event Deletion Structure', async () => {
102
+ const keyPair = await generateKeyPair();
103
+ const originalEvent = await signEvent(createEvent({
104
+ kind: NostrEventKind.TEXT_NOTE,
105
+ content: 'Original post',
106
+ tags: [],
107
+ created_at: Math.floor(Date.now() / 1000)
108
+ }), keyPair.privateKey);
109
+ const deleteEvent = createEvent({
110
+ kind: NostrEventKind.DELETE,
111
+ content: 'Deleted due to error',
112
+ tags: [['e', originalEvent.id]],
113
+ created_at: Math.floor(Date.now() / 1000)
114
+ });
115
+ const validation = validateEvent(deleteEvent);
116
+ expect(validation.isValid).toBe(true);
117
+ expect(deleteEvent.kind).toBe(NostrEventKind.DELETE);
118
+ expect(deleteEvent.tags[0][0]).toBe('e');
119
+ expect(deleteEvent.tags[0][1]).toBe(originalEvent.id);
120
+ });
121
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,113 @@
1
+ import { parseNostrMessage, NostrEventKind, createEvent, signEvent, generateKeyPair, validateSignedEvent, formatEventForRelay } from '../index';
2
+ // Mock WebSocket for testing
3
+ class MockWebSocket {
4
+ constructor(url) {
5
+ this.url = url;
6
+ this.listeners = {};
7
+ this.readyState = 1;
8
+ }
9
+ send(data) {
10
+ // Simulate relay behavior
11
+ const message = JSON.parse(data);
12
+ if (message[0] === 'REQ') {
13
+ // Simulate EOSE after subscription
14
+ this.emit('message', JSON.stringify(['EOSE', message[1]]));
15
+ }
16
+ }
17
+ addEventListener(event, callback) {
18
+ if (!this.listeners[event]) {
19
+ this.listeners[event] = [];
20
+ }
21
+ this.listeners[event].push(callback);
22
+ }
23
+ emit(event, data) {
24
+ if (this.listeners[event]) {
25
+ this.listeners[event].forEach(callback => {
26
+ callback({ data });
27
+ });
28
+ }
29
+ }
30
+ close() {
31
+ this.readyState = 3;
32
+ this.emit('close', null);
33
+ }
34
+ }
35
+ describe('Transport Layer Integration', () => {
36
+ let keyPair;
37
+ let mockRelay;
38
+ beforeEach(async () => {
39
+ keyPair = await generateKeyPair();
40
+ mockRelay = new MockWebSocket('wss://mock.relay');
41
+ });
42
+ test('Subscription Flow', done => {
43
+ // Create subscription request
44
+ const subId = 'test-sub';
45
+ const filter = { kinds: [1], limit: 10 };
46
+ const subMessage = JSON.stringify(['REQ', subId, filter]);
47
+ // Listen for EOSE
48
+ mockRelay.addEventListener('message', event => {
49
+ const message = JSON.parse(event.data);
50
+ if (message[0] === 'EOSE' && message[1] === subId) {
51
+ done();
52
+ }
53
+ });
54
+ // Send subscription
55
+ mockRelay.send(subMessage);
56
+ });
57
+ test('Event Publication Flow', async () => {
58
+ // Create and sign an event
59
+ const timestamp = Math.floor(Date.now() / 1000);
60
+ const event = createEvent({
61
+ kind: NostrEventKind.TEXT_NOTE,
62
+ content: 'Test message',
63
+ tags: [],
64
+ created_at: timestamp,
65
+ pubkey: keyPair.publicKey
66
+ });
67
+ // Sign the event
68
+ const signedEvent = await signEvent(event, keyPair.privateKey);
69
+ // Validate event before sending
70
+ const validation = await validateSignedEvent(signedEvent);
71
+ expect(validation.isValid).toBe(true);
72
+ // Format event message
73
+ const [messageType, eventPayload] = formatEventForRelay(signedEvent);
74
+ expect(messageType).toBe('EVENT');
75
+ // Simulate sending to relay
76
+ mockRelay.send(JSON.stringify([messageType, eventPayload]));
77
+ });
78
+ test('Message Parsing', () => {
79
+ // Test EVENT message parsing
80
+ const eventMessage = ['EVENT', { id: 'test_id', pubkey: 'test_pubkey', kind: 1, content: 'test', created_at: 123, sig: 'test_sig', tags: [] }];
81
+ const eventResponse = parseNostrMessage(eventMessage);
82
+ expect(eventResponse.type).toBe('EVENT');
83
+ if (eventResponse.type === 'EVENT') {
84
+ expect(eventResponse.payload).toMatchObject({
85
+ id: 'test_id',
86
+ pubkey: 'test_pubkey',
87
+ kind: 1
88
+ });
89
+ }
90
+ // Test NOTICE message parsing
91
+ const noticeMessage = ['NOTICE', 'test message'];
92
+ const noticeResponse = parseNostrMessage(noticeMessage);
93
+ expect(noticeResponse.type).toBe('NOTICE');
94
+ if (noticeResponse.type === 'NOTICE') {
95
+ expect(noticeResponse.payload).toBe('test message');
96
+ }
97
+ });
98
+ test('Connection State Handling', () => {
99
+ expect(mockRelay.readyState).toBe(1); // Connected
100
+ mockRelay.close();
101
+ expect(mockRelay.readyState).toBe(3); // Closed
102
+ });
103
+ });
104
+ describe('Transport Error Handling', () => {
105
+ test('Invalid Message Format', () => {
106
+ const invalidMessage = ['EVENT', { id: 'test_id', pubkey: 'test_pubkey', kind: 1, content: 'test', created_at: 123, sig: 'test_sig', tags: [] }];
107
+ expect(() => parseNostrMessage(invalidMessage)).not.toThrow();
108
+ });
109
+ test('Malformed JSON', () => {
110
+ const malformedMessage = ['EVENT', { id: 'test_id', pubkey: 'test_pubkey', kind: 1, content: 'test', created_at: 123, sig: 'test_sig', tags: [] }];
111
+ expect(() => parseNostrMessage(malformedMessage)).not.toThrow();
112
+ });
113
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,227 @@
1
+ import { isNostrEvent, isSignedNostrEvent, isNostrFilter, isNostrSubscription, NostrEventKind } from '../types';
2
+ describe('Type Guards', () => {
3
+ describe('isNostrEvent', () => {
4
+ it('should validate a valid NostrEvent', () => {
5
+ const validEvent = {
6
+ kind: NostrEventKind.TEXT_NOTE,
7
+ created_at: Math.floor(Date.now() / 1000),
8
+ content: 'Hello, Nostr!',
9
+ tags: [['p', '1234'], ['t', 'test']],
10
+ pubkey: '0123456789abcdef'
11
+ };
12
+ expect(isNostrEvent(validEvent)).toBe(true);
13
+ });
14
+ it('should validate a NostrEvent without optional pubkey', () => {
15
+ const validEvent = {
16
+ kind: NostrEventKind.TEXT_NOTE,
17
+ created_at: Math.floor(Date.now() / 1000),
18
+ content: 'Hello, Nostr!',
19
+ tags: []
20
+ };
21
+ expect(isNostrEvent(validEvent)).toBe(true);
22
+ });
23
+ it('should reject invalid NostrEvent objects', () => {
24
+ const invalidEvents = [
25
+ null,
26
+ undefined,
27
+ {},
28
+ { kind: 'not a number' },
29
+ { kind: 1, created_at: 'not a number' },
30
+ { kind: 1, created_at: 123, content: 42 },
31
+ { kind: 1, created_at: 123, content: 'valid', tags: 'not an array' },
32
+ { kind: 1, created_at: 123, content: 'valid', tags: [['valid'], 42] },
33
+ { kind: 1, created_at: 123, content: 'valid', tags: [], pubkey: 42 }
34
+ ];
35
+ invalidEvents.forEach(event => {
36
+ expect(isNostrEvent(event)).toBe(false);
37
+ });
38
+ });
39
+ it('should handle edge cases for NostrEvent', () => {
40
+ const edgeCases = [
41
+ // Large tag array
42
+ {
43
+ kind: NostrEventKind.TEXT_NOTE,
44
+ created_at: Math.floor(Date.now() / 1000),
45
+ content: 'Test',
46
+ tags: Array(1000).fill(['p', '1234']),
47
+ },
48
+ // Unicode content
49
+ {
50
+ kind: NostrEventKind.TEXT_NOTE,
51
+ created_at: Math.floor(Date.now() / 1000),
52
+ content: '🦄 Unicode test 漢字',
53
+ tags: [],
54
+ },
55
+ // Zero timestamp
56
+ {
57
+ kind: NostrEventKind.TEXT_NOTE,
58
+ created_at: 0,
59
+ content: 'Test',
60
+ tags: [],
61
+ },
62
+ // Reserved event kind
63
+ {
64
+ kind: 10000,
65
+ created_at: Math.floor(Date.now() / 1000),
66
+ content: 'Test',
67
+ tags: [],
68
+ },
69
+ // Empty content
70
+ {
71
+ kind: NostrEventKind.TEXT_NOTE,
72
+ created_at: Math.floor(Date.now() / 1000),
73
+ content: '',
74
+ tags: [],
75
+ }
76
+ ];
77
+ edgeCases.forEach(event => {
78
+ expect(isNostrEvent(event)).toBe(true);
79
+ });
80
+ });
81
+ });
82
+ describe('isSignedNostrEvent', () => {
83
+ it('should validate a valid SignedNostrEvent', () => {
84
+ const validEvent = {
85
+ kind: NostrEventKind.TEXT_NOTE,
86
+ created_at: Math.floor(Date.now() / 1000),
87
+ content: 'Hello, Nostr!',
88
+ tags: [['p', '1234'], ['t', 'test']],
89
+ pubkey: '0123456789abcdef',
90
+ id: 'event_id_hash',
91
+ sig: 'valid_signature'
92
+ };
93
+ expect(isSignedNostrEvent(validEvent)).toBe(true);
94
+ });
95
+ it('should reject invalid SignedNostrEvent objects', () => {
96
+ const invalidEvents = [
97
+ null,
98
+ undefined,
99
+ {},
100
+ { ...validNostrEvent }, // Missing id and sig
101
+ { ...validNostrEvent, id: 42 }, // Wrong id type
102
+ { ...validNostrEvent, id: 'valid', sig: 42 }, // Wrong sig type
103
+ { ...validNostrEvent, id: 'valid', sig: 'valid', pubkey: undefined } // Missing required pubkey
104
+ ];
105
+ invalidEvents.forEach(event => {
106
+ expect(isSignedNostrEvent(event)).toBe(false);
107
+ });
108
+ });
109
+ });
110
+ describe('isNostrFilter', () => {
111
+ it('should validate a valid NostrFilter', () => {
112
+ const validFilter = {
113
+ ids: ['1234', '5678'],
114
+ authors: ['abcd', 'efgh'],
115
+ kinds: [1, 2, 3],
116
+ '#e': ['event1', 'event2'],
117
+ '#p': ['pubkey1', 'pubkey2'],
118
+ since: 123456789,
119
+ until: 987654321,
120
+ limit: 100
121
+ };
122
+ expect(isNostrFilter(validFilter)).toBe(true);
123
+ });
124
+ it('should validate a NostrFilter with partial fields', () => {
125
+ const partialFilters = [
126
+ { ids: ['1234'] },
127
+ { authors: ['abcd'] },
128
+ { kinds: [1] },
129
+ { '#e': ['event1'] },
130
+ { '#p': ['pubkey1'] },
131
+ { since: 123456789 },
132
+ { until: 987654321 },
133
+ { limit: 100 }
134
+ ];
135
+ partialFilters.forEach(filter => {
136
+ expect(isNostrFilter(filter)).toBe(true);
137
+ });
138
+ });
139
+ it('should reject invalid NostrFilter objects', () => {
140
+ const invalidFilters = [
141
+ null,
142
+ undefined,
143
+ { ids: [42] }, // Wrong type in array
144
+ { authors: ['valid', 42] }, // Mixed types in array
145
+ { kinds: ['1'] }, // Wrong array type
146
+ { '#e': [42] }, // Wrong type in array
147
+ { '#p': [true] }, // Wrong type in array
148
+ { since: '123' }, // Wrong type
149
+ { until: false }, // Wrong type
150
+ { limit: '100' } // Wrong type
151
+ ];
152
+ invalidFilters.forEach(filter => {
153
+ expect(isNostrFilter(filter)).toBe(false);
154
+ });
155
+ });
156
+ it('should handle edge cases for NostrFilter', () => {
157
+ const edgeCases = [
158
+ // Multiple tag types
159
+ {
160
+ '#e': ['1234'],
161
+ '#p': ['5678'],
162
+ '#t': ['test'], // Custom tag
163
+ },
164
+ // Time range edge cases
165
+ {
166
+ since: 0,
167
+ until: Number.MAX_SAFE_INTEGER,
168
+ },
169
+ // Large limit
170
+ {
171
+ limit: Number.MAX_SAFE_INTEGER,
172
+ },
173
+ // Multiple array fields
174
+ {
175
+ ids: ['1', '2', '3'],
176
+ authors: ['a', 'b', 'c'],
177
+ kinds: [1, 2, 3, 10000],
178
+ }
179
+ ];
180
+ edgeCases.forEach(filter => {
181
+ expect(isNostrFilter(filter)).toBe(true);
182
+ });
183
+ });
184
+ });
185
+ describe('isNostrSubscription', () => {
186
+ it('should validate a valid NostrSubscription', () => {
187
+ const validSubscription = {
188
+ id: 'sub_123',
189
+ filters: [
190
+ { kinds: [1], limit: 10 },
191
+ { authors: ['abcd'], since: 123456789 }
192
+ ]
193
+ };
194
+ expect(isNostrSubscription(validSubscription)).toBe(true);
195
+ });
196
+ it('should validate a NostrSubscription with empty filters', () => {
197
+ const validSubscription = {
198
+ id: 'sub_123',
199
+ filters: []
200
+ };
201
+ expect(isNostrSubscription(validSubscription)).toBe(true);
202
+ });
203
+ it('should reject invalid NostrSubscription objects', () => {
204
+ const invalidSubscriptions = [
205
+ null,
206
+ undefined,
207
+ {},
208
+ { id: 123, filters: [] }, // Wrong id type
209
+ { id: 'valid' }, // Missing filters
210
+ { id: 'valid', filters: 'not an array' }, // Wrong filters type
211
+ { id: 'valid', filters: [null] }, // Invalid filter
212
+ { id: 'valid', filters: [{ invalid: true }] } // Invalid filter object
213
+ ];
214
+ invalidSubscriptions.forEach(sub => {
215
+ expect(isNostrSubscription(sub)).toBe(false);
216
+ });
217
+ });
218
+ });
219
+ });
220
+ // Helper for tests
221
+ const validNostrEvent = {
222
+ kind: NostrEventKind.TEXT_NOTE,
223
+ created_at: Math.floor(Date.now() / 1000),
224
+ content: 'Hello, Nostr!',
225
+ tags: [['p', '1234'], ['t', 'test']],
226
+ pubkey: '0123456789abcdef'
227
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,118 @@
1
+ import { validateEvent, validateSignedEvent, validateFilter, validateSubscription } from '../validation';
2
+ describe('Validation Functions', () => {
3
+ describe('validateEvent', () => {
4
+ it('should validate a valid event', () => {
5
+ const event = {
6
+ kind: 1,
7
+ content: 'Hello, Nostr!',
8
+ created_at: Math.floor(Date.now() / 1000),
9
+ tags: [['p', '1234567890abcdef']],
10
+ pubkey: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'
11
+ };
12
+ const result = validateEvent(event);
13
+ expect(result.isValid).toBe(true);
14
+ expect(result.errors).toHaveLength(0);
15
+ });
16
+ it('should reject an event with invalid kind', () => {
17
+ const event = {
18
+ kind: -1,
19
+ content: 'Hello, Nostr!',
20
+ created_at: Math.floor(Date.now() / 1000),
21
+ tags: [],
22
+ pubkey: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'
23
+ };
24
+ const result = validateEvent(event);
25
+ expect(result.isValid).toBe(false);
26
+ expect(result.errors).toContain('Event kind must be a non-negative integer');
27
+ });
28
+ it('should reject an event with future timestamp', () => {
29
+ const event = {
30
+ kind: 1,
31
+ content: 'Hello, Nostr!',
32
+ created_at: Math.floor(Date.now() / 1000) + 7200, // 2 hours in the future
33
+ tags: [],
34
+ pubkey: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'
35
+ };
36
+ const result = validateEvent(event);
37
+ expect(result.isValid).toBe(false);
38
+ expect(result.errors).toContain('Event timestamp is too far in the future');
39
+ });
40
+ });
41
+ describe('validateSignedEvent', () => {
42
+ it('should validate a valid signed event', () => {
43
+ const event = {
44
+ id: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
45
+ sig: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
46
+ kind: 1,
47
+ content: 'Hello, Nostr!',
48
+ created_at: Math.floor(Date.now() / 1000),
49
+ tags: [],
50
+ pubkey: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'
51
+ };
52
+ const result = validateSignedEvent(event);
53
+ expect(result.isValid).toBe(true);
54
+ expect(result.errors).toHaveLength(0);
55
+ });
56
+ it('should reject an event with invalid signature format', () => {
57
+ const event = {
58
+ id: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
59
+ sig: 'invalid-signature',
60
+ kind: 1,
61
+ content: 'Hello, Nostr!',
62
+ created_at: Math.floor(Date.now() / 1000),
63
+ tags: [],
64
+ pubkey: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'
65
+ };
66
+ const result = validateSignedEvent(event);
67
+ expect(result.isValid).toBe(false);
68
+ expect(result.errors).toContain('Invalid signature format');
69
+ });
70
+ });
71
+ describe('validateFilter', () => {
72
+ it('should validate a valid filter', () => {
73
+ const filter = {
74
+ kinds: [1, 2],
75
+ authors: ['1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'],
76
+ since: Math.floor(Date.now() / 1000) - 3600,
77
+ until: Math.floor(Date.now() / 1000),
78
+ limit: 100
79
+ };
80
+ const result = validateFilter(filter);
81
+ expect(result.isValid).toBe(true);
82
+ expect(result.errors).toHaveLength(0);
83
+ });
84
+ it('should reject a filter with invalid timestamps', () => {
85
+ const filter = {
86
+ kinds: [1],
87
+ since: Math.floor(Date.now() / 1000),
88
+ until: Math.floor(Date.now() / 1000) - 3600 // until before since
89
+ };
90
+ const result = validateFilter(filter);
91
+ expect(result.isValid).toBe(false);
92
+ expect(result.errors).toContain('since timestamp cannot be greater than until timestamp');
93
+ });
94
+ });
95
+ describe('validateSubscription', () => {
96
+ it('should validate a valid subscription', () => {
97
+ const subscription = {
98
+ id: 'sub1',
99
+ filters: [{
100
+ kinds: [1],
101
+ limit: 100
102
+ }]
103
+ };
104
+ const result = validateSubscription(subscription);
105
+ expect(result.isValid).toBe(true);
106
+ expect(result.errors).toHaveLength(0);
107
+ });
108
+ it('should reject a subscription without filters', () => {
109
+ const subscription = {
110
+ id: 'sub1',
111
+ filters: []
112
+ };
113
+ const result = validateSubscription(subscription);
114
+ expect(result.isValid).toBe(false);
115
+ expect(result.errors).toContain('Subscription must contain at least one filter');
116
+ });
117
+ });
118
+ });