@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
|
@@ -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
|
-
});
|