Parthy 43e0025f4c
fix: prevent tag corruption in bulk updates (#856)
* fix(task-manager): prevent tag corruption in bulk updates and add tag preservation test

- Fix writeJSON call in scripts/modules/task-manager/update-tasks.js (line 469) to include projectRoot and tag parameters.
- Ensure tagged task lists maintain data integrity during bulk updates, preventing task disappearance in tagged contexts.
- Update MCP tools to properly pass tag context through the call chain.
- Introduce a comprehensive test case to verify that all tags are preserved when updating tasks, covering both master and feature-branch scenarios.

Addresses an issue where bulk updates could corrupt tasks.json in tagged task list structures, reinforcing task management robustness.

* style(tests): format task data in update-tasks test
2025-07-02 12:53:12 +02:00

362 lines
8.6 KiB
JavaScript

/**
* Tests for the update-tasks.js module
*/
import { jest } from '@jest/globals';
// Mock the dependencies before importing the module under test
jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({
readJSON: jest.fn(),
writeJSON: jest.fn(),
log: jest.fn(),
CONFIG: {
model: 'mock-claude-model',
maxTokens: 4000,
temperature: 0.7,
debug: false
},
sanitizePrompt: jest.fn((prompt) => prompt),
truncate: jest.fn((text) => text),
isSilentMode: jest.fn(() => false),
findTaskById: jest.fn(),
getCurrentTag: jest.fn(() => 'master'),
ensureTagMetadata: jest.fn((tagObj) => tagObj),
flattenTasksWithSubtasks: jest.fn((tasks) => tasks),
findProjectRoot: jest.fn(() => '/mock/project/root')
}));
jest.unstable_mockModule(
'../../../../../scripts/modules/ai-services-unified.js',
() => ({
generateTextService: jest.fn().mockResolvedValue({
mainResult: '[]', // mainResult is the text string directly
telemetryData: {}
})
})
);
jest.unstable_mockModule('../../../../../scripts/modules/ui.js', () => ({
getStatusWithColor: jest.fn((status) => status),
startLoadingIndicator: jest.fn(),
stopLoadingIndicator: jest.fn(),
displayAiUsageSummary: jest.fn()
}));
jest.unstable_mockModule(
'../../../../../scripts/modules/config-manager.js',
() => ({
getDebugFlag: jest.fn(() => false)
})
);
jest.unstable_mockModule(
'../../../../../scripts/modules/task-manager/generate-task-files.js',
() => ({
default: jest.fn().mockResolvedValue()
})
);
jest.unstable_mockModule(
'../../../../../scripts/modules/task-manager/models.js',
() => ({
getModelConfiguration: jest.fn(() => ({
model: 'mock-model',
maxTokens: 4000,
temperature: 0.7
}))
})
);
// Import the mocked modules
const { readJSON, writeJSON, log } = await import(
'../../../../../scripts/modules/utils.js'
);
const { generateTextService } = await import(
'../../../../../scripts/modules/ai-services-unified.js'
);
// Import the module under test
const { default: updateTasks } = await import(
'../../../../../scripts/modules/task-manager/update-tasks.js'
);
describe('updateTasks', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('should update tasks based on new context', async () => {
// Arrange
const mockTasksPath = '/mock/path/tasks.json';
const mockFromId = 2;
const mockPrompt = 'New project direction';
const mockInitialTasks = {
master: {
tasks: [
{
id: 1,
title: 'Old Task 1',
status: 'done',
details: 'Done details'
},
{
id: 2,
title: 'Old Task 2',
status: 'pending',
details: 'Old details 2'
},
{
id: 3,
title: 'Old Task 3',
status: 'in-progress',
details: 'Old details 3'
}
]
}
};
const mockUpdatedTasks = [
{
id: 2,
title: 'Updated Task 2',
status: 'pending',
details: 'New details 2 based on direction',
description: 'Updated description',
dependencies: [],
priority: 'medium',
testStrategy: 'Unit test the updated functionality',
subtasks: []
},
{
id: 3,
title: 'Updated Task 3',
status: 'pending',
details: 'New details 3 based on direction',
description: 'Updated description',
dependencies: [],
priority: 'medium',
testStrategy: 'Integration test the updated features',
subtasks: []
}
];
const mockApiResponse = {
mainResult: JSON.stringify(mockUpdatedTasks), // mainResult is the JSON string directly
telemetryData: {}
};
// Configure mocks - readJSON should return the resolved view with tasks at top level
readJSON.mockReturnValue({
...mockInitialTasks.master,
tag: 'master',
_rawTaggedData: mockInitialTasks
});
generateTextService.mockResolvedValue(mockApiResponse);
// Act
const result = await updateTasks(
mockTasksPath,
mockFromId,
mockPrompt,
false, // research
{ projectRoot: '/mock/path' }, // context
'json' // output format
);
// Assert
// 1. Read JSON called
expect(readJSON).toHaveBeenCalledWith(
mockTasksPath,
'/mock/path',
'master'
);
// 2. AI Service called with correct args
expect(generateTextService).toHaveBeenCalledWith(expect.any(Object));
// 3. Write JSON called with correctly merged tasks
expect(writeJSON).toHaveBeenCalledWith(
mockTasksPath,
expect.objectContaining({
_rawTaggedData: expect.objectContaining({
master: expect.objectContaining({
tasks: expect.arrayContaining([
expect.objectContaining({ id: 1 }),
expect.objectContaining({ id: 2, title: 'Updated Task 2' }),
expect.objectContaining({ id: 3, title: 'Updated Task 3' })
])
})
})
}),
'/mock/path',
'master'
);
// 4. Check return value
expect(result).toEqual(
expect.objectContaining({
success: true,
updatedTasks: mockUpdatedTasks,
telemetryData: {}
})
);
});
test('should handle no tasks to update', async () => {
// Arrange
const mockTasksPath = '/mock/path/tasks.json';
const mockFromId = 99; // Non-existent ID
const mockPrompt = 'Update non-existent tasks';
const mockInitialTasks = {
master: {
tasks: [
{ id: 1, status: 'done' },
{ id: 2, status: 'done' }
]
}
};
// Configure mocks - readJSON should return the resolved view with tasks at top level
readJSON.mockReturnValue({
...mockInitialTasks.master,
tag: 'master',
_rawTaggedData: mockInitialTasks
});
// Act
const result = await updateTasks(
mockTasksPath,
mockFromId,
mockPrompt,
false,
{ projectRoot: '/mock/path' },
'json'
);
// Assert
expect(readJSON).toHaveBeenCalledWith(
mockTasksPath,
'/mock/path',
'master'
);
expect(generateTextService).not.toHaveBeenCalled();
expect(writeJSON).not.toHaveBeenCalled();
expect(log).toHaveBeenCalledWith(
'info',
expect.stringContaining('No tasks to update')
);
// Should return early with no updates
expect(result).toBeUndefined();
});
test('should preserve all tags when updating tasks in tagged context', async () => {
// Arrange - Simple 2-tag structure to test tag corruption fix
const mockTasksPath = '/mock/path/tasks.json';
const mockFromId = 1;
const mockPrompt = 'Update master tag tasks';
const mockTaggedData = {
master: {
tasks: [
{
id: 1,
title: 'Master Task',
status: 'pending',
details: 'Old details'
},
{
id: 2,
title: 'Master Task 2',
status: 'done',
details: 'Done task'
}
],
metadata: {
created: '2024-01-01T00:00:00.000Z',
description: 'Master tag tasks'
}
},
'feature-branch': {
tasks: [
{
id: 1,
title: 'Feature Task',
status: 'pending',
details: 'Feature work'
}
],
metadata: {
created: '2024-01-02T00:00:00.000Z',
description: 'Feature branch tasks'
}
}
};
const mockUpdatedTasks = [
{
id: 1,
title: 'Updated Master Task',
status: 'pending',
details: 'Updated details',
description: 'Updated description',
dependencies: [],
priority: 'medium',
testStrategy: 'Test the updated functionality',
subtasks: []
}
];
// Configure mocks - readJSON returns resolved view for master tag
readJSON.mockReturnValue({
...mockTaggedData.master,
tag: 'master',
_rawTaggedData: mockTaggedData
});
generateTextService.mockResolvedValue({
mainResult: JSON.stringify(mockUpdatedTasks),
telemetryData: { commandName: 'update-tasks', totalCost: 0.05 }
});
// Act
const result = await updateTasks(
mockTasksPath,
mockFromId,
mockPrompt,
false, // research
{ projectRoot: '/mock/project/root', tag: 'master' },
'json'
);
// Assert - CRITICAL: Both tags must be preserved (this would fail before the fix)
expect(writeJSON).toHaveBeenCalledWith(
mockTasksPath,
expect.objectContaining({
_rawTaggedData: expect.objectContaining({
master: expect.objectContaining({
tasks: expect.arrayContaining([
expect.objectContaining({ id: 1, title: 'Updated Master Task' }),
expect.objectContaining({ id: 2, title: 'Master Task 2' }) // Unchanged done task
])
}),
// CRITICAL: This tag would be missing/corrupted if the bug existed
'feature-branch': expect.objectContaining({
tasks: expect.arrayContaining([
expect.objectContaining({ id: 1, title: 'Feature Task' })
]),
metadata: expect.objectContaining({
description: 'Feature branch tasks'
})
})
})
}),
'/mock/project/root',
'master'
);
expect(result.success).toBe(true);
expect(result.updatedTasks).toEqual(mockUpdatedTasks);
});
});