/**
* Tests for search_people tool
*
* Tests search with $contains filter on name and email fields.
* Demonstrates limit handling and pagination via has_more flag.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { handleSearchPeople } from '../../../src/tools/search-people.js';
import { createMockAttioClient } from '../../helpers/mock-attio-client.js';
import { loadFixture } from '../../helpers/fixtures.js';
import * as attioClientModule from '../../../src/attio-client.js';
vi.mock('../../../src/attio-client.js', () => ({
createAttioClient: vi.fn(),
}));
describe('search_people', () => {
let mockClient: ReturnType<typeof createMockAttioClient>;
let originalApiKey: string | undefined;
beforeEach(() => {
originalApiKey = process.env.ATTIO_API_KEY;
process.env.ATTIO_API_KEY = 'test-api-key';
mockClient = createMockAttioClient();
vi.mocked(attioClientModule.createAttioClient).mockReturnValue(
mockClient as any
);
});
afterEach(() => {
if (originalApiKey) {
process.env.ATTIO_API_KEY = originalApiKey;
} else {
delete process.env.ATTIO_API_KEY;
}
mockClient.reset();
});
describe('Successful searches', () => {
it('should search people by name', async () => {
const personData = loadFixture('people.json', 'john-doe');
mockClient.mockResponse('POST', '/objects/people/records/query', {
data: { data: [personData] },
});
const result = await handleSearchPeople({ query: 'John' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
expect(parsed.data.count).toBe(1);
expect(parsed.data.people[0].name).toBe('John Doe');
expect(parsed.data.people[0].record_id).toBe('person-john-123');
});
it('should search people by email', async () => {
const personData = loadFixture('people.json', 'john-doe');
mockClient.mockResponse('POST', '/objects/people/records/query', {
data: { data: [personData] },
});
const result = await handleSearchPeople({ query: 'john@acme.com' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
expect(parsed.data.count).toBe(1);
expect(parsed.data.people[0].email_addresses).toContain('john@acme.com');
});
it('should return multiple results', async () => {
const john = loadFixture('people.json', 'john-doe');
const jane = loadFixture('people.json', 'jane-smith');
mockClient.mockResponse('POST', '/objects/people/records/query', {
data: { data: [john, jane] },
});
const result = await handleSearchPeople({ query: 'J' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
expect(parsed.data.count).toBe(2);
expect(parsed.data.people).toHaveLength(2);
});
it('should return empty results when no matches', async () => {
mockClient.mockResponse('POST', '/objects/people/records/query', {
data: { data: [] },
});
const result = await handleSearchPeople({ query: 'NonexistentName' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
expect(parsed.data.count).toBe(0);
expect(parsed.data.people).toEqual([]);
expect(parsed.data.has_more).toBe(false);
});
it('should use default limit of 50', async () => {
mockClient.mockResponse('POST', '/objects/people/records/query', {
data: { data: [] },
});
const result = await handleSearchPeople({ query: 'test' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.data.limit).toBe(50);
const calls = mockClient.getCallsFor('POST', '/objects/people/records/query');
expect(calls[0].body.limit).toBe(50);
});
it('should use custom limit', async () => {
mockClient.mockResponse('POST', '/objects/people/records/query', {
data: { data: [] },
});
const result = await handleSearchPeople({ query: 'test', limit: 10 });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.data.limit).toBe(10);
const calls = mockClient.getCallsFor('POST', '/objects/people/records/query');
expect(calls[0].body.limit).toBe(10);
});
it('should cap limit at 500', async () => {
mockClient.mockResponse('POST', '/objects/people/records/query', {
data: { data: [] },
});
const result = await handleSearchPeople({ query: 'test', limit: 1000 });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.data.limit).toBe(500);
});
it('should enforce minimum limit of 1', async () => {
mockClient.mockResponse('POST', '/objects/people/records/query', {
data: { data: [] },
});
const result = await handleSearchPeople({ query: 'test', limit: 0 });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.data.limit).toBe(1);
});
it('should set has_more to true when results equal limit', async () => {
const people = Array(50)
.fill(null)
.map((_, i) => ({
id: {
workspace_id: 'ws-123',
object_id: 'people',
record_id: `person-${i}`,
},
values: {
name: [{ first_name: `Person${i}`, last_name: 'Test', full_name: `Person${i} Test` }],
},
}));
mockClient.mockResponse('POST', '/objects/people/records/query', {
data: { data: people },
});
const result = await handleSearchPeople({ query: 'Person', limit: 50 });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.data.count).toBe(50);
expect(parsed.data.has_more).toBe(true);
});
it('should set has_more to false when results less than limit', async () => {
const personData = loadFixture('people.json', 'john-doe');
mockClient.mockResponse('POST', '/objects/people/records/query', {
data: { data: [personData] },
});
const result = await handleSearchPeople({ query: 'John', limit: 50 });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.data.count).toBe(1);
expect(parsed.data.has_more).toBe(false);
});
it('should use POST method with correct filter structure', async () => {
mockClient.mockResponse('POST', '/objects/people/records/query', {
data: { data: [] },
});
await handleSearchPeople({ query: 'test' });
const calls = mockClient.getCallsFor('POST', '/objects/people/records/query');
expect(calls).toHaveLength(1);
expect(calls[0].body.filter).toMatchObject({
$or: [
{ name: { first_name: { $contains: 'test' } } },
{ name: { last_name: { $contains: 'test' } } },
{ name: { full_name: { $contains: 'test' } } },
{ email_addresses: { email_address: { $contains: 'test' } } },
],
});
});
it('should include all person fields in results', async () => {
const personData = loadFixture('people.json', 'john-doe');
mockClient.mockResponse('POST', '/objects/people/records/query', {
data: { data: [personData] },
});
const result = await handleSearchPeople({ query: 'John' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.data.people[0]).toMatchObject({
record_id: 'person-john-123',
name: 'John Doe',
first_name: 'John',
last_name: 'Doe',
email_addresses: ['john@acme.com'],
});
});
});
describe('Validation errors', () => {
it('should fail if ATTIO_API_KEY is not configured', async () => {
delete process.env.ATTIO_API_KEY;
const result = await handleSearchPeople({ 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 people when no query provided', async () => {
const personData = loadFixture('people.json', 'john-doe');
mockClient.mockResponse('POST', '/objects/people/records/query', {
data: { data: [personData] },
});
const result = await handleSearchPeople({});
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/people/records/query');
expect(calls[0].body.filter).toBeUndefined();
});
it('should list all people when query is empty string', async () => {
mockClient.mockResponse('POST', '/objects/people/records/query', {
data: { data: [] },
});
const result = await handleSearchPeople({ query: '' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
expect(parsed.data.filters.query).toBeNull();
});
it('should list all people when query is whitespace', async () => {
mockClient.mockResponse('POST', '/objects/people/records/query', {
data: { data: [] },
});
const result = await handleSearchPeople({ query: ' ' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
});
});
describe('Company filter', () => {
it('should filter by company_id', async () => {
mockClient.mockResponse('POST', '/objects/people/records/query', {
data: { data: [] },
});
await handleSearchPeople({ company_id: 'company-123' });
const calls = mockClient.getCallsFor('POST', '/objects/people/records/query');
expect(calls[0].body.filter).toMatchObject({
company: { target_record_id: 'company-123' },
});
});
it('should combine company filter with query using $and', async () => {
mockClient.mockResponse('POST', '/objects/people/records/query', {
data: { data: [] },
});
await handleSearchPeople({ query: 'John', company_id: 'company-123' });
const calls = mockClient.getCallsFor('POST', '/objects/people/records/query');
expect(calls[0].body.filter.$and).toBeDefined();
expect(calls[0].body.filter.$and).toHaveLength(2);
});
});
describe('Sorting', () => {
it('should sort by created_at descending by default', async () => {
mockClient.mockResponse('POST', '/objects/people/records/query', {
data: { data: [] },
});
const result = await handleSearchPeople({ 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/people/records/query');
expect(calls[0].body.sorts).toEqual([{ attribute: 'created_at', direction: 'desc' }]);
});
it('should sort ascending when specified', async () => {
mockClient.mockResponse('POST', '/objects/people/records/query', {
data: { data: [] },
});
const result = await handleSearchPeople({ 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/people/records/query');
expect(calls[0].body.sorts).toEqual([{ attribute: 'name', direction: 'asc' }]);
});
});
describe('Date range filters', () => {
it('should filter by created_after', async () => {
mockClient.mockResponse('POST', '/objects/people/records/query', {
data: { data: [] },
});
await handleSearchPeople({ created_after: '2024-01-01' });
const calls = mockClient.getCallsFor('POST', '/objects/people/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/people/records/query', {
data: { data: [] },
});
await handleSearchPeople({ created_before: '2024-12-31' });
const calls = mockClient.getCallsFor('POST', '/objects/people/records/query');
expect(calls[0].body.filter).toMatchObject({
created_at: { $lte: '2024-12-31' },
});
});
});
describe('Response includes web_url and created_at', () => {
it('should include web_url in results', async () => {
const personData = loadFixture('people.json', 'john-doe');
mockClient.mockResponse('POST', '/objects/people/records/query', {
data: { data: [personData] },
});
const result = await handleSearchPeople({ query: 'John' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.data.people[0].web_url).toBe(
'https://app.attio.com/workspace/people/person-john-123'
);
});
it('should include created_at in results', async () => {
const personData = {
...loadFixture('people.json', 'john-doe'),
created_at: '2024-06-15T10:30:00Z',
};
mockClient.mockResponse('POST', '/objects/people/records/query', {
data: { data: [personData] },
});
const result = await handleSearchPeople({ query: 'John' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.data.people[0].created_at).toBe('2024-06-15T10:30:00Z');
});
});
describe('Tags filtering', () => {
it('should filter by single tag', async () => {
mockClient.mockResponse('POST', '/objects/people/records/query', {
data: { data: [] },
});
await handleSearchPeople({ tags: ['VIP'] });
const calls = mockClient.getCallsFor('POST', '/objects/people/records/query');
expect(calls[0].body.filter).toMatchObject({
tags: 'VIP',
});
});
it('should filter by multiple tags using OR logic', async () => {
mockClient.mockResponse('POST', '/objects/people/records/query', {
data: { data: [] },
});
await handleSearchPeople({ tags: ['VIP', 'Investor'] });
const calls = mockClient.getCallsFor('POST', '/objects/people/records/query');
expect(calls[0].body.filter).toMatchObject({
$or: [{ tags: 'VIP' }, { tags: 'Investor' }],
});
});
it('should exclude tags using $not', async () => {
mockClient.mockResponse('POST', '/objects/people/records/query', {
data: { data: [] },
});
await handleSearchPeople({ tags_exclude: ['Inactive', 'Archived'] });
const calls = mockClient.getCallsFor('POST', '/objects/people/records/query');
expect(calls[0].body.filter.$and).toBeDefined();
expect(calls[0].body.filter.$and).toContainEqual({
tags: { $not: 'Inactive' },
});
expect(calls[0].body.filter.$and).toContainEqual({
tags: { $not: 'Archived' },
});
});
it('should combine tags with query using $and', async () => {
mockClient.mockResponse('POST', '/objects/people/records/query', {
data: { data: [] },
});
await handleSearchPeople({ query: 'John', tags: ['VIP'] });
const calls = mockClient.getCallsFor('POST', '/objects/people/records/query');
expect(calls[0].body.filter.$and).toBeDefined();
expect(calls[0].body.filter.$and).toHaveLength(2);
});
it('should return tags in response', async () => {
const mockResponse = {
data: [
{
id: {
workspace_id: 'ws-123',
object_id: 'people',
record_id: 'person-123',
},
values: {
name: [{ first_name: 'Test', last_name: 'User', full_name: 'Test User' }],
tags: [
{ option: { title: 'VIP' } },
{ option: { title: 'Investor' } },
],
},
},
],
};
mockClient.mockResponse('POST', '/objects/people/records/query', {
data: mockResponse,
});
const result = await handleSearchPeople({ query: 'Test' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.data.people[0].tags).toEqual(['VIP', 'Investor']);
});
it('should include tags filters in response', async () => {
mockClient.mockResponse('POST', '/objects/people/records/query', {
data: { data: [] },
});
const result = await handleSearchPeople({
tags: ['VIP'],
tags_exclude: ['Inactive'],
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.data.filters.tags).toEqual(['VIP']);
expect(parsed.data.filters.tags_exclude).toEqual(['Inactive']);
});
});
describe('API errors', () => {
it('should handle authentication errors', async () => {
const authError = loadFixture('api-errors.json', 'authentication');
mockClient.mockResponse('POST', '/objects/people/records/query', {
error: {
statusCode: authError.error.status,
message: authError.error.message,
},
});
const result = await handleSearchPeople({ query: 'test' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(false);
expect(parsed.error.type).toBe('authentication_error');
});
it('should handle server errors', async () => {
const serverError = loadFixture('api-errors.json', 'server-error');
mockClient.mockResponse('POST', '/objects/people/records/query', {
error: {
statusCode: serverError.error.status,
message: serverError.error.message,
},
});
const result = await handleSearchPeople({ query: 'test' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(false);
expect(parsed.error.type).toBe('server_error');
expect(parsed.error.retryable).toBe(true);
});
});
describe('Response transformation', () => {
it('should filter out null email values', async () => {
const mockResponse = {
data: [
{
id: {
workspace_id: 'ws-123',
object_id: 'people',
record_id: 'person-123',
},
values: {
name: [{ first_name: 'Test', last_name: 'User', full_name: 'Test User' }],
email_addresses: [
{ email_address: 'test@example.com' },
{ email_address: null },
{ email_address: 'valid@test.com' },
],
},
},
],
};
mockClient.mockResponse('POST', '/objects/people/records/query', {
data: mockResponse,
});
const result = await handleSearchPeople({ query: 'Test' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.data.people[0].email_addresses).toEqual([
'test@example.com',
'valid@test.com',
]);
});
it('should handle missing optional fields gracefully', async () => {
const mockResponse = {
data: [
{
id: {
workspace_id: 'ws-123',
object_id: 'people',
record_id: 'person-123',
},
values: {
name: [{ first_name: 'Bare', last_name: 'Minimum', full_name: 'Bare Minimum' }],
// No email_addresses, description, linkedin
},
},
],
};
mockClient.mockResponse('POST', '/objects/people/records/query', {
data: mockResponse,
});
const result = await handleSearchPeople({ query: 'Bare' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed.data.people[0].name).toBe('Bare Minimum');
expect(parsed.data.people[0].email_addresses).toEqual([]);
expect(parsed.data.people[0].description).toBeNull();
expect(parsed.data.people[0].linkedin).toBeNull();
});
});
});