diff --git a/tests/config-manager.test.ts b/tests/config-manager.test.ts new file mode 100644 index 0000000..3e7bba4 --- /dev/null +++ b/tests/config-manager.test.ts @@ -0,0 +1,280 @@ +import * as fs from 'fs-extra'; +import * as path from 'path'; +import * as os from 'os'; +import { ConfigManager } from '../src/config/manager'; + +// Mock fs-extra +jest.mock('fs-extra'); + +const mockedFs = fs as jest.Mocked; +const mockEnsureDir = mockedFs.ensureDir as any; +const mockPathExists = mockedFs.pathExists as any; +const mockReadJson = mockedFs.readJson as any; +const mockWriteJson = mockedFs.writeJson as any; + +describe('ConfigManager', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { ...originalEnv }; + // Clear any SENSAY_ env vars + Object.keys(process.env).forEach(key => { + if (key.startsWith('SENSAY_') || key.startsWith('SENTRY_')) { + delete process.env[key]; + } + }); + }); + + afterAll(() => { + process.env = originalEnv; + }); + + describe('getUserConfig', () => { + it('should return config from user config file when it exists', async () => { + const expectedConfig = { apiKey: 'test-key', userId: 'user-1' }; + mockEnsureDir.mockResolvedValue(undefined); + mockPathExists.mockResolvedValue(true); + mockReadJson.mockResolvedValue(expectedConfig); + + const config = await ConfigManager.getUserConfig(); + + expect(config).toEqual(expectedConfig); + expect(mockPathExists).toHaveBeenCalledWith( + path.join(os.homedir(), '.sensay', 'config.json') + ); + }); + + it('should return empty object when user config file does not exist', async () => { + mockEnsureDir.mockResolvedValue(undefined); + mockPathExists.mockResolvedValue(false); + + const config = await ConfigManager.getUserConfig(); + + expect(config).toEqual({}); + }); + + it('should return empty object and warn on read error', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + mockEnsureDir.mockResolvedValue(undefined); + mockPathExists.mockRejectedValue(new Error('Permission denied')); + + const config = await ConfigManager.getUserConfig(); + + expect(config).toEqual({}); + expect(warnSpy).toHaveBeenCalledWith('Failed to read user config:', expect.any(Error)); + warnSpy.mockRestore(); + }); + }); + + describe('saveUserConfig', () => { + it('should save config to user config file with formatting', async () => { + mockEnsureDir.mockResolvedValue(undefined); + mockWriteJson.mockResolvedValue(undefined); + + const config = { apiKey: 'new-key', userId: 'user-2' }; + await ConfigManager.saveUserConfig(config); + + expect(mockWriteJson).toHaveBeenCalledWith( + path.join(os.homedir(), '.sensay', 'config.json'), + config, + { spaces: 2 } + ); + }); + }); + + describe('getProjectConfig', () => { + it('should return config from project config file when it exists', async () => { + const expectedConfig = { replicaId: 'replica-1', organizationId: 'org-1' }; + mockPathExists.mockResolvedValue(true); + mockReadJson.mockResolvedValue(expectedConfig); + + const config = await ConfigManager.getProjectConfig('/project'); + + expect(config).toEqual(expectedConfig); + expect(mockPathExists).toHaveBeenCalledWith( + path.join('/project', 'sensay.config.json') + ); + }); + + it('should return empty object when project config file does not exist', async () => { + mockPathExists.mockResolvedValue(false); + + const config = await ConfigManager.getProjectConfig('/project'); + + expect(config).toEqual({}); + }); + + it('should default to current directory when no folderPath provided', async () => { + mockPathExists.mockResolvedValue(false); + + await ConfigManager.getProjectConfig(); + + expect(mockPathExists).toHaveBeenCalledWith( + path.join('.', 'sensay.config.json') + ); + }); + + it('should return empty object and warn on read error', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + mockPathExists.mockRejectedValue(new Error('Corrupt JSON')); + + const config = await ConfigManager.getProjectConfig('/project'); + + expect(config).toEqual({}); + expect(warnSpy).toHaveBeenCalledWith('Failed to read project config:', expect.any(Error)); + warnSpy.mockRestore(); + }); + }); + + describe('saveProjectConfig', () => { + it('should save config to project config file with formatting', async () => { + mockWriteJson.mockResolvedValue(undefined); + + const config = { replicaId: 'replica-1' }; + await ConfigManager.saveProjectConfig(config, '/project'); + + expect(mockWriteJson).toHaveBeenCalledWith( + path.join('/project', 'sensay.config.json'), + config, + { spaces: 2 } + ); + }); + + it('should default to current directory when no folderPath provided', async () => { + mockWriteJson.mockResolvedValue(undefined); + + await ConfigManager.saveProjectConfig({ replicaId: 'r1' }); + + expect(mockWriteJson).toHaveBeenCalledWith( + path.join('.', 'sensay.config.json'), + { replicaId: 'r1' }, + { spaces: 2 } + ); + }); + }); + + describe('getMergedConfig', () => { + it('should return both user and project configs', async () => { + const userConfig = { apiKey: 'user-key', userId: 'user-1' }; + const projectConfig = { replicaId: 'replica-1', organizationId: 'org-1' }; + + mockEnsureDir.mockResolvedValue(undefined); + // getProjectConfig hits pathExists before getUserConfig (getUserConfig awaits ensureDir first) + mockPathExists.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + mockReadJson.mockResolvedValueOnce(projectConfig).mockResolvedValueOnce(userConfig); + + const result = await ConfigManager.getMergedConfig('/project'); + + expect(result.userConfig).toEqual(userConfig); + expect(result.projectConfig).toEqual(projectConfig); + }); + + it('should fetch user and project configs in parallel', async () => { + mockEnsureDir.mockResolvedValue(undefined); + mockPathExists.mockResolvedValue(false); + + await ConfigManager.getMergedConfig(); + + // Both pathExists calls should have been made (parallel via Promise.all) + expect(mockPathExists).toHaveBeenCalledTimes(2); + }); + }); + + describe('getConfigFromEnv', () => { + it('should read all supported environment variables', () => { + process.env.SENSAY_API_KEY = 'env-api-key'; + process.env.SENSAY_ORGANIZATION_ID = 'env-org-id'; + process.env.SENSAY_USER_ID = 'env-user-id'; + process.env.SENSAY_BASE_URL = 'https://custom.api.com'; + process.env.SENSAY_VERCEL_PROTECTION_BYPASS = 'bypass-token'; + process.env.SENTRY_DSN = 'https://sentry.io/123'; + process.env.SENTRY_ENVIRONMENT = 'production'; + + const config = ConfigManager.getConfigFromEnv(); + + expect(config).toEqual({ + apiKey: 'env-api-key', + organizationId: 'env-org-id', + userId: 'env-user-id', + baseUrl: 'https://custom.api.com', + vercelProtectionBypass: 'bypass-token', + sentryDsn: 'https://sentry.io/123', + sentryEnvironment: 'production', + }); + }); + + it('should return undefined for unset environment variables', () => { + const config = ConfigManager.getConfigFromEnv(); + + expect(config.apiKey).toBeUndefined(); + expect(config.organizationId).toBeUndefined(); + expect(config.userId).toBeUndefined(); + expect(config.baseUrl).toBeUndefined(); + }); + }); + + describe('getEffectiveConfig', () => { + it('should merge configs with correct priority: env > project > user', async () => { + const userConfig = { apiKey: 'user-key', userId: 'user-1', baseUrl: 'https://user.api.com' }; + const projectConfig = { apiKey: 'project-key', replicaId: 'replica-1' }; + + process.env.SENSAY_API_KEY = 'env-key'; + + mockEnsureDir.mockResolvedValue(undefined); + // getProjectConfig hits pathExists before getUserConfig + mockPathExists.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + mockReadJson.mockResolvedValueOnce(projectConfig).mockResolvedValueOnce(userConfig); + + const config = await ConfigManager.getEffectiveConfig('/project'); + + // env takes highest priority + expect(config.apiKey).toBe('env-key'); + // project overrides user + expect((config as any).replicaId).toBe('replica-1'); + // user value preserved when not overridden + expect(config.userId).toBe('user-1'); + expect(config.baseUrl).toBe('https://user.api.com'); + }); + + it('should not include undefined env values in merged config', async () => { + const userConfig = { apiKey: 'user-key' }; + + mockEnsureDir.mockResolvedValue(undefined); + // getProjectConfig hits pathExists first (false), then getUserConfig (true) + mockPathExists.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + mockReadJson.mockResolvedValueOnce(userConfig); + + const config = await ConfigManager.getEffectiveConfig(); + + // User key should not be overwritten by undefined env value + expect(config.apiKey).toBe('user-key'); + }); + + it('should return empty config when all sources are empty', async () => { + mockEnsureDir.mockResolvedValue(undefined); + mockPathExists.mockResolvedValue(false); + + const config = await ConfigManager.getEffectiveConfig(); + + expect(config).toEqual({}); + }); + + it('should handle project config overriding user config', async () => { + const userConfig = { apiKey: 'user-key', userId: 'user-from-user-config' }; + const projectConfig = { userId: 'user-from-project' }; + + mockEnsureDir.mockResolvedValue(undefined); + // getProjectConfig hits pathExists before getUserConfig (getUserConfig awaits ensureDir first) + mockPathExists.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + mockReadJson.mockResolvedValueOnce(projectConfig).mockResolvedValueOnce(userConfig); + + const config = await ConfigManager.getEffectiveConfig(); + + // Project config overrides user config + expect(config.userId).toBe('user-from-project'); + // User config value preserved when not overridden by project + expect(config.apiKey).toBe('user-key'); + }); + }); +}); diff --git a/tests/file-processor.test.ts b/tests/file-processor.test.ts new file mode 100644 index 0000000..322ac16 --- /dev/null +++ b/tests/file-processor.test.ts @@ -0,0 +1,165 @@ +import * as fs from 'fs-extra'; +import * as path from 'path'; +import { FileProcessor } from '../src/utils/files'; + +// Mock fs-extra +jest.mock('fs-extra'); +// Mock the generated SDK +jest.mock('../src/generated/index'); + +const mockedFs = fs as jest.Mocked; + +describe('FileProcessor', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('formatFileSize', () => { + it('should format bytes', () => { + expect(FileProcessor.formatFileSize(0)).toBe('0.0B'); + expect(FileProcessor.formatFileSize(100)).toBe('100.0B'); + expect(FileProcessor.formatFileSize(512)).toBe('512.0B'); + }); + + it('should format kilobytes', () => { + expect(FileProcessor.formatFileSize(1024)).toBe('1.0KB'); + expect(FileProcessor.formatFileSize(1536)).toBe('1.5KB'); + expect(FileProcessor.formatFileSize(10240)).toBe('10.0KB'); + }); + + it('should format megabytes', () => { + expect(FileProcessor.formatFileSize(1048576)).toBe('1.0MB'); + expect(FileProcessor.formatFileSize(5242880)).toBe('5.0MB'); + expect(FileProcessor.formatFileSize(52428800)).toBe('50.0MB'); + }); + + it('should format gigabytes', () => { + expect(FileProcessor.formatFileSize(1073741824)).toBe('1.0GB'); + }); + }); + + describe('readSystemMessage', () => { + it('should read system-message.txt when it exists', async () => { + (mockedFs.pathExists as any).mockResolvedValue(true); + (mockedFs.readFile as any).mockResolvedValue('You are a helpful assistant.'); + + const result = await FileProcessor.readSystemMessage('/project'); + + expect(result).toBe('You are a helpful assistant.'); + expect(mockedFs.pathExists).toHaveBeenCalledWith( + path.join('/project', 'system-message.txt') + ); + }); + + it('should return null when system-message.txt does not exist', async () => { + (mockedFs.pathExists as any).mockResolvedValue(false); + + const result = await FileProcessor.readSystemMessage('/project'); + + expect(result).toBeNull(); + }); + + it('should return null on read error', async () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(); + (mockedFs.pathExists as any).mockRejectedValue(new Error('IO error')); + + const result = await FileProcessor.readSystemMessage('/project'); + + expect(result).toBeNull(); + errorSpy.mockRestore(); + }); + }); + + describe('scanTrainingFiles', () => { + it('should return empty arrays when training-data folder does not exist', async () => { + (mockedFs.pathExists as any).mockResolvedValue(false); + + const result = await FileProcessor.scanTrainingFiles('/project'); + + expect(result.files).toEqual([]); + expect(result.skipped).toEqual([]); + }); + + it('should scan supported files in training-data folder', async () => { + (mockedFs.pathExists as any).mockResolvedValue(true); + (mockedFs.readdir as any).mockResolvedValue([ + { name: 'readme.md', isDirectory: () => false, isFile: () => true }, + { name: 'data.csv', isDirectory: () => false, isFile: () => true }, + { name: 'image.png', isDirectory: () => false, isFile: () => true }, + ]); + (mockedFs.stat as any).mockResolvedValue({ size: 1024 }); + (mockedFs.readFile as any).mockResolvedValue('file content'); + + const result = await FileProcessor.scanTrainingFiles('/project'); + + // .md and .csv are supported, .png is not + expect(result.files).toHaveLength(2); + expect(result.skipped).toHaveLength(1); + expect(result.skipped[0]).toBe('image.png'); + }); + + it('should skip files exceeding max size', async () => { + const bigFileSize = 51 * 1024 * 1024; // 51MB, over the 50MB limit + + (mockedFs.pathExists as any).mockResolvedValue(true); + (mockedFs.readdir as any).mockResolvedValue([ + { name: 'huge.txt', isDirectory: () => false, isFile: () => true }, + ]); + (mockedFs.stat as any).mockResolvedValue({ size: bigFileSize }); + + const result = await FileProcessor.scanTrainingFiles('/project'); + + expect(result.files).toHaveLength(0); + expect(result.skipped).toHaveLength(1); + expect(result.skipped[0]).toContain('too large'); + }); + + it('should recursively scan subdirectories', async () => { + (mockedFs.pathExists as any).mockResolvedValue(true); + // First readdir call - root directory with a subdirectory + (mockedFs.readdir as any) + .mockResolvedValueOnce([ + { name: 'subdir', isDirectory: () => true, isFile: () => false }, + { name: 'root.txt', isDirectory: () => false, isFile: () => true }, + ]) + // Second readdir call - subdirectory + .mockResolvedValueOnce([ + { name: 'nested.md', isDirectory: () => false, isFile: () => true }, + ]); + (mockedFs.stat as any).mockResolvedValue({ size: 100 }); + (mockedFs.readFile as any).mockResolvedValue('content'); + + const result = await FileProcessor.scanTrainingFiles('/project'); + + expect(result.files).toHaveLength(2); + }); + }); + + describe('displayFilesSummary', () => { + it('should handle empty files and skipped arrays', () => { + const logSpy = jest.spyOn(console, 'log').mockImplementation(); + + FileProcessor.displayFilesSummary([], []); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('No training files found')); + logSpy.mockRestore(); + }); + + it('should display files and skipped counts', () => { + const logSpy = jest.spyOn(console, 'log').mockImplementation(); + + const files = [ + { path: '/project/training-data/a.txt', relativePath: 'a.txt', content: 'hello', size: 5 }, + ]; + const skipped = ['image.png']; + + FileProcessor.displayFilesSummary(files, skipped); + + // Should show both processed and skipped + const calls = logSpy.mock.calls.map(c => c[0]); + expect(calls.some((c: string) => c.includes('1 files will be processed'))).toBe(true); + expect(calls.some((c: string) => c.includes('1 files skipped'))).toBe(true); + logSpy.mockRestore(); + }); + }); +});