@ragbits/api-client 0.0.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.
@@ -0,0 +1,484 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { RagbitsClient } from '../index'
3
+ import { server } from './setup'
4
+ import { http, HttpResponse } from 'msw'
5
+ import type { FeedbackRequest } from '../types'
6
+ import { FeedbackType } from '../types'
7
+ import { defaultConfigResponse } from './utils'
8
+
9
+ describe('RagbitsClient', () => {
10
+ let client: RagbitsClient
11
+
12
+ beforeEach(() => {
13
+ client = new RagbitsClient()
14
+ })
15
+
16
+ describe('Constructor', () => {
17
+ it('should create client with default base URL', () => {
18
+ const defaultClient = new RagbitsClient()
19
+ expect(defaultClient).toBeInstanceOf(RagbitsClient)
20
+ })
21
+
22
+ it('should create client with custom base URL', () => {
23
+ const customClient = new RagbitsClient({
24
+ baseUrl: 'https://api.example.com',
25
+ })
26
+ expect(customClient).toBeInstanceOf(RagbitsClient)
27
+ })
28
+
29
+ it('should remove trailing slash from base URL', () => {
30
+ const clientWithSlash = new RagbitsClient({
31
+ baseUrl: 'https://api.example.com/',
32
+ })
33
+ expect(clientWithSlash).toBeInstanceOf(RagbitsClient)
34
+ })
35
+
36
+ it('should throw error for invalid base URL', () => {
37
+ expect(() => {
38
+ new RagbitsClient({ baseUrl: 'invalid-url' })
39
+ }).toThrow(
40
+ 'Invalid base URL: invalid-url. Please provide a valid URL.'
41
+ )
42
+ })
43
+ })
44
+
45
+ describe('getBaseUrl', () => {
46
+ it('should return the base URL', () => {
47
+ expect(client.getBaseUrl()).toBe('http://127.0.0.1:8000')
48
+ })
49
+ })
50
+
51
+ describe('makeRequest', () => {
52
+ it('should make successful GET request', async () => {
53
+ const response = await client.makeRequest('/api/config')
54
+
55
+ expect(response).toEqual(defaultConfigResponse)
56
+ })
57
+
58
+ it('should make successful POST request with body', async () => {
59
+ const requestBody: FeedbackRequest = {
60
+ message_id: 'msg-123',
61
+ feedback: FeedbackType.LIKE,
62
+ payload: { reason: 'Great response!' },
63
+ }
64
+
65
+ const response = await client.makeRequest('/api/feedback', {
66
+ method: 'POST',
67
+ body: requestBody,
68
+ })
69
+
70
+ expect(response).toEqual({
71
+ status: 'success',
72
+ })
73
+ })
74
+
75
+ it('should handle request with custom headers', async () => {
76
+ server.use(
77
+ http.get('http://127.0.0.1:8000/api/config', ({ request }) => {
78
+ const customHeader = request.headers.get('X-Custom-Header')
79
+ return HttpResponse.json({
80
+ feedback: {
81
+ like: { enabled: true, form: null },
82
+ dislike: { enabled: false, form: null },
83
+ },
84
+ receivedHeader: customHeader,
85
+ })
86
+ })
87
+ )
88
+
89
+ const response = await client.makeRequest('/api/config', {
90
+ headers: {
91
+ 'X-Custom-Header': 'test-value',
92
+ },
93
+ })
94
+
95
+ // Check that the header was received by accessing the custom property
96
+ const responseWithHeader = response as typeof response & {
97
+ receivedHeader: string
98
+ }
99
+ expect(responseWithHeader.receivedHeader).toBe('test-value')
100
+ })
101
+
102
+ it('should handle HTTP errors', async () => {
103
+ // Mock error response for testing
104
+ server.use(
105
+ http.get('http://127.0.0.1:8000/api/config', () => {
106
+ return new HttpResponse(null, { status: 500 })
107
+ })
108
+ )
109
+
110
+ await expect(client.makeRequest('/api/config')).rejects.toThrow(
111
+ 'HTTP error! status: 500'
112
+ )
113
+ })
114
+
115
+ it('should handle request cancellation', async () => {
116
+ const abortController = new AbortController()
117
+
118
+ // Mock a slow config endpoint
119
+ server.use(
120
+ http.get('http://127.0.0.1:8000/api/config', () => {
121
+ return new Promise((resolve) => {
122
+ setTimeout(() => {
123
+ resolve(
124
+ HttpResponse.json({
125
+ feedback: {
126
+ like: { enabled: true, form: null },
127
+ dislike: { enabled: false, form: null },
128
+ },
129
+ })
130
+ )
131
+ }, 1000)
132
+ })
133
+ })
134
+ )
135
+
136
+ const requestPromise = client.makeRequest('/api/config', {
137
+ signal: abortController.signal,
138
+ })
139
+
140
+ // Cancel the request immediately
141
+ abortController.abort()
142
+
143
+ await expect(requestPromise).rejects.toThrow()
144
+ })
145
+ })
146
+
147
+ describe('makeStreamRequest', () => {
148
+ it('should handle streaming response', async () => {
149
+ const messages: unknown[] = []
150
+ const errors: Error[] = []
151
+ let closed = false
152
+
153
+ const cancelFn = client.makeStreamRequest(
154
+ '/api/chat',
155
+ { message: 'Start streaming', history: [] },
156
+ {
157
+ onMessage: (data) => {
158
+ messages.push(data)
159
+ return Promise.resolve()
160
+ },
161
+ onError: (error) => {
162
+ errors.push(error)
163
+ return Promise.resolve()
164
+ },
165
+ onClose: () => {
166
+ closed = true
167
+ },
168
+ }
169
+ )
170
+
171
+ // Wait for stream to complete
172
+ await new Promise((resolve) => {
173
+ const checkComplete = () => {
174
+ if (closed || errors.length > 0) {
175
+ resolve(void 0)
176
+ } else {
177
+ setTimeout(checkComplete, 50)
178
+ }
179
+ }
180
+ checkComplete()
181
+ })
182
+
183
+ expect(messages).toHaveLength(4)
184
+ expect(messages[0]).toEqual({
185
+ type: 'text',
186
+ content: 'Hello there!',
187
+ })
188
+ expect(messages[1]).toEqual({
189
+ type: 'text',
190
+ content: 'How can I help you?',
191
+ })
192
+ expect(messages[2]).toEqual({
193
+ type: 'message_id',
194
+ content: 'msg-123',
195
+ })
196
+ expect(messages[3]).toEqual({
197
+ type: 'conversation_id',
198
+ content: 'conv-456',
199
+ })
200
+ expect(errors).toHaveLength(0)
201
+ expect(closed).toBe(true)
202
+
203
+ // Cleanup
204
+ cancelFn()
205
+ })
206
+
207
+ it('should handle stream cancellation', async () => {
208
+ const messages: unknown[] = []
209
+ const errors: Error[] = []
210
+
211
+ const cancelFn = client.makeStreamRequest(
212
+ '/api/chat',
213
+ { message: 'Start streaming', history: [] },
214
+ {
215
+ onMessage: (data) => {
216
+ messages.push(data)
217
+ return Promise.resolve()
218
+ },
219
+ onError: (error) => {
220
+ errors.push(error)
221
+ return Promise.resolve()
222
+ },
223
+ }
224
+ )
225
+
226
+ // Cancel immediately
227
+ cancelFn()
228
+
229
+ // Wait a bit to ensure cancellation takes effect
230
+ await new Promise((resolve) => setTimeout(resolve, 100))
231
+
232
+ expect(messages.length).toBeLessThan(4) // Should not receive all messages
233
+ })
234
+
235
+ it('should handle stream errors', async () => {
236
+ server.use(
237
+ http.post('http://127.0.0.1:8000/api/chat', () => {
238
+ return new HttpResponse(null, { status: 500 })
239
+ })
240
+ )
241
+
242
+ const errors: Error[] = []
243
+
244
+ client.makeStreamRequest(
245
+ '/api/chat',
246
+ { message: 'Start streaming', history: [] },
247
+ {
248
+ onMessage: () => Promise.resolve(),
249
+ onError: (error) => {
250
+ errors.push(error)
251
+ return Promise.resolve()
252
+ },
253
+ }
254
+ )
255
+
256
+ // Wait for error to be captured
257
+ await new Promise((resolve) => setTimeout(resolve, 100))
258
+
259
+ expect(errors).toHaveLength(1)
260
+ expect(errors[0].message).toContain('HTTP error! status: 500')
261
+ })
262
+
263
+ it('should handle malformed JSON in stream', async () => {
264
+ server.use(
265
+ http.post('http://127.0.0.1:8000/api/chat', () => {
266
+ const encoder = new TextEncoder()
267
+
268
+ const stream = new ReadableStream({
269
+ start(controller) {
270
+ controller.enqueue(
271
+ encoder.encode('data: invalid-json\n\n')
272
+ )
273
+ controller.close()
274
+ },
275
+ })
276
+
277
+ return new HttpResponse(stream, {
278
+ headers: {
279
+ 'Content-Type': 'text/event-stream',
280
+ },
281
+ })
282
+ })
283
+ )
284
+
285
+ const errors: Error[] = []
286
+
287
+ client.makeStreamRequest(
288
+ '/api/chat',
289
+ { message: 'Start streaming', history: [] },
290
+ {
291
+ onMessage: () => Promise.resolve(),
292
+ onError: (error) => {
293
+ errors.push(error)
294
+ return Promise.resolve()
295
+ },
296
+ }
297
+ )
298
+
299
+ // Wait for error to be captured
300
+ await new Promise((resolve) => setTimeout(resolve, 100))
301
+
302
+ expect(errors).toHaveLength(1)
303
+ expect(errors[0].message).toBe('Error processing server response')
304
+ })
305
+
306
+ it('should handle stream with AbortSignal', async () => {
307
+ const abortController = new AbortController()
308
+ const messages: unknown[] = []
309
+ const errors: Error[] = []
310
+
311
+ client.makeStreamRequest(
312
+ '/api/chat',
313
+ { message: 'Start streaming', history: [] },
314
+ {
315
+ onMessage: (data) => {
316
+ messages.push(data)
317
+ return Promise.resolve()
318
+ },
319
+ onError: (error) => {
320
+ errors.push(error)
321
+ return Promise.resolve()
322
+ },
323
+ },
324
+ abortController.signal
325
+ )
326
+
327
+ // Abort after receiving first message (shorter delay to be more reliable)
328
+ setTimeout(() => {
329
+ abortController.abort()
330
+ }, 5)
331
+
332
+ // Wait longer for abort to take effect
333
+ await new Promise((resolve) => setTimeout(resolve, 200))
334
+
335
+ // Should receive fewer than 4 messages due to abort
336
+ // Allow for some variation in timing but ensure it's not all 4
337
+ expect(messages.length).toBeLessThanOrEqual(3)
338
+ })
339
+
340
+ it('should handle null response body', async () => {
341
+ server.use(
342
+ http.post('http://127.0.0.1:8000/api/chat', () => {
343
+ return new HttpResponse(null, {
344
+ headers: {
345
+ 'Content-Type': 'text/event-stream',
346
+ },
347
+ })
348
+ })
349
+ )
350
+
351
+ const errors: Error[] = []
352
+
353
+ client.makeStreamRequest(
354
+ '/api/chat',
355
+ { message: 'Start streaming', history: [] },
356
+ {
357
+ onMessage: () => Promise.resolve(),
358
+ onError: (error) => {
359
+ errors.push(error)
360
+ return Promise.resolve()
361
+ },
362
+ }
363
+ )
364
+
365
+ // Wait for error to be captured
366
+ await new Promise((resolve) => setTimeout(resolve, 100))
367
+
368
+ expect(errors).toHaveLength(1)
369
+ expect(errors[0].message).toBe('Response body is null')
370
+ })
371
+
372
+ it('should handle reader stream errors', async () => {
373
+ server.use(
374
+ http.post('http://127.0.0.1:8000/api/chat', () => {
375
+ const stream = new ReadableStream({
376
+ start(controller) {
377
+ // Simulate a stream error by enqueuing something and then erroring
378
+ const encoder = new TextEncoder()
379
+ controller.enqueue(
380
+ encoder.encode(
381
+ 'data: {"type": "text", "content": "Starting"}\n\n'
382
+ )
383
+ )
384
+ // Force an error in the stream
385
+ controller.error(new Error('Stream read error'))
386
+ },
387
+ })
388
+
389
+ return new HttpResponse(stream, {
390
+ headers: {
391
+ 'Content-Type': 'text/event-stream',
392
+ },
393
+ })
394
+ })
395
+ )
396
+
397
+ const errors: Error[] = []
398
+ const messages: unknown[] = []
399
+
400
+ client.makeStreamRequest(
401
+ '/api/chat',
402
+ { message: 'Start streaming', history: [] },
403
+ {
404
+ onMessage: (data) => {
405
+ messages.push(data)
406
+ return Promise.resolve()
407
+ },
408
+ onError: (error) => {
409
+ errors.push(error)
410
+ return Promise.resolve()
411
+ },
412
+ }
413
+ )
414
+
415
+ // Wait for error to be captured
416
+ await new Promise((resolve) => setTimeout(resolve, 200))
417
+
418
+ expect(errors).toHaveLength(1)
419
+ expect(errors[0].message).toBe('Error reading stream')
420
+ })
421
+
422
+ it('should handle synchronous errors in makeStreamRequest', async () => {
423
+ const errors: Error[] = []
424
+
425
+ // Create a spy to force a synchronous error
426
+ const originalFetch = global.fetch
427
+ global.fetch = vi.fn().mockImplementation(() => {
428
+ throw new Error('Synchronous fetch error')
429
+ })
430
+
431
+ client.makeStreamRequest(
432
+ '/api/chat',
433
+ { message: 'Start streaming', history: [] },
434
+ {
435
+ onMessage: () => Promise.resolve(),
436
+ onError: (error) => {
437
+ errors.push(error)
438
+ return Promise.resolve()
439
+ },
440
+ }
441
+ )
442
+
443
+ // Wait for error to be captured
444
+ await new Promise((resolve) => setTimeout(resolve, 100))
445
+
446
+ expect(errors).toHaveLength(1)
447
+ expect(errors[0].message).toBe('Synchronous fetch error')
448
+
449
+ // Restore original fetch
450
+ global.fetch = originalFetch
451
+ })
452
+
453
+ it('should handle non-Error exceptions in stream processing', async () => {
454
+ const errors: Error[] = []
455
+
456
+ // Mock fetch to throw a non-Error object
457
+ const originalFetch = global.fetch
458
+ global.fetch = vi.fn().mockImplementation(() => {
459
+ throw 'String error' // Non-Error exception
460
+ })
461
+
462
+ client.makeStreamRequest(
463
+ '/api/chat',
464
+ { message: 'Start streaming', history: [] },
465
+ {
466
+ onMessage: () => Promise.resolve(),
467
+ onError: (error) => {
468
+ errors.push(error)
469
+ return Promise.resolve()
470
+ },
471
+ }
472
+ )
473
+
474
+ // Wait for error to be captured
475
+ await new Promise((resolve) => setTimeout(resolve, 100))
476
+
477
+ expect(errors).toHaveLength(1)
478
+ expect(errors[0].message).toBe('Error connecting to server')
479
+
480
+ // Restore original fetch
481
+ global.fetch = originalFetch
482
+ })
483
+ })
484
+ })
@@ -0,0 +1,98 @@
1
+ import { http, HttpResponse } from 'msw'
2
+ import { defaultConfigResponse } from '../utils'
3
+
4
+ export const handlers = [
5
+ // Config endpoint with conditional error handling
6
+ http.get('http://127.0.0.1:8000/api/config', ({ request }) => {
7
+ const url = new URL(request.url)
8
+ if (url.searchParams.get('error') === 'true') {
9
+ return new HttpResponse(null, { status: 500 })
10
+ }
11
+
12
+ return HttpResponse.json(defaultConfigResponse)
13
+ }),
14
+
15
+ // Feedback endpoint
16
+ http.post('http://127.0.0.1:8000/api/feedback', async ({ request }) => {
17
+ const _body = await request.json()
18
+ return HttpResponse.json({
19
+ status: 'success',
20
+ })
21
+ }),
22
+
23
+ // Chat streaming endpoint
24
+ http.post('http://127.0.0.1:8000/api/chat', ({ request }) => {
25
+ const url = new URL(request.url)
26
+
27
+ // Handle different test scenarios
28
+ if (url.searchParams.get('error') === 'true') {
29
+ return new HttpResponse(null, { status: 500 })
30
+ }
31
+
32
+ if (url.searchParams.get('malformed') === 'true') {
33
+ const encoder = new TextEncoder()
34
+ const stream = new ReadableStream({
35
+ start(controller) {
36
+ controller.enqueue(encoder.encode('data: invalid-json\n\n'))
37
+ controller.close()
38
+ },
39
+ })
40
+ return new HttpResponse(stream, {
41
+ headers: { 'Content-Type': 'text/event-stream' },
42
+ })
43
+ }
44
+
45
+ if (url.searchParams.get('empty') === 'true') {
46
+ return new HttpResponse(null, {
47
+ headers: { 'Content-Type': 'text/event-stream' },
48
+ })
49
+ }
50
+
51
+ if (url.searchParams.get('slow') === 'true') {
52
+ return new Promise((resolve) => {
53
+ setTimeout(() => {
54
+ resolve(
55
+ HttpResponse.json({
56
+ type: 'text',
57
+ content: 'Slow response',
58
+ })
59
+ )
60
+ }, 1000)
61
+ })
62
+ }
63
+
64
+ const encoder = new TextEncoder()
65
+
66
+ const stream = new ReadableStream({
67
+ start(controller) {
68
+ const messages = [
69
+ { type: 'text', content: 'Hello there!' },
70
+ { type: 'text', content: 'How can I help you?' },
71
+ { type: 'message_id', content: 'msg-123' },
72
+ { type: 'conversation_id', content: 'conv-456' },
73
+ ]
74
+
75
+ messages.forEach((message, index) => {
76
+ setTimeout(() => {
77
+ controller.enqueue(
78
+ encoder.encode(
79
+ `data: ${JSON.stringify(message)}\n\n`
80
+ )
81
+ )
82
+ if (index === messages.length - 1) {
83
+ controller.close()
84
+ }
85
+ }, index * 10)
86
+ })
87
+ },
88
+ })
89
+
90
+ return new HttpResponse(stream, {
91
+ headers: {
92
+ 'Content-Type': 'text/event-stream',
93
+ 'Cache-Control': 'no-cache',
94
+ Connection: 'keep-alive',
95
+ },
96
+ })
97
+ }),
98
+ ]
@@ -0,0 +1,23 @@
1
+ import '@testing-library/jest-dom'
2
+ import { beforeAll, afterEach, afterAll } from 'vitest'
3
+ import { setupServer } from 'msw/node'
4
+ import { handlers } from './mocks/handlers'
5
+
6
+ // Setup MSW server
7
+ export const server = setupServer(...handlers)
8
+
9
+ beforeAll(() => {
10
+ // Start the interception on the client side before all tests run.
11
+ server.listen({ onUnhandledRequest: 'error' })
12
+ })
13
+
14
+ afterEach(() => {
15
+ // Reset any request handlers that we may add during the tests,
16
+ // so they don't affect other tests.
17
+ server.resetHandlers()
18
+ })
19
+
20
+ afterAll(() => {
21
+ // Clean up after the tests are finished.
22
+ server.close()
23
+ })
@@ -0,0 +1,35 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import * as RagbitsApiClient from '../index'
3
+
4
+ describe('Package Exports', () => {
5
+ it('should export RagbitsClient class', () => {
6
+ expect(RagbitsApiClient.RagbitsClient).toBeDefined()
7
+ expect(typeof RagbitsApiClient.RagbitsClient).toBe('function')
8
+ })
9
+
10
+ it('should export MessageRole enum with correct values', () => {
11
+ expect(RagbitsApiClient.MessageRole).toBeDefined()
12
+ expect(RagbitsApiClient.MessageRole.USER).toBe('user')
13
+ expect(RagbitsApiClient.MessageRole.ASSISTANT).toBe('assistant')
14
+ expect(RagbitsApiClient.MessageRole.SYSTEM).toBe('system')
15
+ })
16
+
17
+ it('should export ChatResponseType enum with correct values', () => {
18
+ expect(RagbitsApiClient.ChatResponseType).toBeDefined()
19
+ expect(RagbitsApiClient.ChatResponseType.TEXT).toBe('text')
20
+ expect(RagbitsApiClient.ChatResponseType.REFERENCE).toBe('reference')
21
+ expect(RagbitsApiClient.ChatResponseType.STATE_UPDATE).toBe(
22
+ 'state_update'
23
+ )
24
+ expect(RagbitsApiClient.ChatResponseType.MESSAGE_ID).toBe('message_id')
25
+ expect(RagbitsApiClient.ChatResponseType.CONVERSATION_ID).toBe(
26
+ 'conversation_id'
27
+ )
28
+ })
29
+
30
+ it('should export FeedbackType enum with correct values', () => {
31
+ expect(RagbitsApiClient.FeedbackType).toBeDefined()
32
+ expect(RagbitsApiClient.FeedbackType.LIKE).toBe('like')
33
+ expect(RagbitsApiClient.FeedbackType.DISLIKE).toBe('dislike')
34
+ })
35
+ })
@@ -0,0 +1,28 @@
1
+ import type { ConfigResponse } from '../types'
2
+
3
+ // Shared config response for tests
4
+ export const defaultConfigResponse: ConfigResponse = {
5
+ feedback: {
6
+ like: {
7
+ enabled: true,
8
+ form: {
9
+ title: 'Like Form',
10
+ type: 'object',
11
+ required: ['like_reason'],
12
+ properties: {
13
+ like_reason: {
14
+ title: 'Like Reason',
15
+ description: 'Why do you like this?',
16
+ type: 'string',
17
+ minLength: 1,
18
+ },
19
+ },
20
+ },
21
+ },
22
+ dislike: {
23
+ enabled: true,
24
+ form: null,
25
+ },
26
+ },
27
+ customization: null,
28
+ }