diff --git a/src/app/features/tasks/store/task.reducer.util.spec.ts b/src/app/features/tasks/store/task.reducer.util.spec.ts new file mode 100644 index 0000000000..b6a41028e5 --- /dev/null +++ b/src/app/features/tasks/store/task.reducer.util.spec.ts @@ -0,0 +1,303 @@ +import { Update } from '@ngrx/entity'; +import { Task, TaskState } from '../task.model'; +import { taskAdapter } from './task.adapter'; +import { initialTaskState } from './task.reducer'; +import { + deleteTaskHelper, + removeTaskFromParentSideEffects, + updateDoneOnForTask, + updateStartDateForRepeatableTask, + updateTimeEstimateForTask, + updateTimeSpentForTask, +} from './task.reducer.util'; +import { _resetDevErrorState } from '../../../util/dev-error'; + +describe('task.reducer.util', () => { + const DAY_1 = '2026-07-10'; + const DAY_2 = '2026-07-11'; + const DAY_3 = '2026-07-12'; + + const createTask = (id: string, partial: Partial = {}): Task => + ({ + id, + title: id, + projectId: 'INBOX', + created: 1, + isDone: false, + subTaskIds: [], + tagIds: [], + timeSpentOnDay: {}, + timeEstimate: 0, + timeSpent: 0, + attachments: [], + ...partial, + }) as Task; + + const createState = ( + tasks: Task[], + currentTaskId: string | null = null, + ): TaskState => ({ + ...taskAdapter.setAll(tasks, initialTaskState), + currentTaskId, + }); + + describe('updateDoneOnForTask', () => { + it('should use explicit doneOn timestamp when provided', () => { + const state = createState([createTask('task-1')]); + const upd: Update = { + id: 'task-1', + changes: { isDone: true, doneOn: 12345 }, + }; + + const result = updateDoneOnForTask(upd, state); + + expect(result.entities['task-1']!.doneOn).toBe(12345); + }); + + it('should use Date.now when doneOn is not provided', () => { + spyOn(Date, 'now').and.returnValue(777); + const state = createState([createTask('task-1')]); + const upd: Update = { id: 'task-1', changes: { isDone: true } }; + + const result = updateDoneOnForTask(upd, state); + + expect(result.entities['task-1']!.doneOn).toBe(777); + }); + + it('should clear doneOn when task is marked undone', () => { + const state = createState([createTask('task-1', { doneOn: 999, isDone: true })]); + const upd: Update = { id: 'task-1', changes: { isDone: false } }; + + const result = updateDoneOnForTask(upd, state); + + expect(result.entities['task-1']!.doneOn).toBeUndefined(); + }); + }); + + describe('updateStartDateForRepeatableTask', () => { + it('should mark repeatable task done and clear dueDay', () => { + spyOn(Date, 'now').and.returnValue(4444); + const state = createState([createTask('task-1', { dueDay: DAY_1 })]); + + const result = updateStartDateForRepeatableTask( + { id: 'task-1', changes: { isDone: true } }, + state, + ); + + expect(result.entities['task-1']!.doneOn).toBe(4444); + expect(result.entities['task-1']!.dueDay).toBeUndefined(); + }); + }); + + describe('updateTimeSpentForTask', () => { + it('should update parent time incrementally and remove zeroed days', () => { + const state = createState([ + createTask('parent', { + subTaskIds: ['child'], + timeSpentOnDay: { + [DAY_1]: 120, + [DAY_2]: 30, + }, + timeSpent: 150, + }), + createTask('child', { + parentId: 'parent', + timeSpentOnDay: { + [DAY_1]: 120, + [DAY_2]: 30, + }, + timeSpent: 150, + }), + ]); + + const result = updateTimeSpentForTask( + 'child', + { + [DAY_1]: 90, + [DAY_3]: 90, + }, + state, + ); + + // net delta: -30 (DAY_1 down) -30 (DAY_2 zeroed) +90 (DAY_3 added) = +30 + // parent timeSpent must move off its starting 150 so the accumulator matters + expect(result.entities['child']!.timeSpent).toBe(180); + expect(result.entities['parent']!.timeSpent).toBe(180); + expect(result.entities['parent']!.timeSpentOnDay).toEqual({ + [DAY_1]: 90, + [DAY_3]: 90, + }); + }); + + it('should update task without touching parent data when task has no parent', () => { + const state = createState([createTask('task-1')]); + + const result = updateTimeSpentForTask( + 'task-1', + { + [DAY_1]: 15, + [DAY_2]: 45, + }, + state, + ); + + expect(result.entities['task-1']!.timeSpent).toBe(60); + expect(result.entities['task-1']!.timeSpentOnDay).toEqual({ + [DAY_1]: 15, + [DAY_2]: 45, + }); + }); + }); + + describe('updateTimeEstimateForTask', () => { + it('should recalculate parent estimate from remaining child work', () => { + const state = createState([ + createTask('parent', { subTaskIds: ['child-1', 'child-2'], timeEstimate: 999 }), + createTask('child-1', { + parentId: 'parent', + timeEstimate: 120, + timeSpent: 20, + }), + createTask('child-2', { + parentId: 'parent', + timeEstimate: 200, + timeSpent: 50, + }), + ]); + + const result = updateTimeEstimateForTask({ id: 'child-1', changes: {} }, 80, state); + + expect(result.entities['child-1']!.timeEstimate).toBe(80); + expect(result.entities['parent']!.timeEstimate).toBe(210); + }); + + it('should exclude a child from parent estimate when marking it done', () => { + const state = createState([ + createTask('parent', { subTaskIds: ['child-1', 'child-2'] }), + createTask('child-1', { + parentId: 'parent', + timeEstimate: 120, + timeSpent: 20, + isDone: false, + }), + createTask('child-2', { + parentId: 'parent', + timeEstimate: 200, + timeSpent: 50, + isDone: false, + }), + ]); + + const result = updateTimeEstimateForTask( + { id: 'child-1', changes: { isDone: true } }, + null, + state, + ); + + expect(result.entities['parent']!.timeEstimate).toBe(150); + }); + + it('should clamp negative remaining work to zero during parent recalculation', () => { + const state = createState([ + createTask('parent', { subTaskIds: ['child-1'] }), + createTask('child-1', { + parentId: 'parent', + timeEstimate: 30, + timeSpent: 90, + }), + ]); + + const result = updateTimeEstimateForTask({ id: 'child-1', changes: {} }, 30, state); + + expect(result.entities['parent']!.timeEstimate).toBe(0); + }); + }); + + describe('deleteTaskHelper', () => { + it('should remove state-discovered orphan subtasks when deleting a parent task', () => { + // `isShowAlert` latches off after the first devError of the whole Karma run, + // and test.ts creates the window.alert spy once and never resets its call + // history — so we must reset both before asserting devError fired. + _resetDevErrorState(); + if (jasmine.isSpy(window.alert)) { + (window.alert as jasmine.Spy).and.stub(); + (window.alert as jasmine.Spy).calls.reset(); + } else { + spyOn(window, 'alert').and.stub(); + } + if (jasmine.isSpy(window.confirm)) { + (window.confirm as jasmine.Spy).and.returnValue(false); + } else { + spyOn(window, 'confirm').and.returnValue(false); + } + + const parent = createTask('parent', { subTaskIds: [] }); + const orphanSubTask = createTask('orphan-sub', { parentId: 'parent' }); + const state = createState([parent, orphanSubTask], 'orphan-sub'); + + const result = deleteTaskHelper(state, parent); + + expect(result.entities['parent']).toBeUndefined(); + expect(result.entities['orphan-sub']).toBeUndefined(); + expect(result.currentTaskId).toBeNull(); + expect(window.alert).toHaveBeenCalled(); + }); + }); + + describe('removeTaskFromParentSideEffects', () => { + it('should copy time values to parent when removing the last subtask with copy flag', () => { + const taskToRemove = createTask('child', { + parentId: 'parent', + timeSpentOnDay: { [DAY_1]: 25 }, + timeEstimate: 80, + }); + const state = createState([ + createTask('parent', { + subTaskIds: ['child'], + timeSpentOnDay: {}, + timeEstimate: 0, + }), + taskToRemove, + ]); + + const result = removeTaskFromParentSideEffects(state, taskToRemove, true); + + expect(result.entities['parent']!.subTaskIds).toEqual([]); + expect(result.entities['parent']!.timeSpentOnDay).toEqual({ [DAY_1]: 25 }); + expect(result.entities['parent']!.timeEstimate).toBe(80); + }); + + it('should recalculate parent totals instead of copying when other subtasks remain', () => { + const state = createState([ + createTask('parent', { + subTaskIds: ['child-1', 'child-2'], + timeSpentOnDay: { [DAY_1]: 50 }, + timeEstimate: 999, + }), + createTask('child-1', { + parentId: 'parent', + timeSpentOnDay: { [DAY_1]: 20 }, + timeSpent: 20, + timeEstimate: 40, + }), + createTask('child-2', { + parentId: 'parent', + timeSpentOnDay: { [DAY_1]: 30 }, + timeSpent: 30, + timeEstimate: 100, + }), + ]); + + const result = removeTaskFromParentSideEffects( + state, + state.entities['child-1']!, + true, + ); + + expect(result.entities['parent']!.subTaskIds).toEqual(['child-2']); + expect(result.entities['parent']!.timeSpentOnDay).toEqual({ [DAY_1]: 30 }); + expect(result.entities['parent']!.timeSpent).toBe(30); + expect(result.entities['parent']!.timeEstimate).toBe(70); + }); + }); +}); diff --git a/src/app/imex/sync/oauth-state.util.spec.ts b/src/app/imex/sync/oauth-state.util.spec.ts new file mode 100644 index 0000000000..065847d64f --- /dev/null +++ b/src/app/imex/sync/oauth-state.util.spec.ts @@ -0,0 +1,33 @@ +import { addOAuthState, validateOAuthState } from './oauth-state.util'; + +describe('oauth-state.util', () => { + it('should validate a matching state only once', () => { + spyOn(Date, 'now').and.returnValue(1000); + addOAuthState('onedrive', 'state-once'); + + expect(validateOAuthState('onedrive', 'state-once')).toBeTrue(); + expect(validateOAuthState('onedrive', 'state-once')).toBeFalse(); + }); + + it('should reject mismatched provider without consuming the state', () => { + spyOn(Date, 'now').and.returnValue(2000); + addOAuthState('onedrive', 'state-provider-check'); + + expect(validateOAuthState('googleDrive', 'state-provider-check')).toBeFalse(); + expect(validateOAuthState('onedrive', 'state-provider-check')).toBeTrue(); + }); + + it('should reject expired states', () => { + const dateNowSpy = spyOn(Date, 'now'); + dateNowSpy.and.returnValue(0); + addOAuthState('onedrive', 'state-expired'); + + const justAfterExpiryMs = 600001; + dateNowSpy.and.returnValue(justAfterExpiryMs); + expect(validateOAuthState('onedrive', 'state-expired')).toBeFalse(); + }); + + it('should reject missing state values', () => { + expect(validateOAuthState('onedrive', null)).toBeFalse(); + }); +}); diff --git a/src/app/op-log/apply/bulk-archive-filter.util.spec.ts b/src/app/op-log/apply/bulk-archive-filter.util.spec.ts new file mode 100644 index 0000000000..23ba66c06f --- /dev/null +++ b/src/app/op-log/apply/bulk-archive-filter.util.spec.ts @@ -0,0 +1,252 @@ +import { ActionType, Operation, OpType } from '../core/operation.types'; +import { toLwwUpdateActionType } from '../core/lww-update-action-types'; +import { TASK_FEATURE_NAME } from '../../features/tasks/store/task.reducer'; +import { + collectTaskRemovalEntityIdsFromBatch, + stripBatchArchivedTaskIdsFromLwwPayload, +} from './bulk-archive-filter.util'; +import { Log as OpLog } from '../../core/log'; + +describe('bulk-archive-filter.util', () => { + const TASK_ID = 'task-1'; + + const createOperation = (overrides: Partial = {}): Operation => ({ + id: 'op-1', + opType: OpType.Update, + entityType: 'TASK', + actionType: ActionType.TASK_SHARED_UPDATE, + payload: {}, + vectorClock: { clientA: 1 }, + clientId: 'clientA', + timestamp: 1, + schemaVersion: 1, + ...overrides, + }); + + it('should include cascaded subtask IDs for TASK_SHARED_DELETE_MULTIPLE', () => { + const operations: Operation[] = [ + createOperation({ + actionType: ActionType.TASK_SHARED_DELETE_MULTIPLE, + entityIds: ['parent'], + }), + ]; + const state = { + [TASK_FEATURE_NAME]: { + entities: { + parent: { id: 'parent', subTaskIds: ['child'] }, + child: { id: 'child', parentId: 'parent' }, + }, + }, + }; + + const result = collectTaskRemovalEntityIdsFromBatch(operations, state); + + expect(result.all).toEqual(new Set(['parent', 'child'])); + expect(result.archiving).toEqual(new Set()); + }); + + it('should project parent membership updates before a later archive operation', () => { + const operations: Operation[] = [ + createOperation({ + id: 'op-lww', + actionType: toLwwUpdateActionType('TASK'), + entityType: 'TASK', + entityId: 'child', + payload: { + id: 'child', + parentId: 'parent', + }, + }), + createOperation({ + id: 'op-archive', + actionType: ActionType.TASK_SHARED_MOVE_TO_ARCHIVE, + entityType: 'TASK', + entityId: 'parent', + payload: { + actionPayload: { + tasks: [{ id: 'parent', subTaskIds: [] }], + }, + entityChanges: [], + }, + }), + ]; + const state = { + [TASK_FEATURE_NAME]: { + entities: { + parent: { id: 'parent', subTaskIds: [] }, + child: { id: 'child', parentId: null }, + }, + }, + }; + + const result = collectTaskRemovalEntityIdsFromBatch(operations, state); + + expect(result.all).toEqual(new Set(['parent', 'child'])); + expect(result.archiving).toEqual(new Set(['parent', 'child'])); + }); + + it('should strip archived task IDs from project LWW payload arrays', () => { + spyOn(OpLog, 'warn').and.stub(); + const op = createOperation({ + entityType: 'PROJECT', + entityId: 'project-1', + actionType: toLwwUpdateActionType('PROJECT'), + payload: { + actionPayload: { + taskIds: ['keep', 'remove-me', 123], + backlogTaskIds: ['keep-backlog', 'remove-too'], + title: 'Project', + }, + entityChanges: [], + }, + }); + + const result = stripBatchArchivedTaskIdsFromLwwPayload( + op, + true, + new Set(['remove-me', 'remove-too']), + ); + + expect(result).not.toBe(op); + expect((result.payload as any).actionPayload.taskIds).toEqual(['keep']); + expect((result.payload as any).actionPayload.backlogTaskIds).toEqual([ + 'keep-backlog', + ]); + expect((result.payload as any).actionPayload.title).toBe('Project'); + }); + + it('should return the original operation if nothing needs filtering', () => { + const op = createOperation({ + entityType: 'PROJECT', + entityId: 'project-1', + actionType: toLwwUpdateActionType('PROJECT'), + payload: { + actionPayload: { + taskIds: ['keep'], + backlogTaskIds: ['also-keep'], + }, + entityChanges: [], + }, + }); + + const result = stripBatchArchivedTaskIdsFromLwwPayload( + op, + true, + new Set(['other-id']), + ); + + expect(result).toBe(op); + }); + + it('should return empty sets when the batch has no archive or delete operations', () => { + const operations: Operation[] = [ + createOperation({ + id: 'op-lww', + actionType: toLwwUpdateActionType('TASK'), + entityType: 'TASK', + entityId: 'task-1', + payload: { id: 'task-1', title: 'Updated' }, + }), + ]; + + const result = collectTaskRemovalEntityIdsFromBatch(operations, { + [TASK_FEATURE_NAME]: { entities: { [TASK_ID]: { id: TASK_ID } } }, + }); + + expect(result.all).toEqual(new Set()); + expect(result.archiving).toEqual(new Set()); + }); + + it('should include state-backed child references for deleteTask even when payload is stale', () => { + const operations: Operation[] = [ + createOperation({ + actionType: ActionType.TASK_SHARED_DELETE, + entityType: 'TASK', + entityId: 'parent', + payload: { + actionPayload: { + task: { id: 'parent', subTaskIds: [] }, + }, + entityChanges: [], + }, + }), + ]; + const state = { + [TASK_FEATURE_NAME]: { + entities: { + parent: { id: 'parent', subTaskIds: [] }, + child: { id: 'child', parentId: 'parent' }, + }, + }, + }; + + const result = collectTaskRemovalEntityIdsFromBatch(operations, state); + + expect(result.all).toEqual(new Set(['parent', 'child'])); + expect(result.archiving).toEqual(new Set()); + }); + + it('should ignore malformed archive payloads with null actionPayload', () => { + const operations: Operation[] = [ + createOperation({ + actionType: ActionType.TASK_SHARED_MOVE_TO_ARCHIVE, + entityType: 'TASK', + entityId: 'parent', + payload: { + actionPayload: null, + entityChanges: [], + }, + }), + ]; + + const result = collectTaskRemovalEntityIdsFromBatch(operations, { + [TASK_FEATURE_NAME]: { entities: {} }, + }); + + expect(result.all).toEqual(new Set(['parent'])); + expect(result.archiving).toEqual(new Set(['parent'])); + }); + + it('should strip archived task IDs from direct TAG LWW payloads', () => { + const op = createOperation({ + entityType: 'TAG', + entityId: 'tag-1', + actionType: toLwwUpdateActionType('TAG'), + payload: { + taskIds: ['keep', 'remove-me', false], + title: 'Tag', + }, + }); + + const result = stripBatchArchivedTaskIdsFromLwwPayload( + op, + true, + new Set(['remove-me']), + ); + + expect(result).not.toBe(op); + expect(result.payload).toEqual({ + taskIds: ['keep'], + title: 'Tag', + }); + }); + + it('should return the operation unchanged when isLww is false', () => { + const op = createOperation({ + entityType: 'TAG', + entityId: 'tag-1', + actionType: toLwwUpdateActionType('TAG'), + payload: { + taskIds: ['remove-me'], + }, + }); + + const result = stripBatchArchivedTaskIdsFromLwwPayload( + op, + false, + new Set(['remove-me']), + ); + + expect(result).toBe(op); + }); +}); diff --git a/src/app/plugins/oauth/plugin-oauth-redirect.handler.spec.ts b/src/app/plugins/oauth/plugin-oauth-redirect.handler.spec.ts new file mode 100644 index 0000000000..73e20d904f --- /dev/null +++ b/src/app/plugins/oauth/plugin-oauth-redirect.handler.spec.ts @@ -0,0 +1,124 @@ +import { TestBed } from '@angular/core/testing'; +import { PluginOAuthRedirectHandler } from './plugin-oauth-redirect.handler'; +import { PluginOAuthService } from './plugin-oauth.service'; + +describe('PluginOAuthRedirectHandler', () => { + let serviceSpy: jasmine.SpyObj; + + beforeEach(() => { + serviceSpy = jasmine.createSpyObj('PluginOAuthService', [ + 'handleRedirectCode', + 'handleRedirectError', + ]); + + TestBed.configureTestingModule({ + providers: [ + PluginOAuthRedirectHandler, + { provide: PluginOAuthService, useValue: serviceSpy }, + ], + }); + }); + + it('should forward same-origin OAuth code callbacks', () => { + let messageListener: ((event: MessageEvent) => void) | undefined; + spyOn(window, 'addEventListener').and.callFake( + (type: string, listener: EventListenerOrEventListenerObject) => { + if (type === 'message' && typeof listener === 'function') { + messageListener = listener as (event: MessageEvent) => void; + } + }, + ); + + TestBed.inject(PluginOAuthRedirectHandler); + expect(messageListener).toBeDefined(); + + messageListener!( + new MessageEvent('message', { + origin: window.location.origin, + data: { type: 'SP_OAUTH_CALLBACK', code: 'auth-code', state: 'oauth-state' }, + }), + ); + + expect(serviceSpy.handleRedirectCode).toHaveBeenCalledOnceWith( + 'auth-code', + 'oauth-state', + ); + expect(serviceSpy.handleRedirectError).not.toHaveBeenCalled(); + }); + + it('should forward same-origin OAuth error callbacks', () => { + let messageListener: ((event: MessageEvent) => void) | undefined; + spyOn(window, 'addEventListener').and.callFake( + (type: string, listener: EventListenerOrEventListenerObject) => { + if (type === 'message' && typeof listener === 'function') { + messageListener = listener as (event: MessageEvent) => void; + } + }, + ); + + TestBed.inject(PluginOAuthRedirectHandler); + + messageListener!( + new MessageEvent('message', { + origin: window.location.origin, + data: { + type: 'SP_OAUTH_CALLBACK', + error: 'access_denied', + state: 'oauth-state', + }, + }), + ); + + expect(serviceSpy.handleRedirectError).toHaveBeenCalledOnceWith( + 'access_denied', + 'oauth-state', + ); + expect(serviceSpy.handleRedirectCode).not.toHaveBeenCalled(); + }); + + it('should ignore callbacks from a different origin or message type', () => { + let messageListener: ((event: MessageEvent) => void) | undefined; + spyOn(window, 'addEventListener').and.callFake( + (type: string, listener: EventListenerOrEventListenerObject) => { + if (type === 'message' && typeof listener === 'function') { + messageListener = listener as (event: MessageEvent) => void; + } + }, + ); + + TestBed.inject(PluginOAuthRedirectHandler); + + messageListener!( + new MessageEvent('message', { + origin: 'https://evil.example.com', + data: { type: 'SP_OAUTH_CALLBACK', code: 'ignored' }, + }), + ); + messageListener!( + new MessageEvent('message', { + origin: window.location.origin, + data: { type: 'OTHER_EVENT', code: 'ignored' }, + }), + ); + + expect(serviceSpy.handleRedirectCode).not.toHaveBeenCalled(); + expect(serviceSpy.handleRedirectError).not.toHaveBeenCalled(); + }); + + it('should remove the registered message listener on destroy', () => { + let messageListener: ((event: MessageEvent) => void) | undefined; + spyOn(window, 'addEventListener').and.callFake( + (type: string, listener: EventListenerOrEventListenerObject) => { + if (type === 'message' && typeof listener === 'function') { + messageListener = listener as (event: MessageEvent) => void; + } + }, + ); + const removeEventListenerSpy = spyOn(window, 'removeEventListener'); + + const handler = TestBed.inject(PluginOAuthRedirectHandler); + handler.ngOnDestroy(); + + expect(removeEventListenerSpy).toHaveBeenCalledOnceWith('message', messageListener!); + }); +});