@dpesch/mantisbt-mcp-server 1.10.3 → 1.10.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/package.json +1 -1
  3. package/server.json +2 -2
  4. package/.gitea/PULL_REQUEST_TEMPLATE.md +0 -17
  5. package/.gitea/workflows/ci.yml +0 -95
  6. package/.github/workflows/ci.yml +0 -32
  7. package/scripts/hooks/pre-push.mjs +0 -220
  8. package/scripts/init.mjs +0 -95
  9. package/scripts/record-fixtures.ts +0 -160
  10. package/tests/cache.test.ts +0 -265
  11. package/tests/client.test.ts +0 -372
  12. package/tests/config.test.ts +0 -263
  13. package/tests/fixtures/get_current_user.json +0 -40
  14. package/tests/fixtures/get_issue.json +0 -133
  15. package/tests/fixtures/get_issue_enums.json +0 -67
  16. package/tests/fixtures/get_issue_fields_sample.json +0 -76
  17. package/tests/fixtures/get_project_categories.json +0 -157
  18. package/tests/fixtures/get_project_versions.json +0 -3
  19. package/tests/fixtures/get_project_versions_with_data.json +0 -52
  20. package/tests/fixtures/list_issues.json +0 -95
  21. package/tests/fixtures/list_projects.json +0 -65
  22. package/tests/helpers/mock-server.ts +0 -166
  23. package/tests/helpers/search-mocks.ts +0 -84
  24. package/tests/prompts/prompts.test.ts +0 -242
  25. package/tests/resources/resources.test.ts +0 -309
  26. package/tests/search/embedder.test.ts +0 -81
  27. package/tests/search/highlight.test.ts +0 -129
  28. package/tests/search/store.test.ts +0 -193
  29. package/tests/search/sync.test.ts +0 -249
  30. package/tests/search/tools.test.ts +0 -661
  31. package/tests/tools/config.test.ts +0 -212
  32. package/tests/tools/files.test.ts +0 -343
  33. package/tests/tools/issues.test.ts +0 -1180
  34. package/tests/tools/metadata.test.ts +0 -509
  35. package/tests/tools/monitors.test.ts +0 -101
  36. package/tests/tools/projects.test.ts +0 -338
  37. package/tests/tools/relationships.test.ts +0 -177
  38. package/tests/tools/string-coercion.test.ts +0 -317
  39. package/tests/tools/users.test.ts +0 -62
  40. package/tests/utils/date-filter.test.ts +0 -169
  41. package/tests/version-hint.test.ts +0 -230
  42. package/tsconfig.build.json +0 -8
  43. package/vitest.config.ts +0 -8
@@ -1,129 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { extractTerms, highlightText, extractSnippet } from '../../src/search/highlight.js';
3
-
4
- // ---------------------------------------------------------------------------
5
- // extractTerms
6
- // ---------------------------------------------------------------------------
7
-
8
- describe('extractTerms', () => {
9
- it('splits by whitespace and returns terms of length >= 3', () => {
10
- expect(extractTerms('login error')).toEqual(['login', 'error']);
11
- });
12
-
13
- it('filters out terms shorter than 3 characters', () => {
14
- expect(extractTerms('a db login')).toEqual(['login']);
15
- });
16
-
17
- it('deduplicates terms case-insensitively', () => {
18
- const terms = extractTerms('Login login LOGIN');
19
- expect(terms).toHaveLength(1);
20
- expect(terms[0]!.toLowerCase()).toBe('login');
21
- });
22
-
23
- it('sorts longest terms first to prevent partial-match overlap', () => {
24
- const terms = extractTerms('err error errors');
25
- expect(terms[0]).toBe('errors');
26
- expect(terms[1]).toBe('error');
27
- });
28
-
29
- it('returns empty array for empty query', () => {
30
- expect(extractTerms('')).toEqual([]);
31
- });
32
-
33
- it('returns empty array when all terms are too short', () => {
34
- expect(extractTerms('a ab')).toEqual([]);
35
- });
36
-
37
- it('trims whitespace', () => {
38
- expect(extractTerms(' login error ')).toEqual(['login', 'error']);
39
- });
40
- });
41
-
42
- // ---------------------------------------------------------------------------
43
- // highlightText
44
- // ---------------------------------------------------------------------------
45
-
46
- describe('highlightText', () => {
47
- it('wraps a matching term in **bold**', () => {
48
- expect(highlightText('Login failed', ['login'])).toBe('**Login** failed');
49
- });
50
-
51
- it('is case-insensitive', () => {
52
- expect(highlightText('CRASH on startup', ['crash'])).toBe('**CRASH** on startup');
53
- });
54
-
55
- it('highlights multiple terms', () => {
56
- const result = highlightText('Login error occurred', ['login', 'error']);
57
- expect(result).toBe('**Login** **error** occurred');
58
- });
59
-
60
- it('returns original text when no terms match', () => {
61
- expect(highlightText('Something unrelated', ['crash'])).toBe('Something unrelated');
62
- });
63
-
64
- it('returns original text when terms array is empty', () => {
65
- expect(highlightText('Login failed', [])).toBe('Login failed');
66
- });
67
-
68
- it('does not match term as substring within a word (word-boundary-aware)', () => {
69
- // "or" should NOT match inside "error"
70
- expect(highlightText('error occurred', ['or'])).toBe('error occurred');
71
- });
72
-
73
- it('escapes special regex characters in terms', () => {
74
- // Term with special regex chars should not throw
75
- expect(() => highlightText('test (foo)', ['(foo)'])).not.toThrow();
76
- });
77
-
78
- it('highlights all occurrences of a term', () => {
79
- const result = highlightText('login and login again', ['login']);
80
- expect(result).toBe('**login** and **login** again');
81
- });
82
- });
83
-
84
- // ---------------------------------------------------------------------------
85
- // extractSnippet
86
- // ---------------------------------------------------------------------------
87
-
88
- describe('extractSnippet', () => {
89
- it('returns full text highlighted when text is short', () => {
90
- const text = 'Login error on startup';
91
- const result = extractSnippet(text, ['login']);
92
- expect(result).toBe('**Login** error on startup');
93
- });
94
-
95
- it('returns first ~300 chars around the first match for long text', () => {
96
- const prefix = 'x'.repeat(200);
97
- const text = `${prefix} login error ${' unrelated text'.repeat(30)}`;
98
- const result = extractSnippet(text, ['login']);
99
- expect(result).toContain('**login**');
100
- expect(result.length).toBeLessThanOrEqual(350); // snippet + some overhead
101
- });
102
-
103
- it('centers snippet around first match', () => {
104
- const padding = 'word '.repeat(60); // ~300 chars before the match
105
- const text = `${padding}crash happens here ${'and more text '.repeat(30)}`;
106
- const result = extractSnippet(text, ['crash']);
107
- expect(result).toContain('**crash**');
108
- });
109
-
110
- it('returns first 300 chars (no highlight) when no term matches', () => {
111
- const text = 'a'.repeat(600);
112
- const result = extractSnippet(text, ['nomatch']);
113
- expect(result).toBe('a'.repeat(300) + '…');
114
- });
115
-
116
- it('returns full text (no truncation) when text is shorter than 300 chars and no match', () => {
117
- const text = 'Short text with no match';
118
- const result = extractSnippet(text, ['nomatch']);
119
- expect(result).toBe(text);
120
- });
121
-
122
- it('respects custom contextChars parameter', () => {
123
- const padding = 'x'.repeat(100);
124
- const text = `${padding} crash ${padding}`;
125
- const result = extractSnippet(text, ['crash'], 50);
126
- expect(result).toContain('**crash**');
127
- expect(result.length).toBeLessThanOrEqual(150);
128
- });
129
- });
@@ -1,193 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
- import { randomBytes } from 'node:crypto';
3
- import { rm } from 'node:fs/promises';
4
- import { join } from 'node:path';
5
- import { tmpdir } from 'node:os';
6
- import { VectraStore } from '../../src/search/store.js';
7
-
8
- // ---------------------------------------------------------------------------
9
- // Helpers
10
- // ---------------------------------------------------------------------------
11
-
12
- function randomVector(dim = 384): number[] {
13
- return Array.from({ length: dim }, () => Math.random() * 2 - 1);
14
- }
15
-
16
- function tmpDir(): string {
17
- return join(tmpdir(), `mantis-search-test-${randomBytes(8).toString('hex')}`);
18
- }
19
-
20
- // ---------------------------------------------------------------------------
21
- // Tests
22
- // ---------------------------------------------------------------------------
23
-
24
- let store: VectraStore;
25
- let dir: string;
26
-
27
- beforeEach(() => {
28
- dir = tmpDir();
29
- store = new VectraStore(dir);
30
- });
31
-
32
- afterEach(async () => {
33
- await rm(dir, { recursive: true, force: true });
34
- });
35
-
36
- describe('VectraStore.count', () => {
37
- it('returns 0 on an empty store', async () => {
38
- expect(await store.count()).toBe(0);
39
- });
40
- });
41
-
42
- describe('VectraStore.add', () => {
43
- it('increases count after adding an item', async () => {
44
- await store.add({ id: 1, vector: randomVector(), metadata: { summary: 'First issue' } });
45
- expect(await store.count()).toBe(1);
46
- });
47
-
48
- it('persists across store instances (same dir)', async () => {
49
- await store.add({ id: 42, vector: randomVector(), metadata: { summary: 'Persistent' } });
50
-
51
- const store2 = new VectraStore(dir);
52
- expect(await store2.count()).toBe(1);
53
- });
54
-
55
- it('overwrites an existing item with the same id', async () => {
56
- const vec1 = randomVector();
57
- const vec2 = randomVector();
58
- await store.add({ id: 7, vector: vec1, metadata: { summary: 'Original' } });
59
- await store.add({ id: 7, vector: vec2, metadata: { summary: 'Updated' } });
60
- expect(await store.count()).toBe(1);
61
- });
62
- });
63
-
64
- describe('VectraStore.search', () => {
65
- it('returns results sorted by descending score', async () => {
66
- const queryVec = randomVector();
67
- // Add items with known vectors; one is identical to the query (score = 1)
68
- await store.add({ id: 1, vector: [...queryVec], metadata: { summary: 'Exact match' } });
69
- await store.add({ id: 2, vector: randomVector(), metadata: { summary: 'Random' } });
70
- await store.add({ id: 3, vector: randomVector(), metadata: { summary: 'Another random' } });
71
-
72
- const results = await store.search(queryVec, 3);
73
-
74
- expect(results.length).toBe(3);
75
- expect(results[0]!.id).toBe(1);
76
- expect(results[0]!.score).toBeCloseTo(1, 5);
77
- // Scores are descending
78
- for (let i = 1; i < results.length; i++) {
79
- expect(results[i - 1]!.score).toBeGreaterThanOrEqual(results[i]!.score);
80
- }
81
- });
82
-
83
- it('respects the topN limit', async () => {
84
- for (let i = 1; i <= 5; i++) {
85
- await store.add({ id: i, vector: randomVector(), metadata: { summary: `Issue ${i}` } });
86
- }
87
- const results = await store.search(randomVector(), 3);
88
- expect(results.length).toBe(3);
89
- });
90
-
91
- it('returns empty array on empty store', async () => {
92
- const results = await store.search(randomVector(), 5);
93
- expect(results).toEqual([]);
94
- });
95
- });
96
-
97
- describe('VectraStore.delete', () => {
98
- it('removes an item and decreases count', async () => {
99
- await store.add({ id: 10, vector: randomVector(), metadata: { summary: 'To delete' } });
100
- await store.add({ id: 11, vector: randomVector(), metadata: { summary: 'To keep' } });
101
- await store.delete(10);
102
- expect(await store.count()).toBe(1);
103
- });
104
-
105
- it('does nothing when deleting a non-existent id', async () => {
106
- await store.add({ id: 5, vector: randomVector(), metadata: { summary: 'Exists' } });
107
- await store.delete(9999);
108
- expect(await store.count()).toBe(1);
109
- });
110
- });
111
-
112
- describe('VectraStore.clear', () => {
113
- it('removes all items', async () => {
114
- for (let i = 1; i <= 3; i++) {
115
- await store.add({ id: i, vector: randomVector(), metadata: { summary: `Issue ${i}` } });
116
- }
117
- await store.clear();
118
- expect(await store.count()).toBe(0);
119
- });
120
- });
121
-
122
- describe('VectraStore.getLastSyncedAt / setLastSyncedAt', () => {
123
- it('returns null initially', async () => {
124
- expect(await store.getLastSyncedAt()).toBeNull();
125
- });
126
-
127
- it('returns the value after setting it', async () => {
128
- const ts = '2024-01-15T10:00:00.000Z';
129
- await store.setLastSyncedAt(ts);
130
- expect(await store.getLastSyncedAt()).toBe(ts);
131
- });
132
-
133
- it('persists the value across instances', async () => {
134
- const ts = '2024-06-01T12:34:56.000Z';
135
- await store.setLastSyncedAt(ts);
136
-
137
- const store2 = new VectraStore(dir);
138
- expect(await store2.getLastSyncedAt()).toBe(ts);
139
- });
140
- });
141
-
142
- describe('VectraStore.resetLastSyncedAt', () => {
143
- it('clears the lastSyncedAt value', async () => {
144
- await store.setLastSyncedAt('2024-01-01T00:00:00.000Z');
145
- await store.resetLastSyncedAt();
146
- expect(await store.getLastSyncedAt()).toBeNull();
147
- });
148
- });
149
-
150
- describe('VectraStore.addBatch', () => {
151
- it('increases in-memory count without persisting to disk', async () => {
152
- await store.addBatch([
153
- { id: 1, vector: randomVector(), metadata: { summary: 'A' } },
154
- { id: 2, vector: randomVector(), metadata: { summary: 'B' } },
155
- ]);
156
- expect(await store.count()).toBe(2);
157
-
158
- // A new instance reading the same dir must not see the items yet
159
- const store2 = new VectraStore(dir);
160
- expect(await store2.count()).toBe(0);
161
- });
162
-
163
- it('persists items after flush()', async () => {
164
- await store.addBatch([
165
- { id: 10, vector: randomVector(), metadata: { summary: 'X' } },
166
- { id: 11, vector: randomVector(), metadata: { summary: 'Y' } },
167
- ]);
168
- await store.flush();
169
-
170
- const store2 = new VectraStore(dir);
171
- expect(await store2.count()).toBe(2);
172
- });
173
-
174
- it('accumulates items across multiple addBatch calls before flush', async () => {
175
- await store.addBatch([{ id: 1, vector: randomVector(), metadata: { summary: 'First' } }]);
176
- await store.addBatch([{ id: 2, vector: randomVector(), metadata: { summary: 'Second' } }]);
177
- await store.flush();
178
-
179
- const store2 = new VectraStore(dir);
180
- expect(await store2.count()).toBe(2);
181
- });
182
- });
183
-
184
- describe('VectraStore.flush (atomic write)', () => {
185
- it('leaves no .tmp file after a successful flush', async () => {
186
- const { readdir } = await import('node:fs/promises');
187
- await store.addBatch([{ id: 1, vector: randomVector(), metadata: { summary: 'Test' } }]);
188
- await store.flush();
189
-
190
- const files = await readdir(join(dir, 'vectra'));
191
- expect(files.some(f => f.endsWith('.tmp'))).toBe(false);
192
- });
193
- });
@@ -1,249 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { MantisClient } from '../../src/client.js';
3
- import { SearchSyncService } from '../../src/search/sync.js';
4
- import { makeMockStore, makeMockEmbedder } from '../helpers/search-mocks.js';
5
- import { makeResponse } from '../helpers/mock-server.js';
6
- import type { Embedder } from '../../src/search/embedder.js';
7
-
8
- // ---------------------------------------------------------------------------
9
- // Fixtures
10
- // ---------------------------------------------------------------------------
11
-
12
- const ISSUE_FIXTURE = [
13
- { id: 101, summary: 'Login fails on mobile', description: 'Steps to reproduce...', updated_at: '2024-03-10T08:00:00Z' },
14
- { id: 102, summary: 'Dashboard loads slowly', description: 'Takes over 10 seconds', updated_at: '2024-03-09T14:00:00Z' },
15
- { id: 103, description: 'No summary here', updated_at: '2024-03-08T10:00:00Z' }, // no summary → skipped
16
- ];
17
-
18
- const LIST_ISSUES_RESPONSE = {
19
- issues: ISSUE_FIXTURE,
20
- total_count: ISSUE_FIXTURE.length,
21
- };
22
-
23
- // ---------------------------------------------------------------------------
24
- // Setup
25
- // ---------------------------------------------------------------------------
26
-
27
- let client: MantisClient;
28
- let embedder: Embedder;
29
-
30
- beforeEach(() => {
31
- client = new MantisClient('https://mantis.example.com', 'test-token');
32
- embedder = makeMockEmbedder();
33
- vi.stubGlobal('fetch', vi.fn());
34
- });
35
-
36
- afterEach(() => {
37
- vi.unstubAllGlobals();
38
- vi.clearAllMocks();
39
- });
40
-
41
- // ---------------------------------------------------------------------------
42
- // sync without previous state
43
- // ---------------------------------------------------------------------------
44
-
45
- describe('SearchSyncService.sync – no previous state', () => {
46
- it('indexes all issues with summaries and skips those without', async () => {
47
- const store = makeMockStore({ lastSyncedAt: null });
48
- vi.mocked(fetch).mockResolvedValue(
49
- makeResponse(200, JSON.stringify(LIST_ISSUES_RESPONSE))
50
- );
51
-
52
- const service = new SearchSyncService(client, store, embedder);
53
- const result = await service.sync();
54
-
55
- // 2 issues have summaries, 1 does not
56
- expect(result.indexed).toBe(2);
57
- expect(result.skipped).toBe(1);
58
- expect(store.addBatch).toHaveBeenCalledTimes(1);
59
- expect(store.setLastSyncedAt).toHaveBeenCalledTimes(1);
60
- });
61
-
62
- it('calls the API without updated_after when no lastSyncedAt', async () => {
63
- const store = makeMockStore({ lastSyncedAt: null });
64
- vi.mocked(fetch).mockResolvedValue(
65
- makeResponse(200, JSON.stringify(LIST_ISSUES_RESPONSE))
66
- );
67
-
68
- const service = new SearchSyncService(client, store, embedder);
69
- await service.sync();
70
-
71
- const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
72
- const url = new URL(calledUrl);
73
- expect(url.searchParams.has('updated_after')).toBe(false);
74
- });
75
- });
76
-
77
- // ---------------------------------------------------------------------------
78
- // sync with lastSyncedAt (incremental)
79
- // ---------------------------------------------------------------------------
80
-
81
- describe('SearchSyncService.sync – incremental (with lastSyncedAt)', () => {
82
- it('passes updated_after to the API', async () => {
83
- const lastSync = '2024-03-09T00:00:00.000Z';
84
- const store = makeMockStore({ lastSyncedAt: lastSync });
85
- const incrementalResponse = {
86
- issues: [ISSUE_FIXTURE[0]!], // only one newer issue
87
- total_count: 1,
88
- };
89
- vi.mocked(fetch).mockResolvedValue(
90
- makeResponse(200, JSON.stringify(incrementalResponse))
91
- );
92
-
93
- const service = new SearchSyncService(client, store, embedder);
94
- await service.sync();
95
-
96
- const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
97
- const url = new URL(calledUrl);
98
- expect(url.searchParams.get('updated_after')).toBe(lastSync);
99
- expect(store.addBatch).toHaveBeenCalledTimes(1);
100
- });
101
- });
102
-
103
- // ---------------------------------------------------------------------------
104
- // Issues without summary are skipped
105
- // ---------------------------------------------------------------------------
106
-
107
- describe('SearchSyncService.sync – skip issues without summary', () => {
108
- it('returns correct skipped count', async () => {
109
- const store = makeMockStore({ lastSyncedAt: null });
110
- const onlyNoSummaryResponse = {
111
- issues: [{ id: 200, description: 'No summary', updated_at: '2024-01-01T00:00:00Z' }],
112
- total_count: 1,
113
- };
114
- vi.mocked(fetch).mockResolvedValue(
115
- makeResponse(200, JSON.stringify(onlyNoSummaryResponse))
116
- );
117
-
118
- const service = new SearchSyncService(client, store, embedder);
119
- const result = await service.sync();
120
-
121
- expect(result.indexed).toBe(0);
122
- expect(result.skipped).toBe(1);
123
- expect(store.addBatch).not.toHaveBeenCalled();
124
- });
125
- });
126
-
127
- // ---------------------------------------------------------------------------
128
- // project_id is forwarded
129
- // ---------------------------------------------------------------------------
130
-
131
- describe('SearchSyncService.sync – project_id', () => {
132
- it('passes project_id to the API', async () => {
133
- const store = makeMockStore({ lastSyncedAt: null });
134
- vi.mocked(fetch).mockResolvedValue(
135
- makeResponse(200, JSON.stringify({ issues: [], total_count: 0 }))
136
- );
137
-
138
- const service = new SearchSyncService(client, store, embedder);
139
- await service.sync(7);
140
-
141
- const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
142
- const url = new URL(calledUrl);
143
- expect(url.searchParams.get('project_id')).toBe('7');
144
- });
145
- });
146
-
147
- // ---------------------------------------------------------------------------
148
- // flush / checkpoint behaviour
149
- // ---------------------------------------------------------------------------
150
-
151
- describe('SearchSyncService.sync – flush and checkpoint', () => {
152
- function makeIssues(count: number) {
153
- return Array.from({ length: count }, (_, i) => ({
154
- id: i + 1,
155
- summary: `Issue ${i + 1}`,
156
- description: `Description ${i + 1}`,
157
- updated_at: '2024-03-10T08:00:00Z',
158
- }));
159
- }
160
-
161
- it('calls flush exactly once when fewer than 100 issues are indexed', async () => {
162
- const store = makeMockStore({ lastSyncedAt: null });
163
- vi.mocked(fetch).mockResolvedValue(
164
- makeResponse(200, JSON.stringify({ issues: makeIssues(50), total_count: 50 }))
165
- );
166
-
167
- const service = new SearchSyncService(client, store, embedder);
168
- await service.sync();
169
-
170
- // Only the final flush, no checkpoint
171
- expect(store.flush).toHaveBeenCalledTimes(1);
172
- });
173
-
174
- it('calls flush exactly once when exactly 100 issues are indexed (checkpoint covers all, no redundant final)', async () => {
175
- const store = makeMockStore({ lastSyncedAt: null });
176
- vi.mocked(fetch).mockResolvedValue(
177
- makeResponse(200, JSON.stringify({ issues: makeIssues(100), total_count: 100 }))
178
- );
179
-
180
- const service = new SearchSyncService(client, store, embedder);
181
- await service.sync();
182
-
183
- // Checkpoint at 100 covers all items; indexedSinceCheckpoint resets to 0 → no redundant final flush
184
- expect(store.flush).toHaveBeenCalledTimes(1);
185
- });
186
-
187
- it('calls flush twice when 110 issues are indexed (checkpoint at 100 + final for remaining 10)', async () => {
188
- const store = makeMockStore({ lastSyncedAt: null });
189
- vi.mocked(fetch).mockResolvedValue(
190
- makeResponse(200, JSON.stringify({ issues: makeIssues(110), total_count: 110 }))
191
- );
192
-
193
- const service = new SearchSyncService(client, store, embedder);
194
- await service.sync();
195
-
196
- // Checkpoint at 100, then final flush for remaining 10
197
- expect(store.flush).toHaveBeenCalledTimes(2);
198
- });
199
- });
200
-
201
- // ---------------------------------------------------------------------------
202
- // total_count persistence (regression: MantisBT installations without total_count)
203
- // ---------------------------------------------------------------------------
204
-
205
- describe('SearchSyncService.sync – setLastKnownTotal persistence', () => {
206
- it('persists total_count from API response', async () => {
207
- const store = makeMockStore({ lastSyncedAt: null });
208
- vi.mocked(fetch).mockResolvedValue(
209
- makeResponse(200, JSON.stringify({ issues: ISSUE_FIXTURE, total_count: 42 }))
210
- );
211
-
212
- const service = new SearchSyncService(client, store, embedder);
213
- const result = await service.sync();
214
-
215
- expect(store.setLastKnownTotal).toHaveBeenCalledWith(42);
216
- expect(result.total).toBe(42);
217
- });
218
-
219
- it('uses indexed+skipped as fallback when API omits total_count (full rebuild)', async () => {
220
- // Regression test: MantisBT installations that do not return total_count
221
- const store = makeMockStore({ lastSyncedAt: null });
222
- vi.mocked(fetch).mockResolvedValue(
223
- makeResponse(200, JSON.stringify({ issues: ISSUE_FIXTURE })) // no total_count field
224
- );
225
-
226
- const service = new SearchSyncService(client, store, embedder);
227
- const result = await service.sync();
228
-
229
- // ISSUE_FIXTURE: 2 indexed (have summary), 1 skipped (no summary) → total = 3
230
- expect(result.total).toBe(3);
231
- expect(store.setLastKnownTotal).toHaveBeenCalledWith(3);
232
- });
233
-
234
- it('falls back to store.count() for incremental sync when API omits total_count', async () => {
235
- // MantisBT installations without total_count: incremental sync uses the current store
236
- // size as best available estimate so the status tool never shows "total unknown".
237
- const store = makeMockStore({ lastSyncedAt: '2024-03-01T00:00:00.000Z', itemCount: 100 });
238
- vi.mocked(fetch).mockResolvedValue(
239
- makeResponse(200, JSON.stringify({ issues: [ISSUE_FIXTURE[0]!] })) // no total_count, partial result
240
- );
241
-
242
- const service = new SearchSyncService(client, store, embedder);
243
- const result = await service.sync();
244
-
245
- // store.count() returns 101 after adding the 1 new item (mock increments count on addBatch)
246
- expect(result.total).toBeGreaterThan(0);
247
- expect(store.setLastKnownTotal).toHaveBeenCalled();
248
- });
249
- });