Files
gemini-cli/packages/cli/src/utils/sessionCleanup.test.ts

1752 lines
52 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import {
SESSION_FILE_PREFIX,
type Config,
debugLogger,
} from '@google/gemini-cli-core';
import type { Settings } from '../config/settings.js';
import { cleanupExpiredSessions } from './sessionCleanup.js';
import { type SessionInfo, getAllSessionFiles } from './sessionUtils.js';
// Mock the fs module
vi.mock('fs/promises');
vi.mock('./sessionUtils.js', () => ({
getAllSessionFiles: vi.fn(),
}));
const mockFs = vi.mocked(fs);
const mockGetAllSessionFiles = vi.mocked(getAllSessionFiles);
// Create mock config
function createMockConfig(overrides: Partial<Config> = {}): Config {
return {
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/test-project'),
},
getSessionId: vi.fn().mockReturnValue('current123'),
getDebugMode: vi.fn().mockReturnValue(false),
initialize: vi.fn().mockResolvedValue(undefined),
...overrides,
} as unknown as Config;
}
// Create test session data
function createTestSessions(): SessionInfo[] {
const now = new Date();
const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const twoWeeksAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000);
const oneMonthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
return [
{
id: 'current123',
file: `${SESSION_FILE_PREFIX}2025-01-20T10-30-00-current12`,
fileName: `${SESSION_FILE_PREFIX}2025-01-20T10-30-00-current12.json`,
startTime: now.toISOString(),
lastUpdated: now.toISOString(),
messageCount: 5,
displayName: 'Current session',
firstUserMessage: 'Current session',
isCurrentSession: true,
index: 1,
},
{
id: 'recent456',
file: `${SESSION_FILE_PREFIX}2025-01-18T15-45-00-recent45`,
fileName: `${SESSION_FILE_PREFIX}2025-01-18T15-45-00-recent45.json`,
startTime: oneWeekAgo.toISOString(),
lastUpdated: oneWeekAgo.toISOString(),
messageCount: 10,
displayName: 'Recent session',
firstUserMessage: 'Recent session',
isCurrentSession: false,
index: 2,
},
{
id: 'old789abc',
file: `${SESSION_FILE_PREFIX}2025-01-10T09-15-00-old789ab`,
fileName: `${SESSION_FILE_PREFIX}2025-01-10T09-15-00-old789ab.json`,
startTime: twoWeeksAgo.toISOString(),
lastUpdated: twoWeeksAgo.toISOString(),
messageCount: 3,
displayName: 'Old session',
firstUserMessage: 'Old session',
isCurrentSession: false,
index: 3,
},
{
id: 'ancient12',
file: `${SESSION_FILE_PREFIX}2024-12-25T12-00-00-ancient1`,
fileName: `${SESSION_FILE_PREFIX}2024-12-25T12-00-00-ancient1.json`,
startTime: oneMonthAgo.toISOString(),
lastUpdated: oneMonthAgo.toISOString(),
messageCount: 15,
displayName: 'Ancient session',
firstUserMessage: 'Ancient session',
isCurrentSession: false,
index: 4,
},
];
}
describe('Session Cleanup', () => {
beforeEach(() => {
vi.clearAllMocks();
// By default, return all test sessions as valid
const sessions = createTestSessions();
mockGetAllSessionFiles.mockResolvedValue(
sessions.map((session) => ({
fileName: session.fileName,
sessionInfo: session,
})),
);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('cleanupExpiredSessions', () => {
it('should return early when cleanup is disabled', async () => {
const config = createMockConfig();
const settings: Settings = {
general: { sessionRetention: { enabled: false } },
};
const result = await cleanupExpiredSessions(config, settings);
expect(result.disabled).toBe(true);
expect(result.scanned).toBe(0);
expect(result.deleted).toBe(0);
expect(result.skipped).toBe(0);
expect(result.failed).toBe(0);
});
it('should return early when sessionRetention is not configured', async () => {
const config = createMockConfig();
const settings: Settings = {};
const result = await cleanupExpiredSessions(config, settings);
expect(result.disabled).toBe(true);
expect(result.scanned).toBe(0);
expect(result.deleted).toBe(0);
});
it('should handle invalid maxAge configuration', async () => {
const config = createMockConfig({
getDebugMode: vi.fn().mockReturnValue(true),
});
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: 'invalid-format',
},
},
};
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const result = await cleanupExpiredSessions(config, settings);
expect(result.disabled).toBe(true);
expect(result.scanned).toBe(0);
expect(result.deleted).toBe(0);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Session cleanup disabled: Error: Invalid retention period format',
),
);
errorSpy.mockRestore();
});
it('should delete sessions older than maxAge', async () => {
const config = createMockConfig();
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: '10d', // 10 days
},
},
};
// Mock successful file operations
mockFs.access.mockResolvedValue(undefined);
mockFs.readFile.mockResolvedValue(
JSON.stringify({
sessionId: 'test',
messages: [],
startTime: '2025-01-01T00:00:00Z',
lastUpdated: '2025-01-01T00:00:00Z',
}),
);
mockFs.unlink.mockResolvedValue(undefined);
const result = await cleanupExpiredSessions(config, settings);
expect(result.disabled).toBe(false);
expect(result.scanned).toBe(4);
expect(result.deleted).toBe(2); // Should delete the 2-week-old and 1-month-old sessions
expect(result.skipped).toBe(2); // Current session + recent session should be skipped
expect(result.failed).toBe(0);
});
it('should never delete current session', async () => {
const config = createMockConfig();
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: '1d', // Very short retention
},
},
};
// Mock successful file operations
mockFs.access.mockResolvedValue(undefined);
mockFs.readFile.mockResolvedValue(
JSON.stringify({
sessionId: 'test',
messages: [],
startTime: '2025-01-01T00:00:00Z',
lastUpdated: '2025-01-01T00:00:00Z',
}),
);
mockFs.unlink.mockResolvedValue(undefined);
const result = await cleanupExpiredSessions(config, settings);
// Should delete all sessions except the current one
expect(result.disabled).toBe(false);
expect(result.deleted).toBe(3);
// Verify that unlink was never called with the current session file
const unlinkCalls = mockFs.unlink.mock.calls;
const currentSessionPath = path.join(
'/tmp/test-project',
'chats',
`${SESSION_FILE_PREFIX}2025-01-20T10-30-00-current12.json`,
);
expect(
unlinkCalls.find((call) => call[0] === currentSessionPath),
).toBeUndefined();
});
it('should handle count-based retention', async () => {
const config = createMockConfig();
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxCount: 2, // Keep only 2 most recent sessions
},
},
};
// Mock successful file operations
mockFs.access.mockResolvedValue(undefined);
mockFs.readFile.mockResolvedValue(
JSON.stringify({
sessionId: 'test',
messages: [],
startTime: '2025-01-01T00:00:00Z',
lastUpdated: '2025-01-01T00:00:00Z',
}),
);
mockFs.unlink.mockResolvedValue(undefined);
const result = await cleanupExpiredSessions(config, settings);
expect(result.disabled).toBe(false);
expect(result.scanned).toBe(4);
expect(result.deleted).toBe(2); // Should delete 2 oldest sessions (after skipping the current one)
expect(result.skipped).toBe(2); // Current session + 1 recent session should be kept
});
it('should handle file system errors gracefully', async () => {
const config = createMockConfig();
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: '1d',
},
},
};
// Mock file operations to succeed for access and readFile but fail for unlink
mockFs.access.mockResolvedValue(undefined);
mockFs.readFile.mockResolvedValue(
JSON.stringify({
sessionId: 'test',
messages: [],
startTime: '2025-01-01T00:00:00Z',
lastUpdated: '2025-01-01T00:00:00Z',
}),
);
mockFs.unlink.mockRejectedValue(new Error('Permission denied'));
const result = await cleanupExpiredSessions(config, settings);
expect(result.disabled).toBe(false);
expect(result.scanned).toBe(4);
expect(result.deleted).toBe(0);
expect(result.failed).toBeGreaterThan(0);
});
it('should handle empty sessions directory', async () => {
const config = createMockConfig();
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: '30d',
},
},
};
mockGetAllSessionFiles.mockResolvedValue([]);
const result = await cleanupExpiredSessions(config, settings);
expect(result.disabled).toBe(false);
expect(result.scanned).toBe(0);
expect(result.deleted).toBe(0);
expect(result.skipped).toBe(0);
expect(result.failed).toBe(0);
});
it('should handle global errors gracefully', async () => {
const config = createMockConfig();
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: '30d',
},
},
};
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
// Mock getSessionFiles to throw an error
mockGetAllSessionFiles.mockRejectedValue(
new Error('Directory access failed'),
);
const result = await cleanupExpiredSessions(config, settings);
expect(result.disabled).toBe(false);
expect(result.failed).toBe(1);
expect(errorSpy).toHaveBeenCalledWith(
'Session cleanup failed: Directory access failed',
);
errorSpy.mockRestore();
});
it('should respect minRetention configuration', async () => {
const config = createMockConfig();
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: '12h', // Less than 1 day minimum
minRetention: '1d',
},
},
};
const result = await cleanupExpiredSessions(config, settings);
// Should disable cleanup due to minRetention violation
expect(result.disabled).toBe(true);
expect(result.scanned).toBe(0);
expect(result.deleted).toBe(0);
});
it('should log debug information when enabled', async () => {
const config = createMockConfig({
getDebugMode: vi.fn().mockReturnValue(true),
});
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: '10d',
},
},
};
// Mock successful file operations
mockFs.access.mockResolvedValue(undefined);
mockFs.readFile.mockResolvedValue(
JSON.stringify({
sessionId: 'test',
messages: [],
startTime: '2025-01-01T00:00:00Z',
lastUpdated: '2025-01-01T00:00:00Z',
}),
);
mockFs.unlink.mockResolvedValue(undefined);
const debugSpy = vi
.spyOn(debugLogger, 'debug')
.mockImplementation(() => {});
await cleanupExpiredSessions(config, settings);
expect(debugSpy).toHaveBeenCalledWith(
expect.stringContaining('Session cleanup: deleted'),
);
expect(debugSpy).toHaveBeenCalledWith(
expect.stringContaining('Deleted expired session:'),
);
debugSpy.mockRestore();
});
});
describe('Specific cleanup scenarios', () => {
it('should delete sessions that exceed the cutoff date', async () => {
const config = createMockConfig();
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: '7d', // Keep sessions for 7 days
},
},
};
// Create sessions with specific dates
const now = new Date();
const fiveDaysAgo = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000);
const eightDaysAgo = new Date(now.getTime() - 8 * 24 * 60 * 60 * 1000);
const fifteenDaysAgo = new Date(now.getTime() - 15 * 24 * 60 * 60 * 1000);
const testSessions: SessionInfo[] = [
{
id: 'current',
file: `${SESSION_FILE_PREFIX}current`,
fileName: `${SESSION_FILE_PREFIX}current.json`,
startTime: now.toISOString(),
lastUpdated: now.toISOString(),
messageCount: 1,
displayName: 'Current',
firstUserMessage: 'Current',
isCurrentSession: true,
index: 1,
},
{
id: 'session5d',
file: `${SESSION_FILE_PREFIX}5d`,
fileName: `${SESSION_FILE_PREFIX}5d.json`,
startTime: fiveDaysAgo.toISOString(),
lastUpdated: fiveDaysAgo.toISOString(),
messageCount: 1,
displayName: '5 days old',
firstUserMessage: '5 days',
isCurrentSession: false,
index: 2,
},
{
id: 'session8d',
file: `${SESSION_FILE_PREFIX}8d`,
fileName: `${SESSION_FILE_PREFIX}8d.json`,
startTime: eightDaysAgo.toISOString(),
lastUpdated: eightDaysAgo.toISOString(),
messageCount: 1,
displayName: '8 days old',
firstUserMessage: '8 days',
isCurrentSession: false,
index: 3,
},
{
id: 'session15d',
file: `${SESSION_FILE_PREFIX}15d`,
fileName: `${SESSION_FILE_PREFIX}15d.json`,
startTime: fifteenDaysAgo.toISOString(),
lastUpdated: fifteenDaysAgo.toISOString(),
messageCount: 1,
displayName: '15 days old',
firstUserMessage: '15 days',
isCurrentSession: false,
index: 4,
},
];
mockGetAllSessionFiles.mockResolvedValue(
testSessions.map((session) => ({
fileName: session.fileName,
sessionInfo: session,
})),
);
// Mock successful file operations
mockFs.access.mockResolvedValue(undefined);
mockFs.readFile.mockResolvedValue(
JSON.stringify({
sessionId: 'test',
messages: [],
startTime: '2025-01-01T00:00:00Z',
lastUpdated: '2025-01-01T00:00:00Z',
}),
);
mockFs.unlink.mockResolvedValue(undefined);
const result = await cleanupExpiredSessions(config, settings);
// Should delete sessions older than 7 days (8d and 15d sessions)
expect(result.disabled).toBe(false);
expect(result.scanned).toBe(4);
expect(result.deleted).toBe(2);
expect(result.skipped).toBe(2); // Current + 5d session
// Verify which files were deleted
const unlinkCalls = mockFs.unlink.mock.calls.map((call) => call[0]);
expect(unlinkCalls).toContain(
path.join(
'/tmp/test-project',
'chats',
`${SESSION_FILE_PREFIX}8d.json`,
),
);
expect(unlinkCalls).toContain(
path.join(
'/tmp/test-project',
'chats',
`${SESSION_FILE_PREFIX}15d.json`,
),
);
expect(unlinkCalls).not.toContain(
path.join(
'/tmp/test-project',
'chats',
`${SESSION_FILE_PREFIX}5d.json`,
),
);
});
it('should NOT delete sessions within the cutoff date', async () => {
const config = createMockConfig();
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: '14d', // Keep sessions for 14 days
},
},
};
// Create sessions all within the retention period
const now = new Date();
const oneDayAgo = new Date(now.getTime() - 1 * 24 * 60 * 60 * 1000);
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const thirteenDaysAgo = new Date(
now.getTime() - 13 * 24 * 60 * 60 * 1000,
);
const testSessions: SessionInfo[] = [
{
id: 'current',
file: `${SESSION_FILE_PREFIX}current`,
fileName: `${SESSION_FILE_PREFIX}current.json`,
startTime: now.toISOString(),
lastUpdated: now.toISOString(),
messageCount: 1,
displayName: 'Current',
firstUserMessage: 'Current',
isCurrentSession: true,
index: 1,
},
{
id: 'session1d',
file: `${SESSION_FILE_PREFIX}1d`,
fileName: `${SESSION_FILE_PREFIX}1d.json`,
startTime: oneDayAgo.toISOString(),
lastUpdated: oneDayAgo.toISOString(),
messageCount: 1,
displayName: '1 day old',
firstUserMessage: '1 day',
isCurrentSession: false,
index: 2,
},
{
id: 'session7d',
file: `${SESSION_FILE_PREFIX}7d`,
fileName: `${SESSION_FILE_PREFIX}7d.json`,
startTime: sevenDaysAgo.toISOString(),
lastUpdated: sevenDaysAgo.toISOString(),
messageCount: 1,
displayName: '7 days old',
firstUserMessage: '7 days',
isCurrentSession: false,
index: 3,
},
{
id: 'session13d',
file: `${SESSION_FILE_PREFIX}13d`,
fileName: `${SESSION_FILE_PREFIX}13d.json`,
startTime: thirteenDaysAgo.toISOString(),
lastUpdated: thirteenDaysAgo.toISOString(),
messageCount: 1,
displayName: '13 days old',
firstUserMessage: '13 days',
isCurrentSession: false,
index: 4,
},
];
mockGetAllSessionFiles.mockResolvedValue(
testSessions.map((session) => ({
fileName: session.fileName,
sessionInfo: session,
})),
);
// Mock successful file operations
mockFs.access.mockResolvedValue(undefined);
mockFs.readFile.mockResolvedValue(
JSON.stringify({
sessionId: 'test',
messages: [],
startTime: '2025-01-01T00:00:00Z',
lastUpdated: '2025-01-01T00:00:00Z',
}),
);
mockFs.unlink.mockResolvedValue(undefined);
const result = await cleanupExpiredSessions(config, settings);
// Should NOT delete any sessions as all are within 14 days
expect(result.disabled).toBe(false);
expect(result.scanned).toBe(4);
expect(result.deleted).toBe(0);
expect(result.skipped).toBe(4);
expect(result.failed).toBe(0);
// Verify no files were deleted
expect(mockFs.unlink).not.toHaveBeenCalled();
});
it('should keep N most recent deletable sessions', async () => {
const config = createMockConfig();
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxCount: 3, // Keep only 3 most recent sessions
},
},
};
// Create 6 sessions with different timestamps
const now = new Date();
const sessions: SessionInfo[] = [
{
id: 'current',
file: `${SESSION_FILE_PREFIX}current`,
fileName: `${SESSION_FILE_PREFIX}current.json`,
startTime: now.toISOString(),
lastUpdated: now.toISOString(),
messageCount: 1,
displayName: 'Current (newest)',
firstUserMessage: 'Current',
isCurrentSession: true,
index: 1,
},
];
// Add 5 more sessions with decreasing timestamps
for (let i = 1; i <= 5; i++) {
const daysAgo = new Date(now.getTime() - i * 24 * 60 * 60 * 1000);
sessions.push({
id: `session${i}`,
file: `${SESSION_FILE_PREFIX}${i}d`,
fileName: `${SESSION_FILE_PREFIX}${i}d.json`,
startTime: daysAgo.toISOString(),
lastUpdated: daysAgo.toISOString(),
messageCount: 1,
displayName: `${i} days old`,
firstUserMessage: `${i} days`,
isCurrentSession: false,
index: i + 1,
});
}
mockGetAllSessionFiles.mockResolvedValue(
sessions.map((session) => ({
fileName: session.fileName,
sessionInfo: session,
})),
);
// Mock successful file operations
mockFs.access.mockResolvedValue(undefined);
mockFs.readFile.mockResolvedValue(
JSON.stringify({
sessionId: 'test',
messages: [],
startTime: '2025-01-01T00:00:00Z',
lastUpdated: '2025-01-01T00:00:00Z',
}),
);
mockFs.unlink.mockResolvedValue(undefined);
const result = await cleanupExpiredSessions(config, settings);
// Should keep current + 2 most recent (1d and 2d), delete 3d, 4d, 5d
expect(result.disabled).toBe(false);
expect(result.scanned).toBe(6);
expect(result.deleted).toBe(3);
expect(result.skipped).toBe(3);
// Verify which files were deleted (should be the 3 oldest)
const unlinkCalls = mockFs.unlink.mock.calls.map((call) => call[0]);
expect(unlinkCalls).toContain(
path.join(
'/tmp/test-project',
'chats',
`${SESSION_FILE_PREFIX}3d.json`,
),
);
expect(unlinkCalls).toContain(
path.join(
'/tmp/test-project',
'chats',
`${SESSION_FILE_PREFIX}4d.json`,
),
);
expect(unlinkCalls).toContain(
path.join(
'/tmp/test-project',
'chats',
`${SESSION_FILE_PREFIX}5d.json`,
),
);
// Verify which files were NOT deleted
expect(unlinkCalls).not.toContain(
path.join(
'/tmp/test-project',
'chats',
`${SESSION_FILE_PREFIX}current.json`,
),
);
expect(unlinkCalls).not.toContain(
path.join(
'/tmp/test-project',
'chats',
`${SESSION_FILE_PREFIX}1d.json`,
),
);
expect(unlinkCalls).not.toContain(
path.join(
'/tmp/test-project',
'chats',
`${SESSION_FILE_PREFIX}2d.json`,
),
);
});
it('should handle combined maxAge and maxCount retention (most restrictive wins)', async () => {
const config = createMockConfig();
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: '10d', // Keep sessions for 10 days
maxCount: 2, // But also keep only 2 most recent
},
},
};
// Create sessions where maxCount is more restrictive
const now = new Date();
const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000);
const fiveDaysAgo = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000);
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const twelveDaysAgo = new Date(now.getTime() - 12 * 24 * 60 * 60 * 1000);
const testSessions: SessionInfo[] = [
{
id: 'current',
file: `${SESSION_FILE_PREFIX}current`,
fileName: `${SESSION_FILE_PREFIX}current.json`,
startTime: now.toISOString(),
lastUpdated: now.toISOString(),
messageCount: 1,
displayName: 'Current',
firstUserMessage: 'Current',
isCurrentSession: true,
index: 1,
},
{
id: 'session3d',
file: `${SESSION_FILE_PREFIX}3d`,
fileName: `${SESSION_FILE_PREFIX}3d.json`,
startTime: threeDaysAgo.toISOString(),
lastUpdated: threeDaysAgo.toISOString(),
messageCount: 1,
displayName: '3 days old',
firstUserMessage: '3 days',
isCurrentSession: false,
index: 2,
},
{
id: 'session5d',
file: `${SESSION_FILE_PREFIX}5d`,
fileName: `${SESSION_FILE_PREFIX}5d.json`,
startTime: fiveDaysAgo.toISOString(),
lastUpdated: fiveDaysAgo.toISOString(),
messageCount: 1,
displayName: '5 days old',
firstUserMessage: '5 days',
isCurrentSession: false,
index: 3,
},
{
id: 'session7d',
file: `${SESSION_FILE_PREFIX}7d`,
fileName: `${SESSION_FILE_PREFIX}7d.json`,
startTime: sevenDaysAgo.toISOString(),
lastUpdated: sevenDaysAgo.toISOString(),
messageCount: 1,
displayName: '7 days old',
firstUserMessage: '7 days',
isCurrentSession: false,
index: 4,
},
{
id: 'session12d',
file: `${SESSION_FILE_PREFIX}12d`,
fileName: `${SESSION_FILE_PREFIX}12d.json`,
startTime: twelveDaysAgo.toISOString(),
lastUpdated: twelveDaysAgo.toISOString(),
messageCount: 1,
displayName: '12 days old',
firstUserMessage: '12 days',
isCurrentSession: false,
index: 5,
},
];
mockGetAllSessionFiles.mockResolvedValue(
testSessions.map((session) => ({
fileName: session.fileName,
sessionInfo: session,
})),
);
// Mock successful file operations
mockFs.access.mockResolvedValue(undefined);
mockFs.readFile.mockResolvedValue(
JSON.stringify({
sessionId: 'test',
messages: [],
startTime: '2025-01-01T00:00:00Z',
lastUpdated: '2025-01-01T00:00:00Z',
}),
);
mockFs.unlink.mockResolvedValue(undefined);
const result = await cleanupExpiredSessions(config, settings);
// Should delete:
// - session12d (exceeds maxAge of 10d)
// - session7d and session5d (exceed maxCount of 2, keeping current + 3d)
expect(result.disabled).toBe(false);
expect(result.scanned).toBe(5);
expect(result.deleted).toBe(3);
expect(result.skipped).toBe(2); // Current + 3d session
// Verify which files were deleted
const unlinkCalls = mockFs.unlink.mock.calls.map((call) => call[0]);
expect(unlinkCalls).toContain(
path.join(
'/tmp/test-project',
'chats',
`${SESSION_FILE_PREFIX}5d.json`,
),
);
expect(unlinkCalls).toContain(
path.join(
'/tmp/test-project',
'chats',
`${SESSION_FILE_PREFIX}7d.json`,
),
);
expect(unlinkCalls).toContain(
path.join(
'/tmp/test-project',
'chats',
`${SESSION_FILE_PREFIX}12d.json`,
),
);
// Verify which files were NOT deleted
expect(unlinkCalls).not.toContain(
path.join(
'/tmp/test-project',
'chats',
`${SESSION_FILE_PREFIX}current.json`,
),
);
expect(unlinkCalls).not.toContain(
path.join(
'/tmp/test-project',
'chats',
`${SESSION_FILE_PREFIX}3d.json`,
),
);
});
});
describe('parseRetentionPeriod format validation', () => {
// Test all supported formats
it.each([
['1h', 60 * 60 * 1000],
['24h', 24 * 60 * 60 * 1000],
['168h', 168 * 60 * 60 * 1000],
['1d', 24 * 60 * 60 * 1000],
['7d', 7 * 24 * 60 * 60 * 1000],
['30d', 30 * 24 * 60 * 60 * 1000],
['365d', 365 * 24 * 60 * 60 * 1000],
['1w', 7 * 24 * 60 * 60 * 1000],
['2w', 14 * 24 * 60 * 60 * 1000],
['4w', 28 * 24 * 60 * 60 * 1000],
['52w', 364 * 24 * 60 * 60 * 1000],
['1m', 30 * 24 * 60 * 60 * 1000],
['3m', 90 * 24 * 60 * 60 * 1000],
['6m', 180 * 24 * 60 * 60 * 1000],
['12m', 360 * 24 * 60 * 60 * 1000],
])('should correctly parse valid format %s', async (input) => {
const config = createMockConfig();
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: input,
// Set minRetention to 1h to allow testing of hour-based maxAge values
minRetention: '1h',
},
},
};
mockGetAllSessionFiles.mockResolvedValue([]);
// If it parses correctly, cleanup should proceed without error
const result = await cleanupExpiredSessions(config, settings);
expect(result.disabled).toBe(false);
expect(result.failed).toBe(0);
});
// Test invalid formats
it.each([
'30', // Missing unit
'30x', // Invalid unit
'd', // No number
'1.5d', // Decimal not supported
'-5d', // Negative number
'1 d', // Space in format
'1dd', // Double unit
'abc', // Non-numeric
'30s', // Unsupported unit (seconds)
'30y', // Unsupported unit (years)
'0d', // Zero value (technically valid regex but semantically invalid)
])('should reject invalid format %s', async (input) => {
const config = createMockConfig({
getDebugMode: vi.fn().mockReturnValue(true),
});
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: input,
},
},
};
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const result = await cleanupExpiredSessions(config, settings);
expect(result.disabled).toBe(true);
expect(result.scanned).toBe(0);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining(
input === '0d'
? 'Invalid retention period: 0d. Value must be greater than 0'
: `Invalid retention period format: ${input}`,
),
);
errorSpy.mockRestore();
});
// Test special case - empty string
it('should reject empty string', async () => {
const config = createMockConfig({
getDebugMode: vi.fn().mockReturnValue(true),
});
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: '',
},
},
};
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const result = await cleanupExpiredSessions(config, settings);
expect(result.disabled).toBe(true);
expect(result.scanned).toBe(0);
// Empty string means no valid retention method specified
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('Either maxAge or maxCount must be specified'),
);
errorSpy.mockRestore();
});
// Test edge cases
it('should handle very large numbers', async () => {
const config = createMockConfig();
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: '9999d', // Very large number
},
},
};
mockGetAllSessionFiles.mockResolvedValue([]);
const result = await cleanupExpiredSessions(config, settings);
expect(result.disabled).toBe(false);
expect(result.failed).toBe(0);
});
it('should validate minRetention format', async () => {
const config = createMockConfig({
getDebugMode: vi.fn().mockReturnValue(true),
});
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: '5d',
minRetention: 'invalid-format', // Invalid minRetention
},
},
};
mockGetAllSessionFiles.mockResolvedValue([]);
// Should fall back to default minRetention and proceed
const result = await cleanupExpiredSessions(config, settings);
// Since maxAge (5d) > default minRetention (1d), this should succeed
expect(result.disabled).toBe(false);
expect(result.failed).toBe(0);
});
});
describe('Configuration validation', () => {
it('should require either maxAge or maxCount', async () => {
const config = createMockConfig({
getDebugMode: vi.fn().mockReturnValue(true),
});
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
// Neither maxAge nor maxCount specified
},
},
};
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const result = await cleanupExpiredSessions(config, settings);
expect(result.disabled).toBe(true);
expect(result.scanned).toBe(0);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('Either maxAge or maxCount must be specified'),
);
errorSpy.mockRestore();
});
it('should validate maxCount range', async () => {
const config = createMockConfig({
getDebugMode: vi.fn().mockReturnValue(true),
});
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxCount: 0, // Invalid count
},
},
};
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const result = await cleanupExpiredSessions(config, settings);
expect(result.disabled).toBe(true);
expect(result.scanned).toBe(0);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('maxCount must be at least 1'),
);
errorSpy.mockRestore();
});
describe('maxAge format validation', () => {
it('should reject invalid maxAge format - no unit', async () => {
const config = createMockConfig({
getDebugMode: vi.fn().mockReturnValue(true),
});
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: '30', // Missing unit
},
},
};
const errorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const result = await cleanupExpiredSessions(config, settings);
expect(result.disabled).toBe(true);
expect(result.scanned).toBe(0);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('Invalid retention period format: 30'),
);
errorSpy.mockRestore();
});
it('should reject invalid maxAge format - invalid unit', async () => {
const config = createMockConfig({
getDebugMode: vi.fn().mockReturnValue(true),
});
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: '30x', // Invalid unit 'x'
},
},
};
const errorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const result = await cleanupExpiredSessions(config, settings);
expect(result.disabled).toBe(true);
expect(result.scanned).toBe(0);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('Invalid retention period format: 30x'),
);
errorSpy.mockRestore();
});
it('should reject invalid maxAge format - no number', async () => {
const config = createMockConfig({
getDebugMode: vi.fn().mockReturnValue(true),
});
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: 'd', // No number
},
},
};
const errorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const result = await cleanupExpiredSessions(config, settings);
expect(result.disabled).toBe(true);
expect(result.scanned).toBe(0);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('Invalid retention period format: d'),
);
errorSpy.mockRestore();
});
it('should reject invalid maxAge format - decimal number', async () => {
const config = createMockConfig({
getDebugMode: vi.fn().mockReturnValue(true),
});
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: '1.5d', // Decimal not supported
},
},
};
const errorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const result = await cleanupExpiredSessions(config, settings);
expect(result.disabled).toBe(true);
expect(result.scanned).toBe(0);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('Invalid retention period format: 1.5d'),
);
errorSpy.mockRestore();
});
it('should reject invalid maxAge format - negative number', async () => {
const config = createMockConfig({
getDebugMode: vi.fn().mockReturnValue(true),
});
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: '-5d', // Negative not allowed
},
},
};
const errorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const result = await cleanupExpiredSessions(config, settings);
expect(result.disabled).toBe(true);
expect(result.scanned).toBe(0);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('Invalid retention period format: -5d'),
);
errorSpy.mockRestore();
});
it('should accept valid maxAge format - hours', async () => {
const config = createMockConfig();
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: '48h', // Valid: 48 hours
maxCount: 10, // Need at least one valid retention method
},
},
};
mockGetAllSessionFiles.mockResolvedValue([]);
const result = await cleanupExpiredSessions(config, settings);
// Should not reject the configuration
expect(result.disabled).toBe(false);
expect(result.scanned).toBe(0);
expect(result.failed).toBe(0);
});
it('should accept valid maxAge format - days', async () => {
const config = createMockConfig();
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: '7d', // Valid: 7 days
},
},
};
mockGetAllSessionFiles.mockResolvedValue([]);
const result = await cleanupExpiredSessions(config, settings);
// Should not reject the configuration
expect(result.disabled).toBe(false);
expect(result.scanned).toBe(0);
expect(result.failed).toBe(0);
});
it('should accept valid maxAge format - weeks', async () => {
const config = createMockConfig();
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: '2w', // Valid: 2 weeks
},
},
};
mockGetAllSessionFiles.mockResolvedValue([]);
const result = await cleanupExpiredSessions(config, settings);
// Should not reject the configuration
expect(result.disabled).toBe(false);
expect(result.scanned).toBe(0);
expect(result.failed).toBe(0);
});
it('should accept valid maxAge format - months', async () => {
const config = createMockConfig();
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: '3m', // Valid: 3 months
},
},
};
mockGetAllSessionFiles.mockResolvedValue([]);
const result = await cleanupExpiredSessions(config, settings);
// Should not reject the configuration
expect(result.disabled).toBe(false);
expect(result.scanned).toBe(0);
expect(result.failed).toBe(0);
});
});
describe('minRetention validation', () => {
it('should reject maxAge less than default minRetention (1d)', async () => {
const config = createMockConfig({
getDebugMode: vi.fn().mockReturnValue(true),
});
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: '12h', // Less than default 1d minRetention
},
},
};
const errorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const result = await cleanupExpiredSessions(config, settings);
expect(result.disabled).toBe(true);
expect(result.scanned).toBe(0);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining(
'maxAge cannot be less than minRetention (1d)',
),
);
errorSpy.mockRestore();
});
it('should reject maxAge less than custom minRetention', async () => {
const config = createMockConfig({
getDebugMode: vi.fn().mockReturnValue(true),
});
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: '2d',
minRetention: '3d', // maxAge < minRetention
},
},
};
const errorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const result = await cleanupExpiredSessions(config, settings);
expect(result.disabled).toBe(true);
expect(result.scanned).toBe(0);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining(
'maxAge cannot be less than minRetention (3d)',
),
);
errorSpy.mockRestore();
});
it('should accept maxAge equal to minRetention', async () => {
const config = createMockConfig();
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: '2d',
minRetention: '2d', // maxAge == minRetention (edge case)
},
},
};
mockGetAllSessionFiles.mockResolvedValue([]);
const result = await cleanupExpiredSessions(config, settings);
// Should not reject the configuration
expect(result.disabled).toBe(false);
expect(result.scanned).toBe(0);
expect(result.failed).toBe(0);
});
it('should accept maxAge greater than minRetention', async () => {
const config = createMockConfig();
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: '7d',
minRetention: '2d', // maxAge > minRetention
},
},
};
mockGetAllSessionFiles.mockResolvedValue([]);
const result = await cleanupExpiredSessions(config, settings);
// Should not reject the configuration
expect(result.disabled).toBe(false);
expect(result.scanned).toBe(0);
expect(result.failed).toBe(0);
});
it('should handle invalid minRetention format gracefully', async () => {
const config = createMockConfig({
getDebugMode: vi.fn().mockReturnValue(true),
});
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: '5d',
minRetention: 'invalid', // Invalid format
},
},
};
mockGetAllSessionFiles.mockResolvedValue([]);
// When minRetention is invalid, it should default to 1d
// Since maxAge (5d) > default minRetention (1d), this should be valid
const result = await cleanupExpiredSessions(config, settings);
// Should not reject due to minRetention (falls back to default)
expect(result.disabled).toBe(false);
expect(result.scanned).toBe(0);
expect(result.failed).toBe(0);
});
});
describe('maxCount boundary validation', () => {
it('should accept maxCount = 1 (minimum valid)', async () => {
const config = createMockConfig();
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxCount: 1, // Minimum valid value
},
},
};
mockGetAllSessionFiles.mockResolvedValue([]);
const result = await cleanupExpiredSessions(config, settings);
// Should accept the configuration
expect(result.disabled).toBe(false);
expect(result.scanned).toBe(0);
expect(result.failed).toBe(0);
});
it('should accept maxCount = 1000 (maximum valid)', async () => {
const config = createMockConfig();
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxCount: 1000, // Maximum valid value
},
},
};
mockGetAllSessionFiles.mockResolvedValue([]);
const result = await cleanupExpiredSessions(config, settings);
// Should accept the configuration
expect(result.disabled).toBe(false);
expect(result.scanned).toBe(0);
expect(result.failed).toBe(0);
});
it('should reject negative maxCount', async () => {
const config = createMockConfig({
getDebugMode: vi.fn().mockReturnValue(true),
});
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxCount: -1, // Negative value
},
},
};
const errorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const result = await cleanupExpiredSessions(config, settings);
expect(result.disabled).toBe(true);
expect(result.scanned).toBe(0);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('maxCount must be at least 1'),
);
errorSpy.mockRestore();
});
it('should accept valid maxCount in normal range', async () => {
const config = createMockConfig();
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxCount: 50, // Normal valid value
},
},
};
mockGetAllSessionFiles.mockResolvedValue([]);
const result = await cleanupExpiredSessions(config, settings);
// Should accept the configuration
expect(result.disabled).toBe(false);
expect(result.scanned).toBe(0);
expect(result.failed).toBe(0);
});
});
describe('combined configuration validation', () => {
it('should accept valid maxAge and maxCount together', async () => {
const config = createMockConfig();
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: '30d',
maxCount: 10,
},
},
};
mockGetAllSessionFiles.mockResolvedValue([]);
const result = await cleanupExpiredSessions(config, settings);
// Should accept the configuration
expect(result.disabled).toBe(false);
expect(result.scanned).toBe(0);
expect(result.failed).toBe(0);
});
it('should reject if both maxAge and maxCount are invalid', async () => {
const config = createMockConfig({
getDebugMode: vi.fn().mockReturnValue(true),
});
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: 'invalid',
maxCount: 0,
},
},
};
const errorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const result = await cleanupExpiredSessions(config, settings);
expect(result.disabled).toBe(true);
expect(result.scanned).toBe(0);
// Should fail on first validation error (maxAge format)
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('Invalid retention period format'),
);
errorSpy.mockRestore();
});
it('should reject if maxAge is invalid even when maxCount is valid', async () => {
const config = createMockConfig({
getDebugMode: vi.fn().mockReturnValue(true),
});
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: 'invalid', // Invalid format
maxCount: 5, // Valid count
},
},
};
// The validation logic rejects invalid maxAge format even if maxCount is valid
const errorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const result = await cleanupExpiredSessions(config, settings);
// Should reject due to invalid maxAge format
expect(result.disabled).toBe(true);
expect(result.scanned).toBe(0);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('Invalid retention period format'),
);
errorSpy.mockRestore();
});
});
it('should never throw an exception, always returning a result', async () => {
const config = createMockConfig();
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: '7d',
},
},
};
// Mock getSessionFiles to throw an error
mockGetAllSessionFiles.mockRejectedValue(
new Error('Failed to read directory'),
);
// Should not throw, should return a result with errors
const result = await cleanupExpiredSessions(config, settings);
expect(result).toBeDefined();
expect(result.disabled).toBe(false);
expect(result.failed).toBe(1);
});
it('should delete corrupted session files', async () => {
const config = createMockConfig();
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: '30d',
},
},
};
// Mock getAllSessionFiles to return both valid and corrupted files
const validSession = createTestSessions()[0];
mockGetAllSessionFiles.mockResolvedValue([
{ fileName: validSession.fileName, sessionInfo: validSession },
{
fileName: `${SESSION_FILE_PREFIX}2025-01-02T10-00-00-corrupt1.json`,
sessionInfo: null,
},
{
fileName: `${SESSION_FILE_PREFIX}2025-01-03T10-00-00-corrupt2.json`,
sessionInfo: null,
},
]);
mockFs.unlink.mockResolvedValue(undefined);
const result = await cleanupExpiredSessions(config, settings);
expect(result.disabled).toBe(false);
expect(result.scanned).toBe(3); // 1 valid + 2 corrupted
expect(result.deleted).toBe(2); // Should delete the 2 corrupted files
expect(result.skipped).toBe(1); // The valid session is kept
// Verify corrupted files were deleted
expect(mockFs.unlink).toHaveBeenCalledWith(
expect.stringContaining('corrupt1.json'),
);
expect(mockFs.unlink).toHaveBeenCalledWith(
expect.stringContaining('corrupt2.json'),
);
});
it('should handle unexpected errors without throwing', async () => {
const config = createMockConfig();
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: '7d',
},
},
};
// Mock getSessionFiles to throw a non-Error object
mockGetAllSessionFiles.mockRejectedValue('String error');
// Should not throw, should return a result with errors
const result = await cleanupExpiredSessions(config, settings);
expect(result).toBeDefined();
expect(result.disabled).toBe(false);
expect(result.failed).toBe(1);
});
});
});