@dpesch/mantisbt-mcp-server 1.10.3 → 1.10.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/package.json +1 -1
  3. package/server.json +2 -2
  4. package/.gitea/PULL_REQUEST_TEMPLATE.md +0 -17
  5. package/.gitea/workflows/ci.yml +0 -95
  6. package/.github/workflows/ci.yml +0 -32
  7. package/scripts/hooks/pre-push.mjs +0 -220
  8. package/scripts/init.mjs +0 -95
  9. package/scripts/record-fixtures.ts +0 -160
  10. package/tests/cache.test.ts +0 -265
  11. package/tests/client.test.ts +0 -372
  12. package/tests/config.test.ts +0 -263
  13. package/tests/fixtures/get_current_user.json +0 -40
  14. package/tests/fixtures/get_issue.json +0 -133
  15. package/tests/fixtures/get_issue_enums.json +0 -67
  16. package/tests/fixtures/get_issue_fields_sample.json +0 -76
  17. package/tests/fixtures/get_project_categories.json +0 -157
  18. package/tests/fixtures/get_project_versions.json +0 -3
  19. package/tests/fixtures/get_project_versions_with_data.json +0 -52
  20. package/tests/fixtures/list_issues.json +0 -95
  21. package/tests/fixtures/list_projects.json +0 -65
  22. package/tests/helpers/mock-server.ts +0 -166
  23. package/tests/helpers/search-mocks.ts +0 -84
  24. package/tests/prompts/prompts.test.ts +0 -242
  25. package/tests/resources/resources.test.ts +0 -309
  26. package/tests/search/embedder.test.ts +0 -81
  27. package/tests/search/highlight.test.ts +0 -129
  28. package/tests/search/store.test.ts +0 -193
  29. package/tests/search/sync.test.ts +0 -249
  30. package/tests/search/tools.test.ts +0 -661
  31. package/tests/tools/config.test.ts +0 -212
  32. package/tests/tools/files.test.ts +0 -343
  33. package/tests/tools/issues.test.ts +0 -1180
  34. package/tests/tools/metadata.test.ts +0 -509
  35. package/tests/tools/monitors.test.ts +0 -101
  36. package/tests/tools/projects.test.ts +0 -338
  37. package/tests/tools/relationships.test.ts +0 -177
  38. package/tests/tools/string-coercion.test.ts +0 -317
  39. package/tests/tools/users.test.ts +0 -62
  40. package/tests/utils/date-filter.test.ts +0 -169
  41. package/tests/version-hint.test.ts +0 -230
  42. package/tsconfig.build.json +0 -8
  43. package/vitest.config.ts +0 -8
@@ -1,265 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
-
3
- // vi.mock must be at module top level — vitest hoists it automatically
4
- vi.mock('node:fs/promises');
5
-
6
- import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises';
7
- import { MetadataCache, type CachedMetadata } from '../src/cache.js';
8
-
9
- // ---------------------------------------------------------------------------
10
- // Helpers
11
- // ---------------------------------------------------------------------------
12
-
13
- const CACHE_DIR = '/tmp/test-cache';
14
- const TTL = 3600; // 1 hour in seconds
15
-
16
- function makeCache(): MetadataCache {
17
- return new MetadataCache(CACHE_DIR, TTL);
18
- }
19
-
20
- function makeSampleMetadata(): CachedMetadata {
21
- return {
22
- timestamp: Date.now(),
23
- projects: [{ id: 1, name: 'Test Project' }],
24
- byProject: {},
25
- tags: [],
26
- };
27
- }
28
-
29
- // ---------------------------------------------------------------------------
30
- // Setup
31
- // ---------------------------------------------------------------------------
32
-
33
- beforeEach(() => {
34
- vi.resetAllMocks();
35
- });
36
-
37
- // ---------------------------------------------------------------------------
38
- // isValid()
39
- // ---------------------------------------------------------------------------
40
-
41
- describe('MetadataCache – isValid()', () => {
42
- it('returns false when file does not exist (readFile throws)', async () => {
43
- vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
44
-
45
- const cache = makeCache();
46
- await expect(cache.isValid()).resolves.toBe(false);
47
- });
48
-
49
- it('returns true when file is fresh (within TTL)', async () => {
50
- const file = { timestamp: Date.now(), data: makeSampleMetadata() };
51
- vi.mocked(readFile).mockResolvedValue(JSON.stringify(file) as any);
52
-
53
- const cache = makeCache();
54
- await expect(cache.isValid()).resolves.toBe(true);
55
- });
56
-
57
- it('returns false when file has expired (timestamp older than TTL)', async () => {
58
- const expiredTimestamp = Date.now() - (TTL + 1) * 1000;
59
- const file = { timestamp: expiredTimestamp, data: makeSampleMetadata() };
60
- vi.mocked(readFile).mockResolvedValue(JSON.stringify(file) as any);
61
-
62
- const cache = makeCache();
63
- await expect(cache.isValid()).resolves.toBe(false);
64
- });
65
- });
66
-
67
- // ---------------------------------------------------------------------------
68
- // load()
69
- // ---------------------------------------------------------------------------
70
-
71
- describe('MetadataCache – load()', () => {
72
- it('returns null when file does not exist', async () => {
73
- vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
74
-
75
- const cache = makeCache();
76
- await expect(cache.load()).resolves.toBeNull();
77
- });
78
-
79
- it('returns CachedMetadata when file exists', async () => {
80
- const metadata = makeSampleMetadata();
81
- const file = { timestamp: Date.now(), data: metadata };
82
- vi.mocked(readFile).mockResolvedValue(JSON.stringify(file) as any);
83
-
84
- const cache = makeCache();
85
- const result = await cache.load();
86
- expect(result).toEqual(metadata);
87
- });
88
- });
89
-
90
- // ---------------------------------------------------------------------------
91
- // save()
92
- // ---------------------------------------------------------------------------
93
-
94
- describe('MetadataCache – save()', () => {
95
- it('calls mkdir with recursive:true and writeFile with JSON content', async () => {
96
- vi.mocked(mkdir).mockResolvedValue(undefined);
97
- vi.mocked(writeFile).mockResolvedValue(undefined);
98
-
99
- const cache = makeCache();
100
- const metadata = makeSampleMetadata();
101
- await cache.save(metadata);
102
-
103
- expect(mkdir).toHaveBeenCalledWith(CACHE_DIR, { recursive: true });
104
- expect(writeFile).toHaveBeenCalledOnce();
105
-
106
- // Verify the written JSON contains our data
107
- const writtenContent = vi.mocked(writeFile).mock.calls[0][1] as string;
108
- const parsed = JSON.parse(writtenContent) as { timestamp: number; data: CachedMetadata };
109
- expect(parsed.data).toEqual(metadata);
110
- });
111
-
112
- it('writes a timestamp to the cache file', async () => {
113
- vi.mocked(mkdir).mockResolvedValue(undefined);
114
- vi.mocked(writeFile).mockResolvedValue(undefined);
115
-
116
- const before = Date.now();
117
- const cache = makeCache();
118
- await cache.save(makeSampleMetadata());
119
- const after = Date.now();
120
-
121
- const writtenContent = vi.mocked(writeFile).mock.calls[0][1] as string;
122
- const parsed = JSON.parse(writtenContent) as { timestamp: number };
123
- expect(parsed.timestamp).toBeGreaterThanOrEqual(before);
124
- expect(parsed.timestamp).toBeLessThanOrEqual(after);
125
- });
126
- });
127
-
128
- // ---------------------------------------------------------------------------
129
- // invalidate()
130
- // ---------------------------------------------------------------------------
131
-
132
- describe('MetadataCache – invalidate()', () => {
133
- it('calls unlink on the cache file', async () => {
134
- vi.mocked(unlink).mockResolvedValue(undefined);
135
-
136
- const cache = makeCache();
137
- await cache.invalidate();
138
-
139
- expect(unlink).toHaveBeenCalledOnce();
140
- const calledPath = vi.mocked(unlink).mock.calls[0][0] as string;
141
- expect(calledPath).toContain('metadata.json');
142
- });
143
-
144
- it('does not throw when file does not exist (unlink rejects)', async () => {
145
- vi.mocked(unlink).mockRejectedValue(new Error('ENOENT'));
146
-
147
- const cache = makeCache();
148
- await expect(cache.invalidate()).resolves.toBeUndefined();
149
- });
150
- });
151
-
152
- // ---------------------------------------------------------------------------
153
- // loadIfValid()
154
- // ---------------------------------------------------------------------------
155
-
156
- describe('MetadataCache – loadIfValid()', () => {
157
- it('returns null when file does not exist', async () => {
158
- vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
159
-
160
- const cache = makeCache();
161
- await expect(cache.loadIfValid()).resolves.toBeNull();
162
- });
163
-
164
- it('returns data when file is fresh (within TTL)', async () => {
165
- const metadata = makeSampleMetadata();
166
- const file = { timestamp: Date.now(), data: metadata };
167
- vi.mocked(readFile).mockResolvedValue(JSON.stringify(file) as any);
168
-
169
- const cache = makeCache();
170
- const result = await cache.loadIfValid();
171
- expect(result).toEqual(metadata);
172
- });
173
-
174
- it('returns null when file has expired', async () => {
175
- const expiredTimestamp = Date.now() - (TTL + 1) * 1000;
176
- const metadata = makeSampleMetadata();
177
- const file = { timestamp: expiredTimestamp, data: metadata };
178
- vi.mocked(readFile).mockResolvedValue(JSON.stringify(file) as any);
179
-
180
- const cache = makeCache();
181
- await expect(cache.loadIfValid()).resolves.toBeNull();
182
- });
183
-
184
- it('reads from metadata.json path', async () => {
185
- vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
186
-
187
- const cache = makeCache();
188
- await cache.loadIfValid();
189
-
190
- expect(readFile).toHaveBeenCalledOnce();
191
- const calledPath = vi.mocked(readFile).mock.calls[0][0] as string;
192
- expect(calledPath).toContain('metadata.json');
193
- expect(calledPath).not.toContain('issue_fields.json');
194
- });
195
- });
196
-
197
- // ---------------------------------------------------------------------------
198
- // loadIssueFields()
199
- // ---------------------------------------------------------------------------
200
-
201
- describe('MetadataCache – loadIssueFields()', () => {
202
- it('returns null when file does not exist', async () => {
203
- vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
204
-
205
- const cache = makeCache();
206
- await expect(cache.loadIssueFields()).resolves.toBeNull();
207
- });
208
-
209
- it('returns fields array when file is fresh', async () => {
210
- const fields = ['id', 'summary', 'status'];
211
- const file = { timestamp: Date.now(), fields };
212
- vi.mocked(readFile).mockResolvedValue(JSON.stringify(file) as any);
213
-
214
- const cache = makeCache();
215
- const result = await cache.loadIssueFields();
216
- expect(result).toEqual(fields);
217
- });
218
-
219
- it('returns null when file has expired', async () => {
220
- const expiredTimestamp = Date.now() - (TTL + 1) * 1000;
221
- const file = { timestamp: expiredTimestamp, fields: ['id', 'summary'] };
222
- vi.mocked(readFile).mockResolvedValue(JSON.stringify(file) as any);
223
-
224
- const cache = makeCache();
225
- await expect(cache.loadIssueFields()).resolves.toBeNull();
226
- });
227
- });
228
-
229
- // ---------------------------------------------------------------------------
230
- // saveIssueFields()
231
- // ---------------------------------------------------------------------------
232
-
233
- describe('MetadataCache – saveIssueFields()', () => {
234
- it('calls mkdir with recursive:true and writes to issue_fields.json', async () => {
235
- vi.mocked(mkdir).mockResolvedValue(undefined);
236
- vi.mocked(writeFile).mockResolvedValue(undefined);
237
-
238
- const cache = makeCache();
239
- const fields = ['id', 'summary', 'status', 'priority'];
240
- await cache.saveIssueFields(fields);
241
-
242
- expect(mkdir).toHaveBeenCalledWith(CACHE_DIR, { recursive: true });
243
- expect(writeFile).toHaveBeenCalledOnce();
244
-
245
- const calledPath = vi.mocked(writeFile).mock.calls[0][0] as string;
246
- expect(calledPath).toContain('issue_fields.json');
247
- });
248
-
249
- it('written JSON contains the fields array and a timestamp', async () => {
250
- vi.mocked(mkdir).mockResolvedValue(undefined);
251
- vi.mocked(writeFile).mockResolvedValue(undefined);
252
-
253
- const cache = makeCache();
254
- const fields = ['id', 'summary', 'status'];
255
- const before = Date.now();
256
- await cache.saveIssueFields(fields);
257
- const after = Date.now();
258
-
259
- const writtenContent = vi.mocked(writeFile).mock.calls[0][1] as string;
260
- const parsed = JSON.parse(writtenContent) as { timestamp: number; fields: string[] };
261
- expect(parsed.fields).toEqual(fields);
262
- expect(parsed.timestamp).toBeGreaterThanOrEqual(before);
263
- expect(parsed.timestamp).toBeLessThanOrEqual(after);
264
- });
265
- });
@@ -1,372 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { MantisClient, MantisApiError, buildIssueViewUrl, buildNoteViewUrl } from '../src/client.js';
3
-
4
- // ---------------------------------------------------------------------------
5
- // Helpers
6
- // ---------------------------------------------------------------------------
7
-
8
- function makeResponse(
9
- status: number,
10
- body: string,
11
- headers: Record<string, string> = {},
12
- ): Response {
13
- return {
14
- ok: status >= 200 && status < 300,
15
- status,
16
- statusText: `Status ${status}`,
17
- text: () => Promise.resolve(body),
18
- headers: {
19
- get: (key: string) => headers[key] ?? null,
20
- },
21
- } as unknown as Response;
22
- }
23
-
24
- // ---------------------------------------------------------------------------
25
- // Setup
26
- // ---------------------------------------------------------------------------
27
-
28
- beforeEach(() => {
29
- vi.unstubAllGlobals();
30
- });
31
-
32
- // ---------------------------------------------------------------------------
33
- // URL building
34
- // ---------------------------------------------------------------------------
35
-
36
- describe('MantisClient – URL building', () => {
37
- it('appends /api/rest/<path> to the base URL', () => {
38
- const fetchMock = vi.fn().mockResolvedValue(makeResponse(200, '{}'));
39
- vi.stubGlobal('fetch', fetchMock);
40
-
41
- const client = new MantisClient('https://mantis.example.com', 'token123');
42
- return client.get('issues/42').then(() => {
43
- const calledUrl: string = fetchMock.mock.calls[0][0] as string;
44
- expect(calledUrl).toBe('https://mantis.example.com/api/rest/issues/42');
45
- });
46
- });
47
-
48
- it('strips trailing slash from base URL', () => {
49
- const fetchMock = vi.fn().mockResolvedValue(makeResponse(200, '{}'));
50
- vi.stubGlobal('fetch', fetchMock);
51
-
52
- const client = new MantisClient('https://mantis.example.com/', 'token123');
53
- return client.get('issues').then(() => {
54
- const calledUrl: string = fetchMock.mock.calls[0][0] as string;
55
- expect(calledUrl).toBe('https://mantis.example.com/api/rest/issues');
56
- });
57
- });
58
-
59
- it('strips /api/rest suffix when user includes it in the base URL', () => {
60
- const fetchMock = vi.fn().mockResolvedValue(makeResponse(200, '{}'));
61
- vi.stubGlobal('fetch', fetchMock);
62
-
63
- const client = new MantisClient('https://mantis.example.com/api/rest', 'token123');
64
- return client.get('issues/42').then(() => {
65
- const calledUrl: string = fetchMock.mock.calls[0][0] as string;
66
- expect(calledUrl).toBe('https://mantis.example.com/api/rest/issues/42');
67
- });
68
- });
69
-
70
- it('strips /api/rest/ (with trailing slash) from base URL', () => {
71
- const fetchMock = vi.fn().mockResolvedValue(makeResponse(200, '{}'));
72
- vi.stubGlobal('fetch', fetchMock);
73
-
74
- const client = new MantisClient('https://mantis.example.com/api/rest/', 'token123');
75
- return client.get('issues/42').then(() => {
76
- const calledUrl: string = fetchMock.mock.calls[0][0] as string;
77
- expect(calledUrl).toBe('https://mantis.example.com/api/rest/issues/42');
78
- });
79
- });
80
-
81
- it('appends defined query parameters', () => {
82
- const fetchMock = vi.fn().mockResolvedValue(makeResponse(200, '{}'));
83
- vi.stubGlobal('fetch', fetchMock);
84
-
85
- const client = new MantisClient('https://mantis.example.com', 'token123');
86
- return client.get('issues', { page_size: 10, page: 1 }).then(() => {
87
- const calledUrl: string = fetchMock.mock.calls[0][0] as string;
88
- const url = new URL(calledUrl);
89
- expect(url.searchParams.get('page_size')).toBe('10');
90
- expect(url.searchParams.get('page')).toBe('1');
91
- });
92
- });
93
-
94
- it('omits undefined query parameters', () => {
95
- const fetchMock = vi.fn().mockResolvedValue(makeResponse(200, '{}'));
96
- vi.stubGlobal('fetch', fetchMock);
97
-
98
- const client = new MantisClient('https://mantis.example.com', 'token123');
99
- return client.get('issues', { page_size: 10, filter_id: undefined }).then(() => {
100
- const calledUrl: string = fetchMock.mock.calls[0][0] as string;
101
- const url = new URL(calledUrl);
102
- expect(url.searchParams.has('filter_id')).toBe(false);
103
- expect(url.searchParams.get('page_size')).toBe('10');
104
- });
105
- });
106
- });
107
-
108
- // ---------------------------------------------------------------------------
109
- // Request headers
110
- // ---------------------------------------------------------------------------
111
-
112
- describe('MantisClient – request headers', () => {
113
- it('sends Authorization and Content-Type headers', () => {
114
- const fetchMock = vi.fn().mockResolvedValue(makeResponse(200, '{}'));
115
- vi.stubGlobal('fetch', fetchMock);
116
-
117
- const client = new MantisClient('https://mantis.example.com', 'myApiKey');
118
- return client.get('issues').then(() => {
119
- const options = fetchMock.mock.calls[0][1] as RequestInit;
120
- const headers = options.headers as Record<string, string>;
121
- expect(headers['Authorization']).toBe('myApiKey');
122
- expect(headers['Content-Type']).toBe('application/json');
123
- });
124
- });
125
- });
126
-
127
- // ---------------------------------------------------------------------------
128
- // handleResponse
129
- // ---------------------------------------------------------------------------
130
-
131
- describe('MantisClient – handleResponse', () => {
132
- it('returns parsed JSON on 200', () => {
133
- const fetchMock = vi.fn().mockResolvedValue(
134
- makeResponse(200, JSON.stringify({ id: 42 })),
135
- );
136
- vi.stubGlobal('fetch', fetchMock);
137
-
138
- const client = new MantisClient('https://mantis.example.com', 'token');
139
- return client.get<{ id: number }>('issues/42').then((result) => {
140
- expect(result).toEqual({ id: 42 });
141
- });
142
- });
143
-
144
- it('returns undefined on 204 No Content', () => {
145
- const fetchMock = vi.fn().mockResolvedValue(makeResponse(204, ''));
146
- vi.stubGlobal('fetch', fetchMock);
147
-
148
- const client = new MantisClient('https://mantis.example.com', 'token');
149
- return client.delete('issues/42').then((result) => {
150
- expect(result).toBeUndefined();
151
- });
152
- });
153
-
154
- it('throws MantisApiError with statusCode on 4xx', async () => {
155
- const fetchMock = vi.fn().mockResolvedValue(
156
- makeResponse(404, JSON.stringify({ message: 'Issue not found' })),
157
- );
158
- vi.stubGlobal('fetch', fetchMock);
159
-
160
- const client = new MantisClient('https://mantis.example.com', 'token');
161
- await expect(client.get('issues/9999')).rejects.toMatchObject({
162
- statusCode: 404,
163
- name: 'MantisApiError',
164
- });
165
- });
166
-
167
- it('uses raw body as message when JSON has no message field', async () => {
168
- const fetchMock = vi.fn().mockResolvedValue(
169
- makeResponse(403, JSON.stringify({ detail: 'forbidden' })),
170
- );
171
- vi.stubGlobal('fetch', fetchMock);
172
-
173
- const client = new MantisClient('https://mantis.example.com', 'token');
174
- // When body is JSON but has no `message` key, the raw body string is used
175
- await expect(client.get('issues')).rejects.toThrow('{"detail":"forbidden"}');
176
- });
177
-
178
- it('MantisApiError is instanceof Error', async () => {
179
- const fetchMock = vi.fn().mockResolvedValue(
180
- makeResponse(401, JSON.stringify({ message: 'Unauthorized' })),
181
- );
182
- vi.stubGlobal('fetch', fetchMock);
183
-
184
- const client = new MantisClient('https://mantis.example.com', 'token');
185
- await expect(client.get('issues')).rejects.toBeInstanceOf(MantisApiError);
186
- });
187
- });
188
-
189
- // ---------------------------------------------------------------------------
190
- // HTTP methods
191
- // ---------------------------------------------------------------------------
192
-
193
- describe('MantisClient – HTTP methods', () => {
194
- it('sends POST with JSON body', () => {
195
- const fetchMock = vi.fn().mockResolvedValue(
196
- makeResponse(201, JSON.stringify({ id: 1 })),
197
- );
198
- vi.stubGlobal('fetch', fetchMock);
199
-
200
- const client = new MantisClient('https://mantis.example.com', 'token');
201
- const payload = { summary: 'New issue', project: { id: 1 } };
202
- return client.post('issues', payload).then(() => {
203
- const options = fetchMock.mock.calls[0][1] as RequestInit;
204
- expect(options.method).toBe('POST');
205
- expect(options.body).toBe(JSON.stringify(payload));
206
- });
207
- });
208
-
209
- it('sends PATCH request', () => {
210
- const fetchMock = vi.fn().mockResolvedValue(
211
- makeResponse(200, JSON.stringify({ id: 1 })),
212
- );
213
- vi.stubGlobal('fetch', fetchMock);
214
-
215
- const client = new MantisClient('https://mantis.example.com', 'token');
216
- return client.patch('issues/1', { summary: 'Updated' }).then(() => {
217
- const options = fetchMock.mock.calls[0][1] as RequestInit;
218
- expect(options.method).toBe('PATCH');
219
- });
220
- });
221
-
222
- it('sends DELETE request', () => {
223
- const fetchMock = vi.fn().mockResolvedValue(makeResponse(204, ''));
224
- vi.stubGlobal('fetch', fetchMock);
225
-
226
- const client = new MantisClient('https://mantis.example.com', 'token');
227
- return client.delete('issues/1').then(() => {
228
- const options = fetchMock.mock.calls[0][1] as RequestInit;
229
- expect(options.method).toBe('DELETE');
230
- });
231
- });
232
- });
233
-
234
- // ---------------------------------------------------------------------------
235
- // Credential factory (lazy constructor)
236
- // ---------------------------------------------------------------------------
237
-
238
- describe('MantisClient – credential factory', () => {
239
- it('does not call the factory until the first API method', async () => {
240
- const factory = vi.fn().mockResolvedValue({
241
- baseUrl: 'https://lazy.example.com',
242
- apiKey: 'lazy-key',
243
- });
244
-
245
- new MantisClient(factory);
246
-
247
- expect(factory).not.toHaveBeenCalled();
248
- });
249
-
250
- it('calls the factory on first API method and uses the returned credentials', async () => {
251
- const fetchMock = vi.fn().mockResolvedValue(makeResponse(200, '{}'));
252
- vi.stubGlobal('fetch', fetchMock);
253
-
254
- const factory = vi.fn().mockResolvedValue({
255
- baseUrl: 'https://lazy.example.com',
256
- apiKey: 'lazy-key',
257
- });
258
-
259
- const client = new MantisClient(factory);
260
- await client.get('issues');
261
-
262
- expect(factory).toHaveBeenCalledOnce();
263
- const calledUrl: string = fetchMock.mock.calls[0][0] as string;
264
- expect(calledUrl).toBe('https://lazy.example.com/api/rest/issues');
265
- const options = fetchMock.mock.calls[0][1] as RequestInit;
266
- expect((options.headers as Record<string, string>)['Authorization']).toBe('lazy-key');
267
- });
268
-
269
- it('caches credentials after the first call and does not call the factory again', async () => {
270
- const fetchMock = vi.fn().mockResolvedValue(makeResponse(200, '{}'));
271
- vi.stubGlobal('fetch', fetchMock);
272
-
273
- const factory = vi.fn().mockResolvedValue({
274
- baseUrl: 'https://lazy.example.com',
275
- apiKey: 'lazy-key',
276
- });
277
-
278
- const client = new MantisClient(factory);
279
- await client.get('issues');
280
- await client.get('projects');
281
-
282
- expect(factory).toHaveBeenCalledOnce();
283
- });
284
-
285
- it('forwards the responseObserver when using factory constructor', async () => {
286
- const observer = vi.fn();
287
- const fakeResponse = makeResponse(200, '{}');
288
- const fetchMock = vi.fn().mockResolvedValue(fakeResponse);
289
- vi.stubGlobal('fetch', fetchMock);
290
-
291
- const factory = vi.fn().mockResolvedValue({
292
- baseUrl: 'https://lazy.example.com',
293
- apiKey: 'lazy-key',
294
- });
295
-
296
- const client = new MantisClient(factory, observer);
297
- await client.get('issues');
298
-
299
- expect(observer).toHaveBeenCalledOnce();
300
- expect(observer).toHaveBeenCalledWith(fakeResponse);
301
- });
302
- });
303
-
304
- // ---------------------------------------------------------------------------
305
- // responseObserver
306
- // ---------------------------------------------------------------------------
307
-
308
- describe('MantisClient – responseObserver', () => {
309
- it('calls responseObserver on successful response', () => {
310
- const observer = vi.fn();
311
- const fakeResponse = makeResponse(200, '{}');
312
- const fetchMock = vi.fn().mockResolvedValue(fakeResponse);
313
- vi.stubGlobal('fetch', fetchMock);
314
-
315
- const client = new MantisClient('https://mantis.example.com', 'token', observer);
316
- return client.get('issues').then(() => {
317
- expect(observer).toHaveBeenCalledOnce();
318
- expect(observer).toHaveBeenCalledWith(fakeResponse);
319
- });
320
- });
321
-
322
- it('does not call responseObserver on error response', async () => {
323
- const observer = vi.fn();
324
- const fetchMock = vi.fn().mockResolvedValue(
325
- makeResponse(500, JSON.stringify({ message: 'Internal Server Error' })),
326
- );
327
- vi.stubGlobal('fetch', fetchMock);
328
-
329
- const client = new MantisClient('https://mantis.example.com', 'token', observer);
330
- await expect(client.get('issues')).rejects.toBeInstanceOf(MantisApiError);
331
- expect(observer).not.toHaveBeenCalled();
332
- });
333
- });
334
-
335
- // ---------------------------------------------------------------------------
336
- // URL helpers
337
- // ---------------------------------------------------------------------------
338
-
339
- describe('buildIssueViewUrl', () => {
340
- it('builds the correct MantisBT issue view URL', () => {
341
- expect(buildIssueViewUrl('https://mantis.example.com', 42))
342
- .toBe('https://mantis.example.com/view.php?id=42');
343
- });
344
-
345
- it('works with base URLs that have a path prefix', () => {
346
- expect(buildIssueViewUrl('https://example.com/mantis', 1))
347
- .toBe('https://example.com/mantis/view.php?id=1');
348
- });
349
- });
350
-
351
- describe('buildNoteViewUrl', () => {
352
- it('builds the correct MantisBT note anchor URL', () => {
353
- expect(buildNoteViewUrl('https://mantis.example.com', 42, 99))
354
- .toBe('https://mantis.example.com/view.php?id=42#bugnote99');
355
- });
356
- });
357
-
358
- describe('MantisClient – getBaseUrl', () => {
359
- it('returns the normalized base URL (direct constructor)', async () => {
360
- const client = new MantisClient('https://mantis.example.com', 'token');
361
- expect(await client.getBaseUrl()).toBe('https://mantis.example.com');
362
- });
363
-
364
- it('returns the base URL from the credential factory', async () => {
365
- const factory = vi.fn().mockResolvedValue({
366
- baseUrl: 'https://lazy.example.com',
367
- apiKey: 'key',
368
- });
369
- const client = new MantisClient(factory);
370
- expect(await client.getBaseUrl()).toBe('https://lazy.example.com');
371
- });
372
- });