@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.
@@ -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