/**
* @vitest-environment node
*/
import { describe, it, expect, beforeEach, afterAll, vi } from 'vitest';
import nock from 'nock';
import { BaseDeepSourceClient } from '../../client/base-client.js';
import { GraphQLResponse } from '../../types/graphql-responses.js';
import { PaginationParams, PaginatedResponse } from '../../utils/pagination/types.js';
// Extend the BaseDeepSourceClient to expose the protected methods
class TestableBaseClient extends BaseDeepSourceClient {
// Expose protected methods for testing
async testExecuteGraphQL<T>(
query: string,
variables?: Record<string, unknown>
): Promise<GraphQLResponse<T>> {
return this.executeGraphQL(query, variables);
}
async testExecuteGraphQLMutation<T>(
mutation: string,
variables?: Record<string, unknown>
): Promise<T> {
return this.executeGraphQLMutation(mutation, variables);
}
async testFindProjectByKey(projectKey: string) {
return this.findProjectByKey(projectKey);
}
async testFetchWithPagination<T>(
fetcher: (params: PaginationParams) => Promise<PaginatedResponse<T>>,
params: PaginationParams
): Promise<PaginatedResponse<T>> {
return this.fetchWithPagination<T>(fetcher, params);
}
// skipcq: JS-0105 - Test helper method calling static method
testNormalizePaginationParams(params: Record<string, unknown>) {
return BaseDeepSourceClient.normalizePaginationParams(params);
}
// skipcq: JS-0105 - Test helper method calling static method
testCreateEmptyPaginatedResponse<T>() {
return BaseDeepSourceClient.createEmptyPaginatedResponse<T>();
}
// skipcq: JS-0105 - Test helper method calling static method
testExtractErrorMessages(errors: Array<{ message: string }>) {
return BaseDeepSourceClient.extractErrorMessages(errors);
}
}
describe('BaseDeepSourceClient', () => {
const API_KEY = 'test-api-key';
const API_URL = 'https://api.deepsource.io';
beforeEach(() => {
nock.cleanAll();
});
afterAll(() => {
nock.restore();
});
describe('constructor', () => {
it('should throw an error when API key is not provided', () => {
expect(() => new BaseDeepSourceClient('')).toThrow('DeepSource API key is required');
expect(() => new BaseDeepSourceClient(null as unknown as string)).toThrow(
'DeepSource API key is required'
);
expect(() => new BaseDeepSourceClient(undefined as unknown as string)).toThrow(
'DeepSource API key is required'
);
});
});
describe('executeGraphQL', () => {
it('should execute a GraphQL query successfully', async () => {
// Setup
const client = new TestableBaseClient(API_KEY);
const query = 'query { viewer { email } }';
const mockResponseData = {
data: {
viewer: {
email: 'test@example.com',
},
},
};
// Mock API response
nock(API_URL).post('/graphql/', { query }).reply(200, { data: mockResponseData });
// Execute
const result = await client.testExecuteGraphQL(query);
// Verify
expect(result).toEqual({ data: mockResponseData });
});
it('should throw an error when GraphQL response contains errors', async () => {
// Setup
const client = new TestableBaseClient(API_KEY);
const query = 'query { invalidField }';
const mockErrors = [{ message: "Field invalidField doesn't exist" }];
// Mock API response
nock(API_URL).post('/graphql/', { query }).reply(200, { errors: mockErrors });
// Execute and verify
await expect(client.testExecuteGraphQL(query)).rejects.toThrow(/GraphQL Errors/);
});
it('should handle network errors', async () => {
// Setup
const client = new TestableBaseClient(API_KEY);
const query = 'query { viewer { email } }';
// Mock network error
nock(API_URL)
.post('/graphql/', { query })
.replyWithError('Network error: Connection refused');
// Execute and verify
await expect(client.testExecuteGraphQL(query)).rejects.toThrow(/Network error/);
});
it('should handle HTTP error responses', async () => {
// Setup
const client = new TestableBaseClient(API_KEY);
const query = 'query { viewer { email } }';
// Mock HTTP error
nock(API_URL).post('/graphql/', { query }).reply(401, { message: 'Unauthorized' });
// Execute and verify
await expect(client.testExecuteGraphQL(query)).rejects.toThrow(/Authentication error/);
});
});
describe('executeGraphQLMutation', () => {
it('should execute a GraphQL mutation successfully', async () => {
// Setup
const client = new TestableBaseClient(API_KEY);
const mutation = 'mutation { updateProject(id: "123") { id } }';
const mockResponseData = {
data: {
updateProject: {
id: '123',
},
},
};
// Mock API response
nock(API_URL).post('/graphql/', { query: mutation }).reply(200, { data: mockResponseData });
// Execute
const result = await client.testExecuteGraphQLMutation(mutation);
// Verify
expect(result).toEqual({ data: mockResponseData });
});
it('should throw an error when GraphQL mutation response contains errors', async () => {
// Setup
const client = new TestableBaseClient(API_KEY);
const mutation = 'mutation { updateProject(id: "123") { id } }';
const mockErrors = [{ message: 'Permission denied' }];
// Mock API response
nock(API_URL).post('/graphql/', { query: mutation }).reply(200, { errors: mockErrors });
// Execute and verify
await expect(client.testExecuteGraphQLMutation(mutation)).rejects.toThrow(/GraphQL Errors/);
});
it('should handle timeout errors', async () => {
// Setup
const client = new TestableBaseClient(API_KEY, {
timeout: 100, // Very short timeout
});
const mutation = 'mutation { updateProject(id: "123") { id } }';
// Mock a delayed response to trigger timeout
nock(API_URL)
.post('/graphql/', { query: mutation })
.delayConnection(200) // Delay longer than timeout
.reply(200, { data: { updateProject: { id: '123' } } });
// Execute and verify
await expect(client.testExecuteGraphQLMutation(mutation)).rejects.toThrow(/timeout/i);
});
});
describe('findProjectByKey', () => {
it('should return a project for a valid project key', async () => {
const client = new TestableBaseClient(API_KEY);
const projectKey = 'organization/repository';
const result = await client.testFindProjectByKey(projectKey);
expect(result).not.toBeNull();
expect(result?.key).toBe(projectKey);
expect(result?.name).toBe('Project');
expect(result?.repository.login).toBe('organization');
expect(result?.repository.name).toBe('repository');
});
it('should handle simple project keys without slash', async () => {
const client = new TestableBaseClient(API_KEY);
const projectKey = 'simple-project';
const result = await client.testFindProjectByKey(projectKey);
expect(result).not.toBeNull();
expect(result?.key).toBe(projectKey);
// When there's no slash, split returns the full key for both parts
expect(result?.repository.login).toBe('simple-project');
expect(result?.repository.name).toBe('unknown');
});
it('should handle normal processing without errors', async () => {
const client = new TestableBaseClient(API_KEY);
const projectKey = 'test/project';
const result = await client.testFindProjectByKey(projectKey);
expect(result).not.toBeNull();
expect(result?.key).toBe(projectKey);
expect(result?.repository.login).toBe('test');
expect(result?.repository.name).toBe('project');
});
});
describe('normalizePaginationParams', () => {
const client = new TestableBaseClient(API_KEY);
it('should normalize offset to non-negative integer', () => {
const result = client.testNormalizePaginationParams({ offset: -5.7 });
expect(result.offset).toBe(0);
const result2 = client.testNormalizePaginationParams({ offset: 10.9 });
expect(result2.offset).toBe(10);
});
it('should normalize first to positive integer', () => {
const result = client.testNormalizePaginationParams({ first: -5 });
expect(result.first).toBe(1);
const result2 = client.testNormalizePaginationParams({ first: 15.7 });
expect(result2.first).toBe(15);
});
it('should normalize last to positive integer', () => {
const result = client.testNormalizePaginationParams({ last: -3 });
expect(result.last).toBe(1);
const result2 = client.testNormalizePaginationParams({ last: 20.2 });
expect(result2.last).toBe(20);
});
it('should convert after and before to strings when they are not deleted by pagination logic', () => {
// Test after conversion (before cursor logic doesn't apply here)
const result1 = client.testNormalizePaginationParams({
after: 123,
});
expect(result1.after).toBe('123');
expect(result1.first).toBe(10); // default value
// Test before conversion with empty string (null converts to empty string but empty string is falsy)
const result2 = client.testNormalizePaginationParams({
before: 'some-cursor',
});
expect(result2.before).toBe('some-cursor');
expect(result2.last).toBe(10); // default value when before is truthy
});
it('should handle before cursor pagination precedence', () => {
const result = client.testNormalizePaginationParams({
before: 'cursor123',
first: 10,
after: 'cursor456',
});
expect(result.before).toBe('cursor123');
expect(result.last).toBe(10);
expect(result.first).toBeUndefined();
expect(result.after).toBeUndefined();
});
it('should use last value when before is provided without first or last', () => {
const result = client.testNormalizePaginationParams({
before: 'cursor123',
});
expect(result.before).toBe('cursor123');
expect(result.last).toBe(10); // default value
});
it('should handle pagination with both after and before (before takes precedence)', () => {
const result = client.testNormalizePaginationParams({
after: 'cursor456',
last: 5,
before: 'cursor789',
});
// before logic wins due to if/else if structure - before is preserved, after/first are deleted
expect(result.before).toBe('cursor789');
expect(result.last).toBe(5); // existing value preserved
expect(result.first).toBeUndefined();
expect(result.after).toBeUndefined();
});
it('should use existing first value when after is provided', () => {
const result = client.testNormalizePaginationParams({
after: 'cursor456',
first: 25,
});
expect(result.after).toBe('cursor456');
expect(result.first).toBe(25); // preserved existing value
});
it('should preserve params when no cursor-based pagination is used', () => {
const result = client.testNormalizePaginationParams({
first: 15,
offset: 20,
});
expect(result.first).toBe(15);
expect(result.offset).toBe(20);
expect(result.after).toBeUndefined();
expect(result.before).toBeUndefined();
});
});
describe('createEmptyPaginatedResponse', () => {
it('should create an empty paginated response with correct structure', () => {
const client = new TestableBaseClient(API_KEY);
const result = client.testCreateEmptyPaginatedResponse();
expect(result).toEqual({
items: [],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: undefined,
endCursor: undefined,
},
totalCount: 0,
});
});
});
describe('extractErrorMessages', () => {
it('should extract single error message', () => {
const client = new TestableBaseClient(API_KEY);
const errors = [{ message: 'Single error' }];
const result = client.testExtractErrorMessages(errors);
expect(result).toBe('Single error');
});
it('should extract multiple error messages joined by semicolon', () => {
const client = new TestableBaseClient(API_KEY);
const errors = [
{ message: 'First error' },
{ message: 'Second error' },
{ message: 'Third error' },
];
const result = client.testExtractErrorMessages(errors);
expect(result).toBe('First error; Second error; Third error');
});
it('should handle empty errors array', () => {
const client = new TestableBaseClient(API_KEY);
const errors: Array<{ message: string }> = [];
const result = client.testExtractErrorMessages(errors);
expect(result).toBe('');
});
});
describe('fetchWithPagination', () => {
let client: TestableBaseClient;
beforeEach(() => {
client = new TestableBaseClient(API_KEY);
});
it('should fetch single page when max_pages is not provided', async () => {
const mockFetcher = vi.fn().mockResolvedValue({
items: ['item1', 'item2'],
pageInfo: {
hasNextPage: true,
hasPreviousPage: false,
endCursor: 'cursor1',
},
totalCount: 10,
});
const result = await client.testFetchWithPagination(mockFetcher, {
first: 2,
});
expect(result.items).toEqual(['item1', 'item2']);
expect(mockFetcher).toHaveBeenCalledOnce();
expect(mockFetcher).toHaveBeenCalledWith({ first: 2 });
});
it('should fetch multiple pages when max_pages is provided', async () => {
const page1 = {
items: ['item1', 'item2'],
pageInfo: {
hasNextPage: true,
hasPreviousPage: false,
endCursor: 'cursor1',
},
totalCount: 5,
};
const page2 = {
items: ['item3', 'item4'],
pageInfo: {
hasNextPage: true,
hasPreviousPage: true,
startCursor: 'cursor1',
endCursor: 'cursor2',
},
totalCount: 5,
};
const page3 = {
items: ['item5'],
pageInfo: {
hasNextPage: false,
hasPreviousPage: true,
startCursor: 'cursor2',
},
totalCount: 5,
};
const mockFetcher = vi
.fn()
.mockResolvedValueOnce(page1)
.mockResolvedValueOnce(page2)
.mockResolvedValueOnce(page3);
const result = await client.testFetchWithPagination(mockFetcher, {
first: 2,
max_pages: 5,
});
expect(result.items).toEqual(['item1', 'item2', 'item3', 'item4', 'item5']);
expect(result.pageInfo.hasNextPage).toBe(false);
expect(mockFetcher).toHaveBeenCalledTimes(3);
});
it('should handle page_size alias', async () => {
const mockFetcher = vi.fn().mockResolvedValue({
items: ['item1'],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
},
totalCount: 1,
});
await client.testFetchWithPagination(mockFetcher, {
page_size: 10,
});
expect(mockFetcher).toHaveBeenCalledWith({ first: 10 });
});
it('should return correct structure when max_pages is 1', async () => {
const mockFetcher = vi.fn().mockResolvedValue({
items: ['item1', 'item2'],
pageInfo: {
hasNextPage: true,
hasPreviousPage: false,
endCursor: 'cursor1',
},
totalCount: 10,
});
const result = await client.testFetchWithPagination(mockFetcher, {
first: 2,
max_pages: 1,
});
expect(result.items).toEqual(['item1', 'item2']);
expect(result.pageInfo.hasNextPage).toBe(true);
expect(result.pageInfo.endCursor).toBe('cursor1');
expect(result.totalCount).toBe(10);
});
it('should handle errors during multi-page fetching', async () => {
const mockFetcher = vi
.fn()
.mockResolvedValueOnce({
items: ['item1'],
pageInfo: {
hasNextPage: true,
hasPreviousPage: false,
endCursor: 'cursor1',
},
totalCount: 3,
})
.mockRejectedValueOnce(new Error('Network error'));
await expect(
client.testFetchWithPagination(mockFetcher, {
first: 1,
max_pages: 3,
})
).rejects.toThrow('Network error');
expect(mockFetcher).toHaveBeenCalledTimes(2);
});
it('should include endCursor in pageInfo when multi-page fetch has lastCursor', async () => {
// Mock the fetchMultiplePages to return a result with lastCursor
const mockFetcher = vi.fn();
// We need to test the actual implementation, so let's mock fetchMultiplePages directly
// First, let's test with real multi-page fetching behavior
const page1 = {
items: ['item1', 'item2'],
pageInfo: {
hasNextPage: true,
hasPreviousPage: false,
endCursor: 'cursor1',
},
totalCount: 4,
};
const page2 = {
items: ['item3', 'item4'],
pageInfo: {
hasNextPage: false,
hasPreviousPage: true,
startCursor: 'cursor1',
endCursor: 'cursor2',
},
totalCount: 4,
};
mockFetcher.mockResolvedValueOnce(page1).mockResolvedValueOnce(page2);
const result = await client.testFetchWithPagination(mockFetcher, {
first: 2,
max_pages: 3,
});
// The result should have the endCursor from the last page
expect(result.pageInfo.endCursor).toBe('cursor2');
expect(result.items).toEqual(['item1', 'item2', 'item3', 'item4']);
expect(result.pageInfo.hasNextPage).toBe(false);
expect(mockFetcher).toHaveBeenCalledTimes(2);
});
});
});