/** * @fileoverview Unit tests for ConfigPersistence service */ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { promises as fs } from 'node:fs'; import { ConfigPersistence } from './config-persistence.service.js'; vi.mock('node:fs', () => ({ promises: { readFile: vi.fn(), writeFile: vi.fn(), mkdir: vi.fn(), unlink: vi.fn(), access: vi.fn(), readdir: vi.fn(), rename: vi.fn() } })); describe('ConfigPersistence', () => { let persistence: ConfigPersistence; const testProjectRoot = '/test/project'; beforeEach(() => { persistence = new ConfigPersistence(testProjectRoot); vi.clearAllMocks(); }); afterEach(() => { vi.restoreAllMocks(); }); describe('saveConfig', () => { const mockConfig = { models: { main: 'test-model' }, storage: { type: 'file' as const } }; it('should save configuration to file', async () => { vi.mocked(fs.mkdir).mockResolvedValue(undefined); vi.mocked(fs.writeFile).mockResolvedValue(undefined); await persistence.saveConfig(mockConfig); expect(fs.mkdir).toHaveBeenCalledWith('/test/project/.taskmaster', { recursive: true }); expect(fs.writeFile).toHaveBeenCalledWith( '/test/project/.taskmaster/config.json', JSON.stringify(mockConfig, null, 2), 'utf-8' ); }); it('should use atomic write when specified', async () => { vi.mocked(fs.mkdir).mockResolvedValue(undefined); vi.mocked(fs.writeFile).mockResolvedValue(undefined); vi.mocked(fs.rename).mockResolvedValue(undefined); await persistence.saveConfig(mockConfig, { atomic: true }); // Should write to temp file first expect(fs.writeFile).toHaveBeenCalledWith( '/test/project/.taskmaster/config.json.tmp', JSON.stringify(mockConfig, null, 2), 'utf-8' ); // Then rename to final location expect(fs.rename).toHaveBeenCalledWith( '/test/project/.taskmaster/config.json.tmp', '/test/project/.taskmaster/config.json' ); }); it('should create backup when requested', async () => { vi.mocked(fs.mkdir).mockResolvedValue(undefined); vi.mocked(fs.writeFile).mockResolvedValue(undefined); vi.mocked(fs.access).mockResolvedValue(undefined); // Config exists vi.mocked(fs.readFile).mockResolvedValue('{"old": "config"}'); vi.mocked(fs.readdir).mockResolvedValue([]); await persistence.saveConfig(mockConfig, { createBackup: true }); // Should create backup directory expect(fs.mkdir).toHaveBeenCalledWith( '/test/project/.taskmaster/backups', { recursive: true } ); // Should read existing config for backup expect(fs.readFile).toHaveBeenCalledWith( '/test/project/.taskmaster/config.json', 'utf-8' ); // Should write backup file expect(fs.writeFile).toHaveBeenCalledWith( expect.stringContaining('/test/project/.taskmaster/backups/config-'), '{"old": "config"}', 'utf-8' ); }); it('should not create backup if config does not exist', async () => { vi.mocked(fs.mkdir).mockResolvedValue(undefined); vi.mocked(fs.writeFile).mockResolvedValue(undefined); vi.mocked(fs.access).mockRejectedValue(new Error('Not found')); await persistence.saveConfig(mockConfig, { createBackup: true }); // Should not read or create backup expect(fs.readFile).not.toHaveBeenCalled(); expect(fs.writeFile).toHaveBeenCalledTimes(1); // Only the main config }); it('should throw TaskMasterError on save failure', async () => { vi.mocked(fs.mkdir).mockRejectedValue(new Error('Disk full')); await expect(persistence.saveConfig(mockConfig)).rejects.toThrow( 'Failed to save configuration' ); }); }); describe('configExists', () => { it('should return true when config exists', async () => { vi.mocked(fs.access).mockResolvedValue(undefined); const exists = await persistence.configExists(); expect(fs.access).toHaveBeenCalledWith( '/test/project/.taskmaster/config.json' ); expect(exists).toBe(true); }); it('should return false when config does not exist', async () => { vi.mocked(fs.access).mockRejectedValue(new Error('Not found')); const exists = await persistence.configExists(); expect(exists).toBe(false); }); }); describe('deleteConfig', () => { it('should delete configuration file', async () => { vi.mocked(fs.unlink).mockResolvedValue(undefined); await persistence.deleteConfig(); expect(fs.unlink).toHaveBeenCalledWith( '/test/project/.taskmaster/config.json' ); }); it('should not throw when file does not exist', async () => { const error = new Error('File not found') as any; error.code = 'ENOENT'; vi.mocked(fs.unlink).mockRejectedValue(error); await expect(persistence.deleteConfig()).resolves.not.toThrow(); }); it('should throw TaskMasterError for other errors', async () => { vi.mocked(fs.unlink).mockRejectedValue(new Error('Permission denied')); await expect(persistence.deleteConfig()).rejects.toThrow( 'Failed to delete configuration' ); }); }); describe('getBackups', () => { it('should return list of backup files sorted newest first', async () => { vi.mocked(fs.readdir).mockResolvedValue([ 'config-2024-01-01T10-00-00-000Z.json', 'config-2024-01-02T10-00-00-000Z.json', 'config-2024-01-03T10-00-00-000Z.json', 'other-file.txt' ] as any); const backups = await persistence.getBackups(); expect(fs.readdir).toHaveBeenCalledWith( '/test/project/.taskmaster/backups' ); expect(backups).toEqual([ 'config-2024-01-03T10-00-00-000Z.json', 'config-2024-01-02T10-00-00-000Z.json', 'config-2024-01-01T10-00-00-000Z.json' ]); }); it('should return empty array when backup directory does not exist', async () => { vi.mocked(fs.readdir).mockRejectedValue(new Error('Not found')); const backups = await persistence.getBackups(); expect(backups).toEqual([]); }); it('should filter out non-backup files', async () => { vi.mocked(fs.readdir).mockResolvedValue([ 'config-2024-01-01T10-00-00-000Z.json', 'README.md', '.DS_Store', 'config.json', 'config-backup.json' // Wrong format ] as any); const backups = await persistence.getBackups(); expect(backups).toEqual(['config-2024-01-01T10-00-00-000Z.json']); }); }); describe('restoreFromBackup', () => { const backupFile = 'config-2024-01-01T10-00-00-000Z.json'; const backupContent = '{"restored": "config"}'; it('should restore configuration from backup', async () => { vi.mocked(fs.readFile).mockResolvedValue(backupContent); vi.mocked(fs.writeFile).mockResolvedValue(undefined); await persistence.restoreFromBackup(backupFile); expect(fs.readFile).toHaveBeenCalledWith( `/test/project/.taskmaster/backups/${backupFile}`, 'utf-8' ); expect(fs.writeFile).toHaveBeenCalledWith( '/test/project/.taskmaster/config.json', backupContent, 'utf-8' ); }); it('should throw TaskMasterError when backup file not found', async () => { vi.mocked(fs.readFile).mockRejectedValue(new Error('File not found')); await expect( persistence.restoreFromBackup('nonexistent.json') ).rejects.toThrow('Failed to restore from backup'); }); it('should throw TaskMasterError on write failure', async () => { vi.mocked(fs.readFile).mockResolvedValue(backupContent); vi.mocked(fs.writeFile).mockRejectedValue(new Error('Disk full')); await expect(persistence.restoreFromBackup(backupFile)).rejects.toThrow( 'Failed to restore from backup' ); }); }); describe('backup management', () => { it('should clean old backups when limit exceeded', async () => { vi.mocked(fs.mkdir).mockResolvedValue(undefined); vi.mocked(fs.writeFile).mockResolvedValue(undefined); vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.readFile).mockResolvedValue('{"old": "config"}'); vi.mocked(fs.unlink).mockResolvedValue(undefined); // Mock 7 existing backups vi.mocked(fs.readdir).mockResolvedValue([ 'config-2024-01-01T10-00-00-000Z.json', 'config-2024-01-02T10-00-00-000Z.json', 'config-2024-01-03T10-00-00-000Z.json', 'config-2024-01-04T10-00-00-000Z.json', 'config-2024-01-05T10-00-00-000Z.json', 'config-2024-01-06T10-00-00-000Z.json', 'config-2024-01-07T10-00-00-000Z.json' ] as any); await persistence.saveConfig({}, { createBackup: true }); // Should delete oldest backups (keeping 5) expect(fs.unlink).toHaveBeenCalledWith( '/test/project/.taskmaster/backups/config-2024-01-01T10-00-00-000Z.json' ); expect(fs.unlink).toHaveBeenCalledWith( '/test/project/.taskmaster/backups/config-2024-01-02T10-00-00-000Z.json' ); }); it('should handle backup cleanup errors gracefully', async () => { vi.mocked(fs.mkdir).mockResolvedValue(undefined); vi.mocked(fs.writeFile).mockResolvedValue(undefined); vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.readFile).mockResolvedValue('{"old": "config"}'); vi.mocked(fs.readdir).mockResolvedValue(['config-old.json'] as any); vi.mocked(fs.unlink).mockRejectedValue(new Error('Permission denied')); // Mock console.warn to verify it's called const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); // Should not throw even if cleanup fails await expect( persistence.saveConfig({}, { createBackup: true }) ).resolves.not.toThrow(); expect(warnSpy).toHaveBeenCalledWith( 'Failed to clean old backups:', expect.any(Error) ); warnSpy.mockRestore(); }); }); });