Download src/server/__tests__/settings.test.ts from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 31.3 kB
-
https://huggingface.co/chenbhao/codev/resolve/afbc7edaae4e6bb0102ba024b49ce8be89d2264c/src/server/__tests__/settings.test.ts
- Command line
-
hf download hf://chenbhao/codev@afbc7edaae4e6bb0102ba024b49ce8be89d2264c/src/server/__tests__/settings.test.ts
-
curl -L -o settings.test.ts https://huggingface.co/chenbhao/codev/resolve/afbc7edaae4e6bb0102ba024b49ce8be89d2264c/src/server/__tests__/settings.test.ts
31.3 kB
| /** | |
| * Unit tests for Settings, Models, and Status APIs | |
| */ | |
| 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' | |
| // ─── Test helpers ───────────────────────────────────────────────────────────── | |
| 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() | |
| } | |
| /** 创建一个模拟 Request */ | |
| 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 } | |
| } | |
| // ============================================================================= | |
| // SettingsService | |
| // ============================================================================= | |
| 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() | |
| // project overrides user | |
| expect(merged.theme).toBe('light') | |
| // user value preserved when not overridden | |
| 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') | |
| }) | |
| }) | |
| // ============================================================================= | |
| // Settings API | |
| // ============================================================================= | |
| describe('Settings API', () => { | |
| beforeEach(setup) | |
| afterEach(teardown) | |
| it('GET /api/settings should return merged settings', async () => { | |
| // Seed some user settings | |
| 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) | |
| // Verify persisted | |
| 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) | |
| }) | |
| }) | |
| // ============================================================================= | |
| // Models API | |
| // ============================================================================= | |
| 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') | |
| // Verify persisted | |
| 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') | |
| }) | |
| }) | |
| // ============================================================================= | |
| // Status API | |
| // ============================================================================= | |
| 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) | |
| }) | |
| }) | |
| // ============================================================================= | |
| // Activity Stats API | |
| // ============================================================================= | |
| 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) | |
| }) | |
| }) | |