| |
| |
| |
|
|
| import { describe, it, expect, beforeAll, beforeEach, afterEach, spyOn } from 'bun:test' |
| import * as fs from 'fs/promises' |
| import * as path from 'path' |
| import * as os from 'os' |
| import { SettingsService } from '../services/settingsService.js' |
| import { conversationService } from '../services/conversationService.js' |
| import { handleSettingsApi } from '../api/settings.js' |
| import { handleModelsApi } from '../api/models.js' |
| import { handleStatusApi, resetUsage, addUsage } from '../api/status.js' |
| import { ProviderService } from '../services/providerService.js' |
| import { |
| clearOpenAIOAuthTokenCache, |
| } from '../../services/openaiAuth/storage.js' |
| import { plainTextStorage } from '../../utils/secureStorage/plainTextStorage.js' |
| import { |
| clearKeychainCache, |
| primeKeychainCacheFromPrefetch, |
| } from '../../utils/secureStorage/macOsKeychainHelpers.js' |
| import type { OpenAIOAuthTokens } from '../../services/openaiAuth/types.js' |
| import { getModelOptions } from '../../utils/model/modelOptions.js' |
| import { |
| getSettingsForSource, |
| updateSettingsForSource, |
| } from '../../utils/settings/settings.js' |
| import { resetSettingsCache } from '../../utils/settings/settingsCache.js' |
|
|
| |
|
|
| let tmpDir: string |
| let originalConfigDir: string | undefined |
| let originalHome: string | undefined |
| let originalUserProfile: string | undefined |
| let originalShell: string | undefined |
| let originalPath: string | undefined |
| let originalCliPath: string | undefined |
| let originalAnthropicApiKey: string | undefined |
| let originalAnthropicBaseUrl: string | undefined |
| let originalAnthropicModel: string | undefined |
| let originalAnthropicDefaultHaikuModel: string | undefined |
| let originalAnthropicDefaultSonnetModel: string | undefined |
| let originalAnthropicDefaultOpusModel: string | undefined |
|
|
| async function setup() { |
| tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-test-')) |
| resetSettingsCache() |
| originalConfigDir = process.env.CLAUDE_CONFIG_DIR |
| originalHome = process.env.HOME |
| originalUserProfile = process.env.USERPROFILE |
| originalShell = process.env.SHELL |
| originalPath = process.env.PATH |
| originalCliPath = process.env.CLAUDE_CLI_PATH |
| originalAnthropicApiKey = process.env.ANTHROPIC_API_KEY |
| originalAnthropicBaseUrl = process.env.ANTHROPIC_BASE_URL |
| originalAnthropicModel = process.env.ANTHROPIC_MODEL |
| originalAnthropicDefaultHaikuModel = process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL |
| originalAnthropicDefaultSonnetModel = process.env.ANTHROPIC_DEFAULT_SONNET_MODEL |
| originalAnthropicDefaultOpusModel = process.env.ANTHROPIC_DEFAULT_OPUS_MODEL |
| process.env.CLAUDE_CONFIG_DIR = tmpDir |
| process.env.HOME = tmpDir |
| process.env.USERPROFILE = tmpDir |
| process.env.SHELL = '/bin/zsh' |
| process.env.PATH = '' |
| delete process.env.ANTHROPIC_API_KEY |
| delete process.env.ANTHROPIC_BASE_URL |
| delete process.env.ANTHROPIC_MODEL |
| delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL |
| delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL |
| delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL |
| clearKeychainCache() |
| primeKeychainCacheFromPrefetch(null) |
| clearOpenAIOAuthTokenCache() |
| } |
|
|
| async function teardown() { |
| plainTextStorage.delete() |
| clearKeychainCache() |
| clearOpenAIOAuthTokenCache() |
| resetSettingsCache() |
|
|
| if (originalConfigDir !== undefined) { |
| process.env.CLAUDE_CONFIG_DIR = originalConfigDir |
| } else { |
| delete process.env.CLAUDE_CONFIG_DIR |
| } |
|
|
| if (originalHome !== undefined) { |
| process.env.HOME = originalHome |
| } else { |
| delete process.env.HOME |
| } |
|
|
| if (originalUserProfile !== undefined) { |
| process.env.USERPROFILE = originalUserProfile |
| } else { |
| delete process.env.USERPROFILE |
| } |
|
|
| if (originalShell !== undefined) { |
| process.env.SHELL = originalShell |
| } else { |
| delete process.env.SHELL |
| } |
|
|
| if (originalPath !== undefined) { |
| process.env.PATH = originalPath |
| } else { |
| delete process.env.PATH |
| } |
|
|
| if (originalCliPath !== undefined) { |
| process.env.CLAUDE_CLI_PATH = originalCliPath |
| } else { |
| delete process.env.CLAUDE_CLI_PATH |
| } |
|
|
| if (originalAnthropicApiKey !== undefined) { |
| process.env.ANTHROPIC_API_KEY = originalAnthropicApiKey |
| } else { |
| delete process.env.ANTHROPIC_API_KEY |
| } |
|
|
| if (originalAnthropicBaseUrl !== undefined) { |
| process.env.ANTHROPIC_BASE_URL = originalAnthropicBaseUrl |
| } else { |
| delete process.env.ANTHROPIC_BASE_URL |
| } |
|
|
| if (originalAnthropicModel !== undefined) { |
| process.env.ANTHROPIC_MODEL = originalAnthropicModel |
| } else { |
| delete process.env.ANTHROPIC_MODEL |
| } |
|
|
| if (originalAnthropicDefaultHaikuModel !== undefined) { |
| process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = originalAnthropicDefaultHaikuModel |
| } else { |
| delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL |
| } |
|
|
| if (originalAnthropicDefaultSonnetModel !== undefined) { |
| process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = originalAnthropicDefaultSonnetModel |
| } else { |
| delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL |
| } |
|
|
| if (originalAnthropicDefaultOpusModel !== undefined) { |
| process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = originalAnthropicDefaultOpusModel |
| } else { |
| delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL |
| } |
|
|
| await fs.rm(tmpDir, { recursive: true, force: true }) |
| } |
|
|
| function saveTestOpenAIOAuthTokens(tokens: OpenAIOAuthTokens) { |
| plainTextStorage.update({ openaiCodexOauth: tokens }) |
| clearOpenAIOAuthTokenCache() |
| } |
|
|
| |
| function makeRequest( |
| method: string, |
| urlStr: string, |
| body?: Record<string, unknown>, |
| ): { req: Request; url: URL; segments: string[] } { |
| const url = new URL(urlStr, 'http://localhost:3456') |
| const init: RequestInit = { method } |
| if (body) { |
| init.headers = { 'Content-Type': 'application/json' } |
| init.body = JSON.stringify(body) |
| } |
| const req = new Request(url.toString(), init) |
| const segments = url.pathname.split('/').filter(Boolean) |
| return { req, url, segments } |
| } |
|
|
| |
| |
| |
|
|
| describe('SettingsService', () => { |
| beforeEach(setup) |
| afterEach(teardown) |
|
|
| it('should return empty object when settings file does not exist', async () => { |
| const svc = new SettingsService() |
| const settings = await svc.getUserSettings() |
| expect(settings).toEqual({}) |
| }) |
|
|
| it('should recover from malformed user settings after an upgrade', async () => { |
| await fs.writeFile(path.join(tmpDir, 'settings.json'), '{not json', 'utf-8') |
|
|
| const svc = new SettingsService() |
| const settings = await svc.getUserSettings() |
| const files = await fs.readdir(tmpDir) |
|
|
| expect(settings).toEqual({}) |
| expect(files.some((name) => name.startsWith('settings.json.invalid-'))).toBe(true) |
| }) |
|
|
| it('should write and read user settings', async () => { |
| const svc = new SettingsService() |
| await svc.updateUserSettings({ theme: 'dark', model: 'claude-opus-4-7' }) |
|
|
| const settings = await svc.getUserSettings() |
| expect(settings.theme).toBe('dark') |
| expect(settings.model).toBe('claude-opus-4-7') |
| }) |
|
|
| it('should write and read the pure white theme setting', async () => { |
| const svc = new SettingsService() |
| await svc.updateUserSettings({ theme: 'white' }) |
|
|
| const settings = await svc.getUserSettings() |
| expect(settings.theme).toBe('white') |
| }) |
|
|
| it('should merge settings on update (shallow merge)', async () => { |
| const svc = new SettingsService() |
| await svc.updateUserSettings({ theme: 'dark' }) |
| await svc.updateUserSettings({ model: 'claude-haiku-4-5' }) |
|
|
| const settings = await svc.getUserSettings() |
| expect(settings.theme).toBe('dark') |
| expect(settings.model).toBe('claude-haiku-4-5') |
| }) |
|
|
| it('should not let cached CLI settings overwrite desktop settings updates', async () => { |
| const svc = new SettingsService() |
| await svc.updateUserSettings({ |
| enabledPlugins: { |
| 'demo@test-market': false, |
| }, |
| }) |
|
|
| expect(getSettingsForSource('userSettings')?.enabledPlugins?.['demo@test-market']).toBe(false) |
|
|
| await svc.updateUserSettings({ |
| language: 'chinese', |
| desktopNotificationsEnabled: true, |
| alwaysThinkingEnabled: false, |
| }) |
|
|
| const { error } = updateSettingsForSource('userSettings', { |
| enabledPlugins: { |
| ...getSettingsForSource('userSettings')?.enabledPlugins, |
| 'demo@test-market': true, |
| }, |
| }) |
| expect(error).toBeNull() |
|
|
| const settings = await svc.getUserSettings() |
| expect(settings.language).toBe('chinese') |
| expect(settings.desktopNotificationsEnabled).toBe(true) |
| expect(settings.alwaysThinkingEnabled).toBe(false) |
| expect((settings.enabledPlugins as Record<string, unknown>)['demo@test-market']).toBe(true) |
| }) |
|
|
| it('should read and write project settings', async () => { |
| const projectRoot = path.join(tmpDir, 'myproject') |
| await fs.mkdir(path.join(projectRoot, '.claude'), { recursive: true }) |
|
|
| const svc = new SettingsService(projectRoot) |
| await svc.updateProjectSettings({ outputStyle: 'verbose' }) |
|
|
| const settings = await svc.getProjectSettings() |
| expect(settings.outputStyle).toBe('verbose') |
| }) |
|
|
| it('should merge user and project settings', async () => { |
| const projectRoot = path.join(tmpDir, 'myproject') |
| await fs.mkdir(path.join(projectRoot, '.claude'), { recursive: true }) |
|
|
| const svc = new SettingsService(projectRoot) |
| await svc.updateUserSettings({ theme: 'dark', model: 'claude-opus-4-7' }) |
| await svc.updateProjectSettings({ theme: 'light' }) |
|
|
| const merged = await svc.getSettings() |
| |
| expect(merged.theme).toBe('light') |
| |
| expect(merged.model).toBe('claude-opus-4-7') |
| }) |
|
|
| it('should get default permission mode', async () => { |
| const svc = new SettingsService() |
| const mode = await svc.getPermissionMode() |
| expect(mode).toBe('default') |
| }) |
|
|
| it('should ignore stale invalid permission modes from older installs', async () => { |
| await fs.writeFile( |
| path.join(tmpDir, 'settings.json'), |
| JSON.stringify({ defaultMode: 'legacy-yolo' }), |
| 'utf-8', |
| ) |
|
|
| const svc = new SettingsService() |
| const mode = await svc.getPermissionMode() |
|
|
| expect(mode).toBe('default') |
| }) |
|
|
| it('should set and get permission mode', async () => { |
| const svc = new SettingsService() |
| await svc.setPermissionMode('plan') |
| const mode = await svc.getPermissionMode() |
| expect(mode).toBe('plan') |
| }) |
|
|
| it('should reject invalid permission mode', async () => { |
| const svc = new SettingsService() |
| await expect(svc.setPermissionMode('invalid')).rejects.toThrow('Invalid permission mode') |
| }) |
|
|
| it('should preserve other settings when updating permission mode', async () => { |
| const svc = new SettingsService() |
| await svc.updateUserSettings({ theme: 'dark' }) |
| await svc.setPermissionMode('acceptEdits') |
|
|
| const settings = await svc.getUserSettings() |
| expect(settings.theme).toBe('dark') |
| expect(settings.defaultMode).toBe('acceptEdits') |
| }) |
|
|
| it('should serialize concurrent user settings writes to the same file', async () => { |
| const svc = new SettingsService() |
| const originalNow = Date.now |
| Date.now = () => 1776695497171 |
|
|
| try { |
| await Promise.all([ |
| svc.updateUserSettings({ theme: 'dark' }), |
| svc.setPermissionMode('bypassPermissions'), |
| ]) |
| } finally { |
| Date.now = originalNow |
| } |
|
|
| const settings = await svc.getUserSettings() |
| expect(settings.theme).toBe('dark') |
| expect(settings.defaultMode).toBe('bypassPermissions') |
| }) |
| }) |
|
|
| |
| |
| |
|
|
| describe('Settings API', () => { |
| beforeEach(setup) |
| afterEach(teardown) |
|
|
| it('GET /api/settings should return merged settings', async () => { |
| |
| const settingsPath = path.join(tmpDir, 'settings.json') |
| await fs.writeFile(settingsPath, JSON.stringify({ theme: 'dark' })) |
|
|
| const { req, url, segments } = makeRequest('GET', '/api/settings') |
| const res = await handleSettingsApi(req, url, segments) |
|
|
| expect(res.status).toBe(200) |
| const body = await res.json() |
| expect(body.theme).toBe('dark') |
| }) |
|
|
| it('GET /api/settings/user should return user settings', async () => { |
| const { req, url, segments } = makeRequest('GET', '/api/settings/user') |
| const res = await handleSettingsApi(req, url, segments) |
|
|
| expect(res.status).toBe(200) |
| const body = await res.json() |
| expect(body).toEqual({}) |
| }) |
|
|
| it('PUT /api/settings/user should update user settings', async () => { |
| const { req, url, segments } = makeRequest('PUT', '/api/settings/user', { |
| model: 'claude-opus-4-7', |
| }) |
| const res = await handleSettingsApi(req, url, segments) |
|
|
| expect(res.status).toBe(200) |
| const body = await res.json() |
| expect(body.ok).toBe(true) |
|
|
| |
| const { req: r2, url: u2, segments: s2 } = makeRequest('GET', '/api/settings/user') |
| const res2 = await handleSettingsApi(r2, u2, s2) |
| const body2 = await res2.json() |
| expect(body2.model).toBe('claude-opus-4-7') |
| }) |
|
|
| it('PUT /api/settings/user should sync thinking changes to active CLI sessions', async () => { |
| const syncSpy = spyOn(conversationService, 'setMaxThinkingTokensForActiveSessions') |
| .mockImplementation(() => 0) |
|
|
| try { |
| const disabled = makeRequest('PUT', '/api/settings/user', { |
| alwaysThinkingEnabled: false, |
| }) |
| expect((await handleSettingsApi(disabled.req, disabled.url, disabled.segments)).status).toBe(200) |
|
|
| const enabled = makeRequest('PUT', '/api/settings/user', { |
| alwaysThinkingEnabled: true, |
| }) |
| expect((await handleSettingsApi(enabled.req, enabled.url, enabled.segments)).status).toBe(200) |
|
|
| expect(syncSpy).toHaveBeenNthCalledWith(1, 0) |
| expect(syncSpy).toHaveBeenNthCalledWith(2, null) |
| } finally { |
| syncSpy.mockRestore() |
| } |
| }) |
|
|
| it('GET /api/settings/cli-launcher should expose bundled launcher status', async () => { |
| if (process.platform === 'win32') return |
|
|
| const sidecarPath = path.join(tmpDir, 'claude-sidecar') |
| await fs.writeFile(sidecarPath, '#!/bin/sh\necho desktop-sidecar\n', { |
| encoding: 'utf8', |
| mode: 0o755, |
| }) |
| process.env.CLAUDE_CLI_PATH = sidecarPath |
|
|
| const { req, url, segments } = makeRequest('GET', '/api/settings/cli-launcher') |
| const res = await handleSettingsApi(req, url, segments) |
|
|
| expect(res.status).toBe(200) |
| const body = await res.json() |
| expect(body.command).toBe('claude-haha') |
| expect(body.installed).toBe(true) |
| expect(body.availableInNewTerminals).toBe(true) |
| }) |
|
|
| it('GET /api/permissions/mode should return default mode', async () => { |
| const { req, url, segments } = makeRequest('GET', '/api/permissions/mode') |
| const res = await handleSettingsApi(req, url, segments) |
|
|
| expect(res.status).toBe(200) |
| const body = await res.json() |
| expect(body.mode).toBe('default') |
| }) |
|
|
| it('PUT /api/permissions/mode should set mode', async () => { |
| const { req, url, segments } = makeRequest('PUT', '/api/permissions/mode', { |
| mode: 'bypassPermissions', |
| }) |
| const res = await handleSettingsApi(req, url, segments) |
|
|
| expect(res.status).toBe(200) |
| const body = await res.json() |
| expect(body.ok).toBe(true) |
| expect(body.mode).toBe('bypassPermissions') |
| }) |
|
|
| it('PUT /api/permissions/mode should reject invalid mode', async () => { |
| const { req, url, segments } = makeRequest('PUT', '/api/permissions/mode', { |
| mode: 'yolo', |
| }) |
| const res = await handleSettingsApi(req, url, segments) |
|
|
| expect(res.status).toBe(400) |
| }) |
|
|
| it('should return 404 for unknown settings endpoint', async () => { |
| const { req, url, segments } = makeRequest('GET', '/api/settings/unknown') |
| const res = await handleSettingsApi(req, url, segments) |
| expect(res.status).toBe(404) |
| }) |
| }) |
|
|
| |
| |
| |
|
|
| describe('Models API', () => { |
| beforeEach(setup) |
| afterEach(teardown) |
|
|
| it('GET /api/models should return available models', async () => { |
| const { req, url, segments } = makeRequest('GET', '/api/models') |
| const res = await handleModelsApi(req, url, segments) |
|
|
| expect(res.status).toBe(200) |
| const body = await res.json() |
| expect(body.models).toBeArray() |
| expect(body.models.length).toBe(3) |
| expect(body.models[0].id).toContain('claude') |
| }) |
|
|
| it('GET /api/models should merge env-configured provider models with saved OpenAI OAuth models', async () => { |
| process.env.ANTHROPIC_API_KEY = 'deepseek-key' |
| process.env.ANTHROPIC_BASE_URL = 'https://api.deepseek.com/anthropic' |
| process.env.ANTHROPIC_MODEL = 'deepseek-v4-pro' |
| process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = 'deepseek-v4-flash' |
| process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'deepseek-v4-pro' |
| process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = 'deepseek-v4-pro' |
| saveTestOpenAIOAuthTokens({ |
| accessToken: 'access-token', |
| refreshToken: 'refresh-token', |
| expiresAt: Date.now() + 60_000, |
| }) |
|
|
| const { req, url, segments } = makeRequest('GET', '/api/models') |
| const res = await handleModelsApi(req, url, segments) |
|
|
| expect(res.status).toBe(200) |
| const body = await res.json() |
| const ids = body.models.map((model: { id: string }) => model.id) |
|
|
| expect(ids).toContain('deepseek-v4-pro') |
| expect(ids).toContain('deepseek-v4-flash') |
| expect(ids).toContain('gpt-5.3-codex') |
| expect(ids).toContain('gpt-5.4') |
| expect(ids).toContain('gpt-5.4-mini') |
| expect(ids.filter((id: string) => id === 'deepseek-v4-pro')).toHaveLength(1) |
| }) |
|
|
| it('GET /api/models/current should return default model when not set', async () => { |
| const { req, url, segments } = makeRequest('GET', '/api/models/current') |
| const res = await handleModelsApi(req, url, segments) |
|
|
| expect(res.status).toBe(200) |
| const body = await res.json() |
| expect(body.model.id).toBe('claude-opus-4-7') |
| }) |
|
|
| it('GET /api/models/current should respect env-configured default model when no provider is active', async () => { |
| process.env.ANTHROPIC_MODEL = 'deepseek-v4-pro' |
|
|
| const { req, url, segments } = makeRequest('GET', '/api/models/current') |
| const res = await handleModelsApi(req, url, segments) |
|
|
| expect(res.status).toBe(200) |
| const body = await res.json() |
| expect(body.model.id).toBe('deepseek-v4-pro') |
| }) |
|
|
| it('PUT /api/models/current should switch model', async () => { |
| const { req, url, segments } = makeRequest('PUT', '/api/models/current', { |
| modelId: 'claude-opus-4-7', |
| }) |
| const res = await handleModelsApi(req, url, segments) |
|
|
| expect(res.status).toBe(200) |
| const body = await res.json() |
| expect(body.ok).toBe(true) |
| expect(body.model).toBe('claude-opus-4-7') |
|
|
| |
| const { req: r2, url: u2, segments: s2 } = makeRequest('GET', '/api/models/current') |
| const res2 = await handleModelsApi(r2, u2, s2) |
| const body2 = await res2.json() |
| expect(body2.model.id).toBe('claude-opus-4-7') |
| }) |
|
|
| it('PUT /api/models/current should reject missing modelId', async () => { |
| const { req, url, segments } = makeRequest('PUT', '/api/models/current', {}) |
| const res = await handleModelsApi(req, url, segments) |
| expect(res.status).toBe(400) |
| }) |
|
|
| it('GET /api/models/current should prefer cc-haha managed model over global user model when provider is active', async () => { |
| const settingsSvc = new SettingsService() |
| await settingsSvc.updateUserSettings({ model: 'kimi-k2.6' }) |
|
|
| const providerSvc = new ProviderService() |
| const provider = await providerSvc.addProvider({ |
| presetId: 'zhipuglm', |
| name: 'Zhipu GLM', |
| baseUrl: 'https://open.bigmodel.cn/api/anthropic', |
| apiKey: 'test-key', |
| apiFormat: 'anthropic', |
| models: { |
| main: 'glm-5.1', |
| haiku: 'glm-4.5-air', |
| sonnet: 'glm-5-turbo', |
| opus: 'glm-5.1', |
| }, |
| }) |
| await providerSvc.activateProvider(provider.id) |
| await providerSvc.updateManagedSettings({ model: 'glm-5-turbo' }) |
|
|
| const { req, url, segments } = makeRequest('GET', '/api/models/current') |
| const res = await handleModelsApi(req, url, segments) |
|
|
| expect(res.status).toBe(200) |
| const body = await res.json() |
| expect(body.model.id).toBe('glm-5-turbo') |
| }) |
|
|
| it('PUT /api/models/current should persist to cc-haha managed settings when provider is active', async () => { |
| const settingsSvc = new SettingsService() |
| const providerSvc = new ProviderService() |
| const provider = await providerSvc.addProvider({ |
| presetId: 'zhipuglm', |
| name: 'Zhipu GLM', |
| baseUrl: 'https://open.bigmodel.cn/api/anthropic', |
| apiKey: 'test-key', |
| apiFormat: 'anthropic', |
| models: { |
| main: 'glm-5.1', |
| haiku: 'glm-4.5-air', |
| sonnet: 'glm-5-turbo', |
| opus: 'glm-5.1', |
| }, |
| }) |
| await providerSvc.activateProvider(provider.id) |
|
|
| const putReq = makeRequest('PUT', '/api/models/current', { |
| modelId: 'glm-5-turbo', |
| }) |
| const putRes = await handleModelsApi(putReq.req, putReq.url, putReq.segments) |
| expect(putRes.status).toBe(200) |
|
|
| const managedSettings = await providerSvc.getManagedSettings() |
| expect(managedSettings.model).toBe('glm-5-turbo') |
| expect((managedSettings.env as Record<string, string>).CLAUDE_CODE_ATTRIBUTION_HEADER).toBe('0') |
|
|
| const globalSettings = await settingsSvc.getUserSettings() |
| expect(globalSettings.model).toBeUndefined() |
| }) |
|
|
| it('GET /api/models should return the OpenAI model catalog when ChatGPT Official is active', async () => { |
| const providerSvc = new ProviderService() |
| await providerSvc.activateProvider('openai-official') |
|
|
| const { req, url, segments } = makeRequest('GET', '/api/models') |
| const res = await handleModelsApi(req, url, segments) |
|
|
| expect(res.status).toBe(200) |
| const body = await res.json() as { |
| models: Array<{ id: string; name: string }> |
| provider: { id: string; name: string } | null |
| } |
| expect(body.provider).toEqual({ |
| id: 'openai-official', |
| name: 'ChatGPT Official', |
| }) |
| expect(body.models.map((model) => model.id)).toEqual([ |
| 'gpt-5.3-codex', |
| 'gpt-5.4', |
| 'gpt-5.5', |
| 'gpt-5.4-mini', |
| ]) |
| }) |
|
|
| it('PUT /api/models/current should persist GPT model to managed settings when ChatGPT Official is active', async () => { |
| const settingsSvc = new SettingsService() |
| const providerSvc = new ProviderService() |
| await settingsSvc.updateUserSettings({ model: 'claude-haiku-4-5' }) |
| await providerSvc.activateProvider('openai-official') |
|
|
| const { req, url, segments } = makeRequest('PUT', '/api/models/current', { |
| modelId: 'gpt-5.5', |
| }) |
| const res = await handleModelsApi(req, url, segments) |
|
|
| expect(res.status).toBe(200) |
| const managedSettings = await providerSvc.getManagedSettings() |
| expect(managedSettings.model).toBe('gpt-5.5') |
|
|
| const globalSettings = await settingsSvc.getUserSettings() |
| expect(globalSettings.model).toBe('claude-haiku-4-5') |
| }) |
|
|
| it('GET /api/models/current should read current GPT model from managed settings when ChatGPT Official is active', async () => { |
| const providerSvc = new ProviderService() |
| await providerSvc.activateProvider('openai-official') |
| await providerSvc.updateManagedSettings({ model: 'gpt-5.5' }) |
|
|
| const { req, url, segments } = makeRequest('GET', '/api/models/current') |
| const res = await handleModelsApi(req, url, segments) |
|
|
| expect(res.status).toBe(200) |
| const body = await res.json() |
| expect(body.model).toMatchObject({ |
| id: 'gpt-5.5', |
| name: 'GPT-5.5', |
| }) |
| }) |
|
|
| it('GET /api/effort should return default effort level', async () => { |
| const { req, url, segments } = makeRequest('GET', '/api/effort') |
| const res = await handleModelsApi(req, url, segments) |
|
|
| expect(res.status).toBe(200) |
| const body = await res.json() |
| expect(body.level).toBe('medium') |
| expect(body.available).toEqual(['low', 'medium', 'high', 'max']) |
| }) |
|
|
| it('GET /api/effort should fall back when stored effort is stale', async () => { |
| const settingsSvc = new SettingsService() |
| await settingsSvc.updateUserSettings({ effort: 'turbo' }) |
|
|
| const { req, url, segments } = makeRequest('GET', '/api/effort') |
| const res = await handleModelsApi(req, url, segments) |
|
|
| expect(res.status).toBe(200) |
| const body = await res.json() |
| expect(body.level).toBe('medium') |
| expect(body.available).toEqual(['low', 'medium', 'high', 'max']) |
| }) |
|
|
| it('PUT /api/effort should set effort level', async () => { |
| const { req, url, segments } = makeRequest('PUT', '/api/effort', { level: 'high' }) |
| const res = await handleModelsApi(req, url, segments) |
|
|
| expect(res.status).toBe(200) |
| const body = await res.json() |
| expect(body.ok).toBe(true) |
| expect(body.level).toBe('high') |
| }) |
|
|
| it('PUT /api/effort should reject invalid level', async () => { |
| const { req, url, segments } = makeRequest('PUT', '/api/effort', { level: 'turbo' }) |
| const res = await handleModelsApi(req, url, segments) |
| expect(res.status).toBe(400) |
| }) |
|
|
| it('should return 404 for unknown models endpoint', async () => { |
| const { req, url, segments } = makeRequest('GET', '/api/models/unknown') |
| const res = await handleModelsApi(req, url, segments) |
| expect(res.status).toBe(404) |
| }) |
| }) |
|
|
| describe('Model Options', () => { |
| beforeEach(setup) |
| afterEach(teardown) |
|
|
| it('should keep OpenAI OAuth models visible alongside env-configured provider models', () => { |
| process.env.ANTHROPIC_API_KEY = 'deepseek-key' |
| process.env.ANTHROPIC_BASE_URL = 'https://api.deepseek.com/anthropic' |
| process.env.ANTHROPIC_MODEL = 'deepseek-v4-pro' |
| process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = 'deepseek-v4-flash' |
| process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'deepseek-v4-pro' |
| process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = 'deepseek-v4-pro' |
| saveTestOpenAIOAuthTokens({ |
| accessToken: 'access-token', |
| refreshToken: 'refresh-token', |
| expiresAt: Date.now() + 60_000, |
| }) |
|
|
| const options = getModelOptions() |
| const values = options |
| .map(option => option.value) |
| .filter((value): value is string => typeof value === 'string') |
| const labels = options.map(option => option.label) |
|
|
| expect(values).toContain('gpt-5.3-codex') |
| expect(values).toContain('gpt-5.4') |
| expect(values).toContain('gpt-5.4-mini') |
| expect(labels).toContain('deepseek-v4-pro') |
| expect(labels).toContain('deepseek-v4-flash') |
| }) |
| }) |
|
|
| |
| |
| |
|
|
| describe('Status API', () => { |
| beforeEach(async () => { |
| await setup() |
| resetUsage() |
| }) |
| afterEach(teardown) |
|
|
| it('GET /api/status should return health check', async () => { |
| const { req, url, segments } = makeRequest('GET', '/api/status') |
| const res = await handleStatusApi(req, url, segments) |
|
|
| expect(res.status).toBe(200) |
| const body = await res.json() |
| expect(body.status).toBe('ok') |
| expect(body.version).toBeDefined() |
| expect(body.uptime).toBeGreaterThanOrEqual(0) |
| }) |
|
|
| it('GET /api/status/diagnostics should return system info', async () => { |
| const { req, url, segments } = makeRequest('GET', '/api/status/diagnostics') |
| const res = await handleStatusApi(req, url, segments) |
|
|
| expect(res.status).toBe(200) |
| const body = await res.json() |
| expect(body.platform).toBeDefined() |
| expect(body.arch).toBeDefined() |
| expect(body.configDir).toBeDefined() |
| }) |
|
|
| it('GET /api/status/usage should return token usage', async () => { |
| addUsage(100, 50, 0.005) |
| addUsage(200, 100, 0.01) |
|
|
| const { req, url, segments } = makeRequest('GET', '/api/status/usage') |
| const res = await handleStatusApi(req, url, segments) |
|
|
| expect(res.status).toBe(200) |
| const body = await res.json() |
| expect(body.totalInputTokens).toBe(300) |
| expect(body.totalOutputTokens).toBe(150) |
| expect(body.totalCost).toBeCloseTo(0.015) |
| }) |
|
|
| it('GET /api/status/user should return user info', async () => { |
| const { req, url, segments } = makeRequest('GET', '/api/status/user') |
| const res = await handleStatusApi(req, url, segments) |
|
|
| expect(res.status).toBe(200) |
| const body = await res.json() |
| expect(body.configDir).toBe(tmpDir) |
| expect(body.projects).toBeArray() |
| }) |
|
|
| it('should reject non-GET methods', async () => { |
| const { req, url, segments } = makeRequest('POST', '/api/status') |
| const res = await handleStatusApi(req, url, segments) |
| expect(res.status).toBe(405) |
| }) |
|
|
| it('should return 404 for unknown status endpoint', async () => { |
| const { req, url, segments } = makeRequest('GET', '/api/status/nonexistent') |
| const res = await handleStatusApi(req, url, segments) |
| expect(res.status).toBe(404) |
| }) |
| }) |
|
|
| |
| |
| |
|
|
| describe('Activity Stats API', () => { |
| let handleApiRequest: typeof import('../router.js').handleApiRequest |
|
|
| beforeAll(async () => { |
| ;({ handleApiRequest } = await import('../router.js')) |
| }) |
|
|
| beforeEach(async () => { |
| await setup() |
| }) |
|
|
| afterEach(teardown) |
|
|
| it('GET /api/activity-stats should default to the all range', async () => { |
| const { req, url } = makeRequest('GET', '/api/activity-stats') |
| const res = await handleApiRequest(req, url) |
|
|
| expect(res.status).toBe(200) |
|
|
| const body = await res.json() |
| expect(body.range).toBe('all') |
| expect(body.stats.totalSessions).toBe(0) |
| expect(new Date(body.generatedAt).toString()).not.toBe('Invalid Date') |
| }) |
|
|
| it('GET /api/activity-stats/:range should return stats for supported ranges', async () => { |
| for (const range of ['7d', '30d', 'all'] as const) { |
| const { req, url } = makeRequest('GET', `/api/activity-stats/${range}`) |
| const res = await handleApiRequest(req, url) |
|
|
| expect(res.status).toBe(200) |
| const body = await res.json() |
| expect(body.range).toBe(range) |
| expect(body.stats).toBeDefined() |
| } |
| }) |
|
|
| it('should reject non-GET methods', async () => { |
| const { req, url } = makeRequest('POST', '/api/activity-stats') |
| const res = await handleApiRequest(req, url) |
|
|
| expect(res.status).toBe(405) |
| }) |
|
|
| it('should reject unknown activity stats ranges', async () => { |
| const { req, url } = makeRequest('GET', '/api/activity-stats/90d') |
| const res = await handleApiRequest(req, url) |
|
|
| expect(res.status).toBe(400) |
| }) |
| }) |
|
|