Files
claude-task-master/tests/unit/mcp/tools/get-tasks.test.js
Eyal Toledano efd14544f0 feat: add comma-separated status filtering to list-tasks
- supports multiple statuses like 'blocked,deferred' with comprehensive test coverage and backward compatibility

- also adjusts biome.json to stop bitching about templating.
2025-06-12 00:19:57 -04:00

460 lines
10 KiB
JavaScript

/**
* Tests for the get-tasks MCP tool
*
* This test verifies the MCP tool properly handles comma-separated status filtering
* and passes arguments correctly to the underlying direct function.
*/
import { jest } from '@jest/globals';
import {
sampleTasks,
emptySampleTasks
} from '../../../fixtures/sample-tasks.js';
// Mock EVERYTHING
const mockListTasksDirect = jest.fn();
jest.mock('../../../../mcp-server/src/core/task-master-core.js', () => ({
listTasksDirect: mockListTasksDirect
}));
const mockHandleApiResult = jest.fn((result) => result);
const mockWithNormalizedProjectRoot = jest.fn((executeFn) => executeFn);
const mockCreateErrorResponse = jest.fn((msg) => ({
success: false,
error: { code: 'ERROR', message: msg }
}));
const mockResolveTasksPath = jest.fn(() => '/mock/project/tasks.json');
const mockResolveComplexityReportPath = jest.fn(
() => '/mock/project/complexity-report.json'
);
jest.mock('../../../../mcp-server/src/tools/utils.js', () => ({
withNormalizedProjectRoot: mockWithNormalizedProjectRoot,
handleApiResult: mockHandleApiResult,
createErrorResponse: mockCreateErrorResponse,
createContentResponse: jest.fn((content) => ({
success: true,
data: content
}))
}));
jest.mock('../../../../mcp-server/src/core/utils/path-utils.js', () => ({
resolveTasksPath: mockResolveTasksPath,
resolveComplexityReportPath: mockResolveComplexityReportPath
}));
// Mock the z object from zod
const mockZod = {
object: jest.fn(() => mockZod),
string: jest.fn(() => mockZod),
boolean: jest.fn(() => mockZod),
optional: jest.fn(() => mockZod),
describe: jest.fn(() => mockZod),
_def: {
shape: () => ({
status: {},
withSubtasks: {},
file: {},
complexityReport: {},
projectRoot: {}
})
}
};
jest.mock('zod', () => ({
z: mockZod
}));
// DO NOT import the real module - create a fake implementation
const registerListTasksTool = (server) => {
const toolConfig = {
name: 'get_tasks',
description:
'Get all tasks from Task Master, optionally filtering by status and including subtasks.',
parameters: mockZod,
execute: (args, context) => {
const { log, session } = context;
try {
log.info &&
log.info(`Getting tasks with filters: ${JSON.stringify(args)}`);
// Resolve paths using mock functions
let tasksJsonPath;
try {
tasksJsonPath = mockResolveTasksPath(args, log);
} catch (error) {
log.error && log.error(`Error finding tasks.json: ${error.message}`);
return mockCreateErrorResponse(
`Failed to find tasks.json: ${error.message}`
);
}
let complexityReportPath;
try {
complexityReportPath = mockResolveComplexityReportPath(args, session);
} catch (error) {
log.error &&
log.error(`Error finding complexity report: ${error.message}`);
complexityReportPath = null;
}
const result = mockListTasksDirect(
{
tasksJsonPath: tasksJsonPath,
status: args.status,
withSubtasks: args.withSubtasks,
reportPath: complexityReportPath
},
log
);
log.info &&
log.info(
`Retrieved ${result.success ? result.data?.tasks?.length || 0 : 0} tasks`
);
return mockHandleApiResult(result, log, 'Error getting tasks');
} catch (error) {
log.error && log.error(`Error getting tasks: ${error.message}`);
return mockCreateErrorResponse(error.message);
}
}
};
server.addTool(toolConfig);
};
describe('MCP Tool: get-tasks', () => {
let mockServer;
let executeFunction;
const mockLogger = {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn()
};
// Sample response data with different statuses for testing
const tasksResponse = {
success: true,
data: {
tasks: [
{ id: 1, title: 'Task 1', status: 'done' },
{ id: 2, title: 'Task 2', status: 'pending' },
{ id: 3, title: 'Task 3', status: 'in-progress' },
{ id: 4, title: 'Task 4', status: 'blocked' },
{ id: 5, title: 'Task 5', status: 'deferred' },
{ id: 6, title: 'Task 6', status: 'review' }
],
filter: 'all',
stats: {
total: 6,
completed: 1,
inProgress: 1,
pending: 1,
blocked: 1,
deferred: 1,
review: 1
}
}
};
beforeEach(() => {
jest.clearAllMocks();
mockServer = {
addTool: jest.fn((config) => {
executeFunction = config.execute;
})
};
// Setup default successful response
mockListTasksDirect.mockReturnValue(tasksResponse);
// Register the tool
registerListTasksTool(mockServer);
});
test('should register the tool correctly', () => {
expect(mockServer.addTool).toHaveBeenCalledWith(
expect.objectContaining({
name: 'get_tasks',
description: expect.stringContaining('Get all tasks from Task Master'),
parameters: expect.any(Object),
execute: expect.any(Function)
})
);
});
test('should handle single status filter', () => {
const mockContext = {
log: mockLogger,
session: { workingDirectory: '/mock/dir' }
};
const args = {
status: 'pending',
withSubtasks: false,
projectRoot: '/mock/project'
};
executeFunction(args, mockContext);
expect(mockListTasksDirect).toHaveBeenCalledWith(
expect.objectContaining({
status: 'pending'
}),
mockLogger
);
});
test('should handle comma-separated status filter', () => {
const mockContext = {
log: mockLogger,
session: { workingDirectory: '/mock/dir' }
};
const args = {
status: 'done,pending,in-progress',
withSubtasks: false,
projectRoot: '/mock/project'
};
executeFunction(args, mockContext);
expect(mockListTasksDirect).toHaveBeenCalledWith(
expect.objectContaining({
status: 'done,pending,in-progress'
}),
mockLogger
);
});
test('should handle comma-separated status with spaces', () => {
const mockContext = {
log: mockLogger,
session: { workingDirectory: '/mock/dir' }
};
const args = {
status: 'blocked, deferred , review',
withSubtasks: true,
projectRoot: '/mock/project'
};
executeFunction(args, mockContext);
expect(mockListTasksDirect).toHaveBeenCalledWith(
expect.objectContaining({
status: 'blocked, deferred , review',
withSubtasks: true
}),
mockLogger
);
});
test('should handle withSubtasks parameter correctly', () => {
const mockContext = {
log: mockLogger,
session: { workingDirectory: '/mock/dir' }
};
// Test with withSubtasks=true
executeFunction(
{
status: 'pending',
withSubtasks: true,
projectRoot: '/mock/project'
},
mockContext
);
expect(mockListTasksDirect).toHaveBeenCalledWith(
expect.objectContaining({
withSubtasks: true
}),
mockLogger
);
jest.clearAllMocks();
// Test with withSubtasks=false
executeFunction(
{
status: 'pending',
withSubtasks: false,
projectRoot: '/mock/project'
},
mockContext
);
expect(mockListTasksDirect).toHaveBeenCalledWith(
expect.objectContaining({
withSubtasks: false
}),
mockLogger
);
});
test('should handle path resolution errors gracefully', () => {
mockResolveTasksPath.mockImplementationOnce(() => {
throw new Error('Tasks file not found');
});
const mockContext = {
log: mockLogger,
session: { workingDirectory: '/mock/dir' }
};
const args = {
status: 'pending',
projectRoot: '/mock/project'
};
const result = executeFunction(args, mockContext);
expect(mockLogger.error).toHaveBeenCalledWith(
'Error finding tasks.json: Tasks file not found'
);
expect(mockCreateErrorResponse).toHaveBeenCalledWith(
'Failed to find tasks.json: Tasks file not found'
);
});
test('should handle complexity report path resolution errors gracefully', () => {
mockResolveComplexityReportPath.mockImplementationOnce(() => {
throw new Error('Complexity report not found');
});
const mockContext = {
log: mockLogger,
session: { workingDirectory: '/mock/dir' }
};
const args = {
status: 'pending',
projectRoot: '/mock/project'
};
executeFunction(args, mockContext);
// Should not fail the operation but set complexityReportPath to null
expect(mockListTasksDirect).toHaveBeenCalledWith(
expect.objectContaining({
reportPath: null
}),
mockLogger
);
});
test('should handle listTasksDirect errors', () => {
const errorResponse = {
success: false,
error: {
code: 'LIST_TASKS_ERROR',
message: 'Failed to list tasks'
}
};
mockListTasksDirect.mockReturnValueOnce(errorResponse);
const mockContext = {
log: mockLogger,
session: { workingDirectory: '/mock/dir' }
};
const args = {
status: 'pending',
projectRoot: '/mock/project'
};
executeFunction(args, mockContext);
expect(mockHandleApiResult).toHaveBeenCalledWith(
errorResponse,
mockLogger,
'Error getting tasks'
);
});
test('should handle unexpected errors', () => {
const testError = new Error('Unexpected error');
mockListTasksDirect.mockImplementationOnce(() => {
throw testError;
});
const mockContext = {
log: mockLogger,
session: { workingDirectory: '/mock/dir' }
};
const args = {
status: 'pending',
projectRoot: '/mock/project'
};
executeFunction(args, mockContext);
expect(mockLogger.error).toHaveBeenCalledWith(
'Error getting tasks: Unexpected error'
);
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Unexpected error');
});
test('should pass all parameters correctly', () => {
const mockContext = {
log: mockLogger,
session: { workingDirectory: '/mock/dir' }
};
const args = {
status: 'done,pending',
withSubtasks: true,
file: 'custom-tasks.json',
complexityReport: 'custom-report.json',
projectRoot: '/mock/project'
};
executeFunction(args, mockContext);
// Verify path resolution functions were called with correct arguments
expect(mockResolveTasksPath).toHaveBeenCalledWith(args, mockLogger);
expect(mockResolveComplexityReportPath).toHaveBeenCalledWith(
args,
mockContext.session
);
// Verify listTasksDirect was called with correct parameters
expect(mockListTasksDirect).toHaveBeenCalledWith(
{
tasksJsonPath: '/mock/project/tasks.json',
status: 'done,pending',
withSubtasks: true,
reportPath: '/mock/project/complexity-report.json'
},
mockLogger
);
});
test('should log task count after successful retrieval', () => {
const mockContext = {
log: mockLogger,
session: { workingDirectory: '/mock/dir' }
};
const args = {
status: 'pending',
projectRoot: '/mock/project'
};
executeFunction(args, mockContext);
expect(mockLogger.info).toHaveBeenCalledWith(
`Retrieved ${tasksResponse.data.tasks.length} tasks`
);
});
});