@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.
Files changed (49) hide show
  1. package/.env.local +2 -0
  2. package/CHANGELOG.md +68 -0
  3. package/LICENSE +21 -0
  4. package/README.de.md +177 -0
  5. package/README.md +177 -0
  6. package/dist/cache.js +52 -0
  7. package/dist/client.js +114 -0
  8. package/dist/config.js +54 -0
  9. package/dist/constants.js +23 -0
  10. package/dist/index.js +120 -0
  11. package/dist/tools/config.js +107 -0
  12. package/dist/tools/files.js +37 -0
  13. package/dist/tools/filters.js +35 -0
  14. package/dist/tools/issues.js +191 -0
  15. package/dist/tools/metadata.js +119 -0
  16. package/dist/tools/monitors.js +38 -0
  17. package/dist/tools/notes.js +96 -0
  18. package/dist/tools/projects.js +127 -0
  19. package/dist/tools/relationships.js +54 -0
  20. package/dist/tools/tags.js +78 -0
  21. package/dist/tools/users.js +34 -0
  22. package/dist/tools/version.js +58 -0
  23. package/dist/types.js +4 -0
  24. package/dist/version-hint.js +117 -0
  25. package/package.json +41 -0
  26. package/scripts/record-fixtures.ts +138 -0
  27. package/tests/cache.test.ts +149 -0
  28. package/tests/client.test.ts +241 -0
  29. package/tests/config.test.ts +164 -0
  30. package/tests/fixtures/get_current_user.json +39 -0
  31. package/tests/fixtures/get_issue.json +151 -0
  32. package/tests/fixtures/get_project_categories.json +60 -0
  33. package/tests/fixtures/get_project_versions.json +3 -0
  34. package/tests/fixtures/get_project_versions_with_data.json +28 -0
  35. package/tests/fixtures/list_issues.json +67 -0
  36. package/tests/fixtures/list_projects.json +65 -0
  37. package/tests/fixtures/recorded/get_current_user.json +108 -0
  38. package/tests/fixtures/recorded/get_issue.json +320 -0
  39. package/tests/fixtures/recorded/get_project_categories.json +241 -0
  40. package/tests/fixtures/recorded/get_project_versions.json +3 -0
  41. package/tests/fixtures/recorded/list_issues.json +824 -0
  42. package/tests/fixtures/recorded/list_projects.json +10641 -0
  43. package/tests/helpers/mock-server.ts +32 -0
  44. package/tests/tools/issues.test.ts +130 -0
  45. package/tests/tools/projects.test.ts +169 -0
  46. package/tests/tools/users.test.ts +76 -0
  47. package/tests/version-hint.test.ts +230 -0
  48. package/tsconfig.build.json +8 -0
  49. package/vitest.config.ts +8 -0
@@ -0,0 +1,32 @@
1
+ // Typ für das Result-Objekt das die Tools zurückgeben
2
+ export interface ToolResult {
3
+ content: Array<{ type: string; text: string }>;
4
+ isError?: boolean;
5
+ }
6
+
7
+ // Handler-Typ (args ist der Zod-geparste Input)
8
+ type ToolHandler = (args: Record<string, unknown>) => Promise<ToolResult>;
9
+
10
+ export class MockMcpServer {
11
+ private readonly handlers = new Map<string, ToolHandler>();
12
+
13
+ // Nachahmt McpServer.registerTool – fängt Handler ein
14
+ registerTool(name: string, _definition: unknown, handler: ToolHandler): void {
15
+ this.handlers.set(name, handler);
16
+ }
17
+
18
+ // Ruft den eingefangenen Handler auf
19
+ async callTool(name: string, args: Record<string, unknown> = {}): Promise<ToolResult> {
20
+ const handler = this.handlers.get(name);
21
+ if (!handler) throw new Error(`Tool not registered: ${name}`);
22
+ return handler(args);
23
+ }
24
+
25
+ hasToolRegistered(name: string): boolean {
26
+ return this.handlers.has(name);
27
+ }
28
+
29
+ registeredToolNames(): string[] {
30
+ return [...this.handlers.keys()];
31
+ }
32
+ }
@@ -0,0 +1,130 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { readFileSync, existsSync } from 'node:fs';
3
+ import { join, dirname } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { MantisClient } from '../../src/client.js';
6
+ import { registerIssueTools } from '../../src/tools/issues.js';
7
+ import { MockMcpServer } from '../helpers/mock-server.js';
8
+
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = dirname(__filename);
11
+ const fixturesDir = join(__dirname, '..', 'fixtures');
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Fixtures laden (mit Inline-Fallback)
15
+ // ---------------------------------------------------------------------------
16
+
17
+ const getIssueFixturePath = join(fixturesDir, 'get_issue.json');
18
+ const listIssuesFixturePath = join(fixturesDir, 'list_issues.json');
19
+
20
+ const getIssueFixture = existsSync(getIssueFixturePath)
21
+ ? (JSON.parse(readFileSync(getIssueFixturePath, 'utf-8')) as { issues: Array<{ id: number; summary: string }> })
22
+ : { issues: [{ id: 42, summary: 'Test Issue' }] };
23
+
24
+ const listIssuesFixture = existsSync(listIssuesFixturePath)
25
+ ? (JSON.parse(readFileSync(listIssuesFixturePath, 'utf-8')) as { issues: Array<{ id: number; summary: string }>; total_count: number })
26
+ : { issues: [{ id: 42, summary: 'Test Issue' }], total_count: 1 };
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Helper: minimale Response nachbauen
30
+ // ---------------------------------------------------------------------------
31
+
32
+ function makeResponse(status: number, body: string): Response {
33
+ return {
34
+ ok: status >= 200 && status < 300,
35
+ status,
36
+ statusText: `Status ${status}`,
37
+ text: () => Promise.resolve(body),
38
+ headers: { get: (_key: string) => null },
39
+ } as unknown as Response;
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Setup
44
+ // ---------------------------------------------------------------------------
45
+
46
+ let mockServer: MockMcpServer;
47
+ let client: MantisClient;
48
+
49
+ beforeEach(() => {
50
+ mockServer = new MockMcpServer();
51
+ client = new MantisClient('https://mantis.example.com', 'test-token');
52
+ registerIssueTools(mockServer as never, client);
53
+ vi.stubGlobal('fetch', vi.fn());
54
+ });
55
+
56
+ afterEach(() => {
57
+ vi.unstubAllGlobals();
58
+ });
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // get_issue
62
+ // ---------------------------------------------------------------------------
63
+
64
+ describe('get_issue', () => {
65
+ it('ist registriert', () => {
66
+ expect(mockServer.hasToolRegistered('get_issue')).toBe(true);
67
+ });
68
+
69
+ it('gibt issue-Daten aus der Fixture zurück', async () => {
70
+ const issueId = getIssueFixture.issues[0]!.id;
71
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(getIssueFixture)));
72
+
73
+ const result = await mockServer.callTool('get_issue', { id: issueId });
74
+
75
+ expect(result.isError).toBeUndefined();
76
+ const parsed = JSON.parse(result.content[0]!.text) as { id: number };
77
+ expect(parsed.id).toBe(issueId);
78
+ });
79
+
80
+ it('ruft den richtigen Endpoint auf', async () => {
81
+ const issueId = getIssueFixture.issues[0]!.id;
82
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(getIssueFixture)));
83
+
84
+ await mockServer.callTool('get_issue', { id: issueId });
85
+
86
+ const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
87
+ expect(calledUrl).toContain(`issues/${issueId}`);
88
+ });
89
+
90
+ it('gibt isError: true bei 404 zurück', async () => {
91
+ vi.mocked(fetch).mockResolvedValue(
92
+ makeResponse(404, JSON.stringify({ message: 'Issue not found' })),
93
+ );
94
+
95
+ const result = await mockServer.callTool('get_issue', { id: 9999 });
96
+
97
+ expect(result.isError).toBe(true);
98
+ expect(result.content[0]!.text).toContain('Error:');
99
+ });
100
+ });
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // list_issues
104
+ // ---------------------------------------------------------------------------
105
+
106
+ describe('list_issues', () => {
107
+ it('ist registriert', () => {
108
+ expect(mockServer.hasToolRegistered('list_issues')).toBe(true);
109
+ });
110
+
111
+ it('gibt Issues-Array zurück', async () => {
112
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
113
+
114
+ const result = await mockServer.callTool('list_issues', { page: 1, page_size: 3 });
115
+
116
+ expect(result.isError).toBeUndefined();
117
+ const parsed = JSON.parse(result.content[0]!.text) as { issues: unknown[] };
118
+ expect(Array.isArray(parsed.issues)).toBe(true);
119
+ });
120
+
121
+ it('übergibt project_id als Query-Parameter', async () => {
122
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
123
+
124
+ await mockServer.callTool('list_issues', { project_id: 7, page: 1, page_size: 10 });
125
+
126
+ const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
127
+ const url = new URL(calledUrl);
128
+ expect(url.searchParams.get('project_id')).toBe('7');
129
+ });
130
+ });
@@ -0,0 +1,169 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { readFileSync, existsSync } from 'node:fs';
3
+ import { join, dirname } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { MantisClient } from '../../src/client.js';
6
+ import { registerProjectTools } from '../../src/tools/projects.js';
7
+ import { MockMcpServer } from '../helpers/mock-server.js';
8
+
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = dirname(__filename);
11
+ const fixturesDir = join(__dirname, '..', 'fixtures');
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Fixtures laden (mit Inline-Fallback)
15
+ // ---------------------------------------------------------------------------
16
+
17
+ const listProjectsFixturePath = join(fixturesDir, 'list_projects.json');
18
+
19
+ const listProjectsFixture = existsSync(listProjectsFixturePath)
20
+ ? (JSON.parse(readFileSync(listProjectsFixturePath, 'utf-8')) as { projects: Array<{ id: number; name: string }> })
21
+ : { projects: [{ id: 1, name: 'Test Project' }] };
22
+
23
+ const firstProjectId = listProjectsFixture.projects[0]?.id ?? 1;
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Helper
27
+ // ---------------------------------------------------------------------------
28
+
29
+ function makeResponse(status: number, body: string): Response {
30
+ return {
31
+ ok: status >= 200 && status < 300,
32
+ status,
33
+ statusText: `Status ${status}`,
34
+ text: () => Promise.resolve(body),
35
+ headers: { get: (_key: string) => null },
36
+ } as unknown as Response;
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Setup
41
+ // ---------------------------------------------------------------------------
42
+
43
+ let mockServer: MockMcpServer;
44
+ let client: MantisClient;
45
+
46
+ beforeEach(() => {
47
+ mockServer = new MockMcpServer();
48
+ client = new MantisClient('https://mantis.example.com', 'test-token');
49
+ registerProjectTools(mockServer as never, client);
50
+ vi.stubGlobal('fetch', vi.fn());
51
+ });
52
+
53
+ afterEach(() => {
54
+ vi.unstubAllGlobals();
55
+ });
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // list_projects
59
+ // ---------------------------------------------------------------------------
60
+
61
+ describe('list_projects', () => {
62
+ it('ist registriert', () => {
63
+ expect(mockServer.hasToolRegistered('list_projects')).toBe(true);
64
+ });
65
+
66
+ it('gibt Projekte-Array zurück', async () => {
67
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listProjectsFixture)));
68
+
69
+ const result = await mockServer.callTool('list_projects', {});
70
+
71
+ expect(result.isError).toBeUndefined();
72
+ const parsed = JSON.parse(result.content[0]!.text) as unknown[];
73
+ expect(Array.isArray(parsed)).toBe(true);
74
+ expect(parsed.length).toBeGreaterThan(0);
75
+ });
76
+
77
+ it('gibt isError: true bei 401 zurück', async () => {
78
+ vi.mocked(fetch).mockResolvedValue(
79
+ makeResponse(401, JSON.stringify({ message: 'Unauthorized' })),
80
+ );
81
+
82
+ const result = await mockServer.callTool('list_projects', {});
83
+
84
+ expect(result.isError).toBe(true);
85
+ expect(result.content[0]!.text).toContain('Error:');
86
+ });
87
+ });
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // get_project_versions
91
+ // ---------------------------------------------------------------------------
92
+
93
+ describe('get_project_versions', () => {
94
+ it('ist registriert', () => {
95
+ expect(mockServer.hasToolRegistered('get_project_versions')).toBe(true);
96
+ });
97
+
98
+ it('ruft den richtigen Endpoint auf', async () => {
99
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ versions: [] })));
100
+
101
+ await mockServer.callTool('get_project_versions', { project_id: firstProjectId });
102
+
103
+ const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
104
+ expect(calledUrl).toContain(`projects/${firstProjectId}/versions`);
105
+ });
106
+
107
+ it('gibt leeres Array zurück wenn keine Versionen vorhanden', async () => {
108
+ const fixture = JSON.parse(readFileSync(join(fixturesDir, 'get_project_versions.json'), 'utf-8')) as { versions: unknown[] };
109
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(fixture)));
110
+
111
+ const result = await mockServer.callTool('get_project_versions', { project_id: firstProjectId });
112
+
113
+ expect(result.isError).toBeUndefined();
114
+ const parsed = JSON.parse(result.content[0]!.text) as unknown[];
115
+ expect(Array.isArray(parsed)).toBe(true);
116
+ expect(parsed).toHaveLength(0);
117
+ });
118
+
119
+ it('gibt Versionen zurück wenn vorhanden', async () => {
120
+ const fixture = JSON.parse(readFileSync(join(fixturesDir, 'get_project_versions_with_data.json'), 'utf-8')) as { versions: Array<{ id: number; name: string; released: boolean }> };
121
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(fixture)));
122
+
123
+ const result = await mockServer.callTool('get_project_versions', { project_id: firstProjectId });
124
+
125
+ expect(result.isError).toBeUndefined();
126
+ const parsed = JSON.parse(result.content[0]!.text) as Array<{ id: number; name: string; released: boolean }>;
127
+ expect(Array.isArray(parsed)).toBe(true);
128
+ expect(parsed.length).toBeGreaterThan(0);
129
+ expect(parsed[0]).toHaveProperty('id');
130
+ expect(parsed[0]).toHaveProperty('name');
131
+ expect(parsed[0]).toHaveProperty('released');
132
+ });
133
+ });
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // get_project_categories
137
+ // ---------------------------------------------------------------------------
138
+
139
+ describe('get_project_categories', () => {
140
+ it('ist registriert', () => {
141
+ expect(mockServer.hasToolRegistered('get_project_categories')).toBe(true);
142
+ });
143
+
144
+ it('strippt den [All Projects] Prefix', async () => {
145
+ const categoriesFixture = {
146
+ projects: [{
147
+ id: firstProjectId,
148
+ name: 'Test Project',
149
+ categories: [
150
+ { id: 1, name: '[All Projects] General' },
151
+ { id: 2, name: 'Backend' },
152
+ ],
153
+ }],
154
+ };
155
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(categoriesFixture)));
156
+
157
+ const result = await mockServer.callTool('get_project_categories', { project_id: firstProjectId });
158
+
159
+ expect(result.isError).toBeUndefined();
160
+ const parsed = JSON.parse(result.content[0]!.text) as Array<{ id: number; name: string }>;
161
+ expect(Array.isArray(parsed)).toBe(true);
162
+ // Erstes Element soll den Prefix nicht mehr haben
163
+ const firstCategory = parsed.find((c) => c.id === 1);
164
+ expect(firstCategory?.name).toBe('General');
165
+ // Zweites Element ohne Prefix bleibt unverändert
166
+ const secondCategory = parsed.find((c) => c.id === 2);
167
+ expect(secondCategory?.name).toBe('Backend');
168
+ });
169
+ });
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { readFileSync, existsSync } from 'node:fs';
3
+ import { join, dirname } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { MantisClient } from '../../src/client.js';
6
+ import { registerUserTools } from '../../src/tools/users.js';
7
+ import { MockMcpServer } from '../helpers/mock-server.js';
8
+
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = dirname(__filename);
11
+ const fixturesDir = join(__dirname, '..', 'fixtures');
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Fixtures laden (mit Inline-Fallback)
15
+ // ---------------------------------------------------------------------------
16
+
17
+ const getCurrentUserFixturePath = join(fixturesDir, 'get_current_user.json');
18
+
19
+ const getCurrentUserFixture = existsSync(getCurrentUserFixturePath)
20
+ ? (JSON.parse(readFileSync(getCurrentUserFixturePath, 'utf-8')) as { id: number; name: string; real_name?: string; email?: string })
21
+ : { id: 5, name: 'testuser', real_name: 'Test User', email: 'test@example.com' };
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Helper
25
+ // ---------------------------------------------------------------------------
26
+
27
+ function makeResponse(status: number, body: string): Response {
28
+ return {
29
+ ok: status >= 200 && status < 300,
30
+ status,
31
+ statusText: `Status ${status}`,
32
+ text: () => Promise.resolve(body),
33
+ headers: { get: (_key: string) => null },
34
+ } as unknown as Response;
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Setup
39
+ // ---------------------------------------------------------------------------
40
+
41
+ let mockServer: MockMcpServer;
42
+ let client: MantisClient;
43
+
44
+ beforeEach(() => {
45
+ mockServer = new MockMcpServer();
46
+ client = new MantisClient('https://mantis.example.com', 'test-token');
47
+ registerUserTools(mockServer as never, client);
48
+ vi.stubGlobal('fetch', vi.fn());
49
+ });
50
+
51
+ afterEach(() => {
52
+ vi.unstubAllGlobals();
53
+ });
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // get_current_user
57
+ // ---------------------------------------------------------------------------
58
+
59
+ describe('get_current_user', () => {
60
+ it('ist registriert', () => {
61
+ expect(mockServer.hasToolRegistered('get_current_user')).toBe(true);
62
+ });
63
+
64
+ it('gibt User-Daten zurück mit id und name', async () => {
65
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(getCurrentUserFixture)));
66
+
67
+ const result = await mockServer.callTool('get_current_user', {});
68
+
69
+ expect(result.isError).toBeUndefined();
70
+ const parsed = JSON.parse(result.content[0]!.text) as { id: number; name: string };
71
+ expect(typeof parsed.id).toBe('number');
72
+ expect(typeof parsed.name).toBe('string');
73
+ expect(parsed.id).toBe(getCurrentUserFixture.id);
74
+ expect(parsed.name).toBe(getCurrentUserFixture.name);
75
+ });
76
+ });
@@ -0,0 +1,230 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { parseVersion, compareVersions, VersionHintService } from '../src/version-hint.js';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // parseVersion
6
+ // ---------------------------------------------------------------------------
7
+
8
+ describe('parseVersion()', () => {
9
+ it('parses a standard version string', () => {
10
+ expect(parseVersion('2.25.7')).toEqual([2, 25, 7]);
11
+ });
12
+
13
+ it('strips leading v prefix', () => {
14
+ expect(parseVersion('v1.2.3')).toEqual([1, 2, 3]);
15
+ });
16
+
17
+ it('returns null for invalid strings', () => {
18
+ expect(parseVersion('not-a-version')).toBeNull();
19
+ expect(parseVersion('')).toBeNull();
20
+ expect(parseVersion('1.2')).toBeNull();
21
+ });
22
+
23
+ it('ignores extra suffix after patch number', () => {
24
+ expect(parseVersion('2.25.7-beta')).toEqual([2, 25, 7]);
25
+ });
26
+ });
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // compareVersions
30
+ // ---------------------------------------------------------------------------
31
+
32
+ describe('compareVersions()', () => {
33
+ it('returns 0 for equal versions', () => {
34
+ expect(compareVersions([2, 25, 7], [2, 25, 7])).toBe(0);
35
+ });
36
+
37
+ it('returns -1 when first version is older (major)', () => {
38
+ expect(compareVersions([1, 0, 0], [2, 0, 0])).toBe(-1);
39
+ });
40
+
41
+ it('returns 1 when first version is newer (major)', () => {
42
+ expect(compareVersions([3, 0, 0], [2, 99, 99])).toBe(1);
43
+ });
44
+
45
+ it('compares minor version correctly', () => {
46
+ expect(compareVersions([2, 24, 0], [2, 25, 0])).toBe(-1);
47
+ expect(compareVersions([2, 26, 0], [2, 25, 0])).toBe(1);
48
+ });
49
+
50
+ it('compares patch version correctly', () => {
51
+ expect(compareVersions([2, 25, 6], [2, 25, 7])).toBe(-1);
52
+ expect(compareVersions([2, 25, 8], [2, 25, 7])).toBe(1);
53
+ });
54
+ });
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // VersionHintService – getUpdateHint()
58
+ // ---------------------------------------------------------------------------
59
+
60
+ describe('VersionHintService – getUpdateHint()', () => {
61
+ it('returns null when installedVersion is not set', () => {
62
+ const svc = new VersionHintService();
63
+ expect(svc.getUpdateHint()).toBeNull();
64
+ });
65
+
66
+ it('returns null when latestVersion is not set', () => {
67
+ const svc = new VersionHintService();
68
+ // Simulate installed version via onSuccessfulResponse
69
+ const mockResponse = {
70
+ headers: { get: (key: string) => key === 'X-Mantis-Version' ? '2.25.6' : null },
71
+ } as unknown as Response;
72
+ svc.onSuccessfulResponse(mockResponse);
73
+ expect(svc.getUpdateHint()).toBeNull();
74
+ });
75
+
76
+ it('returns hint string when latestVersion > installedVersion', async () => {
77
+ const fetchMock = vi.fn().mockResolvedValue({
78
+ ok: true,
79
+ json: () => Promise.resolve([{ name: 'release-2.25.7' }]),
80
+ });
81
+ vi.stubGlobal('fetch', fetchMock);
82
+
83
+ const svc = new VersionHintService();
84
+
85
+ const mockResponse = {
86
+ headers: { get: (key: string) => key === 'X-Mantis-Version' ? '2.25.6' : null },
87
+ } as unknown as Response;
88
+ svc.onSuccessfulResponse(mockResponse);
89
+
90
+ // Trigger fetch and wait for it to complete
91
+ svc.triggerLatestVersionFetch();
92
+ await svc.waitForLatestVersion(1000);
93
+
94
+ const hint = svc.getUpdateHint();
95
+ expect(hint).not.toBeNull();
96
+ expect(hint).toContain('2.25.7');
97
+ expect(hint).toContain('2.25.6');
98
+ });
99
+
100
+ it('returns null when installed version equals latest version', async () => {
101
+ const fetchMock = vi.fn().mockResolvedValue({
102
+ ok: true,
103
+ json: () => Promise.resolve([{ name: 'release-2.25.7' }]),
104
+ });
105
+ vi.stubGlobal('fetch', fetchMock);
106
+
107
+ const svc = new VersionHintService();
108
+
109
+ const mockResponse = {
110
+ headers: { get: (key: string) => key === 'X-Mantis-Version' ? '2.25.7' : null },
111
+ } as unknown as Response;
112
+ svc.onSuccessfulResponse(mockResponse);
113
+
114
+ svc.triggerLatestVersionFetch();
115
+ await svc.waitForLatestVersion(1000);
116
+
117
+ expect(svc.getUpdateHint()).toBeNull();
118
+ });
119
+
120
+ it('returns null when installedVersion is newer than latestVersion', async () => {
121
+ const fetchMock = vi.fn().mockResolvedValue({
122
+ ok: true,
123
+ json: () => Promise.resolve([{ name: 'release-2.25.6' }]),
124
+ });
125
+ vi.stubGlobal('fetch', fetchMock);
126
+
127
+ const svc = new VersionHintService();
128
+
129
+ const mockResponse = {
130
+ headers: { get: (key: string) => key === 'X-Mantis-Version' ? '2.25.7' : null },
131
+ } as unknown as Response;
132
+ svc.onSuccessfulResponse(mockResponse);
133
+
134
+ svc.triggerLatestVersionFetch();
135
+ await svc.waitForLatestVersion(1000);
136
+
137
+ expect(svc.getUpdateHint()).toBeNull();
138
+ });
139
+ });
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // VersionHintService – onSuccessfulResponse()
143
+ // ---------------------------------------------------------------------------
144
+
145
+ describe('VersionHintService – onSuccessfulResponse()', () => {
146
+ it('extracts X-Mantis-Version header from response', () => {
147
+ const svc = new VersionHintService();
148
+ const mockResponse = {
149
+ headers: { get: (key: string) => key === 'X-Mantis-Version' ? '2.25.7' : null },
150
+ } as unknown as Response;
151
+
152
+ svc.onSuccessfulResponse(mockResponse);
153
+ expect(svc.getInstalledVersion()).toBe('2.25.7');
154
+ });
155
+
156
+ it('does not overwrite already set installedVersion', () => {
157
+ const svc = new VersionHintService();
158
+
159
+ const first = {
160
+ headers: { get: (key: string) => key === 'X-Mantis-Version' ? '2.25.6' : null },
161
+ } as unknown as Response;
162
+ const second = {
163
+ headers: { get: (key: string) => key === 'X-Mantis-Version' ? '2.25.7' : null },
164
+ } as unknown as Response;
165
+
166
+ svc.onSuccessfulResponse(first);
167
+ svc.onSuccessfulResponse(second);
168
+
169
+ expect(svc.getInstalledVersion()).toBe('2.25.6');
170
+ });
171
+
172
+ it('ignores response without X-Mantis-Version header', () => {
173
+ const svc = new VersionHintService();
174
+ const mockResponse = {
175
+ headers: { get: (_key: string) => null },
176
+ } as unknown as Response;
177
+
178
+ svc.onSuccessfulResponse(mockResponse);
179
+ expect(svc.getInstalledVersion()).toBeNull();
180
+ });
181
+ });
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // VersionHintService – triggerLatestVersionFetch()
185
+ // ---------------------------------------------------------------------------
186
+
187
+ describe('VersionHintService – triggerLatestVersionFetch()', () => {
188
+ beforeEach(() => {
189
+ vi.unstubAllGlobals();
190
+ });
191
+
192
+ it('only triggers fetch once even when called multiple times', async () => {
193
+ const fetchMock = vi.fn().mockResolvedValue({
194
+ ok: true,
195
+ json: () => Promise.resolve([{ name: 'release-2.25.7' }]),
196
+ });
197
+ vi.stubGlobal('fetch', fetchMock);
198
+
199
+ const svc = new VersionHintService();
200
+ svc.triggerLatestVersionFetch();
201
+ svc.triggerLatestVersionFetch();
202
+ svc.triggerLatestVersionFetch();
203
+ await svc.waitForLatestVersion(1000);
204
+
205
+ expect(fetchMock).toHaveBeenCalledOnce();
206
+ });
207
+
208
+ it('handles GitHub API errors gracefully (no crash, latestVersion stays null)', async () => {
209
+ const fetchMock = vi.fn().mockResolvedValue({ ok: false });
210
+ vi.stubGlobal('fetch', fetchMock);
211
+
212
+ const svc = new VersionHintService();
213
+ svc.triggerLatestVersionFetch();
214
+ await svc.waitForLatestVersion(500);
215
+
216
+ expect(svc.getLatestVersion()).toBeNull();
217
+ });
218
+
219
+ it('handles fetch network errors gracefully', async () => {
220
+ const fetchMock = vi.fn().mockRejectedValue(new Error('Network error'));
221
+ vi.stubGlobal('fetch', fetchMock);
222
+
223
+ const svc = new VersionHintService();
224
+ svc.triggerLatestVersionFetch();
225
+ // Give async fire-and-forget time to complete
226
+ await new Promise(r => setTimeout(r, 100));
227
+
228
+ expect(svc.getLatestVersion()).toBeNull();
229
+ });
230
+ });
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": "./src",
5
+ "noEmit": false
6
+ },
7
+ "include": ["src/**/*"]
8
+ }
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: 'node',
6
+ include: ['tests/**/*.test.ts'],
7
+ },
8
+ });