Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 185 additions & 0 deletions src/mcp/utils/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { expect } from '@oclif/test'
import sinon from 'sinon'
import * as assert from 'assert'
import { DevCycleApiClient, handleZodiosValidationErrors } from './api'
import { DevCycleAuth } from './auth'
import { setMCPToolCommand } from './headers'
import * as apiClientModule from '../../api/apiClient'

describe('DevCycleApiClient', () => {
let apiClient: DevCycleApiClient
let authStub: sinon.SinonStubbedInstance<DevCycleAuth>
let setDVCReferrerStub: sinon.SinonStub

beforeEach(() => {
// Create stubbed auth instance
authStub = sinon.createStubInstance(DevCycleAuth)
authStub.getAuthToken.returns('mock-auth-token')
authStub.getProjectKey.returns('test-project')
authStub.getOrgId.returns('test-org-id')

apiClient = new DevCycleApiClient(authStub)

// Stub the setDVCReferrer function from apiClient module
setDVCReferrerStub = sinon.stub(apiClientModule, 'setDVCReferrer')
})

afterEach(() => {
sinon.restore()
})

describe('Zodios Error Handling', () => {
it('should extract data from Zodios validation errors with 200 OK response', async () => {
const mockResponseData = { id: '123', name: 'Test Feature' }

// Create a proper mock Zodios error with data property
class ZodiosValidationError extends Error {
constructor(
message: string,
public data: any,
) {
super(message)
this.name = 'ZodiosValidationError'
}
}

const zodiosError = new ZodiosValidationError(
'Zodios: Invalid response - status: 200 OK',
mockResponseData,
)

const apiCall = sinon.stub().rejects(zodiosError)

const result = await handleZodiosValidationErrors(
apiCall,
'testOperation',
)

expect(result).to.deep.equal(mockResponseData)
})

it('should re-throw non-Zodios errors unchanged', async () => {
const networkError = new Error('Network timeout')
const apiCall = sinon.stub().rejects(networkError)

try {
await handleZodiosValidationErrors(apiCall, 'testOperation')
assert.fail('Expected function to throw')
} catch (error) {
expect(error).to.equal(networkError)
}
})
})

describe('executeWithLogging', () => {
it('should execute operation successfully with valid auth and project', async () => {
const mockResult = { id: '123', name: 'Test Feature' }
const mockOperation = sinon.stub().resolves(mockResult)

authStub.requireAuth.returns()
authStub.requireProject.returns()

const result = await apiClient.executeWithLogging(
'testOperation',
{ key: 'test-key' },
mockOperation,
)

expect(result).to.deep.equal(mockResult)
sinon.assert.calledOnce(authStub.requireAuth)
sinon.assert.calledOnce(authStub.requireProject)
sinon.assert.calledWith(
mockOperation,
'mock-auth-token',
'test-project',
)
sinon.assert.calledWith(
setDVCReferrerStub,
'testOperation',
sinon.match.string,
'mcp',
)
})

it('should handle authentication errors gracefully', async () => {
const authError = new Error('Authentication failed')
authStub.requireAuth.throws(authError)

const mockOperation = sinon.stub().resolves({})

try {
await apiClient.executeWithLogging(
'testOperation',
null,
mockOperation,
)
assert.fail('Expected function to throw')
} catch (error) {
expect((error as Error).message).to.equal(
Copy link

Copilot AI Jul 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type casting to Error is unnecessary since the caught error in a try-catch block should already be of type Error based on the test setup.

Suggested change
expect((error as Error).message).to.equal(
expect(error.message).to.equal(

Copilot uses AI. Check for mistakes.
'Authentication failed',
)
sinon.assert.notCalled(mockOperation)
}
})
})

describe('executeWithDashboardLink', () => {
it('should generate dashboard links correctly', async () => {
const mockResult = { key: 'test-feature', name: 'Test Feature' }
const mockOperation = sinon.stub().resolves(mockResult)
const dashboardLinkGenerator = sinon
.stub()
.returns(
'https://app.devcycle.com/o/test-org-id/p/test-project/features',
)

authStub.requireAuth.returns()
authStub.requireProject.returns()

const result = await apiClient.executeWithDashboardLink(
'createFeature',
{ key: 'test-feature' },
mockOperation,
dashboardLinkGenerator,
)

expect(result).to.deep.equal({
result: mockResult,
dashboardLink:
'https://app.devcycle.com/o/test-org-id/p/test-project/features',
})

sinon.assert.calledWith(
dashboardLinkGenerator,
'test-org-id',
'test-project',
mockResult,
)
})
})
})

describe('Header Management', () => {
let setDVCReferrerStub: sinon.SinonStub

beforeEach(() => {
setDVCReferrerStub = sinon.stub(apiClientModule, 'setDVCReferrer')
})

afterEach(() => {
sinon.restore()
})

describe('setMCPToolCommand', () => {
it('should set MCP headers correctly for tool commands', () => {
setMCPToolCommand('list_features')

sinon.assert.calledWith(
setDVCReferrerStub,
'list_features',
sinon.match.string, // version
'mcp',
)
})
})
})