cc-haha/adapters/common/__tests__/http-client.test.ts
程序员阿江(Relakkes) 2459488703 Harden provider auth, adapter paths, and notification retries
This captures the pending worktree fixes before applying them to the
current local main. The changes tighten IM adapter path and credential
handling, preserve retry behavior for failed desktop notifications, and
make Azure/OpenAI provider auth and stop reasons reflect actual runtime
state.

Constraint: Worktree was detached from an older local main with pending uncommitted fixes
Rejected: Merge the detached HEAD directly | would also replay unrelated stale history
Rejected: Leave notification dedupe as fire-and-forget | failed sends consumed retry keys
Confidence: high
Scope-risk: broad
Directive: Keep adapter absolute-path matching constrained to configured work roots
Tested: git diff --check
Not-tested: full quality gate before local main integration
2026-05-04 21:37:30 +08:00

148 lines
5.2 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test'
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { AdapterHttpClient } from '../http-client.js'
describe('AdapterHttpClient', () => {
let client: AdapterHttpClient
const originalFetch = globalThis.fetch
beforeEach(() => {
client = new AdapterHttpClient('ws://127.0.0.1:3456')
})
afterEach(() => {
globalThis.fetch = originalFetch
})
it('derives HTTP URL from WS URL', () => {
expect(client.httpBaseUrl).toBe('http://127.0.0.1:3456')
const secure = new AdapterHttpClient('wss://example.com:443')
expect(secure.httpBaseUrl).toBe('https://example.com:443')
})
it('createSession calls POST /api/sessions', async () => {
const mockSessionId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
globalThis.fetch = mock(() =>
Promise.resolve(new Response(JSON.stringify({ sessionId: mockSessionId }), {
status: 201,
headers: { 'Content-Type': 'application/json' },
}))
) as any
const sessionId = await client.createSession('/path/to/project')
expect(sessionId).toBe(mockSessionId)
const call = (globalThis.fetch as any).mock.calls[0]
expect(call[0]).toBe('http://127.0.0.1:3456/api/sessions')
const body = JSON.parse(call[1].body)
expect(body.workDir).toBe('/path/to/project')
})
it('listRecentProjects calls GET /api/sessions/recent-projects', async () => {
const mockProjects = [
{ projectName: 'my-app', realPath: '/home/user/my-app', sessionCount: 3 },
]
globalThis.fetch = mock(() =>
Promise.resolve(new Response(JSON.stringify({ projects: mockProjects }), {
headers: { 'Content-Type': 'application/json' },
}))
) as any
const projects = await client.listRecentProjects()
expect(projects).toHaveLength(1)
expect(projects[0].projectName).toBe('my-app')
})
it('matchProject accepts an absolute local project path inside an allowed root without recent history', async () => {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'im-root-'))
const projectDir = fs.mkdtempSync(path.join(rootDir, 'project-'))
try {
client = new AdapterHttpClient('ws://127.0.0.1:3456', { allowedProjectRoots: [rootDir] })
globalThis.fetch = mock(() => {
throw new Error('recent projects should not be queried for absolute paths')
}) as any
const result = await client.matchProject(projectDir)
expect(result.project?.realPath).toBe(fs.realpathSync(projectDir))
expect(result.project?.projectName).toBe(path.basename(projectDir))
expect((globalThis.fetch as any).mock.calls).toHaveLength(0)
} finally {
fs.rmSync(rootDir, { recursive: true, force: true })
}
})
it('matchProject rejects absolute local project paths outside allowed roots', async () => {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'im-root-'))
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'im-project-'))
try {
client = new AdapterHttpClient('ws://127.0.0.1:3456', { allowedProjectRoots: [rootDir] })
globalThis.fetch = mock(() => {
throw new Error('recent projects should not be queried for rejected absolute paths')
}) as any
const result = await client.matchProject(projectDir)
expect(result.project).toBeUndefined()
expect(result.ambiguous).toBeUndefined()
expect((globalThis.fetch as any).mock.calls).toHaveLength(0)
} finally {
fs.rmSync(rootDir, { recursive: true, force: true })
fs.rmSync(projectDir, { recursive: true, force: true })
}
})
it('createSession throws on server error', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(new Response(JSON.stringify({ error: 'BAD_REQUEST', message: 'workDir required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
}))
) as any
expect(client.createSession('')).rejects.toThrow()
})
it('getGitInfo calls GET /api/sessions/:id/git-info', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(new Response(JSON.stringify({
branch: 'main',
repoName: 'claude-code-haha',
workDir: '/repo/claude-code-haha',
changedFiles: 2,
}), {
headers: { 'Content-Type': 'application/json' },
}))
) as any
const gitInfo = await client.getGitInfo('session-123')
expect(gitInfo.repoName).toBe('claude-code-haha')
expect((globalThis.fetch as any).mock.calls[0][0]).toBe(
'http://127.0.0.1:3456/api/sessions/session-123/git-info',
)
})
it('getTasksForSession calls GET /api/tasks/lists/:id', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(new Response(JSON.stringify({
tasks: [
{ id: '1', subject: 'Fix bug', status: 'in_progress' },
{ id: '2', subject: 'Write docs', status: 'pending' },
],
}), {
headers: { 'Content-Type': 'application/json' },
}))
) as any
const tasks = await client.getTasksForSession('session-123')
expect(tasks).toHaveLength(2)
expect(tasks[0]?.status).toBe('in_progress')
expect((globalThis.fetch as any).mock.calls[0][0]).toBe(
'http://127.0.0.1:3456/api/tasks/lists/session-123',
)
})
})