feature.spec.unit.ts•29.1 kB
import axios from 'axios';
import { searchCode } from './feature';
import { WebApi } from 'azure-devops-node-api';
import { AzureDevOpsError } from '../../../shared/errors';
import { GitVersionType } from 'azure-devops-node-api/interfaces/GitInterfaces';
// Mock Azure Identity
jest.mock('@azure/identity', () => {
const mockGetToken = jest.fn().mockResolvedValue({ token: 'mock-token' });
return {
DefaultAzureCredential: jest.fn().mockImplementation(() => ({
getToken: mockGetToken,
})),
AzureCliCredential: jest.fn().mockImplementation(() => ({
getToken: mockGetToken,
})),
};
});
// Mock axios
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
describe('searchCode unit', () => {
// Mock WebApi connection
const mockConnection = {
getGitApi: jest.fn().mockImplementation(() => ({
getItemContent: jest.fn().mockImplementation((_repoId, path) => {
// Return different content based on the path to simulate different files
if (path === '/src/example.ts') {
return Buffer.from('export function example() { return "test"; }');
}
return Buffer.from('// Empty file');
}),
})),
_getHttpClient: jest.fn().mockReturnValue({
getAuthorizationHeader: jest.fn().mockReturnValue('Bearer mock-token'),
}),
getCoreApi: jest.fn().mockImplementation(() => ({
getProjects: jest
.fn()
.mockResolvedValue([{ name: 'TestProject', id: 'project-id' }]),
})),
serverUrl: 'https://dev.azure.com/testorg',
} as unknown as WebApi;
// Store original console.error
const originalConsoleError = console.error;
beforeEach(() => {
jest.clearAllMocks();
// Mock console.error to prevent error messages from being displayed during tests
console.error = jest.fn();
});
afterEach(() => {
// Restore original console.error
console.error = originalConsoleError;
});
test('should return search results with content', async () => {
// Arrange
const mockSearchResponse = {
data: {
count: 1,
results: [
{
fileName: 'example.ts',
path: '/src/example.ts',
matches: {
content: [
{
charOffset: 17,
length: 7,
},
],
},
collection: {
name: 'DefaultCollection',
},
project: {
name: 'TestProject',
id: 'project-id',
},
repository: {
name: 'TestRepo',
id: 'repo-id',
type: 'git',
},
versions: [
{
branchName: 'main',
changeId: 'commit-hash',
},
],
contentId: 'content-hash',
},
],
},
};
mockedAxios.post.mockResolvedValueOnce(mockSearchResponse);
// Create a mock stream with content
const fileContent = 'export function example() { return "test"; }';
const mockStream = {
on: jest.fn().mockImplementation((event, callback) => {
if (event === 'data') {
// Call the callback with the data
callback(Buffer.from(fileContent));
} else if (event === 'end') {
// Call the end callback asynchronously
setTimeout(callback, 0);
}
return mockStream; // Return this for chaining
}),
};
// Mock Git API to return content
const mockGitApi = {
getItemContent: jest.fn().mockResolvedValue(mockStream),
};
const mockConnectionWithContent = {
...mockConnection,
getGitApi: jest.fn().mockResolvedValue(mockGitApi),
serverUrl: 'https://dev.azure.com/testorg',
} as unknown as WebApi;
// Act
const result = await searchCode(mockConnectionWithContent, {
searchText: 'example',
projectId: 'TestProject',
includeContent: true,
});
// Assert
expect(result).toBeDefined();
expect(result.count).toBe(1);
expect(result.results).toHaveLength(1);
expect(result.results[0].fileName).toBe('example.ts');
expect(result.results[0].content).toBe(
'export function example() { return "test"; }',
);
expect(mockedAxios.post).toHaveBeenCalledTimes(1);
expect(mockGitApi.getItemContent).toHaveBeenCalledTimes(1);
expect(mockGitApi.getItemContent).toHaveBeenCalledWith(
'repo-id',
'/src/example.ts',
'TestProject',
undefined,
undefined,
undefined,
undefined,
false,
{
version: 'commit-hash',
versionType: GitVersionType.Commit,
},
true,
);
});
test('should not fetch content when includeContent is false', async () => {
// Arrange
const mockSearchResponse = {
data: {
count: 1,
results: [
{
fileName: 'example.ts',
path: '/src/example.ts',
matches: {
content: [
{
charOffset: 17,
length: 7,
},
],
},
collection: {
name: 'DefaultCollection',
},
project: {
name: 'TestProject',
id: 'project-id',
},
repository: {
name: 'TestRepo',
id: 'repo-id',
type: 'git',
},
versions: [
{
branchName: 'main',
changeId: 'commit-hash',
},
],
contentId: 'content-hash',
},
],
},
};
mockedAxios.post.mockResolvedValueOnce(mockSearchResponse);
// Act
const result = await searchCode(mockConnection, {
searchText: 'example',
projectId: 'TestProject',
includeContent: false,
});
// Assert
expect(result).toBeDefined();
expect(result.count).toBe(1);
expect(result.results).toHaveLength(1);
expect(result.results[0].fileName).toBe('example.ts');
expect(result.results[0].content).toBeUndefined();
expect(mockConnection.getGitApi).not.toHaveBeenCalled();
});
test('should handle empty search results', async () => {
// Arrange
const mockSearchResponse = {
data: {
count: 0,
results: [],
},
};
mockedAxios.post.mockResolvedValueOnce(mockSearchResponse);
// Act
const result = await searchCode(mockConnection, {
searchText: 'nonexistent',
projectId: 'TestProject',
});
// Assert
expect(result).toBeDefined();
expect(result.count).toBe(0);
expect(result.results).toHaveLength(0);
});
test('should handle API errors', async () => {
// Arrange
const axiosError = new Error('API Error');
(axiosError as any).isAxiosError = true;
(axiosError as any).response = {
status: 404,
data: {
message: 'Project not found',
},
};
mockedAxios.post.mockRejectedValueOnce(axiosError);
// Act & Assert
await expect(
searchCode(mockConnection, {
searchText: 'example',
projectId: 'NonExistentProject',
}),
).rejects.toThrow(AzureDevOpsError);
});
test('should propagate custom errors when thrown internally', async () => {
// Arrange
const customError = new AzureDevOpsError('Custom error');
// Mock axios to properly return the custom error
mockedAxios.post.mockImplementationOnce(() => {
throw customError;
});
// Act & Assert
await expect(
searchCode(mockConnection, {
searchText: 'example',
projectId: 'TestProject',
}),
).rejects.toThrow(AzureDevOpsError);
// Reset mock and set it up again for the second test
mockedAxios.post.mockReset();
mockedAxios.post.mockImplementationOnce(() => {
throw customError;
});
await expect(
searchCode(mockConnection, {
searchText: 'example',
projectId: 'TestProject',
}),
).rejects.toThrow('Custom error');
});
test('should apply filters when provided', async () => {
// Arrange
const mockSearchResponse = {
data: {
count: 1,
results: [
{
fileName: 'example.ts',
path: '/src/example.ts',
matches: {
content: [
{
charOffset: 17,
length: 7,
},
],
},
collection: {
name: 'DefaultCollection',
},
project: {
name: 'TestProject',
id: 'project-id',
},
repository: {
name: 'TestRepo',
id: 'repo-id',
type: 'git',
},
versions: [
{
branchName: 'main',
changeId: 'commit-hash',
},
],
contentId: 'content-hash',
},
],
},
};
mockedAxios.post.mockResolvedValueOnce(mockSearchResponse);
// Act
await searchCode(mockConnection, {
searchText: 'example',
projectId: 'TestProject',
filters: {
Repository: ['TestRepo'],
Path: ['/src'],
Branch: ['main'],
CodeElement: ['function'],
},
});
// Assert
expect(mockedAxios.post).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
filters: {
Project: ['TestProject'],
Repository: ['TestRepo'],
Path: ['/src'],
Branch: ['main'],
CodeElement: ['function'],
},
}),
expect.any(Object),
);
});
test('should handle pagination parameters', async () => {
// Arrange
const mockSearchResponse = {
data: {
count: 100,
results: Array(10)
.fill(0)
.map((_, i) => ({
fileName: `example${i}.ts`,
path: `/src/example${i}.ts`,
matches: {
content: [
{
charOffset: 17,
length: 7,
},
],
},
collection: {
name: 'DefaultCollection',
},
project: {
name: 'TestProject',
id: 'project-id',
},
repository: {
name: 'TestRepo',
id: 'repo-id',
type: 'git',
},
versions: [
{
branchName: 'main',
changeId: 'commit-hash',
},
],
contentId: `content-hash-${i}`,
})),
},
};
mockedAxios.post.mockResolvedValueOnce(mockSearchResponse);
// Act
await searchCode(mockConnection, {
searchText: 'example',
projectId: 'TestProject',
top: 10,
skip: 20,
});
// Assert
expect(mockedAxios.post).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
$top: 10,
$skip: 20,
}),
expect.any(Object),
);
});
test('should handle errors when fetching file content', async () => {
// Arrange
const mockSearchResponse = {
data: {
count: 1,
results: [
{
fileName: 'example.ts',
path: '/src/example.ts',
matches: {
content: [
{
charOffset: 17,
length: 7,
},
],
},
collection: {
name: 'DefaultCollection',
},
project: {
name: 'TestProject',
id: 'project-id',
},
repository: {
name: 'TestRepo',
id: 'repo-id',
type: 'git',
},
versions: [
{
branchName: 'main',
changeId: 'commit-hash',
},
],
contentId: 'content-hash',
},
],
},
};
mockedAxios.post.mockResolvedValueOnce(mockSearchResponse);
// Mock Git API to throw an error
const mockGitApi = {
getItemContent: jest
.fn()
.mockRejectedValue(new Error('Failed to fetch content')),
};
const mockConnectionWithError = {
...mockConnection,
getGitApi: jest.fn().mockResolvedValue(mockGitApi),
} as unknown as WebApi;
// Act
const result = await searchCode(mockConnectionWithError, {
searchText: 'example',
projectId: 'TestProject',
includeContent: true,
});
// Assert
expect(result).toBeDefined();
expect(result.count).toBe(1);
expect(result.results).toHaveLength(1);
// Content should be undefined when there's an error fetching it
expect(result.results[0].content).toBeUndefined();
});
test('should use default project when projectId is not provided', async () => {
// Arrange
// Set up environment variable for default project
const originalEnv = process.env.AZURE_DEVOPS_DEFAULT_PROJECT;
process.env.AZURE_DEVOPS_DEFAULT_PROJECT = 'DefaultProject';
const mockSearchResponse = {
data: {
count: 2,
results: [
{
fileName: 'example1.ts',
path: '/src/example1.ts',
matches: {
content: [
{
charOffset: 17,
length: 7,
},
],
},
collection: {
name: 'DefaultCollection',
},
project: {
name: 'DefaultProject',
id: 'default-project-id',
},
repository: {
name: 'Repo1',
id: 'repo-id-1',
type: 'git',
},
versions: [
{
branchName: 'main',
changeId: 'commit-hash-1',
},
],
contentId: 'content-hash-1',
},
{
fileName: 'example2.ts',
path: '/src/example2.ts',
matches: {
content: [
{
charOffset: 17,
length: 7,
},
],
},
collection: {
name: 'DefaultCollection',
},
project: {
name: 'DefaultProject',
id: 'default-project-id',
},
repository: {
name: 'Repo2',
id: 'repo-id-2',
type: 'git',
},
versions: [
{
branchName: 'main',
changeId: 'commit-hash-2',
},
],
contentId: 'content-hash-2',
},
],
},
};
mockedAxios.post.mockResolvedValueOnce(mockSearchResponse);
try {
// Act
const result = await searchCode(mockConnection, {
searchText: 'example',
includeContent: false,
});
// Assert
expect(result).toBeDefined();
expect(result.count).toBe(2);
expect(result.results).toHaveLength(2);
expect(result.results[0].project.name).toBe('DefaultProject');
expect(result.results[1].project.name).toBe('DefaultProject');
expect(mockedAxios.post).toHaveBeenCalledTimes(1);
expect(mockedAxios.post).toHaveBeenCalledWith(
expect.stringContaining(
'https://almsearch.dev.azure.com/testorg/DefaultProject/_apis/search/codesearchresults',
),
expect.objectContaining({
filters: expect.objectContaining({
Project: ['DefaultProject'],
}),
}),
expect.any(Object),
);
} finally {
// Restore original environment variable
process.env.AZURE_DEVOPS_DEFAULT_PROJECT = originalEnv;
}
});
test('should throw error when no projectId is provided and no default project is set', async () => {
// Arrange
// Ensure no default project is set
const originalEnv = process.env.AZURE_DEVOPS_DEFAULT_PROJECT;
process.env.AZURE_DEVOPS_DEFAULT_PROJECT = '';
try {
// Act & Assert
await expect(
searchCode(mockConnection, {
searchText: 'example',
includeContent: false,
}),
).rejects.toThrow('Project ID is required');
} finally {
// Restore original environment variable
process.env.AZURE_DEVOPS_DEFAULT_PROJECT = originalEnv;
}
});
test('should handle includeContent for different content types', async () => {
// Arrange
const mockSearchResponse = {
data: {
count: 4,
results: [
// Result 1 - Buffer content
{
fileName: 'example1.ts',
path: '/src/example1.ts',
matches: {
content: [
{
charOffset: 17,
length: 7,
},
],
},
collection: {
name: 'DefaultCollection',
},
project: {
name: 'TestProject',
id: 'project-id',
},
repository: {
name: 'TestRepo',
id: 'repo-id-1',
type: 'git',
},
versions: [
{
branchName: 'main',
changeId: 'commit-hash-1',
},
],
contentId: 'content-hash-1',
},
// Result 2 - String content
{
fileName: 'example2.ts',
path: '/src/example2.ts',
matches: {
content: [
{
charOffset: 17,
length: 7,
},
],
},
collection: {
name: 'DefaultCollection',
},
project: {
name: 'TestProject',
id: 'project-id',
},
repository: {
name: 'TestRepo',
id: 'repo-id-2',
type: 'git',
},
versions: [
{
branchName: 'main',
changeId: 'commit-hash-2',
},
],
contentId: 'content-hash-2',
},
// Result 3 - Object content
{
fileName: 'example3.ts',
path: '/src/example3.ts',
matches: {
content: [
{
charOffset: 17,
length: 7,
},
],
},
collection: {
name: 'DefaultCollection',
},
project: {
name: 'TestProject',
id: 'project-id',
},
repository: {
name: 'TestRepo',
id: 'repo-id-3',
type: 'git',
},
versions: [
{
branchName: 'main',
changeId: 'commit-hash-3',
},
],
contentId: 'content-hash-3',
},
// Result 4 - Uint8Array content
{
fileName: 'example4.ts',
path: '/src/example4.ts',
matches: {
content: [
{
charOffset: 17,
length: 7,
},
],
},
collection: {
name: 'DefaultCollection',
},
project: {
name: 'TestProject',
id: 'project-id',
},
repository: {
name: 'TestRepo',
id: 'repo-id-4',
type: 'git',
},
versions: [
{
branchName: 'main',
changeId: 'commit-hash-4',
},
],
contentId: 'content-hash-4',
},
],
},
};
mockedAxios.post.mockResolvedValueOnce(mockSearchResponse);
// Create mock contents for each type - all as streams, since that's what getItemContent returns
// These are all streams but with different content to demonstrate handling different data types from the stream
const createMockStream = (content: string) => ({
on: jest.fn().mockImplementation((event, callback) => {
if (event === 'data') {
callback(Buffer.from(content));
} else if (event === 'end') {
setTimeout(callback, 0);
}
return createMockStream(content); // Return this for chaining
}),
});
// Create four different mock streams with different content
const mockStream1 = createMockStream('Buffer content');
const mockStream2 = createMockStream('String content');
const mockStream3 = createMockStream(
JSON.stringify({ foo: 'bar', baz: 42 }),
);
const mockStream4 = createMockStream('hello');
// Mock Git API to return our different mock streams for each repository
const mockGitApi = {
getItemContent: jest
.fn()
.mockImplementationOnce(() => Promise.resolve(mockStream1))
.mockImplementationOnce(() => Promise.resolve(mockStream2))
.mockImplementationOnce(() => Promise.resolve(mockStream3))
.mockImplementationOnce(() => Promise.resolve(mockStream4)),
};
const mockConnectionWithStreams = {
...mockConnection,
getGitApi: jest.fn().mockResolvedValue(mockGitApi),
serverUrl: 'https://dev.azure.com/testorg',
} as unknown as WebApi;
// Act
const result = await searchCode(mockConnectionWithStreams, {
searchText: 'example',
projectId: 'TestProject',
includeContent: true,
});
// Assert
expect(result).toBeDefined();
expect(result.count).toBe(4);
expect(result.results).toHaveLength(4);
// Check each result has appropriate content from the streams
// Result 1 - Buffer content stream
expect(result.results[0].content).toBe('Buffer content');
// Result 2 - String content stream
expect(result.results[1].content).toBe('String content');
// Result 3 - JSON object content stream
expect(result.results[2].content).toBe('{"foo":"bar","baz":42}');
// Result 4 - Text content stream
expect(result.results[3].content).toBe('hello');
// Git API should have been called 4 times
expect(mockGitApi.getItemContent).toHaveBeenCalledTimes(4);
// Verify the parameters for the first call
expect(mockGitApi.getItemContent.mock.calls[0]).toEqual([
'repo-id-1',
'/src/example1.ts',
'TestProject',
undefined,
undefined,
undefined,
undefined,
false,
{
version: 'commit-hash-1',
versionType: GitVersionType.Commit,
},
true,
]);
});
test('should properly convert content stream to string', async () => {
// Arrange
const mockSearchResponse = {
data: {
count: 1,
results: [
{
fileName: 'example.ts',
path: '/src/example.ts',
matches: {
content: [
{
charOffset: 17,
length: 7,
},
],
},
collection: {
name: 'DefaultCollection',
},
project: {
name: 'TestProject',
id: 'project-id',
},
repository: {
name: 'TestRepo',
id: 'repo-id',
type: 'git',
},
versions: [
{
branchName: 'main',
changeId: 'commit-hash',
},
],
contentId: 'content-hash',
},
],
},
};
mockedAxios.post.mockResolvedValueOnce(mockSearchResponse);
// Create a mock ReadableStream
const mockContent = 'This is the file content';
// Create a simplified mock stream that emits the content
const mockStream = {
on: jest.fn().mockImplementation((event, callback) => {
if (event === 'data') {
// Call the callback with the data
callback(Buffer.from(mockContent));
} else if (event === 'end') {
// Call the end callback asynchronously
setTimeout(callback, 0);
}
return mockStream; // Return this for chaining
}),
};
// Mock Git API to return our mock stream
const mockGitApi = {
getItemContent: jest.fn().mockResolvedValue(mockStream),
};
const mockConnectionWithStream = {
...mockConnection,
getGitApi: jest.fn().mockResolvedValue(mockGitApi),
serverUrl: 'https://dev.azure.com/testorg',
} as unknown as WebApi;
// Act
const result = await searchCode(mockConnectionWithStream, {
searchText: 'example',
projectId: 'TestProject',
includeContent: true,
});
// Assert
expect(result).toBeDefined();
expect(result.count).toBe(1);
expect(result.results).toHaveLength(1);
// Check that the content was properly converted from stream to string
expect(result.results[0].content).toBe(mockContent);
// Verify the stream event handlers were attached
expect(mockStream.on).toHaveBeenCalledWith('data', expect.any(Function));
expect(mockStream.on).toHaveBeenCalledWith('end', expect.any(Function));
expect(mockStream.on).toHaveBeenCalledWith('error', expect.any(Function));
// Verify the parameters for getItemContent
expect(mockGitApi.getItemContent).toHaveBeenCalledWith(
'repo-id',
'/src/example.ts',
'TestProject',
undefined,
undefined,
undefined,
undefined,
false,
{
version: 'commit-hash',
versionType: GitVersionType.Commit,
},
true,
);
});
test('should limit top to 10 when includeContent is true', async () => {
// Arrange
const mockSearchResponse = {
data: {
count: 10,
results: Array(10)
.fill(0)
.map((_, i) => ({
fileName: `example${i}.ts`,
path: `/src/example${i}.ts`,
matches: {
content: [
{
charOffset: 17,
length: 7,
},
],
},
collection: {
name: 'DefaultCollection',
},
project: {
name: 'TestProject',
id: 'project-id',
},
repository: {
name: 'TestRepo',
id: 'repo-id',
type: 'git',
},
versions: [
{
branchName: 'main',
changeId: 'commit-hash',
},
],
contentId: `content-hash-${i}`,
})),
},
};
mockedAxios.post.mockResolvedValueOnce(mockSearchResponse);
// For this test, we don't need to mock the Git API since we're only testing the top parameter
// We'll create a connection that doesn't have includeContent functionality
const mockConnectionWithoutContent = {
...mockConnection,
getGitApi: jest.fn().mockImplementation(() => {
throw new Error('Git API not available');
}),
serverUrl: 'https://dev.azure.com/testorg',
} as unknown as WebApi;
// Act
await searchCode(mockConnectionWithoutContent, {
searchText: 'example',
projectId: 'TestProject',
top: 50, // User tries to get 50 results
includeContent: true, // But includeContent is true
});
// Assert
expect(mockedAxios.post).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
$top: 10, // Should be limited to 10
}),
expect.any(Object),
);
});
test('should not limit top when includeContent is false', async () => {
// Arrange
const mockSearchResponse = {
data: {
count: 50,
results: Array(50)
.fill(0)
.map((_, i) => ({
// ... simplified result object
fileName: `example${i}.ts`,
})),
},
};
mockedAxios.post.mockResolvedValueOnce(mockSearchResponse);
// Act
await searchCode(mockConnection, {
searchText: 'example',
projectId: 'TestProject',
top: 50, // User wants 50 results
includeContent: false, // includeContent is false
});
// Assert
expect(mockedAxios.post).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
$top: 50, // Should use requested value
}),
expect.any(Object),
);
});
});