/**
* Tests for create_person tool
*
* Tests person creation with name parsing, email handling, and duplicate detection.
* More complex than create_company due to name field logic.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { handleCreatePerson } from '../../../src/tools/create-person.js';
import { createMockAttioClient } from '../../helpers/mock-attio-client.js';
import { loadFixture } from '../../helpers/fixtures.js';
import * as attioClientModule from '../../../src/attio-client.js';
import { handleSearchPeople } from '../../../src/tools/search-people.js';
vi.mock('../../../src/attio-client.js', () => ({
createAttioClient: vi.fn(),
}));
vi.mock('../../../src/tools/search-people.js', () => ({
handleSearchPeople: vi.fn(),
}));
describe('create_person', () => {
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();
vi.clearAllMocks();
});
describe('Successful creation', () => {
it('should create person with first_name and last_name', async () => {
const mockResponse = {
data: {
id: {
workspace_id: 'ws-123',
object_id: 'people',
record_id: 'person-123',
},
created_at: '2024-11-15T10:00:00Z',
web_url: 'https://app.attio.com/people/person-123',
values: {
name: [
{
first_name: 'John',
last_name: 'Doe',
full_name: 'John Doe',
},
],
},
},
};
mockClient.mockResponse('POST', '/objects/people/records', {
data: mockResponse,
});
const result = await handleCreatePerson({
first_name: 'John',
last_name: 'Doe',
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
expect(parsed.data.first_name).toBe('John');
expect(parsed.data.last_name).toBe('Doe');
expect(parsed.data.name).toBe('John Doe');
});
it('should create person with full_name only', async () => {
const mockResponse = {
data: {
id: {
workspace_id: 'ws-123',
object_id: 'people',
record_id: 'person-456',
},
created_at: '2024-11-15T10:00:00Z',
web_url: 'https://app.attio.com/people/person-456',
values: {
name: [
{
first_name: 'Jane',
last_name: 'Smith',
full_name: 'Jane Smith',
},
],
},
},
};
mockClient.mockResponse('POST', '/objects/people/records', {
data: mockResponse,
});
const result = await handleCreatePerson({
full_name: 'Jane Smith',
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
expect(parsed.data.name).toBe('Jane Smith');
});
it('should parse full_name into first_name and last_name', async () => {
mockClient.mockResponse('POST', '/objects/people/records', {
data: {
data: {
id: { record_id: 'person-789' },
values: {
name: [
{
first_name: 'Alice',
last_name: 'Johnson',
full_name: 'Alice Johnson',
},
],
},
},
},
});
await handleCreatePerson({
full_name: 'Alice Johnson',
});
const calls = mockClient.getCallsFor('POST', '/objects/people/records');
expect(calls[0].body.data.values.name[0]).toMatchObject({
first_name: 'Alice',
last_name: 'Johnson',
full_name: 'Alice Johnson',
});
});
it('should construct full_name from first_name and last_name', async () => {
mockClient.mockResponse('POST', '/objects/people/records', {
data: {
data: {
id: { record_id: 'person-abc' },
values: {},
},
},
});
await handleCreatePerson({
first_name: 'Bob',
last_name: 'Brown',
});
const calls = mockClient.getCallsFor('POST', '/objects/people/records');
expect(calls[0].body.data.values.name[0].full_name).toBe('Bob Brown');
});
it('should create person with email only (no name)', async () => {
const mockResponse = {
data: {
id: {
workspace_id: 'ws-123',
object_id: 'people',
record_id: 'person-email-only',
},
created_at: '2024-11-15T10:00:00Z',
web_url: 'https://app.attio.com/people/person-email-only',
values: {
email_addresses: [{ email_address: 'test@example.com' }],
},
},
};
mockClient.mockResponse('POST', '/objects/people/records', {
data: mockResponse,
});
const result = await handleCreatePerson({
email_addresses: ['test@example.com'],
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
expect(parsed.data.email_addresses).toEqual(['test@example.com']);
expect(parsed.data.name).toBeNull();
});
it('should create person with all fields', async () => {
const mockResponse = {
data: {
id: {
workspace_id: 'ws-123',
object_id: 'people',
record_id: 'person-full',
},
created_at: '2024-11-15T10:00:00Z',
web_url: 'https://app.attio.com/people/person-full',
values: {
name: [
{
first_name: 'Complete',
last_name: 'Person',
full_name: 'Complete Person',
},
],
email_addresses: [
{ email_address: 'complete@example.com' },
{ email_address: 'person@work.com' },
],
description: [{ value: 'Test description' }],
linkedin: [{ value: 'https://linkedin.com/in/complete' }],
},
},
};
mockClient.mockResponse('POST', '/objects/people/records', {
data: mockResponse,
});
const result = await handleCreatePerson({
first_name: 'Complete',
last_name: 'Person',
email_addresses: ['complete@example.com', 'person@work.com'],
description: 'Test description',
linkedin: 'https://linkedin.com/in/complete',
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
expect(parsed.data).toMatchObject({
first_name: 'Complete',
last_name: 'Person',
name: 'Complete Person',
email_addresses: ['complete@example.com', 'person@work.com'],
description: 'Test description',
linkedin: 'https://linkedin.com/in/complete',
});
});
it('should trim whitespace from all fields', async () => {
mockClient.mockResponse('POST', '/objects/people/records', {
data: {
data: {
id: { record_id: 'person-trim' },
values: {},
},
},
});
await handleCreatePerson({
first_name: ' John ',
last_name: ' Doe ',
email_addresses: [' john@example.com ', ' john.doe@work.com '],
description: ' Trimmed description ',
linkedin: ' https://linkedin.com ',
});
const calls = mockClient.getCallsFor('POST', '/objects/people/records');
const values = calls[0].body.data.values;
expect(values.name[0]).toMatchObject({
first_name: 'John',
last_name: 'Doe',
});
expect(values.email_addresses).toEqual([
{ email_address: 'john@example.com' },
{ email_address: 'john.doe@work.com' },
]);
expect(values.description).toEqual([{ value: 'Trimmed description' }]);
expect(values.linkedin).toEqual([{ value: 'https://linkedin.com' }]);
});
it('should filter out empty email addresses', async () => {
mockClient.mockResponse('POST', '/objects/people/records', {
data: {
data: {
id: { record_id: 'person-filtered' },
values: {},
},
},
});
await handleCreatePerson({
full_name: 'Test Person',
email_addresses: ['test@example.com', '', ' ', 'valid@test.com'],
});
const calls = mockClient.getCallsFor('POST', '/objects/people/records');
expect(calls[0].body.data.values.email_addresses).toEqual([
{ email_address: 'test@example.com' },
{ email_address: 'valid@test.com' },
]);
});
it('should handle multi-word last names from full_name', async () => {
mockClient.mockResponse('POST', '/objects/people/records', {
data: {
data: {
id: { record_id: 'person-multi' },
values: {},
},
},
});
await handleCreatePerson({
full_name: 'John van der Berg',
});
const calls = mockClient.getCallsFor('POST', '/objects/people/records');
expect(calls[0].body.data.values.name[0]).toMatchObject({
first_name: 'John',
last_name: 'van der Berg',
full_name: 'John van der Berg',
});
});
});
describe('Validation errors', () => {
it('should fail if ATTIO_API_KEY is not configured', async () => {
delete process.env.ATTIO_API_KEY;
const result = await handleCreatePerson({
full_name: 'Test Person',
});
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');
});
it('should fail if no name or email provided', async () => {
const result = await handleCreatePerson({});
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('At least one name field');
expect(parsed.error.message).toContain('email address');
});
it('should fail if only empty email addresses provided', async () => {
const result = await handleCreatePerson({
email_addresses: ['', ' '],
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(false);
expect(parsed.error.message).toContain('At least one name');
});
});
describe('API errors', () => {
it('should handle authentication errors', async () => {
const authError = loadFixture('api-errors.json', 'authentication');
mockClient.mockResponse('POST', '/objects/people/records', {
error: {
statusCode: authError.error.status,
message: authError.error.message,
},
});
const result = await handleCreatePerson({
full_name: 'Test Person',
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(false);
expect(parsed.error.type).toBe('authentication_error');
});
it('should handle validation errors from API', async () => {
mockClient.mockResponse('POST', '/objects/people/records', {
error: {
statusCode: 400,
message: 'Validation failed',
response: {
validation_errors: [
{
path: ['email_addresses'],
message: 'Invalid email format',
},
],
},
},
});
const result = await handleCreatePerson({
full_name: 'Test',
email_addresses: ['invalid-email'],
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(false);
expect(parsed.error.type).toBe('validation_error');
});
});
describe('Duplicate email handling', () => {
it('should return existing person when email already exists', async () => {
// First, mock the create request to fail with uniqueness constraint
mockClient.mockResponse('POST', '/objects/people/records', {
error: {
statusCode: 400,
message: 'uniqueness constraint violation for email_addresses',
},
});
// Mock the handleSearchPeople call that happens during duplicate detection
vi.mocked(handleSearchPeople).mockResolvedValueOnce({
content: [
{
type: 'text' as const,
text: JSON.stringify({
success: true,
count: 1,
people: [
{
record_id: 'existing-person-123',
name: 'Existing Person',
first_name: 'Existing',
last_name: 'Person',
email_addresses: ['duplicate@example.com'],
description: 'Already exists',
linkedin: 'https://linkedin.com/in/existing',
},
],
}),
},
],
isError: false,
});
const result = await handleCreatePerson({
full_name: 'New Person',
email_addresses: ['duplicate@example.com'],
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
expect(parsed.data.status).toBe('already_exists');
expect(parsed.data.message).toContain('duplicate@example.com');
expect(parsed.data.message).toContain('already exists');
expect(parsed.data.record_id).toBe('existing-person-123');
expect(parsed.data.first_name).toBe('Existing');
expect(parsed.data.last_name).toBe('Person');
expect(parsed.data.email_addresses).toEqual(['duplicate@example.com']);
});
it('should handle uniqueness constraint when search finds no results', async () => {
// Mock create to fail with uniqueness constraint
mockClient.mockResponse('POST', '/objects/people/records', {
error: {
statusCode: 400,
message: 'uniqueness constraint violation for email_addresses',
},
});
// Mock search to return no results
vi.mocked(handleSearchPeople).mockResolvedValueOnce({
content: [
{
type: 'text' as const,
text: JSON.stringify({
success: true,
count: 0,
people: [],
}),
},
],
isError: false,
});
const result = await handleCreatePerson({
full_name: 'Test Person',
email_addresses: ['test@example.com'],
});
const parsed = JSON.parse(result.content[0].text);
// Should fall through to normal error handling (400 = conflict_error)
expect(parsed.success).toBe(false);
expect(parsed.error.type).toBe('conflict_error');
});
it('should handle uniqueness constraint when search fails', async () => {
// Mock create to fail with uniqueness constraint
mockClient.mockResponse('POST', '/objects/people/records', {
error: {
statusCode: 400,
message: 'uniqueness constraint violation for email_addresses',
},
});
// Mock search to throw an error
vi.mocked(handleSearchPeople).mockRejectedValueOnce(
new Error('Search failed')
);
const result = await handleCreatePerson({
full_name: 'Test Person',
email_addresses: ['test@example.com'],
});
const parsed = JSON.parse(result.content[0].text);
// Should fall through to normal error handling (400 = conflict_error)
expect(parsed.success).toBe(false);
expect(parsed.error.type).toBe('conflict_error');
});
it('should not trigger duplicate handling for non-uniqueness errors', async () => {
mockClient.mockResponse('POST', '/objects/people/records', {
error: {
statusCode: 400,
message: 'Some other validation error',
},
});
const result = await handleCreatePerson({
full_name: 'Test Person',
email_addresses: ['test@example.com'],
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(false);
expect(parsed.error.type).toBe('validation_error');
// Should not have triggered search
const searchCalls = mockClient.getCallsFor(
'POST',
'/objects/people/records/query'
);
expect(searchCalls).toHaveLength(0);
});
});
describe('Name handling edge cases', () => {
it('should handle first_name only', async () => {
mockClient.mockResponse('POST', '/objects/people/records', {
data: {
data: {
id: { record_id: 'person-first-only' },
values: {},
},
},
});
await handleCreatePerson({
first_name: 'Madonna',
});
const calls = mockClient.getCallsFor('POST', '/objects/people/records');
expect(calls[0].body.data.values.name[0]).toMatchObject({
first_name: 'Madonna',
last_name: '',
full_name: 'Madonna',
});
});
it('should handle last_name only', async () => {
mockClient.mockResponse('POST', '/objects/people/records', {
data: {
data: {
id: { record_id: 'person-last-only' },
values: {},
},
},
});
await handleCreatePerson({
last_name: 'Cher',
});
const calls = mockClient.getCallsFor('POST', '/objects/people/records');
expect(calls[0].body.data.values.name[0]).toMatchObject({
first_name: '',
last_name: 'Cher',
full_name: 'Cher',
});
});
it('should handle single-word full_name', async () => {
mockClient.mockResponse('POST', '/objects/people/records', {
data: {
data: {
id: { record_id: 'person-single' },
values: {},
},
},
});
await handleCreatePerson({
full_name: 'Prince',
});
const calls = mockClient.getCallsFor('POST', '/objects/people/records');
expect(calls[0].body.data.values.name[0]).toMatchObject({
first_name: 'Prince',
last_name: '',
full_name: 'Prince',
});
});
});
});