@dpesch/mantisbt-mcp-server 1.0.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/.env.local +2 -0
- package/CHANGELOG.md +68 -0
- package/LICENSE +21 -0
- package/README.de.md +177 -0
- package/README.md +177 -0
- package/dist/cache.js +52 -0
- package/dist/client.js +114 -0
- package/dist/config.js +54 -0
- package/dist/constants.js +23 -0
- package/dist/index.js +120 -0
- package/dist/tools/config.js +107 -0
- package/dist/tools/files.js +37 -0
- package/dist/tools/filters.js +35 -0
- package/dist/tools/issues.js +191 -0
- package/dist/tools/metadata.js +119 -0
- package/dist/tools/monitors.js +38 -0
- package/dist/tools/notes.js +96 -0
- package/dist/tools/projects.js +127 -0
- package/dist/tools/relationships.js +54 -0
- package/dist/tools/tags.js +78 -0
- package/dist/tools/users.js +34 -0
- package/dist/tools/version.js +58 -0
- package/dist/types.js +4 -0
- package/dist/version-hint.js +117 -0
- package/package.json +41 -0
- package/scripts/record-fixtures.ts +138 -0
- package/tests/cache.test.ts +149 -0
- package/tests/client.test.ts +241 -0
- package/tests/config.test.ts +164 -0
- package/tests/fixtures/get_current_user.json +39 -0
- package/tests/fixtures/get_issue.json +151 -0
- package/tests/fixtures/get_project_categories.json +60 -0
- package/tests/fixtures/get_project_versions.json +3 -0
- package/tests/fixtures/get_project_versions_with_data.json +28 -0
- package/tests/fixtures/list_issues.json +67 -0
- package/tests/fixtures/list_projects.json +65 -0
- package/tests/fixtures/recorded/get_current_user.json +108 -0
- package/tests/fixtures/recorded/get_issue.json +320 -0
- package/tests/fixtures/recorded/get_project_categories.json +241 -0
- package/tests/fixtures/recorded/get_project_versions.json +3 -0
- package/tests/fixtures/recorded/list_issues.json +824 -0
- package/tests/fixtures/recorded/list_projects.json +10641 -0
- package/tests/helpers/mock-server.ts +32 -0
- package/tests/tools/issues.test.ts +130 -0
- package/tests/tools/projects.test.ts +169 -0
- package/tests/tools/users.test.ts +76 -0
- package/tests/version-hint.test.ts +230 -0
- package/tsconfig.build.json +8 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { writeFileSync, mkdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { MantisClient } from '../src/client.js';
|
|
5
|
+
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = dirname(__filename);
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// .env.local laden (falls vorhanden)
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const envPath = join(__dirname, '..', '.env.local');
|
|
15
|
+
const lines = readFileSync(envPath, 'utf-8').split('\n');
|
|
16
|
+
for (const line of lines) {
|
|
17
|
+
const match = line.match(/^([^#=\s][^=]*)=(.*)$/);
|
|
18
|
+
if (match) {
|
|
19
|
+
const key = match[1].trim();
|
|
20
|
+
const value = match[2].trim().replace(/^["']|["']$/g, '');
|
|
21
|
+
if (!process.env[key]) process.env[key] = value;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
} catch {
|
|
25
|
+
// .env.local nicht vorhanden – ENV-Vars direkt nutzen
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Config aus ENV
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
const baseUrlRaw = process.env['MANTIS_BASE_URL'];
|
|
33
|
+
const apiKey = process.env['MANTIS_API_KEY'];
|
|
34
|
+
|
|
35
|
+
if (!baseUrlRaw || !apiKey) {
|
|
36
|
+
console.error('Error: MANTIS_BASE_URL and MANTIS_API_KEY environment variables must be set.');
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const baseUrl = baseUrlRaw.replace(/\/$/, '');
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Fixtures-Verzeichnis
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
const fixturesDir = join(__dirname, '..', 'tests', 'fixtures', 'recorded');
|
|
47
|
+
mkdirSync(fixturesDir, { recursive: true });
|
|
48
|
+
|
|
49
|
+
function saveFixture(filename: string, data: unknown): void {
|
|
50
|
+
const filePath = join(fixturesDir, filename);
|
|
51
|
+
writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
|
52
|
+
console.log(`Saved: ${filePath}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Client instanziieren
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
const client = new MantisClient(baseUrl, apiKey);
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Fixtures aufzeichnen
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
async function recordFixtures(): Promise<void> {
|
|
66
|
+
// GET projects
|
|
67
|
+
let firstProjectId: number | undefined;
|
|
68
|
+
let projectIdWithVersions: number | undefined;
|
|
69
|
+
try {
|
|
70
|
+
const projectsResult = await client.get<{ projects: Array<{ id: number; name: string; versions?: unknown[] }> }>('projects');
|
|
71
|
+
saveFixture('list_projects.json', projectsResult);
|
|
72
|
+
if (Array.isArray(projectsResult.projects) && projectsResult.projects.length > 0) {
|
|
73
|
+
firstProjectId = projectsResult.projects[0]?.id;
|
|
74
|
+
// Erstes Projekt mit nicht-leeren Versionen bevorzugen
|
|
75
|
+
projectIdWithVersions = projectsResult.projects.find(
|
|
76
|
+
(p) => Array.isArray(p.versions) && p.versions.length > 0,
|
|
77
|
+
)?.id ?? firstProjectId;
|
|
78
|
+
}
|
|
79
|
+
} catch (err) {
|
|
80
|
+
console.error('Failed to fetch projects:', err instanceof Error ? err.message : String(err));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// GET users/me
|
|
84
|
+
try {
|
|
85
|
+
const userResult = await client.get<unknown>('users/me');
|
|
86
|
+
saveFixture('get_current_user.json', userResult);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
console.error('Failed to fetch users/me:', err instanceof Error ? err.message : String(err));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// GET issues?page=1&page_size=3
|
|
92
|
+
let firstIssueId: number | undefined;
|
|
93
|
+
try {
|
|
94
|
+
const issuesResult = await client.get<{ issues: Array<{ id: number }> }>('issues', {
|
|
95
|
+
page: 1,
|
|
96
|
+
page_size: 3,
|
|
97
|
+
});
|
|
98
|
+
saveFixture('list_issues.json', issuesResult);
|
|
99
|
+
if (Array.isArray(issuesResult.issues) && issuesResult.issues.length > 0) {
|
|
100
|
+
firstIssueId = issuesResult.issues[0]?.id;
|
|
101
|
+
}
|
|
102
|
+
} catch (err) {
|
|
103
|
+
console.error('Failed to fetch issues:', err instanceof Error ? err.message : String(err));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// GET issues/{id}
|
|
107
|
+
if (firstIssueId !== undefined) {
|
|
108
|
+
try {
|
|
109
|
+
const issueResult = await client.get<unknown>(`issues/${firstIssueId}`);
|
|
110
|
+
saveFixture('get_issue.json', issueResult);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
console.error(`Failed to fetch issues/${firstIssueId}:`, err instanceof Error ? err.message : String(err));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// GET projects/{id}/versions + categories
|
|
117
|
+
if (firstProjectId !== undefined) {
|
|
118
|
+
const versionProjectId = projectIdWithVersions ?? firstProjectId;
|
|
119
|
+
try {
|
|
120
|
+
const versionsResult = await client.get<unknown>(`projects/${versionProjectId}/versions`);
|
|
121
|
+
saveFixture('get_project_versions.json', versionsResult);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
console.error(`Failed to fetch projects/${versionProjectId}/versions:`, err instanceof Error ? err.message : String(err));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const projectResult = await client.get<{ projects: Array<{ categories?: unknown[] }> }>(`projects/${firstProjectId}`);
|
|
128
|
+
saveFixture('get_project_categories.json', { categories: projectResult.projects?.[0]?.categories ?? [] });
|
|
129
|
+
} catch (err) {
|
|
130
|
+
console.error(`Failed to fetch projects/${firstProjectId} (categories):`, err instanceof Error ? err.message : String(err));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
recordFixtures().catch((err) => {
|
|
136
|
+
console.error('Unexpected error:', err);
|
|
137
|
+
process.exit(1);
|
|
138
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
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
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Setup
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
vi.resetAllMocks();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// isValid()
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
describe('MetadataCache – isValid()', () => {
|
|
41
|
+
it('returns false when file does not exist (readFile throws)', async () => {
|
|
42
|
+
vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
|
|
43
|
+
|
|
44
|
+
const cache = makeCache();
|
|
45
|
+
await expect(cache.isValid()).resolves.toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('returns true when file is fresh (within TTL)', async () => {
|
|
49
|
+
const file = { timestamp: Date.now(), data: makeSampleMetadata() };
|
|
50
|
+
vi.mocked(readFile).mockResolvedValue(JSON.stringify(file) as any);
|
|
51
|
+
|
|
52
|
+
const cache = makeCache();
|
|
53
|
+
await expect(cache.isValid()).resolves.toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('returns false when file has expired (timestamp older than TTL)', async () => {
|
|
57
|
+
const expiredTimestamp = Date.now() - (TTL + 1) * 1000;
|
|
58
|
+
const file = { timestamp: expiredTimestamp, data: makeSampleMetadata() };
|
|
59
|
+
vi.mocked(readFile).mockResolvedValue(JSON.stringify(file) as any);
|
|
60
|
+
|
|
61
|
+
const cache = makeCache();
|
|
62
|
+
await expect(cache.isValid()).resolves.toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// load()
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
describe('MetadataCache – load()', () => {
|
|
71
|
+
it('returns null when file does not exist', async () => {
|
|
72
|
+
vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
|
|
73
|
+
|
|
74
|
+
const cache = makeCache();
|
|
75
|
+
await expect(cache.load()).resolves.toBeNull();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('returns CachedMetadata when file exists', async () => {
|
|
79
|
+
const metadata = makeSampleMetadata();
|
|
80
|
+
const file = { timestamp: Date.now(), data: metadata };
|
|
81
|
+
vi.mocked(readFile).mockResolvedValue(JSON.stringify(file) as any);
|
|
82
|
+
|
|
83
|
+
const cache = makeCache();
|
|
84
|
+
const result = await cache.load();
|
|
85
|
+
expect(result).toEqual(metadata);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// save()
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
describe('MetadataCache – save()', () => {
|
|
94
|
+
it('calls mkdir with recursive:true and writeFile with JSON content', async () => {
|
|
95
|
+
vi.mocked(mkdir).mockResolvedValue(undefined);
|
|
96
|
+
vi.mocked(writeFile).mockResolvedValue(undefined);
|
|
97
|
+
|
|
98
|
+
const cache = makeCache();
|
|
99
|
+
const metadata = makeSampleMetadata();
|
|
100
|
+
await cache.save(metadata);
|
|
101
|
+
|
|
102
|
+
expect(mkdir).toHaveBeenCalledWith(CACHE_DIR, { recursive: true });
|
|
103
|
+
expect(writeFile).toHaveBeenCalledOnce();
|
|
104
|
+
|
|
105
|
+
// Verify the written JSON contains our data
|
|
106
|
+
const writtenContent = vi.mocked(writeFile).mock.calls[0][1] as string;
|
|
107
|
+
const parsed = JSON.parse(writtenContent) as { timestamp: number; data: CachedMetadata };
|
|
108
|
+
expect(parsed.data).toEqual(metadata);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('writes a timestamp to the cache file', async () => {
|
|
112
|
+
vi.mocked(mkdir).mockResolvedValue(undefined);
|
|
113
|
+
vi.mocked(writeFile).mockResolvedValue(undefined);
|
|
114
|
+
|
|
115
|
+
const before = Date.now();
|
|
116
|
+
const cache = makeCache();
|
|
117
|
+
await cache.save(makeSampleMetadata());
|
|
118
|
+
const after = Date.now();
|
|
119
|
+
|
|
120
|
+
const writtenContent = vi.mocked(writeFile).mock.calls[0][1] as string;
|
|
121
|
+
const parsed = JSON.parse(writtenContent) as { timestamp: number };
|
|
122
|
+
expect(parsed.timestamp).toBeGreaterThanOrEqual(before);
|
|
123
|
+
expect(parsed.timestamp).toBeLessThanOrEqual(after);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// invalidate()
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
describe('MetadataCache – invalidate()', () => {
|
|
132
|
+
it('calls unlink on the cache file', async () => {
|
|
133
|
+
vi.mocked(unlink).mockResolvedValue(undefined);
|
|
134
|
+
|
|
135
|
+
const cache = makeCache();
|
|
136
|
+
await cache.invalidate();
|
|
137
|
+
|
|
138
|
+
expect(unlink).toHaveBeenCalledOnce();
|
|
139
|
+
const calledPath = vi.mocked(unlink).mock.calls[0][0] as string;
|
|
140
|
+
expect(calledPath).toContain('metadata.json');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('does not throw when file does not exist (unlink rejects)', async () => {
|
|
144
|
+
vi.mocked(unlink).mockRejectedValue(new Error('ENOENT'));
|
|
145
|
+
|
|
146
|
+
const cache = makeCache();
|
|
147
|
+
await expect(cache.invalidate()).resolves.toBeUndefined();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { MantisClient, MantisApiError } 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('appends defined query parameters', () => {
|
|
60
|
+
const fetchMock = vi.fn().mockResolvedValue(makeResponse(200, '{}'));
|
|
61
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
62
|
+
|
|
63
|
+
const client = new MantisClient('https://mantis.example.com', 'token123');
|
|
64
|
+
return client.get('issues', { page_size: 10, page: 1 }).then(() => {
|
|
65
|
+
const calledUrl: string = fetchMock.mock.calls[0][0] as string;
|
|
66
|
+
const url = new URL(calledUrl);
|
|
67
|
+
expect(url.searchParams.get('page_size')).toBe('10');
|
|
68
|
+
expect(url.searchParams.get('page')).toBe('1');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('omits undefined query parameters', () => {
|
|
73
|
+
const fetchMock = vi.fn().mockResolvedValue(makeResponse(200, '{}'));
|
|
74
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
75
|
+
|
|
76
|
+
const client = new MantisClient('https://mantis.example.com', 'token123');
|
|
77
|
+
return client.get('issues', { page_size: 10, filter_id: undefined }).then(() => {
|
|
78
|
+
const calledUrl: string = fetchMock.mock.calls[0][0] as string;
|
|
79
|
+
const url = new URL(calledUrl);
|
|
80
|
+
expect(url.searchParams.has('filter_id')).toBe(false);
|
|
81
|
+
expect(url.searchParams.get('page_size')).toBe('10');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Request headers
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
describe('MantisClient – request headers', () => {
|
|
91
|
+
it('sends Authorization and Content-Type headers', () => {
|
|
92
|
+
const fetchMock = vi.fn().mockResolvedValue(makeResponse(200, '{}'));
|
|
93
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
94
|
+
|
|
95
|
+
const client = new MantisClient('https://mantis.example.com', 'myApiKey');
|
|
96
|
+
return client.get('issues').then(() => {
|
|
97
|
+
const options = fetchMock.mock.calls[0][1] as RequestInit;
|
|
98
|
+
const headers = options.headers as Record<string, string>;
|
|
99
|
+
expect(headers['Authorization']).toBe('myApiKey');
|
|
100
|
+
expect(headers['Content-Type']).toBe('application/json');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// handleResponse
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
describe('MantisClient – handleResponse', () => {
|
|
110
|
+
it('returns parsed JSON on 200', () => {
|
|
111
|
+
const fetchMock = vi.fn().mockResolvedValue(
|
|
112
|
+
makeResponse(200, JSON.stringify({ id: 42 })),
|
|
113
|
+
);
|
|
114
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
115
|
+
|
|
116
|
+
const client = new MantisClient('https://mantis.example.com', 'token');
|
|
117
|
+
return client.get<{ id: number }>('issues/42').then((result) => {
|
|
118
|
+
expect(result).toEqual({ id: 42 });
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('returns undefined on 204 No Content', () => {
|
|
123
|
+
const fetchMock = vi.fn().mockResolvedValue(makeResponse(204, ''));
|
|
124
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
125
|
+
|
|
126
|
+
const client = new MantisClient('https://mantis.example.com', 'token');
|
|
127
|
+
return client.delete('issues/42').then((result) => {
|
|
128
|
+
expect(result).toBeUndefined();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('throws MantisApiError with statusCode on 4xx', async () => {
|
|
133
|
+
const fetchMock = vi.fn().mockResolvedValue(
|
|
134
|
+
makeResponse(404, JSON.stringify({ message: 'Issue not found' })),
|
|
135
|
+
);
|
|
136
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
137
|
+
|
|
138
|
+
const client = new MantisClient('https://mantis.example.com', 'token');
|
|
139
|
+
await expect(client.get('issues/9999')).rejects.toMatchObject({
|
|
140
|
+
statusCode: 404,
|
|
141
|
+
name: 'MantisApiError',
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('uses raw body as message when JSON has no message field', async () => {
|
|
146
|
+
const fetchMock = vi.fn().mockResolvedValue(
|
|
147
|
+
makeResponse(403, JSON.stringify({ detail: 'forbidden' })),
|
|
148
|
+
);
|
|
149
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
150
|
+
|
|
151
|
+
const client = new MantisClient('https://mantis.example.com', 'token');
|
|
152
|
+
// When body is JSON but has no `message` key, the raw body string is used
|
|
153
|
+
await expect(client.get('issues')).rejects.toThrow('{"detail":"forbidden"}');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('MantisApiError is instanceof Error', async () => {
|
|
157
|
+
const fetchMock = vi.fn().mockResolvedValue(
|
|
158
|
+
makeResponse(401, JSON.stringify({ message: 'Unauthorized' })),
|
|
159
|
+
);
|
|
160
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
161
|
+
|
|
162
|
+
const client = new MantisClient('https://mantis.example.com', 'token');
|
|
163
|
+
await expect(client.get('issues')).rejects.toBeInstanceOf(MantisApiError);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// HTTP methods
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
describe('MantisClient – HTTP methods', () => {
|
|
172
|
+
it('sends POST with JSON body', () => {
|
|
173
|
+
const fetchMock = vi.fn().mockResolvedValue(
|
|
174
|
+
makeResponse(201, JSON.stringify({ id: 1 })),
|
|
175
|
+
);
|
|
176
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
177
|
+
|
|
178
|
+
const client = new MantisClient('https://mantis.example.com', 'token');
|
|
179
|
+
const payload = { summary: 'New issue', project: { id: 1 } };
|
|
180
|
+
return client.post('issues', payload).then(() => {
|
|
181
|
+
const options = fetchMock.mock.calls[0][1] as RequestInit;
|
|
182
|
+
expect(options.method).toBe('POST');
|
|
183
|
+
expect(options.body).toBe(JSON.stringify(payload));
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('sends PATCH request', () => {
|
|
188
|
+
const fetchMock = vi.fn().mockResolvedValue(
|
|
189
|
+
makeResponse(200, JSON.stringify({ id: 1 })),
|
|
190
|
+
);
|
|
191
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
192
|
+
|
|
193
|
+
const client = new MantisClient('https://mantis.example.com', 'token');
|
|
194
|
+
return client.patch('issues/1', { summary: 'Updated' }).then(() => {
|
|
195
|
+
const options = fetchMock.mock.calls[0][1] as RequestInit;
|
|
196
|
+
expect(options.method).toBe('PATCH');
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('sends DELETE request', () => {
|
|
201
|
+
const fetchMock = vi.fn().mockResolvedValue(makeResponse(204, ''));
|
|
202
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
203
|
+
|
|
204
|
+
const client = new MantisClient('https://mantis.example.com', 'token');
|
|
205
|
+
return client.delete('issues/1').then(() => {
|
|
206
|
+
const options = fetchMock.mock.calls[0][1] as RequestInit;
|
|
207
|
+
expect(options.method).toBe('DELETE');
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// responseObserver
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
describe('MantisClient – responseObserver', () => {
|
|
217
|
+
it('calls responseObserver on successful response', () => {
|
|
218
|
+
const observer = vi.fn();
|
|
219
|
+
const fakeResponse = makeResponse(200, '{}');
|
|
220
|
+
const fetchMock = vi.fn().mockResolvedValue(fakeResponse);
|
|
221
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
222
|
+
|
|
223
|
+
const client = new MantisClient('https://mantis.example.com', 'token', observer);
|
|
224
|
+
return client.get('issues').then(() => {
|
|
225
|
+
expect(observer).toHaveBeenCalledOnce();
|
|
226
|
+
expect(observer).toHaveBeenCalledWith(fakeResponse);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('does not call responseObserver on error response', async () => {
|
|
231
|
+
const observer = vi.fn();
|
|
232
|
+
const fetchMock = vi.fn().mockResolvedValue(
|
|
233
|
+
makeResponse(500, JSON.stringify({ message: 'Internal Server Error' })),
|
|
234
|
+
);
|
|
235
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
236
|
+
|
|
237
|
+
const client = new MantisClient('https://mantis.example.com', 'token', observer);
|
|
238
|
+
await expect(client.get('issues')).rejects.toBeInstanceOf(MantisApiError);
|
|
239
|
+
expect(observer).not.toHaveBeenCalled();
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// vi.mock is hoisted — must be at module top level
|
|
4
|
+
vi.mock('node:fs/promises');
|
|
5
|
+
|
|
6
|
+
import { readFile } from 'node:fs/promises';
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Helpers
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Reset the module registry so that the `cachedConfig` singleton in config.ts
|
|
14
|
+
* is re-initialized for each test. Then import getConfig fresh.
|
|
15
|
+
*/
|
|
16
|
+
async function freshGetConfig(): Promise<(typeof import('../src/config.js'))['getConfig']> {
|
|
17
|
+
vi.resetModules();
|
|
18
|
+
const mod = await import('../src/config.js');
|
|
19
|
+
return mod.getConfig;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Setup
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
vi.resetAllMocks();
|
|
28
|
+
vi.unstubAllEnvs();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// ENV-based configuration
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
describe('getConfig() – ENV variables', () => {
|
|
36
|
+
it('reads baseUrl and apiKey from environment variables', async () => {
|
|
37
|
+
vi.stubEnv('MANTIS_BASE_URL', 'https://mantis.example.com/api/rest');
|
|
38
|
+
vi.stubEnv('MANTIS_API_KEY', 'env-api-key');
|
|
39
|
+
|
|
40
|
+
const getConfig = await freshGetConfig();
|
|
41
|
+
const config = await getConfig();
|
|
42
|
+
|
|
43
|
+
expect(config.baseUrl).toBe('https://mantis.example.com/api/rest');
|
|
44
|
+
expect(config.apiKey).toBe('env-api-key');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('strips trailing slash from baseUrl', async () => {
|
|
48
|
+
vi.stubEnv('MANTIS_BASE_URL', 'https://mantis.example.com/api/rest/');
|
|
49
|
+
vi.stubEnv('MANTIS_API_KEY', 'key');
|
|
50
|
+
|
|
51
|
+
const getConfig = await freshGetConfig();
|
|
52
|
+
const config = await getConfig();
|
|
53
|
+
|
|
54
|
+
expect(config.baseUrl).toBe('https://mantis.example.com/api/rest');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('parses MANTIS_CACHE_TTL as a number', async () => {
|
|
58
|
+
vi.stubEnv('MANTIS_BASE_URL', 'https://mantis.example.com');
|
|
59
|
+
vi.stubEnv('MANTIS_API_KEY', 'key');
|
|
60
|
+
vi.stubEnv('MANTIS_CACHE_TTL', '7200');
|
|
61
|
+
|
|
62
|
+
const getConfig = await freshGetConfig();
|
|
63
|
+
const config = await getConfig();
|
|
64
|
+
|
|
65
|
+
expect(config.cacheTtl).toBe(7200);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('uses 3600 as default cacheTtl when MANTIS_CACHE_TTL is not set', async () => {
|
|
69
|
+
vi.stubEnv('MANTIS_BASE_URL', 'https://mantis.example.com');
|
|
70
|
+
vi.stubEnv('MANTIS_API_KEY', 'key');
|
|
71
|
+
|
|
72
|
+
const getConfig = await freshGetConfig();
|
|
73
|
+
const config = await getConfig();
|
|
74
|
+
|
|
75
|
+
expect(config.cacheTtl).toBe(3600);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('uses MANTIS_CACHE_DIR when provided', async () => {
|
|
79
|
+
vi.stubEnv('MANTIS_BASE_URL', 'https://mantis.example.com');
|
|
80
|
+
vi.stubEnv('MANTIS_API_KEY', 'key');
|
|
81
|
+
vi.stubEnv('MANTIS_CACHE_DIR', '/custom/cache/dir');
|
|
82
|
+
|
|
83
|
+
const getConfig = await freshGetConfig();
|
|
84
|
+
const config = await getConfig();
|
|
85
|
+
|
|
86
|
+
expect(config.cacheDir).toBe('/custom/cache/dir');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// JSON fallback
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
describe('getConfig() – mantis.json fallback', () => {
|
|
95
|
+
it('falls back to ~/.claude/mantis.json when ENV vars are missing', async () => {
|
|
96
|
+
const json = JSON.stringify({ base_url: 'https://from-json.example.com', api_key: 'json-key' });
|
|
97
|
+
vi.mocked(readFile).mockResolvedValue(json as any);
|
|
98
|
+
|
|
99
|
+
const getConfig = await freshGetConfig();
|
|
100
|
+
const config = await getConfig();
|
|
101
|
+
|
|
102
|
+
expect(config.baseUrl).toBe('https://from-json.example.com');
|
|
103
|
+
expect(config.apiKey).toBe('json-key');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('prefers ENV vars over mantis.json values', async () => {
|
|
107
|
+
vi.stubEnv('MANTIS_BASE_URL', 'https://from-env.example.com');
|
|
108
|
+
vi.stubEnv('MANTIS_API_KEY', 'env-key');
|
|
109
|
+
const json = JSON.stringify({ base_url: 'https://from-json.example.com', api_key: 'json-key' });
|
|
110
|
+
vi.mocked(readFile).mockResolvedValue(json as any);
|
|
111
|
+
|
|
112
|
+
const getConfig = await freshGetConfig();
|
|
113
|
+
const config = await getConfig();
|
|
114
|
+
|
|
115
|
+
expect(config.baseUrl).toBe('https://from-env.example.com');
|
|
116
|
+
expect(config.apiKey).toBe('env-key');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Error cases
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
describe('getConfig() – errors', () => {
|
|
125
|
+
it('throws when neither ENV nor mantis.json provides baseUrl', async () => {
|
|
126
|
+
vi.stubEnv('MANTIS_API_KEY', 'some-key');
|
|
127
|
+
vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
|
|
128
|
+
|
|
129
|
+
const getConfig = await freshGetConfig();
|
|
130
|
+
await expect(getConfig()).rejects.toThrow('MANTIS_BASE_URL');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('throws when neither ENV nor mantis.json provides apiKey', async () => {
|
|
134
|
+
vi.stubEnv('MANTIS_BASE_URL', 'https://mantis.example.com');
|
|
135
|
+
vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
|
|
136
|
+
|
|
137
|
+
const getConfig = await freshGetConfig();
|
|
138
|
+
await expect(getConfig()).rejects.toThrow('MANTIS_API_KEY');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('throws when no configuration is available at all', async () => {
|
|
142
|
+
vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
|
|
143
|
+
|
|
144
|
+
const getConfig = await freshGetConfig();
|
|
145
|
+
await expect(getConfig()).rejects.toThrow('Missing required MantisBT configuration');
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// Singleton caching
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
describe('getConfig() – singleton', () => {
|
|
154
|
+
it('returns the same object on repeated calls within the same module instance', async () => {
|
|
155
|
+
vi.stubEnv('MANTIS_BASE_URL', 'https://mantis.example.com');
|
|
156
|
+
vi.stubEnv('MANTIS_API_KEY', 'key');
|
|
157
|
+
|
|
158
|
+
const getConfig = await freshGetConfig();
|
|
159
|
+
const first = await getConfig();
|
|
160
|
+
const second = await getConfig();
|
|
161
|
+
|
|
162
|
+
expect(first).toBe(second);
|
|
163
|
+
});
|
|
164
|
+
});
|