@dpesch/mantisbt-mcp-server 1.0.3 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,274 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { MantisClient } from '../../src/client.js';
3
+ import { registerFileTools } from '../../src/tools/files.js';
4
+ import { MockMcpServer, makeResponse } from '../helpers/mock-server.js';
5
+
6
+ vi.mock('node:fs/promises', () => ({
7
+ readFile: vi.fn(),
8
+ }));
9
+
10
+ // Import after mock so the mock is in place
11
+ import { readFile } from 'node:fs/promises';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Setup
15
+ // ---------------------------------------------------------------------------
16
+
17
+ let mockServer: MockMcpServer;
18
+ let client: MantisClient;
19
+
20
+ beforeEach(() => {
21
+ mockServer = new MockMcpServer();
22
+ client = new MantisClient('https://mantis.example.com', 'test-token');
23
+ registerFileTools(mockServer as never, client);
24
+ vi.stubGlobal('fetch', vi.fn());
25
+ });
26
+
27
+ afterEach(() => {
28
+ vi.unstubAllGlobals();
29
+ vi.clearAllMocks();
30
+ });
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // list_issue_files
34
+ // ---------------------------------------------------------------------------
35
+
36
+ describe('list_issue_files', () => {
37
+ it('is registered', () => {
38
+ expect(mockServer.hasToolRegistered('list_issue_files')).toBe(true);
39
+ });
40
+
41
+ it('returns an attachment array', async () => {
42
+ const apiResponse = {
43
+ issues: [{
44
+ id: 42,
45
+ attachments: [
46
+ { id: 1, file_name: 'screenshot.png', size: 12345, content_type: 'image/png' },
47
+ ],
48
+ }],
49
+ };
50
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(apiResponse)));
51
+
52
+ const result = await mockServer.callTool('list_issue_files', { issue_id: 42 });
53
+
54
+ expect(result.isError).toBeUndefined();
55
+ const parsed = JSON.parse(result.content[0]!.text) as Array<{ file_name: string }>;
56
+ expect(Array.isArray(parsed)).toBe(true);
57
+ expect(parsed[0]!.file_name).toBe('screenshot.png');
58
+ });
59
+
60
+ it('returns an empty array when there are no attachments', async () => {
61
+ const apiResponse = { issues: [{ id: 42 }] };
62
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(apiResponse)));
63
+
64
+ const result = await mockServer.callTool('list_issue_files', { issue_id: 42 });
65
+
66
+ expect(result.isError).toBeUndefined();
67
+ const parsed = JSON.parse(result.content[0]!.text) as unknown[];
68
+ expect(parsed).toEqual([]);
69
+ });
70
+
71
+ it('calls the correct endpoint', async () => {
72
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ issues: [{ id: 42 }] })));
73
+
74
+ await mockServer.callTool('list_issue_files', { issue_id: 42 });
75
+
76
+ const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
77
+ expect(calledUrl).toContain('issues/42');
78
+ });
79
+ });
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // upload_file – file_path mode
83
+ // ---------------------------------------------------------------------------
84
+
85
+ describe('upload_file', () => {
86
+ it('is registered', () => {
87
+ expect(mockServer.hasToolRegistered('upload_file')).toBe(true);
88
+ });
89
+
90
+ it('reads the file and posts to the correct endpoint', async () => {
91
+ vi.mocked(readFile).mockResolvedValue(Buffer.from('file content') as never);
92
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 5, file_name: 'report.pdf' })));
93
+
94
+ await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/report.pdf' });
95
+
96
+ expect(readFile).toHaveBeenCalledWith('/tmp/report.pdf');
97
+ const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
98
+ expect(calledUrl).toContain('issues/42/files');
99
+ });
100
+
101
+ it('sends a POST request with FormData', async () => {
102
+ vi.mocked(readFile).mockResolvedValue(Buffer.from('content') as never);
103
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 5 })));
104
+
105
+ await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/test.txt' });
106
+
107
+ const options = vi.mocked(fetch).mock.calls[0]![1] as RequestInit;
108
+ expect(options.method).toBe('POST');
109
+ expect(options.body).toBeInstanceOf(FormData);
110
+ });
111
+
112
+ it('does not set Content-Type header (set automatically by fetch)', async () => {
113
+ vi.mocked(readFile).mockResolvedValue(Buffer.from('content') as never);
114
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 5 })));
115
+
116
+ await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/test.txt' });
117
+
118
+ const options = vi.mocked(fetch).mock.calls[0]![1] as RequestInit;
119
+ const headers = options.headers as Record<string, string>;
120
+ expect(headers['Content-Type']).toBeUndefined();
121
+ });
122
+
123
+ it('appends description when provided', async () => {
124
+ vi.mocked(readFile).mockResolvedValue(Buffer.from('content') as never);
125
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 5 })));
126
+
127
+ await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/test.txt', description: 'My attachment' });
128
+
129
+ const options = vi.mocked(fetch).mock.calls[0]![1] as RequestInit;
130
+ const formData = options.body as FormData;
131
+ expect(formData.get('description')).toBe('My attachment');
132
+ });
133
+
134
+ it('returns the API response', async () => {
135
+ vi.mocked(readFile).mockResolvedValue(Buffer.from('content') as never);
136
+ const apiResponse = { id: 5, file_name: 'report.pdf', size: 7 };
137
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(apiResponse)));
138
+
139
+ const result = await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/report.pdf' });
140
+
141
+ expect(result.isError).toBeUndefined();
142
+ const parsed = JSON.parse(result.content[0]!.text) as { file_name: string };
143
+ expect(parsed.file_name).toBe('report.pdf');
144
+ });
145
+
146
+ it('returns isError when the file is not found', async () => {
147
+ vi.mocked(readFile).mockRejectedValue(new Error("ENOENT: no such file or directory, open '/tmp/missing.txt'") as never);
148
+
149
+ const result = await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/missing.txt' });
150
+
151
+ expect(result.isError).toBe(true);
152
+ expect(result.content[0]!.text).toContain('Error:');
153
+ });
154
+
155
+ it('returns isError on API error', async () => {
156
+ vi.mocked(readFile).mockResolvedValue(Buffer.from('content') as never);
157
+ vi.mocked(fetch).mockResolvedValue(makeResponse(403, JSON.stringify({ message: 'Forbidden' })));
158
+
159
+ const result = await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/test.txt' });
160
+
161
+ expect(result.isError).toBe(true);
162
+ expect(result.content[0]!.text).toContain('Error:');
163
+ });
164
+
165
+ it('overrides the filename when filename is provided', async () => {
166
+ vi.mocked(readFile).mockResolvedValue(Buffer.from('content') as never);
167
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 5 })));
168
+
169
+ await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/report.pdf', filename: 'custom-name.pdf' });
170
+
171
+ const options = vi.mocked(fetch).mock.calls[0]![1] as RequestInit;
172
+ const formData = options.body as FormData;
173
+ const fileEntry = formData.get('file') as File;
174
+ expect(fileEntry.name).toBe('custom-name.pdf');
175
+ });
176
+ });
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // upload_file – Base64 mode
180
+ // ---------------------------------------------------------------------------
181
+
182
+ describe('upload_file (Base64)', () => {
183
+ it('decodes Base64 content and uploads it', async () => {
184
+ const originalContent = 'Hello, World!';
185
+ const base64Content = Buffer.from(originalContent).toString('base64');
186
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 7, file_name: 'hello.txt' })));
187
+
188
+ const result = await mockServer.callTool('upload_file', {
189
+ issue_id: 42,
190
+ content: base64Content,
191
+ filename: 'hello.txt',
192
+ });
193
+
194
+ expect(result.isError).toBeUndefined();
195
+ expect(readFile).not.toHaveBeenCalled();
196
+ const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
197
+ expect(calledUrl).toContain('issues/42/files');
198
+ });
199
+
200
+ it('uses the provided filename', async () => {
201
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 7 })));
202
+
203
+ await mockServer.callTool('upload_file', {
204
+ issue_id: 42,
205
+ content: Buffer.from('data').toString('base64'),
206
+ filename: 'export.csv',
207
+ });
208
+
209
+ const options = vi.mocked(fetch).mock.calls[0]![1] as RequestInit;
210
+ const formData = options.body as FormData;
211
+ const fileEntry = formData.get('file') as File;
212
+ expect(fileEntry.name).toBe('export.csv');
213
+ });
214
+
215
+ it('sets the content_type when provided', async () => {
216
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 7 })));
217
+
218
+ await mockServer.callTool('upload_file', {
219
+ issue_id: 42,
220
+ content: Buffer.from('data').toString('base64'),
221
+ filename: 'image.png',
222
+ content_type: 'image/png',
223
+ });
224
+
225
+ const options = vi.mocked(fetch).mock.calls[0]![1] as RequestInit;
226
+ const formData = options.body as FormData;
227
+ const fileEntry = formData.get('file') as File;
228
+ expect(fileEntry.type).toBe('image/png');
229
+ });
230
+
231
+ it('returns isError when neither file_path nor content is provided', async () => {
232
+ const result = await mockServer.callTool('upload_file', { issue_id: 42 });
233
+
234
+ expect(result.isError).toBe(true);
235
+ expect(result.content[0]!.text).toContain('Either file_path or content');
236
+ });
237
+
238
+ it('returns isError when both file_path and content are provided', async () => {
239
+ vi.mocked(readFile).mockResolvedValue(Buffer.from('x') as never);
240
+
241
+ const result = await mockServer.callTool('upload_file', {
242
+ issue_id: 42,
243
+ file_path: '/tmp/test.txt',
244
+ content: Buffer.from('x').toString('base64'),
245
+ filename: 'test.txt',
246
+ });
247
+
248
+ expect(result.isError).toBe(true);
249
+ expect(result.content[0]!.text).toContain('Only one of');
250
+ });
251
+
252
+ it('returns isError when content is provided without filename', async () => {
253
+ const result = await mockServer.callTool('upload_file', {
254
+ issue_id: 42,
255
+ content: Buffer.from('data').toString('base64'),
256
+ });
257
+
258
+ expect(result.isError).toBe(true);
259
+ expect(result.content[0]!.text).toContain('filename is required');
260
+ });
261
+
262
+ it('returns isError on API error', async () => {
263
+ vi.mocked(fetch).mockResolvedValue(makeResponse(500, JSON.stringify({ message: 'Internal Server Error' })));
264
+
265
+ const result = await mockServer.callTool('upload_file', {
266
+ issue_id: 42,
267
+ content: Buffer.from('data').toString('base64'),
268
+ filename: 'test.txt',
269
+ });
270
+
271
+ expect(result.isError).toBe(true);
272
+ expect(result.content[0]!.text).toContain('Error:');
273
+ });
274
+ });
@@ -4,7 +4,8 @@ import { join, dirname } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { MantisClient } from '../../src/client.js';
6
6
  import { registerIssueTools } from '../../src/tools/issues.js';
7
- import { MockMcpServer } from '../helpers/mock-server.js';
7
+ import { MockMcpServer, makeResponse } from '../helpers/mock-server.js';
8
+ import { MANTIS_RESOLVED_STATUS_ID } from '../../src/constants.js';
8
9
 
9
10
  const __filename = fileURLToPath(import.meta.url);
10
11
  const __dirname = dirname(__filename);
@@ -22,22 +23,13 @@ const getIssueFixture = existsSync(getIssueFixturePath)
22
23
  : { issues: [{ id: 42, summary: 'Test Issue' }] };
23
24
 
24
25
  const listIssuesFixture = existsSync(listIssuesFixturePath)
25
- ? (JSON.parse(readFileSync(listIssuesFixturePath, 'utf-8')) as { issues: Array<{ id: number; summary: string }>; total_count: number })
26
- : { issues: [{ id: 42, summary: 'Test Issue' }], total_count: 1 };
26
+ ? (JSON.parse(readFileSync(listIssuesFixturePath, 'utf-8')) as { issues: Array<{ id: number; summary: string; status: { id: number; name: string } }>; total_count: number })
27
+ : { issues: [{ id: 42, summary: 'Test Issue', status: { id: 80, name: 'resolved' } }], total_count: 1 };
27
28
 
28
- // ---------------------------------------------------------------------------
29
- // Helper: minimale Response nachbauen
30
- // ---------------------------------------------------------------------------
31
-
32
- function makeResponse(status: number, body: string): Response {
33
- return {
34
- ok: status >= 200 && status < 300,
35
- status,
36
- statusText: `Status ${status}`,
37
- text: () => Promise.resolve(body),
38
- headers: { get: (_key: string) => null },
39
- } as unknown as Response;
40
- }
29
+ const recordedListIssuesPath = join(fixturesDir, 'recorded', 'list_issues.json');
30
+ const recordedListIssuesFixture = existsSync(recordedListIssuesPath)
31
+ ? (JSON.parse(readFileSync(recordedListIssuesPath, 'utf-8')) as { issues: Array<{ id: number; summary: string; status: { id: number; name: string } }> })
32
+ : null;
41
33
 
42
34
  // ---------------------------------------------------------------------------
43
35
  // Setup
@@ -127,4 +119,93 @@ describe('list_issues', () => {
127
119
  const url = new URL(calledUrl);
128
120
  expect(url.searchParams.get('project_id')).toBe('7');
129
121
  });
122
+
123
+ it('passes select as query parameter', async () => {
124
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
125
+
126
+ await mockServer.callTool('list_issues', { select: 'id,summary,status', page: 1, page_size: 10 });
127
+
128
+ const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
129
+ const url = new URL(calledUrl);
130
+ expect(url.searchParams.get('select')).toBe('id,summary,status');
131
+ });
132
+
133
+ it('status "open" filters to issues with status.id < 80', async () => {
134
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
135
+
136
+ const result = await mockServer.callTool('list_issues', { status: 'open', page: 1, page_size: 50 });
137
+
138
+ expect(result.isError).toBeUndefined();
139
+ const parsed = JSON.parse(result.content[0]!.text) as { issues: Array<{ status: { id: number } }> };
140
+ expect(parsed.issues.length).toBeGreaterThan(0);
141
+ parsed.issues.forEach(issue => {
142
+ expect(issue.status.id).toBeLessThan(MANTIS_RESOLVED_STATUS_ID);
143
+ });
144
+ });
145
+
146
+ it('status "resolved" filters to resolved issues only', async () => {
147
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
148
+
149
+ const result = await mockServer.callTool('list_issues', { status: 'resolved', page: 1, page_size: 50 });
150
+
151
+ expect(result.isError).toBeUndefined();
152
+ const parsed = JSON.parse(result.content[0]!.text) as { issues: Array<{ status: { name: string } }> };
153
+ expect(parsed.issues.length).toBeGreaterThan(0);
154
+ parsed.issues.forEach(issue => {
155
+ expect(issue.status.name).toBe('resolved');
156
+ });
157
+ });
158
+
159
+ it('status "new" filters to new issues only', async () => {
160
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
161
+
162
+ const result = await mockServer.callTool('list_issues', { status: 'new', page: 1, page_size: 50 });
163
+
164
+ expect(result.isError).toBeUndefined();
165
+ const parsed = JSON.parse(result.content[0]!.text) as { issues: Array<{ id: number; status: { name: string } }> };
166
+ expect(parsed.issues).toHaveLength(1);
167
+ expect(parsed.issues[0]!.id).toBe(7901);
168
+ expect(parsed.issues[0]!.status.name).toBe('new');
169
+ });
170
+
171
+ it('status filter is case-insensitive', async () => {
172
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
173
+ const resultUpper = await mockServer.callTool('list_issues', { status: 'OPEN', page: 1, page_size: 50 });
174
+
175
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
176
+ const resultLower = await mockServer.callTool('list_issues', { status: 'open', page: 1, page_size: 50 });
177
+
178
+ const parsedUpper = JSON.parse(resultUpper.content[0]!.text) as { issues: unknown[] };
179
+ const parsedLower = JSON.parse(resultLower.content[0]!.text) as { issues: unknown[] };
180
+ expect(parsedUpper.issues.length).toBe(parsedLower.issues.length);
181
+ expect(parsedUpper.issues).toEqual(parsedLower.issues);
182
+ });
183
+ });
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // list_issues – recorded fixtures
187
+ // ---------------------------------------------------------------------------
188
+
189
+ describe('list_issues – recorded fixtures', () => {
190
+ it.skipIf(!recordedListIssuesFixture)('status "open" filter matches open issues in recorded fixture', async () => {
191
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(recordedListIssuesFixture!)));
192
+
193
+ const result = await mockServer.callTool('list_issues', { status: 'open', page: 1, page_size: 50 });
194
+
195
+ expect(result.isError).toBeUndefined();
196
+ const parsed = JSON.parse(result.content[0]!.text) as { issues: unknown[] };
197
+ const openInFixture = recordedListIssuesFixture!.issues.filter(i => i.status.id < 80).length;
198
+ expect(parsed.issues).toHaveLength(openInFixture);
199
+ });
200
+
201
+ it.skipIf(!recordedListIssuesFixture)('status "resolved" filter matches resolved issues in recorded fixture', async () => {
202
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(recordedListIssuesFixture!)));
203
+
204
+ const result = await mockServer.callTool('list_issues', { status: 'resolved', page: 1, page_size: 50 });
205
+
206
+ expect(result.isError).toBeUndefined();
207
+ const parsed = JSON.parse(result.content[0]!.text) as { issues: unknown[] };
208
+ const resolvedInFixture = recordedListIssuesFixture!.issues.filter(i => i.status.id >= 80).length;
209
+ expect(parsed.issues).toHaveLength(resolvedInFixture);
210
+ });
130
211
  });
@@ -0,0 +1,261 @@
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
+
6
+ // vi.mock must be at module top level — vitest hoists it automatically
7
+ vi.mock('node:fs/promises');
8
+
9
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
10
+ import { MantisClient } from '../../src/client.js';
11
+ import { MetadataCache, type CachedMetadata } from '../../src/cache.js';
12
+ import { registerMetadataTools } from '../../src/tools/metadata.js';
13
+ import { MockMcpServer, makeResponse } from '../helpers/mock-server.js';
14
+
15
+ const __filename = fileURLToPath(import.meta.url);
16
+ const __dirname = dirname(__filename);
17
+ const recordedFixturesDir = join(__dirname, '..', 'fixtures', 'recorded');
18
+
19
+ // Recorded fixture: single issue returned by issues?page=1&page_size=1
20
+ const sampleFixturePath = join(recordedFixturesDir, 'get_issue_fields_sample.json');
21
+ const recordedSampleFixture = existsSync(sampleFixturePath)
22
+ ? (JSON.parse(readFileSync(sampleFixturePath, 'utf-8')) as { issues: Array<Record<string, unknown>> })
23
+ : null;
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Helpers
27
+ // ---------------------------------------------------------------------------
28
+
29
+ const CACHE_DIR = '/tmp/test-cache-metadata';
30
+ const TTL = 3600;
31
+
32
+ function makeServer(): MockMcpServer {
33
+ return new MockMcpServer();
34
+ }
35
+
36
+ function makeClient(): MantisClient {
37
+ return new MantisClient('https://mantis.example.com', 'test-token');
38
+ }
39
+
40
+ function makeCache(): MetadataCache {
41
+ return new MetadataCache(CACHE_DIR, TTL);
42
+ }
43
+
44
+ function makeSampleMetadata(): CachedMetadata {
45
+ return {
46
+ timestamp: Date.now(),
47
+ projects: [{ id: 1, name: 'Test Project' }],
48
+ byProject: {},
49
+ };
50
+ }
51
+
52
+ function makeValidMetadataCacheFile(data: CachedMetadata): string {
53
+ return JSON.stringify({ timestamp: Date.now(), data });
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Setup
58
+ // ---------------------------------------------------------------------------
59
+
60
+ let mockServer: MockMcpServer;
61
+ let client: MantisClient;
62
+ let cache: MetadataCache;
63
+
64
+ beforeEach(() => {
65
+ vi.resetAllMocks();
66
+ mockServer = makeServer();
67
+ client = makeClient();
68
+ cache = makeCache();
69
+ registerMetadataTools(mockServer as never, client, cache);
70
+ vi.stubGlobal('fetch', vi.fn());
71
+ });
72
+
73
+ afterEach(() => {
74
+ vi.unstubAllGlobals();
75
+ });
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // get_metadata
79
+ // ---------------------------------------------------------------------------
80
+
81
+ describe('get_metadata', () => {
82
+ it('is registered', () => {
83
+ expect(mockServer.hasToolRegistered('get_metadata')).toBe(true);
84
+ });
85
+
86
+ it('loads from cache when valid (single file read — no re-fetch)', async () => {
87
+ const metadata = makeSampleMetadata();
88
+ vi.mocked(readFile).mockResolvedValue(makeValidMetadataCacheFile(metadata) as any);
89
+
90
+ const result = await mockServer.callTool('get_metadata', {});
91
+
92
+ expect(result.isError).toBeUndefined();
93
+ // fetch must NOT have been called — data came from cache
94
+ expect(fetch).not.toHaveBeenCalled();
95
+ const parsed = JSON.parse(result.content[0]!.text) as CachedMetadata;
96
+ expect(parsed.projects).toEqual(metadata.projects);
97
+ });
98
+
99
+ it('fetches and caches when cache is missing', async () => {
100
+ vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
101
+ vi.mocked(mkdir).mockResolvedValue(undefined);
102
+ vi.mocked(writeFile).mockResolvedValue(undefined);
103
+
104
+ const projectsResponse = { projects: [{ id: 1, name: 'Test Project' }] };
105
+ const emptyResponse = { users: [], versions: [], categories: [] };
106
+ vi.mocked(fetch)
107
+ .mockResolvedValueOnce(makeResponse(200, JSON.stringify(projectsResponse)))
108
+ .mockResolvedValue(makeResponse(200, JSON.stringify(emptyResponse)));
109
+
110
+ const result = await mockServer.callTool('get_metadata', {});
111
+
112
+ expect(result.isError).toBeUndefined();
113
+ expect(fetch).toHaveBeenCalled();
114
+ expect(writeFile).toHaveBeenCalled();
115
+ const writtenPath = vi.mocked(writeFile).mock.calls[0]![0] as string;
116
+ expect(writtenPath).toContain('metadata.json');
117
+ });
118
+ });
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // get_issue_fields
122
+ // ---------------------------------------------------------------------------
123
+
124
+ describe('get_issue_fields', () => {
125
+ it('is registered', () => {
126
+ expect(mockServer.hasToolRegistered('get_issue_fields')).toBe(true);
127
+ });
128
+
129
+ it('returns cached fields when cache is valid', async () => {
130
+ const cachedFields = ['id', 'summary', 'status', 'priority', 'attachments'];
131
+ const cacheFile = JSON.stringify({ timestamp: Date.now(), fields: cachedFields });
132
+
133
+ // loadIssueFields reads from issue_fields.json
134
+ vi.mocked(readFile).mockImplementation(async (path) => {
135
+ if (String(path).includes('issue_fields.json')) {
136
+ return cacheFile as any;
137
+ }
138
+ throw new Error('ENOENT');
139
+ });
140
+
141
+ const result = await mockServer.callTool('get_issue_fields', {});
142
+
143
+ expect(result.isError).toBeUndefined();
144
+ // fetch must NOT have been called — data came from cache
145
+ expect(fetch).not.toHaveBeenCalled();
146
+ const parsed = JSON.parse(result.content[0]!.text) as { fields: string[]; source: string };
147
+ expect(parsed.source).toBe('cache');
148
+ expect(parsed.fields).toEqual(cachedFields);
149
+ });
150
+
151
+ it('fetches sample issue and discovers fields', async () => {
152
+ // Cache miss
153
+ vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
154
+ vi.mocked(mkdir).mockResolvedValue(undefined);
155
+ vi.mocked(writeFile).mockResolvedValue(undefined);
156
+
157
+ const sampleIssue = {
158
+ id: 42,
159
+ summary: 'Sample',
160
+ status: { id: 10, name: 'new' },
161
+ priority: { id: 30, name: 'normal' },
162
+ reporter: { id: 1, name: 'user' },
163
+ };
164
+ const issuesResponse = { issues: [sampleIssue] };
165
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(issuesResponse)));
166
+
167
+ const result = await mockServer.callTool('get_issue_fields', {});
168
+
169
+ expect(result.isError).toBeUndefined();
170
+ const parsed = JSON.parse(result.content[0]!.text) as { fields: string[]; source: string };
171
+ expect(parsed.source).toBe('live');
172
+ // Fields from sample issue
173
+ expect(parsed.fields).toContain('id');
174
+ expect(parsed.fields).toContain('summary');
175
+ expect(parsed.fields).toContain('status');
176
+ // Fields from EMPTY_STRIPPED_FIELDS that are always merged in
177
+ expect(parsed.fields).toContain('attachments');
178
+ expect(parsed.fields).toContain('notes');
179
+ expect(parsed.fields).toContain('relationships');
180
+ });
181
+
182
+ it('falls back to static list when no issues available', async () => {
183
+ // Cache miss
184
+ vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
185
+ vi.mocked(mkdir).mockResolvedValue(undefined);
186
+ vi.mocked(writeFile).mockResolvedValue(undefined);
187
+
188
+ const emptyIssuesResponse = { issues: [] };
189
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(emptyIssuesResponse)));
190
+
191
+ const result = await mockServer.callTool('get_issue_fields', {});
192
+
193
+ expect(result.isError).toBeUndefined();
194
+ const parsed = JSON.parse(result.content[0]!.text) as { fields: string[]; source: string };
195
+ expect(parsed.source).toBe('static');
196
+ // Static list must contain common fields
197
+ expect(parsed.fields).toContain('id');
198
+ expect(parsed.fields).toContain('summary');
199
+ expect(parsed.fields).toContain('status');
200
+ expect(parsed.fields).toContain('attachments');
201
+ expect(parsed.fields).toContain('notes');
202
+ });
203
+
204
+ it('caches discovered fields after fetching', async () => {
205
+ // Cache miss
206
+ vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
207
+ vi.mocked(mkdir).mockResolvedValue(undefined);
208
+ vi.mocked(writeFile).mockResolvedValue(undefined);
209
+
210
+ const sampleIssue = { id: 1, summary: 'Test', status: { id: 10, name: 'new' } };
211
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ issues: [sampleIssue] })));
212
+
213
+ await mockServer.callTool('get_issue_fields', {});
214
+
215
+ // writeFile must have been called with issue_fields.json path
216
+ expect(writeFile).toHaveBeenCalled();
217
+ const writtenPath = vi.mocked(writeFile).mock.calls.find(
218
+ call => String(call[0]).includes('issue_fields.json')
219
+ );
220
+ expect(writtenPath).toBeDefined();
221
+
222
+ // Written content must be valid JSON with a fields array
223
+ const writtenContent = writtenPath![1] as string;
224
+ const parsed = JSON.parse(writtenContent) as { timestamp: number; fields: string[] };
225
+ expect(Array.isArray(parsed.fields)).toBe(true);
226
+ expect(parsed.fields.length).toBeGreaterThan(0);
227
+ expect(typeof parsed.timestamp).toBe('number');
228
+ });
229
+ });
230
+
231
+ // ---------------------------------------------------------------------------
232
+ // get_issue_fields – recorded fixtures
233
+ // ---------------------------------------------------------------------------
234
+
235
+ describe('get_issue_fields – recorded fixtures', () => {
236
+ it.skipIf(!recordedSampleFixture)('discovers fields from recorded sample issue', async () => {
237
+ // Cache miss — force live discovery
238
+ vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
239
+ vi.mocked(mkdir).mockResolvedValue(undefined);
240
+ vi.mocked(writeFile).mockResolvedValue(undefined);
241
+
242
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(recordedSampleFixture!)));
243
+
244
+ const result = await mockServer.callTool('get_issue_fields', {});
245
+
246
+ expect(result.isError).toBeUndefined();
247
+ const parsed = JSON.parse(result.content[0]!.text) as { fields: string[]; source: string };
248
+ expect(parsed.source).toBe('live');
249
+
250
+ // Every top-level key of the recorded sample issue must be in the discovered fields
251
+ const sampleKeys = Object.keys(recordedSampleFixture!.issues[0]!);
252
+ for (const key of sampleKeys) {
253
+ expect(parsed.fields).toContain(key);
254
+ }
255
+
256
+ // EMPTY_STRIPPED_FIELDS must always be present even if absent from the sample
257
+ expect(parsed.fields).toContain('attachments');
258
+ expect(parsed.fields).toContain('notes');
259
+ expect(parsed.fields).toContain('relationships');
260
+ });
261
+ });