@dpesch/mantisbt-mcp-server 1.8.2 → 1.9.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.
- package/CHANGELOG.md +31 -0
- package/README.de.md +3 -3
- package/README.md +3 -3
- package/dist/client.js +7 -0
- package/dist/config.js +1 -0
- package/dist/date-filter.js +55 -0
- package/dist/index.js +1 -1
- package/dist/resources/index.js +14 -7
- package/dist/search/highlight.js +63 -0
- package/dist/search/store.js +4 -0
- package/dist/search/tools.js +65 -4
- package/dist/tools/config.js +23 -8
- package/dist/tools/issues.js +123 -18
- package/dist/tools/notes.js +1 -1
- package/docs/cookbook.de.md +64 -7
- package/docs/cookbook.md +64 -7
- package/docs/examples.de.md +12 -0
- package/docs/examples.md +12 -0
- package/package.json +1 -1
- package/server.json +2 -2
- package/tests/fixtures/get_issue.json +22 -0
- package/tests/helpers/search-mocks.ts +29 -6
- package/tests/search/highlight.test.ts +129 -0
- package/tests/search/tools.test.ts +258 -0
- package/tests/tools/issues.test.ts +446 -4
- package/tests/utils/date-filter.test.ts +169 -0
|
@@ -7,6 +7,7 @@ import { registerIssueTools } from '../../src/tools/issues.js';
|
|
|
7
7
|
import { MetadataCache } from '../../src/cache.js';
|
|
8
8
|
import { MockMcpServer, makeResponse } from '../helpers/mock-server.js';
|
|
9
9
|
import { MANTIS_RESOLVED_STATUS_ID } from '../../src/constants.js';
|
|
10
|
+
import { clearIssueEnumCache } from '../../src/tools/config.js';
|
|
10
11
|
|
|
11
12
|
function makeStubCache(projectUsers?: Array<{ id: number; name: string; real_name?: string }>): MetadataCache {
|
|
12
13
|
return {
|
|
@@ -31,7 +32,7 @@ const getIssueFixturePath = join(fixturesDir, 'get_issue.json');
|
|
|
31
32
|
const listIssuesFixturePath = join(fixturesDir, 'list_issues.json');
|
|
32
33
|
|
|
33
34
|
const getIssueFixture = existsSync(getIssueFixturePath)
|
|
34
|
-
? (JSON.parse(readFileSync(getIssueFixturePath, 'utf-8')) as { issues: Array<{ id: number; summary: string }> })
|
|
35
|
+
? (JSON.parse(readFileSync(getIssueFixturePath, 'utf-8')) as { issues: Array<{ id: number; summary: string; notes?: Array<{ id: number }> }> })
|
|
35
36
|
: { issues: [{ id: 42, summary: 'Test Issue' }] };
|
|
36
37
|
|
|
37
38
|
const listIssuesFixture = existsSync(listIssuesFixturePath)
|
|
@@ -55,6 +56,7 @@ beforeEach(() => {
|
|
|
55
56
|
client = new MantisClient('https://mantis.example.com', 'test-token');
|
|
56
57
|
registerIssueTools(mockServer as never, client, makeStubCache());
|
|
57
58
|
vi.stubGlobal('fetch', vi.fn());
|
|
59
|
+
clearIssueEnumCache();
|
|
58
60
|
});
|
|
59
61
|
|
|
60
62
|
afterEach(() => {
|
|
@@ -101,6 +103,139 @@ describe('get_issue', () => {
|
|
|
101
103
|
expect(result.isError).toBe(true);
|
|
102
104
|
expect(result.content[0]!.text).toContain('Error:');
|
|
103
105
|
});
|
|
106
|
+
|
|
107
|
+
it('enthält notes direkt in der Response (kein separater list_notes-Call nötig)', async () => {
|
|
108
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(getIssueFixture)));
|
|
109
|
+
|
|
110
|
+
const result = await mockServer.callTool('get_issue', { id: getIssueFixture.issues[0]!.id });
|
|
111
|
+
|
|
112
|
+
expect(result.isError).toBeUndefined();
|
|
113
|
+
const parsed = JSON.parse(result.content[0]!.text) as { notes?: Array<{ id: number }> };
|
|
114
|
+
const fixtureNotes = getIssueFixture.issues[0]!.notes!;
|
|
115
|
+
expect(Array.isArray(parsed.notes)).toBe(true);
|
|
116
|
+
expect(parsed.notes!.length).toBe(fixtureNotes.length);
|
|
117
|
+
expect(parsed.notes![0]!.id).toBe(fixtureNotes[0]!.id);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// get_issues
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
describe('get_issues', () => {
|
|
126
|
+
it('ist registriert', () => {
|
|
127
|
+
expect(mockServer.hasToolRegistered('get_issues')).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('gibt ein Array von Issues zurück wenn alle IDs existieren', async () => {
|
|
131
|
+
vi.mocked(fetch)
|
|
132
|
+
.mockResolvedValueOnce(makeResponse(200, JSON.stringify({ issues: [{ id: 42, summary: 'Issue 42' }] })))
|
|
133
|
+
.mockResolvedValueOnce(makeResponse(200, JSON.stringify({ issues: [{ id: 43, summary: 'Issue 43' }] })));
|
|
134
|
+
|
|
135
|
+
const result = await mockServer.callTool('get_issues', { ids: [42, 43] });
|
|
136
|
+
|
|
137
|
+
expect(result.isError).toBeUndefined();
|
|
138
|
+
const parsed = JSON.parse(result.content[0]!.text) as {
|
|
139
|
+
issues: Array<{ id: number } | null>;
|
|
140
|
+
requested: number;
|
|
141
|
+
found: number;
|
|
142
|
+
failed: number;
|
|
143
|
+
};
|
|
144
|
+
expect(parsed.requested).toBe(2);
|
|
145
|
+
expect(parsed.found).toBe(2);
|
|
146
|
+
expect(parsed.failed).toBe(0);
|
|
147
|
+
expect(parsed.issues[0]?.id).toBe(42);
|
|
148
|
+
expect(parsed.issues[1]?.id).toBe(43);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('gibt null für nicht gefundene IDs zurück ohne isError zu setzen', async () => {
|
|
152
|
+
vi.mocked(fetch)
|
|
153
|
+
.mockResolvedValueOnce(makeResponse(200, JSON.stringify({ issues: [{ id: 42, summary: 'Issue 42' }] })))
|
|
154
|
+
.mockResolvedValueOnce(makeResponse(404, JSON.stringify({ message: 'Issue not found' })));
|
|
155
|
+
|
|
156
|
+
const result = await mockServer.callTool('get_issues', { ids: [42, 9999] });
|
|
157
|
+
|
|
158
|
+
expect(result.isError).toBeUndefined();
|
|
159
|
+
const parsed = JSON.parse(result.content[0]!.text) as {
|
|
160
|
+
issues: Array<{ id: number } | null>;
|
|
161
|
+
found: number;
|
|
162
|
+
failed: number;
|
|
163
|
+
};
|
|
164
|
+
expect(parsed.found).toBe(1);
|
|
165
|
+
expect(parsed.failed).toBe(1);
|
|
166
|
+
expect(parsed.issues[0]?.id).toBe(42);
|
|
167
|
+
expect(parsed.issues[1]).toBeNull();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('gibt isError nicht zurück auch wenn alle IDs fehlschlagen', async () => {
|
|
171
|
+
vi.mocked(fetch).mockResolvedValue(
|
|
172
|
+
makeResponse(404, JSON.stringify({ message: 'Not found' })),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const result = await mockServer.callTool('get_issues', { ids: [9001, 9002] });
|
|
176
|
+
|
|
177
|
+
expect(result.isError).toBeUndefined();
|
|
178
|
+
const parsed = JSON.parse(result.content[0]!.text) as {
|
|
179
|
+
issues: Array<null>;
|
|
180
|
+
found: number;
|
|
181
|
+
failed: number;
|
|
182
|
+
};
|
|
183
|
+
expect(parsed.found).toBe(0);
|
|
184
|
+
expect(parsed.failed).toBe(2);
|
|
185
|
+
expect(parsed.issues).toEqual([null, null]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('erhält die Reihenfolge der Input-IDs im Ergebnis-Array', async () => {
|
|
189
|
+
vi.mocked(fetch)
|
|
190
|
+
.mockResolvedValueOnce(makeResponse(200, JSON.stringify({ issues: [{ id: 10, summary: 'A' }] })))
|
|
191
|
+
.mockResolvedValueOnce(makeResponse(200, JSON.stringify({ issues: [{ id: 20, summary: 'B' }] })))
|
|
192
|
+
.mockResolvedValueOnce(makeResponse(200, JSON.stringify({ issues: [{ id: 30, summary: 'C' }] })));
|
|
193
|
+
|
|
194
|
+
const result = await mockServer.callTool('get_issues', { ids: [10, 20, 30] });
|
|
195
|
+
|
|
196
|
+
const parsed = JSON.parse(result.content[0]!.text) as { issues: Array<{ id: number }> };
|
|
197
|
+
expect(parsed.issues.map((i) => i.id)).toEqual([10, 20, 30]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('validiert: leeres ids-Array wird abgelehnt', async () => {
|
|
201
|
+
const result = await mockServer.callTool('get_issues', { ids: [] }, { validate: true });
|
|
202
|
+
expect(result.isError).toBe(true);
|
|
203
|
+
expect(vi.mocked(fetch)).not.toHaveBeenCalled();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('validiert: mehr als 50 IDs werden abgelehnt', async () => {
|
|
207
|
+
const ids = Array.from({ length: 51 }, (_, i) => i + 1);
|
|
208
|
+
const result = await mockServer.callTool('get_issues', { ids }, { validate: true });
|
|
209
|
+
expect(result.isError).toBe(true);
|
|
210
|
+
expect(vi.mocked(fetch)).not.toHaveBeenCalled();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('akzeptiert String-IDs via z.coerce', async () => {
|
|
214
|
+
vi.mocked(fetch).mockResolvedValue(
|
|
215
|
+
makeResponse(200, JSON.stringify({ issues: [{ id: 42, summary: 'Test' }] })),
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
const result = await mockServer.callTool('get_issues', { ids: ['42'] }, { validate: true });
|
|
219
|
+
|
|
220
|
+
expect(result.isError).toBeUndefined();
|
|
221
|
+
const parsed = JSON.parse(result.content[0]!.text) as { issues: Array<{ id: number }> };
|
|
222
|
+
expect(parsed.issues[0]?.id).toBe(42);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('führt alle Requests aus (Concurrency-Nachweis mit 6 IDs)', async () => {
|
|
226
|
+
Array.from({ length: 6 }, (_, i) => {
|
|
227
|
+
vi.mocked(fetch).mockResolvedValueOnce(
|
|
228
|
+
makeResponse(200, JSON.stringify({ issues: [{ id: i + 1, summary: 'x' }] })),
|
|
229
|
+
);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const result = await mockServer.callTool('get_issues', { ids: [1, 2, 3, 4, 5, 6] });
|
|
233
|
+
|
|
234
|
+
expect(vi.mocked(fetch)).toHaveBeenCalledTimes(6);
|
|
235
|
+
expect(result.isError).toBeUndefined();
|
|
236
|
+
const parsed = JSON.parse(result.content[0]!.text) as { requested: number };
|
|
237
|
+
expect(parsed.requested).toBe(6);
|
|
238
|
+
});
|
|
104
239
|
});
|
|
105
240
|
|
|
106
241
|
// ---------------------------------------------------------------------------
|
|
@@ -235,7 +370,6 @@ describe('create_issue', () => {
|
|
|
235
370
|
expect(result.isError).toBe(true);
|
|
236
371
|
expect(result.content[0]!.text).toContain('schwerer Fehler');
|
|
237
372
|
expect(result.content[0]!.text).toContain('minor');
|
|
238
|
-
expect(fetch).not.toHaveBeenCalled();
|
|
239
373
|
});
|
|
240
374
|
|
|
241
375
|
it('creates issue without any optional fields (backward compatibility)', async () => {
|
|
@@ -330,7 +464,6 @@ describe('create_issue', () => {
|
|
|
330
464
|
expect(result.isError).toBe(true);
|
|
331
465
|
expect(result.content[0]!.text).toContain('immer');
|
|
332
466
|
expect(result.content[0]!.text).toContain('always');
|
|
333
|
-
expect(fetch).not.toHaveBeenCalled();
|
|
334
467
|
});
|
|
335
468
|
|
|
336
469
|
it('returns error for empty reproducibility string', async () => {
|
|
@@ -343,7 +476,6 @@ describe('create_issue', () => {
|
|
|
343
476
|
}, { validate: true });
|
|
344
477
|
|
|
345
478
|
expect(result.isError).toBe(true);
|
|
346
|
-
expect(fetch).not.toHaveBeenCalled();
|
|
347
479
|
});
|
|
348
480
|
|
|
349
481
|
it('sends view_state as { name } object', async () => {
|
|
@@ -655,6 +787,45 @@ describe('list_issues', () => {
|
|
|
655
787
|
expect(parsedUpper.issues.length).toBe(parsedLower.issues.length);
|
|
656
788
|
expect(parsedUpper.issues).toEqual(parsedLower.issues);
|
|
657
789
|
});
|
|
790
|
+
|
|
791
|
+
it('status canonical name matches by ID even when API returns localized status names', async () => {
|
|
792
|
+
// Simulate a German MantisBT installation where issue.status.name is localized
|
|
793
|
+
const localizedFixture = {
|
|
794
|
+
issues: [
|
|
795
|
+
{ id: 1, status: { id: 10, name: 'Neu' } },
|
|
796
|
+
{ id: 2, status: { id: 80, name: 'Erledigt' } },
|
|
797
|
+
{ id: 3, status: { id: 10, name: 'Neu' } },
|
|
798
|
+
],
|
|
799
|
+
total_count: 3,
|
|
800
|
+
};
|
|
801
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(localizedFixture)));
|
|
802
|
+
|
|
803
|
+
const result = await mockServer.callTool('list_issues', { status: 'new', page: 1, page_size: 50 });
|
|
804
|
+
|
|
805
|
+
expect(result.isError).toBeUndefined();
|
|
806
|
+
const parsed = JSON.parse(result.content[0]!.text) as { issues: Array<{ id: number }> };
|
|
807
|
+
expect(parsed.issues).toHaveLength(2);
|
|
808
|
+
expect(parsed.issues.map(i => i.id)).toEqual([1, 3]);
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
it('status filter falls back to name comparison for non-canonical values', async () => {
|
|
812
|
+
// "Neu" is not a canonical status name → falls back to name comparison
|
|
813
|
+
const localizedFixture = {
|
|
814
|
+
issues: [
|
|
815
|
+
{ id: 1, status: { id: 10, name: 'Neu' } },
|
|
816
|
+
{ id: 2, status: { id: 80, name: 'Erledigt' } },
|
|
817
|
+
],
|
|
818
|
+
total_count: 2,
|
|
819
|
+
};
|
|
820
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(localizedFixture)));
|
|
821
|
+
|
|
822
|
+
const result = await mockServer.callTool('list_issues', { status: 'Neu', page: 1, page_size: 50 });
|
|
823
|
+
|
|
824
|
+
expect(result.isError).toBeUndefined();
|
|
825
|
+
const parsed = JSON.parse(result.content[0]!.text) as { issues: Array<{ id: number }> };
|
|
826
|
+
expect(parsed.issues).toHaveLength(1);
|
|
827
|
+
expect(parsed.issues[0]!.id).toBe(1);
|
|
828
|
+
});
|
|
658
829
|
});
|
|
659
830
|
|
|
660
831
|
// ---------------------------------------------------------------------------
|
|
@@ -736,3 +907,274 @@ describe('update_issue – fields allowlist', () => {
|
|
|
736
907
|
expect(vi.mocked(fetch)).not.toHaveBeenCalled();
|
|
737
908
|
});
|
|
738
909
|
});
|
|
910
|
+
|
|
911
|
+
// ---------------------------------------------------------------------------
|
|
912
|
+
// update_issue – dry_run
|
|
913
|
+
// ---------------------------------------------------------------------------
|
|
914
|
+
|
|
915
|
+
describe('update_issue – dry_run', () => {
|
|
916
|
+
it('returns preview payload without calling the API', async () => {
|
|
917
|
+
const result = await mockServer.callTool(
|
|
918
|
+
'update_issue',
|
|
919
|
+
{ id: 42, fields: { summary: 'Preview title', status: { name: 'resolved' } }, dry_run: true },
|
|
920
|
+
{ validate: true },
|
|
921
|
+
);
|
|
922
|
+
|
|
923
|
+
expect(result.isError).toBeUndefined();
|
|
924
|
+
expect(vi.mocked(fetch)).not.toHaveBeenCalled();
|
|
925
|
+
|
|
926
|
+
const parsed = JSON.parse(result.content[0]!.text) as { dry_run: boolean; id: number; would_patch: Record<string, unknown> };
|
|
927
|
+
expect(parsed.dry_run).toBe(true);
|
|
928
|
+
expect(parsed.id).toBe(42);
|
|
929
|
+
expect(parsed.would_patch).toEqual({ summary: 'Preview title', status: { name: 'resolved' } });
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
it('defaults to false (normal update) when dry_run is omitted', async () => {
|
|
933
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ issue: { id: 42, summary: 'Updated' } })));
|
|
934
|
+
|
|
935
|
+
const result = await mockServer.callTool(
|
|
936
|
+
'update_issue',
|
|
937
|
+
{ id: 42, fields: { summary: 'Updated' } },
|
|
938
|
+
{ validate: true },
|
|
939
|
+
);
|
|
940
|
+
|
|
941
|
+
expect(result.isError).toBeUndefined();
|
|
942
|
+
expect(vi.mocked(fetch)).toHaveBeenCalledOnce();
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
it('also rejects unknown fields in dry_run mode without calling the API', async () => {
|
|
946
|
+
const result = await mockServer.callTool(
|
|
947
|
+
'update_issue',
|
|
948
|
+
{ id: 42, fields: { unknown_field: 'bad' }, dry_run: true },
|
|
949
|
+
{ validate: true },
|
|
950
|
+
);
|
|
951
|
+
|
|
952
|
+
expect(result.isError).toBe(true);
|
|
953
|
+
expect(vi.mocked(fetch)).not.toHaveBeenCalled();
|
|
954
|
+
});
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
// ---------------------------------------------------------------------------
|
|
958
|
+
// update_issue – enum resolution
|
|
959
|
+
// ---------------------------------------------------------------------------
|
|
960
|
+
|
|
961
|
+
describe('update_issue – enum resolution', () => {
|
|
962
|
+
it('resolves canonical severity name to { id } before sending to API', async () => {
|
|
963
|
+
vi.mocked(fetch).mockResolvedValue(
|
|
964
|
+
makeResponse(200, JSON.stringify({ issue: { id: 1 } })),
|
|
965
|
+
);
|
|
966
|
+
|
|
967
|
+
await mockServer.callTool(
|
|
968
|
+
'update_issue',
|
|
969
|
+
{ id: 1, fields: { severity: { name: 'major' } } },
|
|
970
|
+
{ validate: true },
|
|
971
|
+
);
|
|
972
|
+
|
|
973
|
+
// Only one fetch call (the PATCH itself — canonical lookup needs no API call)
|
|
974
|
+
expect(vi.mocked(fetch)).toHaveBeenCalledOnce();
|
|
975
|
+
const body = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) as Record<string, unknown>;
|
|
976
|
+
expect(body.severity).toEqual({ id: 60 }); // major = id 60
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
it('resolves canonical priority name to { id } before sending to API', async () => {
|
|
980
|
+
vi.mocked(fetch).mockResolvedValue(
|
|
981
|
+
makeResponse(200, JSON.stringify({ issue: { id: 1 } })),
|
|
982
|
+
);
|
|
983
|
+
|
|
984
|
+
await mockServer.callTool(
|
|
985
|
+
'update_issue',
|
|
986
|
+
{ id: 1, fields: { priority: { name: 'high' } } },
|
|
987
|
+
{ validate: true },
|
|
988
|
+
);
|
|
989
|
+
|
|
990
|
+
expect(vi.mocked(fetch)).toHaveBeenCalledOnce();
|
|
991
|
+
const body = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) as Record<string, unknown>;
|
|
992
|
+
expect(body.priority).toEqual({ id: 40 }); // high = id 40
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
it('resolves canonical status name to { id } before sending to API', async () => {
|
|
996
|
+
vi.mocked(fetch).mockResolvedValue(
|
|
997
|
+
makeResponse(200, JSON.stringify({ issue: { id: 1 } })),
|
|
998
|
+
);
|
|
999
|
+
|
|
1000
|
+
await mockServer.callTool(
|
|
1001
|
+
'update_issue',
|
|
1002
|
+
{ id: 1, fields: { status: { name: 'resolved' } } },
|
|
1003
|
+
{ validate: true },
|
|
1004
|
+
);
|
|
1005
|
+
|
|
1006
|
+
expect(vi.mocked(fetch)).toHaveBeenCalledOnce();
|
|
1007
|
+
const body = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) as Record<string, unknown>;
|
|
1008
|
+
expect(body.status).toEqual({ id: 80 }); // resolved = id 80
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
it('does not resolve when id is already provided', async () => {
|
|
1012
|
+
vi.mocked(fetch).mockResolvedValue(
|
|
1013
|
+
makeResponse(200, JSON.stringify({ issue: { id: 1 } })),
|
|
1014
|
+
);
|
|
1015
|
+
|
|
1016
|
+
await mockServer.callTool(
|
|
1017
|
+
'update_issue',
|
|
1018
|
+
{ id: 1, fields: { severity: { id: 60 } } },
|
|
1019
|
+
{ validate: true },
|
|
1020
|
+
);
|
|
1021
|
+
|
|
1022
|
+
// Only one fetch call — no enum resolution needed
|
|
1023
|
+
expect(vi.mocked(fetch)).toHaveBeenCalledOnce();
|
|
1024
|
+
const body = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) as Record<string, unknown>;
|
|
1025
|
+
expect(body.severity).toEqual({ id: 60 });
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
it('resolves localized enum name to { id } via live enum lookup', async () => {
|
|
1029
|
+
// First fetch: config endpoint for enum data
|
|
1030
|
+
// Second fetch: the actual PATCH
|
|
1031
|
+
vi.mocked(fetch)
|
|
1032
|
+
.mockResolvedValueOnce(makeResponse(200, JSON.stringify({
|
|
1033
|
+
configs: [{
|
|
1034
|
+
option: 'severity_enum_string',
|
|
1035
|
+
value: [
|
|
1036
|
+
{ id: 50, name: 'Kleiner Fehler' },
|
|
1037
|
+
{ id: 60, name: 'Großer Fehler' },
|
|
1038
|
+
],
|
|
1039
|
+
}],
|
|
1040
|
+
})))
|
|
1041
|
+
.mockResolvedValueOnce(makeResponse(200, JSON.stringify({ issue: { id: 1 } })));
|
|
1042
|
+
|
|
1043
|
+
await mockServer.callTool(
|
|
1044
|
+
'update_issue',
|
|
1045
|
+
{ id: 1, fields: { severity: { name: 'Großer Fehler' } } },
|
|
1046
|
+
{ validate: true },
|
|
1047
|
+
);
|
|
1048
|
+
|
|
1049
|
+
// Call 0 = config endpoint, Call 1 = PATCH
|
|
1050
|
+
const patchBody = JSON.parse(vi.mocked(fetch).mock.calls[1]![1]!.body as string) as Record<string, unknown>;
|
|
1051
|
+
expect(patchBody.severity).toEqual({ id: 60 });
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
it('passes unknown enum name through unchanged when resolution fails', async () => {
|
|
1055
|
+
// Config returns no useful data → name stays unchanged
|
|
1056
|
+
vi.mocked(fetch)
|
|
1057
|
+
.mockResolvedValueOnce(makeResponse(200, JSON.stringify({ configs: [] })))
|
|
1058
|
+
.mockResolvedValueOnce(makeResponse(200, JSON.stringify({ issue: { id: 1 } })));
|
|
1059
|
+
|
|
1060
|
+
await mockServer.callTool(
|
|
1061
|
+
'update_issue',
|
|
1062
|
+
{ id: 1, fields: { severity: { name: 'völlig_unbekannt' } } },
|
|
1063
|
+
{ validate: true },
|
|
1064
|
+
);
|
|
1065
|
+
|
|
1066
|
+
const patchBody = JSON.parse(vi.mocked(fetch).mock.calls[1]![1]!.body as string) as Record<string, unknown>;
|
|
1067
|
+
expect(patchBody.severity).toEqual({ name: 'völlig_unbekannt' });
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
it('resolves multiple enum fields in one patch call', async () => {
|
|
1071
|
+
vi.mocked(fetch).mockResolvedValue(
|
|
1072
|
+
makeResponse(200, JSON.stringify({ issue: { id: 1 } })),
|
|
1073
|
+
);
|
|
1074
|
+
|
|
1075
|
+
await mockServer.callTool(
|
|
1076
|
+
'update_issue',
|
|
1077
|
+
{ id: 1, fields: { status: { name: 'resolved' }, priority: { name: 'high' }, severity: { name: 'major' } } },
|
|
1078
|
+
{ validate: true },
|
|
1079
|
+
);
|
|
1080
|
+
|
|
1081
|
+
expect(vi.mocked(fetch)).toHaveBeenCalledOnce(); // all canonical → single PATCH call
|
|
1082
|
+
const body = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) as Record<string, unknown>;
|
|
1083
|
+
expect(body.status).toEqual({ id: 80 });
|
|
1084
|
+
expect(body.priority).toEqual({ id: 40 });
|
|
1085
|
+
expect(body.severity).toEqual({ id: 60 });
|
|
1086
|
+
});
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
// ---------------------------------------------------------------------------
|
|
1090
|
+
// list_issues – date filters
|
|
1091
|
+
// ---------------------------------------------------------------------------
|
|
1092
|
+
|
|
1093
|
+
function makeIssuePage(issues: Array<{ id: number; updated_at: string; created_at: string }>) {
|
|
1094
|
+
return { issues, total_count: issues.length };
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
describe('list_issues – updated_after filter', () => {
|
|
1098
|
+
it('returns only issues updated after the given date', async () => {
|
|
1099
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(makeIssuePage([
|
|
1100
|
+
{ id: 1, updated_at: '2026-03-26T10:00:00Z', created_at: '2026-03-01T00:00:00Z' },
|
|
1101
|
+
{ id: 2, updated_at: '2026-03-23T10:00:00Z', created_at: '2026-03-01T00:00:00Z' },
|
|
1102
|
+
{ id: 3, updated_at: '2026-03-25T00:00:01Z', created_at: '2026-03-01T00:00:00Z' },
|
|
1103
|
+
]))));
|
|
1104
|
+
|
|
1105
|
+
const result = await mockServer.callTool('list_issues', {
|
|
1106
|
+
updated_after: '2026-03-25T00:00:00Z',
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
expect(result.isError).toBeUndefined();
|
|
1110
|
+
const parsed = JSON.parse(result.content[0]!.text) as { issues: Array<{ id: number }> };
|
|
1111
|
+
expect(parsed.issues.map(i => i.id)).toEqual([1, 3]);
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
it('returns an empty list when no issue matches', async () => {
|
|
1115
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(makeIssuePage([
|
|
1116
|
+
{ id: 1, updated_at: '2026-03-20T00:00:00Z', created_at: '2026-03-01T00:00:00Z' },
|
|
1117
|
+
]))));
|
|
1118
|
+
|
|
1119
|
+
const result = await mockServer.callTool('list_issues', {
|
|
1120
|
+
updated_after: '2026-03-25T00:00:00Z',
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
const parsed = JSON.parse(result.content[0]!.text) as { issues: Array<unknown> };
|
|
1124
|
+
expect(parsed.issues).toHaveLength(0);
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
it('stops scanning pages when all issues in a batch are older than updated_after (early-exit)', async () => {
|
|
1128
|
+
// Page 1: one matching issue, one too old
|
|
1129
|
+
// Page 2: should NOT be fetched because the last item of page 1 is already older
|
|
1130
|
+
vi.mocked(fetch)
|
|
1131
|
+
.mockResolvedValueOnce(makeResponse(200, JSON.stringify(makeIssuePage([
|
|
1132
|
+
{ id: 10, updated_at: '2026-03-26T00:00:00Z', created_at: '2026-03-01T00:00:00Z' },
|
|
1133
|
+
{ id: 9, updated_at: '2026-03-20T00:00:00Z', created_at: '2026-03-01T00:00:00Z' },
|
|
1134
|
+
]))))
|
|
1135
|
+
.mockResolvedValueOnce(makeResponse(200, JSON.stringify(makeIssuePage([
|
|
1136
|
+
{ id: 8, updated_at: '2026-03-18T00:00:00Z', created_at: '2026-03-01T00:00:00Z' },
|
|
1137
|
+
]))));
|
|
1138
|
+
|
|
1139
|
+
await mockServer.callTool('list_issues', {
|
|
1140
|
+
updated_after: '2026-03-25T00:00:00Z',
|
|
1141
|
+
page_size: 50,
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1);
|
|
1145
|
+
});
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
describe('list_issues – created_after filter', () => {
|
|
1149
|
+
it('returns only issues created after the given date', async () => {
|
|
1150
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(makeIssuePage([
|
|
1151
|
+
{ id: 1, updated_at: '2026-03-26T00:00:00Z', created_at: '2026-03-26T00:00:00Z' },
|
|
1152
|
+
{ id: 2, updated_at: '2026-03-26T00:00:00Z', created_at: '2026-03-23T00:00:00Z' },
|
|
1153
|
+
]))));
|
|
1154
|
+
|
|
1155
|
+
const result = await mockServer.callTool('list_issues', {
|
|
1156
|
+
created_after: '2026-03-25T00:00:00Z',
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
const parsed = JSON.parse(result.content[0]!.text) as { issues: Array<{ id: number }> };
|
|
1160
|
+
expect(parsed.issues.map(i => i.id)).toEqual([1]);
|
|
1161
|
+
});
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
describe('list_issues – combined date window', () => {
|
|
1165
|
+
it('returns only issues within the updated_after + updated_before window', async () => {
|
|
1166
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(makeIssuePage([
|
|
1167
|
+
{ id: 1, updated_at: '2026-03-22T00:00:00Z', created_at: '2026-03-01T00:00:00Z' }, // in window
|
|
1168
|
+
{ id: 2, updated_at: '2026-03-19T00:00:00Z', created_at: '2026-03-01T00:00:00Z' }, // too old
|
|
1169
|
+
{ id: 3, updated_at: '2026-03-26T00:00:00Z', created_at: '2026-03-01T00:00:00Z' }, // too new
|
|
1170
|
+
]))));
|
|
1171
|
+
|
|
1172
|
+
const result = await mockServer.callTool('list_issues', {
|
|
1173
|
+
updated_after: '2026-03-20T00:00:00Z',
|
|
1174
|
+
updated_before: '2026-03-25T00:00:00Z',
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
const parsed = JSON.parse(result.content[0]!.text) as { issues: Array<{ id: number }> };
|
|
1178
|
+
expect(parsed.issues.map(i => i.id)).toEqual([1]);
|
|
1179
|
+
});
|
|
1180
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { matchesDateFilter } from '../../src/date-filter.js';
|
|
3
|
+
|
|
4
|
+
describe('matchesDateFilter', () => {
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// No filter → always pass
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
it('returns true when no filter is set', () => {
|
|
11
|
+
expect(matchesDateFilter(
|
|
12
|
+
{ updated_at: '2026-03-20T10:00:00Z', created_at: '2026-03-01T00:00:00Z' },
|
|
13
|
+
{}
|
|
14
|
+
)).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('returns true for an item with no dates when no filter is set', () => {
|
|
18
|
+
expect(matchesDateFilter({}, {})).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// updated_after
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
it('updated_after: passes when updated_at is after the threshold', () => {
|
|
26
|
+
expect(matchesDateFilter(
|
|
27
|
+
{ updated_at: '2026-03-25T12:00:00Z' },
|
|
28
|
+
{ updated_after: '2026-03-24T00:00:00Z' }
|
|
29
|
+
)).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('updated_after: fails when updated_at is before the threshold', () => {
|
|
33
|
+
expect(matchesDateFilter(
|
|
34
|
+
{ updated_at: '2026-03-23T12:00:00Z' },
|
|
35
|
+
{ updated_after: '2026-03-24T00:00:00Z' }
|
|
36
|
+
)).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('updated_after: fails when updated_at equals the threshold (exclusive)', () => {
|
|
40
|
+
expect(matchesDateFilter(
|
|
41
|
+
{ updated_at: '2026-03-24T00:00:00Z' },
|
|
42
|
+
{ updated_after: '2026-03-24T00:00:00Z' }
|
|
43
|
+
)).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('updated_after: fails when updated_at is missing', () => {
|
|
47
|
+
expect(matchesDateFilter(
|
|
48
|
+
{ created_at: '2026-03-25T00:00:00Z' },
|
|
49
|
+
{ updated_after: '2026-03-24T00:00:00Z' }
|
|
50
|
+
)).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// updated_before
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
it('updated_before: passes when updated_at is before the threshold', () => {
|
|
58
|
+
expect(matchesDateFilter(
|
|
59
|
+
{ updated_at: '2026-03-20T00:00:00Z' },
|
|
60
|
+
{ updated_before: '2026-03-25T00:00:00Z' }
|
|
61
|
+
)).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('updated_before: fails when updated_at is after the threshold', () => {
|
|
65
|
+
expect(matchesDateFilter(
|
|
66
|
+
{ updated_at: '2026-03-26T00:00:00Z' },
|
|
67
|
+
{ updated_before: '2026-03-25T00:00:00Z' }
|
|
68
|
+
)).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('updated_before: fails when updated_at equals the threshold (exclusive)', () => {
|
|
72
|
+
expect(matchesDateFilter(
|
|
73
|
+
{ updated_at: '2026-03-25T00:00:00Z' },
|
|
74
|
+
{ updated_before: '2026-03-25T00:00:00Z' }
|
|
75
|
+
)).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('updated_before: fails when updated_at is missing', () => {
|
|
79
|
+
expect(matchesDateFilter(
|
|
80
|
+
{},
|
|
81
|
+
{ updated_before: '2026-03-25T00:00:00Z' }
|
|
82
|
+
)).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// created_after
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
it('created_after: passes when created_at is after the threshold', () => {
|
|
90
|
+
expect(matchesDateFilter(
|
|
91
|
+
{ created_at: '2026-03-25T12:00:00Z' },
|
|
92
|
+
{ created_after: '2026-03-24T00:00:00Z' }
|
|
93
|
+
)).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('created_after: fails when created_at is before the threshold', () => {
|
|
97
|
+
expect(matchesDateFilter(
|
|
98
|
+
{ created_at: '2026-03-23T00:00:00Z' },
|
|
99
|
+
{ created_after: '2026-03-24T00:00:00Z' }
|
|
100
|
+
)).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('created_after: fails when created_at is missing', () => {
|
|
104
|
+
expect(matchesDateFilter(
|
|
105
|
+
{ updated_at: '2026-03-25T00:00:00Z' },
|
|
106
|
+
{ created_after: '2026-03-24T00:00:00Z' }
|
|
107
|
+
)).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// created_before
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
it('created_before: passes when created_at is before the threshold', () => {
|
|
115
|
+
expect(matchesDateFilter(
|
|
116
|
+
{ created_at: '2026-03-20T00:00:00Z' },
|
|
117
|
+
{ created_before: '2026-03-25T00:00:00Z' }
|
|
118
|
+
)).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('created_before: fails when created_at is after the threshold', () => {
|
|
122
|
+
expect(matchesDateFilter(
|
|
123
|
+
{ created_at: '2026-03-26T00:00:00Z' },
|
|
124
|
+
{ created_before: '2026-03-25T00:00:00Z' }
|
|
125
|
+
)).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Combined filters (time window)
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
it('updated_after + updated_before: passes when updated_at is within window', () => {
|
|
133
|
+
expect(matchesDateFilter(
|
|
134
|
+
{ updated_at: '2026-03-22T00:00:00Z' },
|
|
135
|
+
{ updated_after: '2026-03-20T00:00:00Z', updated_before: '2026-03-25T00:00:00Z' }
|
|
136
|
+
)).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('updated_after + updated_before: fails when updated_at is outside window', () => {
|
|
140
|
+
expect(matchesDateFilter(
|
|
141
|
+
{ updated_at: '2026-03-26T00:00:00Z' },
|
|
142
|
+
{ updated_after: '2026-03-20T00:00:00Z', updated_before: '2026-03-25T00:00:00Z' }
|
|
143
|
+
)).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('all four filters: passes only when both dates are within their windows', () => {
|
|
147
|
+
expect(matchesDateFilter(
|
|
148
|
+
{ updated_at: '2026-03-22T00:00:00Z', created_at: '2026-03-10T00:00:00Z' },
|
|
149
|
+
{
|
|
150
|
+
updated_after: '2026-03-20T00:00:00Z',
|
|
151
|
+
updated_before: '2026-03-25T00:00:00Z',
|
|
152
|
+
created_after: '2026-03-05T00:00:00Z',
|
|
153
|
+
created_before: '2026-03-15T00:00:00Z',
|
|
154
|
+
}
|
|
155
|
+
)).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('all four filters: fails when one date is out of range', () => {
|
|
159
|
+
expect(matchesDateFilter(
|
|
160
|
+
{ updated_at: '2026-03-22T00:00:00Z', created_at: '2026-03-16T00:00:00Z' }, // created_at too late
|
|
161
|
+
{
|
|
162
|
+
updated_after: '2026-03-20T00:00:00Z',
|
|
163
|
+
updated_before: '2026-03-25T00:00:00Z',
|
|
164
|
+
created_after: '2026-03-05T00:00:00Z',
|
|
165
|
+
created_before: '2026-03-15T00:00:00Z',
|
|
166
|
+
}
|
|
167
|
+
)).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
});
|