@dpesch/mantisbt-mcp-server 1.10.3 → 1.11.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 +41 -0
- package/README.de.md +11 -8
- package/README.md +11 -8
- package/dist/config.js +13 -0
- package/dist/index.js +12 -12
- package/dist/tools/files.js +11 -2
- package/dist/tools/issues.js +47 -15
- package/dist/tools/notes.js +15 -10
- package/docs/cookbook.de.md +67 -0
- package/docs/cookbook.md +67 -0
- package/docs/examples.de.md +8 -0
- package/docs/examples.md +8 -0
- package/package.json +1 -1
- package/server.json +2 -2
- package/.gitea/PULL_REQUEST_TEMPLATE.md +0 -17
- package/.gitea/workflows/ci.yml +0 -95
- package/.github/workflows/ci.yml +0 -32
- package/scripts/hooks/pre-push.mjs +0 -220
- package/scripts/init.mjs +0 -95
- package/scripts/record-fixtures.ts +0 -160
- package/tests/cache.test.ts +0 -265
- package/tests/client.test.ts +0 -372
- package/tests/config.test.ts +0 -263
- package/tests/fixtures/get_current_user.json +0 -40
- package/tests/fixtures/get_issue.json +0 -133
- package/tests/fixtures/get_issue_enums.json +0 -67
- package/tests/fixtures/get_issue_fields_sample.json +0 -76
- package/tests/fixtures/get_project_categories.json +0 -157
- package/tests/fixtures/get_project_versions.json +0 -3
- package/tests/fixtures/get_project_versions_with_data.json +0 -52
- package/tests/fixtures/list_issues.json +0 -95
- package/tests/fixtures/list_projects.json +0 -65
- package/tests/helpers/mock-server.ts +0 -166
- package/tests/helpers/search-mocks.ts +0 -84
- package/tests/prompts/prompts.test.ts +0 -242
- package/tests/resources/resources.test.ts +0 -309
- package/tests/search/embedder.test.ts +0 -81
- package/tests/search/highlight.test.ts +0 -129
- package/tests/search/store.test.ts +0 -193
- package/tests/search/sync.test.ts +0 -249
- package/tests/search/tools.test.ts +0 -661
- package/tests/tools/config.test.ts +0 -212
- package/tests/tools/files.test.ts +0 -343
- package/tests/tools/issues.test.ts +0 -1180
- package/tests/tools/metadata.test.ts +0 -509
- package/tests/tools/monitors.test.ts +0 -101
- package/tests/tools/projects.test.ts +0 -338
- package/tests/tools/relationships.test.ts +0 -177
- package/tests/tools/string-coercion.test.ts +0 -317
- package/tests/tools/users.test.ts +0 -62
- package/tests/utils/date-filter.test.ts +0 -169
- package/tests/version-hint.test.ts +0 -230
- package/tsconfig.build.json +0 -8
- package/vitest.config.ts +0 -8
|
@@ -1,1180 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
3
|
-
import { join, dirname } from 'node:path';
|
|
4
|
-
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import { MantisClient } from '../../src/client.js';
|
|
6
|
-
import { registerIssueTools } from '../../src/tools/issues.js';
|
|
7
|
-
import { MetadataCache } from '../../src/cache.js';
|
|
8
|
-
import { MockMcpServer, makeResponse } from '../helpers/mock-server.js';
|
|
9
|
-
import { MANTIS_RESOLVED_STATUS_ID } from '../../src/constants.js';
|
|
10
|
-
import { clearIssueEnumCache } from '../../src/tools/config.js';
|
|
11
|
-
|
|
12
|
-
function makeStubCache(projectUsers?: Array<{ id: number; name: string; real_name?: string }>): MetadataCache {
|
|
13
|
-
return {
|
|
14
|
-
loadIfValid: vi.fn(async () => projectUsers ? {
|
|
15
|
-
timestamp: Date.now(),
|
|
16
|
-
projects: [],
|
|
17
|
-
tags: [],
|
|
18
|
-
byProject: { 1: { users: projectUsers, versions: [], categories: [] } },
|
|
19
|
-
} : null),
|
|
20
|
-
} as unknown as MetadataCache;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
24
|
-
const __dirname = dirname(__filename);
|
|
25
|
-
const fixturesDir = join(__dirname, '..', 'fixtures');
|
|
26
|
-
|
|
27
|
-
// ---------------------------------------------------------------------------
|
|
28
|
-
// Fixtures laden (mit Inline-Fallback)
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
|
|
31
|
-
const getIssueFixturePath = join(fixturesDir, 'get_issue.json');
|
|
32
|
-
const listIssuesFixturePath = join(fixturesDir, 'list_issues.json');
|
|
33
|
-
|
|
34
|
-
const getIssueFixture = existsSync(getIssueFixturePath)
|
|
35
|
-
? (JSON.parse(readFileSync(getIssueFixturePath, 'utf-8')) as { issues: Array<{ id: number; summary: string; notes?: Array<{ id: number }> }> })
|
|
36
|
-
: { issues: [{ id: 42, summary: 'Test Issue' }] };
|
|
37
|
-
|
|
38
|
-
const listIssuesFixture = existsSync(listIssuesFixturePath)
|
|
39
|
-
? (JSON.parse(readFileSync(listIssuesFixturePath, 'utf-8')) as { issues: Array<{ id: number; summary: string; status: { id: number; name: string } }>; total_count: number })
|
|
40
|
-
: { issues: [{ id: 42, summary: 'Test Issue', status: { id: 80, name: 'resolved' } }], total_count: 1 };
|
|
41
|
-
|
|
42
|
-
const recordedListIssuesPath = join(fixturesDir, 'recorded', 'list_issues.json');
|
|
43
|
-
const recordedListIssuesFixture = existsSync(recordedListIssuesPath)
|
|
44
|
-
? (JSON.parse(readFileSync(recordedListIssuesPath, 'utf-8')) as { issues: Array<{ id: number; summary: string; status: { id: number; name: string } }> })
|
|
45
|
-
: null;
|
|
46
|
-
|
|
47
|
-
// ---------------------------------------------------------------------------
|
|
48
|
-
// Setup
|
|
49
|
-
// ---------------------------------------------------------------------------
|
|
50
|
-
|
|
51
|
-
let mockServer: MockMcpServer;
|
|
52
|
-
let client: MantisClient;
|
|
53
|
-
|
|
54
|
-
beforeEach(() => {
|
|
55
|
-
mockServer = new MockMcpServer();
|
|
56
|
-
client = new MantisClient('https://mantis.example.com', 'test-token');
|
|
57
|
-
registerIssueTools(mockServer as never, client, makeStubCache());
|
|
58
|
-
vi.stubGlobal('fetch', vi.fn());
|
|
59
|
-
clearIssueEnumCache();
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
afterEach(() => {
|
|
63
|
-
vi.unstubAllGlobals();
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
// ---------------------------------------------------------------------------
|
|
67
|
-
// get_issue
|
|
68
|
-
// ---------------------------------------------------------------------------
|
|
69
|
-
|
|
70
|
-
describe('get_issue', () => {
|
|
71
|
-
it('ist registriert', () => {
|
|
72
|
-
expect(mockServer.hasToolRegistered('get_issue')).toBe(true);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it('gibt issue-Daten aus der Fixture zurück', async () => {
|
|
76
|
-
const issueId = getIssueFixture.issues[0]!.id;
|
|
77
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(getIssueFixture)));
|
|
78
|
-
|
|
79
|
-
const result = await mockServer.callTool('get_issue', { id: issueId });
|
|
80
|
-
|
|
81
|
-
expect(result.isError).toBeUndefined();
|
|
82
|
-
const parsed = JSON.parse(result.content[0]!.text) as { id: number };
|
|
83
|
-
expect(parsed.id).toBe(issueId);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it('ruft den richtigen Endpoint auf', async () => {
|
|
87
|
-
const issueId = getIssueFixture.issues[0]!.id;
|
|
88
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(getIssueFixture)));
|
|
89
|
-
|
|
90
|
-
await mockServer.callTool('get_issue', { id: issueId });
|
|
91
|
-
|
|
92
|
-
const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
|
|
93
|
-
expect(calledUrl).toContain(`issues/${issueId}`);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it('gibt isError: true bei 404 zurück', async () => {
|
|
97
|
-
vi.mocked(fetch).mockResolvedValue(
|
|
98
|
-
makeResponse(404, JSON.stringify({ message: 'Issue not found' })),
|
|
99
|
-
);
|
|
100
|
-
|
|
101
|
-
const result = await mockServer.callTool('get_issue', { id: 9999 });
|
|
102
|
-
|
|
103
|
-
expect(result.isError).toBe(true);
|
|
104
|
-
expect(result.content[0]!.text).toContain('Error:');
|
|
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
|
-
});
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
// ---------------------------------------------------------------------------
|
|
242
|
-
// create_issue
|
|
243
|
-
// ---------------------------------------------------------------------------
|
|
244
|
-
|
|
245
|
-
describe('create_issue', () => {
|
|
246
|
-
it('ist registriert', () => {
|
|
247
|
-
expect(mockServer.hasToolRegistered('create_issue')).toBe(true);
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
it('sends severity as { id: 50 } (minor) by default when no severity is provided', async () => {
|
|
251
|
-
// Regression: omitting severity caused MantisBT to store 0 → displayed as "@0@".
|
|
252
|
-
// The server now resolves canonical English names to IDs so MantisBT language setting
|
|
253
|
-
// does not affect how the value is stored.
|
|
254
|
-
// validate: true ensures Zod defaults are applied before the handler runs.
|
|
255
|
-
vi.mocked(fetch).mockResolvedValue(
|
|
256
|
-
makeResponse(201, JSON.stringify({ issue: { id: 100, summary: 'Test' } }))
|
|
257
|
-
);
|
|
258
|
-
|
|
259
|
-
await mockServer.callTool('create_issue', {
|
|
260
|
-
summary: 'Test issue',
|
|
261
|
-
description: 'Test description.',
|
|
262
|
-
project_id: 1,
|
|
263
|
-
category: 'General',
|
|
264
|
-
}, { validate: true });
|
|
265
|
-
|
|
266
|
-
const body = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) as Record<string, unknown>;
|
|
267
|
-
expect(body.severity).toEqual({ id: 50 });
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
it('returns full issue object when API responds with complete issue', async () => {
|
|
271
|
-
const fullIssue = { id: 100, summary: 'New issue', status: { id: 10, name: 'new' }, severity: { id: 50, name: 'minor' } };
|
|
272
|
-
vi.mocked(fetch).mockResolvedValue(
|
|
273
|
-
makeResponse(201, JSON.stringify({ issue: fullIssue }))
|
|
274
|
-
);
|
|
275
|
-
|
|
276
|
-
const result = await mockServer.callTool('create_issue', {
|
|
277
|
-
summary: 'New issue', description: 'New issue description.', project_id: 1, category: 'General',
|
|
278
|
-
}, { validate: true });
|
|
279
|
-
|
|
280
|
-
expect(result.isError).toBeUndefined();
|
|
281
|
-
const parsed = JSON.parse(result.content[0]!.text) as typeof fullIssue;
|
|
282
|
-
expect(parsed.id).toBe(100);
|
|
283
|
-
expect(parsed.summary).toBe('New issue');
|
|
284
|
-
// Only one API call — no extra GET needed
|
|
285
|
-
expect(fetch).toHaveBeenCalledTimes(1);
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
it('fetches full issue via GET when API returns only an id (older MantisBT)', async () => {
|
|
289
|
-
const fullIssue = { id: 101, summary: 'Created issue', status: { id: 10, name: 'new' } };
|
|
290
|
-
vi.mocked(fetch)
|
|
291
|
-
.mockResolvedValueOnce(makeResponse(201, JSON.stringify({ id: 101 })))
|
|
292
|
-
.mockResolvedValueOnce(makeResponse(200, JSON.stringify({ issues: [fullIssue] })));
|
|
293
|
-
|
|
294
|
-
const result = await mockServer.callTool('create_issue', {
|
|
295
|
-
summary: 'Created issue', description: 'Created issue description.', project_id: 1, category: 'General',
|
|
296
|
-
}, { validate: true });
|
|
297
|
-
|
|
298
|
-
expect(result.isError).toBeUndefined();
|
|
299
|
-
const parsed = JSON.parse(result.content[0]!.text) as typeof fullIssue;
|
|
300
|
-
expect(parsed.summary).toBe('Created issue');
|
|
301
|
-
// Two API calls: POST + GET
|
|
302
|
-
expect(fetch).toHaveBeenCalledTimes(2);
|
|
303
|
-
const getUrl = vi.mocked(fetch).mock.calls[1]![0] as string;
|
|
304
|
-
expect(getUrl).toContain('issues/101');
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
it('returns minimal object when GET fallback fails (issue was already created)', async () => {
|
|
308
|
-
vi.mocked(fetch)
|
|
309
|
-
.mockResolvedValueOnce(makeResponse(201, JSON.stringify({ id: 102 })))
|
|
310
|
-
.mockResolvedValueOnce(makeResponse(500, 'Server Error'));
|
|
311
|
-
|
|
312
|
-
const result = await mockServer.callTool('create_issue', {
|
|
313
|
-
summary: 'Test', description: 'Test description.', project_id: 1, category: 'General',
|
|
314
|
-
}, { validate: true });
|
|
315
|
-
|
|
316
|
-
expect(result.isError).toBeUndefined();
|
|
317
|
-
const parsed = JSON.parse(result.content[0]!.text) as { id: number };
|
|
318
|
-
expect(parsed.id).toBe(102);
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
it('respects an explicitly passed severity', async () => {
|
|
322
|
-
vi.mocked(fetch).mockResolvedValue(
|
|
323
|
-
makeResponse(201, JSON.stringify({ issue: { id: 101, summary: 'Test' } }))
|
|
324
|
-
);
|
|
325
|
-
|
|
326
|
-
await mockServer.callTool('create_issue', {
|
|
327
|
-
summary: 'Crash bug',
|
|
328
|
-
description: 'Crash bug description.',
|
|
329
|
-
project_id: 1,
|
|
330
|
-
category: 'General',
|
|
331
|
-
severity: 'crash',
|
|
332
|
-
}, { validate: true });
|
|
333
|
-
|
|
334
|
-
const body = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) as Record<string, unknown>;
|
|
335
|
-
expect(body.severity).toEqual({ id: 70 });
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
it('returns a validation error when description is missing', async () => {
|
|
339
|
-
const result = await mockServer.callTool('create_issue', {
|
|
340
|
-
summary: 'Test',
|
|
341
|
-
project_id: 1,
|
|
342
|
-
category: 'General',
|
|
343
|
-
}, { validate: true });
|
|
344
|
-
|
|
345
|
-
expect(result.isError).toBe(true);
|
|
346
|
-
expect(fetch).not.toHaveBeenCalled();
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
it('returns a validation error when description is empty', async () => {
|
|
350
|
-
const result = await mockServer.callTool('create_issue', {
|
|
351
|
-
summary: 'Test',
|
|
352
|
-
description: '',
|
|
353
|
-
project_id: 1,
|
|
354
|
-
category: 'General',
|
|
355
|
-
}, { validate: true });
|
|
356
|
-
|
|
357
|
-
expect(result.isError).toBe(true);
|
|
358
|
-
expect(fetch).not.toHaveBeenCalled();
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
it('returns an error for an unknown severity name', async () => {
|
|
362
|
-
const result = await mockServer.callTool('create_issue', {
|
|
363
|
-
summary: 'Test',
|
|
364
|
-
description: 'Test description.',
|
|
365
|
-
project_id: 1,
|
|
366
|
-
category: 'General',
|
|
367
|
-
severity: 'schwerer Fehler',
|
|
368
|
-
}, { validate: true });
|
|
369
|
-
|
|
370
|
-
expect(result.isError).toBe(true);
|
|
371
|
-
expect(result.content[0]!.text).toContain('schwerer Fehler');
|
|
372
|
-
expect(result.content[0]!.text).toContain('minor');
|
|
373
|
-
});
|
|
374
|
-
|
|
375
|
-
it('creates issue without any optional fields (backward compatibility)', async () => {
|
|
376
|
-
vi.mocked(fetch).mockResolvedValue(
|
|
377
|
-
makeResponse(201, JSON.stringify({ issue: { id: 300, summary: 'Minimal' } }))
|
|
378
|
-
);
|
|
379
|
-
|
|
380
|
-
const result = await mockServer.callTool('create_issue', {
|
|
381
|
-
summary: 'Minimal issue',
|
|
382
|
-
description: 'Minimal description.',
|
|
383
|
-
project_id: 1,
|
|
384
|
-
category: 'General',
|
|
385
|
-
}, { validate: true });
|
|
386
|
-
|
|
387
|
-
expect(result.isError).toBeUndefined();
|
|
388
|
-
const body = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) as Record<string, unknown>;
|
|
389
|
-
expect(body.version).toBeUndefined();
|
|
390
|
-
expect(body.target_version).toBeUndefined();
|
|
391
|
-
expect(body.fixed_in_version).toBeUndefined();
|
|
392
|
-
expect(body.steps_to_reproduce).toBeUndefined();
|
|
393
|
-
expect(body.additional_information).toBeUndefined();
|
|
394
|
-
expect(body.reproducibility).toBeUndefined();
|
|
395
|
-
expect(body.view_state).toBeUndefined();
|
|
396
|
-
});
|
|
397
|
-
|
|
398
|
-
it('sends version fields as { name } objects', async () => {
|
|
399
|
-
vi.mocked(fetch).mockResolvedValue(
|
|
400
|
-
makeResponse(201, JSON.stringify({ issue: { id: 301, summary: 'With versions' } }))
|
|
401
|
-
);
|
|
402
|
-
|
|
403
|
-
await mockServer.callTool('create_issue', {
|
|
404
|
-
summary: 'Version test',
|
|
405
|
-
description: 'Version test description.',
|
|
406
|
-
project_id: 1,
|
|
407
|
-
category: 'General',
|
|
408
|
-
version: '1.0.0',
|
|
409
|
-
target_version: '1.1.0',
|
|
410
|
-
fixed_in_version: '1.0.1',
|
|
411
|
-
}, { validate: true });
|
|
412
|
-
|
|
413
|
-
const body = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) as Record<string, unknown>;
|
|
414
|
-
expect(body.version).toEqual({ name: '1.0.0' });
|
|
415
|
-
expect(body.target_version).toEqual({ name: '1.1.0' });
|
|
416
|
-
expect(body.fixed_in_version).toEqual({ name: '1.0.1' });
|
|
417
|
-
});
|
|
418
|
-
|
|
419
|
-
it('sends steps_to_reproduce and additional_information as plain strings', async () => {
|
|
420
|
-
vi.mocked(fetch).mockResolvedValue(
|
|
421
|
-
makeResponse(201, JSON.stringify({ issue: { id: 302, summary: 'With text fields' } }))
|
|
422
|
-
);
|
|
423
|
-
|
|
424
|
-
await mockServer.callTool('create_issue', {
|
|
425
|
-
summary: 'Text fields test',
|
|
426
|
-
description: 'Description.',
|
|
427
|
-
project_id: 1,
|
|
428
|
-
category: 'General',
|
|
429
|
-
steps_to_reproduce: '1. Open app\n2. Click button',
|
|
430
|
-
additional_information: 'Happens only on Windows',
|
|
431
|
-
}, { validate: true });
|
|
432
|
-
|
|
433
|
-
const body = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) as Record<string, unknown>;
|
|
434
|
-
expect(body.steps_to_reproduce).toBe('1. Open app\n2. Click button');
|
|
435
|
-
expect(body.additional_information).toBe('Happens only on Windows');
|
|
436
|
-
});
|
|
437
|
-
|
|
438
|
-
it('resolves reproducibility to { id } via enum lookup', async () => {
|
|
439
|
-
vi.mocked(fetch).mockResolvedValue(
|
|
440
|
-
makeResponse(201, JSON.stringify({ issue: { id: 303, summary: 'Reproducibility test' } }))
|
|
441
|
-
);
|
|
442
|
-
|
|
443
|
-
await mockServer.callTool('create_issue', {
|
|
444
|
-
summary: 'Repro test',
|
|
445
|
-
description: 'Description.',
|
|
446
|
-
project_id: 1,
|
|
447
|
-
category: 'General',
|
|
448
|
-
reproducibility: 'always',
|
|
449
|
-
}, { validate: true });
|
|
450
|
-
|
|
451
|
-
const body = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) as Record<string, unknown>;
|
|
452
|
-
expect(body.reproducibility).toEqual({ id: 10 });
|
|
453
|
-
});
|
|
454
|
-
|
|
455
|
-
it('returns error for unknown reproducibility name', async () => {
|
|
456
|
-
const result = await mockServer.callTool('create_issue', {
|
|
457
|
-
summary: 'Repro test',
|
|
458
|
-
description: 'Description.',
|
|
459
|
-
project_id: 1,
|
|
460
|
-
category: 'General',
|
|
461
|
-
reproducibility: 'immer',
|
|
462
|
-
}, { validate: true });
|
|
463
|
-
|
|
464
|
-
expect(result.isError).toBe(true);
|
|
465
|
-
expect(result.content[0]!.text).toContain('immer');
|
|
466
|
-
expect(result.content[0]!.text).toContain('always');
|
|
467
|
-
});
|
|
468
|
-
|
|
469
|
-
it('returns error for empty reproducibility string', async () => {
|
|
470
|
-
const result = await mockServer.callTool('create_issue', {
|
|
471
|
-
summary: 'Repro test',
|
|
472
|
-
description: 'Description.',
|
|
473
|
-
project_id: 1,
|
|
474
|
-
category: 'General',
|
|
475
|
-
reproducibility: '',
|
|
476
|
-
}, { validate: true });
|
|
477
|
-
|
|
478
|
-
expect(result.isError).toBe(true);
|
|
479
|
-
});
|
|
480
|
-
|
|
481
|
-
it('sends view_state as { name } object', async () => {
|
|
482
|
-
vi.mocked(fetch).mockResolvedValue(
|
|
483
|
-
makeResponse(201, JSON.stringify({ issue: { id: 304, summary: 'Private issue' } }))
|
|
484
|
-
);
|
|
485
|
-
|
|
486
|
-
await mockServer.callTool('create_issue', {
|
|
487
|
-
summary: 'Private issue',
|
|
488
|
-
description: 'Description.',
|
|
489
|
-
project_id: 1,
|
|
490
|
-
category: 'General',
|
|
491
|
-
view_state: 'private',
|
|
492
|
-
}, { validate: true });
|
|
493
|
-
|
|
494
|
-
const body = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) as Record<string, unknown>;
|
|
495
|
-
expect(body.view_state).toEqual({ name: 'private' });
|
|
496
|
-
});
|
|
497
|
-
|
|
498
|
-
it('rejects invalid view_state values', async () => {
|
|
499
|
-
const result = await mockServer.callTool('create_issue', {
|
|
500
|
-
summary: 'Test',
|
|
501
|
-
description: 'Description.',
|
|
502
|
-
project_id: 1,
|
|
503
|
-
category: 'General',
|
|
504
|
-
view_state: 'restricted',
|
|
505
|
-
}, { validate: true });
|
|
506
|
-
|
|
507
|
-
expect(result.isError).toBe(true);
|
|
508
|
-
expect(fetch).not.toHaveBeenCalled();
|
|
509
|
-
});
|
|
510
|
-
|
|
511
|
-
it('sends all new optional fields together in a single request', async () => {
|
|
512
|
-
vi.mocked(fetch).mockResolvedValue(
|
|
513
|
-
makeResponse(201, JSON.stringify({ issue: { id: 305, summary: 'Full issue' } }))
|
|
514
|
-
);
|
|
515
|
-
|
|
516
|
-
await mockServer.callTool('create_issue', {
|
|
517
|
-
summary: 'Full issue',
|
|
518
|
-
description: 'Full description.',
|
|
519
|
-
project_id: 1,
|
|
520
|
-
category: 'General',
|
|
521
|
-
version: '2.0.0',
|
|
522
|
-
target_version: '2.1.0',
|
|
523
|
-
fixed_in_version: '2.0.1',
|
|
524
|
-
steps_to_reproduce: 'Step 1',
|
|
525
|
-
additional_information: 'Extra info',
|
|
526
|
-
reproducibility: 'sometimes',
|
|
527
|
-
view_state: 'public',
|
|
528
|
-
}, { validate: true });
|
|
529
|
-
|
|
530
|
-
expect(fetch).toHaveBeenCalledTimes(1);
|
|
531
|
-
const body = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) as Record<string, unknown>;
|
|
532
|
-
expect(body.version).toEqual({ name: '2.0.0' });
|
|
533
|
-
expect(body.target_version).toEqual({ name: '2.1.0' });
|
|
534
|
-
expect(body.fixed_in_version).toEqual({ name: '2.0.1' });
|
|
535
|
-
expect(body.steps_to_reproduce).toBe('Step 1');
|
|
536
|
-
expect(body.additional_information).toBe('Extra info');
|
|
537
|
-
expect(body.reproducibility).toEqual({ id: 30 });
|
|
538
|
-
expect(body.view_state).toEqual({ name: 'public' });
|
|
539
|
-
});
|
|
540
|
-
});
|
|
541
|
-
|
|
542
|
-
// ---------------------------------------------------------------------------
|
|
543
|
-
// create_issue – handler username
|
|
544
|
-
// ---------------------------------------------------------------------------
|
|
545
|
-
|
|
546
|
-
describe('create_issue – handler username', () => {
|
|
547
|
-
it('resolves handler username to id from cache and sets handler in body', async () => {
|
|
548
|
-
const cache = makeStubCache([{ id: 7, name: 'dom' }, { id: 8, name: 'jane' }]);
|
|
549
|
-
const server = new MockMcpServer();
|
|
550
|
-
registerIssueTools(server as never, client, cache);
|
|
551
|
-
|
|
552
|
-
vi.mocked(fetch).mockResolvedValue(
|
|
553
|
-
makeResponse(201, JSON.stringify({ issue: { id: 200, summary: 'New issue', handler: { id: 7, name: 'dom' } } }))
|
|
554
|
-
);
|
|
555
|
-
|
|
556
|
-
await server.callTool('create_issue', {
|
|
557
|
-
summary: 'Test', description: 'Test description.', project_id: 1, category: 'General', handler: 'dom',
|
|
558
|
-
}, { validate: true });
|
|
559
|
-
|
|
560
|
-
const body = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) as { handler: { id: number } };
|
|
561
|
-
expect(body.handler).toEqual({ id: 7 });
|
|
562
|
-
});
|
|
563
|
-
|
|
564
|
-
it('resolves handler by real_name when name does not match', async () => {
|
|
565
|
-
const cache = makeStubCache([{ id: 9, name: 'jdoe', real_name: 'John Doe' }]);
|
|
566
|
-
const server = new MockMcpServer();
|
|
567
|
-
registerIssueTools(server as never, client, cache);
|
|
568
|
-
|
|
569
|
-
vi.mocked(fetch).mockResolvedValue(
|
|
570
|
-
makeResponse(201, JSON.stringify({ issue: { id: 201, summary: 'New' } }))
|
|
571
|
-
);
|
|
572
|
-
|
|
573
|
-
await server.callTool('create_issue', {
|
|
574
|
-
summary: 'Test', description: 'Test description.', project_id: 1, category: 'General', handler: 'John Doe',
|
|
575
|
-
}, { validate: true });
|
|
576
|
-
|
|
577
|
-
const body = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) as { handler: { id: number } };
|
|
578
|
-
expect(body.handler).toEqual({ id: 9 });
|
|
579
|
-
});
|
|
580
|
-
|
|
581
|
-
it('fetches users from API when cache returns null', async () => {
|
|
582
|
-
const cache = makeStubCache(); // returns null from loadIfValid
|
|
583
|
-
const server = new MockMcpServer();
|
|
584
|
-
registerIssueTools(server as never, client, cache);
|
|
585
|
-
|
|
586
|
-
vi.mocked(fetch)
|
|
587
|
-
.mockResolvedValueOnce(makeResponse(200, JSON.stringify({ users: [{ id: 42, name: 'alice' }] })))
|
|
588
|
-
.mockResolvedValueOnce(makeResponse(201, JSON.stringify({ issue: { id: 202, summary: 'New' } })));
|
|
589
|
-
|
|
590
|
-
await server.callTool('create_issue', {
|
|
591
|
-
summary: 'Test', description: 'Test description.', project_id: 1, category: 'General', handler: 'alice',
|
|
592
|
-
}, { validate: true });
|
|
593
|
-
|
|
594
|
-
const projectUsersCall = vi.mocked(fetch).mock.calls[0]![0] as string;
|
|
595
|
-
expect(projectUsersCall).toContain('projects/1/users');
|
|
596
|
-
|
|
597
|
-
const createBody = JSON.parse(vi.mocked(fetch).mock.calls[1]![1]!.body as string) as { handler: { id: number } };
|
|
598
|
-
expect(createBody.handler).toEqual({ id: 42 });
|
|
599
|
-
});
|
|
600
|
-
|
|
601
|
-
it('returns error when handler username is not found', async () => {
|
|
602
|
-
const cache = makeStubCache([{ id: 7, name: 'dom' }]);
|
|
603
|
-
const server = new MockMcpServer();
|
|
604
|
-
registerIssueTools(server as never, client, cache);
|
|
605
|
-
|
|
606
|
-
const result = await server.callTool('create_issue', {
|
|
607
|
-
summary: 'Test', description: 'Test description.', project_id: 1, category: 'General', handler: 'nonexistent',
|
|
608
|
-
});
|
|
609
|
-
|
|
610
|
-
expect(result.isError).toBe(true);
|
|
611
|
-
expect(result.content[0]!.text).toContain('nonexistent');
|
|
612
|
-
expect(result.content[0]!.text).toContain('dom');
|
|
613
|
-
expect(fetch).not.toHaveBeenCalled();
|
|
614
|
-
});
|
|
615
|
-
|
|
616
|
-
it('handler_id takes precedence over handler username', async () => {
|
|
617
|
-
const cache = makeStubCache([{ id: 7, name: 'dom' }]);
|
|
618
|
-
const server = new MockMcpServer();
|
|
619
|
-
registerIssueTools(server as never, client, cache);
|
|
620
|
-
|
|
621
|
-
vi.mocked(fetch).mockResolvedValue(
|
|
622
|
-
makeResponse(201, JSON.stringify({ issue: { id: 203, summary: 'New' } }))
|
|
623
|
-
);
|
|
624
|
-
|
|
625
|
-
await server.callTool('create_issue', {
|
|
626
|
-
summary: 'Test', description: 'Test description.', project_id: 1, category: 'General', handler_id: 99, handler: 'dom',
|
|
627
|
-
}, { validate: true });
|
|
628
|
-
|
|
629
|
-
const body = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) as { handler: { id: number } };
|
|
630
|
-
expect(body.handler).toEqual({ id: 99 });
|
|
631
|
-
});
|
|
632
|
-
});
|
|
633
|
-
|
|
634
|
-
// ---------------------------------------------------------------------------
|
|
635
|
-
// list_issues
|
|
636
|
-
// ---------------------------------------------------------------------------
|
|
637
|
-
|
|
638
|
-
describe('list_issues', () => {
|
|
639
|
-
it('ist registriert', () => {
|
|
640
|
-
expect(mockServer.hasToolRegistered('list_issues')).toBe(true);
|
|
641
|
-
});
|
|
642
|
-
|
|
643
|
-
it('gibt Issues-Array zurück', async () => {
|
|
644
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
|
|
645
|
-
|
|
646
|
-
const result = await mockServer.callTool('list_issues', { page: 1, page_size: 3 });
|
|
647
|
-
|
|
648
|
-
expect(result.isError).toBeUndefined();
|
|
649
|
-
const parsed = JSON.parse(result.content[0]!.text) as { issues: unknown[] };
|
|
650
|
-
expect(Array.isArray(parsed.issues)).toBe(true);
|
|
651
|
-
});
|
|
652
|
-
|
|
653
|
-
it('übergibt project_id als Query-Parameter', async () => {
|
|
654
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
|
|
655
|
-
|
|
656
|
-
await mockServer.callTool('list_issues', { project_id: 7, page: 1, page_size: 10 });
|
|
657
|
-
|
|
658
|
-
const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
|
|
659
|
-
const url = new URL(calledUrl);
|
|
660
|
-
expect(url.searchParams.get('project_id')).toBe('7');
|
|
661
|
-
});
|
|
662
|
-
|
|
663
|
-
it('passes select as query parameter', async () => {
|
|
664
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
|
|
665
|
-
|
|
666
|
-
await mockServer.callTool('list_issues', { select: 'id,summary,status', page: 1, page_size: 10 });
|
|
667
|
-
|
|
668
|
-
const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
|
|
669
|
-
const url = new URL(calledUrl);
|
|
670
|
-
expect(url.searchParams.get('select')).toBe('id,summary,status');
|
|
671
|
-
});
|
|
672
|
-
|
|
673
|
-
it('status "open" filters to issues with status.id < 80', async () => {
|
|
674
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
|
|
675
|
-
|
|
676
|
-
const result = await mockServer.callTool('list_issues', { status: 'open', page: 1, page_size: 50 });
|
|
677
|
-
|
|
678
|
-
expect(result.isError).toBeUndefined();
|
|
679
|
-
const parsed = JSON.parse(result.content[0]!.text) as { issues: Array<{ status: { id: number } }> };
|
|
680
|
-
expect(parsed.issues.length).toBeGreaterThan(0);
|
|
681
|
-
parsed.issues.forEach(issue => {
|
|
682
|
-
expect(issue.status.id).toBeLessThan(MANTIS_RESOLVED_STATUS_ID);
|
|
683
|
-
});
|
|
684
|
-
});
|
|
685
|
-
|
|
686
|
-
it('status "resolved" filters to resolved issues only', async () => {
|
|
687
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
|
|
688
|
-
|
|
689
|
-
const result = await mockServer.callTool('list_issues', { status: 'resolved', page: 1, page_size: 50 });
|
|
690
|
-
|
|
691
|
-
expect(result.isError).toBeUndefined();
|
|
692
|
-
const parsed = JSON.parse(result.content[0]!.text) as { issues: Array<{ status: { name: string } }> };
|
|
693
|
-
const resolvedInFixture = listIssuesFixture.issues.filter(i => i.status.name === 'resolved').length;
|
|
694
|
-
expect(parsed.issues).toHaveLength(resolvedInFixture);
|
|
695
|
-
parsed.issues.forEach(issue => {
|
|
696
|
-
expect(issue.status.name).toBe('resolved');
|
|
697
|
-
});
|
|
698
|
-
});
|
|
699
|
-
|
|
700
|
-
it('status "new" filters to new issues only', async () => {
|
|
701
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
|
|
702
|
-
|
|
703
|
-
const result = await mockServer.callTool('list_issues', { status: 'new', page: 1, page_size: 50 });
|
|
704
|
-
|
|
705
|
-
expect(result.isError).toBeUndefined();
|
|
706
|
-
const parsed = JSON.parse(result.content[0]!.text) as { issues: Array<{ id: number; status: { name: string } }> };
|
|
707
|
-
const newInFixture = listIssuesFixture.issues.filter(i => i.status.name === 'new');
|
|
708
|
-
expect(parsed.issues).toHaveLength(newInFixture.length);
|
|
709
|
-
if (newInFixture.length > 0) {
|
|
710
|
-
expect(parsed.issues[0]!.id).toBe(newInFixture[0]!.id);
|
|
711
|
-
expect(parsed.issues[0]!.status.name).toBe('new');
|
|
712
|
-
}
|
|
713
|
-
});
|
|
714
|
-
|
|
715
|
-
it('assigned_to scans multiple API pages (small page_size does not miss results)', async () => {
|
|
716
|
-
// Regression: previously fetched only page_size items from the API before filtering,
|
|
717
|
-
// so assigned_to:X with page_size:1 returned 0 results when user's issue was not
|
|
718
|
-
// in the first page returned by the server.
|
|
719
|
-
// Now the tool always fetches API_PAGE_SIZE=50 internally when filters are active.
|
|
720
|
-
const fixtureWithUser51: typeof listIssuesFixture = {
|
|
721
|
-
...listIssuesFixture,
|
|
722
|
-
issues: [
|
|
723
|
-
// first "slot" has a different user — previously this would have been the only
|
|
724
|
-
// item fetched with page_size:1, causing a false empty result
|
|
725
|
-
listIssuesFixture.issues.find(i => (i as { handler?: { id: number } }).handler?.id === 52)!,
|
|
726
|
-
...listIssuesFixture.issues.filter(i => (i as { handler?: { id: number } }).handler?.id === 51),
|
|
727
|
-
].filter(Boolean),
|
|
728
|
-
};
|
|
729
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(fixtureWithUser51)));
|
|
730
|
-
|
|
731
|
-
const result = await mockServer.callTool('list_issues', { assigned_to: 51, page: 1, page_size: 1 });
|
|
732
|
-
|
|
733
|
-
expect(result.isError).toBeUndefined();
|
|
734
|
-
const parsed = JSON.parse(result.content[0]!.text) as { issues: Array<{ handler: { id: number } }> };
|
|
735
|
-
// Must find results for user 51 despite page_size:1
|
|
736
|
-
expect(parsed.issues.length).toBeGreaterThan(0);
|
|
737
|
-
parsed.issues.forEach(issue => expect(issue.handler.id).toBe(51));
|
|
738
|
-
});
|
|
739
|
-
|
|
740
|
-
it('assigned_to filters issues by handler.id (client-side)', async () => {
|
|
741
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
|
|
742
|
-
|
|
743
|
-
const result = await mockServer.callTool('list_issues', { assigned_to: 51, page: 1, page_size: 50 });
|
|
744
|
-
|
|
745
|
-
expect(result.isError).toBeUndefined();
|
|
746
|
-
const parsed = JSON.parse(result.content[0]!.text) as { issues: Array<{ handler: { id: number } }> };
|
|
747
|
-
const expectedCount = listIssuesFixture.issues.filter(i => (i as { handler?: { id: number } }).handler?.id === 51).length;
|
|
748
|
-
expect(parsed.issues).toHaveLength(expectedCount);
|
|
749
|
-
parsed.issues.forEach(issue => {
|
|
750
|
-
expect(issue.handler.id).toBe(51);
|
|
751
|
-
});
|
|
752
|
-
});
|
|
753
|
-
|
|
754
|
-
it('assigned_to still sends the parameter to the API (server-side hint)', async () => {
|
|
755
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
|
|
756
|
-
|
|
757
|
-
await mockServer.callTool('list_issues', { assigned_to: 51, page: 1, page_size: 50 });
|
|
758
|
-
|
|
759
|
-
const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
|
|
760
|
-
const url = new URL(calledUrl);
|
|
761
|
-
expect(url.searchParams.get('assigned_to')).toBe('51');
|
|
762
|
-
});
|
|
763
|
-
|
|
764
|
-
it('reporter_id filters issues by reporter.id (client-side)', async () => {
|
|
765
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
|
|
766
|
-
|
|
767
|
-
const result = await mockServer.callTool('list_issues', { reporter_id: 52, page: 1, page_size: 50 });
|
|
768
|
-
|
|
769
|
-
expect(result.isError).toBeUndefined();
|
|
770
|
-
const parsed = JSON.parse(result.content[0]!.text) as { issues: Array<{ reporter: { id: number } }> };
|
|
771
|
-
const expectedCount = listIssuesFixture.issues.filter(i => (i as { reporter?: { id: number } }).reporter?.id === 52).length;
|
|
772
|
-
expect(parsed.issues).toHaveLength(expectedCount);
|
|
773
|
-
parsed.issues.forEach(issue => {
|
|
774
|
-
expect(issue.reporter.id).toBe(52);
|
|
775
|
-
});
|
|
776
|
-
});
|
|
777
|
-
|
|
778
|
-
it('status filter is case-insensitive', async () => {
|
|
779
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
|
|
780
|
-
const resultUpper = await mockServer.callTool('list_issues', { status: 'OPEN', page: 1, page_size: 50 });
|
|
781
|
-
|
|
782
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
|
|
783
|
-
const resultLower = await mockServer.callTool('list_issues', { status: 'open', page: 1, page_size: 50 });
|
|
784
|
-
|
|
785
|
-
const parsedUpper = JSON.parse(resultUpper.content[0]!.text) as { issues: unknown[] };
|
|
786
|
-
const parsedLower = JSON.parse(resultLower.content[0]!.text) as { issues: unknown[] };
|
|
787
|
-
expect(parsedUpper.issues.length).toBe(parsedLower.issues.length);
|
|
788
|
-
expect(parsedUpper.issues).toEqual(parsedLower.issues);
|
|
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
|
-
});
|
|
829
|
-
});
|
|
830
|
-
|
|
831
|
-
// ---------------------------------------------------------------------------
|
|
832
|
-
// list_issues – recorded fixtures
|
|
833
|
-
// ---------------------------------------------------------------------------
|
|
834
|
-
|
|
835
|
-
describe('list_issues – recorded fixtures', () => {
|
|
836
|
-
it.skipIf(!recordedListIssuesFixture)('status "open" filter matches open issues in recorded fixture', async () => {
|
|
837
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(recordedListIssuesFixture!)));
|
|
838
|
-
|
|
839
|
-
const result = await mockServer.callTool('list_issues', { status: 'open', page: 1, page_size: 50 });
|
|
840
|
-
|
|
841
|
-
expect(result.isError).toBeUndefined();
|
|
842
|
-
const parsed = JSON.parse(result.content[0]!.text) as { issues: unknown[] };
|
|
843
|
-
const openInFixture = recordedListIssuesFixture!.issues.filter(i => i.status.id < 80).length;
|
|
844
|
-
expect(parsed.issues).toHaveLength(openInFixture);
|
|
845
|
-
});
|
|
846
|
-
|
|
847
|
-
it.skipIf(!recordedListIssuesFixture)('status "resolved" filter matches resolved issues in recorded fixture', async () => {
|
|
848
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(recordedListIssuesFixture!)));
|
|
849
|
-
|
|
850
|
-
const result = await mockServer.callTool('list_issues', { status: 'resolved', page: 1, page_size: 50 });
|
|
851
|
-
|
|
852
|
-
expect(result.isError).toBeUndefined();
|
|
853
|
-
const parsed = JSON.parse(result.content[0]!.text) as { issues: unknown[] };
|
|
854
|
-
const resolvedInFixture = recordedListIssuesFixture!.issues.filter(i => i.status.id >= 80).length;
|
|
855
|
-
expect(parsed.issues).toHaveLength(resolvedInFixture);
|
|
856
|
-
});
|
|
857
|
-
});
|
|
858
|
-
|
|
859
|
-
// ---------------------------------------------------------------------------
|
|
860
|
-
// update_issue – fields allowlist
|
|
861
|
-
// ---------------------------------------------------------------------------
|
|
862
|
-
|
|
863
|
-
describe('update_issue – fields allowlist', () => {
|
|
864
|
-
it('accepts known string fields (summary, description)', async () => {
|
|
865
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ issue: { id: 1, summary: 'Updated' } })));
|
|
866
|
-
|
|
867
|
-
const result = await mockServer.callTool(
|
|
868
|
-
'update_issue',
|
|
869
|
-
{ id: 1, fields: { summary: 'Updated', description: 'New desc' } },
|
|
870
|
-
{ validate: true },
|
|
871
|
-
);
|
|
872
|
-
|
|
873
|
-
expect(result.isError).toBeUndefined();
|
|
874
|
-
});
|
|
875
|
-
|
|
876
|
-
it('accepts known object fields (status, resolution, handler)', async () => {
|
|
877
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ issue: { id: 1 } })));
|
|
878
|
-
|
|
879
|
-
const result = await mockServer.callTool(
|
|
880
|
-
'update_issue',
|
|
881
|
-
{ id: 1, fields: { status: { name: 'resolved' }, resolution: { id: 20 }, handler: { id: 5 } } },
|
|
882
|
-
{ validate: true },
|
|
883
|
-
);
|
|
884
|
-
|
|
885
|
-
expect(result.isError).toBeUndefined();
|
|
886
|
-
});
|
|
887
|
-
|
|
888
|
-
it('rejects unknown fields without calling the API', async () => {
|
|
889
|
-
const result = await mockServer.callTool(
|
|
890
|
-
'update_issue',
|
|
891
|
-
{ id: 1, fields: { reporter: { id: 99 } } },
|
|
892
|
-
{ validate: true },
|
|
893
|
-
);
|
|
894
|
-
|
|
895
|
-
expect(result.isError).toBe(true);
|
|
896
|
-
expect(vi.mocked(fetch)).not.toHaveBeenCalled();
|
|
897
|
-
});
|
|
898
|
-
|
|
899
|
-
it('rejects fields with an unknown key mixed with known keys without calling the API', async () => {
|
|
900
|
-
const result = await mockServer.callTool(
|
|
901
|
-
'update_issue',
|
|
902
|
-
{ id: 1, fields: { summary: 'ok', unknown_field: 'bad' } },
|
|
903
|
-
{ validate: true },
|
|
904
|
-
);
|
|
905
|
-
|
|
906
|
-
expect(result.isError).toBe(true);
|
|
907
|
-
expect(vi.mocked(fetch)).not.toHaveBeenCalled();
|
|
908
|
-
});
|
|
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
|
-
});
|