infrahub-sdk 0.0.8 → 0.0.9

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,1107 @@
1
+ import { InfrahubClient, InfrahubClientOptions } from './index';
2
+ import { beforeEach, describe, expect, it, jest } from '@jest/globals';
3
+
4
+ // We need to mock node-fetch since the SDK uses it internally
5
+ jest.mock('node-fetch', () => {
6
+ const originalModule = jest.requireActual('node-fetch') as any;
7
+ return {
8
+ __esModule: true,
9
+ ...originalModule,
10
+ default: jest.fn()
11
+ };
12
+ });
13
+
14
+ import fetch from 'node-fetch';
15
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
16
+ const mockedFetch = fetch as any;
17
+
18
+ // The SDK now extracts URL, method, headers, body from Request objects before calling fetch
19
+ // So the mock receives (url: string, options: {method, headers, body, ...})
20
+
21
+ // Helper to extract URL - now receives string directly
22
+ function extractUrl(input: any): string {
23
+ return String(input);
24
+ }
25
+
26
+ // Helper to extract headers from fetch options
27
+ function extractHeaders(options: any): Headers | undefined {
28
+ if (options && options.headers) {
29
+ // headers could be Headers object or plain object
30
+ if (options.headers instanceof Headers) {
31
+ return options.headers;
32
+ }
33
+ return new Headers(options.headers);
34
+ }
35
+ return undefined;
36
+ }
37
+
38
+ // Helper to extract method from fetch options
39
+ function extractMethod(options: any): string | undefined {
40
+ return options?.method;
41
+ }
42
+
43
+ // Helper to extract body from fetch options
44
+ function extractBody(options: any): any | undefined {
45
+ if (options?.body) {
46
+ try {
47
+ // body could be string, Buffer, or ReadableStream
48
+ if (typeof options.body === 'string') {
49
+ return JSON.parse(options.body);
50
+ }
51
+ // Handle Buffer
52
+ if (Buffer.isBuffer(options.body)) {
53
+ return JSON.parse(options.body.toString());
54
+ }
55
+ // Handle ReadableStream - try to read it as a string
56
+ if (options.body.toString && typeof options.body.toString === 'function') {
57
+ const str = options.body.toString();
58
+ if (str !== '[object ReadableStream]' && str !== '[object Object]') {
59
+ return JSON.parse(str);
60
+ }
61
+ }
62
+ } catch {
63
+ return undefined;
64
+ }
65
+ }
66
+ return undefined;
67
+ }
68
+
69
+ describe('REST Client', () => {
70
+ const baseURL = 'https://example.com';
71
+ const token = 'test-token';
72
+ let client: InfrahubClient;
73
+
74
+ beforeEach(() => {
75
+ jest.clearAllMocks();
76
+ const options: InfrahubClientOptions = {
77
+ address: baseURL,
78
+ token: token
79
+ };
80
+ client = new InfrahubClient(options);
81
+ });
82
+
83
+ describe('REST Client Initialization', () => {
84
+ it('should initialize rest client', () => {
85
+ expect(client.rest).toBeDefined();
86
+ });
87
+
88
+ it('should have GET method available', () => {
89
+ expect(client.rest.GET).toBeDefined();
90
+ expect(typeof client.rest.GET).toBe('function');
91
+ });
92
+
93
+ it('should have POST method available', () => {
94
+ expect(client.rest.POST).toBeDefined();
95
+ expect(typeof client.rest.POST).toBe('function');
96
+ });
97
+
98
+ it('should have middleware use method', () => {
99
+ expect(client.rest.use).toBeDefined();
100
+ expect(typeof client.rest.use).toBe('function');
101
+ });
102
+
103
+ it('should expose rest client publicly', () => {
104
+ expect(client.rest).toBeDefined();
105
+ // Verify the rest client has the expected structure from openapi-fetch
106
+ expect(typeof client.rest.GET).toBe('function');
107
+ expect(typeof client.rest.POST).toBe('function');
108
+ expect(typeof client.rest.PUT).toBe('function');
109
+ expect(typeof client.rest.DELETE).toBe('function');
110
+ expect(typeof client.rest.PATCH).toBe('function');
111
+ });
112
+ });
113
+
114
+ describe('REST Client Middleware', () => {
115
+ it('should be able to add custom middleware', () => {
116
+ const middlewareSpy = jest.fn();
117
+
118
+ // Add custom middleware - this should not throw
119
+ expect(() => {
120
+ client.rest.use({
121
+ onRequest: (req) => {
122
+ middlewareSpy(req);
123
+ return req;
124
+ }
125
+ });
126
+ }).not.toThrow();
127
+ });
128
+
129
+ it('should include X-INFRAHUB-KEY header in requests', async () => {
130
+ let capturedHeaders: Headers | undefined;
131
+
132
+ mockedFetch.mockImplementation(async (_url: any, options: any) => {
133
+ capturedHeaders = extractHeaders(options);
134
+ return {
135
+ ok: true,
136
+ status: 200,
137
+ headers: new Headers({ 'Content-Type': 'application/json' }),
138
+ json: async () => ({ version: '1.0.0' }),
139
+ text: async () => JSON.stringify({ version: '1.0.0' })
140
+ };
141
+ });
142
+
143
+ await client.rest.GET('/api/info');
144
+
145
+ expect(mockedFetch).toHaveBeenCalled();
146
+ expect(capturedHeaders?.get('X-INFRAHUB-KEY')).toBe(token);
147
+ expect(capturedHeaders?.get('content-type')).toBe('application/json');
148
+ });
149
+
150
+ it('should update auth header when token changes via setAuthToken', async () => {
151
+ const newToken = 'new-test-token';
152
+ let capturedHeaders: Headers | undefined;
153
+
154
+ mockedFetch.mockImplementation(async (_url: any, options: any) => {
155
+ capturedHeaders = extractHeaders(options);
156
+ return {
157
+ ok: true,
158
+ status: 200,
159
+ headers: new Headers({ 'Content-Type': 'application/json' }),
160
+ json: async () => ({}),
161
+ text: async () => '{}'
162
+ };
163
+ });
164
+
165
+ // Update the token
166
+ client.setAuthToken(newToken);
167
+
168
+ await client.rest.GET('/api/info');
169
+
170
+ expect(capturedHeaders?.get('X-INFRAHUB-KEY')).toBe(newToken);
171
+ });
172
+ });
173
+
174
+ describe('REST API Endpoints - GET Requests', () => {
175
+ beforeEach(() => {
176
+ mockedFetch.mockReset();
177
+ });
178
+
179
+ it('should call GET /api/info endpoint', async () => {
180
+ const mockData = {
181
+ version: '1.0.0',
182
+ deployment_id: 'test-deployment'
183
+ };
184
+
185
+ mockedFetch.mockImplementation(async () => ({
186
+ ok: true,
187
+ status: 200,
188
+ headers: new Headers({ 'Content-Type': 'application/json' }),
189
+ json: async () => mockData,
190
+ text: async () => JSON.stringify(mockData)
191
+ }));
192
+
193
+ const result = await client.rest.GET('/api/info');
194
+
195
+ expect(result.data).toEqual(mockData);
196
+ expect(result.error).toBeUndefined();
197
+ });
198
+
199
+ it('should call GET /api/config endpoint', async () => {
200
+ const mockConfig = {
201
+ main: {
202
+ default_branch: 'main'
203
+ }
204
+ };
205
+
206
+ mockedFetch.mockImplementation(async () => ({
207
+ ok: true,
208
+ status: 200,
209
+ headers: new Headers({ 'Content-Type': 'application/json' }),
210
+ json: async () => mockConfig,
211
+ text: async () => JSON.stringify(mockConfig)
212
+ }));
213
+
214
+ const result = await client.rest.GET('/api/config');
215
+
216
+ expect(result.data).toEqual(mockConfig);
217
+ expect(result.error).toBeUndefined();
218
+ });
219
+
220
+ it('should call GET /api/schema endpoint', async () => {
221
+ const mockSchema = {
222
+ nodes: [],
223
+ generics: [],
224
+ version: '1.0'
225
+ };
226
+
227
+ mockedFetch.mockImplementation(async () => ({
228
+ ok: true,
229
+ status: 200,
230
+ headers: new Headers({ 'Content-Type': 'application/json' }),
231
+ json: async () => mockSchema,
232
+ text: async () => JSON.stringify(mockSchema)
233
+ }));
234
+
235
+ const result = await client.rest.GET('/api/schema');
236
+
237
+ expect(result.data).toEqual(mockSchema);
238
+ expect(result.error).toBeUndefined();
239
+ });
240
+
241
+ it('should call GET /api/schema/summary endpoint', async () => {
242
+ const mockSummary = {
243
+ nodes: ['Node1', 'Node2']
244
+ };
245
+
246
+ mockedFetch.mockImplementation(async () => ({
247
+ ok: true,
248
+ status: 200,
249
+ headers: new Headers({ 'Content-Type': 'application/json' }),
250
+ json: async () => mockSummary,
251
+ text: async () => JSON.stringify(mockSummary)
252
+ }));
253
+
254
+ const result = await client.rest.GET('/api/schema/summary');
255
+
256
+ expect(result.data).toEqual(mockSummary);
257
+ expect(result.error).toBeUndefined();
258
+ });
259
+
260
+ it('should call GET /api/schema/{schema_kind} with path parameter', async () => {
261
+ const mockSchemaKind = {
262
+ name: 'TestNode',
263
+ namespace: 'Test'
264
+ };
265
+
266
+ let capturedUrl: string | undefined;
267
+ mockedFetch.mockImplementation(async (request: any) => {
268
+ capturedUrl = extractUrl(request);
269
+ return {
270
+ ok: true,
271
+ status: 200,
272
+ headers: new Headers({ 'Content-Type': 'application/json' }),
273
+ json: async () => mockSchemaKind,
274
+ text: async () => JSON.stringify(mockSchemaKind)
275
+ };
276
+ });
277
+
278
+ const result = await client.rest.GET('/api/schema/{schema_kind}', {
279
+ params: {
280
+ path: { schema_kind: 'TestNode' }
281
+ }
282
+ });
283
+
284
+ expect(capturedUrl).toContain('/api/schema/TestNode');
285
+ expect(result.data).toEqual(mockSchemaKind);
286
+ });
287
+
288
+ it('should call GET /api/menu endpoint', async () => {
289
+ const mockMenu = {
290
+ items: [{ label: 'Item 1' }]
291
+ };
292
+
293
+ mockedFetch.mockImplementation(async () => ({
294
+ ok: true,
295
+ status: 200,
296
+ headers: new Headers({ 'Content-Type': 'application/json' }),
297
+ json: async () => mockMenu,
298
+ text: async () => JSON.stringify(mockMenu)
299
+ }));
300
+
301
+ const result = await client.rest.GET('/api/menu');
302
+
303
+ expect(result.data).toEqual(mockMenu);
304
+ expect(result.error).toBeUndefined();
305
+ });
306
+
307
+ it('should call GET /api/artifact/{artifact_id} with path parameter', async () => {
308
+ const mockArtifact = {
309
+ id: 'artifact-123',
310
+ content: 'test content'
311
+ };
312
+
313
+ let capturedUrl: string | undefined;
314
+ mockedFetch.mockImplementation(async (request: any) => {
315
+ capturedUrl = extractUrl(request);
316
+ return {
317
+ ok: true,
318
+ status: 200,
319
+ headers: new Headers({ 'Content-Type': 'application/json' }),
320
+ json: async () => mockArtifact,
321
+ text: async () => JSON.stringify(mockArtifact)
322
+ };
323
+ });
324
+
325
+ const result = await client.rest.GET('/api/artifact/{artifact_id}', {
326
+ params: {
327
+ path: { artifact_id: 'artifact-123' }
328
+ }
329
+ });
330
+
331
+ expect(capturedUrl).toContain('/api/artifact/artifact-123');
332
+ expect(result.data).toEqual(mockArtifact);
333
+ });
334
+
335
+ it('should call GET /api/storage/object/{identifier} with path parameter', async () => {
336
+ const mockContent = 'file content';
337
+
338
+ let capturedUrl: string | undefined;
339
+ mockedFetch.mockImplementation(async (request: any) => {
340
+ capturedUrl = extractUrl(request);
341
+ return {
342
+ ok: true,
343
+ status: 200,
344
+ headers: new Headers({ 'Content-Type': 'text/plain' }),
345
+ json: async () => mockContent,
346
+ text: async () => mockContent
347
+ };
348
+ });
349
+
350
+ await client.rest.GET('/api/storage/object/{identifier}', {
351
+ params: {
352
+ path: { identifier: 'file-123' }
353
+ }
354
+ });
355
+
356
+ expect(capturedUrl).toContain('/api/storage/object/file-123');
357
+ });
358
+
359
+ it('should call GET /api/transform/python/{transform_id} with path parameter', async () => {
360
+ const mockTransform = {
361
+ result: 'transformed data'
362
+ };
363
+
364
+ let capturedUrl: string | undefined;
365
+ mockedFetch.mockImplementation(async (request: any) => {
366
+ capturedUrl = extractUrl(request);
367
+ return {
368
+ ok: true,
369
+ status: 200,
370
+ headers: new Headers({ 'Content-Type': 'application/json' }),
371
+ json: async () => mockTransform,
372
+ text: async () => JSON.stringify(mockTransform)
373
+ };
374
+ });
375
+
376
+ const result = await client.rest.GET('/api/transform/python/{transform_id}', {
377
+ params: {
378
+ path: { transform_id: 'transform-123' }
379
+ }
380
+ });
381
+
382
+ expect(capturedUrl).toContain('/api/transform/python/transform-123');
383
+ expect(result.data).toEqual(mockTransform);
384
+ });
385
+
386
+ it('should call GET /api/transform/jinja2/{transform_id} with path parameter', async () => {
387
+ const mockTransform = {
388
+ result: 'jinja2 transformed data'
389
+ };
390
+
391
+ let capturedUrl: string | undefined;
392
+ mockedFetch.mockImplementation(async (request: any) => {
393
+ capturedUrl = extractUrl(request);
394
+ return {
395
+ ok: true,
396
+ status: 200,
397
+ headers: new Headers({ 'Content-Type': 'application/json' }),
398
+ json: async () => mockTransform,
399
+ text: async () => JSON.stringify(mockTransform)
400
+ };
401
+ });
402
+
403
+ const result = await client.rest.GET('/api/transform/jinja2/{transform_id}', {
404
+ params: {
405
+ path: { transform_id: 'transform-456' }
406
+ }
407
+ });
408
+
409
+ expect(capturedUrl).toContain('/api/transform/jinja2/transform-456');
410
+ expect(result.data).toEqual(mockTransform);
411
+ });
412
+
413
+ it('should call GET /api/query/{query_id} with path parameter', async () => {
414
+ const mockQueryResult = {
415
+ data: { test: 'value' }
416
+ };
417
+
418
+ let capturedUrl: string | undefined;
419
+ mockedFetch.mockImplementation(async (request: any) => {
420
+ capturedUrl = extractUrl(request);
421
+ return {
422
+ ok: true,
423
+ status: 200,
424
+ headers: new Headers({ 'Content-Type': 'application/json' }),
425
+ json: async () => mockQueryResult,
426
+ text: async () => JSON.stringify(mockQueryResult)
427
+ };
428
+ });
429
+
430
+ const result = await client.rest.GET('/api/query/{query_id}', {
431
+ params: {
432
+ path: { query_id: 'query-789' }
433
+ }
434
+ });
435
+
436
+ expect(capturedUrl).toContain('/api/query/query-789');
437
+ expect(result.data).toEqual(mockQueryResult);
438
+ });
439
+
440
+ it('should call GET /api/diff/files endpoint', async () => {
441
+ const mockDiff = {
442
+ files: ['file1.txt', 'file2.txt']
443
+ };
444
+
445
+ mockedFetch.mockImplementation(async () => ({
446
+ ok: true,
447
+ status: 200,
448
+ headers: new Headers({ 'Content-Type': 'application/json' }),
449
+ json: async () => mockDiff,
450
+ text: async () => JSON.stringify(mockDiff)
451
+ }));
452
+
453
+ const result = await client.rest.GET('/api/diff/files');
454
+
455
+ expect(result.data).toEqual(mockDiff);
456
+ });
457
+
458
+ it('should call GET /api/diff/artifacts endpoint', async () => {
459
+ const mockDiff = {
460
+ artifacts: ['artifact1', 'artifact2']
461
+ };
462
+
463
+ mockedFetch.mockImplementation(async () => ({
464
+ ok: true,
465
+ status: 200,
466
+ headers: new Headers({ 'Content-Type': 'application/json' }),
467
+ json: async () => mockDiff,
468
+ text: async () => JSON.stringify(mockDiff)
469
+ }));
470
+
471
+ const result = await client.rest.GET('/api/diff/artifacts');
472
+
473
+ expect(result.data).toEqual(mockDiff);
474
+ });
475
+
476
+ it('should call GET /api/file/{repository_id}/{file_path} with path parameters', async () => {
477
+ const mockFileContent = 'file content here';
478
+
479
+ let capturedUrl: string | undefined;
480
+ mockedFetch.mockImplementation(async (request: any) => {
481
+ capturedUrl = extractUrl(request);
482
+ return {
483
+ ok: true,
484
+ status: 200,
485
+ headers: new Headers({ 'Content-Type': 'text/plain' }),
486
+ json: async () => mockFileContent,
487
+ text: async () => mockFileContent
488
+ };
489
+ });
490
+
491
+ await client.rest.GET('/api/file/{repository_id}/{file_path}', {
492
+ params: {
493
+ path: { repository_id: 'repo-123', file_path: 'src/index.ts' }
494
+ }
495
+ });
496
+
497
+ expect(capturedUrl).toContain('/api/file/repo-123/src');
498
+ });
499
+ });
500
+
501
+ describe('REST API Endpoints - POST Requests', () => {
502
+ beforeEach(() => {
503
+ mockedFetch.mockReset();
504
+ });
505
+
506
+ it('should call POST /api/auth/login endpoint', async () => {
507
+ const mockLoginResponse = {
508
+ access_token: 'jwt-token',
509
+ token_type: 'bearer'
510
+ };
511
+
512
+ let capturedMethod: string | undefined;
513
+ mockedFetch.mockImplementation(async (_url: any, options: any) => {
514
+ capturedMethod = extractMethod(options);
515
+ return {
516
+ ok: true,
517
+ status: 200,
518
+ headers: new Headers({ 'Content-Type': 'application/json' }),
519
+ json: async () => mockLoginResponse,
520
+ text: async () => JSON.stringify(mockLoginResponse)
521
+ };
522
+ });
523
+
524
+ const result = await client.rest.POST('/api/auth/login', {
525
+ body: {
526
+ username: 'testuser',
527
+ password: 'testpass'
528
+ }
529
+ });
530
+
531
+ expect(capturedMethod).toBe('POST');
532
+ expect(result.data).toEqual(mockLoginResponse);
533
+ });
534
+
535
+ it('should call POST /api/auth/refresh endpoint', async () => {
536
+ const mockRefreshResponse = {
537
+ access_token: 'new-jwt-token',
538
+ token_type: 'bearer'
539
+ };
540
+
541
+ let capturedMethod: string | undefined;
542
+ mockedFetch.mockImplementation(async (_url: any, options: any) => {
543
+ capturedMethod = extractMethod(options);
544
+ return {
545
+ ok: true,
546
+ status: 200,
547
+ headers: new Headers({ 'Content-Type': 'application/json' }),
548
+ json: async () => mockRefreshResponse,
549
+ text: async () => JSON.stringify(mockRefreshResponse)
550
+ };
551
+ });
552
+
553
+ const result = await client.rest.POST('/api/auth/refresh');
554
+
555
+ expect(capturedMethod).toBe('POST');
556
+ expect(result.data).toEqual(mockRefreshResponse);
557
+ });
558
+
559
+ it('should call POST /api/auth/logout endpoint', async () => {
560
+ let capturedMethod: string | undefined;
561
+ mockedFetch.mockImplementation(async (_url: any, options: any) => {
562
+ capturedMethod = extractMethod(options);
563
+ return {
564
+ ok: true,
565
+ status: 200,
566
+ headers: new Headers({ 'Content-Type': 'application/json' }),
567
+ json: async () => ({ success: true }),
568
+ text: async () => JSON.stringify({ success: true })
569
+ };
570
+ });
571
+
572
+ const result = await client.rest.POST('/api/auth/logout');
573
+
574
+ expect(capturedMethod).toBe('POST');
575
+ expect(result.error).toBeUndefined();
576
+ });
577
+
578
+ it('should call POST /api/artifact/generate/{artifact_definition_id} with path parameter', async () => {
579
+ const mockGenerateResponse = {
580
+ artifact_id: 'generated-artifact-123'
581
+ };
582
+
583
+ let capturedUrl: string | undefined;
584
+ let capturedMethod: string | undefined;
585
+ mockedFetch.mockImplementation(async (url: any, options: any) => {
586
+ capturedUrl = extractUrl(url);
587
+ capturedMethod = extractMethod(options);
588
+ return {
589
+ ok: true,
590
+ status: 200,
591
+ headers: new Headers({ 'Content-Type': 'application/json' }),
592
+ json: async () => mockGenerateResponse,
593
+ text: async () => JSON.stringify(mockGenerateResponse)
594
+ };
595
+ });
596
+
597
+ const result = await client.rest.POST('/api/artifact/generate/{artifact_definition_id}', {
598
+ params: {
599
+ path: { artifact_definition_id: 'def-123' }
600
+ }
601
+ });
602
+
603
+ expect(capturedUrl).toContain('/api/artifact/generate/def-123');
604
+ expect(capturedMethod).toBe('POST');
605
+ expect(result.data).toEqual(mockGenerateResponse);
606
+ });
607
+
608
+ it('should call POST /api/schema/load endpoint with body', async () => {
609
+ const mockLoadResponse = {
610
+ success: true,
611
+ schema_hash: 'abc123'
612
+ };
613
+
614
+ let capturedMethod: string | undefined;
615
+ mockedFetch.mockImplementation(async (_url: any, options: any) => {
616
+ capturedMethod = extractMethod(options);
617
+ return {
618
+ ok: true,
619
+ status: 200,
620
+ headers: new Headers({ 'Content-Type': 'application/json' }),
621
+ json: async () => mockLoadResponse,
622
+ text: async () => JSON.stringify(mockLoadResponse)
623
+ };
624
+ });
625
+
626
+ const schemaToLoad = {
627
+ schemas: [{
628
+ version: '1.0',
629
+ extensions: {}
630
+ }]
631
+ };
632
+
633
+ const result = await client.rest.POST('/api/schema/load', {
634
+ body: schemaToLoad
635
+ } as any);
636
+
637
+ expect(capturedMethod).toBe('POST');
638
+ expect(result.data).toEqual(mockLoadResponse);
639
+ });
640
+
641
+ it('should call POST /api/schema/check endpoint', async () => {
642
+ const mockCheckResponse = {
643
+ valid: true,
644
+ errors: []
645
+ };
646
+
647
+ mockedFetch.mockImplementation(async () => ({
648
+ ok: true,
649
+ status: 200,
650
+ headers: new Headers({ 'Content-Type': 'application/json' }),
651
+ json: async () => mockCheckResponse,
652
+ text: async () => JSON.stringify(mockCheckResponse)
653
+ }));
654
+
655
+ const schemaToCheck = {
656
+ schemas: [{
657
+ version: '1.0',
658
+ extensions: {}
659
+ }]
660
+ };
661
+
662
+ const result = await client.rest.POST('/api/schema/check', {
663
+ body: schemaToCheck
664
+ } as any);
665
+
666
+ expect(result.data).toEqual(mockCheckResponse);
667
+ });
668
+
669
+ it('should call POST /api/query/{query_id} with path parameter and body', async () => {
670
+ const mockQueryResponse = {
671
+ data: { results: [] }
672
+ };
673
+
674
+ let capturedUrl: string | undefined;
675
+ let capturedMethod: string | undefined;
676
+ mockedFetch.mockImplementation(async (url: any, options: any) => {
677
+ capturedUrl = extractUrl(url);
678
+ capturedMethod = extractMethod(options);
679
+ return {
680
+ ok: true,
681
+ status: 200,
682
+ headers: new Headers({ 'Content-Type': 'application/json' }),
683
+ json: async () => mockQueryResponse,
684
+ text: async () => JSON.stringify(mockQueryResponse)
685
+ };
686
+ });
687
+
688
+ const queryVariables = { variables: { 'limit': '10' } };
689
+
690
+ const result = await client.rest.POST('/api/query/{query_id}', {
691
+ params: {
692
+ path: { query_id: 'my-query' }
693
+ },
694
+ body: queryVariables
695
+ });
696
+
697
+ expect(capturedUrl).toContain('/api/query/my-query');
698
+ expect(capturedMethod).toBe('POST');
699
+ expect(result.data).toEqual(mockQueryResponse);
700
+ });
701
+
702
+ it('should call POST /api/storage/upload/content endpoint', async () => {
703
+ const mockUploadResponse = {
704
+ identifier: 'uploaded-123',
705
+ checksum: 'sha256:abc'
706
+ };
707
+
708
+ mockedFetch.mockImplementation(async () => ({
709
+ ok: true,
710
+ status: 200,
711
+ headers: new Headers({ 'Content-Type': 'application/json' }),
712
+ json: async () => mockUploadResponse,
713
+ text: async () => JSON.stringify(mockUploadResponse)
714
+ }));
715
+
716
+ const result = await client.rest.POST('/api/storage/upload/content', {
717
+ body: { content: 'test content' }
718
+ } as any);
719
+
720
+ expect(result.data).toEqual(mockUploadResponse);
721
+ });
722
+ });
723
+
724
+ describe('REST API Error Handling', () => {
725
+ beforeEach(() => {
726
+ mockedFetch.mockReset();
727
+ });
728
+
729
+ it('should handle 401 Unauthorized error', async () => {
730
+ const mockError = {
731
+ detail: 'Not authenticated'
732
+ };
733
+
734
+ mockedFetch.mockImplementation(async () => ({
735
+ ok: false,
736
+ status: 401,
737
+ statusText: 'Unauthorized',
738
+ headers: new Headers({ 'Content-Type': 'application/json' }),
739
+ json: async () => mockError,
740
+ text: async () => JSON.stringify(mockError)
741
+ }));
742
+
743
+ const result = await client.rest.GET('/api/info');
744
+
745
+ expect(result.error).toBeDefined();
746
+ expect(result.response?.status).toBe(401);
747
+ });
748
+
749
+ it('should handle 403 Forbidden error', async () => {
750
+ const mockError = {
751
+ detail: 'Permission denied'
752
+ };
753
+
754
+ mockedFetch.mockImplementation(async () => ({
755
+ ok: false,
756
+ status: 403,
757
+ statusText: 'Forbidden',
758
+ headers: new Headers({ 'Content-Type': 'application/json' }),
759
+ json: async () => mockError,
760
+ text: async () => JSON.stringify(mockError)
761
+ }));
762
+
763
+ const result = await client.rest.GET('/api/schema');
764
+
765
+ expect(result.error).toBeDefined();
766
+ expect(result.response?.status).toBe(403);
767
+ });
768
+
769
+ it('should handle 404 Not Found error', async () => {
770
+ const mockError = {
771
+ detail: 'Resource not found'
772
+ };
773
+
774
+ mockedFetch.mockImplementation(async () => ({
775
+ ok: false,
776
+ status: 404,
777
+ statusText: 'Not Found',
778
+ headers: new Headers({ 'Content-Type': 'application/json' }),
779
+ json: async () => mockError,
780
+ text: async () => JSON.stringify(mockError)
781
+ }));
782
+
783
+ const result = await client.rest.GET('/api/artifact/{artifact_id}', {
784
+ params: {
785
+ path: { artifact_id: 'non-existent' }
786
+ }
787
+ });
788
+
789
+ expect(result.error).toBeDefined();
790
+ expect(result.response?.status).toBe(404);
791
+ });
792
+
793
+ it('should handle 500 Internal Server Error', async () => {
794
+ const mockError = {
795
+ detail: 'Internal server error'
796
+ };
797
+
798
+ mockedFetch.mockImplementation(async () => ({
799
+ ok: false,
800
+ status: 500,
801
+ statusText: 'Internal Server Error',
802
+ headers: new Headers({ 'Content-Type': 'application/json' }),
803
+ json: async () => mockError,
804
+ text: async () => JSON.stringify(mockError)
805
+ }));
806
+
807
+ const result = await client.rest.GET('/api/info');
808
+
809
+ expect(result.error).toBeDefined();
810
+ expect(result.response?.status).toBe(500);
811
+ });
812
+
813
+ it('should handle 422 Validation Error', async () => {
814
+ const mockError = {
815
+ detail: [
816
+ {
817
+ loc: ['body', 'name'],
818
+ msg: 'field required',
819
+ type: 'value_error.missing'
820
+ }
821
+ ]
822
+ };
823
+
824
+ mockedFetch.mockImplementation(async () => ({
825
+ ok: false,
826
+ status: 422,
827
+ statusText: 'Unprocessable Entity',
828
+ headers: new Headers({ 'Content-Type': 'application/json' }),
829
+ json: async () => mockError,
830
+ text: async () => JSON.stringify(mockError)
831
+ }));
832
+
833
+ const result = await client.rest.POST('/api/schema/load', {
834
+ body: { schemas: [] }
835
+ } as any);
836
+
837
+ expect(result.error).toBeDefined();
838
+ expect(result.response?.status).toBe(422);
839
+ });
840
+
841
+ it('should handle network errors gracefully', async () => {
842
+ mockedFetch.mockImplementation(async () => {
843
+ throw new Error('Network request failed');
844
+ });
845
+
846
+ await expect(client.rest.GET('/api/info')).rejects.toThrow('Network request failed');
847
+ });
848
+
849
+ it('should handle timeout errors', async () => {
850
+ mockedFetch.mockImplementation(async () => {
851
+ throw new Error('Request timeout');
852
+ });
853
+
854
+ await expect(client.rest.GET('/api/info')).rejects.toThrow('Request timeout');
855
+ });
856
+ });
857
+
858
+ describe('REST Client with TLS Configuration', () => {
859
+ it('should create REST client with TLS config - rejectUnauthorized false', () => {
860
+ const options: InfrahubClientOptions = {
861
+ address: baseURL,
862
+ token: token,
863
+ tls: {
864
+ rejectUnauthorized: false
865
+ }
866
+ };
867
+ const tlsClient = new InfrahubClient(options);
868
+
869
+ expect(tlsClient.rest).toBeDefined();
870
+ expect(tlsClient.rest.GET).toBeDefined();
871
+ expect(tlsClient.rest.POST).toBeDefined();
872
+ });
873
+
874
+ it('should create REST client with custom CA certificate', () => {
875
+ const options: InfrahubClientOptions = {
876
+ address: baseURL,
877
+ token: token,
878
+ tls: {
879
+ ca: '-----BEGIN CERTIFICATE-----\nMockCA\n-----END CERTIFICATE-----'
880
+ }
881
+ };
882
+ const tlsClient = new InfrahubClient(options);
883
+
884
+ expect(tlsClient.rest).toBeDefined();
885
+ });
886
+
887
+ it('should create REST client with client certificate for mutual TLS', () => {
888
+ const options: InfrahubClientOptions = {
889
+ address: baseURL,
890
+ token: token,
891
+ tls: {
892
+ cert: '-----BEGIN CERTIFICATE-----\nMockCert\n-----END CERTIFICATE-----',
893
+ key: '-----BEGIN PRIVATE KEY-----\nMockKey\n-----END PRIVATE KEY-----'
894
+ }
895
+ };
896
+ const tlsClient = new InfrahubClient(options);
897
+
898
+ expect(tlsClient.rest).toBeDefined();
899
+ });
900
+
901
+ it('should create REST client with full TLS configuration', () => {
902
+ const options: InfrahubClientOptions = {
903
+ address: baseURL,
904
+ token: token,
905
+ tls: {
906
+ rejectUnauthorized: true,
907
+ ca: '-----BEGIN CERTIFICATE-----\nMockCA\n-----END CERTIFICATE-----',
908
+ cert: '-----BEGIN CERTIFICATE-----\nMockCert\n-----END CERTIFICATE-----',
909
+ key: '-----BEGIN PRIVATE KEY-----\nMockKey\n-----END PRIVATE KEY-----'
910
+ }
911
+ };
912
+ const tlsClient = new InfrahubClient(options);
913
+
914
+ expect(tlsClient.rest).toBeDefined();
915
+ expect(tlsClient.rest.GET).toBeDefined();
916
+ expect(tlsClient.rest.POST).toBeDefined();
917
+ });
918
+ });
919
+
920
+ describe('REST Client without Token', () => {
921
+ it('should initialize REST client without token', () => {
922
+ const options: InfrahubClientOptions = {
923
+ address: baseURL
924
+ };
925
+ const noTokenClient = new InfrahubClient(options);
926
+
927
+ expect(noTokenClient.rest).toBeDefined();
928
+ });
929
+
930
+ it('should not include X-INFRAHUB-KEY header when no token is provided', async () => {
931
+ const options: InfrahubClientOptions = {
932
+ address: baseURL
933
+ };
934
+ const noTokenClient = new InfrahubClient(options);
935
+
936
+ let capturedHeaders: Headers | undefined;
937
+ mockedFetch.mockImplementation(async (_url: any, options: any) => {
938
+ capturedHeaders = extractHeaders(options);
939
+ return {
940
+ ok: true,
941
+ status: 200,
942
+ headers: new Headers({ 'Content-Type': 'application/json' }),
943
+ json: async () => ({}),
944
+ text: async () => '{}'
945
+ };
946
+ });
947
+
948
+ await noTokenClient.rest.GET('/api/info');
949
+
950
+ // When no token is set, the header should not be present or be empty
951
+ const authHeader = capturedHeaders?.get('X-INFRAHUB-KEY');
952
+ expect(!authHeader).toBe(true);
953
+ });
954
+ });
955
+
956
+ describe('REST Client URL Construction', () => {
957
+ beforeEach(() => {
958
+ mockedFetch.mockReset();
959
+ });
960
+
961
+ it('should construct correct base URL for REST requests', async () => {
962
+ let capturedUrl: string | undefined;
963
+
964
+ mockedFetch.mockImplementation(async (request: any) => {
965
+ capturedUrl = extractUrl(request);
966
+ return {
967
+ ok: true,
968
+ status: 200,
969
+ headers: new Headers({ 'Content-Type': 'application/json' }),
970
+ json: async () => ({}),
971
+ text: async () => '{}'
972
+ };
973
+ });
974
+
975
+ await client.rest.GET('/api/info');
976
+
977
+ expect(capturedUrl).toBe(`${baseURL}/api/info`);
978
+ });
979
+
980
+ it('should handle trailing slash in base URL', async () => {
981
+ const optionsWithSlash: InfrahubClientOptions = {
982
+ address: `${baseURL}/`,
983
+ token: token
984
+ };
985
+ const clientWithSlash = new InfrahubClient(optionsWithSlash);
986
+
987
+ let capturedUrl: string | undefined;
988
+ mockedFetch.mockImplementation(async (request: any) => {
989
+ capturedUrl = extractUrl(request);
990
+ return {
991
+ ok: true,
992
+ status: 200,
993
+ headers: new Headers({ 'Content-Type': 'application/json' }),
994
+ json: async () => ({}),
995
+ text: async () => '{}'
996
+ };
997
+ });
998
+
999
+ await clientWithSlash.rest.GET('/api/info');
1000
+
1001
+ // The URL should contain /api/info
1002
+ expect(capturedUrl).toContain('/api/info');
1003
+ });
1004
+ });
1005
+
1006
+ describe('REST Client Query Parameters', () => {
1007
+ beforeEach(() => {
1008
+ mockedFetch.mockReset();
1009
+ });
1010
+
1011
+ it('should include query parameters in GET request', async () => {
1012
+ let capturedUrl: string | undefined;
1013
+
1014
+ mockedFetch.mockImplementation(async (request: any) => {
1015
+ capturedUrl = extractUrl(request);
1016
+ return {
1017
+ ok: true,
1018
+ status: 200,
1019
+ headers: new Headers({ 'Content-Type': 'application/json' }),
1020
+ json: async () => ({}),
1021
+ text: async () => '{}'
1022
+ };
1023
+ });
1024
+
1025
+ await client.rest.GET('/api/diff/files', {
1026
+ params: {
1027
+ query: {
1028
+ branch: 'test-branch'
1029
+ }
1030
+ }
1031
+ });
1032
+
1033
+ expect(capturedUrl).toContain('branch=test-branch');
1034
+ });
1035
+
1036
+ it('should handle multiple query parameters', async () => {
1037
+ let capturedUrl: string | undefined;
1038
+
1039
+ mockedFetch.mockImplementation(async (request: any) => {
1040
+ capturedUrl = extractUrl(request);
1041
+ return {
1042
+ ok: true,
1043
+ status: 200,
1044
+ headers: new Headers({ 'Content-Type': 'application/json' }),
1045
+ json: async () => ({}),
1046
+ text: async () => '{}'
1047
+ };
1048
+ });
1049
+
1050
+ await client.rest.GET('/api/schema', {
1051
+ params: {
1052
+ query: {
1053
+ branch: 'test-branch'
1054
+ }
1055
+ }
1056
+ });
1057
+
1058
+ expect(capturedUrl).toContain('branch=test-branch');
1059
+ });
1060
+ });
1061
+
1062
+ describe('REST Client HTTP Methods', () => {
1063
+ beforeEach(() => {
1064
+ mockedFetch.mockReset();
1065
+ });
1066
+
1067
+ it('should use correct HTTP method for GET requests', async () => {
1068
+ let capturedMethod: string | undefined;
1069
+
1070
+ mockedFetch.mockImplementation(async (_url: any, options: any) => {
1071
+ capturedMethod = extractMethod(options);
1072
+ return {
1073
+ ok: true,
1074
+ status: 200,
1075
+ headers: new Headers({ 'Content-Type': 'application/json' }),
1076
+ json: async () => ({}),
1077
+ text: async () => '{}'
1078
+ };
1079
+ });
1080
+
1081
+ await client.rest.GET('/api/info');
1082
+
1083
+ expect(capturedMethod).toBe('GET');
1084
+ });
1085
+
1086
+ it('should use correct HTTP method for POST requests', async () => {
1087
+ let capturedMethod: string | undefined;
1088
+
1089
+ mockedFetch.mockImplementation(async (_url: any, options: any) => {
1090
+ capturedMethod = extractMethod(options);
1091
+ return {
1092
+ ok: true,
1093
+ status: 200,
1094
+ headers: new Headers({ 'Content-Type': 'application/json' }),
1095
+ json: async () => ({}),
1096
+ text: async () => '{}'
1097
+ };
1098
+ });
1099
+
1100
+ await client.rest.POST('/api/auth/login', {
1101
+ body: { username: 'test', password: 'test' }
1102
+ });
1103
+
1104
+ expect(capturedMethod).toBe('POST');
1105
+ });
1106
+ });
1107
+ });