Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
280 changes: 280 additions & 0 deletions tests/config-manager.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof fs>;
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');
});
});
});
Loading