cc-haha/src/server/__tests__/settings.test.ts
2026-04-18 20:34:56 +08:00

377 lines
13 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'
// ─── Test helpers ─────────────────────────────────────────────────────────────
let tmpDir: string
let originalConfigDir: string | undefined
async function setup() {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-test-'))
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
process.env.CLAUDE_CONFIG_DIR = tmpDir
}
async function teardown() {
if (originalConfigDir !== undefined) {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
} else {
delete process.env.CLAUDE_CONFIG_DIR
}
await fs.rm(tmpDir, { recursive: true, force: true })
}
/** 创建一个模拟 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')
})
})
// =============================================================================
// 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/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(4)
expect(body.models[0].id).toContain('claude')
})
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-sonnet-4-6')
})
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/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)
})
})
// =============================================================================
// 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)
})
})