@efficy/tribecrm-mcp-server 0.3.0 → 0.4.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 CHANGED
@@ -24,6 +24,12 @@ Model Context Protocol (MCP) server for TribeCRM API integration. This server en
24
24
  - **Entity Type Definitions**: Access schema and field information for entity types
25
25
  - **Dynamic Resources**: Entity data exposed as MCP resources for context
26
26
 
27
+ ### Security Modes
28
+ - **Read-Only Mode** (default): Secure by default - only allows querying and retrieving data
29
+ - **Read-Write Mode**: Full access including create, update, and delete operations
30
+
31
+ > ⚠️ **Breaking Change in v0.4.0**: The server now defaults to read-only mode for security. If you need write access, set `TRIBECRM_MODE=read-write` in your configuration.
32
+
27
33
  ## 📋 Prerequisites
28
34
 
29
35
  - Node.js 18 or higher
@@ -66,13 +72,16 @@ Add to your Claude Desktop config file:
66
72
  "TRIBECRM_AUTH_URL": "https://auth.tribecrm.nl",
67
73
  "TRIBECRM_CLIENT_ID": "your_client_id",
68
74
  "TRIBECRM_CLIENT_SECRET": "your_client_secret",
69
- "TRIBECRM_ORGANIZATION_ID": "your_org_id"
75
+ "TRIBECRM_ORGANIZATION_ID": "your_org_id",
76
+ "TRIBECRM_MODE": "read-only"
70
77
  }
71
78
  }
72
79
  }
73
80
  }
74
81
  ```
75
82
 
83
+ **Note**: Set `TRIBECRM_MODE` to `read-write` if you need to create, update, or delete entities.
84
+
76
85
  #### Using Local Installation
77
86
 
78
87
  ```json
@@ -86,7 +95,8 @@ Add to your Claude Desktop config file:
86
95
  "TRIBECRM_AUTH_URL": "https://auth.tribecrm.nl",
87
96
  "TRIBECRM_CLIENT_ID": "your_client_id",
88
97
  "TRIBECRM_CLIENT_SECRET": "your_client_secret",
89
- "TRIBECRM_ORGANIZATION_ID": "your_org_id"
98
+ "TRIBECRM_ORGANIZATION_ID": "your_org_id",
99
+ "TRIBECRM_MODE": "read-only"
90
100
  }
91
101
  }
92
102
  }
@@ -108,6 +118,9 @@ Add to your Claude Desktop config file:
108
118
  - `TRIBECRM_CLIENT_ID` (required): OAuth2 Client ID
109
119
  - `TRIBECRM_CLIENT_SECRET` (required): OAuth2 Client Secret
110
120
  - `TRIBECRM_ORGANIZATION_ID` (optional): Organization UUID for multi-tenant setups
121
+ - `TRIBECRM_MODE` (optional): Server operation mode - `read-only` (default) or `read-write`
122
+ - `read-only`: Only allows queries and data retrieval (get, query operations). Write tools are hidden.
123
+ - `read-write`: Allows full access including create, update, and delete operations.
111
124
 
112
125
  ## 📚 Available Tools
113
126
 
@@ -263,6 +276,48 @@ The server uses OAuth2 Client Credentials flow:
263
276
  3. Automatically refreshes token before expiry
264
277
  4. Includes token in all API requests
265
278
 
279
+ ## 🧪 Testing
280
+
281
+ The project has comprehensive unit tests with >95% code coverage.
282
+
283
+ ### Running Tests
284
+
285
+ ```bash
286
+ # Run all tests
287
+ npm test
288
+
289
+ # Run tests in watch mode
290
+ npm run test:watch
291
+
292
+ # Run tests with coverage report
293
+ npm run test:coverage
294
+
295
+ # Run tests with UI
296
+ npm run test:ui
297
+ ```
298
+
299
+ ### Test Coverage
300
+
301
+ | File | Coverage | Details |
302
+ |------|----------|---------|
303
+ | client.ts | 100% | All API client methods tested |
304
+ | types.ts | - | Type definitions (no runtime coverage needed) |
305
+ | index.ts | - | Server initialization (tested separately) |
306
+
307
+ **Coverage Standards**:
308
+ - Lines: 95%+
309
+ - Functions: 95%+
310
+ - Branches: 95%+
311
+ - Statements: 95%+
312
+
313
+ ### Writing Tests
314
+
315
+ Tests are written using [Vitest](https://vitest.dev/) and follow these conventions:
316
+ - Test files: `*.test.ts` in `src/` directory
317
+ - Comprehensive mocking of external dependencies (axios)
318
+ - Clear test descriptions using describe/it blocks
319
+ - Both positive and negative test cases
320
+
266
321
  ## 🛠️ Development
267
322
 
268
323
  ### Project Structure
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=client.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.test.d.ts","sourceRoot":"","sources":["../src/client.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,547 @@
1
+ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
2
+ import axios from 'axios';
3
+ import { TribeCRMClient } from './client.js';
4
+ // Mock axios
5
+ vi.mock('axios');
6
+ const mockedAxios = vi.mocked(axios, true);
7
+ describe('TribeCRMClient', () => {
8
+ let client;
9
+ let config;
10
+ beforeEach(() => {
11
+ // Reset mocks before each test
12
+ vi.clearAllMocks();
13
+ // Setup test configuration
14
+ config = {
15
+ apiUrl: 'https://api-test.tribecrm.nl',
16
+ authUrl: 'https://auth-test.tribecrm.nl',
17
+ clientId: 'test-client-id',
18
+ clientSecret: 'test-client-secret',
19
+ organizationId: 'test-org-id',
20
+ mode: 'read-write',
21
+ };
22
+ // Mock axios.create to return a mocked instance
23
+ mockedAxios.create = vi.fn().mockReturnValue({
24
+ get: vi.fn(),
25
+ post: vi.fn(),
26
+ delete: vi.fn(),
27
+ defaults: {
28
+ headers: {
29
+ common: {},
30
+ },
31
+ },
32
+ });
33
+ client = new TribeCRMClient(config);
34
+ });
35
+ afterEach(() => {
36
+ vi.restoreAllMocks();
37
+ });
38
+ describe('Authentication', () => {
39
+ it('should authenticate successfully with valid credentials', async () => {
40
+ const mockToken = {
41
+ access_token: 'test-access-token',
42
+ token_type: 'bearer',
43
+ expires_in: 86399,
44
+ scope: 'read write offline',
45
+ };
46
+ // Mock OAuth token request
47
+ mockedAxios.post = vi.fn().mockResolvedValue({
48
+ data: mockToken,
49
+ });
50
+ // Mock getCurrentEmployee to trigger authentication
51
+ const axiosInstance = client.axiosInstance;
52
+ axiosInstance.get = vi.fn().mockResolvedValue({
53
+ data: { ID: 'test-employee-id', Name: 'Test User' },
54
+ });
55
+ const result = await client.getCurrentEmployee();
56
+ expect(mockedAxios.post).toHaveBeenCalledWith('https://auth-test.tribecrm.nl/oauth2/token', expect.any(URLSearchParams), expect.objectContaining({
57
+ headers: {
58
+ 'Content-Type': 'application/x-www-form-urlencoded',
59
+ },
60
+ }));
61
+ expect(result).toEqual({ ID: 'test-employee-id', Name: 'Test User' });
62
+ });
63
+ it('should include organization_id in auth request when provided', async () => {
64
+ const mockToken = {
65
+ access_token: 'test-token',
66
+ token_type: 'bearer',
67
+ expires_in: 86399,
68
+ scope: 'read write offline',
69
+ };
70
+ mockedAxios.post = vi.fn().mockResolvedValue({
71
+ data: mockToken,
72
+ });
73
+ const axiosInstance = client.axiosInstance;
74
+ axiosInstance.get = vi.fn().mockResolvedValue({
75
+ data: { ID: 'test-id' },
76
+ });
77
+ await client.getCurrentEmployee();
78
+ const authCall = mockedAxios.post.mock.calls[0];
79
+ const params = authCall[1];
80
+ expect(params.get('organization_id')).toBe('test-org-id');
81
+ });
82
+ it('should handle authentication failure', async () => {
83
+ mockedAxios.post = vi.fn().mockRejectedValue({
84
+ response: {
85
+ data: {
86
+ error: 'invalid_client',
87
+ error_description: 'Invalid credentials',
88
+ },
89
+ },
90
+ });
91
+ await expect(client.getCurrentEmployee()).rejects.toThrow('Authentication failed: Invalid credentials');
92
+ });
93
+ it('should reuse existing token if not expired', async () => {
94
+ const mockToken = {
95
+ access_token: 'test-token',
96
+ token_type: 'bearer',
97
+ expires_in: 86399,
98
+ scope: 'read write offline',
99
+ };
100
+ mockedAxios.post = vi.fn().mockResolvedValue({
101
+ data: mockToken,
102
+ });
103
+ const axiosInstance = client.axiosInstance;
104
+ axiosInstance.get = vi.fn().mockResolvedValue({
105
+ data: { ID: 'test-id' },
106
+ });
107
+ // First call - should authenticate
108
+ await client.getCurrentEmployee();
109
+ expect(mockedAxios.post).toHaveBeenCalledTimes(1);
110
+ // Second call - should reuse token
111
+ await client.getCurrentEmployee();
112
+ expect(mockedAxios.post).toHaveBeenCalledTimes(1); // Still only 1
113
+ });
114
+ it('should refresh token when expired', async () => {
115
+ const mockToken = {
116
+ access_token: 'test-token',
117
+ token_type: 'bearer',
118
+ expires_in: 0, // Expires immediately
119
+ scope: 'read write offline',
120
+ };
121
+ mockedAxios.post = vi.fn().mockResolvedValue({
122
+ data: mockToken,
123
+ });
124
+ const axiosInstance = client.axiosInstance;
125
+ axiosInstance.get = vi.fn().mockResolvedValue({
126
+ data: { ID: 'test-id' },
127
+ });
128
+ // First call
129
+ await client.getCurrentEmployee();
130
+ expect(mockedAxios.post).toHaveBeenCalledTimes(1);
131
+ // Wait for token to expire
132
+ await new Promise((resolve) => setTimeout(resolve, 100));
133
+ // Second call - should refresh token
134
+ await client.getCurrentEmployee();
135
+ expect(mockedAxios.post).toHaveBeenCalledTimes(2);
136
+ });
137
+ });
138
+ describe('getEntity', () => {
139
+ beforeEach(async () => {
140
+ // Setup authentication
141
+ mockedAxios.post = vi.fn().mockResolvedValue({
142
+ data: {
143
+ access_token: 'test-token',
144
+ token_type: 'bearer',
145
+ expires_in: 86399,
146
+ scope: 'read write offline',
147
+ },
148
+ });
149
+ });
150
+ it('should get entity by ID successfully', async () => {
151
+ const mockEntity = {
152
+ ID: 'entity-123',
153
+ Name: 'Test Organization',
154
+ Type: 'Relation_Organization',
155
+ };
156
+ const axiosInstance = client.axiosInstance;
157
+ axiosInstance.get = vi.fn().mockResolvedValue({
158
+ data: mockEntity,
159
+ });
160
+ const result = await client.getEntity('Relation_Organization', 'entity-123');
161
+ expect(axiosInstance.get).toHaveBeenCalledWith('/v1/odata/Relation_Organization(entity-123)', { params: {} });
162
+ expect(result).toEqual(mockEntity);
163
+ });
164
+ it('should include expand parameter when provided', async () => {
165
+ const axiosInstance = client.axiosInstance;
166
+ axiosInstance.get = vi.fn().mockResolvedValue({
167
+ data: { ID: 'test-id' },
168
+ });
169
+ await client.getEntity('Relation_Organization', 'entity-123', 'Address');
170
+ expect(axiosInstance.get).toHaveBeenCalledWith('/v1/odata/Relation_Organization(entity-123)', { params: { $expand: 'Address' } });
171
+ });
172
+ it('should include select parameter when provided', async () => {
173
+ const axiosInstance = client.axiosInstance;
174
+ axiosInstance.get = vi.fn().mockResolvedValue({
175
+ data: { ID: 'test-id' },
176
+ });
177
+ await client.getEntity('Relation_Organization', 'entity-123', undefined, 'Name,Email');
178
+ expect(axiosInstance.get).toHaveBeenCalledWith('/v1/odata/Relation_Organization(entity-123)', { params: { $select: 'Name,Email' } });
179
+ });
180
+ it('should include both expand and select parameters', async () => {
181
+ const axiosInstance = client.axiosInstance;
182
+ axiosInstance.get = vi.fn().mockResolvedValue({
183
+ data: { ID: 'test-id' },
184
+ });
185
+ await client.getEntity('Relation_Organization', 'entity-123', 'Address', 'Name,Email');
186
+ expect(axiosInstance.get).toHaveBeenCalledWith('/v1/odata/Relation_Organization(entity-123)', { params: { $expand: 'Address', $select: 'Name,Email' } });
187
+ });
188
+ });
189
+ describe('createEntity', () => {
190
+ beforeEach(async () => {
191
+ mockedAxios.post = vi.fn().mockResolvedValue({
192
+ data: {
193
+ access_token: 'test-token',
194
+ token_type: 'bearer',
195
+ expires_in: 86399,
196
+ scope: 'read write offline',
197
+ },
198
+ });
199
+ });
200
+ it('should create entity successfully', async () => {
201
+ const entityData = {
202
+ Name: 'New Organization',
203
+ Email: 'test@example.com',
204
+ };
205
+ const mockCreatedEntity = {
206
+ ID: 'new-entity-id',
207
+ ...entityData,
208
+ };
209
+ const axiosInstance = client.axiosInstance;
210
+ axiosInstance.post = vi.fn().mockResolvedValue({
211
+ data: mockCreatedEntity,
212
+ });
213
+ const result = await client.createEntity('Relation_Organization', entityData);
214
+ expect(axiosInstance.post).toHaveBeenCalledWith('/v1/odata/Relation_Organization', entityData);
215
+ expect(result).toEqual(mockCreatedEntity);
216
+ });
217
+ it('should not include ID in create request', async () => {
218
+ const entityData = {
219
+ Name: 'New Organization',
220
+ };
221
+ const axiosInstance = client.axiosInstance;
222
+ axiosInstance.post = vi.fn().mockResolvedValue({
223
+ data: { ID: 'new-id', ...entityData },
224
+ });
225
+ await client.createEntity('Relation_Organization', entityData);
226
+ const callData = axiosInstance.post.mock.calls[0][1];
227
+ expect(callData).not.toHaveProperty('ID');
228
+ });
229
+ });
230
+ describe('updateEntity', () => {
231
+ beforeEach(async () => {
232
+ mockedAxios.post = vi.fn().mockResolvedValue({
233
+ data: {
234
+ access_token: 'test-token',
235
+ token_type: 'bearer',
236
+ expires_in: 86399,
237
+ scope: 'read write offline',
238
+ },
239
+ });
240
+ });
241
+ it('should update entity successfully', async () => {
242
+ const updateData = {
243
+ Name: 'Updated Organization',
244
+ };
245
+ const mockUpdatedEntity = {
246
+ ID: 'entity-123',
247
+ ...updateData,
248
+ };
249
+ const axiosInstance = client.axiosInstance;
250
+ axiosInstance.post = vi.fn().mockResolvedValue({
251
+ data: mockUpdatedEntity,
252
+ });
253
+ const result = await client.updateEntity('Relation_Organization', 'entity-123', updateData);
254
+ expect(axiosInstance.post).toHaveBeenCalledWith('/v1/odata/Relation_Organization', { ...updateData, ID: 'entity-123' });
255
+ expect(result).toEqual(mockUpdatedEntity);
256
+ });
257
+ it('should include ID in update request payload', async () => {
258
+ const updateData = {
259
+ Name: 'Updated Name',
260
+ };
261
+ const axiosInstance = client.axiosInstance;
262
+ axiosInstance.post = vi.fn().mockResolvedValue({
263
+ data: { ID: 'entity-123', ...updateData },
264
+ });
265
+ await client.updateEntity('Relation_Organization', 'entity-123', updateData);
266
+ const callData = axiosInstance.post.mock.calls[0][1];
267
+ expect(callData).toHaveProperty('ID', 'entity-123');
268
+ });
269
+ });
270
+ describe('deleteEntity', () => {
271
+ beforeEach(async () => {
272
+ mockedAxios.post = vi.fn().mockResolvedValue({
273
+ data: {
274
+ access_token: 'test-token',
275
+ token_type: 'bearer',
276
+ expires_in: 86399,
277
+ scope: 'read write offline',
278
+ },
279
+ });
280
+ });
281
+ it('should delete entity successfully', async () => {
282
+ const axiosInstance = client.axiosInstance;
283
+ axiosInstance.delete = vi.fn().mockResolvedValue({});
284
+ await client.deleteEntity('Relation_Organization', 'entity-123');
285
+ expect(axiosInstance.delete).toHaveBeenCalledWith('/v1/odata/Relation_Organization(entity-123)');
286
+ });
287
+ it('should not return data on successful delete', async () => {
288
+ const axiosInstance = client.axiosInstance;
289
+ axiosInstance.delete = vi.fn().mockResolvedValue({});
290
+ const result = await client.deleteEntity('Relation_Organization', 'entity-123');
291
+ expect(result).toBeUndefined();
292
+ });
293
+ });
294
+ describe('queryEntities', () => {
295
+ beforeEach(async () => {
296
+ mockedAxios.post = vi.fn().mockResolvedValue({
297
+ data: {
298
+ access_token: 'test-token',
299
+ token_type: 'bearer',
300
+ expires_in: 86399,
301
+ scope: 'read write offline',
302
+ },
303
+ });
304
+ });
305
+ it('should query entities with no filters', async () => {
306
+ const mockResults = {
307
+ value: [{ ID: '1', Name: 'Entity 1' }],
308
+ };
309
+ const axiosInstance = client.axiosInstance;
310
+ axiosInstance.get = vi.fn().mockResolvedValue({
311
+ data: mockResults,
312
+ });
313
+ const result = await client.queryEntities('Relation_Organization');
314
+ expect(axiosInstance.get).toHaveBeenCalledWith('/v1/odata/Relation_Organization', {
315
+ params: {},
316
+ });
317
+ expect(result).toEqual(mockResults);
318
+ });
319
+ it('should include filter parameter', async () => {
320
+ const axiosInstance = client.axiosInstance;
321
+ axiosInstance.get = vi.fn().mockResolvedValue({
322
+ data: { value: [] },
323
+ });
324
+ await client.queryEntities('Relation_Organization', {
325
+ filter: "contains(Name, 'test')",
326
+ });
327
+ expect(axiosInstance.get).toHaveBeenCalledWith('/v1/odata/Relation_Organization', {
328
+ params: { $filter: "contains(Name, 'test')" },
329
+ });
330
+ });
331
+ it('should include select parameter', async () => {
332
+ const axiosInstance = client.axiosInstance;
333
+ axiosInstance.get = vi.fn().mockResolvedValue({
334
+ data: { value: [] },
335
+ });
336
+ await client.queryEntities('Relation_Organization', {
337
+ select: 'ID,Name',
338
+ });
339
+ expect(axiosInstance.get).toHaveBeenCalledWith('/v1/odata/Relation_Organization', {
340
+ params: { $select: 'ID,Name' },
341
+ });
342
+ });
343
+ it('should include expand parameter', async () => {
344
+ const axiosInstance = client.axiosInstance;
345
+ axiosInstance.get = vi.fn().mockResolvedValue({
346
+ data: { value: [] },
347
+ });
348
+ await client.queryEntities('Relation_Organization', {
349
+ expand: 'Address',
350
+ });
351
+ expect(axiosInstance.get).toHaveBeenCalledWith('/v1/odata/Relation_Organization', {
352
+ params: { $expand: 'Address' },
353
+ });
354
+ });
355
+ it('should include orderby parameter', async () => {
356
+ const axiosInstance = client.axiosInstance;
357
+ axiosInstance.get = vi.fn().mockResolvedValue({
358
+ data: { value: [] },
359
+ });
360
+ await client.queryEntities('Relation_Organization', {
361
+ orderby: 'Name desc',
362
+ });
363
+ expect(axiosInstance.get).toHaveBeenCalledWith('/v1/odata/Relation_Organization', {
364
+ params: { $orderby: 'Name desc' },
365
+ });
366
+ });
367
+ it('should include pagination parameters (top and skip)', async () => {
368
+ const axiosInstance = client.axiosInstance;
369
+ axiosInstance.get = vi.fn().mockResolvedValue({
370
+ data: { value: [] },
371
+ });
372
+ await client.queryEntities('Relation_Organization', {
373
+ top: 10,
374
+ skip: 20,
375
+ });
376
+ expect(axiosInstance.get).toHaveBeenCalledWith('/v1/odata/Relation_Organization', {
377
+ params: { $top: 10, $skip: 20 },
378
+ });
379
+ });
380
+ it('should include count parameter', async () => {
381
+ const axiosInstance = client.axiosInstance;
382
+ axiosInstance.get = vi.fn().mockResolvedValue({
383
+ data: { value: [], '@odata.count': 42 },
384
+ });
385
+ await client.queryEntities('Relation_Organization', {
386
+ count: true,
387
+ });
388
+ expect(axiosInstance.get).toHaveBeenCalledWith('/v1/odata/Relation_Organization', {
389
+ params: { $count: 'true' },
390
+ });
391
+ });
392
+ it('should include all query parameters together', async () => {
393
+ const axiosInstance = client.axiosInstance;
394
+ axiosInstance.get = vi.fn().mockResolvedValue({
395
+ data: { value: [] },
396
+ });
397
+ await client.queryEntities('Relation_Organization', {
398
+ filter: "contains(Name, 'test')",
399
+ select: 'ID,Name',
400
+ expand: 'Address',
401
+ orderby: 'Name asc',
402
+ top: 10,
403
+ skip: 20,
404
+ count: true,
405
+ });
406
+ expect(axiosInstance.get).toHaveBeenCalledWith('/v1/odata/Relation_Organization', {
407
+ params: {
408
+ $filter: "contains(Name, 'test')",
409
+ $select: 'ID,Name',
410
+ $expand: 'Address',
411
+ $orderby: 'Name asc',
412
+ $top: 10,
413
+ $skip: 20,
414
+ $count: 'true',
415
+ },
416
+ });
417
+ });
418
+ });
419
+ describe('getCurrentEmployee', () => {
420
+ beforeEach(async () => {
421
+ mockedAxios.post = vi.fn().mockResolvedValue({
422
+ data: {
423
+ access_token: 'test-token',
424
+ token_type: 'bearer',
425
+ expires_in: 86399,
426
+ scope: 'read write offline',
427
+ },
428
+ });
429
+ });
430
+ it('should get current employee without expand', async () => {
431
+ const mockEmployee = {
432
+ ID: 'employee-123',
433
+ Name: 'Test Employee',
434
+ };
435
+ const axiosInstance = client.axiosInstance;
436
+ axiosInstance.get = vi.fn().mockResolvedValue({
437
+ data: mockEmployee,
438
+ });
439
+ const result = await client.getCurrentEmployee();
440
+ expect(axiosInstance.get).toHaveBeenCalledWith('/v1/odata/GetCurrentEmployee()', {
441
+ params: {},
442
+ });
443
+ expect(result).toEqual(mockEmployee);
444
+ });
445
+ it('should include expand parameter when provided', async () => {
446
+ const mockEmployee = {
447
+ ID: 'employee-123',
448
+ Person: {
449
+ ID: 'person-123',
450
+ Name: 'Test Person',
451
+ },
452
+ };
453
+ const axiosInstance = client.axiosInstance;
454
+ axiosInstance.get = vi.fn().mockResolvedValue({
455
+ data: mockEmployee,
456
+ });
457
+ const result = await client.getCurrentEmployee('Person');
458
+ expect(axiosInstance.get).toHaveBeenCalledWith('/v1/odata/GetCurrentEmployee()', {
459
+ params: { $expand: 'Person' },
460
+ });
461
+ expect(result).toEqual(mockEmployee);
462
+ });
463
+ });
464
+ describe('listEntityTypes', () => {
465
+ beforeEach(async () => {
466
+ mockedAxios.post = vi.fn().mockResolvedValue({
467
+ data: {
468
+ access_token: 'test-token',
469
+ token_type: 'bearer',
470
+ expires_in: 86399,
471
+ scope: 'read write offline',
472
+ },
473
+ });
474
+ });
475
+ it('should return hardcoded entity types', async () => {
476
+ const result = await client.listEntityTypes();
477
+ expect(result).toBeInstanceOf(Array);
478
+ expect(result.length).toBeGreaterThan(0);
479
+ expect(result).toContainEqual(expect.objectContaining({
480
+ code: 'Relation_Organization',
481
+ name: 'Organizations',
482
+ }));
483
+ expect(result).toContainEqual(expect.objectContaining({
484
+ code: 'Relation_Person',
485
+ name: 'Persons',
486
+ }));
487
+ });
488
+ it('should return entities with required fields', async () => {
489
+ const result = await client.listEntityTypes();
490
+ result.forEach((entityType) => {
491
+ expect(entityType).toHaveProperty('code');
492
+ expect(entityType).toHaveProperty('name');
493
+ expect(entityType).toHaveProperty('fields');
494
+ expect(Array.isArray(entityType.fields)).toBe(true);
495
+ });
496
+ });
497
+ it('should handle metadata fetch errors gracefully', async () => {
498
+ const axiosInstance = client.axiosInstance;
499
+ axiosInstance.get = vi.fn().mockRejectedValue(new Error('Metadata fetch failed'));
500
+ const result = await client.listEntityTypes();
501
+ // Should return empty array on error
502
+ expect(result).toBeInstanceOf(Array);
503
+ expect(result.length).toBe(0);
504
+ });
505
+ });
506
+ describe('Error Handling', () => {
507
+ beforeEach(async () => {
508
+ mockedAxios.post = vi.fn().mockResolvedValue({
509
+ data: {
510
+ access_token: 'test-token',
511
+ token_type: 'bearer',
512
+ expires_in: 86399,
513
+ scope: 'read write offline',
514
+ },
515
+ });
516
+ });
517
+ it('should handle network errors', async () => {
518
+ const axiosInstance = client.axiosInstance;
519
+ axiosInstance.get = vi.fn().mockRejectedValue(new Error('Network error'));
520
+ await expect(client.getEntity('Relation_Organization', 'entity-123')).rejects.toThrow('Network error');
521
+ });
522
+ it('should handle API errors with error messages', async () => {
523
+ const axiosInstance = client.axiosInstance;
524
+ axiosInstance.get = vi.fn().mockRejectedValue({
525
+ response: {
526
+ data: {
527
+ error: {
528
+ message: 'Entity not found',
529
+ },
530
+ },
531
+ },
532
+ });
533
+ await expect(client.getEntity('Relation_Organization', 'entity-123')).rejects.toThrow();
534
+ });
535
+ it('should handle HTTP status errors', async () => {
536
+ const axiosInstance = client.axiosInstance;
537
+ axiosInstance.get = vi.fn().mockRejectedValue({
538
+ response: {
539
+ status: 404,
540
+ statusText: 'Not Found',
541
+ },
542
+ });
543
+ await expect(client.getEntity('Relation_Organization', 'entity-123')).rejects.toThrow();
544
+ });
545
+ });
546
+ });
547
+ //# sourceMappingURL=client.test.js.map