/**
* Tests for search_companies tool
*
* This demonstrates testing a complete tool using:
* - MockAttioClient (to simulate API)
* - Fixtures (for test data)
* - Environment variables (API key)
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { handleSearchCompanies } from '../../../src/tools/search-companies.js';
import { createMockAttioClient } from '../../helpers/mock-attio-client.js';
import { loadFixture } from '../../helpers/fixtures.js';
import * as attioClientModule from '../../../src/attio-client.js';
// Mock the attio-client module
vi.mock('../../../src/attio-client.js', () => ({
createAttioClient: vi.fn(),
}));
describe('search_companies', () => {
let mockClient: ReturnType<typeof createMockAttioClient>;
let originalApiKey: string | undefined;
beforeEach(() => {
// Save original API key
originalApiKey = process.env.ATTIO_API_KEY;
// Set API key for tests
process.env.ATTIO_API_KEY = 'test-api-key';
// Create fresh mock client
mockClient = createMockAttioClient();
// Mock createAttioClient to return our mock
vi.mocked(attioClientModule.createAttioClient).mockReturnValue(
mockClient as any
);
});
afterEach(() => {
// Restore original API key
if (originalApiKey) {
process.env.ATTIO_API_KEY = originalApiKey;
} else {
delete process.env.ATTIO_API_KEY;
}
// Reset mock
mockClient.reset();
});
describe('Successful searches', () => {
it('should search companies by name', async () => {
// Load test data from fixture
const companyData = loadFixture('companies.json', 'acme-corp');
// Configure mock to return this data
mockClient.mockResponse('POST', '/objects/companies/records/query', {
data: { data: [companyData] },
});
// Execute the search
const result = await handleSearchCompanies({ query: 'Acme' });
// Parse the response
const parsed = JSON.parse(result.content[0].text);
// Verify success
expect(parsed.success).toBe(true);
expect(parsed.data.filters.query).toBe('Acme');
expect(parsed.data.count).toBe(1);
expect(parsed.data.companies).toHaveLength(1);
expect(parsed.data.companies[0].name).toBe('Acme Corporation');
});
it('should search companies by domain', async () => {
const companyData = loadFixture('companies.json', 'acme-corp');
mockClient.mockResponse('POST', '/objects/companies/records/query', {
data: { data: [companyData] },
});
const result = await handleSearchCompanies({ query: 'acme.com' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
expect(parsed.data.companies[0].domains).toContain('acme.com');
});
it('should return multiple companies', async () => {
const companies = loadFixture('companies.json');
mockClient.mockResponse('POST', '/objects/companies/records/query', {
data: {
data: [companies['acme-corp'], companies['tech-startup']],
},
});
const result = await handleSearchCompanies({ query: 'tech' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.data.count).toBe(2);
expect(parsed.data.companies).toHaveLength(2);
});
it('should return empty array when no matches found', async () => {
mockClient.mockResponse('POST', '/objects/companies/records/query', {
data: { data: [] },
});
const result = await handleSearchCompanies({ query: 'nonexistent' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
expect(parsed.data.count).toBe(0);
expect(parsed.data.companies).toEqual([]);
expect(parsed.data.has_more).toBe(false);
});
it('should respect custom limit parameter', async () => {
const companyData = loadFixture('companies.json', 'acme-corp');
mockClient.mockResponse('POST', '/objects/companies/records/query', {
data: { data: [companyData] },
});
const result = await handleSearchCompanies({ query: 'test', limit: 10 });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.data.limit).toBe(10);
// Verify the API was called with correct limit
const calls = mockClient.getCallsFor('POST', '/objects/companies/records/query');
expect(calls[0].body).toMatchObject({ limit: 10 });
});
it('should use default limit of 50', async () => {
mockClient.mockResponse('POST', '/objects/companies/records/query', {
data: { data: [] },
});
const result = await handleSearchCompanies({ query: 'test' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.data.limit).toBe(50);
});
it('should cap limit at 500', async () => {
mockClient.mockResponse('POST', '/objects/companies/records/query', {
data: { data: [] },
});
const result = await handleSearchCompanies({ query: 'test', limit: 9999 });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.data.limit).toBe(500); // Capped at max
});
it('should enforce minimum limit of 1', async () => {
mockClient.mockResponse('POST', '/objects/companies/records/query', {
data: { data: [] },
});
const result = await handleSearchCompanies({ query: 'test', limit: 0 });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.data.limit).toBe(1); // Minimum enforced
});
it('should set has_more flag when result count equals limit', async () => {
const companyData = loadFixture('companies.json', 'acme-corp');
mockClient.mockResponse('POST', '/objects/companies/records/query', {
data: { data: [companyData] },
});
const result = await handleSearchCompanies({ query: 'test', limit: 1 });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.data.has_more).toBe(true); // 1 result = 1 limit
});
it('should include company record_id in results', async () => {
const companyData = loadFixture('companies.json', 'acme-corp');
mockClient.mockResponse('POST', '/objects/companies/records/query', {
data: { data: [companyData] },
});
const result = await handleSearchCompanies({ query: 'Acme' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.data.companies[0].record_id).toBe('company-acme-123');
});
it('should handle companies with minimal data', async () => {
const minimalCompany = loadFixture('companies.json', 'minimal-company');
mockClient.mockResponse('POST', '/objects/companies/records/query', {
data: { data: [minimalCompany] },
});
const result = await handleSearchCompanies({ query: 'minimal' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
expect(parsed.data.companies[0].name).toBe('Minimal Corp');
expect(parsed.data.companies[0].domains).toEqual([]);
expect(parsed.data.companies[0].description).toBeNull();
});
});
describe('Validation errors', () => {
it('should fail if ATTIO_API_KEY is not configured', async () => {
// Remove API key
delete process.env.ATTIO_API_KEY;
const result = await handleSearchCompanies({ query: 'test' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(false);
expect(parsed.error.type).toBe('validation_error');
expect(parsed.error.message).toContain('ATTIO_API_KEY');
});
});
describe('Optional query (list all)', () => {
it('should list all companies when no query provided', async () => {
const companyData = loadFixture('companies.json', 'acme-corp');
mockClient.mockResponse('POST', '/objects/companies/records/query', {
data: { data: [companyData] },
});
const result = await handleSearchCompanies({});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
expect(parsed.data.filters.query).toBeNull();
// Verify no filter was sent
const calls = mockClient.getCallsFor('POST', '/objects/companies/records/query');
expect(calls[0].body.filter).toBeUndefined();
});
it('should list all companies when query is empty string', async () => {
mockClient.mockResponse('POST', '/objects/companies/records/query', {
data: { data: [] },
});
const result = await handleSearchCompanies({ query: '' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
expect(parsed.data.filters.query).toBeNull();
});
it('should list all companies when query is whitespace', async () => {
mockClient.mockResponse('POST', '/objects/companies/records/query', {
data: { data: [] },
});
const result = await handleSearchCompanies({ query: ' ' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
});
});
describe('Sorting', () => {
it('should sort by created_at descending by default', async () => {
mockClient.mockResponse('POST', '/objects/companies/records/query', {
data: { data: [] },
});
const result = await handleSearchCompanies({ sort_by: 'created_at' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.data.sort).toEqual({ by: 'created_at', direction: 'desc' });
const calls = mockClient.getCallsFor('POST', '/objects/companies/records/query');
expect(calls[0].body.sorts).toEqual([{ attribute: 'created_at', direction: 'desc' }]);
});
it('should sort ascending when specified', async () => {
mockClient.mockResponse('POST', '/objects/companies/records/query', {
data: { data: [] },
});
const result = await handleSearchCompanies({ sort_by: 'name', sort_direction: 'asc' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.data.sort).toEqual({ by: 'name', direction: 'asc' });
const calls = mockClient.getCallsFor('POST', '/objects/companies/records/query');
expect(calls[0].body.sorts).toEqual([{ attribute: 'name', direction: 'asc' }]);
});
it('should not include sorts when sort_by not provided', async () => {
mockClient.mockResponse('POST', '/objects/companies/records/query', {
data: { data: [] },
});
const result = await handleSearchCompanies({});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.data.sort).toBeNull();
const calls = mockClient.getCallsFor('POST', '/objects/companies/records/query');
expect(calls[0].body.sorts).toBeUndefined();
});
});
describe('Date range filters', () => {
it('should filter by created_after', async () => {
mockClient.mockResponse('POST', '/objects/companies/records/query', {
data: { data: [] },
});
await handleSearchCompanies({ created_after: '2024-01-01' });
const calls = mockClient.getCallsFor('POST', '/objects/companies/records/query');
expect(calls[0].body.filter).toMatchObject({
created_at: { $gte: '2024-01-01' },
});
});
it('should filter by created_before', async () => {
mockClient.mockResponse('POST', '/objects/companies/records/query', {
data: { data: [] },
});
await handleSearchCompanies({ created_before: '2024-12-31' });
const calls = mockClient.getCallsFor('POST', '/objects/companies/records/query');
expect(calls[0].body.filter).toMatchObject({
created_at: { $lte: '2024-12-31' },
});
});
it('should combine date filters with query using $and', async () => {
mockClient.mockResponse('POST', '/objects/companies/records/query', {
data: { data: [] },
});
await handleSearchCompanies({
query: 'Acme',
created_after: '2024-01-01',
created_before: '2024-12-31',
});
const calls = mockClient.getCallsFor('POST', '/objects/companies/records/query');
expect(calls[0].body.filter.$and).toBeDefined();
expect(calls[0].body.filter.$and).toHaveLength(3);
});
});
describe('Response includes web_url and created_at', () => {
it('should include web_url in results', async () => {
const companyData = loadFixture('companies.json', 'acme-corp');
mockClient.mockResponse('POST', '/objects/companies/records/query', {
data: { data: [companyData] },
});
const result = await handleSearchCompanies({ query: 'Acme' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.data.companies[0].web_url).toBe(
'https://app.attio.com/workspace/companies/company-acme-123'
);
});
it('should include created_at in results', async () => {
const companyData = {
...loadFixture('companies.json', 'acme-corp'),
created_at: '2024-06-15T10:30:00Z',
};
mockClient.mockResponse('POST', '/objects/companies/records/query', {
data: { data: [companyData] },
});
const result = await handleSearchCompanies({ query: 'Acme' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.data.companies[0].created_at).toBe('2024-06-15T10:30:00Z');
});
});
describe('API errors', () => {
it('should handle API errors gracefully', async () => {
const errorResponse = loadFixture('api-errors.json', 'not-found');
mockClient.mockResponse('POST', '/objects/companies/records/query', {
error: errorResponse.error,
});
const result = await handleSearchCompanies({ query: 'test' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(false);
expect(parsed.error).toBeDefined();
});
it('should include helpful error suggestions', async () => {
const errorResponse = loadFixture('api-errors.json', 'authentication');
mockClient.mockResponse('POST', '/objects/companies/records/query', {
error: errorResponse.error,
});
const result = await handleSearchCompanies({ query: 'test' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.error.suggestions).toBeDefined();
expect(parsed.error.suggestions.length).toBeGreaterThan(0);
});
});
describe('Search filter', () => {
it('should search using $or filter on name and domains', async () => {
mockClient.mockResponse('POST', '/objects/companies/records/query', {
data: { data: [] },
});
await handleSearchCompanies({ query: 'test-query' });
// Verify the API was called with correct filter
const calls = mockClient.getCallsFor('POST', '/objects/companies/records/query');
expect(calls).toHaveLength(1);
expect(calls[0].body).toMatchObject({
filter: {
$or: [
{ name: { value: { $contains: 'test-query' } } },
{ domains: { domain: { $contains: 'test-query' } } },
],
},
});
});
});
});