@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 +57 -2
- package/dist/client.test.d.ts +2 -0
- package/dist/client.test.d.ts.map +1 -0
- package/dist/client.test.js +547 -0
- package/dist/client.test.js.map +1 -0
- package/dist/index.js +168 -128
- package/dist/index.js.map +1 -1
- package/dist/index.test.d.ts +2 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +380 -0
- package/dist/index.test.js.map +1 -0
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.test.d.ts +2 -0
- package/dist/types.test.d.ts.map +1 -0
- package/dist/types.test.js +279 -0
- package/dist/types.test.js.map +1 -0
- package/package.json +10 -3
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 @@
|
|
|
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
|