@uarlouski/testrail-mcp-server 1.1.0 → 1.3.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +62 -16
  3. package/package.json +3 -2
  4. package/src/client/testrail.ts +111 -56
  5. package/src/index.ts +32 -2
  6. package/src/tools/add_attachment_to_run.ts +62 -0
  7. package/src/tools/add_results.ts +28 -0
  8. package/src/tools/add_run.ts +24 -0
  9. package/src/tools/create_case.ts +10 -17
  10. package/src/tools/get_case.ts +5 -12
  11. package/src/tools/get_case_fields.ts +4 -11
  12. package/src/tools/get_cases.ts +9 -16
  13. package/src/tools/get_projects.ts +7 -27
  14. package/src/tools/get_sections.ts +7 -27
  15. package/src/tools/get_statuses.ts +18 -0
  16. package/src/tools/get_templates.ts +7 -27
  17. package/src/tools/get_tests.ts +22 -0
  18. package/src/tools/update_case.ts +7 -14
  19. package/src/tools/update_cases.ts +10 -16
  20. package/src/types/custom.ts +1 -16
  21. package/src/types/testrail.ts +140 -87
  22. package/src/utils/sanitizer.ts +29 -0
  23. package/test/client/testrail.test.ts +329 -141
  24. package/test/tools/add_results.test.ts +94 -0
  25. package/test/tools/add_run.test.ts +83 -0
  26. package/test/tools/create_case.test.ts +10 -16
  27. package/test/tools/get_case.test.ts +47 -62
  28. package/test/tools/get_case_fields.test.ts +7 -80
  29. package/test/tools/get_cases.test.ts +19 -108
  30. package/test/tools/get_projects.test.ts +4 -41
  31. package/test/tools/get_sections.test.ts +7 -36
  32. package/test/tools/get_statuses.test.ts +56 -0
  33. package/test/tools/get_templates.test.ts +1 -30
  34. package/test/tools/get_tests.test.ts +47 -0
  35. package/test/tools/update_case.test.ts +10 -16
  36. package/test/tools/update_cases.test.ts +29 -28
  37. package/test/utils/mapper.test.ts +13 -13
  38. package/test/utils/sanitizer.test.ts +85 -1
@@ -1,103 +1,99 @@
1
- import { jest, describe, test, expect, beforeEach } from '@jest/globals';
1
+ import { jest, describe, test, expect, beforeEach, afterEach } from '@jest/globals';
2
2
  import { TestRailClient } from '../../src/client/testrail.js';
3
- import axios, { AxiosInstance, AxiosError } from 'axios';
3
+ import * as fs from 'fs';
4
4
 
5
5
  describe('TestRailClient', () => {
6
6
  let client: TestRailClient;
7
- let mockAxiosInstance: any;
7
+ let fetchMock: any;
8
8
 
9
9
  beforeEach(() => {
10
- // Create a simple object mock for AxiosInstance to avoid complex mocking types
11
- mockAxiosInstance = {
12
- get: jest.fn(),
13
- post: jest.fn(),
14
- defaults: { headers: { common: {} } },
15
- interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } }
16
- };
17
-
18
- // Mock axios.create to return our mock instance - using type casting to bypass TS warnings
19
- jest.spyOn(axios, 'create').mockReturnValue(mockAxiosInstance);
20
-
21
- // Pass the mock instance directly to constructor
10
+ fetchMock = jest.spyOn(global, 'fetch');
22
11
  client = new TestRailClient('https://testrail.io/', 'user', 'apikey');
23
12
  });
24
13
 
14
+ afterEach(() => {
15
+ jest.restoreAllMocks();
16
+ });
17
+
25
18
  test('constructor cleans trailing slash from baseUrl', async () => {
26
19
  const mockData = { id: 1, title: 'Test Case', template_id: 1 };
27
- mockAxiosInstance.get.mockResolvedValue({ data: mockData });
20
+ fetchMock.mockResolvedValue({
21
+ ok: true,
22
+ json: async () => mockData
23
+ });
28
24
 
29
25
  await client.getCase('1');
30
26
 
31
- // Verify axios.create was called with cleaned URL (no trailing slash)
32
- expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({
33
- baseURL: 'https://testrail.io'
34
- }));
35
- });
36
-
37
- test('validateStatus returns true for 2xx status codes', () => {
38
- // Get the config passed to axios.create
39
- const createCall = (axios.create as jest.Mock).mock.calls[0][0] as { validateStatus: (status: number) => boolean };
40
- const validateStatus = createCall.validateStatus;
41
-
42
- expect(validateStatus(200)).toBe(true);
43
- expect(validateStatus(201)).toBe(true);
44
- expect(validateStatus(299)).toBe(true);
45
- });
46
-
47
- test('validateStatus returns false for non-2xx status codes', () => {
48
- // Get the config passed to axios.create
49
- const createCall = (axios.create as jest.Mock).mock.calls[0][0] as { validateStatus: (status: number) => boolean };
50
- const validateStatus = createCall.validateStatus;
51
-
52
- expect(validateStatus(199)).toBe(false);
53
- expect(validateStatus(300)).toBe(false);
54
- expect(validateStatus(400)).toBe(false);
55
- expect(validateStatus(500)).toBe(false);
27
+ expect(fetchMock).toHaveBeenCalledWith('https://testrail.io/index.php?/api/v2/get_case/1', expect.any(Object));
56
28
  });
57
29
 
58
30
  test('getCase returns data on success', async () => {
59
31
  const mockData = { id: 1, title: 'Test Case', template_id: 1 };
60
- mockAxiosInstance.get.mockResolvedValue({ data: mockData });
32
+ fetchMock.mockResolvedValue({
33
+ ok: true,
34
+ json: async () => mockData
35
+ });
61
36
 
62
37
  const result = await client.getCase('1');
63
38
  expect(result).toEqual(mockData);
64
- expect(mockAxiosInstance.get).toHaveBeenCalledWith('/index.php?/api/v2/get_case/1');
39
+ expect(fetchMock).toHaveBeenCalledWith('https://testrail.io/index.php?/api/v2/get_case/1', expect.objectContaining({
40
+ method: 'GET',
41
+ headers: expect.objectContaining({
42
+ 'Authorization': expect.stringContaining('Basic'),
43
+ 'Content-Type': 'application/json'
44
+ })
45
+ }));
65
46
  });
66
47
 
67
48
  test('getCase throws formatted error on API error', async () => {
68
- const mockError = {
69
- message: 'Request failed',
70
- response: {
71
- status: 404,
72
- statusText: 'Not Found',
73
- data: { error: 'Case not found' }
74
- },
75
- isAxiosError: true,
76
- toJSON: () => ({})
77
- };
78
-
79
- mockAxiosInstance.get.mockRejectedValue(mockError);
49
+ const errorResponse = { error: 'Case not found' };
50
+ fetchMock.mockResolvedValue({
51
+ ok: false,
52
+ status: 404,
53
+ statusText: 'Not Found',
54
+ text: async () => JSON.stringify(errorResponse),
55
+ json: async () => errorResponse
56
+ });
80
57
 
81
58
  await expect(client.getCase('999')).rejects.toThrow(
82
- 'TestRail API error: 404 Not Found - {"error":"Case not found"}'
59
+ 'TestRail: 404 Not Found - {"error":"Case not found"}'
60
+ );
61
+ });
62
+
63
+ test('getCase throws error containing raw text when response is not JSON', async () => {
64
+ fetchMock.mockResolvedValue({
65
+ ok: false,
66
+ status: 500,
67
+ statusText: 'Internal Server Error',
68
+ text: async () => 'Not JSON Content',
69
+ json: async () => { throw new Error('Not JSON'); }
70
+ });
71
+
72
+ await expect(client.getCase('1')).rejects.toThrow(
73
+ 'TestRail: 500 Internal Server Error - Not JSON Content'
83
74
  );
84
75
  });
85
76
 
86
77
  test('getCases returns cases without pagination', async () => {
87
78
  const mockCases = [{ id: 1, title: 'Case 1' }, { id: 2, title: 'Case 2' }];
88
- mockAxiosInstance.get.mockResolvedValue({
89
- data: { cases: mockCases, _links: { next: null } }
79
+ fetchMock.mockResolvedValue({
80
+ ok: true,
81
+ json: async () => ({ cases: mockCases, _links: { next: null } })
90
82
  });
91
83
 
92
84
  const result = await client.getCases('1');
93
85
  expect(result).toEqual(mockCases);
94
- expect(mockAxiosInstance.get).toHaveBeenCalledWith('/index.php?/api/v2/get_cases/1');
86
+ expect(fetchMock).toHaveBeenCalledWith(
87
+ 'https://testrail.io/index.php?/api/v2/get_cases/1',
88
+ expect.objectContaining({ method: 'GET' })
89
+ );
95
90
  });
96
91
 
97
92
  test('getCases handles response with undefined _links', async () => {
98
93
  const mockCases = [{ id: 1, title: 'Case 1' }];
99
- mockAxiosInstance.get.mockResolvedValue({
100
- data: { cases: mockCases }
94
+ fetchMock.mockResolvedValue({
95
+ ok: true,
96
+ json: async () => ({ cases: mockCases })
101
97
  });
102
98
 
103
99
  const result = await client.getCases('1');
@@ -108,36 +104,52 @@ describe('TestRailClient', () => {
108
104
  const page1Cases = [{ id: 1, title: 'Case 1' }];
109
105
  const page2Cases = [{ id: 2, title: 'Case 2' }];
110
106
 
111
- mockAxiosInstance.get
107
+ fetchMock
112
108
  .mockResolvedValueOnce({
113
- data: { cases: page1Cases, _links: { next: '/api/v2/get_cases/1&offset=1' } }
109
+ ok: true,
110
+ json: async () => ({ cases: page1Cases, _links: { next: '/api/v2/get_cases/1&offset=1' } })
114
111
  })
115
112
  .mockResolvedValueOnce({
116
- data: { cases: page2Cases, _links: { next: null } }
113
+ ok: true,
114
+ json: async () => ({ cases: page2Cases, _links: { next: null } })
117
115
  });
118
116
 
119
117
  const result = await client.getCases('1');
120
118
  expect(result).toEqual([...page1Cases, ...page2Cases]);
121
- expect(mockAxiosInstance.get).toHaveBeenCalledTimes(2);
119
+ expect(fetchMock).toHaveBeenCalledTimes(2);
120
+ expect(fetchMock).toHaveBeenCalledWith(
121
+ 'https://testrail.io/index.php?/api/v2/get_cases/1',
122
+ expect.any(Object)
123
+ );
124
+ expect(fetchMock).toHaveBeenCalledWith(
125
+ 'https://testrail.io/index.php?/api/v2/get_cases/1&offset=1',
126
+ expect.any(Object)
127
+ );
122
128
  });
123
129
 
124
130
  test('getCases with sectionId adds section filter', async () => {
125
- mockAxiosInstance.get.mockResolvedValue({
126
- data: { cases: [], _links: { next: null } }
131
+ fetchMock.mockResolvedValue({
132
+ ok: true,
133
+ json: async () => ({ cases: [], _links: { next: null } })
127
134
  });
128
135
 
129
136
  await client.getCases('1', '5');
130
- expect(mockAxiosInstance.get).toHaveBeenCalledWith('/index.php?/api/v2/get_cases/1&section_id=5');
137
+ expect(fetchMock).toHaveBeenCalledWith(
138
+ 'https://testrail.io/index.php?/api/v2/get_cases/1&section_id=5',
139
+ expect.any(Object)
140
+ );
131
141
  });
132
142
 
133
143
  test('getCases with filter adds query params', async () => {
134
- mockAxiosInstance.get.mockResolvedValue({
135
- data: { cases: [], _links: { next: null } }
144
+ fetchMock.mockResolvedValue({
145
+ ok: true,
146
+ json: async () => ({ cases: [], _links: { next: null } })
136
147
  });
137
148
 
138
149
  await client.getCases('1', undefined, { type_id: '1', priority_id: '2' });
139
- expect(mockAxiosInstance.get).toHaveBeenCalledWith(
140
- '/index.php?/api/v2/get_cases/1&type_id=1&priority_id=2'
150
+ expect(fetchMock).toHaveBeenCalledWith(
151
+ 'https://testrail.io/index.php?/api/v2/get_cases/1&type_id=1&priority_id=2',
152
+ expect.any(Object)
141
153
  );
142
154
  });
143
155
 
@@ -151,11 +163,17 @@ describe('TestRailClient', () => {
151
163
  display_order: 1,
152
164
  suite_id: 1
153
165
  };
154
- mockAxiosInstance.get.mockResolvedValue({ data: mockData });
166
+ fetchMock.mockResolvedValue({
167
+ ok: true,
168
+ json: async () => mockData
169
+ });
155
170
 
156
171
  const result = await client.getSection('1');
157
172
  expect(result).toEqual(mockData);
158
- expect(mockAxiosInstance.get).toHaveBeenCalledWith('/index.php?/api/v2/get_section/1');
173
+ expect(fetchMock).toHaveBeenCalledWith(
174
+ 'https://testrail.io/index.php?/api/v2/get_section/1',
175
+ expect.any(Object)
176
+ );
159
177
  });
160
178
 
161
179
  test('getPriorities returns data on success', async () => {
@@ -166,21 +184,30 @@ describe('TestRailClient', () => {
166
184
  priority: 1,
167
185
  short_name: 'Low'
168
186
  }];
169
- mockAxiosInstance.get.mockResolvedValue({ data: mockData });
187
+ fetchMock.mockResolvedValue({
188
+ ok: true,
189
+ json: async () => mockData
190
+ });
170
191
 
171
192
  const result = await client.getPriorities();
172
193
  expect(result).toEqual(mockData);
173
- expect(mockAxiosInstance.get).toHaveBeenCalledWith('/index.php?/api/v2/get_priorities');
194
+ expect(fetchMock).toHaveBeenCalledWith(
195
+ 'https://testrail.io/index.php?/api/v2/get_priorities',
196
+ expect.any(Object)
197
+ );
174
198
  });
175
199
 
176
200
  test('getPriorities caches result', async () => {
177
201
  const mockData = [{ id: 1, name: 'Low' }];
178
- mockAxiosInstance.get.mockResolvedValue({ data: mockData });
202
+ fetchMock.mockResolvedValue({
203
+ ok: true,
204
+ json: async () => mockData
205
+ });
179
206
 
180
207
  await client.getPriorities();
181
208
  await client.getPriorities();
182
209
 
183
- expect(mockAxiosInstance.get).toHaveBeenCalledTimes(1);
210
+ expect(fetchMock).toHaveBeenCalledTimes(1);
184
211
  });
185
212
 
186
213
  test('getCaseTypes returns data on success', async () => {
@@ -189,11 +216,17 @@ describe('TestRailClient', () => {
189
216
  is_default: true,
190
217
  name: 'Automated'
191
218
  }];
192
- mockAxiosInstance.get.mockResolvedValue({ data: mockData });
219
+ fetchMock.mockResolvedValue({
220
+ ok: true,
221
+ json: async () => mockData
222
+ });
193
223
 
194
224
  const result = await client.getCaseTypes();
195
225
  expect(result).toEqual(mockData);
196
- expect(mockAxiosInstance.get).toHaveBeenCalledWith('/index.php?/api/v2/get_case_types');
226
+ expect(fetchMock).toHaveBeenCalledWith(
227
+ 'https://testrail.io/index.php?/api/v2/get_case_types',
228
+ expect.any(Object)
229
+ );
197
230
  });
198
231
 
199
232
  test('getCaseFields returns data on success', async () => {
@@ -204,154 +237,309 @@ describe('TestRailClient', () => {
204
237
  label: 'Custom Field',
205
238
  type_id: 1
206
239
  }];
207
- mockAxiosInstance.get.mockResolvedValue({ data: mockData });
240
+ fetchMock.mockResolvedValue({
241
+ ok: true,
242
+ json: async () => mockData
243
+ });
208
244
 
209
245
  const result = await client.getCaseFields();
210
246
  expect(result).toEqual(mockData);
211
- expect(mockAxiosInstance.get).toHaveBeenCalledWith('/index.php?/api/v2/get_case_fields');
247
+ expect(fetchMock).toHaveBeenCalledWith(
248
+ 'https://testrail.io/index.php?/api/v2/get_case_fields',
249
+ expect.any(Object)
250
+ );
212
251
  });
213
252
 
214
253
  test('getCaseFields caches result', async () => {
215
254
  const mockData = [{ id: 1, name: 'field' }];
216
- mockAxiosInstance.get.mockResolvedValue({ data: mockData });
255
+ fetchMock.mockResolvedValue({
256
+ ok: true,
257
+ json: async () => mockData
258
+ });
217
259
 
218
260
  await client.getCaseFields();
219
261
  await client.getCaseFields();
220
262
 
221
- expect(mockAxiosInstance.get).toHaveBeenCalledTimes(1);
263
+ expect(fetchMock).toHaveBeenCalledTimes(1);
222
264
  });
223
265
 
224
266
  test('getTemplates returns data on success', async () => {
225
267
  const mockData = [{ id: 1, name: 'Test Template', is_default: true }];
226
- mockAxiosInstance.get.mockResolvedValue({ data: mockData });
268
+ fetchMock.mockResolvedValue({
269
+ ok: true,
270
+ json: async () => mockData
271
+ });
227
272
 
228
273
  const result = await client.getTemplates('1');
229
274
  expect(result).toEqual(mockData);
230
- expect(mockAxiosInstance.get).toHaveBeenCalledWith('/index.php?/api/v2/get_templates/1');
275
+ expect(fetchMock).toHaveBeenCalledWith(
276
+ 'https://testrail.io/index.php?/api/v2/get_templates/1',
277
+ expect.any(Object)
278
+ );
231
279
  });
232
280
 
233
281
  test('getTemplates caches result per project', async () => {
234
282
  const mockData1 = [{ id: 1, name: 'Template 1' }];
235
283
  const mockData2 = [{ id: 2, name: 'Template 2' }];
236
- mockAxiosInstance.get
237
- .mockResolvedValueOnce({ data: mockData1 })
238
- .mockResolvedValueOnce({ data: mockData2 });
284
+ fetchMock
285
+ .mockResolvedValueOnce({
286
+ ok: true,
287
+ json: async () => mockData1
288
+ })
289
+ .mockResolvedValueOnce({
290
+ ok: true,
291
+ json: async () => mockData2
292
+ });
239
293
 
240
294
  await client.getTemplates('1');
241
295
  await client.getTemplates('1');
242
296
  await client.getTemplates('2');
243
297
 
244
- expect(mockAxiosInstance.get).toHaveBeenCalledTimes(2);
298
+ expect(fetchMock).toHaveBeenCalledTimes(2);
245
299
  });
246
300
 
247
301
  test('updateCase sends POST request', async () => {
248
302
  const mockData = { id: 1, title: 'Updated Case' };
249
- mockAxiosInstance.post.mockResolvedValue({ data: mockData });
303
+ fetchMock.mockResolvedValue({
304
+ ok: true,
305
+ json: async () => mockData
306
+ });
250
307
 
251
308
  const result = await client.updateCase('1', { title: 'Updated Case' });
252
309
  expect(result).toEqual(mockData);
253
- expect(mockAxiosInstance.post).toHaveBeenCalledWith(
254
- '/index.php?/api/v2/update_case/1',
255
- { title: 'Updated Case' }
310
+ expect(fetchMock).toHaveBeenCalledWith(
311
+ 'https://testrail.io/index.php?/api/v2/update_case/1',
312
+ expect.objectContaining({
313
+ method: 'POST',
314
+ body: JSON.stringify({ title: 'Updated Case' })
315
+ })
256
316
  );
257
317
  });
258
318
 
259
- test('updateCases gets suite_id from first case and updates all', async () => {
260
- const mockCase = { id: 1, suite_id: 10 };
319
+ test('updateCases uses passed suiteId', async () => {
261
320
  const mockUpdated = [{ id: 1, title: 'Updated' }, { id: 2, title: 'Updated' }];
262
321
 
263
- mockAxiosInstance.get.mockResolvedValue({ data: mockCase });
264
- mockAxiosInstance.post.mockResolvedValue({ data: { updated_cases: mockUpdated } });
322
+ fetchMock.mockResolvedValue({
323
+ ok: true,
324
+ json: async () => ({ updated_cases: mockUpdated })
325
+ });
265
326
 
266
- const result = await client.updateCases([1, 2], { title: 'Updated' });
327
+ const result = await client.updateCases(10, [1, 2], { title: 'Updated' });
267
328
  expect(result).toEqual(mockUpdated);
268
- expect(mockAxiosInstance.get).toHaveBeenCalledWith('/index.php?/api/v2/get_case/1');
269
- expect(mockAxiosInstance.post).toHaveBeenCalledWith(
270
- '/index.php?/api/v2/update_cases/10',
271
- { case_ids: [1, 2], title: 'Updated' }
329
+ expect(fetchMock).toHaveBeenCalledWith(
330
+ 'https://testrail.io/index.php?/api/v2/update_cases/10',
331
+ expect.objectContaining({
332
+ method: 'POST',
333
+ body: JSON.stringify({ case_ids: [1, 2], title: 'Updated' })
334
+ })
272
335
  );
273
336
  });
274
337
 
275
338
  test('createCase sends POST request', async () => {
276
339
  const mockData = { id: 1, title: 'New Case' };
277
- mockAxiosInstance.post.mockResolvedValue({ data: mockData });
340
+ fetchMock.mockResolvedValue({
341
+ ok: true,
342
+ json: async () => mockData
343
+ });
278
344
 
279
345
  const result = await client.createCase('5', { title: 'New Case' });
280
346
  expect(result).toEqual(mockData);
281
- expect(mockAxiosInstance.post).toHaveBeenCalledWith(
282
- '/index.php?/api/v2/add_case/5',
283
- { title: 'New Case' }
347
+ expect(fetchMock).toHaveBeenCalledWith(
348
+ 'https://testrail.io/index.php?/api/v2/add_case/5',
349
+ expect.objectContaining({
350
+ method: 'POST',
351
+ body: JSON.stringify({ title: 'New Case' })
352
+ })
284
353
  );
285
354
  });
286
355
 
287
356
  test('getSections returns sections array', async () => {
288
357
  const mockSections = [{ id: 1, name: 'Section 1' }, { id: 2, name: 'Section 2' }];
289
- mockAxiosInstance.get.mockResolvedValue({ data: { sections: mockSections } });
358
+ fetchMock.mockResolvedValue({
359
+ ok: true,
360
+ json: async () => ({ sections: mockSections })
361
+ });
290
362
 
291
363
  const result = await client.getSections('1');
292
364
  expect(result).toEqual(mockSections);
293
- expect(mockAxiosInstance.get).toHaveBeenCalledWith('/index.php?/api/v2/get_sections/1');
365
+ expect(fetchMock).toHaveBeenCalledWith(
366
+ 'https://testrail.io/index.php?/api/v2/get_sections/1',
367
+ expect.any(Object)
368
+ );
294
369
  });
295
370
 
296
371
  test('getProjects returns projects array', async () => {
297
372
  const mockProjects = [{ id: 1, name: 'Project 1' }];
298
- mockAxiosInstance.get.mockResolvedValue({ data: { projects: mockProjects } });
373
+ fetchMock.mockResolvedValue({
374
+ ok: true,
375
+ json: async () => ({ projects: mockProjects })
376
+ });
299
377
 
300
378
  const result = await client.getProjects();
301
379
  expect(result).toEqual(mockProjects);
302
- expect(mockAxiosInstance.get).toHaveBeenCalledWith('/index.php?/api/v2/get_projects');
380
+ expect(fetchMock).toHaveBeenCalledWith(
381
+ 'https://testrail.io/index.php?/api/v2/get_projects',
382
+ expect.any(Object)
383
+ );
303
384
  });
304
385
 
305
386
  test('getProjects caches result', async () => {
306
387
  const mockProjects = [{ id: 1, name: 'Project 1' }];
307
- mockAxiosInstance.get.mockResolvedValue({ data: { projects: mockProjects } });
388
+ fetchMock.mockResolvedValue({
389
+ ok: true,
390
+ json: async () => ({ projects: mockProjects })
391
+ });
308
392
 
309
393
  await client.getProjects();
310
394
  await client.getProjects();
311
395
 
312
- expect(mockAxiosInstance.get).toHaveBeenCalledTimes(1);
396
+ expect(fetchMock).toHaveBeenCalledTimes(1);
313
397
  });
314
398
 
315
399
  test('postRequest throws formatted error on API error', async () => {
316
- const mockError = {
317
- message: 'Request failed',
318
- response: {
319
- status: 400,
320
- statusText: 'Bad Request',
321
- data: { error: 'Invalid field' }
322
- },
323
- isAxiosError: true,
324
- toJSON: () => ({})
325
- };
326
-
327
- mockAxiosInstance.post.mockRejectedValue(mockError);
400
+ const errorResponse = { error: 'Invalid field' };
401
+ fetchMock.mockResolvedValue({
402
+ ok: false,
403
+ status: 400,
404
+ statusText: 'Bad Request',
405
+ text: async () => JSON.stringify(errorResponse),
406
+ json: async () => errorResponse
407
+ });
328
408
 
329
409
  await expect(client.updateCase('1', {})).rejects.toThrow(
330
- 'TestRail API error: 400 Bad Request - {"error":"Invalid field"}'
410
+ 'TestRail: 400 Bad Request - {"error":"Invalid field"}'
331
411
  );
332
412
  });
333
413
 
334
- test('handleError formats error when no response received', async () => {
335
- const mockError = {
336
- message: 'Network Error',
337
- request: {},
338
- isAxiosError: true,
339
- toJSON: () => ({})
340
- };
414
+ test('addRun posts fields directly', async () => {
415
+ const mockRun = { id: 1, name: 'New Run' };
341
416
 
342
- mockAxiosInstance.get.mockRejectedValue(mockError);
417
+ fetchMock.mockResolvedValue({
418
+ ok: true,
419
+ json: async () => mockRun
420
+ });
343
421
 
344
- await expect(client.getCase('1')).rejects.toThrow(
345
- 'TestRail API error: No response received. Network Error'
422
+ const result = await client.addRun('1', { name: 'New Run', suite_id: 20 });
423
+
424
+ expect(result).toEqual(mockRun);
425
+ expect(fetchMock).toHaveBeenCalledWith(
426
+ 'https://testrail.io/index.php?/api/v2/add_run/1',
427
+ expect.objectContaining({
428
+ method: 'POST',
429
+ body: JSON.stringify({ name: 'New Run', suite_id: 20 })
430
+ })
346
431
  );
347
432
  });
348
433
 
349
- test('handleError returns generic message for non-axios errors', async () => {
350
- const mockError = new Error('Unknown error');
351
- mockAxiosInstance.get.mockRejectedValue(mockError);
434
+ test('getTests handles pagination', async () => {
435
+ fetchMock
436
+ .mockImplementationOnce(() => Promise.resolve({
437
+ ok: true,
438
+ json: () => Promise.resolve({
439
+ tests: [{ id: 1, case_id: 1, status_id: 1, title: 'Test 1', run_id: 1 }],
440
+ _links: { next: '/api/v2/get_tests/1&offset=1' }
441
+ }),
442
+ } as Response))
443
+ .mockImplementationOnce(() => Promise.resolve({
444
+ ok: true,
445
+ json: () => Promise.resolve({
446
+ tests: [{ id: 2, case_id: 2, status_id: 1, title: 'Test 2', run_id: 1 }],
447
+ _links: { next: null }
448
+ }),
449
+ } as Response));
450
+
451
+ const tests = await client.getTests(1);
452
+ expect(tests).toHaveLength(2);
453
+ expect(tests[0].id).toBe(1);
454
+ expect(tests[1].id).toBe(2);
455
+ expect(fetchMock).toHaveBeenCalledTimes(2);
456
+ });
352
457
 
353
- await expect(client.getCase('1')).rejects.toThrow(
354
- 'TestRail API error: Unknown error'
458
+ test('getTests with status_id adds filter', async () => {
459
+ fetchMock.mockResolvedValue({
460
+ ok: true,
461
+ json: async () => ({
462
+ tests: [],
463
+ _links: { next: null }
464
+ })
465
+ } as Response);
466
+
467
+ await client.getTests(1, [1, 5]);
468
+
469
+ expect(fetchMock).toHaveBeenCalledWith(
470
+ expect.stringContaining('/get_tests/1&status_id=1,5'),
471
+ expect.any(Object)
472
+ );
473
+ });
474
+
475
+ test('getStatuses caches result', async () => {
476
+ const mockStatuses = [{ id: 1, name: 'passed', label: 'Passed' }];
477
+ fetchMock.mockResolvedValue({
478
+ ok: true,
479
+ json: async () => mockStatuses
480
+ });
481
+
482
+ await client.getStatuses();
483
+ await client.getStatuses();
484
+
485
+ expect(fetchMock).toHaveBeenCalledTimes(1);
486
+ });
487
+
488
+ test('addResults posts results array', async () => {
489
+ const mockResults = [
490
+ { id: 1, test_id: 100, status_id: 1, comment: 'Passed', defects: null },
491
+ { id: 2, test_id: 101, status_id: 5, comment: 'Failed', defects: 'BUG-123' }
492
+ ];
493
+
494
+ fetchMock.mockResolvedValue({
495
+ ok: true,
496
+ json: async () => mockResults
497
+ });
498
+
499
+ const results = await client.addResults(50, [
500
+ { test_id: 100, status_id: 1, comment: 'Passed' },
501
+ { test_id: 101, status_id: 5, comment: 'Failed', defects: 'BUG-123' }
502
+ ]);
503
+
504
+ expect(results).toEqual(mockResults);
505
+ expect(fetchMock).toHaveBeenCalledWith(
506
+ 'https://testrail.io/index.php?/api/v2/add_results/50',
507
+ expect.objectContaining({
508
+ method: 'POST',
509
+ body: JSON.stringify({
510
+ results: [
511
+ { test_id: 100, status_id: 1, comment: 'Passed' },
512
+ { test_id: 101, status_id: 5, comment: 'Failed', defects: 'BUG-123' }
513
+ ]
514
+ })
515
+ })
516
+ );
517
+ });
518
+
519
+ test('addAttachmentToRun uploads file using FormData', async () => {
520
+ const mockAttachment = { attachment_id: 443 };
521
+ const mockFileBuffer = Buffer.from('file content');
522
+
523
+ jest.spyOn(fs.promises, 'readFile').mockResolvedValue(mockFileBuffer);
524
+
525
+ fetchMock.mockResolvedValue({
526
+ ok: true,
527
+ json: async () => mockAttachment
528
+ });
529
+
530
+ const result = await client.addAttachmentToRun(100, '/path/to/file.txt', 'file.txt');
531
+
532
+ expect(result).toEqual(mockAttachment);
533
+ expect(fs.promises.readFile).toHaveBeenCalledWith('/path/to/file.txt');
534
+ expect(fetchMock).toHaveBeenCalledWith(
535
+ 'https://testrail.io/index.php?/api/v2/add_attachment_to_run/100',
536
+ expect.objectContaining({
537
+ method: 'POST',
538
+ headers: expect.objectContaining({
539
+ 'Authorization': expect.stringContaining('Basic')
540
+ }),
541
+ body: expect.any(FormData)
542
+ })
355
543
  );
356
544
  });
357
545
  });