mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
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:
parent
e5a9ff7866
commit
67cd017bc7
4 changed files with 712 additions and 0 deletions
303
src/app/features/tasks/store/task.reducer.util.spec.ts
Normal file
303
src/app/features/tasks/store/task.reducer.util.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
33
src/app/imex/sync/oauth-state.util.spec.ts
Normal file
33
src/app/imex/sync/oauth-state.util.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
252
src/app/op-log/apply/bulk-archive-filter.util.spec.ts
Normal file
252
src/app/op-log/apply/bulk-archive-filter.util.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
124
src/app/plugins/oauth/plugin-oauth-redirect.handler.spec.ts
Normal file
124
src/app/plugins/oauth/plugin-oauth-redirect.handler.spec.ts
Normal 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!);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue