super-productivity/src/app/op-log/testing/integration/task-done-replay.integration.spec.ts
Johannes Millan d1ff7963d0
fix(sync): rebase repair op clocks on the durable clock (#8939) (#9080)
The REPAIR paths built their vector clock from the per-tab in-memory
clock cache and then REPLACED the durable clock with it. A stale cache
(another tab advanced the clock) regressed the durable clock, letting
subsequent captures reuse counters already shipped — silently corrupting
cross-device dominance comparisons.

- createRepairOperation: route through
  appendMixedSourceBatchSkipDuplicates; the in-transaction rebase makes
  regression unrepresentable. State cache stores the clock actually
  written.
- replaceRejectedRepair: rebase the replacement clock onto the durable
  clock inside its transaction (shared rebaseLocalClockOnDurable helper).
- Drop client-side clock pruning from repair op building: inert under
  the rebase, and it dropped client IDs the server still tracks (false
  CONCURRENT). Server prunes after conflict detection.
- Rename appendWithVectorClockUpdate -> appendWithVectorClockOverwrite
  and document the derivation invariant; the capture path (its only
  remaining production caller) already satisfies it.
2026-07-16 17:13:57 +02:00

476 lines
17 KiB
TypeScript

import { TestBed } from '@angular/core/testing';
import { Action, ActionReducer } from '@ngrx/store';
import { provideMockActions } from '@ngrx/effects/testing';
import { Observable, of } from 'rxjs';
import { bulkOperationsMetaReducer } from '../../apply/bulk-hydration.meta-reducer';
import { bulkApplyOperations } from '../../apply/bulk-hydration.action';
import { ActionType, Operation, OpType } from '../../core/operation.types';
import { taskSharedCrudMetaReducer } from '../../../root-store/meta/task-shared-meta-reducers/task-shared-crud.reducer';
import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions';
import {
createBaseState,
createMockTask,
} from '../../../root-store/meta/task-shared-meta-reducers/test-utils';
import { RootState } from '../../../root-store/root-state';
import { TASK_FEATURE_NAME } from '../../../features/tasks/store/task.reducer';
import { TAG_FEATURE_NAME } from '../../../features/tag/store/tag.reducer';
import { Task } from '../../../features/tasks/task.model';
import { Tag } from '../../../features/tag/tag.model';
import { TODAY_TAG } from '../../../features/tag/tag.const';
import { appStateFeatureKey } from '../../../root-store/app-state/app-state.reducer';
import { plannerFeatureKey } from '../../../features/planner/store/planner.reducer';
import { OperationLogEffects } from '../../capture/operation-log.effects';
import { OperationLogStoreService } from '../../persistence/operation-log-store.service';
import { LockService } from '../../sync/lock.service';
import { VectorClockService } from '../../sync/vector-clock.service';
import { OperationLogCompactionService } from '../../persistence/operation-log-compaction.service';
import { SnackService } from '../../../core/snack/snack.service';
import { ImmediateUploadService } from '../../sync/immediate-upload.service';
import { ClientIdService } from '../../../core/util/client-id.service';
import { OperationCaptureService } from '../../capture/operation-capture.service';
import { SuperSyncStatusService } from '../../sync/super-sync-status.service';
describe('done task operation replay', () => {
const TASK_ID = 'task-done-yesterday';
const ACTION_TODAY = '2024-06-14';
const REPLAY_TODAY = '2024-06-15';
const DONE_TIMESTAMP = new Date(2024, 5, 14, 12, 0, 0, 0).getTime();
const createState = (
taskOverrides: Partial<Task> = {},
todayStr = REPLAY_TODAY,
): RootState => {
const base = createBaseState();
const task = createMockTask({
id: TASK_ID,
isDone: false,
doneOn: undefined,
dueDay: undefined,
dueWithTime: undefined,
...taskOverrides,
});
return {
...base,
[TASK_FEATURE_NAME]: {
...base[TASK_FEATURE_NAME],
ids: [TASK_ID],
entities: { [TASK_ID]: task },
},
[appStateFeatureKey]: {
...base[appStateFeatureKey],
todayStr,
startOfNextDayDiffMs: 0,
},
};
};
const applyOperation = (state: RootState, op: Operation): RootState => {
const passThroughReducer: ActionReducer<RootState, Action> = (s) => s as RootState;
const reducer = bulkOperationsMetaReducer(
taskSharedCrudMetaReducer(passThroughReducer),
);
return reducer(state, bulkApplyOperations({ operations: [op] }));
};
const createDoneOp = (changes: Partial<Task>, id = 'op-done-update'): Operation => ({
id,
actionType: ActionType.TASK_SHARED_UPDATE,
opType: OpType.Update,
entityType: 'TASK',
entityId: TASK_ID,
payload: {
actionPayload: {
task: { id: TASK_ID, changes: { isDone: true, ...changes } },
},
entityChanges: [],
},
clientId: 'clientA',
vectorClock: { clientA: 1 },
timestamp: DONE_TIMESTAMP,
schemaVersion: 1,
});
const reduceLocalAction = (state: RootState, action: Action): RootState => {
const passThroughReducer: ActionReducer<RootState, Action> = (s) => s as RootState;
const reducer = taskSharedCrudMetaReducer(passThroughReducer);
return reducer(state, action);
};
const persistLocalAction = async (action: Action): Promise<Operation> => {
const actions$: Observable<Action> = of(action);
const mockOpLogStore = jasmine.createSpyObj<OperationLogStoreService>(
'OperationLogStoreService',
['appendWithVectorClockOverwrite', 'getCompactionCounter', 'clearVectorClockCache'],
);
const mockLockService = jasmine.createSpyObj<LockService>('LockService', ['request']);
const mockVectorClockService = jasmine.createSpyObj<VectorClockService>(
'VectorClockService',
['getCurrentVectorClock'],
);
const mockCompactionService = jasmine.createSpyObj<OperationLogCompactionService>(
'OperationLogCompactionService',
['compact', 'emergencyCompact'],
);
const mockOperationCaptureService = jasmine.createSpyObj<OperationCaptureService>(
'OperationCaptureService',
['extractEntityChanges', 'decrementPending'],
);
const mockSnackService = jasmine.createSpyObj<SnackService>('SnackService', ['open']);
const mockImmediateUploadService = jasmine.createSpyObj<ImmediateUploadService>(
'ImmediateUploadService',
['trigger'],
);
const mockClientIdService = jasmine.createSpyObj<ClientIdService>('ClientIdService', [
'getOrGenerateClientId',
]);
const mockSuperSyncStatusService = jasmine.createSpyObj<SuperSyncStatusService>(
'SuperSyncStatusService',
['updatePendingOpsStatus'],
);
mockLockService.request.and.callFake(async <T>(_name: string, fn: () => Promise<T>) =>
fn(),
);
mockOpLogStore.appendWithVectorClockOverwrite.and.returnValue(Promise.resolve(1));
mockOpLogStore.getCompactionCounter.and.returnValue(Promise.resolve(0));
mockVectorClockService.getCurrentVectorClock.and.returnValue(
Promise.resolve({ clientA: 1 }),
);
mockCompactionService.compact.and.returnValue(Promise.resolve(true));
mockCompactionService.emergencyCompact.and.returnValue(Promise.resolve(true));
mockOperationCaptureService.extractEntityChanges.and.returnValue([]);
mockClientIdService.getOrGenerateClientId.and.returnValue(Promise.resolve('clientA'));
TestBed.configureTestingModule({
providers: [
OperationLogEffects,
provideMockActions(() => actions$),
{ provide: OperationLogStoreService, useValue: mockOpLogStore },
{ provide: LockService, useValue: mockLockService },
{ provide: VectorClockService, useValue: mockVectorClockService },
{ provide: OperationLogCompactionService, useValue: mockCompactionService },
{ provide: SnackService, useValue: mockSnackService },
{ provide: ImmediateUploadService, useValue: mockImmediateUploadService },
{ provide: ClientIdService, useValue: mockClientIdService },
{ provide: OperationCaptureService, useValue: mockOperationCaptureService },
{ provide: SuperSyncStatusService, useValue: mockSuperSyncStatusService },
],
});
const effects = TestBed.inject(OperationLogEffects);
await new Promise<void>((resolve, reject) => {
effects.persistOperation$.subscribe({
complete: resolve,
error: reject,
});
});
return mockOpLogStore.appendWithVectorClockOverwrite.calls.mostRecent()
.args[0] as Operation;
};
const createScheduledState = (todayStr: string): RootState => {
const scheduledDay = '2024-06-13';
return {
...createState({ dueDay: scheduledDay }, todayStr),
[plannerFeatureKey]: {
days: { [scheduledDay]: [TASK_ID] },
addPlannedTasksDialogLastShown: undefined,
},
};
};
const createTimedScheduledState = (todayStr: string): RootState => {
const dueWithTime = new Date(2024, 5, 16, 9, 0, 0, 0).getTime();
return {
...createState({ dueDay: undefined, dueWithTime }, todayStr),
[plannerFeatureKey]: {
days: {},
addPlannedTasksDialogLastShown: undefined,
},
};
};
const getTodayTaskIds = (state: RootState): string[] =>
(state[TAG_FEATURE_NAME].entities[TODAY_TAG.id] as Tag).taskIds;
afterEach(() => {
TestBed.resetTestingModule();
});
it('records only doneOn (never a dueDay) when replaying an unscheduled completion', () => {
const op: Operation = {
id: 'op-done-yesterday',
actionType: ActionType.TASK_SHARED_UPDATE,
opType: OpType.Update,
entityType: 'TASK',
entityId: TASK_ID,
payload: {
actionPayload: {
task: { id: TASK_ID, changes: { isDone: true } },
},
entityChanges: [],
},
clientId: 'clientA',
vectorClock: { clientA: 1 },
timestamp: DONE_TIMESTAMP,
schemaVersion: 1,
};
const result = applyOperation(createState(), op);
const task = result[TASK_FEATURE_NAME].entities[TASK_ID] as Task;
expect(task.isDone).toBe(true);
expect(task.doneOn).toBe(DONE_TIMESTAMP);
// Completion never synthesizes a dueDay: dueDay stays a pure planning field,
// so local apply and replay both yield no dueDay for an unscheduled completion.
expect(task.dueDay).toBeUndefined();
});
it('does not move an already scheduled task to the completion day during replay', () => {
const scheduledDay = '2024-06-13';
const initialState = createState();
const stateWithScheduledTask: RootState = {
...initialState,
[TASK_FEATURE_NAME]: {
...initialState[TASK_FEATURE_NAME],
entities: {
...initialState[TASK_FEATURE_NAME].entities,
[TASK_ID]: {
...(initialState[TASK_FEATURE_NAME].entities[TASK_ID] as Task),
dueDay: scheduledDay,
},
},
},
};
const op: Operation = {
id: 'op-done-preserve-due-day',
actionType: ActionType.TASK_SHARED_UPDATE,
opType: OpType.Update,
entityType: 'TASK',
entityId: TASK_ID,
payload: {
actionPayload: {
task: { id: TASK_ID, changes: { isDone: true, doneOn: DONE_TIMESTAMP } },
},
entityChanges: [],
},
clientId: 'clientA',
vectorClock: { clientA: 1 },
timestamp: DONE_TIMESTAMP,
schemaVersion: 1,
};
const result = applyOperation(stateWithScheduledTask, op);
const task = result[TASK_FEATURE_NAME].entities[TASK_ID] as Task;
expect(task.isDone).toBe(true);
expect(task.doneOn).toBe(DONE_TIMESTAMP);
expect(task.dueDay).toBe(scheduledDay);
});
it('replays a doneOn-carrying unscheduled completion without synthesizing a dueDay', () => {
const op: Operation = {
id: 'op-done-on-only',
actionType: ActionType.TASK_SHARED_UPDATE,
opType: OpType.Update,
entityType: 'TASK',
entityId: TASK_ID,
payload: {
actionPayload: {
task: { id: TASK_ID, changes: { isDone: true, doneOn: DONE_TIMESTAMP } },
},
entityChanges: [],
},
clientId: 'clientA',
vectorClock: { clientA: 1 },
timestamp: DONE_TIMESTAMP,
schemaVersion: 1,
};
const result = applyOperation(createState(), op);
const task = result[TASK_FEATURE_NAME].entities[TASK_ID] as Task;
expect(task.isDone).toBe(true);
expect(task.doneOn).toBe(DONE_TIMESTAMP);
expect(task.dueDay).toBeUndefined();
});
it('ignores legacy synthetic completion dueDay when replaying onto a scheduled task', () => {
const scheduledDay = '2024-06-13';
const initialState = createState();
const stateWithScheduledTask: RootState = {
...initialState,
[TASK_FEATURE_NAME]: {
...initialState[TASK_FEATURE_NAME],
entities: {
...initialState[TASK_FEATURE_NAME].entities,
[TASK_ID]: {
...(initialState[TASK_FEATURE_NAME].entities[TASK_ID] as Task),
dueDay: scheduledDay,
},
},
},
};
const op: Operation = {
id: 'op-legacy-bad-due-day',
actionType: ActionType.TASK_SHARED_UPDATE,
opType: OpType.Update,
entityType: 'TASK',
entityId: TASK_ID,
payload: {
actionPayload: {
task: {
id: TASK_ID,
changes: {
isDone: true,
doneOn: DONE_TIMESTAMP,
dueDay: ACTION_TODAY,
},
},
},
entityChanges: [],
},
clientId: 'clientA',
vectorClock: { clientA: 1 },
timestamp: DONE_TIMESTAMP,
schemaVersion: 1,
};
const result = applyOperation(stateWithScheduledTask, op);
const task = result[TASK_FEATURE_NAME].entities[TASK_ID] as Task;
expect(task.isDone).toBe(true);
expect(task.doneOn).toBe(DONE_TIMESTAMP);
expect(task.dueDay).toBe(scheduledDay);
});
it('handles legacy synthetic completion dueDay replay across schedule states', () => {
const dueWithTime = new Date(2024, 5, 16, 9, 0, 0, 0).getTime();
const legacyOp = createDoneOp(
{
doneOn: DONE_TIMESTAMP,
dueDay: ACTION_TODAY,
},
'op-legacy-synthetic-due-day-matrix',
);
const cases: {
description: string;
taskOverrides: Partial<Task>;
expectedDueDay: string | undefined;
expectedDueWithTime?: number;
}[] = [
{
description: 'existing dueDay is preserved',
taskOverrides: { dueDay: '2024-06-13' },
expectedDueDay: '2024-06-13',
},
{
description: 'existing dueWithTime is preserved',
taskOverrides: { dueWithTime },
expectedDueDay: undefined,
expectedDueWithTime: dueWithTime,
},
{
description: 'unscheduled main task keeps legacy completion dueDay',
taskOverrides: {},
expectedDueDay: ACTION_TODAY,
},
{
description: 'unscheduled subtask drops legacy completion dueDay',
taskOverrides: { parentId: 'parent-task' },
expectedDueDay: undefined,
},
{
description: 'schedule matching completion day is preserved',
taskOverrides: { dueDay: ACTION_TODAY },
expectedDueDay: ACTION_TODAY,
},
];
for (const {
description,
taskOverrides,
expectedDueDay,
expectedDueWithTime,
} of cases) {
const result = applyOperation(createState(taskOverrides), legacyOp);
const task = result[TASK_FEATURE_NAME].entities[TASK_ID] as Task;
expect(task.isDone).withContext(description).toBe(true);
expect(task.doneOn).withContext(description).toBe(DONE_TIMESTAMP);
expect(task.dueDay).withContext(description).toBe(expectedDueDay);
if (expectedDueWithTime !== undefined) {
expect(task.dueWithTime).withContext(description).toBe(expectedDueWithTime);
}
}
});
it('round-trips a locally captured scheduled completion without schedule drift', async () => {
jasmine.clock().install();
jasmine.clock().mockDate(new Date(DONE_TIMESTAMP));
try {
const action = TaskSharedActions.updateTask({
task: { id: TASK_ID, changes: { isDone: true } },
});
const localResult = reduceLocalAction(createScheduledState(ACTION_TODAY), action);
const persistedOp = await persistLocalAction(action);
const replayResult = applyOperation(
createScheduledState(REPLAY_TODAY),
persistedOp,
);
const localTask = localResult[TASK_FEATURE_NAME].entities[TASK_ID] as Task;
const replayTask = replayResult[TASK_FEATURE_NAME].entities[TASK_ID] as Task;
expect(persistedOp.actionType).toBe(ActionType.TASK_SHARED_UPDATE);
expect(localTask.doneOn).toBe(DONE_TIMESTAMP);
expect(replayTask.doneOn).toBe(localTask.doneOn);
expect(replayTask.dueDay).toBe(localTask.dueDay);
expect(replayTask.dueWithTime).toBe(localTask.dueWithTime);
expect(getTodayTaskIds(replayResult)).toEqual(getTodayTaskIds(localResult));
expect(replayResult[plannerFeatureKey].days).toEqual(
localResult[plannerFeatureKey].days,
);
} finally {
jasmine.clock().uninstall();
}
});
it('round-trips a locally captured timed completion without schedule drift', async () => {
jasmine.clock().install();
jasmine.clock().mockDate(new Date(DONE_TIMESTAMP));
try {
const action = TaskSharedActions.updateTask({
task: { id: TASK_ID, changes: { isDone: true } },
});
const localResult = reduceLocalAction(
createTimedScheduledState(ACTION_TODAY),
action,
);
const persistedOp = await persistLocalAction(action);
const replayResult = applyOperation(
createTimedScheduledState(REPLAY_TODAY),
persistedOp,
);
const localTask = localResult[TASK_FEATURE_NAME].entities[TASK_ID] as Task;
const replayTask = replayResult[TASK_FEATURE_NAME].entities[TASK_ID] as Task;
expect(persistedOp.actionType).toBe(ActionType.TASK_SHARED_UPDATE);
expect(localTask.doneOn).toBe(DONE_TIMESTAMP);
expect(replayTask.doneOn).toBe(localTask.doneOn);
expect(replayTask.dueDay).toBeUndefined();
expect(replayTask.dueWithTime).toBe(localTask.dueWithTime);
expect(getTodayTaskIds(replayResult)).toEqual(getTodayTaskIds(localResult));
expect(replayResult[plannerFeatureKey].days).toEqual(
localResult[plannerFeatureKey].days,
);
} finally {
jasmine.clock().uninstall();
}
});
});