@google/gemini-cli 0.9.0-nightly.20251006.0cf01df4 → 0.9.0-nightly.20251007.4f53919a
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/dist/package.json +2 -2
- package/dist/src/config/config.js +33 -4
- package/dist/src/config/config.js.map +1 -1
- package/dist/src/config/settings.d.ts +10 -0
- package/dist/src/config/settings.js.map +1 -1
- package/dist/src/config/settingsSchema.d.ts +47 -0
- package/dist/src/config/settingsSchema.js +47 -0
- package/dist/src/config/settingsSchema.js.map +1 -1
- package/dist/src/gemini.js +6 -3
- package/dist/src/gemini.js.map +1 -1
- package/dist/src/generated/git-commit.d.ts +2 -2
- package/dist/src/generated/git-commit.js +2 -2
- package/dist/src/ui/hooks/useGeminiStream.js +12 -0
- package/dist/src/ui/hooks/useGeminiStream.js.map +1 -1
- package/dist/src/utils/sessionCleanup.d.ts +22 -0
- package/dist/src/utils/sessionCleanup.integration.test.d.ts +6 -0
- package/dist/src/utils/sessionCleanup.integration.test.js +182 -0
- package/dist/src/utils/sessionCleanup.integration.test.js.map +1 -0
- package/dist/src/utils/sessionCleanup.js +214 -0
- package/dist/src/utils/sessionCleanup.js.map +1 -0
- package/dist/src/utils/sessionCleanup.test.d.ts +6 -0
- package/dist/src/utils/sessionCleanup.test.js +1232 -0
- package/dist/src/utils/sessionCleanup.test.js.map +1 -0
- package/dist/src/utils/sessionUtils.d.ts +37 -0
- package/dist/src/utils/sessionUtils.js +71 -0
- package/dist/src/utils/sessionUtils.js.map +1 -0
- package/dist/src/zed-integration/schema.d.ts +30 -30
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,1232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2025 Google LLC
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
7
|
+
import * as fs from 'node:fs/promises';
|
|
8
|
+
import * as path from 'node:path';
|
|
9
|
+
import { SESSION_FILE_PREFIX } from '@google/gemini-cli-core';
|
|
10
|
+
import { cleanupExpiredSessions } from './sessionCleanup.js';
|
|
11
|
+
import { getAllSessionFiles } from './sessionUtils.js';
|
|
12
|
+
// Mock the fs module
|
|
13
|
+
vi.mock('fs/promises');
|
|
14
|
+
vi.mock('./sessionUtils.js', () => ({
|
|
15
|
+
getAllSessionFiles: vi.fn(),
|
|
16
|
+
}));
|
|
17
|
+
const mockFs = vi.mocked(fs);
|
|
18
|
+
const mockGetAllSessionFiles = vi.mocked(getAllSessionFiles);
|
|
19
|
+
// Create mock config
|
|
20
|
+
function createMockConfig(overrides = {}) {
|
|
21
|
+
return {
|
|
22
|
+
storage: {
|
|
23
|
+
getProjectTempDir: vi.fn().mockReturnValue('/tmp/test-project'),
|
|
24
|
+
},
|
|
25
|
+
getSessionId: vi.fn().mockReturnValue('current123'),
|
|
26
|
+
getDebugMode: vi.fn().mockReturnValue(false),
|
|
27
|
+
initialize: vi.fn().mockResolvedValue(undefined),
|
|
28
|
+
...overrides,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
// Create test session data
|
|
32
|
+
function createTestSessions() {
|
|
33
|
+
const now = new Date();
|
|
34
|
+
const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
35
|
+
const twoWeeksAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000);
|
|
36
|
+
const oneMonthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
37
|
+
return [
|
|
38
|
+
{
|
|
39
|
+
id: 'current123',
|
|
40
|
+
fileName: `${SESSION_FILE_PREFIX}2025-01-20T10-30-00-current12.json`,
|
|
41
|
+
lastUpdated: now.toISOString(),
|
|
42
|
+
isCurrentSession: true,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: 'recent456',
|
|
46
|
+
fileName: `${SESSION_FILE_PREFIX}2025-01-18T15-45-00-recent45.json`,
|
|
47
|
+
lastUpdated: oneWeekAgo.toISOString(),
|
|
48
|
+
isCurrentSession: false,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: 'old789abc',
|
|
52
|
+
fileName: `${SESSION_FILE_PREFIX}2025-01-10T09-15-00-old789ab.json`,
|
|
53
|
+
lastUpdated: twoWeeksAgo.toISOString(),
|
|
54
|
+
isCurrentSession: false,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: 'ancient12',
|
|
58
|
+
fileName: `${SESSION_FILE_PREFIX}2024-12-25T12-00-00-ancient1.json`,
|
|
59
|
+
lastUpdated: oneMonthAgo.toISOString(),
|
|
60
|
+
isCurrentSession: false,
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
describe('Session Cleanup', () => {
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
vi.clearAllMocks();
|
|
67
|
+
// By default, return all test sessions as valid
|
|
68
|
+
const sessions = createTestSessions();
|
|
69
|
+
mockGetAllSessionFiles.mockResolvedValue(sessions.map((session) => ({
|
|
70
|
+
fileName: session.fileName,
|
|
71
|
+
sessionInfo: session,
|
|
72
|
+
})));
|
|
73
|
+
});
|
|
74
|
+
afterEach(() => {
|
|
75
|
+
vi.restoreAllMocks();
|
|
76
|
+
});
|
|
77
|
+
describe('cleanupExpiredSessions', () => {
|
|
78
|
+
it('should return early when cleanup is disabled', async () => {
|
|
79
|
+
const config = createMockConfig();
|
|
80
|
+
const settings = {
|
|
81
|
+
general: { sessionRetention: { enabled: false } },
|
|
82
|
+
};
|
|
83
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
84
|
+
expect(result.disabled).toBe(true);
|
|
85
|
+
expect(result.scanned).toBe(0);
|
|
86
|
+
expect(result.deleted).toBe(0);
|
|
87
|
+
expect(result.skipped).toBe(0);
|
|
88
|
+
expect(result.failed).toBe(0);
|
|
89
|
+
});
|
|
90
|
+
it('should return early when sessionRetention is not configured', async () => {
|
|
91
|
+
const config = createMockConfig();
|
|
92
|
+
const settings = {};
|
|
93
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
94
|
+
expect(result.disabled).toBe(true);
|
|
95
|
+
expect(result.scanned).toBe(0);
|
|
96
|
+
expect(result.deleted).toBe(0);
|
|
97
|
+
});
|
|
98
|
+
it('should handle invalid maxAge configuration', async () => {
|
|
99
|
+
const config = createMockConfig({
|
|
100
|
+
getDebugMode: vi.fn().mockReturnValue(true),
|
|
101
|
+
});
|
|
102
|
+
const settings = {
|
|
103
|
+
general: {
|
|
104
|
+
sessionRetention: {
|
|
105
|
+
enabled: true,
|
|
106
|
+
maxAge: 'invalid-format',
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
111
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
112
|
+
expect(result.disabled).toBe(true);
|
|
113
|
+
expect(result.scanned).toBe(0);
|
|
114
|
+
expect(result.deleted).toBe(0);
|
|
115
|
+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Session cleanup disabled: Error: Invalid retention period format'));
|
|
116
|
+
errorSpy.mockRestore();
|
|
117
|
+
});
|
|
118
|
+
it('should delete sessions older than maxAge', async () => {
|
|
119
|
+
const config = createMockConfig();
|
|
120
|
+
const settings = {
|
|
121
|
+
general: {
|
|
122
|
+
sessionRetention: {
|
|
123
|
+
enabled: true,
|
|
124
|
+
maxAge: '10d', // 10 days
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
// Mock successful file operations
|
|
129
|
+
mockFs.access.mockResolvedValue(undefined);
|
|
130
|
+
mockFs.readFile.mockResolvedValue(JSON.stringify({
|
|
131
|
+
sessionId: 'test',
|
|
132
|
+
messages: [],
|
|
133
|
+
startTime: '2025-01-01T00:00:00Z',
|
|
134
|
+
lastUpdated: '2025-01-01T00:00:00Z',
|
|
135
|
+
}));
|
|
136
|
+
mockFs.unlink.mockResolvedValue(undefined);
|
|
137
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
138
|
+
expect(result.disabled).toBe(false);
|
|
139
|
+
expect(result.scanned).toBe(4);
|
|
140
|
+
expect(result.deleted).toBe(2); // Should delete the 2-week-old and 1-month-old sessions
|
|
141
|
+
expect(result.skipped).toBe(2); // Current session + recent session should be skipped
|
|
142
|
+
expect(result.failed).toBe(0);
|
|
143
|
+
});
|
|
144
|
+
it('should never delete current session', async () => {
|
|
145
|
+
const config = createMockConfig();
|
|
146
|
+
const settings = {
|
|
147
|
+
general: {
|
|
148
|
+
sessionRetention: {
|
|
149
|
+
enabled: true,
|
|
150
|
+
maxAge: '1d', // Very short retention
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
// Mock successful file operations
|
|
155
|
+
mockFs.access.mockResolvedValue(undefined);
|
|
156
|
+
mockFs.readFile.mockResolvedValue(JSON.stringify({
|
|
157
|
+
sessionId: 'test',
|
|
158
|
+
messages: [],
|
|
159
|
+
startTime: '2025-01-01T00:00:00Z',
|
|
160
|
+
lastUpdated: '2025-01-01T00:00:00Z',
|
|
161
|
+
}));
|
|
162
|
+
mockFs.unlink.mockResolvedValue(undefined);
|
|
163
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
164
|
+
// Should delete all sessions except the current one
|
|
165
|
+
expect(result.disabled).toBe(false);
|
|
166
|
+
expect(result.deleted).toBe(3);
|
|
167
|
+
// Verify that unlink was never called with the current session file
|
|
168
|
+
const unlinkCalls = mockFs.unlink.mock.calls;
|
|
169
|
+
const currentSessionPath = path.join('/tmp/test-project', 'chats', `${SESSION_FILE_PREFIX}2025-01-20T10-30-00-current12.json`);
|
|
170
|
+
expect(unlinkCalls.find((call) => call[0] === currentSessionPath)).toBeUndefined();
|
|
171
|
+
});
|
|
172
|
+
it('should handle count-based retention', async () => {
|
|
173
|
+
const config = createMockConfig();
|
|
174
|
+
const settings = {
|
|
175
|
+
general: {
|
|
176
|
+
sessionRetention: {
|
|
177
|
+
enabled: true,
|
|
178
|
+
maxCount: 2, // Keep only 2 most recent sessions
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
// Mock successful file operations
|
|
183
|
+
mockFs.access.mockResolvedValue(undefined);
|
|
184
|
+
mockFs.readFile.mockResolvedValue(JSON.stringify({
|
|
185
|
+
sessionId: 'test',
|
|
186
|
+
messages: [],
|
|
187
|
+
startTime: '2025-01-01T00:00:00Z',
|
|
188
|
+
lastUpdated: '2025-01-01T00:00:00Z',
|
|
189
|
+
}));
|
|
190
|
+
mockFs.unlink.mockResolvedValue(undefined);
|
|
191
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
192
|
+
expect(result.disabled).toBe(false);
|
|
193
|
+
expect(result.scanned).toBe(4);
|
|
194
|
+
expect(result.deleted).toBe(2); // Should delete 2 oldest sessions (after skipping the current one)
|
|
195
|
+
expect(result.skipped).toBe(2); // Current session + 1 recent session should be kept
|
|
196
|
+
});
|
|
197
|
+
it('should handle file system errors gracefully', async () => {
|
|
198
|
+
const config = createMockConfig();
|
|
199
|
+
const settings = {
|
|
200
|
+
general: {
|
|
201
|
+
sessionRetention: {
|
|
202
|
+
enabled: true,
|
|
203
|
+
maxAge: '1d',
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
// Mock file operations to succeed for access and readFile but fail for unlink
|
|
208
|
+
mockFs.access.mockResolvedValue(undefined);
|
|
209
|
+
mockFs.readFile.mockResolvedValue(JSON.stringify({
|
|
210
|
+
sessionId: 'test',
|
|
211
|
+
messages: [],
|
|
212
|
+
startTime: '2025-01-01T00:00:00Z',
|
|
213
|
+
lastUpdated: '2025-01-01T00:00:00Z',
|
|
214
|
+
}));
|
|
215
|
+
mockFs.unlink.mockRejectedValue(new Error('Permission denied'));
|
|
216
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
217
|
+
expect(result.disabled).toBe(false);
|
|
218
|
+
expect(result.scanned).toBe(4);
|
|
219
|
+
expect(result.deleted).toBe(0);
|
|
220
|
+
expect(result.failed).toBeGreaterThan(0);
|
|
221
|
+
});
|
|
222
|
+
it('should handle empty sessions directory', async () => {
|
|
223
|
+
const config = createMockConfig();
|
|
224
|
+
const settings = {
|
|
225
|
+
general: {
|
|
226
|
+
sessionRetention: {
|
|
227
|
+
enabled: true,
|
|
228
|
+
maxAge: '30d',
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
mockGetAllSessionFiles.mockResolvedValue([]);
|
|
233
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
234
|
+
expect(result.disabled).toBe(false);
|
|
235
|
+
expect(result.scanned).toBe(0);
|
|
236
|
+
expect(result.deleted).toBe(0);
|
|
237
|
+
expect(result.skipped).toBe(0);
|
|
238
|
+
expect(result.failed).toBe(0);
|
|
239
|
+
});
|
|
240
|
+
it('should handle global errors gracefully', async () => {
|
|
241
|
+
const config = createMockConfig();
|
|
242
|
+
const settings = {
|
|
243
|
+
general: {
|
|
244
|
+
sessionRetention: {
|
|
245
|
+
enabled: true,
|
|
246
|
+
maxAge: '30d',
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
251
|
+
// Mock getSessionFiles to throw an error
|
|
252
|
+
mockGetAllSessionFiles.mockRejectedValue(new Error('Directory access failed'));
|
|
253
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
254
|
+
expect(result.disabled).toBe(false);
|
|
255
|
+
expect(result.failed).toBe(1);
|
|
256
|
+
expect(errorSpy).toHaveBeenCalledWith('Session cleanup failed: Directory access failed');
|
|
257
|
+
errorSpy.mockRestore();
|
|
258
|
+
});
|
|
259
|
+
it('should respect minRetention configuration', async () => {
|
|
260
|
+
const config = createMockConfig();
|
|
261
|
+
const settings = {
|
|
262
|
+
general: {
|
|
263
|
+
sessionRetention: {
|
|
264
|
+
enabled: true,
|
|
265
|
+
maxAge: '12h', // Less than 1 day minimum
|
|
266
|
+
minRetention: '1d',
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
271
|
+
// Should disable cleanup due to minRetention violation
|
|
272
|
+
expect(result.disabled).toBe(true);
|
|
273
|
+
expect(result.scanned).toBe(0);
|
|
274
|
+
expect(result.deleted).toBe(0);
|
|
275
|
+
});
|
|
276
|
+
it('should log debug information when enabled', async () => {
|
|
277
|
+
const config = createMockConfig({
|
|
278
|
+
getDebugMode: vi.fn().mockReturnValue(true),
|
|
279
|
+
});
|
|
280
|
+
const settings = {
|
|
281
|
+
general: {
|
|
282
|
+
sessionRetention: {
|
|
283
|
+
enabled: true,
|
|
284
|
+
maxAge: '10d',
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
// Mock successful file operations
|
|
289
|
+
mockFs.access.mockResolvedValue(undefined);
|
|
290
|
+
mockFs.readFile.mockResolvedValue(JSON.stringify({
|
|
291
|
+
sessionId: 'test',
|
|
292
|
+
messages: [],
|
|
293
|
+
startTime: '2025-01-01T00:00:00Z',
|
|
294
|
+
lastUpdated: '2025-01-01T00:00:00Z',
|
|
295
|
+
}));
|
|
296
|
+
mockFs.unlink.mockResolvedValue(undefined);
|
|
297
|
+
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => { });
|
|
298
|
+
await cleanupExpiredSessions(config, settings);
|
|
299
|
+
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining('Session cleanup: deleted'));
|
|
300
|
+
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining('Deleted expired session:'));
|
|
301
|
+
debugSpy.mockRestore();
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
describe('Specific cleanup scenarios', () => {
|
|
305
|
+
it('should delete sessions that exceed the cutoff date', async () => {
|
|
306
|
+
const config = createMockConfig();
|
|
307
|
+
const settings = {
|
|
308
|
+
general: {
|
|
309
|
+
sessionRetention: {
|
|
310
|
+
enabled: true,
|
|
311
|
+
maxAge: '7d', // Keep sessions for 7 days
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
// Create sessions with specific dates
|
|
316
|
+
const now = new Date();
|
|
317
|
+
const fiveDaysAgo = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000);
|
|
318
|
+
const eightDaysAgo = new Date(now.getTime() - 8 * 24 * 60 * 60 * 1000);
|
|
319
|
+
const fifteenDaysAgo = new Date(now.getTime() - 15 * 24 * 60 * 60 * 1000);
|
|
320
|
+
const testSessions = [
|
|
321
|
+
{
|
|
322
|
+
id: 'current',
|
|
323
|
+
fileName: `${SESSION_FILE_PREFIX}current.json`,
|
|
324
|
+
lastUpdated: now.toISOString(),
|
|
325
|
+
isCurrentSession: true,
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
id: 'session5d',
|
|
329
|
+
fileName: `${SESSION_FILE_PREFIX}5d.json`,
|
|
330
|
+
lastUpdated: fiveDaysAgo.toISOString(),
|
|
331
|
+
isCurrentSession: false,
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
id: 'session8d',
|
|
335
|
+
fileName: `${SESSION_FILE_PREFIX}8d.json`,
|
|
336
|
+
lastUpdated: eightDaysAgo.toISOString(),
|
|
337
|
+
isCurrentSession: false,
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
id: 'session15d',
|
|
341
|
+
fileName: `${SESSION_FILE_PREFIX}15d.json`,
|
|
342
|
+
lastUpdated: fifteenDaysAgo.toISOString(),
|
|
343
|
+
isCurrentSession: false,
|
|
344
|
+
},
|
|
345
|
+
];
|
|
346
|
+
mockGetAllSessionFiles.mockResolvedValue(testSessions.map((session) => ({
|
|
347
|
+
fileName: session.fileName,
|
|
348
|
+
sessionInfo: session,
|
|
349
|
+
})));
|
|
350
|
+
// Mock successful file operations
|
|
351
|
+
mockFs.access.mockResolvedValue(undefined);
|
|
352
|
+
mockFs.readFile.mockResolvedValue(JSON.stringify({
|
|
353
|
+
sessionId: 'test',
|
|
354
|
+
messages: [],
|
|
355
|
+
startTime: '2025-01-01T00:00:00Z',
|
|
356
|
+
lastUpdated: '2025-01-01T00:00:00Z',
|
|
357
|
+
}));
|
|
358
|
+
mockFs.unlink.mockResolvedValue(undefined);
|
|
359
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
360
|
+
// Should delete sessions older than 7 days (8d and 15d sessions)
|
|
361
|
+
expect(result.disabled).toBe(false);
|
|
362
|
+
expect(result.scanned).toBe(4);
|
|
363
|
+
expect(result.deleted).toBe(2);
|
|
364
|
+
expect(result.skipped).toBe(2); // Current + 5d session
|
|
365
|
+
// Verify which files were deleted
|
|
366
|
+
const unlinkCalls = mockFs.unlink.mock.calls.map((call) => call[0]);
|
|
367
|
+
expect(unlinkCalls).toContain(path.join('/tmp/test-project', 'chats', `${SESSION_FILE_PREFIX}8d.json`));
|
|
368
|
+
expect(unlinkCalls).toContain(path.join('/tmp/test-project', 'chats', `${SESSION_FILE_PREFIX}15d.json`));
|
|
369
|
+
expect(unlinkCalls).not.toContain(path.join('/tmp/test-project', 'chats', `${SESSION_FILE_PREFIX}5d.json`));
|
|
370
|
+
});
|
|
371
|
+
it('should NOT delete sessions within the cutoff date', async () => {
|
|
372
|
+
const config = createMockConfig();
|
|
373
|
+
const settings = {
|
|
374
|
+
general: {
|
|
375
|
+
sessionRetention: {
|
|
376
|
+
enabled: true,
|
|
377
|
+
maxAge: '14d', // Keep sessions for 14 days
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
};
|
|
381
|
+
// Create sessions all within the retention period
|
|
382
|
+
const now = new Date();
|
|
383
|
+
const oneDayAgo = new Date(now.getTime() - 1 * 24 * 60 * 60 * 1000);
|
|
384
|
+
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
385
|
+
const thirteenDaysAgo = new Date(now.getTime() - 13 * 24 * 60 * 60 * 1000);
|
|
386
|
+
const testSessions = [
|
|
387
|
+
{
|
|
388
|
+
id: 'current',
|
|
389
|
+
fileName: `${SESSION_FILE_PREFIX}current.json`,
|
|
390
|
+
lastUpdated: now.toISOString(),
|
|
391
|
+
isCurrentSession: true,
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
id: 'session1d',
|
|
395
|
+
fileName: `${SESSION_FILE_PREFIX}1d.json`,
|
|
396
|
+
lastUpdated: oneDayAgo.toISOString(),
|
|
397
|
+
isCurrentSession: false,
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
id: 'session7d',
|
|
401
|
+
fileName: `${SESSION_FILE_PREFIX}7d.json`,
|
|
402
|
+
lastUpdated: sevenDaysAgo.toISOString(),
|
|
403
|
+
isCurrentSession: false,
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
id: 'session13d',
|
|
407
|
+
fileName: `${SESSION_FILE_PREFIX}13d.json`,
|
|
408
|
+
lastUpdated: thirteenDaysAgo.toISOString(),
|
|
409
|
+
isCurrentSession: false,
|
|
410
|
+
},
|
|
411
|
+
];
|
|
412
|
+
mockGetAllSessionFiles.mockResolvedValue(testSessions.map((session) => ({
|
|
413
|
+
fileName: session.fileName,
|
|
414
|
+
sessionInfo: session,
|
|
415
|
+
})));
|
|
416
|
+
// Mock successful file operations
|
|
417
|
+
mockFs.access.mockResolvedValue(undefined);
|
|
418
|
+
mockFs.readFile.mockResolvedValue(JSON.stringify({
|
|
419
|
+
sessionId: 'test',
|
|
420
|
+
messages: [],
|
|
421
|
+
startTime: '2025-01-01T00:00:00Z',
|
|
422
|
+
lastUpdated: '2025-01-01T00:00:00Z',
|
|
423
|
+
}));
|
|
424
|
+
mockFs.unlink.mockResolvedValue(undefined);
|
|
425
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
426
|
+
// Should NOT delete any sessions as all are within 14 days
|
|
427
|
+
expect(result.disabled).toBe(false);
|
|
428
|
+
expect(result.scanned).toBe(4);
|
|
429
|
+
expect(result.deleted).toBe(0);
|
|
430
|
+
expect(result.skipped).toBe(4);
|
|
431
|
+
expect(result.failed).toBe(0);
|
|
432
|
+
// Verify no files were deleted
|
|
433
|
+
expect(mockFs.unlink).not.toHaveBeenCalled();
|
|
434
|
+
});
|
|
435
|
+
it('should keep N most recent deletable sessions', async () => {
|
|
436
|
+
const config = createMockConfig();
|
|
437
|
+
const settings = {
|
|
438
|
+
general: {
|
|
439
|
+
sessionRetention: {
|
|
440
|
+
enabled: true,
|
|
441
|
+
maxCount: 3, // Keep only 3 most recent sessions
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
// Create 6 sessions with different timestamps
|
|
446
|
+
const now = new Date();
|
|
447
|
+
const sessions = [
|
|
448
|
+
{
|
|
449
|
+
id: 'current',
|
|
450
|
+
fileName: `${SESSION_FILE_PREFIX}current.json`,
|
|
451
|
+
lastUpdated: now.toISOString(),
|
|
452
|
+
isCurrentSession: true,
|
|
453
|
+
},
|
|
454
|
+
];
|
|
455
|
+
// Add 5 more sessions with decreasing timestamps
|
|
456
|
+
for (let i = 1; i <= 5; i++) {
|
|
457
|
+
const daysAgo = new Date(now.getTime() - i * 24 * 60 * 60 * 1000);
|
|
458
|
+
sessions.push({
|
|
459
|
+
id: `session${i}`,
|
|
460
|
+
fileName: `${SESSION_FILE_PREFIX}${i}d.json`,
|
|
461
|
+
lastUpdated: daysAgo.toISOString(),
|
|
462
|
+
isCurrentSession: false,
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
mockGetAllSessionFiles.mockResolvedValue(sessions.map((session) => ({
|
|
466
|
+
fileName: session.fileName,
|
|
467
|
+
sessionInfo: session,
|
|
468
|
+
})));
|
|
469
|
+
// Mock successful file operations
|
|
470
|
+
mockFs.access.mockResolvedValue(undefined);
|
|
471
|
+
mockFs.readFile.mockResolvedValue(JSON.stringify({
|
|
472
|
+
sessionId: 'test',
|
|
473
|
+
messages: [],
|
|
474
|
+
startTime: '2025-01-01T00:00:00Z',
|
|
475
|
+
lastUpdated: '2025-01-01T00:00:00Z',
|
|
476
|
+
}));
|
|
477
|
+
mockFs.unlink.mockResolvedValue(undefined);
|
|
478
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
479
|
+
// Should keep current + 2 most recent (1d and 2d), delete 3d, 4d, 5d
|
|
480
|
+
expect(result.disabled).toBe(false);
|
|
481
|
+
expect(result.scanned).toBe(6);
|
|
482
|
+
expect(result.deleted).toBe(3);
|
|
483
|
+
expect(result.skipped).toBe(3);
|
|
484
|
+
// Verify which files were deleted (should be the 3 oldest)
|
|
485
|
+
const unlinkCalls = mockFs.unlink.mock.calls.map((call) => call[0]);
|
|
486
|
+
expect(unlinkCalls).toContain(path.join('/tmp/test-project', 'chats', `${SESSION_FILE_PREFIX}3d.json`));
|
|
487
|
+
expect(unlinkCalls).toContain(path.join('/tmp/test-project', 'chats', `${SESSION_FILE_PREFIX}4d.json`));
|
|
488
|
+
expect(unlinkCalls).toContain(path.join('/tmp/test-project', 'chats', `${SESSION_FILE_PREFIX}5d.json`));
|
|
489
|
+
// Verify which files were NOT deleted
|
|
490
|
+
expect(unlinkCalls).not.toContain(path.join('/tmp/test-project', 'chats', `${SESSION_FILE_PREFIX}current.json`));
|
|
491
|
+
expect(unlinkCalls).not.toContain(path.join('/tmp/test-project', 'chats', `${SESSION_FILE_PREFIX}1d.json`));
|
|
492
|
+
expect(unlinkCalls).not.toContain(path.join('/tmp/test-project', 'chats', `${SESSION_FILE_PREFIX}2d.json`));
|
|
493
|
+
});
|
|
494
|
+
it('should handle combined maxAge and maxCount retention (most restrictive wins)', async () => {
|
|
495
|
+
const config = createMockConfig();
|
|
496
|
+
const settings = {
|
|
497
|
+
general: {
|
|
498
|
+
sessionRetention: {
|
|
499
|
+
enabled: true,
|
|
500
|
+
maxAge: '10d', // Keep sessions for 10 days
|
|
501
|
+
maxCount: 2, // But also keep only 2 most recent
|
|
502
|
+
},
|
|
503
|
+
},
|
|
504
|
+
};
|
|
505
|
+
// Create sessions where maxCount is more restrictive
|
|
506
|
+
const now = new Date();
|
|
507
|
+
const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000);
|
|
508
|
+
const fiveDaysAgo = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000);
|
|
509
|
+
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
510
|
+
const twelveDaysAgo = new Date(now.getTime() - 12 * 24 * 60 * 60 * 1000);
|
|
511
|
+
const testSessions = [
|
|
512
|
+
{
|
|
513
|
+
id: 'current',
|
|
514
|
+
fileName: `${SESSION_FILE_PREFIX}current.json`,
|
|
515
|
+
lastUpdated: now.toISOString(),
|
|
516
|
+
isCurrentSession: true,
|
|
517
|
+
},
|
|
518
|
+
{
|
|
519
|
+
id: 'session3d',
|
|
520
|
+
fileName: `${SESSION_FILE_PREFIX}3d.json`,
|
|
521
|
+
lastUpdated: threeDaysAgo.toISOString(),
|
|
522
|
+
isCurrentSession: false,
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
id: 'session5d',
|
|
526
|
+
fileName: `${SESSION_FILE_PREFIX}5d.json`,
|
|
527
|
+
lastUpdated: fiveDaysAgo.toISOString(),
|
|
528
|
+
isCurrentSession: false,
|
|
529
|
+
},
|
|
530
|
+
{
|
|
531
|
+
id: 'session7d',
|
|
532
|
+
fileName: `${SESSION_FILE_PREFIX}7d.json`,
|
|
533
|
+
lastUpdated: sevenDaysAgo.toISOString(),
|
|
534
|
+
isCurrentSession: false,
|
|
535
|
+
},
|
|
536
|
+
{
|
|
537
|
+
id: 'session12d',
|
|
538
|
+
fileName: `${SESSION_FILE_PREFIX}12d.json`,
|
|
539
|
+
lastUpdated: twelveDaysAgo.toISOString(),
|
|
540
|
+
isCurrentSession: false,
|
|
541
|
+
},
|
|
542
|
+
];
|
|
543
|
+
mockGetAllSessionFiles.mockResolvedValue(testSessions.map((session) => ({
|
|
544
|
+
fileName: session.fileName,
|
|
545
|
+
sessionInfo: session,
|
|
546
|
+
})));
|
|
547
|
+
// Mock successful file operations
|
|
548
|
+
mockFs.access.mockResolvedValue(undefined);
|
|
549
|
+
mockFs.readFile.mockResolvedValue(JSON.stringify({
|
|
550
|
+
sessionId: 'test',
|
|
551
|
+
messages: [],
|
|
552
|
+
startTime: '2025-01-01T00:00:00Z',
|
|
553
|
+
lastUpdated: '2025-01-01T00:00:00Z',
|
|
554
|
+
}));
|
|
555
|
+
mockFs.unlink.mockResolvedValue(undefined);
|
|
556
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
557
|
+
// Should delete:
|
|
558
|
+
// - session12d (exceeds maxAge of 10d)
|
|
559
|
+
// - session7d and session5d (exceed maxCount of 2, keeping current + 3d)
|
|
560
|
+
expect(result.disabled).toBe(false);
|
|
561
|
+
expect(result.scanned).toBe(5);
|
|
562
|
+
expect(result.deleted).toBe(3);
|
|
563
|
+
expect(result.skipped).toBe(2); // Current + 3d session
|
|
564
|
+
// Verify which files were deleted
|
|
565
|
+
const unlinkCalls = mockFs.unlink.mock.calls.map((call) => call[0]);
|
|
566
|
+
expect(unlinkCalls).toContain(path.join('/tmp/test-project', 'chats', `${SESSION_FILE_PREFIX}5d.json`));
|
|
567
|
+
expect(unlinkCalls).toContain(path.join('/tmp/test-project', 'chats', `${SESSION_FILE_PREFIX}7d.json`));
|
|
568
|
+
expect(unlinkCalls).toContain(path.join('/tmp/test-project', 'chats', `${SESSION_FILE_PREFIX}12d.json`));
|
|
569
|
+
// Verify which files were NOT deleted
|
|
570
|
+
expect(unlinkCalls).not.toContain(path.join('/tmp/test-project', 'chats', `${SESSION_FILE_PREFIX}current.json`));
|
|
571
|
+
expect(unlinkCalls).not.toContain(path.join('/tmp/test-project', 'chats', `${SESSION_FILE_PREFIX}3d.json`));
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
describe('parseRetentionPeriod format validation', () => {
|
|
575
|
+
// Test all supported formats
|
|
576
|
+
it.each([
|
|
577
|
+
['1h', 60 * 60 * 1000],
|
|
578
|
+
['24h', 24 * 60 * 60 * 1000],
|
|
579
|
+
['168h', 168 * 60 * 60 * 1000],
|
|
580
|
+
['1d', 24 * 60 * 60 * 1000],
|
|
581
|
+
['7d', 7 * 24 * 60 * 60 * 1000],
|
|
582
|
+
['30d', 30 * 24 * 60 * 60 * 1000],
|
|
583
|
+
['365d', 365 * 24 * 60 * 60 * 1000],
|
|
584
|
+
['1w', 7 * 24 * 60 * 60 * 1000],
|
|
585
|
+
['2w', 14 * 24 * 60 * 60 * 1000],
|
|
586
|
+
['4w', 28 * 24 * 60 * 60 * 1000],
|
|
587
|
+
['52w', 364 * 24 * 60 * 60 * 1000],
|
|
588
|
+
['1m', 30 * 24 * 60 * 60 * 1000],
|
|
589
|
+
['3m', 90 * 24 * 60 * 60 * 1000],
|
|
590
|
+
['6m', 180 * 24 * 60 * 60 * 1000],
|
|
591
|
+
['12m', 360 * 24 * 60 * 60 * 1000],
|
|
592
|
+
])('should correctly parse valid format %s', async (input) => {
|
|
593
|
+
const config = createMockConfig();
|
|
594
|
+
const settings = {
|
|
595
|
+
general: {
|
|
596
|
+
sessionRetention: {
|
|
597
|
+
enabled: true,
|
|
598
|
+
maxAge: input,
|
|
599
|
+
// Set minRetention to 1h to allow testing of hour-based maxAge values
|
|
600
|
+
minRetention: '1h',
|
|
601
|
+
},
|
|
602
|
+
},
|
|
603
|
+
};
|
|
604
|
+
mockGetAllSessionFiles.mockResolvedValue([]);
|
|
605
|
+
// If it parses correctly, cleanup should proceed without error
|
|
606
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
607
|
+
expect(result.disabled).toBe(false);
|
|
608
|
+
expect(result.failed).toBe(0);
|
|
609
|
+
});
|
|
610
|
+
// Test invalid formats
|
|
611
|
+
it.each([
|
|
612
|
+
'30', // Missing unit
|
|
613
|
+
'30x', // Invalid unit
|
|
614
|
+
'd', // No number
|
|
615
|
+
'1.5d', // Decimal not supported
|
|
616
|
+
'-5d', // Negative number
|
|
617
|
+
'1 d', // Space in format
|
|
618
|
+
'1dd', // Double unit
|
|
619
|
+
'abc', // Non-numeric
|
|
620
|
+
'30s', // Unsupported unit (seconds)
|
|
621
|
+
'30y', // Unsupported unit (years)
|
|
622
|
+
'0d', // Zero value (technically valid regex but semantically invalid)
|
|
623
|
+
])('should reject invalid format %s', async (input) => {
|
|
624
|
+
const config = createMockConfig({
|
|
625
|
+
getDebugMode: vi.fn().mockReturnValue(true),
|
|
626
|
+
});
|
|
627
|
+
const settings = {
|
|
628
|
+
general: {
|
|
629
|
+
sessionRetention: {
|
|
630
|
+
enabled: true,
|
|
631
|
+
maxAge: input,
|
|
632
|
+
},
|
|
633
|
+
},
|
|
634
|
+
};
|
|
635
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
636
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
637
|
+
expect(result.disabled).toBe(true);
|
|
638
|
+
expect(result.scanned).toBe(0);
|
|
639
|
+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining(input === '0d'
|
|
640
|
+
? 'Invalid retention period: 0d. Value must be greater than 0'
|
|
641
|
+
: `Invalid retention period format: ${input}`));
|
|
642
|
+
errorSpy.mockRestore();
|
|
643
|
+
});
|
|
644
|
+
// Test special case - empty string
|
|
645
|
+
it('should reject empty string', async () => {
|
|
646
|
+
const config = createMockConfig({
|
|
647
|
+
getDebugMode: vi.fn().mockReturnValue(true),
|
|
648
|
+
});
|
|
649
|
+
const settings = {
|
|
650
|
+
general: {
|
|
651
|
+
sessionRetention: {
|
|
652
|
+
enabled: true,
|
|
653
|
+
maxAge: '',
|
|
654
|
+
},
|
|
655
|
+
},
|
|
656
|
+
};
|
|
657
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
658
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
659
|
+
expect(result.disabled).toBe(true);
|
|
660
|
+
expect(result.scanned).toBe(0);
|
|
661
|
+
// Empty string means no valid retention method specified
|
|
662
|
+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Either maxAge or maxCount must be specified'));
|
|
663
|
+
errorSpy.mockRestore();
|
|
664
|
+
});
|
|
665
|
+
// Test edge cases
|
|
666
|
+
it('should handle very large numbers', async () => {
|
|
667
|
+
const config = createMockConfig();
|
|
668
|
+
const settings = {
|
|
669
|
+
general: {
|
|
670
|
+
sessionRetention: {
|
|
671
|
+
enabled: true,
|
|
672
|
+
maxAge: '9999d', // Very large number
|
|
673
|
+
},
|
|
674
|
+
},
|
|
675
|
+
};
|
|
676
|
+
mockGetAllSessionFiles.mockResolvedValue([]);
|
|
677
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
678
|
+
expect(result.disabled).toBe(false);
|
|
679
|
+
expect(result.failed).toBe(0);
|
|
680
|
+
});
|
|
681
|
+
it('should validate minRetention format', async () => {
|
|
682
|
+
const config = createMockConfig({
|
|
683
|
+
getDebugMode: vi.fn().mockReturnValue(true),
|
|
684
|
+
});
|
|
685
|
+
const settings = {
|
|
686
|
+
general: {
|
|
687
|
+
sessionRetention: {
|
|
688
|
+
enabled: true,
|
|
689
|
+
maxAge: '5d',
|
|
690
|
+
minRetention: 'invalid-format', // Invalid minRetention
|
|
691
|
+
},
|
|
692
|
+
},
|
|
693
|
+
};
|
|
694
|
+
mockGetAllSessionFiles.mockResolvedValue([]);
|
|
695
|
+
// Should fall back to default minRetention and proceed
|
|
696
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
697
|
+
// Since maxAge (5d) > default minRetention (1d), this should succeed
|
|
698
|
+
expect(result.disabled).toBe(false);
|
|
699
|
+
expect(result.failed).toBe(0);
|
|
700
|
+
});
|
|
701
|
+
});
|
|
702
|
+
describe('Configuration validation', () => {
|
|
703
|
+
it('should require either maxAge or maxCount', async () => {
|
|
704
|
+
const config = createMockConfig({
|
|
705
|
+
getDebugMode: vi.fn().mockReturnValue(true),
|
|
706
|
+
});
|
|
707
|
+
const settings = {
|
|
708
|
+
general: {
|
|
709
|
+
sessionRetention: {
|
|
710
|
+
enabled: true,
|
|
711
|
+
// Neither maxAge nor maxCount specified
|
|
712
|
+
},
|
|
713
|
+
},
|
|
714
|
+
};
|
|
715
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
716
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
717
|
+
expect(result.disabled).toBe(true);
|
|
718
|
+
expect(result.scanned).toBe(0);
|
|
719
|
+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Either maxAge or maxCount must be specified'));
|
|
720
|
+
errorSpy.mockRestore();
|
|
721
|
+
});
|
|
722
|
+
it('should validate maxCount range', async () => {
|
|
723
|
+
const config = createMockConfig({
|
|
724
|
+
getDebugMode: vi.fn().mockReturnValue(true),
|
|
725
|
+
});
|
|
726
|
+
const settings = {
|
|
727
|
+
general: {
|
|
728
|
+
sessionRetention: {
|
|
729
|
+
enabled: true,
|
|
730
|
+
maxCount: 0, // Invalid count
|
|
731
|
+
},
|
|
732
|
+
},
|
|
733
|
+
};
|
|
734
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
735
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
736
|
+
expect(result.disabled).toBe(true);
|
|
737
|
+
expect(result.scanned).toBe(0);
|
|
738
|
+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('maxCount must be at least 1'));
|
|
739
|
+
errorSpy.mockRestore();
|
|
740
|
+
});
|
|
741
|
+
describe('maxAge format validation', () => {
|
|
742
|
+
it('should reject invalid maxAge format - no unit', async () => {
|
|
743
|
+
const config = createMockConfig({
|
|
744
|
+
getDebugMode: vi.fn().mockReturnValue(true),
|
|
745
|
+
});
|
|
746
|
+
const settings = {
|
|
747
|
+
general: {
|
|
748
|
+
sessionRetention: {
|
|
749
|
+
enabled: true,
|
|
750
|
+
maxAge: '30', // Missing unit
|
|
751
|
+
},
|
|
752
|
+
},
|
|
753
|
+
};
|
|
754
|
+
const errorSpy = vi
|
|
755
|
+
.spyOn(console, 'error')
|
|
756
|
+
.mockImplementation(() => { });
|
|
757
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
758
|
+
expect(result.disabled).toBe(true);
|
|
759
|
+
expect(result.scanned).toBe(0);
|
|
760
|
+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid retention period format: 30'));
|
|
761
|
+
errorSpy.mockRestore();
|
|
762
|
+
});
|
|
763
|
+
it('should reject invalid maxAge format - invalid unit', async () => {
|
|
764
|
+
const config = createMockConfig({
|
|
765
|
+
getDebugMode: vi.fn().mockReturnValue(true),
|
|
766
|
+
});
|
|
767
|
+
const settings = {
|
|
768
|
+
general: {
|
|
769
|
+
sessionRetention: {
|
|
770
|
+
enabled: true,
|
|
771
|
+
maxAge: '30x', // Invalid unit 'x'
|
|
772
|
+
},
|
|
773
|
+
},
|
|
774
|
+
};
|
|
775
|
+
const errorSpy = vi
|
|
776
|
+
.spyOn(console, 'error')
|
|
777
|
+
.mockImplementation(() => { });
|
|
778
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
779
|
+
expect(result.disabled).toBe(true);
|
|
780
|
+
expect(result.scanned).toBe(0);
|
|
781
|
+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid retention period format: 30x'));
|
|
782
|
+
errorSpy.mockRestore();
|
|
783
|
+
});
|
|
784
|
+
it('should reject invalid maxAge format - no number', async () => {
|
|
785
|
+
const config = createMockConfig({
|
|
786
|
+
getDebugMode: vi.fn().mockReturnValue(true),
|
|
787
|
+
});
|
|
788
|
+
const settings = {
|
|
789
|
+
general: {
|
|
790
|
+
sessionRetention: {
|
|
791
|
+
enabled: true,
|
|
792
|
+
maxAge: 'd', // No number
|
|
793
|
+
},
|
|
794
|
+
},
|
|
795
|
+
};
|
|
796
|
+
const errorSpy = vi
|
|
797
|
+
.spyOn(console, 'error')
|
|
798
|
+
.mockImplementation(() => { });
|
|
799
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
800
|
+
expect(result.disabled).toBe(true);
|
|
801
|
+
expect(result.scanned).toBe(0);
|
|
802
|
+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid retention period format: d'));
|
|
803
|
+
errorSpy.mockRestore();
|
|
804
|
+
});
|
|
805
|
+
it('should reject invalid maxAge format - decimal number', async () => {
|
|
806
|
+
const config = createMockConfig({
|
|
807
|
+
getDebugMode: vi.fn().mockReturnValue(true),
|
|
808
|
+
});
|
|
809
|
+
const settings = {
|
|
810
|
+
general: {
|
|
811
|
+
sessionRetention: {
|
|
812
|
+
enabled: true,
|
|
813
|
+
maxAge: '1.5d', // Decimal not supported
|
|
814
|
+
},
|
|
815
|
+
},
|
|
816
|
+
};
|
|
817
|
+
const errorSpy = vi
|
|
818
|
+
.spyOn(console, 'error')
|
|
819
|
+
.mockImplementation(() => { });
|
|
820
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
821
|
+
expect(result.disabled).toBe(true);
|
|
822
|
+
expect(result.scanned).toBe(0);
|
|
823
|
+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid retention period format: 1.5d'));
|
|
824
|
+
errorSpy.mockRestore();
|
|
825
|
+
});
|
|
826
|
+
it('should reject invalid maxAge format - negative number', async () => {
|
|
827
|
+
const config = createMockConfig({
|
|
828
|
+
getDebugMode: vi.fn().mockReturnValue(true),
|
|
829
|
+
});
|
|
830
|
+
const settings = {
|
|
831
|
+
general: {
|
|
832
|
+
sessionRetention: {
|
|
833
|
+
enabled: true,
|
|
834
|
+
maxAge: '-5d', // Negative not allowed
|
|
835
|
+
},
|
|
836
|
+
},
|
|
837
|
+
};
|
|
838
|
+
const errorSpy = vi
|
|
839
|
+
.spyOn(console, 'error')
|
|
840
|
+
.mockImplementation(() => { });
|
|
841
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
842
|
+
expect(result.disabled).toBe(true);
|
|
843
|
+
expect(result.scanned).toBe(0);
|
|
844
|
+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid retention period format: -5d'));
|
|
845
|
+
errorSpy.mockRestore();
|
|
846
|
+
});
|
|
847
|
+
it('should accept valid maxAge format - hours', async () => {
|
|
848
|
+
const config = createMockConfig();
|
|
849
|
+
const settings = {
|
|
850
|
+
general: {
|
|
851
|
+
sessionRetention: {
|
|
852
|
+
enabled: true,
|
|
853
|
+
maxAge: '48h', // Valid: 48 hours
|
|
854
|
+
maxCount: 10, // Need at least one valid retention method
|
|
855
|
+
},
|
|
856
|
+
},
|
|
857
|
+
};
|
|
858
|
+
mockGetAllSessionFiles.mockResolvedValue([]);
|
|
859
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
860
|
+
// Should not reject the configuration
|
|
861
|
+
expect(result.disabled).toBe(false);
|
|
862
|
+
expect(result.scanned).toBe(0);
|
|
863
|
+
expect(result.failed).toBe(0);
|
|
864
|
+
});
|
|
865
|
+
it('should accept valid maxAge format - days', async () => {
|
|
866
|
+
const config = createMockConfig();
|
|
867
|
+
const settings = {
|
|
868
|
+
general: {
|
|
869
|
+
sessionRetention: {
|
|
870
|
+
enabled: true,
|
|
871
|
+
maxAge: '7d', // Valid: 7 days
|
|
872
|
+
},
|
|
873
|
+
},
|
|
874
|
+
};
|
|
875
|
+
mockGetAllSessionFiles.mockResolvedValue([]);
|
|
876
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
877
|
+
// Should not reject the configuration
|
|
878
|
+
expect(result.disabled).toBe(false);
|
|
879
|
+
expect(result.scanned).toBe(0);
|
|
880
|
+
expect(result.failed).toBe(0);
|
|
881
|
+
});
|
|
882
|
+
it('should accept valid maxAge format - weeks', async () => {
|
|
883
|
+
const config = createMockConfig();
|
|
884
|
+
const settings = {
|
|
885
|
+
general: {
|
|
886
|
+
sessionRetention: {
|
|
887
|
+
enabled: true,
|
|
888
|
+
maxAge: '2w', // Valid: 2 weeks
|
|
889
|
+
},
|
|
890
|
+
},
|
|
891
|
+
};
|
|
892
|
+
mockGetAllSessionFiles.mockResolvedValue([]);
|
|
893
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
894
|
+
// Should not reject the configuration
|
|
895
|
+
expect(result.disabled).toBe(false);
|
|
896
|
+
expect(result.scanned).toBe(0);
|
|
897
|
+
expect(result.failed).toBe(0);
|
|
898
|
+
});
|
|
899
|
+
it('should accept valid maxAge format - months', async () => {
|
|
900
|
+
const config = createMockConfig();
|
|
901
|
+
const settings = {
|
|
902
|
+
general: {
|
|
903
|
+
sessionRetention: {
|
|
904
|
+
enabled: true,
|
|
905
|
+
maxAge: '3m', // Valid: 3 months
|
|
906
|
+
},
|
|
907
|
+
},
|
|
908
|
+
};
|
|
909
|
+
mockGetAllSessionFiles.mockResolvedValue([]);
|
|
910
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
911
|
+
// Should not reject the configuration
|
|
912
|
+
expect(result.disabled).toBe(false);
|
|
913
|
+
expect(result.scanned).toBe(0);
|
|
914
|
+
expect(result.failed).toBe(0);
|
|
915
|
+
});
|
|
916
|
+
});
|
|
917
|
+
describe('minRetention validation', () => {
|
|
918
|
+
it('should reject maxAge less than default minRetention (1d)', async () => {
|
|
919
|
+
const config = createMockConfig({
|
|
920
|
+
getDebugMode: vi.fn().mockReturnValue(true),
|
|
921
|
+
});
|
|
922
|
+
const settings = {
|
|
923
|
+
general: {
|
|
924
|
+
sessionRetention: {
|
|
925
|
+
enabled: true,
|
|
926
|
+
maxAge: '12h', // Less than default 1d minRetention
|
|
927
|
+
},
|
|
928
|
+
},
|
|
929
|
+
};
|
|
930
|
+
const errorSpy = vi
|
|
931
|
+
.spyOn(console, 'error')
|
|
932
|
+
.mockImplementation(() => { });
|
|
933
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
934
|
+
expect(result.disabled).toBe(true);
|
|
935
|
+
expect(result.scanned).toBe(0);
|
|
936
|
+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('maxAge cannot be less than minRetention (1d)'));
|
|
937
|
+
errorSpy.mockRestore();
|
|
938
|
+
});
|
|
939
|
+
it('should reject maxAge less than custom minRetention', async () => {
|
|
940
|
+
const config = createMockConfig({
|
|
941
|
+
getDebugMode: vi.fn().mockReturnValue(true),
|
|
942
|
+
});
|
|
943
|
+
const settings = {
|
|
944
|
+
general: {
|
|
945
|
+
sessionRetention: {
|
|
946
|
+
enabled: true,
|
|
947
|
+
maxAge: '2d',
|
|
948
|
+
minRetention: '3d', // maxAge < minRetention
|
|
949
|
+
},
|
|
950
|
+
},
|
|
951
|
+
};
|
|
952
|
+
const errorSpy = vi
|
|
953
|
+
.spyOn(console, 'error')
|
|
954
|
+
.mockImplementation(() => { });
|
|
955
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
956
|
+
expect(result.disabled).toBe(true);
|
|
957
|
+
expect(result.scanned).toBe(0);
|
|
958
|
+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('maxAge cannot be less than minRetention (3d)'));
|
|
959
|
+
errorSpy.mockRestore();
|
|
960
|
+
});
|
|
961
|
+
it('should accept maxAge equal to minRetention', async () => {
|
|
962
|
+
const config = createMockConfig();
|
|
963
|
+
const settings = {
|
|
964
|
+
general: {
|
|
965
|
+
sessionRetention: {
|
|
966
|
+
enabled: true,
|
|
967
|
+
maxAge: '2d',
|
|
968
|
+
minRetention: '2d', // maxAge == minRetention (edge case)
|
|
969
|
+
},
|
|
970
|
+
},
|
|
971
|
+
};
|
|
972
|
+
mockGetAllSessionFiles.mockResolvedValue([]);
|
|
973
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
974
|
+
// Should not reject the configuration
|
|
975
|
+
expect(result.disabled).toBe(false);
|
|
976
|
+
expect(result.scanned).toBe(0);
|
|
977
|
+
expect(result.failed).toBe(0);
|
|
978
|
+
});
|
|
979
|
+
it('should accept maxAge greater than minRetention', async () => {
|
|
980
|
+
const config = createMockConfig();
|
|
981
|
+
const settings = {
|
|
982
|
+
general: {
|
|
983
|
+
sessionRetention: {
|
|
984
|
+
enabled: true,
|
|
985
|
+
maxAge: '7d',
|
|
986
|
+
minRetention: '2d', // maxAge > minRetention
|
|
987
|
+
},
|
|
988
|
+
},
|
|
989
|
+
};
|
|
990
|
+
mockGetAllSessionFiles.mockResolvedValue([]);
|
|
991
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
992
|
+
// Should not reject the configuration
|
|
993
|
+
expect(result.disabled).toBe(false);
|
|
994
|
+
expect(result.scanned).toBe(0);
|
|
995
|
+
expect(result.failed).toBe(0);
|
|
996
|
+
});
|
|
997
|
+
it('should handle invalid minRetention format gracefully', async () => {
|
|
998
|
+
const config = createMockConfig({
|
|
999
|
+
getDebugMode: vi.fn().mockReturnValue(true),
|
|
1000
|
+
});
|
|
1001
|
+
const settings = {
|
|
1002
|
+
general: {
|
|
1003
|
+
sessionRetention: {
|
|
1004
|
+
enabled: true,
|
|
1005
|
+
maxAge: '5d',
|
|
1006
|
+
minRetention: 'invalid', // Invalid format
|
|
1007
|
+
},
|
|
1008
|
+
},
|
|
1009
|
+
};
|
|
1010
|
+
mockGetAllSessionFiles.mockResolvedValue([]);
|
|
1011
|
+
// When minRetention is invalid, it should default to 1d
|
|
1012
|
+
// Since maxAge (5d) > default minRetention (1d), this should be valid
|
|
1013
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
1014
|
+
// Should not reject due to minRetention (falls back to default)
|
|
1015
|
+
expect(result.disabled).toBe(false);
|
|
1016
|
+
expect(result.scanned).toBe(0);
|
|
1017
|
+
expect(result.failed).toBe(0);
|
|
1018
|
+
});
|
|
1019
|
+
});
|
|
1020
|
+
describe('maxCount boundary validation', () => {
|
|
1021
|
+
it('should accept maxCount = 1 (minimum valid)', async () => {
|
|
1022
|
+
const config = createMockConfig();
|
|
1023
|
+
const settings = {
|
|
1024
|
+
general: {
|
|
1025
|
+
sessionRetention: {
|
|
1026
|
+
enabled: true,
|
|
1027
|
+
maxCount: 1, // Minimum valid value
|
|
1028
|
+
},
|
|
1029
|
+
},
|
|
1030
|
+
};
|
|
1031
|
+
mockGetAllSessionFiles.mockResolvedValue([]);
|
|
1032
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
1033
|
+
// Should accept the configuration
|
|
1034
|
+
expect(result.disabled).toBe(false);
|
|
1035
|
+
expect(result.scanned).toBe(0);
|
|
1036
|
+
expect(result.failed).toBe(0);
|
|
1037
|
+
});
|
|
1038
|
+
it('should accept maxCount = 1000 (maximum valid)', async () => {
|
|
1039
|
+
const config = createMockConfig();
|
|
1040
|
+
const settings = {
|
|
1041
|
+
general: {
|
|
1042
|
+
sessionRetention: {
|
|
1043
|
+
enabled: true,
|
|
1044
|
+
maxCount: 1000, // Maximum valid value
|
|
1045
|
+
},
|
|
1046
|
+
},
|
|
1047
|
+
};
|
|
1048
|
+
mockGetAllSessionFiles.mockResolvedValue([]);
|
|
1049
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
1050
|
+
// Should accept the configuration
|
|
1051
|
+
expect(result.disabled).toBe(false);
|
|
1052
|
+
expect(result.scanned).toBe(0);
|
|
1053
|
+
expect(result.failed).toBe(0);
|
|
1054
|
+
});
|
|
1055
|
+
it('should reject negative maxCount', async () => {
|
|
1056
|
+
const config = createMockConfig({
|
|
1057
|
+
getDebugMode: vi.fn().mockReturnValue(true),
|
|
1058
|
+
});
|
|
1059
|
+
const settings = {
|
|
1060
|
+
general: {
|
|
1061
|
+
sessionRetention: {
|
|
1062
|
+
enabled: true,
|
|
1063
|
+
maxCount: -1, // Negative value
|
|
1064
|
+
},
|
|
1065
|
+
},
|
|
1066
|
+
};
|
|
1067
|
+
const errorSpy = vi
|
|
1068
|
+
.spyOn(console, 'error')
|
|
1069
|
+
.mockImplementation(() => { });
|
|
1070
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
1071
|
+
expect(result.disabled).toBe(true);
|
|
1072
|
+
expect(result.scanned).toBe(0);
|
|
1073
|
+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('maxCount must be at least 1'));
|
|
1074
|
+
errorSpy.mockRestore();
|
|
1075
|
+
});
|
|
1076
|
+
it('should accept valid maxCount in normal range', async () => {
|
|
1077
|
+
const config = createMockConfig();
|
|
1078
|
+
const settings = {
|
|
1079
|
+
general: {
|
|
1080
|
+
sessionRetention: {
|
|
1081
|
+
enabled: true,
|
|
1082
|
+
maxCount: 50, // Normal valid value
|
|
1083
|
+
},
|
|
1084
|
+
},
|
|
1085
|
+
};
|
|
1086
|
+
mockGetAllSessionFiles.mockResolvedValue([]);
|
|
1087
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
1088
|
+
// Should accept the configuration
|
|
1089
|
+
expect(result.disabled).toBe(false);
|
|
1090
|
+
expect(result.scanned).toBe(0);
|
|
1091
|
+
expect(result.failed).toBe(0);
|
|
1092
|
+
});
|
|
1093
|
+
});
|
|
1094
|
+
describe('combined configuration validation', () => {
|
|
1095
|
+
it('should accept valid maxAge and maxCount together', async () => {
|
|
1096
|
+
const config = createMockConfig();
|
|
1097
|
+
const settings = {
|
|
1098
|
+
general: {
|
|
1099
|
+
sessionRetention: {
|
|
1100
|
+
enabled: true,
|
|
1101
|
+
maxAge: '30d',
|
|
1102
|
+
maxCount: 10,
|
|
1103
|
+
},
|
|
1104
|
+
},
|
|
1105
|
+
};
|
|
1106
|
+
mockGetAllSessionFiles.mockResolvedValue([]);
|
|
1107
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
1108
|
+
// Should accept the configuration
|
|
1109
|
+
expect(result.disabled).toBe(false);
|
|
1110
|
+
expect(result.scanned).toBe(0);
|
|
1111
|
+
expect(result.failed).toBe(0);
|
|
1112
|
+
});
|
|
1113
|
+
it('should reject if both maxAge and maxCount are invalid', async () => {
|
|
1114
|
+
const config = createMockConfig({
|
|
1115
|
+
getDebugMode: vi.fn().mockReturnValue(true),
|
|
1116
|
+
});
|
|
1117
|
+
const settings = {
|
|
1118
|
+
general: {
|
|
1119
|
+
sessionRetention: {
|
|
1120
|
+
enabled: true,
|
|
1121
|
+
maxAge: 'invalid',
|
|
1122
|
+
maxCount: 0,
|
|
1123
|
+
},
|
|
1124
|
+
},
|
|
1125
|
+
};
|
|
1126
|
+
const errorSpy = vi
|
|
1127
|
+
.spyOn(console, 'error')
|
|
1128
|
+
.mockImplementation(() => { });
|
|
1129
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
1130
|
+
expect(result.disabled).toBe(true);
|
|
1131
|
+
expect(result.scanned).toBe(0);
|
|
1132
|
+
// Should fail on first validation error (maxAge format)
|
|
1133
|
+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid retention period format'));
|
|
1134
|
+
errorSpy.mockRestore();
|
|
1135
|
+
});
|
|
1136
|
+
it('should reject if maxAge is invalid even when maxCount is valid', async () => {
|
|
1137
|
+
const config = createMockConfig({
|
|
1138
|
+
getDebugMode: vi.fn().mockReturnValue(true),
|
|
1139
|
+
});
|
|
1140
|
+
const settings = {
|
|
1141
|
+
general: {
|
|
1142
|
+
sessionRetention: {
|
|
1143
|
+
enabled: true,
|
|
1144
|
+
maxAge: 'invalid', // Invalid format
|
|
1145
|
+
maxCount: 5, // Valid count
|
|
1146
|
+
},
|
|
1147
|
+
},
|
|
1148
|
+
};
|
|
1149
|
+
// The validation logic rejects invalid maxAge format even if maxCount is valid
|
|
1150
|
+
const errorSpy = vi
|
|
1151
|
+
.spyOn(console, 'error')
|
|
1152
|
+
.mockImplementation(() => { });
|
|
1153
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
1154
|
+
// Should reject due to invalid maxAge format
|
|
1155
|
+
expect(result.disabled).toBe(true);
|
|
1156
|
+
expect(result.scanned).toBe(0);
|
|
1157
|
+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid retention period format'));
|
|
1158
|
+
errorSpy.mockRestore();
|
|
1159
|
+
});
|
|
1160
|
+
});
|
|
1161
|
+
it('should never throw an exception, always returning a result', async () => {
|
|
1162
|
+
const config = createMockConfig();
|
|
1163
|
+
const settings = {
|
|
1164
|
+
general: {
|
|
1165
|
+
sessionRetention: {
|
|
1166
|
+
enabled: true,
|
|
1167
|
+
maxAge: '7d',
|
|
1168
|
+
},
|
|
1169
|
+
},
|
|
1170
|
+
};
|
|
1171
|
+
// Mock getSessionFiles to throw an error
|
|
1172
|
+
mockGetAllSessionFiles.mockRejectedValue(new Error('Failed to read directory'));
|
|
1173
|
+
// Should not throw, should return a result with errors
|
|
1174
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
1175
|
+
expect(result).toBeDefined();
|
|
1176
|
+
expect(result.disabled).toBe(false);
|
|
1177
|
+
expect(result.failed).toBe(1);
|
|
1178
|
+
});
|
|
1179
|
+
it('should delete corrupted session files', async () => {
|
|
1180
|
+
const config = createMockConfig();
|
|
1181
|
+
const settings = {
|
|
1182
|
+
general: {
|
|
1183
|
+
sessionRetention: {
|
|
1184
|
+
enabled: true,
|
|
1185
|
+
maxAge: '30d',
|
|
1186
|
+
},
|
|
1187
|
+
},
|
|
1188
|
+
};
|
|
1189
|
+
// Mock getAllSessionFiles to return both valid and corrupted files
|
|
1190
|
+
const validSession = createTestSessions()[0];
|
|
1191
|
+
mockGetAllSessionFiles.mockResolvedValue([
|
|
1192
|
+
{ fileName: validSession.fileName, sessionInfo: validSession },
|
|
1193
|
+
{
|
|
1194
|
+
fileName: `${SESSION_FILE_PREFIX}2025-01-02T10-00-00-corrupt1.json`,
|
|
1195
|
+
sessionInfo: null,
|
|
1196
|
+
},
|
|
1197
|
+
{
|
|
1198
|
+
fileName: `${SESSION_FILE_PREFIX}2025-01-03T10-00-00-corrupt2.json`,
|
|
1199
|
+
sessionInfo: null,
|
|
1200
|
+
},
|
|
1201
|
+
]);
|
|
1202
|
+
mockFs.unlink.mockResolvedValue(undefined);
|
|
1203
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
1204
|
+
expect(result.disabled).toBe(false);
|
|
1205
|
+
expect(result.scanned).toBe(3); // 1 valid + 2 corrupted
|
|
1206
|
+
expect(result.deleted).toBe(2); // Should delete the 2 corrupted files
|
|
1207
|
+
expect(result.skipped).toBe(1); // The valid session is kept
|
|
1208
|
+
// Verify corrupted files were deleted
|
|
1209
|
+
expect(mockFs.unlink).toHaveBeenCalledWith(expect.stringContaining('corrupt1.json'));
|
|
1210
|
+
expect(mockFs.unlink).toHaveBeenCalledWith(expect.stringContaining('corrupt2.json'));
|
|
1211
|
+
});
|
|
1212
|
+
it('should handle unexpected errors without throwing', async () => {
|
|
1213
|
+
const config = createMockConfig();
|
|
1214
|
+
const settings = {
|
|
1215
|
+
general: {
|
|
1216
|
+
sessionRetention: {
|
|
1217
|
+
enabled: true,
|
|
1218
|
+
maxAge: '7d',
|
|
1219
|
+
},
|
|
1220
|
+
},
|
|
1221
|
+
};
|
|
1222
|
+
// Mock getSessionFiles to throw a non-Error object
|
|
1223
|
+
mockGetAllSessionFiles.mockRejectedValue('String error');
|
|
1224
|
+
// Should not throw, should return a result with errors
|
|
1225
|
+
const result = await cleanupExpiredSessions(config, settings);
|
|
1226
|
+
expect(result).toBeDefined();
|
|
1227
|
+
expect(result.disabled).toBe(false);
|
|
1228
|
+
expect(result.failed).toBe(1);
|
|
1229
|
+
});
|
|
1230
|
+
});
|
|
1231
|
+
});
|
|
1232
|
+
//# sourceMappingURL=sessionCleanup.test.js.map
|