Add readSettings unit test (#602)
This commit is contained in:
431
src/__tests__/readSettings.test.ts
Normal file
431
src/__tests__/readSettings.test.ts
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { safeStorage } from "electron";
|
||||||
|
import { readSettings, getSettingsFilePath } from "@/main/settings";
|
||||||
|
import { getUserDataPath } from "@/paths/paths";
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock("node:fs");
|
||||||
|
vi.mock("node:path");
|
||||||
|
vi.mock("electron", () => ({
|
||||||
|
safeStorage: {
|
||||||
|
isEncryptionAvailable: vi.fn(),
|
||||||
|
decryptString: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/paths/paths", () => ({
|
||||||
|
getUserDataPath: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockFs = vi.mocked(fs);
|
||||||
|
const mockPath = vi.mocked(path);
|
||||||
|
const mockSafeStorage = vi.mocked(safeStorage);
|
||||||
|
const mockGetUserDataPath = vi.mocked(getUserDataPath);
|
||||||
|
|
||||||
|
describe("readSettings", () => {
|
||||||
|
const mockUserDataPath = "/mock/user/data";
|
||||||
|
const mockSettingsPath = "/mock/user/data/user-settings.json";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockGetUserDataPath.mockReturnValue(mockUserDataPath);
|
||||||
|
mockPath.join.mockReturnValue(mockSettingsPath);
|
||||||
|
mockSafeStorage.isEncryptionAvailable.mockReturnValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when settings file does not exist", () => {
|
||||||
|
it("should create default settings file and return default settings", () => {
|
||||||
|
mockFs.existsSync.mockReturnValue(false);
|
||||||
|
mockFs.writeFileSync.mockImplementation(() => {});
|
||||||
|
|
||||||
|
const result = readSettings();
|
||||||
|
|
||||||
|
expect(mockFs.existsSync).toHaveBeenCalledWith(mockSettingsPath);
|
||||||
|
expect(mockFs.writeFileSync).toHaveBeenCalledWith(
|
||||||
|
mockSettingsPath,
|
||||||
|
expect.stringContaining('"selectedModel"'),
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
selectedModel: {
|
||||||
|
name: "auto",
|
||||||
|
provider: "auto",
|
||||||
|
},
|
||||||
|
providerSettings: {},
|
||||||
|
telemetryConsent: "unset",
|
||||||
|
telemetryUserId: expect.any(String),
|
||||||
|
hasRunBefore: false,
|
||||||
|
experiments: {},
|
||||||
|
enableProLazyEditsMode: true,
|
||||||
|
enableProSmartFilesContextMode: true,
|
||||||
|
selectedChatMode: "build",
|
||||||
|
enableAutoFixProblems: false,
|
||||||
|
enableAutoUpdate: true,
|
||||||
|
releaseChannel: "stable",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when settings file exists", () => {
|
||||||
|
it("should read and merge settings with defaults", () => {
|
||||||
|
const mockFileContent = {
|
||||||
|
selectedModel: {
|
||||||
|
name: "gpt-4",
|
||||||
|
provider: "openai",
|
||||||
|
},
|
||||||
|
telemetryConsent: "opted_in",
|
||||||
|
hasRunBefore: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFs.existsSync.mockReturnValue(true);
|
||||||
|
mockFs.readFileSync.mockReturnValue(JSON.stringify(mockFileContent));
|
||||||
|
|
||||||
|
const result = readSettings();
|
||||||
|
|
||||||
|
expect(mockFs.readFileSync).toHaveBeenCalledWith(
|
||||||
|
mockSettingsPath,
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
expect(result.selectedModel).toEqual({
|
||||||
|
name: "gpt-4",
|
||||||
|
provider: "openai",
|
||||||
|
});
|
||||||
|
expect(result.telemetryConsent).toBe("opted_in");
|
||||||
|
expect(result.hasRunBefore).toBe(true);
|
||||||
|
// Should still have defaults for missing properties
|
||||||
|
expect(result.enableAutoUpdate).toBe(true);
|
||||||
|
expect(result.releaseChannel).toBe("stable");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should decrypt encrypted provider API keys", () => {
|
||||||
|
const mockFileContent = {
|
||||||
|
providerSettings: {
|
||||||
|
openai: {
|
||||||
|
apiKey: {
|
||||||
|
value: "encrypted-api-key",
|
||||||
|
encryptionType: "electron-safe-storage",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFs.existsSync.mockReturnValue(true);
|
||||||
|
mockFs.readFileSync.mockReturnValue(JSON.stringify(mockFileContent));
|
||||||
|
mockSafeStorage.decryptString.mockReturnValue("decrypted-api-key");
|
||||||
|
|
||||||
|
const result = readSettings();
|
||||||
|
|
||||||
|
expect(mockSafeStorage.decryptString).toHaveBeenCalledWith(
|
||||||
|
Buffer.from("encrypted-api-key", "base64"),
|
||||||
|
);
|
||||||
|
expect(result.providerSettings.openai.apiKey).toEqual({
|
||||||
|
value: "decrypted-api-key",
|
||||||
|
encryptionType: "electron-safe-storage",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should decrypt encrypted GitHub access token", () => {
|
||||||
|
const mockFileContent = {
|
||||||
|
githubAccessToken: {
|
||||||
|
value: "encrypted-github-token",
|
||||||
|
encryptionType: "electron-safe-storage",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFs.existsSync.mockReturnValue(true);
|
||||||
|
mockFs.readFileSync.mockReturnValue(JSON.stringify(mockFileContent));
|
||||||
|
mockSafeStorage.decryptString.mockReturnValue("decrypted-github-token");
|
||||||
|
|
||||||
|
const result = readSettings();
|
||||||
|
|
||||||
|
expect(mockSafeStorage.decryptString).toHaveBeenCalledWith(
|
||||||
|
Buffer.from("encrypted-github-token", "base64"),
|
||||||
|
);
|
||||||
|
expect(result.githubAccessToken).toEqual({
|
||||||
|
value: "decrypted-github-token",
|
||||||
|
encryptionType: "electron-safe-storage",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should decrypt encrypted Supabase tokens", () => {
|
||||||
|
const mockFileContent = {
|
||||||
|
supabase: {
|
||||||
|
accessToken: {
|
||||||
|
value: "encrypted-access-token",
|
||||||
|
encryptionType: "electron-safe-storage",
|
||||||
|
},
|
||||||
|
refreshToken: {
|
||||||
|
value: "encrypted-refresh-token",
|
||||||
|
encryptionType: "electron-safe-storage",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFs.existsSync.mockReturnValue(true);
|
||||||
|
mockFs.readFileSync.mockReturnValue(JSON.stringify(mockFileContent));
|
||||||
|
mockSafeStorage.decryptString
|
||||||
|
.mockReturnValueOnce("decrypted-refresh-token")
|
||||||
|
.mockReturnValueOnce("decrypted-access-token");
|
||||||
|
|
||||||
|
const result = readSettings();
|
||||||
|
|
||||||
|
expect(mockSafeStorage.decryptString).toHaveBeenCalledTimes(2);
|
||||||
|
expect(result.supabase?.refreshToken).toEqual({
|
||||||
|
value: "decrypted-refresh-token",
|
||||||
|
encryptionType: "electron-safe-storage",
|
||||||
|
});
|
||||||
|
expect(result.supabase?.accessToken).toEqual({
|
||||||
|
value: "decrypted-access-token",
|
||||||
|
encryptionType: "electron-safe-storage",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle plaintext secrets without decryption", () => {
|
||||||
|
const mockFileContent = {
|
||||||
|
githubAccessToken: {
|
||||||
|
value: "plaintext-token",
|
||||||
|
encryptionType: "plaintext",
|
||||||
|
},
|
||||||
|
providerSettings: {
|
||||||
|
openai: {
|
||||||
|
apiKey: {
|
||||||
|
value: "plaintext-api-key",
|
||||||
|
encryptionType: "plaintext",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFs.existsSync.mockReturnValue(true);
|
||||||
|
mockFs.readFileSync.mockReturnValue(JSON.stringify(mockFileContent));
|
||||||
|
|
||||||
|
const result = readSettings();
|
||||||
|
|
||||||
|
expect(mockSafeStorage.decryptString).not.toHaveBeenCalled();
|
||||||
|
expect(result.githubAccessToken?.value).toBe("plaintext-token");
|
||||||
|
expect(result.providerSettings.openai.apiKey?.value).toBe(
|
||||||
|
"plaintext-api-key",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle secrets without encryptionType", () => {
|
||||||
|
const mockFileContent = {
|
||||||
|
githubAccessToken: {
|
||||||
|
value: "token-without-encryption-type",
|
||||||
|
},
|
||||||
|
providerSettings: {
|
||||||
|
openai: {
|
||||||
|
apiKey: {
|
||||||
|
value: "api-key-without-encryption-type",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFs.existsSync.mockReturnValue(true);
|
||||||
|
mockFs.readFileSync.mockReturnValue(JSON.stringify(mockFileContent));
|
||||||
|
|
||||||
|
const result = readSettings();
|
||||||
|
|
||||||
|
expect(mockSafeStorage.decryptString).not.toHaveBeenCalled();
|
||||||
|
expect(result.githubAccessToken?.value).toBe(
|
||||||
|
"token-without-encryption-type",
|
||||||
|
);
|
||||||
|
expect(result.providerSettings.openai.apiKey?.value).toBe(
|
||||||
|
"api-key-without-encryption-type",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should strip extra fields not recognized by the schema", () => {
|
||||||
|
const mockFileContent = {
|
||||||
|
selectedModel: {
|
||||||
|
name: "gpt-4",
|
||||||
|
provider: "openai",
|
||||||
|
},
|
||||||
|
telemetryConsent: "opted_in",
|
||||||
|
hasRunBefore: true,
|
||||||
|
// Extra fields that are not in the schema
|
||||||
|
unknownField: "should be removed",
|
||||||
|
deprecatedSetting: true,
|
||||||
|
extraConfig: {
|
||||||
|
someValue: 123,
|
||||||
|
anotherValue: "test",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFs.existsSync.mockReturnValue(true);
|
||||||
|
mockFs.readFileSync.mockReturnValue(JSON.stringify(mockFileContent));
|
||||||
|
|
||||||
|
const result = readSettings();
|
||||||
|
|
||||||
|
expect(mockFs.readFileSync).toHaveBeenCalledWith(
|
||||||
|
mockSettingsPath,
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
expect(result.selectedModel).toEqual({
|
||||||
|
name: "gpt-4",
|
||||||
|
provider: "openai",
|
||||||
|
});
|
||||||
|
expect(result.telemetryConsent).toBe("opted_in");
|
||||||
|
expect(result.hasRunBefore).toBe(true);
|
||||||
|
|
||||||
|
// Extra fields should be stripped by schema validation
|
||||||
|
expect(result).not.toHaveProperty("unknownField");
|
||||||
|
expect(result).not.toHaveProperty("deprecatedSetting");
|
||||||
|
expect(result).not.toHaveProperty("extraConfig");
|
||||||
|
|
||||||
|
// Should still have defaults for missing properties
|
||||||
|
expect(result.enableAutoUpdate).toBe(true);
|
||||||
|
expect(result.releaseChannel).toBe("stable");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("error handling", () => {
|
||||||
|
it("should return default settings when file read fails", () => {
|
||||||
|
mockFs.existsSync.mockReturnValue(true);
|
||||||
|
mockFs.readFileSync.mockImplementation(() => {
|
||||||
|
throw new Error("File read error");
|
||||||
|
});
|
||||||
|
|
||||||
|
const consoleSpy = vi
|
||||||
|
.spyOn(console, "error")
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
|
||||||
|
const result = readSettings();
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
"Error reading settings:",
|
||||||
|
expect.any(Error),
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
selectedModel: {
|
||||||
|
name: "auto",
|
||||||
|
provider: "auto",
|
||||||
|
},
|
||||||
|
providerSettings: {},
|
||||||
|
telemetryConsent: "unset",
|
||||||
|
telemetryUserId: expect.any(String),
|
||||||
|
hasRunBefore: false,
|
||||||
|
experiments: {},
|
||||||
|
enableProLazyEditsMode: true,
|
||||||
|
enableProSmartFilesContextMode: true,
|
||||||
|
selectedChatMode: "build",
|
||||||
|
enableAutoFixProblems: false,
|
||||||
|
enableAutoUpdate: true,
|
||||||
|
releaseChannel: "stable",
|
||||||
|
});
|
||||||
|
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return default settings when JSON parsing fails", () => {
|
||||||
|
mockFs.existsSync.mockReturnValue(true);
|
||||||
|
mockFs.readFileSync.mockReturnValue("invalid json");
|
||||||
|
|
||||||
|
const consoleSpy = vi
|
||||||
|
.spyOn(console, "error")
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
|
||||||
|
const result = readSettings();
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
"Error reading settings:",
|
||||||
|
expect.any(Error),
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
selectedModel: {
|
||||||
|
name: "auto",
|
||||||
|
provider: "auto",
|
||||||
|
},
|
||||||
|
releaseChannel: "stable",
|
||||||
|
});
|
||||||
|
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return default settings when schema validation fails", () => {
|
||||||
|
const mockFileContent = {
|
||||||
|
selectedModel: {
|
||||||
|
name: "gpt-4",
|
||||||
|
// Missing required 'provider' field
|
||||||
|
},
|
||||||
|
releaseChannel: "invalid-channel", // Invalid enum value
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFs.existsSync.mockReturnValue(true);
|
||||||
|
mockFs.readFileSync.mockReturnValue(JSON.stringify(mockFileContent));
|
||||||
|
|
||||||
|
const consoleSpy = vi
|
||||||
|
.spyOn(console, "error")
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
|
||||||
|
const result = readSettings();
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
"Error reading settings:",
|
||||||
|
expect.any(Error),
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
selectedModel: {
|
||||||
|
name: "auto",
|
||||||
|
provider: "auto",
|
||||||
|
},
|
||||||
|
releaseChannel: "stable",
|
||||||
|
});
|
||||||
|
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle decryption errors gracefully", () => {
|
||||||
|
const mockFileContent = {
|
||||||
|
githubAccessToken: {
|
||||||
|
value: "corrupted-encrypted-data",
|
||||||
|
encryptionType: "electron-safe-storage",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFs.existsSync.mockReturnValue(true);
|
||||||
|
mockFs.readFileSync.mockReturnValue(JSON.stringify(mockFileContent));
|
||||||
|
mockSafeStorage.decryptString.mockImplementation(() => {
|
||||||
|
throw new Error("Decryption failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
const consoleSpy = vi
|
||||||
|
.spyOn(console, "error")
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
|
||||||
|
const result = readSettings();
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
"Error reading settings:",
|
||||||
|
expect.any(Error),
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
selectedModel: {
|
||||||
|
name: "auto",
|
||||||
|
provider: "auto",
|
||||||
|
},
|
||||||
|
releaseChannel: "stable",
|
||||||
|
});
|
||||||
|
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getSettingsFilePath", () => {
|
||||||
|
it("should return correct settings file path", () => {
|
||||||
|
const result = getSettingsFilePath();
|
||||||
|
|
||||||
|
expect(mockGetUserDataPath).toHaveBeenCalled();
|
||||||
|
expect(mockPath.join).toHaveBeenCalledWith(
|
||||||
|
mockUserDataPath,
|
||||||
|
"user-settings.json",
|
||||||
|
);
|
||||||
|
expect(result).toBe(mockSettingsPath);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user