@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.
- package/CHANGELOG.md +24 -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
package/tests/cache.test.ts
DELETED
|
@@ -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
|
-
});
|
package/tests/client.test.ts
DELETED
|
@@ -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
|
-
});
|