/**
* Integration Tests: Custom Fields
*
* Tests custom field management for all 15 ClickUp field types:
* - Text, Number, Date, DateTime
* - Dropdown, Labels
* - Email, Phone, URL
* - Checkbox, Users, Tasks
* - Location, Rating, Money, Manual Progress
*
* Custom fields enable AI agents to track execution state, store metadata,
* and manage complex project data - a critical feature missing from
* native ClickUp MCP implementation.
*
* These tests verify:
* - Field discovery and introspection
* - Setting values for each field type
* - Getting field values from tasks
* - Updating and removing field values
* - Type validation and error handling
* - Multi-field operations
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { MCPTestClient, createMCPTestClient } from './mcp-test-client';
import { setupTestEnvironment, teardownTestEnvironment, getTestEnvironment } from './setup';
import { createTask, setCustomFields, uniqueTestName } from './utils';
describe('Custom Fields Integration Tests', () => {
let client: MCPTestClient;
let testEnv: any;
let listId: string;
let availableFields: any[] = [];
beforeAll(async () => {
// Setup test environment
testEnv = await setupTestEnvironment();
listId = testEnv.listId;
// Connect MCP client
client = await createMCPTestClient({
apiKey: testEnv.apiKey,
teamId: testEnv.teamId,
logRequests: false,
logResponses: false
});
// Get available custom fields for the list
const result = await client.callTool('clickup_custom_field_get_accessible', {
list_id: listId
});
if (!result.isError) {
availableFields = client.parseJsonResult(result)?.fields || [];
console.log(`Found ${availableFields.length} custom fields available`);
}
});
afterAll(async () => {
// Disconnect client
await client.disconnect();
// Cleanup test environment
await teardownTestEnvironment(false);
});
describe('Custom Field Discovery', () => {
it('should get accessible custom fields for list', async () => {
const result = await client.callTool('clickup_custom_field_get_accessible', {
list_id: listId
});
client.assertSuccess(result, 'Failed to get accessible custom fields');
const data = client.parseJsonResult(result);
expect(data).toBeDefined();
expect(data.fields).toBeDefined();
expect(Array.isArray(data.fields)).toBe(true);
// Log field types available for testing
if (data.fields.length > 0) {
console.log('Available field types:', data.fields.map((f: any) => f.type).join(', '));
}
});
it('should fail to get custom fields for invalid list', async () => {
const invalidListId = 'invalid_list_xyz';
const result = await client.callTool('clickup_custom_field_get_accessible', {
list_id: invalidListId
});
expect(result.isError).toBe(true);
const errorText = client.parseTextResult(result);
expect(errorText.toLowerCase()).toMatch(/error|invalid|not found/);
});
it('should get custom field values for task', async () => {
// Create task
const task = await createTask(client, listId, {
name: uniqueTestName('Custom Field Task'),
description: 'Task for testing custom field retrieval'
});
// Get custom field values
const values = await client.getCustomFields(task.id);
expect(values).toBeDefined();
// May be empty array if no fields set yet
expect(Array.isArray(values)).toBe(true);
});
});
describe('Text Field Operations', () => {
it('should set and retrieve text field value', async () => {
const textField = availableFields.find(f => f.type === 'text' || f.type === 'short_text');
if (!textField) {
console.log('Skipping text field test - no text field available');
return;
}
// Create task
const task = await createTask(client, listId, {
name: uniqueTestName('Text Field Task')
});
// Set text field value
const testValue = 'This is a test text value for custom field';
await client.setCustomField(
task.id,
textField.id,
testValue,
textField.type
);
// Get custom field values
const values = await client.getCustomFields(task.id);
expect(values).toBeDefined();
const textFieldValue = values.find((v: any) => v.id === textField.id);
expect(textFieldValue).toBeDefined();
expect(textFieldValue.value).toBe(testValue);
});
it('should update text field value', async () => {
const textField = availableFields.find(f => f.type === 'text' || f.type === 'short_text');
if (!textField) {
console.log('Skipping text field update test');
return;
}
const task = await createTask(client, listId, {
name: uniqueTestName('Text Update Task')
});
// Set initial value
await client.setCustomField(task.id, textField.id, 'Initial value', textField.type);
// Update value
const updatedValue = 'Updated text value';
await client.setCustomField(task.id, textField.id, updatedValue, textField.type);
// Verify update
const values = await client.getCustomFields(task.id);
const textFieldValue = values.find((v: any) => v.id === textField.id);
expect(textFieldValue.value).toBe(updatedValue);
});
});
describe('Number Field Operations', () => {
it('should set and retrieve number field value', async () => {
const numberField = availableFields.find(f => f.type === 'number');
if (!numberField) {
console.log('Skipping number field test - no number field available');
return;
}
const task = await createTask(client, listId, {
name: uniqueTestName('Number Field Task')
});
// Set number field value
const testValue = 42;
await client.setCustomField(
task.id,
numberField.id,
testValue,
numberField.type
);
// Get custom field values
const values = await client.getCustomFields(task.id);
const numberFieldValue = values.find((v: any) => v.id === numberField.id);
expect(numberFieldValue).toBeDefined();
expect(parseFloat(numberFieldValue.value)).toBe(testValue);
});
it('should handle decimal number values', async () => {
const numberField = availableFields.find(f => f.type === 'number');
if (!numberField) {
console.log('Skipping decimal number test');
return;
}
const task = await createTask(client, listId, {
name: uniqueTestName('Decimal Number Task')
});
const decimalValue = 3.14159;
await client.setCustomField(
task.id,
numberField.id,
decimalValue,
numberField.type
);
const values = await client.getCustomFields(task.id);
const numberFieldValue = values.find((v: any) => v.id === numberField.id);
expect(numberFieldValue).toBeDefined();
expect(parseFloat(numberFieldValue.value)).toBeCloseTo(decimalValue, 5);
});
});
describe('Date/DateTime Field Operations', () => {
it('should set and retrieve date field value', async () => {
const dateField = availableFields.find(f => f.type === 'date');
if (!dateField) {
console.log('Skipping date field test - no date field available');
return;
}
const task = await createTask(client, listId, {
name: uniqueTestName('Date Field Task')
});
// Set date field value (Unix timestamp in milliseconds)
const testDate = Date.now();
await client.setCustomField(
task.id,
dateField.id,
testDate,
dateField.type
);
const values = await client.getCustomFields(task.id);
const dateFieldValue = values.find((v: any) => v.id === dateField.id);
expect(dateFieldValue).toBeDefined();
expect(dateFieldValue.value).toBeDefined();
});
});
describe('Dropdown Field Operations', () => {
it('should set and retrieve dropdown field value', async () => {
const dropdownField = availableFields.find(f => f.type === 'drop_down');
if (!dropdownField) {
console.log('Skipping dropdown field test - no dropdown field available');
return;
}
const task = await createTask(client, listId, {
name: uniqueTestName('Dropdown Field Task')
});
// Get first option from dropdown
if (!dropdownField.type_config?.options || dropdownField.type_config.options.length === 0) {
console.log('Skipping - dropdown has no options');
return;
}
const firstOption = dropdownField.type_config.options[0];
// Set dropdown field value (use option ID or index)
await client.setCustomField(
task.id,
dropdownField.id,
firstOption.id || firstOption.orderindex || 0,
dropdownField.type
);
const values = await client.getCustomFields(task.id);
const dropdownFieldValue = values.find((v: any) => v.id === dropdownField.id);
expect(dropdownFieldValue).toBeDefined();
expect(dropdownFieldValue.value).toBeDefined();
});
});
describe('Checkbox Field Operations', () => {
it('should set and retrieve checkbox field value (true)', async () => {
const checkboxField = availableFields.find(f => f.type === 'checkbox');
if (!checkboxField) {
console.log('Skipping checkbox field test - no checkbox field available');
return;
}
const task = await createTask(client, listId, {
name: uniqueTestName('Checkbox Field Task')
});
// Set checkbox to true
await client.setCustomField(
task.id,
checkboxField.id,
true,
checkboxField.type
);
const values = await client.getCustomFields(task.id);
const checkboxFieldValue = values.find((v: any) => v.id === checkboxField.id);
expect(checkboxFieldValue).toBeDefined();
expect(checkboxFieldValue.value).toBe(true);
});
it('should toggle checkbox field value', async () => {
const checkboxField = availableFields.find(f => f.type === 'checkbox');
if (!checkboxField) {
console.log('Skipping checkbox toggle test');
return;
}
const task = await createTask(client, listId, {
name: uniqueTestName('Checkbox Toggle Task')
});
// Set to true
await client.setCustomField(task.id, checkboxField.id, true, checkboxField.type);
let values = await client.getCustomFields(task.id);
let checkboxValue = values.find((v: any) => v.id === checkboxField.id);
expect(checkboxValue.value).toBe(true);
// Toggle to false
await client.setCustomField(task.id, checkboxField.id, false, checkboxField.type);
values = await client.getCustomFields(task.id);
checkboxValue = values.find((v: any) => v.id === checkboxField.id);
expect(checkboxValue.value).toBe(false);
});
});
describe('Email Field Operations', () => {
it('should set and retrieve email field value', async () => {
const emailField = availableFields.find(f => f.type === 'email');
if (!emailField) {
console.log('Skipping email field test - no email field available');
return;
}
const task = await createTask(client, listId, {
name: uniqueTestName('Email Field Task')
});
const testEmail = 'test@example.com';
await client.setCustomField(
task.id,
emailField.id,
testEmail,
emailField.type
);
const values = await client.getCustomFields(task.id);
const emailFieldValue = values.find((v: any) => v.id === emailField.id);
expect(emailFieldValue).toBeDefined();
expect(emailFieldValue.value).toBe(testEmail);
});
it('should validate email format', async () => {
const emailField = availableFields.find(f => f.type === 'email');
if (!emailField) {
console.log('Skipping email validation test');
return;
}
const task = await createTask(client, listId, {
name: uniqueTestName('Email Validation Task')
});
// Try setting invalid email
const result = await client.callTool('clickup_custom_field_set_value', {
task_id: task.id,
field_id: emailField.id,
value: 'not-an-email',
field_type: emailField.type
});
// Depending on validation, may error or accept
expect(result).toBeDefined();
});
});
describe('URL Field Operations', () => {
it('should set and retrieve URL field value', async () => {
const urlField = availableFields.find(f => f.type === 'url');
if (!urlField) {
console.log('Skipping URL field test - no URL field available');
return;
}
const task = await createTask(client, listId, {
name: uniqueTestName('URL Field Task')
});
const testUrl = 'https://example.com/test-page';
await client.setCustomField(
task.id,
urlField.id,
testUrl,
urlField.type
);
const values = await client.getCustomFields(task.id);
const urlFieldValue = values.find((v: any) => v.id === urlField.id);
expect(urlFieldValue).toBeDefined();
expect(urlFieldValue.value).toBe(testUrl);
});
});
describe('Phone Field Operations', () => {
it('should set and retrieve phone field value', async () => {
const phoneField = availableFields.find(f => f.type === 'phone');
if (!phoneField) {
console.log('Skipping phone field test - no phone field available');
return;
}
const task = await createTask(client, listId, {
name: uniqueTestName('Phone Field Task')
});
const testPhone = '+1-555-123-4567';
await client.setCustomField(
task.id,
phoneField.id,
testPhone,
phoneField.type
);
const values = await client.getCustomFields(task.id);
const phoneFieldValue = values.find((v: any) => v.id === phoneField.id);
expect(phoneFieldValue).toBeDefined();
expect(phoneFieldValue.value).toBeDefined();
});
});
describe('Money Field Operations', () => {
it('should set and retrieve money field value', async () => {
const moneyField = availableFields.find(f => f.type === 'currency');
if (!moneyField) {
console.log('Skipping money field test - no money field available');
return;
}
const task = await createTask(client, listId, {
name: uniqueTestName('Money Field Task')
});
// Money value in cents (e.g., 4999 = $49.99)
const testAmount = 4999;
await client.setCustomField(
task.id,
moneyField.id,
testAmount,
moneyField.type
);
const values = await client.getCustomFields(task.id);
const moneyFieldValue = values.find((v: any) => v.id === moneyField.id);
expect(moneyFieldValue).toBeDefined();
expect(moneyFieldValue.value).toBeDefined();
});
});
describe('Rating Field Operations', () => {
it('should set and retrieve rating field value', async () => {
const ratingField = availableFields.find(f => f.type === 'emoji' || f.type === 'rating');
if (!ratingField) {
console.log('Skipping rating field test - no rating field available');
return;
}
const task = await createTask(client, listId, {
name: uniqueTestName('Rating Field Task')
});
// Rating value (typically 0-5 or 0-10 depending on configuration)
const testRating = 5;
await client.setCustomField(
task.id,
ratingField.id,
testRating,
ratingField.type
);
const values = await client.getCustomFields(task.id);
const ratingFieldValue = values.find((v: any) => v.id === ratingField.id);
expect(ratingFieldValue).toBeDefined();
expect(ratingFieldValue.value).toBeDefined();
});
});
describe('Multiple Custom Fields', () => {
it('should set multiple custom fields on same task', async () => {
if (availableFields.length < 2) {
console.log('Skipping multi-field test - need at least 2 fields');
return;
}
const task = await createTask(client, listId, {
name: uniqueTestName('Multi-Field Task')
});
// Set multiple fields
const fieldsToSet = availableFields.slice(0, Math.min(3, availableFields.length));
for (const field of fieldsToSet) {
const value = getTestValueForFieldType(field.type, field);
if (value !== null) {
await client.setCustomField(
task.id,
field.id,
value,
field.type
);
}
}
// Get all custom field values
const values = await client.getCustomFields(task.id);
expect(values).toBeDefined();
expect(values.length).toBeGreaterThanOrEqual(fieldsToSet.filter(f =>
getTestValueForFieldType(f.type, f) !== null
).length);
});
it('should update one field without affecting others', async () => {
if (availableFields.length < 2) {
console.log('Skipping field isolation test');
return;
}
const task = await createTask(client, listId, {
name: uniqueTestName('Field Isolation Task')
});
const field1 = availableFields[0];
const field2 = availableFields[1];
// Set both fields
const value1 = getTestValueForFieldType(field1.type, field1);
const value2 = getTestValueForFieldType(field2.type, field2);
if (value1 !== null) {
await client.setCustomField(task.id, field1.id, value1, field1.type);
}
if (value2 !== null) {
await client.setCustomField(task.id, field2.id, value2, field2.type);
}
// Update only field1
const newValue1 = getTestValueForFieldType(field1.type, field1, true);
if (newValue1 !== null) {
await client.setCustomField(task.id, field1.id, newValue1, field1.type);
}
// Verify field2 unchanged
const values = await client.getCustomFields(task.id);
const field2Value = values.find((v: any) => v.id === field2.id);
if (value2 !== null && field2Value) {
expect(field2Value).toBeDefined();
}
});
});
describe('Custom Field Removal', () => {
it('should remove custom field value', async () => {
const textField = availableFields.find(f => f.type === 'text' || f.type === 'short_text');
if (!textField) {
console.log('Skipping remove field test');
return;
}
const task = await createTask(client, listId, {
name: uniqueTestName('Remove Field Task')
});
// Set field value
await client.setCustomField(task.id, textField.id, 'Value to remove', textField.type);
// Remove field value
const result = await client.callTool('clickup_custom_field_remove_value', {
task_id: task.id,
field_id: textField.id
});
client.assertSuccess(result, 'Failed to remove custom field value');
// Verify field removed
const values = await client.getCustomFields(task.id);
const fieldValue = values.find((v: any) => v.id === textField.id);
// Field should be absent or have null/empty value
expect(fieldValue === undefined || fieldValue.value === null || fieldValue.value === '').toBe(true);
});
});
describe('Error Handling', () => {
it('should fail to set field with invalid field ID', async () => {
const task = await createTask(client, listId, {
name: uniqueTestName('Invalid Field ID Task')
});
const invalidFieldId = 'invalid_field_xyz';
const result = await client.callTool('clickup_custom_field_set_value', {
task_id: task.id,
field_id: invalidFieldId,
value: 'test',
field_type: 'text'
});
expect(result.isError).toBe(true);
const errorText = client.parseTextResult(result);
expect(errorText.toLowerCase()).toMatch(/error|invalid|not found/);
});
it('should fail to set field on non-existent task', async () => {
if (availableFields.length === 0) {
console.log('Skipping - no fields available');
return;
}
const invalidTaskId = 'non_existent_task_999';
const field = availableFields[0];
const result = await client.callTool('clickup_custom_field_set_value', {
task_id: invalidTaskId,
field_id: field.id,
value: 'test',
field_type: field.type
});
expect(result.isError).toBe(true);
const errorText = client.parseTextResult(result);
expect(errorText.toLowerCase()).toMatch(/error|invalid|not found/);
});
it('should fail to get fields for invalid task', async () => {
const invalidTaskId = 'invalid_task_xyz';
const result = await client.callTool('clickup_custom_field_get_values', {
task_id: invalidTaskId
});
expect(result.isError).toBe(true);
const errorText = client.parseTextResult(result);
expect(errorText.toLowerCase()).toMatch(/error|invalid|not found/);
});
});
describe('Use Case: Agent State Tracking', () => {
it('should use custom fields to track AI agent execution state', async () => {
// Simulate use case from PRE-LAUNCH-STATUS-REPORT.md:
// "Track agent execution state (pending/running/complete) using
// custom fields, store agent logs in text fields, track cost in
// money fields."
const textField = availableFields.find(f => f.type === 'text' || f.type === 'short_text');
const dropdownField = availableFields.find(f => f.type === 'drop_down');
const moneyField = availableFields.find(f => f.type === 'currency');
if (!textField || !dropdownField) {
console.log('Skipping agent state tracking test - missing required fields');
return;
}
// Create task representing agent job
const task = await createTask(client, listId, {
name: uniqueTestName('Agent Job: Data Processing'),
description: 'AI agent task for processing customer data'
});
// Set agent execution log (text field)
await client.setCustomField(
task.id,
textField.id,
'Agent started at 2024-01-15T10:30:00Z\nProcessing 1000 records\nStatus: In Progress',
textField.type
);
// Set execution state (dropdown field)
if (dropdownField.type_config?.options && dropdownField.type_config.options.length > 0) {
const runningOption = dropdownField.type_config.options[0];
await client.setCustomField(
task.id,
dropdownField.id,
runningOption.id || runningOption.orderindex || 0,
dropdownField.type
);
}
// Set execution cost (money field, if available)
if (moneyField) {
await client.setCustomField(
task.id,
moneyField.id,
1250, // $12.50 in cents
moneyField.type
);
}
// Verify all fields set
const values = await client.getCustomFields(task.id);
expect(values.length).toBeGreaterThanOrEqual(2);
const logValue = values.find((v: any) => v.id === textField.id);
expect(logValue).toBeDefined();
expect(logValue.value).toContain('Agent started');
});
});
});
/**
* Helper: Get test value for field type
*/
function getTestValueForFieldType(type: string, field: any, alternate: boolean = false): any {
switch (type) {
case 'text':
case 'short_text':
return alternate ? 'Alternate text value' : 'Test text value';
case 'number':
return alternate ? 100 : 42;
case 'date':
return Date.now() + (alternate ? 86400000 : 0);
case 'drop_down':
if (field.type_config?.options && field.type_config.options.length > 0) {
const option = field.type_config.options[alternate ? 1 : 0] || field.type_config.options[0];
return option.id || option.orderindex || 0;
}
return null;
case 'checkbox':
return alternate ? false : true;
case 'email':
return alternate ? 'alternate@example.com' : 'test@example.com';
case 'url':
return alternate ? 'https://example.com/alternate' : 'https://example.com/test';
case 'phone':
return alternate ? '+1-555-987-6543' : '+1-555-123-4567';
case 'currency':
return alternate ? 9999 : 4999;
case 'emoji':
case 'rating':
return alternate ? 3 : 5;
default:
return null;
}
}