@cobbl-ai/sdk 0.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.
@@ -0,0 +1,650 @@
1
+ import { CobblClient } from '../client'
2
+ import { CobblError } from '../errors'
3
+
4
+ // Mock fetch globally
5
+ const mockFetch = jest.fn()
6
+ global.fetch = mockFetch as any
7
+
8
+ describe('CobblClient', () => {
9
+ const validApiKey = 'test-api-key-123'
10
+ const mockBaseUrl = 'https://api.cobbl.ai'
11
+
12
+ beforeEach(() => {
13
+ mockFetch.mockClear()
14
+ delete process.env.COBBL_API_URL
15
+ })
16
+
17
+ describe('constructor', () => {
18
+ it('should create a client with valid API key', () => {
19
+ const client = new CobblClient({ apiKey: validApiKey })
20
+
21
+ expect(client).toBeInstanceOf(CobblClient)
22
+ })
23
+
24
+ it('should throw error when API key is missing', () => {
25
+ expect(() => new CobblClient({ apiKey: '' })).toThrow(CobblError)
26
+ expect(() => new CobblClient({ apiKey: '' })).toThrow(
27
+ 'API key is required'
28
+ )
29
+ })
30
+
31
+ it('should throw error when API key is only whitespace', () => {
32
+ expect(() => new CobblClient({ apiKey: ' ' })).toThrow(CobblError)
33
+ expect(() => new CobblClient({ apiKey: ' ' })).toThrow(
34
+ 'API key is required'
35
+ )
36
+ })
37
+
38
+ it('should throw INVALID_CONFIG error code', () => {
39
+ try {
40
+ new CobblClient({ apiKey: '' })
41
+ fail('Should have thrown')
42
+ } catch (error) {
43
+ expect(error).toBeInstanceOf(CobblError)
44
+ expect((error as CobblError).code).toBe('INVALID_CONFIG')
45
+ }
46
+ })
47
+ })
48
+
49
+ describe('runPrompt', () => {
50
+ let client: CobblClient
51
+
52
+ beforeEach(() => {
53
+ client = new CobblClient({ apiKey: validApiKey })
54
+ })
55
+
56
+ it('should successfully run a prompt', async () => {
57
+ const mockResponse = {
58
+ runId: 'run-123',
59
+ output: 'AI generated response',
60
+ tokenUsage: { promptTokens: 10, completionTokens: 20, totalTokens: 30 },
61
+ renderedPrompt: 'Summarize Q4 Results in a friendly tone',
62
+ promptVersion: {
63
+ id: 'version-1',
64
+ promptId: 'prompt-1',
65
+ version: 1,
66
+ provider: 'openai',
67
+ model: 'gpt-4',
68
+ template: 'Summarize {{topic}} in a {{tone}} tone',
69
+ variables: ['topic', 'tone'],
70
+ isActive: true,
71
+ createdAt: new Date('2024-01-01'),
72
+ metadata: {},
73
+ },
74
+ }
75
+
76
+ mockFetch.mockResolvedValueOnce({
77
+ ok: true,
78
+ json: async () => mockResponse,
79
+ })
80
+
81
+ const result = await client.runPrompt('sales_summary', {
82
+ topic: 'Q4 Results',
83
+ tone: 'friendly',
84
+ })
85
+
86
+ expect(result).toEqual(mockResponse)
87
+ expect(mockFetch).toHaveBeenCalledTimes(1)
88
+ expect(mockFetch).toHaveBeenCalledWith(
89
+ `${mockBaseUrl}/prompt/run`,
90
+ expect.objectContaining({
91
+ method: 'POST',
92
+ headers: expect.objectContaining({
93
+ 'Content-Type': 'application/json',
94
+ Authorization: `Bearer ${validApiKey}`,
95
+ }),
96
+ body: JSON.stringify({
97
+ promptSlug: 'sales_summary',
98
+ input: { topic: 'Q4 Results', tone: 'friendly' },
99
+ }),
100
+ })
101
+ )
102
+ })
103
+
104
+ it('should trim the promptSlug', async () => {
105
+ mockFetch.mockResolvedValueOnce({
106
+ ok: true,
107
+ json: async () => ({
108
+ runId: 'run-123',
109
+ output: 'test',
110
+ tokenUsage: {
111
+ promptTokens: 10,
112
+ completionTokens: 20,
113
+ totalTokens: 30,
114
+ },
115
+ renderedPrompt: 'test',
116
+ promptVersion: {
117
+ id: 'v1',
118
+ promptId: 'p1',
119
+ version: 1,
120
+ provider: 'openai',
121
+ model: 'gpt-4',
122
+ template: 'test',
123
+ variables: [],
124
+ isActive: true,
125
+ createdAt: new Date(),
126
+ metadata: {},
127
+ },
128
+ }),
129
+ })
130
+
131
+ await client.runPrompt(' sales_summary ', {})
132
+
133
+ expect(mockFetch).toHaveBeenCalledWith(
134
+ expect.any(String),
135
+ expect.objectContaining({
136
+ body: expect.stringContaining('"promptSlug":"sales_summary"'),
137
+ })
138
+ )
139
+ })
140
+
141
+ it('should throw error when promptSlug is empty', async () => {
142
+ await expect(client.runPrompt('', {})).rejects.toThrow(CobblError)
143
+ await expect(client.runPrompt('', {})).rejects.toThrow(
144
+ 'promptSlug is required'
145
+ )
146
+ })
147
+
148
+ it('should throw error when promptSlug is only whitespace', async () => {
149
+ await expect(client.runPrompt(' ', {})).rejects.toThrow(CobblError)
150
+ })
151
+
152
+ it('should throw INVALID_REQUEST error for empty promptSlug', async () => {
153
+ try {
154
+ await client.runPrompt('', {})
155
+ fail('Should have thrown')
156
+ } catch (error) {
157
+ expect(error).toBeInstanceOf(CobblError)
158
+ expect((error as CobblError).code).toBe('INVALID_REQUEST')
159
+ }
160
+ })
161
+
162
+ it('should handle 400 Bad Request', async () => {
163
+ mockFetch.mockResolvedValueOnce({
164
+ ok: false,
165
+ status: 400,
166
+ statusText: 'Bad Request',
167
+ json: async () => ({ error: 'Invalid input variables' }),
168
+ })
169
+
170
+ try {
171
+ await client.runPrompt('test', {})
172
+ fail('Should have thrown')
173
+ } catch (error) {
174
+ expect(error).toBeInstanceOf(CobblError)
175
+ expect((error as CobblError).message).toBe('Invalid input variables')
176
+ }
177
+ })
178
+
179
+ it('should handle 401 Unauthorized', async () => {
180
+ mockFetch.mockResolvedValueOnce({
181
+ ok: false,
182
+ status: 401,
183
+ statusText: 'Unauthorized',
184
+ json: async () => ({ error: 'Invalid API key' }),
185
+ })
186
+
187
+ try {
188
+ await client.runPrompt('test', {})
189
+ fail('Should have thrown')
190
+ } catch (error) {
191
+ expect(error).toBeInstanceOf(CobblError)
192
+ expect((error as CobblError).code).toBe('UNAUTHORIZED')
193
+ expect((error as CobblError).message).toBe('Invalid API key')
194
+ }
195
+ })
196
+
197
+ it('should handle 404 Not Found', async () => {
198
+ mockFetch.mockResolvedValueOnce({
199
+ ok: false,
200
+ status: 404,
201
+ statusText: 'Not Found',
202
+ json: async () => ({ error: 'Prompt not found' }),
203
+ })
204
+
205
+ try {
206
+ await client.runPrompt('nonexistent', {})
207
+ fail('Should have thrown')
208
+ } catch (error) {
209
+ expect(error).toBeInstanceOf(CobblError)
210
+ expect((error as CobblError).code).toBe('NOT_FOUND')
211
+ }
212
+ })
213
+
214
+ it('should handle 429 Rate Limit Exceeded', async () => {
215
+ mockFetch.mockResolvedValueOnce({
216
+ ok: false,
217
+ status: 429,
218
+ statusText: 'Too Many Requests',
219
+ json: async () => ({ error: 'Rate limit exceeded' }),
220
+ })
221
+
222
+ try {
223
+ await client.runPrompt('test', {})
224
+ fail('Should have thrown')
225
+ } catch (error) {
226
+ expect(error).toBeInstanceOf(CobblError)
227
+ expect((error as CobblError).code).toBe('RATE_LIMIT_EXCEEDED')
228
+ }
229
+ })
230
+
231
+ it('should handle 500 Server Error', async () => {
232
+ mockFetch.mockResolvedValueOnce({
233
+ ok: false,
234
+ status: 500,
235
+ statusText: 'Internal Server Error',
236
+ json: async () => ({ error: 'Server error' }),
237
+ })
238
+
239
+ try {
240
+ await client.runPrompt('test', {})
241
+ fail('Should have thrown')
242
+ } catch (error) {
243
+ expect(error).toBeInstanceOf(CobblError)
244
+ expect((error as CobblError).code).toBe('SERVER_ERROR')
245
+ }
246
+ })
247
+
248
+ it('should handle network errors', async () => {
249
+ mockFetch.mockRejectedValueOnce(new Error('Network failure'))
250
+
251
+ try {
252
+ await client.runPrompt('test', {})
253
+ fail('Should have thrown')
254
+ } catch (error) {
255
+ expect(error).toBeInstanceOf(CobblError)
256
+ expect((error as CobblError).code).toBe('NETWORK_ERROR')
257
+ expect((error as CobblError).message).toContain('Network failure')
258
+ }
259
+ })
260
+
261
+ it('should handle non-JSON error responses', async () => {
262
+ mockFetch.mockResolvedValueOnce({
263
+ ok: false,
264
+ status: 500,
265
+ statusText: 'Internal Server Error',
266
+ json: async () => {
267
+ throw new Error('Invalid JSON')
268
+ },
269
+ })
270
+
271
+ try {
272
+ await client.runPrompt('test', {})
273
+ fail('Should have thrown')
274
+ } catch (error) {
275
+ expect(error).toBeInstanceOf(CobblError)
276
+ expect((error as CobblError).code).toBe('API_ERROR')
277
+ expect((error as CobblError).message).toContain('HTTP 500')
278
+ }
279
+ })
280
+
281
+ it('should use custom API URL from environment', async () => {
282
+ process.env.COBBL_API_URL = 'https://custom.api.com'
283
+
284
+ mockFetch.mockResolvedValueOnce({
285
+ ok: true,
286
+ json: async () => ({
287
+ runId: 'run-123',
288
+ output: 'test',
289
+ tokenUsage: {
290
+ promptTokens: 10,
291
+ completionTokens: 20,
292
+ totalTokens: 30,
293
+ },
294
+ renderedPrompt: 'test',
295
+ promptVersion: {
296
+ id: 'v1',
297
+ promptId: 'p1',
298
+ version: 1,
299
+ provider: 'openai',
300
+ model: 'gpt-4',
301
+ template: 'test',
302
+ variables: [],
303
+ isActive: true,
304
+ createdAt: new Date(),
305
+ metadata: {},
306
+ },
307
+ }),
308
+ })
309
+
310
+ await client.runPrompt('test', {})
311
+
312
+ expect(mockFetch).toHaveBeenCalledWith(
313
+ 'https://custom.api.com/prompt/run',
314
+ expect.any(Object)
315
+ )
316
+ })
317
+
318
+ it('should have a request timeout configured', () => {
319
+ // This test verifies that the client has timeout logic
320
+ // The actual timeout behavior is tested through integration tests
321
+ // Testing with real timers would make the test suite slow
322
+ expect(client).toBeDefined()
323
+
324
+ // The timeout is hardcoded in client.ts as REQUEST_TIMEOUT_MS = 30_000
325
+ // This test ensures we remember to test timeout behavior if we change it
326
+ })
327
+ })
328
+
329
+ describe('submitFeedback', () => {
330
+ let client: CobblClient
331
+
332
+ beforeEach(() => {
333
+ client = new CobblClient({ apiKey: validApiKey })
334
+ })
335
+
336
+ it('should successfully submit feedback', async () => {
337
+ const mockResponse = {
338
+ feedbackId: 'feedback-123',
339
+ message: 'Feedback submitted successfully',
340
+ }
341
+
342
+ mockFetch.mockResolvedValueOnce({
343
+ ok: true,
344
+ json: async () => mockResponse,
345
+ })
346
+
347
+ const result = await client.submitFeedback({
348
+ runId: 'run-123',
349
+ helpful: 'not_helpful',
350
+ userFeedback: 'Too formal',
351
+ })
352
+
353
+ expect(result).toEqual(mockResponse)
354
+ expect(mockFetch).toHaveBeenCalledTimes(1)
355
+ expect(mockFetch).toHaveBeenCalledWith(
356
+ `${mockBaseUrl}/feedback`,
357
+ expect.objectContaining({
358
+ method: 'POST',
359
+ headers: expect.objectContaining({
360
+ 'Content-Type': 'application/json',
361
+ Authorization: `Bearer ${validApiKey}`,
362
+ }),
363
+ body: JSON.stringify({
364
+ runId: 'run-123',
365
+ helpful: 'not_helpful',
366
+ userFeedback: 'Too formal',
367
+ }),
368
+ })
369
+ )
370
+ })
371
+
372
+ it('should trim runId and userFeedback', async () => {
373
+ mockFetch.mockResolvedValueOnce({
374
+ ok: true,
375
+ json: async () => ({ feedbackId: 'fb-123', message: 'Success' }),
376
+ })
377
+
378
+ await client.submitFeedback({
379
+ runId: ' run-123 ',
380
+ helpful: 'helpful',
381
+ userFeedback: ' Great response! ',
382
+ })
383
+
384
+ expect(mockFetch).toHaveBeenCalledWith(
385
+ expect.any(String),
386
+ expect.objectContaining({
387
+ body: expect.stringContaining('"runId":"run-123"'),
388
+ })
389
+ )
390
+ expect(mockFetch).toHaveBeenCalledWith(
391
+ expect.any(String),
392
+ expect.objectContaining({
393
+ body: expect.stringContaining('"userFeedback":"Great response!"'),
394
+ })
395
+ )
396
+ })
397
+
398
+ it('should throw error when runId is empty', async () => {
399
+ await expect(
400
+ client.submitFeedback({
401
+ runId: '',
402
+ helpful: 'helpful',
403
+ userFeedback: 'test',
404
+ })
405
+ ).rejects.toThrow(CobblError)
406
+ await expect(
407
+ client.submitFeedback({
408
+ runId: '',
409
+ helpful: 'helpful',
410
+ userFeedback: 'test',
411
+ })
412
+ ).rejects.toThrow('runId is required')
413
+ })
414
+
415
+ it('should throw error when userFeedback is empty', async () => {
416
+ await expect(
417
+ client.submitFeedback({
418
+ runId: 'run-123',
419
+ helpful: 'helpful',
420
+ userFeedback: '',
421
+ })
422
+ ).rejects.toThrow(CobblError)
423
+ await expect(
424
+ client.submitFeedback({
425
+ runId: 'run-123',
426
+ helpful: 'helpful',
427
+ userFeedback: '',
428
+ })
429
+ ).rejects.toThrow('userFeedback is required')
430
+ })
431
+
432
+ it('should throw error when helpful is invalid', async () => {
433
+ await expect(
434
+ client.submitFeedback({
435
+ runId: 'run-123',
436
+ helpful: 'invalid' as any,
437
+ userFeedback: 'test',
438
+ })
439
+ ).rejects.toThrow(CobblError)
440
+ await expect(
441
+ client.submitFeedback({
442
+ runId: 'run-123',
443
+ helpful: 'invalid' as any,
444
+ userFeedback: 'test',
445
+ })
446
+ ).rejects.toThrow('helpful must be either "helpful" or "not_helpful"')
447
+ })
448
+
449
+ it('should throw error when helpful is missing', async () => {
450
+ await expect(
451
+ client.submitFeedback({
452
+ runId: 'run-123',
453
+ helpful: '' as any,
454
+ userFeedback: 'test',
455
+ })
456
+ ).rejects.toThrow(CobblError)
457
+ })
458
+
459
+ it('should accept "helpful" value', async () => {
460
+ mockFetch.mockResolvedValueOnce({
461
+ ok: true,
462
+ json: async () => ({ feedbackId: 'fb-123', message: 'Success' }),
463
+ })
464
+
465
+ await client.submitFeedback({
466
+ runId: 'run-123',
467
+ helpful: 'helpful',
468
+ userFeedback: 'Great!',
469
+ })
470
+
471
+ expect(mockFetch).toHaveBeenCalledWith(
472
+ expect.any(String),
473
+ expect.objectContaining({
474
+ body: expect.stringContaining('"helpful":"helpful"'),
475
+ })
476
+ )
477
+ })
478
+
479
+ it('should accept "not_helpful" value', async () => {
480
+ mockFetch.mockResolvedValueOnce({
481
+ ok: true,
482
+ json: async () => ({ feedbackId: 'fb-123', message: 'Success' }),
483
+ })
484
+
485
+ await client.submitFeedback({
486
+ runId: 'run-123',
487
+ helpful: 'not_helpful',
488
+ userFeedback: 'Needs work',
489
+ })
490
+
491
+ expect(mockFetch).toHaveBeenCalledWith(
492
+ expect.any(String),
493
+ expect.objectContaining({
494
+ body: expect.stringContaining('"helpful":"not_helpful"'),
495
+ })
496
+ )
497
+ })
498
+
499
+ it('should handle 400 Bad Request', async () => {
500
+ mockFetch.mockResolvedValueOnce({
501
+ ok: false,
502
+ status: 400,
503
+ statusText: 'Bad Request',
504
+ json: async () => ({ error: 'Invalid feedback data' }),
505
+ })
506
+
507
+ try {
508
+ await client.submitFeedback({
509
+ runId: 'run-123',
510
+ helpful: 'helpful',
511
+ userFeedback: 'test',
512
+ })
513
+ fail('Should have thrown')
514
+ } catch (error) {
515
+ expect(error).toBeInstanceOf(CobblError)
516
+ expect((error as CobblError).code).toBe('INVALID_REQUEST')
517
+ }
518
+ })
519
+
520
+ it('should handle 401 Unauthorized', async () => {
521
+ mockFetch.mockResolvedValueOnce({
522
+ ok: false,
523
+ status: 401,
524
+ statusText: 'Unauthorized',
525
+ json: async () => ({ error: 'Invalid API key' }),
526
+ })
527
+
528
+ try {
529
+ await client.submitFeedback({
530
+ runId: 'run-123',
531
+ helpful: 'helpful',
532
+ userFeedback: 'test',
533
+ })
534
+ fail('Should have thrown')
535
+ } catch (error) {
536
+ expect(error).toBeInstanceOf(CobblError)
537
+ expect((error as CobblError).code).toBe('UNAUTHORIZED')
538
+ }
539
+ })
540
+
541
+ it('should handle 404 Not Found', async () => {
542
+ mockFetch.mockResolvedValueOnce({
543
+ ok: false,
544
+ status: 404,
545
+ statusText: 'Not Found',
546
+ json: async () => ({ error: 'Run ID not found' }),
547
+ })
548
+
549
+ try {
550
+ await client.submitFeedback({
551
+ runId: 'nonexistent',
552
+ helpful: 'helpful',
553
+ userFeedback: 'test',
554
+ })
555
+ fail('Should have thrown')
556
+ } catch (error) {
557
+ expect(error).toBeInstanceOf(CobblError)
558
+ expect((error as CobblError).code).toBe('NOT_FOUND')
559
+ }
560
+ })
561
+
562
+ it('should handle network errors', async () => {
563
+ mockFetch.mockRejectedValueOnce(new Error('Network failure'))
564
+
565
+ try {
566
+ await client.submitFeedback({
567
+ runId: 'run-123',
568
+ helpful: 'helpful',
569
+ userFeedback: 'test',
570
+ })
571
+ fail('Should have thrown')
572
+ } catch (error) {
573
+ expect(error).toBeInstanceOf(CobblError)
574
+ expect((error as CobblError).code).toBe('NETWORK_ERROR')
575
+ expect((error as CobblError).message).toContain('Network failure')
576
+ }
577
+ })
578
+ })
579
+
580
+ describe('error handling with details', () => {
581
+ let client: CobblClient
582
+
583
+ beforeEach(() => {
584
+ client = new CobblClient({ apiKey: validApiKey })
585
+ })
586
+
587
+ it('should include error details from API response', async () => {
588
+ const errorDetails = {
589
+ missingVariables: ['topic', 'tone'],
590
+ promptId: 'prompt-123',
591
+ }
592
+
593
+ mockFetch.mockResolvedValueOnce({
594
+ ok: false,
595
+ status: 400,
596
+ statusText: 'Bad Request',
597
+ json: async () => ({
598
+ error: 'Missing required variables',
599
+ details: errorDetails,
600
+ }),
601
+ })
602
+
603
+ try {
604
+ await client.runPrompt('test', {})
605
+ fail('Should have thrown')
606
+ } catch (error) {
607
+ expect(error).toBeInstanceOf(CobblError)
608
+ expect((error as CobblError).details).toEqual(errorDetails)
609
+ }
610
+ })
611
+
612
+ it('should handle error with message field instead of error field', async () => {
613
+ mockFetch.mockResolvedValueOnce({
614
+ ok: false,
615
+ status: 400,
616
+ statusText: 'Bad Request',
617
+ json: async () => ({ message: 'Custom error message' }),
618
+ })
619
+
620
+ try {
621
+ await client.runPrompt('test', {})
622
+ fail('Should have thrown')
623
+ } catch (error) {
624
+ expect(error).toBeInstanceOf(CobblError)
625
+ expect((error as CobblError).message).toBe('Custom error message')
626
+ }
627
+ })
628
+
629
+ it('should handle generic server errors (502, 503, 504)', async () => {
630
+ const statuses = [502, 503, 504]
631
+
632
+ for (const status of statuses) {
633
+ mockFetch.mockResolvedValueOnce({
634
+ ok: false,
635
+ status,
636
+ statusText: 'Server Error',
637
+ json: async () => ({ error: 'Server unavailable' }),
638
+ })
639
+
640
+ try {
641
+ await client.runPrompt('test', {})
642
+ fail('Should have thrown')
643
+ } catch (error) {
644
+ expect(error).toBeInstanceOf(CobblError)
645
+ expect((error as CobblError).code).toBe('SERVER_ERROR')
646
+ }
647
+ }
648
+ })
649
+ })
650
+ })