callumalpass_tasknotes/tests/unit/services/TaskService.test.ts
callumalpass da854a0ff0 test: fix TaskService test mock for addDTSTARTToRecurrenceRule
The mock function signature was incorrect - it accepted a string but the
actual function accepts a TaskInfo object. Updated the mock to match the
real function signature and adjusted the test expectation to expect the
DTSTART prefix in the recurrence output.
2026-02-08 11:41:38 +11:00

1148 lines
No EOL
41 KiB
TypeScript

/**
* TaskService Unit Tests
*
* Tests for the core TaskService functionality including:
* - Task creation with various scenarios
* - Task property updates
* - Status toggling and completion handling
* - Time tracking operations
* - Bulk task updates
* - Task deletion
* - Error handling and edge cases
*/
import { FileSystemFactory, PluginFactory, TaskFactory } from '../../helpers/mock-factories';
import { MockObsidian, TFile } from '../../__mocks__/obsidian';
import { TaskCreationData, TaskService } from '../../../src/services/TaskService';
import { TaskInfo, TimeEntry } from '../../../src/types';
// Mock external dependencies
jest.mock('../../../src/utils/dateUtils', () => ({
getCurrentTimestamp: jest.fn(() => '2025-01-01T12:00:00Z'),
getCurrentDateString: jest.fn(() => '2025-01-01')
}));
jest.mock('../../../src/utils/filenameGenerator', () => ({
generateTaskFilename: jest.fn((context) => `${context.title.toLowerCase().replace(/\s+/g, '-')}`),
generateUniqueFilename: jest.fn((base) => base)
}));
jest.mock('../../../src/utils/helpers', () => ({
ensureFolderExists: jest.fn().mockResolvedValue(undefined),
calculateDefaultDate: jest.fn().mockImplementation((option: string) => {
switch (option) {
case 'today': return '2025-01-15';
case 'tomorrow': return '2025-01-16';
case 'next-week': return '2025-01-22';
default: return undefined;
}
}),
addDTSTARTToRecurrenceRule: jest.fn((task: { recurrence?: string }) => task.recurrence ? `DTSTART:20250110T120000Z;${task.recurrence}` : null),
updateDTSTARTInRecurrenceRule: jest.fn((rule: string) => rule),
updateToNextScheduledOccurrence: jest.fn(),
splitFrontmatterAndBody: jest.fn(() => ({ frontmatter: {}, body: '' }))
}));
jest.mock('../../../src/utils/templateProcessor', () => ({
processTemplate: jest.fn(() => ({
frontmatter: {},
body: 'Template content'
})),
mergeTemplateFrontmatter: jest.fn((base, template) => ({ ...base, ...template }))
}));
describe('TaskService', () => {
let taskService: TaskService;
let mockPlugin: any;
beforeEach(() => {
// Reset all mocks
jest.clearAllMocks();
MockObsidian.reset();
// Create mock plugin with enhanced services
mockPlugin = PluginFactory.createMockPlugin({
statusManager: {
isCompletedStatus: jest.fn((status) => status === 'done'),
getCompletedStatuses: jest.fn(() => ['done', 'completed'])
},
getActiveTimeSession: jest.fn(),
cacheManager: {
updateTaskInfoInCache: jest.fn().mockResolvedValue(undefined),
getTaskInfo: jest.fn(),
clearCacheEntry: jest.fn()
}
});
// Add getActiveFile method to workspace mock
mockPlugin.app.workspace.getActiveFile = jest.fn().mockReturnValue(null);
taskService = new TaskService(mockPlugin);
});
describe('createTask', () => {
it('should create a basic task with minimal data', async () => {
const taskData: TaskCreationData = {
title: 'Test Task'
};
const { file, taskInfo } = await taskService.createTask(taskData);
expect(file).toBeInstanceOf(TFile);
expect(taskInfo).toMatchObject({
title: 'Test Task',
status: 'open',
priority: 'normal',
archived: false
});
expect(taskInfo.path).toMatch(/test-task\.md$/);
expect(taskInfo.dateCreated).toBe('2025-01-01T12:00:00Z');
expect(taskInfo.dateModified).toBe('2025-01-01T12:00:00Z');
});
it('should create a task with all properties', async () => {
const taskData: TaskCreationData = {
title: 'Complex Task',
status: 'in-progress',
priority: 'high',
due: '2025-01-15',
scheduled: '2025-01-10',
contexts: ['work', 'urgent'],
timeEstimate: 120,
recurrence: 'FREQ=DAILY;INTERVAL=1',
details: 'Task description'
};
const { file, taskInfo } = await taskService.createTask(taskData);
expect(taskInfo).toMatchObject({
title: 'Complex Task',
status: 'in-progress',
priority: 'high',
due: '2025-01-15',
scheduled: '2025-01-10',
contexts: ['work', 'urgent'],
timeEstimate: 120,
recurrence: 'DTSTART:20250110T120000Z;FREQ=DAILY;INTERVAL=1'
});
// With default tag-based identification, task tag should be included
expect(taskInfo.tags).toContain('task');
});
it('should handle default folder configuration', async () => {
mockPlugin.settings.tasksFolder = 'Projects/Tasks';
const taskData: TaskCreationData = {
title: 'Folder Test Task'
};
await taskService.createTask(taskData);
expect(mockPlugin.app.vault.create).toHaveBeenCalledWith(
'Projects/Tasks/folder-test-task.md',
expect.stringContaining('title: Folder Test Task')
);
});
it('should apply template when configured', async () => {
mockPlugin.settings.taskCreationDefaults.useBodyTemplate = true;
mockPlugin.settings.taskCreationDefaults.bodyTemplate = 'templates/task-template.md';
const mockTemplateFile = new TFile('templates/task-template.md');
mockPlugin.app.vault.getAbstractFileByPath.mockReturnValue(mockTemplateFile);
mockPlugin.app.vault.read.mockResolvedValue('Template content with {{title}}');
const taskData: TaskCreationData = {
title: 'Template Task',
details: 'Custom details'
};
await taskService.createTask(taskData);
expect(mockPlugin.app.vault.read).toHaveBeenCalledWith(mockTemplateFile);
});
it('should handle inline conversion context with currentNotePath variable', async () => {
mockPlugin.settings.inlineTaskConvertFolder = 'Tasks/{{currentNotePath}}';
const mockCurrentFile = new TFile('Projects/MyProject/note.md');
mockCurrentFile.parent = { path: 'Projects/MyProject' } as any;
mockPlugin.app.workspace.getActiveFile.mockReturnValue(mockCurrentFile);
const taskData: TaskCreationData = {
title: 'Inline Task',
creationContext: 'inline-conversion'
};
await taskService.createTask(taskData);
expect(mockPlugin.app.vault.create).toHaveBeenCalledWith(
'Tasks/Projects/MyProject/inline-task.md',
expect.stringContaining('title: Inline Task')
);
});
it('should handle inline conversion context without currentNotePath variable', async () => {
mockPlugin.settings.inlineTaskConvertFolder = 'InlineTasks';
const taskData: TaskCreationData = {
title: 'Simple Inline Task',
creationContext: 'inline-conversion'
};
await taskService.createTask(taskData);
expect(mockPlugin.app.vault.create).toHaveBeenCalledWith(
'InlineTasks/simple-inline-task.md',
expect.stringContaining('title: Simple Inline Task')
);
});
it('should handle inline conversion context with currentNotePath when no active file', async () => {
mockPlugin.settings.inlineTaskConvertFolder = 'Tasks/{{currentNotePath}}';
mockPlugin.app.workspace.getActiveFile.mockReturnValue(null);
const taskData: TaskCreationData = {
title: 'No File Context Task',
creationContext: 'inline-conversion'
};
await taskService.createTask(taskData);
expect(mockPlugin.app.vault.create).toHaveBeenCalledWith(
'Tasks//no-file-context-task.md',
expect.stringContaining('title: No File Context Task')
);
});
it('should handle inline conversion context with currentNotePath when file has no parent', async () => {
mockPlugin.settings.inlineTaskConvertFolder = 'Tasks/{{currentNotePath}}';
const mockCurrentFile = new TFile('note.md');
// Don't set parent - this will be undefined
mockPlugin.app.workspace.getActiveFile.mockReturnValue(mockCurrentFile);
const taskData: TaskCreationData = {
title: 'Root File Task',
creationContext: 'inline-conversion'
};
await taskService.createTask(taskData);
expect(mockPlugin.app.vault.create).toHaveBeenCalledWith(
'Tasks//root-file-task.md',
expect.stringContaining('title: Root File Task')
);
});
it('should handle inline conversion context with currentNoteTitle variable', async () => {
mockPlugin.settings.inlineTaskConvertFolder = 'Tasks/{{currentNoteTitle}}';
const mockCurrentFile = new TFile('Projects/MyProject/HAB NB 1.md');
Object.defineProperty(mockCurrentFile, 'basename', { value: 'HAB NB 1', writable: false });
mockPlugin.app.workspace.getActiveFile.mockReturnValue(mockCurrentFile);
const taskData: TaskCreationData = {
title: 'Task in note specific folder',
creationContext: 'inline-conversion'
};
await taskService.createTask(taskData);
expect(mockPlugin.app.vault.create).toHaveBeenCalledWith(
'Tasks/HAB NB 1/task-in-note-specific-folder.md',
expect.stringContaining('title: Task in note specific folder')
);
});
it('should handle inline conversion context with both currentNotePath and currentNoteTitle variables', async () => {
mockPlugin.settings.inlineTaskConvertFolder = '{{currentNotePath}}/{{currentNoteTitle}}';
const mockCurrentFile = new TFile('Projects/MyProject/HAB NB 1.md');
Object.defineProperty(mockCurrentFile, 'basename', { value: 'HAB NB 1', writable: false });
mockCurrentFile.parent = { path: 'Projects/MyProject' } as any;
mockPlugin.app.workspace.getActiveFile.mockReturnValue(mockCurrentFile);
const taskData: TaskCreationData = {
title: 'Combined template task',
creationContext: 'inline-conversion'
};
await taskService.createTask(taskData);
expect(mockPlugin.app.vault.create).toHaveBeenCalledWith(
'Projects/MyProject/HAB NB 1/combined-template-task.md',
expect.stringContaining('title: Combined template task')
);
});
it('should handle inline conversion context with currentNoteTitle when no active file', async () => {
mockPlugin.settings.inlineTaskConvertFolder = 'Tasks/{{currentNoteTitle}}';
mockPlugin.app.workspace.getActiveFile.mockReturnValue(null);
const taskData: TaskCreationData = {
title: 'No active file task',
creationContext: 'inline-conversion'
};
await taskService.createTask(taskData);
expect(mockPlugin.app.vault.create).toHaveBeenCalledWith(
'Tasks//no-active-file-task.md',
expect.stringContaining('title: No active file task')
);
});
// Issue #1334: Support {{currentNotePath}} for modal-inline-creation context (Create new inline task command)
// The 'Create New Inline Task' command now uses 'modal-inline-creation' context (fixed in #1424)
// to distinguish it from 'manual-creation' (Create New Task command).
it('should handle modal-inline-creation context with currentNotePath variable (#1334)', async () => {
// modal-inline-creation uses inlineTaskConvertFolder with variable support
mockPlugin.settings.inlineTaskConvertFolder = 'Tasks/{{currentNotePath}}';
const mockCurrentFile = new TFile('Projects/MyProject/note.md');
mockCurrentFile.parent = { path: 'Projects/MyProject' } as any;
mockPlugin.app.workspace.getActiveFile.mockReturnValue(mockCurrentFile);
const taskData: TaskCreationData = {
title: 'Modal Inline Task',
creationContext: 'modal-inline-creation' // Create New Inline Task command uses this
};
await taskService.createTask(taskData);
// modal-inline-creation respects inlineTaskConvertFolder with {{currentNotePath}}
expect(mockPlugin.app.vault.create).toHaveBeenCalledWith(
'Tasks/Projects/MyProject/modal-inline-task.md',
expect.stringContaining('title: Modal Inline Task')
);
});
// Issue #1424: Create New Task command should use default task folder (tasksFolder), not inlineTaskConvertFolder
// The "Create New Task" command (via TaskCreationModal) should create tasks in the configured default folder,
// NOT in the active folder. Fixed by introducing 'modal-inline-creation' context for Create New Inline Task.
//
// The distinction:
// - "Create New Task" command: Uses 'manual-creation' -> tasksFolder (default task folder setting)
// - "Create New Inline Task" command: Uses 'modal-inline-creation' -> inlineTaskConvertFolder with {{currentNotePath}} support
// - Checkbox conversion: Uses 'inline-conversion' -> inlineTaskConvertFolder with {{currentNotePath}} support
it('should use default task folder for Create New Task command, not inlineTaskConvertFolder (#1424)', async () => {
// Configure both folders - this is the scenario described in the bug
mockPlugin.settings.tasksFolder = 'TaskNotes/Tasks'; // Default folder setting
mockPlugin.settings.inlineTaskConvertFolder = '{{currentNotePath}}'; // Inline conversion setting
// User has a note open somewhere else
const mockCurrentFile = new TFile('Projects/MyProject/meeting-notes.md');
mockCurrentFile.parent = { path: 'Projects/MyProject' } as any;
mockPlugin.app.workspace.getActiveFile.mockReturnValue(mockCurrentFile);
// TaskCreationModal (Create New Task command) sets creationContext: 'manual-creation'
const taskData: TaskCreationData = {
title: 'Buy groceries',
creationContext: 'manual-creation'
};
await taskService.createTask(taskData);
// FIXED: Now correctly creates in 'TaskNotes/Tasks/buy-groceries.md' (default folder)
// Previously incorrectly created in 'Projects/MyProject/buy-groceries.md' (active folder)
expect(mockPlugin.app.vault.create).toHaveBeenCalledWith(
'TaskNotes/Tasks/buy-groceries.md',
expect.stringContaining('title: Buy groceries')
);
});
// Additional test to clarify the expected behavior difference between command types
it('should still use inlineTaskConvertFolder for inline-conversion context (#1424)', async () => {
// Configure both folders
mockPlugin.settings.tasksFolder = 'TaskNotes/Tasks'; // Default folder setting
mockPlugin.settings.inlineTaskConvertFolder = '{{currentNotePath}}'; // Inline conversion setting
// User has a note open
const mockCurrentFile = new TFile('Projects/MyProject/meeting-notes.md');
mockCurrentFile.parent = { path: 'Projects/MyProject' } as any;
mockPlugin.app.workspace.getActiveFile.mockReturnValue(mockCurrentFile);
// Inline conversion (checkbox to task) sets creationContext: 'inline-conversion'
const taskData: TaskCreationData = {
title: 'Review proposal',
creationContext: 'inline-conversion'
};
await taskService.createTask(taskData);
// Inline conversion SHOULD use inlineTaskConvertFolder with {{currentNotePath}}
expect(mockPlugin.app.vault.create).toHaveBeenCalledWith(
'Projects/MyProject/review-proposal.md',
expect.stringContaining('title: Review proposal')
);
});
// Test for modal-inline-creation context (Create New Inline Task command)
it('should use inlineTaskConvertFolder for modal-inline-creation context (#1424)', async () => {
// Configure both folders
mockPlugin.settings.tasksFolder = 'TaskNotes/Tasks'; // Default folder setting
mockPlugin.settings.inlineTaskConvertFolder = '{{currentNotePath}}'; // Inline conversion setting
// User has a note open
const mockCurrentFile = new TFile('Projects/MyProject/meeting-notes.md');
mockCurrentFile.parent = { path: 'Projects/MyProject' } as any;
mockPlugin.app.workspace.getActiveFile.mockReturnValue(mockCurrentFile);
// Create New Inline Task command sets creationContext: 'modal-inline-creation'
const taskData: TaskCreationData = {
title: 'Follow up on meeting',
creationContext: 'modal-inline-creation'
};
await taskService.createTask(taskData);
// modal-inline-creation uses inlineTaskConvertFolder with {{currentNotePath}}
expect(mockPlugin.app.vault.create).toHaveBeenCalledWith(
'Projects/MyProject/follow-up-on-meeting.md',
expect.stringContaining('title: Follow up on meeting')
);
});
it('should handle project template variables correctly by extracting basenames from wikilinks', async () => {
mockPlugin.settings.tasksFolder = 'projects/{{context}}/{{project}}/tasks';
// Mock metadataCache to resolve project links
const mockFile = { basename: 'testProject Root' };
mockPlugin.app.metadataCache.getFirstLinkpathDest = jest.fn().mockReturnValue(mockFile);
const taskData: TaskCreationData = {
title: 'Test Task with Project Link',
contexts: ['testContext'],
projects: ['[[projects/testContext/testProject/testProject Root]]']
};
await taskService.createTask(taskData);
expect(mockPlugin.app.vault.create).toHaveBeenCalledWith(
'projects/testContext/testProject Root/tasks/test-task-with-project-link.md',
expect.stringContaining('title: Test Task with Project Link')
);
});
it('should handle project template variables with pipe syntax correctly', async () => {
mockPlugin.settings.tasksFolder = 'projects/{{project}}/tasks';
const taskData: TaskCreationData = {
title: 'Test Task with Pipe Syntax',
projects: ['[[long/path/to/project|Display Name]]']
};
await taskService.createTask(taskData);
expect(mockPlugin.app.vault.create).toHaveBeenCalledWith(
'projects/Display Name/tasks/test-task-with-pipe-syntax.md',
expect.stringContaining('title: Test Task with Pipe Syntax')
);
});
it('should handle project template variables for non-wikilink projects', async () => {
mockPlugin.settings.tasksFolder = 'projects/{{project}}/tasks';
const taskData: TaskCreationData = {
title: 'Test Task with Simple Project',
projects: ['Simple Project Name']
};
await taskService.createTask(taskData);
expect(mockPlugin.app.vault.create).toHaveBeenCalledWith(
'projects/Simple Project Name/tasks/test-task-with-simple-project.md',
expect.stringContaining('title: Test Task with Simple Project')
);
});
it('should validate required fields', async () => {
await expect(taskService.createTask({ title: '' })).rejects.toThrow('Title is required');
await expect(taskService.createTask({ title: ' ' })).rejects.toThrow('Title is required');
});
it('should accept long titles without error', async () => {
const longTitle = 'A'.repeat(201);
const result = await taskService.createTask({ title: longTitle });
expect(result.taskInfo.title).toBe(longTitle);
});
it('should ensure task tag is included when using tag-based identification', async () => {
// Ensure we're using tag-based identification (default)
mockPlugin.settings.taskIdentificationMethod = 'tag';
const taskData: TaskCreationData = {
title: 'Tag Test Task',
tags: ['custom', 'tags']
};
const { taskInfo } = await taskService.createTask(taskData);
expect(taskInfo.tags).toEqual(['task', 'custom', 'tags']);
});
it('should use property-based identification when configured', async () => {
// Configure property-based identification
mockPlugin.settings.taskIdentificationMethod = 'property';
mockPlugin.settings.taskPropertyName = 'category';
mockPlugin.settings.taskPropertyValue = '[[Tasks]]';
const taskData: TaskCreationData = {
title: 'Property Test Task',
tags: ['custom', 'tags']
};
const { taskInfo } = await taskService.createTask(taskData);
// Should NOT include task tag in tags array
expect(taskInfo.tags).toEqual(['custom', 'tags']);
});
it('should not add task tag when using property identification with no custom tags', async () => {
// Configure property-based identification
mockPlugin.settings.taskIdentificationMethod = 'property';
mockPlugin.settings.taskPropertyName = 'type';
mockPlugin.settings.taskPropertyValue = 'task';
const taskData: TaskCreationData = {
title: 'Property No Tags Test',
// No tags provided
};
const { taskInfo } = await taskService.createTask(taskData);
// Should have empty tags array (no task tag added)
expect(taskInfo.tags).toEqual([]);
});
it('should handle template processing errors gracefully', async () => {
mockPlugin.settings.taskCreationDefaults.useBodyTemplate = true;
mockPlugin.settings.taskCreationDefaults.bodyTemplate = 'nonexistent-template.md';
mockPlugin.app.vault.getAbstractFileByPath.mockReturnValue(null);
const taskData: TaskCreationData = {
title: 'Template Error Task'
};
// Should not throw, but should log warning
const { taskInfo } = await taskService.createTask(taskData);
expect(taskInfo.title).toBe('Template Error Task');
});
it('should emit task creation event', async () => {
const taskData: TaskCreationData = {
title: 'Event Test Task'
};
await taskService.createTask(taskData);
expect(mockPlugin.emitter.trigger).toHaveBeenCalledWith('task-updated', {
path: expect.stringMatching(/event-test-task\.md$/),
updatedTask: expect.objectContaining({ title: 'Event Test Task' })
});
});
it('should update cache proactively', async () => {
const taskData: TaskCreationData = {
title: 'Cache Test Task'
};
const { taskInfo } = await taskService.createTask(taskData);
expect(mockPlugin.cacheManager.updateTaskInfoInCache).toHaveBeenCalledWith(
taskInfo.path,
taskInfo
);
});
it('should coerce boolean-like property value to boolean true in frontmatter when using property identification', async () => {
// Configure property-based identification with boolean-like string
mockPlugin.settings.taskIdentificationMethod = 'property';
mockPlugin.settings.taskPropertyName = 'isTask';
mockPlugin.settings.taskPropertyValue = 'true';
// Spy on stringifyYaml to capture the frontmatter object passed during file creation
const obsidian = require('obsidian');
const yamlSpy = jest.spyOn(obsidian, 'stringifyYaml');
await taskService.createTask({ title: 'Boolean Property Task' });
expect(yamlSpy).toHaveBeenCalled();
const fmArg = yamlSpy.mock.calls[0][0] as any;
expect(typeof fmArg.isTask).toBe('boolean');
expect(fmArg.isTask).toBe(true);
yamlSpy.mockRestore();
});
it('should coerce boolean-like property value to boolean false in frontmatter when using property identification', async () => {
// Configure property-based identification with boolean-like string
mockPlugin.settings.taskIdentificationMethod = 'property';
mockPlugin.settings.taskPropertyName = 'isTask';
mockPlugin.settings.taskPropertyValue = 'false';
const obsidian = require('obsidian');
const yamlSpy = jest.spyOn(obsidian, 'stringifyYaml');
await taskService.createTask({ title: 'Boolean Property False Task' });
expect(yamlSpy).toHaveBeenCalled();
const fmArg = yamlSpy.mock.calls[0][0] as any;
expect(typeof fmArg.isTask).toBe('boolean');
expect(fmArg.isTask).toBe(false);
yamlSpy.mockRestore();
});
it('should write boolean status "true" as boolean true in frontmatter', async () => {
const obsidian = require('obsidian');
const yamlSpy = jest.spyOn(obsidian, 'stringifyYaml');
const taskData: TaskCreationData = {
title: 'Boolean Status Task',
status: 'true'
};
await taskService.createTask(taskData);
expect(yamlSpy).toHaveBeenCalled();
const fmArg = yamlSpy.mock.calls[0][0] as any;
expect(typeof fmArg.status).toBe('boolean');
expect(fmArg.status).toBe(true);
yamlSpy.mockRestore();
});
it('should write boolean status "false" as boolean false in frontmatter', async () => {
const obsidian = require('obsidian');
const yamlSpy = jest.spyOn(obsidian, 'stringifyYaml');
const taskData: TaskCreationData = {
title: 'Boolean Status False Task',
status: 'false'
};
await taskService.createTask(taskData);
expect(yamlSpy).toHaveBeenCalled();
const fmArg = yamlSpy.mock.calls[0][0] as any;
expect(typeof fmArg.status).toBe('boolean');
expect(fmArg.status).toBe(false);
yamlSpy.mockRestore();
});
it('should write regular status values as strings in frontmatter', async () => {
const obsidian = require('obsidian');
const yamlSpy = jest.spyOn(obsidian, 'stringifyYaml');
const taskData: TaskCreationData = {
title: 'Regular Status Task',
status: 'in-progress'
};
await taskService.createTask(taskData);
expect(yamlSpy).toHaveBeenCalled();
const fmArg = yamlSpy.mock.calls[0][0] as any;
expect(typeof fmArg.status).toBe('string');
expect(fmArg.status).toBe('in-progress');
yamlSpy.mockRestore();
});
});
describe('toggleStatus', () => {
it('should toggle from open to completed status', async () => {
const task = TaskFactory.createTask({ status: 'open' });
const result = await taskService.toggleStatus(task);
expect(result.status).toBe('done');
});
it('should toggle from completed to open status', async () => {
const task = TaskFactory.createTask({ status: 'done' });
mockPlugin.statusManager.isCompletedStatus.mockReturnValue(true);
const result = await taskService.toggleStatus(task);
expect(result.status).toBe('open');
});
it('should use first completed status when toggling to completed', async () => {
const task = TaskFactory.createTask({ status: 'open' });
mockPlugin.statusManager.getCompletedStatuses.mockReturnValue(['completed', 'done']);
const result = await taskService.toggleStatus(task);
expect(result.status).toBe('completed');
});
it('should handle missing completed statuses gracefully', async () => {
const task = TaskFactory.createTask({ status: 'open' });
mockPlugin.statusManager.getCompletedStatuses.mockReturnValue([]);
const result = await taskService.toggleStatus(task);
expect(result.status).toBe('done'); // fallback
});
});
describe('updateProperty', () => {
let task: TaskInfo;
let mockFile: TFile;
beforeEach(() => {
task = TaskFactory.createTask();
mockFile = new TFile(task.path);
mockPlugin.app.vault.getAbstractFileByPath.mockReturnValue(mockFile);
mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(task);
});
it('should update a single property', async () => {
const result = await taskService.updateProperty(task, 'priority', 'high');
expect(result.priority).toBe('high');
expect(result.dateModified).toBe('2025-01-01T12:00:00Z');
});
it('should handle status updates with completion date for non-recurring tasks', async () => {
const nonRecurringTask = TaskFactory.createTask({ recurrence: undefined });
const result = await taskService.updateProperty(nonRecurringTask, 'status', 'done');
expect(result.status).toBe('done');
expect(result.completedDate).toBe('2025-01-01');
});
it('should not set completion date for recurring tasks', async () => {
const recurringTask = TaskFactory.createTask({ recurrence: 'FREQ=DAILY' });
mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(recurringTask);
const result = await taskService.updateProperty(recurringTask, 'status', 'done');
expect(result.status).toBe('done');
expect(result.completedDate).toBeUndefined();
});
it('should clear completion date when marking as incomplete', async () => {
const completedTask = TaskFactory.createTask({
status: 'done',
completedDate: '2025-01-01'
});
const result = await taskService.updateProperty(completedTask, 'status', 'open');
expect(result.status).toBe('open');
expect(result.completedDate).toBeUndefined();
});
it('should remove empty due/scheduled dates', async () => {
await taskService.updateProperty(task, 'due', undefined);
expect(mockPlugin.app.fileManager.processFrontMatter).toHaveBeenCalledWith(
mockFile,
expect.any(Function)
);
});
it('should use fresh task data to prevent overwrites', async () => {
const freshTask = { ...task, priority: 'medium' };
mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(freshTask);
const result = await taskService.updateProperty(task, 'status', 'done');
expect(result.priority).toBe('medium'); // from fresh data
expect(result.status).toBe('done'); // from update
});
it('should handle file not found error', async () => {
mockPlugin.app.vault.getAbstractFileByPath.mockReturnValue(null);
await expect(taskService.updateProperty(task, 'status', 'done'))
.rejects.toThrow('Cannot find task file');
});
it('should handle cache errors gracefully', async () => {
mockPlugin.cacheManager.updateTaskInfoInCache.mockImplementation(() => {
throw new Error('Cache error');
});
// Should not throw, just log error
const result = await taskService.updateProperty(task, 'priority', 'high');
expect(result.priority).toBe('high');
});
it('should handle event emission errors gracefully', async () => {
mockPlugin.emitter.trigger.mockImplementation(() => {
throw new Error('Event error');
});
// Should not throw, just log error
const result = await taskService.updateProperty(task, 'priority', 'high');
expect(result.priority).toBe('high');
});
});
describe('toggleArchive', () => {
let task: TaskInfo;
let mockFile: TFile;
beforeEach(() => {
task = TaskFactory.createTask({ archived: false, tags: ['task'] });
mockFile = new TFile(task.path);
mockPlugin.app.vault.getAbstractFileByPath.mockReturnValue(mockFile);
});
it('should archive an unarchived task', async () => {
const result = await taskService.toggleArchive(task);
expect(result.archived).toBe(true);
expect(result.tags).toContain('archived');
});
it('should unarchive an archived task', async () => {
const archivedTask = TaskFactory.createTask({
archived: true,
tags: ['task', 'archived']
});
const result = await taskService.toggleArchive(archivedTask);
expect(result.archived).toBe(false);
expect(result.tags).not.toContain('archived');
});
it('should handle tasks without existing tags', async () => {
const taskWithoutTags = TaskFactory.createTask({ tags: undefined });
const result = await taskService.toggleArchive(taskWithoutTags);
expect(result.tags).toEqual(['archived']);
});
it('should use custom archive tag from field mapping', async () => {
// Update the fieldMapper to use a custom archive tag
const customMapping = {
...mockPlugin.fieldMapper.getMapping(),
archiveTag: 'custom-archived'
};
mockPlugin.fieldMapper.updateMapping(customMapping);
const result = await taskService.toggleArchive(task);
expect(result.tags).toContain('custom-archived');
});
});
describe('startTimeTracking', () => {
let task: TaskInfo;
let mockFile: TFile;
beforeEach(() => {
task = TaskFactory.createTask();
mockFile = new TFile(task.path);
mockPlugin.app.vault.getAbstractFileByPath.mockReturnValue(mockFile);
mockPlugin.getActiveTimeSession.mockReturnValue(null);
});
it('should start time tracking for a task', async () => {
const result = await taskService.startTimeTracking(task);
expect(result.timeEntries).toHaveLength(1);
expect(result.timeEntries![0]).toMatchObject({
startTime: '2025-01-01T12:00:00Z',
description: 'Work session'
});
expect(result.timeEntries![0].endTime).toBeUndefined();
});
it('should add to existing time entries', async () => {
const taskWithEntries = TaskFactory.createTaskWithTimeTracking();
const existingCount = taskWithEntries.timeEntries?.length || 0;
const result = await taskService.startTimeTracking(taskWithEntries);
expect(result.timeEntries).toHaveLength(existingCount + 1);
});
it('should prevent starting when already tracking', async () => {
const activeSession = { startTime: '2025-01-01T11:00:00Z' };
mockPlugin.getActiveTimeSession.mockReturnValue(activeSession);
await expect(taskService.startTimeTracking(task))
.rejects.toThrow('Time tracking is already active for this task');
});
it('should handle file not found error', async () => {
mockPlugin.app.vault.getAbstractFileByPath.mockReturnValue(null);
await expect(taskService.startTimeTracking(task))
.rejects.toThrow('Cannot find task file');
});
});
describe('stopTimeTracking', () => {
let task: TaskInfo;
let mockFile: TFile;
let activeSession: TimeEntry;
beforeEach(() => {
activeSession = {
startTime: '2025-01-01T11:00:00Z',
description: 'Active session'
};
task = TaskFactory.createTask({
timeEntries: [activeSession]
});
mockFile = new TFile(task.path);
mockPlugin.app.vault.getAbstractFileByPath.mockReturnValue(mockFile);
mockPlugin.getActiveTimeSession.mockReturnValue(activeSession);
});
it('should stop active time tracking', async () => {
const result = await taskService.stopTimeTracking(task);
expect(result.timeEntries![0]).toMatchObject({
startTime: '2025-01-01T11:00:00Z',
endTime: '2025-01-01T12:00:00Z',
description: 'Active session'
});
});
it('should prevent stopping when not tracking', async () => {
mockPlugin.getActiveTimeSession.mockReturnValue(null);
await expect(taskService.stopTimeTracking(task))
.rejects.toThrow('No active time tracking session for this task');
});
it('should handle missing time entries array', async () => {
const taskWithoutEntries = TaskFactory.createTask({ timeEntries: undefined });
// Should not throw but won't find entry to update
await taskService.stopTimeTracking(taskWithoutEntries);
});
});
describe('updateTask', () => {
let task: TaskInfo;
let mockFile: TFile;
beforeEach(() => {
task = TaskFactory.createTask();
mockFile = new TFile(task.path);
mockPlugin.app.vault.getAbstractFileByPath.mockReturnValue(mockFile);
});
it('should update multiple properties', async () => {
const updates = {
title: 'Updated Task',
priority: 'high',
status: 'in-progress'
};
const result = await taskService.updateTask(task, updates);
expect(result).toMatchObject(updates);
expect(result.dateModified).toBe('2025-01-01T12:00:00Z');
});
it('should handle completion date for status changes', async () => {
const updates = { status: 'done' };
const result = await taskService.updateTask(task, updates);
expect(result.status).toBe('done');
expect(result.completedDate).toBe('2025-01-01');
});
it('should preserve complete_instances for recurring tasks', async () => {
const recurringTask = TaskFactory.createRecurringTask('FREQ=DAILY', {
complete_instances: ['2024-12-30', '2024-12-31']
});
const result = await taskService.updateTask(recurringTask, { priority: 'high' });
expect(result.complete_instances).toEqual(['2024-12-30', '2024-12-31']);
});
it('should remove undefined fields from frontmatter', async () => {
const updates = {
due: undefined,
scheduled: undefined,
timeEstimate: undefined
};
await taskService.updateTask(task, updates);
// Verify that processFrontMatter callback removes these fields
expect(mockPlugin.app.fileManager.processFrontMatter).toHaveBeenCalled();
});
it('should preserve tags when not being updated', async () => {
const taskWithTags = TaskFactory.createTask({ tags: ['task', 'important'] });
const updates = { priority: 'high' };
const result = await taskService.updateTask(taskWithTags, updates);
expect(result.tags).toEqual(['task', 'important']);
});
it('should handle cache and event errors gracefully', async () => {
mockPlugin.cacheManager.updateTaskInfoInCache.mockImplementation(() => {
throw new Error('Cache error');
});
mockPlugin.emitter.trigger.mockImplementation(() => {
throw new Error('Event error');
});
const updates = { priority: 'high' };
const result = await taskService.updateTask(task, updates);
expect(result.priority).toBe('high');
});
});
describe('deleteTask', () => {
let task: TaskInfo;
let mockFile: TFile;
beforeEach(() => {
task = TaskFactory.createTask();
mockFile = new TFile(task.path);
mockPlugin.app.vault.getAbstractFileByPath.mockReturnValue(mockFile);
});
it('should delete a task successfully', async () => {
await taskService.deleteTask(task);
expect(mockPlugin.app.vault.delete).toHaveBeenCalledWith(mockFile);
expect(mockPlugin.cacheManager.clearCacheEntry).toHaveBeenCalledWith(task.path);
expect(mockPlugin.emitter.trigger).toHaveBeenCalledWith('task-deleted', {
path: task.path,
deletedTask: task
});
});
it('should handle file not found error', async () => {
mockPlugin.app.vault.getAbstractFileByPath.mockReturnValue(null);
await expect(taskService.deleteTask(task))
.rejects.toThrow('Cannot find task file');
});
it('should handle vault deletion errors', async () => {
mockPlugin.app.vault.delete.mockRejectedValue(new Error('Deletion failed'));
await expect(taskService.deleteTask(task))
.rejects.toThrow('Failed to delete task');
});
});
describe('Error Handling', () => {
it('should handle field mapper errors gracefully', async () => {
// Create a spy on the real fieldMapper method to force it to throw
const mapToFrontmatterSpy = jest.spyOn(mockPlugin.fieldMapper, 'mapToFrontmatter')
.mockImplementation(() => {
throw new Error('Field mapping error');
});
const taskData: TaskCreationData = { title: 'Error Test Task' };
await expect(taskService.createTask(taskData))
.rejects.toThrow('Failed to create task');
mapToFrontmatterSpy.mockRestore();
});
it('should handle vault operation errors', async () => {
mockPlugin.app.vault.create.mockRejectedValue(new Error('Vault error'));
const taskData: TaskCreationData = { title: 'Vault Error Task' };
await expect(taskService.createTask(taskData))
.rejects.toThrow('Failed to create task');
});
it('should include error details in thrown errors', async () => {
mockPlugin.app.vault.create.mockRejectedValue(new Error('Specific vault error'));
const taskData: TaskCreationData = { title: 'Error Details Task' };
await expect(taskService.createTask(taskData))
.rejects.toThrow('Failed to create task: Specific vault error');
});
});
describe('Integration Scenarios', () => {
it('should handle complete task creation workflow', async () => {
const taskData: TaskCreationData = {
title: 'Integration Test Task',
status: 'open',
priority: 'normal',
due: '2025-01-15',
contexts: ['work'],
timeEstimate: 60,
details: 'Integration test details'
};
const { taskInfo } = await taskService.createTask(taskData);
// Verify file creation
expect(mockPlugin.app.vault.create).toHaveBeenCalledWith(
expect.stringMatching(/integration-test-task\.md$/),
expect.stringContaining('title: Integration Test Task')
);
// Verify cache update
expect(mockPlugin.cacheManager.updateTaskInfoInCache).toHaveBeenCalledWith(
taskInfo.path,
taskInfo
);
// Verify event emission
expect(mockPlugin.emitter.trigger).toHaveBeenCalledWith('task-updated', {
path: taskInfo.path,
updatedTask: taskInfo
});
// Verify task info structure
expect(taskInfo).toMatchObject({
title: 'Integration Test Task',
status: 'open',
priority: 'normal',
due: '2025-01-15',
contexts: ['work'],
timeEstimate: 60,
archived: false
});
});
it('should handle task lifecycle from creation to completion', async () => {
// Create task
const { taskInfo: created } = await taskService.createTask({
title: 'Lifecycle Task'
});
// Update task
const updated = await taskService.updateTask(created, {
status: 'in-progress',
priority: 'high'
});
// Complete task
const completed = await taskService.toggleStatus(updated);
expect(completed.status).toBe('done');
expect(completed.priority).toBe('high');
});
});
});