@marinade.finance/ts-subscription-client 1.0.0-beta.1
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/README.md +107 -0
- package/__tests__/client.e2e.ts +245 -0
- package/__tests__/client.test.ts +348 -0
- package/__tests__/message.test.ts +30 -0
- package/client.ts +158 -0
- package/dist/client.d.ts +12 -0
- package/dist/client.js +112 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +16 -0
- package/dist/message.d.ts +6 -0
- package/dist/message.js +17 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/dist/types.d.ts +91 -0
- package/dist/types.js +12 -0
- package/index.ts +32 -0
- package/jest.config.js +10 -0
- package/message.ts +25 -0
- package/package.json +24 -0
- package/tsconfig.json +23 -0
- package/types.ts +119 -0
package/README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# ts-subscription-client
|
|
2
|
+
|
|
3
|
+
TypeScript client for the Marinade notification subscription API.
|
|
4
|
+
Allows users to subscribe/unsubscribe to notifications and list
|
|
5
|
+
active subscriptions.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
import {
|
|
11
|
+
createSubscriptionClient,
|
|
12
|
+
subscribeMessage,
|
|
13
|
+
unsubscribeMessage,
|
|
14
|
+
listSubscriptionsMessage,
|
|
15
|
+
} from '@marinade.finance/ts-subscription-client'
|
|
16
|
+
|
|
17
|
+
const client = createSubscriptionClient({
|
|
18
|
+
base_url: 'https://notifications.marinade.finance',
|
|
19
|
+
})
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The message format is built by helper functions (`subscribeMessage`,
|
|
23
|
+
`unsubscribeMessage`, `listSubscriptionsMessage`). Each request
|
|
24
|
+
includes a signature and message for server-side authentication.
|
|
25
|
+
|
|
26
|
+
For local development and testing subscriptions end-to-end, see
|
|
27
|
+
[notification-service/DEV_GUIDE.md](../notification-service/DEV_GUIDE.md).
|
|
28
|
+
|
|
29
|
+
## Channels
|
|
30
|
+
|
|
31
|
+
A "channel" is a delivery medium for notifications. Each subscription
|
|
32
|
+
binds a user to a channel + address pair.
|
|
33
|
+
|
|
34
|
+
### telegram
|
|
35
|
+
|
|
36
|
+
Telegram subscriptions use a deep-link activation flow:
|
|
37
|
+
|
|
38
|
+
1. **Subscribe** — the client sends a `POST /subscriptions` request
|
|
39
|
+
with `channel=telegram`. The server creates a subscription with
|
|
40
|
+
`telegram_status='pending'` and returns a `deep_link` in the
|
|
41
|
+
response (e.g., `https://t.me/MarinadeBot?start=feature_sam_auction-<uuid>`).
|
|
42
|
+
|
|
43
|
+
2. **Activate** — the user opens the deep link in Telegram and
|
|
44
|
+
presses "Start". The telegram-bot processes the link and maps
|
|
45
|
+
the user's chat to the subscription. From this point on,
|
|
46
|
+
notifications are delivered as Telegram messages.
|
|
47
|
+
|
|
48
|
+
The `telegram_status` field tracks delivery state:
|
|
49
|
+
`pending` → `active` → `inactive` / `unsubscribed` / `blocked`
|
|
50
|
+
|
|
51
|
+
### api
|
|
52
|
+
|
|
53
|
+
The API channel stores notifications in a server-side outbox.
|
|
54
|
+
Clients pull them via the read API (notifications endpoint).
|
|
55
|
+
No activation step is needed — subscribing is sufficient.
|
|
56
|
+
|
|
57
|
+
## Notification types
|
|
58
|
+
|
|
59
|
+
### sam_auction
|
|
60
|
+
|
|
61
|
+
SAM auction notifications for validators. Subscribing requires
|
|
62
|
+
proof of authority over a bond — the signer must be the bond
|
|
63
|
+
authority, validator identity, or vote account owner. The server
|
|
64
|
+
verifies this against on-chain bond accounts.
|
|
65
|
+
|
|
66
|
+
The `additional_data` field for subscribe/unsubscribe should include:
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
{
|
|
70
|
+
config_address: string // bonds program config
|
|
71
|
+
vote_account: string // validator vote account
|
|
72
|
+
bond_pubkey: string // derived bond PDA
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
For listing subscriptions (`GET /subscriptions`), no `additional_data`
|
|
77
|
+
is needed — the server performs a reverse lookup from the pubkey.
|
|
78
|
+
|
|
79
|
+
## Known limitations
|
|
80
|
+
|
|
81
|
+
### Telegram subscription lifecycle
|
|
82
|
+
|
|
83
|
+
The notification service tracks `telegram_status` for each telegram subscription:
|
|
84
|
+
`pending` → `active` → `inactive` / `unsubscribed` / `blocked`.
|
|
85
|
+
|
|
86
|
+
The consumer filters by `telegram_status` before attempting delivery.
|
|
87
|
+
It does not attempt to send notifications to subscriptions marked as `inactive`,
|
|
88
|
+
`unsubscribed`, or `blocked`. The status is updated based on the telegram-bot's
|
|
89
|
+
`/send` response:
|
|
90
|
+
|
|
91
|
+
- **200** → `active` (message delivered)
|
|
92
|
+
- **404** → `inactive` (subscription never existed in telegram-bot — bad external_id)
|
|
93
|
+
- **410 "Subscription inactive"** → `unsubscribed` (user unsubscribed via bot UI)
|
|
94
|
+
- **410 "User blocked bot"** → `blocked` (user blocked the bot in Telegram)
|
|
95
|
+
- **429 / 5xx** → retry with backoff, status unchanged
|
|
96
|
+
|
|
97
|
+
#### Re-subscribe after unsubscribe or block
|
|
98
|
+
|
|
99
|
+
When a user unsubscribes via the Telegram bot UI or blocks the bot, the
|
|
100
|
+
notification service marks the subscription as `unsubscribed` or `blocked` and
|
|
101
|
+
stops delivery. Clicking the original Telegram deep link again reactivates the
|
|
102
|
+
subscription on the telegram-bot side, but the notification service is not
|
|
103
|
+
notified — it still sees the old status and skips delivery.
|
|
104
|
+
|
|
105
|
+
To re-subscribe, the user must use the **CLI subscribe command**. This calls
|
|
106
|
+
`POST /subscriptions` which resets `telegram_status` to `pending`, allowing
|
|
107
|
+
delivery to resume on the next event.
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import http from 'http'
|
|
2
|
+
|
|
3
|
+
import { createSubscriptionClient, NetworkError } from '../index'
|
|
4
|
+
|
|
5
|
+
import type { SubscriptionClient } from '../index'
|
|
6
|
+
import type { IncomingMessage, ServerResponse } from 'http'
|
|
7
|
+
|
|
8
|
+
let server: http.Server
|
|
9
|
+
let port: number
|
|
10
|
+
let client: SubscriptionClient
|
|
11
|
+
|
|
12
|
+
function startServer(
|
|
13
|
+
handler: (req: IncomingMessage, res: ServerResponse) => void,
|
|
14
|
+
): Promise<void> {
|
|
15
|
+
return new Promise(resolve => {
|
|
16
|
+
server = http.createServer(handler)
|
|
17
|
+
server.listen(0, () => {
|
|
18
|
+
const addr = server.address()
|
|
19
|
+
if (addr && typeof addr !== 'string') {
|
|
20
|
+
port = addr.port
|
|
21
|
+
}
|
|
22
|
+
client = createSubscriptionClient({
|
|
23
|
+
base_url: `http://localhost:${port}`,
|
|
24
|
+
})
|
|
25
|
+
resolve()
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function stopServer(): Promise<void> {
|
|
31
|
+
return new Promise(resolve => {
|
|
32
|
+
server.close(() => resolve())
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
37
|
+
return new Promise(resolve => {
|
|
38
|
+
let data = ''
|
|
39
|
+
req.on('data', (chunk: Buffer) => {
|
|
40
|
+
data += chunk.toString()
|
|
41
|
+
})
|
|
42
|
+
req.on('end', () => resolve(data))
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('SubscriptionClient E2E', () => {
|
|
47
|
+
afterEach(async () => {
|
|
48
|
+
if (server) {
|
|
49
|
+
await stopServer()
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('subscribe', () => {
|
|
54
|
+
it('sends POST with correct body and returns typed response', async () => {
|
|
55
|
+
let receivedBody: Record<string, unknown> | undefined
|
|
56
|
+
let receivedMethod: string | undefined
|
|
57
|
+
|
|
58
|
+
await startServer((req, res) => {
|
|
59
|
+
receivedMethod = req.method
|
|
60
|
+
void readBody(req).then(body => {
|
|
61
|
+
receivedBody = JSON.parse(body) as Record<string, unknown>
|
|
62
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
63
|
+
res.end(
|
|
64
|
+
JSON.stringify({
|
|
65
|
+
user_id: 'u1',
|
|
66
|
+
notification_type: 'sam_auction',
|
|
67
|
+
channel: 'telegram',
|
|
68
|
+
channel_address: '@test',
|
|
69
|
+
created_at: '2024-01-01',
|
|
70
|
+
deep_link: 'https://t.me/bot?start=abc',
|
|
71
|
+
}),
|
|
72
|
+
)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const result = await client.subscribe({
|
|
77
|
+
pubkey: 'pk1',
|
|
78
|
+
notification_type: 'sam_auction',
|
|
79
|
+
channel: 'telegram',
|
|
80
|
+
channel_address: '@test',
|
|
81
|
+
signature: 'sig1',
|
|
82
|
+
message: 'Subscribe sam_auction telegram 1710000000',
|
|
83
|
+
additional_data: { vote_account: 'va1' },
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
expect(receivedMethod).toBe('POST')
|
|
87
|
+
expect(receivedBody).toEqual({
|
|
88
|
+
pubkey: 'pk1',
|
|
89
|
+
notification_type: 'sam_auction',
|
|
90
|
+
channel: 'telegram',
|
|
91
|
+
channel_address: '@test',
|
|
92
|
+
signature: 'sig1',
|
|
93
|
+
message: 'Subscribe sam_auction telegram 1710000000',
|
|
94
|
+
additional_data: { vote_account: 'va1' },
|
|
95
|
+
})
|
|
96
|
+
expect(result.deep_link).toBe('https://t.me/bot?start=abc')
|
|
97
|
+
expect(result.user_id).toBe('u1')
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('unsubscribe', () => {
|
|
102
|
+
it('sends DELETE with correct body', async () => {
|
|
103
|
+
let receivedMethod: string | undefined
|
|
104
|
+
let receivedBody: Record<string, unknown> | undefined
|
|
105
|
+
|
|
106
|
+
await startServer((req, res) => {
|
|
107
|
+
receivedMethod = req.method
|
|
108
|
+
void readBody(req).then(body => {
|
|
109
|
+
receivedBody = JSON.parse(body) as Record<string, unknown>
|
|
110
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
111
|
+
res.end(JSON.stringify({ deleted: true }))
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
const result = await client.unsubscribe({
|
|
116
|
+
pubkey: 'pk1',
|
|
117
|
+
notification_type: 'sam_auction',
|
|
118
|
+
channel: 'telegram',
|
|
119
|
+
signature: 'sig1',
|
|
120
|
+
message: 'Unsubscribe sam_auction telegram 1710000000',
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
expect(receivedMethod).toBe('DELETE')
|
|
124
|
+
expect(receivedBody?.channel_address).toBeUndefined()
|
|
125
|
+
expect(result.deleted).toBe(true)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('sends DELETE with channel_address when provided', async () => {
|
|
129
|
+
let receivedBody: Record<string, unknown> | undefined
|
|
130
|
+
|
|
131
|
+
await startServer((req, res) => {
|
|
132
|
+
void readBody(req).then(body => {
|
|
133
|
+
receivedBody = JSON.parse(body) as Record<string, unknown>
|
|
134
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
135
|
+
res.end(JSON.stringify({ deleted: true }))
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
await client.unsubscribe({
|
|
140
|
+
pubkey: 'pk1',
|
|
141
|
+
notification_type: 'sam_auction',
|
|
142
|
+
channel: 'telegram',
|
|
143
|
+
channel_address: '@testuser',
|
|
144
|
+
signature: 'sig1',
|
|
145
|
+
message: 'Unsubscribe sam_auction telegram 1710000000',
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
expect(receivedBody?.channel_address).toBe('@testuser')
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
describe('listSubscriptions', () => {
|
|
153
|
+
it('sends GET with correct query params and auth headers', async () => {
|
|
154
|
+
let receivedUrl: string | undefined
|
|
155
|
+
let receivedHeaders: Record<string, string | undefined> | undefined
|
|
156
|
+
|
|
157
|
+
await startServer((req, res) => {
|
|
158
|
+
receivedUrl = req.url
|
|
159
|
+
receivedHeaders = {
|
|
160
|
+
'x-solana-signature': req.headers['x-solana-signature'] as string,
|
|
161
|
+
'x-solana-message': req.headers['x-solana-message'] as string,
|
|
162
|
+
}
|
|
163
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
164
|
+
res.end(
|
|
165
|
+
JSON.stringify([
|
|
166
|
+
{
|
|
167
|
+
user_id: 'u1',
|
|
168
|
+
notification_type: 'sam_auction',
|
|
169
|
+
channel: 'telegram',
|
|
170
|
+
channel_address: '@test',
|
|
171
|
+
created_at: '2024-01-01',
|
|
172
|
+
},
|
|
173
|
+
]),
|
|
174
|
+
)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
const result = await client.listSubscriptions(
|
|
178
|
+
{
|
|
179
|
+
pubkey: 'pk1',
|
|
180
|
+
notification_type: 'sam_auction',
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
signature: 'sig1',
|
|
184
|
+
message: 'ListSubscriptions pk1 1710000000',
|
|
185
|
+
},
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
expect(receivedUrl).toBe(
|
|
189
|
+
'/subscriptions?pubkey=pk1¬ification_type=sam_auction',
|
|
190
|
+
)
|
|
191
|
+
expect(receivedHeaders).toEqual({
|
|
192
|
+
'x-solana-signature': 'sig1',
|
|
193
|
+
'x-solana-message': 'ListSubscriptions pk1 1710000000',
|
|
194
|
+
})
|
|
195
|
+
expect(result).toHaveLength(1)
|
|
196
|
+
expect(result[0].channel).toBe('telegram')
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
describe('error propagation', () => {
|
|
201
|
+
it.each([400, 401, 403, 404])(
|
|
202
|
+
'throws NetworkError with status %d',
|
|
203
|
+
async statusCode => {
|
|
204
|
+
await startServer((_req, res) => {
|
|
205
|
+
res.writeHead(statusCode, { 'Content-Type': 'text/plain' })
|
|
206
|
+
res.end(`error ${statusCode}`)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
const error = await client
|
|
210
|
+
.subscribe({
|
|
211
|
+
pubkey: 'p',
|
|
212
|
+
notification_type: 'sam_auction',
|
|
213
|
+
channel: 'telegram',
|
|
214
|
+
channel_address: '@t',
|
|
215
|
+
signature: 's',
|
|
216
|
+
message: 'm',
|
|
217
|
+
})
|
|
218
|
+
.catch((e: unknown) => e)
|
|
219
|
+
|
|
220
|
+
expect(error).toBeInstanceOf(NetworkError)
|
|
221
|
+
expect((error as NetworkError).status).toBe(statusCode)
|
|
222
|
+
expect((error as NetworkError).response).toBe(`error ${statusCode}`)
|
|
223
|
+
},
|
|
224
|
+
)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
describe('connection refused', () => {
|
|
228
|
+
it('throws NetworkError when server is unreachable', async () => {
|
|
229
|
+
const unreachableClient = createSubscriptionClient({
|
|
230
|
+
base_url: 'http://localhost:19999',
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
await expect(
|
|
234
|
+
unreachableClient.subscribe({
|
|
235
|
+
pubkey: 'p',
|
|
236
|
+
notification_type: 'sam_auction',
|
|
237
|
+
channel: 'telegram',
|
|
238
|
+
channel_address: '@t',
|
|
239
|
+
signature: 's',
|
|
240
|
+
message: 'm',
|
|
241
|
+
}),
|
|
242
|
+
).rejects.toThrow(NetworkError)
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
})
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { createSubscriptionClient, NetworkError } from '../index'
|
|
2
|
+
|
|
3
|
+
global.fetch = jest.fn()
|
|
4
|
+
|
|
5
|
+
describe('SubscriptionClient', () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
jest.clearAllMocks()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
jest.restoreAllMocks()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
describe('subscribe', () => {
|
|
15
|
+
it('should send POST request with correct body', async () => {
|
|
16
|
+
const mockFetch = global.fetch as jest.Mock
|
|
17
|
+
const responseBody = {
|
|
18
|
+
user_id: 'user-1',
|
|
19
|
+
notification_type: 'sam_auction',
|
|
20
|
+
channel: 'telegram',
|
|
21
|
+
channel_address: '@testuser',
|
|
22
|
+
created_at: '2024-01-01T00:00:00Z',
|
|
23
|
+
deep_link: 'https://t.me/test',
|
|
24
|
+
}
|
|
25
|
+
mockFetch.mockResolvedValue({
|
|
26
|
+
ok: true,
|
|
27
|
+
json: () => Promise.resolve(responseBody),
|
|
28
|
+
} as Response)
|
|
29
|
+
|
|
30
|
+
const client = createSubscriptionClient({
|
|
31
|
+
base_url: 'http://localhost:3000',
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const request = {
|
|
35
|
+
pubkey: 'pubkey123',
|
|
36
|
+
notification_type: 'sam_auction',
|
|
37
|
+
channel: 'telegram',
|
|
38
|
+
channel_address: '@testuser',
|
|
39
|
+
signature: 'sig123',
|
|
40
|
+
message: 'Subscribe sam_auction telegram 1710000000',
|
|
41
|
+
additional_data: { vote_account: 'vote123' },
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const result = await client.subscribe(request)
|
|
45
|
+
|
|
46
|
+
expect(result).toEqual(responseBody)
|
|
47
|
+
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
48
|
+
const [url, options] = mockFetch.mock.calls[0]
|
|
49
|
+
expect(url).toBe('http://localhost:3000/subscriptions')
|
|
50
|
+
expect(options?.method).toBe('POST')
|
|
51
|
+
expect(options?.headers).toEqual({ 'Content-Type': 'application/json' })
|
|
52
|
+
expect(JSON.parse(options?.body as string)).toEqual(request)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('should throw NetworkError on HTTP error', async () => {
|
|
56
|
+
const mockFetch = global.fetch as jest.Mock
|
|
57
|
+
mockFetch.mockResolvedValue({
|
|
58
|
+
ok: false,
|
|
59
|
+
status: 400,
|
|
60
|
+
statusText: 'Bad Request',
|
|
61
|
+
text: () => Promise.resolve('validation failed'),
|
|
62
|
+
} as Response)
|
|
63
|
+
|
|
64
|
+
const client = createSubscriptionClient({
|
|
65
|
+
base_url: 'http://localhost:3000',
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
await expect(
|
|
69
|
+
client.subscribe({
|
|
70
|
+
pubkey: 'p',
|
|
71
|
+
notification_type: 'sam_auction',
|
|
72
|
+
channel: 'telegram',
|
|
73
|
+
channel_address: '@test',
|
|
74
|
+
signature: 's',
|
|
75
|
+
message: 'm',
|
|
76
|
+
}),
|
|
77
|
+
).rejects.toThrow(NetworkError)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('should throw NetworkError on network failure', async () => {
|
|
81
|
+
const mockFetch = global.fetch as jest.Mock
|
|
82
|
+
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'))
|
|
83
|
+
|
|
84
|
+
const client = createSubscriptionClient({
|
|
85
|
+
base_url: 'http://localhost:3000',
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
await expect(
|
|
89
|
+
client.subscribe({
|
|
90
|
+
pubkey: 'p',
|
|
91
|
+
notification_type: 'sam_auction',
|
|
92
|
+
channel: 'telegram',
|
|
93
|
+
channel_address: '@test',
|
|
94
|
+
signature: 's',
|
|
95
|
+
message: 'm',
|
|
96
|
+
}),
|
|
97
|
+
).rejects.toThrow('ECONNREFUSED')
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('unsubscribe', () => {
|
|
102
|
+
it('should send DELETE request with correct body', async () => {
|
|
103
|
+
const mockFetch = global.fetch as jest.Mock
|
|
104
|
+
mockFetch.mockResolvedValue({
|
|
105
|
+
ok: true,
|
|
106
|
+
json: () => Promise.resolve({ deleted: true }),
|
|
107
|
+
} as Response)
|
|
108
|
+
|
|
109
|
+
const client = createSubscriptionClient({
|
|
110
|
+
base_url: 'http://localhost:3000',
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const request = {
|
|
114
|
+
pubkey: 'pubkey123',
|
|
115
|
+
notification_type: 'sam_auction',
|
|
116
|
+
channel: 'telegram',
|
|
117
|
+
signature: 'sig123',
|
|
118
|
+
message: 'Unsubscribe sam_auction telegram 1710000000',
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const result = await client.unsubscribe(request)
|
|
122
|
+
|
|
123
|
+
expect(result).toEqual({ deleted: true })
|
|
124
|
+
const [url, options] = mockFetch.mock.calls[0]
|
|
125
|
+
expect(url).toBe('http://localhost:3000/subscriptions')
|
|
126
|
+
expect(options?.method).toBe('DELETE')
|
|
127
|
+
expect(JSON.parse(options?.body as string)).toEqual(request)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('should include channel_address when provided', async () => {
|
|
131
|
+
const mockFetch = global.fetch as jest.Mock
|
|
132
|
+
mockFetch.mockResolvedValue({
|
|
133
|
+
ok: true,
|
|
134
|
+
json: () => Promise.resolve({ deleted: true }),
|
|
135
|
+
} as Response)
|
|
136
|
+
|
|
137
|
+
const client = createSubscriptionClient({
|
|
138
|
+
base_url: 'http://localhost:3000',
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
const request = {
|
|
142
|
+
pubkey: 'pubkey123',
|
|
143
|
+
notification_type: 'sam_auction',
|
|
144
|
+
channel: 'telegram',
|
|
145
|
+
channel_address: '@testuser',
|
|
146
|
+
signature: 'sig123',
|
|
147
|
+
message: 'Unsubscribe sam_auction telegram 1710000000',
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
await client.unsubscribe(request)
|
|
151
|
+
|
|
152
|
+
const [, options] = mockFetch.mock.calls[0]
|
|
153
|
+
const body = JSON.parse(options?.body as string)
|
|
154
|
+
expect(body.channel_address).toBe('@testuser')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('should throw NetworkError on HTTP error', async () => {
|
|
158
|
+
const mockFetch = global.fetch as jest.Mock
|
|
159
|
+
mockFetch.mockResolvedValue({
|
|
160
|
+
ok: false,
|
|
161
|
+
status: 403,
|
|
162
|
+
statusText: 'Forbidden',
|
|
163
|
+
text: () => Promise.resolve('unauthorized'),
|
|
164
|
+
} as Response)
|
|
165
|
+
|
|
166
|
+
const client = createSubscriptionClient({
|
|
167
|
+
base_url: 'http://localhost:3000',
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
await expect(
|
|
171
|
+
client.unsubscribe({
|
|
172
|
+
pubkey: 'p',
|
|
173
|
+
notification_type: 'sam_auction',
|
|
174
|
+
channel: 'telegram',
|
|
175
|
+
signature: 's',
|
|
176
|
+
message: 'm',
|
|
177
|
+
}),
|
|
178
|
+
).rejects.toThrow(NetworkError)
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('listSubscriptions', () => {
|
|
183
|
+
it('should send GET request with correct query params and auth headers', async () => {
|
|
184
|
+
const mockFetch = global.fetch as jest.Mock
|
|
185
|
+
const responseBody = [
|
|
186
|
+
{
|
|
187
|
+
user_id: 'user-1',
|
|
188
|
+
notification_type: 'sam_auction',
|
|
189
|
+
channel: 'telegram',
|
|
190
|
+
channel_address: '@testuser',
|
|
191
|
+
created_at: '2024-01-01T00:00:00Z',
|
|
192
|
+
},
|
|
193
|
+
]
|
|
194
|
+
mockFetch.mockResolvedValue({
|
|
195
|
+
ok: true,
|
|
196
|
+
json: () => Promise.resolve(responseBody),
|
|
197
|
+
} as Response)
|
|
198
|
+
|
|
199
|
+
const client = createSubscriptionClient({
|
|
200
|
+
base_url: 'http://localhost:3000',
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
const result = await client.listSubscriptions(
|
|
204
|
+
{
|
|
205
|
+
pubkey: 'pubkey123',
|
|
206
|
+
notification_type: 'sam_auction',
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
signature: 'sig123',
|
|
210
|
+
message: 'ListSubscriptions pubkey123 1710000000',
|
|
211
|
+
},
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
expect(result).toEqual(responseBody)
|
|
215
|
+
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
216
|
+
const [url, options] = mockFetch.mock.calls[0]
|
|
217
|
+
expect(url).toBe(
|
|
218
|
+
'http://localhost:3000/subscriptions?pubkey=pubkey123¬ification_type=sam_auction',
|
|
219
|
+
)
|
|
220
|
+
expect(options?.method).toBe('GET')
|
|
221
|
+
expect(options?.headers).toEqual({
|
|
222
|
+
'x-solana-signature': 'sig123',
|
|
223
|
+
'x-solana-message': 'ListSubscriptions pubkey123 1710000000',
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('should include additional_data as JSON query param', async () => {
|
|
228
|
+
const mockFetch = global.fetch as jest.Mock
|
|
229
|
+
mockFetch.mockResolvedValue({
|
|
230
|
+
ok: true,
|
|
231
|
+
json: () => Promise.resolve([]),
|
|
232
|
+
} as Response)
|
|
233
|
+
|
|
234
|
+
const client = createSubscriptionClient({
|
|
235
|
+
base_url: 'http://localhost:3000',
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
await client.listSubscriptions(
|
|
239
|
+
{
|
|
240
|
+
pubkey: 'pubkey123',
|
|
241
|
+
notification_type: 'sam_auction',
|
|
242
|
+
additional_data: { vote_account: 'vote123' },
|
|
243
|
+
},
|
|
244
|
+
{ signature: 'sig', message: 'msg' },
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
const [url] = mockFetch.mock.calls[0]
|
|
248
|
+
const parsed = new URL(url)
|
|
249
|
+
expect(parsed.searchParams.get('additional_data')).toBe(
|
|
250
|
+
JSON.stringify({ vote_account: 'vote123' }),
|
|
251
|
+
)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('should omit additional_data when empty object', async () => {
|
|
255
|
+
const mockFetch = global.fetch as jest.Mock
|
|
256
|
+
mockFetch.mockResolvedValue({
|
|
257
|
+
ok: true,
|
|
258
|
+
json: () => Promise.resolve([]),
|
|
259
|
+
} as Response)
|
|
260
|
+
|
|
261
|
+
const client = createSubscriptionClient({
|
|
262
|
+
base_url: 'http://localhost:3000',
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
await client.listSubscriptions(
|
|
266
|
+
{ pubkey: 'pubkey123', additional_data: {} },
|
|
267
|
+
{ signature: 'sig', message: 'msg' },
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
const [url] = mockFetch.mock.calls[0]
|
|
271
|
+
const parsed = new URL(url)
|
|
272
|
+
expect(parsed.searchParams.get('additional_data')).toBeNull()
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('should omit optional query params when not provided', async () => {
|
|
276
|
+
const mockFetch = global.fetch as jest.Mock
|
|
277
|
+
mockFetch.mockResolvedValue({
|
|
278
|
+
ok: true,
|
|
279
|
+
json: () => Promise.resolve([]),
|
|
280
|
+
} as Response)
|
|
281
|
+
|
|
282
|
+
const client = createSubscriptionClient({
|
|
283
|
+
base_url: 'http://localhost:3000',
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
await client.listSubscriptions(
|
|
287
|
+
{ pubkey: 'pubkey123' },
|
|
288
|
+
{ signature: 'sig', message: 'msg' },
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
const [url] = mockFetch.mock.calls[0]
|
|
292
|
+
expect(url).toBe('http://localhost:3000/subscriptions?pubkey=pubkey123')
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('should throw NetworkError on HTTP error', async () => {
|
|
296
|
+
const mockFetch = global.fetch as jest.Mock
|
|
297
|
+
mockFetch.mockResolvedValue({
|
|
298
|
+
ok: false,
|
|
299
|
+
status: 401,
|
|
300
|
+
statusText: 'Unauthorized',
|
|
301
|
+
text: () => Promise.resolve('invalid signature'),
|
|
302
|
+
} as Response)
|
|
303
|
+
|
|
304
|
+
const client = createSubscriptionClient({
|
|
305
|
+
base_url: 'http://localhost:3000',
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
await expect(
|
|
309
|
+
client.listSubscriptions(
|
|
310
|
+
{ pubkey: 'p' },
|
|
311
|
+
{ signature: 's', message: 'm' },
|
|
312
|
+
),
|
|
313
|
+
).rejects.toThrow(NetworkError)
|
|
314
|
+
})
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
describe('timeout', () => {
|
|
318
|
+
it('should throw NetworkError on timeout', async () => {
|
|
319
|
+
const mockFetch = global.fetch as jest.Mock
|
|
320
|
+
mockFetch.mockImplementation(
|
|
321
|
+
(_url: string, options: { signal: AbortSignal }) =>
|
|
322
|
+
new Promise((_resolve, reject) => {
|
|
323
|
+
options.signal.addEventListener('abort', () => {
|
|
324
|
+
const err = new Error('The operation was aborted')
|
|
325
|
+
err.name = 'AbortError'
|
|
326
|
+
reject(err)
|
|
327
|
+
})
|
|
328
|
+
}),
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
const client = createSubscriptionClient({
|
|
332
|
+
base_url: 'http://localhost:3000',
|
|
333
|
+
timeout_ms: 50,
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
await expect(
|
|
337
|
+
client.subscribe({
|
|
338
|
+
pubkey: 'p',
|
|
339
|
+
notification_type: 'sam_auction',
|
|
340
|
+
channel: 'telegram',
|
|
341
|
+
channel_address: '@test',
|
|
342
|
+
signature: 's',
|
|
343
|
+
message: 'm',
|
|
344
|
+
}),
|
|
345
|
+
).rejects.toThrow(/timeout/)
|
|
346
|
+
})
|
|
347
|
+
})
|
|
348
|
+
})
|