mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
PR 217 combined OpenAI OAuth model options with env-configured provider models, but the picker path returned before appending third-party model aliases. The desktop workspace test also exercised Prism highlighting for every expanded line, which was costly enough to time out on CI runners. The docs job did not need the root React Vite plugin; the desktop package owns that dependency.\n\nConstraint: PR Quality must pass server, desktop, and docs jobs before merge.\nRejected: Increase Vitest timeout | keeps the expensive render path and hides the CI regression.\nRejected: Add root Vite 8 peer dependencies | root only builds VitePress docs; React Vite plugin belongs in desktop/.\nConfidence: high\nScope-risk: narrow\nTested: npm ci --ignore-scripts; npm run docs:build; bun test src/server/__tests__/settings.test.ts src/server/__tests__/conversation-service.test.ts; bun run check:server; bun run check:desktop\nNot-tested: live provider OAuth login
668 lines
23 KiB
TypeScript
668 lines
23 KiB
TypeScript
/**
|
|
* Unit tests for Settings, Models, and Status APIs
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } 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 { 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'
|
|
|
|
// ─── 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-'))
|
|
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()
|
|
|
|
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 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 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 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 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('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')
|
|
|
|
const globalSettings = await settingsSvc.getUserSettings()
|
|
expect(globalSettings.model).toBeUndefined()
|
|
})
|
|
|
|
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('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)
|
|
})
|
|
})
|