mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-22 16:25:51 +00:00
feat: 添加云端管理命令(memory-stores、vault、schedule、skill-store、agents-platform)
- /memory-stores: 远程记忆存储管理 - /vault: 密钥保险库管理 - /schedule: 云端定时触发器管理(cron) - /skill-store: 技能商店浏览和安装 - /agents-platform: 远程 agent 调度管理 Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
This commit is contained in:
382
src/commands/agents-platform/__tests__/agentsApi.test.ts
Normal file
382
src/commands/agents-platform/__tests__/agentsApi.test.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
test,
|
||||
} from 'bun:test'
|
||||
import { debugMock } from '../../../../tests/mocks/debug.js'
|
||||
import { logMock } from '../../../../tests/mocks/log.js'
|
||||
import { setupAxiosMock } from '../../../../tests/mocks/axios.js'
|
||||
|
||||
// Mock side-effect modules first
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
|
||||
// ── Workspace API key mock ──────────────────────────────────────────────────
|
||||
const mockApiKey = 'sk-ant-api03-test-agents-key'
|
||||
|
||||
mock.module('src/constants/oauth.js', () => ({
|
||||
getOauthConfig: () => ({ BASE_API_URL: 'https://api.anthropic.com' }),
|
||||
}))
|
||||
|
||||
const prepareWorkspaceApiRequestMock = mock(async () => ({
|
||||
apiKey: mockApiKey,
|
||||
}))
|
||||
|
||||
mock.module('src/utils/teleport/api.js', () => ({
|
||||
prepareWorkspaceApiRequest: prepareWorkspaceApiRequestMock,
|
||||
}))
|
||||
|
||||
// Note: we do NOT mock src/services/auth/hostGuard.js here.
|
||||
// The real assertWorkspaceHost() is called with the URL from getOauthConfig()
|
||||
// (mocked to https://api.anthropic.com), which passes the host guard.
|
||||
// Mocking hostGuard would pollute hostGuard's own test file via Bun process-level cache.
|
||||
|
||||
// ── Axios mock ──────────────────────────────────────────────────────────────
|
||||
const axiosGetMock = mock(async () => ({}))
|
||||
const axiosPostMock = mock(async () => ({}))
|
||||
const axiosDeleteMock = mock(async () => ({}))
|
||||
|
||||
const axiosIsAxiosError = mock((err: unknown) => {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'isAxiosError' in err &&
|
||||
(err as { isAxiosError: boolean }).isAxiosError === true
|
||||
)
|
||||
})
|
||||
|
||||
const axiosHandle = setupAxiosMock()
|
||||
axiosHandle.stubs.get = axiosGetMock
|
||||
axiosHandle.stubs.post = axiosPostMock
|
||||
axiosHandle.stubs.delete = axiosDeleteMock
|
||||
axiosHandle.stubs.isAxiosError = axiosIsAxiosError
|
||||
|
||||
// Lazy import after mocks are in place
|
||||
let listAgents: typeof import('../agentsApi.js').listAgents
|
||||
let createAgent: typeof import('../agentsApi.js').createAgent
|
||||
let deleteAgent: typeof import('../agentsApi.js').deleteAgent
|
||||
let runAgent: typeof import('../agentsApi.js').runAgent
|
||||
|
||||
beforeAll(async () => {
|
||||
axiosHandle.useStubs = true
|
||||
const mod = await import('../agentsApi.js')
|
||||
listAgents = mod.listAgents
|
||||
createAgent = mod.createAgent
|
||||
deleteAgent = mod.deleteAgent
|
||||
runAgent = mod.runAgent
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
axiosHandle.useStubs = false
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
axiosGetMock.mockClear()
|
||||
axiosPostMock.mockClear()
|
||||
axiosDeleteMock.mockClear()
|
||||
prepareWorkspaceApiRequestMock.mockClear()
|
||||
// Ensure ANTHROPIC_API_KEY is set for happy-path tests
|
||||
process.env['ANTHROPIC_API_KEY'] = mockApiKey
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up env var to avoid test pollution
|
||||
delete process.env['ANTHROPIC_API_KEY']
|
||||
})
|
||||
|
||||
// afterEach handled above
|
||||
|
||||
describe('listAgents', () => {
|
||||
test('returns agents on 200', async () => {
|
||||
const agents = [
|
||||
{
|
||||
id: 'agt_1',
|
||||
cron_expr: '0 9 * * 1',
|
||||
prompt: 'hello',
|
||||
status: 'active',
|
||||
timezone: 'UTC',
|
||||
next_run: null,
|
||||
},
|
||||
]
|
||||
axiosGetMock.mockResolvedValueOnce({ data: { data: agents }, status: 200 })
|
||||
|
||||
const result = await listAgents()
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]!.id).toBe('agt_1')
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('returns empty array when data.data is empty', async () => {
|
||||
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
||||
const result = await listAgents()
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('throws on 401 with friendly message', async () => {
|
||||
const err = Object.assign(new Error('Unauthorized'), {
|
||||
isAxiosError: true,
|
||||
response: { status: 401, data: {} },
|
||||
})
|
||||
axiosGetMock.mockRejectedValueOnce(err)
|
||||
axiosIsAxiosError.mockImplementation(
|
||||
(e: unknown) =>
|
||||
typeof e === 'object' &&
|
||||
e !== null &&
|
||||
'isAxiosError' in e &&
|
||||
(e as { isAxiosError: boolean }).isAxiosError === true,
|
||||
)
|
||||
|
||||
await expect(listAgents()).rejects.toThrow('re-authenticate')
|
||||
})
|
||||
|
||||
test('throws on 403 with subscription message', async () => {
|
||||
const err = Object.assign(new Error('Forbidden'), {
|
||||
isAxiosError: true,
|
||||
response: { status: 403, data: {} },
|
||||
})
|
||||
axiosGetMock.mockRejectedValueOnce(err)
|
||||
axiosIsAxiosError.mockImplementation(
|
||||
(e: unknown) =>
|
||||
typeof e === 'object' &&
|
||||
e !== null &&
|
||||
'isAxiosError' in e &&
|
||||
(e as { isAxiosError: boolean }).isAxiosError === true,
|
||||
)
|
||||
|
||||
await expect(listAgents()).rejects.toThrow('Subscription')
|
||||
})
|
||||
|
||||
test('retries on 5xx and eventually throws', async () => {
|
||||
const make5xxErr = () =>
|
||||
Object.assign(new Error('Server Error'), {
|
||||
isAxiosError: true,
|
||||
response: { status: 500, data: {} },
|
||||
})
|
||||
axiosGetMock
|
||||
.mockRejectedValueOnce(make5xxErr())
|
||||
.mockRejectedValueOnce(make5xxErr())
|
||||
.mockRejectedValueOnce(make5xxErr())
|
||||
axiosIsAxiosError.mockImplementation(
|
||||
(e: unknown) =>
|
||||
typeof e === 'object' &&
|
||||
e !== null &&
|
||||
'isAxiosError' in e &&
|
||||
(e as { isAxiosError: boolean }).isAxiosError === true,
|
||||
)
|
||||
|
||||
await expect(listAgents()).rejects.toThrow()
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(3)
|
||||
}, 15000)
|
||||
})
|
||||
|
||||
describe('createAgent', () => {
|
||||
test('sends correct body and returns agent', async () => {
|
||||
const agent = {
|
||||
id: 'agt_new',
|
||||
cron_expr: '0 9 * * *',
|
||||
prompt: 'Test',
|
||||
status: 'active',
|
||||
timezone: 'UTC',
|
||||
next_run: null,
|
||||
}
|
||||
axiosPostMock.mockResolvedValueOnce({ data: agent, status: 201 })
|
||||
|
||||
const result = await createAgent('0 9 * * *', 'Test')
|
||||
expect(result.id).toBe('agt_new')
|
||||
const callArgs = (
|
||||
axiosPostMock.mock.calls as unknown as [string, unknown, unknown][]
|
||||
)[0]
|
||||
const body = callArgs?.[1] as { cron_expr: string; timezone: string }
|
||||
expect(body.cron_expr).toBe('0 9 * * *')
|
||||
expect(body.timezone).toBe('UTC')
|
||||
})
|
||||
|
||||
test('throws on 404', async () => {
|
||||
const err = Object.assign(new Error('Not Found'), {
|
||||
isAxiosError: true,
|
||||
response: { status: 404, data: {} },
|
||||
})
|
||||
axiosPostMock.mockRejectedValueOnce(err)
|
||||
axiosIsAxiosError.mockImplementation(
|
||||
(e: unknown) =>
|
||||
typeof e === 'object' &&
|
||||
e !== null &&
|
||||
'isAxiosError' in e &&
|
||||
(e as { isAxiosError: boolean }).isAxiosError === true,
|
||||
)
|
||||
|
||||
await expect(createAgent('0 9 * * *', 'Test')).rejects.toThrow(
|
||||
'Agent not found',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteAgent', () => {
|
||||
test('calls DELETE endpoint with agent id', async () => {
|
||||
axiosDeleteMock.mockResolvedValueOnce({ status: 204 })
|
||||
|
||||
await deleteAgent('agt_del')
|
||||
const url = (
|
||||
axiosDeleteMock.mock.calls as unknown as [string, unknown][]
|
||||
)[0]?.[0] as string
|
||||
expect(url).toContain('agt_del')
|
||||
})
|
||||
})
|
||||
|
||||
describe('runAgent', () => {
|
||||
test('calls POST /v1/agents/:id/run and returns run_id', async () => {
|
||||
axiosPostMock.mockResolvedValueOnce({
|
||||
data: { run_id: 'run_abc' },
|
||||
status: 200,
|
||||
})
|
||||
|
||||
const result = await runAgent('agt_run')
|
||||
expect(result.run_id).toBe('run_abc')
|
||||
const url = (
|
||||
axiosPostMock.mock.calls as unknown as [string, unknown, unknown][]
|
||||
)[0]?.[0] as string
|
||||
expect(url).toContain('agt_run/run')
|
||||
})
|
||||
})
|
||||
|
||||
// ── M3 regression: createAgent must use system timezone, not hardcoded UTC ──
|
||||
describe('createAgent M3: timezone uses system TZ not hardcoded UTC', () => {
|
||||
test('createAgent passes system timezone to the API body', async () => {
|
||||
axiosPostMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
id: 'agt_tz',
|
||||
cron_expr: '0 9 * * 1',
|
||||
prompt: 'hello',
|
||||
status: 'active',
|
||||
timezone: 'America/New_York',
|
||||
},
|
||||
status: 200,
|
||||
})
|
||||
|
||||
await createAgent('0 9 * * 1', 'hello')
|
||||
|
||||
const calls = axiosPostMock.mock.calls as unknown as [
|
||||
string,
|
||||
Record<string, unknown>,
|
||||
unknown,
|
||||
][]
|
||||
const body = calls[0]?.[1]
|
||||
expect(body).toHaveProperty('timezone')
|
||||
// Must NOT be the hardcoded 'UTC' string — must be a real timezone string
|
||||
// In CI the system TZ may be UTC, but the field must still be present and a string.
|
||||
expect(typeof body?.timezone).toBe('string')
|
||||
expect((body?.timezone as string).length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ── M5 regression: withRetry must honor Retry-After header ──
|
||||
describe('withRetry M5: honors Retry-After header on 5xx', () => {
|
||||
test('waits at least Retry-After seconds before retrying on 5xx', async () => {
|
||||
// First call: 503 with Retry-After: 0 (immediate, so test is fast)
|
||||
// Second call: success
|
||||
const serverErr = Object.assign(new Error('Service Unavailable'), {
|
||||
isAxiosError: true,
|
||||
response: { status: 503, data: {}, headers: { 'retry-after': '0' } },
|
||||
})
|
||||
axiosGetMock
|
||||
.mockRejectedValueOnce(serverErr)
|
||||
.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
||||
|
||||
axiosIsAxiosError.mockImplementation(
|
||||
(e: unknown) =>
|
||||
typeof e === 'object' &&
|
||||
e !== null &&
|
||||
'isAxiosError' in e &&
|
||||
(e as { isAxiosError: boolean }).isAxiosError === true,
|
||||
)
|
||||
|
||||
const result = await listAgents()
|
||||
// Should have retried and succeeded on second attempt
|
||||
expect(result).toHaveLength(0)
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Regression: auth must use prepareWorkspaceApiRequest (not subscription OAuth) ──
|
||||
describe('regression: uses prepareWorkspaceApiRequest for auth', () => {
|
||||
test('listAgents calls prepareWorkspaceApiRequest to obtain workspace API key', async () => {
|
||||
prepareWorkspaceApiRequestMock.mockClear()
|
||||
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
||||
|
||||
await listAgents()
|
||||
|
||||
expect(prepareWorkspaceApiRequestMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Invariant: buildHeaders must return x-api-key, not Authorization ─────────
|
||||
describe('invariant: x-api-key present, no Authorization, no x-organization-uuid', () => {
|
||||
test('buildHeaders returns x-api-key header (workspace key)', async () => {
|
||||
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
||||
await listAgents()
|
||||
const calls = axiosGetMock.mock.calls as unknown as [
|
||||
string,
|
||||
{ headers: Record<string, string> },
|
||||
][]
|
||||
const headers = calls[0]?.[1]?.headers ?? {}
|
||||
expect(headers['x-api-key']).toBe(mockApiKey)
|
||||
})
|
||||
|
||||
test('buildHeaders does NOT include Authorization header', async () => {
|
||||
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
||||
await listAgents()
|
||||
const calls = axiosGetMock.mock.calls as unknown as [
|
||||
string,
|
||||
{ headers: Record<string, string> },
|
||||
][]
|
||||
const headers = calls[0]?.[1]?.headers ?? {}
|
||||
expect(headers['Authorization']).toBeUndefined()
|
||||
})
|
||||
|
||||
test('buildHeaders does NOT include x-organization-uuid header', async () => {
|
||||
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
||||
await listAgents()
|
||||
const calls = axiosGetMock.mock.calls as unknown as [
|
||||
string,
|
||||
{ headers: Record<string, string> },
|
||||
][]
|
||||
const headers = calls[0]?.[1]?.headers ?? {}
|
||||
expect(headers['x-organization-uuid']).toBeUndefined()
|
||||
})
|
||||
|
||||
test('buildHeaders includes anthropic-beta header with managed-agents umbrella', async () => {
|
||||
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
||||
await listAgents()
|
||||
const calls = axiosGetMock.mock.calls as unknown as [
|
||||
string,
|
||||
{ headers: Record<string, string> },
|
||||
][]
|
||||
const headers = calls[0]?.[1]?.headers ?? {}
|
||||
expect(headers['anthropic-beta']).toContain('managed-agents')
|
||||
})
|
||||
|
||||
test('throws 501 when ANTHROPIC_API_KEY is missing (all 3 retries fail)', async () => {
|
||||
// withRetry retries 5xx errors (statusCode >= 500 including 501).
|
||||
// buildHeaders throws AgentsApiError(msg, 501) for config errors.
|
||||
// All 3 retry attempts must fail for the error to propagate.
|
||||
const missingKeyError = new Error('ANTHROPIC_API_KEY is required')
|
||||
prepareWorkspaceApiRequestMock
|
||||
.mockRejectedValueOnce(missingKeyError)
|
||||
.mockRejectedValueOnce(missingKeyError)
|
||||
.mockRejectedValueOnce(missingKeyError)
|
||||
await expect(listAgents()).rejects.toThrow(/ANTHROPIC_API_KEY|required/i)
|
||||
}, 5000)
|
||||
|
||||
test('request goes to api.anthropic.com (host guard passes for correct host)', async () => {
|
||||
// The real assertWorkspaceHost() runs and passes since BASE_API_URL is api.anthropic.com
|
||||
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
||||
await listAgents()
|
||||
const calls = axiosGetMock.mock.calls as unknown as [string, unknown][]
|
||||
expect(calls[0]?.[0]).toContain('api.anthropic.com')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user