test: add comprehensive tests for TaskService and StartupService

TaskService (45 tests):
- Task selection, lifecycle, updates, ordering
- Subtask management, archiving, project transfer
- Scheduling, time tracking, task creation

StartupService (16 tests):
- Initialization flow with BroadcastChannel mocking
- App rating logic (day tracking, dialog triggers)
- Tour detection, plugin init, storage persistence
This commit is contained in:
Johannes Millan 2026-01-05 18:31:46 +01:00
parent f0f536671b
commit a7c780a444
2 changed files with 1003 additions and 89 deletions

View file

@ -0,0 +1,369 @@
import { TestBed, fakeAsync, tick, flush } from '@angular/core/testing';
import { StartupService } from './startup.service';
import { PfapiService } from '../../pfapi/pfapi.service';
import { ImexViewService } from '../../imex/imex-meta/imex-view.service';
import { TranslateService } from '@ngx-translate/core';
import { LocalBackupService } from '../../imex/local-backup/local-backup.service';
import { GlobalConfigService } from '../../features/config/global-config.service';
import { SnackService } from '../snack/snack.service';
import { MatDialog } from '@angular/material/dialog';
import { PluginService } from '../../plugins/plugin.service';
import { SyncWrapperService } from '../../imex/sync/sync-wrapper.service';
import { BannerService } from '../banner/banner.service';
import { UiHelperService } from '../../features/ui-helper/ui-helper.service';
import { ChromeExtensionInterfaceService } from '../chrome-extension-interface/chrome-extension-interface.service';
import { ProjectService } from '../../features/project/project.service';
import { TrackingReminderService } from '../../features/tracking-reminder/tracking-reminder.service';
import { SyncSafetyBackupService } from '../../imex/sync/sync-safety-backup.service';
import { of } from 'rxjs';
import { signal } from '@angular/core';
import { LS } from '../persistence/storage-keys.const';
describe('StartupService', () => {
let service: StartupService;
let pfapiService: jasmine.SpyObj<PfapiService>;
let matDialog: jasmine.SpyObj<MatDialog>;
let pluginService: jasmine.SpyObj<PluginService>;
beforeEach(() => {
// Mock localStorage
const localStorageMock: { [key: string]: string } = {};
spyOn(localStorage, 'getItem').and.callFake(
(key: string) => localStorageMock[key] || null,
);
spyOn(localStorage, 'setItem').and.callFake(
(key: string, value: string) => (localStorageMock[key] = value),
);
// Create spies for all dependencies
const pfapiServiceSpy = jasmine.createSpyObj('PfapiService', [
'isCheckForStrayLocalTmpDBBackupAndImport',
]);
pfapiServiceSpy.isCheckForStrayLocalTmpDBBackupAndImport.and.returnValue(
Promise.resolve(),
);
pfapiServiceSpy.pf = {
metaModel: {
load: jasmine.createSpy().and.returnValue(Promise.resolve(null)),
},
};
const imexViewServiceSpy = jasmine.createSpyObj('ImexViewService', ['']);
const translateServiceSpy = jasmine.createSpyObj('TranslateService', ['']);
const localBackupServiceSpy = jasmine.createSpyObj('LocalBackupService', [
'askForFileStoreBackupIfAvailable',
'init',
]);
localBackupServiceSpy.askForFileStoreBackupIfAvailable.and.returnValue(
Promise.resolve(),
);
const globalConfigServiceSpy = jasmine.createSpyObj('GlobalConfigService', [''], {
cfg: signal({
misc: {
isConfirmBeforeExit: false,
defaultProjectId: null,
isShowProductivityTipLonger: false,
},
}),
misc: signal({
isShowProductivityTipLonger: false,
}),
});
const snackServiceSpy = jasmine.createSpyObj('SnackService', ['open']);
const matDialogSpy = jasmine.createSpyObj('MatDialog', ['open']);
const pluginServiceSpy = jasmine.createSpyObj('PluginService', ['initializePlugins']);
pluginServiceSpy.initializePlugins.and.returnValue(Promise.resolve());
const syncWrapperServiceSpy = jasmine.createSpyObj('SyncWrapperService', [
'isSyncInProgressSync',
]);
syncWrapperServiceSpy.isSyncInProgressSync.and.returnValue(false);
syncWrapperServiceSpy.afterCurrentSyncDoneOrSyncDisabled$ = of(undefined);
const bannerServiceSpy = jasmine.createSpyObj('BannerService', [
'open',
'dismissAll',
]);
const uiHelperServiceSpy = jasmine.createSpyObj('UiHelperService', ['initElectron']);
const chromeExtensionInterfaceServiceSpy = jasmine.createSpyObj(
'ChromeExtensionInterfaceService',
['init'],
);
const projectServiceSpy = jasmine.createSpyObj('ProjectService', [''], {
list: signal([{ id: 'project-1' }, { id: 'project-2' }, { id: 'project-3' }]),
});
const trackingReminderServiceSpy = jasmine.createSpyObj('TrackingReminderService', [
'init',
]);
const syncSafetyBackupServiceSpy = jasmine.createSpyObj('SyncSafetyBackupService', [
'',
]);
TestBed.configureTestingModule({
providers: [
StartupService,
{ provide: PfapiService, useValue: pfapiServiceSpy },
{ provide: ImexViewService, useValue: imexViewServiceSpy },
{ provide: TranslateService, useValue: translateServiceSpy },
{ provide: LocalBackupService, useValue: localBackupServiceSpy },
{ provide: GlobalConfigService, useValue: globalConfigServiceSpy },
{ provide: SnackService, useValue: snackServiceSpy },
{ provide: MatDialog, useValue: matDialogSpy },
{ provide: PluginService, useValue: pluginServiceSpy },
{ provide: SyncWrapperService, useValue: syncWrapperServiceSpy },
{ provide: BannerService, useValue: bannerServiceSpy },
{ provide: UiHelperService, useValue: uiHelperServiceSpy },
{
provide: ChromeExtensionInterfaceService,
useValue: chromeExtensionInterfaceServiceSpy,
},
{ provide: ProjectService, useValue: projectServiceSpy },
{ provide: TrackingReminderService, useValue: trackingReminderServiceSpy },
{ provide: SyncSafetyBackupService, useValue: syncSafetyBackupServiceSpy },
],
});
service = TestBed.inject(StartupService);
pfapiService = TestBed.inject(PfapiService) as jasmine.SpyObj<PfapiService>;
matDialog = TestBed.inject(MatDialog) as jasmine.SpyObj<MatDialog>;
pluginService = TestBed.inject(PluginService) as jasmine.SpyObj<PluginService>;
});
describe('service creation', () => {
it('should be created', () => {
expect(service).toBeTruthy();
});
});
describe('init', () => {
// Note: Full init() testing requires complex BroadcastChannel mocking
// These tests cover the testable parts
it('should check for stray backups during initialization', fakeAsync(() => {
// Mock BroadcastChannel to prevent multi-instance blocking
const mockChannel = {
postMessage: jasmine.createSpy(),
addEventListener: jasmine.createSpy(),
removeEventListener: jasmine.createSpy(),
close: jasmine.createSpy(),
};
const originalBroadcastChannel = (window as any).BroadcastChannel;
(window as any).BroadcastChannel = jasmine
.createSpy('BroadcastChannel')
.and.returnValue(mockChannel);
service.init();
tick(200); // Wait for single instance check
expect(pfapiService.isCheckForStrayLocalTmpDBBackupAndImport).toHaveBeenCalled();
flush();
// Restore
(window as any).BroadcastChannel = originalBroadcastChannel;
}));
});
describe('_handleAppStartRating (private, tested via init)', () => {
it('should increment app start count on new day', () => {
// Set up initial state - different day
(localStorage.getItem as jasmine.Spy).and.callFake((key: string) => {
if (key === LS.APP_START_COUNT) return '5';
if (key === LS.APP_START_COUNT_LAST_START_DAY) return '2026-01-04'; // Different day
return null;
});
// Call the private method via reflection for unit testing
(service as any)._handleAppStartRating();
expect(localStorage.setItem).toHaveBeenCalledWith(LS.APP_START_COUNT, '6');
});
it('should show rating dialog at 32 app starts', () => {
(localStorage.getItem as jasmine.Spy).and.callFake((key: string) => {
if (key === LS.APP_START_COUNT) return '32';
if (key === LS.APP_START_COUNT_LAST_START_DAY) return '2026-01-04';
return null;
});
(service as any)._handleAppStartRating();
expect(matDialog.open).toHaveBeenCalled();
});
it('should show rating dialog at 96 app starts', () => {
(localStorage.getItem as jasmine.Spy).and.callFake((key: string) => {
if (key === LS.APP_START_COUNT) return '96';
if (key === LS.APP_START_COUNT_LAST_START_DAY) return '2026-01-04';
return null;
});
(service as any)._handleAppStartRating();
expect(matDialog.open).toHaveBeenCalled();
});
it('should not show rating dialog at other counts', () => {
(localStorage.getItem as jasmine.Spy).and.callFake((key: string) => {
if (key === LS.APP_START_COUNT) return '50';
if (key === LS.APP_START_COUNT_LAST_START_DAY) return '2026-01-04';
return null;
});
(service as any)._handleAppStartRating();
expect(matDialog.open).not.toHaveBeenCalled();
});
it('should not increment count if same day', () => {
const todayStr = new Date().toISOString().split('T')[0];
(localStorage.getItem as jasmine.Spy).and.callFake((key: string) => {
if (key === LS.APP_START_COUNT) return '10';
if (key === LS.APP_START_COUNT_LAST_START_DAY) return todayStr;
return null;
});
(service as any)._handleAppStartRating();
// Should not have set a new count since it's the same day
// (the method only sets when lastStartDay !== todayStr)
const setItemCalls = (localStorage.setItem as jasmine.Spy).calls.all();
const countSetCalls = setItemCalls.filter(
(call) => call.args[0] === LS.APP_START_COUNT,
);
expect(countSetCalls.length).toBe(0);
});
});
describe('_isTourLikelyToBeShown (private)', () => {
it('should return false if IS_SKIP_TOUR is set', () => {
(localStorage.getItem as jasmine.Spy).and.callFake((key: string) => {
if (key === LS.IS_SKIP_TOUR) return 'true';
return null;
});
const result = (service as any)._isTourLikelyToBeShown();
expect(result).toBe(false);
});
it('should return false for NIGHTWATCH user agent', () => {
const originalUserAgent = navigator.userAgent;
Object.defineProperty(navigator, 'userAgent', {
value: 'NIGHTWATCH',
configurable: true,
});
const result = (service as any)._isTourLikelyToBeShown();
expect(result).toBe(false);
// Restore
Object.defineProperty(navigator, 'userAgent', {
value: originalUserAgent,
configurable: true,
});
});
it('should return false for PLAYWRIGHT user agent', () => {
const originalUserAgent = navigator.userAgent;
Object.defineProperty(navigator, 'userAgent', {
value: 'Something PLAYWRIGHT Something',
configurable: true,
});
const result = (service as any)._isTourLikelyToBeShown();
expect(result).toBe(false);
// Restore
Object.defineProperty(navigator, 'userAgent', {
value: originalUserAgent,
configurable: true,
});
});
it('should return false when more than 2 projects exist', () => {
// projectService.list returns signal with 3 projects in setup
const result = (service as any)._isTourLikelyToBeShown();
expect(result).toBe(false);
});
});
describe('_initPlugins (private)', () => {
it('should initialize plugins after sync completes', async () => {
await (service as any)._initPlugins();
expect(pluginService.initializePlugins).toHaveBeenCalled();
});
it('should handle plugin initialization errors gracefully', async () => {
pluginService.initializePlugins.and.returnValue(
Promise.reject(new Error('Plugin init failed')),
);
// Should not throw
await expectAsync((service as any)._initPlugins()).toBeResolved();
});
});
describe('_requestPersistence (private)', () => {
it('should request persistent storage', fakeAsync(() => {
const mockStorage = {
persisted: jasmine.createSpy().and.returnValue(Promise.resolve(false)),
persist: jasmine.createSpy().and.returnValue(Promise.resolve(true)),
estimate: jasmine.createSpy(),
};
Object.defineProperty(navigator, 'storage', {
value: mockStorage,
configurable: true,
});
(service as any)._requestPersistence();
tick();
expect(mockStorage.persisted).toHaveBeenCalled();
expect(mockStorage.persist).toHaveBeenCalled();
flush();
}));
it('should not request persistence if already persisted', fakeAsync(() => {
const mockStorage = {
persisted: jasmine.createSpy().and.returnValue(Promise.resolve(true)),
persist: jasmine.createSpy(),
estimate: jasmine.createSpy(),
};
Object.defineProperty(navigator, 'storage', {
value: mockStorage,
configurable: true,
});
(service as any)._requestPersistence();
tick();
expect(mockStorage.persisted).toHaveBeenCalled();
expect(mockStorage.persist).not.toHaveBeenCalled();
flush();
}));
});
describe('_initOfflineBanner (private)', () => {
// Note: This requires mocking isOnline$ which is complex
// Basic test to ensure the method can be called
it('should set up offline banner subscription', () => {
expect(() => (service as any)._initOfflineBanner()).not.toThrow();
});
});
});

View file

@ -1,30 +1,47 @@
// This test file has been disabled due to complex dependency injection issues
// The move-to-archive.spec.ts file provides a cleaner test that demonstrates the issue
/*
import { TestBed } from '@angular/core/testing';
import { TaskService } from './task.service';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { WorkContextService } from '../work-context/work-context.service';
import { SnackService } from '../../core/snack/snack.service';
import { TaskRepeatCfgService } from '../task-repeat-cfg/task-repeat-cfg.service';
import { GlobalTrackingIntervalService } from '../../core/global-tracking-interval/global-tracking-interval.service';
import { DateService } from '../../core/date/date.service';
import { Router } from '@angular/router';
import { ArchiveService } from '../time-tracking/archive.service';
import { TaskArchiveService } from '../time-tracking/task-archive.service';
import { GlobalConfigService } from '../config/global-config.service';
import { TaskFocusService } from './task-focus.service';
import { ImexViewService } from '../../imex/imex-meta/imex-view.service';
import { DEFAULT_TASK, Task, TaskWithSubTasks } from './task.model';
import { WorkContextType } from '../work-context/work-context.model';
import { of } from 'rxjs';
import { of, Subject } from 'rxjs';
import { TaskSharedActions } from '../../root-store/meta/task-shared.actions';
import {
setCurrentTask,
unsetCurrentTask,
setSelectedTask,
addSubTask,
moveSubTaskToTop,
moveSubTaskToBottom,
} from './store/task.actions';
import { TaskDetailTargetPanel, TaskReminderOptionId } from './task.model';
import { TODAY_TAG } from '../tag/tag.const';
import { INBOX_PROJECT } from '../project/project.const';
import { signal } from '@angular/core';
describe('TaskService', () => {
let service: TaskService;
let store: MockStore;
let workContextService: jasmine.SpyObj<WorkContextService>;
let archiveService: jasmine.SpyObj<ArchiveService>;
let tickSubject: Subject<{ duration: number; date: string }>;
const createMockTask = (id: string, isDone: boolean, parentId?: string): Task => ({
...DEFAULT_TASK,
id,
title: id + ' title',
isDone,
parentId,
projectId: 'test-project',
tagIds: [],
});
const createMockTask = (id: string, overrides: Partial<Task> = {}): Task =>
({
...DEFAULT_TASK,
id,
title: `Task ${id}`,
created: Date.now(),
projectId: 'test-project',
...overrides,
}) as Task;
const createMockTaskWithSubTasks = (
task: Task,
@ -35,114 +52,642 @@ describe('TaskService', () => {
});
beforeEach(() => {
tickSubject = new Subject();
const workContextServiceSpy = jasmine.createSpyObj('WorkContextService', [''], {
activeWorkContextType: WorkContextType.PROJECT,
activeWorkContextId: 'test-project',
mainListTaskIds$: of(['task-1', 'task-2']),
backlogTaskIds$: of([]),
doneTaskIds$: of([]),
doneBacklogTaskIds$: of([]),
startableTasksForActiveContext$: of([]),
startableTasksForActiveContext: signal([]),
activeWorkContext$: of({
id: 'test-project',
type: WorkContextType.PROJECT,
taskIds: ['task-1', 'task-2'],
}),
});
const snackServiceSpy = jasmine.createSpyObj('SnackService', ['open']);
const taskRepeatCfgServiceSpy = jasmine.createSpyObj('TaskRepeatCfgService', [
'getTaskRepeatCfgById$',
const globalTrackingIntervalServiceSpy = jasmine.createSpyObj(
'GlobalTrackingIntervalService',
[''],
{
tick$: tickSubject.asObservable(),
},
);
const dateServiceSpy = jasmine.createSpyObj('DateService', ['todayStr']);
dateServiceSpy.todayStr.and.returnValue('2026-01-05');
const routerSpy = jasmine.createSpyObj('Router', ['navigate']);
routerSpy.navigate.and.returnValue(Promise.resolve(true));
const archiveServiceSpy = jasmine.createSpyObj('ArchiveService', [
'moveTasksToArchiveAndFlushArchiveIfDue',
]);
taskRepeatCfgServiceSpy.getTaskRepeatCfgById$.and.returnValue(of(null));
archiveServiceSpy.moveTasksToArchiveAndFlushArchiveIfDue.and.returnValue(
Promise.resolve(),
);
const taskArchiveServiceSpy = jasmine.createSpyObj('TaskArchiveService', [
'load',
'updateTask',
'updateTasks',
'getById',
'roundTimeSpent',
]);
taskArchiveServiceSpy.load.and.returnValue(
Promise.resolve({ ids: [], entities: {} }),
);
taskArchiveServiceSpy.getById.and.returnValue(Promise.resolve(null));
const globalConfigServiceSpy = jasmine.createSpyObj('GlobalConfigService', [''], {
cfg: signal({
misc: { defaultProjectId: null },
reminder: { defaultTaskRemindOption: 'AT_START' },
appFeatures: { isTimeTrackingEnabled: true },
}),
misc: signal({ isShowProductivityTipLonger: false }),
});
const taskFocusServiceSpy = jasmine.createSpyObj('TaskFocusService', [''], {
lastFocusedTaskComponent: signal(null),
});
const imexViewServiceSpy = jasmine.createSpyObj('ImexViewService', [''], {
isDataImportInProgress$: of(false),
});
TestBed.configureTestingModule({
providers: [
TaskService,
provideMockStore({
initialState: {
task: {
ids: [],
entities: {},
tasks: {
ids: ['task-1', 'task-2'],
entities: {
['task-1']: createMockTask('task-1'),
['task-2']: createMockTask('task-2'),
},
currentTaskId: null,
selectedTaskId: null,
taskDetailTargetPanel: null,
isDataLoaded: true,
},
},
selectors: [],
}),
{ provide: WorkContextService, useValue: workContextServiceSpy },
{ provide: SnackService, useValue: snackServiceSpy },
{ provide: TaskRepeatCfgService, useValue: taskRepeatCfgServiceSpy },
{
provide: GlobalTrackingIntervalService,
useValue: globalTrackingIntervalServiceSpy,
},
{ provide: DateService, useValue: dateServiceSpy },
{ provide: Router, useValue: routerSpy },
{ provide: ArchiveService, useValue: archiveServiceSpy },
{ provide: TaskArchiveService, useValue: taskArchiveServiceSpy },
{ provide: GlobalConfigService, useValue: globalConfigServiceSpy },
{ provide: TaskFocusService, useValue: taskFocusServiceSpy },
{ provide: ImexViewService, useValue: imexViewServiceSpy },
],
});
service = TestBed.inject(TaskService);
store = TestBed.inject(MockStore);
workContextService = TestBed.inject(
WorkContextService,
) as jasmine.SpyObj<WorkContextService>;
archiveService = TestBed.inject(ArchiveService) as jasmine.SpyObj<ArchiveService>;
spyOn(store, 'dispatch').and.callThrough();
});
it('should be created', () => {
expect(service).toBeTruthy();
afterEach(() => {
store.resetSelectors();
});
describe('moveToArchive', () => {
it('should successfully archive tasks without subtasks in project context', () => {
// Arrange
const task1 = createMockTask('task-1', true);
const task2 = createMockTask('task-2', true);
const tasksWithSubTasks: TaskWithSubTasks[] = [
createMockTaskWithSubTasks(task1),
createMockTaskWithSubTasks(task2),
];
describe('setCurrentId', () => {
it('should dispatch setCurrentTask when id is provided', () => {
service.setCurrentId('task-1');
spyOn(store, 'dispatch');
// Act
service.moveToArchive(tasksWithSubTasks);
// Assert
expect(store.dispatch).toHaveBeenCalled();
expect(store.dispatch).toHaveBeenCalledWith(setCurrentTask({ id: 'task-1' }));
});
it('should throw error when trying to archive subtasks in project context', () => {
// Arrange
const parentTask = createMockTask('parent-1', true);
const subTask1 = createMockTask('sub-1', true, 'parent-1');
const subTask2 = createMockTask('sub-2', true, 'parent-1');
it('should dispatch unsetCurrentTask when id is null', () => {
service.setCurrentId(null);
// Include both parent and subtasks in the array (simulating the bug)
const tasksWithSubTasks: TaskWithSubTasks[] = [
createMockTaskWithSubTasks(parentTask, [subTask1, subTask2]),
createMockTaskWithSubTasks(subTask1),
createMockTaskWithSubTasks(subTask2),
];
expect(store.dispatch).toHaveBeenCalledWith(unsetCurrentTask());
});
});
// Act & Assert
expect(() => service.moveToArchive(tasksWithSubTasks)).toThrowError(
'Trying to move sub tasks into archive for project',
describe('setSelectedId', () => {
it('should dispatch setSelectedTask with default panel', () => {
service.setSelectedId('task-1');
expect(store.dispatch).toHaveBeenCalledWith(
setSelectedTask({
id: 'task-1',
taskDetailTargetPanel: TaskDetailTargetPanel.Default,
}),
);
});
it('should successfully handle subtasks in tag context by removing tag', () => {
// Arrange - Switch to TAG context
(workContextService as any).activeWorkContextType = WorkContextType.TAG;
(workContextService as any).activeWorkContextId = 'TODAY';
it('should dispatch setSelectedTask with specified panel', () => {
service.setSelectedId('task-1', TaskDetailTargetPanel.Attachments);
const parentTask = createMockTask('parent-1', true);
const subTask1 = {
...createMockTask('sub-1', true, 'parent-1'),
tagIds: ['TODAY', 'other-tag'],
};
const subTask2 = {
...createMockTask('sub-2', true, 'parent-1'),
tagIds: ['TODAY'],
};
expect(store.dispatch).toHaveBeenCalledWith(
setSelectedTask({
id: 'task-1',
taskDetailTargetPanel: TaskDetailTargetPanel.Attachments,
}),
);
});
const tasksWithSubTasks: TaskWithSubTasks[] = [
createMockTaskWithSubTasks(parentTask, [subTask1, subTask2]),
createMockTaskWithSubTasks(subTask1),
createMockTaskWithSubTasks(subTask2),
];
it('should handle null id', () => {
service.setSelectedId(null);
spyOn(service, 'updateTags');
spyOn(store, 'dispatch');
// Act
service.moveToArchive(tasksWithSubTasks);
// Assert - subtasks should have tag removed, parent task should be archived
expect(service.updateTags).toHaveBeenCalledWith(subTask1, ['other-tag']);
expect(service.updateTags).toHaveBeenCalledWith(subTask2, []);
expect(store.dispatch).toHaveBeenCalled();
expect(store.dispatch).toHaveBeenCalledWith(
setSelectedTask({
id: null,
taskDetailTargetPanel: TaskDetailTargetPanel.Default,
}),
);
});
});
describe('pauseCurrent', () => {
it('should dispatch unsetCurrentTask', () => {
service.pauseCurrent();
expect(store.dispatch).toHaveBeenCalledWith(unsetCurrentTask());
});
});
describe('add', () => {
it('should dispatch addTask with correct payload', () => {
const id = service.add('New Task');
expect(store.dispatch).toHaveBeenCalledWith(
jasmine.objectContaining({
type: TaskSharedActions.addTask.type,
}),
);
expect(id).toBeTruthy();
});
it('should create task with project context', () => {
service.add('New Task');
const dispatchCall = (store.dispatch as jasmine.Spy).calls.mostRecent();
const action = dispatchCall.args[0] as ReturnType<typeof TaskSharedActions.addTask>;
expect(action.task.projectId).toBe('test-project');
expect(action.workContextType).toBe(WorkContextType.PROJECT);
});
it('should pass isAddToBacklog flag', () => {
service.add('New Task', true);
const dispatchCall = (store.dispatch as jasmine.Spy).calls.mostRecent();
const action = dispatchCall.args[0] as ReturnType<typeof TaskSharedActions.addTask>;
expect(action.isAddToBacklog).toBe(true);
});
it('should pass isAddToBottom flag', () => {
service.add('New Task', false, {}, true);
const dispatchCall = (store.dispatch as jasmine.Spy).calls.mostRecent();
const action = dispatchCall.args[0] as ReturnType<typeof TaskSharedActions.addTask>;
expect(action.isAddToBottom).toBe(true);
});
it('should merge additional fields', () => {
service.add('New Task', false, { notes: 'Test notes' });
const dispatchCall = (store.dispatch as jasmine.Spy).calls.mostRecent();
const action = dispatchCall.args[0] as ReturnType<typeof TaskSharedActions.addTask>;
expect(action.task.notes).toBe('Test notes');
});
});
describe('addToToday', () => {
it('should dispatch planTasksForToday', () => {
const task = createMockTaskWithSubTasks(createMockTask('task-1'));
service.addToToday(task);
expect(store.dispatch).toHaveBeenCalledWith(
TaskSharedActions.planTasksForToday({ taskIds: ['task-1'] }),
);
});
});
describe('remove', () => {
it('should dispatch deleteTask', () => {
const task = createMockTaskWithSubTasks(createMockTask('task-1'));
service.remove(task);
expect(store.dispatch).toHaveBeenCalledWith(TaskSharedActions.deleteTask({ task }));
});
});
describe('removeMultipleTasks', () => {
it('should dispatch deleteTasks', () => {
service.removeMultipleTasks(['task-1', 'task-2']);
expect(store.dispatch).toHaveBeenCalledWith(
TaskSharedActions.deleteTasks({ taskIds: ['task-1', 'task-2'] }),
);
});
});
describe('update', () => {
it('should dispatch updateTask with changes', () => {
service.update('task-1', { title: 'Updated Title' });
expect(store.dispatch).toHaveBeenCalledWith(
TaskSharedActions.updateTask({
task: { id: 'task-1', changes: { title: 'Updated Title' } },
}),
);
});
});
describe('updateTags', () => {
it('should dispatch updateTask with unique tagIds', () => {
const task = createMockTask('task-1');
service.updateTags(task, ['tag-1', 'tag-2', 'tag-1']);
expect(store.dispatch).toHaveBeenCalledWith(
TaskSharedActions.updateTask({
task: { id: 'task-1', changes: { tagIds: ['tag-1', 'tag-2'] } },
}),
);
});
});
describe('removeTagsForAllTask', () => {
it('should dispatch removeTagsForAllTasks', () => {
service.removeTagsForAllTask(['tag-1', 'tag-2']);
expect(store.dispatch).toHaveBeenCalledWith(
TaskSharedActions.removeTagsForAllTasks({ tagIdsToRemove: ['tag-1', 'tag-2'] }),
);
});
});
describe('setDone', () => {
it('should update task with isDone: true', () => {
service.setDone('task-1');
expect(store.dispatch).toHaveBeenCalledWith(
TaskSharedActions.updateTask({
task: { id: 'task-1', changes: { isDone: true } },
}),
);
});
});
describe('setUnDone', () => {
it('should update task with isDone: false', () => {
service.setUnDone('task-1');
expect(store.dispatch).toHaveBeenCalledWith(
TaskSharedActions.updateTask({
task: { id: 'task-1', changes: { isDone: false } },
}),
);
});
});
describe('markIssueUpdatesAsRead', () => {
it('should update task with issueWasUpdated: false', () => {
service.markIssueUpdatesAsRead('task-1');
expect(store.dispatch).toHaveBeenCalledWith(
TaskSharedActions.updateTask({
task: { id: 'task-1', changes: { issueWasUpdated: false } },
}),
);
});
});
describe('moveToTop', () => {
it('should dispatch moveSubTaskToTop for subtask', () => {
service.moveToTop('subtask-1', 'parent-1', false);
expect(store.dispatch).toHaveBeenCalledWith(
moveSubTaskToTop({ id: 'subtask-1', parentId: 'parent-1' }),
);
});
});
describe('moveToBottom', () => {
it('should dispatch moveSubTaskToBottom for subtask', () => {
service.moveToBottom('subtask-1', 'parent-1', false);
expect(store.dispatch).toHaveBeenCalledWith(
moveSubTaskToBottom({ id: 'subtask-1', parentId: 'parent-1' }),
);
});
});
describe('addSubTaskTo', () => {
it('should dispatch addSubTask', () => {
const id = service.addSubTaskTo('parent-1', { title: 'Subtask' });
expect(store.dispatch).toHaveBeenCalledWith(
jasmine.objectContaining({
type: addSubTask.type,
parentId: 'parent-1',
}),
);
expect(id).toBeTruthy();
});
});
describe('moveToArchive', () => {
it('should dispatch moveToArchive and call archive service for parent tasks', async () => {
const task = createMockTaskWithSubTasks(
createMockTask('task-1', { projectId: 'test-project' }),
);
await service.moveToArchive(task);
expect(store.dispatch).toHaveBeenCalledWith(
TaskSharedActions.moveToArchive({ tasks: [task] }),
);
expect(archiveService.moveTasksToArchiveAndFlushArchiveIfDue).toHaveBeenCalledWith([
task,
]);
});
it('should handle array of tasks', async () => {
const tasks = [
createMockTaskWithSubTasks(createMockTask('task-1')),
createMockTaskWithSubTasks(createMockTask('task-2')),
];
await service.moveToArchive(tasks);
expect(store.dispatch).toHaveBeenCalledWith(
TaskSharedActions.moveToArchive({ tasks }),
);
});
it('should only archive parent tasks (subtasks are handled separately)', async () => {
// When archiving both parent and subtasks, only parent tasks are dispatched
// Subtasks trigger a devError in PROJECT context (tested behavior)
const parentTask = createMockTaskWithSubTasks(createMockTask('parent-1'));
await service.moveToArchive([parentTask]);
expect(store.dispatch).toHaveBeenCalledWith(
TaskSharedActions.moveToArchive({ tasks: [parentTask] }),
);
});
it('should handle null/undefined gracefully', async () => {
await service.moveToArchive(null as any);
expect(store.dispatch).not.toHaveBeenCalledWith(
jasmine.objectContaining({ type: TaskSharedActions.moveToArchive.type }),
);
});
});
describe('moveToProject', () => {
it('should dispatch moveToOtherProject', () => {
const task = createMockTaskWithSubTasks(createMockTask('task-1'));
service.moveToProject(task, 'new-project');
expect(store.dispatch).toHaveBeenCalledWith(
TaskSharedActions.moveToOtherProject({
task,
targetProjectId: 'new-project',
}),
);
});
it('should throw error for subtask', () => {
const subtask = createMockTaskWithSubTasks(
createMockTask('subtask-1', { parentId: 'parent-1' }),
);
expect(() => service.moveToProject(subtask, 'new-project')).toThrowError(
'Wrong task model',
);
});
});
describe('restoreTask', () => {
it('should dispatch restoreTask', () => {
const task = createMockTask('task-1');
const subTasks = [createMockTask('subtask-1', { parentId: 'task-1' })];
service.restoreTask(task, subTasks);
expect(store.dispatch).toHaveBeenCalledWith(
TaskSharedActions.restoreTask({ task, subTasks }),
);
});
});
describe('scheduleTask', () => {
it('should dispatch scheduleTaskWithTime', () => {
const task = createMockTask('task-1');
const due = Date.now() + 3600000;
service.scheduleTask(task, due, TaskReminderOptionId.AtStart);
expect(store.dispatch).toHaveBeenCalledWith(
jasmine.objectContaining({
type: TaskSharedActions.scheduleTaskWithTime.type,
}),
);
});
});
describe('reScheduleTask', () => {
it('should dispatch reScheduleTaskWithTime', () => {
const task = createMockTask('task-1');
const due = Date.now() + 3600000;
service.reScheduleTask({
task,
due,
remindCfg: TaskReminderOptionId.AtStart,
isMoveToBacklog: false,
});
expect(store.dispatch).toHaveBeenCalledWith(
jasmine.objectContaining({
type: TaskSharedActions.reScheduleTaskWithTime.type,
}),
);
});
});
describe('createNewTaskWithDefaults', () => {
it('should create task with default values', () => {
const task = service.createNewTaskWithDefaults({ title: 'Test Task' });
expect(task.title).toBe('Test Task');
expect(task.id).toBeTruthy();
expect(task.created).toBeTruthy();
expect(task.projectId).toBe('test-project');
});
it('should set projectId for project context', () => {
const task = service.createNewTaskWithDefaults({
title: 'Test',
workContextType: WorkContextType.PROJECT,
workContextId: 'my-project',
});
expect(task.projectId).toBe('my-project');
});
it('should set tagIds for tag context (non-TODAY)', () => {
const task = service.createNewTaskWithDefaults({
title: 'Test',
workContextType: WorkContextType.TAG,
workContextId: 'my-tag',
});
expect(task.tagIds).toContain('my-tag');
});
it('should NOT set tagIds for TODAY tag context', () => {
const task = service.createNewTaskWithDefaults({
title: 'Test',
workContextType: WorkContextType.TAG,
workContextId: TODAY_TAG.id,
});
expect(task.tagIds).not.toContain(TODAY_TAG.id);
});
it('should set dueDay for TODAY tag context', () => {
const task = service.createNewTaskWithDefaults({
title: 'Test',
workContextType: WorkContextType.TAG,
workContextId: TODAY_TAG.id,
});
expect(task.dueDay).toBeTruthy();
});
it('should use custom id if provided', () => {
const task = service.createNewTaskWithDefaults({
title: 'Test',
id: 'custom-id',
});
expect(task.id).toBe('custom-id');
});
it('should merge additional fields', () => {
const task = service.createNewTaskWithDefaults({
title: 'Test',
additional: {
notes: 'Some notes',
timeEstimate: 3600000,
},
});
expect(task.notes).toBe('Some notes');
expect(task.timeEstimate).toBe(3600000);
});
it('should use INBOX_PROJECT if no projectId available', () => {
// Create a task with TAG context but no default project configured
const task = service.createNewTaskWithDefaults({
title: 'Test',
workContextType: WorkContextType.TAG,
workContextId: 'some-tag',
});
// Should fallback to INBOX_PROJECT since no default is configured
expect(task.projectId).toBe(INBOX_PROJECT.id);
});
});
describe('getByIdOnce$', () => {
it('should return observable that completes after one emission', (done) => {
store.overrideSelector('selectTaskById', createMockTask('task-1'));
let emissionCount = 0;
service.getByIdOnce$('task-1').subscribe({
next: (task) => {
emissionCount++;
expect(task).toBeTruthy();
},
complete: () => {
expect(emissionCount).toBe(1);
done();
},
});
});
});
describe('addTimeSpent', () => {
it('should dispatch addTimeSpent action', () => {
const task = createMockTask('task-1');
service.addTimeSpent(task, 60000);
expect(store.dispatch).toHaveBeenCalledWith(
jasmine.objectContaining({
type: '[TimeTracking] Add time spent',
task,
duration: 60000,
}),
);
});
it('should use provided date', () => {
const task = createMockTask('task-1');
service.addTimeSpent(task, 60000, '2026-01-01');
expect(store.dispatch).toHaveBeenCalledWith(
jasmine.objectContaining({
date: '2026-01-01',
}),
);
});
});
describe('removeTimeSpent', () => {
it('should dispatch removeTimeSpent action', () => {
service.removeTimeSpent('task-1', 30000);
expect(store.dispatch).toHaveBeenCalledWith(
jasmine.objectContaining({
type: '[Task] Remove time spent',
id: 'task-1',
duration: 30000,
}),
);
});
});
describe('toggleStartTask', () => {
it('should dispatch toggleStart when time tracking is enabled', () => {
service.toggleStartTask();
expect(store.dispatch).toHaveBeenCalledWith(
jasmine.objectContaining({ type: '[Task] Toggle start' }),
);
});
});
// Note: convertToMainTask requires complex selector mocking that doesn't work well
// with the current test setup. It's better tested via integration tests.
});
*/