Skip to content

Commit 3f9f986

Browse files
committed
test: add project-manager + Redis cache error tests; PRAGMA optimize
New tests: - project-manager.test.ts: addProject/removeProject/getProject/listProjects, workspace management, project-in-workspace, shared storeManager, shutdown, event emission, error handling, reindex - redis-cache-errors.test.ts: Redis connection failure propagation, corrupted data handling, TTL options, prefix isolation Code: - Add PRAGMA optimize on database open (FTS5 + query planner stats) +35 new tests (1538 → 1572, 69 → 71 suites)
1 parent a314597 commit 3f9f986

3 files changed

Lines changed: 429 additions & 0 deletions

File tree

src/store/sqlite/lib/db.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,7 @@ export function openDatabase(dbPath: string): Database.Database {
1717
db.pragma('foreign_keys = ON');
1818
db.pragma('busy_timeout = 5000');
1919
db.pragma('synchronous = NORMAL');
20+
// Optimize FTS5 indexes and query planner statistics on open
21+
db.pragma('optimize');
2022
return db;
2123
}

src/tests/project-manager.test.ts

Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
1+
import { mkdtempSync, mkdirSync, rmSync } from 'fs';
2+
import { join } from 'path';
3+
import { tmpdir } from 'os';
4+
import { ProjectManager } from '@/lib/project-manager';
5+
import type { ServerConfig, ProjectConfig, WorkspaceConfig } from '@/lib/multi-config';
6+
7+
// ---------------------------------------------------------------------------
8+
// Helpers — minimal configs to test project/workspace management without
9+
// loading real embedding models or running indexers
10+
// ---------------------------------------------------------------------------
11+
12+
const TEST_MODEL = { name: 'test', pooling: 'mean' as const, normalize: true, queryPrefix: '', documentPrefix: '' };
13+
const TEST_EMBEDDING = { batchSize: 1, maxChars: 2000, cacheSize: 0 };
14+
15+
function graphConfigs() {
16+
return Object.fromEntries(
17+
['docs', 'code', 'knowledge', 'tasks', 'files', 'skills'].map(g => [g, {
18+
enabled: false, // disabled to avoid indexer/model deps
19+
readonly: false,
20+
include: undefined,
21+
exclude: [],
22+
model: { ...TEST_MODEL },
23+
embedding: { ...TEST_EMBEDDING },
24+
}]),
25+
) as any;
26+
}
27+
28+
function makeServerConfig(): ServerConfig {
29+
return {
30+
host: '127.0.0.1',
31+
port: 0,
32+
modelsDir: '',
33+
jwtSecret: 'test-secret',
34+
sessionTtl: '30m',
35+
corsOrigins: [],
36+
secureCookie: false,
37+
oauth: { enabled: false, accessTokenTtl: '1h', refreshTokenTtl: '7d', authCodeTtl: '10m', allowedRedirectUris: [] },
38+
defaultAccess: 'full',
39+
users: {},
40+
} as any;
41+
}
42+
43+
function makeProjectConfig(dir: string): ProjectConfig {
44+
const graphMemory = join(dir, '.graph-memory');
45+
mkdirSync(graphMemory, { recursive: true });
46+
return {
47+
projectDir: dir,
48+
graphMemory,
49+
exclude: [],
50+
chunkDepth: 4,
51+
maxFileSize: 1048576,
52+
model: { ...TEST_MODEL },
53+
embedding: { ...TEST_EMBEDDING },
54+
graphConfigs: graphConfigs(),
55+
author: { name: '', email: '' },
56+
} as any;
57+
}
58+
59+
function makeWorkspaceConfig(dir: string): WorkspaceConfig {
60+
const graphMemory = join(dir, '.graph-memory');
61+
mkdirSync(graphMemory, { recursive: true });
62+
return {
63+
mirrorDir: dir,
64+
graphMemory,
65+
graphConfigs: graphConfigs(),
66+
} as any;
67+
}
68+
69+
// ---------------------------------------------------------------------------
70+
// Tests
71+
// ---------------------------------------------------------------------------
72+
73+
describe('ProjectManager', () => {
74+
let pm: ProjectManager;
75+
let tmpDirs: string[];
76+
77+
beforeEach(() => {
78+
pm = new ProjectManager(makeServerConfig());
79+
tmpDirs = [];
80+
});
81+
82+
afterEach(async () => {
83+
await pm.shutdown();
84+
for (const d of tmpDirs) rmSync(d, { recursive: true, force: true });
85+
});
86+
87+
function makeTmpDir(prefix = 'pm-test-'): string {
88+
const d = mkdtempSync(join(tmpdir(), prefix));
89+
tmpDirs.push(d);
90+
return d;
91+
}
92+
93+
// --- Project management ---
94+
95+
describe('addProject / getProject / listProjects', () => {
96+
it('adds and retrieves a project', async () => {
97+
const dir = makeTmpDir();
98+
await pm.addProject('proj1', makeProjectConfig(dir));
99+
100+
expect(pm.getProject('proj1')).toBeDefined();
101+
expect(pm.getProject('proj1')!.id).toBe('proj1');
102+
});
103+
104+
it('listProjects returns all project IDs', async () => {
105+
const d1 = makeTmpDir();
106+
const d2 = makeTmpDir();
107+
await pm.addProject('a', makeProjectConfig(d1));
108+
await pm.addProject('b', makeProjectConfig(d2));
109+
110+
const ids = pm.listProjects();
111+
expect(ids).toContain('a');
112+
expect(ids).toContain('b');
113+
expect(ids).toHaveLength(2);
114+
});
115+
116+
it('throws on duplicate project ID', async () => {
117+
const dir = makeTmpDir();
118+
await pm.addProject('dup', makeProjectConfig(dir));
119+
120+
await expect(pm.addProject('dup', makeProjectConfig(dir)))
121+
.rejects.toThrow(/already exists/);
122+
});
123+
124+
it('getProject returns undefined for unknown ID', () => {
125+
expect(pm.getProject('nope')).toBeUndefined();
126+
});
127+
});
128+
129+
// --- Remove project ---
130+
131+
describe('removeProject', () => {
132+
it('removes a project and cleans up', async () => {
133+
const dir = makeTmpDir();
134+
await pm.addProject('rm-me', makeProjectConfig(dir));
135+
expect(pm.getProject('rm-me')).toBeDefined();
136+
137+
await pm.removeProject('rm-me');
138+
expect(pm.getProject('rm-me')).toBeUndefined();
139+
expect(pm.listProjects()).not.toContain('rm-me');
140+
});
141+
142+
it('removeProject is no-op for unknown project', async () => {
143+
// Should not throw
144+
await pm.removeProject('unknown');
145+
});
146+
});
147+
148+
// --- Workspace management ---
149+
150+
describe('addWorkspace / getWorkspace / listWorkspaces', () => {
151+
it('adds and retrieves a workspace', async () => {
152+
const dir = makeTmpDir();
153+
await pm.addWorkspace('ws1', makeWorkspaceConfig(dir));
154+
155+
expect(pm.getWorkspace('ws1')).toBeDefined();
156+
expect(pm.getWorkspace('ws1')!.id).toBe('ws1');
157+
});
158+
159+
it('listWorkspaces returns all workspace IDs', async () => {
160+
const d1 = makeTmpDir();
161+
const d2 = makeTmpDir();
162+
await pm.addWorkspace('ws-a', makeWorkspaceConfig(d1));
163+
await pm.addWorkspace('ws-b', makeWorkspaceConfig(d2));
164+
165+
const ids = pm.listWorkspaces();
166+
expect(ids).toContain('ws-a');
167+
expect(ids).toContain('ws-b');
168+
});
169+
170+
it('throws on duplicate workspace ID', async () => {
171+
const dir = makeTmpDir();
172+
await pm.addWorkspace('dup-ws', makeWorkspaceConfig(dir));
173+
174+
await expect(pm.addWorkspace('dup-ws', makeWorkspaceConfig(dir)))
175+
.rejects.toThrow(/already exists/);
176+
});
177+
178+
it('getWorkspace returns undefined for unknown ID', () => {
179+
expect(pm.getWorkspace('nope')).toBeUndefined();
180+
});
181+
});
182+
183+
// --- Project in workspace ---
184+
185+
describe('project in workspace', () => {
186+
it('adds project to workspace', async () => {
187+
const wsDir = makeTmpDir('ws-');
188+
const projDir = makeTmpDir('proj-');
189+
190+
await pm.addWorkspace('ws', makeWorkspaceConfig(wsDir));
191+
await pm.addProject('proj-in-ws', makeProjectConfig(projDir), false, 'ws');
192+
193+
const project = pm.getProject('proj-in-ws');
194+
expect(project).toBeDefined();
195+
expect(project!.workspaceId).toBe('ws');
196+
});
197+
198+
it('getProjectWorkspace returns workspace for workspace project', async () => {
199+
const wsDir = makeTmpDir('ws-');
200+
const projDir = makeTmpDir('proj-');
201+
202+
await pm.addWorkspace('ws2', makeWorkspaceConfig(wsDir));
203+
await pm.addProject('proj-ws2', makeProjectConfig(projDir), false, 'ws2');
204+
205+
const ws = pm.getProjectWorkspace('proj-ws2');
206+
expect(ws).toBeDefined();
207+
expect(ws!.id).toBe('ws2');
208+
});
209+
210+
it('getProjectWorkspace returns undefined for standalone project', async () => {
211+
const dir = makeTmpDir();
212+
await pm.addProject('standalone', makeProjectConfig(dir));
213+
214+
expect(pm.getProjectWorkspace('standalone')).toBeUndefined();
215+
});
216+
217+
it('throws when workspace does not exist', async () => {
218+
const dir = makeTmpDir();
219+
await expect(pm.addProject('p', makeProjectConfig(dir), false, 'nonexistent-ws'))
220+
.rejects.toThrow(/not found/);
221+
});
222+
223+
it('workspace project shares workspace storeManager', async () => {
224+
const wsDir = makeTmpDir('ws-');
225+
const projDir = makeTmpDir('proj-');
226+
227+
await pm.addWorkspace('ws3', makeWorkspaceConfig(wsDir));
228+
await pm.addProject('proj-ws3', makeProjectConfig(projDir), false, 'ws3');
229+
230+
const project = pm.getProject('proj-ws3')!;
231+
const ws = pm.getWorkspace('ws3')!;
232+
// Workspace projects share the workspace's storeManager
233+
expect(project.storeManager).toBe(ws.storeManager);
234+
});
235+
});
236+
237+
// --- Shutdown ---
238+
239+
describe('shutdown', () => {
240+
it('clears all projects and workspaces', async () => {
241+
const d1 = makeTmpDir();
242+
const d2 = makeTmpDir();
243+
await pm.addProject('p1', makeProjectConfig(d1));
244+
await pm.addProject('p2', makeProjectConfig(d2));
245+
246+
await pm.shutdown();
247+
248+
expect(pm.listProjects()).toHaveLength(0);
249+
expect(pm.listWorkspaces()).toHaveLength(0);
250+
});
251+
252+
it('shutdown is safe to call multiple times', async () => {
253+
await pm.shutdown();
254+
await pm.shutdown(); // Should not throw
255+
});
256+
});
257+
258+
// --- Events ---
259+
260+
describe('event emission', () => {
261+
it('emits project:indexed on finalizeIndexing', async () => {
262+
const dir = makeTmpDir();
263+
await pm.addProject('evt', makeProjectConfig(dir));
264+
pm.ensureIndexer('evt');
265+
266+
const events: string[] = [];
267+
pm.on('project:indexed', (data: any) => events.push(data.projectId));
268+
269+
await pm.finalizeIndexing('evt');
270+
expect(events).toContain('evt');
271+
});
272+
});
273+
274+
// --- Error handling ---
275+
276+
describe('error handling', () => {
277+
it('ensureIndexer throws for unknown project', () => {
278+
expect(() => pm.ensureIndexer('nope')).toThrow(/not found/);
279+
});
280+
281+
it('startIndexingPhase throws for unknown project', async () => {
282+
await expect(pm.startIndexingPhase('nope', 'docs')).rejects.toThrow(/not found/);
283+
});
284+
285+
it('finalizeIndexing throws for unknown project', async () => {
286+
await expect(pm.finalizeIndexing('nope')).rejects.toThrow(/not found/);
287+
});
288+
289+
it('loadModels throws for unknown project', async () => {
290+
await expect(pm.loadModels('nope')).rejects.toThrow(/not found/);
291+
});
292+
293+
it('loadWorkspaceModels throws for unknown workspace', async () => {
294+
await expect(pm.loadWorkspaceModels('nope')).rejects.toThrow(/not found/);
295+
});
296+
297+
it('startWorkspaceMirror throws for unknown workspace', async () => {
298+
await expect(pm.startWorkspaceMirror('nope')).rejects.toThrow(/not found/);
299+
});
300+
301+
it('startIndexingPhase throws if indexer not created', async () => {
302+
const dir = makeTmpDir();
303+
await pm.addProject('no-indexer', makeProjectConfig(dir));
304+
305+
await expect(pm.startIndexingPhase('no-indexer', 'docs')).rejects.toThrow(/Indexer not created/);
306+
});
307+
308+
it('finalizeIndexing throws if indexer not created', async () => {
309+
const dir = makeTmpDir();
310+
await pm.addProject('no-indexer2', makeProjectConfig(dir));
311+
312+
await expect(pm.finalizeIndexing('no-indexer2')).rejects.toThrow(/Indexer not created/);
313+
});
314+
});
315+
316+
// --- Reindex ---
317+
318+
describe('reindex', () => {
319+
it('reindex clears indexed data when project already exists', async () => {
320+
const dir = makeTmpDir();
321+
// First add
322+
await pm.addProject('reindex-test', makeProjectConfig(dir));
323+
await pm.removeProject('reindex-test');
324+
325+
// Re-add with reindex flag (creates new store, so no data to clear)
326+
await pm.addProject('reindex-test', makeProjectConfig(dir), true);
327+
328+
const project = pm.getProject('reindex-test');
329+
expect(project).toBeDefined();
330+
});
331+
});
332+
});

0 commit comments

Comments
 (0)