Add critical tests for task and sync helper flows (#9079)

* test: add critical coverage for task and sync helpers

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* test(tasks): fix vacuous alert assertion and zero-delta time test

- Reset devError latch and alert spy call history before orphan-subtask
  assertion so the test is not vacuous in a full Karma suite run
- Change DAY_3 timeSpent from 60 to 90 in updateTimeSpentForTask test
  so totalDelta is non-zero (+30), making the accumulator mutation
  detectable by mutation testing
- Add isLww=false short-circuit case to bulk-archive-filter spec

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
ghostkeni 2026-07-17 18:56:25 +09:00 committed by GitHub
parent e5a9ff7866
commit 67cd017bc7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 712 additions and 0 deletions

View file

@ -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> = {}): 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<Task> = {
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<Task> = { 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<Task> = { 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);
});
});
});

View file

@ -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();
});
});

View file

@ -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> = {}): 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<string>());
});
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<string>());
expect(result.archiving).toEqual(new Set<string>());
});
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<string>());
});
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);
});
});

View file

@ -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<PluginOAuthService>;
beforeEach(() => {
serviceSpy = jasmine.createSpyObj<PluginOAuthService>('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!);
});
});