@uarlouski/testrail-mcp-server 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,108 @@
1
+ import { jest, describe, test, expect, beforeEach } from '@jest/globals';
2
+ import { updateCaseTool } from '../../src/tools/update_case.js';
3
+ import { TestRailClient } from '../../src/client/testrail.js';
4
+ import { Case } from '../../src/types/testrail.js';
5
+
6
+ describe('update_case tool', () => {
7
+ let mockClient: jest.Mocked<TestRailClient>;
8
+ let updateCaseMock: jest.Mock<(caseId: string, fields: Record<string, any>) => Promise<Case>>;
9
+
10
+ const mockUpdatedCase: Case = {
11
+ id: 123,
12
+ title: 'Updated Title',
13
+ section_id: 1,
14
+ template_id: 1,
15
+ type_id: 1,
16
+ priority_id: 2,
17
+ milestone_id: null,
18
+ refs: null,
19
+ created_by: 1,
20
+ created_on: 1700000000,
21
+ updated_by: 1,
22
+ updated_on: 1700000001,
23
+ estimate: null,
24
+ estimate_forecast: null,
25
+ suite_id: 1,
26
+ display_order: 1,
27
+ is_deleted: 0,
28
+ labels: [],
29
+ };
30
+
31
+ beforeEach(() => {
32
+ updateCaseMock = jest.fn<(caseId: string, fields: Record<string, any>) => Promise<Case>>()
33
+ .mockResolvedValue(mockUpdatedCase);
34
+
35
+ mockClient = {
36
+ updateCase: updateCaseMock
37
+ } as unknown as jest.Mocked<TestRailClient>;
38
+ });
39
+
40
+ test('exports correct tool definition', () => {
41
+ expect(updateCaseTool.name).toBe('update_case');
42
+ expect(updateCaseTool.description).toContain('Update');
43
+ expect(updateCaseTool.parameters).toBeDefined();
44
+ expect(updateCaseTool.parameters.case_id).toBeDefined();
45
+ expect(updateCaseTool.parameters.fields).toBeDefined();
46
+ });
47
+
48
+ test('handler updates case and returns success response', async () => {
49
+ const result = await updateCaseTool.handler(
50
+ { case_id: '123', fields: { title: 'Updated Title' } },
51
+ mockClient
52
+ );
53
+
54
+ expect(result).toBeDefined();
55
+ expect(result.content[0].type).toBe('text');
56
+
57
+ const parsed = JSON.parse(result.content[0].text);
58
+ expect(parsed.success).toBe(true);
59
+ expect(parsed.case_id).toBe(123);
60
+ expect(parsed.message).toContain('C123');
61
+ expect(mockClient.updateCase).toHaveBeenCalledWith('123', { title: 'Updated Title' });
62
+ });
63
+
64
+ test('strips C prefix from case_id', async () => {
65
+ await updateCaseTool.handler(
66
+ { case_id: 'C456', fields: { title: 'New Title' } },
67
+ mockClient
68
+ );
69
+
70
+ expect(mockClient.updateCase).toHaveBeenCalledWith('456', { title: 'New Title' });
71
+ });
72
+
73
+ test('handles lowercase c prefix', async () => {
74
+ await updateCaseTool.handler(
75
+ { case_id: 'c789', fields: { priority_id: 3 } },
76
+ mockClient
77
+ );
78
+
79
+ expect(mockClient.updateCase).toHaveBeenCalledWith('789', { priority_id: 3 });
80
+ });
81
+
82
+ test('passes multiple fields correctly', async () => {
83
+ const fields = {
84
+ title: 'New Title',
85
+ priority_id: 2,
86
+ custom_automation_priority: 1,
87
+ refs: 'JIRA-123',
88
+ };
89
+
90
+ await updateCaseTool.handler({ case_id: '123', fields }, mockClient);
91
+
92
+ expect(mockClient.updateCase).toHaveBeenCalledWith('123', fields);
93
+ });
94
+
95
+ test('handler returns error on failure', async () => {
96
+ updateCaseMock.mockRejectedValue(new Error('API Error'));
97
+
98
+ const result = await updateCaseTool.handler(
99
+ { case_id: '123', fields: { title: 'Test' } },
100
+ mockClient
101
+ );
102
+
103
+ expect(result).toEqual({
104
+ content: [{ type: 'text', text: 'Error: API Error' }],
105
+ isError: true
106
+ });
107
+ });
108
+ });
@@ -0,0 +1,88 @@
1
+ import { jest, describe, test, expect, beforeEach } from '@jest/globals';
2
+ import { updateCasesTool } from '../../src/tools/update_cases.js';
3
+ import { TestRailClient } from '../../src/client/testrail.js';
4
+ import { Case } from '../../src/types/testrail.js';
5
+
6
+ describe('update_cases tool', () => {
7
+ let mockClient: jest.Mocked<TestRailClient>;
8
+ let updateCasesMock: jest.Mock<(caseIds: number[], fields: Record<string, any>) => Promise<Case[]>>;
9
+
10
+ const mockUpdatedCases: Case[] = [
11
+ {
12
+ id: 1, title: 'Test Case 1', section_id: 1, template_id: 1, type_id: 1,
13
+ priority_id: 3, milestone_id: null, refs: null, created_by: 1, created_on: 1700000000,
14
+ updated_by: 1, updated_on: 1700000001, estimate: null, estimate_forecast: null,
15
+ suite_id: 1, display_order: 1, is_deleted: 0, labels: []
16
+ },
17
+ {
18
+ id: 2, title: 'Test Case 2', section_id: 1, template_id: 1, type_id: 1,
19
+ priority_id: 3, milestone_id: null, refs: null, created_by: 1, created_on: 1700000000,
20
+ updated_by: 1, updated_on: 1700000001, estimate: null, estimate_forecast: null,
21
+ suite_id: 1, display_order: 2, is_deleted: 0, labels: []
22
+ },
23
+ {
24
+ id: 3, title: 'Test Case 3', section_id: 1, template_id: 1, type_id: 1,
25
+ priority_id: 3, milestone_id: null, refs: null, created_by: 1, created_on: 1700000000,
26
+ updated_by: 1, updated_on: 1700000001, estimate: null, estimate_forecast: null,
27
+ suite_id: 1, display_order: 3, is_deleted: 0, labels: []
28
+ },
29
+ ];
30
+
31
+ beforeEach(() => {
32
+ updateCasesMock = jest.fn<(caseIds: number[], fields: Record<string, any>) => Promise<Case[]>>()
33
+ .mockResolvedValue(mockUpdatedCases);
34
+
35
+ mockClient = {
36
+ updateCases: updateCasesMock
37
+ } as unknown as jest.Mocked<TestRailClient>;
38
+ });
39
+
40
+ test('exports correct tool definition', () => {
41
+ expect(updateCasesTool.name).toBe('update_cases');
42
+ expect(updateCasesTool.description).toContain('Bulk');
43
+ expect(updateCasesTool.parameters).toBeDefined();
44
+ expect(updateCasesTool.parameters.case_ids).toBeDefined();
45
+ expect(updateCasesTool.parameters.fields).toBeDefined();
46
+ });
47
+
48
+ test('handler updates cases and returns success response', async () => {
49
+ const result = await updateCasesTool.handler(
50
+ { case_ids: [1, 2, 3], fields: { priority_id: 3 } },
51
+ mockClient
52
+ );
53
+
54
+ expect(result).toBeDefined();
55
+ expect(result.content[0].type).toBe('text');
56
+
57
+ const parsed = JSON.parse(result.content[0].text);
58
+ expect(parsed.success).toBe(true);
59
+ expect(parsed.updated_count).toBe(3);
60
+ expect(parsed.case_ids).toEqual([1, 2, 3]);
61
+ expect(parsed.message).toContain('3 test cases');
62
+ });
63
+
64
+ test('passes case_ids and fields correctly', async () => {
65
+ const fields = { priority_id: 2, type_id: 1 };
66
+
67
+ await updateCasesTool.handler(
68
+ { case_ids: [10, 20, 30], fields },
69
+ mockClient
70
+ );
71
+
72
+ expect(mockClient.updateCases).toHaveBeenCalledWith([10, 20, 30], fields);
73
+ });
74
+
75
+ test('handler returns error on failure', async () => {
76
+ updateCasesMock.mockRejectedValue(new Error('API Error'));
77
+
78
+ const result = await updateCasesTool.handler(
79
+ { case_ids: [1, 2], fields: { priority_id: 2 } },
80
+ mockClient
81
+ );
82
+
83
+ expect(result).toEqual({
84
+ content: [{ type: 'text', text: 'Error: API Error' }],
85
+ isError: true
86
+ });
87
+ });
88
+ });
@@ -0,0 +1,112 @@
1
+ import { jest, describe, test, expect } from '@jest/globals';
2
+ import { Case, CaseField } from '../../src/types/testrail.js';
3
+ import { processCustomFields } from '../../src/utils/mapper.js';
4
+
5
+ describe('processCustomFields', () => {
6
+
7
+ // Mock CaseField array simulating TestRail API response
8
+ const mockCaseFields: CaseField[] = [
9
+ { id: 1, name: 'is_automated', system_name: 'custom_is_automated', label: 'Is Automated', type_id: 1, template_ids: [], include_all: true, is_active: true, display_order: 1, description: null, configs: [] },
10
+ { id: 2, name: 'refs', system_name: 'custom_refs', label: 'Defects', type_id: 1, template_ids: [], include_all: true, is_active: true, display_order: 2, description: null, configs: [] },
11
+ { id: 3, name: 'case_automation_priority', system_name: 'custom_case_automation_priority', label: 'Automation Priority', type_id: 6, template_ids: [], include_all: true, is_active: true, display_order: 3, description: null, configs: [{ context: { is_global: true, project_ids: null }, options: { items: '2, P0\n3, P1\n4, P2\n5, P3\n6, P4' } }] },
12
+ { id: 4, name: 'preconds', system_name: 'custom_preconds', label: 'Preconditions', type_id: 1, template_ids: [], include_all: true, is_active: true, display_order: 4, description: null, configs: [] },
13
+ { id: 5, name: 'steps', system_name: 'custom_steps', label: 'Steps', type_id: 1, template_ids: [1], include_all: false, is_active: true, display_order: 5, description: null, configs: [] },
14
+ { id: 6, name: 'expected', system_name: 'custom_expected', label: 'Expected', type_id: 1, template_ids: [1], include_all: false, is_active: true, display_order: 6, description: null, configs: [] },
15
+ { id: 7, name: 'steps_separated', system_name: 'custom_steps_separated', label: 'Steps Separated', type_id: 1, template_ids: [2], include_all: false, is_active: true, display_order: 7, description: null, configs: [] },
16
+ ];
17
+
18
+ test('maps custom fields using field metadata', () => {
19
+ const input: Case = {
20
+ id: 1, title: 'Foo', template_id: 1,
21
+ section_id: 0, type_id: 0, priority_id: 0,
22
+ custom_is_automated: true,
23
+ custom_refs: 'DEF-123',
24
+ display_order: 1, suite_id: 1, created_by: 1, created_on: 1, updated_by: 1, updated_on: 1,
25
+ is_deleted: 0, refs: null, labels: [],
26
+ } as unknown as Case;
27
+
28
+ const result = processCustomFields(input, mockCaseFields);
29
+ expect(result.is_automated).toBe(true);
30
+ expect(result.defects).toBe('DEF-123');
31
+ });
32
+
33
+ test('maps fields using value mapping from config', () => {
34
+ const input: Case = {
35
+ id: 1, title: 'Foo', template_id: 1,
36
+ section_id: 0, type_id: 0, priority_id: 0,
37
+ custom_case_automation_priority: 2,
38
+ display_order: 1, suite_id: 1, created_by: 1, created_on: 1, updated_by: 1, updated_on: 1,
39
+ is_deleted: 0, refs: null, labels: [],
40
+ } as unknown as Case;
41
+
42
+ const result = processCustomFields(input, mockCaseFields);
43
+ expect(result.automation_priority).toBe('P0');
44
+ });
45
+
46
+ test('includes only template-specific fields for template 1', () => {
47
+ const input: Case = {
48
+ id: 1, title: 'Foo', template_id: 1,
49
+ custom_steps: 'Step 1...',
50
+ custom_expected: 'Expectation...',
51
+ custom_steps_separated: [{ content: 'Should not appear' }], // Template 2 field
52
+ section_id: 0, type_id: 0, priority_id: 0,
53
+ display_order: 1, suite_id: 1, created_by: 1, created_on: 1, updated_by: 1, updated_on: 1,
54
+ is_deleted: 0, refs: null, labels: [],
55
+ } as unknown as Case;
56
+
57
+ const result = processCustomFields(input, mockCaseFields);
58
+ expect(result.steps).toBe('Step 1...');
59
+ expect(result.expected).toBe('Expectation...');
60
+ expect(result.steps_separated).toBeUndefined(); // Not included for template 1
61
+ });
62
+
63
+ test('includes only template-specific fields for template 2', () => {
64
+ const input: Case = {
65
+ id: 1, title: 'Foo', template_id: 2,
66
+ custom_steps: 'Should not appear', // Template 1 field
67
+ custom_steps_separated: [{ content: 'Step 1' }],
68
+ section_id: 0, type_id: 0, priority_id: 0,
69
+ display_order: 1, suite_id: 1, created_by: 1, created_on: 1, updated_by: 1, updated_on: 1,
70
+ is_deleted: 0, refs: null, labels: [],
71
+ } as unknown as Case;
72
+
73
+ const result = processCustomFields(input, mockCaseFields);
74
+ expect(result.steps_separated).toEqual([{ content: 'Step 1' }]);
75
+ expect(result.steps).toBeUndefined(); // Not included for template 2
76
+ });
77
+
78
+ test('keeps unknown fields as-is when no mapping found', () => {
79
+ const input: Case = {
80
+ id: 1, title: 'Foo', template_id: 999,
81
+ custom_unknown_field: 'some value',
82
+ section_id: 0, type_id: 0, priority_id: 0,
83
+ display_order: 1, suite_id: 1, created_by: 1, created_on: 1, updated_by: 1, updated_on: 1,
84
+ is_deleted: 0, refs: null, labels: [],
85
+ } as unknown as Case;
86
+
87
+ const result = processCustomFields(input, mockCaseFields);
88
+ expect(result.custom_unknown_field).toBe('some value'); // Kept as-is with original key
89
+ });
90
+
91
+ test('throws error when testCase is null', () => {
92
+ expect(() => processCustomFields(null as any, mockCaseFields)).toThrow('Test case is undefined or null');
93
+ });
94
+
95
+ test('throws error when testCase is undefined', () => {
96
+ expect(() => processCustomFields(undefined as any, mockCaseFields)).toThrow('Test case is undefined or null');
97
+ });
98
+
99
+ test('returns original value when dropdown option is not found', () => {
100
+ const input: Case = {
101
+ id: 1, title: 'Foo', template_id: 1,
102
+ section_id: 0, type_id: 0, priority_id: 0,
103
+ custom_case_automation_priority: 999, // Unknown option ID
104
+ display_order: 1, suite_id: 1, created_by: 1, created_on: 1, updated_by: 1, updated_on: 1,
105
+ is_deleted: 0, refs: null, labels: [],
106
+ } as unknown as Case;
107
+
108
+ const result = processCustomFields(input, mockCaseFields);
109
+ expect(result.automation_priority).toBe(999); // Falls back to original value
110
+ });
111
+ });
112
+
@@ -0,0 +1,59 @@
1
+ import { describe, test, expect } from '@jest/globals';
2
+ import { sanitizeValue } from '../../src/utils/sanitizer.js';
3
+
4
+ describe('sanitizeValue', () => {
5
+ test('strips style attributes from string values', () => {
6
+ const input = '<p style="color: red; font-size: 14px;">Hello</p>';
7
+ expect(sanitizeValue(input)).toBe('<p>Hello</p>');
8
+ });
9
+
10
+ test('strips multiple style attributes', () => {
11
+ const input = '<div style="margin: 10px;"><span style="font-weight: bold;">Text</span></div>';
12
+ expect(sanitizeValue(input)).toBe('<div><span>Text</span></div>');
13
+ });
14
+
15
+ test('handles single quotes in style', () => {
16
+ const input = "<p style='color: blue;'>Text</p>";
17
+ expect(sanitizeValue(input)).toBe('<p>Text</p>');
18
+ });
19
+
20
+ test('preserves other attributes', () => {
21
+ const input = '<a href="http://example.com" style="color: blue;" class="link">Link</a>';
22
+ expect(sanitizeValue(input)).toBe('<a href="http://example.com" class="link">Link</a>');
23
+ });
24
+
25
+ test('returns unchanged text without styles', () => {
26
+ const input = '<p>No styles here</p>';
27
+ expect(sanitizeValue(input)).toBe('<p>No styles here</p>');
28
+ });
29
+ test('sanitizes string values', () => {
30
+ const input = '<p style="color: red;">Hello</p>';
31
+ expect(sanitizeValue(input)).toBe('<p>Hello</p>');
32
+ });
33
+
34
+ test('sanitizes nested objects', () => {
35
+ const input = {
36
+ content: '<div style="margin: 10px;">Step 1</div>',
37
+ expected: '<p style="color: green;">Result</p>'
38
+ };
39
+ const result = sanitizeValue(input);
40
+ expect(result.content).toBe('<div>Step 1</div>');
41
+ expect(result.expected).toBe('<p>Result</p>');
42
+ });
43
+
44
+ test('sanitizes arrays', () => {
45
+ const input = [
46
+ { content: '<span style="font-size: 12px;">Item 1</span>' },
47
+ { content: '<span style="font-size: 14px;">Item 2</span>' }
48
+ ];
49
+ const result = sanitizeValue(input);
50
+ expect(result[0].content).toBe('<span>Item 1</span>');
51
+ expect(result[1].content).toBe('<span>Item 2</span>');
52
+ });
53
+
54
+ test('preserves non-string values', () => {
55
+ expect(sanitizeValue(42)).toBe(42);
56
+ expect(sanitizeValue(true)).toBe(true);
57
+ expect(sanitizeValue(null)).toBe(null);
58
+ });
59
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "types": [
13
+ "node",
14
+ "jest"
15
+ ]
16
+ },
17
+ "include": [
18
+ "src/**/*"
19
+ ],
20
+ "exclude": [
21
+ "node_modules",
22
+ "**/*.test.ts"
23
+ ]
24
+ }